Merge branch 'test' into dev

This commit is contained in:
Marek Maslowski
2026-04-03 16:44:14 +02:00
committed by GitHub
19 changed files with 1977 additions and 260 deletions

View File

@@ -518,6 +518,179 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
`);
},
() => {
// Normalize trip_photos to provider-based schema used by current routes
const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get();
if (!tripPhotosExists) {
db.exec(`
CREATE TABLE trip_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
asset_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'immich',
shared INTEGER NOT NULL DEFAULT 1,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, asset_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id);
`);
} else {
const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
const names = new Set(columns.map(c => c.name));
const assetSource = names.has('asset_id') ? 'asset_id' : (names.has('immich_asset_id') ? 'immich_asset_id' : null);
if (assetSource) {
const providerExpr = names.has('provider')
? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END"
: "'immich'";
const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1';
const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
db.exec(`
CREATE TABLE trip_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
asset_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'immich',
shared INTEGER NOT NULL DEFAULT 1,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, asset_id, provider)
);
`);
db.exec(`
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at)
SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr}
FROM trip_photos
WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != ''
`);
db.exec('DROP TABLE trip_photos');
db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos');
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)');
}
}
},
() => {
// Normalize trip_album_links to provider + album_id schema used by current routes
const linksExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'").get();
if (!linksExists) {
db.exec(`
CREATE TABLE trip_album_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
album_id TEXT NOT NULL,
album_name TEXT NOT NULL DEFAULT '',
sync_enabled INTEGER NOT NULL DEFAULT 1,
last_synced_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, provider, album_id)
);
CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id);
`);
} else {
const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>;
const names = new Set(columns.map(c => c.name));
const albumIdSource = names.has('album_id') ? 'album_id' : (names.has('immich_album_id') ? 'immich_album_id' : null);
if (albumIdSource) {
const providerExpr = names.has('provider')
? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END"
: "'immich'";
const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''";
const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1';
const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL';
const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
db.exec(`
CREATE TABLE trip_album_links_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
album_id TEXT NOT NULL,
album_name TEXT NOT NULL DEFAULT '',
sync_enabled INTEGER NOT NULL DEFAULT 1,
last_synced_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, provider, album_id)
);
`);
db.exec(`
INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at)
SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr}
FROM trip_album_links
WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != ''
`);
db.exec('DROP TABLE trip_album_links');
db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links');
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)');
}
}
},
() => {
// Add Synology credential columns for existing databases
try { db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
() => {
// 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 (?, ?, ?, ?, ?, ?, ?)
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',
'Synology Photos',
'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) {
if (!err.message?.includes('no such table')) throw err;
}
try {
const insertField = db.prepare(`
INSERT INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider_id, field_key) DO UPDATE SET
label = excluded.label,
input_type = excluded.input_type,
placeholder = excluded.placeholder,
required = excluded.required,
secret = excluded.secret,
settings_key = excluded.settings_key,
payload_key = excluded.payload_key,
sort_order = excluded.sort_order
`);
insertField.run('synologyphotos', 'synology_url', 'Server URL', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0);
insertField.run('synologyphotos', 'synology_username', 'Username', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1);
insertField.run('synologyphotos', 'synology_password', 'Password', 'password', 'Password', 1, 1, null, 'synology_password', 2);
} catch (err: any) {
if (!err.message?.includes('no such table')) throw err;
}
},
];
if (currentVersion < migrations.length) {

View File

@@ -18,6 +18,10 @@ function createTables(db: Database.Database): void {
mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT,
mfa_backup_codes TEXT,
synology_url TEXT,
synology_username TEXT,
synology_password TEXT,
synology_sid TEXT,
must_change_password INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -222,6 +226,31 @@ function createTables(db: Database.Database): void {
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS photo_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
icon TEXT DEFAULT 'Image',
enabled INTEGER DEFAULT 0,
config TEXT DEFAULT '{}',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS photo_provider_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT,
required INTEGER DEFAULT 0,
secret INTEGER DEFAULT 0,
settings_key TEXT,
payload_key TEXT,
sort_order INTEGER DEFAULT 0,
UNIQUE(provider_id, field_key)
);
-- Vacay addon tables
CREATE TABLE IF NOT EXISTS vacay_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -92,6 +92,51 @@ function seedAddons(db: Database.Database): void {
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
const providerRows = [
{
id: 'immich',
name: 'Immich',
description: 'Immich photo provider',
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',
name: 'Synology Photos',
description: 'Synology Photos integration with separate account settings',
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 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 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'Server URL', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'Username', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'Password', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
];
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
for (const f of providerFields) {
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
}
console.log('Default addons seeded');
} catch (err: unknown) {
console.error('Error seeding addons:', err instanceof Error ? err.message : err);

View File

@@ -1,6 +1,7 @@
import express, { Request, Response } from 'express';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { db } from '../db/database';
import { AuthRequest, Addon } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import * as svc from '../services/adminService';
@@ -264,21 +265,100 @@ router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res
// ── Addons ─────────────────────────────────────────────────────────────────
router.get('/addons', (_req: Request, res: Response) => {
res.json({ addons: svc.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
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 }>;
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
ORDER BY sort_order, id
`).all() as Array<{
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder?: string | null;
required: number;
secret: number;
settings_key?: string | null;
payload_key?: string | null;
sort_order: number;
}>;
const fieldsByProvider = new Map<string, typeof fields>();
for (const field of fields) {
const arr = fieldsByProvider.get(field.provider_id) || [];
arr.push(field);
fieldsByProvider.set(field.provider_id, arr);
}
res.json({
addons: [
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
...providers.map(p => ({
id: p.id,
name: p.name,
description: p.description,
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: JSON.parse(p.config || '{}'),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
placeholder: f.placeholder || '',
required: !!f.required,
secret: !!f.secret,
settings_key: f.settings_key || null,
payload_key: f.payload_key || null,
sort_order: f.sort_order,
})),
sort_order: p.sort_order,
})),
],
});
});
router.put('/addons/:id', (req: Request, res: Response) => {
const result = svc.updateAddon(req.params.id, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined;
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
if (!addon && !provider) return res.status(404).json({ error: 'Addon not found' });
const { enabled, config } = req.body;
if (addon) {
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
} else {
if (enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
if (config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
}
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined;
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
const updated = updatedAddon
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
: updatedProvider
? {
id: updatedProvider.id,
name: updatedProvider.name,
description: updatedProvider.description,
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
config: JSON.parse(updatedProvider.config || '{}'),
sort_order: updatedProvider.sort_order,
}
: null;
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.addon_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: result.auditDetails,
details: { enabled: req.body.enabled, config: req.body.config },
});
res.json({ addon: result.addon });
res.json({ addon: updated });
});
// ── MCP Tokens ─────────────────────────────────────────────────────────────
@@ -300,12 +380,9 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
if (result.error) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
user_id: authReq.user?.id ?? null,
username: authReq.user?.username ?? 'unknown',
userId: authReq.user?.id ?? null,
action: 'admin.rotate_jwt_secret',
target_type: 'system',
target_id: null,
details: null,
resource: 'system',
ip: getClientIp(req),
});
res.json({ success: true });

