Last consumer of the on-disk thumbnail pipeline was the vision
worker reading /data/thumbs/{id}/medium.webp. Now it asks Nextcloud
for a 640px preview (the same edge size the old thumb used) and
decodes the bytes in-memory — no disk dependency.
- nextcloud_dav.get_preview_bytes: sync sibling of get_preview_async,
for the celery vision worker (which is sync).
- vision._load_thumb: tries NC preview first; transitional disk
fallback stays for rows still indexed during the rollout.
- thumbs.WORKER_THUMB_SIZES = set() — generate_thumbnails still runs
the decode + pHash side-effect (perceptual dedup is mule-only and
needs original-resolution pixels) but no longer writes thumbnail
files.
The HTTP thumbnail endpoint's disk fallback path stays in place
unchanged: for NC-404 cases (e.g. iPhone JPEGs mis-extensioned as
.DNG), inline Pillow regeneration still writes a tiny per-photo
file so subsequent requests are fast. That path is rare and the
files are small.
Disk impact: /data/thumbs currently has ~22k medium.webp totaling
~1 GB. They'll stop being read after the worker-vision container
restarts, but no automatic delete — purge with the same find
pattern used for small/large reclaim when ready:
find /data/thumbs -name "medium.webp" -delete
463 lines
17 KiB
Python
463 lines
17 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)
|
|
|
|
|
|
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 = _async_client((nc_user, app_pw))
|
|
try:
|
|
resp = await client.get(
|
|
url,
|
|
headers={
|
|
"OCS-APIRequest": "true",
|
|
"Accept": "application/json",
|
|
},
|
|
)
|
|
finally:
|
|
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 = _async_client((nc_user, app_pw))
|
|
try:
|
|
return await client.get(url, params=params)
|
|
finally:
|
|
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)
|