import { Injectable } from '@nestjs/common'; import type { BackupConnectionTestResult, BackupSettingsView } from '@dorfteich/shared'; import { webdavCheck, type WebDavTarget } from '@dorfteich/shared/webdav'; import { SecretStoreService } from '../config/secret-store.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; /** * The api's view of the Nextcloud backup target (issue #103): instance * settings hold everything non-secret, the app password lives in the * wizard-written secret store (security.md §Secrets) under the key the * backup sidecar reads. This service resolves, tests, and persists the * combination — it never returns the password. */ export const NEXTCLOUD_PASSWORD_SECRET_KEY = 'BACKUP_NEXTCLOUD_PASSWORD'; @Injectable() export class BackupTargetService { constructor( private readonly settings: InstanceSettingsService, private readonly secretStore: SecretStoreService, ) {} async settingsView(): Promise { return { localRetentionDays: await this.settings.get('backup.localRetentionDays'), remoteRetentionDays: await this.settings.get('backup.remoteRetentionDays'), nextcloud: { enabled: await this.settings.get('backup.nextcloud.enabled'), baseUrl: await this.settings.get('backup.nextcloud.baseUrl'), username: await this.settings.get('backup.nextcloud.username'), folder: await this.settings.get('backup.nextcloud.folder'), uploadSchedule: await this.settings.get('backup.nextcloud.uploadSchedule'), passwordSet: Boolean(this.storedPassword()), }, }; } /** * The effective WebDAV target, or null when disabled or not fully * configured — the exact resolution the sidecar applies on its side. */ async resolveTarget(): Promise { const view = await this.settingsView(); const password = this.storedPassword(); const { enabled, baseUrl, username, folder } = view.nextcloud; if (!enabled || !baseUrl || !username || !password) return null; return { baseUrl, username, password, folder }; } /** * Live connection test (credentials + folder, creating missing folder * segments) for the admin "test connection" button. An empty password * falls back to the stored one, so a saved configuration can be re-tested * without re-entering the secret. */ async testConnection(candidate: { baseUrl: string; username: string; folder: string; password?: string; }): Promise { const password = candidate.password || this.storedPassword(); if (!password) return { ok: false, error: 'no app password provided or stored' }; const result = await webdavCheck({ baseUrl: candidate.baseUrl, username: candidate.username, folder: candidate.folder, password, }); return result.ok ? { ok: true } : { ok: false, error: result.error }; } /** Stores a new app password; empty input keeps the current one. */ async storePassword(password: string): Promise { if (!password) return; await this.secretStore.set({ [NEXTCLOUD_PASSWORD_SECRET_KEY]: password }); } storedPassword(): string { return this.secretStore.read()[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? ''; } }