From d765a80ea30bb4c80f5a9425406d505fe774a296 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 3 Apr 2026 22:32:20 +0200 Subject: [PATCH] fix(immich): proxy shared photos using owner's Immich credentials Trip members viewing another member's shared photo were getting a 404 because the proxy endpoints always used the requesting user's Immich credentials instead of the photo owner's. The ?userId= query param the client already sent was silently ignored. - Add canAccessUserPhoto() to verify the asset is shared and the requesting user is a trip member before allowing cross-user proxying - Pass optional ownerUserId through proxyThumbnail, proxyOriginal, and getAssetInfo so credentials are fetched for the correct user - Enforce shared=1 check so unshared photos remain inaccessible --- server/src/routes/immich.ts | 22 ++++++++++++++++--- server/src/services/immichService.ts | 32 ++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 198b6e8..8cc93b7 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -20,6 +20,7 @@ import { proxyThumbnail, proxyOriginal, isValidAssetId, + canAccessUserPhoto, listAlbums, listAlbumLinks, createAlbumLink, @@ -143,7 +144,12 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - const result = await getAssetInfo(authReq.user.id, assetId); + const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; + const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; + if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) { + return res.status(403).json({ error: 'Forbidden' }); + } + const result = await getAssetInfo(authReq.user.id, assetId, ownerUserId); if (result.error) return res.status(result.status!).json({ error: result.error }); res.json(result.data); }); @@ -154,7 +160,12 @@ router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - const result = await proxyThumbnail(authReq.user.id, assetId); + const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; + const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; + if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) { + return res.status(403).send('Forbidden'); + } + const result = await proxyThumbnail(authReq.user.id, assetId, ownerUserId); if (result.error) return res.status(result.status!).send(result.error); res.set('Content-Type', result.contentType!); res.set('Cache-Control', 'public, max-age=86400'); @@ -165,7 +176,12 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - const result = await proxyOriginal(authReq.user.id, assetId); + const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; + const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; + if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) { + return res.status(403).send('Forbidden'); + } + const result = await proxyOriginal(authReq.user.id, assetId, ownerUserId); if (result.error) return res.status(result.status!).send(result.error); res.set('Content-Type', result.contentType!); res.set('Cache-Control', 'public, max-age=86400'); diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index 6468338..aceb8c0 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -230,11 +230,27 @@ export function togglePhotoSharing(tripId: string, userId: number, assetId: stri // ── 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 row = db.prepare(` + SELECT tp.trip_id FROM trip_photos tp + WHERE tp.immich_asset_id = ? AND tp.user_id = ? AND tp.shared = 1 + `).get(assetId, ownerUserId) as { trip_id: number } | undefined; + if (!row) return false; + return !!canAccessTrip(String(row.trip_id), requestingUserId); +} + export async function getAssetInfo( userId: number, - assetId: string + assetId: string, + ownerUserId?: number ): Promise<{ data?: any; error?: string; status?: number }> { - const creds = getImmichCredentials(userId); + const effectiveUserId = ownerUserId ?? userId; + const creds = getImmichCredentials(effectiveUserId); if (!creds) return { error: 'Not found', status: 404 }; try { @@ -272,9 +288,11 @@ export async function getAssetInfo( export async function proxyThumbnail( userId: number, - assetId: string + assetId: string, + ownerUserId?: number ): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> { - const creds = getImmichCredentials(userId); + const effectiveUserId = ownerUserId ?? userId; + const creds = getImmichCredentials(effectiveUserId); if (!creds) return { error: 'Not found', status: 404 }; try { @@ -293,9 +311,11 @@ export async function proxyThumbnail( export async function proxyOriginal( userId: number, - assetId: string + assetId: string, + ownerUserId?: number ): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> { - const creds = getImmichCredentials(userId); + const effectiveUserId = ownerUserId ?? userId; + const creds = getImmichCredentials(effectiveUserId); if (!creds) return { error: 'Not found', status: 404 }; try {