"""Read-only image catalogs; provider credentials never enter SQLite.""" import base64 import json import os import posixpath import random import stat import time from pathlib import Path from typing import Literal from urllib.parse import unquote, urljoin, urlsplit from xml.etree import ElementTree from pydantic import Field from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import StrictModel from pigway_frame_flow.providers.photo_names import path_caption from pigway_frame_flow.providers.photo_orientation import dimensions, orientation from pigway_frame_flow.providers.synology_photos import SynologyPhotos from pigway_frame_flow.settings import secret from pigway_frame_flow.storage.database import encode class PhotoSourceInput(StrictModel): name: str = Field(min_length=1, max_length=120) kind: Literal["local", "webdav"] location: str = Field(min_length=1, max_length=2048) recursive: bool = True enabled: bool = True catalog_limit: int = Field(default=10000, ge=1, le=100000) synology_previews: bool = False organized_only: bool = False folder_caption: bool = False orientation: Literal["auto", "landscape", "portrait"] | None = None people_filter: bool = False person_ids: list[int] = Field(default_factory=list, max_length=50) person_match: Literal["any", "all"] = "any" credential_env: str | None = Field( default=None, pattern=r"^FRAME_FLOW_SOURCE_[A-Z0-9_]+$" ) class PhotoSourceView(PhotoSourceInput): id: str class PhotoItem(StrictModel): id: str name: str caption: dict[str, str] | None = None provider_preview: bool = False orientation: Literal["landscape", "portrait", "square"] | None = None width: int | None = None height: int | None = None class PhotoCatalog(StrictModel): items: list[PhotoItem] total: int offset: int limit: int catalog_limit: int class PhotoSources: extensions = {".jpg", ".jpeg", ".png", ".webp"} def __init__(self, db, settings, assets): self.db, self.settings, self.assets = db, settings, assets self.cache = {} self.synology = SynologyPhotos(settings) # Register provider secrets before request IDs or errors can be reflected. for row in self.list(): if row["kind"] == "webdav" and row.get("credential_env"): try: self.headers( PhotoSourceInput.model_validate( {k: v for k, v in row.items() if k != "id"} ) ) except FlowError: pass def list(self): return [ dict(json.loads(row["payload"]), id=row["key"][13:]) for row in self.db.all( "SELECT key,payload FROM app_config WHERE key LIKE 'photo_source:%' ORDER BY key" ) ] def get(self, source_id): row = self.db.one( "SELECT payload FROM app_config WHERE key=?", ("photo_source:" + source_id,) ) if not row: raise FlowError(E.NOT_FOUND, "Photo source not found", 404) return PhotoSourceInput.model_validate_json(row["payload"]) def save(self, source_id, source): if source.orientation and source.kind != "local": raise FlowError( E.UNSUPPORTED_CAPABILITY, "Orientation filtering requires a local photo source", ) if source.people_filter and source.kind != "local": raise FlowError( E.INVALID_REQUEST, "Person filtering requires a local Synology photo source", ) if any(i <= 0 for i in source.person_ids) or len(set(source.person_ids)) != len( source.person_ids ): raise FlowError( E.INVALID_REQUEST, "Person IDs must be unique positive integers" ) if source.people_filter and not source.person_ids: raise FlowError(E.INVALID_REQUEST, "Select at least one person") if source.kind == "local": self.local_directory(source.location) if source.credential_env: raise FlowError( E.INVALID_REQUEST, "Local sources do not use credentials" ) else: source = source.model_copy( update={ "recursive": False, "location": source.location.rstrip("/") + "/", } ) parsed = urlsplit(source.location) if ( parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.port not in (None, 443) ): raise FlowError( E.INVALID_REQUEST, "WebDAV requires a plain HTTPS directory URL" ) if parsed.hostname.lower() not in self.settings.image_url_hosts: raise FlowError( E.FORBIDDEN, "Configure the allowed image host first", 403 ) self.db.execute( "INSERT OR REPLACE INTO app_config(key,payload) VALUES(?,?)", ("photo_source:" + source_id, encode(source.model_dump())), ) self.cache.pop(source_id, None) return dict(source.model_dump(), id=source_id) def delete(self, source_id): self.get(source_id) for row in self.db.all("SELECT payload FROM jobs"): if json.loads(row["payload"])["source"].get("source_id") == source_id: raise FlowError(E.CONFLICT, "Remove jobs using this source first", 409) for row in self.db.all( "SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')" ): payload = json.loads(row["payload"]) if payload.get("content", payload).get("source_id") == source_id: raise FlowError(E.CONFLICT, "This source has pending operations", 409) self.db.execute( "DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,) ) self.cache.pop(source_id, None) def local_directory(self, location): root = self.settings.photo_root.resolve() directory = (root / location).resolve() if Path(location).is_absolute() or not directory.is_relative_to(root): raise FlowError( E.FORBIDDEN, "Photo directory must be inside the mounted photo root", 403, ) if not directory.is_dir(): raise FlowError( E.NOT_FOUND, "Photo directory is unavailable; check its read-only mount", 404, ) try: with os.scandir(directory): pass except PermissionError: raise FlowError( E.FORBIDDEN, "Photo directory is not readable; configure the container's existing NAS read group", 403, ) from None return directory def headers(self, source): if not source.credential_env: return {} try: value = secret(source.credential_env) except OSError: raise FlowError( E.CONFIG_REQUIRED, "WebDAV credential secret is unavailable" ) from None if value and value not in self.settings.additional_secrets: self.settings.additional_secrets.append(value) try: credentials = json.loads(value) username, password = credentials["username"], credentials["password"] if ( not isinstance(username, str) or not isinstance(password, str) or ":" in username ): raise ValueError() except (ValueError, KeyError, TypeError): raise FlowError( E.CONFIG_REQUIRED, "WebDAV credential secret must contain username and password", ) from None for sensitive in (value, username, password): if sensitive and sensitive not in self.settings.additional_secrets: self.settings.additional_secrets.append(sensitive) encoded = base64.b64encode((username + ":" + password).encode()).decode() if encoded not in self.settings.additional_secrets: self.settings.additional_secrets.append(encoded) return {"Authorization": "Basic " + encoded} def thumbnail(self, path): candidate = path.parent / "@eaDir" / path.name / "SYNOPHOTO_THUMB_XL.jpg" try: if candidate.is_symlink() or not candidate.resolve().is_relative_to( path.parent.resolve() ): return None if ( candidate.is_file() and candidate.stat().st_size <= self.settings.max_bytes ): return candidate except OSError: pass return None def catalog(self, source_id, refresh=False): source = self.get(source_id) if not source.enabled: raise FlowError(E.CONFLICT, "Photo source is disabled", 409) cached = self.cache.get(source_id) if cached and not refresh and time.monotonic() - cached[0] < 60: return cached[1] allowed = ( self.synology.paths(source.person_ids, source.person_match, refresh=refresh) if source.people_filter else None ) items = [] sizes = ( {r["path"]: r for r in self.db.all("SELECT * FROM photo_dimensions")} if source.orientation else {} ) size_updates = [] wanted = "portrait" if source.orientation == "auto" else source.orientation if source.kind == "local": directory = self.local_directory(source.location) root = self.settings.photo_root.resolve() # Do not follow symlink directories or import Synology's thumbnail trees. for folder, dirs, files in os.walk(directory, followlinks=False): dirs[:] = ( sorted( d for d in dirs if d != "#recycle" and not d.startswith(".") and not d.startswith("@") and not Path(folder, d).is_symlink() ) if source.recursive else [] ) caption = path_caption( Path(folder).relative_to(root).as_posix() + "/__photo__.jpg" ) if source.organized_only and caption is None: continue for name in sorted(files): path = Path(folder, name) if ( name.startswith(".") or path.suffix.lower() not in self.extensions or path.is_symlink() ): continue try: large = path.stat().st_size > self.settings.max_bytes preview = ( large and source.synology_previews and self.thumbnail(path) is not None ) if large and not preview: continue relative = path.relative_to(root).as_posix() if allowed is not None and relative not in allowed: continue info = {} if wanted: key = str(path) stamp = path.stat() cached_size = sizes.get(key) if ( cached_size and cached_size["mtime"] == stamp.st_mtime_ns and cached_size["bytes"] == stamp.st_size ): width, height = ( cached_size["width"], cached_size["height"], ) else: size = dimensions(root, path) if size is None: continue width, height = size size_updates.append( ( key, stamp.st_mtime_ns, stamp.st_size, width, height, ) ) direction = orientation(width, height) if direction != wanted: continue info = { "width": width, "height": height, "orientation": direction, } items.append( { **info, "id": relative, "name": name, "provider_preview": preview, "caption": caption if source.folder_caption else None, } ) except OSError: continue if len(items) >= source.catalog_limit: break if len(items) >= source.catalog_limit: break else: data = self.assets.fetch_url( source.location, method="PROPFIND", headers={**self.headers(source), "Depth": "1"}, limit=2 * 1024 * 1024, accepted_status=(207,), ) if ( b"\x00" in data or b"= source.catalog_limit: break except ElementTree.ParseError: raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None if size_updates: with self.db.transaction() as connection: connection.executemany( "INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)", size_updates, ) self.cache[source_id] = (time.monotonic(), items) return items def read(self, source_id, item_id=None): source = self.get(source_id) items = self.catalog(source_id) if not items: raise FlowError( E.NOT_FOUND, "Photo source contains no supported images", 404 ) item = ( next((x for x in items if x["id"] == item_id), None) if item_id else random.SystemRandom().choice(items) ) if item is None: raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404) if source.kind == "local": root = self.settings.photo_root.resolve() path = root / item["id"] if item.get("provider_preview"): preview = self.thumbnail(path) if preview is None: raise FlowError(E.NOT_FOUND, "Synology preview is unavailable", 404) path = preview if path.is_symlink() or not path.resolve().is_relative_to( self.local_directory(source.location) ): raise FlowError(E.FORBIDDEN, "Photo path is outside its source", 403) try: # Open every component without following links; a renamed parent # must not turn a previously cataloged image into a secret-file read. parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) try: components = path.relative_to(root).parts for component in components[:-1]: child_fd = os.open( component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd, ) os.close(parent_fd) parent_fd = child_fd file_fd = os.open( components[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd, ) with os.fdopen(file_fd, "rb") as handle: if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode): raise FlowError( E.FORBIDDEN, "Photo must be a regular file", 403 ) data = handle.read(self.settings.max_bytes + 1) finally: os.close(parent_fd) except OSError: raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None else: data = self.assets.fetch_url(item["id"], headers=self.headers(source)) if len(data) > self.settings.max_bytes: raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit") return data, item["name"], item.get("caption")