diff --git a/client/src/components/Settings/PhotoProvidersSection.test.tsx b/client/src/components/Settings/PhotoProvidersSection.test.tsx
index 2f4df8114..e26c72da5 100644
--- a/client/src/components/Settings/PhotoProvidersSection.test.tsx
+++ b/client/src/components/Settings/PhotoProvidersSection.test.tsx
@@ -1,4 +1,4 @@
-// FE-COMP-PHOTOPROVIDERS-001 to FE-COMP-PHOTOPROVIDERS-024
+// FE-COMP-PHOTOPROVIDERS-001 to FE-COMP-PHOTOPROVIDERS-026
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -470,3 +470,56 @@ describe('PhotoProvidersSection – checkbox fields and failures', () => {
await screen.findByText('Connected to Immich');
});
});
+
+// ── The Immich self-signed switch, as the server describes it (025, 026) ─────
+
+/** The Immich card exactly as the provider rows seed it (#2475). */
+const immichProvider = {
+ ...fakeProvider,
+ fields: [
+ { key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: true, secret: false, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
+ { key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: true, secret: true, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
+ { key: 'immich_allow_insecure_tls', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, required: false, secret: false, settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
+ ],
+};
+
+describe('PhotoProvidersSection: Immich self-signed certificates', () => {
+ it('FE-COMP-PHOTOPROVIDERS-025: a stored switch shows as on under its Synology wording', async () => {
+ server.use(
+ http.get('/api/addons/immich/settings', () =>
+ HttpResponse.json({ immich_url: 'https://immich.lan', connected: true, auto_upload: false, allow_insecure_tls: true }),
+ ),
+ );
+ seedMemoriesEnabled([immichProvider]);
+ render();
+
+ expect(await screen.findByText('Skip SSL certificate verification')).toBeInTheDocument();
+ await waitFor(() => expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'true'));
+ });
+
+ it('FE-COMP-PHOTOPROVIDERS-026: turning it on sends it with the connection test, as a boolean', async () => {
+ const user = userEvent.setup();
+ let body: Record | null = null;
+ server.use(
+ http.get('/api/addons/immich/settings', () =>
+ HttpResponse.json({ immich_url: 'https://immich.lan', connected: false, auto_upload: false, allow_insecure_tls: false }),
+ ),
+ http.post('/api/addons/immich/test', async ({ request }) => {
+ body = (await request.json()) as Record;
+ return HttpResponse.json({ connected: true });
+ }),
+ );
+ seedMemoriesEnabled([immichProvider]);
+ render();
+
+ await screen.findByDisplayValue('https://immich.lan');
+ await waitFor(() => expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'false'));
+ await user.type(screen.getByPlaceholderText('API Key'), 'secret-key');
+ await user.click(checkboxToggle());
+ await user.click(screen.getByRole('button', { name: /test connection/i }));
+
+ await waitFor(() =>
+ expect(body).toEqual({ immich_url: 'https://immich.lan', immich_api_key: 'secret-key', allow_insecure_tls: true }),
+ );
+ });
+});
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 596ea745f..47235d413 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -5226,6 +5226,37 @@ function runMigrations(db: Database.Database): void {
const seated = reseatBookedNights(db);
if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`);
},
+
+ /*
+ * Immich learns the switch Synology, AirTrail and Dawarich already have: a
+ * server behind a self-signed certificate can be trusted per user (#2475).
+ * Off by default, and only 1 counts as on.
+ *
+ * The two settings rows are the toggle for it and the auto-upload toggle
+ * migration 112 meant to add. That one only ran where the Immich provider
+ * row already existed, which on a fresh install it never did (the seeds run
+ * after the migrations), so fresh installs never showed the upload toggle.
+ * Both rows are in seeds.ts as well for the same reason; here they reach the
+ * installs that already have the provider row. Re-runnable.
+ */
+ () => {
+ const hasColumn = db
+ .prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'immich_allow_insecure_tls'")
+ .get();
+ if (!hasColumn) {
+ db.exec('ALTER TABLE users ADD COLUMN immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0');
+ }
+ const hasImmich = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich'").get();
+ if (hasImmich) {
+ db.exec(`
+ INSERT OR IGNORE INTO photo_provider_fields
+ (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
+ VALUES
+ ('immich', 'immich_allow_insecure_tls', 'skipSSLVerification', 'checkbox', NULL, NULL, 0, 0, 'allow_insecure_tls', 'allow_insecure_tls', 2),
+ ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, NULL, 0, 0, 'auto_upload', 'auto_upload', 5)
+ `);
+ }
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index 9c09c4bae..a326d1328 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -155,6 +155,8 @@ function seedAddons(db: Database.Database): void {
const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
+ { provider_id: 'immich', field_key: 'immich_allow_insecure_tls', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
+ { provider_id: 'immich', field_key: 'immich_auto_upload', label: 'immichAutoUpload', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 5 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com/photo', hint: 'providerUrlHintSynology', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', hint: null, required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
diff --git a/server/src/nest/memories/immich.controller.ts b/server/src/nest/memories/immich.controller.ts
index 4c8c694af..a626d72d2 100644
--- a/server/src/nest/memories/immich.controller.ts
+++ b/server/src/nest/memories/immich.controller.ts
@@ -35,8 +35,10 @@ export class ImmichMemoriesController {
@Req() req: Request,
@Res() res: Response,
): Promise {
- const { immich_url, immich_api_key, auto_upload } = body;
- const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req));
+ const { immich_url, immich_api_key, auto_upload, allow_insecure_tls } = body;
+ // Absent stays undefined and leaves the stored choice alone, so an older
+ // client cannot clear it by saving.
+ const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req), allow_insecure_tls);
if (!result.success) {
res.status(400).json({ error: result.error });
return;
@@ -59,11 +61,11 @@ export class ImmichMemoriesController {
@Post('test')
@HttpCode(200)
async test(@Body() body: ImmichTestDto) {
- const { immich_url, immich_api_key } = body;
+ const { immich_url, immich_api_key, allow_insecure_tls } = body;
if (!immich_url || !immich_api_key) {
return { connected: false, error: 'URL and API key required' };
}
- return this.memories.immichTestConnection(immich_url, immich_api_key);
+ return this.memories.immichTestConnection(immich_url, immich_api_key, allow_insecure_tls === true);
}
@Get('browse')
diff --git a/server/src/nest/memories/immich.service.ts b/server/src/nest/memories/immich.service.ts
index 5c9e54f0f..7c94f9d7d 100644
--- a/server/src/nest/memories/immich.service.ts
+++ b/server/src/nest/memories/immich.service.ts
@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
-import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
+import { checkSsrf, safeFetch, type SafeFetchOptions } from '../../utils/ssrfGuard';
import { AuditService } from '../audit/audit.service';
import { StorageService } from '../storage/storage.service';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
-import { fail, handleServiceResult, isWithinLocalDayRange, pipeAsset, shiftCalendarDay, sortAssetsByTakenAtDesc, type Selection } from './memories.helpers';
+import { describeFetchFailure, fail, handleServiceResult, isWithinLocalDayRange, pipeAsset, shiftCalendarDay, sortAssetsByTakenAtDesc, type Selection } from './memories.helpers';
const ALBUM_PAGE_SIZE = 1000;
const ALBUM_MAX_PAGES = 20;
@@ -23,6 +23,25 @@ const ALBUM_MAX_PAGES = 20;
* partial page for every page the caller asks for.
*/
const SEARCH_MAX_RAW_PAGES = 20;
+/** A mirrored journey upload is one photo; a server that sits on it this long is not coming back. */
+const UPLOAD_TIMEOUT_MS = 60_000;
+
+/** One user's Immich connection, as every request to that server needs it. */
+export interface ImmichCreds {
+ immich_url: string;
+ immich_api_key: string;
+ /** The user trusts a self-signed certificate on this server (#2475). */
+ allow_insecure_tls: boolean;
+}
+
+/**
+ * The TLS half of every request to a user's Immich. The certificate check is
+ * the only thing the switch relaxes: the SSRF guard and the DNS pinning still
+ * run on every hop.
+ */
+function tlsOptions(allowInsecureTls: boolean): SafeFetchOptions {
+ return { rejectUnauthorized: !allowInsecureTls };
+}
/**
* Immich photo provider: credentials, connection test, timeline/search browsing,
@@ -40,12 +59,19 @@ export class ImmichService {
private readonly storage: StorageService,
) {}
- getImmichCredentials(userId: number) {
- const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
+ getImmichCredentials(userId: number): ImmichCreds | null {
+ const user = this.db
+ .prepare('SELECT immich_url, immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?')
+ .get(userId) as { immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number | null } | undefined;
if (!user?.immich_url || !user?.immich_api_key) return null;
const apiKey = decrypt_api_key(user.immich_api_key);
if (!apiKey) return null;
- return { immich_url: user.immich_url as string, immich_api_key: apiKey };
+ return {
+ immich_url: user.immich_url,
+ immich_api_key: apiKey,
+ // Fail closed: anything but an explicit 1 keeps the certificate check on.
+ allow_insecure_tls: user.immich_allow_insecure_tls === 1,
+ };
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
@@ -73,11 +99,14 @@ export class ImmichService {
getConnectionSettings(userId: number) {
const creds = this.getImmichCredentials(userId);
- const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
+ const prefs = this.db
+ .prepare('SELECT immich_auto_upload, immich_allow_insecure_tls FROM users WHERE id = ?')
+ .get(userId) as { immich_auto_upload?: number; immich_allow_insecure_tls?: number } | undefined;
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
auto_upload: !!(prefs?.immich_auto_upload),
+ allow_insecure_tls: prefs?.immich_allow_insecure_tls === 1,
};
}
@@ -85,11 +114,17 @@ export class ImmichService {
this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
+ /**
+ * `allowInsecureTls` left undefined keeps the stored choice, so a client that
+ * does not know the switch cannot clear it by saving. Disconnecting (no URL)
+ * always turns it off again.
+ */
async saveImmichSettings(
userId: number,
immichUrl: string | undefined,
immichApiKey: string | undefined,
- clientIp: string | null
+ clientIp: string | null,
+ allowInsecureTls?: boolean,
): Promise<{ success: boolean; warning?: string; error?: string }> {
if (immichUrl) {
if (immichUrl.endsWith('/')) {
@@ -99,11 +134,16 @@ export class ImmichService {
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
- this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
- immichUrl.trim(),
- maybe_encrypt_api_key(immichApiKey),
- userId
- );
+ this.db
+ .prepare(
+ 'UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = COALESCE(?, immich_allow_insecure_tls) WHERE id = ?',
+ )
+ .run(
+ immichUrl.trim(),
+ maybe_encrypt_api_key(immichApiKey),
+ allowInsecureTls === undefined ? null : Number(allowInsecureTls),
+ userId,
+ );
if (ssrf.isPrivate) {
this.audit.writeAudit({
userId,
@@ -117,7 +157,7 @@ export class ImmichService {
};
}
} else {
- this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
+ this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 0 WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
@@ -130,7 +170,8 @@ export class ImmichService {
async testConnection(
immichUrl: string,
- immichApiKey: string
+ immichApiKey: string,
+ allowInsecureTls = false,
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string }; canonicalUrl?: string }> {
if (immichUrl.endsWith('/')) {
immichUrl = immichUrl.slice(0, -1);
@@ -141,7 +182,7 @@ export class ImmichService {
const resp = await safeFetch(`${immichUrl}/api/users/me`, {
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
- });
+ }, tlsOptions(allowInsecureTls));
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
@@ -162,7 +203,7 @@ export class ImmichService {
return { connected: true, user: { name: data.name, email: data.email }, canonicalUrl };
} catch (err: unknown) {
- return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
+ return { connected: false, error: describeFetchFailure(err) };
}
}
@@ -175,12 +216,12 @@ export class ImmichService {
const resp = await safeFetch(`${creds.immich_url}/api/users/me`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
- });
+ }, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
return { connected: true, user: { name: data.name, email: data.email } };
} catch (err: unknown) {
- return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
+ return { connected: false, error: describeFetchFailure(err) };
}
}
@@ -197,7 +238,7 @@ export class ImmichService {
method: 'GET',
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
- });
+ }, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status };
const buckets = await resp.json();
return { buckets };
@@ -214,7 +255,7 @@ export class ImmichService {
* that has to stay identical on every page of the same search.
*/
private async fetchSearchPage(
- creds: { immich_url: string; immich_api_key: string },
+ creds: ImmichCreds,
from: string | undefined,
to: string | undefined,
page: number,
@@ -258,7 +299,7 @@ export class ImmichService {
page,
}),
signal: AbortSignal.timeout(15000) as any,
- });
+ }, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
return { items: data.assets?.items || [] };
@@ -354,7 +395,7 @@ export class ImmichService {
const resp = await safeFetch(`${creds.immich_url}/api/assets/${assetId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
- });
+ }, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { error: 'Failed', status: resp.status };
const asset = await resp.json() as any;
return {
@@ -398,7 +439,7 @@ export class ImmichService {
const resp = await safeFetch(url, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000) as any,
- });
+ }, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
@@ -459,7 +500,7 @@ export class ImmichService {
timeout = 30000;
}
- await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl);
+ await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl, tlsOptions(creds.allow_insecure_tls));
}
// ── Albums ──────────────────────────────────────────────────────────────────
@@ -476,11 +517,11 @@ export class ImmichService {
safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
- }),
+ }, tlsOptions(creds.allow_insecure_tls)),
safeFetch(`${creds.immich_url}/api/albums?shared=true`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
- }),
+ }, tlsOptions(creds.allow_insecure_tls)),
]);
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
const ownAlbums = await ownResp.json() as any[];
@@ -534,13 +575,13 @@ export class ImmichService {
* the search path is correct. A version probe with a wrong boundary would not be.
*/
private async fetchAlbumAssets(
- creds: { immich_url: string; immich_api_key: string },
+ creds: ImmichCreds,
albumId: string,
): Promise<{ assets?: any[]; status?: number }> {
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
- });
+ }, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
@@ -557,7 +598,7 @@ export class ImmichService {
* it Immich omits `exifInfo` entirely and every photo's city/country goes null.
*/
private async fetchAlbumAssetsViaSearch(
- creds: { immich_url: string; immich_api_key: string },
+ creds: ImmichCreds,
albumId: string,
): Promise<{ assets?: any[]; status?: number }> {
const all: any[] = [];
@@ -575,7 +616,7 @@ export class ImmichService {
page,
}),
signal: AbortSignal.timeout(15000) as any,
- });
+ }, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
@@ -710,7 +751,10 @@ export class ImmichService {
'Content-Length': String(body.length),
},
body,
- });
+ // The journey upload waits on this mirror, so a server that never
+ // answers must not hold the upload open with it.
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
+ }, tlsOptions(creds.allow_insecure_tls));
if (res.ok) {
const data = await res.json() as { id?: string };
diff --git a/server/src/nest/memories/memories.helpers.ts b/server/src/nest/memories/memories.helpers.ts
index b6a5a8010..f9652c94b 100644
--- a/server/src/nest/memories/memories.helpers.ts
+++ b/server/src/nest/memories/memories.helpers.ts
@@ -213,6 +213,24 @@ export type AssetInfo = {
fileName?: string | null;
}
+/**
+ * Why a request to a provider never got an answer, worded for the settings card.
+ *
+ * undici rejects with a bare "fetch failed" and keeps the reason on `cause`
+ * (an unknown host, a refused connection, a self-signed certificate), so the
+ * message alone gave the user nothing to act on (#2475). The first cause that
+ * says something is appended; an error without one reads exactly as before.
+ */
+export function describeFetchFailure(err: unknown): string {
+ if (!(err instanceof Error)) return 'Connection failed';
+ let cause: unknown = err.cause;
+ for (let depth = 0; depth < 3 && cause instanceof Error; depth++) {
+ if (cause.message && cause.message !== err.message) return `${err.message} (${cause.message})`;
+ cause = cause.cause;
+ }
+ return err.message;
+}
+
/**
* Proxy an upstream asset straight to the client.
*
diff --git a/server/src/nest/memories/memories.service.ts b/server/src/nest/memories/memories.service.ts
index 21b13eab4..ff2b3476d 100644
--- a/server/src/nest/memories/memories.service.ts
+++ b/server/src/nest/memories/memories.service.ts
@@ -67,8 +67,8 @@ export class MemoriesService {
return this.immich.getConnectionSettings(userId);
}
- immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) {
- return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
+ immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null, allowInsecureTls?: boolean) {
+ return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp, allowInsecureTls);
}
immichSetAutoUpload(userId: number, enabled: boolean): void {
@@ -79,8 +79,8 @@ export class MemoriesService {
return this.immich.getConnectionStatus(userId);
}
- immichTestConnection(immichUrl: string, immichApiKey: string) {
- return this.immich.testConnection(immichUrl, immichApiKey);
+ immichTestConnection(immichUrl: string, immichApiKey: string, allowInsecureTls: boolean) {
+ return this.immich.testConnection(immichUrl, immichApiKey, allowInsecureTls);
}
immichBrowseTimeline(userId: number) {
diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts
index 818769461..0da55bf77 100644
--- a/server/tests/e2e/memories.e2e.test.ts
+++ b/server/tests/e2e/memories.e2e.test.ts
@@ -67,6 +67,7 @@ import { SynologyService } from '../../src/nest/memories/synology.service';
import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
+import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
const BASE = '/api/integrations/memories';
const UNIFIED = `${BASE}/unified`;
@@ -87,6 +88,9 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => {
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+ // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
+ // by metatype, exactly as they do under buildApp().
+ nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
@@ -209,6 +213,33 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => {
expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' });
});
+ it('PUT settings hands the self-signed switch to the save, and leaves it undefined when absent (#2475)', async () => {
+ immich.saveImmichSettings.mockResolvedValue({ success: true });
+ const on = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: true });
+ expect(on.status).toBe(200);
+ expect(immich.saveImmichSettings).toHaveBeenLastCalledWith(1, 'https://x', 'k', expect.anything(), true);
+
+ await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' });
+ expect(immich.saveImmichSettings).toHaveBeenLastCalledWith(1, 'https://x', 'k', expect.anything(), undefined);
+ });
+
+ it('400 PUT settings and /test with a switch value that is not a boolean, before the service runs', async () => {
+ const put = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: 'true' });
+ expect(put.status).toBe(400);
+ const test = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: 1 });
+ expect(test.status).toBe(400);
+ expect(immich.saveImmichSettings).not.toHaveBeenCalled();
+ expect(immich.testConnection).not.toHaveBeenCalled();
+ });
+
+ it('200 /test probes with the switch the form carries', async () => {
+ immich.testConnection.mockResolvedValue({ connected: false, error: 'HTTP 404' });
+ const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: true });
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ connected: false, error: 'HTTP 404' });
+ expect(immich.testConnection).toHaveBeenCalledWith('https://x', 'k', true);
+ });
+
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' });
const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1));
diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts
index f6b8fc4eb..6224c4fbf 100644
--- a/server/tests/integration/memories-immich.test.ts
+++ b/server/tests/integration/memories-immich.test.ts
@@ -1126,3 +1126,107 @@ describe('Immich testConnection canonical URL detection', () => {
expect(res.body.canonicalUrl).toBeUndefined();
});
});
+
+// ── Self-signed certificates (#2475) ─────────────────────────────────────────
+
+describe('Immich self-signed certificate switch (#2475)', () => {
+ const LAX = { rejectUnauthorized: false };
+ const STRICT = { rejectUnauthorized: true };
+
+ /** Only what the service and the asset proxy read off a response. */
+ function okJson(json: unknown): Response {
+ return { ok: true, status: 200, url: '', headers: { get: () => null }, json: async () => json, body: null } as unknown as Response;
+ }
+
+ function allowInsecureTls(userId: number): number {
+ return (testDb.prepare('SELECT immich_allow_insecure_tls AS v FROM users WHERE id = ?').get(userId) as { v: number }).v;
+ }
+
+ beforeEach(() => {
+ // Earlier cases leave a fixed response behind; these need a plain answer per path.
+ vi.mocked(safeFetch).mockReset();
+ vi.mocked(safeFetch).mockImplementation(async (url: string) =>
+ String(url).includes('/api/search/metadata') ? okJson({ assets: { items: [] } }) : okJson({}),
+ );
+ });
+
+ it('IMMICH-102: PUT /settings stores the switch and GET /settings reports it', async () => {
+ const { user } = createUser(testDb);
+
+ const put = await request(app)
+ .put(`${IMMICH}/settings`)
+ .set('Cookie', authCookie(user.id))
+ .send({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true });
+ expect(put.status).toBe(200);
+
+ const get = await request(app).get(`${IMMICH}/settings`).set('Cookie', authCookie(user.id));
+ expect(get.status).toBe(200);
+ expect(get.body.allow_insecure_tls).toBe(true);
+ });
+
+ it('IMMICH-103: a save without the switch keeps it, and disconnecting clears it', async () => {
+ const { user } = createUser(testDb);
+ const put = (body: Record) =>
+ request(app).put(`${IMMICH}/settings`).set('Cookie', authCookie(user.id)).send(body);
+
+ await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true });
+ expect(allowInsecureTls(user.id)).toBe(1);
+
+ // What a client that predates the switch sends.
+ await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k' });
+ expect(allowInsecureTls(user.id)).toBe(1);
+
+ await put({ immich_url: '', immich_api_key: 'k', allow_insecure_tls: true });
+ expect(allowInsecureTls(user.id)).toBe(0);
+ });
+
+ it('IMMICH-104: browse, search and the thumbnail proxy follow the stored switch', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
+ addTripPhoto(testDb, trip.id, user.id, 'asset-tls', 'immich', { shared: false });
+ const cookie = authCookie(user.id);
+
+ const hitEveryPath = async () => {
+ expect((await request(app).get(`${IMMICH}/browse`).set('Cookie', cookie)).status).toBe(200);
+ expect((await request(app).post(`${IMMICH}/search`).set('Cookie', cookie).send({ page: 1, size: 10 })).status).toBe(200);
+ expect((await request(app).get(`${IMMICH}/assets/${trip.id}/asset-tls/${user.id}/thumbnail`).set('Cookie', cookie)).status).toBe(200);
+ };
+
+ testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 1 WHERE id = ?').run(user.id);
+ await hitEveryPath();
+ expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
+ for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toEqual(LAX);
+
+ vi.mocked(safeFetch).mockClear();
+ testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 0 WHERE id = ?').run(user.id);
+ await hitEveryPath();
+ expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
+ for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toEqual(STRICT);
+ });
+
+ it('IMMICH-105: POST /test probes with the switch in the form, and refuses a value that is not a boolean', async () => {
+ const { user } = createUser(testDb);
+ const test = (allow_insecure_tls: unknown) =>
+ request(app)
+ .post(`${IMMICH}/test`)
+ .set('Cookie', authCookie(user.id))
+ .send({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls });
+
+ const lax = await test(true);
+ expect(lax.status).toBe(200);
+ expect(lax.body.connected).toBe(true);
+ expect(vi.mocked(safeFetch).mock.calls[0][2]).toEqual(LAX);
+
+ const strict = await test(false);
+ expect(strict.status).toBe(200);
+ expect(vi.mocked(safeFetch).mock.calls[1][2]).toEqual(STRICT);
+
+ // Only a real boolean: the form has always sent one, and anything else
+ // must not be read as either answer.
+ for (const value of ['true', 'yes']) {
+ expect((await test(value)).status).toBe(400);
+ }
+ expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/server/tests/unit/db/immich-provider-fields-migration.test.ts b/server/tests/unit/db/immich-provider-fields-migration.test.ts
new file mode 100644
index 000000000..5f0a91cf1
--- /dev/null
+++ b/server/tests/unit/db/immich-provider-fields-migration.test.ts
@@ -0,0 +1,100 @@
+import { runMigrations } from '../../../src/db/migrations';
+import { createTables } from '../../../src/db/schema';
+import { runSeeds } from '../../../src/db/seeds';
+
+import Database from 'better-sqlite3';
+import { afterEach, describe, expect, it } from 'vitest';
+
+// The Immich self-signed switch and the two settings rows are pinned at schema
+// version 243 (#2475). Later migrations are appended after it, so a case that
+// replays it rewinds to just before it; the steps after it are idempotent.
+const IMMICH_TLS_VERSION = 243;
+
+type FieldRow = { field_key: string; input_type: string; settings_key: string | null; payload_key: string | null; sort_order: number };
+
+function immichFields(db: Database.Database): FieldRow[] {
+ return db
+ .prepare(
+ "SELECT field_key, input_type, settings_key, payload_key, sort_order FROM photo_provider_fields WHERE provider_id = 'immich' ORDER BY sort_order",
+ )
+ .all() as FieldRow[];
+}
+
+const EXPECTED_FIELDS: FieldRow[] = [
+ { field_key: 'immich_url', input_type: 'url', settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
+ { field_key: 'immich_api_key', input_type: 'password', settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
+ { field_key: 'immich_allow_insecure_tls', input_type: 'checkbox', settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
+ { field_key: 'immich_auto_upload', input_type: 'checkbox', settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 5 },
+];
+
+function replay(db: Database.Database): void {
+ db.prepare('UPDATE schema_version SET version = ?').run(IMMICH_TLS_VERSION - 1);
+ runMigrations(db);
+}
+
+describe('Immich provider fields and the self-signed switch (#2475)', () => {
+ let db: Database.Database;
+
+ afterEach(() => db?.close());
+
+ it('a fresh install shows every Immich setting, the auto-upload toggle included', () => {
+ // database.ts runs schema, migrations, seeds in that order. On an empty
+ // database the migrations find no Immich provider row yet, so the seeds
+ // are what has to bring the rows along.
+ db = new Database(':memory:');
+ createTables(db);
+ runMigrations(db);
+ // An existing user keeps the admin seeding out of this.
+ db.exec("INSERT INTO users (username, email, password_hash) VALUES ('someone', 'someone@example.test', 'x')");
+ runSeeds(db);
+
+ expect(immichFields(db)).toEqual(EXPECTED_FIELDS);
+ const label = db
+ .prepare("SELECT label FROM photo_provider_fields WHERE provider_id = 'immich' AND field_key = 'immich_allow_insecure_tls'")
+ .get();
+ // The Synology label, so every locale already has the words.
+ expect(label).toEqual({ label: 'skipSSLVerification' });
+ });
+
+ it('the switch is off for every user until they turn it on', () => {
+ db = new Database(':memory:');
+ createTables(db);
+ runMigrations(db);
+ db.exec("INSERT INTO users (username, email, password_hash) VALUES ('someone', 'someone@example.test', 'x')");
+
+ expect(db.prepare('SELECT immich_allow_insecure_tls AS v FROM users').get()).toEqual({ v: 0 });
+ });
+
+ it('an install that already had the Immich provider gets both rows once, and a replay changes nothing', () => {
+ db = new Database(':memory:');
+ createTables(db);
+ runMigrations(db);
+ // What an upgraded fresh install looked like: the seeds of an older release
+ // added the provider with only its URL and key.
+ db.exec(`
+ INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES ('immich', 'Immich', 'Immich photo provider', 'Image', 0, 0);
+ INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES
+ ('immich', 'immich_url', 'providerUrl', 'url', 'https://immich.example.com', 1, 0, 'immich_url', 'immich_url', 0),
+ ('immich', 'immich_api_key', 'providerApiKey', 'password', 'API Key', 1, 1, NULL, 'immich_api_key', 1);
+ `);
+
+ replay(db);
+ expect(immichFields(db)).toEqual(EXPECTED_FIELDS);
+
+ replay(db);
+ expect(immichFields(db)).toEqual(EXPECTED_FIELDS);
+ });
+
+ it('a replay keeps what a user already chose', () => {
+ db = new Database(':memory:');
+ createTables(db);
+ runMigrations(db);
+ db.exec(
+ "INSERT INTO users (username, email, password_hash, immich_allow_insecure_tls) VALUES ('someone', 'someone@example.test', 'x', 1)",
+ );
+
+ replay(db);
+
+ expect(db.prepare('SELECT immich_allow_insecure_tls AS v FROM users').get()).toEqual({ v: 1 });
+ });
+});
diff --git a/server/tests/unit/nest/immich.service.test.ts b/server/tests/unit/nest/immich.service.test.ts
index a4b2b8107..dedd2d393 100644
--- a/server/tests/unit/nest/immich.service.test.ts
+++ b/server/tests/unit/nest/immich.service.test.ts
@@ -58,9 +58,9 @@ const svc = new ImmichService(dbs, audit as unknown as AuditService, access as u
const USER = 1;
-function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0): void {
- testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload) VALUES (?, ?, ?, 'x', ?, ?, ?)")
- .run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload);
+function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0, allowInsecureTls = 0): void {
+ testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload, immich_allow_insecure_tls) VALUES (?, ?, ?, 'x', ?, ?, ?, ?)")
+ .run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload, allowInsecureTls);
}
/** A Response-ish object with only what the service reads. */
@@ -111,7 +111,7 @@ describe('getImmichCredentials', () => {
});
it('IMMICH-005: returns the decrypted pair otherwise', () => {
- expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
+ expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1', allow_insecure_tls: false });
});
});
@@ -125,12 +125,12 @@ describe('isValidAssetId', () => {
describe('getConnectionSettings / setImmichAutoUpload', () => {
it('IMMICH-007: reports connected with the URL when configured', () => {
- expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false });
+ expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false, allow_insecure_tls: false });
});
it('IMMICH-008: reports an empty URL and not connected when it is not', () => {
seedUser(4, null, null);
- expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false });
+ expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false, allow_insecure_tls: false });
});
it('IMMICH-009: surfaces the auto-upload flag both ways', () => {
@@ -155,7 +155,7 @@ describe('saveImmichSettings', () => {
const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null);
expect(result).toEqual({ success: true });
- expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' });
+ expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2', allow_insecure_tls: false });
});
it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => {
@@ -725,3 +725,164 @@ describe('uploadToImmich', () => {
expect(await svc.uploadToImmich(USER, 'journey/rej.jpg', 'rej.jpg')).toBeNull();
});
});
+
+/**
+ * The self-signed switch (#2475). Every request to a user's Immich carries the
+ * TLS options as safeFetch's third argument; these pin that no path was left
+ * on the strict default and that the switch is read fail closed.
+ */
+describe('self-signed certificates', () => {
+ const LAX = { rejectUnauthorized: false };
+ const STRICT = { rejectUnauthorized: true };
+
+ function makeRes() {
+ return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), end: vi.fn(), headersSent: false };
+ }
+
+ function writeJourneyObject(name: string, bytes: string): void {
+ const dir = path.join(journeyFx.root, 'journey');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, name), bytes);
+ }
+
+ /** Drives every request the service makes against the user's own server. */
+ async function exerciseEveryPath(userId: number): Promise {
+ access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
+ safeFetch.mockImplementation(async (url: string) => {
+ if (url.endsWith('/api/albums/album-1')) return upstream({ json: {} });
+ if (url.includes('/api/search/metadata')) return upstream({ json: { assets: { items: [] } } });
+ if (url.includes('/api/albums')) return upstream({ json: [] });
+ if (url.endsWith('/api/assets')) return upstream({ json: { id: 'up-1' } });
+ return upstream({ json: {} });
+ });
+ writeJourneyObject('tls.jpg', 'bytes');
+
+ await svc.getConnectionStatus(userId);
+ await svc.browseTimeline(userId);
+ await svc.searchPhotos(userId);
+ await svc.getAssetInfo(userId, 'a1');
+ await svc.fetchImmichThumbnailBytes(userId, 'a1');
+ await svc.streamImmichAsset(makeRes() as never, userId, 'a1', 'thumbnail');
+ await svc.listAlbums(userId);
+ await svc.getAlbumPhotos(userId, 'album-1');
+ await svc.collectAlbumSelection('1', 'l1', userId);
+ await svc.uploadToImmich(userId, 'journey/tls.jpg', 'tls.jpg');
+ }
+
+ /**
+ * status, timeline, search, info, thumbnail, stream, both album lists, then
+ * the album detail and its v3 search twice (browse and sync), and the upload.
+ */
+ const EVERY_PATH_CALLS = 13;
+
+ it('IMMICH-TLS-001: only a stored 1 turns the switch on', () => {
+ seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
+ expect(svc.getImmichCredentials(USER)!.allow_insecure_tls).toBe(true);
+
+ seedUser(USER, 'https://immich.test', 'key-1', 0, 0);
+ expect(svc.getImmichCredentials(USER)!.allow_insecure_tls).toBe(false);
+
+ // Anything the column should never hold reads as off, not as truthy.
+ seedUser(USER, 'https://immich.test', 'key-1', 0, 2);
+ expect(svc.getImmichCredentials(USER)!.allow_insecure_tls).toBe(false);
+ });
+
+ it('IMMICH-TLS-002: with the switch on, every request to the server skips the certificate check', async () => {
+ seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
+
+ await exerciseEveryPath(USER);
+
+ expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS);
+ for (const call of safeFetch.mock.calls) expect(call[2]).toEqual(LAX);
+ });
+
+ it('IMMICH-TLS-003: with the switch off, every request keeps the certificate check', async () => {
+ await exerciseEveryPath(USER);
+
+ expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS);
+ for (const call of safeFetch.mock.calls) expect(call[2]).toEqual(STRICT);
+ });
+
+ it('IMMICH-TLS-004: a shared photo follows the switch of its owner, not of the viewer', async () => {
+ // The request goes to the owner's server with the owner's key, so the
+ // owner decided whether that server's certificate is trusted.
+ seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0);
+ seedUser(20, 'https://owner.test', 'owner-key', 0, 1);
+ safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } }));
+
+ await svc.getAssetInfo(USER, 'a1', 20);
+ await svc.fetchImmichThumbnailBytes(USER, 'a1', 20);
+ await svc.streamImmichAsset(makeRes() as never, USER, 'a1', 'original', 20);
+
+ expect(safeFetch).toHaveBeenCalledTimes(3);
+ for (const call of safeFetch.mock.calls) {
+ expect(call[0]).toContain('https://owner.test');
+ expect(call[2]).toEqual(LAX);
+ }
+
+ safeFetch.mockClear();
+ seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1);
+ seedUser(20, 'https://owner.test', 'owner-key', 0, 0);
+ await svc.getAssetInfo(USER, 'a1', 20);
+ expect(safeFetch.mock.calls[0][2]).toEqual(STRICT);
+ });
+
+ it('IMMICH-TLS-005: saving without the switch keeps it, saving with it sets it, disconnecting clears it', async () => {
+ await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true);
+ expect(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
+
+ // An older client does not send the field; its save must not turn the switch off.
+ await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null);
+ expect(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
+
+ await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, false);
+ expect(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(false);
+
+ await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true);
+ await svc.saveImmichSettings(USER, undefined, undefined, null, true);
+ const row = testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(USER);
+ expect(row).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 });
+ });
+
+ it('IMMICH-TLS-006: a URL the guard refuses leaves the stored switch alone', async () => {
+ seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
+ checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });
+
+ await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null, false);
+
+ expect(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
+ });
+
+ it('IMMICH-TLS-007: the connection test uses the switch it is handed, strict by default', async () => {
+ safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada' } }));
+
+ await svc.testConnection('https://immich.test', 'k', true);
+ await svc.testConnection('https://immich.test', 'k', false);
+ await svc.testConnection('https://immich.test', 'k');
+
+ expect(safeFetch.mock.calls.map(call => call[2])).toEqual([LAX, STRICT, STRICT]);
+ });
+
+ it('IMMICH-TLS-008: a refused certificate names itself instead of a bare "fetch failed"', async () => {
+ // undici keeps the reason on `cause`; the settings card used to show only
+ // the outer message, which left the user nothing to go on.
+ const refused = () => new TypeError('fetch failed', { cause: new Error('self-signed certificate') });
+ safeFetch.mockRejectedValueOnce(refused());
+ expect(await svc.testConnection('https://immich.test', 'k')).toEqual({
+ connected: false,
+ error: 'fetch failed (self-signed certificate)',
+ });
+
+ safeFetch.mockRejectedValueOnce(refused());
+ expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'fetch failed (self-signed certificate)' });
+ });
+
+ it('IMMICH-TLS-009: the upload mirror gives up on a server that never answers', async () => {
+ writeJourneyObject('slow.jpg', 'bytes');
+ safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'up-2' } }));
+
+ await svc.uploadToImmich(USER, 'journey/slow.jpg', 'slow.jpg');
+
+ expect((safeFetch.mock.calls[0][1] as { signal?: AbortSignal }).signal).toBeInstanceOf(AbortSignal);
+ });
+});
diff --git a/server/tests/unit/nest/memories.controller.test.ts b/server/tests/unit/nest/memories.controller.test.ts
index 9b565161e..c1484c901 100644
--- a/server/tests/unit/nest/memories.controller.test.ts
+++ b/server/tests/unit/nest/memories.controller.test.ts
@@ -242,7 +242,7 @@ describe('ImmichMemoriesController (parity with /api/integrations/memories/immic
const svc = makeService({ immichSaveSettings, immichSetAutoUpload });
const res = makeRes();
await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k', auto_upload: true }, req, res);
- expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4');
+ expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4', undefined);
expect(immichSetAutoUpload).toHaveBeenCalledWith(7, true);
expect(res.json).toHaveBeenCalledWith({ success: true });
});
@@ -255,6 +255,24 @@ describe('ImmichMemoriesController (parity with /api/integrations/memories/immic
expect(immichSetAutoUpload).not.toHaveBeenCalled();
});
+ it('hands the self-signed switch to the save both ways (#2475)', async () => {
+ const immichSaveSettings = vi.fn().mockResolvedValue({ success: true });
+ const svc = makeService({ immichSaveSettings });
+ const controller = new ImmichMemoriesController(svc);
+
+ await controller.putSettings(user, { immich_url: 'x', immich_api_key: 'k', allow_insecure_tls: true }, req, makeRes());
+ await controller.putSettings(user, { immich_url: 'x', immich_api_key: 'k', allow_insecure_tls: false }, req, makeRes());
+
+ expect(immichSaveSettings.mock.calls.map((call) => call[4])).toEqual([true, false]);
+ });
+
+ it('leaves the switch undefined when the body does not carry it, so the stored choice stays', async () => {
+ const immichSaveSettings = vi.fn().mockResolvedValue({ success: true });
+ const svc = makeService({ immichSaveSettings });
+ await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k' }, req, makeRes());
+ expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4', undefined);
+ });
+
it('returns the warning when the save carries one', async () => {
const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'Unverified TLS' }) });
const res = makeRes();
@@ -289,7 +307,19 @@ describe('ImmichMemoriesController (parity with /api/integrations/memories/immic
const immichTestConnection = vi.fn().mockResolvedValue({ connected: true });
const svc = makeService({ immichTestConnection });
expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u', immich_api_key: 'k' })).toEqual({ connected: true });
- expect(immichTestConnection).toHaveBeenCalledWith('u', 'k');
+ // No switch in the body tests with the certificate check on.
+ expect(immichTestConnection).toHaveBeenCalledWith('u', 'k', false);
+ });
+
+ it('tests with the self-signed switch the form carries (#2475)', async () => {
+ const immichTestConnection = vi.fn().mockResolvedValue({ connected: true });
+ const svc = makeService({ immichTestConnection });
+ const controller = new ImmichMemoriesController(svc);
+
+ await controller.test({ immich_url: 'u', immich_api_key: 'k', allow_insecure_tls: true });
+ await controller.test({ immich_url: 'u', immich_api_key: 'k', allow_insecure_tls: false });
+
+ expect(immichTestConnection.mock.calls.map((call) => call[2])).toEqual([true, false]);
});
});
diff --git a/server/tests/unit/nest/memories.helpers.test.ts b/server/tests/unit/nest/memories.helpers.test.ts
index 8f6fc32c8..2d139bb9c 100644
--- a/server/tests/unit/nest/memories.helpers.test.ts
+++ b/server/tests/unit/nest/memories.helpers.test.ts
@@ -10,6 +10,7 @@
import { describe, it, expect } from 'vitest';
import {
dayStartEpochSeconds,
+ describeFetchFailure,
isWithinLocalDayRange,
shiftCalendarDay,
sortAssetsByTakenAtDesc,
@@ -149,3 +150,29 @@ describe('dayStartEpochSeconds', () => {
expect(Number.isNaN(dayStartEpochSeconds('nope'))).toBe(true);
});
});
+
+describe('describeFetchFailure', () => {
+ it('MEM-FETCH-001: appends the reason undici keeps on cause', () => {
+ const err = new TypeError('fetch failed', { cause: new Error('self-signed certificate') });
+ expect(describeFetchFailure(err)).toBe('fetch failed (self-signed certificate)');
+ });
+
+ it('MEM-FETCH-002: an error without a cause reads exactly as before', () => {
+ expect(describeFetchFailure(new Error('ECONNREFUSED'))).toBe('ECONNREFUSED');
+ });
+
+ it('MEM-FETCH-003: looks past a silent or repeated cause to the first one that says something', () => {
+ const deep = new Error('fetch failed', {
+ cause: new Error('', { cause: new Error('fetch failed', { cause: new Error('getaddrinfo ENOTFOUND immich.lan') }) }),
+ });
+ expect(describeFetchFailure(deep)).toBe('fetch failed (getaddrinfo ENOTFOUND immich.lan)');
+ });
+
+ it('MEM-FETCH-004: a cause that is not an Error is ignored', () => {
+ expect(describeFetchFailure(new Error('fetch failed', { cause: 'DEPTH_ZERO_SELF_SIGNED_CERT' }))).toBe('fetch failed');
+ });
+
+ it('MEM-FETCH-005: a rejection that is not an Error falls back to the old wording', () => {
+ expect(describeFetchFailure('boom')).toBe('Connection failed');
+ });
+});
diff --git a/server/tests/unit/nest/memories.service.test.ts b/server/tests/unit/nest/memories.service.test.ts
index e02ab3c24..5523431e9 100644
--- a/server/tests/unit/nest/memories.service.test.ts
+++ b/server/tests/unit/nest/memories.service.test.ts
@@ -120,8 +120,8 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
svc.immichGetConnectionSettings(7);
expect(immich.getConnectionSettings).toHaveBeenCalledWith(7);
- await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4');
- expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4');
+ await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4', true);
+ expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4', true);
svc.immichSetAutoUpload(7, true);
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(7, true);
@@ -129,8 +129,8 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
await svc.immichGetConnectionStatus(7);
expect(immich.getConnectionStatus).toHaveBeenCalledWith(7);
- await svc.immichTestConnection('u', 'k');
- expect(immich.testConnection).toHaveBeenCalledWith('u', 'k');
+ await svc.immichTestConnection('u', 'k', true);
+ expect(immich.testConnection).toHaveBeenCalledWith('u', 'k', true);
await svc.immichBrowseTimeline(7);
expect(immich.browseTimeline).toHaveBeenCalledWith(7);
diff --git a/shared/src/memories/memories.schema.spec.ts b/shared/src/memories/memories.schema.spec.ts
index 97ba07ba9..4a69a1430 100644
--- a/shared/src/memories/memories.schema.spec.ts
+++ b/shared/src/memories/memories.schema.spec.ts
@@ -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);
diff --git a/shared/src/memories/memories.schema.ts b/shared/src/memories/memories.schema.ts
index 505a7c5d8..487e9add8 100644
--- a/shared/src/memories/memories.schema.ts
+++ b/shared/src/memories/memories.schema.ts
@@ -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({
diff --git a/wiki/Photo-Providers.md b/wiki/Photo-Providers.md
index 97a6d304a..6873c89cc 100644
--- a/wiki/Photo-Providers.md
+++ b/wiki/Photo-Providers.md
@@ -29,10 +29,20 @@ Go to **Settings → Integrations**. Each enabled provider gets its own settings
|-------|----------|-------|
| Server URL | Yes | Full URL of your Immich instance, e.g. `https://immich.example.com` |
| API Key | Yes | Stored encrypted; never returned to the browser after saving |
+| Skip SSL certificate verification | No | Checkbox; lets TREK connect to an Immich server with a self-signed certificate. Off by default |
| Mirror journey photos to Immich on upload | No | Checkbox; when enabled, photos you upload in TREK are also pushed to your Immich library |
Enter the full URL of your Immich instance and an Immich API key. The API key is stored encrypted on the TREK server and is never returned to the browser after it is saved.
+#### Self-signed certificates
+
+If your Immich server uses a self-signed certificate (for example behind a reverse proxy on your home network), the connection test fails with `fetch failed (self-signed certificate)`. Turn on **Skip SSL certificate verification**, enter the API key again and test or save.
+
+- The setting is per user and only applies to your own Immich connection. Disconnecting Immich turns it off again.
+- Only the certificate check is skipped. The connection is still encrypted, but TREK can no longer tell your server from an impostor on the same network, and your API key travels over that connection. Prefer a certificate from a trusted CA where you can.
+- Photos you share on a trip are loaded through your connection, so your setting applies when other trip members view them.
+- A server on a local or private address still needs internal network access on the TREK server. See [Internal-Network-Access](Internal-Network-Access).
+
#### Required API key permissions
When generating the API key in Immich (**Account Settings → API Keys**), grant only the scopes TREK actually uses: