Files
imagefind/backend/imagefind/webdav.py
T

1716 lines
70 KiB
Python

from __future__ import annotations
import asyncio
import base64
import ctypes
import errno
import hashlib
import mimetypes
import os
import posixpath
import re
import shutil
import threading
import time
import uuid
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from email.utils import format_datetime
from pathlib import Path
from typing import Annotated
from urllib.parse import quote, unquote, urlsplit, urlunsplit
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field
from .api import require_auth, services, stream_video
from .collections import (
assign_videos,
collection_by_name,
create_collection,
create_group,
delete_group,
move_item,
rename_group,
)
from .container import Services
from .database import utcnow
from .remote import safe_relative_path
from .sources import VIDEO_EXTENSIONS
router = APIRouter()
api_router = APIRouter(prefix="/api")
DAV = "DAV:"
IMAGEFIND_DAV = "urn:imagefind:webdav"
ET.register_namespace("d", DAV)
ET.register_namespace("if", IMAGEFIND_DAV)
TEMP_SUFFIXES = {".part", ".tmp", ".download", ".upload", ".crdownload"}
_CONTENT_RANGE = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+|\*)$", re.IGNORECASE)
WEBDAV_WRITE_QUANTUM = 512 * 1024
WEBDAV_WRITEBACK_INTERVAL = 8 * 1024**2
WEBDAV_DISK_CHECK_INTERVAL = 64 * 1024**2
WEBDAV_PROGRESS_INTERVAL_SECONDS = 10
WEBDAV_PROGRESS_INTERVAL_BYTES = 256 * 1024**2
_SYNC_FILE_RANGE_WRITE = 2
try:
_libc = ctypes.CDLL(None, use_errno=True)
_sync_file_range = _libc.sync_file_range
_sync_file_range.argtypes = [ctypes.c_int, ctypes.c_longlong, ctypes.c_longlong, ctypes.c_uint]
_sync_file_range.restype = ctypes.c_int
except (AttributeError, OSError): # pragma: no cover - non-Linux fallback
_sync_file_range = None
_path_locks_guard = threading.Lock()
_path_locks: dict[str, threading.Lock] = {}
def _path_lock(virtual_path: str) -> threading.Lock:
with _path_locks_guard:
return _path_locks.setdefault(virtual_path, threading.Lock())
def _release_path_lock(virtual_path: str, lock: threading.Lock) -> None:
lock.release()
with _path_locks_guard:
if _path_locks.get(virtual_path) is lock and not lock.locked():
_path_locks.pop(virtual_path, None)
async def _background_io(app: Services, function, /, *args, **kwargs):
# WebDAV disk and SQLite calls must never run on the ASGI loop. This is
# independent of the AI embedding backend: hash-mode development servers
# can receive the same multi-gigabyte uploads as fnOS/OpenVINO installs.
return await asyncio.to_thread(function, *args, **kwargs)
class WebDavConfigBody(BaseModel):
enabled: bool = False
source_id: str | None = None
relative_path: str = Field(default="", max_length=4096)
def _config(app: Services) -> dict:
value = app.db.setting("webdav_server", {})
value = value if isinstance(value, dict) else {}
return {
"enabled": bool(value.get("enabled", False)),
"source_id": value.get("source_id") or None,
"relative_path": str(value.get("relative_path") or ""),
}
def _webdav_url(request: Request, app: Services) -> str:
"""Return the native HTTP listener, never the fnOS HTML gateway.
fnOS' gateway understands browser sessions but does not forward WebDAV
authentication and methods. Keeping the request hostname is useful when
this endpoint is queried through that gateway, while the configured native
listener port makes the result usable by a real DAV client.
"""
forwarded_host = (request.headers.get("x-forwarded-host") or "").split(",", 1)[0].strip()
authority = forwarded_host or request.url.netloc
try:
parsed = urlsplit(f"//{authority}")
hostname = parsed.hostname or request.url.hostname or "127.0.0.1"
except ValueError:
hostname = request.url.hostname or "127.0.0.1"
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
return urlunsplit(("http", f"{host}:{app.settings.port}", "/webdav/", "", ""))
def _public_config(request: Request, app: Services, value: dict) -> dict:
return {
**value,
"url": _webdav_url(request, app),
"username": "imagefind",
"direct_access": app.settings.direct_access,
"direct_port": app.settings.port,
"gateway_supported": False,
}
@api_router.get("/v1/webdav/config")
async def get_webdav_config(
request: Request,
app: Annotated[Services, Depends(services)],
_: Annotated[dict, Depends(require_auth)],
):
return _public_config(request, app, _config(app))
@api_router.patch("/v1/webdav/config")
async def update_webdav_config(
body: WebDavConfigBody,
request: Request,
app: Annotated[Services, Depends(services)],
_: Annotated[dict, Depends(require_auth)],
):
relative = safe_relative_path(body.relative_path)
if body.enabled and not app.settings.direct_access:
raise HTTPException(
409,
"WebDAV 只能通过直接 Web/API 访问;请先在飞牛应用配置中开启直接访问",
)
if body.enabled and not body.source_id:
raise HTTPException(400, "启用 WebDAV 前必须选择默认可写媒体库")
if body.source_id:
try:
app.storage.require_writable(body.source_id)
except KeyError as exc:
raise HTTPException(404, "目标媒体库不存在") from exc
except PermissionError as exc:
raise HTTPException(403, str(exc)) from exc
value = {"enabled": body.enabled, "source_id": body.source_id, "relative_path": relative}
app.db.set_setting("webdav_server", value)
return _public_config(request, app, value)
def _challenge(detail: str = "需要有效的 ImageFind API Token") -> HTTPException:
return HTTPException(
401,
detail,
headers={"WWW-Authenticate": 'Basic realm="ImageFind WebDAV", charset="UTF-8"'},
)
async def _authenticate(request: Request, app: Services) -> None:
value = request.headers.get("authorization", "")
if not value.lower().startswith("basic "):
raise _challenge()
try:
username, token = base64.b64decode(value.split(None, 1)[1], validate=True).decode().split(":", 1)
except (ValueError, UnicodeDecodeError) as exc:
raise _challenge("WebDAV Basic 凭据格式无效") from exc
if username != "imagefind" or not app.auth.verify_api_token(token, "webdav"):
raise _challenge("WebDAV 用户名或 API Token 无效")
def _parts(raw_path: str) -> list[str]:
decoded = unquote(raw_path or "").strip("/")
if not decoded:
return []
safe = safe_relative_path(decoded, allow_empty=False)
parts = safe.split("/")
if any(not value.strip() for value in parts):
raise HTTPException(409, "WebDAV 路径中不能包含空目录名")
return parts
def _href(request: Request, parts: list[str], *, directory: bool = False) -> str:
root = str(request.scope.get("root_path") or "").rstrip("/")
suffix = "/".join(quote(part, safe="") for part in parts)
value = f"{root}/webdav/" + suffix if root else "/webdav/" + suffix
if directory and not value.endswith("/"):
value += "/"
return value
def _collection(app: Services, name: str, *, create: bool = False) -> dict | None:
with app.db.transaction() if create else app.db.read() as conn:
row = collection_by_name(conn, name)
if not row and create:
collection_id = create_collection(conn, name)
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
return dict(row) if row else None
def _group(
app: Services,
collection_id: str,
names: list[str],
*,
create: bool = False,
) -> dict | None:
parent_id: str | None = None
with app.db.transaction() if create else app.db.read() as conn:
for name in names:
condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
args: tuple[object, ...] = (
(collection_id, name) if parent_id is None else (collection_id, parent_id, name)
)
row = conn.execute(
f"SELECT * FROM collection_items WHERE collection_id=? AND {condition} "
"AND kind='group' AND lower(name)=lower(?)",
args,
).fetchone()
if not row and create:
group_id = create_group(conn, collection_id, name, parent_id)
row = conn.execute("SELECT * FROM collection_items WHERE id=?", (group_id,)).fetchone()
if not row:
return None
parent_id = str(row["id"])
if not names:
return {"id": None, "collection_id": collection_id, "name": ""}
return dict(row) # type: ignore[arg-type]
def _video(
app: Services, collection_id: str, parent_id: str | None, filename: str
) -> dict | None:
with app.db.read() as conn:
condition = "ci.parent_id IS NULL" if parent_id is None else "ci.parent_id=?"
args: tuple[object, ...] = (
(collection_id, filename) if parent_id is None else (collection_id, parent_id, filename)
)
row = conn.execute(
"SELECT v.*,ci.id AS collection_item_id,ci.parent_id AS collection_parent_id "
"FROM collection_items ci JOIN videos v ON v.id=ci.video_id "
f"WHERE ci.collection_id=? AND {condition} AND v.display_name=? AND v.available=1 "
"ORDER BY ci.position,v.updated_at DESC LIMIT 1",
args,
).fetchone()
return dict(row) if row else None
def _root_video(app: Services, config: dict, filename: str) -> dict | None:
key = posixpath.join(config["relative_path"], filename) if config["relative_path"] else filename
with app.db.read() as conn:
row = conn.execute(
"SELECT v.* FROM videos v WHERE v.source_id=? AND v.source_key=? AND v.available=1 "
"AND NOT EXISTS(SELECT 1 FROM collection_videos cv WHERE cv.video_id=v.id) "
"ORDER BY v.updated_at DESC LIMIT 1",
(config["source_id"], key),
).fetchone()
return dict(row) if row else None
def _root_videos(app: Services, config: dict) -> list[dict]:
directory = config["relative_path"]
with app.db.read() as conn:
rows = conn.execute(
"SELECT v.* FROM videos v WHERE v.source_id=? AND v.available=1 "
"AND NOT EXISTS(SELECT 1 FROM collection_videos cv WHERE cv.video_id=v.id) "
"ORDER BY v.display_name",
(config["source_id"],),
).fetchall()
return [
dict(row)
for row in rows
if posixpath.dirname(str(row["source_key"])) == directory
]
def _normalize_staging(row) -> dict:
item = dict(row)
item["_resource_origin"] = "staging"
path = Path(item["temp_path"])
if path.is_file():
actual_size = path.stat().st_size
expected = item.get("expected_size")
if expected is None or actual_size <= int(expected):
# Disk is authoritative after a process crash: the last pwrite can
# be durable even if its two-second SQLite checkpoint was delayed.
item["received_bytes"] = actual_size
item["size_bytes"] = actual_size
return item
def _raw_staging(app: Services, virtual_path: str) -> dict | None:
with app.db.read() as conn:
row = conn.execute(
"SELECT * FROM webdav_staging WHERE virtual_path=?", (virtual_path,)
).fetchone()
return _normalize_staging(row) if row else None
def _pending_upload_resource(row) -> dict:
item = dict(row)
status = str(item.pop("upload_status"))
size = int(item.get("size_bytes") or 0)
item.update(
{
"_resource_origin": "upload",
"expected_size": size,
"received_bytes": size,
"state": "error" if status == "failed" else "indexing" if status == "indexing" else "accepted",
}
)
return item
def _pending_uploads(
app: Services,
config: dict,
*,
virtual_path: str | None = None,
collection_id: str | None = None,
parent_id: str | None = None,
) -> list[dict]:
filters = [
"u.source_id=?",
"u.origin='webdav'",
"u.webdav_path IS NOT NULL",
"u.status IN ('queued','transferring','indexing','failed')",
"(u.status<>'failed' OR u.failure_stage='catalog')",
"v.id IS NULL",
]
parameters: list[object] = [config["source_id"]]
if virtual_path is not None:
filters.append("u.webdav_path=?")
parameters.append(virtual_path)
if collection_id is None:
filters.append("u.collection_id IS NULL")
else:
filters.append("u.collection_id=?")
parameters.append(collection_id)
if parent_id is None:
filters.append("u.collection_parent_id IS NULL")
else:
filters.append("u.collection_parent_id=?")
parameters.append(parent_id)
with app.db.read() as conn:
rows = conn.execute(
"SELECT u.id AS upload_id,u.webdav_path AS virtual_path,u.temp_path,u.size_bytes,"
"u.content_sha256,u.content_sha256_verified,u.status AS upload_status,u.error,"
"u.collection_id,u.collection_parent_id,"
"u.created_at,u.updated_at FROM uploads u LEFT JOIN videos v "
"ON v.source_id=u.source_id AND v.source_key=u.target_key AND v.available=1 WHERE "
+ " AND ".join(filters)
+ " ORDER BY u.created_at DESC",
parameters,
).fetchall()
seen: set[str] = set()
result: list[dict] = []
for row in rows:
item = _pending_upload_resource(row)
if item["virtual_path"] in seen:
continue
seen.add(str(item["virtual_path"]))
result.append(item)
return result
def _staging(app: Services, virtual_path: str, config: dict | None = None) -> dict | None:
staged = _raw_staging(app, virtual_path)
if staged:
return staged
pending = _pending_uploads(
app,
config or _config(app),
virtual_path=virtual_path,
collection_id=None,
parent_id=None,
)
if pending:
return pending[0]
# A virtual path inside a collection cannot be filtered without resolving
# its current group ids. Exact-path lookup is still unambiguous and safe.
with app.db.read() as conn:
row = conn.execute(
"SELECT u.id AS upload_id,u.webdav_path AS virtual_path,u.temp_path,u.size_bytes,"
"u.content_sha256,u.content_sha256_verified,u.status AS upload_status,u.error,"
"u.collection_id,u.collection_parent_id,"
"u.created_at,u.updated_at FROM uploads u LEFT JOIN videos v "
"ON v.source_id=u.source_id AND v.source_key=u.target_key AND v.available=1 "
"WHERE u.source_id=? AND u.origin='webdav' AND u.webdav_path=? "
"AND u.status IN ('queued','transferring','indexing','failed') "
"AND (u.status<>'failed' OR u.failure_stage='catalog') AND v.id IS NULL "
"ORDER BY u.created_at DESC LIMIT 1",
((config or _config(app))["source_id"], virtual_path),
).fetchone()
return _pending_upload_resource(row) if row else None
def _staging_rows(
app: Services,
config: dict,
collection_id: str | None,
parent_id: str | None,
) -> list[dict]:
collection_condition = "collection_id IS NULL" if collection_id is None else "collection_id=?"
parent_condition = "collection_parent_id IS NULL" if parent_id is None else "collection_parent_id=?"
parameters = tuple(value for value in (collection_id, parent_id) if value is not None)
with app.db.read() as conn:
staged = [
_normalize_staging(row)
for row in conn.execute(
f"SELECT * FROM webdav_staging WHERE {collection_condition} AND {parent_condition} "
"ORDER BY virtual_path",
parameters,
).fetchall()
]
known = {str(item["virtual_path"]) for item in staged}
staged.extend(
item
for item in _pending_uploads(
app,
config,
collection_id=collection_id,
parent_id=parent_id,
)
if str(item["virtual_path"]) not in known
)
return staged
def _upload_mapping(
app: Services, parts: list[str], *, create: bool
) -> tuple[dict | None, str | None]:
if len(parts) == 1:
return None, None
collection = _collection(app, parts[0], create=create)
if not collection:
raise HTTPException(409 if create else 404, "合集不存在")
parent = _group(app, collection["id"], parts[1:-1], create=create)
if parts[1:-1] and not parent:
raise HTTPException(409 if create else 404, "合集分组不存在")
return collection, str(parent["id"]) if parent and parent.get("id") else None
def _dav_response(
href: str,
display_name: str,
*,
directory: bool,
size: int = 0,
modified: str | None = None,
content_type: str | None = None,
etag: str | None = None,
upload_offset: int | None = None,
upload_length: int | None = None,
upload_state: str | None = None,
) -> ET.Element:
response = ET.Element(f"{{{DAV}}}response")
ET.SubElement(response, f"{{{DAV}}}href").text = href
propstat = ET.SubElement(response, f"{{{DAV}}}propstat")
prop = ET.SubElement(propstat, f"{{{DAV}}}prop")
ET.SubElement(prop, f"{{{DAV}}}displayname").text = display_name
resource = ET.SubElement(prop, f"{{{DAV}}}resourcetype")
if directory:
ET.SubElement(resource, f"{{{DAV}}}collection")
else:
ET.SubElement(prop, f"{{{DAV}}}getcontentlength").text = str(max(0, size))
ET.SubElement(prop, f"{{{DAV}}}getcontenttype").text = content_type or "application/octet-stream"
ET.SubElement(prop, f"{{{DAV}}}getetag").text = f'"{etag or size}"'
if upload_offset is not None:
ET.SubElement(prop, f"{{{IMAGEFIND_DAV}}}upload-offset").text = str(upload_offset)
if upload_length is not None:
ET.SubElement(prop, f"{{{IMAGEFIND_DAV}}}upload-length").text = str(upload_length)
if upload_state:
ET.SubElement(prop, f"{{{IMAGEFIND_DAV}}}upload-state").text = upload_state
if modified:
try:
parsed = datetime.fromisoformat(modified.replace("Z", "+00:00"))
ET.SubElement(prop, f"{{{DAV}}}getlastmodified").text = format_datetime(
parsed.astimezone(UTC), usegmt=True
)
except ValueError:
pass
ET.SubElement(propstat, f"{{{DAV}}}status").text = "HTTP/1.1 200 OK"
return response
def _propfind(request: Request, app: Services, config: dict, parts: list[str]) -> Response:
depth = request.headers.get("depth", "infinity").lower()
if depth not in {"0", "1"}:
return Response(
content=b'<?xml version="1.0"?><d:error xmlns:d="DAV:"><d:propfind-finite-depth/></d:error>',
status_code=403,
media_type="application/xml",
)
root = ET.Element(f"{{{DAV}}}multistatus")
if not parts:
root.append(_dav_response(_href(request, [], directory=True), "ImageFind", directory=True))
if depth == "1":
with app.db.read() as conn:
rows = conn.execute("SELECT id,name,updated_at FROM collections ORDER BY name").fetchall()
staged_rows = _staging_rows(app, config, None, None)
known: set[str] = set()
for row in rows:
known.add(str(row["name"]))
root.append(
_dav_response(
_href(request, [row["name"]], directory=True),
row["name"],
directory=True,
modified=row["updated_at"],
)
)
for row in _root_videos(app, config):
known.add(str(row["display_name"]))
root.append(
_dav_response(
_href(request, [row["display_name"]]),
row["display_name"],
directory=False,
size=int(row["size_bytes"] or 0),
modified=row["modified_at"] or row["updated_at"],
content_type=mimetypes.guess_type(row["display_name"])[0],
etag=row["fingerprint"] or row["id"],
)
)
for row in staged_rows:
filename = str(row["virtual_path"])
if "/" in filename or filename in known:
continue
root.append(
_dav_response(
_href(request, [filename]),
filename,
directory=False,
size=int(row.get("received_bytes") or row.get("size_bytes") or 0),
modified=row["updated_at"],
content_type=mimetypes.guess_type(filename)[0],
etag=row.get("content_sha256") or row["updated_at"],
upload_offset=int(row.get("received_bytes") or 0),
upload_length=row.get("expected_size"),
upload_state=row.get("state"),
)
)
else:
collection = _collection(app, parts[0])
if not collection:
if len(parts) != 1:
raise HTTPException(404, "合集不存在")
staged = _staging(app, parts[0])
video = _root_video(app, config, parts[0])
resource = staged or video
if not resource:
raise HTTPException(404, "视频不存在")
root.append(
_dav_response(
_href(request, parts),
parts[-1],
directory=False,
size=int(
resource.get("received_bytes")
or resource.get("size_bytes")
or 0
),
modified=(
resource["updated_at"]
if staged
else resource["modified_at"] or resource["updated_at"]
),
content_type=mimetypes.guess_type(parts[-1])[0],
etag=(
resource.get("content_sha256") or resource["updated_at"]
if staged
else resource["fingerprint"] or resource["id"]
),
upload_offset=int(resource.get("received_bytes") or 0) if staged else None,
upload_length=resource.get("expected_size") if staged else None,
upload_state=resource.get("state") if staged else None,
)
)
content = ET.tostring(root, encoding="utf-8", xml_declaration=True)
return Response(content=content, status_code=207, media_type="application/xml; charset=utf-8")
group = _group(app, collection["id"], parts[1:])
if len(parts) == 1 or group:
parent_id = str(group["id"]) if group and group.get("id") else None
modified = group.get("updated_at") if group else collection["updated_at"]
root.append(
_dav_response(
_href(request, parts, directory=True),
parts[-1],
directory=True,
modified=modified,
)
)
if depth == "1":
condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
args: tuple[object, ...] = (
(collection["id"],) if parent_id is None else (collection["id"], parent_id)
)
with app.db.read() as conn:
groups = conn.execute(
f"SELECT id,name,updated_at FROM collection_items WHERE collection_id=? AND {condition} "
"AND kind='group' ORDER BY position,name",
args,
).fetchall()
videos = conn.execute(
"SELECT v.id,v.display_name,v.size_bytes,v.modified_at,v.updated_at,v.fingerprint "
"FROM collection_items ci JOIN videos v ON v.id=ci.video_id "
f"WHERE ci.collection_id=? AND {condition.replace('parent_id', 'ci.parent_id')} "
"AND ci.kind='video' AND v.available=1 ORDER BY ci.position,v.display_name",
args,
).fetchall()
staged = _staging_rows(app, config, str(collection["id"]), parent_id)
for child in groups:
root.append(
_dav_response(
_href(request, [*parts, child["name"]], directory=True),
child["name"],
directory=True,
modified=child["updated_at"],
)
)
known: set[str] = set()
for row in videos:
known.add(str(row["display_name"]))
root.append(
_dav_response(
_href(request, [*parts, row["display_name"]]),
row["display_name"],
directory=False,
size=int(row["size_bytes"] or 0),
modified=row["modified_at"] or row["updated_at"],
content_type=mimetypes.guess_type(row["display_name"])[0],
etag=row["fingerprint"] or row["id"],
)
)
for row in staged:
filename = str(row["virtual_path"]).rsplit("/", 1)[-1]
if filename in known:
continue
root.append(
_dav_response(
_href(request, [*parts, filename]),
filename,
directory=False,
size=int(row["received_bytes"] or row["size_bytes"] or 0),
modified=row["updated_at"],
content_type=mimetypes.guess_type(filename)[0],
etag=row["content_sha256"] or row["updated_at"],
upload_offset=int(row["received_bytes"] or 0),
upload_length=row["expected_size"],
upload_state=row["state"],
)
)
else:
parent = _group(app, collection["id"], parts[1:-1])
if parts[1:-1] and not parent:
raise HTTPException(404, "合集分组不存在")
parent_id = str(parent["id"]) if parent and parent.get("id") else None
virtual = "/".join(parts)
staged = _staging(app, virtual)
video = _video(app, collection["id"], parent_id, parts[-1])
resource = staged or video
if not resource:
raise HTTPException(404, "视频不存在")
root.append(
_dav_response(
_href(request, parts),
parts[-1],
directory=False,
size=int(
resource.get("received_bytes")
or resource.get("size_bytes")
or 0
),
modified=resource["updated_at"] if staged else resource["modified_at"] or resource["updated_at"],
content_type=mimetypes.guess_type(parts[-1])[0],
etag=(
resource.get("content_sha256") or resource["updated_at"]
if staged
else resource["fingerprint"] or resource["id"]
),
upload_offset=int(resource.get("received_bytes") or 0) if staged else None,
upload_length=resource.get("expected_size") if staged else None,
upload_state=resource.get("state") if staged else None,
)
)
content = ET.tostring(root, encoding="utf-8", xml_declaration=True)
return Response(content=content, status_code=207, media_type="application/xml; charset=utf-8")
def _is_video(filename: str) -> bool:
return Path(filename).suffix.lower() in VIDEO_EXTENSIONS
def _is_temporary(filename: str) -> bool:
lower = filename.lower()
return any(lower.endswith(suffix) for suffix in TEMP_SUFFIXES) or not _is_video(filename)
def _target_directory(config: dict, hierarchy: list[str]) -> str:
return safe_relative_path(posixpath.join(config["relative_path"], *hierarchy))
def _put_position(request: Request) -> tuple[int, int | None, int | None]:
declared_text = request.headers.get("content-length")
if declared_text is not None and not declared_text.isdigit():
raise HTTPException(400, "Content-Length 无效")
declared = int(declared_text) if declared_text is not None else None
value = request.headers.get("content-range")
if value:
match = _CONTENT_RANGE.fullmatch(value.strip())
if not match:
raise HTTPException(400, "Content-Range 无效")
start, end = int(match.group(1)), int(match.group(2))
total = None if match.group(3) == "*" else int(match.group(3))
if end < start or (declared is not None and declared != end - start + 1):
raise HTTPException(400, "Content-Range 与请求长度不一致")
if total is not None and end >= total:
raise HTTPException(400, "Content-Range 超出文件大小")
return start, total, end
offset_text = request.headers.get("upload-offset")
total_text = request.headers.get("upload-length")
if offset_text is not None and not offset_text.isdigit():
raise HTTPException(400, "Upload-Offset 无效")
if total_text is not None and not total_text.isdigit():
raise HTTPException(400, "Upload-Length 无效")
start = int(offset_text or 0)
total = int(total_text) if total_text is not None else (declared if start == 0 else None)
return start, total, None
def _request_sha256(request: Request) -> str | None:
for name in ("x-content-sha256", "x-checksum-sha256"):
value = (request.headers.get(name) or "").strip().lower()
if re.fullmatch(r"[0-9a-f]{64}", value):
return value
digest = request.headers.get("content-digest") or request.headers.get("digest") or ""
for part in digest.split(","):
name, separator, encoded = part.strip().partition("=")
if separator and name.lower() in {"sha-256", "sha256"}:
try:
raw = base64.b64decode(encoded.strip().strip(":"), validate=True)
except ValueError:
continue
if len(raw) == 32:
return raw.hex()
return None
def _persist_staging(
app: Services,
virtual: str,
temporary: Path,
received: int,
expected: int | None,
content_sha256: str | None,
state: str,
error: str | None,
collection_id: str | None,
parent_id: str | None,
content_sha256_verified: bool = False,
) -> None:
now = utcnow()
def write(conn):
conn.execute(
"INSERT INTO webdav_staging(virtual_path,temp_path,size_bytes,expected_size,received_bytes,"
"content_sha256,content_sha256_verified,state,error,collection_id,collection_parent_id,"
"created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(virtual_path) DO UPDATE SET "
"temp_path=excluded.temp_path,size_bytes=excluded.size_bytes,expected_size=excluded.expected_size,"
"received_bytes=excluded.received_bytes,content_sha256=excluded.content_sha256,state=excluded.state,"
"content_sha256_verified=excluded.content_sha256_verified,error=excluded.error,"
"collection_id=excluded.collection_id,"
"collection_parent_id=excluded.collection_parent_id,updated_at=excluded.updated_at",
(
virtual,
str(temporary),
received,
expected,
received,
content_sha256,
int(content_sha256_verified),
state,
error,
collection_id,
parent_id,
now,
now,
),
)
app.db.write_with_retry(write)
def _pwrite_all(file_descriptor: int, data: bytes, offset: int) -> int:
view = memoryview(data)
written_total = 0
while written_total < len(view):
written = os.pwrite(file_descriptor, view[written_total:], offset + written_total)
if written <= 0:
raise OSError("WebDAV 暂存文件写入未取得进展")
written_total += written
return written_total
def _queue_writeback(file_descriptor: int, offset: int, length: int) -> bool:
"""Start Linux writeback for a received range without waiting for durability.
This keeps the process below the global dirty-page throttle that otherwise
pauses every concurrent PUT at once. Unsupported kernels/filesystems simply
retain the normal buffered-write behavior.
"""
if _sync_file_range is None or length <= 0:
return False
if _sync_file_range(file_descriptor, offset, length, _SYNC_FILE_RANGE_WRITE) == 0:
return True
error_number = ctypes.get_errno()
if error_number in {errno.EINVAL, errno.ENOSYS, errno.EOPNOTSUPP}:
return False
raise OSError(error_number, os.strerror(error_number))
def _open_staging(path: Path, start: int) -> int:
"""Open a staging file without request-path durability barriers.
O_DSYNC looked attractive as a way to avoid a final writeback cliff, but on
slow or busy volumes every small write then becomes a durability barrier.
With several clients those barriers serialize and stop later request
sockets being read for longer than common DAV client write timeouts. The
receiver instead uses bounded, fairly interleaved buffered writes and the
transfer worker subsequently consumes and atomically commits the staging
file. A power loss may therefore leave a resumable partial upload, but it
cannot expose an incomplete video as completed.
"""
path.parent.mkdir(parents=True, exist_ok=True)
file_descriptor = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
try:
actual_size = os.fstat(file_descriptor).st_size
if actual_size < start:
raise OSError(
f"WebDAV 暂存文件短于已确认续传偏移:文件 {actual_size} 字节,偏移 {start} 字节"
)
# A process may stop after pwrite completed but before the matching
# database checkpoint committed. Only the persisted offset is safe to
# acknowledge to a resumed DAV client, so discard that unconfirmed
# tail instead of producing a duplicated/corrupted range.
if actual_size != start:
os.ftruncate(file_descriptor, start)
return file_descriptor
except Exception:
os.close(file_descriptor)
raise
def _staging_reservation(app: Services, virtual: str) -> int:
with app.db.read() as conn:
return int(
conn.execute(
"SELECT coalesce(sum(size_bytes),0) FROM uploads "
"WHERE status IN ('receiving','queued','transferring')"
).fetchone()[0]
) + int(
conn.execute(
"SELECT coalesce(sum(size_bytes),0) FROM webdav_staging WHERE virtual_path<>?",
(virtual,),
).fetchone()[0]
)
def _assign_duplicate_video(
app: Services,
source_id: str,
duplicate_key: str,
collection_id: str | None,
parent_id: str | None,
) -> None:
if not collection_id:
return
with app.db.transaction() as conn:
video = conn.execute(
"SELECT id FROM videos WHERE source_id=? AND source_key=? AND available=1",
(source_id, duplicate_key),
).fetchone()
if video:
assign_videos(conn, collection_id, [video["id"]], parent_id=parent_id)
def _delete_staging(app: Services, virtual: str) -> None:
app.db.write_with_retry(
lambda conn: conn.execute("DELETE FROM webdav_staging WHERE virtual_path=?", (virtual,))
)
async def _put(request: Request, app: Services, config: dict, parts: list[str]) -> Response:
if not parts:
raise HTTPException(409, "WebDAV 根目录不能作为文件覆盖")
virtual = "/".join(parts)
lock = _path_lock(virtual)
if not lock.acquire(blocking=False):
current = await _background_io(app, _staging, app, virtual, config)
offset = int(current.get("received_bytes") or 0) if current else 0
raise HTTPException(
423,
"同一路径正在上传,请稍后继续",
headers={"Upload-Offset": str(offset), "Retry-After": "2"},
)
try:
# Do not create collections/groups before the request body is read.
# A DAV client commonly waits for ``100 Continue`` at this point; a
# contended SQLite writer would otherwise delay the first body byte for
# tens of seconds and make the client report a network rollback.
try:
collection, parent_id = await _background_io(
app, _upload_mapping, app, parts, create=False
)
except HTTPException as exc:
if exc.status_code != 404:
raise
collection, parent_id = None, None
collection_id = str(collection["id"]) if collection else None
start, expected, range_end = _put_position(request)
expected_hash = _request_sha256(request)
maximum = 200 * 1024**3
if start > maximum or (expected is not None and (expected <= 0 or expected > maximum)):
raise HTTPException(413, "WebDAV 单文件最大 200 GB")
target_directory = _target_directory(config, parts[:-1])
if expected_hash and expected is not None and start == 0 and not _is_temporary(parts[-1]):
duplicate_upload = await _background_io(
app,
app.uploads.matching_webdav_upload,
config["source_id"],
virtual,
size_bytes=expected,
content_sha256=expected_hash,
)
if duplicate_upload:
return Response(
status_code=204,
headers={
"X-ImageFind-Deduplicated": "true",
"X-ImageFind-Upload-Id": str(duplicate_upload["id"]),
"Upload-Offset": str(expected),
"Upload-Length": str(expected),
"ETag": f'"sha256:{expected_hash}"',
},
)
duplicate_key = await _background_io(
app,
app.storage.matching_content_key,
config["source_id"],
target_directory,
parts[-1],
size_bytes=expected,
content_sha256=expected_hash,
)
if duplicate_key:
if collection_id:
with app.db.transaction() as conn:
video = conn.execute(
"SELECT id FROM videos WHERE source_id=? AND source_key=? AND available=1",
(config["source_id"], duplicate_key),
).fetchone()
if video:
assign_videos(conn, collection_id, [video["id"]], parent_id=parent_id)
return Response(
status_code=204,
headers={
"X-ImageFind-Deduplicated": "true",
"Upload-Offset": str(expected),
"Upload-Length": str(expected),
"ETag": f'"sha256:{expected_hash}"',
},
)
old = await _background_io(app, _staging, app, virtual, config)
old_received = int(old.get("received_bytes") or old.get("size_bytes") or 0) if old else 0
temporary = (
Path(old["temp_path"])
if old and Path(old["temp_path"]).is_file()
else app.settings.upload_staging_dir / f"webdav-{uuid.uuid4().hex}.part"
)
if start and (not old or not temporary.is_file() or old_received != start):
raise HTTPException(
409,
f"续传偏移不一致,服务器当前偏移为 {old_received if temporary.is_file() else 0}",
headers={"Upload-Offset": str(old_received if temporary.is_file() else 0)},
)
if start > 0 and old and expected is None:
expected = old.get("expected_size")
if start > 0 and old and expected_hash is None:
expected_hash = old.get("content_sha256")
if expected is not None and start > expected:
raise HTTPException(409, "续传偏移超过文件大小", headers={"Upload-Offset": str(old_received)})
reserved = await _background_io(app, _staging_reservation, app, virtual)
quota = int(app.settings.upload_staging_gb * 1024**3)
anticipated = expected if expected is not None else start
if reserved + anticipated > quota:
raise HTTPException(507, "上传暂存配额不足")
reserve = int(app.settings.upload_reserve_gb * 1024**3)
file_descriptor = await _background_io(app, _open_staging, temporary, start)
received = start
hasher = hashlib.sha256() if start == 0 else None
write_buffer = bytearray()
write_buffer_start = start
writeback_start = start
writeback_enabled = True
last_saved_at = time.monotonic()
last_saved_bytes = start
last_disk_check_bytes = start
staging_persisted = False
async def flush_buffer(*, complete: bool = False) -> None:
nonlocal write_buffer_start, writeback_start, writeback_enabled
# Never turn a large ASGI body chunk into one unbounded disk
# operation. Each PUT writes its own file in a worker thread; a
# device-level dirty-page stall in one request must not hold a
# process-wide lock and stop all other client sockets being read.
while len(write_buffer) >= WEBDAV_WRITE_QUANTUM or (complete and write_buffer):
length = min(len(write_buffer), WEBDAV_WRITE_QUANTUM)
data = bytes(write_buffer[:length])
written = await _background_io(
app, _pwrite_all, file_descriptor, data, write_buffer_start
)
if written != len(data):
raise OSError("WebDAV 暂存文件写入不完整")
write_buffer_start += written
del write_buffer[:written]
if (
writeback_enabled
and write_buffer_start - writeback_start >= WEBDAV_WRITEBACK_INTERVAL
):
writeback_enabled = await _background_io(
app,
_queue_writeback,
file_descriptor,
writeback_start,
write_buffer_start - writeback_start,
)
writeback_start = write_buffer_start
try:
async for chunk in request.stream():
if not chunk:
continue
if not staging_persisted:
# Enter the request stream before the first SQLite write so
# servers can acknowledge Expect: 100-continue promptly.
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
expected_hash,
"receiving",
None,
collection_id,
parent_id,
)
staging_persisted = True
received += len(chunk)
if received > maximum or (expected is not None and received > expected):
raise HTTPException(413, "上传内容超过声明的文件大小")
if reserved + received > quota:
raise HTTPException(507, "上传暂存配额不足")
if received - last_disk_check_bytes >= WEBDAV_DISK_CHECK_INTERVAL:
if (await _background_io(app, shutil.disk_usage, temporary.parent)).free < reserve:
raise HTTPException(507, "NAS 可用空间不足")
last_disk_check_bytes = received
if hasher is not None:
hasher.update(chunk)
write_buffer.extend(chunk)
if len(write_buffer) >= WEBDAV_WRITE_QUANTUM:
await flush_buffer()
now = time.monotonic()
if (
now - last_saved_at >= WEBDAV_PROGRESS_INTERVAL_SECONDS
or received - last_saved_bytes >= WEBDAV_PROGRESS_INTERVAL_BYTES
):
await flush_buffer(complete=True)
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
expected_hash,
"receiving",
None,
collection_id,
parent_id,
)
last_saved_at, last_saved_bytes = now, received
await flush_buffer(complete=True)
if writeback_enabled and write_buffer_start > writeback_start:
await _background_io(
app,
_queue_writeback,
file_descriptor,
writeback_start,
write_buffer_start - writeback_start,
)
# A synchronous fsync here used to hold the HTTP response for up
# to several minutes when multiple GiB PUTs completed together.
# Worse, the resulting device-wide writeback stopped the server
# reading other request bodies long enough for DAV clients to hit
# their socket write timeout. pwrite completion plus the strict
# fstat check below is sufficient to acknowledge receipt; the
# single transfer worker then consumes/atomically commits this
# staging file without making client connectivity depend on NAS
# flush latency.
final_stat = await _background_io(app, os.fstat, file_descriptor)
actual_size = final_stat.st_size
if actual_size != received:
raise OSError(
f"WebDAV 暂存文件尺寸异常:已接收 {received} 字节,磁盘记录 {actual_size} 字节"
)
except Exception as exc:
received = os.fstat(file_descriptor).st_size
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
expected_hash,
"partial",
str(exc)[:4000],
collection_id,
parent_id,
)
raise
finally:
os.close(file_descriptor)
if received <= 0:
raise HTTPException(400, "不能上传空文件")
if range_end is not None and received != range_end + 1:
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
expected_hash,
"partial",
"本次分块长度不足",
collection_id,
parent_id,
)
raise HTTPException(400, "Content-Range 与实际上传长度不一致")
if expected is not None and received < expected:
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
expected_hash,
"partial",
None,
collection_id,
parent_id,
)
return Response(
status_code=204,
headers={"Upload-Offset": str(received), "Upload-Length": str(expected)},
)
content_hash = hasher.hexdigest() if hasher is not None else expected_hash
if expected_hash and hasher is not None and content_hash != expected_hash:
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
content_hash,
"error",
"文件 SHA-256 校验失败",
collection_id,
parent_id,
)
raise HTTPException(422, "文件 SHA-256 校验失败")
expected = expected or received
if not _is_temporary(parts[-1]):
# Collection/group creation is deliberately after body receipt;
# see the request-stream comment above. Existing directories are
# still resolved before the body when possible, so normal uploads
# retain their collection mapping without delaying 100 Continue.
collection, parent_id = await _background_io(
app, _upload_mapping, app, parts, create=True
)
collection_id = str(collection["id"]) if collection else None
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
content_hash,
"complete",
None,
collection_id,
parent_id,
hasher is not None,
)
etag = (
f'"sha256:{content_hash}"'
if content_hash
else f'"upload:{received}:{final_stat.st_mtime_ns}"'
)
common_headers = {
"Upload-Offset": str(received),
"Upload-Length": str(expected),
"ETag": etag,
}
if _is_temporary(parts[-1]):
return Response(status_code=201 if old is None else 204, headers=common_headers)
if content_hash:
duplicate_upload = await _background_io(
app,
app.uploads.matching_webdav_upload,
config["source_id"],
virtual,
size_bytes=received,
content_sha256=content_hash,
)
if duplicate_upload:
await _background_io(app, temporary.unlink, True)
await _background_io(app, _delete_staging, app, virtual)
return Response(
status_code=204,
headers={
**common_headers,
"X-ImageFind-Deduplicated": "true",
"X-ImageFind-Upload-Id": str(duplicate_upload["id"]),
},
)
duplicate_key = await _background_io(
app,
app.storage.matching_content_key,
config["source_id"],
target_directory,
parts[-1],
size_bytes=received,
content_sha256=content_hash,
)
if duplicate_key:
await _background_io(
app,
_assign_duplicate_video,
app,
config["source_id"],
duplicate_key,
collection_id,
parent_id,
)
await _background_io(app, temporary.unlink, True)
await _background_io(app, _delete_staging, app, virtual)
return Response(
status_code=204,
headers={**common_headers, "X-ImageFind-Deduplicated": "true"},
)
try:
item = await _background_io(
app,
app.uploads.adopt_staged,
config["source_id"],
target_directory,
parts[-1],
temporary,
collection_id=collection_id,
collection_parent_id=parent_id,
content_sha256=content_hash,
webdav_path=virtual,
content_sha256_verified=hasher is not None,
)
except Exception as exc:
await _background_io(
app,
_persist_staging,
app,
virtual,
temporary,
received,
expected,
content_hash,
"error",
str(exc)[:4000],
collection_id,
parent_id,
)
raise
await _background_io(app, _delete_staging, app, virtual)
return Response(
status_code=201 if old is None else 204,
headers={**common_headers, "X-ImageFind-Upload-Id": item["id"]},
)
finally:
_release_path_lock(virtual, lock)
def _destination_parts(request: Request) -> list[str]:
value = request.headers.get("destination")
if not value:
raise HTTPException(400, "MOVE 缺少 Destination")
path = unquote(urlsplit(value).path)
marker = "/webdav/"
if marker not in path:
raise HTTPException(400, "Destination 不属于 ImageFind WebDAV")
return _parts(path.split(marker, 1)[1])
def _exists(app: Services, config: dict, parts: list[str]) -> bool:
if not parts:
return True
collection = _collection(app, parts[0])
if not collection:
return len(parts) == 1 and bool(
_staging(app, parts[0]) or _root_video(app, config, parts[0])
)
if len(parts) == 1 or _group(app, collection["id"], parts[1:]):
return True
parent = _group(app, collection["id"], parts[1:-1])
if parts[1:-1] and not parent:
return False
parent_id = str(parent["id"]) if parent and parent.get("id") else None
return bool(_staging(app, "/".join(parts)) or _video(app, collection["id"], parent_id, parts[-1]))
def _move(request: Request, app: Services, config: dict, parts: list[str]) -> Response:
if not parts:
raise HTTPException(403, "不能移动 WebDAV 根目录")
source_virtual = "/".join(parts)
staged = _raw_staging(app, source_virtual)
destination = _destination_parts(request)
if not destination:
raise HTTPException(409, "目标文件路径无效")
if request.headers.get("overwrite", "T").upper() == "F" and _exists(app, config, destination):
raise HTTPException(412, "目标已存在")
if staged:
collection, destination_parent_id = _upload_mapping(app, destination, create=True)
destination_collection_id = str(collection["id"]) if collection else None
destination_virtual = "/".join(destination)
if _is_temporary(destination[-1]):
with app.db.transaction() as conn:
if destination_virtual != source_virtual:
existing = conn.execute(
"SELECT temp_path FROM webdav_staging WHERE virtual_path=?", (destination_virtual,)
).fetchone()
if existing:
Path(existing["temp_path"]).unlink(missing_ok=True)
conn.execute("DELETE FROM webdav_staging WHERE virtual_path=?", (destination_virtual,))
conn.execute(
"UPDATE webdav_staging SET virtual_path=?,collection_id=?,collection_parent_id=?,"
"updated_at=? WHERE virtual_path=?",
(
destination_virtual,
collection["id"] if collection else None,
destination_parent_id,
utcnow(),
source_virtual,
),
)
return Response(status_code=201)
if staged.get("state") != "complete":
raise HTTPException(409, "文件尚未上传完整,不能提交入库")
content_hash = staged.get("content_sha256")
if content_hash:
duplicate_upload = app.uploads.matching_webdav_upload(
config["source_id"],
destination_virtual,
size_bytes=int(staged.get("received_bytes") or staged.get("size_bytes") or 0),
content_sha256=content_hash,
)
if duplicate_upload:
Path(staged["temp_path"]).unlink(missing_ok=True)
_delete_staging(app, source_virtual)
return Response(
status_code=204,
headers={
"X-ImageFind-Deduplicated": "true",
"X-ImageFind-Upload-Id": str(duplicate_upload["id"]),
},
)
duplicate_key = app.storage.matching_content_key(
config["source_id"],
_target_directory(config, destination[:-1]),
destination[-1],
size_bytes=int(staged.get("received_bytes") or staged.get("size_bytes") or 0),
content_sha256=content_hash,
)
if duplicate_key:
if destination_collection_id:
with app.db.transaction() as conn:
video = conn.execute(
"SELECT id FROM videos WHERE source_id=? AND source_key=? AND available=1",
(config["source_id"], duplicate_key),
).fetchone()
if video:
assign_videos(
conn,
destination_collection_id,
[video["id"]],
parent_id=destination_parent_id,
)
Path(staged["temp_path"]).unlink(missing_ok=True)
_delete_staging(app, source_virtual)
return Response(
status_code=204,
headers={"X-ImageFind-Deduplicated": "true"},
)
item = app.uploads.adopt_staged(
config["source_id"],
_target_directory(config, destination[:-1]),
destination[-1],
Path(staged["temp_path"]),
collection_id=destination_collection_id,
collection_parent_id=destination_parent_id,
content_sha256=content_hash,
webdav_path=destination_virtual,
content_sha256_verified=bool(staged.get("content_sha256_verified")),
)
_delete_staging(app, source_virtual)
return Response(status_code=201, headers={"X-ImageFind-Upload-Id": item["id"]})
pending = _staging(app, source_virtual, config)
if pending and pending.get("_resource_origin") == "upload":
raise HTTPException(
423,
"文件已接收并正在入库,暂时不能移动",
headers={
"Upload-Offset": str(int(pending.get("received_bytes") or 0)),
"Retry-After": "2",
},
)
destination_pending = _staging(app, "/".join(destination), config)
if destination_pending and destination_pending.get("_resource_origin") == "upload":
headers = {"X-ImageFind-Deduplicated": "true"}
if destination_pending.get("upload_id"):
headers["X-ImageFind-Upload-Id"] = str(destination_pending["upload_id"])
return Response(status_code=204, headers=headers)
if _is_temporary(parts[-1]) and _exists(app, config, destination):
return Response(status_code=204, headers={"X-ImageFind-Deduplicated": "true"})
if len(parts) < 2:
raise HTTPException(405, "已入库根目录视频请在 ImageFind 中管理")
if len(destination) < 2:
raise HTTPException(409, "已入库合集内容不能移动到根目录")
source_collection = _collection(app, parts[0])
if not source_collection:
raise HTTPException(404, "合集不存在")
collection = _collection(app, destination[0], create=True)
assert collection is not None
if collection["id"] != source_collection["id"]:
raise HTTPException(409, "已入库节点暂不支持跨合集移动")
destination_parent = _group(app, collection["id"], destination[1:-1])
if destination[1:-1] and not destination_parent:
raise HTTPException(409, "目标分组不存在")
destination_parent_id = (
str(destination_parent["id"]) if destination_parent and destination_parent.get("id") else None
)
source_group = _group(app, source_collection["id"], parts[1:])
with app.db.transaction() as conn:
if source_group:
rename_group(conn, source_collection["id"], str(source_group["id"]), destination[-1])
move_item(
conn,
source_collection["id"],
str(source_group["id"]),
destination_parent_id,
2**31 - 1,
)
else:
source_parent = _group(app, source_collection["id"], parts[1:-1])
source_parent_id = (
str(source_parent["id"]) if source_parent and source_parent.get("id") else None
)
video = _video(app, source_collection["id"], source_parent_id, parts[-1])
if not video:
raise HTTPException(404, "视频不存在")
if destination[-1] != parts[-1]:
raise HTTPException(409, "移动已入库视频时不能修改源文件名")
move_item(
conn,
source_collection["id"],
str(video["collection_item_id"]),
destination_parent_id,
2**31 - 1,
)
return Response(status_code=201)
def _staged_response(request: Request, staged: dict, filename: str) -> Response:
path = Path(staged["temp_path"])
if not path.is_file():
if staged.get("_resource_origin") != "upload":
raise HTTPException(404, "暂存文件不存在")
size = int(staged.get("received_bytes") or staged.get("size_bytes") or 0)
headers = {
"Accept-Ranges": "bytes",
"Content-Type": mimetypes.guess_type(filename)[0] or "application/octet-stream",
"Upload-Offset": str(size),
"Upload-Length": str(int(staged.get("expected_size") or size)),
"X-ImageFind-Upload-State": str(staged.get("state") or "accepted"),
"Retry-After": "2",
}
if request.method == "HEAD":
headers["Content-Length"] = str(size)
return Response(status_code=200, headers=headers)
raise HTTPException(503, "文件已接收,正在写入媒体库", headers=headers)
size = path.stat().st_size
start, end, status = 0, max(0, size - 1), 200
value = request.headers.get("range")
if value and value.startswith("bytes="):
try:
left, right = value[6:].split("-", 1)
if left:
start = int(left)
end = min(int(right) if right else size - 1, size - 1)
else:
suffix = min(int(right), size)
start = size - suffix
if start < 0 or start > end:
raise ValueError
status = 206
except ValueError as exc:
raise HTTPException(416, "无效的 Range") from exc
headers = {
"Accept-Ranges": "bytes",
"Content-Length": str(0 if size == 0 else end - start + 1),
"Content-Type": mimetypes.guess_type(filename)[0] or "application/octet-stream",
"Upload-Offset": str(int(staged.get("received_bytes") or size)),
"X-ImageFind-Upload-State": str(staged.get("state") or "complete"),
}
if staged.get("expected_size") is not None:
headers["Upload-Length"] = str(staged["expected_size"])
if status == 206:
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
if request.method == "HEAD":
return Response(status_code=status, headers=headers)
async def body():
with path.open("rb") as handle:
handle.seek(start)
remaining = end - start + 1
while remaining:
chunk = handle.read(min(1024 * 1024, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
return StreamingResponse(body(), status_code=status, headers=headers)
def _resolve_read(request: Request, app: Services, parts: list[str]) -> Response | str:
if not parts:
raise HTTPException(405, "目录只能使用 PROPFIND 浏览")
staged = _staging(app, "/".join(parts))
if staged:
return _staged_response(request, staged, parts[-1])
config = _config(app)
collection = _collection(app, parts[0])
if not collection:
if len(parts) != 1:
raise HTTPException(404, "合集不存在")
video = _root_video(app, config, parts[0])
if not video:
raise HTTPException(404, "视频不存在")
return str(video["id"])
parent = _group(app, collection["id"], parts[1:-1])
if parts[1:-1] and not parent:
raise HTTPException(404, "合集分组不存在")
parent_id = str(parent["id"]) if parent and parent.get("id") else None
video = _video(app, collection["id"], parent_id, parts[-1])
if not video:
raise HTTPException(404, "视频不存在")
return str(video["id"])
async def _read(request: Request, app: Services, parts: list[str]) -> Response:
resolved = await _background_io(app, _resolve_read, request, app, parts)
if isinstance(resolved, Response):
return resolved
return await stream_video(resolved, request, app, {"kind": "api"}, mode="proxy")
def _mkcol(app: Services, parts: list[str]) -> Response:
if not parts:
raise HTTPException(405, "WebDAV 根目录已存在")
if len(parts) == 1:
existed = _collection(app, parts[0]) is not None
_collection(app, parts[0], create=True)
return Response(status_code=405 if existed else 201)
collection = _collection(app, parts[0])
if not collection:
raise HTTPException(409, "请先创建合集目录")
if _group(app, collection["id"], parts[1:]):
return Response(status_code=405)
parent = _group(app, collection["id"], parts[1:-1])
if parts[1:-1] and not parent:
raise HTTPException(409, "请先创建上级分组")
parent_id = str(parent["id"]) if parent and parent.get("id") else None
try:
with app.db.transaction() as conn:
create_group(conn, collection["id"], parts[-1], parent_id)
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
return Response(status_code=201)
def _delete(app: Services, config: dict, parts: list[str]) -> Response:
virtual = "/".join(parts)
staged = _staging(app, virtual, config)
if staged:
if staged.get("_resource_origin") == "upload":
raise HTTPException(
423,
"文件已接收并正在入库,请在上传中心管理该任务",
headers={"Retry-After": "2"},
)
Path(staged["temp_path"]).unlink(missing_ok=True)
_delete_staging(app, virtual)
return Response(status_code=204)
if len(parts) < 2:
raise HTTPException(405, "不能通过 WebDAV 删除根目录或合集")
collection = _collection(app, parts[0])
group = _group(app, collection["id"], parts[1:]) if collection else None
if not collection or not group:
raise HTTPException(405, "已入库视频必须在 ImageFind 中删除")
with app.db.transaction() as conn:
delete_group(conn, collection["id"], str(group["id"]))
return Response(status_code=204)
@router.api_route(
"/webdav",
methods=["OPTIONS", "PROPFIND", "MKCOL", "PUT", "MOVE", "GET", "HEAD", "DELETE"],
include_in_schema=False,
)
@router.api_route(
"/webdav/{dav_path:path}",
methods=["OPTIONS", "PROPFIND", "MKCOL", "PUT", "MOVE", "GET", "HEAD", "DELETE"],
include_in_schema=False,
)
async def webdav(request: Request, dav_path: str = ""):
app: Services = request.app.state.services
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"DAV": "1",
"Allow": "OPTIONS, PROPFIND, MKCOL, PUT, MOVE, GET, HEAD, DELETE",
"MS-Author-Via": "DAV",
"X-ImageFind-Resumable": "content-range,upload-offset",
},
)
config = await _background_io(app, _config, app)
if not config["enabled"] or not config["source_id"]:
raise HTTPException(404, "ImageFind WebDAV 未启用")
await _authenticate(request, app)
parts = _parts(dav_path)
if request.method == "PROPFIND":
return await _background_io(app, _propfind, request, app, config, parts)
if request.method == "MKCOL":
return await _background_io(app, _mkcol, app, parts)
if request.method == "PUT":
return await _put(request, app, config, parts)
if request.method == "MOVE":
return await _background_io(app, _move, request, app, config, parts)
if request.method in {"GET", "HEAD"}:
return await _read(request, app, parts)
if request.method == "DELETE":
return await _background_io(app, _delete, app, config, parts)
raise HTTPException(405)