"""Optional, read-only Synology Photos person catalog with pinned TLS.""" import hashlib import http.client import json import os import ssl import threading import time from urllib.parse import urlencode, urlsplit from pydantic import Field, SecretStr from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import StrictModel class PhotosLogin(StrictModel): username: str = Field(min_length=1, max_length=120) password: SecretStr = Field(min_length=1, max_length=256) class PhotosConnection(StrictModel): configured: bool connected: bool class PhotosPerson(StrictModel): id: int name: str count: int = 0 named: bool = False class SynologyPhotos: def __init__(self, settings): self.settings = settings self.path = settings.config_dir / "synology-photos-session.json" self.lock = threading.RLock() self.cache = {} if self.path.is_file(): try: self.sid() except FlowError: pass def endpoint(self): url = urlsplit(os.getenv("SYNOLOGY_PHOTOS_URL", "")) pin = os.getenv("SYNOLOGY_PHOTOS_CERT_SHA256", "").lower().replace(":", "") if ( url.scheme != "https" or not url.hostname or url.username or url.password or url.query or url.fragment or url.path not in ("", "/") or len(pin) != 64 or any(c not in "0123456789abcdef" for c in pin) ): raise FlowError( E.CONFIG_REQUIRED, "Configure Synology Photos URL and certificate fingerprint", 503, ) return url, pin def status(self): try: self.endpoint() configured = True except FlowError: configured = False return { "configured": configured, "connected": configured and self.path.is_file(), } def request(self, api, method, version, **params): # No arbitrary RPC forwarding or mutation methods, even for administrators. if (api, method) not in { ("SYNO.API.Auth", "login"), ("SYNO.API.Auth", "logout"), ("SYNO.FotoTeam.Browse.Person", "list"), ("SYNO.Foto.Browse.Person", "list"), ("SYNO.Foto.Browse.Item", "list"), ("SYNO.Foto.Browse.Folder", "get"), ("SYNO.FotoTeam.Browse.Item", "list"), ("SYNO.FotoTeam.Browse.Folder", "get"), }: raise FlowError(E.FORBIDDEN, "Unsupported Photos query", 403) url, pin = self.endpoint() connection = http.client.HTTPSConnection( url.hostname, url.port or 443, timeout=30, context=ssl._create_unverified_context(), ) try: connection.connect() certificate = connection.sock.getpeercert(binary_form=True) if not certificate or hashlib.sha256(certificate).hexdigest() != pin: raise FlowError(E.FORBIDDEN, "Synology Photos certificate changed", 403) # Check the certificate on this exact socket BEFORE transmitting a secret. data = urlencode( {"api": api, "method": method, "version": version, **params} ) connection.request( "POST", "/webapi/entry.cgi", data, {"Content-Type": "application/x-www-form-urlencoded"}, ) response = connection.getresponse() raw = response.read(4 * 1024 * 1024 + 1) if response.status != 200 or len(raw) > 4 * 1024 * 1024: raise ValueError() result = json.loads(raw) if not result.get("success"): code = result.get("error", {}).get("code") if api == "SYNO.API.Auth" or code in (106, 107, 119): if api != "SYNO.API.Auth": self.disconnect() raise FlowError( E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409 ) if code in (102, 103, 104): raise FlowError( E.UNSUPPORTED_CAPABILITY, "This NAS Photos API or method is unavailable", ) if code == 105: raise FlowError( E.FORBIDDEN, "Photos account cannot access the selected space", 403, ) raise FlowError( E.CLOUD_UNAVAILABLE, "Synology Photos query was rejected", 502 ) return result.get("data", {}) except (OSError, ValueError, http.client.HTTPException): raise FlowError( E.CLOUD_UNAVAILABLE, "Synology Photos is unavailable", 502 ) from None finally: connection.close() def sid(self): try: sid = json.loads(self.path.read_text())["sid"] if not isinstance(sid, str) or not sid: raise ValueError() except (OSError, ValueError, KeyError, TypeError): raise FlowError( E.CONFIG_REQUIRED, "Connect Synology Photos account first", 409 ) from None if sid not in self.settings.additional_secrets: self.settings.additional_secrets.append(sid) return sid def connect(self, payload): password = payload.password.get_secret_value() self.settings.additional_secrets.append(password) with self.lock: sid = self.request( "SYNO.API.Auth", "login", 7, account=payload.username, passwd=password, session="SynologyPhotos", format="sid", )["sid"] self.settings.additional_secrets.append(sid) self.path.parent.mkdir(parents=True, exist_ok=True) temporary = self.path.with_suffix(".tmp") fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as stream: json.dump({"sid": sid}, stream) os.chmod(temporary, 0o600) temporary.replace(self.path) self.cache.clear() return self.status() def disconnect(self): with self.lock: self.path.unlink(missing_ok=True) self.cache.clear() return self.status() def pages(self, api, version, **params): rows = [] for offset in range(0, 100000, 500): page = self.request( api, "list", version, offset=offset, limit=500, _sid=self.sid(), **params, ).get("list", []) rows.extend(page) if len(page) < 500: return rows raise FlowError(E.CONFLICT, "Photos catalog exceeds query limit", 409) def people(self, space="shared"): prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" with self.lock: rows = self.pages(prefix + ".Browse.Person", 3) return [ { "id": r["id"], "name": r.get("name") or str(r["id"]), "count": r.get("item_count", 0), "named": bool(r.get("name")), } for r in rows ] def paths(self, person_ids, mode, refresh=False, *, space="shared"): prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" key = (tuple(sorted(person_ids)), mode, space) with self.lock: cached = self.cache.get(key) if cached and not refresh and time.monotonic() - cached[0] < 900: return cached[1] known = { p["id"] for p in (self.people() if space == "shared" else self.people(space)) } ids = person_ids or sorted(known) if not set(ids) <= known: raise FlowError(E.INVALID_REQUEST, "Selected person no longer exists") sets = [] folders = {} for person in ids: paths = set() for row in self.pages( prefix + ".Browse.Item", 7, person_id=person, additional=json.dumps(["folder"]), ): folder = row.get("additional", {}).get("folder", {}) if isinstance(folder, str): name = folder elif isinstance(folder, dict) and folder.get("name"): name = folder["name"] else: folder_id = row.get("folder_id") if folder_id is None: raise FlowError( E.CLOUD_UNAVAILABLE, "Photos response has no folder identity", 502, ) if folder_id not in folders: folders[folder_id] = self.request( prefix + ".Browse.Folder", "get", 2, id=folder_id, _sid=self.sid(), )["folder"]["name"] name = folders[folder_id] if "filename" not in row: raise FlowError( E.CLOUD_UNAVAILABLE, "Photos response has no filename", 502 ) paths.add(name.strip("/") + "/" + row["filename"]) sets.append(paths) selected = ( (set.intersection(*sets) if mode == "all" else set.union(*sets)) if sets else set() ) self.cache[key] = (time.monotonic(), selected) return selected def items(self, person_ids=(), mode="any", *, space="shared", limit=10000): """Intersect identities, not filenames, for any/all people selection.""" prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" if mode not in ("any", "all") or space not in ("shared", "personal"): raise FlowError(E.INVALID_REQUEST, "Invalid Photos selection") with self.lock: self.sid() if person_ids: known = {x["id"] for x in self.people(space)} if not set(person_ids) <= known: raise FlowError( E.INVALID_REQUEST, "Selected person no longer exists" ) groups = [] for person in person_ids or [None]: rows = self.pages( prefix + ".Browse.Item", 7, additional=json.dumps( ["folder", "thumbnail", "resolution", "orientation"] ), **({"person_id": person} if person is not None else {}), ) groups.append( {str(r["id"]): r for r in rows if r.get("type") == "photo"} ) selected = set(groups[0]) for group in groups[1:]: selected = ( selected & group.keys() if mode == "all" else selected | group.keys() ) merged = {key: row for group in groups for key, row in group.items()} return [merged[key] for key in sorted(selected, key=int)][:limit] def item_people(self, item_id, *, space="shared"): """Reuse verified person albums; never label an unnamed face by its ID.""" prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" with self.lock: key = ("caption-people", self.sid(), space) cached = self.cache.get(key) if not cached or time.monotonic() - cached[0] >= 900: names: dict[str, list[str]] = {} for person in self.people(space): if not person["named"]: continue for row in self.pages( prefix + ".Browse.Item", 7, person_id=person["id"] ): item_names = names.setdefault(str(row["id"]), []) if person["name"] not in item_names: item_names.append(person["name"]) self.cache[key] = (time.monotonic(), names) return list(self.cache[key][1].get(str(item_id), [])) def download(self, row, *, space="shared", rendition="thumbnail", size="xl"): from pigway_frame_flow.providers.photos_cache import PhotosCache sid = self.sid() url, pin = self.endpoint() prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" if size not in ("m", "xl"): raise FlowError(E.INVALID_REQUEST, "Invalid Photos preview size") if rendition == "thumbnail": thumb = row.get("additional", {}).get("thumbnail", {}) if not thumb.get("cache_key") or not thumb.get("unit_id"): raise FlowError( E.UNSUPPORTED_CAPABILITY, "Photos thumbnail is unavailable" ) api, method = prefix + ".Thumbnail", "get" params = dict( id=thumb["unit_id"], cache_key=thumb["cache_key"], size=size, type="unit", ) else: raise FlowError( E.UNSUPPORTED_CAPABILITY, "Only verified XL thumbnails are supported; original download is unavailable", ) if not hasattr(self, "image_cache"): self.image_cache = PhotosCache(self.settings) # Session/endpoint scoping prevents a different account from reusing a # previous account's cached photo. Cache keys never contain raw secrets. identity = hashlib.sha256((sid + str(url.geturl()) + pin).encode()).hexdigest() key = json.dumps( [ identity, space, row["id"], rendition, size, row.get("indexed_time"), row.get("filesize"), row.get("additional", {}).get("thumbnail", {}).get("cache_key"), ] ) return self.image_cache.read( key, lambda handle, cap, deadline: self._download( api, method, params, sid, handle, cap, deadline ), ) def _download(self, api, method, params, sid, handle, cap, deadline): if (api, method) not in { ("SYNO.FotoTeam.Thumbnail", "get"), ("SYNO.Foto.Thumbnail", "get"), }: raise FlowError(E.FORBIDDEN, "Unsupported Photos download", 403) url, pin = self.endpoint() connection = http.client.HTTPSConnection( url.hostname, url.port or 443, timeout=30, context=ssl._create_unverified_context(), ) try: connection.connect() certificate = connection.sock.getpeercert(binary_form=True) if not certificate or hashlib.sha256(certificate).hexdigest() != pin: raise FlowError(E.FORBIDDEN, "Synology Photos certificate changed", 403) connection.request( "POST", "/webapi/entry.cgi", urlencode(dict(api=api, method=method, version=2, _sid=sid, **params)), {"Content-Type": "application/x-www-form-urlencoded"}, ) response = connection.getresponse() if response.status != 200: raise ValueError() if "json" in (response.getheader("Content-Type") or "").lower(): result = json.loads(response.read(65537)) if result.get("error", {}).get("code") in (106, 107, 119): self.disconnect() raise FlowError( E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409 ) if result.get("error", {}).get("code") == 105: raise FlowError( E.FORBIDDEN, "Photos account cannot download this photo", 403 ) raise FlowError( E.CLOUD_UNAVAILABLE, "Photos download was rejected", 502 ) expected = response.getheader("Content-Length") if expected and int(expected) > cap: raise FlowError( E.INVALID_REQUEST, "Photos image exceeds download limit; select thumbnail", ) total = 0 while True: if time.monotonic() > deadline: raise TimeoutError() chunk = response.read(min(65536, cap - total + 1)) if not chunk: break total += len(chunk) if total > cap: raise FlowError( E.INVALID_REQUEST, "Photos image exceeds download limit; select thumbnail", ) handle.write(chunk) if expected and total != int(expected): raise ValueError() except (OSError, ValueError, http.client.HTTPException): raise FlowError( E.CLOUD_UNAVAILABLE, "Photos download interrupted; retry explicitly", 502, ) from None finally: connection.close()