# 120 — `mule-images` Hosts `mule-image` / "mulita" — the photos app at `photos.hubris.network`. Auto-deploys from `dtoro/mule-image` on `git push origin main`. ## At a glance - **Hostname:** `mule-images` - **IP:** `192.168.8.136` - **Privilege:** privileged - **Resources:** 4 cores / 8 GiB RAM / 60 GiB rootfs - **Mounts:** `/mnt/library` ↔ `/mnt/library` - **Public hostname:** [`photos.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → `:3000` (frontend) ## Stack (`/opt/mule-image`) `/opt/mule-image` IS the working tree of `dtoro/mule-image`. Compose at `/opt/mule-image/docker-compose.yml`. Services: | Service | Port | Notes | | ----------------- | ------ | ----- | | frontend | 3000 | Reverse-proxied by Caddy | | backend | 8001 | FastAPI | | worker-vision | — | ML scan worker | | worker-light | — | Lightweight worker (+ celery beat scheduler since 2026-05-11) | | db | (pg) | pgvector | | redis | (rd) | queue | `worker-watcher` was retired 2026-05-11 — file events come from NC webhook_listeners instead of watchfiles inotify. See "Nextcloud integration" below. `.env` is **untracked** — `git checkout .env` will wipe it. Holds: - `PHOTO_DIRS=/mnt/library/images/` - `NEXTCLOUD_USERS_HOST_PATH=/mnt/library/homecloud` - `NEXTCLOUD_BASE_URL=https://cloud.hubris.network` - OIDC client secret + scopes - `SECRET_KEY` (generated) - `NEXTCLOUD_WEBHOOK_SECRET` (since 2026-05-11; bearer token NC presents on every webhook call to `/api/v1/internal/nc-webhook`) ## Nextcloud integration (since 2026-05-11) Phase 1 + 2 of dropping the duplicated indexing work between mule and Nextcloud — see `~/.claude/projects/-root/memory/project_mule_nc_integration.md` for the full detail. **Thumbnails (Phase 1, commit 576b0c2):** `GET /api/v1/photos/{id}/thumb/{size}` proxies `https://cloud.hubris.network/index.php/core/preview` keyed by `photos.nextcloud_fileid` (alembic 0018), authenticated with the photo owner's encrypted NC app password. NC handles JPEG/HEIC/TIFF/RAW/DNG/Movie previews natively. `X-Mule-Thumb-Source: nextcloud|disk` on every response. Worker now only writes the `medium` size to `/data/thumbs/{user}/{photo}/medium.webp` (the vision worker still loads it from disk); `small` + `large` are NC-proxied. Backfill script: `backend/scripts/backfill_nextcloud_fileid.py` — 17,591/17,591 photos filled. **File events (Phase 2, commits 362fbc6 + f657e2c):** mule no longer runs `watchfiles`. NC's `webhook_listeners` app (already installed, registered via `backend/scripts/register_nc_webhooks.py`) POSTs every `NodeCreated|NodeWritten|NodeDeleted|NodeRenamed` event to `http://192.168.8.136:8001/api/v1/internal/nc-webhook` with `Authorization: Bearer $NEXTCLOUD_WEBHOOK_SECRET`. The endpoint dispatches the same `scan_folder` / `handle_file_deletion` machinery the watcher used. **NC dispatches webhooks asynchronously via its background-job queue** — `crontab -u www-data` on LXC 114 runs `cron.php` every minute (`*/1`), so end-to-end latency is ≲60 s. Re-running the registrar script is idempotent (it deletes existing webhooks pointing at the same URL first). ## Nextcloud-rooted libraries (since 2026-04-26) Photo libraries live under each user's Nextcloud `files/` tree, NOT in `/mnt/library/images/*`. - `/mnt/library/homecloud` is bind-mounted into `backend`, `worker-light`, `worker-watcher`, `worker-vision` as `/nextcloud-users`. Each NC user is `/nextcloud-users//files/`. - Reads use that bind directly. - Mutations (upload, delete, rename, move) dispatch through `services/nextcloud_dav.py` (HTTP Basic auth, per-user app password Fernet-encrypted in `users.nextcloud_app_password_enc`) so Nextcloud's `oc_filecache`, trashbin, comments, and desktop-sync clients stay coherent. - Photo copy + cross-system moves return 501 with a "use Nextcloud's web UI" hint — defer until needed. - `users.nextcloud_username` overrides the default OIDC `preferred_username`. **`dtoro` (mule-image) maps to `admin` (Nextcloud)** — don't assume username equality. - Surviving SourceRoots in DB: `Photos` → `/nextcloud-users/admin/files/Photos`; `Memories` → `/nextcloud-users/admin/files/Memories` (both owned by `dtoro`). User `muli` has `nextcloud_username=muli` backfilled but no SourceRoot yet. - Pre-migration DB dump: `/root/snapshots/mulita-pre-nc-migration-20260426-075132.dump` (11 MB) on the host. ## Authentication (since 2026-04-22) Native OIDC via Authentik. Code in `backend/app/auth_oidc.py`, routes `/api/v1/auth/oidc/{login,callback}`. Authentik side: - OAuth2/OIDC Provider, client ID `fCuHew48ONTskDjUKnMTZjFbVXuHwvQqTScQRNQ1` - App slug `mule-image` - Redirect URI: `https://photos.hubris.network/api/v1/auth/oidc/callback` Backend container needs `extra_hosts: auth.hubris.network:192.168.8.175` via `docker-compose.override.yml` (gitignored). Otherwise Authlib's metadata fetch fails with `SSL: CERTIFICATE_VERIFY_FAILED: self-signed certificate` (it ends up at a random public host because LXC DNS resolves the public IONOS A record). Caddyfile stays plain `reverse_proxy 192.168.8.136:3000` — no forward-auth, no `/api/*` bypass needed. ## Auto-deploy Push to `dtoro/mule-image` `main` → gitea webhook → `http://192.168.8.136:9797/deploy` → `mule-deploy-webhook.service`: - Validates HMAC against `/etc/mule-deploy/secret` - Filters to `refs/heads/main` - Runs `/opt/mule-deploy/deploy.sh` in a daemon thread (returns 202 immediately — docker builds exceed gitea's request timeout) - `git pull --ff-only` + `docker compose up -d --build` + `docker image prune -f` Deploy tooling is **outside** the app repo: `/opt/mule-deploy/{deploy.sh,webhook.py}`, secret at `/etc/mule-deploy/secret`, unit at `/etc/systemd/system/mule-deploy-webhook.service`. Same shape as the Caddy + Artifacto pipelines. Gitea webhook id 6. `app.ini` `ALLOWED_HOST_LIST` on [gitea](104-gitea.md) includes `192.168.8.136`. Logs: `pct exec 120 -- journalctl -u mule-deploy-webhook -f`. Manual deploy: `pct exec 120 -- /opt/mule-deploy/deploy.sh`. For pushes from inside the LXC, gitea creds at `/etc/mule-deploy/git-credentials` (mode 600) — same token as `/etc/caddy-deploy/git-credentials` on [caddy](121-caddy.md). ## Related - [Nextcloud (114)](114-nextcloud.md) — source of truth for photo libraries - [Authentik (124)](124-authentik.md) - [Caddy (121)](121-caddy.md) - [DNS](../infrastructure/dns.md) - [Auto-deploy](../infrastructure/auto-deploy.md) - [Gitea (104)](104-gitea.md) ## Changelog ### 2026-05-11 — Stop duplicating Nextcloud's indexing (Phase 1 + 2) Two big architectural shifts in one session, both aimed at killing work mule was doing that NC already does for the same source files. **Phase 1 — thumbnail proxy (commits `576b0c2`, `28738ac`).** Photos table gains `nextcloud_fileid` (migration `0018`). `GET /api/v1/photos/{id}/thumb/{size}` proxies NC's `/index.php/core/preview` keyed by that fileid, authenticated with the owner's encrypted app password (small=240, medium=640, large=1280). Worker now only writes the `medium` size to `/data/thumbs` (the vision worker still loads it from disk); `small` + `large` no longer touch disk. Disk fallback stays wired for legacy rows and the rare 404 from NC (iPhone JPEGs mis-extensioned as `.DNG` — verified). Existing 17,591 photos backfilled via `backend/scripts/backfill_nextcloud_fileid.py`. Tested with real CR2, real DNG, fake DNG, JPEG, HEIC — all green. **Range support for `/original` (commit `18dce33`).** Pre-existing bug surfaced by Phase 1 testing: `