Merge branch 'test' into dev
This commit is contained in:
@@ -465,17 +465,92 @@ export function deleteTemplateItem(itemId: string) {
|
||||
|
||||
export function listAddons() {
|
||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||
return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') }));
|
||||
const providers = db.prepare(`
|
||||
SELECT id, name, description, icon, enabled, config, sort_order
|
||||
FROM photo_providers
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>;
|
||||
const fields = db.prepare(`
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>;
|
||||
const fieldsByProvider = new Map<string, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
arr.push(field);
|
||||
fieldsByProvider.set(field.provider_id, arr);
|
||||
}
|
||||
|
||||
return [
|
||||
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
|
||||
...providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: JSON.parse(p.config || '{}'),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
placeholder: f.placeholder || '',
|
||||
required: !!f.required,
|
||||
secret: !!f.secret,
|
||||
settings_key: f.settings_key || null,
|
||||
payload_key: f.payload_key || null,
|
||||
sort_order: f.sort_order,
|
||||
})),
|
||||
sort_order: p.sort_order,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id);
|
||||
if (!addon) return { error: 'Addon not found', status: 404 };
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
||||
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon;
|
||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
|
||||
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
||||
|
||||
if (addon) {
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
||||
} else {
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
||||
}
|
||||
|
||||
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
|
||||
const updated = updatedAddon
|
||||
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
|
||||
: updatedProvider
|
||||
? {
|
||||
id: updatedProvider.id,
|
||||
name: updatedProvider.name,
|
||||
description: updatedProvider.description,
|
||||
type: 'photo_provider',
|
||||
icon: updatedProvider.icon,
|
||||
enabled: !!updatedProvider.enabled,
|
||||
config: JSON.parse(updatedProvider.config || '{}'),
|
||||
sort_order: updatedProvider.sort_order,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') },
|
||||
addon: updated,
|
||||
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -175,11 +175,12 @@ export async function searchPhotos(
|
||||
|
||||
export function listTripPhotos(tripId: string, userId: number) {
|
||||
return db.prepare(`
|
||||
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||
SELECT tp.asset_id AS immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||
u.username, u.avatar, u.immich_url
|
||||
FROM trip_photos tp
|
||||
JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.trip_id = ?
|
||||
AND tp.provider = 'immich'
|
||||
AND (tp.user_id = ? OR tp.shared = 1)
|
||||
ORDER BY tp.added_at ASC
|
||||
`).all(tripId, userId);
|
||||
@@ -191,25 +192,23 @@ export function addTripPhotos(
|
||||
assetIds: string[],
|
||||
shared: boolean
|
||||
): number {
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)');
|
||||
let added = 0;
|
||||
for (const assetId of assetIds) {
|
||||
const result = insert.run(tripId, userId, assetId, shared ? 1 : 0);
|
||||
const result = insert.run(tripId, userId, assetId, 'immich', shared ? 1 : 0);
|
||||
if (result.changes > 0) added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
export function removeTripPhoto(tripId: string, userId: number, assetId: string) {
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(tripId, userId, assetId);
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?')
|
||||
.run(tripId, userId, assetId, 'immich');
|
||||
}
|
||||
|
||||
export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) {
|
||||
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(shared ? 1 : 0, tripId, userId, assetId);
|
||||
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?')
|
||||
.run(shared ? 1 : 0, tripId, userId, assetId, 'immich');
|
||||
}
|
||||
|
||||
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
|
||||
@@ -329,7 +328,7 @@ export function listAlbumLinks(tripId: string) {
|
||||
SELECT tal.*, u.username
|
||||
FROM trip_album_links tal
|
||||
JOIN users u ON tal.user_id = u.id
|
||||
WHERE tal.trip_id = ?
|
||||
WHERE tal.trip_id = ? AND tal.provider = 'immich'
|
||||
ORDER BY tal.created_at ASC
|
||||
`).all(tripId);
|
||||
}
|
||||
@@ -342,8 +341,8 @@ export function createAlbumLink(
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
|
||||
).run(tripId, userId, albumId, albumName || '');
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, 'immich', albumId, albumName || '');
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: 'Album already linked' };
|
||||
@@ -360,15 +359,15 @@ export async function syncAlbumAssets(
|
||||
linkId: string,
|
||||
userId: number
|
||||
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
|
||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.get(linkId, tripId, userId) as any;
|
||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?')
|
||||
.get(linkId, tripId, userId, 'immich') as any;
|
||||
if (!link) return { error: 'Album link not found', status: 404 };
|
||||
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, {
|
||||
const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
@@ -376,9 +375,7 @@ export async function syncAlbumAssets(
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)'
|
||||
);
|
||||
const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)");
|
||||
let added = 0;
|
||||
for (const asset of assets) {
|
||||
const r = insert.run(tripId, userId, asset.id);
|
||||
|
||||
651
server/src/services/synologyService.ts
Normal file
651
server/src/services/synologyService.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { NextFunction, Request, Response as ExpressResponse } from 'express';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { consumeEphemeralToken } from './ephemeralTokens';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
import { no } from 'zod/locales';
|
||||
|
||||
const SYNOLOGY_API_TIMEOUT_MS = 30000;
|
||||
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
||||
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
|
||||
const SYNOLOGY_DEFAULT_THUMBNAIL_SIZE = 'sm';
|
||||
|
||||
interface SynologyCredentials {
|
||||
synology_url: string;
|
||||
synology_username: string;
|
||||
synology_password: string;
|
||||
}
|
||||
|
||||
interface SynologySession {
|
||||
success: boolean;
|
||||
sid?: string;
|
||||
error?: { code: number; message?: string };
|
||||
}
|
||||
|
||||
interface ApiCallParams {
|
||||
api: string;
|
||||
method: string;
|
||||
version?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SynologyApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: number; message?: string };
|
||||
}
|
||||
|
||||
export class SynologyServiceError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SynologySettings {
|
||||
synology_url: string;
|
||||
synology_username: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface SynologyConnectionResult {
|
||||
connected: boolean;
|
||||
user?: { username: string };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SynologyAlbumLinkInput {
|
||||
album_id?: string | number;
|
||||
album_name?: string;
|
||||
}
|
||||
|
||||
export interface SynologySearchInput {
|
||||
from?: string;
|
||||
to?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SynologyProxyResult {
|
||||
status: number;
|
||||
headers: Record<string, string | null>;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
}
|
||||
|
||||
interface SynologyPhotoInfo {
|
||||
id: string;
|
||||
takenAt: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
state?: string | null;
|
||||
camera?: string | null;
|
||||
lens?: string | null;
|
||||
focalLength?: string | number | null;
|
||||
aperture?: string | number | null;
|
||||
shutter?: string | number | null;
|
||||
iso?: string | number | null;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
orientation?: number | null;
|
||||
description?: string | null;
|
||||
filename?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
fileSize?: number | null;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
interface SynologyPhotoItem {
|
||||
id?: string | number;
|
||||
filename?: string;
|
||||
filesize?: number;
|
||||
time?: number;
|
||||
item_count?: number;
|
||||
name?: string;
|
||||
additional?: {
|
||||
thumbnail?: { cache_key?: string };
|
||||
address?: { city?: string; country?: string; state?: string };
|
||||
resolution?: { width?: number; height?: number };
|
||||
exif?: {
|
||||
camera?: string;
|
||||
lens?: string;
|
||||
focal_length?: string | number;
|
||||
aperture?: string | number;
|
||||
exposure_time?: string | number;
|
||||
iso?: string | number;
|
||||
};
|
||||
gps?: { latitude?: number; longitude?: number };
|
||||
orientation?: number;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type SynologyUserRecord = {
|
||||
synology_url?: string | null;
|
||||
synology_username?: string | null;
|
||||
synology_password?: string | null;
|
||||
synology_sid?: string | null;
|
||||
};
|
||||
|
||||
function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null {
|
||||
try {
|
||||
|
||||
if (!columns) return null;
|
||||
|
||||
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const filtered: SynologyUserRecord = {};
|
||||
for (const column of columns) {
|
||||
filtered[column] = row[column];
|
||||
}
|
||||
|
||||
return filtered || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSynologyCredentials(userId: number): SynologyCredentials | null {
|
||||
const user = readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
||||
if (!user?.synology_url || !user.synology_username || !user.synology_password) return null;
|
||||
return {
|
||||
synology_url: user.synology_url,
|
||||
synology_username: user.synology_username,
|
||||
synology_password: decrypt_api_key(user.synology_password) as string,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function buildSynologyEndpoint(url: string): string {
|
||||
const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
|
||||
return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`;
|
||||
}
|
||||
|
||||
function buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
|
||||
const body = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<SynologyApiResponse<T>> {
|
||||
const endpoint = buildSynologyEndpoint(url);
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
return { success: false, error: { code: resp.status, message: text } };
|
||||
}
|
||||
|
||||
return resp.json() as Promise<SynologyApiResponse<T>>;
|
||||
}
|
||||
|
||||
async function loginToSynology(url: string, username: string, password: string): Promise<SynologyApiResponse<{ sid?: string }>> {
|
||||
const body = new URLSearchParams({
|
||||
api: 'SYNO.API.Auth',
|
||||
method: 'login',
|
||||
version: '3',
|
||||
account: username,
|
||||
passwd: password,
|
||||
});
|
||||
|
||||
return fetchSynologyJson<{ sid?: string }>(url, body);
|
||||
}
|
||||
|
||||
async function requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<SynologyApiResponse<T>> {
|
||||
const creds = getSynologyCredentials(userId);
|
||||
if (!creds) {
|
||||
return { success: false, error: { code: 400, message: 'Synology not configured' } };
|
||||
}
|
||||
|
||||
const session = await getSynologySession(userId);
|
||||
if (!session.success || !session.sid) {
|
||||
return { success: false, error: session.error || { code: 400, message: 'Failed to get Synology session' } };
|
||||
}
|
||||
|
||||
const body = buildSynologyFormBody({ ...params, _sid: session.sid });
|
||||
const result = await fetchSynologyJson<T>(creds.synology_url, body);
|
||||
if (!result.success && result.error?.code === 119) {
|
||||
clearSynologySID(userId);
|
||||
const retrySession = await getSynologySession(userId);
|
||||
if (!retrySession.success || !retrySession.sid) {
|
||||
return { success: false, error: retrySession.error || { code: 400, message: 'Failed to get Synology session' } };
|
||||
}
|
||||
return fetchSynologyJson<T>(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid }));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function requestSynologyStream(url: string): Promise<globalThis.Response> {
|
||||
return fetch(url, {
|
||||
signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo {
|
||||
const address = item.additional?.address || {};
|
||||
const exif = item.additional?.exif || {};
|
||||
const gps = item.additional?.gps || {};
|
||||
|
||||
return {
|
||||
id: String(item.additional?.thumbnail?.cache_key || ''),
|
||||
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
|
||||
city: address.city || null,
|
||||
country: address.country || null,
|
||||
state: address.state || null,
|
||||
camera: exif.camera || null,
|
||||
lens: exif.lens || null,
|
||||
focalLength: exif.focal_length || null,
|
||||
aperture: exif.aperture || null,
|
||||
shutter: exif.exposure_time || null,
|
||||
iso: exif.iso || null,
|
||||
lat: gps.latitude || null,
|
||||
lng: gps.longitude || null,
|
||||
orientation: item.additional?.orientation || null,
|
||||
description: item.additional?.description || null,
|
||||
filename: item.filename || null,
|
||||
filesize: item.filesize || null,
|
||||
width: item.additional?.resolution?.width || null,
|
||||
height: item.additional?.resolution?.height || null,
|
||||
fileSize: item.filesize || null,
|
||||
fileName: item.filename || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function synologyAuthFromQuery(req: Request, res: ExpressResponse, next: NextFunction) {
|
||||
const queryToken = req.query.token as string | undefined;
|
||||
if (queryToken) {
|
||||
const userId = consumeEphemeralToken(queryToken, SYNOLOGY_PROVIDER);
|
||||
if (!userId) return res.status(401).send('Invalid or expired token');
|
||||
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
|
||||
if (!user) return res.status(401).send('User not found');
|
||||
(req as AuthRequest).user = user;
|
||||
return next();
|
||||
}
|
||||
return (authenticate as any)(req, res, next);
|
||||
}
|
||||
|
||||
export function getSynologyTargetUserId(req: Request): number {
|
||||
const { userId } = req.query;
|
||||
return Number(userId);
|
||||
}
|
||||
|
||||
export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse {
|
||||
if (err instanceof SynologyServiceError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
return res.status(502).json({ error: err instanceof Error ? err.message : fallbackMessage });
|
||||
}
|
||||
|
||||
function cacheSynologySID(userId: number, sid: string): void {
|
||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
|
||||
}
|
||||
|
||||
function clearSynologySID(userId: number): void {
|
||||
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
|
||||
const id = rawId.split('_')[0];
|
||||
return { id, cacheKey: rawId, assetId: rawId };
|
||||
}
|
||||
|
||||
function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean {
|
||||
if (requestingUserId === targetUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sharedAsset = db.prepare(`
|
||||
SELECT 1
|
||||
FROM trip_photos
|
||||
WHERE user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = 'synologyphotos'
|
||||
AND shared = 1
|
||||
LIMIT 1
|
||||
`).get(targetUserId, assetId);
|
||||
|
||||
return !!sharedAsset;
|
||||
}
|
||||
|
||||
async function getSynologySession(userId: number): Promise<SynologySession> {
|
||||
const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null;
|
||||
if (cachedSid) {
|
||||
return { success: true, sid: cachedSid };
|
||||
}
|
||||
|
||||
const creds = getSynologyCredentials(userId);
|
||||
if (!creds) {
|
||||
return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } };
|
||||
}
|
||||
|
||||
const resp = await loginToSynology(creds.synology_url, creds.synology_username, creds.synology_password);
|
||||
|
||||
if (!resp.success || !resp.data?.sid) {
|
||||
return { success: false, error: resp.error || { code: 400, message: 'Failed to authenticate with Synology' } };
|
||||
}
|
||||
|
||||
cacheSynologySID(userId, resp.data.sid);
|
||||
return { success: true, sid: resp.data.sid };
|
||||
}
|
||||
|
||||
export async function getSynologySettings(userId: number): Promise<SynologySettings> {
|
||||
const creds = getSynologyCredentials(userId);
|
||||
const session = await getSynologySession(userId);
|
||||
return {
|
||||
synology_url: creds?.synology_url || '',
|
||||
synology_username: creds?.synology_username || '',
|
||||
connected: session.success,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<void> {
|
||||
|
||||
const ssrf = await checkSsrf(synologyUrl);
|
||||
if (!ssrf.allowed) {
|
||||
throw new SynologyServiceError(400, ssrf.error ?? 'Invalid Synology URL');
|
||||
}
|
||||
|
||||
const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null;
|
||||
|
||||
if (!synologyPassword && !existingEncryptedPassword) {
|
||||
throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.');
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
|
||||
synologyUrl,
|
||||
synologyUsername,
|
||||
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
|
||||
userId,
|
||||
);
|
||||
} catch {
|
||||
throw new SynologyServiceError(400, 'Failed to save settings');
|
||||
}
|
||||
|
||||
clearSynologySID(userId);
|
||||
await getSynologySession(userId);
|
||||
}
|
||||
|
||||
export async function getSynologyStatus(userId: number): Promise<SynologyConnectionResult> {
|
||||
try {
|
||||
const sid = await getSynologySession(userId);
|
||||
if (!sid.success || !sid.sid) {
|
||||
return { connected: false, error: 'Authentication failed' };
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
|
||||
return { connected: true, user: { username: user?.synology_username || '' } };
|
||||
} catch (err: unknown) {
|
||||
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<SynologyConnectionResult> {
|
||||
|
||||
const ssrf = await checkSsrf(synologyUrl);
|
||||
if (!ssrf.allowed) {
|
||||
return { connected: false, error: ssrf.error ?? 'Invalid Synology URL' };
|
||||
}
|
||||
try {
|
||||
const login = await loginToSynology(synologyUrl, synologyUsername, synologyPassword);
|
||||
if (!login.success || !login.data?.sid) {
|
||||
return { connected: false, error: login.error?.message || 'Authentication failed' };
|
||||
}
|
||||
return { connected: true, user: { username: synologyUsername } };
|
||||
} catch (err: unknown) {
|
||||
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> {
|
||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Album',
|
||||
method: 'list',
|
||||
version: 4,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new SynologyServiceError(result.error?.code || 500, result.error?.message || 'Failed to fetch albums');
|
||||
}
|
||||
|
||||
const albums = (result.data.list || []).map((album: SynologyPhotoItem) => ({
|
||||
id: String(album.id),
|
||||
albumName: album.name || '',
|
||||
assetCount: album.item_count || 0,
|
||||
}));
|
||||
|
||||
return { albums };
|
||||
}
|
||||
|
||||
export function linkSynologyAlbum(userId: number, tripId: string, albumId: string | number | undefined, albumName?: string): void {
|
||||
if (!canAccessTrip(tripId, userId)) {
|
||||
throw new SynologyServiceError(404, 'Trip not found');
|
||||
}
|
||||
|
||||
if (!albumId) {
|
||||
throw new SynologyServiceError(400, 'album_id required');
|
||||
}
|
||||
|
||||
const changes = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, SYNOLOGY_PROVIDER, String(albumId), albumName || '').changes;
|
||||
|
||||
if (changes === 0) {
|
||||
throw new SynologyServiceError(400, 'Album already linked');
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> {
|
||||
const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`)
|
||||
.get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined;
|
||||
|
||||
if (!link) {
|
||||
throw new SynologyServiceError(404, 'Album link not found');
|
||||
}
|
||||
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(link.album_id),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album');
|
||||
}
|
||||
|
||||
const items = result.data.list || [];
|
||||
allItems.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
const insert = db.prepare(
|
||||
"INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)"
|
||||
);
|
||||
|
||||
let added = 0;
|
||||
for (const item of allItems) {
|
||||
const transformed = normalizeSynologyPhotoInfo(item);
|
||||
const assetId = String(transformed?.id || '').trim();
|
||||
if (!assetId) continue;
|
||||
const result = insert.run(tripId, userId, assetId);
|
||||
if (result.changes > 0) added++;
|
||||
}
|
||||
|
||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||
|
||||
return { added, total: allItems.length };
|
||||
}
|
||||
|
||||
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {
|
||||
const params: ApiCallParams = {
|
||||
api: 'SYNO.Foto.Search.Search',
|
||||
method: 'list_item',
|
||||
version: 1,
|
||||
offset,
|
||||
limit,
|
||||
keyword: '.',
|
||||
additional: ['thumbnail', 'address'],
|
||||
};
|
||||
|
||||
if (from || to) {
|
||||
if (from) {
|
||||
params.start_time = Math.floor(new Date(from).getTime() / 1000);
|
||||
}
|
||||
if (to) {
|
||||
params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
|
||||
}
|
||||
}
|
||||
|
||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
|
||||
if (!result.success || !result.data) {
|
||||
throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album photos');
|
||||
}
|
||||
|
||||
const allItems = result.data.list || [];
|
||||
const total = allItems.length;
|
||||
const assets = allItems.map(item => normalizeSynologyPhotoInfo(item));
|
||||
|
||||
return {
|
||||
assets,
|
||||
total,
|
||||
hasMore: total === limit,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<SynologyPhotoInfo> {
|
||||
if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) {
|
||||
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
||||
}
|
||||
const parsedId = splitPackedSynologyId(photoId);
|
||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'get',
|
||||
version: 5,
|
||||
id: `[${parsedId.id}]`,
|
||||
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new SynologyServiceError(404, 'Photo not found');
|
||||
}
|
||||
|
||||
const metadata = result.data.list?.[0];
|
||||
if (!metadata) {
|
||||
throw new SynologyServiceError(404, 'Photo not found');
|
||||
}
|
||||
|
||||
const normalized = normalizeSynologyPhotoInfo(metadata);
|
||||
normalized.id = photoId;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function streamSynologyAsset(
|
||||
userId: number,
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
kind: 'thumbnail' | 'original',
|
||||
size?: string,
|
||||
): Promise<SynologyProxyResult> {
|
||||
if (!canStreamSynologyAsset(userId, targetUserId, photoId)) {
|
||||
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
||||
}
|
||||
|
||||
const parsedId = splitPackedSynologyId(photoId);
|
||||
const synology_url = getSynologyCredentials(targetUserId).synology_url;
|
||||
if (!synology_url) {
|
||||
throw new SynologyServiceError(402, 'User not configured with Synology');
|
||||
}
|
||||
|
||||
const sid = await getSynologySession(targetUserId);
|
||||
if (!sid.success || !sid.sid) {
|
||||
throw new SynologyServiceError(401, 'Authentication failed');
|
||||
}
|
||||
|
||||
|
||||
|
||||
const params = kind === 'thumbnail'
|
||||
? new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
version: '2',
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE),
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.sid,
|
||||
})
|
||||
: new URLSearchParams({
|
||||
api: 'SYNO.Foto.Download',
|
||||
method: 'download',
|
||||
version: '2',
|
||||
cache_key: parsedId.cacheKey,
|
||||
unit_id: `[${parsedId.id}]`,
|
||||
_sid: sid.sid,
|
||||
});
|
||||
|
||||
const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`;
|
||||
const resp = await requestSynologyStream(url);
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = kind === 'original' ? await resp.text() : 'Failed';
|
||||
throw new SynologyServiceError(resp.status, kind === 'original' ? `Failed: ${body}` : body);
|
||||
}
|
||||
|
||||
return {
|
||||
status: resp.status,
|
||||
headers: {
|
||||
'content-type': resp.headers.get('content-type') || (kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream'),
|
||||
'cache-control': resp.headers.get('cache-control') || 'public, max-age=86400',
|
||||
'content-length': resp.headers.get('content-length'),
|
||||
'content-disposition': resp.headers.get('content-disposition'),
|
||||
},
|
||||
body: resp.body,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pipeSynologyProxy(response: ExpressResponse, proxy: SynologyProxyResult): Promise<void> {
|
||||
response.status(proxy.status);
|
||||
if (proxy.headers['content-type']) response.set('Content-Type', proxy.headers['content-type'] as string);
|
||||
if (proxy.headers['cache-control']) response.set('Cache-Control', proxy.headers['cache-control'] as string);
|
||||
if (proxy.headers['content-length']) response.set('Content-Length', proxy.headers['content-length'] as string);
|
||||
if (proxy.headers['content-disposition']) response.set('Content-Disposition', proxy.headers['content-disposition'] as string);
|
||||
|
||||
if (!proxy.body) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await pipeline(Readable.fromWeb(proxy.body), response);
|
||||
}
|
||||
Reference in New Issue
Block a user