mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
moving business logic for synology to separet file
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { authenticate, adminOnly } from '../middleware/auth';
|
import { authenticate, adminOnly } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { db } from '../db/database';
|
||||||
|
import { AuthRequest, Addon } from '../types';
|
||||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||||
import * as svc from '../services/adminService';
|
import * as svc from '../services/adminService';
|
||||||
|
|
||||||
@@ -355,7 +356,7 @@ router.put('/addons/:id', (req: Request, res: Response) => {
|
|||||||
action: 'admin.addon_update',
|
action: 'admin.addon_update',
|
||||||
resource: String(req.params.id),
|
resource: String(req.params.id),
|
||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
details: result.auditDetails,
|
details: { enabled: req.body.enabled, config: req.body.config },
|
||||||
});
|
});
|
||||||
res.json({ addon: updated });
|
res.json({ addon: updated });
|
||||||
});
|
});
|
||||||
|
|||||||
+129
-591
@@ -1,645 +1,183 @@
|
|||||||
import express, { NextFunction, Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { Readable } from 'node:stream';
|
|
||||||
import { pipeline } from 'node:stream/promises';
|
|
||||||
import { db, canAccessTrip } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto';
|
import {
|
||||||
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
getSynologySettings,
|
||||||
import { checkSsrf } from '../utils/ssrfGuard';
|
updateSynologySettings,
|
||||||
|
getSynologyStatus,
|
||||||
|
testSynologyConnection,
|
||||||
|
listSynologyAlbums,
|
||||||
|
linkSynologyAlbum,
|
||||||
|
syncSynologyAlbumLink,
|
||||||
|
searchSynologyPhotos,
|
||||||
|
getSynologyAssetInfo,
|
||||||
|
pipeSynologyProxy,
|
||||||
|
synologyAuthFromQuery,
|
||||||
|
getSynologyTargetUserId,
|
||||||
|
streamSynologyAsset,
|
||||||
|
handleSynologyError,
|
||||||
|
SynologyServiceError,
|
||||||
|
} from '../services/synologyService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
function copyProxyHeaders(resp: Response, upstream: globalThis.Response, headerNames: string[]): void {
|
function parseStringBodyField(value: unknown): string {
|
||||||
for (const headerName of headerNames) {
|
return String(value ?? '').trim();
|
||||||
const value = upstream.headers.get(headerName);
|
|
||||||
if (value) {
|
|
||||||
resp.set(headerName, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Get Synology credentials from users table
|
function parseNumberBodyField(value: unknown, fallback: number): number {
|
||||||
function getSynologyCredentials(userId: number) {
|
const parsed = Number(value);
|
||||||
try {
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
const user = db.prepare('SELECT synology_url, synology_username, synology_password FROM users WHERE id = ?').get(userId) as any;
|
|
||||||
if (!user?.synology_url || !user?.synology_username || !user?.synology_password) return null;
|
|
||||||
return {
|
|
||||||
synology_url: user.synology_url as string,
|
|
||||||
synology_username: user.synology_username as string,
|
|
||||||
synology_password: decrypt_api_key(user.synology_password) as string,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Get cached SID from settings or users table
|
|
||||||
function getCachedSynologySID(userId: number) {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT synology_sid FROM users WHERE id = ?').get(userId) as any;
|
|
||||||
return row?.synology_sid || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Cache SID in users table
|
|
||||||
function cacheSynologySID(userId: number, sid: string) {
|
|
||||||
try {
|
|
||||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
|
|
||||||
} catch (err) {
|
|
||||||
// Ignore if columns don't exist yet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Get authenticated session
|
|
||||||
|
|
||||||
interface SynologySession {
|
|
||||||
success: boolean;
|
|
||||||
sid?: string;
|
|
||||||
error?: { code: number; message?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSynologySession(userId: number): Promise<SynologySession> {
|
|
||||||
// Check for cached SID
|
|
||||||
const cachedSid = getCachedSynologySID(userId);
|
|
||||||
if (cachedSid) {
|
|
||||||
return { success: true, sid: cachedSid };
|
|
||||||
}
|
|
||||||
|
|
||||||
const creds = getSynologyCredentials(userId);
|
|
||||||
// Login with credentials
|
|
||||||
if (!creds) {
|
|
||||||
return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } };
|
|
||||||
}
|
|
||||||
const endpoint = prepareSynologyEndpoint(creds.synology_url);
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
api: 'SYNO.API.Auth',
|
|
||||||
method: 'login',
|
|
||||||
version: '3',
|
|
||||||
account: creds.synology_username,
|
|
||||||
passwd: creds.synology_password,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 { success: false, error: { code: resp.status, message: 'Failed to authenticate with Synology' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await resp.json() as { success: boolean; data?: { sid?: string } };
|
|
||||||
|
|
||||||
if (data.success && data.data?.sid) {
|
|
||||||
const sid = data.data.sid;
|
|
||||||
cacheSynologySID(userId, sid);
|
|
||||||
return { success: true, sid };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: { code: 500, message: 'Failed to get Synology session' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Clear cached SID
|
|
||||||
|
|
||||||
function clearSynologySID(userId: number): void {
|
|
||||||
try {
|
|
||||||
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
|
||||||
} catch {
|
|
||||||
// Ignore if columns don't exist yet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiCallParams {
|
|
||||||
api: string;
|
|
||||||
method: string;
|
|
||||||
version?: number;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SynologyApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data?: T;
|
|
||||||
error?: { code: number, message?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareSynologyEndpoint(url: string): string {
|
|
||||||
url = url.replace(/\/$/, '');
|
|
||||||
if (!/^https?:\/\//.test(url)) {
|
|
||||||
url = `https://${url}`;
|
|
||||||
}
|
|
||||||
return `${url}/photo/webapi/entry.cgi`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
|
|
||||||
const id = rawId.split('_')[0];
|
|
||||||
return { id: id, cacheKey: rawId, assetId: rawId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformSynologyPhoto(item: any): any {
|
|
||||||
const address = item.additional?.address || {};
|
|
||||||
return {
|
|
||||||
id: item.additional?.thumbnail?.cache_key,
|
|
||||||
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
|
|
||||||
city: address.city || null,
|
|
||||||
country: address.country || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callSynologyApi<T>(userId: number, params: ApiCallParams): Promise<SynologyApiResponse<T>> {
|
|
||||||
try {
|
|
||||||
const creds = getSynologyCredentials(userId);
|
|
||||||
if (!creds) {
|
|
||||||
return { success: false, error: { code: 400, message: 'Synology not configured' } };
|
|
||||||
}
|
|
||||||
const endpoint = prepareSynologyEndpoint(creds.synology_url);
|
|
||||||
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
const sid = await getSynologySession(userId);
|
|
||||||
if (!sid.success || !sid.sid) {
|
|
||||||
return { success: false, error: sid.error || { code: 500, message: 'Failed to get Synology session' } };
|
|
||||||
}
|
|
||||||
body.append('_sid', sid.sid);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const text = await resp.text();
|
|
||||||
return { success: false, error: { code: resp.status, message: text } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await resp.json() as SynologyApiResponse<T>;
|
|
||||||
if (!result.success && result.error?.code === 119) {
|
|
||||||
clearSynologySID(userId);
|
|
||||||
return callSynologyApi(userId, params);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: { code: -1, message: err instanceof Error ? err.message : 'Unknown error' } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const creds = getSynologyCredentials(authReq.user.id);
|
res.json(getSynologySettings(authReq.user.id));
|
||||||
res.json({
|
|
||||||
synology_url: creds?.synology_url || '',
|
|
||||||
synology_username: creds?.synology_username || '',
|
|
||||||
connected: !!(creds?.synology_url && creds?.synology_username),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/settings', authenticate, (req: Request, res: Response) => {
|
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { synology_url, synology_username, synology_password } = req.body;
|
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);
|
||||||
|
|
||||||
const url = String(synology_url || '').trim();
|
if (!synology_url || !synology_username) {
|
||||||
const username = String(synology_username || '').trim();
|
return handleSynologyError(res, new SynologyServiceError(400, 'URL and username are required'), 'Missing required fields');
|
||||||
const password = String(synology_password || '').trim();
|
}
|
||||||
|
|
||||||
if (!url || !username) {
|
try {
|
||||||
return res.status(400).json({ error: 'URL and username are required' });
|
await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password);
|
||||||
}
|
res.json({ success: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
const existing = db.prepare('SELECT synology_password FROM users WHERE id = ?').get(authReq.user.id) as { synology_password?: string | null } | undefined;
|
handleSynologyError(res, err, 'Failed to save settings');
|
||||||
const existingEncryptedPassword = existing?.synology_password || null;
|
}
|
||||||
|
|
||||||
// First-time setup requires password; later updates may keep existing password.
|
|
||||||
if (!password && !existingEncryptedPassword) {
|
|
||||||
return res.status(400).json({ error: 'Password is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
password ? maybe_encrypt_api_key(password) : existingEncryptedPassword,
|
|
||||||
authReq.user.id
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(400).json({ error: 'Failed to save settings' });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSynologySID(authReq.user.id);
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Status
|
|
||||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
|
res.json(await getSynologyStatus(authReq.user.id));
|
||||||
try {
|
|
||||||
const sid = await getSynologySession(authReq.user.id);
|
|
||||||
if (!sid.success || !sid.sid) {
|
|
||||||
return res.json({ connected: false, error: 'Authentication failed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(authReq.user.id) as any;
|
|
||||||
res.json({ connected: true, user: { username: user.synology_username } });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test connection with provided credentials only
|
|
||||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||||
const { synology_url, synology_username, synology_password } = req.body as { synology_url?: string; synology_username?: string; synology_password?: string };
|
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);
|
||||||
|
|
||||||
const url = String(synology_url || '').trim();
|
if (!synology_url || !synology_username || !synology_password) {
|
||||||
const username = String(synology_username || '').trim();
|
return handleSynologyError(res, new SynologyServiceError(400, 'URL, username and password are required'), 'Missing required fields');
|
||||||
const password = String(synology_password || '').trim();
|
|
||||||
|
|
||||||
if (!url || !username || !password) {
|
|
||||||
return res.json({ connected: false, error: 'URL, username, and password are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ssrf = await checkSsrf(url);
|
|
||||||
if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Synology URL' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpoint = prepareSynologyEndpoint(url);
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
api: 'SYNO.API.Auth',
|
|
||||||
method: 'login',
|
|
||||||
version: '3',
|
|
||||||
account: username,
|
|
||||||
passwd: password,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 res.json({ connected: false, error: `HTTP ${resp.status}` });
|
|
||||||
const data = await resp.json() as { success: boolean; data?: { sid?: string } };
|
|
||||||
if (!data.success || !data.data?.sid) return res.json({ connected: false, error: 'Authentication failed' });
|
|
||||||
return res.json({ connected: true, user: { username } });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
return res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Album linking parity with Immich
|
|
||||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
try {
|
|
||||||
const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, {
|
|
||||||
api: 'SYNO.Foto.Browse.Album',
|
|
||||||
method: 'list',
|
|
||||||
version: 4,
|
|
||||||
offset: 0,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
return res.status(502).json({ error: result.error?.message || 'Failed to fetch albums' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const albums = (result.data.list || []).map((a: any) => ({
|
res.json(await testSynologyConnection(synology_url, synology_username, synology_password));
|
||||||
id: String(a.id),
|
});
|
||||||
albumName: a.name || '',
|
|
||||||
assetCount: a.item_count || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json({ albums });
|
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||||
} catch (err: unknown) {
|
const authReq = req as AuthRequest;
|
||||||
res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' });
|
try {
|
||||||
}
|
res.json(await listSynologyAlbums(authReq.user.id));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
handleSynologyError(res, err, 'Could not reach Synology');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
const body = req.body as Record<string, unknown>;
|
||||||
const { album_id, album_name } = req.body;
|
const albumId = parseStringBodyField(body.album_id);
|
||||||
if (!album_id) return res.status(400).json({ error: 'album_id required' });
|
const albumName = parseStringBodyField(body.album_name);
|
||||||
|
|
||||||
try {
|
if (!albumId) {
|
||||||
db.prepare(
|
return handleSynologyError(res, new SynologyServiceError(400, 'Album ID is required'), 'Missing required fields');
|
||||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
}
|
||||||
).run(tripId, authReq.user.id, 'synologyphotos', String(album_id), album_name || '');
|
|
||||||
res.json({ success: true });
|
try {
|
||||||
} catch {
|
linkSynologyAlbum(authReq.user.id, tripId, albumId, albumName || undefined);
|
||||||
res.status(400).json({ error: 'Album already linked' });
|
res.json({ success: true });
|
||||||
}
|
} catch (err: unknown) {
|
||||||
|
handleSynologyError(res, err, 'Failed to link album');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, linkId } = req.params;
|
const { tripId, linkId } = req.params;
|
||||||
|
|
||||||
const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'synologyphotos'")
|
try {
|
||||||
.get(linkId, tripId, authReq.user.id) as any;
|
const result = await syncSynologyAlbumLink(authReq.user.id, tripId, linkId);
|
||||||
if (!link) return res.status(404).json({ error: 'Album link not found' });
|
res.json({ success: true, ...result });
|
||||||
|
if (result.added > 0) {
|
||||||
try {
|
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
const allItems: any[] = [];
|
}
|
||||||
const pageSize = 1000;
|
} catch (err: unknown) {
|
||||||
let offset = 0;
|
handleSynologyError(res, err, 'Could not reach Synology');
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, {
|
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
|
||||||
method: 'list',
|
|
||||||
version: 1,
|
|
||||||
album_id: Number(link.album_id),
|
|
||||||
offset,
|
|
||||||
limit: pageSize,
|
|
||||||
additional: ['thumbnail'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
return res.status(502).json({ error: result.error?.message || 'Failed to fetch album' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = result.data.list || [];
|
|
||||||
allItems.push(...items);
|
|
||||||
if (items.length < pageSize) break;
|
|
||||||
offset += pageSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = db.prepare(
|
|
||||||
"INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)"
|
|
||||||
);
|
|
||||||
|
|
||||||
let added = 0;
|
|
||||||
for (const item of allItems) {
|
|
||||||
const transformed = transformSynologyPhoto(item);
|
|
||||||
const assetId = String(transformed?.id || '').trim();
|
|
||||||
if (!assetId) continue;
|
|
||||||
const r = insert.run(tripId, authReq.user.id, assetId);
|
|
||||||
if (r.changes > 0) added++;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
|
||||||
|
|
||||||
res.json({ success: true, added, total: allItems.length });
|
|
||||||
if (added > 0) {
|
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search
|
|
||||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
let { from, to, offset = 0, limit = 300 } = req.body;
|
const body = req.body as Record<string, unknown>;
|
||||||
|
const from = parseStringBodyField(body.from);
|
||||||
|
const to = parseStringBodyField(body.to);
|
||||||
|
const offset = parseNumberBodyField(body.offset, 0);
|
||||||
|
const limit = parseNumberBodyField(body.limit, 300);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params: any = {
|
const result = await searchSynologyPhotos(
|
||||||
api: 'SYNO.Foto.Search.Search',
|
authReq.user.id,
|
||||||
method: 'list_item',
|
from || undefined,
|
||||||
version: 1,
|
to || undefined,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
keyword: '.',
|
);
|
||||||
additional: ['thumbnail', 'address'],
|
res.json(result);
|
||||||
};
|
} catch (err: unknown) {
|
||||||
|
handleSynologyError(res, err, 'Could not reach Synology');
|
||||||
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; // Include entire end day
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const result = await callSynologyApi<{ list: any[]; total: number }>(authReq.user.id, params);
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
return res.status(502).json({ error: result.error?.message || 'Failed to fetch album photos' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const allItems = (result.data.list || []);
|
|
||||||
const total = allItems.length;
|
|
||||||
|
|
||||||
const assets = allItems.map((item: any) => transformSynologyPhoto(item));
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
assets,
|
|
||||||
total,
|
|
||||||
hasMore: total == limit,
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Proxy Synology Assets
|
|
||||||
|
|
||||||
// Asset info endpoint (returns metadata, not image)
|
|
||||||
router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => {
|
router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
const parsedId = splitPackedSynologyId(photoId);
|
|
||||||
const { userId } = req.query;
|
|
||||||
|
|
||||||
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
try {
|
||||||
|
res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req)));
|
||||||
try {
|
} catch (err: unknown) {
|
||||||
const result = await callSynologyApi<any>(targetUserId, {
|
handleSynologyError(res, err, 'Could not reach Synology');
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
|
||||||
method: 'get',
|
|
||||||
version: 5,
|
|
||||||
id: `[${parsedId.id}]`,
|
|
||||||
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
|
||||||
});
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
return res.status(404).json({ error: 'Photo not found' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = result.data.list[0];
|
|
||||||
console.log(metadata);
|
|
||||||
const exif = metadata.additional?.exif || {};
|
|
||||||
const address = metadata.additional?.address || {};
|
|
||||||
const gps = metadata.additional?.gps || {};
|
|
||||||
res.json({
|
|
||||||
id: photoId,
|
|
||||||
takenAt: metadata.time ? new Date(metadata.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: metadata.additional?.orientation || null,
|
|
||||||
description: metadata.additional?.description || null,
|
|
||||||
filename: metadata.filename || null,
|
|
||||||
filesize: metadata.filesize || null,
|
|
||||||
width: metadata.additional?.resolution?.width || null,
|
|
||||||
height: metadata.additional?.resolution?.height || null,
|
|
||||||
fileSize: metadata.filesize || null,
|
|
||||||
fileName: metadata.filename || null,
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology'});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware: Accept ephemeral token from query param for <img> tags
|
router.get('/assets/:photoId/thumbnail', synologyAuthFromQuery, async (req: Request, res: Response) => {
|
||||||
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
const authReq = req as AuthRequest;
|
||||||
const queryToken = req.query.token as string | undefined;
|
const { photoId } = req.params;
|
||||||
if (queryToken) {
|
const { size = 'sm' } = req.query;
|
||||||
const userId = consumeEphemeralToken(queryToken, 'synologyphotos');
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
try {
|
||||||
const authReq = req as AuthRequest;
|
const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'thumbnail', String(size));
|
||||||
const { photoId } = req.params;
|
await pipeSynologyProxy(res, proxy);
|
||||||
const parsedId = splitPackedSynologyId(photoId);
|
} catch (err: unknown) {
|
||||||
const { userId, size = 'sm' } = req.query;
|
if (res.headersSent) {
|
||||||
|
return;
|
||||||
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
}
|
||||||
|
handleSynologyError(res, err, 'Proxy error');
|
||||||
const creds = getSynologyCredentials(targetUserId);
|
|
||||||
if (!creds) {
|
|
||||||
return res.status(404).send('Not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sid = await getSynologySession(authReq.user.id);
|
|
||||||
if (!sid.success && !sid.sid) {
|
|
||||||
return res.status(401).send('Authentication failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
api: 'SYNO.Foto.Thumbnail',
|
|
||||||
method: 'get',
|
|
||||||
version: '2',
|
|
||||||
mode: 'download',
|
|
||||||
id: parsedId.id,
|
|
||||||
type: 'unit',
|
|
||||||
size: String(size),
|
|
||||||
cache_key: parsedId.cacheKey,
|
|
||||||
_sid: sid.sid,
|
|
||||||
});
|
|
||||||
const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString();
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
signal: AbortSignal.timeout(30000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
return res.status(resp.status).send('Failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(resp.status);
|
|
||||||
copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']);
|
|
||||||
res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg');
|
|
||||||
res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400');
|
|
||||||
|
|
||||||
if (!resp.body) {
|
|
||||||
return res.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline(Readable.fromWeb(resp.body), res);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (res.headersSent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err)));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/assets/:photoId/original', synologyAuthFromQuery, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
router.get('/assets/:photoId/original', authFromQuery, async (req: Request, res: Response) => {
|
try {
|
||||||
const authReq = req as AuthRequest;
|
const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'original');
|
||||||
const { photoId } = req.params;
|
await pipeSynologyProxy(res, proxy);
|
||||||
const parsedId = splitPackedSynologyId(photoId || '');
|
} catch (err: unknown) {
|
||||||
const { userId} = req.query;
|
if (res.headersSent) {
|
||||||
|
return;
|
||||||
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
}
|
||||||
|
handleSynologyError(res, err, 'Proxy error');
|
||||||
const creds = getSynologyCredentials(targetUserId);
|
|
||||||
if (!creds) {
|
|
||||||
return res.status(404).send('Not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sid = await getSynologySession(authReq.user.id);
|
|
||||||
if (!sid.success && !sid.sid) {
|
|
||||||
return res.status(401).send('Authentication failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
api: 'SYNO.Foto.Download',
|
|
||||||
method: 'download',
|
|
||||||
version: '2',
|
|
||||||
cache_key: parsedId.cacheKey,
|
|
||||||
unit_id: `[${parsedId.id}]`,
|
|
||||||
_sid: sid.sid,
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString();
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
signal: AbortSignal.timeout(30000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.text();
|
|
||||||
return res.status(resp.status).send('Failed: ' + body);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(resp.status);
|
|
||||||
copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']);
|
|
||||||
res.set('Content-Type', resp.headers.get('content-type') || 'application/octet-stream');
|
|
||||||
res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400');
|
|
||||||
|
|
||||||
if (!resp.body) {
|
|
||||||
return res.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline(Readable.fromWeb(resp.body), res);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (res.headersSent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err)));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
export default router;
|
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { NextFunction, Request, Response as ExpressResponse } from 'express';
|
||||||
|
import { db, canAccessTrip } from '../db/database';
|
||||||
|
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { AuthRequest } from '../types';
|
||||||
|
import { consumeEphemeralToken } from './ephemeralTokens';
|
||||||
|
import { checkSsrf } from '../utils/ssrfGuard';
|
||||||
|
import { no } from 'zod/locales';
|
||||||
|
|
||||||
|
const SYNOLOGY_API_TIMEOUT_MS = 30000;
|
||||||
|
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
||||||
|
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
|
||||||
|
const SYNOLOGY_DEFAULT_THUMBNAIL_SIZE = 'sm';
|
||||||
|
|
||||||
|
interface SynologyCredentials {
|
||||||
|
synology_url: string;
|
||||||
|
synology_username: string;
|
||||||
|
synology_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SynologySession {
|
||||||
|
success: boolean;
|
||||||
|
sid?: string;
|
||||||
|
error?: { code: number; message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiCallParams {
|
||||||
|
api: string;
|
||||||
|
method: string;
|
||||||
|
version?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SynologyApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: { code: number; message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SynologyServiceError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynologySettings {
|
||||||
|
synology_url: string;
|
||||||
|
synology_username: string;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynologyConnectionResult {
|
||||||
|
connected: boolean;
|
||||||
|
user?: { username: string };
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynologyAlbumLinkInput {
|
||||||
|
album_id?: string | number;
|
||||||
|
album_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynologySearchInput {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynologyProxyResult {
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string | null>;
|
||||||
|
body: ReadableStream<Uint8Array> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SynologyPhotoInfo {
|
||||||
|
id: string;
|
||||||
|
takenAt: string | null;
|
||||||
|
city: string | null;
|
||||||
|
country: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
camera?: string | null;
|
||||||
|
lens?: string | null;
|
||||||
|
focalLength?: string | number | null;
|
||||||
|
aperture?: string | number | null;
|
||||||
|
shutter?: string | number | null;
|
||||||
|
iso?: string | number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
lng?: number | null;
|
||||||
|
orientation?: number | null;
|
||||||
|
description?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
filesize?: number | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
fileSize?: number | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SynologyPhotoItem {
|
||||||
|
id?: string | number;
|
||||||
|
filename?: string;
|
||||||
|
filesize?: number;
|
||||||
|
time?: number;
|
||||||
|
item_count?: number;
|
||||||
|
name?: string;
|
||||||
|
additional?: {
|
||||||
|
thumbnail?: { cache_key?: string };
|
||||||
|
address?: { city?: string; country?: string; state?: string };
|
||||||
|
resolution?: { width?: number; height?: number };
|
||||||
|
exif?: {
|
||||||
|
camera?: string;
|
||||||
|
lens?: string;
|
||||||
|
focal_length?: string | number;
|
||||||
|
aperture?: string | number;
|
||||||
|
exposure_time?: string | number;
|
||||||
|
iso?: string | number;
|
||||||
|
};
|
||||||
|
gps?: { latitude?: number; longitude?: number };
|
||||||
|
orientation?: number;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type SynologyUserRecord = {
|
||||||
|
synology_url?: string | null;
|
||||||
|
synology_username?: string | null;
|
||||||
|
synology_password?: string | null;
|
||||||
|
synology_sid?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null {
|
||||||
|
try {
|
||||||
|
|
||||||
|
if (!columns) return null;
|
||||||
|
|
||||||
|
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const filtered: SynologyUserRecord = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
filtered[column] = row[column];
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSynologyCredentials(userId: number): SynologyCredentials | null {
|
||||||
|
const user = readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
||||||
|
if (!user?.synology_url || !user.synology_username || !user.synology_password) return null;
|
||||||
|
return {
|
||||||
|
synology_url: user.synology_url,
|
||||||
|
synology_username: user.synology_username,
|
||||||
|
synology_password: decrypt_api_key(user.synology_password) as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function buildSynologyEndpoint(url: string): string {
|
||||||
|
const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
|
||||||
|
return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<SynologyApiResponse<T>> {
|
||||||
|
const endpoint = buildSynologyEndpoint(url);
|
||||||
|
const resp = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
return { success: false, error: { code: resp.status, message: text } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.json() as Promise<SynologyApiResponse<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginToSynology(url: string, username: string, password: string): Promise<SynologyApiResponse<{ sid?: string }>> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
api: 'SYNO.API.Auth',
|
||||||
|
method: 'login',
|
||||||
|
version: '3',
|
||||||
|
account: username,
|
||||||
|
passwd: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchSynologyJson<{ sid?: string }>(url, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<SynologyApiResponse<T>> {
|
||||||
|
const creds = getSynologyCredentials(userId);
|
||||||
|
if (!creds) {
|
||||||
|
return { success: false, error: { code: 400, message: 'Synology not configured' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSynologySession(userId);
|
||||||
|
if (!session.success || !session.sid) {
|
||||||
|
return { success: false, error: session.error || { code: 400, message: 'Failed to get Synology session' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = buildSynologyFormBody({ ...params, _sid: session.sid });
|
||||||
|
const result = await fetchSynologyJson<T>(creds.synology_url, body);
|
||||||
|
if (!result.success && result.error?.code === 119) {
|
||||||
|
clearSynologySID(userId);
|
||||||
|
const retrySession = await getSynologySession(userId);
|
||||||
|
if (!retrySession.success || !retrySession.sid) {
|
||||||
|
return { success: false, error: retrySession.error || { code: 400, message: 'Failed to get Synology session' } };
|
||||||
|
}
|
||||||
|
return fetchSynologyJson<T>(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid }));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSynologyStream(url: string): Promise<globalThis.Response> {
|
||||||
|
return fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo {
|
||||||
|
const address = item.additional?.address || {};
|
||||||
|
const exif = item.additional?.exif || {};
|
||||||
|
const gps = item.additional?.gps || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(item.additional?.thumbnail?.cache_key || ''),
|
||||||
|
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
|
||||||
|
city: address.city || null,
|
||||||
|
country: address.country || null,
|
||||||
|
state: address.state || null,
|
||||||
|
camera: exif.camera || null,
|
||||||
|
lens: exif.lens || null,
|
||||||
|
focalLength: exif.focal_length || null,
|
||||||
|
aperture: exif.aperture || null,
|
||||||
|
shutter: exif.exposure_time || null,
|
||||||
|
iso: exif.iso || null,
|
||||||
|
lat: gps.latitude || null,
|
||||||
|
lng: gps.longitude || null,
|
||||||
|
orientation: item.additional?.orientation || null,
|
||||||
|
description: item.additional?.description || null,
|
||||||
|
filename: item.filename || null,
|
||||||
|
filesize: item.filesize || null,
|
||||||
|
width: item.additional?.resolution?.width || null,
|
||||||
|
height: item.additional?.resolution?.height || null,
|
||||||
|
fileSize: item.filesize || null,
|
||||||
|
fileName: item.filename || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synologyAuthFromQuery(req: Request, res: ExpressResponse, next: NextFunction) {
|
||||||
|
const queryToken = req.query.token as string | undefined;
|
||||||
|
if (queryToken) {
|
||||||
|
const userId = consumeEphemeralToken(queryToken, SYNOLOGY_PROVIDER);
|
||||||
|
if (!userId) return res.status(401).send('Invalid or expired token');
|
||||||
|
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
|
||||||
|
if (!user) return res.status(401).send('User not found');
|
||||||
|
(req as AuthRequest).user = user;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return (authenticate as any)(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSynologyTargetUserId(req: Request): number {
|
||||||
|
const { userId } = req.query;
|
||||||
|
return Number(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse {
|
||||||
|
if (err instanceof SynologyServiceError) {
|
||||||
|
return res.status(err.status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
return res.status(502).json({ error: err instanceof Error ? err.message : fallbackMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSynologySID(userId: number, sid: string): void {
|
||||||
|
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSynologySID(userId: number): void {
|
||||||
|
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
|
||||||
|
const id = rawId.split('_')[0];
|
||||||
|
return { id, cacheKey: rawId, assetId: rawId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean {
|
||||||
|
if (requestingUserId === targetUserId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedAsset = db.prepare(`
|
||||||
|
SELECT 1
|
||||||
|
FROM trip_photos
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND asset_id = ?
|
||||||
|
AND provider = 'synologyphotos'
|
||||||
|
AND shared = 1
|
||||||
|
LIMIT 1
|
||||||
|
`).get(targetUserId, assetId);
|
||||||
|
|
||||||
|
return !!sharedAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSynologySession(userId: number): Promise<SynologySession> {
|
||||||
|
const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null;
|
||||||
|
if (cachedSid) {
|
||||||
|
return { success: true, sid: cachedSid };
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = getSynologyCredentials(userId);
|
||||||
|
if (!creds) {
|
||||||
|
return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await loginToSynology(creds.synology_url, creds.synology_username, creds.synology_password);
|
||||||
|
|
||||||
|
if (!resp.success || !resp.data?.sid) {
|
||||||
|
return { success: false, error: resp.error || { code: 400, message: 'Failed to authenticate with Synology' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheSynologySID(userId, resp.data.sid);
|
||||||
|
return { success: true, sid: resp.data.sid };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSynologySettings(userId: number): Promise<SynologySettings> {
|
||||||
|
const creds = getSynologyCredentials(userId);
|
||||||
|
const session = await getSynologySession(userId);
|
||||||
|
return {
|
||||||
|
synology_url: creds?.synology_url || '',
|
||||||
|
synology_username: creds?.synology_username || '',
|
||||||
|
connected: session.success,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<void> {
|
||||||
|
|
||||||
|
const ssrf = await checkSsrf(synologyUrl);
|
||||||
|
if (!ssrf.allowed) {
|
||||||
|
throw new SynologyServiceError(400, ssrf.error ?? 'Invalid Synology URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null;
|
||||||
|
|
||||||
|
if (!synologyPassword && !existingEncryptedPassword) {
|
||||||
|
throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
|
||||||
|
synologyUrl,
|
||||||
|
synologyUsername,
|
||||||
|
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new SynologyServiceError(400, 'Failed to save settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSynologySID(userId);
|
||||||
|
await getSynologySession(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSynologyStatus(userId: number): Promise<SynologyConnectionResult> {
|
||||||
|
try {
|
||||||
|
const sid = await getSynologySession(userId);
|
||||||
|
if (!sid.success || !sid.sid) {
|
||||||
|
return { connected: false, error: 'Authentication failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
|
||||||
|
return { connected: true, user: { username: user?.synology_username || '' } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<SynologyConnectionResult> {
|
||||||
|
|
||||||
|
const ssrf = await checkSsrf(synologyUrl);
|
||||||
|
if (!ssrf.allowed) {
|
||||||
|
return { connected: false, error: ssrf.error ?? 'Invalid Synology URL' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const login = await loginToSynology(synologyUrl, synologyUsername, synologyPassword);
|
||||||
|
if (!login.success || !login.data?.sid) {
|
||||||
|
return { connected: false, error: login.error?.message || 'Authentication failed' };
|
||||||
|
}
|
||||||
|
return { connected: true, user: { username: synologyUsername } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> {
|
||||||
|
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||||
|
api: 'SYNO.Foto.Browse.Album',
|
||||||
|
method: 'list',
|
||||||
|
version: 4,
|
||||||
|
offset: 0,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new SynologyServiceError(result.error?.code || 500, result.error?.message || 'Failed to fetch albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
const albums = (result.data.list || []).map((album: SynologyPhotoItem) => ({
|
||||||
|
id: String(album.id),
|
||||||
|
albumName: album.name || '',
|
||||||
|
assetCount: album.item_count || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { albums };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkSynologyAlbum(userId: number, tripId: string, albumId: string | number | undefined, albumName?: string): void {
|
||||||
|
if (!canAccessTrip(tripId, userId)) {
|
||||||
|
throw new SynologyServiceError(404, 'Trip not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!albumId) {
|
||||||
|
throw new SynologyServiceError(400, 'album_id required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(tripId, userId, SYNOLOGY_PROVIDER, String(albumId), albumName || '').changes;
|
||||||
|
|
||||||
|
if (changes === 0) {
|
||||||
|
throw new SynologyServiceError(400, 'Album already linked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> {
|
||||||
|
const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`)
|
||||||
|
.get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined;
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new SynologyServiceError(404, 'Album link not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItems: SynologyPhotoItem[] = [];
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||||
|
api: 'SYNO.Foto.Browse.Item',
|
||||||
|
method: 'list',
|
||||||
|
version: 1,
|
||||||
|
album_id: Number(link.album_id),
|
||||||
|
offset,
|
||||||
|
limit: pageSize,
|
||||||
|
additional: ['thumbnail'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result.data.list || [];
|
||||||
|
allItems.push(...items);
|
||||||
|
if (items.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const item of allItems) {
|
||||||
|
const transformed = normalizeSynologyPhotoInfo(item);
|
||||||
|
const assetId = String(transformed?.id || '').trim();
|
||||||
|
if (!assetId) continue;
|
||||||
|
const result = insert.run(tripId, userId, assetId);
|
||||||
|
if (result.changes > 0) added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||||
|
|
||||||
|
return { added, total: allItems.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {
|
||||||
|
const params: ApiCallParams = {
|
||||||
|
api: 'SYNO.Foto.Search.Search',
|
||||||
|
method: 'list_item',
|
||||||
|
version: 1,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
keyword: '.',
|
||||||
|
additional: ['thumbnail', 'address'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (from || to) {
|
||||||
|
if (from) {
|
||||||
|
params.start_time = Math.floor(new Date(from).getTime() / 1000);
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItems = result.data.list || [];
|
||||||
|
const total = allItems.length;
|
||||||
|
const assets = allItems.map(item => normalizeSynologyPhotoInfo(item));
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
total,
|
||||||
|
hasMore: total === limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<SynologyPhotoInfo> {
|
||||||
|
if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) {
|
||||||
|
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
||||||
|
}
|
||||||
|
const parsedId = splitPackedSynologyId(photoId);
|
||||||
|
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, {
|
||||||
|
api: 'SYNO.Foto.Browse.Item',
|
||||||
|
method: 'get',
|
||||||
|
version: 5,
|
||||||
|
id: `[${parsedId.id}]`,
|
||||||
|
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new SynologyServiceError(404, 'Photo not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = result.data.list?.[0];
|
||||||
|
if (!metadata) {
|
||||||
|
throw new SynologyServiceError(404, 'Photo not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeSynologyPhotoInfo(metadata);
|
||||||
|
normalized.id = photoId;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamSynologyAsset(
|
||||||
|
userId: number,
|
||||||
|
targetUserId: number,
|
||||||
|
photoId: string,
|
||||||
|
kind: 'thumbnail' | 'original',
|
||||||
|
size?: string,
|
||||||
|
): Promise<SynologyProxyResult> {
|
||||||
|
if (!canStreamSynologyAsset(userId, targetUserId, photoId)) {
|
||||||
|
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedId = splitPackedSynologyId(photoId);
|
||||||
|
const synology_url = getSynologyCredentials(targetUserId).synology_url;
|
||||||
|
if (!synology_url) {
|
||||||
|
throw new SynologyServiceError(402, 'User not configured with Synology');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = await getSynologySession(targetUserId);
|
||||||
|
if (!sid.success || !sid.sid) {
|
||||||
|
throw new SynologyServiceError(401, 'Authentication failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const params = kind === 'thumbnail'
|
||||||
|
? new URLSearchParams({
|
||||||
|
api: 'SYNO.Foto.Thumbnail',
|
||||||
|
method: 'get',
|
||||||
|
version: '2',
|
||||||
|
mode: 'download',
|
||||||
|
id: parsedId.id,
|
||||||
|
type: 'unit',
|
||||||
|
size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE),
|
||||||
|
cache_key: parsedId.cacheKey,
|
||||||
|
_sid: sid.sid,
|
||||||
|
})
|
||||||
|
: new URLSearchParams({
|
||||||
|
api: 'SYNO.Foto.Download',
|
||||||
|
method: 'download',
|
||||||
|
version: '2',
|
||||||
|
cache_key: parsedId.cacheKey,
|
||||||
|
unit_id: `[${parsedId.id}]`,
|
||||||
|
_sid: sid.sid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`;
|
||||||
|
const resp = await requestSynologyStream(url);
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = kind === 'original' ? await resp.text() : 'Failed';
|
||||||
|
throw new SynologyServiceError(resp.status, kind === 'original' ? `Failed: ${body}` : body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: resp.status,
|
||||||
|
headers: {
|
||||||
|
'content-type': resp.headers.get('content-type') || (kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream'),
|
||||||
|
'cache-control': resp.headers.get('cache-control') || 'public, max-age=86400',
|
||||||
|
'content-length': resp.headers.get('content-length'),
|
||||||
|
'content-disposition': resp.headers.get('content-disposition'),
|
||||||
|
},
|
||||||
|
body: resp.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pipeSynologyProxy(response: ExpressResponse, proxy: SynologyProxyResult): Promise<void> {
|
||||||
|
response.status(proxy.status);
|
||||||
|
if (proxy.headers['content-type']) response.set('Content-Type', proxy.headers['content-type'] as string);
|
||||||
|
if (proxy.headers['cache-control']) response.set('Cache-Control', proxy.headers['cache-control'] as string);
|
||||||
|
if (proxy.headers['content-length']) response.set('Content-Length', proxy.headers['content-length'] as string);
|
||||||
|
if (proxy.headers['content-disposition']) response.set('Content-Disposition', proxy.headers['content-disposition'] as string);
|
||||||
|
|
||||||
|
if (!proxy.body) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline(Readable.fromWeb(proxy.body), response);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user