mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 23:01:48 +00:00
changing handling of rights for accesing assets
This commit is contained in:
@@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const thumbnailBaseUrl = (photo: TripPhoto) =>
|
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}`
|
const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
|
||||||
|
|
||||||
@@ -775,12 +775,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
|
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
|
||||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
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)
|
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||||
setLightboxOriginalSrc('')
|
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)
|
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))
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
|
|||||||
+18
-24
@@ -15,11 +15,11 @@ import {
|
|||||||
proxyThumbnail,
|
proxyThumbnail,
|
||||||
proxyOriginal,
|
proxyOriginal,
|
||||||
isValidAssetId,
|
isValidAssetId,
|
||||||
canAccessUserPhoto,
|
|
||||||
listAlbums,
|
listAlbums,
|
||||||
syncAlbumAssets,
|
syncAlbumAssets,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
} from '../services/immichService';
|
} from '../services/immichService';
|
||||||
|
import { canAccessUserPhoto } from '../services/memoriesService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -83,48 +83,42 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
// ── 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 authReq = req as AuthRequest;
|
||||||
const { assetId } = req.params;
|
const { tripId, assetId, ownerId } = req.params;
|
||||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
|
||||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
|
||||||
if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) {
|
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
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 });
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
res.json(result.data);
|
res.json(result.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Proxy Immich Assets ────────────────────────────────────────────────────
|
// ── 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 authReq = req as AuthRequest;
|
||||||
const { assetId } = req.params;
|
const { tripId, assetId, ownerId } = req.params;
|
||||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
|
||||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) {
|
|
||||||
return res.status(403).send('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);
|
if (result.error) return res.status(result.status!).send(result.error);
|
||||||
res.set('Content-Type', result.contentType!);
|
res.set('Content-Type', result.contentType!);
|
||||||
res.set('Cache-Control', 'public, max-age=86400');
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
res.send(result.buffer);
|
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 authReq = req as AuthRequest;
|
||||||
const { assetId } = req.params;
|
const { tripId, assetId, ownerId } = req.params;
|
||||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
|
||||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) {
|
|
||||||
return res.status(403).send('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);
|
if (result.error) return res.status(result.status!).send(result.error);
|
||||||
res.set('Content-Type', result.contentType!);
|
res.set('Content-Type', result.contentType!);
|
||||||
res.set('Cache-Control', 'public, max-age=86400');
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import {
|
|||||||
searchSynologyPhotos,
|
searchSynologyPhotos,
|
||||||
getSynologyAssetInfo,
|
getSynologyAssetInfo,
|
||||||
pipeSynologyProxy,
|
pipeSynologyProxy,
|
||||||
getSynologyTargetUserId,
|
|
||||||
streamSynologyAsset,
|
streamSynologyAsset,
|
||||||
handleSynologyError,
|
handleSynologyError,
|
||||||
SynologyServiceError,
|
SynologyServiceError,
|
||||||
} from '../services/synologyService';
|
} from '../services/synologyService';
|
||||||
|
import { canAccessUserPhoto } from '../services/memoriesService';
|
||||||
|
|
||||||
const router = express.Router();
|
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 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 {
|
try {
|
||||||
res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req)));
|
res.json(await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
handleSynologyError(res, err, 'Could not reach Synology');
|
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 authReq = req as AuthRequest;
|
||||||
const { photoId } = req.params;
|
const { tripId, photoId, ownerId } = req.params;
|
||||||
const { size = 'sm' } = req.query;
|
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 {
|
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);
|
await pipeSynologyProxy(res, proxy);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (res.headersSent) {
|
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 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 {
|
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);
|
await pipeSynologyProxy(res, proxy);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
|
|||||||
@@ -190,19 +190,6 @@ export async function searchPhotos(
|
|||||||
|
|
||||||
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
|
// ── 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(
|
export async function getAssetInfo(
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -3,6 +3,34 @@ import { notifyTripMembers } from './notifications';
|
|||||||
|
|
||||||
type ServiceError = { error: string; status: number };
|
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 {
|
function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null {
|
||||||
if (!canAccessTrip(tripId, userId)) {
|
if (!canAccessTrip(tripId, userId)) {
|
||||||
return { error: 'Trip not found', status: 404 };
|
return { error: 'Trip not found', status: 404 };
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { NextFunction, Request, Response as ExpressResponse } from 'express';
|
import { Request, Response as ExpressResponse } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
|
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 { checkSsrf } from '../utils/ssrfGuard';
|
||||||
import { no } from 'zod/locales';
|
|
||||||
|
|
||||||
const SYNOLOGY_API_TIMEOUT_MS = 30000;
|
const SYNOLOGY_API_TIMEOUT_MS = 30000;
|
||||||
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
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 {
|
export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse {
|
||||||
if (err instanceof SynologyServiceError) {
|
if (err instanceof SynologyServiceError) {
|
||||||
return res.status(err.status).json({ error: err.message });
|
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 };
|
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<SynologySession> {
|
async function getSynologySession(userId: number): Promise<SynologySession> {
|
||||||
const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null;
|
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<SynologyPhotoInfo> {
|
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<SynologyPhotoInfo> {
|
||||||
if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) {
|
|
||||||
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
|
||||||
}
|
|
||||||
const parsedId = splitPackedSynologyId(photoId);
|
const parsedId = splitPackedSynologyId(photoId);
|
||||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, {
|
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, {
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
api: 'SYNO.Foto.Browse.Item',
|
||||||
@@ -546,11 +517,7 @@ export async function streamSynologyAsset(
|
|||||||
photoId: string,
|
photoId: string,
|
||||||
kind: 'thumbnail' | 'original',
|
kind: 'thumbnail' | 'original',
|
||||||
size?: string,
|
size?: string,
|
||||||
): Promise<SynologyProxyResult> {
|
): Promise<SynologyProxyResult> {
|
||||||
if (!canStreamSynologyAsset(userId, targetUserId, photoId)) {
|
|
||||||
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedId = splitPackedSynologyId(photoId);
|
const parsedId = splitPackedSynologyId(photoId);
|
||||||
const synology_url = getSynologyCredentials(targetUserId).synology_url;
|
const synology_url = getSynologyCredentials(targetUserId).synology_url;
|
||||||
if (!synology_url) {
|
if (!synology_url) {
|
||||||
|
|||||||
Reference in New Issue
Block a user