Merge branch 'test' into dev

This commit is contained in:
Marek Maslowski
2026-04-04 19:27:16 +02:00
22 changed files with 2230 additions and 479 deletions
@@ -0,0 +1,186 @@
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { Response } from 'express';
import { canAccessTrip, db } from "../../db/database";
// helpers for handling return types
type ServiceError = { success: false; error: { message: string; status: number } };
export type ServiceResult<T> = { success: true; data: T } | ServiceError;
export function fail(error: string, status: number): ServiceError {
return { success: false, error: { message: error, status } };
}
export function success<T>(data: T): ServiceResult<T> {
return { success: true, data: data };
}
export function mapDbError(error: Error, fallbackMessage: string): ServiceError {
if (error && /unique|constraint/i.test(error.message)) {
return fail('Resource already exists', 409);
}
return fail(error.message, 500);
}
export function handleServiceResult<T>(res: Response, result: ServiceResult<T>): void {
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
}
else {
res.json(result.data);
}
}
// ----------------------------------------------
// types used across memories services
export type Selection = {
provider: string;
asset_ids: string[];
};
export type StatusResult = {
connected: true;
user: { name: string }
} | {
connected: false;
error: string
};
export type SyncAlbumResult = {
added: number;
total: number
};
export type AlbumsList = {
albums: Array<{ id: string; albumName: string; assetCount: number }>
};
export type Asset = {
id: string;
takenAt: string;
};
export type AssetsList = {
assets: Asset[],
total: number,
hasMore: boolean
};
export type AssetInfo = {
id: string;
takenAt: string | null;
city: string | null;
country: string | null;
state?: string | null;
camera?: string | null;
lens?: string | null;
focalLength?: string | number | null;
aperture?: string | number | null;
shutter?: string | number | null;
iso?: string | number | null;
lat?: number | null;
lng?: number | null;
orientation?: number | null;
description?: string | null;
width?: number | null;
height?: number | null;
fileSize?: number | null;
fileName?: string | null;
}
//for loading routes to settings page, and validating which services user has connected
type PhotoProviderConfig = {
settings_get: string;
settings_put: string;
status_get: string;
test_post: string;
};
export function getPhotoProviderConfig(providerId: string): PhotoProviderConfig {
const prefix = `/integrations/memories/${providerId}`;
return {
settings_get: `${prefix}/settings`,
settings_put: `${prefix}/settings`,
status_get: `${prefix}/status`,
test_post: `${prefix}/test`,
};
}
//-----------------------------------------------
//access check helper
export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
if (requestingUserId === ownerUserId) {
return true;
}
const sharedAsset = db.prepare(`
SELECT 1
FROM trip_photos
WHERE user_id = ?
AND asset_id = ?
AND provider = ?
AND trip_id = ?
AND shared = 1
LIMIT 1
`).get(ownerUserId, assetId, provider, tripId);
if (!sharedAsset) {
return false;
}
return !!canAccessTrip(tripId, requestingUserId);
}
// ----------------------------------------------
//helpers for album link syncing
export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<string> {
const access = canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.get(linkId, tripId, userId) as { album_id: string } | null;
return row ? success(row.album_id) : fail('Album link not found', 404);
} catch {
return fail('Failed to retrieve album link', 500);
}
}
export function updateSyncTimeForAlbumLink(linkId: string): void {
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
}
export async function pipeAsset(url: string, response: Response): Promise<void> {
try{
const resp = await fetch(url);
response.status(resp.status);
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
if (!resp.body) {
response.end();
}
else {
pipeline(Readable.fromWeb(resp.body), response);
}
}
catch (error) {
response.status(500).json({ error: 'Failed to fetch asset' });
response.end();
}
}
@@ -0,0 +1,377 @@
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { writeAudit } from '../auditLog';
import { addTripPhotos} from './unifiedService';
import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService';
// ── Credentials ────────────────────────────────────────────────────────────
export function getImmichCredentials(userId: number) {
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
if (!user?.immich_url || !user?.immich_api_key) return null;
return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string };
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
export function isValidAssetId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
}
// ── Connection Settings ────────────────────────────────────────────────────
export function getConnectionSettings(userId: number) {
const creds = getImmichCredentials(userId);
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
};
}
export async function saveImmichSettings(
userId: number,
immichUrl: string | undefined,
immichApiKey: string | undefined,
clientIp: string | null
): Promise<{ success: boolean; warning?: string; error?: string }> {
if (immichUrl) {
const ssrf = await checkSsrf(immichUrl.trim());
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
userId
);
if (ssrf.isPrivate) {
writeAudit({
userId,
action: 'immich.private_ip_configured',
ip: clientIp,
details: { immich_url: immichUrl.trim(), resolved_ip: ssrf.resolvedIp },
});
return {
success: true,
warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`,
};
}
} else {
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
);
}
return { success: true };
}
// ── Connection Test / Status ───────────────────────────────────────────────
export async function testConnection(
immichUrl: string,
immichApiKey: string
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string }; canonicalUrl?: string }> {
const ssrf = await checkSsrf(immichUrl);
if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' };
try {
const resp = await fetch(`${immichUrl}/api/users/me`, {
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
// Detect http → https upgrade only: same host/port, protocol changed to https
let canonicalUrl: string | undefined;
if (resp.url) {
const finalUrl = new URL(resp.url);
const inputUrl = new URL(immichUrl);
if (
inputUrl.protocol === 'http:' &&
finalUrl.protocol === 'https:' &&
finalUrl.hostname === inputUrl.hostname &&
finalUrl.port === inputUrl.port
) {
canonicalUrl = finalUrl.origin;
}
}
return { connected: true, user: { name: data.name, email: data.email }, canonicalUrl };
} catch (err: unknown) {
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
}
}
export async function getConnectionStatus(
userId: number
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> {
const creds = getImmichCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
try {
const resp = await fetch(`${creds.immich_url}/api/users/me`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
return { connected: true, user: { name: data.name, email: data.email } };
} catch (err: unknown) {
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
}
}
// ── Browse Timeline / Search ───────────────────────────────────────────────
export async function browseTimeline(
userId: number
): Promise<{ buckets?: any; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, {
method: 'GET',
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status };
const buckets = await resp.json();
return { buckets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export async function searchPhotos(
userId: number,
from?: string,
to?: string
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
// Paginate through all results (Immich limits per-page to 1000)
const allAssets: any[] = [];
let page = 1;
const pageSize = 1000;
while (true) {
const resp = await fetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size: pageSize,
page,
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
allAssets.push(...items);
if (items.length < pageSize) break; // Last page
page++;
if (page > 20) break; // Safety limit (20k photos max)
}
const assets = allAssets.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
return { assets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
export async function getAssetInfo(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ data?: any; error?: string; status?: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
try {
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const asset = await resp.json() as any;
return {
data: {
id: asset.id,
takenAt: asset.fileCreatedAt || asset.createdAt,
width: asset.exifInfo?.exifImageWidth || null,
height: asset.exifInfo?.exifImageHeight || null,
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
lens: asset.exifInfo?.lensModel || null,
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
shutter: asset.exifInfo?.exposureTime || null,
iso: asset.exifInfo?.iso || null,
city: asset.exifInfo?.city || null,
state: asset.exifInfo?.state || null,
country: asset.exifInfo?.country || null,
lat: asset.exifInfo?.latitude || null,
lng: asset.exifInfo?.longitude || null,
fileSize: asset.exifInfo?.fileSizeInByte || null,
fileName: asset.originalFileName || null,
},
};
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function proxyThumbnail(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
try {
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const buffer = Buffer.from(await resp.arrayBuffer());
const contentType = resp.headers.get('content-type') || 'image/webp';
return { buffer, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function proxyOriginal(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
try {
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const buffer = Buffer.from(await resp.arrayBuffer());
const contentType = resp.headers.get('content-type') || 'image/jpeg';
return { buffer, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
// ── Albums ──────────────────────────────────────────────────────────────────
export async function listAlbums(
userId: number
): Promise<{ albums?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
const albums = (await resp.json() as any[]).map((a: any) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount || 0,
startDate: a.startDate,
endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId,
}));
return { albums };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export function listAlbumLinks(tripId: string) {
return db.prepare(`
SELECT tal.*, u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
ORDER BY tal.created_at ASC
`).all(tripId);
}
export function createAlbumLink(
tripId: string,
userId: number,
albumId: string,
albumName: string
): { 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 || '');
return { success: true };
} catch {
return { success: false, error: 'Album already linked' };
}
}
export function deleteAlbumLink(linkId: string, tripId: string, userId: number) {
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
}
export async function syncAlbumAssets(
tripId: string,
linkId: string,
userId: number
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
const selection: Selection = {
provider: 'immich',
asset_ids: assets.map((a: any) => a.id),
};
const result = await addTripPhotos(tripId, userId, true, [selection]);
if ('error' in result) return { error: result.error.message, status: result.error.status };
updateSyncTimeForAlbumLink(linkId);
return { success: true, added: result.data.added, total: assets.length };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
@@ -0,0 +1,496 @@
import { Response } from 'express';
import { db } from '../../db/database';
import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { addTripPhotos } from './unifiedService';
import {
getAlbumIdFromLink,
updateSyncTimeForAlbumLink,
Selection,
ServiceResult,
fail,
success,
handleServiceResult,
pipeAsset,
AlbumsList,
AssetsList,
StatusResult,
SyncAlbumResult,
AssetInfo
} from './helpersService';
const SYNOLOGY_PROVIDER = 'synologyphotos';
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
interface SynologyUserRecord {
synology_url?: string | null;
synology_username?: string | null;
synology_password?: string | null;
synology_sid?: string | null;
};
interface SynologyCredentials {
synology_url: string;
synology_username: string;
synology_password: string;
}
interface SynologySettings {
synology_url: string;
synology_username: string;
connected: boolean;
}
interface ApiCallParams {
api: string;
method: string;
version?: number;
[key: string]: unknown;
}
interface SynologyApiResponse<T> {
success: boolean;
data?: T;
error?: { code: number };
}
interface SynologyPhotoItem {
id?: string | number;
filename?: string;
filesize?: number;
time?: number;
item_count?: number;
name?: string;
additional?: {
thumbnail?: { cache_key?: string };
address?: { city?: string; country?: string; state?: string };
resolution?: { width?: number; height?: number };
exif?: {
camera?: string;
lens?: string;
focal_length?: string | number;
aperture?: string | number;
exposure_time?: string | number;
iso?: string | number;
};
gps?: { latitude?: number; longitude?: number };
orientation?: number;
description?: string;
};
}
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
try {
if (!columns) return null;
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
if (!row) {
return fail('User not found', 404);
}
const filtered: SynologyUserRecord = {};
for (const column of columns) {
filtered[column] = row[column];
}
if (!filtered) {
return fail('Failed to read Synology user data', 500);
}
return success(filtered);
} catch {
return fail('Failed to read Synology user data', 500);
}
}
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
return success({
synology_url: user.data.synology_url,
synology_username: user.data.synology_username,
synology_password: decrypt_api_key(user.data.synology_password) as string,
});
}
function _buildSynologyEndpoint(url: string): string {
const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`;
}
function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
}
return body;
}
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
const endpoint = _buildSynologyEndpoint(url);
const resp = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body,
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
return fail('Synology API request failed with status ' + resp.status, resp.status);
}
const response = await resp.json() as SynologyApiResponse<T>;
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
}
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
const body = new URLSearchParams({
api: 'SYNO.API.Auth',
method: 'login',
version: '3',
account: username,
passwd: password,
});
const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
if (!result.success) {
return result as ServiceResult<string>;
}
if (!result.data.sid) {
return fail('Failed to get session ID from Synology', 500);
}
return success(result.data.sid);
}
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
const creds = _getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<T>;
}
const session = await _getSynologySession(userId);
if (!session.success || !session.data) {
return session as ServiceResult<T>;
}
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body);
if ('error' in result && result.error.status === 119) {
_clearSynologySID(userId);
const retrySession = await _getSynologySession(userId);
if (!retrySession.success || !retrySession.data) {
return session as ServiceResult<T>;
}
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
}
return result;
}
function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo {
const address = item.additional?.address || {};
const exif = item.additional?.exif || {};
const gps = item.additional?.gps || {};
return {
id: String(item.additional?.thumbnail?.cache_key || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
city: address.city || null,
country: address.country || null,
state: address.state || null,
camera: exif.camera || null,
lens: exif.lens || null,
focalLength: exif.focal_length || null,
aperture: exif.aperture || null,
shutter: exif.exposure_time || null,
iso: exif.iso || null,
lat: gps.latitude || null,
lng: gps.longitude || null,
orientation: item.additional?.orientation || null,
description: item.additional?.description || null,
width: item.additional?.resolution?.width || null,
height: item.additional?.resolution?.height || null,
fileSize: item.filesize || null,
fileName: item.filename || null,
};
}
function _cacheSynologySID(userId: number, sid: string): void {
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
}
function _clearSynologySID(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
}
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
const id = rawId.split('_')[0];
return { id, cacheKey: rawId, assetId: rawId };
}
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
if (cachedSid.success && cachedSid.data?.synology_sid) {
return success(cachedSid.data.synology_sid);
}
const creds = _getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<string>;
}
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
if (!resp.success) {
return resp as ServiceResult<string>;
}
_cacheSynologySID(userId, resp.data);
return success(resp.data);
}
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
const creds = _getSynologyCredentials(userId);
if (!creds.success) return creds as ServiceResult<SynologySettings>;
const session = await _getSynologySession(userId);
return success({
synology_url: creds.data.synology_url || '',
synology_username: creds.data.synology_username || '',
connected: session.success,
});
}
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const result = _readSynologyUser(userId, ['synology_password'])
if (!result.success) return result as ServiceResult<string>;
const existingEncryptedPassword = result.data?.synology_password || null;
if (!synologyPassword && !existingEncryptedPassword) {
return fail('No stored password found. Please provide a password to save settings.', 400);
}
try {
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
synologyUrl,
synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
userId,
);
} catch {
return fail('Failed to update Synology settings', 500);
}
_clearSynologySID(userId);
return success("settings updated");
}
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
const sid = await _getSynologySession(userId);
if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message });
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
try {
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } });
} catch (err: unknown) {
return success({ connected: true, user: { name: 'unknown user' } });
}
}
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
if ('error' in resp) {
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
}
return success({ connected: true, user: { name: synologyUsername } });
}
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Album',
method: 'list',
version: 4,
offset: 0,
limit: 100,
});
if (!result.success) return result as ServiceResult<AlbumsList>;
const albums = (result.data.list || []).map((album: any) => ({
id: String(album.id),
albumName: album.name || '',
assetCount: album.item_count || 0,
}));
return success({ albums });
}
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<ServiceResult<SyncAlbumResult>> {
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000;
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Item',
method: 'list',
version: 1,
album_id: Number(response.data),
offset,
limit: pageSize,
additional: ['thumbnail'],
});
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const selection: Selection = {
provider: SYNOLOGY_PROVIDER,
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
};
updateSyncTimeForAlbumLink(linkId);
const result = await addTripPhotos(tripId, userId, true, [selection]);
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
return success({ added: result.data.added, total: allItems.length });
}
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<ServiceResult<AssetsList>> {
const params: ApiCallParams = {
api: 'SYNO.Foto.Search.Search',
method: 'list_item',
version: 1,
offset,
limit,
keyword: '.',
additional: ['thumbnail', 'address'],
};
if (from || to) {
if (from) {
params.start_time = Math.floor(new Date(from).getTime() / 1000);
}
if (to) {
params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
}
}
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
if (!result.success) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>;
const allItems = result.data.list || [];
const total = allItems.length;
const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item));
return success({
assets,
total,
hasMore: total === limit,
});
}
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<ServiceResult<AssetInfo>> {
const parsedId = _splitPackedSynologyId(photoId);
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
api: 'SYNO.Foto.Browse.Item',
method: 'get',
version: 5,
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
});
if (!result.success) return result as ServiceResult<AssetInfo>;
const metadata = result.data.list?.[0];
if (!metadata) return fail('Photo not found', 404);
const normalized = _normalizeSynologyPhotoInfo(metadata);
normalized.id = photoId;
return success(normalized);
}
export async function streamSynologyAsset(
response: Response,
userId: number,
targetUserId: number,
photoId: string,
kind: 'thumbnail' | 'original',
size?: string,
): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId);
const synology_credentials = _getSynologyCredentials(targetUserId);
if (!synology_credentials.success) {
handleServiceResult(response, synology_credentials);
return;
}
const sid = await _getSynologySession(targetUserId);
if (!sid.success) {
handleServiceResult(response, sid);
return;
}
if (!sid.data) {
handleServiceResult(response, fail('Failed to retrieve session ID', 500));
return;
}
const params = kind === 'thumbnail'
? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: size,
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
: new URLSearchParams({
api: 'SYNO.Foto.Download',
method: 'download',
version: '2',
cache_key: parsedId.cacheKey,
unit_id: `[${parsedId.id}]`,
_sid: sid.data,
});
const url = `${_buildSynologyEndpoint(synology_credentials.data.synology_url)}?${params.toString()}`;
await pipeAsset(url, response)
}
@@ -0,0 +1,249 @@
import { db, canAccessTrip } from '../../db/database';
import { notifyTripMembers } from '../notifications';
import { broadcast } from '../../websocket';
import {
ServiceResult,
fail,
success,
mapDbError,
Selection,
} from './helpersService';
export function listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
const photos = db.prepare(`
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
ORDER BY tp.added_at ASC
`).all(tripId, userId) as any[];
return success(photos);
} catch (error) {
return mapDbError(error, 'Failed to list trip photos');
}
}
export function listTripAlbumLinks(tripId: string, userId: number): ServiceResult<any[]> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
const links = db.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
ORDER BY tal.created_at ASC
`).all(tripId);
return success(links);
} catch (error) {
return mapDbError(error, 'Failed to list trip album links');
}
}
//-----------------------------------------------
// managing photos in trip
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean {
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, shared ? 1 : 0);
return result.changes > 0;
}
export async function addTripPhotos(
tripId: string,
userId: number,
shared: boolean,
selections: Selection[],
sid?: string,
): Promise<ServiceResult<{ added: number; shared: boolean }>> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
if (selections.length === 0) {
return fail('No photos selected', 400);
}
try {
let added = 0;
for (const selection of selections) {
for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim();
if (!assetId) continue;
if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared)) {
added++;
}
}
}
await _notifySharedTripPhotos(tripId, userId, added);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success({ added, shared });
} catch (error) {
return mapDbError(error, 'Failed to add trip photos');
}
}
export async function setTripPhotoSharing(
tripId: string,
userId: number,
provider: string,
assetId: string,
shared: boolean,
sid?: string,
): Promise<ServiceResult<true>> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
await _notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to update photo sharing');
}
}
export function removeTripPhoto(
tripId: string,
userId: number,
provider: string,
assetId: string,
sid?: string,
): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(tripId, userId, assetId, provider);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove trip photo');
}
}
// ----------------------------------------------
// managing album links in trip
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const provider = String(providerRaw || '').toLowerCase();
const albumId = String(albumIdRaw || '').trim();
const albumName = String(albumNameRaw || '').trim();
if (!provider) {
return fail('provider is required', 400);
}
if (!albumId) {
return fail('album_id required', 400);
}
try {
const result = db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName);
if (result.changes === 0) {
return fail('Album already linked', 409);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to link album');
}
}
export function removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove album link');
}
}
//-----------------------------------------------
// notifications helper
async function _notifySharedTripPhotos(
tripId: string,
actorUserId: number,
added: number,
): Promise<ServiceResult<void>> {
if (added <= 0) return fail('No photos shared, skipping notifications', 200);
try {
const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null };
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', {
trip: tripInfo?.title || 'Untitled',
actor: actorRow?.username || 'Unknown',
count: String(added),
});
return success(undefined);
} catch {
return fail('Failed to send notifications', 500);
}
}