mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
e395935f6a
Large Immich libraries (7k+ photos) caused timeouts and pending requests when using "All Photos". Cap pagination at 5 pages (5000 photos) and abort in-flight requests when switching tabs.
446 lines
17 KiB
TypeScript
446 lines
17 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 {
|
|
const allAssets: any[] = [];
|
|
let page = 1;
|
|
const pageSize = 1000;
|
|
const maxPages = 5; // Cap at 5000 photos to avoid timeouts on large libraries
|
|
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;
|
|
page++;
|
|
if (page > maxPages) break;
|
|
}
|
|
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, hasMore: page > maxPages };
|
|
} 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 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 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 { 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').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 };
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
// ── 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(__dirname, '../../../uploads', 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;
|
|
}
|
|
}
|