diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index baed3cd3..ec14ab25 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -714,6 +714,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa return (
+ {/* Disconnected banner — shown when photos exist but provider is unreachable */} + {!connected && allVisible.length > 0 && enabledProviders.length > 0 && ( +
+ + + {t('memories.providerDisconnectedBanner', { + provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ') + })} + +
+ )} + {/* Header */}
diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx index 4c00e292..845d1db4 100644 --- a/client/src/components/Settings/PhotoProvidersSection.tsx +++ b/client/src/components/Settings/PhotoProvidersSection.tsx @@ -11,6 +11,7 @@ interface ProviderField { label: string input_type: string placeholder?: string | null + hint?: string | null required: boolean secret: boolean settings_key?: string | null @@ -71,6 +72,10 @@ export default function PhotoProvidersSection(): React.ReactElement { const payload: Record = {} for (const field of getProviderFields(provider)) { const payloadKey = field.payload_key || field.settings_key || field.key + if (field.input_type === 'checkbox') { + payload[payloadKey] = values[field.key] === 'true' + continue + } const value = (values[field.key] || '').trim() if (field.secret && !value) continue payload[payloadKey] = value @@ -102,6 +107,18 @@ export default function PhotoProvidersSection(): React.ReactElement { const cfg = getProviderConfig(provider) const fields = getProviderFields(provider) + // Seed checkbox defaults before the async settings load resolves + const checkboxDefaults: Record = {} + for (const field of fields) { + if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false' + } + if (Object.keys(checkboxDefaults).length > 0) { + setProviderValues(prev => ({ + ...prev, + [provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) }, + })) + } + if (cfg.settings_get) { apiClient.get(cfg.settings_get).then(res => { if (isCancelled) return @@ -112,7 +129,13 @@ export default function PhotoProvidersSection(): React.ReactElement { if (field.secret) continue const sourceKey = field.settings_key || field.payload_key || field.key const rawValue = (res.data as Record)[sourceKey] - nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : '' + if (rawValue != null) { + nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue) + } else if (field.input_type === 'checkbox') { + nextValues[field.key] = 'false' + } else { + nextValues[field.key] = '' + } } setProviderValues(prev => ({ ...prev, @@ -198,14 +221,31 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => (
- - handleProviderFieldChange(provider.id, field.key, e.target.value)} - placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" - /> + {field.input_type === 'checkbox' ? ( + + ) : ( + <> + + handleProviderFieldChange(provider.id, field.key, e.target.value)} + placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} + className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" + /> + {field.hint && ( +

{t(`memories.${field.hint}`)}

+ )} + + )}
))}
@@ -228,11 +268,16 @@ export default function PhotoProvidersSection(): React.ReactElement { : } {t('memories.testConnection')} - {connected && ( + {connected ? ( {t('memories.connected')} + ) : ( + + + {t('memories.disconnected')} + )}
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 5791f64c..df59b102 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1438,6 +1438,7 @@ const ar: Record = { 'memories.title': 'صور', 'memories.notConnected': 'Immich غير متصل', 'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.', + 'memories.notConnectedMultipleHint': 'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.', 'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.', 'memories.noPhotos': 'لم يتم العثور على صور', 'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.', @@ -1448,24 +1449,35 @@ const ar: Record = { 'memories.reviewTitle': 'مراجعة صورك', 'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.', 'memories.shareCount': 'مشاركة {count} صور', + 'memories.providerUrl': 'عنوان URL للخادم', + 'memories.providerApiKey': 'مفتاح API', + 'memories.providerUsername': 'اسم المستخدم', + 'memories.providerPassword': 'كلمة المرور', + 'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)', + 'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL', + 'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo', 'memories.testConnection': 'اختبار الاتصال', 'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.connected': 'متصل', 'memories.disconnected': 'غير متصل', 'memories.connectionSuccess': 'تم الاتصال بـ Immich', 'memories.connectionError': 'تعذر الاتصال بـ Immich', - 'memories.saved': 'تم حفظ إعدادات Immich', + 'memories.saved': 'تم حفظ إعدادات {provider_name}', + 'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.', + 'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}', 'memories.oldest': 'الأقدم أولاً', 'memories.newest': 'الأحدث أولاً', 'memories.allLocations': 'جميع المواقع', 'memories.addPhotos': 'إضافة صور', 'memories.linkAlbum': 'ربط ألبوم', 'memories.selectAlbum': 'اختيار ألبوم Immich', + 'memories.selectAlbumMultiple': 'اختيار ألبوم', 'memories.noAlbums': 'لم يتم العثور على ألبومات', 'memories.syncAlbum': 'مزامنة الألبوم', 'memories.unlinkAlbum': 'إلغاء الربط', 'memories.photos': 'صور', 'memories.selectPhotos': 'اختيار صور من Immich', + 'memories.selectPhotosMultiple': 'اختيار الصور', 'memories.selectHint': 'انقر على الصور لتحديدها.', 'memories.selected': 'محدد', 'memories.addSelected': 'إضافة {count} صور', @@ -1622,6 +1634,8 @@ const ar: Record = { 'notifications.markUnread': 'تحديد كغير مقروء', 'notifications.delete': 'حذف', 'notifications.system': 'النظام', + 'notifications.synologySessionCleared.title': 'تم قطع اتصال Synology Photos', + 'notifications.synologySessionCleared.text': 'تغير خادمك أو حسابك — انتقل إلى الإعدادات لاختبار اتصالك مرة أخرى.', 'memories.error.loadAlbums': 'فشل تحميل الألبومات', 'memories.error.linkAlbum': 'فشل ربط الألبوم', 'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 35cc1c1a..0e4d7c6d 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1477,6 +1477,7 @@ const br: Record = { 'memories.title': 'Fotos', 'memories.notConnected': 'Immich não conectado', 'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.', + 'memories.notConnectedMultipleHint': 'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.', 'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.', 'memories.noPhotos': 'Nenhuma foto encontrada', 'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.', @@ -1487,21 +1488,32 @@ const br: Record = { 'memories.reviewTitle': 'Revise suas fotos', 'memories.reviewHint': 'Clique nas fotos para excluí-las do compartilhamento.', 'memories.shareCount': 'Compartilhar {count} fotos', + 'memories.providerUrl': 'URL do servidor', + 'memories.providerApiKey': 'Chave de API', + 'memories.providerUsername': 'Nome de usuário', + 'memories.providerPassword': 'Senha', + 'memories.providerOTP': 'Código MFA (se habilitado)', + 'memories.skipSSLVerification': 'Pular verificação de certificado SSL', + 'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo', 'memories.testConnection': 'Testar conexão', 'memories.testFirst': 'Teste a conexão primeiro', 'memories.connected': 'Conectado', 'memories.disconnected': 'Não conectado', 'memories.connectionSuccess': 'Conectado ao Immich', 'memories.connectionError': 'Não foi possível conectar ao Immich', - 'memories.saved': 'Configurações do Immich salvas', + 'memories.saved': 'Configurações do {provider_name} salvas', + 'memories.providerDisconnectedBanner': 'Sua conexão com {provider_name} foi perdida. Reconecte nas Configurações para ver as fotos.', + 'memories.saveError': 'Não foi possível salvar as configurações de {provider_name}', 'memories.addPhotos': 'Adicionar fotos', 'memories.linkAlbum': 'Vincular álbum', 'memories.selectAlbum': 'Selecionar álbum do Immich', + 'memories.selectAlbumMultiple': 'Selecionar álbum', 'memories.noAlbums': 'Nenhum álbum encontrado', 'memories.syncAlbum': 'Sincronizar álbum', 'memories.unlinkAlbum': 'Desvincular', 'memories.photos': 'fotos', 'memories.selectPhotos': 'Selecionar fotos do Immich', + 'memories.selectPhotosMultiple': 'Selecionar fotos', 'memories.selectHint': 'Toque nas fotos para selecioná-las.', 'memories.selected': 'selecionadas', 'memories.addSelected': 'Adicionar {count} fotos', @@ -1617,6 +1629,8 @@ const br: Record = { 'notifications.markUnread': 'Marcar como não lido', 'notifications.delete': 'Excluir', 'notifications.system': 'Sistema', + 'notifications.synologySessionCleared.title': 'Synology Photos desconectado', + 'notifications.synologySessionCleared.text': 'Seu servidor ou conta foi alterado — vá para Configurações para testar sua conexão novamente.', 'memories.error.loadAlbums': 'Falha ao carregar álbuns', 'memories.error.linkAlbum': 'Falha ao vincular álbum', 'memories.error.unlinkAlbum': 'Falha ao desvincular álbum', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 43a01d9b..fb8f5ec7 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1436,6 +1436,7 @@ const cs: Record = { 'memories.title': 'Fotky', 'memories.notConnected': 'Immich není připojen', 'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.', + 'memories.notConnectedMultipleHint': 'Pro přidání fotek k tomuto výletu připojte v Nastavení jednoho z těchto poskytovatelů fotek: {provider_names}.', 'memories.noDates': 'Přidejte data k cestě pro načtení fotek.', 'memories.noPhotos': 'Nenalezeny žádné fotky', 'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.', @@ -1446,21 +1447,32 @@ const cs: Record = { 'memories.reviewTitle': 'Zkontrolujte své fotky', 'memories.reviewHint': 'Klikněte na fotky pro vyloučení ze sdílení.', 'memories.shareCount': 'Sdílet {count} fotek', + 'memories.providerUrl': 'URL serveru', + 'memories.providerApiKey': 'API klíč', + 'memories.providerUsername': 'Uživatelské jméno', + 'memories.providerPassword': 'Heslo', + 'memories.providerOTP': 'MFA kód (pokud je povoleno)', + 'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu', + 'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo', 'memories.testConnection': 'Otestovat připojení', 'memories.testFirst': 'Nejprve otestujte připojení', 'memories.connected': 'Připojeno', 'memories.disconnected': 'Nepřipojeno', 'memories.connectionSuccess': 'Připojeno k Immich', 'memories.connectionError': 'Nepodařilo se připojit k Immich', - 'memories.saved': 'Nastavení Immich uloženo', + 'memories.saved': 'Nastavení {provider_name} uloženo', + 'memories.providerDisconnectedBanner': 'Vaše připojení k {provider_name} bylo ztraceno. Obnovte připojení v Nastavení pro zobrazení fotek.', + 'memories.saveError': 'Nepodařilo se uložit nastavení {provider_name}', 'memories.addPhotos': 'Přidat fotky', 'memories.linkAlbum': 'Propojit album', 'memories.selectAlbum': 'Vybrat album z Immich', + 'memories.selectAlbumMultiple': 'Vybrat album', 'memories.noAlbums': 'Žádná alba nenalezena', 'memories.syncAlbum': 'Synchronizovat album', 'memories.unlinkAlbum': 'Odpojit', 'memories.photos': 'fotek', 'memories.selectPhotos': 'Vybrat fotky z Immich', + 'memories.selectPhotosMultiple': 'Vybrat fotky', 'memories.selectHint': 'Klepněte na fotky pro jejich výběr.', 'memories.selected': 'vybráno', 'memories.addSelected': 'Přidat {count} fotek', @@ -1620,6 +1632,8 @@ const cs: Record = { 'notifications.markUnread': 'Označit jako nepřečtené', 'notifications.delete': 'Smazat', 'notifications.system': 'Systém', + 'notifications.synologySessionCleared.title': 'Synology Photos odpojeno', + 'notifications.synologySessionCleared.text': 'Váš server nebo účet se změnil — přejděte do Nastavení a znovu otestujte připojení.', 'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.', 'atlas.searchCountry': 'Hledat zemi...', 'memories.error.loadAlbums': 'Načtení alb se nezdařilo', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 86c88395..b31fb23d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1436,6 +1436,7 @@ const de: Record = { 'memories.title': 'Fotos', 'memories.notConnected': 'Immich nicht verbunden', 'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.', + 'memories.notConnectedMultipleHint': 'Verbinde einen dieser Fotoanbieter: {provider_names} in den Einstellungen, um Fotos zu dieser Reise hinzufügen zu können.', 'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.', 'memories.noPhotos': 'Keine Fotos gefunden', 'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.', @@ -1446,21 +1447,32 @@ const de: Record = { 'memories.reviewTitle': 'Deine Fotos prüfen', 'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.', 'memories.shareCount': '{count} Fotos teilen', + 'memories.providerUrl': 'Server-URL', + 'memories.providerApiKey': 'API-Schlüssel', + 'memories.providerUsername': 'Benutzername', + 'memories.providerPassword': 'Passwort', + 'memories.providerOTP': 'MFA-Code (falls aktiviert)', + 'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen', + 'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo', 'memories.testConnection': 'Verbindung testen', 'memories.testFirst': 'Verbindung zuerst testen', 'memories.connected': 'Verbunden', 'memories.disconnected': 'Nicht verbunden', 'memories.connectionSuccess': 'Verbindung zu Immich hergestellt', 'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen', - 'memories.saved': 'Immich-Einstellungen gespeichert', + 'memories.saved': '{provider_name}-Einstellungen gespeichert', + 'memories.providerDisconnectedBanner': 'Deine {provider_name}-Verbindung wurde getrennt. Verbinde erneut in den Einstellungen, um Fotos anzuzeigen.', + 'memories.saveError': '{provider_name}-Einstellungen konnten nicht gespeichert werden', 'memories.addPhotos': 'Fotos hinzufügen', 'memories.linkAlbum': 'Album verknüpfen', 'memories.selectAlbum': 'Immich-Album auswählen', + 'memories.selectAlbumMultiple': 'Album auswählen', 'memories.noAlbums': 'Keine Alben gefunden', 'memories.syncAlbum': 'Album synchronisieren', 'memories.unlinkAlbum': 'Album trennen', 'memories.photos': 'Fotos', 'memories.selectPhotos': 'Fotos aus Immich auswählen', + 'memories.selectPhotosMultiple': 'Fotos auswählen', 'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.', 'memories.selected': 'ausgewählt', 'memories.addSelected': '{count} Fotos hinzufügen', @@ -1620,6 +1632,8 @@ const de: Record = { 'notifications.markUnread': 'Als ungelesen markieren', 'notifications.delete': 'Löschen', 'notifications.system': 'System', + 'notifications.synologySessionCleared.title': 'Synology Photos getrennt', + 'notifications.synologySessionCleared.text': 'Dein Server oder Konto hat sich geändert — gehe zu Einstellungen, um deine Verbindung erneut zu testen.', 'memories.error.loadAlbums': 'Alben konnten nicht geladen werden', 'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden', 'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 27ddaad6..37c6138b 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1475,6 +1475,9 @@ const en: Record = { 'memories.providerApiKey': 'API Key', 'memories.providerUsername': 'Username', 'memories.providerPassword': 'Password', + 'memories.providerOTP': 'MFA code (if enabled)', + 'memories.skipSSLVerification': 'Skip SSL certificate verification', + 'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo', 'memories.testConnection': 'Test connection', 'memories.testFirst': 'Test connection first', 'memories.connected': 'Connected', @@ -1482,6 +1485,7 @@ const en: Record = { 'memories.connectionSuccess': 'Connected to {provider_name}', 'memories.connectionError': 'Could not connect to {provider_name}', 'memories.saved': '{provider_name} settings saved', + 'memories.providerDisconnectedBanner': 'Your {provider_name} connection is lost. Reconnect in Settings to view photos.', 'memories.saveError': 'Could not save {provider_name} settings', //------------------------ 'memories.addPhotos': 'Add photos', @@ -1664,6 +1668,8 @@ const en: Record = { 'notifications.markUnread': 'Mark as unread', 'notifications.delete': 'Delete', 'notifications.system': 'System', + 'notifications.synologySessionCleared.title': 'Synology Photos disconnected', + 'notifications.synologySessionCleared.text': 'Your server or account changed — go to Settings to test your connection again.', // Notification test keys (dev only) 'notifications.versionAvailable.title': 'Update Available', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index ea643e50..700122a6 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1387,6 +1387,7 @@ const es: Record = { 'memories.title': 'Fotos', 'memories.notConnected': 'Immich no conectado', 'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.', + 'memories.notConnectedMultipleHint': 'Conecta alguno de estos proveedores de fotos: {provider_names} en Configuración para poder añadir fotos a este viaje.', 'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.', 'memories.noPhotos': 'No se encontraron fotos', 'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.', @@ -1397,24 +1398,35 @@ const es: Record = { 'memories.reviewTitle': 'Revisar tus fotos', 'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.', 'memories.shareCount': 'Compartir {count} fotos', + 'memories.providerUrl': 'URL del servidor', + 'memories.providerApiKey': 'Clave API', + 'memories.providerUsername': 'Nombre de usuario', + 'memories.providerPassword': 'Contraseña', + 'memories.providerOTP': 'Código MFA (si está habilitado)', + 'memories.skipSSLVerification': 'Omitir verificación del certificado SSL', + 'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo', 'memories.testConnection': 'Probar conexión', 'memories.testFirst': 'Probar conexión primero', 'memories.connected': 'Conectado', 'memories.disconnected': 'No conectado', 'memories.connectionSuccess': 'Conectado a Immich', 'memories.connectionError': 'No se pudo conectar a Immich', - 'memories.saved': 'Configuración de Immich guardada', + 'memories.saved': 'Configuración de {provider_name} guardada', + 'memories.providerDisconnectedBanner': 'Se perdió la conexión con {provider_name}. Vuelve a conectar en Configuración para ver las fotos.', + 'memories.saveError': 'No se pudieron guardar los ajustes de {provider_name}', 'memories.oldest': 'Más antiguas', 'memories.newest': 'Más recientes', 'memories.allLocations': 'Todas las ubicaciones', 'memories.addPhotos': 'Añadir fotos', 'memories.linkAlbum': 'Vincular álbum', 'memories.selectAlbum': 'Seleccionar álbum de Immich', + 'memories.selectAlbumMultiple': 'Seleccionar álbum', 'memories.noAlbums': 'No se encontraron álbumes', 'memories.syncAlbum': 'Sincronizar álbum', 'memories.unlinkAlbum': 'Desvincular', 'memories.photos': 'fotos', 'memories.selectPhotos': 'Seleccionar fotos de Immich', + 'memories.selectPhotosMultiple': 'Seleccionar fotos', 'memories.selectHint': 'Toca las fotos para seleccionarlas.', 'memories.selected': 'seleccionado(s)', 'memories.addSelected': 'Añadir {count} fotos', @@ -1624,6 +1636,8 @@ const es: Record = { 'notifications.markUnread': 'Marcar como no leída', 'notifications.delete': 'Eliminar', 'notifications.system': 'Sistema', + 'notifications.synologySessionCleared.title': 'Synology Photos desconectado', + 'notifications.synologySessionCleared.text': 'Tu servidor o cuenta ha cambiado — ve a Configuración para probar la conexión de nuevo.', 'memories.error.loadAlbums': 'Error al cargar los álbumes', 'memories.error.linkAlbum': 'Error al vincular el álbum', 'memories.error.unlinkAlbum': 'Error al desvincular el álbum', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index bf06c73b..2efc4681 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1434,6 +1434,7 @@ const fr: Record = { 'memories.title': 'Photos', 'memories.notConnected': 'Immich non connecté', 'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.', + 'memories.notConnectedMultipleHint': 'Connectez un de ces fournisseurs de photos : {provider_names} dans les Paramètres pour pouvoir ajouter des photos à ce voyage.', 'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.', 'memories.noPhotos': 'Aucune photo trouvée', 'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.', @@ -1444,24 +1445,35 @@ const fr: Record = { 'memories.reviewTitle': 'Vérifier vos photos', 'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.', 'memories.shareCount': 'Partager {count} photos', + 'memories.providerUrl': 'URL du serveur', + 'memories.providerApiKey': 'Clé API', + 'memories.providerUsername': 'Nom d\'utilisateur', + 'memories.providerPassword': 'Mot de passe', + 'memories.providerOTP': 'Code MFA (si activé)', + 'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL', + 'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo', 'memories.testConnection': 'Tester la connexion', 'memories.testFirst': 'Testez la connexion avant de sauvegarder', 'memories.connected': 'Connecté', 'memories.disconnected': 'Non connecté', 'memories.connectionSuccess': 'Connecté à Immich', 'memories.connectionError': 'Impossible de se connecter à Immich', - 'memories.saved': 'Paramètres Immich enregistrés', + 'memories.saved': 'Paramètres {provider_name} enregistrés', + 'memories.providerDisconnectedBanner': 'Votre connexion {provider_name} est perdue. Reconnectez-vous dans les Paramètres pour voir les photos.', + 'memories.saveError': 'Impossible d\'enregistrer les paramètres de {provider_name}', 'memories.oldest': 'Plus anciennes', 'memories.newest': 'Plus récentes', 'memories.allLocations': 'Tous les lieux', 'memories.addPhotos': 'Ajouter des photos', 'memories.linkAlbum': 'Lier un album', 'memories.selectAlbum': 'Choisir un album Immich', + 'memories.selectAlbumMultiple': 'Sélectionner un album', 'memories.noAlbums': 'Aucun album trouvé', 'memories.syncAlbum': 'Synchroniser', 'memories.unlinkAlbum': 'Délier', 'memories.photos': 'photos', 'memories.selectPhotos': 'Sélectionner des photos depuis Immich', + 'memories.selectPhotosMultiple': 'Sélectionner des photos', 'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.', 'memories.selected': 'sélectionné(s)', 'memories.addSelected': 'Ajouter {count} photos', @@ -1618,6 +1630,8 @@ const fr: Record = { 'notifications.markUnread': 'Marquer comme non lu', 'notifications.delete': 'Supprimer', 'notifications.system': 'Système', + 'notifications.synologySessionCleared.title': 'Synology Photos déconnecté', + 'notifications.synologySessionCleared.text': 'Votre serveur ou compte a changé — allez dans Paramètres pour tester à nouveau votre connexion.', 'memories.error.loadAlbums': 'Impossible de charger les albums', 'memories.error.linkAlbum': 'Impossible de lier l\'album', 'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 080faece..9d743aaf 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1505,6 +1505,7 @@ const hu: Record = { 'memories.title': 'Fotók', 'memories.notConnected': 'Immich nincs csatlakoztatva', 'memories.notConnectedHint': 'Csatlakoztasd az Immich példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.', + 'memories.notConnectedMultipleHint': 'A fényképek hozzáadásához csatlakoztasson egyet a következő fényképszolgáltatók közül a Beállításokban: {provider_names}.', 'memories.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.', 'memories.noPhotos': 'Nem találhatók fotók', 'memories.noPhotosHint': 'Nem találhatók fotók az Immichben erre az utazási időszakra.', @@ -1515,21 +1516,32 @@ const hu: Record = { 'memories.reviewTitle': 'Nézd át a fotóidat', 'memories.reviewHint': 'Kattints a fotókra a megosztásból való kizáráshoz.', 'memories.shareCount': '{count} fotó megosztása', + 'memories.providerUrl': 'Szerver URL', + 'memories.providerApiKey': 'API kulcs', + 'memories.providerUsername': 'Felhasználónév', + 'memories.providerPassword': 'Jelszó', + 'memories.providerOTP': 'MFA kód (ha engedélyezve van)', + 'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása', + 'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo', 'memories.testConnection': 'Kapcsolat tesztelése', 'memories.testFirst': 'Először teszteld a kapcsolatot', 'memories.connected': 'Csatlakoztatva', 'memories.disconnected': 'Nincs csatlakoztatva', 'memories.connectionSuccess': 'Csatlakozva az Immichhez', 'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez', - 'memories.saved': 'Immich beállítások mentve', + 'memories.saved': '{provider_name} beállítások mentve', + 'memories.providerDisconnectedBanner': 'A {provider_name} kapcsolat megszakadt. Csatlakozzon újra a Beállításokban a fényképek megtekintéséhez.', + 'memories.saveError': 'Nem sikerült menteni a(z) {provider_name} beállításait', 'memories.addPhotos': 'Fotók hozzáadása', 'memories.linkAlbum': 'Album csatolása', 'memories.selectAlbum': 'Immich album kiválasztása', + 'memories.selectAlbumMultiple': 'Album kiválasztása', 'memories.noAlbums': 'Nem található album', 'memories.syncAlbum': 'Album szinkronizálása', 'memories.unlinkAlbum': 'Leválasztás', 'memories.photos': 'fotó', 'memories.selectPhotos': 'Fotók kiválasztása az Immichből', + 'memories.selectPhotosMultiple': 'Fényképek kiválasztása', 'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.', 'memories.selected': 'kijelölve', 'memories.addSelected': '{count} fotó hozzáadása', @@ -1619,6 +1631,8 @@ const hu: Record = { 'notifications.markUnread': 'Olvasatlannak jelölés', 'notifications.delete': 'Törlés', 'notifications.system': 'Rendszer', + 'notifications.synologySessionCleared.title': 'Synology Photos leválasztva', + 'notifications.synologySessionCleared.text': 'A szerver vagy a fiók megváltozott — lépjen a Beállításokba a kapcsolat újrateszteléséhez.', 'memories.error.loadAlbums': 'Az albumok betöltése sikertelen', 'memories.error.linkAlbum': 'Az album csatolása sikertelen', 'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index e5692c01..d968a188 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1435,6 +1435,7 @@ const it: Record = { 'memories.title': 'Foto', 'memories.notConnected': 'Immich non connesso', 'memories.notConnectedHint': 'Connetti la tua istanza Immich nelle Impostazioni per vedere qui le foto del tuo viaggio.', + 'memories.notConnectedMultipleHint': 'Collega uno di questi provider di foto: {provider_names} nelle Impostazioni per poter aggiungere foto a questo viaggio.', 'memories.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.', 'memories.noPhotos': 'Nessuna foto trovata', 'memories.noPhotosHint': 'Nessuna foto trovata in Immich per l\'intervallo di date di questo viaggio.', @@ -1445,21 +1446,32 @@ const it: Record = { 'memories.reviewTitle': 'Rivedi le tue foto', 'memories.reviewHint': 'Clicca sulle foto per escluderle dalla condivisione.', 'memories.shareCount': 'Condividi {count} foto', + 'memories.providerUrl': 'URL del server', + 'memories.providerApiKey': 'Chiave API', + 'memories.providerUsername': 'Nome utente', + 'memories.providerPassword': 'Password', + 'memories.providerOTP': 'Codice MFA (se abilitato)', + 'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL', + 'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo', 'memories.testConnection': 'Test connessione', 'memories.testFirst': 'Testa prima la connessione', 'memories.connected': 'Connesso', 'memories.disconnected': 'Non connesso', 'memories.connectionSuccess': 'Connesso a Immich', 'memories.connectionError': 'Impossibile connettersi a Immich', - 'memories.saved': 'Impostazioni Immich salvate', + 'memories.saved': 'Impostazioni {provider_name} salvate', + 'memories.providerDisconnectedBanner': 'La connessione a {provider_name} è persa. Riconnetti nelle Impostazioni per visualizzare le foto.', + 'memories.saveError': 'Impossibile salvare le impostazioni di {provider_name}', 'memories.addPhotos': 'Aggiungi foto', 'memories.linkAlbum': 'Collega album', 'memories.selectAlbum': 'Seleziona album Immich', + 'memories.selectAlbumMultiple': 'Seleziona album', 'memories.noAlbums': 'Nessun album trovato', 'memories.syncAlbum': 'Sincronizza album', 'memories.unlinkAlbum': 'Scollega', 'memories.photos': 'foto', 'memories.selectPhotos': 'Seleziona foto da Immich', + 'memories.selectPhotosMultiple': 'Seleziona foto', 'memories.selectHint': 'Tocca le foto per selezionarle.', 'memories.selected': 'selezionate', 'memories.addSelected': 'Aggiungi {count} foto', @@ -1621,6 +1633,8 @@ const it: Record = { 'notifications.markUnread': 'Segna come non letto', 'notifications.delete': 'Elimina', 'notifications.system': 'Sistema', + 'notifications.synologySessionCleared.title': 'Synology Photos disconnesso', + 'notifications.synologySessionCleared.text': 'Il server o l\'account è cambiato — vai alle Impostazioni per testare nuovamente la connessione.', 'memories.error.loadAlbums': 'Caricamento album non riuscito', 'memories.error.linkAlbum': 'Collegamento album non riuscito', 'memories.error.unlinkAlbum': 'Scollegamento album non riuscito', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 1b2f5430..cfa1ebcd 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1434,6 +1434,7 @@ const nl: Record = { 'memories.title': 'Foto\'s', 'memories.notConnected': 'Immich niet verbonden', 'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.', + 'memories.notConnectedMultipleHint': 'Verbind een van deze fotoproviders: {provider_names} in Instellingen om foto\'s aan dit reisplan toe te voegen.', 'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.', 'memories.noPhotos': 'Geen foto\'s gevonden', 'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.', @@ -1444,24 +1445,35 @@ const nl: Record = { 'memories.reviewTitle': 'Je foto\'s bekijken', 'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.', 'memories.shareCount': '{count} foto\'s delen', + 'memories.providerUrl': 'Server-URL', + 'memories.providerApiKey': 'API-sleutel', + 'memories.providerUsername': 'Gebruikersnaam', + 'memories.providerPassword': 'Wachtwoord', + 'memories.providerOTP': 'MFA-code (indien ingeschakeld)', + 'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan', + 'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo', 'memories.testConnection': 'Verbinding testen', 'memories.testFirst': 'Test eerst de verbinding', 'memories.connected': 'Verbonden', 'memories.disconnected': 'Niet verbonden', 'memories.connectionSuccess': 'Verbonden met Immich', 'memories.connectionError': 'Kon niet verbinden met Immich', - 'memories.saved': 'Immich-instellingen opgeslagen', + 'memories.saved': '{provider_name}-instellingen opgeslagen', + 'memories.providerDisconnectedBanner': 'Je {provider_name}-verbinding is verbroken. Maak opnieuw verbinding in Instellingen om foto\'s te bekijken.', + 'memories.saveError': '{provider_name}-instellingen konden niet worden opgeslagen', 'memories.oldest': 'Oudste eerst', 'memories.newest': 'Nieuwste eerst', 'memories.allLocations': 'Alle locaties', 'memories.addPhotos': 'Foto\'s toevoegen', 'memories.linkAlbum': 'Album koppelen', 'memories.selectAlbum': 'Immich-album selecteren', + 'memories.selectAlbumMultiple': 'Album selecteren', 'memories.noAlbums': 'Geen albums gevonden', 'memories.syncAlbum': 'Album synchroniseren', 'memories.unlinkAlbum': 'Ontkoppelen', 'memories.photos': 'fotos', 'memories.selectPhotos': 'Selecteer foto\'s uit Immich', + 'memories.selectPhotosMultiple': 'Foto\'s selecteren', 'memories.selectHint': 'Tik op foto\'s om ze te selecteren.', 'memories.selected': 'geselecteerd', 'memories.addSelected': '{count} foto\'s toevoegen', @@ -1618,6 +1630,8 @@ const nl: Record = { 'notifications.markUnread': 'Markeren als ongelezen', 'notifications.delete': 'Verwijderen', 'notifications.system': 'Systeem', + 'notifications.synologySessionCleared.title': 'Synology Photos verbroken', + 'notifications.synologySessionCleared.text': 'Je server of account is gewijzigd — ga naar Instellingen om je verbinding opnieuw te testen.', 'memories.error.loadAlbums': 'Albums laden mislukt', 'memories.error.linkAlbum': 'Album koppelen mislukt', 'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index aa418719..86d14b37 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1393,6 +1393,7 @@ const pl: Record = { 'memories.title': 'Zdjęcia', 'memories.notConnected': 'Immich nie jest połączony', 'memories.notConnectedHint': 'Połącz swoją instancję Immich w ustawieniach, aby przeglądać tutaj swoje zdjęcia z podróży.', + 'memories.notConnectedMultipleHint': 'Połącz jednego z tych dostawców zdjęć: {provider_names} w Ustawieniach, aby móc dodawać zdjęcia do tej podróży.', 'memories.noDates': 'Dodaj daty do swojej podróży, aby załadować zdjęcia.', 'memories.noPhotos': 'Nie znaleziono zdjęć', 'memories.noPhotosHint': 'Nie znaleziono zdjęć w Immich dla tego zakresu dat podróży.', @@ -1403,14 +1404,24 @@ const pl: Record = { 'memories.reviewTitle': 'Przejrzyj swoje zdjęcia', 'memories.reviewHint': 'Kliknij w zdjęcia, aby wykluczyć je z udostępnienia.', 'memories.shareCount': 'Udostępnij {count} zdjęć', + 'memories.providerUrl': 'URL serwera', + 'memories.providerApiKey': 'Klucz API', + 'memories.providerUsername': 'Nazwa użytkownika', + 'memories.providerPassword': 'Hasło', + 'memories.providerOTP': 'Kod MFA (jeśli włączony)', + 'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL', + 'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo', 'memories.testConnection': 'Test', 'memories.connected': 'Połączono', 'memories.disconnected': 'Nie połączono', 'memories.connectionSuccess': 'Połączono z Immich', 'memories.connectionError': 'Nie udało się połączyć z Immich', - 'memories.saved': 'Ustawienia Immich zostały zapisane', + 'memories.saved': 'Ustawienia {provider_name} zostały zapisane', + 'memories.providerDisconnectedBanner': 'Połączenie z {provider_name} zostało utracone. Połącz ponownie w Ustawieniach, aby wyświetlać zdjęcia.', + 'memories.saveError': 'Nie można zapisać ustawień {provider_name}', 'memories.addPhotos': 'Dodaj zdjęcia', 'memories.selectPhotos': 'Wybierz zdjęcia z Immich', + 'memories.selectPhotosMultiple': 'Wybierz zdjęcia', 'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.', 'memories.selected': 'wybranych', 'memories.addSelected': 'Dodaj {count} zdjęć', @@ -1561,6 +1572,7 @@ const pl: Record = { 'memories.testFirst': 'Najpierw przetestuj połączenie', 'memories.linkAlbum': 'Połącz album', 'memories.selectAlbum': 'Wybierz album Immich', + 'memories.selectAlbumMultiple': 'Wybierz album', 'memories.noAlbums': 'Nie znaleziono albumów', 'memories.syncAlbum': 'Synchronizuj album', 'memories.unlinkAlbum': 'Odłącz album', @@ -1646,6 +1658,8 @@ const pl: Record = { 'notifications.markUnread': 'Oznacz jako nieprzeczytane', 'notifications.delete': 'Usuń', 'notifications.system': 'System', + 'notifications.synologySessionCleared.title': 'Synology Photos rozłączone', + 'notifications.synologySessionCleared.text': 'Twój serwer lub konto zostało zmienione — przejdź do Ustawień, aby ponownie przetestować połączenie.', 'notifications.versionAvailable.title': 'Dostępna aktualizacja', 'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.', 'notifications.versionAvailable.button': 'Zobacz szczegóły', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 515ebc54..999e36bb 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1434,6 +1434,7 @@ const ru: Record = { 'memories.title': 'Фото', 'memories.notConnected': 'Immich не подключён', 'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.', + 'memories.notConnectedMultipleHint': 'Подключите одного из этих фотопровайдеров: {provider_names} в Настройках, чтобы добавлять фотографии к этому путешествию.', 'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.', 'memories.noPhotos': 'Фотографии не найдены', 'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.', @@ -1444,24 +1445,35 @@ const ru: Record = { 'memories.reviewTitle': 'Проверьте ваши фото', 'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.', 'memories.shareCount': 'Поделиться ({count} фото)', + 'memories.providerUrl': 'URL сервера', + 'memories.providerApiKey': 'API-ключ', + 'memories.providerUsername': 'Имя пользователя', + 'memories.providerPassword': 'Пароль', + 'memories.providerOTP': 'Код MFA (если включён)', + 'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата', + 'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo', 'memories.testConnection': 'Проверить подключение', 'memories.testFirst': 'Сначала проверьте подключение', 'memories.connected': 'Подключено', 'memories.disconnected': 'Не подключено', 'memories.connectionSuccess': 'Подключение к Immich установлено', 'memories.connectionError': 'Не удалось подключиться к Immich', - 'memories.saved': 'Настройки Immich сохранены', + 'memories.saved': 'Настройки {provider_name} сохранены', + 'memories.providerDisconnectedBanner': 'Соединение с {provider_name} потеряно. Переподключитесь в Настройках для просмотра фотографий.', + 'memories.saveError': 'Не удалось сохранить настройки {provider_name}', 'memories.oldest': 'Сначала старые', 'memories.newest': 'Сначала новые', 'memories.allLocations': 'Все места', 'memories.addPhotos': 'Добавить фото', 'memories.linkAlbum': 'Привязать альбом', 'memories.selectAlbum': 'Выбрать альбом Immich', + 'memories.selectAlbumMultiple': 'Выбрать альбом', 'memories.noAlbums': 'Альбомы не найдены', 'memories.syncAlbum': 'Синхронизировать', 'memories.unlinkAlbum': 'Отвязать', 'memories.photos': 'фото', 'memories.selectPhotos': 'Выбрать фото из Immich', + 'memories.selectPhotosMultiple': 'Выбрать фотографии', 'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.', 'memories.selected': 'выбрано', 'memories.addSelected': 'Добавить {count} фото', @@ -1618,6 +1630,8 @@ const ru: Record = { 'notifications.markUnread': 'Отметить как непрочитанное', 'notifications.delete': 'Удалить', 'notifications.system': 'Система', + 'notifications.synologySessionCleared.title': 'Synology Photos отключено', + 'notifications.synologySessionCleared.text': 'Ваш сервер или аккаунт изменился — перейдите в Настройки, чтобы проверить соединение снова.', 'memories.error.loadAlbums': 'Не удалось загрузить альбомы', 'memories.error.linkAlbum': 'Не удалось привязать альбом', 'memories.error.unlinkAlbum': 'Не удалось отвязать альбом', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 0ddcf5c9..7a5fb32e 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1434,6 +1434,7 @@ const zh: Record = { 'memories.title': '照片', 'memories.notConnected': 'Immich 未连接', 'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。', + 'memories.notConnectedMultipleHint': '请在设置中连接以下任一照片提供商:{provider_names},以便向此行程添加照片。', 'memories.noDates': '为旅行添加日期以加载照片。', 'memories.noPhotos': '未找到照片', 'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。', @@ -1444,24 +1445,35 @@ const zh: Record = { 'memories.reviewTitle': '审查您的照片', 'memories.reviewHint': '点击照片以将其从分享中排除。', 'memories.shareCount': '分享 {count} 张照片', + 'memories.providerUrl': '服务器 URL', + 'memories.providerApiKey': 'API 密钥', + 'memories.providerUsername': '用户名', + 'memories.providerPassword': '密码', + 'memories.providerOTP': 'MFA 验证码(如已启用)', + 'memories.skipSSLVerification': '跳过 SSL 证书验证', + 'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo', 'memories.testConnection': '测试连接', 'memories.testFirst': '请先测试连接', 'memories.connected': '已连接', 'memories.disconnected': '未连接', 'memories.connectionSuccess': '已连接到 Immich', 'memories.connectionError': '无法连接到 Immich', - 'memories.saved': 'Immich 设置已保存', + 'memories.saved': '{provider_name} 设置已保存', + 'memories.providerDisconnectedBanner': '您与 {provider_name} 的连接已断开。请在设置中重新连接以查看照片。', + 'memories.saveError': '无法保存 {provider_name} 设置', 'memories.oldest': '最早优先', 'memories.newest': '最新优先', 'memories.allLocations': '所有地点', 'memories.addPhotos': '添加照片', 'memories.linkAlbum': '关联相册', 'memories.selectAlbum': '选择 Immich 相册', + 'memories.selectAlbumMultiple': '选择相册', 'memories.noAlbums': '未找到相册', 'memories.syncAlbum': '同步相册', 'memories.unlinkAlbum': '取消关联', 'memories.photos': '张照片', 'memories.selectPhotos': '从 Immich 选择照片', + 'memories.selectPhotosMultiple': '选择照片', 'memories.selectHint': '点击照片以选择。', 'memories.selected': '已选择', 'memories.addSelected': '添加 {count} 张照片', @@ -1618,6 +1630,8 @@ const zh: Record = { 'notifications.markUnread': '标为未读', 'notifications.delete': '删除', 'notifications.system': '系统', + 'notifications.synologySessionCleared.title': 'Synology Photos 已断开连接', + 'notifications.synologySessionCleared.text': '您的服务器或账户已更改 — 请前往设置重新测试您的连接。', 'memories.error.loadAlbums': '加载相册失败', 'memories.error.linkAlbum': '关联相册失败', 'memories.error.unlinkAlbum': '取消关联相册失败', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index f8e8a22b..c66de8a2 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1474,6 +1474,9 @@ const zhTw: Record = { 'memories.providerApiKey': 'API 金鑰', 'memories.providerUsername': '使用者名稱', 'memories.providerPassword': '密碼', + 'memories.providerOTP': 'MFA 驗證碼(如已啟用)', + 'memories.skipSSLVerification': '跳過 SSL 憑證驗證', + 'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo', 'memories.testConnection': '測試連線', 'memories.testFirst': '請先測試連線', 'memories.connected': '已連線', @@ -1481,6 +1484,7 @@ const zhTw: Record = { 'memories.connectionSuccess': '已連線到 {provider_name}', 'memories.connectionError': '無法連線到 {provider_name}', 'memories.saved': '{provider_name} 設定已儲存', + 'memories.providerDisconnectedBanner': '您與 {provider_name} 的連線已中斷。請在設定中重新連線以查看照片。', 'memories.saveError': '無法儲存 {provider_name} 設定', 'memories.oldest': '最早優先', 'memories.newest': '最新優先', @@ -1685,6 +1689,8 @@ const zhTw: Record = { 'notifications.markUnread': '標為未讀', 'notifications.delete': '刪除', 'notifications.system': '系統', + 'notifications.synologySessionCleared.title': 'Synology Photos 已斷線', + 'notifications.synologySessionCleared.text': '您的伺服器或帳號已更改 — 請前往設定重新測試連線。', 'memories.error.loadAlbums': '載入相簿失敗', 'memories.error.linkAlbum': '關聯相簿失敗', 'memories.error.unlinkAlbum': '取消關聯相簿失敗', diff --git a/server/src/app.ts b/server/src/app.ts index 11dbe249..53c8fe87 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -205,7 +205,7 @@ export function createApp(): express.Application { ORDER BY sort_order, id `).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; const fields = db.prepare(` - SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order + SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order FROM photo_provider_fields ORDER BY sort_order, id `).all() as Array<{ @@ -214,6 +214,7 @@ export function createApp(): express.Application { label: string; input_type: string; placeholder?: string | null; + hint?: string | null; required: number; secret: number; settings_key?: string | null; @@ -243,6 +244,7 @@ export function createApp(): express.Application { label: f.label, input_type: f.input_type, placeholder: f.placeholder || '', + hint: f.hint || null, required: !!f.required, secret: !!f.secret, settings_key: f.settings_key || null, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index cffc990e..56acf672 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -973,6 +973,32 @@ function runMigrations(db: Database.Database): void { } }, }, + // Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos + () => { + const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'hint')) { + db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`); + } + db.exec(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3) + `); + db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`); + db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`); + db.exec(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4) + `); + db.exec(` + UPDATE photo_provider_fields + SET hint = 'providerUrlHintSynology' + WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url' + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index eb2d72cb..9df9d013 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -245,6 +245,7 @@ function createTables(db: Database.Database): void { label TEXT NOT NULL, input_type TEXT NOT NULL DEFAULT 'text', placeholder TEXT, + hint TEXT, required INTEGER DEFAULT 0, secret INTEGER DEFAULT 0, settings_key TEXT, diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index fe02892e..6ffed848 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -115,15 +115,17 @@ function seedAddons(db: Database.Database): void { for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order); const providerFields = [ - { provider_id: 'immich', field_key: 'immich_url', label: '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: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, 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', hint: null, 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/photo', hint: 'providerUrlHintSynology', 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', hint: null, 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', hint: null, 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: 'text', placeholder: '123456', hint: null, required: 0, secret: 0, settings_key: null, payload_key: 'synology_otp', sort_order: 3 }, + { provider_id: 'synologyphotos', field_key: 'synology_skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'synology_skip_ssl', payload_key: 'synology_skip_ssl', sort_order: 4 }, ]; - 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); for (const f of providerFields) { - insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order); + insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order); } console.log('Default addons seeded'); } catch (err: unknown) { diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 7781f534..30acf2ee 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -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_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true'; 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_skip_ssl)); } }); @@ -51,10 +52,13 @@ router.get('/status', authenticate, async (req: Request, res: Response) => { }); router.post('/test', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; const body = req.body as Record; 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); + const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true'; if (!synology_url || !synology_username || !synology_password) { const missingFields: string[] = []; @@ -64,7 +68,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(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl)); } }); diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index 8a66143f..d15ea3f0 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -19,26 +19,62 @@ import { SyncAlbumResult, AssetInfo } from './helpersService'; +import { send as sendNotification } from '../notificationService'; const SYNOLOGY_PROVIDER = 'synologyphotos'; -const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi'; +// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo). +// The API endpoint is always at {base_url}/webapi/entry.cgi. +const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi'; + +const SYNOLOGY_ERROR_MESSAGES: Record = { + 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: 'Invalid credentials.', + 401: 'Session expired or account disabled.', + 402: 'No permission to use this account.', + 403: 'Two-factor authentication code required.', + 404: 'Two-factor authentication failed.', + 406: 'Two-factor authentication is enforced for this account.', + 407: 'Maximum login 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; synology_username?: string | null; synology_password?: string | null; synology_sid?: string | null; + synology_did?: string | null; + synology_skip_ssl?: number | null; }; interface SynologyCredentials { synology_url: string; synology_username: string; synology_password: string; + synology_skip_ssl: boolean; } interface SynologySettings { synology_url: string; synology_username: string; + synology_skip_ssl: boolean; connected: boolean; } @@ -84,7 +120,7 @@ interface SynologyPhotoItem { function _readSynologyUser(userId: number, columns: string[]): ServiceResult { try { - const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; + const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; if (!row) { return fail('User not found', 404); @@ -102,7 +138,7 @@ function _readSynologyUser(userId: number, columns: string[]): ServiceResult { - const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']); + const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']); if (!user.success) return user as ServiceResult; if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400); const password = decrypt_api_key(user.data.synology_password); @@ -111,6 +147,7 @@ function _getSynologyCredentials(userId: number): ServiceResult(url: string, body: URLSearchParams): Promise> { +async function _fetchSynologyJson(url: string, body: URLSearchParams, skipSsl = true): Promise> { const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`); try { const resp = await safeFetch(endpoint, { @@ -139,12 +176,20 @@ async function _fetchSynologyJson(url: string, body: URLSearchParams): Promis }, body, signal: AbortSignal.timeout(30000) as any, - }); + }, { rejectUnauthorized: !skipSsl }); if (!resp.ok) { return fail('Synology API request failed with status ' + resp.status, resp.status); } const response = await resp.json() as SynologyApiResponse; - return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code); + if (!response.success) { + const code = response.error.code; + const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')'; + // Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi. + // All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes. + const httpStatus = [106, 107, 119].includes(code) ? code : 400; + return fail(message, httpStatus); + } + return success(response.data); } catch (error) { if (error instanceof SsrfBlockedError) { return fail(error.message, 400); @@ -153,25 +198,41 @@ async function _fetchSynologyJson(url: string, body: URLSearchParams): Promis } } -async function _loginToSynology(url: string, username: string, password: string): Promise> { +const SYNOLOGY_DEVICE_NAME = 'trek'; + +async function _loginToSynology( + url: string, + username: string, + password: string, + opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {}, +): Promise> { + const { otp, deviceId, skipSsl = false } = opts; const body = new URLSearchParams({ api: 'SYNO.API.Auth', method: 'login', - version: '3', + version: '6', account: username, passwd: password, + format: 'sid', + client: 'browser', + device_name: SYNOLOGY_DEVICE_NAME, }); + if (otp && otp.trim()) { + body.append('otp_code', otp.trim()); + body.append('enable_device_token', 'yes'); + } + if (deviceId) { + body.append('device_id', deviceId); + } - const result = await _fetchSynologyJson<{ sid?: string }>(url, body); + const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl); if (!result.success) { - return result as ServiceResult; + return result as ServiceResult<{ sid: string; did?: string }>; } if (!result.data.sid) { return fail('Failed to get session ID from Synology', 500); } - return success(result.data.sid); - - + return success({ sid: result.data.sid, did: result.data.did }); } async function _requestSynologyApi(userId: number, params: ApiCallParams): Promise> { @@ -185,8 +246,9 @@ async function _requestSynologyApi(userId: number, params: ApiCallParams): Pr return session as ServiceResult; } + const skipSsl = creds.data.synology_skip_ssl; const body = _buildSynologyFormBody({ ...params, _sid: session.data }); - const result = await _fetchSynologyJson(creds.data.synology_url, body); + const result = await _fetchSynologyJson(creds.data.synology_url, body, skipSsl); // 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid if ('error' in result && [106, 107, 119].includes(result.error.status)) { _clearSynologySID(userId); @@ -194,7 +256,7 @@ async function _requestSynologyApi(userId: number, params: ApiCallParams): Pr if (!retrySession.success || !retrySession.data) { return retrySession as ServiceResult; } - return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data })); + return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl); } return result; } @@ -232,6 +294,10 @@ function _clearSynologySID(userId: number): void { db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); } +function _clearSynologySession(userId: number): void { + db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId); +} + function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null { // cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236". // The first segment must be a non-empty integer (the unit ID used for API calls). @@ -241,9 +307,9 @@ function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; } async function _getSynologySession(userId: number): Promise> { - const cachedSid = _readSynologyUser(userId, ['synology_sid']); - if (cachedSid.success && cachedSid.data?.synology_sid) { - const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid); + const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']); + if (cached.success && cached.data?.synology_sid) { + const decryptedSid = decrypt_api_key(cached.data.synology_sid); if (decryptedSid) return success(decryptedSid); // Decryption failed (e.g. key rotation) — clear the stale SID and re-login _clearSynologySID(userId); @@ -254,15 +320,22 @@ async function _getSynologySession(userId: number): Promise; } - const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password); + // Use stored device ID to skip OTP on re-login (trusted device flow) + const storedDid = cached.success && cached.data?.synology_did + ? (decrypt_api_key(cached.data.synology_did) || undefined) + : undefined; + + const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, { + deviceId: storedDid, + skipSsl: creds.data.synology_skip_ssl, + }); if (!resp.success) { return resp as ServiceResult; } - const encrypted = encrypt_api_key(resp.data); - db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId); - return success(resp.data); + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); + return success(resp.data.sid); } export async function getSynologySettings(userId: number): Promise> { @@ -272,11 +345,12 @@ export async function getSynologySettings(userId: number): Promise> { +export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise> { const ssrf = await checkSsrf(synologyUrl); if (!ssrf.allowed) { @@ -291,24 +365,42 @@ export async function updateSynologySettings(userId: number, synologyUrl: string return fail('No stored password found. Please provide a password to save settings.', 400); } + // Only invalidate the session when the account itself changes (different URL or username). + // If the user just tested the connection, testSynologyConnection already stored a fresh + // sid + did — clearing them here would force an unnecessary re-login that may fail (MFA). + const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']); + const urlChanged = existing.success && existing.data.synology_url !== synologyUrl; + const userChanged = existing.success && existing.data.synology_username !== synologyUsername; + const sessionCleared = urlChanged || userChanged; + if (sessionCleared) { + _clearSynologySession(userId); + sendNotification({ + event: 'synology_session_cleared', + actorId: null, + params: {}, + scope: 'user', + targetId: userId, + }); + } + try { - db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( + db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run( synologyUrl, synologyUsername, synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword, + synologySkipSsl ? 1 : 0, userId, ); } catch { return fail('Failed to update Synology settings', 500); } - _clearSynologySID(userId); - return success("settings updated"); + return success('settings updated'); } export async function getSynologyStatus(userId: number): Promise> { const sid = await _getSynologySession(userId); - if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message }); + if ('error' in sid) return success({ connected: false, error: 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; @@ -318,17 +410,25 @@ export async function getSynologyStatus(userId: number): Promise> { +export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise> { 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, { otp: synologyOtp, skipSsl: synologySkipSsl }); 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.message }); } + + // Persist the session so the OTP code is not required again on save. + // The did (device token) allows future re-logins without OTP. + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); + if (resp.data.did) { + db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId); + } + return success({ connected: true, user: { name: synologyUsername } }); } diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts index f8380fd5..5b7ef73f 100644 --- a/server/src/services/notificationPreferencesService.ts +++ b/server/src/services/notificationPreferencesService.ts @@ -13,7 +13,8 @@ export type NotifEventType = | 'photos_shared' | 'collab_message' | 'packing_tagged' - | 'version_available'; + | 'version_available' + | 'synology_session_cleared'; export interface AvailableChannels { email: boolean; @@ -32,6 +33,7 @@ const IMPLEMENTED_COMBOS: Record = { collab_message: ['inapp', 'email', 'webhook'], packing_tagged: ['inapp', 'email', 'webhook'], version_available: ['inapp', 'email', 'webhook'], + synology_session_cleared: ['inapp'], }; /** Events that target admins only (shown in admin panel, not in user settings). */ diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index 6fdc1eed..243faaf7 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -112,6 +112,12 @@ const EVENT_NOTIFICATION_CONFIG: Record = { navigateTextKey: 'notif.action.view_admin', navigateTarget: () => '/admin', }, + synology_session_cleared: { + inAppType: 'simple', + titleKey: 'notifications.synologySessionCleared.title', + textKey: 'notifications.synologySessionCleared.text', + navigateTarget: () => null, + }, }; // ── Fallback config for unknown event types ──────────────────────────────── diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index b4d3a26e..56363404 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -104,6 +104,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }), version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }), + synology_session_cleared: () => ({ title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.' }), }, de: { trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }), @@ -114,6 +115,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }), version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }), + synology_session_cleared: () => ({ title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.' }), }, fr: { trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }), @@ -124,6 +126,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }), packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }), version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }), + synology_session_cleared: () => ({ title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.' }), }, es: { trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }), @@ -134,6 +137,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }), version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }), + synology_session_cleared: () => ({ title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.' }), }, nl: { trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }), @@ -144,6 +148,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }), version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }), + synology_session_cleared: () => ({ title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.' }), }, ru: { trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }), @@ -154,6 +159,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }), version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }), + synology_session_cleared: () => ({ title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.' }), }, zh: { trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }), @@ -164,6 +170,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }), packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }), version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }), + synology_session_cleared: () => ({ title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。' }), }, 'zh-TW': { trip_invite: p => ({ title: `邀請加入「${p.trip}」`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }), @@ -173,6 +180,8 @@ const EVENT_TEXTS: Record> = { photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }), collab_message: p => ({ title: `「${p.trip}」中的新訊息`, body: `${p.actor}:${p.preview}` }), packing_tagged: p => ({ title: `打包清單:${p.category}`, body: `${p.actor} 已將您指派到「${p.trip}」中的「${p.category}」分類。` }), + version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。` }), + synology_session_cleared: () => ({ title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。' }), }, ar: { trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }), @@ -183,6 +192,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }), version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }), + synology_session_cleared: () => ({ title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.' }), }, br: { trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }), @@ -193,6 +203,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }), version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }), + synology_session_cleared: () => ({ title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.' }), }, cs: { trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }), @@ -203,6 +214,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }), version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }), + synology_session_cleared: () => ({ title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.' }), }, hu: { trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }), @@ -213,6 +225,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }), version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }), + synology_session_cleared: () => ({ title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.' }), }, it: { trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }), @@ -223,6 +236,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }), version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }), + synology_session_cleared: () => ({ title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.' }), }, pl: { trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }), @@ -233,6 +247,7 @@ const EVENT_TEXTS: Record> = { collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }), version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }), + synology_session_cleared: () => ({ title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.' }), }, }; diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index 8bbb3f8e..d5f2aa3f 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -114,17 +114,25 @@ export class SsrfBlockedError extends Error { } } +export interface SafeFetchOptions { + rejectUnauthorized?: boolean; +} + /** * SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes * the request using a DNS-pinned dispatcher so the resolved IP cannot change * between the check and the actual connection (DNS rebinding prevention). + * + * Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS + * certificates (e.g. a Synology NAS on a local network). The SSRF guard still + * applies — only the TLS certificate check is relaxed. */ -export async function safeFetch(url: string, init?: RequestInit): Promise { +export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise { const ssrf = await checkSsrf(url); if (!ssrf.allowed) { throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard'); } - const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!); + const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, options?.rejectUnauthorized ?? true); return fetch(url, { ...init, dispatcher } as any); } @@ -133,9 +141,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise, callback: Function) => { const family = resolvedIp.includes(':') ? 6 : 4; // Node.js 18+ may call lookup with `all: true`, expecting an array of address objects diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index c238b3be..11371bea 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -39,7 +39,7 @@ vi.mock('../../src/config', () => ({ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // ── SSRF guard mock — routes all Synology API calls to fake responses ───────── vi.mock('../../src/utils/ssrfGuard', async () => { diff --git a/server/tests/unit/services/notificationPreferencesService.test.ts b/server/tests/unit/services/notificationPreferencesService.test.ts index 16767714..6126ff8d 100644 --- a/server/tests/unit/services/notificationPreferencesService.test.ts +++ b/server/tests/unit/services/notificationPreferencesService.test.ts @@ -94,14 +94,14 @@ describe('getPreferencesMatrix', () => { const { user } = createUser(testDb); const { event_types } = getPreferencesMatrix(user.id, 'user'); expect(event_types).not.toContain('version_available'); - expect(event_types.length).toBe(7); + expect(event_types.length).toBe(8); }); it('NPREF-005 — user scope excludes version_available for everyone including admins', () => { const { user } = createAdmin(testDb); const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user'); expect(event_types).not.toContain('version_available'); - expect(event_types.length).toBe(7); + expect(event_types.length).toBe(8); }); it('NPREF-005b — admin scope returns only version_available', () => {