Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/src/components/Settings/PhotoProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface PhotoProviderAddon {
name: string
type: string
enabled: boolean
tooltip?: string
config?: Record<string, unknown>
fields?: ProviderField[]
}
Expand Down Expand Up @@ -194,7 +195,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get)

return (
<Section key={provider.id} title={provider.name || provider.id} icon={Camera}>
<Section key={provider.id} title={provider.name || provider.id} icon={Camera} tooltip={provider.tooltip}>
<div className="space-y-3">
{fields.map(field => (
<div key={`${provider.id}-${field.key}`}>
Expand Down
48 changes: 46 additions & 2 deletions client/src/components/Settings/Section.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,62 @@
import React from 'react'
import React, { useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Info } from 'lucide-react'

interface SectionProps {
title: string
icon: LucideIcon
children: React.ReactNode
tooltip?: string
}

export default function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
export default function Section({ title, icon: Icon, children, tooltip }: SectionProps): React.ReactElement {
const [showTooltip, setShowTooltip] = useState(false)

return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', marginBottom: 24 }}>
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{/* Spacer to push tooltip to the end if needed */}
<div style={{ flex: 1 }} />
{tooltip && (
<span style={{ position: 'relative', display: 'inline-block' }}>
<span
style={{
visibility: showTooltip ? 'visible' : 'hidden',
opacity: showTooltip ? 1 : 0,
width: 'max-content',
background: 'rgba(40,40,40,0.95)',
color: '#fff',
textAlign: 'left',
borderRadius: 6,
padding: '7px 12px',
position: 'absolute',
zIndex: 10,
right: '110%',
top: '50%',
transform: 'translateY(-50%)',
fontSize: 13,
pointerEvents: showTooltip ? 'auto' : 'none',
transition: 'opacity 0.15s',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
marginRight: 8,
}}
className="info-tooltip-message"
>
{tooltip}
</span>
<Info
className="w-5 h-5"
style={{ color: 'var(--text-secondary)', verticalAlign: 'middle', cursor: 'pointer' }}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
tabIndex={0}
onFocus={() => setShowTooltip(true)}
onBlur={() => setShowTooltip(false)}
/>
</span>
)}
</div>
<div className="p-6 space-y-4">
{children}
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.providerApiKey': 'API Key',
'memories.providerUsername': 'Username',
'memories.providerPassword': 'Password',
'memories.providerOTP': 'Your MFA code',
'memories.testConnection': 'Test connection',
'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected',
Expand Down
6 changes: 4 additions & 2 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import shareRoutes from './routes/share';
import { mcpHandler } from './mcp';
import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService';
import { ToolAnnotationsSchema } from '@modelcontextprotocol/sdk/types.js';

export function createApp(): express.Application {
const app = express();
Expand Down Expand Up @@ -198,11 +199,11 @@ export function createApp(): express.Application {
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = db.prepare(`
SELECT id, name, icon, enabled, sort_order
SELECT *
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
`).all() as Array<{ id: string; name: string; icon: string; tooltip: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
Expand Down Expand Up @@ -235,6 +236,7 @@ export function createApp(): express.Application {
name: p.name,
type: 'photo_provider',
icon: p.icon,
tooltip: p.tooltip,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
Expand Down
27 changes: 27 additions & 0 deletions server/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,33 @@ function runMigrations(db: Database.Database): void {
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
}
},
// Migration 75: Normalize existing Synology URLs to include /photo and no trailing slash
() => {
const usersWithSynologyUrl = db
.prepare("SELECT id, synology_url FROM users WHERE synology_url IS NOT NULL AND TRIM(synology_url) != ''")
.all() as Array<{ id: number; synology_url: string }>;

const updateSynologyUrl = db.prepare('UPDATE users SET synology_url = ? WHERE id = ?');

for (const user of usersWithSynologyUrl) {
let normalizedUrl = user.synology_url.trim().replace(/\/+$/, '');

if (!/\/photo$/i.test(normalizedUrl)) {
normalizedUrl = `${normalizedUrl}/photo`;
}

normalizedUrl = normalizedUrl.replace(/\/+$/, '');

if (normalizedUrl !== user.synology_url) {
updateSynologyUrl.run(normalizedUrl, user.id);
}
}
},
() => {
try {db.exec("alter table photo_providers add column tooltip TEXT default ''");} catch (err) {}
try {db.exec("UPDATE photo_providers SET tooltip = 'Supports only DSM version 6.2+' WHERE id = 'synologyphotos'");} catch (err) {}
}

];

if (currentVersion < migrations.length) {
Expand Down
1 change: 1 addition & 0 deletions server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ function createTables(db: Database.Database): void {
name TEXT NOT NULL,
description TEXT,
icon TEXT DEFAULT 'Image',
tooltip TEXT DEFAULT '',
enabled INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
);
Expand Down
7 changes: 5 additions & 2 deletions server/src/db/seeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function seedAddons(db: Database.Database): void {
name: 'Immich',
description: 'Immich photo provider',
icon: 'Image',
tooltip: '',
enabled: 0,
sort_order: 0,
},
Expand All @@ -107,19 +108,21 @@ function seedAddons(db: Database.Database): void {
name: 'Synology Photos',
description: 'Synology Photos integration with separate account settings',
icon: 'Image',
tooltip: 'Supports only DSM version 6.2+',
enabled: 0,
sort_order: 1,
},
];
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, tooltip, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.tooltip, p.enabled, p.sort_order);

const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
{ provider_id: 'synologyphotos', field_key: 'synology_otp', label: 'providerOTP', input_type: 'number', placeholder: 'OTP', required: 0, secret: 0, settings_key: null, payload_key: 'synology_otp', sort_order: 3 },
];
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
for (const f of providerFields) {
Expand Down
6 changes: 4 additions & 2 deletions server/src/routes/memories/synology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);

if (!synology_url || !synology_username) {
handleServiceResult(res, fail('URL and username are required', 400));
}
else {
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password));
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_otp));
}
});

