When unlinking an Immich album, photos synced from that album are now deleted. A new `album_link_id` FK column on `trip_photos` tracks the source album link at sync time; `deleteAlbumLink` deletes matching photos before removing the link. Individually-added photos are unaffected. The client now refreshes the photo grid after unlinking. Adds integration tests IMMICH-020 through IMMICH-024. Closes #398
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
import { db, canAccessTrip } from '../db/database';
|
|
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
|
import { checkSsrf } from '../utils/ssrfGuard';
|
|
import { writeAudit } from './auditLog';
|
|
|
|
// ── 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;
|
|
return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string };
|
|
}
|
|
|
|
/** 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 fetch(`${immichUrl}/api/users/me`, {
|
|
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
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 fetch(`${creds.immich_url}/api/users/me`, {
|
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
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 fetch(`${creds.immich_url}/api/timeline/buckets`, {
|
|
method: 'GET',
|
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
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 fetch(`${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),
|
|
});
|
|
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 };
|
|
}
|
|
}
|
|
|
|
// ── Trip Photos ────────────────────────────────────────────────────────────
|
|
|
|
export function listTripPhotos(tripId: string, userId: number) {
|
|
return db.prepare(`
|
|
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
|
u.username, u.avatar, u.immich_url
|
|
FROM trip_photos tp
|
|
JOIN users u ON tp.user_id = u.id
|
|
WHERE tp.trip_id = ?
|
|
AND (tp.user_id = ? OR tp.shared = 1)
|
|
ORDER BY tp.added_at ASC
|
|
`).all(tripId, userId);
|
|
}
|
|
|
|
export function addTripPhotos(
|
|
tripId: string,
|
|
userId: number,
|
|
assetIds: string[],
|
|
shared: boolean
|
|
): number {
|
|
const insert = db.prepare(
|
|
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
|
);
|
|
let added = 0;
|
|
for (const assetId of assetIds) {
|
|
const result = insert.run(tripId, userId, assetId, shared ? 1 : 0);
|
|
if (result.changes > 0) added++;
|
|
}
|
|
return added;
|
|
}
|
|
|
|
export function removeTripPhoto(tripId: string, userId: number, assetId: string) {
|
|
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
|
.run(tripId, userId, assetId);
|
|
}
|
|
|
|
export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) {
|
|
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
|
.run(shared ? 1 : 0, tripId, userId, assetId);
|
|
}
|
|
|
|
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify that requestingUserId can access a shared photo belonging to ownerUserId.
|
|
* The asset must be shared (shared=1) and the requesting user must be a member of
|
|
* the same trip that contains the photo.
|
|
*/
|
|
export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, assetId: string): boolean {
|
|
const rows = db.prepare(`
|
|
SELECT tp.trip_id FROM trip_photos tp
|
|
WHERE tp.immich_asset_id = ? AND tp.user_id = ? AND tp.shared = 1
|
|
`).all(assetId, ownerUserId) as { trip_id: number }[];
|
|
if (rows.length === 0) return false;
|
|
return rows.some(row => !!canAccessTrip(String(row.trip_id), requestingUserId));
|
|
}
|
|
|
|
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 fetch(`${creds.immich_url}/api/assets/${assetId}`, {
|
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
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 proxyThumbnail(
|
|
userId: number,
|
|
assetId: string,
|
|
ownerUserId?: number
|
|
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
|
|
const effectiveUserId = ownerUserId ?? userId;
|
|
const creds = getImmichCredentials(effectiveUserId);
|
|
if (!creds) return { error: 'Not found', status: 404 };
|
|
|
|
try {
|
|
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, {
|
|
headers: { 'x-api-key': creds.immich_api_key },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!resp.ok) return { error: 'Failed', status: resp.status };
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
const contentType = resp.headers.get('content-type') || 'image/webp';
|
|
return { buffer, contentType };
|
|
} catch {
|
|
return { error: 'Proxy error', status: 502 };
|
|
}
|
|
}
|
|
|
|
export async function proxyOriginal(
|
|
userId: number,
|
|
assetId: string,
|
|
ownerUserId?: number
|
|
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
|
|
const effectiveUserId = ownerUserId ?? userId;
|
|
const creds = getImmichCredentials(effectiveUserId);
|
|
if (!creds) return { error: 'Not found', status: 404 };
|
|
|
|
try {
|
|
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, {
|
|
headers: { 'x-api-key': creds.immich_api_key },
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
if (!resp.ok) return { error: 'Failed', status: resp.status };
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
|
return { buffer, contentType };
|
|
} catch {
|
|
return { error: 'Proxy error', status: 502 };
|
|
}
|
|
}
|
|
|
|
// ── 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 fetch(`${creds.immich_url}/api/albums`, {
|
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
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, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
|
|
).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.prepare('DELETE FROM trip_photos WHERE album_link_id = ? AND trip_id = ? AND user_id = ?')
|
|
.run(linkId, tripId, userId);
|
|
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
|
.run(linkId, tripId, userId);
|
|
}
|
|
|
|
export async function syncAlbumAssets(
|
|
tripId: string,
|
|
linkId: string,
|
|
userId: number
|
|
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
|
|
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
|
.get(linkId, tripId, userId) as any;
|
|
if (!link) 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 fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, {
|
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
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 insert = db.prepare(
|
|
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)'
|
|
);
|
|
let added = 0;
|
|
for (const asset of assets) {
|
|
const r = insert.run(tripId, userId, asset.id, linkId);
|
|
if (r.changes > 0) added++;
|
|
}
|
|
|
|
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
|
|
|
return { success: true, added, total: assets.length };
|
|
} catch {
|
|
return { error: 'Could not reach Immich', status: 502 };
|
|
}
|
|
}
|