diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index d8d167d..da9873e 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -89,7 +89,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const res = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } @@ -98,7 +98,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get(`/integrations/${provider}/albums`) + const res = await apiClient.get(`/integrations/memories/${provider}/albums`) setAlbums(res.data.albums || []) } catch { setAlbums([]) @@ -120,7 +120,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa } try { - await apiClient.post(`/integrations/memories/trips/${tripId}/album-links`, { + await apiClient.post(`/integrations/memories/unified/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName, provider: selectedProvider, @@ -128,7 +128,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const linksRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } @@ -136,7 +136,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/album-links/${linkId}`) loadAlbumLinks() } catch { toast.error(t('memories.error.unlinkAlbum')) } } @@ -146,7 +146,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(`/integrations/memories/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -175,7 +175,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`) + const photosRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/photos`) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -257,7 +257,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setPickerPhotos([]) return } - const res = await apiClient.post(`/integrations/${provider.id}/search`, { + const res = await apiClient.post(`/integrations/memories/${provider.id}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) @@ -296,7 +296,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa groupedByProvider.set(provider, list) } - await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { + await apiClient.post(`/integrations/memories/unified/trips/${tripId}/photos`, { selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) @@ -310,7 +310,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, { + await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/photos`, { data: { asset_id: photo.asset_id, provider: photo.provider, @@ -324,7 +324,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, { + await apiClient.put(`/integrations/memories/unified/trips/${tripId}/photos/sharing`, { shared, asset_id: photo.asset_id, provider: photo.provider, @@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── const thumbnailBaseUrl = (photo: TripPhoto) => - `/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` + `/api/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` @@ -598,7 +598,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> diff --git a/server/src/app.ts b/server/src/app.ts index 13b7668..60ddcb8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -33,9 +33,7 @@ import backupRoutes from './routes/backup'; import oidcRoutes from './routes/oidc'; import vacayRoutes from './routes/vacay'; import atlasRoutes from './routes/atlas'; -import immichRoutes from './routes/immich'; -import synologyRoutes from './routes/synology'; -import memoriesRoutes from './routes/memories'; +import memoriesRoutes from './routes/memories/unified'; import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; @@ -255,8 +253,6 @@ export function createApp(): express.Application { app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/atlas', atlasRoutes); app.use('/api/integrations/memories', memoriesRoutes); - app.use('/api/integrations/immich', immichRoutes); - app.use('/api/integrations/synologyphotos', synologyRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); diff --git a/server/src/routes/immich.ts b/server/src/routes/memories/immich.ts similarity index 93% rename from server/src/routes/immich.ts rename to server/src/routes/memories/immich.ts index 012d7ef..af9b4d0 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -1,10 +1,10 @@ import express, { Request, Response, NextFunction } from 'express'; -import { db, canAccessTrip } from '../db/database'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { AuthRequest } from '../types'; -import { consumeEphemeralToken } from '../services/ephemeralTokens'; -import { getClientIp } from '../services/auditLog'; +import { db, canAccessTrip } from '../../db/database'; +import { authenticate } from '../../middleware/auth'; +import { broadcast } from '../../websocket'; +import { AuthRequest } from '../../types'; +import { consumeEphemeralToken } from '../../services/ephemeralTokens'; +import { getClientIp } from '../../services/auditLog'; import { getConnectionSettings, saveImmichSettings, @@ -14,12 +14,11 @@ import { searchPhotos, proxyThumbnail, proxyOriginal, - isValidAssetId, listAlbums, syncAlbumAssets, getAssetInfo, -} from '../services/immichService'; -import { canAccessUserPhoto } from '../services/memoriesService'; +} from '../../services/memories/immichService'; +import { canAccessUserPhoto } from '../../services/memories/helpersService'; const router = express.Router(); diff --git a/server/src/routes/synology.ts b/server/src/routes/memories/synology.ts similarity index 96% rename from server/src/routes/synology.ts rename to server/src/routes/memories/synology.ts index 838a3de..cd3512d 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { AuthRequest } from '../types'; +import { authenticate } from '../../middleware/auth'; +import { broadcast } from '../../websocket'; +import { AuthRequest } from '../../types'; import { getSynologySettings, updateSynologySettings, @@ -15,8 +15,8 @@ import { streamSynologyAsset, handleSynologyError, SynologyServiceError, -} from '../services/synologyService'; -import { canAccessUserPhoto } from '../services/memoriesService'; +} from '../../services/memories/synologyService'; +import { canAccessUserPhoto } from '../../services/memories/helpersService'; const router = express.Router(); diff --git a/server/src/routes/memories.ts b/server/src/routes/memories/unified.ts similarity index 74% rename from server/src/routes/memories.ts rename to server/src/routes/memories/unified.ts index e60b9e1..3e4561d 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories/unified.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; +import { authenticate } from '../../middleware/auth'; +import { AuthRequest } from '../../types'; import { listTripPhotos, listTripAlbumLinks, @@ -9,14 +9,19 @@ import { addTripPhotos, removeTripPhoto, setTripPhotoSharing, -} from '../services/memoriesService'; +} from '../../services/memories/unifiedService'; +import immichRouter from './immich'; +import synologyRouter from './synology'; const router = express.Router(); +router.use('/immich', immichRouter); +router.use('/synologyphotos', synologyRouter); + //------------------------------------------------ // routes for managing photos linked to trip -router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripPhotos(tripId, authReq.user.id); @@ -24,7 +29,7 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) res.json({ photos: result.data }); }); -router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { +router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const sid = req.headers['x-socket-id'] as string; @@ -42,7 +47,7 @@ router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Res res.json({ success: true, added: result.data.added }); }); -router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { +router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = await setTripPhotoSharing( @@ -56,7 +61,7 @@ router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, r res.json({ success: true }); }); -router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { +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); @@ -67,7 +72,7 @@ router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: R //------------------------------ // routes for managing album links -router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { +router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripAlbumLinks(tripId, authReq.user.id); @@ -75,7 +80,7 @@ router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Respo res.json({ links: result.data }); }); -router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { +router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name); @@ -83,7 +88,7 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res res.json({ success: true }); }); -router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { +router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; const result = removeAlbumLink(tripId, linkId, authReq.user.id); @@ -91,4 +96,7 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Re res.json({ success: true }); }); + + + export default router; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts new file mode 100644 index 0000000..51c899a --- /dev/null +++ b/server/src/services/memories/helpersService.ts @@ -0,0 +1,79 @@ +import { canAccessTrip, db } from "../../db/database"; + +// helpers for handling return types + +type ServiceError = { success: false; error: { message: string; status: number } }; +export type ServiceResult = { success: true; data: T } | ServiceError; + + +export function fail(error: string, status: number): ServiceError { + return { success: false, error: { message: error, status } }; +} + + +export function success(data: T): ServiceResult { + return { success: true, data: data }; +} + + +export function mapDbError(error: unknown, fallbackMessage: string): ServiceError { + if (error instanceof Error && /unique|constraint/i.test(error.message)) { + return fail('Resource already exists', 409); + } + return fail(fallbackMessage, 500); +} + + +// ---------------------------------------------- +// types used across memories services +export type Selection = { + provider: string; + asset_ids: string[]; +}; + + +//----------------------------------------------- +//access check helper + +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(tripId, requestingUserId); +} + + +// ---------------------------------------------- +//helpers for album link syncing + +export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) return fail('Trip not found or access denied', 404); + + try { + const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .get(linkId, tripId, userId) as { album_id: string } | null; + + return row ? success(row.album_id) : fail('Album link not found', 404); + } catch { + return fail('Failed to retrieve album link', 500); + } +} + +export function updateSyncTimeForAlbumLink(linkId: string): void { + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); +} diff --git a/server/src/services/immichService.ts b/server/src/services/memories/immichService.ts similarity index 97% rename from server/src/services/immichService.ts rename to server/src/services/memories/immichService.ts index d4a353b..47a9895 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -1,9 +1,9 @@ -import { db } from '../db/database'; -import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { writeAudit } from './auditLog'; -import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; -import { error } from 'node:console'; +import { db } from '../../db/database'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { writeAudit } from '../auditLog'; +import { addTripPhotos} from './unifiedService'; +import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; // ── Credentials ──────────────────────────────────────────────────────────── diff --git a/server/src/services/synologyService.ts b/server/src/services/memories/synologyService.ts similarity index 98% rename from server/src/services/synologyService.ts rename to server/src/services/memories/synologyService.ts index 4f66fbd..c9c8b57 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -1,12 +1,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { Response as ExpressResponse } from 'express'; -import { db } from '../db/database'; -import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; -import { error } from 'node:console'; -import { th } from 'zod/locales'; +import { db } from '../../db/database'; +import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { addTripPhotos} from './unifiedService'; +import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; diff --git a/server/src/services/memoriesService.ts b/server/src/services/memories/unifiedService.ts similarity index 75% rename from server/src/services/memoriesService.ts rename to server/src/services/memories/unifiedService.ts index 03ac465..35d47eb 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -1,49 +1,15 @@ -import { db, canAccessTrip } from '../db/database'; -import { notifyTripMembers } from './notifications'; -import { broadcast } from '../websocket'; +import { db, canAccessTrip } from '../../db/database'; +import { notifyTripMembers } from '../notifications'; +import { broadcast } from '../../websocket'; +import { + ServiceResult, + fail, + success, + mapDbError, + Selection, +} from './helpersService'; -type ServiceError = { success: false; error: { message: string; status: number } }; -type ServiceResult = { success: true; data: T } | ServiceError; - -function fail(error: string, status: number): ServiceError { - return { success: false, error: { message: error, status }}; -} - -function success(data: T): ServiceResult { - return { success: true, data: data }; -} - -function mapDbError(error: unknown, fallbackMessage: string): ServiceError { - if (error instanceof Error && /unique|constraint/i.test(error.message)) { - return fail('Resource already exists', 409); - } - return fail(fallbackMessage, 500); -} - -//----------------------------------------------- -//access check helper - -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(tripId, requestingUserId); -} export function listTripPhotos(tripId: string, userId: number): ServiceResult { const access = canAccessTrip(tripId, userId); @@ -108,11 +74,6 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId return result.changes > 0; } -export type Selection = { - provider: string; - asset_ids: string[]; -}; - export async function addTripPhotos( tripId: string, userId: number, @@ -181,7 +142,6 @@ export async function setTripPhotoSharing( } } - export function removeTripPhoto( tripId: string, userId: number, @@ -262,25 +222,6 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): } } -//helpers for album link syncing - -export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult { - const access = canAccessTrip(tripId, userId); - if (!access) return fail('Trip not found or access denied', 404); - - try { - const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .get(linkId, tripId, userId) as { album_id: string } | null; - - return row ? success(row.album_id) : fail('Album link not found', 404); - } catch { - return fail('Failed to retrieve album link', 500); - } -} - -export function updateSyncTimeForAlbumLink(linkId: string): void { - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); -} //----------------------------------------------- // notifications helper @@ -306,5 +247,3 @@ async function _notifySharedTripPhotos( return fail('Failed to send notifications', 500); } } - -