View File

@@ -317,9 +317,13 @@ router.post('/ws-token', authenticate, (req: Request, res: Response) => {
// Short-lived single-use token for direct resource URLs
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createResourceToken(authReq.user.id, req.body.purpose);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ token: result.token });
const { purpose } = req.body as { purpose?: string };
if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') {
return res.status(400).json({ error: 'Invalid purpose' });
}
const token = createResourceToken(authReq.user.id, purpose);
if (!token) return res.status(503).json({ error: 'Service unavailable' });
res.json(token);
});
export default router;

View File

@@ -30,7 +30,6 @@ import {
const router = express.Router();
// ── Dual auth middleware (JWT or ephemeral token for <img> src) ─────────────
function authFromQuery(req: Request, res: Response, next: NextFunction) {
const queryToken = req.query.token as string | undefined;
if (queryToken) {
@@ -88,7 +87,7 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
res.json({ assets: result.assets });
});
// ── Trip Photos (selected by user) ────────────────────────────────────────
// ── Asset Details ──────────────────────────────────────────────────────────
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
@@ -97,57 +96,6 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
res.json({ photos: listTripPhotos(tripId, authReq.user.id) });
});
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { asset_ids, shared = true } = req.body;
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
return res.status(400).json({ error: 'asset_ids required' });
}
const added = addTripPhotos(tripId, authReq.user.id, asset_ids, shared);
res.json({ success: true, added });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
// Notify trip members about shared photos
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
});
}
});
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
removeTripPhoto(req.params.tripId, authReq.user.id, req.params.assetId);
res.json({ success: true });
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { shared } = req.body;
togglePhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, shared);
res.json({ success: true });
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
// ── Asset Details ──────────────────────────────────────────────────────────
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { assetId } = req.params;
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
const result = await getAssetInfo(authReq.user.id, assetId);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json(result.data);
});
// ── Proxy Immich Assets ────────────────────────────────────────────────────
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
@@ -181,12 +129,6 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
res.json({ albums: result.albums });
});
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
res.json({ links: listAlbumLinks(req.params.tripId) });
});
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -198,12 +140,6 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res
res.json({ success: true });
});
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id);
res.json({ success: true });
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;

