Files
mule-image/backend/app/services/nextcloud_dav.py
Claudio 347f58b4f3 perf(thumbs): pool NC client, smaller grid thumbs, eager owner load
Five stacked optimisations for the thumbnail hot path so the timeline
grid lands in fewer round trips and fewer bytes.

1. PhotoThumbnail: switch from 'medium' (640px) to 'small' (240px) for
   grid cells. 240px oversamples 150-200px logical cells on 2x retina
   and drops payload 5-8x. Lightbox and preview filmstrip keep 'large'
   and 'medium' respectively.

2. nextcloud_dav: pool the httpx client. A module-level AsyncClient
   with HTTP/2 + keepalive (max_connections=64, keepalive_expiry=120s)
   replaces the per-request constructor that paid a fresh TCP+TLS
   handshake on every preview fetch. Auth is per-user so it stays at
   the call site via auth=BasicAuth(...). Lifespan-managed: init in
   main.py's lifespan startup, aclose on shutdown. requirements.txt
   gains the http2 extra to pull in h2 (not currently installed).
   Same change applies to fetch_memories_info_async since it hits the
   same host.

3. PhotoThumbnail img: add decoding="async" so JPEG/WebP decode moves
   off the main thread, plus fetchPriority="low" so grid backfill
   doesn't fight UI fetches.

4. Eager-load Photo.user via joinedload from the thumb handler.
   _get_photo_with_share_fallback gains an options parameter so other
   callers stay zero-overhead; only the thumb handler asks for the
   owner join. Eliminates the second SELECT users per request.

5. Disk-fallback path picks up Cache-Control: private, max-age=86400
   in both the FileResponse and X-Accel branches so re-renders match
   the NC primary path's caching behaviour.

Net: a warm grid page should drop from ~200-400 ms median per thumb to
well under 100 ms; payload drops ~5-8x; backend sustains higher
concurrency with fewer sockets to Nextcloud and one fewer Postgres
round-trip per request.
2026-05-12 00:30:43 +02:00

513 lines
18 KiB
Python

