fix(immich): a server with a self-signed certificate can be connected, as the other providers can

Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
This commit is contained in:
Maurice
2026-09-23 16:12:18 +02:00
parent 41b14ebc2a
commit ae80ad266d
17 changed files with 704 additions and 53 deletions
@@ -3,6 +3,7 @@ import {
createAlbumLinkSchema,
immichSearchSchema,
immichSettingsSchema,
immichTestSchema,
setTripPhotoSharingSchema,
synologySearchSchema,
synologySettingsSchema,
@@ -29,6 +30,32 @@ describe('immich contracts', () => {
}
});
it('accepts the self-signed switch as a boolean, on save and on test (#2475)', () => {
for (const allow_insecure_tls of [true, false]) {
expect(immichSettingsSchema.safeParse({ allow_insecure_tls }).success).toBe(true);
expect(
immichTestSchema.safeParse({
immich_url: 'https://immich.example.org',
immich_api_key: 'k',
allow_insecure_tls,
}).success,
).toBe(true);
}
});
it('leaves the switch optional, since an older client never sends it', () => {
const parsed = immichSettingsSchema.safeParse({ immich_url: 'https://immich.example.org', immich_api_key: 'k' });
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.allow_insecure_tls).toBeUndefined();
});
it('refuses a switch value that is not a boolean, its string included', () => {
for (const allow_insecure_tls of ['true', 'yes', 1, null]) {
expect(immichSettingsSchema.safeParse({ allow_insecure_tls }).success).toBe(false);
expect(immichTestSchema.safeParse({ allow_insecure_tls }).success).toBe(false);
}
});
it('accepts search paging as numbers OR strings (the controller does Number(x) || fallback)', () => {
expect(immichSearchSchema.safeParse({ page: 2, size: 50 }).success).toBe(true);
expect(immichSearchSchema.safeParse({ page: '2', size: '50' }).success).toBe(true);
+11
View File
@@ -40,17 +40,28 @@ const utcOffsetMinutes = looseNumber;
// ── Immich ────────────────────────────────────────────────────────────────
/**
* Trust a self-signed certificate on the Immich server (#2475). Absent means
* "leave the stored choice as it is", so a client that predates the switch
* cannot turn it off by saving. A plain boolean: the field is new, and the
* settings form has always sent its checkboxes as booleans, so none of the
* string leniency `synology_skip_ssl` carries is owed here.
*/
const immichAllowInsecureTls = z.boolean().optional();
export const immichSettingsSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
// Applied only when it is a real boolean (`typeof auto_upload === 'boolean'`),
// so anything else is accepted and ignored, exactly as before.
auto_upload: z.unknown().optional(),
allow_insecure_tls: immichAllowInsecureTls,
});
export const immichTestSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
allow_insecure_tls: immichAllowInsecureTls,
});
export const immichSearchSchema = z.looseObject({