mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-14 16:30:50 +00:00
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:
1 parent
265b3b30a0
commit
c8df8df937
5 files changed
+93
-17
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([])
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in new issue
Block a user