Expand All @@ -55,6 +56,7 @@ router.post('/test', authenticate, async (req: Request, res: Response) => {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);

if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
Expand All @@ -64,7 +66,7 @@ router.post('/test', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
}
else{
handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password));
handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password, synology_otp));
}
});

Expand Down
63 changes: 53 additions & 10 deletions server/src/services/memories/synologyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,36 @@ import {
} from './helpersService';

const SYNOLOGY_PROVIDER = 'synologyphotos';
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';

const ERROR_MESSAGES: Record<number, string> = {
101: 'Missing API, method, or version parameter.',
102: 'Requested API does not exist.',
103: 'Requested method does not exist.',
104: 'Requested API version is not supported.',
105: 'Insufficient privilege.',
106: 'Connection timeout.',
107: 'Multiple logins blocked from this IP.',
117: 'Manager privilege required.',
119: 'Session is invalid or expired.',
400: 'Authentication failed.',
401: 'Session expired or account disabled.',
402: 'No permission to use this account.',
403: 'Two-factor authentication is required.',
404: 'Two-factor authentication failed.',
406: 'Two-factor authentication is enforced for this account.',
407: 'Maximum attempts reached.',
408: 'Password expired.',
409: 'Remote password expired.',
410: 'Password must be changed before login.',
412: 'Guest account cannot log in.',
413: 'OTP system files are corrupted.',
414: 'Unable to log in.',
416: 'Unable to log in.',
417: 'OTP system is full.',
498: 'System is upgrading.',
499: 'System is not ready.',
};

interface SynologyUserRecord {
synology_url?: string | null;
Expand Down Expand Up @@ -144,7 +173,7 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
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);
return response.success ? success(response.data) : fail(ERROR_MESSAGES[response.error.code] || 'Synology API request failed', response.error.code);
} catch (error) {
if (error instanceof SsrfBlockedError) {
return fail(error.message, 400);
Expand All @@ -153,14 +182,19 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
}
}

async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
async function _loginToSynology(url: string, username: string, password: string, otp?: string): Promise<ServiceResult<string>> {
const body = new URLSearchParams({
api: 'SYNO.API.Auth',
method: 'login',
version: '3',
version: '6',
account: username,
passwd: password,
format: 'sid',
client: 'browser'
});
if (otp) {
body.append('otp_code', otp);
}

const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
if (!result.success) {
Expand Down Expand Up @@ -276,7 +310,7 @@ export async function getSynologySettings(userId: number): Promise<ServiceResult
});
}

export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> {
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologyOtp?: string): Promise<ServiceResult<string>> {

const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
Expand All @@ -303,6 +337,15 @@ export async function updateSynologySettings(userId: number, synologyUrl: string
}

_clearSynologySID(userId);
if (synologyOtp && synologyOtp.trim()) {
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword || decrypt_api_key(existingEncryptedPassword || '') || '', synologyOtp);
if ('error' in resp) {
return fail("Failed to connect to Synology with provided OTP: " + resp.error.message, resp.error.status);
}
const encrypted = encrypt_api_key(resp.data);
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId);
return success(resp.data);
}
return success("settings updated");
}

Expand All @@ -318,16 +361,16 @@ export async function getSynologyStatus(userId: number): Promise<ServiceResult<S
}
}

export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> {
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string): Promise<ServiceResult<StatusResult>> {

const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}

const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, synologyOtp);
if ('error' in resp) {
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
return success({ connected: false, error: `${resp.error.status}: ${resp.error.message}` },);
}
return success({ connected: true, user: { name: synologyUsername } });
}
Expand All @@ -338,7 +381,7 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
method: 'list',
version: 4,
offset: 0,
limit: 100,
limit: 1000,
});
if (!result.success) return result as ServiceResult<AlbumsList>;

Expand Down Expand Up @@ -393,7 +436,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
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>> {
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 1000): Promise<ServiceResult<AssetsList>> {
const params: ApiCallParams = {
api: 'SYNO.Foto.Search.Search',
method: 'list_item',
Expand Down