View File

@@ -0,0 +1,192 @@
import express, { Request, Response } from 'express';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
const router = express.Router();
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
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, authReq.user.id) as any[];
res.json({ photos });
});
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
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);
res.json({ links });
});
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, authReq.user.id);
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 { shared = true } = req.body;
const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null;
const provider = String(req.body?.provider || '').toLowerCase();
const assetIdsRaw = req.body?.asset_ids;
if (!canAccessTrip(tripId, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
const selections = selectionsRaw && selectionsRaw.length > 0
? selectionsRaw
.map((selection: any) => ({
provider: String(selection?.provider || '').toLowerCase(),
asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [],
}))
.filter((selection: { provider: string; asset_ids: unknown[] }) => selection.provider && selection.asset_ids.length > 0)
: (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0
? [{ provider, asset_ids: assetIdsRaw }]
: []);
if (selections.length === 0) {
return res.status(400).json({ error: 'selections required' });
}
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, authReq.user.id, assetId, selection.provider, shared ? 1 : 0);
if (result.changes > 0) added++;
}
}
res.json({ success: true, added });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', {
trip: tripInfo?.title || 'Untitled',
actor: authReq.user.username || authReq.user.email,
count: String(added),
}).catch(() => {});
});
}
});
router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const provider = String(req.body?.provider || '').toLowerCase();
const assetId = String(req.body?.asset_id || '');
if (!assetId) {
return res.status(400).json({ error: 'asset_id is required' });
}
if (!provider) {
return res.status(400).json({ error: 'provider is required' });
}
if (!canAccessTrip(tripId, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(tripId, authReq.user.id, assetId, provider);
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 provider = String(req.body?.provider || '').toLowerCase();
const assetId = String(req.body?.asset_id || '');
const { shared } = req.body;
if (!assetId) {
return res.status(400).json({ error: 'asset_id is required' });
}
if (!provider) {
return res.status(400).json({ error: 'provider is required' });
}
if (!canAccessTrip(tripId, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(shared ? 1 : 0, tripId, authReq.user.id, assetId, provider);
res.json({ success: true });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
export default router;

View File

@@ -0,0 +1,183 @@
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,
linkSynologyAlbum,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
pipeSynologyProxy,
synologyAuthFromQuery,
getSynologyTargetUserId,
streamSynologyAsset,
handleSynologyError,
SynologyServiceError,
} from '../services/synologyService';
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, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(getSynologySettings(authReq.user.id));
});
router.put('/settings', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
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<string, unknown>;
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', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const body = req.body as Record<string, unknown>;
const albumId = parseStringBodyField(body.album_id);
const albumName = parseStringBodyField(body.album_name);
if (!albumId) {
return handleSynologyError(res, new SynologyServiceError(400, 'Album ID is required'), 'Missing required fields');
}
try {
linkSynologyAlbum(authReq.user.id, tripId, albumId, albumName || undefined);
res.json({ success: true });
} catch (err: unknown) {
handleSynologyError(res, err, 'Failed to link album');
}
});
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<string, unknown>;
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/:photoId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { photoId } = req.params;
try {
res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req)));
} catch (err: unknown) {
handleSynologyError(res, err, 'Could not reach Synology');
}
});
router.get('/assets/:photoId/thumbnail', synologyAuthFromQuery, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { photoId } = req.params;
const { size = 'sm' } = req.query;
try {
const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'thumbnail', String(size));
await pipeSynologyProxy(res, proxy);
} catch (err: unknown) {
if (res.headersSent) {
return;
}
handleSynologyError(res, err, 'Proxy error');
}
});
router.get('/assets/:photoId/original', synologyAuthFromQuery, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { photoId } = req.params;
try {
const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'original');
await pipeSynologyProxy(res, proxy);
} catch (err: unknown) {
if (res.headersSent) {
return;
}
handleSynologyError(res, err, 'Proxy error');
}
});
export default router;

