Immich : une clé illisible n'est plus signalée comme configurée

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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 18:11:17 +02:00
1 parent 265b3b30a0
commit c8df8df937
5 files changed
+93 -17

No files matched your search

+19 -10
View File
@@ -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-<uuid> »), 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) => {
+61 -3
View File
@@ -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',
+1 -1
View File
@@ -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([])
+10 -3
View File
@@ -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')}
</span>
{immichConfig?.keyNeedsReentry && (
<span className="pz-small">
{t('La clé enregistrée ne peut plus être lue (le secret du serveur a changé). Ressaisissez-la pour réactiver la connexion.')}
</span>
)}
{immichConfig?.configured && (
<span className="pz-small">
{immichStatus === 'ok' && t('Instance joignable.')}
@@ -611,7 +618,7 @@ const AdminPanel = ({
<button type="submit" className="pz-btn pz-btn--primary pz-btn--sm" disabled={immichBusy}>
<PzBusy busy={immichBusy} busyLabel={t('Enregistrement en cours')}>{t('Enregistrer')}</PzBusy>
</button>
{immichConfig?.configured && (
{(immichConfig?.configured || immichConfig?.keyNeedsReentry) && (
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={handleRemoveImmichConfig} disabled={immichBusy}>
<Unplug size={15} aria-hidden="true" />
{t('Supprimer la connexion')}
+2
View File
@@ -129,6 +129,8 @@ export default {
'État inconnu': 'Unknown state',
'Configurée': 'Configured',
'Non configurée': 'Not configured',
'Clé à ressaisir': 'Key to re-enter',
'La clé enregistrée ne peut plus être lue (le secret du serveur a changé). Ressaisissez-la pour réactiver la connexion.': 'The saved key can no longer be read (the server secret changed). Enter it again to reactivate the connection.',
'Instance joignable.': 'Instance reachable.',
'Test en cours…': 'Testing…',
'Test impossible.': 'Test failed.',