feat: unified photo provider abstraction layer (#584)

Introduce trek_photos as central photo registry. Frontend uses
/api/photos/:id/:kind instead of provider-specific URLs. Adding
a new photo provider is now backend-only work.

- New trek_photos table (migration 98) with photo_id FK in
  trip_photos and journey_photos
- Unified /api/photos/:id/thumbnail|original|info endpoint
- photoResolverService for central resolution and streaming
- ProviderPicker: add "All Photos" tab, rename tabs, fix i18n
- Localize all hardcoded strings in JourneyDetailPage (14 langs)
- Fix date formatting to use browser locale instead of hardcoded 'en'
- Journey stats as styled tile cards
This commit is contained in:
Maurice
2026-04-13 20:08:31 +02:00
parent e629548a42
commit c0c59b6d80
34 changed files with 883 additions and 198 deletions
+12 -6
View File
@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService';
import { streamPhoto } from '../services/memories/photoResolverService';
import { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path';
import fs from 'node:fs';
@@ -12,16 +13,23 @@ router.get('/:token', (req: Request, res: Response) => {
res.json(data);
});
// Public photo proxy — validates share token instead of auth
// Unified public photo proxy — uses trek_photo_id
router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => {
const { token, photoId, kind } = req.params;
const valid = validateShareTokenForPhoto(token, Number(photoId));
if (!valid) return res.status(404).json({ error: 'Not found' });
await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original');
});
// Legacy public photo proxy — validates share token instead of auth
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
const { token, provider, assetId, ownerId, kind } = req.params;
// Validate token and that this asset belongs to the shared journey
const valid = validateShareTokenForAsset(token, assetId);
if (!valid) return res.status(404).json({ error: 'Not found' });
if (provider === 'local') {
// Local file — assetId is the file_path
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
@@ -32,12 +40,10 @@ router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Reques
return res.sendFile(resolved);
}
// Immich/Synology — proxy through
const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
} else {
// Synology or other providers — try dynamic import
try {
const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
+2 -3
View File
@@ -55,8 +55,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
const result = await setTripPhotoSharing(
tripId,
authReq.user.id,
req.body?.provider,
req.body?.asset_id,
Number(req.body?.photo_id),
req.body?.shared,
);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
@@ -66,7 +65,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
+47
View File
@@ -0,0 +1,47 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService';
import { canAccessTrekPhoto } from '../services/memories/helpersService';
const router = express.Router();
router.get('/:id/thumbnail', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
await streamPhoto(res, authReq.user.id, photoId, 'thumbnail');
});
router.get('/:id/original', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
await streamPhoto(res, authReq.user.id, photoId, 'original');
});
router.get('/:id/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
const result = await getPhotoInfo(authReq.user.id, photoId);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json(result.data);
});
export default router;