"""Nextcloud WebDAV client — only the verbs we actually need.
Outgoing mutations (upload, delete, rename/move) on files that live
under a user's Nextcloud-rooted SourceRoot route through this client
instead of touching the filesystem directly. That way Nextcloud's
oc_filecache, trashbin, sharing/comments metadata, and desktop sync
clients all stay coherent — the price of bypassing it is a stale
Nextcloud and resurrected files when sync clients re-upload.
Reads (scanning, hashing, EXIF, ML pipelines) keep using the bind
mount at NEXTCLOUD_USERS_ROOT. WebDAV is far too slow for every byte
of every photo, and the read side has no consistency cost — Nextcloud
is the writer, the bind mount is the reader, that's it.
Auth: HTTP Basic with the user's Nextcloud app password (set via the
Settings UI, stored Fernet-encrypted at rest). OIDC bearer reuse is a
later optimization; app passwords work today and are well-supported.
"""
from __future__ import annotations
import logging
import os
from typing import BinaryIO, Optional, Tuple
import httpx
from fastapi import HTTPException, status
from app.config import settings
from app.models.user import User
from app.services.secrets import decrypt
logger = logging.getLogger(__name__)
# Top-level mount inside the backend container. The Nextcloud user tree
# `/mnt/library/homecloud/<nc_user>/files/...` shows up here as
# `/nextcloud-users/<nc_user>/files/...`.
NEXTCLOUD_USERS_ROOT = os.environ.get("NEXTCLOUD_USERS_ROOT", "/nextcloud-users")
def is_nextcloud_path(path: str) -> bool:
"""True iff `path` resolves under the configured NC users mount."""
if not path:
return False
norm = os.path.normpath(path)
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
return norm == root or norm.startswith(root + os.sep)
def split_nextcloud_path(path: str) -> Tuple[str, str]:
"""Return (nc_username, rel_path) for a file/dir under the NC mount.
rel_path is the path relative to `<NEXTCLOUD_USERS_ROOT>/<user>/files/`,
suitable for appending to the WebDAV base URL. Raises if `path`
isn't a Nextcloud-rooted path or doesn't sit under a `files/`
directory.
"""
norm = os.path.normpath(path)
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
if not (norm == root or norm.startswith(root + os.sep)):
raise ValueError(f"Not a Nextcloud-rooted path: {path!r}")
rest = norm[len(root):].lstrip(os.sep) # "<user>/files/foo/bar.jpg"
parts = rest.split(os.sep, 2)
if len(parts) < 3 or parts[1] != "files":
# Either we got just /<user>, /<user>/files (no rel), or a
# different second segment — only the user's `files/` tree is
# safe to mutate via WebDAV.
if len(parts) == 2 and parts[1] == "files":
return parts[0], ""
raise ValueError(
f"Path doesn't live under <user>/files/: {path!r}"
)
return parts[0], parts[2]
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class NextcloudCredentialsMissing(HTTPException):
"""The user hasn't set their Nextcloud app password yet, but the
request needs it to mutate a Nextcloud-managed file. 412 because
the precondition (credentials) is missing rather than the request
itself being malformed."""
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_412_PRECONDITION_FAILED,
detail=(
"Set your Nextcloud app password in Settings → Library "
"before mutating files in your Nextcloud library."
),
)
def _credentials_for(user: User) -> tuple[str, str]:
"""Resolve the (nc_username, app_password) pair for a user.
Raises NextcloudCredentialsMissing when either is missing."""
nc_user = (user.nextcloud_username or "").strip()
app_pw = decrypt(user.nextcloud_app_password_enc)
if not nc_user or not app_pw:
raise NextcloudCredentialsMissing()
return nc_user, app_pw
def _base_url() -> str:
"""The Nextcloud WebDAV base URL (without trailing slash, without
user-suffixed path). Resolved per-call so a config reload picks up
a new value without restarting workers."""
base = (
os.environ.get("NEXTCLOUD_BASE_URL")
or getattr(settings, "nextcloud_base_url", None)
or ""
).rstrip("/")
if not base:
raise HTTPException(
status_code=500,
detail="NEXTCLOUD_BASE_URL is not configured on the backend",
)
return base
def _dav_url(nc_username: str, rel_path: str) -> str:
"""Compose the absolute WebDAV URL for a relative path under the
user's `files/` collection."""
base = _base_url()
rel = (rel_path or "").lstrip("/")
# Each segment must be URL-encoded. httpx encodes path segments at
# request time, so we hand it the raw join — but we explicitly drop
# `..` traversals here as defense in depth.
if any(seg in ("", "..") for seg in rel.split("/") if seg):
raise HTTPException(status_code=400, detail="Invalid relative path")
parts = [base, "remote.php/dav/files", nc_username]
if rel:
parts.append(rel)
return "/".join(parts)
# httpx Client TTL: short, since a single request is the unit of work.
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
def _client(auth: tuple[str, str]) -> httpx.Client:
return httpx.Client(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
def _async_client(auth: tuple[str, str]) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
# Pooled async client for the read-heavy NC endpoints (preview proxy,
# Memories info). Auth is per-user, so it's passed at call time via
# `auth=BasicAuth(...)`; the pool itself is auth-less. Keepalive +
# HTTP/2 cuts the TCP+TLS handshake from every thumbnail request and
# multiplexes the dozens of concurrent grid fetches over one socket.
_PREVIEW_LIMITS = httpx.Limits(
max_connections=64, max_keepalive_connections=32, keepalive_expiry=120.0,
)
_preview_client: httpx.AsyncClient | None = None
async def init_preview_client() -> None:
"""Called from the FastAPI lifespan startup hook."""
global _preview_client
if _preview_client is None:
_preview_client = httpx.AsyncClient(
timeout=_TIMEOUT,
limits=_PREVIEW_LIMITS,
http2=True,
follow_redirects=False,
)
async def close_preview_client() -> None:
"""Called from the FastAPI lifespan shutdown hook."""
global _preview_client
if _preview_client is not None:
await _preview_client.aclose()
_preview_client = None
def _shared_preview_client() -> httpx.AsyncClient:
"""Return the pooled client. Falls back to a one-shot AsyncClient if
init wasn't called (tests, scripts) — caller must aclose it."""
if _preview_client is not None:
return _preview_client
return httpx.AsyncClient(
timeout=_TIMEOUT, http2=True, follow_redirects=False,
)
def _raise_for_dav(resp: httpx.Response, action: str) -> None:
"""Translate Nextcloud WebDAV errors into FastAPI HTTPExceptions
the frontend can show. We surface Nextcloud's body verbatim when
it's small enough, since it tends to carry the actually-useful
detail (quota, permission denied, etc.)."""
if resp.is_success:
return
body = resp.text or ""
if len(body) > 400:
body = body[:400] + ""
logger.warning("Nextcloud %s failed: %s %s%s", action, resp.status_code, resp.reason_phrase, body[:200])
if resp.status_code in (401, 403):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Nextcloud rejected the {action}: {resp.reason_phrase}. "
f"Check your app password under Settings → Library.",
)
if resp.status_code == 404:
raise HTTPException(status_code=404, detail=f"Not found in Nextcloud during {action}")
if resp.status_code == 507:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Nextcloud quota exceeded",
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Nextcloud error during {action}: {resp.status_code} {resp.reason_phrase}",
)
# ---------------------------------------------------------------------------
# Verbs
# ---------------------------------------------------------------------------
def delete_for_user(user: User, abs_path: str) -> None:
"""WebDAV DELETE — moves the file/dir into the user's NC trashbin.
`abs_path` is the absolute filesystem path under the bind mount."""
nc_user, app_pw = _credentials_for(user)
expected_user, rel = split_nextcloud_path(abs_path)
if expected_user != nc_user:
raise HTTPException(
status_code=403,
detail="Path belongs to a different Nextcloud user",
)
url = _dav_url(nc_user, rel)
with _client((nc_user, app_pw)) as c:
resp = c.request("DELETE", url)
# 204 = deleted. 404 = already gone (treat as success, idempotent).
if resp.status_code == 404:
logger.info("Nextcloud DELETE %s already gone, treating as success", rel)
return
_raise_for_dav(resp, "delete")
def move_for_user(user: User, src_abs: str, dst_abs: str) -> None:
"""WebDAV MOVE — rename or move within the same Nextcloud user."""
nc_user, app_pw = _credentials_for(user)
src_user, src_rel = split_nextcloud_path(src_abs)
dst_user, dst_rel = split_nextcloud_path(dst_abs)
if src_user != nc_user or dst_user != nc_user:
raise HTTPException(
status_code=403,
detail="MOVE across Nextcloud users is not supported",
)
src_url = _dav_url(nc_user, src_rel)
dst_url = _dav_url(nc_user, dst_rel)
with _client((nc_user, app_pw)) as c:
resp = c.request(
"MOVE",
src_url,
headers={"Destination": dst_url, "Overwrite": "F"},
)
_raise_for_dav(resp, "move")
def mkcol_for_user(user: User, abs_path: str) -> None:
"""WebDAV MKCOL — create a directory. Idempotent: a 405 (Method Not
Allowed) means the collection already exists, treat as success."""
nc_user, app_pw = _credentials_for(user)
expected_user, rel = split_nextcloud_path(abs_path)
if expected_user != nc_user:
raise HTTPException(
status_code=403,
detail="Path belongs to a different Nextcloud user",
)
url = _dav_url(nc_user, rel)
with _client((nc_user, app_pw)) as c:
resp = c.request("MKCOL", url)
if resp.status_code == 405:
return
_raise_for_dav(resp, "mkcol")
def put_for_user(
user: User,
abs_path: str,
fileobj: BinaryIO,
content_type: Optional[str] = None,
) -> None:
"""WebDAV PUT — upload `fileobj` to `abs_path`. Caller is
responsible for ensuring intermediate collections exist via
`mkcol_for_user`. Streams the body, no in-memory copy."""
nc_user, app_pw = _credentials_for(user)
expected_user, rel = split_nextcloud_path(abs_path)
if expected_user != nc_user:
raise HTTPException(
status_code=403,
detail="Path belongs to a different Nextcloud user",
)
url = _dav_url(nc_user, rel)
headers = {}
if content_type:
headers["Content-Type"] = content_type
with _client((nc_user, app_pw)) as c:
resp = c.request("PUT", url, content=fileobj, headers=headers)
_raise_for_dav(resp, "upload")
def ensure_parents_for_user(user: User, abs_path: str) -> None:
"""Walk the parent chain of `abs_path` under the user's NC root and
`mkcol` any missing collection. Stops at the user's `files/`
directory — never tries to create that, which is owned by Nextcloud
itself."""
nc_user, _ = _credentials_for(user)
expected_user, rel = split_nextcloud_path(abs_path)
if expected_user != nc_user:
raise HTTPException(
status_code=403,
detail="Path belongs to a different Nextcloud user",
)
if not rel:
return
parts = rel.split("/")
if len(parts) <= 1:
return # no intermediate dirs to make
accum: list[str] = []
for seg in parts[:-1]:
accum.append(seg)
sub_rel = "/".join(accum)
sub_abs = os.path.join(NEXTCLOUD_USERS_ROOT, nc_user, "files", sub_rel)
mkcol_for_user(user, sub_abs)
_FILEID_PROPFIND = (
b'<?xml version="1.0"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
b'<d:prop><oc:fileid/></d:prop>'
b'</d:propfind>'
)
def fetch_fileid(user: User, abs_path: str) -> Optional[int]:
"""Look up Nextcloud's numeric fileid for the file at `abs_path`.
`abs_path` is the absolute filesystem path under the bind mount,
e.g. `/nextcloud-users/admin/files/Photos/2024/01/foo.jpg`. Returns
None when the file isn't under a Nextcloud-rooted tree, the user
has no app password set, or Nextcloud returns 404 — callers should
treat None as "skip this row" rather than an error.
Used by `scripts/backfill_nextcloud_fileid.py`. The hot path (the
thumbnail handler) reads `Photo.nextcloud_fileid` directly so it
doesn't round-trip to Nextcloud per request.
"""
if not is_nextcloud_path(abs_path):
return None
try:
nc_user, app_pw = _credentials_for(user)
except NextcloudCredentialsMissing:
return None
try:
expected_user, rel = split_nextcloud_path(abs_path)
except ValueError:
return None
if expected_user != nc_user:
return None
url = _dav_url(nc_user, rel)
with _client((nc_user, app_pw)) as c:
resp = c.request(
"PROPFIND",
url,
headers={"Depth": "0", "Content-Type": "application/xml"},
content=_FILEID_PROPFIND,
)
if resp.status_code == 404:
return None
if not resp.is_success:
logger.warning(
"Nextcloud PROPFIND %s returned %s", rel, resp.status_code
)
return None
import re as _re
m = _re.search(rb"<oc:fileid>(\d+)</oc:fileid>", resp.content)
return int(m.group(1)) if m else None
def get_preview_bytes(
user: User, fileid: int, x: int, y: int
) -> Optional[bytes]:
"""Sync sibling of `get_preview_async` for callers in non-async
contexts (e.g. the vision celery worker, which is sync).
Returns the preview body on success, None on 404 / non-success /
missing credentials. Caller is expected to feed the bytes into
PIL or similar.
"""
try:
nc_user, app_pw = _credentials_for(user)
except NextcloudCredentialsMissing:
return None
url = f"{_base_url()}/index.php/core/preview"
params = {
"fileId": str(fileid),
"x": str(x),
"y": str(y),
"a": "true",
"forceIcon": "false",
}
with _client((nc_user, app_pw)) as c:
resp = c.get(url, params=params)
if resp.status_code == 404 or not resp.is_success:
return None
return resp.content
async def fetch_memories_info_async(
user: User, fileid: int
) -> Optional[dict]:
"""Fetch the Memories app's per-file metadata blob.
`GET /index.php/apps/memories/api/image/info/{fileid}` returns
Memories' pre-extracted view of the file: `w`, `h`, `datetaken`
(unix epoch), `mtime`, `mimetype`, `size`, plus an `exif` dict
of plain-named EXIF fields (Make, Model, ISO, FNumber,
DateTimeOriginal, GPSLatitude, GPSLongitude, etc.). The endpoint
is `#[NoAdminRequired] #[PublicPage]` but CSRF-checked, so we
send `OCS-APIRequest: true` to bypass the check the same way
OCS API clients do.
Returns None on 404 (file not yet indexed by Memories, or
fileid stale) or any non-success response — callers should fall
back to ExifTool extraction in that case.
"""
nc_user, app_pw = _credentials_for(user)
url = (
f"{_base_url()}/index.php/apps/memories/api/image/info/{int(fileid)}"
)
client = _shared_preview_client()
try:
resp = await client.get(
url,
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
auth=httpx.BasicAuth(nc_user, app_pw),
)
finally:
# Only close if we got a one-shot fallback client; the pooled
# one is owned by the lifespan hook.
if client is not _preview_client:
await client.aclose()
if resp.status_code == 404:
return None
if not resp.is_success:
logger.warning(
"Memories info for fileid %s returned %s", fileid, resp.status_code
)
return None
try:
return resp.json()
except Exception as e:
logger.warning("Memories info parse failed for fileid %s: %s", fileid, e)
return None
async def get_preview_async(
user: User, fileid: int, x: int, y: int
) -> httpx.Response:
"""Fetch a Nextcloud preview for `fileid` sized up to (x, y).
Nextcloud's `/index.php/core/preview` endpoint returns a JPEG (or
icon fallback) sized so the longest edge fits within the requested
box. `a=true` preserves the source aspect ratio; `forceIcon=false`
makes it 404 rather than returning a placeholder if no real preview
can be produced.
Auth uses the user's encrypted app password — same path as every
other mutation in this module. The caller streams the body back
to the frontend; we don't buffer the bytes here.
"""
nc_user, app_pw = _credentials_for(user)
url = f"{_base_url()}/index.php/core/preview"
params = {
"fileId": str(fileid),
"x": str(x),
"y": str(y),
"a": "true",
"forceIcon": "false",
}
client = _shared_preview_client()
try:
return await client.get(
url, params=params, auth=httpx.BasicAuth(nc_user, app_pw),
)
finally:
# Only close if we got a one-shot fallback client; the pooled
# one is owned by the lifespan hook.
if client is not _preview_client:
await client.aclose()
def whoami_dir_exists(nc_username: str) -> bool:
"""True iff the bind-mounted `<NEXTCLOUD_USERS_ROOT>/<user>/files`
directory exists. Used by the UI to validate the override field
without round-tripping to Nextcloud — the bind mount is enough to
confirm Nextcloud actually has that user."""
if not nc_username or "/" in nc_username or nc_username in (".", ".."):
return False
target = os.path.join(NEXTCLOUD_USERS_ROOT, nc_username, "files")
return os.path.isdir(target)