diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index ad9be29..963f547 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── const thumbnailBaseUrl = (photo: TripPhoto) => - `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}` + `/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` @@ -775,12 +775,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{ - setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') - fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc) + fetchImageAsBlob(`/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/original`).then(setLightboxOriginalSrc) setLightboxInfoLoading(true) - apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`) + apiClient.get(`/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/info`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 4a166a1..012d7ef 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -15,11 +15,11 @@ import { proxyThumbnail, proxyOriginal, isValidAssetId, - canAccessUserPhoto, listAlbums, syncAlbumAssets, getAssetInfo, } from '../services/immichService'; +import { canAccessUserPhoto } from '../services/memoriesService'; const router = express.Router(); @@ -83,48 +83,42 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { // ── Asset Details ────────────────────────────────────────────────────────── -router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - 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)) { + const { tripId, assetId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { return res.status(403).json({ error: 'Forbidden' }); } - const result = await getAssetInfo(authReq.user.id, assetId, ownerUserId); + const result = await getAssetInfo(authReq.user.id, assetId, Number(ownerId)); if (result.error) return res.status(result.status!).json({ error: result.error }); res.json(result.data); }); // ── Proxy Immich Assets ──────────────────────────────────────────────────── -router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - 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 { tripId, assetId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { + return res.status(403).json({ error: 'Forbidden' }); } - const result = await proxyThumbnail(authReq.user.id, assetId, ownerUserId); + const result = await proxyThumbnail(authReq.user.id, assetId, Number(ownerId)); 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'); res.send(result.buffer); }); -router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/original', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - 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 { tripId, assetId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { + return res.status(403).json({ error: 'Forbidden' }); } - const result = await proxyOriginal(authReq.user.id, assetId, ownerUserId); + const result = await proxyOriginal(authReq.user.id, assetId, Number(ownerId)); 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/routes/synology.ts b/server/src/routes/synology.ts index c01e49e..838a3de 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -12,11 +12,11 @@ import { searchSynologyPhotos, getSynologyAssetInfo, pipeSynologyProxy, - getSynologyTargetUserId, streamSynologyAsset, handleSynologyError, SynologyServiceError, } from '../services/synologyService'; +import { canAccessUserPhoto } from '../services/memoriesService'; const router = express.Router(); @@ -121,24 +121,32 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { } }); -router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { photoId } = req.params; + const { tripId, photoId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + } try { - res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req))); + res.json(await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId))); } catch (err: unknown) { handleSynologyError(res, err, 'Could not reach Synology'); } }); -router.get('/assets/:photoId/thumbnail', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { photoId } = req.params; + const { tripId, photoId, ownerId } = req.params; const { size = 'sm' } = req.query; + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + } + try { - const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'thumbnail', String(size)); + const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'thumbnail', String(size)); await pipeSynologyProxy(res, proxy); } catch (err: unknown) { if (res.headersSent) { @@ -148,12 +156,16 @@ router.get('/assets/:photoId/thumbnail', authenticate, async (req: Request, res: } }); -router.get('/assets/:photoId/original', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/original', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { photoId } = req.params; + const { tripId, photoId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + } try { - const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'original'); + const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'original'); await pipeSynologyProxy(res, proxy); } catch (err: unknown) { if (res.headersSent) { diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index 5515d9e..baef3bb 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -190,19 +190,6 @@ export async function searchPhotos( // ── 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, diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index 5fdfbce..3886c8d 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -3,6 +3,34 @@ import { notifyTripMembers } from './notifications'; type ServiceError = { error: string; status: number }; + +/** + * 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, tripId: string, assetId: string, provider: string): boolean { + if (requestingUserId === ownerUserId) { + return true; + } + const sharedAsset = db.prepare(` + SELECT 1 + FROM trip_photos + WHERE user_id = ? + AND asset_id = ? + AND provider = ? + AND trip_id = ? + AND shared = 1 + LIMIT 1 + `).get(ownerUserId, assetId, provider, tripId); + + if (!sharedAsset) { + return false; + } + return !!canAccessTrip(String(tripId), requestingUserId); +} + + function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null { if (!canAccessTrip(tripId, userId)) { return { error: 'Trip not found', status: 404 }; diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 646c592..0cb3308 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -1,13 +1,9 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { NextFunction, Request, Response as ExpressResponse } from 'express'; -import { db, canAccessTrip } from '../db/database'; +import { Request, Response as ExpressResponse } from 'express'; +import { db } from '../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { consumeEphemeralToken } from './ephemeralTokens'; import { checkSsrf } from '../utils/ssrfGuard'; -import { no } from 'zod/locales'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -270,11 +266,6 @@ function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo }; } -export function getSynologyTargetUserId(req: Request): number { - const { userId } = req.query; - return Number(userId); -} - export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse { if (err instanceof SynologyServiceError) { return res.status(err.status).json({ error: err.message }); @@ -295,23 +286,6 @@ function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; a return { id, cacheKey: rawId, assetId: rawId }; } -function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean { - if (requestingUserId === targetUserId) { - return true; - } - - const sharedAsset = db.prepare(` - SELECT 1 - FROM trip_photos - WHERE user_id = ? - AND asset_id = ? - AND provider = 'synologyphotos' - AND shared = 1 - LIMIT 1 - `).get(targetUserId, assetId); - - return !!sharedAsset; -} async function getSynologySession(userId: number): Promise { const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null; @@ -514,9 +488,6 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s } export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise { - if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) { - throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); - } const parsedId = splitPackedSynologyId(photoId); const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, { api: 'SYNO.Foto.Browse.Item', @@ -546,11 +517,7 @@ export async function streamSynologyAsset( photoId: string, kind: 'thumbnail' | 'original', size?: string, -): Promise { - if (!canStreamSynologyAsset(userId, targetUserId, photoId)) { - throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); - } - +): Promise { const parsedId = splitPackedSynologyId(photoId); const synology_url = getSynologyCredentials(targetUserId).synology_url; if (!synology_url) {