View File

@@ -465,17 +465,92 @@ export function deleteTemplateItem(itemId: string) {
export function listAddons() {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') }));
const providers = db.prepare(`
SELECT id, name, description, icon, enabled, config, 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 }>;
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
ORDER BY sort_order, id
`).all() as Array<{
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder?: string | null;
required: number;
secret: number;
settings_key?: string | null;
payload_key?: string | null;
sort_order: number;
}>;
const fieldsByProvider = new Map<string, typeof fields>();
for (const field of fields) {
const arr = fieldsByProvider.get(field.provider_id) || [];
arr.push(field);
fieldsByProvider.set(field.provider_id, arr);
}
return [
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
...providers.map(p => ({
id: p.id,
name: p.name,
description: p.description,
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: JSON.parse(p.config || '{}'),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
placeholder: f.placeholder || '',
required: !!f.required,
secret: !!f.secret,
settings_key: f.settings_key || null,
payload_key: f.payload_key || null,
sort_order: f.sort_order,
})),
sort_order: p.sort_order,
})),
];
}
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id);
if (!addon) return { error: 'Addon not found', status: 404 };
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon;
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;
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
if (addon) {
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
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 updated = updatedAddon
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
: updatedProvider
? {
id: updatedProvider.id,
name: updatedProvider.name,
description: updatedProvider.description,
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
config: JSON.parse(updatedProvider.config || '{}'),
sort_order: updatedProvider.sort_order,
}
: null;
return {
addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') },
addon: updated,
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
};
}

View File

