services: # Named volumes are created as root:root. Prepare the shared roots and perform # a state-marked directory-only ownership migration before writers start. data-init: image: alpine:3.24.1 user: "0:0" environment: PUID: "${PUID:-99}" PGID: "${PGID:-100}" command: - /bin/sh - -ec - | case "$${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 2 ;; esac case "$${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 2 ;; esac prepare_directory() { prepared_directory=$$1 prepared_mode=$$2 [ ! -L "$${prepared_directory}" ] \ || { echo "refusing symlink: $${prepared_directory}" >&2; return 1; } mkdir -p "$${prepared_directory}" [ ! -L "$${prepared_directory}" ] && [ -d "$${prepared_directory}" ] \ || { echo "refusing non-directory: $${prepared_directory}" >&2; return 1; } prepared_identity=$$(stat -c '%d:%i' "$${prepared_directory}") \ || { echo "cannot identify directory: $${prepared_directory}" >&2; return 1; } case "$${prepared_identity}" in ''|*[!0-9:]*) echo "cannot identify directory: $${prepared_directory}" >&2; return 1 ;; esac # Pin the inode before changing metadata. A peer may rename a shared # /data directory, but it cannot redirect these operations through a # replacement path or symlink. if ! exec 9<"$${prepared_directory}"; then echo "cannot pin directory: $${prepared_directory}" >&2 return 1 fi prepared_fd=/proc/$$$$/fd/9 prepared_fd_identity=$$(stat -Lc '%d:%i' "$${prepared_fd}") || { exec 9<&- echo "cannot identify pinned directory: $${prepared_directory}" >&2 return 1 } if [ ! -d "$${prepared_fd}" ] \ || [ "$${prepared_fd_identity}" != "$${prepared_identity}" ]; then exec 9<&- echo "path changed while pinning directory: $${prepared_directory}" >&2 return 1 fi prepared_current=$$(stat -c '%d:%i' "$${prepared_directory}" 2>/dev/null) || true if [ -L "$${prepared_directory}" ] \ || [ "$${prepared_current}" != "$${prepared_fd_identity}" ]; then exec 9<&- echo "path changed before preparing directory: $${prepared_directory}" >&2 return 1 fi if ! chown "$${PUID}:$${PGID}" "$${prepared_fd}" \ || ! chmod "$${prepared_mode}" "$${prepared_fd}"; then exec 9<&- echo "cannot prepare pinned directory: $${prepared_directory}" >&2 return 1 fi prepared_current=$$(stat -c '%d:%i' "$${prepared_directory}" 2>/dev/null) || true exec 9<&- if [ -L "$${prepared_directory}" ] \ || [ "$${prepared_current}" != "$${prepared_fd_identity}" ]; then echo "path changed during directory preparation: $${prepared_directory}" >&2 return 1 fi } migrate_nodes() { migration_root=$$1 migration_type=$$2 migration_mode=$$3 case "$${migration_type}" in d) migration_kind=directory ;; f) migration_kind='regular file' ;; *) echo "unsupported migration type: $${migration_type}" >&2; return 1 ;; esac migration_list=$$(mktemp /tmp/camera-data-init.XXXXXX) find "$${migration_root}" -xdev -type "$${migration_type}" -print0 \ > "$${migration_list}" while IFS= read -r -d '' migration_node; do [ ! -L "$${migration_node}" ] \ || { echo "refusing symlink: $${migration_node}" >&2; return 1; } case "$${migration_type}" in d) [ -d "$${migration_node}" ] ;; f) [ -f "$${migration_node}" ] ;; esac || { echo "refusing non-$${migration_kind}: $${migration_node}" >&2 return 1 } migration_identity=$$(stat -c '%d:%i' "$${migration_node}") \ || { echo "cannot identify migration node: $${migration_node}" >&2; return 1; } case "$${migration_identity}" in ''|*[!0-9:]*) echo "cannot identify migration node: $${migration_node}" >&2; return 1 ;; esac # Hold the exact inode found above. The shared /data tree can be # renamed concurrently, so all validation and mutation after this # point goes through the process-owned descriptor, never the path. if ! exec 9<"$${migration_node}"; then echo "cannot pin migration node: $${migration_node}" >&2 return 1 fi migration_fd=/proc/$$$$/fd/9 migration_fd_identity=$$(stat -Lc '%d:%i' "$${migration_fd}") || { exec 9<&- echo "cannot identify pinned migration node: $${migration_node}" >&2 return 1 } case "$${migration_type}" in d) [ -d "$${migration_fd}" ] ;; f) [ -f "$${migration_fd}" ] ;; esac || { exec 9<&- echo "refusing pinned non-$${migration_kind}: $${migration_node}" >&2 return 1 } if [ "$${migration_fd_identity}" != "$${migration_identity}" ]; then exec 9<&- echo "path changed while pinning migration node: $${migration_node}" >&2 return 1 fi if [ "$${migration_type}" = f ] \ && [ "$$(stat -Lc %h "$${migration_fd}")" -ne 1 ]; then exec 9<&- echo "refusing hard-linked state file: $${migration_node}" >&2 return 1 fi migration_current=$$(stat -c '%d:%i' "$${migration_node}" 2>/dev/null) || true if [ -L "$${migration_node}" ] \ || [ "$${migration_current}" != "$${migration_fd_identity}" ]; then exec 9<&- echo "path changed before migration: $${migration_node}" >&2 return 1 fi if ! chown "$${PUID}:$${PGID}" "$${migration_fd}" \ || ! chmod "$${migration_mode}" "$${migration_fd}"; then exec 9<&- echo "cannot migrate pinned node: $${migration_node}" >&2 return 1 fi migration_current=$$(stat -c '%d:%i' "$${migration_node}" 2>/dev/null) || true exec 9<&- if [ -L "$${migration_node}" ] \ || [ "$${migration_current}" != "$${migration_fd_identity}" ]; then echo "path changed during migration: $${migration_node}" >&2 return 1 fi done < "$${migration_list}" rm -f "$${migration_list}" } for directory in /data /data/incoming /data/sorted /data/quarantine \ /data/.panel; do prepare_directory "$${directory}" 0775 done prepare_directory /state 0700 # State is a private sorter-only volume. Re-home all existing control # nodes on every start so a supported PUID/PGID change cannot strand # old 0700 directories or 0600 lock/queue files under the previous UID. migrate_nodes /state d 0700 migrate_nodes /state f 0600 data_identity="$$(stat -c '%d-%i' /data)" case "$${data_identity}" in ''|*[!0-9-]*) echo "cannot identify /data" >&2; exit 1 ;; esac migration_marker="/state/.directory-owner-v1-$${PUID}-$${PGID}-$${data_identity}" [ ! -L "$${migration_marker}" ] || { echo "refusing symlink: $${migration_marker}" >&2; exit 1; } if [ -e "$${migration_marker}" ]; then marker_identity=$$(stat -c '%d:%i' "$${migration_marker}") \ || { echo "cannot identify migration marker" >&2; exit 1; } if ! exec 9<"$${migration_marker}"; then echo "cannot pin migration marker" >&2 exit 1 fi marker_fd=/proc/$$$$/fd/9 marker_fd_identity=$$(stat -Lc '%d:%i' "$${marker_fd}") || { exec 9<&- echo "cannot identify pinned migration marker" >&2 exit 1 } marker_metadata=$$(stat -Lc '%u:%g:%a:%h' "$${marker_fd}") || { exec 9<&- echo "cannot inspect pinned migration marker" >&2 exit 1 } if [ ! -f "$${marker_fd}" ]; then exec 9<&- echo "refusing non-regular migration marker" >&2 exit 1 fi marker_current=$$(stat -c '%d:%i' "$${migration_marker}" 2>/dev/null) || true exec 9<&- if [ -L "$${migration_marker}" ] \ || [ "$${marker_fd_identity}" != "$${marker_identity}" ] \ || [ "$${marker_current}" != "$${marker_fd_identity}" ] \ || [ "$${marker_metadata}" != "$${PUID}:$${PGID}:600:1" ]; then echo "refusing unsafe migration marker" >&2 exit 1 fi else # Older deployments commonly wrote date/type directories as root. # Migrate directories only (not photo/video files) once per UID:GID, # so the non-root sorter can add collisions and SMB can delete them. migrate_nodes /data/incoming d 0775 migrate_nodes /data/sorted d 0775 migrate_nodes /data/quarantine d 0775 # Keep the exclusive creation descriptor open through validation and # metadata changes. Noclobber makes this O_EXCL-like: if a peer wins # the name, fail without truncating or following its node. marker_umask=$$(umask) umask 077 set -C if exec 9>"$${migration_marker}"; then marker_created=1 else marker_created=0 fi set +C umask "$${marker_umask}" [ "$${marker_created}" -eq 1 ] \ || { echo "migration marker appeared during creation" >&2; exit 1; } marker_fd=/proc/$$$$/fd/9 marker_fd_identity=$$(stat -Lc '%d:%i' "$${marker_fd}") || { exec 9<&- echo "cannot identify created migration marker" >&2 exit 1 } if [ ! -f "$${marker_fd}" ] \ || [ "$$(stat -Lc %h "$${marker_fd}")" -ne 1 ]; then exec 9<&- echo "refusing unsafe created migration marker" >&2 exit 1 fi marker_current=$$(stat -c '%d:%i' "$${migration_marker}" 2>/dev/null) || true if [ -L "$${migration_marker}" ] \ || [ "$${marker_current}" != "$${marker_fd_identity}" ]; then exec 9<&- echo "migration marker changed before metadata update" >&2 exit 1 fi if ! chown "$${PUID}:$${PGID}" "$${marker_fd}" \ || ! chmod 0600 "$${marker_fd}"; then exec 9<&- echo "cannot prepare pinned migration marker" >&2 exit 1 fi marker_current=$$(stat -c '%d:%i' "$${migration_marker}" 2>/dev/null) || true exec 9<&- if [ -L "$${migration_marker}" ] \ || [ "$${marker_current}" != "$${marker_fd_identity}" ]; then echo "migration marker changed during metadata update" >&2 exit 1 fi fi volumes: # CAMERA_DATA_SOURCE may be the named volume (default) or a bind path such as # ./data. Every writer uses this same expression so initialization can # never target different storage from FTP/sorter/Frame.io. - ${CAMERA_DATA_SOURCE:-camera_data}:/data - camera_sorter_state:/state network_mode: none restart: "no" # Optional state preparation is profile-scoped so a damaged/unused Frame.io # volume can never prevent the core FTP + sorter pipeline from starting. frameio-init: image: alpine:3.24.1 profiles: ["frameio"] user: "0:0" environment: PUID: "${PUID:-99}" PGID: "${PGID:-100}" command: - /bin/sh - -ec - | case "$${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 2 ;; esac case "$${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 2 ;; esac [ ! -L /frameio-state ] || { echo "refusing symlink: /frameio-state" >&2; exit 1; } mkdir -p /frameio-state/staging [ ! -L /frameio-state/staging ] && [ -d /frameio-state/staging ] \ || { echo "refusing unsafe Frame.io staging directory" >&2; exit 1; } chown "$${PUID}:$${PGID}" /frameio-state chmod 0700 /frameio-state special_list=$$(mktemp /tmp/frameio-state-special.XXXXXX) find /frameio-state -xdev ! -type d ! -type f -print0 > "$${special_list}" if [ -s "$${special_list}" ]; then echo "refusing non-file node under /frameio-state" >&2 rm -f "$${special_list}" exit 1 fi rm -f "$${special_list}" directory_list=$$(mktemp /tmp/frameio-state-dirs.XXXXXX) find /frameio-state -xdev -type d -print0 > "$${directory_list}" while IFS= read -r -d '' node; do chown "$${PUID}:$${PGID}" "$${node}" chmod 0700 "$${node}" done < "$${directory_list}" rm -f "$${directory_list}" file_list=$$(mktemp /tmp/frameio-state-files.XXXXXX) find /frameio-state -xdev -type f -print0 > "$${file_list}" while IFS= read -r -d '' node; do [ "$$(stat -c %h "$${node}")" -eq 1 ] \ || { echo "refusing hard-linked state file: $${node}" >&2; exit 1; } chown "$${PUID}:$${PGID}" "$${node}" chmod 0600 "$${node}" done < "$${file_list}" rm -f "$${file_list}" volumes: - frameio_state:/frameio-state network_mode: none restart: "no" ftp: # Vendored fork — see pure-ftpd/Dockerfile. Stock pure-ftpd publishes an # aborted upload under its final name; the fork deletes it instead. build: ./pure-ftpd image: ftp-camera-dropbox/pure-ftpd container_name: camera-ftp environment: PUBLICHOST: ${PUBLICHOST:-127.0.0.1} FTP_USER_NAME: ${FTP_USER:-cameras} FTP_USER_PASS: ${FTP_PASS:-cameras} FTP_USER_HOME: /data/incoming FTP_USER_UID: "${PUID:-99}" FTP_USER_GID: "${PGID:-100}" FTP_PASSIVE_PORTS: "30000:30009" # -0 = atomic uploads: in-flight transfers are hidden .pureftpd-upload.* # temp files that the sorter ignores. Only the fork publishes them under # the real name solely on a completed transfer (226); stock pure-ftpd # renames even a 451-aborted partial into place. ADDED_FLAGS: "-0" ports: - "21:21" - "30000-30009:30000-30009" volumes: # Default is the camera_data volume. Set CAMERA_DATA_SOURCE=./data (or an # absolute host path) to make this and data-init use a bind mount instead. - ${CAMERA_DATA_SOURCE:-camera_data}:/data networks: - camera_net depends_on: data-init: condition: service_completed_successfully restart: unless-stopped sorter: build: . container_name: camera-sorter user: "${PUID:-99}:${PGID:-100}" environment: INCOMING: /data/incoming SORTED: /data/sorted QUARANTINE: /data/quarantine STABLE_WAIT: ${STABLE_WAIT:-60} STABLE_SKIP_AGE: ${STABLE_SKIP_AGE:-3600} SORT_WORKERS: ${SORT_WORKERS:-4} # Periodic full scans catch missed events and report uploads that remain # in incoming longer than the configured number of minutes. RECONCILE_IDLE: ${RECONCILE_IDLE:-300} STUCK_AGE_MIN: ${STUCK_AGE_MIN:-60} LOCK_ROOT: ${LOCK_ROOT:-/var/lib/camera-sorter} WATCH_READY_TIMEOUT: ${WATCH_READY_TIMEOUT:-30} NOTIFY_INTERVAL: ${NOTIFY_INTERVAL:-300} RAW_FULL_VALIDATE: ${RAW_FULL_VALIDATE:-1} RAW_VALIDATE_TIMEOUT: ${RAW_VALIDATE_TIMEOUT:-240} # Empty means sort.sh derives this beneath LOCK_ROOT, so changing the # control mount cannot accidentally send scratch back to image overlay. RAW_VALIDATE_TMPDIR: ${RAW_VALIDATE_TMPDIR:-} RAW_VALIDATE_TMP_STALE_MIN: ${RAW_VALIDATE_TMP_STALE_MIN:-15} # NEF post-sort hooks (optional): rewrite adapted-lens identity tags, # and hard-link each newly sorted NEF into a render queue watched by a # nef-watch container (github.com/rohanpandula/nef-watch) whose --out # points back at sorted/ so TIFFs land next to their NEFs. NEF_LENS_MASSAGE: ${NEF_LENS_MASSAGE:-0} NEF_QUEUE: ${NEF_QUEUE:-} TG_CONFIG: /etc/telegram.json volumes: - ${CAMERA_DATA_SOURCE:-camera_data}:/data # Private persistent control state (locks, queues, validation scratch). # Keep this volume off the SMB/FTP-exported camera data tree. - camera_sorter_state:${LOCK_ROOT:-/var/lib/camera-sorter} # Optional: mount telegram config if you have it. # The sorter skips notifications gracefully if this file is absent. # - ./telegram.json:/etc/telegram.json:ro networks: - camera_net depends_on: data-init: condition: service_completed_successfully ftp: condition: service_started restart: unless-stopped # --------------------------------------------------------------------------- # Optional: Frame.io Camera-to-Cloud mirror # --------------------------------------------------------------------------- # Receives Frame.io V4 webhooks, downloads the asset to /data/incoming, then # (optionally) deletes it from Frame.io to keep the free-tier quota clear. # The sorter picks it up exactly like an FTP upload — same incoming path. # # Opt-in only: this service is in the `frameio` profile, so plain # `docker compose up -d` leaves it off. To include it: # # docker compose --profile frameio up -d # # You'll need a public HTTPS endpoint pointing at this container's :8000 # (Cloudflare Tunnel, ngrok, Caddy + Let's Encrypt — whatever fits). # See frameio-mirror/README.md for the full setup. # --------------------------------------------------------------------------- frameio-mirror: build: ./frameio-mirror container_name: frameio-mirror profiles: ["frameio"] user: "${PUID:-99}:${PGID:-100}" environment: INCOMING_DIR: /data/incoming # FRAMEIO_WEBHOOK_SECRET is REQUIRED for any public deployment — the service # fails closed (503) on unsigned webhooks. ADOBE_* are required to download # and delete assets (the V4 webhook payload is just an ID — there is no # pre-signed-URL shortcut; every download goes through the authed API). FRAMEIO_WEBHOOK_SECRET: ${FRAMEIO_WEBHOOK_SECRET:-} ADOBE_CLIENT_ID: ${ADOBE_CLIENT_ID:-} ADOBE_CLIENT_SECRET: ${ADOBE_CLIENT_SECRET:-} # offline_access is REQUIRED for the OAuth Web App refresh-token flow. ADOBE_SCOPES: ${ADOBE_SCOPES:-openid,AdobeID,additional_info.roles,offline_access,profile,email} # Required for OAuth Web App enrollment: send it only in X-Setup-Secret # to POST /oauth/start so it never appears in browser/access URLs. OAUTH_SETUP_SECRET: ${OAUTH_SETUP_SECRET:-} OAUTH_REDIRECT_URI: ${OAUTH_REDIRECT_URI:-} # Safe default: keep Frame.io as a second copy. Set to 1 only if deleting # after exact size and SHA-256 revalidation is the intended quota policy. DELETE_UPSTREAM: ${DELETE_UPSTREAM:-0} WEBHOOK_MAX_BYTES: ${WEBHOOK_MAX_BYTES:-1000000} FRAMEIO_WORKERS: ${FRAMEIO_WORKERS:-4} FRAMEIO_QUEUE_SIZE: ${FRAMEIO_QUEUE_SIZE:-256} DOWNLOAD_MAX_BYTES: ${DOWNLOAD_MAX_BYTES:-21474836480} DOWNLOAD_MAX_SECONDS: ${DOWNLOAD_MAX_SECONDS:-1800} RECONCILE_INTERVAL_SECONDS: ${RECONCILE_INTERVAL_SECONDS:-900} RECONCILE_MAX_PAGES: ${RECONCILE_MAX_PAGES:-1000} RECONCILE_MAX_ITEMS: ${RECONCILE_MAX_ITEMS:-100000} RECONCILE_MAX_SECONDS: ${RECONCILE_MAX_SECONDS:-300} DOWNLOAD_TMP_STALE_SECONDS: ${DOWNLOAD_TMP_STALE_SECONDS:-3600} FRAMEIO_C2C_FOLDER_ID: ${FRAMEIO_C2C_FOLDER_ID:-} FRAMEIO_C2C_ACCOUNT_ID: ${FRAMEIO_C2C_ACCOUNT_ID:-} # Compose supplies a private persistent state volume for OAuth refresh # tokens, staging, and auto-discovered reconciliation IDs. REFRESH_TOKEN_FILE: ${REFRESH_TOKEN_FILE:-/var/lib/frameio/state.json} STAGING_DIR: /var/lib/frameio/staging volumes: # Same volume as ftp+sorter — downloads land where the sorter is watching: - ${CAMERA_DATA_SOURCE:-camera_data}:/data - frameio_state:/var/lib/frameio # # Optional: mount a frameio.json instead of using env vars (same pattern # as telegram.json). Env vars take precedence if both are set. # - ./frameio.json:/etc/frameio.json:ro # # Optional: mount the same telegram.json the sorter uses → mirror sends # throttled ⚠️ Telegram alerts on failure (HTTP errors, size mismatch, # missing Adobe creds). Plus a 🟢 startup ping each time it boots. # - ./telegram.json:/etc/telegram.json:ro # # The frameio_state volume above is the persistent default. To use a # host file instead, set REFRESH_TOKEN_FILE=/etc/frameio-oauth-state.json, # create/chown/chmod that file first, and mount it RW (not :ro): # - ./oauth-state.json:/etc/frameio-oauth-state.json ports: # Bind to localhost only — expose publicly via a reverse proxy / tunnel # (Cloudflare Tunnel, Caddy, etc.), not by opening the host's LAN interface. # On Unraid with a dedicated br0 IP you can drop this block entirely and # point the tunnel straight at the container IP:8000. - "127.0.0.1:8000:8000" networks: - camera_net depends_on: data-init: condition: service_completed_successfully frameio-init: condition: service_completed_successfully restart: unless-stopped # Internet-facing service — drop all caps (it only needs outbound HTTPS + # file writes via volume perms) and block privilege escalation. security_opt: - no-new-privileges:true cap_drop: - ALL # Optional LAN control panel: status, feature switches, lens rules, watched # folders, library browser, quarantine actions. The pipeline never depends # on it — sort.sh falls back to built-in behavior when /data/.panel/ is # absent. Start with: docker compose --profile panel up -d panel: build: ./panel container_name: dropbox-panel profiles: ["panel"] user: "${PUID:-99}:${PGID:-100}" mem_limit: 512m environment: # Comma-separated Host allowlist (LAN IP / hostname) — DNS-rebinding # protection. Leave unset only on a network you fully trust. PANEL_ALLOWED_HOSTS: ${PANEL_ALLOWED_HOSTS:-} # Keep the dashboard's queue dial pointed at the sorter's real queue. NEF_QUEUE_DIR: ${NEF_QUEUE:-} ports: - "${PANEL_PORT:-8484}:8484" volumes: - ${CAMERA_DATA_SOURCE:-camera_data}:/data # Optional: mount the host healthcheck state dir for the header lamp. # - /var/lib/ftpdropbox-health:/health:ro # Optional: same telegram config as the sorter — enables interactive # lens-decision menus (inline keyboard answers). UI-only without it. # - ./telegram.json:/etc/telegram.json:ro security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped networks: camera_net: driver: bridge volumes: camera_data: camera_sorter_state: frameio_state: