mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+181
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
+28
-10
@@ -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);
|
||||
Reference in New Issue
Block a user