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) {