refactor(server): fold the memories domain into providers

The seven files in services/memories/ become injectable services. Every SQL
statement, error string, status code and header moves verbatim; what changes is
who owns what.

photoResolverService was two files in one, so it stayed two: the trek_photos
rows are a repository under photos/, the provider dispatch is
PhotoResolverService under memories/. That is the photos/ vs memories/ boundary —
storage there, dispatch here — and it is also why the module graph is a straight
line: TrekPhotosModule is a leaf both halves import, so neither needs a
forwardRef.

pipeAsset and the ServiceResult envelope stay free functions over the Express
response. pipeAsset is deliberately not a StreamableFile: it forwards the
upstream 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control
to no-store on an error status, and answers differently once headers are sent.

The trek-photo cache keeps its in-flight map at module scope. That map is the
stampede guard, and the scheduler builds its own instance through a lazy require
outside the container — a per-instance map would give them separate guards and
ten tiles would fetch the same asset ten times, silently.

journeyService's dynamic import() of synologyService is gone: it existed only to
duck the import cycle, which no longer exists.

MemoriesModule imports RealtimeModule explicitly even though it is @Global, so a
single-domain e2e TestingModule can still resolve the broadcast.

The providers arrived at 60% branch coverage or less, because services/memories/
sits outside the gate and the integration suites drive the domain over HTTP
without reaching the failure paths. New suites cover what had no case at all:
the access checks that decide who may fetch someone else's photo, the stampede
guard, the thumbnail mtime check, the poster-less-video 404, and Immich end to
end with safeFetch stubbed — including the hidden-asset filter older servers
depend on (#1474) and the http -> https canonical detection that must NOT
rewrite a changed host.
This commit is contained in:
Maurice
2026-08-08 22:16:50 +02:00
committed by Maurice
parent 5b6c79299f
commit c0d90d57b2
38 changed files with 3615 additions and 2272 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { Addon } from '../../types';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
import { getPhotoProviderConfig } from '../memories/memories.helpers';
/**
* Thin wrapper around the enabled-addons + photo-provider read that the legacy
+1 -1
View File
@@ -18,7 +18,7 @@ import type { User, Addon } from '../../types';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { avatarUrl } from '../common/avatarUrl';
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/llm-config';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
import { getPhotoProviderConfig } from '../memories/memories.helpers';
import { validatePassword } from '../common/passwordPolicy';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { NotificationPreferencesService } from '../notifications/notification-preferences.service';
+3 -1
View File
@@ -3,9 +3,11 @@ import { JourneyController } from './journey.controller';
import { JourneyPublicController } from './journey-public.controller';
import { JourneyService } from './journey.service';
import { AddonsModule } from '../addons/addons.module';
import { MemoriesModule } from '../memories/memories.module';
@Module({
imports: [AddonsModule],
// MemoriesModule: the journey gallery streams provider assets and uploads to Immich.
imports: [AddonsModule, MemoriesModule],
controllers: [JourneyController, JourneyPublicController],
providers: [JourneyService],
})
+13 -8
View File
@@ -2,8 +2,9 @@ import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import * as svc from '../../services/journeyService';
import * as share from '../../services/journeyShareService';
import { uploadToImmich, streamImmichAsset } from '../../services/memories/immichService';
import { streamPhoto } from '../../services/memories/photoResolverService';
import { ImmichService } from '../memories/immich.service';
import { SynologyService } from '../memories/synology.service';
import { PhotoResolverService } from '../memories/photo-resolver.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import type { Response } from 'express';
@@ -19,6 +20,9 @@ export class JourneyService {
constructor(
private readonly dbs: DatabaseService,
private readonly addons: AddonsService,
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly photoResolver: PhotoResolverService,
) {}
private get db() {
@@ -85,16 +89,17 @@ export class JourneyService {
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
return !!prefs?.immich_auto_upload;
}
uploadToImmich(userId: number, relativePath: string, originalName: string) { return uploadToImmich(userId, relativePath, originalName); }
uploadToImmich(userId: number, relativePath: string, originalName: string) { return this.immich.uploadToImmich(userId, relativePath, originalName); }
// Public (share-token) access — no auth, validated by token.
getPublicJourney(token: string) { return share.getPublicJourney(token); }
validateShareTokenForPhoto(token: string, photoId: number) { return share.validateShareTokenForPhoto(token, photoId); }
validateShareTokenForAsset(token: string, assetId: string) { return share.validateShareTokenForAsset(token, assetId); }
streamPhoto(res: Response, ownerId: number, photoId: number, kind: 'thumbnail' | 'original') { return streamPhoto(res, ownerId, photoId, kind); }
streamImmichAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { return streamImmichAsset(res, userId, assetId, kind, ownerId); }
async streamSynologyAsset(res: Response, userId: number, ownerId: number, assetId: string, kind: 'thumbnail' | 'original') {
const { streamSynologyAsset } = await import('../../services/memories/synologyService');
return streamSynologyAsset(res, userId, ownerId, assetId, kind);
streamPhoto(res: Response, ownerId: number, photoId: number, kind: 'thumbnail' | 'original') { return this.photoResolver.streamPhoto(res, ownerId, photoId, kind); }
streamImmichAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { return this.immich.streamImmichAsset(res, userId, assetId, kind, ownerId); }
// Was a dynamic import(): the memories cluster had a real import cycle and
// this was the workaround. The cycle is gone, so it is a plain injection now.
streamSynologyAsset(res: Response, userId: number, ownerId: number, assetId: string, kind: 'thumbnail' | 'original') {
return this.synology.streamSynologyAsset(res, userId, ownerId, assetId, kind);
}
}
+605
View File
@@ -0,0 +1,605 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import path from 'path';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
import { AuditService } from '../audit/audit.service';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import { pipeAsset, type Selection } from './memories.helpers';
import { UPLOADS_ROOT } from './uploads-root';
const ALBUM_PAGE_SIZE = 1000;
const ALBUM_MAX_PAGES = 20;
/**
* Immich photo provider: credentials, connection test, timeline/search browsing,
* album listing and the asset proxy.
*
* Folded 1:1 from services/memories/immichService.ts - every SQL statement,
* error string and status code is the one that shipped.
*/
@Injectable()
export class ImmichService {
constructor(
private readonly db: DatabaseService,
private readonly audit: AuditService,
private readonly access: MemoriesAccessService,
) {}
getImmichCredentials(userId: number) {
const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
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 };
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
isValidAssetId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
}
/**
* Hidden assets have no generated thumbnail — Immich's own enum documents
* `AssetVisibility.Hidden` as "Video part of the LivePhotos and MotionPhotos",
* and its UI never shows them standalone. Surfacing or persisting one yields a
* permanently broken tile, since nothing re-checks visibility on the render
* path (#1474).
*
* Load-bearing on Immich < 1.133, which predates the `visibility` field: those
* servers strip the `visibility: 'timeline'` search filter and never defaulted
* `isVisible`, so they return hidden assets and this is the only thing stopping
* them. They mark the same state as `isVisible: false`.
*/
private isVisibleAsset(a: any): boolean {
return a.visibility !== 'hidden' && a.isVisible !== false;
}
// ── Connection Settings ────────────────────────────────────────────────────
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;
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
auto_upload: !!(prefs?.immich_auto_upload),
};
}
setImmichAutoUpload(userId: number, enabled: boolean): void {
this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
async saveImmichSettings(
userId: number,
immichUrl: string | undefined,
immichApiKey: string | undefined,
clientIp: string | null
): Promise<{ success: boolean; warning?: string; error?: string }> {
if (immichUrl) {
const ssrf = await checkSsrf(immichUrl.trim());
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
);
if (ssrf.isPrivate) {
this.audit.writeAudit({
userId,
action: 'immich.private_ip_configured',
ip: clientIp,
details: { immich_url: immichUrl.trim(), resolved_ip: ssrf.resolvedIp },
});
return {
success: true,
warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`,
};
}
} else {
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
);
}
return { success: true };
}
// ── Connection Test / Status ───────────────────────────────────────────────
async testConnection(
immichUrl: string,
immichApiKey: string
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string }; canonicalUrl?: string }> {
const ssrf = await checkSsrf(immichUrl);
if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' };
try {
const resp = await safeFetch(`${immichUrl}/api/users/me`, {
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
});
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
// Detect http → https upgrade only: same host/port, protocol changed to https
let canonicalUrl: string | undefined;
if (resp.url) {
const finalUrl = new URL(resp.url);
const inputUrl = new URL(immichUrl);
if (
inputUrl.protocol === 'http:' &&
finalUrl.protocol === 'https:' &&
finalUrl.hostname === inputUrl.hostname &&
finalUrl.port === inputUrl.port
) {
canonicalUrl = finalUrl.origin;
}
}
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' };
}
}
async getConnectionStatus(
userId: number
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> {
const creds = this.getImmichCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
try {
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,
});
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' };
}
}
// ── Browse Timeline / Search ───────────────────────────────────────────────
async browseTimeline(
userId: number
): Promise<{ buckets?: any; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/timeline/buckets`, {
method: 'GET',
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status };
const buckets = await resp.json();
return { buckets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
async searchPhotos(
userId: number,
from?: string,
to?: string,
page: number = 1,
size: number = 50,
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
// No type filter — surface videos alongside images (#823).
// Immich 1.133–1.144 defaulted metadata search to `timeline` visibility;
// v3 defaults to any visibility except `locked`, which is what started
// surfacing Live Photo motion parts as broken tiles (#1474). Ask for
// `timeline` explicitly so hidden assets never cross the wire and a full
// page stays a full page of renderable tiles.
//
// `visibility` only exists from 1.133.0. Older servers strip it — Immich
// validates with `whitelist: true` and no `forbidNonWhitelisted`, so an
// unknown property is dropped, never a 400 — and they never defaulted
// `isVisible` either, so they still return hidden assets. this.isVisibleAsset()
// below is the only guard on those versions. Do not remove it.
visibility: 'timeline',
withExif: true,
size,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
// Belt-and-braces: `visibility: 'timeline'` above should mean Immich never
// sends a hidden asset, but an older server that ignores the filter would
// otherwise render broken tiles. hasMore stays on the raw page length so
// pagination still advances past a filtered page.
const assets = items
.filter((a: unknown) => this.isVisibleAsset(a))
.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null,
lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets, hasMore: items.length >= size };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
async getAssetInfo(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ data?: any; error?: string; status?: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
try {
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,
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const asset = await resp.json() as any;
return {
data: {
id: asset.id,
takenAt: asset.fileCreatedAt || asset.createdAt,
width: asset.exifInfo?.exifImageWidth || null,
height: asset.exifInfo?.exifImageHeight || null,
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
lens: asset.exifInfo?.lensModel || null,
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
shutter: asset.exifInfo?.exposureTime || null,
iso: asset.exifInfo?.iso || null,
city: asset.exifInfo?.city || null,
state: asset.exifInfo?.state || null,
country: asset.exifInfo?.country || null,
lat: asset.exifInfo?.latitude || null,
lng: asset.exifInfo?.longitude || null,
fileSize: asset.exifInfo?.fileSizeInByte || null,
fileName: asset.originalFileName || null,
},
};
} catch {
return { error: 'Proxy error', status: 502 };
}
}
async fetchImmichThumbnailBytes(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
try {
const resp = await safeFetch(url, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000) as any,
});
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());
return { bytes, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
async streamImmichAsset(
response: Response,
userId: number,
assetId: string,
kind: 'thumbnail' | 'original',
ownerUserId?: number,
opts?: { mediaType?: string | null; range?: string },
): Promise<{ error?: string; status?: number } | void> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const isVideo = opts?.mediaType === 'video';
const headers: Record<string, string> = { 'x-api-key': creds.immich_api_key };
let url: string;
let timeout: number | undefined;
let cacheControl = 'public, max-age=86400';
if (kind === 'thumbnail') {
// Immich generates a poster thumbnail for video too.
url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
timeout = 10000;
} else if (isVideo) {
// Transcoded, broadly-compatible MP4 with byte-range support; forward the
// viewer's Range so the player can seek. No abort timeout — video is a long
// streaming response, not a quick fetch (#823).
url = `${creds.immich_url}/api/assets/${assetId}/video/playback`;
if (opts?.range) headers['Range'] = opts.range;
cacheControl = 'private, max-age=3600';
timeout = undefined;
} else {
url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
timeout = 30000;
}
await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl);
}
// ── Albums ──────────────────────────────────────────────────────────────────
async listAlbums(
userId: number
): Promise<{ albums?: any[]; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
// Fetch both owned and shared albums
const [ownResp, sharedResp] = await Promise.all([
safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
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,
}),
]);
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
const ownAlbums = await ownResp.json() as any[];
const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : [];
const seenIds = new Set<string>();
const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => {
if (seenIds.has(a.id)) return false;
seenIds.add(a.id);
return true;
});
const albums = allAlbums.map((a: any) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount || 0,
startDate: a.startDate,
endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId,
shared: a.shared || a.sharedUsers?.length > 0,
}));
return { albums };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
/** Immich caps `size` at 1000 for metadata search; a page cap bounds pathological albums. */
/**
* Fetch every asset in an album, across Immich v2 and v3.
*
* Immich v3 removed `assets` from `AlbumResponseDto`, so `GET /api/albums/:id`
* no longer carries album contents (#1492) and we fall back to an
* `albumIds`-filtered metadata search.
*
* We feature-detect on the presence of `assets` rather than switching on a
* probed server version. The two paths are NOT interchangeable:
*
* - Before v3, `searchMetadata` unconditionally scopes results to
* `[self, ...partners]` (`asset.ownerId = ANY(userIds)`), so an albumIds
* search against an album shared by a non-partner returns nothing. v3 added
* an albumIds branch that checks `AlbumRead` and skips that owner filter.
* - 1.133–1.144 also default `visibility` to `timeline`, dropping archived
* album assets.
* - `albumIds` only exists from 1.135.0, and Immich strips unknown properties
* rather than rejecting them (`whitelist: true`, no `forbidNonWhitelisted`).
* Searching an older server would therefore silently drop the album filter
* and return the user's ENTIRE library as the album's contents.
*
* Feature detection makes all three moot: `assets` is present on every version
* that has any of those problems (through 1.144.1) and absent only on v3, where
* 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 },
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,
});
if (!resp.ok) return { status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
if (Array.isArray(albumData.assets)) return { assets: albumData.assets };
return this.fetchAlbumAssetsViaSearch(creds, albumId);
}
/**
* Immich v3 album contents, via `POST /api/search/metadata` filtered by `albumIds`.
*
* `withExif` is required: it has no default and gates an inner join
* (`searchAssetBuilder`: `.$if(!!options.withExif, withExifInner)`), so without
* it Immich omits `exifInfo` entirely and every photo's city/country goes null.
*/
private async fetchAlbumAssetsViaSearch(
creds: { immich_url: string; immich_api_key: string },
albumId: string,
): Promise<{ assets?: any[]; status?: number }> {
const all: any[] = [];
for (let page = 1; page <= ALBUM_MAX_PAGES; page++) {
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
albumIds: [albumId],
withExif: true,
withDeleted: false,
size: ALBUM_PAGE_SIZE,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
all.push(...items);
if (items.length < ALBUM_PAGE_SIZE) break;
}
return { assets: all };
}
async getAlbumPhotos(
userId: number,
albumId: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const result = await this.fetchAlbumAssets(creds, albumId);
if (!result.assets) return { error: 'Failed to fetch album', status: result.status };
// Albums legitimately contain hidden assets on both Immich versions (the v2
// album body and the v3 album search both return them), so filter here (#1474).
const assets = result.assets
.filter((a: unknown) => this.isVisibleAsset(a))
.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
/**
* The Immich half of an album sync: resolve the link, fetch the album, and hand
* back the picked asset ids.
*
* Adding them to the trip stays with the unified service, which owns
* addTripPhotos. Splitting it there is what breaks the import cycle this module
* used to close (immich -> unified -> photoResolver -> immich): the provider now
* only ever points downhill. Every error string and status below is the one the
* combined function returned.
*/
async collectAlbumSelection(
tripId: string,
linkId: string,
userId: number,
): Promise<{ selection: Selection; total: number } | { error: string; status: number }> {
const response = this.access.getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const albumResult = await this.fetchAlbumAssets(creds, response.data as string);
if (!albumResult.assets) return { error: 'Failed to fetch album', status: albumResult.status };
// Hidden assets must never be persisted: the render path never re-checks
// visibility, so a stored hidden asset is a permanently broken tile (#1474).
const assets = albumResult.assets.filter((a: any) => a.type === 'IMAGE' && this.isVisibleAsset(a));
return {
selection: { provider: 'immich', asset_ids: assets.map((a: any) => a.id) },
total: assets.length,
};
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
// ── Upload to Immich ──────────────────────────────────────────────────────
async uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
const creds = this.getImmichCredentials(userId);
if (!creds) return null;
const fs = await import('node:fs');
const path = await import('node:path');
const fullPath = path.join(UPLOADS_ROOT, filePath);
if (!fs.existsSync(fullPath)) return null;
try {
const fileBuffer = fs.readFileSync(fullPath);
const boundary = '----ImmichUpload' + Date.now();
const ext = path.extname(fileName).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
const now = new Date().toISOString();
const parts: Buffer[] = [];
const addField = (name: string, value: string) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
};
addField('deviceAssetId', `trek-${Date.now()}`);
addField('deviceId', 'TREK');
addField('fileCreatedAt', now);
addField('fileModifiedAt', now);
parts.push(Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`
));
parts.push(fileBuffer);
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'POST',
headers: {
'x-api-key': creds.immich_api_key,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
});
if (res.ok) {
const data = await res.json() as { id?: string };
return data.id || null;
}
return null;
} catch {
return null;
}
}
}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../database/database.service';
import { fail, success, type ServiceResult } from './memories.helpers';
/**
* Who may see which photo, and the album-link lookups the provider syncs need.
*
* Split from the pure helpers because these are the only DB-backed ones. Both
* access checks answer for photos that live under a trip *or* a journey — a
* provider asset is reachable through either, which is why neither check can
* live in the trips or journey domain alone.
*/
@Injectable()
export class MemoriesAccessService {
constructor(private readonly db: DatabaseService) {}
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
if (requestingUserId === ownerUserId) {
return true;
}
// Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') {
const journeyPhoto = this.db.get<{ journey_id: number }>(`
SELECT gp.journey_id
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ?
AND tkp.provider = ?
AND tkp.owner_id = ?
LIMIT 1
`, assetId, provider, ownerUserId);
if (!journeyPhoto) return false;
const access = this.db.get(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
LIMIT 1
`, journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
return !!access;
}
// Regular trip photos — join through trek_photos
const sharedAsset = this.db.get(`
SELECT 1
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.user_id = ?
AND tkp.asset_id = ?
AND tkp.provider = ?
AND tp.trip_id = ?
AND tp.shared = 1
LIMIT 1
`, ownerUserId, assetId, provider, tripId);
if (!sharedAsset) {
return false;
}
return !!this.db.canAccessTrip(tripId, requestingUserId);
}
// ── Unified photo access check (trek_photos based) ──────────────────────
canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId);
if (!photo) return false;
// Owner always has access
if (photo.owner_id === requestingUserId) return true;
// Check trip_photos — is this photo shared in a trip the user has access to?
const tripAccess = this.db.get(`
SELECT 1 FROM trip_photos tp
WHERE tp.photo_id = ?
AND tp.shared = 1
AND EXISTS (
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
UNION ALL
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
)
LIMIT 1
`, trekPhotoId, requestingUserId, requestingUserId);
if (tripAccess) return true;
// Check journey_photos — is this photo in a journey the user can access?
const journeyAccess = this.db.get(`
SELECT 1 FROM journey_photos gp
WHERE gp.photo_id = ?
AND EXISTS (
SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
)
LIMIT 1
`, trekPhotoId, requestingUserId, requestingUserId);
if (journeyAccess) return true;
// Local photos without owner (uploaded files) — check if user has journey access
if (photo.provider === 'local' && !photo.owner_id) {
return !!journeyAccess;
}
return false;
}
// ── Album link syncing ──────────────────────────────────────────────────
getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<string> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = this.db.get<{ album_id: string }>(
'SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?',
linkId, tripId, userId,
);
return row ? success(row.album_id) : fail('Album link not found', 404);
} catch {
return fail('Failed to retrieve album link', 500);
}
}
getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = this.db.get<{ album_id: string; passphrase: string | null }>(
'SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?',
linkId, tripId, userId,
);
if (!row) return fail('Album link not found', 404);
const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined;
return success({ albumId: row.album_id, passphrase: decrypted || undefined });
} catch {
return fail('Failed to retrieve album link', 500);
}
}
updateSyncTimeForAlbumLink(linkId: string): void {
this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId);
}
}
@@ -159,3 +159,27 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
}
}
// ── Route shape for the settings page ─────────────────────────────────────
/**
* Where the client finds a provider's settings/status/test endpoints. Pure
* string building, so it stays here rather than on a service — the admin and
* addons surfaces both read it and neither should have to import the memories
* domain to do so.
*/
export interface PhotoProviderConfig {
settings_get: string;
settings_put: string;
status_get: string;
test_post: string;
}
export function getPhotoProviderConfig(providerId: string): PhotoProviderConfig {
const prefix = `/integrations/memories/${providerId}`;
return {
settings_get: `${prefix}/settings`,
settings_put: `${prefix}/settings`,
status_get: `${prefix}/status`,
test_post: `${prefix}/test`,
};
}
+34 -5
View File
@@ -1,19 +1,48 @@
import { Module } from '@nestjs/common';
import { MemoriesService } from './memories.service';
import { MemoriesAccessService } from './memories-access.service';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { UnifiedMemoriesService } from './unified-memories.service';
import { PhotoResolverService } from './photo-resolver.service';
import { ThumbnailService } from './thumbnail.service';
import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { UnifiedMemoriesController } from './unified.controller';
import { ImmichMemoriesController } from './immich.controller';
import { SynologyMemoriesController } from './synology.controller';
import { AddonsModule } from '../addons/addons.module';
import { AuditModule } from '../audit/audit.module';
import { TrekPhotosModule } from '../photos/trek-photos.module';
import { RealtimeModule } from '../realtime/realtime.module';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
*
* Ports the legacy Express router (routes/memories/unified.ts, which composes
* immich.ts + synology.ts) to Nest, reusing services/memories/* unchanged. No
* module-level addon gate — enablement is per-provider-row inside the services,
* exactly as the legacy mount had it.
* No module-level addon gate: enablement is per-provider-row inside the
* services, exactly as the legacy mount had it. TrekPhotosModule supplies the
* trek_photos repository — storage lives there, provider dispatch here.
*
* RealtimeModule is imported explicitly even though it is @Global: an e2e
* TestingModule built around one domain does not get a global AppModule never
* loaded, and MemoriesService broadcasts.
*
* PhotoResolverService and MemoriesAccessService are exported because the
* /api/photos surface and the journey domain resolve provider assets through
* them.
*/
@Module({
imports: [AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule],
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [MemoriesService],
providers: [
MemoriesService,
MemoriesAccessService,
ImmichService,
SynologyService,
UnifiedMemoriesService,
PhotoResolverService,
ThumbnailService,
TrekPhotoCacheService,
],
exports: [MemoriesAccessService, PhotoResolverService, ImmichService, SynologyService],
})
export class MemoriesModule {}
+46 -74
View File
@@ -1,60 +1,32 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import {
listTripPhotos,
listTripAlbumLinks,
createTripAlbumLink,
removeAlbumLink,
addTripPhotos,
syncImmichAlbum,
syncSynologyAlbum,
removeTripPhoto,
setTripPhotoSharing,
} from '../../services/memories/unifiedService';
import {
getConnectionSettings,
saveImmichSettings,
setImmichAutoUpload,
testConnection,
getConnectionStatus,
browseTimeline,
searchPhotos,
streamImmichAsset,
listAlbums,
getAlbumPhotos,
getAssetInfo,
isValidAssetId,
} from '../../services/memories/immichService';
import {
getSynologySettings,
updateSynologySettings,
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
searchSynologyPhotos,
getSynologyAssetInfo,
streamSynologyAsset,
} from '../../services/memories/synologyService';
import { canAccessUserPhoto } from '../../services/memories/helpersService';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { UnifiedMemoriesService } from './unified-memories.service';
import { MemoriesAccessService } from './memories-access.service';
import type { Selection } from './memories.helpers';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
/**
* Thin Nest wrapper around the existing memories (photo-providers) services.
* Every method delegates to the legacy `services/memories/*` code unchanged so
* the provider logic, the per-provider access checks and the streaming helpers
* behave byte-identically to the legacy Express routers. No new business logic
* lives here.
* Controller-facing facade over the memories domain. Every method forwards to
* the provider that owns the work — UnifiedMemoriesService for the trip-side
* photo surface, ImmichService and SynologyService for the providers,
* MemoriesAccessService for the shared access checks. No business logic here.
*/
@Injectable()
export class MemoriesService {
constructor(private readonly realtime: RealtimeService) {}
constructor(
private readonly realtime: RealtimeService,
private readonly unified: UnifiedMemoriesService,
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly access: MemoriesAccessService,
) {}
// ── Access check (reused by both provider asset routes) ──────────────────
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
return this.access.canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
}
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId?: string): void {
@@ -63,124 +35,124 @@ export class MemoriesService {
// ── Unified ──────────────────────────────────────────────────────────────
listTripPhotos(tripId: string, userId: number) {
return listTripPhotos(tripId, userId);
return this.unified.listTripPhotos(tripId, userId);
}
addTripPhotos(tripId: string, userId: number, shared: boolean, selections: Selection[], sid: string) {
return addTripPhotos(tripId, userId, shared, selections, sid);
return this.unified.addTripPhotos(tripId, userId, shared, selections, sid);
}
setTripPhotoSharing(tripId: string, userId: number, photoId: number, shared: boolean) {
return setTripPhotoSharing(tripId, userId, photoId, shared);
return this.unified.setTripPhotoSharing(tripId, userId, photoId, shared);
}
removeTripPhoto(tripId: string, userId: number, photoId: number) {
return removeTripPhoto(tripId, userId, photoId);
return this.unified.removeTripPhoto(tripId, userId, photoId);
}
listTripAlbumLinks(tripId: string, userId: number) {
return listTripAlbumLinks(tripId, userId);
return this.unified.listTripAlbumLinks(tripId, userId);
}
createTripAlbumLink(tripId: string, userId: number, provider: unknown, albumId: unknown, albumName: unknown, passphrase?: string) {
return createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase);
return this.unified.createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase);
}
removeAlbumLink(tripId: string, linkId: string, userId: number) {
return removeAlbumLink(tripId, linkId, userId);
return this.unified.removeAlbumLink(tripId, linkId, userId);
}
// ── Immich ─────────────────────────────────────────────────────────────────
immichGetConnectionSettings(userId: number) {
return getConnectionSettings(userId);
return this.immich.getConnectionSettings(userId);
}
immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) {
return saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
}
immichSetAutoUpload(userId: number, enabled: boolean): void {
setImmichAutoUpload(userId, enabled);
this.immich.setImmichAutoUpload(userId, enabled);
}
immichGetConnectionStatus(userId: number) {
return getConnectionStatus(userId);
return this.immich.getConnectionStatus(userId);
}
immichTestConnection(immichUrl: string, immichApiKey: string) {
return testConnection(immichUrl, immichApiKey);
return this.immich.testConnection(immichUrl, immichApiKey);
}
immichBrowseTimeline(userId: number) {
return browseTimeline(userId);
return this.immich.browseTimeline(userId);
}
immichSearchPhotos(userId: number, from: string | undefined, to: string | undefined, page: number, size: number) {
return searchPhotos(userId, from, to, page, size);
return this.immich.searchPhotos(userId, from, to, page, size);
}
immichIsValidAssetId(assetId: string): boolean {
return isValidAssetId(assetId);
return this.immich.isValidAssetId(assetId);
}
immichGetAssetInfo(userId: number, assetId: string, ownerId: number) {
return getAssetInfo(userId, assetId, ownerId);
return this.immich.getAssetInfo(userId, assetId, ownerId);
}
immichStreamAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) {
return streamImmichAsset(res, userId, assetId, kind, ownerId);
return this.immich.streamImmichAsset(res, userId, assetId, kind, ownerId);
}
immichListAlbums(userId: number) {
return listAlbums(userId);
return this.immich.listAlbums(userId);
}
immichGetAlbumPhotos(userId: number, albumId: string) {
return getAlbumPhotos(userId, albumId);
return this.immich.getAlbumPhotos(userId, albumId);
}
immichSyncAlbumAssets(tripId: string, linkId: string, userId: number, sid: string) {
return syncImmichAlbum(tripId, linkId, userId, sid);
return this.unified.syncImmichAlbum(tripId, linkId, userId, sid);
}
// ── Synology ────────────────────────────────────────────────────────────────
synologyGetSettings(userId: number) {
return getSynologySettings(userId);
return this.synology.getSynologySettings(userId);
}
synologyUpdateSettings(userId: number, url: string, username: string, password: string, skipSsl: boolean) {
return updateSynologySettings(userId, url, username, password, skipSsl);
return this.synology.updateSynologySettings(userId, url, username, password, skipSsl);
}
synologyGetStatus(userId: number) {
return getSynologyStatus(userId);
return this.synology.getSynologyStatus(userId);
}
synologyTestConnection(userId: number, url: string, username: string, password: string, otp: string, skipSsl: boolean) {
return testSynologyConnection(userId, url, username, password, otp, skipSsl);
return this.synology.testSynologyConnection(userId, url, username, password, otp, skipSsl);
}
synologyListAlbums(userId: number) {
return listSynologyAlbums(userId);
return this.synology.listSynologyAlbums(userId);
}
synologyGetAlbumPhotos(userId: number, albumId: string, passphrase?: string) {
return getSynologyAlbumPhotos(userId, albumId, passphrase);
return this.synology.getSynologyAlbumPhotos(userId, albumId, passphrase);
}
synologySyncAlbumLink(userId: number, tripId: string, linkId: string, sid: string) {
return syncSynologyAlbum(userId, tripId, linkId, sid);
return this.unified.syncSynologyAlbum(userId, tripId, linkId, sid);
}
synologySearchPhotos(userId: number, from: string | undefined, to: string | undefined, offset: number, limit: number) {
return searchSynologyPhotos(userId, from, to, offset, limit);
return this.synology.searchSynologyPhotos(userId, from, to, offset, limit);
}
synologyGetAssetInfo(userId: number, photoId: string, ownerId: number, passphrase?: string) {
return getSynologyAssetInfo(userId, photoId, ownerId, passphrase);
return this.synology.getSynologyAssetInfo(userId, photoId, ownerId, passphrase);
}
synologyStreamAsset(res: Response, userId: number, ownerId: number, photoId: string, kind: 'thumbnail' | 'original', size: string, passphrase?: string) {
return streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase);
return this.synology.streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase);
}
}
@@ -0,0 +1,186 @@
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import path from 'path';
import fs from 'fs';
import type { TrekPhoto } from '../../types';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { UPLOADS_ROOT } from './uploads-root';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { ThumbnailService } from './thumbnail.service';
import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { fail, success, type AssetInfo, type ServiceResult } from './memories.helpers';
/**
* Resolves a stored trek_photo to bytes or metadata by asking whichever provider
* owns it. The storage half of the old photoResolverService lives in
* nest/photos/trek-photos.repository.ts; this is the dispatch half.
*/
@Injectable()
export class PhotoResolverService {
constructor(
private readonly photos: TrekPhotosRepository,
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly thumbnails: ThumbnailService,
private readonly cache: TrekPhotoCacheService,
) {}
// ── Streaming ────────────────────────────────────────────────────────────
private async streamCachedThumbnail(
res: Response,
photo: TrekPhoto,
fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>,
fallback: () => Promise<unknown>,
): Promise<void> {
const key = this.cache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!);
if (this.cache.serveFresh(res, key)) return;
const existing = this.cache.getInFlight(key);
if (existing) {
const bytes = await existing;
if (bytes && this.cache.serveFresh(res, key)) return;
await fallback();
return;
}
const promise = fetchBytes().then(async result => {
if ('error' in result) return null;
await this.cache.put(key, result.bytes, result.contentType);
return result.bytes;
});
this.cache.setInFlight(key, promise);
const bytes = await promise;
if (bytes && this.cache.serveFresh(res, key)) return;
await fallback();
}
async streamPhoto(
res: Response,
userId: number,
photoId: number,
kind: 'thumbnail' | 'original',
range?: string,
): Promise<void> {
const photo = this.photos.resolve(photoId);
if (!photo) {
res.status(404).json({ error: 'Photo not found' });
return;
}
if (photo.file_path) {
const uploadsRoot = UPLOADS_ROOT;
if (kind === 'thumbnail') {
const isVideo = photo.media_type === 'video';
let thumbRel = photo.thumbnail_path ?? null;
// Only raster images get a lazily-generated Jimp thumbnail; Jimp can't decode
// video, so a video relies on the poster captured at upload (#823).
if (!thumbRel && !isVideo) {
const result = await this.thumbnails.ensureLocalThumbnail(uploadsRoot, photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height);
}
}
if (thumbRel) {
const thumbAbs = path.join(uploadsRoot, thumbRel);
if (fs.existsSync(thumbAbs)) {
res.set('Cache-Control', 'public, max-age=86400, immutable');
res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(thumbAbs);
return;
}
}
// A poster-less video must NOT fall through to streaming the whole file as a
// "thumbnail"; let the client render its own placeholder instead.
if (isVideo) {
res.status(404).json({ error: 'No poster available' });
return;
}
// Images fall through to original if the thumbnail is unavailable.
}
const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(localPath);
return;
}
}
switch (photo.provider) {
case 'local': {
res.status(404).json({ error: 'File not found' });
return;
}
case 'immich': {
if (kind === 'thumbnail') {
await this.streamCachedThumbnail(
res, photo,
() => this.immich.fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!),
() => this.immich.streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type }),
);
return;
}
await this.immich.streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type, range });
return;
}
case 'synologyphotos': {
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
if (kind === 'thumbnail') {
await this.streamCachedThumbnail(
res, photo,
() => this.synology.fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase),
() => this.synology.streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase),
);
return;
}
await this.synology.streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
return;
}
default:
res.status(400).json({ error: `Unknown provider: ${photo.provider}` });
}
}
// ── Asset Info ────────────────────────────────────────────────────────────
async getPhotoInfo(
userId: number,
photoId: number,
): Promise<ServiceResult<AssetInfo>> {
const photo = this.photos.resolve(photoId);
if (!photo) return fail('Photo not found', 404);
switch (photo.provider) {
case 'local': {
return success({
id: String(photo.id),
takenAt: photo.created_at,
city: null,
country: null,
width: photo.width,
height: photo.height,
fileName: photo.file_path?.split('/').pop() || null,
} as AssetInfo);
}
case 'immich': {
const result = await this.immich.getAssetInfo(userId, photo.asset_id!, photo.owner_id!);
if (result.error) return fail(result.error, result.status || 500);
return success(result.data as AssetInfo);
}
case 'synologyphotos': {
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
return this.synology.getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase);
}
default:
return fail(`Unknown provider: ${photo.provider}`, 400);
}
}
}
@@ -0,0 +1,719 @@
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
import { send as sendNotification } from '../notifications/notifications.bridge';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import {
fail,
handleServiceResult,
success,
pipeAsset,
type AlbumsList,
type AssetInfo,
type AssetsList,
type Selection,
type ServiceResult,
type StatusResult,
} from './memories.helpers';
const SYNOLOGY_PROVIDER = 'synologyphotos';
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
// The API endpoint is always at {base_url}/webapi/entry.cgi.
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
const SYNOLOGY_DEVICE_NAME = 'trek';
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
101: 'Missing API, method, or version parameter.',
102: 'Requested API does not exist.',
103: 'Requested method does not exist.',
104: 'Requested API version is not supported.',
105: 'Insufficient privilege.',
106: 'Connection timeout.',
107: 'Multiple logins blocked from this IP.',
117: 'Manager privilege required.',
119: 'Session is invalid or expired.',
400: 'Invalid credentials.',
401: 'Session expired or account disabled.',
402: 'No permission to use this account.',
403: 'Two-factor authentication code required.',
404: 'Two-factor authentication failed.',
406: 'Two-factor authentication is enforced for this account.',
407: 'Maximum login attempts reached.',
408: 'Password expired.',
409: 'Remote password expired.',
410: 'Password must be changed before login.',
412: 'Guest account cannot log in.',
413: 'OTP system files are corrupted.',
414: 'Unable to log in.',
416: 'Unable to log in.',
417: 'OTP system is full.',
498: 'System is upgrading.',
499: 'System is not ready.',
};
interface SynologyUserRecord {
synology_url?: string | null;
synology_username?: string | null;
synology_password?: string | null;
synology_sid?: string | null;
synology_did?: string | null;
synology_skip_ssl?: number | null;
};
interface SynologyCredentials {
synology_url: string;
synology_username: string;
synology_password: string;
synology_skip_ssl: boolean;
}
interface SynologySettings {
synology_url: string;
synology_username: string;
synology_skip_ssl: boolean;
connected: boolean;
}
interface ApiCallParams {
api: string;
method: string;
version?: number;
[key: string]: unknown;
}
interface SynologyApiResponse<T> {
success: boolean;
data?: T;
error?: { code: number };
}
interface SynologyPhotoItem {
id?: string | number;
filename?: string;
filesize?: number;
time?: number;
item_count?: number;
name?: string;
additional?: {
thumbnail?: { cache_key?: string };
address?: { city?: string; country?: string; state?: string };
resolution?: { width?: number; height?: number };
exif?: {
camera?: string;
lens?: string;
focal_length?: string | number;
aperture?: string | number;
exposure_time?: string | number;
iso?: string | number;
};
gps?: { latitude?: number; longitude?: number };
orientation?: number;
description?: string;
};
}
/**
* Synology Photos provider: credentials and the SID session, album listing
* across personal/shared/shared-with-me sources, search, asset info and the
* asset proxy.
*
* Folded 1:1 from services/memories/synologyService.ts - every SQL statement,
* error code mapping and status is the one that shipped.
*/
@Injectable()
export class SynologyService {
constructor(
private readonly db: DatabaseService,
private readonly access: MemoriesAccessService,
) {}
private _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
try {
const row = this.db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
if (!row) {
return fail('User not found', 404);
}
const filtered: SynologyUserRecord = {};
for (const column of columns) {
filtered[column] = row[column];
}
return success(filtered);
} catch {
return fail('Failed to read Synology user data', 500);
}
}
private _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
const password = decrypt_api_key(user.data.synology_password);
if (!password) return fail('Synology credentials corrupted', 500);
return success({
synology_url: user.data.synology_url,
synology_username: user.data.synology_username,
synology_password: password,
synology_skip_ssl: user.data.synology_skip_ssl !== 0,
});
}
private _buildSynologyEndpoint(url: string, params: string): string {
const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
return `${normalized}${SYNOLOGY_ENDPOINT_PATH}?${params}`;
}
private _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
}
return body;
}
private async _fetchSynologyJson<T>(url: string, body: URLSearchParams, skipSsl = true): Promise<ServiceResult<T>> {
const endpoint = this._buildSynologyEndpoint(url, `api=${body.get('api')}`);
try {
const resp = await safeFetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body,
signal: AbortSignal.timeout(30000) as any,
}, { rejectUnauthorized: !skipSsl });
if (!resp.ok) {
return fail('Synology API request failed with status ' + resp.status, resp.status);
}
const response = await resp.json() as SynologyApiResponse<T>;
if (!response.success) {
const code = response.error.code;
const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')';
// Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi.
// All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes.
const httpStatus = [106, 107, 119].includes(code) ? code : 400;
return fail(message, httpStatus);
}
return success(response.data);
} catch (error) {
if (error instanceof SsrfBlockedError) {
return fail(error.message, 400);
}
return fail('Failed to connect to Synology API', 500);
}
}
private async _loginToSynology(
url: string,
username: string,
password: string,
opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {},
): Promise<ServiceResult<{ sid: string; did?: string }>> {
const { otp, deviceId, skipSsl = false } = opts;
const body = new URLSearchParams({
api: 'SYNO.API.Auth',
method: 'login',
version: '6',
account: username,
passwd: password,
format: 'sid',
client: 'browser',
device_name: SYNOLOGY_DEVICE_NAME,
});
if (otp && otp.trim()) {
body.append('otp_code', otp.trim());
body.append('enable_device_token', 'yes');
}
if (deviceId) {
body.append('device_id', deviceId);
}
const result = await this._fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl);
if (!result.success) {
return result as ServiceResult<{ sid: string; did?: string }>;
}
if (!result.data.sid) {
return fail('Failed to get session ID from Synology', 500);
}
return success({ sid: result.data.sid, did: result.data.did });
}
private async _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
const creds = this._getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<T>;
}
const session = await this._getSynologySession(userId);
if (!session.success || !session.data) {
return session as ServiceResult<T>;
}
const skipSsl = creds.data.synology_skip_ssl;
const body = this._buildSynologyFormBody({ ...params, _sid: session.data });
const result = await this._fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
this._clearSynologySID(userId);
const retrySession = await this._getSynologySession(userId);
if (!retrySession.success || !retrySession.data) {
return retrySession as ServiceResult<T>;
}
return this._fetchSynologyJson<T>(creds.data.synology_url, this._buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl);
}
return result;
}
private _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo {
const address = item.additional?.address || {};
const exif = item.additional?.exif || {};
const gps = item.additional?.gps || {};
return {
id: String(item.additional?.thumbnail?.cache_key || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
city: address.city || null,
country: address.country || null,
state: address.state || null,
camera: exif.camera || null,
lens: exif.lens || null,
focalLength: exif.focal_length || null,
aperture: exif.aperture || null,
shutter: exif.exposure_time || null,
iso: exif.iso || null,
lat: typeof gps.latitude === 'number' ? gps.latitude : null,
lng: typeof gps.longitude === 'number' ? gps.longitude : null,
orientation: item.additional?.orientation || null,
description: item.additional?.description || null,
width: item.additional?.resolution?.width || null,
height: item.additional?.resolution?.height || null,
fileSize: item.filesize || null,
fileName: item.filename || null,
};
}
private _clearSynologySID(userId: number): void {
this.db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
}
private _clearSynologySession(userId: number): void {
this.db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
}
private _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
// The first segment must be a non-empty integer (the unit ID used for API calls).
if (!/^\d+_.+$/.test(rawId)) return null;
const id = rawId.split('_')[0];
return { id, cacheKey: rawId, assetId: rawId };
}
private async _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cached = this._readSynologyUser(userId, ['synology_sid', 'synology_did']);
if (cached.success && cached.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
this._clearSynologySID(userId);
}
const creds = this._getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<string>;
}
// Use stored device ID to skip OTP on re-login (trusted device flow)
const storedDid = cached.success && cached.data?.synology_did
? (decrypt_api_key(cached.data.synology_did) || undefined)
: undefined;
const resp = await this._loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, {
deviceId: storedDid,
skipSsl: creds.data.synology_skip_ssl,
});
if (!resp.success) {
return resp as ServiceResult<string>;
}
this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
return success(resp.data.sid);
}
async getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
const creds = this._getSynologyCredentials(userId);
if (!creds.success) return creds as ServiceResult<SynologySettings>;
const session = await this._getSynologySession(userId);
return success({
synology_url: creds.data.synology_url || '',
synology_username: creds.data.synology_username || '',
synology_skip_ssl: creds.data.synology_skip_ssl,
connected: session.success,
});
}
async updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise<ServiceResult<string>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const result = this._readSynologyUser(userId, ['synology_password'])
if (!result.success) return result as ServiceResult<string>;
const existingEncryptedPassword = result.data?.synology_password || null;
if (!synologyPassword && !existingEncryptedPassword) {
return fail('No stored password found. Please provide a password to save settings.', 400);
}
// Only invalidate the session when the account itself changes (different URL or username).
// If the user just tested the connection, testSynologyConnection already stored a fresh
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
const existing = this._readSynologyUser(userId, ['synology_url', 'synology_username']);
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
const sessionCleared = urlChanged || userChanged;
if (sessionCleared) {
this._clearSynologySession(userId);
// Fire-and-forget like unifiedService's photos_shared send — without the
// .catch an in-app insert failure became an unhandled rejection.
sendNotification({
event: 'synology_session_cleared',
actorId: null,
params: {},
scope: 'user',
targetId: userId,
}).catch(() => {});
}
try {
this.db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
synologyUrl,
synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
synologySkipSsl ? 1 : 0,
userId,
);
} catch {
return fail('Failed to update Synology settings', 500);
}
return success('settings updated');
}
async getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
const sid = await this._getSynologySession(userId);
if ('error' in sid) return success({ connected: false, error: sid.error.message });
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
try {
const user = this.db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } });
} catch (err: unknown) {
return success({ connected: true, user: { name: 'unknown user' } });
}
}
async testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise<ServiceResult<StatusResult>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const resp = await this._loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl });
if ('error' in resp) {
return success({ connected: false, error: resp.error.message });
}
// Persist the session so the OTP code is not required again on save.
// The did (device token) allows future re-logins without OTP.
this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
if (resp.data.did) {
this.db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
}
return success({ connected: true, user: { name: synologyUsername } });
}
private async _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise<ServiceResult<any[]>> {
const pageSize = 100;
const all: any[] = [];
let offset = 0;
while (true) {
const result = await this._requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize });
if (!result.success) return result as ServiceResult<any[]>;
const items = result.data.list || [];
all.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
return success(all);
}
async listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
const [personal, shared, sharedWithMe] = await Promise.allSettled([
this._fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }),
this._fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }),
this._fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }),
]);
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
const addAlbums = (result: PromiseSettledResult<ServiceResult<any[]>>, extractPassphrase: (a: any) => string | undefined) => {
if (result.status === 'rejected') return;
const value = result.value;
if ('error' in value) {
console.warn('[Synology] album list partial failure:', value.error.message);
return;
}
for (const album of value.data ?? []) {
const id = String(album.id);
const passphrase = extractPassphrase(album);
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
}
};
addAlbums(personal, () => undefined);
addAlbums(shared, (a) => a.passphrase || undefined);
addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined);
if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) {
return personal.value as ServiceResult<AlbumsList>;
}
const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName));
return success({ albums });
}
async getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
const allItems: SynologyPhotoItem[] = [];
const pageSize = 50;
let offset = 0;
while (true) {
const params: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
}
/**
* The Synology half of an album sync — see collectAlbumSelection in
* immichService for why the trip-side write lives in the unified service now.
*/
async collectSynologyAlbumSelection(userId: number, tripId: string, linkId: string): Promise<ServiceResult<{ selection: Selection; total: number }>> {
const response = this.access.getAlbumLinkForSync(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<{ selection: Selection; total: number }>;
const { albumId, passphrase } = response.data;
const allItems: SynologyPhotoItem[] = [];
const pageSize = 50;
let offset = 0;
while (true) {
const itemParams: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
if (!result.success) return result as ServiceResult<{ selection: Selection; total: number }>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const selection: Selection = {
provider: SYNOLOGY_PROVIDER,
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
passphrase,
};
return success({ selection, total: allItems.length });
}
async searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<ServiceResult<AssetsList>> {
const params: ApiCallParams = {
api: 'SYNO.Foto.Search.Search',
method: 'list_item',
version: 1,
offset,
limit,
keyword: '.',
additional: ['thumbnail', 'address', 'gps'],
};
if (from || to) {
if (from) {
params.start_time = Math.floor(new Date(from).getTime() / 1000);
}
if (to) {
params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
}
}
// SYNO.Foto.Search.Search list_item does not return a total count — only data.list.
// hasMore is inferred: if we got a full page, there may be more.
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult<AssetsList>;
const allItems = result.data.list || [];
const assets = allItems.map(item => this._normalizeSynologyPhotoInfo(item));
return success({
assets,
total: allItems.length,
hasMore: allItems.length === limit,
});
}
async getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise<ServiceResult<AssetInfo>> {
const parsedId = this._splitPackedSynologyId(photoId);
if (!parsedId) return fail('Invalid photo ID format', 400);
const infoParams: ApiCallParams = {
api: 'SYNO.Foto.Browse.Item',
method: 'get',
version: 5,
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
};
if (passphrase) infoParams.passphrase = passphrase;
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams);
if (!result.success) return result as ServiceResult<AssetInfo>;
const metadata = result.data.list?.[0];
if (!metadata) return fail('Photo not found', 404);
const normalized = this._normalizeSynologyPhotoInfo(metadata);
normalized.id = photoId;
return success(normalized);
}
async fetchSynologyThumbnailBytes(
userId: number,
targetUserId: number,
photoId: string,
passphrase?: string,
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const parsedId = this._splitPackedSynologyId(photoId);
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
const synology_credentials = this._getSynologyCredentials(targetUserId);
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
const sid = await this._getSynologySession(targetUserId);
if (!sid.success) return { error: 'Session error', status: 500 };
if (!sid.data) return { error: 'Session ID missing', status: 500 };
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
// Match the uncached streamSynologyAsset default — 'sm' (240px) looked
// pixelated on retina.
size: 'm',
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = this._buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
try {
const resp = await safeFetch(url, undefined, { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl });
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());
return { bytes, contentType };
} catch (error) {
console.error('fetchSynologyThumbnailBytes: upstream fetch failed:', error);
return { error: 'Proxy error', status: 502 };
}
}
async streamSynologyAsset(
response: Response,
userId: number,
targetUserId: number,
photoId: string,
kind: 'thumbnail' | 'original',
size?: string,
passphrase?: string,
): Promise<void> {
const parsedId = this._splitPackedSynologyId(photoId);
if (!parsedId) {
handleServiceResult(response, fail('Invalid photo ID format', 400));
return;
}
const synology_credentials = this._getSynologyCredentials(targetUserId);
if (!synology_credentials.success) {
handleServiceResult(response, synology_credentials);
return;
}
const sid = await this._getSynologySession(targetUserId);
if (!sid.success) {
handleServiceResult(response, sid);
return;
}
if (!sid.data) {
handleServiceResult(response, fail('Failed to retrieve session ID', 500));
return;
}
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render).
// Thumbnail default is 'm' (~320px) — 'sm' (240px) looked pixelated on
// the journey grid on retina screens.
const resolvedSize = kind === 'original' ? 'xl' : (size || 'm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = this._buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400', { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl })
}
}
@@ -0,0 +1,60 @@
import { Jimp } from 'jimp'
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
import { Injectable } from '@nestjs/common'
import { ADDON_IDS } from '../../addons'
import { AddonsService } from '../addons/addons.service'
const THUMB_MAX = 800
const THUMB_QUALITY = 80
/**
* Downscaled JPEGs for locally uploaded journey photos. Gated on the journey
* addon, because that is the only surface that uploads them.
*/
@Injectable()
export class ThumbnailService {
constructor(private readonly addons: AddonsService) {}
async ensureLocalThumbnail(
uploadsRoot: string,
originalRelPath: string,
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return null
const originalAbs = path.join(uploadsRoot, originalRelPath)
try { await fs.access(originalAbs) } catch { return null }
// Deterministic name so concurrent requests don't race on the same photo.
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
const thumbRel = `journey/thumbs/${hash}.jpg`
const thumbAbs = path.join(uploadsRoot, thumbRel)
try {
const [srcStat, dstStat] = await Promise.all([
fs.stat(originalAbs),
fs.stat(thumbAbs).catch(() => null),
])
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
const img = await Jimp.read(thumbAbs)
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
}
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
// Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior.
const img = await Jimp.read(originalAbs)
const { width: w, height: h } = img.bitmap
if (w > THUMB_MAX || h > THUMB_MAX) {
img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX })
}
await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY })
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
}
}
@@ -0,0 +1,112 @@
import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import crypto from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { DatabaseService } from '../database/database.service';
import { UPLOADS_ROOT } from './uploads-root';
const TREK_PHOTO_DIR = path.join(UPLOADS_ROOT, 'photos/trek');
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
/**
* In-flight fetches, module-scoped on purpose.
*
* This is the stampede guard: ten tiles asking for the same uncached asset must
* share one upstream fetch. The scheduler drives sweepExpired through a lazy
* require outside the container, so a second instance of this service exists by
* design - a per-instance Map would hand each of them a private one and the
* guard would be silently gone. Same reasoning as oauth/oauth.pending-codes.ts
* and the notification channel registry.
*/
const inFlight = new Map<string, Promise<Buffer | null>>();
function ensureDir(): void {
if (!fs.existsSync(TREK_PHOTO_DIR)) {
fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true });
}
}
function cachedFilePath(key: string): string {
return path.join(TREK_PHOTO_DIR, `${key}.bin`);
}
/** Disk + metadata cache for provider thumbnails and originals. */
@Injectable()
export class TrekPhotoCacheService {
constructor(private readonly db: DatabaseService) {}
cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
}
getFresh(key: string): { filePath: string; contentType: string } | null {
const row = this.db.get<{ content_type: string; fetched_at: number }>(
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?', key,
);
if (!row) return null;
if (Date.now() - row.fetched_at >= CACHE_TTL) {
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key);
return null;
}
const fp = cachedFilePath(key);
if (!fs.existsSync(fp)) {
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key);
return null;
}
return { filePath: fp, contentType: row.content_type };
}
async put(key: string, bytes: Buffer, contentType: string): Promise<void> {
ensureDir();
const fp = cachedFilePath(key);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
await fsPromises.rename(tmp, fp);
this.db.run(
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)',
key, contentType, Date.now(),
);
}
serveFresh(res: Response, key: string): boolean {
const entry = this.getFresh(key);
if (!entry) return false;
res.set('Content-Type', entry.contentType);
res.set('Cache-Control', 'public, max-age=3600');
res.sendFile(entry.filePath);
return true;
}
getInFlight(key: string): Promise<Buffer | null> | undefined {
return inFlight.get(key);
}
setInFlight(key: string, promise: Promise<Buffer | null>): void {
inFlight.set(key, promise);
promise.finally(() => inFlight.delete(key));
}
sweepExpired(): void {
const cutoff = Date.now() - CACHE_TTL * 2;
const stale = this.db.all<{ cache_key: string }>(
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?', cutoff,
);
for (const row of stale) {
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', row.cache_key);
const fp = cachedFilePath(row.cache_key);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
}
}
@@ -0,0 +1,378 @@
import { Injectable } from '@nestjs/common';
import { broadcast } from '../../websocket';
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../database/database.service';
import { send } from '../notifications/notifications.bridge';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { MemoriesAccessService } from './memories-access.service';
import {
fail,
success,
mapDbError,
type Selection,
type ServiceResult,
type SyncAlbumResult,
} from './memories.helpers';
/**
* The provider-agnostic trip photo surface: which photos a trip shows, which
* albums it is linked to, and the album sync that writes provider assets into
* the trip. The providers themselves never write here - that direction is what
* used to close the import cycle.
*/
@Injectable()
export class UnifiedMemoriesService {
constructor(
private readonly db: DatabaseService,
private readonly photos: TrekPhotosRepository,
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly access: MemoriesAccessService,
) {}
private _providers(): Array<{id: string; enabled: boolean}> {
const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>;
return rows.map(r => ({ id: r.id, enabled: r.enabled === 1 }));
}
private _validProvider(provider: string): ServiceResult<string> {
const providers = this._providers();
const found = providers.find(p => p.id === provider);
if (!found) {
return fail(`Provider: "${provider}" is not supported`, 400);
}
if (!found.enabled) {
return fail(`Provider: "${provider}" is not enabled, contact server administrator`, 400);
}
return success(provider);
}
listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id);
if (enabledProviders.length === 0) {
return fail('No photo providers enabled', 400);
}
const photos = this.db.prepare(`
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tp.added_at ASC
`).all(tripId, userId, ...enabledProviders);
return success(photos);
} catch (error) {
return mapDbError(error, 'Failed to list trip photos');
}
}
listTripAlbumLinks(tripId: string, userId: number): ServiceResult<any[]> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id);
if (enabledProviders.length === 0) {
return fail('No photo providers enabled', 400);
}
try {
const links = this.db.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
AND tal.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tal.created_at ASC
`).all(tripId, ...enabledProviders);
return success(links);
} catch (error) {
return mapDbError(error, 'Failed to list trip album links');
}
}
//-----------------------------------------------
// managing photos in trip
private _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
const providerResult = this._validProvider(provider);
if (!providerResult.success) {
return providerResult as ServiceResult<boolean>;
}
try {
const photoId = this.photos.getOrCreate(provider, assetId, userId, passphrase);
const result = this.db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
return success(result.changes > 0);
}
catch (error) {
return mapDbError(error, 'Failed to add photo to trip');
}
}
async addTripPhotos(
tripId: string,
userId: number,
shared: boolean,
selections: Selection[],
sid: string,
albumLinkId?: string,
): Promise<ServiceResult<{ added: number; shared: boolean }>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
if (selections.length === 0) {
return fail('No photos selected', 400);
}
let added = 0;
for (const selection of selections) {
const providerResult = this._validProvider(selection.provider);
if (!providerResult.success) {
return providerResult as ServiceResult<{ added: number; shared: boolean }>;
}
for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim();
if (!assetId) continue;
const result = this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
if (!result.success) {
return result as ServiceResult<{ added: number; shared: boolean }>;
}
if (result.data) {
added++;
}
}
}
await this._notifySharedTripPhotos(tripId, userId, added);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success({ added, shared });
}
async setTripPhotoSharing(
tripId: string,
userId: number,
photoId: number,
shared: boolean,
sid?: string,
): Promise<ServiceResult<true>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
this.db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(shared ? 1 : 0, tripId, userId, photoId);
await this._notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to update photo sharing');
}
}
removeTripPhoto(
tripId: string,
userId: number,
photoId: number,
sid?: string,
): ServiceResult<true> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
this.db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(tripId, userId, photoId);
this.photos.deleteIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove trip photo');
}
}
// ----------------------------------------------
// managing album links in trip
createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const provider = String(providerRaw || '').toLowerCase();
const albumId = String(albumIdRaw || '').trim();
const albumName = String(albumNameRaw || '').trim();
if (!provider) {
return fail('provider is required', 400);
}
if (!albumId) {
return fail('album_id required', 400);
}
const providerResult = this._validProvider(provider);
if (!providerResult.success) {
return providerResult as ServiceResult<true>;
}
try {
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
const result = this.db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
if (result.changes === 0) {
return fail('Album already linked', 409);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to link album');
}
}
removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
const linkedPhotos = this.db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
this.db.transaction(() => {
this.db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
this.db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
});
for (const { photo_id } of linkedPhotos) {
this.photos.deleteIfOrphan(photo_id);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove album link');
}
}
//-----------------------------------------------
// notifications helper
private async _notifySharedTripPhotos(
tripId: string,
actorUserId: number,
added: number,
): Promise<ServiceResult<void>> {
if (added <= 0) return success(undefined);
try {
const actorRow = this.db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null };
const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
return success(undefined);
} catch {
return fail('Failed to send notifications', 500);
}
}
// ── Album sync (orchestration) ────────────────────────────────────────────
//
// The provider services collect the asset ids; adding them to the trip and
// stamping the link is this module's job, because addTripPhotos lives here.
// Keeping the write on this side is what lets immich/synology stop importing
// this module — the cycle immich -> unified -> photoResolver -> immich is gone.
// Both functions return exactly what the combined provider-side ones returned.
async syncImmichAlbum(
tripId: string,
linkId: string,
userId: number,
sid: string,
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
const collected = await this.immich.collectAlbumSelection(tripId, linkId, userId);
if ('error' in collected) return { error: collected.error, status: collected.status };
const result = await this.addTripPhotos(tripId, userId, true, [collected.selection], sid, linkId);
if ('error' in result) return { error: result.error.message, status: result.error.status };
this.access.updateSyncTimeForAlbumLink(linkId);
return { success: true, added: result.data.added, total: collected.total };
}
async syncSynologyAlbum(
userId: number,
tripId: string,
linkId: string,
sid: string,
): Promise<ServiceResult<SyncAlbumResult>> {
const collected = await this.synology.collectSynologyAlbumSelection(userId, tripId, linkId);
if (!collected.success) return collected as ServiceResult<SyncAlbumResult>;
const result = await this.addTripPhotos(tripId, userId, true, [collected.data.selection], sid, linkId);
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
this.access.updateSyncTimeForAlbumLink(linkId);
return success({ added: result.data.added, total: collected.data.total });
}
}
+9 -7
View File
@@ -1,17 +1,19 @@
import { Module } from '@nestjs/common';
import { PhotosController } from './photos.controller';
import { PhotosService } from './photos.service';
import { TrekPhotosRepository } from './trek-photos.repository';
import { TrekPhotosModule } from './trek-photos.module';
import { MemoriesModule } from '../memories/memories.module';
/**
* The trek_photos store plus the /api/photos read surface. The repository is
* exported because the trip-photo and journey domains register rows in it
* without caring which provider the bytes come from — that dispatch stays in
* memories/.
* /api/photos — the trek_photo read surface. Access control and the byte
* fetching both come from the memories domain, which owns provider dispatch;
* the row store comes from TrekPhotosModule, which memories imports too. That
* shared leaf is what keeps this a straight line instead of a forwardRef.
*/
@Module({
imports: [TrekPhotosModule, MemoriesModule],
controllers: [PhotosController],
providers: [PhotosService, TrekPhotosRepository],
exports: [TrekPhotosRepository],
providers: [PhotosService],
exports: [TrekPhotosModule],
})
export class PhotosModule {}
+13 -8
View File
@@ -1,24 +1,29 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { streamPhoto, getPhotoInfo } from '../../services/memories/photoResolverService';
import { canAccessTrekPhoto } from '../../services/memories/helpersService';
import { MemoriesAccessService } from '../memories/memories-access.service';
import { PhotoResolverService } from '../memories/photo-resolver.service';
/**
* Thin Nest wrapper around the existing photo resolver/helper services. Access
* control, streaming and the provider-specific info lookups reuse the legacy
* code unchanged.
* /api/photos — the trek_photo read surface. Access control comes from the
* memories domain (a photo is reachable through a trip or a journey), the bytes
* from the resolver, which asks whichever provider owns the asset.
*/
@Injectable()
export class PhotosService {
constructor(
private readonly access: MemoriesAccessService,
private readonly resolver: PhotoResolverService,
) {}
canAccess(userId: number, photoId: number): boolean {
return canAccessTrekPhoto(userId, photoId);
return this.access.canAccessTrekPhoto(userId, photoId);
}
stream(res: Response, userId: number, photoId: number, kind: 'thumbnail' | 'original', range?: string) {
return streamPhoto(res, userId, photoId, kind, range);
return this.resolver.streamPhoto(res, userId, photoId, kind, range);
}
info(userId: number, photoId: number) {
return getPhotoInfo(userId, photoId);
return this.resolver.getPhotoInfo(userId, photoId);
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TrekPhotosRepository } from './trek-photos.repository';
/**
* The trek_photos store on its own, so both halves can have it without
* importing each other.
*
* Memories needs it to record a row when an album sync pulls provider assets
* in; the /api/photos read surface needs the memories resolver to fetch the
* bytes. That is a genuine mutual need, and a module this small breaks it
* without a forwardRef: PhotosModule -> MemoriesModule -> TrekPhotosModule is
* a straight line.
*/
@Module({
providers: [TrekPhotosRepository],
exports: [TrekPhotosRepository],
})
export class TrekPhotosModule {}
@@ -78,6 +78,17 @@ export class TrekPhotosRepository {
);
}
/**
* Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions
* that were already known — a re-generated thumbnail must not blank them.
*/
recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): void {
this.db.run(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?',
thumbnailPath, width, height, photoId,
);
}
/**
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
+21 -4
View File
@@ -352,6 +352,25 @@ function startIdempotencyCleanup(): void {
}
// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL
/**
* The cache sweep, reached from outside the container.
*
* Constructed per call rather than injected: the cron runs before/independently
* of any request, and the service holds no state of its own — the stampede
* guard it does own is a module-scoped Map, deliberately shared with the
* container instance. Required lazily so a boot without the nest graph (some
* tests) does not pull it in.
*/
function sweepTrekPhotoCache(): void {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { TrekPhotoCacheService } = require('./nest/memories/trek-photo-cache.service');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseService } = require('./nest/database/database.service');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { db } = require('./db/database');
new TrekPhotoCacheService(new DatabaseService(db)).sweepExpired();
}
let trekPhotoCacheTask: ScheduledTask | null = null;
function startTrekPhotoCacheCleanup(): void {
@@ -359,14 +378,12 @@ function startTrekPhotoCacheCleanup(): void {
// Run once immediately on startup to evict any entries left over from a previous run
try {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
sweepTrekPhotoCache();
} catch { /* cache dir may not exist yet — harmless */ }
trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => {
try {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
sweepTrekPhotoCache();
} catch (err: unknown) {
logError(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
}
@@ -1,159 +0,0 @@
import { canAccessTrip, db } from "../../db/database";
import { decrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
// The ServiceResult envelope, the asset shapes and pipeAsset moved to
// nest/memories/memories.helpers.ts — this file is down to the DB-backed
// access checks and album-link lookups, and folds with the providers.
import { fail, success, type ServiceResult } from '../../nest/memories/memories.helpers';
export * from '../../nest/memories/memories.helpers';
//for loading routes to settings page, and validating which services user has connected
type PhotoProviderConfig = {
settings_get: string;
settings_put: string;
status_get: string;
test_post: string;
};
export function getPhotoProviderConfig(providerId: string): PhotoProviderConfig {
const prefix = `/integrations/memories/${providerId}`;
return {
settings_get: `${prefix}/settings`,
settings_put: `${prefix}/settings`,
status_get: `${prefix}/status`,
test_post: `${prefix}/test`,
};
}
//-----------------------------------------------
//access check helper
export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
if (requestingUserId === ownerUserId) {
return true;
}
// Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') {
const journeyPhoto = db.prepare(`
SELECT gp.journey_id
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ?
AND tkp.provider = ?
AND tkp.owner_id = ?
LIMIT 1
`).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
if (!journeyPhoto) return false;
const access = db.prepare(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
LIMIT 1
`).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
return !!access;
}
// Regular trip photos — join through trek_photos
const sharedAsset = db.prepare(`
SELECT 1
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.user_id = ?
AND tkp.asset_id = ?
AND tkp.provider = ?
AND tp.trip_id = ?
AND tp.shared = 1
LIMIT 1
`).get(ownerUserId, assetId, provider, tripId);
if (!sharedAsset) {
return false;
}
return !!canAccessTrip(tripId, requestingUserId);
}
// ── Unified photo access check (trek_photos based) ──────────────────────
export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined;
if (!photo) return false;
// Owner always has access
if (photo.owner_id === requestingUserId) return true;
// Check trip_photos — is this photo shared in a trip the user has access to?
const tripAccess = db.prepare(`
SELECT 1 FROM trip_photos tp
WHERE tp.photo_id = ?
AND tp.shared = 1
AND EXISTS (
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
UNION ALL
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
)
LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId);
if (tripAccess) return true;
// Check journey_photos — is this photo in a journey the user can access?
const journeyAccess = db.prepare(`
SELECT 1 FROM journey_photos gp
WHERE gp.photo_id = ?
AND EXISTS (
SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
)
LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId);
if (journeyAccess) return true;
// Local photos without owner (uploaded files) — check if user has journey access
if (photo.provider === 'local' && !photo.owner_id) {
return !!journeyAccess;
}
return false;
}
// ----------------------------------------------
//helpers for album link syncing
export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<string> {
const access = canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.get(linkId, tripId, userId) as { album_id: string } | null;
return row ? success(row.album_id) : fail('Album link not found', 404);
} catch {
return fail('Failed to retrieve album link', 500);
}
}
export function getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
const access = canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = db.prepare('SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.get(linkId, tripId, userId) as { album_id: string; passphrase: string | null } | null;
if (!row) return fail('Album link not found', 404);
const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined;
return success({ albumId: row.album_id, passphrase: decrypted || undefined });
} catch {
return fail('Failed to retrieve album link', 500);
}
}
export function updateSyncTimeForAlbumLink(linkId: string): void {
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
}
@@ -1,587 +0,0 @@
import { Response } from 'express';
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
import { writeAudit } from '../../nest/audit/audit.bridge';
import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection, pipeAsset } from './helpersService';
import { UPLOADS_ROOT } from '../../nest/memories/uploads-root';
// ── Credentials ────────────────────────────────────────────────────────────
export function getImmichCredentials(userId: number) {
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
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 };
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
export function isValidAssetId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
}
/**
* Hidden assets have no generated thumbnail — Immich's own enum documents
* `AssetVisibility.Hidden` as "Video part of the LivePhotos and MotionPhotos",
* and its UI never shows them standalone. Surfacing or persisting one yields a
* permanently broken tile, since nothing re-checks visibility on the render
* path (#1474).
*
* Load-bearing on Immich < 1.133, which predates the `visibility` field: those
* servers strip the `visibility: 'timeline'` search filter and never defaulted
* `isVisible`, so they return hidden assets and this is the only thing stopping
* them. They mark the same state as `isVisible: false`.
*/
function isVisibleAsset(a: any): boolean {
return a.visibility !== 'hidden' && a.isVisible !== false;
}
// ── Connection Settings ────────────────────────────────────────────────────
export function getConnectionSettings(userId: number) {
const creds = getImmichCredentials(userId);
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
auto_upload: !!(prefs?.immich_auto_upload),
};
}
export function setImmichAutoUpload(userId: number, enabled: boolean): void {
db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
export async function saveImmichSettings(
userId: number,
immichUrl: string | undefined,
immichApiKey: string | undefined,
clientIp: string | null
): Promise<{ success: boolean; warning?: string; error?: string }> {
if (immichUrl) {
const ssrf = await checkSsrf(immichUrl.trim());
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
userId
);
if (ssrf.isPrivate) {
writeAudit({
userId,
action: 'immich.private_ip_configured',
ip: clientIp,
details: { immich_url: immichUrl.trim(), resolved_ip: ssrf.resolvedIp },
});
return {
success: true,
warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`,
};
}
} else {
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
);
}
return { success: true };
}
// ── Connection Test / Status ───────────────────────────────────────────────
export async function testConnection(
immichUrl: string,
immichApiKey: string
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string }; canonicalUrl?: string }> {
const ssrf = await checkSsrf(immichUrl);
if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' };
try {
const resp = await safeFetch(`${immichUrl}/api/users/me`, {
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
});
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
// Detect http → https upgrade only: same host/port, protocol changed to https
let canonicalUrl: string | undefined;
if (resp.url) {
const finalUrl = new URL(resp.url);
const inputUrl = new URL(immichUrl);
if (
inputUrl.protocol === 'http:' &&
finalUrl.protocol === 'https:' &&
finalUrl.hostname === inputUrl.hostname &&
finalUrl.port === inputUrl.port
) {
canonicalUrl = finalUrl.origin;
}
}
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' };
}
}
export async function getConnectionStatus(
userId: number
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> {
const creds = getImmichCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
try {
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,
});
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' };
}
}
// ── Browse Timeline / Search ───────────────────────────────────────────────
export async function browseTimeline(
userId: number
): Promise<{ buckets?: any; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/timeline/buckets`, {
method: 'GET',
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status };
const buckets = await resp.json();
return { buckets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export async function searchPhotos(
userId: number,
from?: string,
to?: string,
page: number = 1,
size: number = 50,
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
// No type filter — surface videos alongside images (#823).
// Immich 1.133–1.144 defaulted metadata search to `timeline` visibility;
// v3 defaults to any visibility except `locked`, which is what started
// surfacing Live Photo motion parts as broken tiles (#1474). Ask for
// `timeline` explicitly so hidden assets never cross the wire and a full
// page stays a full page of renderable tiles.
//
// `visibility` only exists from 1.133.0. Older servers strip it — Immich
// validates with `whitelist: true` and no `forbidNonWhitelisted`, so an
// unknown property is dropped, never a 400 — and they never defaulted
// `isVisible` either, so they still return hidden assets. isVisibleAsset()
// below is the only guard on those versions. Do not remove it.
visibility: 'timeline',
withExif: true,
size,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
// Belt-and-braces: `visibility: 'timeline'` above should mean Immich never
// sends a hidden asset, but an older server that ignores the filter would
// otherwise render broken tiles. hasMore stays on the raw page length so
// pagination still advances past a filtered page.
const assets = items
.filter(isVisibleAsset)
.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null,
lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets, hasMore: items.length >= size };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
export async function getAssetInfo(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ data?: any; error?: string; status?: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
try {
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,
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const asset = await resp.json() as any;
return {
data: {
id: asset.id,
takenAt: asset.fileCreatedAt || asset.createdAt,
width: asset.exifInfo?.exifImageWidth || null,
height: asset.exifInfo?.exifImageHeight || null,
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
lens: asset.exifInfo?.lensModel || null,
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
shutter: asset.exifInfo?.exposureTime || null,
iso: asset.exifInfo?.iso || null,
city: asset.exifInfo?.city || null,
state: asset.exifInfo?.state || null,
country: asset.exifInfo?.country || null,
lat: asset.exifInfo?.latitude || null,
lng: asset.exifInfo?.longitude || null,
fileSize: asset.exifInfo?.fileSizeInByte || null,
fileName: asset.originalFileName || null,
},
};
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function fetchImmichThumbnailBytes(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
try {
const resp = await safeFetch(url, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000) as any,
});
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());
return { bytes, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function streamImmichAsset(
response: Response,
userId: number,
assetId: string,
kind: 'thumbnail' | 'original',
ownerUserId?: number,
opts?: { mediaType?: string | null; range?: string },
): Promise<{ error?: string; status?: number } | void> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const isVideo = opts?.mediaType === 'video';
const headers: Record<string, string> = { 'x-api-key': creds.immich_api_key };
let url: string;
let timeout: number | undefined;
let cacheControl = 'public, max-age=86400';
if (kind === 'thumbnail') {
// Immich generates a poster thumbnail for video too.
url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
timeout = 10000;
} else if (isVideo) {
// Transcoded, broadly-compatible MP4 with byte-range support; forward the
// viewer's Range so the player can seek. No abort timeout — video is a long
// streaming response, not a quick fetch (#823).
url = `${creds.immich_url}/api/assets/${assetId}/video/playback`;
if (opts?.range) headers['Range'] = opts.range;
cacheControl = 'private, max-age=3600';
timeout = undefined;
} else {
url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
timeout = 30000;
}
await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl);
}
// ── Albums ──────────────────────────────────────────────────────────────────
export async function listAlbums(
userId: number
): Promise<{ albums?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
// Fetch both owned and shared albums
const [ownResp, sharedResp] = await Promise.all([
safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
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,
}),
]);
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
const ownAlbums = await ownResp.json() as any[];
const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : [];
const seenIds = new Set<string>();
const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => {
if (seenIds.has(a.id)) return false;
seenIds.add(a.id);
return true;
});
const albums = allAlbums.map((a: any) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount || 0,
startDate: a.startDate,
endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId,
shared: a.shared || a.sharedUsers?.length > 0,
}));
return { albums };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
/** Immich caps `size` at 1000 for metadata search; a page cap bounds pathological albums. */
const ALBUM_PAGE_SIZE = 1000;
const ALBUM_MAX_PAGES = 20;
/**
* Fetch every asset in an album, across Immich v2 and v3.
*
* Immich v3 removed `assets` from `AlbumResponseDto`, so `GET /api/albums/:id`
* no longer carries album contents (#1492) and we fall back to an
* `albumIds`-filtered metadata search.
*
* We feature-detect on the presence of `assets` rather than switching on a
* probed server version. The two paths are NOT interchangeable:
*
* - Before v3, `searchMetadata` unconditionally scopes results to
* `[self, ...partners]` (`asset.ownerId = ANY(userIds)`), so an albumIds
* search against an album shared by a non-partner returns nothing. v3 added
* an albumIds branch that checks `AlbumRead` and skips that owner filter.
* - 1.133–1.144 also default `visibility` to `timeline`, dropping archived
* album assets.
* - `albumIds` only exists from 1.135.0, and Immich strips unknown properties
* rather than rejecting them (`whitelist: true`, no `forbidNonWhitelisted`).
* Searching an older server would therefore silently drop the album filter
* and return the user's ENTIRE library as the album's contents.
*
* Feature detection makes all three moot: `assets` is present on every version
* that has any of those problems (through 1.144.1) and absent only on v3, where
* the search path is correct. A version probe with a wrong boundary would not be.
*/
async function fetchAlbumAssets(
creds: { immich_url: string; immich_api_key: string },
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,
});
if (!resp.ok) return { status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
if (Array.isArray(albumData.assets)) return { assets: albumData.assets };
return fetchAlbumAssetsViaSearch(creds, albumId);
}
/**
* Immich v3 album contents, via `POST /api/search/metadata` filtered by `albumIds`.
*
* `withExif` is required: it has no default and gates an inner join
* (`searchAssetBuilder`: `.$if(!!options.withExif, withExifInner)`), so without
* it Immich omits `exifInfo` entirely and every photo's city/country goes null.
*/
async function fetchAlbumAssetsViaSearch(
creds: { immich_url: string; immich_api_key: string },
albumId: string,
): Promise<{ assets?: any[]; status?: number }> {
const all: any[] = [];
for (let page = 1; page <= ALBUM_MAX_PAGES; page++) {
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
albumIds: [albumId],
withExif: true,
withDeleted: false,
size: ALBUM_PAGE_SIZE,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
all.push(...items);
if (items.length < ALBUM_PAGE_SIZE) break;
}
return { assets: all };
}
export async function getAlbumPhotos(
userId: number,
albumId: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const result = await fetchAlbumAssets(creds, albumId);
if (!result.assets) return { error: 'Failed to fetch album', status: result.status };
// Albums legitimately contain hidden assets on both Immich versions (the v2
// album body and the v3 album search both return them), so filter here (#1474).
const assets = result.assets
.filter(isVisibleAsset)
.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
/**
* The Immich half of an album sync: resolve the link, fetch the album, and hand
* back the picked asset ids.
*
* Adding them to the trip stays with the unified service, which owns
* addTripPhotos. Splitting it there is what breaks the import cycle this module
* used to close (immich -> unified -> photoResolver -> immich): the provider now
* only ever points downhill. Every error string and status below is the one the
* combined function returned.
*/
export async function collectAlbumSelection(
tripId: string,
linkId: string,
userId: number,
): Promise<{ selection: Selection; total: number } | { error: string; status: number }> {
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const albumResult = await fetchAlbumAssets(creds, response.data as string);
if (!albumResult.assets) return { error: 'Failed to fetch album', status: albumResult.status };
// Hidden assets must never be persisted: the render path never re-checks
// visibility, so a stored hidden asset is a permanently broken tile (#1474).
const assets = albumResult.assets.filter((a: any) => a.type === 'IMAGE' && isVisibleAsset(a));
return {
selection: { provider: 'immich', asset_ids: assets.map((a: any) => a.id) },
total: assets.length,
};
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
// ── Upload to Immich ──────────────────────────────────────────────────────
export async function uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
const creds = getImmichCredentials(userId);
if (!creds) return null;
const fs = await import('node:fs');
const path = await import('node:path');
const fullPath = path.join(UPLOADS_ROOT, filePath);
if (!fs.existsSync(fullPath)) return null;
try {
const fileBuffer = fs.readFileSync(fullPath);
const boundary = '----ImmichUpload' + Date.now();
const ext = path.extname(fileName).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
const now = new Date().toISOString();
const parts: Buffer[] = [];
const addField = (name: string, value: string) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
};
addField('deviceAssetId', `trek-${Date.now()}`);
addField('deviceId', 'TREK');
addField('fileCreatedAt', now);
addField('fileModifiedAt', now);
parts.push(Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`
));
parts.push(fileBuffer);
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'POST',
headers: {
'x-api-key': creds.immich_api_key,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
});
if (res.ok) {
const data = await res.json() as { id?: string };
return data.id || null;
}
return null;
} catch {
return null;
}
}
@@ -1,175 +0,0 @@
import { Response } from 'express';
import path from 'path';
import fs from 'fs';
import { db } from '../../db/database';
import type { TrekPhoto } from '../../types';
// The trek_photos rows live in nest/photos now — this module is the provider
// dispatch half of what used to be one file.
import { resolveTrekPhoto } from '../../nest/photos/photos.bridge';
import { UPLOADS_ROOT } from '../../nest/memories/uploads-root';
import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService';
import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService';
import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService';
import { decrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
import * as photoCache from './trekPhotoCache';
import { ensureLocalThumbnail } from './thumbnailService';
// ── Streaming ────────────────────────────────────────────────────────────
async function streamCachedThumbnail(
res: Response,
photo: TrekPhoto,
fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>,
fallback: () => Promise<unknown>,
): Promise<void> {
const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!);
if (photoCache.serveFresh(res, key)) return;
const existing = photoCache.getInFlight(key);
if (existing) {
const bytes = await existing;
if (bytes && photoCache.serveFresh(res, key)) return;
await fallback();
return;
}
const promise = fetchBytes().then(async result => {
if ('error' in result) return null;
await photoCache.put(key, result.bytes, result.contentType);
return result.bytes;
});
photoCache.setInFlight(key, promise);
const bytes = await promise;
if (bytes && photoCache.serveFresh(res, key)) return;
await fallback();
}
export async function streamPhoto(
res: Response,
userId: number,
photoId: number,
kind: 'thumbnail' | 'original',
range?: string,
): Promise<void> {
const photo = resolveTrekPhoto(photoId);
if (!photo) {
res.status(404).json({ error: 'Photo not found' });
return;
}
if (photo.file_path) {
const uploadsRoot = UPLOADS_ROOT;
if (kind === 'thumbnail') {
const isVideo = photo.media_type === 'video';
let thumbRel = photo.thumbnail_path ?? null;
// Only raster images get a lazily-generated Jimp thumbnail; Jimp can't decode
// video, so a video relies on the poster captured at upload (#823).
if (!thumbRel && !isVideo) {
const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
db.prepare(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?'
).run(thumbRel, result.width, result.height, photo.id);
}
}
if (thumbRel) {
const thumbAbs = path.join(uploadsRoot, thumbRel);
if (fs.existsSync(thumbAbs)) {
res.set('Cache-Control', 'public, max-age=86400, immutable');
res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(thumbAbs);
return;
}
}
// A poster-less video must NOT fall through to streaming the whole file as a
// "thumbnail"; let the client render its own placeholder instead.
if (isVideo) {
res.status(404).json({ error: 'No poster available' });
return;
}
// Images fall through to original if the thumbnail is unavailable.
}
const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(localPath);
return;
}
}
switch (photo.provider) {
case 'local': {
res.status(404).json({ error: 'File not found' });
return;
}
case 'immich': {
if (kind === 'thumbnail') {
await streamCachedThumbnail(
res, photo,
() => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!),
() => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type }),
);
return;
}
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type, range });
return;
}
case 'synologyphotos': {
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
if (kind === 'thumbnail') {
await streamCachedThumbnail(
res, photo,
() => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase),
() => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase),
);
return;
}
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
return;
}
default:
res.status(400).json({ error: `Unknown provider: ${photo.provider}` });
}
}
// ── Asset Info ────────────────────────────────────────────────────────────
export async function getPhotoInfo(
userId: number,
photoId: number,
): Promise<ServiceResult<AssetInfo>> {
const photo = resolveTrekPhoto(photoId);
if (!photo) return fail('Photo not found', 404);
switch (photo.provider) {
case 'local': {
return success({
id: String(photo.id),
takenAt: photo.created_at,
city: null,
country: null,
width: photo.width,
height: photo.height,
fileName: photo.file_path?.split('/').pop() || null,
} as AssetInfo);
}
case 'immich': {
const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!);
if (result.error) return fail(result.error, result.status || 500);
return success(result.data as AssetInfo);
}
case 'synologyphotos': {
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase);
}
default:
return fail(`Unknown provider: ${photo.provider}`, 400);
}
}
@@ -1,705 +0,0 @@
import { Response } from 'express';
import { db } from '../../db/database';
import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
import {
getAlbumLinkForSync,
updateSyncTimeForAlbumLink,
Selection,
ServiceResult,
fail,
success,
handleServiceResult,
pipeAsset,
AlbumsList,
AssetsList,
StatusResult,
SyncAlbumResult,
AssetInfo
} from './helpersService';
import { send as sendNotification } from '../../nest/notifications/notifications.bridge';
const SYNOLOGY_PROVIDER = 'synologyphotos';
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
// The API endpoint is always at {base_url}/webapi/entry.cgi.
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
101: 'Missing API, method, or version parameter.',
102: 'Requested API does not exist.',
103: 'Requested method does not exist.',
104: 'Requested API version is not supported.',
105: 'Insufficient privilege.',
106: 'Connection timeout.',
107: 'Multiple logins blocked from this IP.',
117: 'Manager privilege required.',
119: 'Session is invalid or expired.',
400: 'Invalid credentials.',
401: 'Session expired or account disabled.',
402: 'No permission to use this account.',
403: 'Two-factor authentication code required.',
404: 'Two-factor authentication failed.',
406: 'Two-factor authentication is enforced for this account.',
407: 'Maximum login attempts reached.',
408: 'Password expired.',
409: 'Remote password expired.',
410: 'Password must be changed before login.',
412: 'Guest account cannot log in.',
413: 'OTP system files are corrupted.',
414: 'Unable to log in.',
416: 'Unable to log in.',
417: 'OTP system is full.',
498: 'System is upgrading.',
499: 'System is not ready.',
};
interface SynologyUserRecord {
synology_url?: string | null;
synology_username?: string | null;
synology_password?: string | null;
synology_sid?: string | null;
synology_did?: string | null;
synology_skip_ssl?: number | null;
};
interface SynologyCredentials {
synology_url: string;
synology_username: string;
synology_password: string;
synology_skip_ssl: boolean;
}
interface SynologySettings {
synology_url: string;
synology_username: string;
synology_skip_ssl: boolean;
connected: boolean;
}
interface ApiCallParams {
api: string;
method: string;
version?: number;
[key: string]: unknown;
}
interface SynologyApiResponse<T> {
success: boolean;
data?: T;
error?: { code: number };
}
interface SynologyPhotoItem {
id?: string | number;
filename?: string;
filesize?: number;
time?: number;
item_count?: number;
name?: string;
additional?: {
thumbnail?: { cache_key?: string };
address?: { city?: string; country?: string; state?: string };
resolution?: { width?: number; height?: number };
exif?: {
camera?: string;
lens?: string;
focal_length?: string | number;
aperture?: string | number;
exposure_time?: string | number;
iso?: string | number;
};
gps?: { latitude?: number; longitude?: number };
orientation?: number;
description?: string;
};
}
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
try {
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
if (!row) {
return fail('User not found', 404);
}
const filtered: SynologyUserRecord = {};
for (const column of columns) {
filtered[column] = row[column];
}
return success(filtered);
} catch {
return fail('Failed to read Synology user data', 500);
}
}
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
const password = decrypt_api_key(user.data.synology_password);
if (!password) return fail('Synology credentials corrupted', 500);
return success({
synology_url: user.data.synology_url,
synology_username: user.data.synology_username,
synology_password: password,
synology_skip_ssl: user.data.synology_skip_ssl !== 0,
});
}
function _buildSynologyEndpoint(url: string, params: string): string {
const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
return `${normalized}${SYNOLOGY_ENDPOINT_PATH}?${params}`;
}
function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
}
return body;
}
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams, skipSsl = true): Promise<ServiceResult<T>> {
const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`);
try {
const resp = await safeFetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body,
signal: AbortSignal.timeout(30000) as any,
}, { rejectUnauthorized: !skipSsl });
if (!resp.ok) {
return fail('Synology API request failed with status ' + resp.status, resp.status);
}
const response = await resp.json() as SynologyApiResponse<T>;
if (!response.success) {
const code = response.error.code;
const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')';
// Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi.
// All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes.
const httpStatus = [106, 107, 119].includes(code) ? code : 400;
return fail(message, httpStatus);
}
return success(response.data);
} catch (error) {
if (error instanceof SsrfBlockedError) {
return fail(error.message, 400);
}
return fail('Failed to connect to Synology API', 500);
}
}
const SYNOLOGY_DEVICE_NAME = 'trek';
async function _loginToSynology(
url: string,
username: string,
password: string,
opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {},
): Promise<ServiceResult<{ sid: string; did?: string }>> {
const { otp, deviceId, skipSsl = false } = opts;
const body = new URLSearchParams({
api: 'SYNO.API.Auth',
method: 'login',
version: '6',
account: username,
passwd: password,
format: 'sid',
client: 'browser',
device_name: SYNOLOGY_DEVICE_NAME,
});
if (otp && otp.trim()) {
body.append('otp_code', otp.trim());
body.append('enable_device_token', 'yes');
}
if (deviceId) {
body.append('device_id', deviceId);
}
const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl);
if (!result.success) {
return result as ServiceResult<{ sid: string; did?: string }>;
}
if (!result.data.sid) {
return fail('Failed to get session ID from Synology', 500);
}
return success({ sid: result.data.sid, did: result.data.did });
}
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
const creds = _getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<T>;
}
const session = await _getSynologySession(userId);
if (!session.success || !session.data) {
return session as ServiceResult<T>;
}
const skipSsl = creds.data.synology_skip_ssl;
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
_clearSynologySID(userId);
const retrySession = await _getSynologySession(userId);
if (!retrySession.success || !retrySession.data) {
return retrySession as ServiceResult<T>;
}
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl);
}
return result;
}
function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo {
const address = item.additional?.address || {};
const exif = item.additional?.exif || {};
const gps = item.additional?.gps || {};
return {
id: String(item.additional?.thumbnail?.cache_key || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
city: address.city || null,
country: address.country || null,
state: address.state || null,
camera: exif.camera || null,
lens: exif.lens || null,
focalLength: exif.focal_length || null,
aperture: exif.aperture || null,
shutter: exif.exposure_time || null,
iso: exif.iso || null,
lat: typeof gps.latitude === 'number' ? gps.latitude : null,
lng: typeof gps.longitude === 'number' ? gps.longitude : null,
orientation: item.additional?.orientation || null,
description: item.additional?.description || null,
width: item.additional?.resolution?.width || null,
height: item.additional?.resolution?.height || null,
fileSize: item.filesize || null,
fileName: item.filename || null,
};
}
function _clearSynologySID(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
}
function _clearSynologySession(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
}
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
// The first segment must be a non-empty integer (the unit ID used for API calls).
if (!/^\d+_.+$/.test(rawId)) return null;
const id = rawId.split('_')[0];
return { id, cacheKey: rawId, assetId: rawId };
}
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']);
if (cached.success && cached.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
_clearSynologySID(userId);
}
const creds = _getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<string>;
}
// Use stored device ID to skip OTP on re-login (trusted device flow)
const storedDid = cached.success && cached.data?.synology_did
? (decrypt_api_key(cached.data.synology_did) || undefined)
: undefined;
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, {
deviceId: storedDid,
skipSsl: creds.data.synology_skip_ssl,
});
if (!resp.success) {
return resp as ServiceResult<string>;
}
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
return success(resp.data.sid);
}
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
const creds = _getSynologyCredentials(userId);
if (!creds.success) return creds as ServiceResult<SynologySettings>;
const session = await _getSynologySession(userId);
return success({
synology_url: creds.data.synology_url || '',
synology_username: creds.data.synology_username || '',
synology_skip_ssl: creds.data.synology_skip_ssl,
connected: session.success,
});
}
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise<ServiceResult<string>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const result = _readSynologyUser(userId, ['synology_password'])
if (!result.success) return result as ServiceResult<string>;
const existingEncryptedPassword = result.data?.synology_password || null;
if (!synologyPassword && !existingEncryptedPassword) {
return fail('No stored password found. Please provide a password to save settings.', 400);
}
// Only invalidate the session when the account itself changes (different URL or username).
// If the user just tested the connection, testSynologyConnection already stored a fresh
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']);
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
const sessionCleared = urlChanged || userChanged;
if (sessionCleared) {
_clearSynologySession(userId);
// Fire-and-forget like unifiedService's photos_shared send — without the
// .catch an in-app insert failure became an unhandled rejection.
sendNotification({
event: 'synology_session_cleared',
actorId: null,
params: {},
scope: 'user',
targetId: userId,
}).catch(() => {});
}
try {
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
synologyUrl,
synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
synologySkipSsl ? 1 : 0,
userId,
);
} catch {
return fail('Failed to update Synology settings', 500);
}
return success('settings updated');
}
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
const sid = await _getSynologySession(userId);
if ('error' in sid) return success({ connected: false, error: sid.error.message });
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
try {
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } });
} catch (err: unknown) {
return success({ connected: true, user: { name: 'unknown user' } });
}
}
export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise<ServiceResult<StatusResult>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl });
if ('error' in resp) {
return success({ connected: false, error: resp.error.message });
}
// Persist the session so the OTP code is not required again on save.
// The did (device token) allows future re-logins without OTP.
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
if (resp.data.did) {
db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
}
return success({ connected: true, user: { name: synologyUsername } });
}
async function _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise<ServiceResult<any[]>> {
const pageSize = 100;
const all: any[] = [];
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize });
if (!result.success) return result as ServiceResult<any[]>;
const items = result.data.list || [];
all.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
return success(all);
}
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
const [personal, shared, sharedWithMe] = await Promise.allSettled([
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }),
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }),
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }),
]);
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
const addAlbums = (result: PromiseSettledResult<ServiceResult<any[]>>, extractPassphrase: (a: any) => string | undefined) => {
if (result.status === 'rejected') return;
const value = result.value;
if ('error' in value) {
console.warn('[Synology] album list partial failure:', value.error.message);
return;
}
for (const album of value.data ?? []) {
const id = String(album.id);
const passphrase = extractPassphrase(album);
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
}
};
addAlbums(personal, () => undefined);
addAlbums(shared, (a) => a.passphrase || undefined);
addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined);
if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) {
return personal.value as ServiceResult<AlbumsList>;
}
const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName));
return success({ albums });
}
export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
const allItems: SynologyPhotoItem[] = [];
const pageSize = 50;
let offset = 0;
while (true) {
const params: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
}
/**
* The Synology half of an album sync — see collectAlbumSelection in
* immichService for why the trip-side write lives in the unified service now.
*/
export async function collectSynologyAlbumSelection(userId: number, tripId: string, linkId: string): Promise<ServiceResult<{ selection: Selection; total: number }>> {
const response = getAlbumLinkForSync(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<{ selection: Selection; total: number }>;
const { albumId, passphrase } = response.data;
const allItems: SynologyPhotoItem[] = [];
const pageSize = 50;
let offset = 0;
while (true) {
const itemParams: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
if (!result.success) return result as ServiceResult<{ selection: Selection; total: number }>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const selection: Selection = {
provider: SYNOLOGY_PROVIDER,
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
passphrase,
};
return success({ selection, total: allItems.length });
}
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<ServiceResult<AssetsList>> {
const params: ApiCallParams = {
api: 'SYNO.Foto.Search.Search',
method: 'list_item',
version: 1,
offset,
limit,
keyword: '.',
additional: ['thumbnail', 'address', 'gps'],
};
if (from || to) {
if (from) {
params.start_time = Math.floor(new Date(from).getTime() / 1000);
}
if (to) {
params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
}
}
// SYNO.Foto.Search.Search list_item does not return a total count — only data.list.
// hasMore is inferred: if we got a full page, there may be more.
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult<AssetsList>;
const allItems = result.data.list || [];
const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item));
return success({
assets,
total: allItems.length,
hasMore: allItems.length === limit,
});
}
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise<ServiceResult<AssetInfo>> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) return fail('Invalid photo ID format', 400);
const infoParams: ApiCallParams = {
api: 'SYNO.Foto.Browse.Item',
method: 'get',
version: 5,
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
};
if (passphrase) infoParams.passphrase = passphrase;
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams);
if (!result.success) return result as ServiceResult<AssetInfo>;
const metadata = result.data.list?.[0];
if (!metadata) return fail('Photo not found', 404);
const normalized = _normalizeSynologyPhotoInfo(metadata);
normalized.id = photoId;
return success(normalized);
}
export async function fetchSynologyThumbnailBytes(
userId: number,
targetUserId: number,
photoId: string,
passphrase?: string,
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
const synology_credentials = _getSynologyCredentials(targetUserId);
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
const sid = await _getSynologySession(targetUserId);
if (!sid.success) return { error: 'Session error', status: 500 };
if (!sid.data) return { error: 'Session ID missing', status: 500 };
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
// Match the uncached streamSynologyAsset default — 'sm' (240px) looked
// pixelated on retina.
size: 'm',
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
try {
const resp = await safeFetch(url, undefined, { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl });
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());
return { bytes, contentType };
} catch (error) {
console.error('fetchSynologyThumbnailBytes: upstream fetch failed:', error);
return { error: 'Proxy error', status: 502 };
}
}
export async function streamSynologyAsset(
response: Response,
userId: number,
targetUserId: number,
photoId: string,
kind: 'thumbnail' | 'original',
size?: string,
passphrase?: string,
): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) {
handleServiceResult(response, fail('Invalid photo ID format', 400));
return;
}
const synology_credentials = _getSynologyCredentials(targetUserId);
if (!synology_credentials.success) {
handleServiceResult(response, synology_credentials);
return;
}
const sid = await _getSynologySession(targetUserId);
if (!sid.success) {
handleServiceResult(response, sid);
return;
}
if (!sid.data) {
handleServiceResult(response, fail('Failed to retrieve session ID', 500));
return;
}
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render).
// Thumbnail default is 'm' (~320px) — 'sm' (240px) looked pixelated on
// the journey grid on retina screens.
const resolvedSize = kind === 'original' ? 'xl' : (size || 'm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400', { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl })
}
@@ -1,50 +0,0 @@
import { Jimp } from 'jimp'
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
import { isAddonEnabled } from '../../nest/addons/addons.bridge'
import { ADDON_IDS } from '../../addons'
const THUMB_MAX = 800
const THUMB_QUALITY = 80
export async function ensureLocalThumbnail(
uploadsRoot: string,
originalRelPath: string,
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return null
const originalAbs = path.join(uploadsRoot, originalRelPath)
try { await fs.access(originalAbs) } catch { return null }
// Deterministic name so concurrent requests don't race on the same photo.
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
const thumbRel = `journey/thumbs/${hash}.jpg`
const thumbAbs = path.join(uploadsRoot, thumbRel)
try {
const [srcStat, dstStat] = await Promise.all([
fs.stat(originalAbs),
fs.stat(thumbAbs).catch(() => null),
])
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
const img = await Jimp.read(thumbAbs)
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
}
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
// Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior.
const img = await Jimp.read(originalAbs)
const { width: w, height: h } = img.bitmap
if (w > THUMB_MAX || h > THUMB_MAX) {
img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX })
}
await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY })
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
}
@@ -1,92 +0,0 @@
import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import crypto from 'node:crypto';
import { Response } from 'express';
import { db } from '../../db/database';
import { UPLOADS_ROOT } from '../../nest/memories/uploads-root';
const TREK_PHOTO_DIR = path.join(UPLOADS_ROOT, 'photos/trek');
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
const inFlight = new Map<string, Promise<Buffer | null>>();
export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
}
function ensureDir(): void {
if (!fs.existsSync(TREK_PHOTO_DIR)) {
fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true });
}
}
function cachedFilePath(key: string): string {
return path.join(TREK_PHOTO_DIR, `${key}.bin`);
}
export function getFresh(key: string): { filePath: string; contentType: string } | null {
const row = db.prepare(
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?'
).get(key) as { content_type: string; fetched_at: number } | undefined;
if (!row) return null;
if (Date.now() - row.fetched_at >= CACHE_TTL) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
return null;
}
const fp = cachedFilePath(key);
if (!fs.existsSync(fp)) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
return null;
}
return { filePath: fp, contentType: row.content_type };
}
export async function put(key: string, bytes: Buffer, contentType: string): Promise<void> {
ensureDir();
const fp = cachedFilePath(key);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
await fsPromises.rename(tmp, fp);
db.prepare(
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)'
).run(key, contentType, Date.now());
}
export function serveFresh(res: Response, key: string): boolean {
const entry = getFresh(key);
if (!entry) return false;
res.set('Content-Type', entry.contentType);
res.set('Cache-Control', 'public, max-age=3600');
res.sendFile(entry.filePath);
return true;
}
export function getInFlight(key: string): Promise<Buffer | null> | undefined {
return inFlight.get(key);
}
export function setInFlight(key: string, promise: Promise<Buffer | null>): void {
inFlight.set(key, promise);
promise.finally(() => inFlight.delete(key));
}
export function sweepExpired(): void {
const cutoff = Date.now() - CACHE_TTL * 2;
const stale = db.prepare(
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?'
).all(cutoff) as { cache_key: string }[];
for (const row of stale) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key);
const fp = cachedFilePath(row.cache_key);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
}
@@ -1,361 +0,0 @@
import { db, canAccessTrip } from '../../db/database';
import { send } from '../../nest/notifications/notifications.bridge';
import { broadcast } from '../../websocket';
import {
ServiceResult,
SyncAlbumResult,
fail,
success,
mapDbError,
Selection,
updateSyncTimeForAlbumLink,
} from './helpersService';
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from '../../nest/photos/photos.bridge';
import { collectAlbumSelection } from './immichService';
import { collectSynologyAlbumSelection } from './synologyService';
import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
function _providers(): Array<{id: string; enabled: boolean}> {
const rows = db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>;
return rows.map(r => ({ id: r.id, enabled: r.enabled === 1 }));
}
function _validProvider(provider: string): ServiceResult<string> {
const providers = _providers();
const found = providers.find(p => p.id === provider);
if (!found) {
return fail(`Provider: "${provider}" is not supported`, 400);
}
if (!found.enabled) {
return fail(`Provider: "${provider}" is not enabled, contact server administrator`, 400);
}
return success(provider);
}
export function listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
const enabledProviders = _providers().filter(p => p.enabled).map(p => p.id);
if (enabledProviders.length === 0) {
return fail('No photo providers enabled', 400);
}
const photos = db.prepare(`
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tp.added_at ASC
`).all(tripId, userId, ...enabledProviders);
return success(photos);
} catch (error) {
return mapDbError(error, 'Failed to list trip photos');
}
}
export function listTripAlbumLinks(tripId: string, userId: number): ServiceResult<any[]> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const enabledProviders = _providers().filter(p => p.enabled).map(p => p.id);
if (enabledProviders.length === 0) {
return fail('No photo providers enabled', 400);
}
try {
const links = db.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
AND tal.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tal.created_at ASC
`).all(tripId, ...enabledProviders);
return success(links);
} catch (error) {
return mapDbError(error, 'Failed to list trip album links');
}
}
//-----------------------------------------------
// managing photos in trip
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
const providerResult = _validProvider(provider);
if (!providerResult.success) {
return providerResult as ServiceResult<boolean>;
}
try {
const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
return success(result.changes > 0);
}
catch (error) {
return mapDbError(error, 'Failed to add photo to trip');
}
}
export async function addTripPhotos(
tripId: string,
userId: number,
shared: boolean,
selections: Selection[],
sid: string,
albumLinkId?: string,
): Promise<ServiceResult<{ added: number; shared: boolean }>> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
if (selections.length === 0) {
return fail('No photos selected', 400);
}
let added = 0;
for (const selection of selections) {
const providerResult = _validProvider(selection.provider);
if (!providerResult.success) {
return providerResult as ServiceResult<{ added: number; shared: boolean }>;
}
for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim();
if (!assetId) continue;
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
if (!result.success) {
return result as ServiceResult<{ added: number; shared: boolean }>;
}
if (result.data) {
added++;
}
}
}
await _notifySharedTripPhotos(tripId, userId, added);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success({ added, shared });
}
export async function setTripPhotoSharing(
tripId: string,
userId: number,
photoId: number,
shared: boolean,
sid?: string,
): Promise<ServiceResult<true>> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(shared ? 1 : 0, tripId, userId, photoId);
await _notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to update photo sharing');
}
}
export function removeTripPhoto(
tripId: string,
userId: number,
photoId: number,
sid?: string,
): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(tripId, userId, photoId);
deleteTrekPhotoIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove trip photo');
}
}
// ----------------------------------------------
// managing album links in trip
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const provider = String(providerRaw || '').toLowerCase();
const albumId = String(albumIdRaw || '').trim();
const albumName = String(albumNameRaw || '').trim();
if (!provider) {
return fail('provider is required', 400);
}
if (!albumId) {
return fail('album_id required', 400);
}
const providerResult = _validProvider(provider);
if (!providerResult.success) {
return providerResult as ServiceResult<true>;
}
try {
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
const result = db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
if (result.changes === 0) {
return fail('Album already linked', 409);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to link album');
}
}
export function removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
const linkedPhotos = db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
db.transaction(() => {
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
})();
for (const { photo_id } of linkedPhotos) {
deleteTrekPhotoIfOrphan(photo_id);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove album link');
}
}
//-----------------------------------------------
// notifications helper
async function _notifySharedTripPhotos(
tripId: string,
actorUserId: number,
added: number,
): Promise<ServiceResult<void>> {
if (added <= 0) return success(undefined);
try {
const actorRow = db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null };
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
return success(undefined);
} catch {
return fail('Failed to send notifications', 500);
}
}
// ── Album sync (orchestration) ────────────────────────────────────────────
//
// The provider services collect the asset ids; adding them to the trip and
// stamping the link is this module's job, because addTripPhotos lives here.
// Keeping the write on this side is what lets immich/synology stop importing
// this module — the cycle immich -> unified -> photoResolver -> immich is gone.
// Both functions return exactly what the combined provider-side ones returned.
export async function syncImmichAlbum(
tripId: string,
linkId: string,
userId: number,
sid: string,
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
const collected = await collectAlbumSelection(tripId, linkId, userId);
if ('error' in collected) return { error: collected.error, status: collected.status };
const result = await addTripPhotos(tripId, userId, true, [collected.selection], sid, linkId);
if ('error' in result) return { error: result.error.message, status: result.error.status };
updateSyncTimeForAlbumLink(linkId);
return { success: true, added: result.data.added, total: collected.total };
}
export async function syncSynologyAlbum(
userId: number,
tripId: string,
linkId: string,
sid: string,
): Promise<ServiceResult<SyncAlbumResult>> {
const collected = await collectSynologyAlbumSelection(userId, tripId, linkId);
if (!collected.success) return collected as ServiceResult<SyncAlbumResult>;
const result = await addTripPhotos(tripId, userId, true, [collected.data.selection], sid, linkId);
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
updateSyncTimeForAlbumLink(linkId);
return success({ added: result.data.added, total: collected.data.total });
}
+1 -1
View File
@@ -36,7 +36,7 @@ vi.mock('../../src/db/database', () => ({
const { getPhotoProviderConfig } = vi.hoisted(() => ({
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
}));
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
vi.mock('../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderConfig }));
import { AddonsModule } from '../../src/nest/addons/addons.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
+13 -2
View File
@@ -57,8 +57,19 @@ const { photoSvc, helperSvc } = vi.hoisted(() => ({
photoSvc: { streamPhoto: vi.fn(), getPhotoInfo: vi.fn(), resolveTrekPhoto: vi.fn() },
helperSvc: { canAccessTrekPhoto: vi.fn() },
}));
vi.mock('../../src/services/memories/photoResolverService', () => photoSvc);
vi.mock('../../src/services/memories/helpersService', () => helperSvc);
// Both are providers since the fold; PhotosModule resolves them through DI, so
// the stubs go on the prototypes rather than on a module path.
vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/nest/memories/photo-resolver.service')>();
actual.PhotoResolverService.prototype.streamPhoto = photoSvc.streamPhoto;
actual.PhotoResolverService.prototype.getPhotoInfo = photoSvc.getPhotoInfo;
return actual;
});
vi.mock('../../src/nest/memories/memories-access.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/nest/memories/memories-access.service')>();
actual.MemoriesAccessService.prototype.canAccessTrekPhoto = helperSvc.canAccessTrekPhoto;
return actual;
});
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
+13 -2
View File
@@ -30,8 +30,19 @@ const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true)
// now come from the real files.constants; only the request-time app_settings
// read is mocked, preserving the old '*'-allowlist semantics for the fixtures.
vi.mock('../../src/nest/files/files.bridge', () => ({ getAllowedExtensions: () => '*' }));
vi.mock('../../src/services/memories/immichService', () => ({ uploadToImmich: vi.fn(), streamImmichAsset: vi.fn() }));
vi.mock('../../src/services/memories/photoResolverService', () => ({ streamPhoto: vi.fn() }));
// The memories providers are injected since the fold — stubbed on the prototype
// so JourneyModule still resolves them through DI.
vi.mock('../../src/nest/memories/immich.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/nest/memories/immich.service')>();
actual.ImmichService.prototype.uploadToImmich = vi.fn();
actual.ImmichService.prototype.streamImmichAsset = vi.fn();
return actual;
});
vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/nest/memories/photo-resolver.service')>();
actual.PhotoResolverService.prototype.streamPhoto = vi.fn();
return actual;
});
const { jsvc } = vi.hoisted(() => ({
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
+13 -11
View File
@@ -52,19 +52,16 @@ const { unified, immich, synology } = vi.hoisted(() => ({
streamSynologyAsset: vi.fn(),
},
}));
vi.mock('../../src/services/memories/unifiedService', () => unified);
vi.mock('../../src/services/memories/immichService', () => immich);
vi.mock('../../src/services/memories/synologyService', () => synology);
// The three provider services and the access check are injected since the fold,
// so they are overridden at the container instead of mocked by module path.
const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() }));
vi.mock('../../src/services/memories/helpersService', async () => {
const actual = await vi.importActual<typeof import('../../src/services/memories/helpersService')>(
'../../src/services/memories/helpersService',
);
return { ...actual, canAccessUserPhoto };
});
import { DatabaseModule } from '../../src/nest/database/database.module';
import { MemoriesModule } from '../../src/nest/memories/memories.module';
import { UnifiedMemoriesService } from '../../src/nest/memories/unified-memories.service';
import { ImmichService } from '../../src/nest/memories/immich.service';
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';
@@ -78,7 +75,12 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [RealtimeModule, MemoriesModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, MemoriesModule] })
.overrideProvider(UnifiedMemoriesService).useValue(unified)
.overrideProvider(ImmichService).useValue(immich)
.overrideProvider(SynologyService).useValue(synology)
.overrideProvider(MemoriesAccessService).useValue({ canAccessUserPhoto })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
@@ -11,7 +11,7 @@ import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) }));
vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
vi.mock('../../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderConfig }));
import { AddonsService } from '../../../src/nest/addons/addons.service';
@@ -0,0 +1,433 @@
/**
* ImmichService — credentials, settings, connection, browsing and the asset
* proxy.
*
* The provider code came out of services/memories/ at ~61% branch coverage,
* because that tree is outside the gate: the integration suites drive it over
* HTTP and never reach most of the failure paths. These cases go at the service
* directly with safeFetch stubbed, so every "upstream said no" branch is pinned.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
return {
testDb: db,
dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null },
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'x'.repeat(40),
ENCRYPTION_KEY: 'a'.repeat(64),
updateJwtSecret: () => {},
}));
const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) }));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: decryptMock,
encrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
}));
const { safeFetch, checkSsrf } = vi.hoisted(() => ({ safeFetch: vi.fn(), checkSsrf: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({
safeFetch,
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError: class extends Error {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { ImmichService } from '../../../src/nest/memories/immich.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
const audit = { writeAudit: vi.fn() };
const access = { getAlbumIdFromLink: vi.fn() };
const dbs = new DatabaseService(testDb);
const svc = new ImmichService(dbs, audit as unknown as AuditService, access as unknown as MemoriesAccessService);
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);
}
/** A Response-ish object with only what the service reads. */
function upstream(opts: { ok?: boolean; status?: number; json?: unknown; url?: string; contentType?: string | null; body?: string }) {
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
url: opts.url ?? '',
headers: { get: (h: string) => (h === 'content-type' ? (opts.contentType ?? 'image/jpeg') : null) },
json: async () => opts.json ?? {},
arrayBuffer: async () => Buffer.from(opts.body ?? 'bytes'),
};
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
vi.clearAllMocks();
decryptMock.mockImplementation((v: string) => v);
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
testDb.prepare('DELETE FROM users').run();
seedUser(USER, 'https://immich.test', 'key-1');
});
afterAll(() => testDb.close());
describe('getImmichCredentials', () => {
it('IMMICH-001: returns null when the user row is missing', () => {
expect(svc.getImmichCredentials(999)).toBeNull();
});
it('IMMICH-002: returns null without a URL', () => {
seedUser(2, null, 'key');
expect(svc.getImmichCredentials(2)).toBeNull();
});
it('IMMICH-003: returns null without an API key', () => {
seedUser(3, 'https://immich.test', null);
expect(svc.getImmichCredentials(3)).toBeNull();
});
it('IMMICH-004: returns null when the stored key cannot be decrypted', () => {
decryptMock.mockReturnValue(null);
expect(svc.getImmichCredentials(USER)).toBeNull();
});
it('IMMICH-005: returns the decrypted pair otherwise', () => {
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
});
});
describe('isValidAssetId', () => {
it('IMMICH-006: accepts UUID-ish ids and rejects traversal or over-long input', () => {
expect(svc.isValidAssetId('abc-123_XYZ')).toBe(true);
expect(svc.isValidAssetId('../etc/passwd')).toBe(false);
expect(svc.isValidAssetId('a'.repeat(101))).toBe(false);
});
});
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 });
});
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 });
});
it('IMMICH-009: surfaces the auto-upload flag both ways', () => {
svc.setImmichAutoUpload(USER, true);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(true);
svc.setImmichAutoUpload(USER, false);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(false);
});
});
describe('saveImmichSettings', () => {
it('IMMICH-010: refuses a URL the SSRF guard blocks, and writes nothing', async () => {
checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });
const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null);
expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' });
expect(svc.getImmichCredentials(USER)!.immich_url).toBe('https://immich.test');
});
it('IMMICH-011: stores a trimmed URL and reports plain success', async () => {
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' });
});
it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => {
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true, resolvedIp: '192.168.1.9' });
const result = await svc.saveImmichSettings(USER, 'http://nas.local', 'k', '5.6.7.8');
expect(result.success).toBe(true);
expect(result.warning).toContain('192.168.1.9');
expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'immich.private_ip_configured', ip: '5.6.7.8' }));
});
it('IMMICH-013: an empty URL clears the connection without an SSRF check', async () => {
const result = await svc.saveImmichSettings(USER, undefined, undefined, null);
expect(result).toEqual({ success: true });
expect(checkSsrf).not.toHaveBeenCalled();
expect(svc.getImmichCredentials(USER)).toBeNull();
});
});
describe('testConnection', () => {
it('IMMICH-014: refuses a blocked URL before any request', async () => {
checkSsrf.mockResolvedValue({ allowed: false, error: 'nope' });
expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'nope' });
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-015: falls back to a generic message when the guard gives no reason', async () => {
checkSsrf.mockResolvedValue({ allowed: false });
expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'Invalid Immich URL' });
});
it('IMMICH-016: reports the HTTP status when the server rejects the key', async () => {
safeFetch.mockResolvedValue(upstream({ ok: false, status: 401 }));
expect(await svc.testConnection('https://immich.test', 'bad')).toEqual({ connected: false, error: 'HTTP 401' });
});
it('IMMICH-017: returns the user on success', async () => {
safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada', email: 'ada@example.test' } }));
const result = await svc.testConnection('https://immich.test', 'k');
expect(result.connected).toBe(true);
expect(result.user).toEqual({ name: 'Ada', email: 'ada@example.test' });
});
it('IMMICH-018: reports the canonical URL after an http → https upgrade', async () => {
safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://immich.test/api/users/me' }));
const result = await svc.testConnection('http://immich.test', 'k');
expect(result.canonicalUrl).toBe('https://immich.test');
});
it('IMMICH-019: does NOT rewrite the URL when the host changed — that is a redirect elsewhere', async () => {
safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://evil.test/api/users/me' }));
const result = await svc.testConnection('http://immich.test', 'k');
expect(result.canonicalUrl).toBeUndefined();
});
it('IMMICH-020: reports the thrown message when the request fails', async () => {
safeFetch.mockRejectedValue(new Error('ECONNREFUSED'));
expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'ECONNREFUSED' });
});
it('IMMICH-021: falls back to "Connection failed" for a non-Error rejection', async () => {
safeFetch.mockRejectedValue('boom');
expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'Connection failed' });
});
});
describe('getConnectionStatus', () => {
it('IMMICH-022: says "Not configured" without credentials', async () => {
seedUser(5, null, null);
expect(await svc.getConnectionStatus(5)).toEqual({ connected: false, error: 'Not configured' });
});
it('IMMICH-023: reports the HTTP status, the user, and a thrown failure', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 500 }));
expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'HTTP 500' });
safeFetch.mockResolvedValueOnce(upstream({ json: { name: 'Ada' } }));
expect((await svc.getConnectionStatus(USER)).user).toEqual({ name: 'Ada', email: undefined });
safeFetch.mockRejectedValueOnce(new Error('timeout'));
expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'timeout' });
});
});
describe('browseTimeline', () => {
it('IMMICH-024: 400s without credentials', async () => {
seedUser(6, null, null);
expect(await svc.browseTimeline(6)).toEqual({ error: 'Immich not configured', status: 400 });
});
it('IMMICH-025: forwards the upstream status on failure and the buckets on success', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 503 }));
expect(await svc.browseTimeline(USER)).toEqual({ error: 'Failed to fetch from Immich', status: 503 });
safeFetch.mockResolvedValueOnce(upstream({ json: [{ count: 3 }] }));
expect(await svc.browseTimeline(USER)).toEqual({ buckets: [{ count: 3 }] });
});
it('IMMICH-026: maps an unreachable server to 502', async () => {
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.browseTimeline(USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
});
describe('searchPhotos', () => {
it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => {
seedUser(7, null, null);
expect(await svc.searchPhotos(7)).toEqual({ error: 'Immich not configured', status: 400 });
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.searchPhotos(USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
it('IMMICH-028: forwards the upstream status on a failed search', async () => {
safeFetch.mockResolvedValue(upstream({ ok: false, status: 400 }));
expect(await svc.searchPhotos(USER)).toEqual({ error: 'Search failed', status: 400 });
});
it('IMMICH-029: drops hidden assets an older server still returns (#1474)', async () => {
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [
{ id: 'ok', fileCreatedAt: '2026-01-01' },
{ id: 'hidden-flag', visibility: 'hidden' },
{ id: 'legacy-flag', isVisible: false },
] } },
}));
const result = await svc.searchPhotos(USER);
expect(result.assets!.map(a => a.id)).toEqual(['ok']);
});
it('IMMICH-030: maps exif fields, falls back to createdAt, and marks videos', async () => {
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [
{ id: 'a', createdAt: '2026-02-02', type: 'VIDEO', exifInfo: { city: 'Kyoto', country: 'JP', latitude: 35.0, longitude: 135.7 } },
{ id: 'b', fileCreatedAt: '2026-03-03', type: 'IMAGE', exifInfo: { latitude: 'nope' } },
] } },
}));
const [a, b] = (await svc.searchPhotos(USER)).assets!;
expect(a).toMatchObject({ takenAt: '2026-02-02', city: 'Kyoto', country: 'JP', lat: 35.0, lng: 135.7, mediaType: 'video' });
// A non-numeric coordinate becomes null rather than reaching the client as a string.
expect(b).toMatchObject({ takenAt: '2026-03-03', city: null, country: null, lat: null, lng: null, mediaType: 'image' });
});
it('IMMICH-031: hasMore counts the raw page, not the filtered one', async () => {
// Otherwise a page that is entirely hidden assets would stop pagination dead.
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [{ id: 'x', visibility: 'hidden' }, { id: 'y', visibility: 'hidden' }] } },
}));
const result = await svc.searchPhotos(USER, undefined, undefined, 1, 2);
expect(result.assets).toEqual([]);
expect(result.hasMore).toBe(true);
});
it('IMMICH-032: turns a from/to pair into the upstream date bounds', async () => {
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } }));
await svc.searchPhotos(USER, '2026-01-01', '2026-01-31');
const body = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body);
expect(body.takenAfter).toBe('2026-01-01T00:00:00.000Z');
expect(body.takenBefore).toBe('2026-01-31T23:59:59.999Z');
// Load-bearing on Immich >= 1.133: hidden assets must not cross the wire.
expect(body.visibility).toBe('timeline');
});
});
describe('getAssetInfo', () => {
it('IMMICH-033: 404s when the owner has no credentials', async () => {
seedUser(8, null, null);
expect(await svc.getAssetInfo(USER, 'a1', 8)).toEqual({ error: 'Not found', status: 404 });
});
it('IMMICH-034: reads the OWNER\'s credentials, not the requester\'s', async () => {
seedUser(9, 'https://owner.test', 'owner-key');
safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } }));
await svc.getAssetInfo(USER, 'a1', 9);
expect(safeFetch.mock.calls[0][0]).toContain('https://owner.test');
expect((safeFetch.mock.calls[0][1] as { headers: Record<string, string> }).headers['x-api-key']).toBe('owner-key');
});
it('IMMICH-035: forwards the upstream status and maps a proxy failure to 502', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 404 }));
expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Failed', status: 404 });
safeFetch.mockRejectedValueOnce(new Error('down'));
expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 });
});
it('IMMICH-036: composes camera/focal/aperture only when both halves are present', async () => {
safeFetch.mockResolvedValueOnce(upstream({
json: { id: 'a1', fileCreatedAt: 'x', originalFileName: 'IMG.jpg', exifInfo: { make: 'Fuji', model: 'X100V', focalLength: 23, fNumber: 2, iso: 200 } },
}));
const full = (await svc.getAssetInfo(USER, 'a1')).data;
expect(full).toMatchObject({ camera: 'Fuji X100V', focalLength: '23mm', aperture: 'f/2', iso: 200, fileName: 'IMG.jpg' });
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'a2', exifInfo: { make: 'Fuji' } } }));
const partial = (await svc.getAssetInfo(USER, 'a2')).data;
expect(partial.camera).toBeNull();
expect(partial.focalLength).toBeNull();
});
it('IMMICH-037: survives an asset with no exif block at all', async () => {
safeFetch.mockResolvedValue(upstream({ json: { id: 'bare' } }));
const data = (await svc.getAssetInfo(USER, 'bare')).data;
expect(data).toMatchObject({ id: 'bare', width: null, height: null, city: null, fileName: null });
});
});
describe('fetchImmichThumbnailBytes', () => {
it('IMMICH-038: 404s without credentials for the owner', async () => {
seedUser(10, null, null);
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1', 10)).toEqual({ error: 'Not found', status: 404 });
});
it('IMMICH-039: returns the bytes with the upstream content type', async () => {
safeFetch.mockResolvedValue(upstream({ contentType: 'image/webp', body: 'thumb' }));
const result = await svc.fetchImmichThumbnailBytes(USER, 'a1');
expect(result).toEqual({ bytes: Buffer.from('thumb'), contentType: 'image/webp' });
});
it('IMMICH-040: defaults the content type when the server omits it', async () => {
safeFetch.mockResolvedValue(upstream({ contentType: null, body: 'thumb' }));
const result = await svc.fetchImmichThumbnailBytes(USER, 'a1') as { contentType: string };
expect(result.contentType).toBe('image/jpeg');
});
it('IMMICH-041: forwards the upstream status and maps a throw to 502', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 403 }));
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Upstream error', status: 403 });
safeFetch.mockRejectedValueOnce(new Error('down'));
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 });
});
});
describe('collectAlbumSelection', () => {
it('IMMICH-042: 404s when the album link does not resolve', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: false });
expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Album link not found', status: 404 });
});
it('IMMICH-043: 400s when the user has no Immich credentials', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
seedUser(11, null, null);
expect(await svc.collectAlbumSelection('1', 'l1', 11)).toEqual({ error: 'Immich not configured', status: 400 });
});
it('IMMICH-044: returns only visible image assets, with the raw total', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
safeFetch.mockResolvedValue(upstream({
json: { assets: [
{ id: 'img', type: 'IMAGE' },
{ id: 'vid', type: 'VIDEO' },
{ id: 'hidden', type: 'IMAGE', visibility: 'hidden' },
] },
}));
const result = await svc.collectAlbumSelection('1', 'l1', USER) as { selection: { asset_ids: string[] }; total: number };
expect(result.selection.asset_ids).toEqual(['img']);
expect(result.total).toBe(1);
});
it('IMMICH-045: maps an unreachable server to 502', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
});
@@ -1,5 +1,7 @@
/**
* Unit tests for memories/helpersService — MEM-HELPERS-001 to MEM-HELPERS-020.
* Unit tests for the memories helpers and access checks — MEM-HELPERS-001 to 020.
* Moved with the fold: the pure half is nest/memories/memories.helpers.ts, the
* DB-backed half is MemoriesAccessService.
* Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -55,7 +57,12 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService';
import { mapDbError, pipeAsset } from '../../../src/nest/memories/memories.helpers';
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
const access = new MemoriesAccessService(new DatabaseService(testDb));
const getAlbumIdFromLink = access.getAlbumIdFromLink.bind(access);
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
beforeAll(() => {
@@ -274,3 +281,175 @@ describe('pipeAsset fetch options (#1611)', () => {
errorSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// The access checks
//
// These decide who may fetch someone else's provider photo, and they had no
// cases at all — they sat outside the measured tree. A provider asset is
// reachable through a trip *or* a journey, which is why neither check can live
// in the trips or the journey domain alone, and why both need pinning here.
// ---------------------------------------------------------------------------
/** A trek_photos row plus the trip_photos link that shares it. */
function shareInTrip(tripId: number, ownerId: number, assetId: string, provider = 'immich', shared = 1): number {
const photoId = Number(testDb.prepare(
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
).run(provider, assetId, ownerId).lastInsertRowid);
testDb.prepare('INSERT INTO trip_photos (trip_id, photo_id, user_id, shared) VALUES (?, ?, ?, ?)')
.run(tripId, photoId, ownerId, shared);
return photoId;
}
function makeJourney(userId: number): number {
return Number(testDb.prepare(
"INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'J', 'draft', 0, 0)"
).run(userId).lastInsertRowid);
}
describe('canAccessUserPhoto', () => {
it('MEM-ACCESS-001: the owner always passes, with no lookup needed', () => {
expect(access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true);
});
it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true);
});
it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-private', 'immich', 0);
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false);
});
it('MEM-ACCESS-004: a stranger is refused even for a shared asset', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const trip = createTrip(testDb, owner.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false);
});
it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'same-id', 'immich');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false);
});
it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-asset', ?)"
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true);
});
it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-asset-2', ?)"
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false);
});
it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
expect(access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false);
});
});
describe('canAccessTrekPhoto', () => {
it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', () => {
expect(access.canAccessTrekPhoto(1, 999999)).toBe(false);
});
it('MEM-ACCESS-011: the owner passes', () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'own', ?)"
).run(user.id).lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(true);
});
it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
const photoId = shareInTrip(trip.id, owner.id, 'trek-shared');
expect(access.canAccessTrekPhoto(member.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false);
});
it('MEM-ACCESS-013: the trip owner passes without a trip_members row', () => {
const { user: owner } = createUser(testDb);
const { user: uploader } = createUser(testDb, { username: 'uploader' });
const trip = createTrip(testDb, owner.id);
const photoId = shareInTrip(trip.id, uploader.id, 'owner-path');
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
});
it('MEM-ACCESS-014: a journey contributor passes', () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-trek', ?)"
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true);
});
it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb, { username: 'outsider' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'journey/x.jpg')"
).run().lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false);
});
it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'orphan.jpg')"
).run().lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(false);
});
});
@@ -15,7 +15,6 @@ const unified = vi.hoisted(() => ({
removeTripPhoto: vi.fn(() => ({ data: {} })),
setTripPhotoSharing: vi.fn(async () => ({ data: {} })),
}));
vi.mock('../../../src/services/memories/unifiedService', () => unified);
const immich = vi.hoisted(() => ({
getConnectionSettings: vi.fn(() => ({})),
@@ -32,7 +31,6 @@ const immich = vi.hoisted(() => ({
getAssetInfo: vi.fn(async () => ({ data: {} })),
isValidAssetId: vi.fn(() => true),
}));
vi.mock('../../../src/services/memories/immichService', () => immich);
const synology = vi.hoisted(() => ({
getSynologySettings: vi.fn(async () => ({ success: true, data: {} })),
@@ -46,16 +44,18 @@ const synology = vi.hoisted(() => ({
getSynologyAssetInfo: vi.fn(async () => ({ success: true, data: {} })),
streamSynologyAsset: vi.fn(async () => undefined),
}));
vi.mock('../../../src/services/memories/synologyService', () => synology);
const helpers = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn(() => true) }));
vi.mock('../../../src/services/memories/helpersService', () => helpers);
const ws = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ws);
import { MemoriesService } from '../../../src/nest/memories/memories.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import type { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
const res = {} as import('express').Response;
@@ -64,7 +64,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
beforeEach(() => {
vi.clearAllMocks();
svc = new MemoriesService(new RealtimeService());
svc = new MemoriesService(
new RealtimeService(),
unified as unknown as UnifiedMemoriesService,
immich as unknown as ImmichService,
synology as unknown as SynologyService,
helpers as unknown as MemoriesAccessService,
);
});
it('access check + broadcast forward verbatim', () => {
@@ -0,0 +1,230 @@
/**
* PhotoResolverService — the provider dispatch half of the old
* photoResolverService.
*
* It routes a stored trek_photo to bytes: local file, generated thumbnail, or
* the provider that owns the asset. None of this was measured before the fold
* (services/memories/ sits outside the coverage gate), so the branch that
* decides a poster-less video must 404 rather than stream the whole file as a
* "thumbnail" had no case at all.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
return {
testDb: db,
dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null },
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'x'.repeat(40),
ENCRYPTION_KEY: 'a'.repeat(64),
updateJwtSecret: () => {},
}));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: (v: string) => v,
encrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
import type { ThumbnailService } from '../../../src/nest/memories/thumbnail.service';
import type { TrekPhotoCacheService } from '../../../src/nest/memories/trek-photo-cache.service';
const immich = {
streamImmichAsset: vi.fn(),
fetchImmichThumbnailBytes: vi.fn(),
getAssetInfo: vi.fn(),
};
const synology = {
streamSynologyAsset: vi.fn(),
fetchSynologyThumbnailBytes: vi.fn(),
getSynologyAssetInfo: vi.fn(),
};
const thumbnails = { ensureLocalThumbnail: vi.fn() };
// The cache has its own suite; here it is a switch for hit/miss/in-flight.
const cache = {
cacheKey: vi.fn(() => 'k'),
serveFresh: vi.fn(() => false),
getInFlight: vi.fn(() => undefined),
setInFlight: vi.fn(),
put: vi.fn(),
};
const dbs = new DatabaseService(testDb);
const repo = new TrekPhotosRepository(dbs);
const svc = new PhotoResolverService(
repo,
immich as unknown as ImmichService,
synology as unknown as SynologyService,
thumbnails as unknown as ThumbnailService,
cache as unknown as TrekPhotoCacheService,
);
function makeRes() {
return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), sendFile: vi.fn(), end: vi.fn() };
}
/** Insert a trek_photos row directly so each case controls the exact shape. */
function insertPhoto(cols: Record<string, unknown>): number {
const keys = Object.keys(cols);
const res = testDb.prepare(
`INSERT INTO trek_photos (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`
).run(...keys.map(k => cols[k] as never));
return Number(res.lastInsertRowid);
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
vi.clearAllMocks();
// owner_id carries a FK to users; the cases pick fixed ids, so seed them.
testDb.prepare('DELETE FROM trek_photos').run();
for (const id of [2, 3, 4, 5, 9]) {
testDb.prepare("INSERT OR IGNORE INTO users (id, username, email, password_hash) VALUES (?, ?, ?, 'x')")
.run(id, `u${id}`, `u${id}@example.test`);
}
cache.serveFresh.mockReturnValue(false);
cache.getInFlight.mockReturnValue(undefined);
cache.cacheKey.mockReturnValue('k');
});
afterAll(() => testDb.close());
describe('streamPhoto — dispatch', () => {
it('RESOLVE-001: 404s a photo id that does not exist', async () => {
const res = makeRes();
await svc.streamPhoto(res as never, 1, 999999, 'original');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' });
});
it('RESOLVE-002: a local row whose file is gone answers "File not found", not a provider call', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/missing.jpg' });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'original');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'File not found' });
expect(immich.streamImmichAsset).not.toHaveBeenCalled();
expect(synology.streamSynologyAsset).not.toHaveBeenCalled();
});
it('RESOLVE-003: a poster-less video 404s instead of streaming the whole file as a thumbnail', async () => {
// #823: falling through here would push a full video down a thumbnail request.
const id = insertPhoto({ provider: 'local', file_path: 'nope/clip.mp4', media_type: 'video' });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'No poster available' });
expect(thumbnails.ensureLocalThumbnail).not.toHaveBeenCalled();
});
it('RESOLVE-004: a local image with no thumbnail asks for one to be generated', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
thumbnails.ensureLocalThumbnail.mockResolvedValue(null);
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
expect(thumbnails.ensureLocalThumbnail).toHaveBeenCalledOnce();
});
it('RESOLVE-005: a generated thumbnail is recorded on the row', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
thumbnails.ensureLocalThumbnail.mockResolvedValue({ thumbnailRelPath: 'journey/thumbs/abc.jpg', width: 800, height: 600 });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
const row = testDb.prepare('SELECT thumbnail_path, width, height FROM trek_photos WHERE id = ?').get(id) as { thumbnail_path: string; width: number; height: number };
expect(row).toEqual({ thumbnail_path: 'journey/thumbs/abc.jpg', width: 800, height: 600 });
});
it('RESOLVE-006: an immich original goes straight to the provider with the range header', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
const res = makeRes();
await svc.streamPhoto(res as never, 3, id, 'original', 'bytes=0-99');
expect(immich.streamImmichAsset).toHaveBeenCalledWith(res, 3, 'a1', 'original', 5, expect.objectContaining({ range: 'bytes=0-99' }));
});
it('RESOLVE-007: an immich thumbnail served from cache never reaches the provider', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
cache.serveFresh.mockReturnValue(true);
const res = makeRes();
await svc.streamPhoto(res as never, 3, id, 'thumbnail');
expect(immich.fetchImmichThumbnailBytes).not.toHaveBeenCalled();
expect(immich.streamImmichAsset).not.toHaveBeenCalled();
});
it('RESOLVE-008: a synology original forwards the decrypted passphrase', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9, passphrase: 'secret' });
const res = makeRes();
await svc.streamPhoto(res as never, 4, id, 'original');
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 4, 9, 's1', 'original', undefined, 'secret');
});
it('RESOLVE-009: a synology row without a passphrase forwards undefined, not an empty string', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's2', owner_id: 9 });
const res = makeRes();
await svc.streamPhoto(res as never, 4, id, 'original');
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 4, 9, 's2', 'original', undefined, undefined);
});
});
describe('getPhotoInfo — dispatch', () => {
it('RESOLVE-010: rejects an unknown photo id', async () => {
const result = await svc.getPhotoInfo(1, 999999);
expect(result.success).toBe(false);
});
it('RESOLVE-011: asks immich for an immich asset', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
immich.getAssetInfo.mockResolvedValue({ success: true, data: { id: 'a1' } });
await svc.getPhotoInfo(3, id);
expect(immich.getAssetInfo).toHaveBeenCalled();
expect(synology.getSynologyAssetInfo).not.toHaveBeenCalled();
});
it('RESOLVE-012: asks synology for a synology asset', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9 });
synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: 's1' } });
await svc.getPhotoInfo(4, id);
expect(synology.getSynologyAssetInfo).toHaveBeenCalled();
expect(immich.getAssetInfo).not.toHaveBeenCalled();
});
it('RESOLVE-013: an unknown provider is an error, not a crash', async () => {
const id = insertPhoto({ provider: 'picasa', asset_id: 'p1', owner_id: 2 });
const result = await svc.getPhotoInfo(1, id);
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,104 @@
/**
* ThumbnailService — the downscaled JPEG for a locally uploaded journey photo.
*
* Untested before the fold, because it lived outside the measured tree. The
* cases below pin the three things that decide whether a gallery shows a
* picture or a broken tile: the addon gate, the "source is gone" bail-out, and
* the mtime check that avoids regenerating an up-to-date thumbnail.
*/
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Jimp } from 'jimp';
import { ThumbnailService } from '../../../src/nest/memories/thumbnail.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
const svc = new ThumbnailService({ isAddonEnabled } as unknown as AddonsService);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-thumbs-'));
/** A real 1200x900 JPEG — Jimp has to be able to decode it for the happy path. */
async function writeSourceImage(rel: string): Promise<void> {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
const img = new Jimp({ width: 1200, height: 900, color: 0xff0000ff });
await img.write(abs as `${string}.jpg`);
}
beforeEach(() => {
vi.clearAllMocks();
isAddonEnabled.mockReturnValue(true);
});
afterAll(() => {
fs.rmSync(root, { recursive: true, force: true });
});
describe('ensureLocalThumbnail', () => {
it('THUMB-001: returns null when the journey addon is off, without touching the disk', async () => {
isAddonEnabled.mockReturnValue(false);
expect(await svc.ensureLocalThumbnail(root, 'anything.jpg')).toBeNull();
});
it('THUMB-002: returns null when the source file does not exist', async () => {
expect(await svc.ensureLocalThumbnail(root, 'missing/nope.jpg')).toBeNull();
});
it('THUMB-003: downscales an oversized image and reports the resulting size', async () => {
await writeSourceImage('journey/big.jpg');
const result = await svc.ensureLocalThumbnail(root, 'journey/big.jpg');
expect(result).not.toBeNull();
expect(result!.thumbnailRelPath).toMatch(/^journey\/thumbs\/[0-9a-f]{16}\.jpg$/);
// 1200x900 fits into an 800 box as 800x600.
expect(Math.max(result!.width, result!.height)).toBeLessThanOrEqual(800);
expect(fs.existsSync(path.join(root, result!.thumbnailRelPath))).toBe(true);
});
it('THUMB-004: the path is deterministic, so concurrent requests cannot race on two names', async () => {
await writeSourceImage('journey/stable.jpg');
const first = await svc.ensureLocalThumbnail(root, 'journey/stable.jpg');
const second = await svc.ensureLocalThumbnail(root, 'journey/stable.jpg');
expect(second!.thumbnailRelPath).toBe(first!.thumbnailRelPath);
});
it('THUMB-005: reuses an existing thumbnail that is newer than the source', async () => {
await writeSourceImage('journey/cached.jpg');
const first = await svc.ensureLocalThumbnail(root, 'journey/cached.jpg');
const thumbAbs = path.join(root, first!.thumbnailRelPath);
const mtimeBefore = fs.statSync(thumbAbs).mtimeMs;
const second = await svc.ensureLocalThumbnail(root, 'journey/cached.jpg');
expect(second).toEqual(first);
expect(fs.statSync(thumbAbs).mtimeMs).toBe(mtimeBefore);
});
it('THUMB-006: regenerates when the source is newer than the thumbnail', async () => {
await writeSourceImage('journey/changed.jpg');
const first = await svc.ensureLocalThumbnail(root, 'journey/changed.jpg');
const thumbAbs = path.join(root, first!.thumbnailRelPath);
// Age the thumbnail past the source.
const old = new Date(Date.now() - 60_000);
fs.utimesSync(thumbAbs, old, old);
const second = await svc.ensureLocalThumbnail(root, 'journey/changed.jpg');
expect(second!.thumbnailRelPath).toBe(first!.thumbnailRelPath);
expect(fs.statSync(thumbAbs).mtimeMs).toBeGreaterThan(old.getTime());
});
it('THUMB-007: returns null for a file Jimp cannot decode instead of throwing', async () => {
const abs = path.join(root, 'journey/corrupt.jpg');
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'not an image at all');
expect(await svc.ensureLocalThumbnail(root, 'journey/corrupt.jpg')).toBeNull();
});
});
@@ -0,0 +1,185 @@
/**
* TrekPhotoCacheService — the disk + metadata cache for provider assets.
*
* It had no suite of its own: it lived in services/memories/, which the
* coverage gate does not measure, so a cache that silently stopped evicting or
* stopped deduping concurrent fetches would not have failed anything. The
* stampede guard in particular is the reason the in-flight map is module-scoped
* rather than an instance field.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
return {
testDb: db,
dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null },
};
});
vi.mock('../../../src/db/database', () => dbMock);
import fs from 'node:fs';
import path from 'node:path';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TrekPhotoCacheService, CACHE_TTL } from '../../../src/nest/memories/trek-photo-cache.service';
import { UPLOADS_ROOT } from '../../../src/nest/memories/uploads-root';
const svc = new TrekPhotoCacheService(new DatabaseService(testDb));
const CACHE_DIR = path.join(UPLOADS_ROOT, 'photos/trek');
const written: string[] = [];
/** Unique per case so parallel files cannot collide on the shared cache dir. */
function freshKey(label: string): string {
const key = svc.cacheKey('test', `${label}-${process.pid}-${written.length}`, 'thumbnail', 1);
written.push(key);
return key;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
testDb.prepare('DELETE FROM trek_photo_cache_meta').run();
});
afterAll(() => {
for (const key of written) {
try { fs.unlinkSync(path.join(CACHE_DIR, `${key}.bin`)); } catch { /* never written */ }
}
testDb.close();
});
describe('cacheKey', () => {
it('CACHE-001: is deterministic and separates provider, asset, kind and owner', () => {
const a = svc.cacheKey('immich', 'asset-1', 'thumbnail', 7);
expect(svc.cacheKey('immich', 'asset-1', 'thumbnail', 7)).toBe(a);
expect(svc.cacheKey('immich', 'asset-1', 'original', 7)).not.toBe(a);
expect(svc.cacheKey('immich', 'asset-1', 'thumbnail', 8)).not.toBe(a);
expect(svc.cacheKey('synologyphotos', 'asset-1', 'thumbnail', 7)).not.toBe(a);
});
});
describe('put / getFresh', () => {
it('CACHE-002: a stored entry comes back with its content type and a real file', async () => {
const key = freshKey('hit');
await svc.put(key, Buffer.from('jpegbytes'), 'image/jpeg');
const entry = svc.getFresh(key);
expect(entry).not.toBeNull();
expect(entry!.contentType).toBe('image/jpeg');
expect(fs.readFileSync(entry!.filePath).toString()).toBe('jpegbytes');
});
it('CACHE-003: an unknown key is a miss, not an error', () => {
expect(svc.getFresh('no-such-key')).toBeNull();
});
it('CACHE-004: an entry past its TTL is a miss and its metadata row is dropped', async () => {
const key = freshKey('stale');
await svc.put(key, Buffer.from('old'), 'image/jpeg');
testDb.prepare('UPDATE trek_photo_cache_meta SET fetched_at = ? WHERE cache_key = ?')
.run(Date.now() - CACHE_TTL - 1000, key);
expect(svc.getFresh(key)).toBeNull();
expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined();
});
it('CACHE-005: metadata without its file is a miss and the row is dropped', () => {
const key = 'orphan-meta-row';
testDb.prepare('INSERT INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)')
.run(key, 'image/jpeg', Date.now());
expect(svc.getFresh(key)).toBeNull();
expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined();
});
it('CACHE-006: writing the same key twice replaces the bytes rather than duplicating the row', async () => {
const key = freshKey('replace');
await svc.put(key, Buffer.from('first'), 'image/jpeg');
await svc.put(key, Buffer.from('second'), 'image/png');
const rows = testDb.prepare('SELECT content_type FROM trek_photo_cache_meta WHERE cache_key = ?').all(key);
expect(rows).toHaveLength(1);
expect(svc.getFresh(key)!.contentType).toBe('image/png');
expect(fs.readFileSync(svc.getFresh(key)!.filePath).toString()).toBe('second');
});
});
describe('serveFresh', () => {
it('CACHE-007: sends the cached file with a one-hour cache header', async () => {
const key = freshKey('serve');
await svc.put(key, Buffer.from('bytes'), 'image/webp');
const res = { set: vi.fn(), sendFile: vi.fn() };
expect(svc.serveFresh(res as never, key)).toBe(true);
expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/webp');
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
expect(res.sendFile).toHaveBeenCalledOnce();
});
it('CACHE-008: reports a miss without touching the response', () => {
const res = { set: vi.fn(), sendFile: vi.fn() };
expect(svc.serveFresh(res as never, 'nothing-cached')).toBe(false);
expect(res.set).not.toHaveBeenCalled();
expect(res.sendFile).not.toHaveBeenCalled();
});
});
describe('the stampede guard', () => {
it('CACHE-009: a second caller gets the first caller\'s in-flight promise', async () => {
const key = 'inflight-key';
let resolveFetch!: (b: Buffer) => void;
const fetch = new Promise<Buffer | null>((resolve) => { resolveFetch = resolve as (b: Buffer) => void; });
svc.setInFlight(key, fetch);
expect(svc.getInFlight(key)).toBe(fetch);
resolveFetch(Buffer.from('done'));
await fetch;
// Settling clears the slot, so the next request starts a fresh fetch.
await Promise.resolve();
expect(svc.getInFlight(key)).toBeUndefined();
});
it('CACHE-010: the map is shared across instances — the scheduler builds its own', () => {
// Load-bearing: a per-instance map would give the cron and the container
// separate guards and the dedup would be silently gone.
const other = new TrekPhotoCacheService(new DatabaseService(testDb));
const promise = Promise.resolve<Buffer | null>(null);
svc.setInFlight('shared-key', promise);
expect(other.getInFlight('shared-key')).toBe(promise);
});
});
describe('sweepExpired', () => {
it('CACHE-011: drops rows and files past twice the TTL and leaves fresh ones alone', async () => {
const staleKey = freshKey('sweep-stale');
const freshKeyId = freshKey('sweep-fresh');
await svc.put(staleKey, Buffer.from('old'), 'image/jpeg');
await svc.put(freshKeyId, Buffer.from('new'), 'image/jpeg');
testDb.prepare('UPDATE trek_photo_cache_meta SET fetched_at = ? WHERE cache_key = ?')
.run(Date.now() - CACHE_TTL * 2 - 1000, staleKey);
const staleFile = path.join(CACHE_DIR, `${staleKey}.bin`);
svc.sweepExpired();
expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(staleKey)).toBeUndefined();
expect(fs.existsSync(staleFile)).toBe(false);
expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(freshKeyId)).toBeDefined();
});
it('CACHE-012: survives a metadata row whose file is already gone', () => {
const key = 'sweep-orphan';
testDb.prepare('INSERT INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)')
.run(key, 'image/jpeg', Date.now() - CACHE_TTL * 3);
expect(() => svc.sweepExpired()).not.toThrow();
expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined();
});
});
@@ -1,5 +1,6 @@
/**
* Unit tests for memories/unifiedService — MEM-UNIFIED-001 to MEM-UNIFIED-010.
* Unit tests for UnifiedMemoriesService — MEM-UNIFIED-001 to MEM-UNIFIED-010.
* Moved 1:1 with the fold; the free functions became methods.
* Covers error paths: access denied, disabled provider, no providers enabled.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -44,15 +45,32 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import {
listTripPhotos,
listTripAlbumLinks,
addTripPhotos,
setTripPhotoSharing,
removeTripPhoto,
createTripAlbumLink,
removeAlbumLink,
} from '../../../src/services/memories/unifiedService';
import { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service';
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { DatabaseService } from '../../../src/nest/database/database.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
// The album-sync paths are the providers' half and have their own suites; these
// cases never reach them, so stubs keep the graph small.
const dbs = new DatabaseService(testDb);
const svc = new UnifiedMemoriesService(
dbs,
new TrekPhotosRepository(dbs),
{} as ImmichService,
{} as SynologyService,
new MemoriesAccessService(dbs),
);
// Legacy free-function names bound to the service, so the moved cases read as before.
const listTripPhotos = svc.listTripPhotos.bind(svc);
const listTripAlbumLinks = svc.listTripAlbumLinks.bind(svc);
const addTripPhotos = svc.addTripPhotos.bind(svc);
const setTripPhotoSharing = svc.setTripPhotoSharing.bind(svc);
const removeTripPhoto = svc.removeTripPhoto.bind(svc);
const createTripAlbumLink = svc.createTripAlbumLink.bind(svc);
const removeAlbumLink = svc.removeAlbumLink.bind(svc);
beforeAll(() => {
createTables(testDb);