import { pipeline } from 'node:stream/promises'; import { Readable } from 'node:stream'; import { Response } from 'express'; import { canAccessTrip, db } from "../../db/database"; import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard'; import { decrypt_api_key } from '../apiKeyCrypto'; // helpers for handling return types type ServiceError = { success: false; error: { message: string; status: number } }; export type ServiceResult = { success: true; data: T } | ServiceError; export function fail(error: string, status: number): ServiceError { return { success: false, error: { message: error, status } }; } export function success(data: T): ServiceResult { return { success: true, data: data }; } export function mapDbError(error: Error, fallbackMessage: string): ServiceError { if (error && /unique|constraint/i.test(error.message)) { return fail('Resource already exists', 409); } return fail(error.message, 500); } export function handleServiceResult(res: Response, result: ServiceResult): void { if ('error' in result) { res.status(result.error.status).json({ error: result.error.message }); } else { res.json(result.data); } } // ---------------------------------------------- // types used across memories services export type Selection = { provider: string; asset_ids: string[]; passphrase?: string; }; export type StatusResult = { connected: true; user: { name: string } } | { connected: false; error: string }; export type SyncAlbumResult = { added: number; total: number }; export type AlbumsList = { albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }> }; export type Asset = { id: string; takenAt: string; }; export type AssetsList = { assets: Asset[], total: number, hasMore: boolean }; export type AssetInfo = { id: string; takenAt: string | null; city: string | null; country: string | null; state?: string | null; camera?: string | null; lens?: string | null; focalLength?: string | number | null; aperture?: string | number | null; shutter?: string | number | null; iso?: string | number | null; lat?: number | null; lng?: number | null; orientation?: number | null; description?: string | null; width?: number | null; height?: number | null; fileSize?: number | null; fileName?: string | null; } //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 { 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); } export async function pipeAsset(url: string, response: Response, headers?: Record, signal?: AbortSignal, defaultCacheControl?: string): Promise { try { const resp = await safeFetch(url, { headers, signal: signal as any }); response.status(resp.status); if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); if (!resp.ok) { response.set('Cache-Control', 'no-store, max-age=0'); } else if (resp.headers.get('cache-control')) { response.set('Cache-Control', resp.headers.get('cache-control') as string); } else if (defaultCacheControl) { response.set('Cache-Control', defaultCacheControl); } if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); if (!resp.body) { response.end(); } else { await pipeline(Readable.fromWeb(resp.body as any), response); } } catch (error) { if (response.headersSent) { response.end(); return; } if (error instanceof SsrfBlockedError) { response.status(400).json({ error: error.message }); } else { response.status(500).json({ error: 'Failed to fetch asset' }); } } }