@@ -981,7 +981,7 @@ export function createWsToken(userId: number): { error?: string; status?: number
}
export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } {
if (purpose !== 'download' && purpose !== 'immich') {
if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') {
return { error: 'Invalid purpose', status: 400 };
}
const token = createEphemeralToken(userId, purpose);

View File

@@ -4,6 +4,7 @@ const TTL: Record<string, number> = {
ws: 30_000,
download: 60_000,
immich: 60_000,
synologyphotos: 60_000,
};
const MAX_STORE_SIZE = 10_000;

View File

@@ -175,11 +175,12 @@ export async function searchPhotos(
export function listTripPhotos(tripId: string, userId: number) {
return db.prepare(`
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
SELECT tp.asset_id AS immich_asset_id, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar, u.immich_url
FROM trip_photos tp
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND tp.provider = 'immich'
AND (tp.user_id = ? OR tp.shared = 1)
ORDER BY tp.added_at ASC
`).all(tripId, userId);
@@ -191,25 +192,23 @@ export function addTripPhotos(
assetIds: string[],
shared: boolean
): number {
const insert = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
);
const insert = db.prepare('INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)');
let added = 0;
for (const assetId of assetIds) {
const result = insert.run(tripId, userId, assetId, shared ? 1 : 0);
const result = insert.run(tripId, userId, assetId, 'immich', shared ? 1 : 0);
if (result.changes > 0) added++;
}
return added;
}
export function removeTripPhoto(tripId: string, userId: number, assetId: string) {
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(tripId, userId, assetId);
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?')
.run(tripId, userId, assetId, 'immich');
}
export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) {
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(shared ? 1 : 0, tripId, userId, assetId);
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, 'immich');
}
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
@@ -329,7 +328,7 @@ export function listAlbumLinks(tripId: string) {
SELECT tal.*, u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
WHERE tal.trip_id = ? AND tal.provider = 'immich'
ORDER BY tal.created_at ASC
`).all(tripId);
}
@@ -342,8 +341,8 @@ export function createAlbumLink(
): { success: boolean; error?: string } {
try {
db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
).run(tripId, userId, albumId, albumName || '');
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, 'immich', albumId, albumName || '');
return { success: true };
} catch {
return { success: false, error: 'Album already linked' };
@@ -360,15 +359,15 @@ 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 = ?')
.get(linkId, tripId, userId) as any;
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 creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, {
const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
@@ -376,9 +375,7 @@ 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, immich_asset_id, shared) VALUES (?, ?, ?, 1)'
);
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);

View File

@@ -0,0 +1,651 @@
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { NextFunction, Request, Response as ExpressResponse } from 'express';
import { db, canAccessTrip } from '../db/database';
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { consumeEphemeralToken } from './ephemeralTokens';
import { checkSsrf } from '../utils/ssrfGuard';
import { no } from 'zod/locales';
const SYNOLOGY_API_TIMEOUT_MS = 30000;
const SYNOLOGY_PROVIDER = 'synologyphotos';
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<T> {
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<string, string | null>;
body: ReadableStream<Uint8Array> | 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<T>(url: string, body: URLSearchParams): Promise<SynologyApiResponse<T>> {
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<SynologyApiResponse<T>>;
}
async function loginToSynology(url: string, username: string, password: string): Promise<SynologyApiResponse<{ sid?: string }>> {
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<T>(userId: number, params: ApiCallParams): Promise<SynologyApiResponse<T>> {
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<T>(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<T>(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid }));
}
return result;
}
async function requestSynologyStream(url: string): Promise<globalThis.Response> {
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 synologyAuthFromQuery(req: Request, res: ExpressResponse, next: NextFunction) {
const queryToken = req.query.token as string | undefined;
if (queryToken) {
const userId = consumeEphemeralToken(queryToken, SYNOLOGY_PROVIDER);
if (!userId) return res.status(401).send('Invalid or expired token');
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
if (!user) return res.status(401).send('User not found');
(req as AuthRequest).user = user;
return next();
}
return (authenticate as any)(req, res, next);
}
export function getSynologyTargetUserId(req: Request): number {
const { userId } = req.query;
return Number(userId);
}
export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse {
if (err instanceof SynologyServiceError) {
return res.status(err.status).json({ error: err.message });
}
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 };
}
function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean {
if (requestingUserId === targetUserId) {
return true;
}
const sharedAsset = db.prepare(`
SELECT 1
FROM trip_photos
WHERE user_id = ?
AND asset_id = ?
AND provider = 'synologyphotos'
AND shared = 1
LIMIT 1
`).get(targetUserId, assetId);
return !!sharedAsset;
}
async function getSynologySession(userId: number): Promise<SynologySession> {
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<SynologySettings> {
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<void> {
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<SynologyConnectionResult> {
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<SynologyConnectionResult> {
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 function linkSynologyAlbum(userId: number, tripId: string, albumId: string | number | undefined, albumName?: string): void {
if (!canAccessTrip(tripId, userId)) {
throw new SynologyServiceError(404, 'Trip not found');
}
if (!albumId) {
throw new SynologyServiceError(400, 'album_id required');
}
const changes = db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, SYNOLOGY_PROVIDER, String(albumId), albumName || '').changes;
if (changes === 0) {
throw new SynologyServiceError(400, 'Album already linked');
}
}
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<SynologyPhotoInfo> {
if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) {
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
}
const parsedId = splitPackedSynologyId(photoId);
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, {
api: 'SYNO.Foto.Browse.Item',
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<SynologyProxyResult> {
if (!canStreamSynologyAsset(userId, targetUserId, photoId)) {
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
}
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<void> {
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);
}