mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
5cc81ae4b0
Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.
Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
import { Response } from 'express';
|
|
import { db } from '../../db/database';
|
|
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
|
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
|
|
import { writeAudit } from '../auditLog';
|
|
import { addTripPhotos} from './unifiedService';
|
|
import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection, pipeAsset } from './helpersService';
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
// ── Connection Settings ────────────────────────────────────────────────────
|
|
|
|
export function getConnectionSettings(userId: number) {
|
|
const creds = getImmichCredentials(userId);
|
|
return {
|
|
immich_url: creds?.immich_url || '',
|
|
connected: !!(creds?.immich_url && creds?.immich_api_key),
|
|
};
|
|
}
|
|
|
|
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
|
|
): Promise<{ assets?: any[]; error?: string; status?: number }> {
|
|
const creds = getImmichCredentials(userId);
|
|
if (!creds) return { error: 'Immich not configured', status: 400 };
|
|
|
|
try {
|
|
// Paginate through all results (Immich limits per-page to 1000)
|
|
const allAssets: any[] = [];
|
|
let page = 1;
|
|
const pageSize = 1000;
|
|
while (true) {
|
|
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,
|
|
type: 'IMAGE',
|
|
size: pageSize,
|
|
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 || [];
|
|
allAssets.push(...items);
|
|
if (items.length < pageSize) break; // Last page
|
|
page++;
|
|
if (page > 20) break; // Safety limit (20k photos max)
|
|
}
|
|
const assets = allAssets.map((a: any) => ({
|
|
id: a.id,
|
|
takenAt: a.fileCreatedAt || a.createdAt,
|
|
city: a.exifInfo?.city || null,
|
|
country: a.exifInfo?.country || null,
|
|
}));
|
|
return { assets };
|
|
} 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 streamImmichAsset(
|
|
response: Response,
|
|
userId: number,
|
|
assetId: string,
|
|
kind: 'thumbnail' | 'original',
|
|
ownerUserId?: number
|
|
): Promise<{ error?: string; status?: number } | void> {
|
|
const effectiveUserId = ownerUserId ?? userId;
|
|
const creds = getImmichCredentials(effectiveUserId);
|
|
if (!creds) return { error: 'Not found', status: 404 };
|
|
|
|
const path = kind === 'thumbnail' ? 'thumbnail' : 'original';
|
|
const timeout = kind === 'thumbnail' ? 10000 : 30000;
|
|
const url = `${creds.immich_url}/api/assets/${assetId}/${path}`;
|
|
|
|
response.set('Cache-Control', 'public, max-age=86400');
|
|
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout));
|
|
}
|
|
|
|
// ── 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 {
|
|
const resp = await safeFetch(`${creds.immich_url}/api/albums`, {
|
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(10000) as any,
|
|
});
|
|
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
|
|
const albums = (await resp.json() as any[]).map((a: any) => ({
|
|
id: a.id,
|
|
albumName: a.albumName,
|
|
assetCount: a.assetCount || 0,
|
|
startDate: a.startDate,
|
|
endDate: a.endDate,
|
|
albumThumbnailAssetId: a.albumThumbnailAssetId,
|
|
}));
|
|
return { albums };
|
|
} catch {
|
|
return { error: 'Could not reach Immich', status: 502 };
|
|
}
|
|
}
|
|
|
|
export function listAlbumLinks(tripId: string) {
|
|
return db.prepare(`
|
|
SELECT tal.*, u.username
|
|
FROM trip_album_links tal
|
|
JOIN users u ON tal.user_id = u.id
|
|
WHERE tal.trip_id = ?
|
|
ORDER BY tal.created_at ASC
|
|
`).all(tripId);
|
|
}
|
|
|
|
export function createAlbumLink(
|
|
tripId: string,
|
|
userId: number,
|
|
albumId: string,
|
|
albumName: string
|
|
): { success: boolean; error?: string } {
|
|
try {
|
|
db.prepare(
|
|
"INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, 'immich')"
|
|
).run(tripId, userId, albumId, albumName || '');
|
|
return { success: true };
|
|
} catch {
|
|
return { success: false, error: 'Album already linked' };
|
|
}
|
|
}
|
|
|
|
export function deleteAlbumLink(linkId: string, tripId: string, userId: number) {
|
|
db.transaction(() => {
|
|
const link = db.prepare('SELECT id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?').get(linkId, tripId, userId);
|
|
if (link) {
|
|
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 = ?').run(linkId);
|
|
}
|
|
})();
|
|
}
|
|
|
|
export async function syncAlbumAssets(
|
|
tripId: string,
|
|
linkId: string,
|
|
userId: number,
|
|
sid: string,
|
|
): Promise<{ success?: boolean; added?: number; 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 resp = await safeFetch(`${creds.immich_url}/api/albums/${response.data}`, {
|
|
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 album', status: resp.status };
|
|
const albumData = await resp.json() as { assets?: any[] };
|
|
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
|
|
|
const selection: Selection = {
|
|
provider: 'immich',
|
|
asset_ids: assets.map((a: any) => a.id),
|
|
};
|
|
|
|
const result = await addTripPhotos(tripId, userId, true, [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: assets.length };
|
|
} catch {
|
|
return { error: 'Could not reach Immich', status: 502 };
|
|
}
|
|
}
|