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..c46cfe7 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -33,13 +33,12 @@ 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';
import { Addon } from './types';
+import { getPhotoProviderConfig } from './services/memories/helpersService';
export function createApp(): express.Application {
const app = express();
@@ -196,11 +195,11 @@ export function createApp(): express.Application {
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick
[];
const providers = db.prepare(`
- SELECT id, name, icon, enabled, config, sort_order
+ SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id
- `).all() as Array<{ id: string; name: string; icon: string; enabled: number; config: string; sort_order: number }>;
+ `).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
@@ -234,7 +233,7 @@ export function createApp(): express.Application {
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
- config: JSON.parse(p.config || '{}'),
+ config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
key: f.field_key,
label: f.label,
@@ -255,8 +254,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/db/migrations.ts b/server/src/db/migrations.ts
index 20f160d..c9fd4ea 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -643,14 +643,13 @@ function runMigrations(db: Database.Database): void {
// Seed Synology Photos provider and fields in existing databases
try {
db.prepare(`
- INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order)
+ VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
icon = excluded.icon,
enabled = excluded.enabled,
- config = excluded.config,
sort_order = excluded.sort_order
`).run(
'synologyphotos',
@@ -658,12 +657,6 @@ function runMigrations(db: Database.Database): void {
'Synology Photos integration with separate account settings',
'Image',
0,
- JSON.stringify({
- settings_get: '/integrations/synologyphotos/settings',
- settings_put: '/integrations/synologyphotos/settings',
- status_get: '/integrations/synologyphotos/status',
- test_post: '/integrations/synologyphotos/test',
- }),
1,
);
} catch (err: any) {
@@ -691,6 +684,23 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('no such table')) throw err;
}
},
+ () => {
+ // Remove the stored config column from photo_providers now that it is generated from provider id.
+ const columns = db.prepare("PRAGMA table_info('photo_providers')").all() as Array<{ name: string }>;
+ const names = new Set(columns.map(c => c.name));
+ if (!names.has('config')) return;
+
+ db.exec('ALTER TABLE photo_providers DROP COLUMN config');
+ },
+ () => {
+ const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
+ const names = new Set(columns.map(c => c.name));
+ if (names.has('asset_id') && !names.has('immich_asset_id')) return;
+ db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id');
+ db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
+ db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
+ db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id');
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index 9e243b6..f18c8d0 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -18,6 +18,8 @@ function createTables(db: Database.Database): void {
mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT,
mfa_backup_codes TEXT,
+ immich_url TEXT,
+ immich_access_token TEXT,
synology_url TEXT,
synology_username TEXT,
synology_password TEXT,
@@ -166,6 +168,7 @@ function createTables(db: Database.Database): void {
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
title TEXT NOT NULL,
+ accommodation_id TEXT,
reservation_time TEXT,
reservation_end_time TEXT,
location TEXT,
@@ -232,7 +235,6 @@ function createTables(db: Database.Database): void {
description TEXT,
icon TEXT DEFAULT 'Image',
enabled INTEGER DEFAULT 0,
- config TEXT DEFAULT '{}',
sort_order INTEGER DEFAULT 0
);
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index ef849d9..2e233f6 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -101,12 +101,6 @@ function seedAddons(db: Database.Database): void {
icon: 'Image',
enabled: 0,
sort_order: 0,
- config: JSON.stringify({
- settings_get: '/integrations/immich/settings',
- settings_put: '/integrations/immich/settings',
- status_get: '/integrations/immich/status',
- test_post: '/integrations/immich/test',
- }),
},
{
id: 'synologyphotos',
@@ -115,16 +109,10 @@ function seedAddons(db: Database.Database): void {
icon: 'Image',
enabled: 0,
sort_order: 1,
- config: JSON.stringify({
- settings_get: '/integrations/synologyphotos/settings',
- settings_put: '/integrations/synologyphotos/settings',
- status_get: '/integrations/synologyphotos/status',
- test_post: '/integrations/synologyphotos/test',
- }),
},
];
- const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
- for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order);
+ const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
+ for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts
deleted file mode 100644
index 5a6f1cf..0000000
--- a/server/src/routes/memories.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import express, { Request, Response } from 'express';
-import { authenticate } from '../middleware/auth';
-import { broadcast } from '../websocket';
-import { AuthRequest } from '../types';
-import {
- listTripPhotos,
- listTripAlbumLinks,
- createTripAlbumLink,
- removeAlbumLink,
- addTripPhotos,
- removeTripPhoto,
- setTripPhotoSharing,
- notifySharedTripPhotos,
-} from '../services/memoriesService';
-
-const router = express.Router();
-
-
-router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const { tripId } = req.params;
- const result = listTripPhotos(tripId, authReq.user.id);
- if ('error' in result) return res.status(result.status).json({ error: result.error });
- res.json({ photos: result.photos });
-});
-
-router.get('/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);
- if ('error' in result) return res.status(result.status).json({ error: result.error });
- res.json({ links: result.links });
-});
-
-router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const { tripId, linkId } = req.params;
- const result = removeAlbumLink(tripId, linkId, authReq.user.id);
- if ('error' in result) return res.status(result.status).json({ error: result.error });
- res.json({ success: true });
- broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
-});
-
-router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const { tripId } = req.params;
- const result = addTripPhotos(
- tripId,
- authReq.user.id,
- req.body?.shared,
- req.body?.selections,
- req.body?.provider,
- req.body?.asset_ids,
- );
- if ('error' in result) return res.status(result.status).json({ error: result.error });
-
- res.json({ success: true, added: result.added });
- broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
-
- if (result.shared && result.added > 0) {
- void notifySharedTripPhotos(
- tripId,
- authReq.user.id,
- authReq.user.username || authReq.user.email,
- result.added,
- ).catch(() => {});
- }
-});
-
-router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const { tripId } = req.params;
- const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
- if ('error' in result) return res.status(result.status).json({ error: result.error });
- res.json({ success: true });
- broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
-});
-
-router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const { tripId } = req.params;
- const result = setTripPhotoSharing(
- tripId,
- authReq.user.id,
- req.body?.provider,
- req.body?.asset_id,
- req.body?.shared,
- );
- if ('error' in result) return res.status(result.status).json({ error: result.error });
- res.json({ success: true });
- broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
-});
-
-router.post('/trips/:tripId/album-links', authenticate, (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);
- if ('error' in result) return res.status(result.status).json({ error: result.error });
- res.json({ success: true });
-});
-
-export default router;
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/memories/synology.ts b/server/src/routes/memories/synology.ts
new file mode 100644
index 0000000..94ddd88
--- /dev/null
+++ b/server/src/routes/memories/synology.ts
@@ -0,0 +1,126 @@
+import express, { Request, Response } from 'express';
+import { authenticate } from '../../middleware/auth';
+import { AuthRequest } from '../../types';
+import {
+ getSynologySettings,
+ updateSynologySettings,
+ getSynologyStatus,
+ testSynologyConnection,
+ listSynologyAlbums,
+ syncSynologyAlbumLink,
+ searchSynologyPhotos,
+ getSynologyAssetInfo,
+ streamSynologyAsset,
+} from '../../services/memories/synologyService';
+import { canAccessUserPhoto, handleServiceResult, fail } from '../../services/memories/helpersService';
+
+const router = express.Router();
+
+function _parseStringBodyField(value: unknown): string {
+ return String(value ?? '').trim();
+}
+
+function _parseNumberBodyField(value: unknown, fallback: number): number {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+router.get('/settings', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ handleServiceResult(res, await getSynologySettings(authReq.user.id));
+});
+
+router.put('/settings', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const body = req.body as Record;
+ const synology_url = _parseStringBodyField(body.synology_url);
+ const synology_username = _parseStringBodyField(body.synology_username);
+ const synology_password = _parseStringBodyField(body.synology_password);
+
+ if (!synology_url || !synology_username) {
+ handleServiceResult(res, fail('URL and username are required', 400));
+ }
+ else {
+ handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password));
+ }
+});
+
+router.get('/status', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ handleServiceResult(res, await getSynologyStatus(authReq.user.id));
+});
+
+router.post('/test', authenticate, async (req: Request, res: Response) => {
+ const body = req.body as Record;
+ const synology_url = _parseStringBodyField(body.synology_url);
+ const synology_username = _parseStringBodyField(body.synology_username);
+ const synology_password = _parseStringBodyField(body.synology_password);
+
+ if (!synology_url || !synology_username || !synology_password) {
+ handleServiceResult(res, fail('URL, username, and password are required', 400));
+ }
+ else{
+ handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password));
+ }
+});
+
+router.get('/albums', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
+});
+
+router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId, linkId } = req.params;
+
+ handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId));
+});
+
+router.post('/search', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const body = req.body as Record;
+ const from = _parseStringBodyField(body.from);
+ const to = _parseStringBodyField(body.to);
+ const offset = _parseNumberBodyField(body.offset, 0);
+ const limit = _parseNumberBodyField(body.limit, 100);
+
+ handleServiceResult(res, await searchSynologyPhotos(
+ authReq.user.id,
+ from || undefined,
+ to || undefined,
+ offset,
+ limit,
+ ));
+});
+
+router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId, photoId, ownerId } = req.params;
+
+ if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
+ handleServiceResult(res, fail('You don\'t have access to this photo', 403));
+ }
+ else {
+ handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
+ }
+});
+
+router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId, photoId, ownerId, kind } = req.params;
+ const { size = "sm" } = req.query;
+
+ if (kind !== 'thumbnail' && kind !== 'original') {
+ handleServiceResult(res, fail('Invalid asset kind', 400));
+ }
+
+ if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
+ handleServiceResult(res, fail('You don\'t have access to this photo', 403));
+ }
+ else{
+ await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size));
+ }
+
+});
+
+export default router;
diff --git a/server/src/routes/memories/unified.ts b/server/src/routes/memories/unified.ts
new file mode 100644
index 0000000..3e4561d
--- /dev/null
+++ b/server/src/routes/memories/unified.ts
@@ -0,0 +1,102 @@
+import express, { Request, Response } from 'express';
+import { authenticate } from '../../middleware/auth';
+import { AuthRequest } from '../../types';
+import {
+ listTripPhotos,
+ listTripAlbumLinks,
+ createTripAlbumLink,
+ removeAlbumLink,
+ addTripPhotos,
+ removeTripPhoto,
+ setTripPhotoSharing,
+} 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('/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);
+ if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
+ res.json({ photos: result.data });
+});
+
+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;
+
+ const shared = req.body?.shared === undefined ? true : !!req.body?.shared;
+ const result = await addTripPhotos(
+ tripId,
+ authReq.user.id,
+ shared,
+ req.body?.selections || [],
+ sid,
+ );
+ if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
+
+ res.json({ success: true, added: result.data.added });
+});
+
+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(
+ tripId,
+ authReq.user.id,
+ req.body?.provider,
+ req.body?.asset_id,
+ req.body?.shared,
+ );
+ if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
+ res.json({ success: true });
+});
+
+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);
+ if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
+ res.json({ success: true });
+});
+
+//------------------------------
+// routes for managing album links
+
+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);
+ if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
+ res.json({ links: result.data });
+});
+
+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);
+ if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
+ res.json({ success: true });
+});
+
+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);
+ if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
+ res.json({ success: true });
+});
+
+
+
+
+export default router;
diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts
deleted file mode 100644
index 838a3de..0000000
--- a/server/src/routes/synology.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import express, { Request, Response } from 'express';
-import { authenticate } from '../middleware/auth';
-import { broadcast } from '../websocket';
-import { AuthRequest } from '../types';
-import {
- getSynologySettings,
- updateSynologySettings,
- getSynologyStatus,
- testSynologyConnection,
- listSynologyAlbums,
- syncSynologyAlbumLink,
- searchSynologyPhotos,
- getSynologyAssetInfo,
- pipeSynologyProxy,
- streamSynologyAsset,
- handleSynologyError,
- SynologyServiceError,
-} from '../services/synologyService';
-import { canAccessUserPhoto } from '../services/memoriesService';
-
-const router = express.Router();
-
-function parseStringBodyField(value: unknown): string {
- return String(value ?? '').trim();
-}
-
-function parseNumberBodyField(value: unknown, fallback: number): number {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : fallback;
-}
-
-router.get('/settings', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- try {
- res.json(await getSynologySettings(authReq.user.id));
- } catch (err: unknown) {
- handleSynologyError(res, err, 'Failed to load settings');
- }
-});
-
-router.put('/settings', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const body = req.body as Record;
- const synology_url = parseStringBodyField(body.synology_url);
- const synology_username = parseStringBodyField(body.synology_username);
- const synology_password = parseStringBodyField(body.synology_password);
-
- if (!synology_url || !synology_username) {
- return handleSynologyError(res, new SynologyServiceError(400, 'URL and username are required'), 'Missing required fields');
- }
-
- try {
- await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password);
- res.json({ success: true });
- } catch (err: unknown) {
- handleSynologyError(res, err, 'Failed to save settings');
- }
-});
-
-router.get('/status', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- res.json(await getSynologyStatus(authReq.user.id));
-});
-
-router.post('/test', authenticate, async (req: Request, res: Response) => {
- const body = req.body as Record;
- const synology_url = parseStringBodyField(body.synology_url);
- const synology_username = parseStringBodyField(body.synology_username);
- const synology_password = parseStringBodyField(body.synology_password);
-
- if (!synology_url || !synology_username || !synology_password) {
- return handleSynologyError(res, new SynologyServiceError(400, 'URL, username and password are required'), 'Missing required fields');
- }
-
- res.json(await testSynologyConnection(synology_url, synology_username, synology_password));
-});
-
-router.get('/albums', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- try {
- res.json(await listSynologyAlbums(authReq.user.id));
- } catch (err: unknown) {
- handleSynologyError(res, err, 'Could not reach Synology');
- }
-});
-
-router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const { tripId, linkId } = req.params;
-
- try {
- const result = await syncSynologyAlbumLink(authReq.user.id, tripId, linkId);
- res.json({ success: true, ...result });
- if (result.added > 0) {
- broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
- }
- } catch (err: unknown) {
- handleSynologyError(res, err, 'Could not reach Synology');
- }
-});
-
-router.post('/search', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- const body = req.body as Record;
- const from = parseStringBodyField(body.from);
- const to = parseStringBodyField(body.to);
- const offset = parseNumberBodyField(body.offset, 0);
- const limit = parseNumberBodyField(body.limit, 300);
-
- try {
- const result = await searchSynologyPhotos(
- authReq.user.id,
- from || undefined,
- to || undefined,
- offset,
- limit,
- );
- res.json(result);
- } catch (err: unknown) {
- handleSynologyError(res, err, 'Could not reach Synology');
- }
-});
-
-router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- 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, Number(ownerId)));
- } catch (err: unknown) {
- handleSynologyError(res, err, 'Could not reach Synology');
- }
-});
-
-router.get('/assets/:tripId/:photoId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- 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, Number(ownerId), photoId, 'thumbnail', String(size));
- await pipeSynologyProxy(res, proxy);
- } catch (err: unknown) {
- if (res.headersSent) {
- return;
- }
- handleSynologyError(res, err, 'Proxy error');
- }
-});
-
-router.get('/assets/:tripId/:photoId/:ownerId/original', authenticate, async (req: Request, res: Response) => {
- const authReq = req as AuthRequest;
- 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, Number(ownerId), photoId, 'original');
- await pipeSynologyProxy(res, proxy);
- } catch (err: unknown) {
- if (res.headersSent) {
- return;
- }
- handleSynologyError(res, err, 'Proxy error');
- }
-});
-
-export default router;
diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts
index f7df1f4..167eaeb 100644
--- a/server/src/services/adminService.ts
+++ b/server/src/services/adminService.ts
@@ -9,6 +9,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions } from '../mcp';
import { validatePassword } from './passwordPolicy';
+import { getPhotoProviderConfig } from './memories/helpersService';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -466,10 +467,10 @@ export function deleteTemplateItem(itemId: string) {
export function listAddons() {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
const providers = db.prepare(`
- SELECT id, name, description, icon, enabled, config, sort_order
+ SELECT id, name, description, icon, enabled, sort_order
FROM photo_providers
ORDER BY sort_order, id
- `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>;
+ `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
@@ -502,7 +503,7 @@ export function listAddons() {
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
- config: JSON.parse(p.config || '{}'),
+ config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
key: f.field_key,
label: f.label,
@@ -521,7 +522,7 @@ export function listAddons() {
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record }) {
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
- const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
+ const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
if (addon) {
@@ -529,11 +530,10 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
} else {
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
- if (data.config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
}
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
- const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
+ const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
const updated = updatedAddon
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
: updatedProvider
@@ -544,7 +544,7 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
- config: JSON.parse(updatedProvider.config || '{}'),
+ config: getPhotoProviderConfig(updatedProvider.id),
sort_order: updatedProvider.sort_order,
}
: null;
diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts
new file mode 100644
index 0000000..161d7bd
--- /dev/null
+++ b/server/src/services/memories/helpersService.ts
@@ -0,0 +1,186 @@
+import { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import { Response } from 'express';
+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: Error, fallbackMessage: string): ServiceError {
+ if (error && /unique|constraint/i.test(error.message)) {
+ return fail('Resource already exists', 409);
+ }
+ return fail(error.message, 500);
+}
+
+
+export function handleServiceResult(res: Response, result: ServiceResult): void {
+ if ('error' in result) {
+ res.status(result.error.status).json({ error: result.error.message });
+ }
+ else {
+ res.json(result.data);
+ }
+}
+
+// ----------------------------------------------
+// types used across memories services
+export type Selection = {
+ provider: string;
+ asset_ids: string[];
+};
+
+export type StatusResult = {
+ connected: true;
+ user: { name: string }
+} | {
+ connected: false;
+ error: string
+};
+
+export type SyncAlbumResult = {
+ added: number;
+ total: number
+};
+
+
+export type AlbumsList = {
+ albums: Array<{ id: string; albumName: string; assetCount: number }>
+};
+
+export type Asset = {
+ id: string;
+ takenAt: string;
+};
+
+export type AssetsList = {
+ assets: Asset[],
+ total: number,
+ hasMore: boolean
+};
+
+
+export type AssetInfo = {
+ id: string;
+ takenAt: string | null;
+ city: string | null;
+ country: string | null;
+ state?: string | null;
+ camera?: string | null;
+ lens?: string | null;
+ focalLength?: string | number | null;
+ aperture?: string | number | null;
+ shutter?: string | number | null;
+ iso?: string | number | null;
+ lat?: number | null;
+ lng?: number | null;
+ orientation?: number | null;
+ description?: string | null;
+ width?: number | null;
+ height?: number | null;
+ fileSize?: number | null;
+ fileName?: string | null;
+}
+
+
+//for loading routes to settings page, and validating which services user has connected
+type PhotoProviderConfig = {
+ settings_get: string;
+ settings_put: string;
+ status_get: string;
+ test_post: string;
+};
+
+
+export function getPhotoProviderConfig(providerId: string): PhotoProviderConfig {
+ const prefix = `/integrations/memories/${providerId}`;
+ return {
+ settings_get: `${prefix}/settings`,
+ settings_put: `${prefix}/settings`,
+ status_get: `${prefix}/status`,
+ test_post: `${prefix}/test`,
+ };
+}
+
+//-----------------------------------------------
+//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);
+}
+
+export async function pipeAsset(url: string, response: Response): Promise {
+ try{
+ const resp = await fetch(url);
+
+ response.status(resp.status);
+ if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
+ if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
+ if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
+ if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
+
+ if (!resp.body) {
+ response.end();
+ }
+ else {
+ pipeline(Readable.fromWeb(resp.body), response);
+ }
+ }
+ catch (error) {
+ response.status(500).json({ error: 'Failed to fetch asset' });
+ response.end();
+ }
+
+}
\ No newline at end of file
diff --git a/server/src/services/immichService.ts b/server/src/services/memories/immichService.ts
similarity index 92%
rename from server/src/services/immichService.ts
rename to server/src/services/memories/immichService.ts
index baef3bb..47a9895 100644
--- a/server/src/services/immichService.ts
+++ b/server/src/services/memories/immichService.ts
@@ -1,7 +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 { 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 ────────────────────────────────────────────────────────────
@@ -313,15 +315,14 @@ export async function syncAlbumAssets(
linkId: string,
userId: number
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
- const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?')
- .get(linkId, tripId, userId, 'immich') as any;
- if (!link) return { error: 'Album link not found', status: 404 };
+ const response = getAlbumIdFromLink(tripId, linkId, userId);
+ if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
- const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, {
+ const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
@@ -329,16 +330,17 @@ export async function syncAlbumAssets(
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
- const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)");
- let added = 0;
- for (const asset of assets) {
- const r = insert.run(tripId, userId, asset.id);
- if (r.changes > 0) added++;
- }
+ const selection: Selection = {
+ provider: 'immich',
+ asset_ids: assets.map((a: any) => a.id),
+ };
- db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
+ const result = await addTripPhotos(tripId, userId, true, [selection]);
+ if ('error' in result) return { error: result.error.message, status: result.error.status };
- return { success: true, added, total: assets.length };
+ updateSyncTimeForAlbumLink(linkId);
+
+ return { success: true, added: result.data.added, total: assets.length };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts
new file mode 100644
index 0000000..76b8916
--- /dev/null
+++ b/server/src/services/memories/synologyService.ts
@@ -0,0 +1,496 @@
+
+import { Response } from 'express';
+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,
+ ServiceResult,
+ fail,
+ success,
+ handleServiceResult,
+ pipeAsset,
+ AlbumsList,
+ AssetsList,
+ StatusResult,
+ SyncAlbumResult,
+ AssetInfo
+} from './helpersService';
+
+const SYNOLOGY_PROVIDER = 'synologyphotos';
+const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
+
+interface SynologyUserRecord {
+ synology_url?: string | null;
+ synology_username?: string | null;
+ synology_password?: string | null;
+ synology_sid?: string | null;
+};
+
+interface SynologyCredentials {
+ synology_url: string;
+ synology_username: string;
+ synology_password: string;
+}
+
+interface SynologySettings {
+ synology_url: string;
+ synology_username: string;
+ connected: boolean;
+}
+
+interface ApiCallParams {
+ api: string;
+ method: string;
+ version?: number;
+ [key: string]: unknown;
+}
+
+interface SynologyApiResponse {
+ success: boolean;
+ data?: T;
+ error?: { code: number };
+}
+
+
+interface SynologyPhotoItem {
+ id?: string | number;
+ filename?: string;
+ filesize?: number;
+ time?: number;
+ item_count?: number;
+ name?: string;
+ additional?: {
+ thumbnail?: { cache_key?: string };
+ address?: { city?: string; country?: string; state?: string };
+ resolution?: { width?: number; height?: number };
+ exif?: {
+ camera?: string;
+ lens?: string;
+ focal_length?: string | number;
+ aperture?: string | number;
+ exposure_time?: string | number;
+ iso?: string | number;
+ };
+ gps?: { latitude?: number; longitude?: number };
+ orientation?: number;
+ description?: string;
+ };
+}
+
+
+function _readSynologyUser(userId: number, columns: string[]): ServiceResult {
+ try {
+
+ if (!columns) return null;
+
+ const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
+
+ if (!row) {
+ return fail('User not found', 404);
+ }
+
+ const filtered: SynologyUserRecord = {};
+ for (const column of columns) {
+ filtered[column] = row[column];
+ }
+
+ if (!filtered) {
+ return fail('Failed to read Synology user data', 500);
+ }
+
+ return success(filtered);
+ } catch {
+ return fail('Failed to read Synology user data', 500);
+ }
+}
+
+function _getSynologyCredentials(userId: number): ServiceResult {
+ const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
+ if (!user.success) return user as ServiceResult;
+ if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
+ return success({
+ synology_url: user.data.synology_url,
+ synology_username: user.data.synology_username,
+ synology_password: decrypt_api_key(user.data.synology_password) as string,
+ });
+}
+
+
+function _buildSynologyEndpoint(url: string): string {
+ const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
+ return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`;
+}
+
+function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
+ const body = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null) continue;
+ body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
+ }
+ return body;
+}
+
+async function _fetchSynologyJson(url: string, body: URLSearchParams): Promise> {
+ const endpoint = _buildSynologyEndpoint(url);
+ const resp = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+ },
+ body,
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!resp.ok) {
+ return fail('Synology API request failed with status ' + resp.status, resp.status);
+ }
+
+ const response = await resp.json() as SynologyApiResponse;
+ return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
+}
+
+async function _loginToSynology(url: string, username: string, password: string): Promise> {
+ const body = new URLSearchParams({
+ api: 'SYNO.API.Auth',
+ method: 'login',
+ version: '3',
+ account: username,
+ passwd: password,
+ });
+
+ const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
+ if (!result.success) {
+ return result as ServiceResult;
+ }
+ if (!result.data.sid) {
+ return fail('Failed to get session ID from Synology', 500);
+ }
+ return success(result.data.sid);
+
+
+}
+
+async function _requestSynologyApi(userId: number, params: ApiCallParams): Promise> {
+ const creds = _getSynologyCredentials(userId);
+ if (!creds.success) {
+ return creds as ServiceResult;
+ }
+
+ const session = await _getSynologySession(userId);
+ if (!session.success || !session.data) {
+ return session as ServiceResult;
+ }
+
+ const body = _buildSynologyFormBody({ ...params, _sid: session.data });
+ const result = await _fetchSynologyJson(creds.data.synology_url, body);
+ if ('error' in result && result.error.status === 119) {
+ _clearSynologySID(userId);
+ const retrySession = await _getSynologySession(userId);
+ if (!retrySession.success || !retrySession.data) {
+ return session as ServiceResult;
+ }
+ return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
+ }
+ return result;
+}
+
+function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo {
+ const address = item.additional?.address || {};
+ const exif = item.additional?.exif || {};
+ const gps = item.additional?.gps || {};
+
+ return {
+ id: String(item.additional?.thumbnail?.cache_key || ''),
+ takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
+ city: address.city || null,
+ country: address.country || null,
+ state: address.state || null,
+ camera: exif.camera || null,
+ lens: exif.lens || null,
+ focalLength: exif.focal_length || null,
+ aperture: exif.aperture || null,
+ shutter: exif.exposure_time || null,
+ iso: exif.iso || null,
+ lat: gps.latitude || null,
+ lng: gps.longitude || null,
+ orientation: item.additional?.orientation || null,
+ description: item.additional?.description || null,
+ width: item.additional?.resolution?.width || null,
+ height: item.additional?.resolution?.height || null,
+ fileSize: item.filesize || null,
+ fileName: item.filename || null,
+ };
+}
+
+function _cacheSynologySID(userId: number, sid: string): void {
+ db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
+}
+
+function _clearSynologySID(userId: number): void {
+ db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
+}
+
+function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
+ const id = rawId.split('_')[0];
+ return { id, cacheKey: rawId, assetId: rawId };
+}
+
+
+async function _getSynologySession(userId: number): Promise> {
+ const cachedSid = _readSynologyUser(userId, ['synology_sid']);
+ if (cachedSid.success && cachedSid.data?.synology_sid) {
+ return success(cachedSid.data.synology_sid);
+ }
+
+ const creds = _getSynologyCredentials(userId);
+ if (!creds.success) {
+ return creds as ServiceResult;
+ }
+
+ const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
+
+ if (!resp.success) {
+ return resp as ServiceResult;
+ }
+
+ _cacheSynologySID(userId, resp.data);
+ return success(resp.data);
+}
+
+export async function getSynologySettings(userId: number): Promise> {
+ const creds = _getSynologyCredentials(userId);
+ if (!creds.success) return creds as ServiceResult;
+ const session = await _getSynologySession(userId);
+ return success({
+ synology_url: creds.data.synology_url || '',
+ synology_username: creds.data.synology_username || '',
+ connected: session.success,
+ });
+}
+
+export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise> {
+
+ const ssrf = await checkSsrf(synologyUrl);
+ if (!ssrf.allowed) {
+ return fail(ssrf.error, 400);
+ }
+
+ const result = _readSynologyUser(userId, ['synology_password'])
+ if (!result.success) return result as ServiceResult;
+ const existingEncryptedPassword = result.data?.synology_password || null;
+
+ if (!synologyPassword && !existingEncryptedPassword) {
+ return fail('No stored password found. Please provide a password to save settings.', 400);
+ }
+
+ try {
+ db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
+ synologyUrl,
+ synologyUsername,
+ synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
+ userId,
+ );
+ } catch {
+ return fail('Failed to update Synology settings', 500);
+ }
+
+ _clearSynologySID(userId);
+ return success("settings updated");
+}
+
+export async function getSynologyStatus(userId: number): Promise> {
+ const sid = await _getSynologySession(userId);
+ if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message });
+ if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
+ try {
+ const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
+ return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } });
+ } catch (err: unknown) {
+ return success({ connected: true, user: { name: 'unknown user' } });
+ }
+}
+
+export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise> {
+
+ const ssrf = await checkSsrf(synologyUrl);
+ if (!ssrf.allowed) {
+ return fail(ssrf.error, 400);
+ }
+
+ const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
+ if ('error' in resp) {
+ return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
+ }
+ return success({ connected: true, user: { name: synologyUsername } });
+}
+
+export async function listSynologyAlbums(userId: number): Promise> {
+ const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
+ api: 'SYNO.Foto.Browse.Album',
+ method: 'list',
+ version: 4,
+ offset: 0,
+ limit: 100,
+ });
+ if (!result.success) return result as ServiceResult;
+
+ const albums = (result.data.list || []).map((album: any) => ({
+ id: String(album.id),
+ albumName: album.name || '',
+ assetCount: album.item_count || 0,
+ }));
+
+ return success({ albums });
+}
+
+
+export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise> {
+ const response = getAlbumIdFromLink(tripId, linkId, userId);
+ if (!response.success) return response as ServiceResult;
+
+ const allItems: SynologyPhotoItem[] = [];
+ const pageSize = 1000;
+ let offset = 0;
+
+ while (true) {
+ const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
+ api: 'SYNO.Foto.Browse.Item',
+ method: 'list',
+ version: 1,
+ album_id: Number(response.data),
+ offset,
+ limit: pageSize,
+ additional: ['thumbnail'],
+ });
+
+ if (!result.success) return result as ServiceResult;
+
+ const items = result.data.list || [];
+ allItems.push(...items);
+ if (items.length < pageSize) break;
+ offset += pageSize;
+ }
+
+ const selection: Selection = {
+ provider: SYNOLOGY_PROVIDER,
+ asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
+ };
+
+ updateSyncTimeForAlbumLink(linkId);
+
+ const result = await addTripPhotos(tripId, userId, true, [selection]);
+ if (!result.success) return result as ServiceResult;
+
+ return success({ added: result.data.added, total: allItems.length });
+}
+
+export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise> {
+ const params: ApiCallParams = {
+ api: 'SYNO.Foto.Search.Search',
+ method: 'list_item',
+ version: 1,
+ offset,
+ limit,
+ keyword: '.',
+ additional: ['thumbnail', 'address'],
+ };
+
+ if (from || to) {
+ if (from) {
+ params.start_time = Math.floor(new Date(from).getTime() / 1000);
+ }
+ if (to) {
+ params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
+ }
+ }
+
+ const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
+ if (!result.success) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>;
+
+ const allItems = result.data.list || [];
+ const total = allItems.length;
+ const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item));
+
+ return success({
+ assets,
+ total,
+ hasMore: total === limit,
+ });
+}
+
+export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise> {
+ const parsedId = _splitPackedSynologyId(photoId);
+ const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
+ api: 'SYNO.Foto.Browse.Item',
+ method: 'get',
+ version: 5,
+ id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
+ additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
+ });
+
+ if (!result.success) return result as ServiceResult;
+
+ const metadata = result.data.list?.[0];
+ if (!metadata) return fail('Photo not found', 404);
+
+ const normalized = _normalizeSynologyPhotoInfo(metadata);
+ normalized.id = photoId;
+ return success(normalized);
+}
+
+export async function streamSynologyAsset(
+ response: Response,
+ userId: number,
+ targetUserId: number,
+ photoId: string,
+ kind: 'thumbnail' | 'original',
+ size?: string,
+): Promise {
+ const parsedId = _splitPackedSynologyId(photoId);
+
+ const synology_credentials = _getSynologyCredentials(targetUserId);
+ if (!synology_credentials.success) {
+ handleServiceResult(response, synology_credentials);
+ return;
+ }
+
+ const sid = await _getSynologySession(targetUserId);
+ if (!sid.success) {
+ handleServiceResult(response, sid);
+ return;
+ }
+ if (!sid.data) {
+ handleServiceResult(response, fail('Failed to retrieve session ID', 500));
+ return;
+ }
+
+ const params = kind === 'thumbnail'
+ ? new URLSearchParams({
+ api: 'SYNO.Foto.Thumbnail',
+ method: 'get',
+ version: '2',
+ mode: 'download',
+ id: parsedId.id,
+ type: 'unit',
+ size: size,
+ cache_key: parsedId.cacheKey,
+ _sid: sid.data,
+ })
+ : new URLSearchParams({
+ api: 'SYNO.Foto.Download',
+ method: 'download',
+ version: '2',
+ cache_key: parsedId.cacheKey,
+ unit_id: `[${parsedId.id}]`,
+ _sid: sid.data,
+ });
+
+ const url = `${_buildSynologyEndpoint(synology_credentials.data.synology_url)}?${params.toString()}`;
+
+ await pipeAsset(url, response)
+}
+
diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts
new file mode 100644
index 0000000..35d47eb
--- /dev/null
+++ b/server/src/services/memories/unifiedService.ts
@@ -0,0 +1,249 @@
+import { db, canAccessTrip } from '../../db/database';
+import { notifyTripMembers } from '../notifications';
+import { broadcast } from '../../websocket';
+
+import {
+ ServiceResult,
+ fail,
+ success,
+ mapDbError,
+ Selection,
+} from './helpersService';
+
+
+export function listTripPhotos(tripId: string, userId: number): ServiceResult {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) {
+ return fail('Trip not found or access denied', 404);
+ }
+
+ try {
+ const photos = db.prepare(`
+ SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
+ u.username, u.avatar
+ FROM trip_photos tp
+ JOIN users u ON tp.user_id = u.id
+ WHERE tp.trip_id = ?
+ AND (tp.user_id = ? OR tp.shared = 1)
+ ORDER BY tp.added_at ASC
+ `).all(tripId, userId) as any[];
+
+ return success(photos);
+ } catch (error) {
+ return mapDbError(error, 'Failed to list trip photos');
+ }
+}
+
+export function listTripAlbumLinks(tripId: string, userId: number): ServiceResult {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) {
+ return fail('Trip not found or access denied', 404);
+ }
+
+ try {
+ const links = db.prepare(`
+ SELECT tal.id,
+ tal.trip_id,
+ tal.user_id,
+ tal.provider,
+ tal.album_id,
+ tal.album_name,
+ tal.sync_enabled,
+ tal.last_synced_at,
+ tal.created_at,
+ u.username
+ FROM trip_album_links tal
+ JOIN users u ON tal.user_id = u.id
+ WHERE tal.trip_id = ?
+ ORDER BY tal.created_at ASC
+ `).all(tripId);
+
+ return success(links);
+ } catch (error) {
+ return mapDbError(error, 'Failed to list trip album links');
+ }
+}
+
+//-----------------------------------------------
+// managing photos in trip
+
+function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean {
+ const result = db.prepare(
+ 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
+ ).run(tripId, userId, assetId, provider, shared ? 1 : 0);
+ return result.changes > 0;
+}
+
+export async function addTripPhotos(
+ tripId: string,
+ userId: number,
+ shared: boolean,
+ selections: Selection[],
+ sid?: string,
+): Promise> {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) {
+ return fail('Trip not found or access denied', 404);
+ }
+
+ if (selections.length === 0) {
+ return fail('No photos selected', 400);
+ }
+
+ try {
+ let added = 0;
+ for (const selection of selections) {
+ for (const raw of selection.asset_ids) {
+ const assetId = String(raw || '').trim();
+ if (!assetId) continue;
+ if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared)) {
+ added++;
+ }
+ }
+ }
+
+ await _notifySharedTripPhotos(tripId, userId, added);
+ broadcast(tripId, 'memories:updated', { userId }, sid);
+ return success({ added, shared });
+ } catch (error) {
+ return mapDbError(error, 'Failed to add trip photos');
+ }
+}
+
+
+export async function setTripPhotoSharing(
+ tripId: string,
+ userId: number,
+ provider: string,
+ assetId: string,
+ shared: boolean,
+ sid?: string,
+): Promise> {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) {
+ return fail('Trip not found or access denied', 404);
+ }
+
+ try {
+ db.prepare(`
+ UPDATE trip_photos
+ SET shared = ?
+ WHERE trip_id = ?
+ AND user_id = ?
+ AND asset_id = ?
+ AND provider = ?
+ `).run(shared ? 1 : 0, tripId, userId, assetId, provider);
+
+ await _notifySharedTripPhotos(tripId, userId, 1);
+ broadcast(tripId, 'memories:updated', { userId }, sid);
+ return success(true);
+ } catch (error) {
+ return mapDbError(error, 'Failed to update photo sharing');
+ }
+}
+
+export function removeTripPhoto(
+ tripId: string,
+ userId: number,
+ provider: string,
+ assetId: string,
+ sid?: string,
+): ServiceResult {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) {
+ return fail('Trip not found or access denied', 404);
+ }
+
+ try {
+ db.prepare(`
+ DELETE FROM trip_photos
+ WHERE trip_id = ?
+ AND user_id = ?
+ AND asset_id = ?
+ AND provider = ?
+ `).run(tripId, userId, assetId, provider);
+
+ broadcast(tripId, 'memories:updated', { userId }, sid);
+
+ return success(true);
+ } catch (error) {
+ return mapDbError(error, 'Failed to remove trip photo');
+ }
+}
+
+// ----------------------------------------------
+// managing album links in trip
+
+export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) {
+ return fail('Trip not found or access denied', 404);
+ }
+
+ const provider = String(providerRaw || '').toLowerCase();
+ const albumId = String(albumIdRaw || '').trim();
+ const albumName = String(albumNameRaw || '').trim();
+
+ if (!provider) {
+ return fail('provider is required', 400);
+ }
+ if (!albumId) {
+ return fail('album_id required', 400);
+ }
+
+ try {
+ const result = db.prepare(
+ 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
+ ).run(tripId, userId, provider, albumId, albumName);
+
+ if (result.changes === 0) {
+ return fail('Album already linked', 409);
+ }
+
+ return success(true);
+ } catch (error) {
+ return mapDbError(error, 'Failed to link album');
+ }
+}
+
+export function removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) {
+ return fail('Trip not found or access denied', 404);
+ }
+
+ try {
+ db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
+ .run(linkId, tripId, userId);
+
+ return success(true);
+ } catch (error) {
+ return mapDbError(error, 'Failed to remove album link');
+ }
+}
+
+
+//-----------------------------------------------
+// notifications helper
+
+async function _notifySharedTripPhotos(
+ tripId: string,
+ actorUserId: number,
+ added: number,
+): Promise> {
+ if (added <= 0) return fail('No photos shared, skipping notifications', 200);
+
+ try {
+ const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null };
+
+ const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
+ await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', {
+ trip: tripInfo?.title || 'Untitled',
+ actor: actorRow?.username || 'Unknown',
+ count: String(added),
+ });
+ return success(undefined);
+ } catch {
+ return fail('Failed to send notifications', 500);
+ }
+}
diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts
deleted file mode 100644
index 3886c8d..0000000
--- a/server/src/services/memoriesService.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-import { db, canAccessTrip } from '../db/database';
-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 };
- }
- return null;
-}
-
-type Selection = {
- provider: string;
- asset_ids: unknown[];
-};
-
-function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] {
- const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null;
- const provider = String(providerRaw || '').toLowerCase();
-
- if (selectionsFromBody && selectionsFromBody.length > 0) {
- return selectionsFromBody
- .map((selection: any) => ({
- provider: String(selection?.provider || '').toLowerCase(),
- asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [],
- }))
- .filter((selection: Selection) => selection.provider && selection.asset_ids.length > 0);
- }
-
- if (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0) {
- return [{ provider, asset_ids: assetIdsRaw }];
- }
-
- return [];
-}
-
-export function listTripPhotos(tripId: string, userId: number): { photos: any[] } | ServiceError {
- const denied = accessDeniedIfMissing(tripId, userId);
- if (denied) return denied;
-
- const photos = db.prepare(`
- SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
- u.username, u.avatar
- FROM trip_photos tp
- JOIN users u ON tp.user_id = u.id
- WHERE tp.trip_id = ?
- AND (tp.user_id = ? OR tp.shared = 1)
- ORDER BY tp.added_at ASC
- `).all(tripId, userId) as any[];
-
- return { photos };
-}
-
-export function listTripAlbumLinks(tripId: string, userId: number): { links: any[] } | ServiceError {
- const denied = accessDeniedIfMissing(tripId, userId);
- if (denied) return denied;
-
- const links = db.prepare(`
- SELECT tal.id,
- tal.trip_id,
- tal.user_id,
- tal.provider,
- tal.album_id,
- tal.album_name,
- tal.sync_enabled,
- tal.last_synced_at,
- tal.created_at,
- u.username
- FROM trip_album_links tal
- JOIN users u ON tal.user_id = u.id
- WHERE tal.trip_id = ?
- ORDER BY tal.created_at ASC
- `).all(tripId);
-
- return { links };
-}
-
-export function createTripAlbumLink(
- tripId: string,
- userId: number,
- providerRaw: unknown,
- albumIdRaw: unknown,
- albumNameRaw: unknown,
-): { success: true } | ServiceError {
- const denied = accessDeniedIfMissing(tripId, userId);
- if (denied) return denied;
-
- const provider = String(providerRaw || '').toLowerCase();
- const albumId = String(albumIdRaw || '').trim();
- const albumName = String(albumNameRaw || '').trim();
-
- if (!provider) {
- return { error: 'provider is required', status: 400 };
- }
- if (!albumId) {
- return { error: 'album_id required', status: 400 };
- }
-
- try {
- db.prepare(
- 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
- ).run(tripId, userId, provider, albumId, albumName);
- return { success: true };
- } catch {
- return { error: 'Album already linked', status: 400 };
- }
-}
-
-export function removeAlbumLink(tripId: string, linkId: string, userId: number): { success: true } | ServiceError {
- const denied = accessDeniedIfMissing(tripId, userId);
- if (denied) return denied;
-
- db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
- .run(linkId, tripId, userId);
-
- return { success: true };
-}
-
-export function addTripPhotos(
- tripId: string,
- userId: number,
- sharedRaw: unknown,
- selectionsRaw: unknown,
- providerRaw: unknown,
- assetIdsRaw: unknown,
-): { success: true; added: number; shared: boolean } | ServiceError {
- const denied = accessDeniedIfMissing(tripId, userId);
- if (denied) return denied;
-
- const shared = sharedRaw === undefined ? true : !!sharedRaw;
- const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw);
- if (selections.length === 0) {
- return { error: 'selections required', status: 400 };
- }
-
- const insert = db.prepare(
- 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
- );
-
- let added = 0;
- for (const selection of selections) {
- for (const raw of selection.asset_ids) {
- const assetId = String(raw || '').trim();
- if (!assetId) continue;
- const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0);
- if (result.changes > 0) added++;
- }
- }
-
- return { success: true, added, shared };
-}
-
-export function removeTripPhoto(
- tripId: string,
- userId: number,
- providerRaw: unknown,
- assetIdRaw: unknown,
-): { success: true } | ServiceError {
- const assetId = String(assetIdRaw || '');
- const provider = String(providerRaw || '').toLowerCase();
-
- if (!assetId) {
- return { error: 'asset_id is required', status: 400 };
- }
- if (!provider) {
- return { error: 'provider is required', status: 400 };
- }
-
- const denied = accessDeniedIfMissing(tripId, userId);
- if (denied) return denied;
-
- db.prepare(`
- DELETE FROM trip_photos
- WHERE trip_id = ?
- AND user_id = ?
- AND asset_id = ?
- AND provider = ?
- `).run(tripId, userId, assetId, provider);
-
- return { success: true };
-}
-
-export function setTripPhotoSharing(
- tripId: string,
- userId: number,
- providerRaw: unknown,
- assetIdRaw: unknown,
- sharedRaw: unknown,
-): { success: true } | ServiceError {
- const assetId = String(assetIdRaw || '');
- const provider = String(providerRaw || '').toLowerCase();
-
- if (!assetId) {
- return { error: 'asset_id is required', status: 400 };
- }
- if (!provider) {
- return { error: 'provider is required', status: 400 };
- }
-
- const denied = accessDeniedIfMissing(tripId, userId);
- if (denied) return denied;
-
- db.prepare(`
- UPDATE trip_photos
- SET shared = ?
- WHERE trip_id = ?
- AND user_id = ?
- AND asset_id = ?
- AND provider = ?
- `).run(sharedRaw ? 1 : 0, tripId, userId, assetId, provider);
-
- return { success: true };
-}
-
-export async function notifySharedTripPhotos(
- tripId: string,
- actorUserId: number,
- actorName: string,
- added: number,
-): Promise {
- if (added <= 0) return;
-
- const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
- await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', {
- trip: tripInfo?.title || 'Untitled',
- actor: actorName,
- count: String(added),
- });
-}
diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts
deleted file mode 100644
index 0cb3308..0000000
--- a/server/src/services/synologyService.ts
+++ /dev/null
@@ -1,588 +0,0 @@
-import { Readable } from 'node:stream';
-import { pipeline } from 'node:stream/promises';
-import { Request, 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';
-
-const SYNOLOGY_API_TIMEOUT_MS = 30000;
-const SYNOLOGY_PROVIDER = 'synologyphotos';
-const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
-const SYNOLOGY_DEFAULT_THUMBNAIL_SIZE = 'sm';
-
-interface SynologyCredentials {
- synology_url: string;
- synology_username: string;
- synology_password: string;
-}
-
-interface SynologySession {
- success: boolean;
- sid?: string;
- error?: { code: number; message?: string };
-}
-
-interface ApiCallParams {
- api: string;
- method: string;
- version?: number;
- [key: string]: unknown;
-}
-
-interface SynologyApiResponse {
- success: boolean;
- data?: T;
- error?: { code: number; message?: string };
-}
-
-export class SynologyServiceError extends Error {
- status: number;
-
- constructor(status: number, message: string) {
- super(message);
- this.status = status;
- }
-}
-
-export interface SynologySettings {
- synology_url: string;
- synology_username: string;
- connected: boolean;
-}
-
-export interface SynologyConnectionResult {
- connected: boolean;
- user?: { username: string };
- error?: string;
-}
-
-export interface SynologyAlbumLinkInput {
- album_id?: string | number;
- album_name?: string;
-}
-
-export interface SynologySearchInput {
- from?: string;
- to?: string;
- offset?: number;
- limit?: number;
-}
-
-export interface SynologyProxyResult {
- status: number;
- headers: Record;
- body: ReadableStream | null;
-}
-
-interface SynologyPhotoInfo {
- id: string;
- takenAt: string | null;
- city: string | null;
- country: string | null;
- state?: string | null;
- camera?: string | null;
- lens?: string | null;
- focalLength?: string | number | null;
- aperture?: string | number | null;
- shutter?: string | number | null;
- iso?: string | number | null;
- lat?: number | null;
- lng?: number | null;
- orientation?: number | null;
- description?: string | null;
- filename?: string | null;
- filesize?: number | null;
- width?: number | null;
- height?: number | null;
- fileSize?: number | null;
- fileName?: string | null;
-}
-
-interface SynologyPhotoItem {
- id?: string | number;
- filename?: string;
- filesize?: number;
- time?: number;
- item_count?: number;
- name?: string;
- additional?: {
- thumbnail?: { cache_key?: string };
- address?: { city?: string; country?: string; state?: string };
- resolution?: { width?: number; height?: number };
- exif?: {
- camera?: string;
- lens?: string;
- focal_length?: string | number;
- aperture?: string | number;
- exposure_time?: string | number;
- iso?: string | number;
- };
- gps?: { latitude?: number; longitude?: number };
- orientation?: number;
- description?: string;
- };
-}
-
-type SynologyUserRecord = {
- synology_url?: string | null;
- synology_username?: string | null;
- synology_password?: string | null;
- synology_sid?: string | null;
-};
-
-function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null {
- try {
-
- if (!columns) return null;
-
- const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
-
- if (!row) return null;
-
- const filtered: SynologyUserRecord = {};
- for (const column of columns) {
- filtered[column] = row[column];
- }
-
- return filtered || null;
- } catch {
- return null;
- }
-}
-
-function getSynologyCredentials(userId: number): SynologyCredentials | null {
- const user = readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
- if (!user?.synology_url || !user.synology_username || !user.synology_password) return null;
- return {
- synology_url: user.synology_url,
- synology_username: user.synology_username,
- synology_password: decrypt_api_key(user.synology_password) as string,
- };
-}
-
-
-function buildSynologyEndpoint(url: string): string {
- const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
- return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`;
-}
-
-function buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
- const body = new URLSearchParams();
- for (const [key, value] of Object.entries(params)) {
- if (value === undefined || value === null) continue;
- body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
- }
- return body;
-}
-
-async function fetchSynologyJson(url: string, body: URLSearchParams): Promise> {
- const endpoint = buildSynologyEndpoint(url);
- const resp = await fetch(endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
- },
- body,
- signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
- });
-
- if (!resp.ok) {
- const text = await resp.text();
- return { success: false, error: { code: resp.status, message: text } };
- }
-
- return resp.json() as Promise>;
-}
-
-async function loginToSynology(url: string, username: string, password: string): Promise> {
- const body = new URLSearchParams({
- api: 'SYNO.API.Auth',
- method: 'login',
- version: '3',
- account: username,
- passwd: password,
- });
-
- return fetchSynologyJson<{ sid?: string }>(url, body);
-}
-
-async function requestSynologyApi(userId: number, params: ApiCallParams): Promise> {
- const creds = getSynologyCredentials(userId);
- if (!creds) {
- return { success: false, error: { code: 400, message: 'Synology not configured' } };
- }
-
- const session = await getSynologySession(userId);
- if (!session.success || !session.sid) {
- return { success: false, error: session.error || { code: 400, message: 'Failed to get Synology session' } };
- }
-
- const body = buildSynologyFormBody({ ...params, _sid: session.sid });
- const result = await fetchSynologyJson(creds.synology_url, body);
- if (!result.success && result.error?.code === 119) {
- clearSynologySID(userId);
- const retrySession = await getSynologySession(userId);
- if (!retrySession.success || !retrySession.sid) {
- return { success: false, error: retrySession.error || { code: 400, message: 'Failed to get Synology session' } };
- }
- return fetchSynologyJson(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid }));
- }
- return result;
-}
-
-async function requestSynologyStream(url: string): Promise {
- return fetch(url, {
- signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
- });
-}
-
-function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo {
- const address = item.additional?.address || {};
- const exif = item.additional?.exif || {};
- const gps = item.additional?.gps || {};
-
- return {
- id: String(item.additional?.thumbnail?.cache_key || ''),
- takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
- city: address.city || null,
- country: address.country || null,
- state: address.state || null,
- camera: exif.camera || null,
- lens: exif.lens || null,
- focalLength: exif.focal_length || null,
- aperture: exif.aperture || null,
- shutter: exif.exposure_time || null,
- iso: exif.iso || null,
- lat: gps.latitude || null,
- lng: gps.longitude || null,
- orientation: item.additional?.orientation || null,
- description: item.additional?.description || null,
- filename: item.filename || null,
- filesize: item.filesize || null,
- width: item.additional?.resolution?.width || null,
- height: item.additional?.resolution?.height || null,
- fileSize: item.filesize || null,
- fileName: item.filename || null,
- };
-}
-
-export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse {
- if (err instanceof SynologyServiceError) {
- return res.status(err.status).json({ error: err.message });
- }
- return res.status(502).json({ error: err instanceof Error ? err.message : fallbackMessage });
-}
-
-function cacheSynologySID(userId: number, sid: string): void {
- db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
-}
-
-function clearSynologySID(userId: number): void {
- db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
-}
-
-function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
- const id = rawId.split('_')[0];
- return { id, cacheKey: rawId, assetId: rawId };
-}
-
-
-async function getSynologySession(userId: number): Promise {
- const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null;
- if (cachedSid) {
- return { success: true, sid: cachedSid };
- }
-
- const creds = getSynologyCredentials(userId);
- if (!creds) {
- return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } };
- }
-
- const resp = await loginToSynology(creds.synology_url, creds.synology_username, creds.synology_password);
-
- if (!resp.success || !resp.data?.sid) {
- return { success: false, error: resp.error || { code: 400, message: 'Failed to authenticate with Synology' } };
- }
-
- cacheSynologySID(userId, resp.data.sid);
- return { success: true, sid: resp.data.sid };
-}
-
-export async function getSynologySettings(userId: number): Promise {
- const creds = getSynologyCredentials(userId);
- const session = await getSynologySession(userId);
- return {
- synology_url: creds?.synology_url || '',
- synology_username: creds?.synology_username || '',
- connected: session.success,
- };
-}
-
-export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise {
-
- const ssrf = await checkSsrf(synologyUrl);
- if (!ssrf.allowed) {
- throw new SynologyServiceError(400, ssrf.error ?? 'Invalid Synology URL');
- }
-
- const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null;
-
- if (!synologyPassword && !existingEncryptedPassword) {
- throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.');
- }
-
- try {
- db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
- synologyUrl,
- synologyUsername,
- synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
- userId,
- );
- } catch {
- throw new SynologyServiceError(400, 'Failed to save settings');
- }
-
- clearSynologySID(userId);
- await getSynologySession(userId);
-}
-
-export async function getSynologyStatus(userId: number): Promise {
- try {
- const sid = await getSynologySession(userId);
- if (!sid.success || !sid.sid) {
- return { connected: false, error: 'Authentication failed' };
- }
-
- const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
- return { connected: true, user: { username: user?.synology_username || '' } };
- } catch (err: unknown) {
- return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
- }
-}
-
-export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise {
-
- const ssrf = await checkSsrf(synologyUrl);
- if (!ssrf.allowed) {
- return { connected: false, error: ssrf.error ?? 'Invalid Synology URL' };
- }
- try {
- const login = await loginToSynology(synologyUrl, synologyUsername, synologyPassword);
- if (!login.success || !login.data?.sid) {
- return { connected: false, error: login.error?.message || 'Authentication failed' };
- }
- return { connected: true, user: { username: synologyUsername } };
- } catch (err: unknown) {
- return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
- }
-}
-
-export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> {
- const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
- api: 'SYNO.Foto.Browse.Album',
- method: 'list',
- version: 4,
- offset: 0,
- limit: 100,
- });
-
- if (!result.success || !result.data) {
- throw new SynologyServiceError(result.error?.code || 500, result.error?.message || 'Failed to fetch albums');
- }
-
- const albums = (result.data.list || []).map((album: SynologyPhotoItem) => ({
- id: String(album.id),
- albumName: album.name || '',
- assetCount: album.item_count || 0,
- }));
-
- return { albums };
-}
-
-
-export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> {
- const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`)
- .get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined;
-
- if (!link) {
- throw new SynologyServiceError(404, 'Album link not found');
- }
-
- const allItems: SynologyPhotoItem[] = [];
- const pageSize = 1000;
- let offset = 0;
-
- while (true) {
- const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
- api: 'SYNO.Foto.Browse.Item',
- method: 'list',
- version: 1,
- album_id: Number(link.album_id),
- offset,
- limit: pageSize,
- additional: ['thumbnail'],
- });
-
- if (!result.success || !result.data) {
- throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album');
- }
-
- const items = result.data.list || [];
- allItems.push(...items);
- if (items.length < pageSize) break;
- offset += pageSize;
- }
-
- const insert = db.prepare(
- "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)"
- );
-
- let added = 0;
- for (const item of allItems) {
- const transformed = normalizeSynologyPhotoInfo(item);
- const assetId = String(transformed?.id || '').trim();
- if (!assetId) continue;
- const result = insert.run(tripId, userId, assetId);
- if (result.changes > 0) added++;
- }
-
- db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
-
- return { added, total: allItems.length };
-}
-
-export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {
- const params: ApiCallParams = {
- api: 'SYNO.Foto.Search.Search',
- method: 'list_item',
- version: 1,
- offset,
- limit,
- keyword: '.',
- additional: ['thumbnail', 'address'],
- };
-
- if (from || to) {
- if (from) {
- params.start_time = Math.floor(new Date(from).getTime() / 1000);
- }
- if (to) {
- params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
- }
- }
-
- const result = await requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
- if (!result.success || !result.data) {
- throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album photos');
- }
-
- const allItems = result.data.list || [];
- const total = allItems.length;
- const assets = allItems.map(item => normalizeSynologyPhotoInfo(item));
-
- return {
- assets,
- total,
- hasMore: total === limit,
- };
-}
-
-export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise {
- const parsedId = splitPackedSynologyId(photoId);
- const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, {
- api: 'SYNO.Foto.Browse.Item',
- method: 'get',
- version: 5,
- id: `[${parsedId.id}]`,
- additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
- });
-
- if (!result.success || !result.data) {
- throw new SynologyServiceError(404, 'Photo not found');
- }
-
- const metadata = result.data.list?.[0];
- if (!metadata) {
- throw new SynologyServiceError(404, 'Photo not found');
- }
-
- const normalized = normalizeSynologyPhotoInfo(metadata);
- normalized.id = photoId;
- return normalized;
-}
-
-export async function streamSynologyAsset(
- userId: number,
- targetUserId: number,
- photoId: string,
- kind: 'thumbnail' | 'original',
- size?: string,
-): Promise {
- const parsedId = splitPackedSynologyId(photoId);
- const synology_url = getSynologyCredentials(targetUserId).synology_url;
- if (!synology_url) {
- throw new SynologyServiceError(402, 'User not configured with Synology');
- }
-
- const sid = await getSynologySession(targetUserId);
- if (!sid.success || !sid.sid) {
- throw new SynologyServiceError(401, 'Authentication failed');
- }
-
-
-
- const params = kind === 'thumbnail'
- ? new URLSearchParams({
- api: 'SYNO.Foto.Thumbnail',
- method: 'get',
- version: '2',
- mode: 'download',
- id: parsedId.id,
- type: 'unit',
- size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE),
- cache_key: parsedId.cacheKey,
- _sid: sid.sid,
- })
- : new URLSearchParams({
- api: 'SYNO.Foto.Download',
- method: 'download',
- version: '2',
- cache_key: parsedId.cacheKey,
- unit_id: `[${parsedId.id}]`,
- _sid: sid.sid,
- });
-
- const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`;
- const resp = await requestSynologyStream(url);
-
- if (!resp.ok) {
- const body = kind === 'original' ? await resp.text() : 'Failed';
- throw new SynologyServiceError(resp.status, kind === 'original' ? `Failed: ${body}` : body);
- }
-
- return {
- status: resp.status,
- headers: {
- 'content-type': resp.headers.get('content-type') || (kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream'),
- 'cache-control': resp.headers.get('cache-control') || 'public, max-age=86400',
- 'content-length': resp.headers.get('content-length'),
- 'content-disposition': resp.headers.get('content-disposition'),
- },
- body: resp.body,
- };
-}
-
-export async function pipeSynologyProxy(response: ExpressResponse, proxy: SynologyProxyResult): Promise {
- response.status(proxy.status);
- if (proxy.headers['content-type']) response.set('Content-Type', proxy.headers['content-type'] as string);
- if (proxy.headers['cache-control']) response.set('Cache-Control', proxy.headers['cache-control'] as string);
- if (proxy.headers['content-length']) response.set('Content-Length', proxy.headers['content-length'] as string);
- if (proxy.headers['content-disposition']) response.set('Content-Disposition', proxy.headers['content-disposition'] as string);
-
- if (!proxy.body) {
- response.end();
- return;
- }
-
- await pipeline(Readable.fromWeb(proxy.body), response);
-}