diff --git a/.env.example b/.env.example index 793ee5b..5c837b2 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,6 @@ BUILD_TIME=unknown # PHOTO_ROOT=/photos # Optional WebDAV credential reference; prefer a secret file under /config. # FRAME_FLOW_SOURCE_CLOUD_FILE=/config/webdav-cloud.secret + +# Optional existing group with photo read permission; does not change NAS ACLs. +# NAS_PHOTOS_GID=101 diff --git a/deploy/docker-compose.photos.yml b/deploy/docker-compose.photos.yml index a062efd..3bbdf41 100644 --- a/deploy/docker-compose.photos.yml +++ b/deploy/docker-compose.photos.yml @@ -3,6 +3,8 @@ services: pigway-frame-flow: environment: PHOTO_ROOT: /photos + group_add: + - "${NAS_PHOTOS_GID:-10333}" volumes: - type: bind source: ${NAS_PHOTOS_DIR:?Set NAS_PHOTOS_DIR to your existing photo directory} diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index 19a599b..f253e59 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -10,6 +10,8 @@ Add the actual existing photo directory to `.env`: ```dotenv NAS_PHOTOS_DIR=/volume2/photo +# Optional: existing NAS group allowed to read that folder (check its ACL). +# NAS_PHOTOS_GID=101 ``` The path above is an example, not a detected directory. Enable the optional mount: @@ -20,7 +22,12 @@ docker compose -f docker-compose.yml -f deploy/docker-compose.photos.yml up -d ``` The folder is mounted **read-only** at `/photos`. The existing container user must -already have read/traverse permission. Frame Flow does not change NAS permissions. +already have read/traverse permission. If the Synology ACL grants a group access, +set `NAS_PHOTOS_GID` to that existing group ID; the optional Compose overlay adds +only container group membership, keeping UID 10333 and the photo bind mount read-only. +Group 101 is a deployment example, not a universal default. Frame Flow does not +change NAS permissions. Unreadable roots return an explicit HTTP 403 rather than +an empty catalog. Missing host folders fail explicitly rather than being created automatically. Open **Gallery → Photo sources → Add source**, choose **NAS local folder**, diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 3d31440..f6fa7dd 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -157,6 +157,15 @@ class PhotoSources: "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): diff --git a/tests/test_external_photo_sources.py b/tests/test_external_photo_sources.py index 9ae775f..b6d73d7 100644 --- a/tests/test_external_photo_sources.py +++ b/tests/test_external_photo_sources.py @@ -204,3 +204,21 @@ def test_scheduled_random_photo_runs_once_with_restore(app, service, photos): op = service.operation(rows[0]["id"]) assert op["status"] in ("SYNCING", "COMPLETED") assert service.state("living-room")["previous_image_id"] == original + + +def test_unreadable_source_is_forbidden_not_empty( + client, photos, settings, monkeypatch +): + import os + + original = os.scandir + + def restricted(path): + if str(path) == str(settings.photo_root / "family"): + raise PermissionError("permission denied") + return original(path) + + monkeypatch.setattr(os, "scandir", restricted) + response = client.get("/api/v1/photo-sources/family/images") + assert response.status_code == 403 + assert response.json()["error"]["code"] == "FORBIDDEN"