Merge branch 'test' into dev
This commit is contained in:
@@ -34,11 +34,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 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();
|
||||
@@ -195,13 +196,66 @@ export function createApp(): express.Application {
|
||||
// Addons list endpoint
|
||||
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<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
|
||||
const providers = db.prepare(`
|
||||
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; 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 })),
|
||||
...providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: getPhotoProviderConfig(p.id),
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Addon routes
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/integrations/immich', immichRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
@@ -518,6 +518,189 @@ 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, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
icon = excluded.icon,
|
||||
enabled = excluded.enabled,
|
||||
sort_order = excluded.sort_order
|
||||
`).run(
|
||||
'synologyphotos',
|
||||
'Synology Photos',
|
||||
'Synology Photos integration with separate account settings',
|
||||
'Image',
|
||||
0,
|
||||
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;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// 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');
|
||||
},
|
||||
() => {
|
||||
// Track which album link each photo was synced from
|
||||
try { db.exec("ALTER TABLE trip_photos ADD COLUMN album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
|
||||
@@ -18,6 +18,12 @@ 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,
|
||||
synology_sid TEXT,
|
||||
must_change_password INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -162,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,
|
||||
@@ -222,6 +229,30 @@ 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,
|
||||
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,
|
||||
|
||||
@@ -92,6 +92,39 @@ 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,
|
||||
},
|
||||
{
|
||||
id: 'synologyphotos',
|
||||
name: 'Synology Photos',
|
||||
description: 'Synology Photos integration with separate account settings',
|
||||
icon: 'Image',
|
||||
enabled: 0,
|
||||
sort_order: 1,
|
||||
},
|
||||
];
|
||||
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 },
|
||||
{ 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);
|
||||
|
||||
@@ -317,9 +317,9 @@ 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 token = createResourceToken(authReq.user.id, req.body.purpose);
|
||||
if (!token) return res.status(503).json({ error: 'Service unavailable' });
|
||||
res.json(token);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
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 {
|
||||
getConnectionSettings,
|
||||
saveImmichSettings,
|
||||
testConnection,
|
||||
getConnectionStatus,
|
||||
browseTimeline,
|
||||
searchPhotos,
|
||||
listTripPhotos,
|
||||
addTripPhotos,
|
||||
removeTripPhoto,
|
||||
togglePhotoSharing,
|
||||
getAssetInfo,
|
||||
proxyThumbnail,
|
||||
proxyOriginal,
|
||||
isValidAssetId,
|
||||
canAccessUserPhoto,
|
||||
listAlbums,
|
||||
listAlbumLinks,
|
||||
createAlbumLink,
|
||||
deleteAlbumLink,
|
||||
syncAlbumAssets,
|
||||
} from '../services/immichService';
|
||||
|
||||
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) {
|
||||
const userId = consumeEphemeralToken(queryToken, 'immich');
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Immich Connection Settings ─────────────────────────────────────────────
|
||||
|
||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getConnectionSettings(authReq.user.id));
|
||||
});
|
||||
|
||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
if (result.warning) return res.json({ success: true, warning: result.warning });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(await getConnectionStatus(authReq.user.id));
|
||||
});
|
||||
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
|
||||
res.json(await testConnection(immich_url, immich_api_key));
|
||||
});
|
||||
|
||||
// ── Browse Immich Library (for photo picker) ───────────────────────────────
|
||||
|
||||
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await browseTimeline(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ buckets: result.buckets });
|
||||
});
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to } = req.body;
|
||||
const result = await searchPhotos(authReq.user.id, from, to);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
});
|
||||
|
||||
// ── Trip Photos (selected by user) ────────────────────────────────────────
|
||||
|
||||
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' });
|
||||
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 queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
||||
if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await getAssetInfo(authReq.user.id, assetId, ownerUserId);
|
||||
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) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
||||
if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
const result = await proxyThumbnail(authReq.user.id, assetId, ownerUserId);
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
});
|
||||
|
||||
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
||||
if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
const result = await proxyOriginal(authReq.user.id, assetId, ownerUserId);
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
});
|
||||
|
||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await listAlbums(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
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;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { album_id, album_name } = req.body;
|
||||
if (!album_id) return res.status(400).json({ error: 'album_id required' });
|
||||
const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name);
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
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;
|
||||
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true, added: result.added, total: result.total });
|
||||
if (result.added! > 0) {
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
147
server/src/routes/memories/immich.ts
Normal file
147
server/src/routes/memories/immich.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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 {
|
||||
getConnectionSettings,
|
||||
saveImmichSettings,
|
||||
testConnection,
|
||||
getConnectionStatus,
|
||||
browseTimeline,
|
||||
searchPhotos,
|
||||
proxyThumbnail,
|
||||
proxyOriginal,
|
||||
listAlbums,
|
||||
syncAlbumAssets,
|
||||
getAssetInfo,
|
||||
} from '../../services/memories/immichService';
|
||||
import { canAccessUserPhoto } from '../../services/memories/helpersService';
|
||||
|
||||
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) {
|
||||
const userId = consumeEphemeralToken(queryToken, 'immich');
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Immich Connection Settings ─────────────────────────────────────────────
|
||||
|
||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getConnectionSettings(authReq.user.id));
|
||||
});
|
||||
|
||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
if (result.warning) return res.json({ success: true, warning: result.warning });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(await getConnectionStatus(authReq.user.id));
|
||||
});
|
||||
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
|
||||
res.json(await testConnection(immich_url, immich_api_key));
|
||||
});
|
||||
|
||||
// ── Browse Immich Library (for photo picker) ───────────────────────────────
|
||||
|
||||
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await browseTimeline(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ buckets: result.buckets });
|
||||
});
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to } = req.body;
|
||||
const result = await searchPhotos(authReq.user.id, from, to);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await getAssetInfo(authReq.user.id, assetId, Number(ownerId));
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
// ── Proxy Immich Assets ────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await proxyThumbnail(authReq.user.id, assetId, Number(ownerId));
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
});
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await proxyOriginal(authReq.user.id, assetId, Number(ownerId));
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
});
|
||||
|
||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await listAlbums(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ albums: result.albums });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true, added: result.added, total: result.total });
|
||||
if (result.added! > 0) {
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
126
server/src/routes/memories/synology.ts
Normal file
126
server/src/routes/memories/synology.ts
Normal file
@@ -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<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) {
|
||||
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<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) {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
102
server/src/routes/memories/unified.ts
Normal file
102
server/src/routes/memories/unified.ts
Normal file
@@ -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;
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -470,17 +471,91 @@ export function isAddonEnabled(addonId: string): boolean {
|
||||
|
||||
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, sort_order
|
||||
FROM photo_providers
|
||||
ORDER BY sort_order, id
|
||||
`).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
|
||||
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: getPhotoProviderConfig(p.id),
|
||||
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; 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);
|
||||
}
|
||||
|
||||
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; 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: getPhotoProviderConfig(updatedProvider.id),
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
186
server/src/services/memories/helpersService.ts
Normal file
186
server/src/services/memories/helpersService.ts
Normal file
@@ -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<T> = { success: true; data: T } | ServiceError;
|
||||
|
||||
|
||||
export function fail(error: string, status: number): ServiceError {
|
||||
return { success: false, error: { message: error, status } };
|
||||
}
|
||||
|
||||
|
||||
export function success<T>(data: T): ServiceResult<T> {
|
||||
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<T>(res: Response, result: ServiceResult<T>): 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<string> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { db, canAccessTrip } 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -187,62 +189,9 @@ export async function searchPhotos(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trip Photos ────────────────────────────────────────────────────────────
|
||||
|
||||
export function listTripPhotos(tripId: string, userId: number) {
|
||||
return db.prepare(`
|
||||
SELECT tp.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.user_id = ? OR tp.shared = 1)
|
||||
ORDER BY tp.added_at ASC
|
||||
`).all(tripId, userId);
|
||||
}
|
||||
|
||||
export function addTripPhotos(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
assetIds: string[],
|
||||
shared: boolean
|
||||
): number {
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
let added = 0;
|
||||
for (const assetId of assetIds) {
|
||||
const result = insert.run(tripId, userId, assetId, 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify that requestingUserId can access a shared photo belonging to ownerUserId.
|
||||
* The asset must be shared (shared=1) and the requesting user must be a member of
|
||||
* the same trip that contains the photo.
|
||||
*/
|
||||
export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, assetId: string): boolean {
|
||||
const rows = db.prepare(`
|
||||
SELECT tp.trip_id FROM trip_photos tp
|
||||
WHERE tp.immich_asset_id = ? AND tp.user_id = ? AND tp.shared = 1
|
||||
`).all(assetId, ownerUserId) as { trip_id: number }[];
|
||||
if (rows.length === 0) return false;
|
||||
return rows.some(row => !!canAccessTrip(String(row.trip_id), requestingUserId));
|
||||
}
|
||||
|
||||
export async function getAssetInfo(
|
||||
userId: number,
|
||||
@@ -387,8 +336,6 @@ export function createAlbumLink(
|
||||
}
|
||||
|
||||
export function deleteAlbumLink(linkId: string, tripId: string, userId: number) {
|
||||
db.prepare('DELETE FROM trip_photos WHERE album_link_id = ? AND trip_id = ? AND user_id = ?')
|
||||
.run(linkId, tripId, userId);
|
||||
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.run(linkId, tripId, userId);
|
||||
}
|
||||
@@ -398,15 +345,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 = ?')
|
||||
.get(linkId, tripId, userId) 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.immich_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),
|
||||
});
|
||||
@@ -414,18 +360,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, immich_asset_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)'
|
||||
);
|
||||
let added = 0;
|
||||
for (const asset of assets) {
|
||||
const r = insert.run(tripId, userId, asset.id, linkId);
|
||||
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 };
|
||||
}
|
||||
496
server/src/services/memories/synologyService.ts
Normal file
496
server/src/services/memories/synologyService.ts
Normal file
@@ -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<T> {
|
||||
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<SynologyUserRecord> {
|
||||
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<SynologyCredentials> {
|
||||
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
||||
if (!user.success) return user as ServiceResult<SynologyCredentials>;
|
||||
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<T>(url: string, body: URLSearchParams): Promise<ServiceResult<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(30000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return fail('Synology API request failed with status ' + resp.status, resp.status);
|
||||
}
|
||||
|
||||
const response = await resp.json() as SynologyApiResponse<T>;
|
||||
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<ServiceResult<string>> {
|
||||
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<string>;
|
||||
}
|
||||
if (!result.data.sid) {
|
||||
return fail('Failed to get session ID from Synology', 500);
|
||||
}
|
||||
return success(result.data.sid);
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
|
||||
const creds = _getSynologyCredentials(userId);
|
||||
if (!creds.success) {
|
||||
return creds as ServiceResult<T>;
|
||||
}
|
||||
|
||||
const session = await _getSynologySession(userId);
|
||||
if (!session.success || !session.data) {
|
||||
return session as ServiceResult<T>;
|
||||
}
|
||||
|
||||
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
|
||||
const result = await _fetchSynologyJson<T>(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<T>;
|
||||
}
|
||||
return _fetchSynologyJson<T>(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<ServiceResult<string>> {
|
||||
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<string>;
|
||||
}
|
||||
|
||||
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
|
||||
|
||||
if (!resp.success) {
|
||||
return resp as ServiceResult<string>;
|
||||
}
|
||||
|
||||
_cacheSynologySID(userId, resp.data);
|
||||
return success(resp.data);
|
||||
}
|
||||
|
||||
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
|
||||
const creds = _getSynologyCredentials(userId);
|
||||
if (!creds.success) return creds as ServiceResult<SynologySettings>;
|
||||
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<ServiceResult<string>> {
|
||||
|
||||
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<string>;
|
||||
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<ServiceResult<StatusResult>> {
|
||||
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<ServiceResult<StatusResult>> {
|
||||
|
||||
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<ServiceResult<AlbumsList>> {
|
||||
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<AlbumsList>;
|
||||
|
||||
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<ServiceResult<SyncAlbumResult>> {
|
||||
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
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<SyncAlbumResult>;
|
||||
|
||||
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<SyncAlbumResult>;
|
||||
|
||||
return success({ added: result.data.added, total: allItems.length });
|
||||
}
|
||||
|
||||
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<ServiceResult<AssetsList>> {
|
||||
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<ServiceResult<AssetInfo>> {
|
||||
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<AssetInfo>;
|
||||
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
249
server/src/services/memories/unifiedService.ts
Normal file
249
server/src/services/memories/unifiedService.ts
Normal file
@@ -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<any[]> {
|
||||
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<any[]> {
|
||||
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<ServiceResult<{ added: number; shared: boolean }>> {
|
||||
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<ServiceResult<true>> {
|
||||
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<true> {
|
||||
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<true> {
|
||||
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<true> {
|
||||
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<ServiceResult<void>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user