461 lines
18 KiB
Python
461 lines
18 KiB
Python
"""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 Photos previews 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()
|