# 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 | | worker-watcher | — | FS watcher | | db | (pg) | pgvector | | redis | (rd) | queue | `.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-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-10 — OIDC auto-redirect on LoginPage `OIDC_ENABLED=true` was already set in `.env`, so the LoginPage rendered a "Sign in with Authentik" button next to the password form. With a single trusted IdP and a logged-in Authentik session, that extra click was friction without upside. `LoginPage.tsx` now reads `/auth/config` on mount and, if OIDC is on, immediately navigates to the OIDC login URL. Authentik recognizes the existing session and bounces back through the callback with no user interaction. Two escape hatches: `?password=1` in the URL forces the password form, and a `skipAutoSso` sessionStorage flag (set by `AuthContext.logout` and by the OIDC callback's error branch) suppresses the next auto-redirect so logouts actually log out and OIDC failures surface their error instead of looping. While the redirect is in flight the page shows "Signing in with Authentik..." plus a "Use password instead" link. ### 2026-05-10 — right sidebar restructure (heap pinned, single scroll, collapsible Metadata) The right sidepanel had three stacked flex regions: ``, `
`, and `` — with `PhotoInfoPanel` carrying its own internal scroll. That left the editable fields (filename, title, notes, rating, color, flag) stuck above the readonly metadata scroll, effectively two scroll boundaries on one sidebar. - Moved the scroll boundary up to `RightSidebar.tsx`: only `ActiveHeapCard` and `Header` stay pinned now. Editable + readonly content scroll together in one `flex-1 overflow-y-auto` region beneath them. - `PhotoInfoPanel.tsx` dropped its `h-full` / inner `flex-1 overflow-y-auto`. - The four readonly sections (Tags / Basic Info / Camera / Location) are now wrapped in a single outer "Metadata" `Collapsible`. Default expanded, one click hides the whole block. Sub-sections stay individually collapsible. - Second pass: the editable form (filename / title / notes / rating / color / flag) got the same treatment under an outer "Edit" `Collapsible` so the panel is now two equal collapsible groups below the title strip. Dropped the X (clear-selection) button from the Header; Esc and grid-empty-area-click still clear. - Third pass: split editable vs read-only between the two groups consistently. `TagsEditor` and `TakenAtEditor` had been buried inside the readonly Metadata sub-sections — `Tags` as its own Section, taken-at wedged into `Basic Info` between size/dims and the filepath. Moved both into the Edit collapsible, ordered identification → description → categorization: Filename · Title · Date Taken · Notes · Tags · Rating · Color · Flag. Metadata now holds only readonly: `Basic Info` (size, dims, path), `Camera`, `Location`. `PreviewView` reuses `RightSidebar` under an `