R-1007 spike A: how the box can ask Immich without the household's password — Immich's own queue works for every user, a key only for the admin
gates / gates (push) Successful in 6m45s
gates / gates (push) Successful in 6m45s
On 9202, Immich v3.2.4, admin made through the setup gate as a household. A1 (immich-admin) has no job command. A2 (scoped key: asset.update + job.create) repairs the admin's photos only — a family member's photo is refused. A3 (nightly missing thumbnails) reads only the database. A4 (the same job on Immich's own queue, added with its own bullmq) repaired 5 assets of both users in 1.08 s with 0 of 50 controls touched, and found a lost original in 0.44 s. Scan of 20,000 files: 0.29 s. Decision page for the operator (Part B). Immich removed through the product. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
15:49:27 immich: deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
|
||||
15:51:02 immich: deployed, state=running
|
||||
deploy: ok
|
||||
15:51:02 immich: deployed, state=running
|
||||
stranger (no dashboard session) admin-sign-up -> 401
|
||||
household (dashboard session, through the gate) admin-sign-up -> 401
|
||||
server config isInitialized: None
|
||||
gate passed as the household: True
|
||||
household admin-sign-up through the gate -> 201
|
||||
isInitialized: True
|
||||
@@ -0,0 +1,31 @@
|
||||
family member: 201
|
||||
admin uploaded: 20
|
||||
member uploaded: 10
|
||||
jobs idle
|
||||
thumbs files: 60; users dirs: 2
|
||||
== A1: immich-admin in v3.2.4
|
||||
/usr/src/app/server/bin/immich-admin
|
||||
Usage: main [options] [command]
|
||||
|
||||
Options:
|
||||
-h, --help display help for command
|
||||
|
||||
Commands:
|
||||
reset-admin-password Reset the admin password
|
||||
enable-password-login Enable password login
|
||||
disable-password-login Disable password login
|
||||
enable-maintenance-mode Enable maintenance mode or regenerate the
|
||||
maintenance token
|
||||
disable-maintenance-mode Disable maintenance mode
|
||||
enable-oauth-login Enable OAuth login
|
||||
disable-oauth-login Disable OAuth login
|
||||
list-users List Immich users
|
||||
version Print Immich version
|
||||
grant-admin Grant admin privileges to a user (by email)
|
||||
revoke-admin Revoke admin privileges from a user (by email)
|
||||
change-media-location Change database file paths to align with a new
|
||||
media location
|
||||
schema-check Verify database migrations and check for schema
|
||||
drift
|
||||
help [command] display help for command
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
== A2: an API key with ONLY asset.update, made by the admin (the household's session)
|
||||
key made: yes (secret never printed)
|
||||
the admin's own photo: {"message":"Missing required permission: job.create"} [403]
|
||||
the family member's photo: {"message":"Missing required permission: job.create"} [403]
|
||||
integrity report with this key: 403
|
||||
visible to the household in Account settings -> API keys: ['Felhom box repair (A2 spike)']
|
||||
key deleted: 204
|
||||
== A3: what does the nightly/"missing" thumbnail run read?
|
||||
services/queue.service.js:214: if (config.nightlyTasks.missingThumbnails) {
|
||||
nightly tasks setting: {'startTime': '00:00', 'databaseCleanup': True, 'missingThumbnails': True, 'clusterNewFaces': True, 'generateMemories': True, 'syncQuotaUsage': True}
|
||||
|
||||
== A2 retry: key with asset.update + job.create (+ maintenance for the report)
|
||||
key made: yes
|
||||
the admin's own photo: [204]
|
||||
the family member's photo: {"message":"Not found or no asset.update access"} [400]
|
||||
integrity report with this key: 200
|
||||
key deleted: 204
|
||||
== A3: the missing-thumbnail query
|
||||
repositories/asset-job.repository.d.ts
|
||||
293: streamForThumbnailJob(options: {
|
||||
294- force: boolean | undefined;
|
||||
295- fullsizeEnabled: boolean;
|
||||
296- }): AsyncIterableIterator<{
|
||||
297- id: string;
|
||||
298- isEdited: boolean;
|
||||
299- } & Partial<Omit<unknown, "id" | "isEdited">>>;
|
||||
300- getForMigrationJob(id: string): Promise<{
|
||||
301- id: string;
|
||||
302- ownerId: string;
|
||||
303- files: {
|
||||
304- id: string;
|
||||
305- type: AssetFileType;
|
||||
306- path: string;
|
||||
307- isEdited: boolean;
|
||||
308- }[];
|
||||
309- } | undefined>;
|
||||
310- getForGenerateThumbnailJob(id: string): Promise<{
|
||||
311- id: string;
|
||||
312- type: AssetType;
|
||||
313- ownerId: string;
|
||||
314- edits: ({
|
||||
315- action: import("../dtos/editing.dto").AssetEditAction.Crop;
|
||||
316- parameters: {
|
||||
317- x: number;
|
||||
|
||||
58: streamForThumbnailJob(options) {
|
||||
59- return this.db
|
||||
60- .selectFrom('asset')
|
||||
61- .select(['asset.id', 'asset.isEdited'])
|
||||
62- .where('asset.deletedAt', 'is', null)
|
||||
63- .where('asset.visibility', '!=', kysely_1.sql.lit(enum_1.AssetVisibility.Hidden))
|
||||
64- .$if(!options.force, (qb) => qb
|
||||
65- .innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id')
|
||||
66- .where(({ and, eb, exists, not, or, selectFrom }) => {
|
||||
67- const file = (type) => selectFrom('asset_file').whereRef('assetId', '=', 'asset.id').where('type', '=', kysely_1.sql.lit(type));
|
||||
68- const conditions = [
|
||||
69- not(exists(file(enum_1.AssetFileType.Thumbnail))),
|
||||
70- not(exists(file(enum_1.AssetFileType.Preview))),
|
||||
71- and([
|
||||
72- eb('asset.isEdited', '=', kysely_1.sql.lit(true)),
|
||||
73- not(exists(file(enum_1.AssetFileType.FullSize).where('asset_file.isEdited', '=', kysely_1.sql.lit(true)))),
|
||||
74- ]),
|
||||
75- eb('asset.thumbhash', 'is', null),
|
||||
76- ];
|
||||
77- if (options.fullsizeEnabled) {
|
||||
78- const isWebUnsupported = kysely_1.sql.join(Object.keys(mime_types_1.mimeTypes.webUnsupportedImage).map((ext) => kysely_1.sql.lit(`%${ext}`)));
|
||||
79- conditions.push(and([
|
||||
80- not(exists(file(enum_1.AssetFileType.FullSize))),
|
||||
81- eb((0, kysely_1.sql) `f_unaccent(asset."originalFileName")`, 'like', (0, kysely_1.sql) `any(array[${isWebUnsupported}]::text[])`),
|
||||
82- ]));
|
||||
83- }
|
||||
84- return or(conditions);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
244- }
|
||||
245: async handleMissingFilesQueueAll({ refreshOnly } = {}) {
|
||||
246- if (refreshOnly) {
|
||||
247- await this.queueRefreshAllMissingFiles();
|
||||
248- return enum_1.JobStatus.Success;
|
||||
249- }
|
||||
250- this.logger.log(`Scanning for missing files...`);
|
||||
251- const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles();
|
||||
252- let total = 0;
|
||||
253- for await (const batchPaths of (0, misc_1.batched)(assetPaths, constants_1.JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
254- await this.jobRepository.queue({
|
||||
255- name: enum_1.JobName.IntegrityMissingFiles,
|
||||
256- data: {
|
||||
257- items: batchPaths,
|
||||
|
||||
deleted one original (the 5th by name)
|
||||
report rows before: 0
|
||||
== 13:54:39 queue IntegrityMissingFilesQueueAll on integrityCheck (no key, no login)
|
||||
queued 1
|
||||
missing_file rows after 0.44 s: 1
|
||||
== the report, READ from the database, joined to what a household note needs (name + date taken + owner):
|
||||
missing_file|gen-004.jpg|2026-01-03 01:00|household@example.invalid
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
users in thumbs/: 2 (10 and 20 photos)
|
||||
found from the disk: 5 broken assets
|
||||
== 13:53:50 A4: queue AssetGenerateThumbnails on thumbnailGeneration with Immich's own bullmq (no key, no login)
|
||||
queued 5
|
||||
rc=0
|
||||
no empty file left after 1.08 s: 0 empty
|
||||
broken assets' files: 10, rewritten 10, empty 0; other assets' files: 50, rewritten 0
|
||||
== the household's view (its own login): served bytes for the 5
|
||||
5964/198427 47/47 5630/197443 47/47 47/47
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
files made: 20000 on /mnt/scan-test
|
||||
dirs: 10525
|
||||
scan 1: 10 empty-file assets found among 20000 files in 0.285 s
|
||||
scan 2: 10 empty-file assets found among 20000 files in 0.287 s
|
||||
scratch folder removed: yes
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
15:56:41 immich: stop 200, remove 200, deployed=False, leftovers=''
|
||||
True
|
||||
left: 0 dirs
|
||||
containers: felhom-controller paperless-webserver paperless-postgres paperless-redis filebrowser traefik
|
||||
immich volumes: 0; images: 0
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Immich repairs its broken photos after a power cut (R-1007) — 2026-10-14
|
||||
|
||||
Times UTC. Scratch guest 9202 on demo-hp (Tier 0). Immich v3.2.4. No key, token, password, photo or file name of a
|
||||
household in this folder (bench content only).
|
||||
|
||||
## Part A — how can the box ask Immich without the household's password?
|
||||
|
||||
**Predictions (written 2026-10-14 before any test):**
|
||||
|
||||
| # | Candidate | Prediction |
|
||||
|---|---|---|
|
||||
| A1 | `immich-admin` CLI | it exists in v3.2.4 but has only account/maintenance commands (reset admin password, password/OAuth login on/off, list users, media location) — **no job command**; fails |
|
||||
| A2 | a scoped API key owned by the box | v3.2.4 has per-permission keys (`asset.update` for the per-asset job); but **an API key acts as its user**: the per-asset job checks access per asset, so the admin's key cannot repair another family member's photos; and the box has no clean moment to mint it without the password or the household's session → works only partly, and needs a capture we do not want |
|
||||
| A3 | Immich's nightly „missing thumbnails" | „missing" reads the database (the asset's file rows), not the disk; nothing short of a database write makes an empty file count as missing → only with A5 |
|
||||
| A4 | queue the same job in Immich's Redis | works, with no key: the job is `AssetGenerateThumbnails {id}` on queue `thumbnailGeneration`; done with Immich's OWN queue library inside `immich-server` (`docker exec … node`) it is the same job the API queues; breaks if Immich renames the job or the queue (an update can) |
|
||||
| A5 | a database write | works (the box has `DB_PASSWORD`) but writes into the household's data; refused unless chosen |
|
||||
| A6 | the household presses a button | works always; costs the household a step and a full rebuild |
|
||||
|
||||
Lost originals: the integrity job can be queued the same way as A4, and its report READ from the database (a read, not a
|
||||
write) — or the box compares the database's `originalPath`s with the disk itself.
|
||||
|
||||
**Set-up (`A/A0-*.txt`):** Immich deployed through the product on 9202. The admin made **as a household**: a stranger's
|
||||
`admin-sign-up` → 401 (the setup gate); the household passed the gate with its dashboard session (`felhom_gate` cookie)
|
||||
→ 201, `isInitialized` true. A second family member made by the admin. 20 photos as the admin, 10 as the member. From
|
||||
here the household password was used only to look at results as the household; no candidate used it.
|
||||
|
||||
**Measured:**
|
||||
|
||||
| # | Works? | What it writes into the household's Immich | What the household sees | What an Immich update breaks | What a compromised controller gains |
|
||||
|---|---|---|---|---|---|
|
||||
| A1 `immich-admin` | **no** — v3.2.4's commands: reset-admin-password, password/OAuth/maintenance on-off, list-users, grant/revoke-admin, change-media-location, schema-check, version. No job command (`A/A0-photos.txt`) | — | — | — | — |
|
||||
| A2 scoped API key | **partly** — needs `asset.update` + `job.create` (with `asset.update` alone: 403 „Missing required permission: job.create"); the admin's own photo 204, **the family member's photo 400 „Not found or no asset.update access"**; the lost-file report needs `maintenance` + an admin. The box cannot make the key itself: only the household's session can (`A/A2-A3-key-and-missing.txt`) | one API key row in the admin's account | „Felhom box repair" under Account settings → API keys; deleting it stops the repair | little: the public API is versioned | the key: update the admin's photos' metadata, run any job — less than it has already |
|
||||
| A3 nightly „missing thumbnails" | **no** (on by default, 00:00) — it reads only the database: no thumbnail/preview ROW, an edited asset without a full-size row, or `thumbhash` null (`asset-job.repository.js` `streamForThumbnailJob`). An empty file never counts; only a database write (A5) would make it | — | — | — | — |
|
||||
| **A4 Immich's own queue** | **yes** — `AssetGenerateThumbnails {id}` on `thumbnailGeneration` (prefix `immich_bull`), added with Immich's own `bullmq` inside `immich-server` (`docker exec … node`) — the same job `POST /api/assets/jobs` queues: **5 broken assets of BOTH users repaired in 1.08 s; their 10 files rewritten; 0 of the other 50 files touched**. Lost originals: `IntegrityMissingFilesQueueAll` on `integrityCheck` → one `missing_file` row in 0.44 s, read from the database with a SELECT (name, date taken, owner) (`A/A4-*.txt`) | only what Immich itself writes: the rebuilt thumbnails and its own integrity-report rows | nothing (the admin's Jobs page shows the queue counts while it runs) | the job name, the queue name, the prefix, the report table — internal; an update can rename them | nothing new: the controller already holds the docker socket and `DB_PASSWORD` |
|
||||
| A5 database write | not tried (not chosen) | rows in the household's database | depends | the schema | — |
|
||||
| A6 the household presses | always works | — | a note; „thumbnails: all" rebuilds the whole library | — | — |
|
||||
|
||||
**Scan cost (`A/scan-20000.txt`):** 20,000 files in the same layout (10,525 folders), 10 empty → found in **0.29 s**
|
||||
(`find -xdev -type f -size 0`; warm cache — a cold scan cannot be forced inside the guest).
|
||||
**Path:** `<upload>/thumbs/<user id>/<aa>/<bb>/<asset id>_thumbnail.webp` / `_preview.jpeg`; the asset id is the first
|
||||
36 characters, followed by **`_`** (the prompt's draft regex had `-`).
|
||||
**Teardown:** Immich removed through the product; no folder, container, volume or image left (`A/teardown.txt`).
|
||||
|
||||
## Part B — the decision (operator)
|
||||
|
||||
**Question: how should the box ask Immich to repair the broken photos after a power cut?**
|
||||
|
||||
| | Option 1 — Immich's own queue (A4) — **my pick** | Option 2 — an API key the household makes (A2) |
|
||||
|---|---|---|
|
||||
| How | the controller runs Immich's own queue library inside `immich-server` and adds the same job Immich's API adds; reads the lost-file report from Immich's database (read only) | the household makes a key in Immich (admin, 3 permissions) and pastes it into the dashboard; the box calls the public API |
|
||||
| Writes into Immich | only what Immich itself writes when it repairs (thumbnails, its report rows) | one API key in the admin's account |
|
||||
| The household sees | nothing (a note only if a photo is lost) | the key in its account; one extra setup step |
|
||||
| Covers | every user's photos | **only the admin's** — a family member's photos are refused (measured) |
|
||||
| An Immich update can break | the job/queue names and the report table → the catalog pins the tested Immich version; any other version: skip and tell the operator, never guess | little (public API) |
|
||||
| Threat | none new: the controller already has the docker socket and the database password | a stored key the box must guard |
|
||||
|
||||
**If you do nothing:** the fallback A6 — no automatic repair; a note tells the household to run „thumbnails: all".
|
||||
Reference in New Issue
Block a user