mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -35,8 +35,10 @@ export class ImmichMemoriesController {
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
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')
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user