From c8df8df9371a9ae40167433af6357ac4cfa46338 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 18:11:17 +0200 Subject: [PATCH] =?UTF-8?q?Immich=20:=20une=20cl=C3=A9=20illisible=20n'est?= =?UTF-8?q?=20plus=20signal=C3=A9e=20comme=20configur=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le résumé ne lisait que l'adresse : après un changement de JWT_SECRET, l'onglet affichait « Configurée » alors que chaque appel échouait. Le résumé déchiffre désormais la clé (configured: false, keyNeedsReentry: true, instance gardée pour préremplir le menu). L'onglet Immich affiche « Clé à ressaisir », garde le bouton de suppression, et un PUT sans clé est refusé tant que la clé en place ne se lit pas. Tests : résumé avec clé illisible, refus de conserver la clé. Co-Authored-By: Claude Opus 5.5 --- backend/src/routes/immich.ts | 29 +++++++---- backend/test/immich-config.test.ts | 64 ++++++++++++++++++++++-- backend/test/immich-unconfigured.test.ts | 2 +- frontend/src/components/AdminPanel.jsx | 13 +++-- frontend/src/i18n/en/compte.js | 2 + 5 files changed, 93 insertions(+), 17 deletions(-) diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 1ac3894..6d713a1 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -15,7 +15,7 @@ import { ImmichUnavailableError, type ImmichPersonSummary, } from '../lib/immich.js' -import { encryptImmichApiKey } from '../lib/immich-secret.js' +import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js' import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js' @@ -71,13 +71,21 @@ function replyImmichError(request: FastifyRequest, reply: FastifyReply, error: u // Métadonnées sûres d'une connexion Immich d'arbre : jamais la clé, jamais son chiffré, jamais // l'adresse (l'instance se nomme par son libellé). Une adresse sortie de la liste de l'opérateur -// vaut « non configuré » : la connexion est inerte. -function immichConfigSummary(config: { apiUrl: string; updatedAt: Date } | null) { +// vaut « non configuré » : la connexion est inerte. Une clé qui ne se déchiffre plus (JWT_SECRET +// changé) vaut aussi « non configuré », avec keyNeedsReentry : l'administrateur doit la ressaisir. +function immichConfigSummary(config: { apiUrl: string; apiKeyEnc: string; updatedAt: Date } | null) { const instance = config ? getImmichInstanceByApiUrl(config.apiUrl) : undefined if (!config || !instance) { - return { configured: false, instanceId: null, label: null, updatedAt: null } + return { configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null } + } + const keyReadable = decryptImmichApiKey(config.apiKeyEnc) !== null + return { + configured: keyReadable, + keyNeedsReentry: !keyReadable, + instanceId: instance.id, + label: instance.label, + updatedAt: config.updatedAt, } - return { configured: true, instanceId: instance.id, label: instance.label, updatedAt: config.updatedAt } } // Portraits venus d'Immich : même nommage que les envois manuels (« avatar- »), avec le @@ -107,7 +115,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { const config = await app.prisma.treeImmichConfig.findUnique({ where: { treeId: params.data.id }, - select: { apiUrl: true, updatedAt: true }, + select: { apiUrl: true, apiKeyEnc: true, updatedAt: true }, }) return reply.send({ @@ -150,8 +158,9 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { }) // Sans nouvelle clé, la clé en place est conservée — mais il en faut une pour une - // première configuration : on ne devine jamais d'identifiants ailleurs. - if (!payload.data.apiKey && !existing) { + // première configuration, et quand celle en place ne se déchiffre plus : on ne devine + // jamais d'identifiants ailleurs. + if (!payload.data.apiKey && (!existing || !decryptImmichApiKey(existing.apiKeyEnc))) { return reply.code(400).send({ error: 'api_key_required' }) } @@ -170,7 +179,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { where: { treeId }, create: { treeId, apiUrl: instance.apiUrl, apiKeyEnc }, update: { apiUrl: instance.apiUrl, apiKeyEnc }, - select: { apiUrl: true, updatedAt: true }, + select: { apiUrl: true, apiKeyEnc: true, updatedAt: true }, }) // Journal d'audit : jamais la clé, seulement le fait qu'elle a changé. @@ -220,7 +229,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { }) } - return reply.send({ config: { configured: false, instanceId: null, label: null, updatedAt: null } }) + return reply.send({ config: { configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null } }) }) app.get('/trees/:id/immich/status', async (request, reply) => { diff --git a/backend/test/immich-config.test.ts b/backend/test/immich-config.test.ts index 0a5a7ed..46b7175 100644 --- a/backend/test/immich-config.test.ts +++ b/backend/test/immich-config.test.ts @@ -110,6 +110,7 @@ beforeEach(async () => { update?: { apiUrl: string } }) => ({ apiUrl: args.create?.apiUrl ?? args.update?.apiUrl, + apiKeyEnc: args.create?.apiKeyEnc ?? args.update?.apiKeyEnc, updatedAt: new Date(), })) prismaMock.treeImmichConfig.deleteMany.mockResolvedValue({ count: 1 }) @@ -142,6 +143,63 @@ describe('Immich configuration: read', () => { await app.close() }) + it('asks for the key again when it no longer decrypts, instead of reporting it configured', async () => { + const app = createApp() + const token = await createUserToken(app) + // Clé chiffrée avec un autre secret (JWT_SECRET changé) : illisible. + prismaMock.treeImmichConfig.findUnique.mockResolvedValue({ + apiUrl: 'http://immich-tree-1.test', + // Octets aléatoires au bon format : le déchiffrement échoue (authentification GCM). + apiKeyEnc: `v1:${Buffer.alloc(64, 7).toString('base64')}`, + updatedAt: new Date('2026-10-01T00:00:00Z'), + }) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/config', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.json().config).toEqual({ + configured: false, + keyNeedsReentry: true, + instanceId: 'c2ec51abd0edebf3', + label: 'immich-tree-1.test', + updatedAt: '2026-10-01T00:00:00.000Z', + }) + + // La connexion ne sert à rien tant que la clé n'est pas ressaisie. + const status = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/status', + headers: { authorization: `Bearer ${token}` }, + }) + expect(status.json()).toEqual({ available: false, reason: 'not_configured' }) + await app.close() + }) + + it('refuses to keep an unreadable key when the address is saved again', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.treeImmichConfig.findUnique.mockResolvedValueOnce({ + apiUrl: 'http://immich-tree-1.test', + apiKeyEnc: 'v1:ce-texte-ne-se-dechiffre-pas', + updatedAt: new Date('2026-10-01T00:00:00Z'), + }) + + const response = await app.inject({ + method: 'PUT', + url: '/trees/tree-1/immich/config', + headers: { authorization: `Bearer ${token}` }, + payload: { instanceId: 'c2ec51abd0edebf3' }, + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ error: 'api_key_required' }) + expect(prismaMock.treeImmichConfig.upsert).not.toHaveBeenCalled() + await app.close() + }) + it('reports a tree without a configuration row as not configured', async () => { const app = createApp() const token = await createUserToken(app) @@ -155,7 +213,7 @@ describe('Immich configuration: read', () => { // Un arbre fraîchement créé n'a pas de ligne : rien de configuré, rien d'hérité. expect(response.statusCode).toBe(200) - expect(response.json().config).toEqual({ configured: false, instanceId: null, label: null, updatedAt: null }) + expect(response.json().config).toEqual({ configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null }) expect(response.json().instances).toEqual([ { id: 'c2ec51abd0edebf3', label: 'immich-tree-1.test' }, { id: '0715d0c35f2b0405', label: 'immich-tree-2.test' }, @@ -334,7 +392,7 @@ describe('Immich configuration: removal', () => { }) expect(response.statusCode).toBe(200) - expect(response.json().config).toEqual({ configured: false, instanceId: null, label: null, updatedAt: null }) + expect(response.json().config).toEqual({ configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null }) expect(prismaMock.treeImmichConfig.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1' } }) expect(prismaMock.auditLog.create).toHaveBeenCalledWith({ data: { @@ -480,7 +538,7 @@ describe('Immich configuration: operator allowlist', () => { headers: { authorization: `Bearer ${token}` }, }) expect(config.statusCode).toBe(200) - expect(config.json().config).toEqual({ configured: false, instanceId: null, label: null, updatedAt: null }) + expect(config.json().config).toEqual({ configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null }) const status = await app.inject({ method: 'GET', diff --git a/backend/test/immich-unconfigured.test.ts b/backend/test/immich-unconfigured.test.ts index 2a0d970..5c0695f 100644 --- a/backend/test/immich-unconfigured.test.ts +++ b/backend/test/immich-unconfigured.test.ts @@ -92,7 +92,7 @@ describe('Immich integration: tree without configuration', () => { headers: { authorization: `Bearer ${token}` }, }) expect(config.statusCode).toBe(200) - expect(config.json().config).toEqual({ configured: false, instanceId: null, label: null, updatedAt: null }) + expect(config.json().config).toEqual({ configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null }) // Aucune instance autorisée par l'opérateur : le menu des adresses est vide. expect(config.json().instances).toEqual([]) diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index ee147f3..aea3aa7 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -338,7 +338,7 @@ const AdminPanel = ({ } const handleRemoveImmichConfig = async () => { - if (!treeId || !authToken || immichBusy || !immichConfig?.configured) return + if (!treeId || !authToken || immichBusy || !(immichConfig?.configured || immichConfig?.keyNeedsReentry)) return if (!window.confirm(t('Supprimer la connexion Immich de cet arbre ? La reconnaissance des visages sera désactivée.'))) { return } @@ -545,8 +545,15 @@ const AdminPanel = ({ ? t('État inconnu') : immichConfig.configured ? t('Configurée') - : t('Non configurée')} + : immichConfig.keyNeedsReentry + ? t('Clé à ressaisir') + : t('Non configurée')} + {immichConfig?.keyNeedsReentry && ( + + {t('La clé enregistrée ne peut plus être lue (le secret du serveur a changé). Ressaisissez-la pour réactiver la connexion.')} + + )} {immichConfig?.configured && ( {immichStatus === 'ok' && t('Instance joignable.')} @@ -611,7 +618,7 @@ const AdminPanel = ({ - {immichConfig?.configured && ( + {(immichConfig?.configured || immichConfig?.keyNeedsReentry) && (