diff --git a/apps/client-standalone/src/lightweight/backup_provider.ts b/apps/client-standalone/src/lightweight/backup_provider.ts index d25b03c729..7677e88325 100644 --- a/apps/client-standalone/src/lightweight/backup_provider.ts +++ b/apps/client-standalone/src/lightweight/backup_provider.ts @@ -127,35 +127,30 @@ export default class StandaloneBackupService extends BackupService { } } - /** - * Download a backup to the user's device. - */ - async downloadBackup(fileName: string): Promise { + override async getBackupContent(filePath: string): Promise { if (!this.isOpfsAvailable()) { - throw new Error("OPFS not available - cannot download backup"); + return null; } try { const dir = await this.ensureBackupDirectory(); if (!dir) { - throw new Error("Backup directory not available"); + return null; + } + + // Extract fileName from filePath (e.g., "/backups/backup-now.db" -> "backup-now.db") + const fileName = filePath.split("/").pop(); + if (!fileName || !BACKUP_FILE_PATTERN.test(fileName)) { + return null; } const fileHandle = await dir.getFileHandle(fileName); const file = await fileHandle.getFile(); const data = await file.arrayBuffer(); - - // Create download link - const blob = new Blob([data], { type: "application/x-sqlite3" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); + return new Uint8Array(data); } catch (error) { - console.error(`[Backup] Failed to download backup ${fileName}:`, error); - throw error; + console.error(`[Backup] Failed to get backup content ${filePath}:`, error); + return null; } } } diff --git a/apps/server/src/backup_provider.ts b/apps/server/src/backup_provider.ts index cedf7e4c09..71f9221f6c 100644 --- a/apps/server/src/backup_provider.ts +++ b/apps/server/src/backup_provider.ts @@ -46,4 +46,20 @@ export default class ServerBackupService extends BackupService { return backupFile; }); } + + override async getBackupContent(filePath: string): Promise { + const resolvedPath = path.resolve(filePath); + const backupDir = path.resolve(dataDir.BACKUP_DIR); + + // Security check: ensure the path is within the backup directory + if (!resolvedPath.startsWith(backupDir + path.sep)) { + return null; + } + + if (!fs.existsSync(resolvedPath)) { + return null; + } + + return fs.readFileSync(resolvedPath); + } } diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index de2247677d..24716b1098 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -167,8 +167,7 @@ function register(app: express.Application) { asyncRoute(PST, "/api/database/rebuild/", [auth.checkApiAuthOrElectron], databaseRoute.rebuildIntegrationTestDatabase, apiResultHandler); } - // backup/download is server-specific (uses Express res.download), backup-database and backups are in core - route(GET, "/api/database/backup/download", [auth.checkApiAuthOrElectron], databaseRoute.downloadBackup); + // backup routes (backups, backup-database, backup/download) are in core // VACUUM requires execution outside of transaction asyncRoute(PST, "/api/database/vacuum-database", [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.vacuumDatabase, apiResultHandler); diff --git a/packages/trilium-core/src/routes/api/backup.ts b/packages/trilium-core/src/routes/api/backup.ts index 68b3c7f7ec..21a5b37738 100644 --- a/packages/trilium-core/src/routes/api/backup.ts +++ b/packages/trilium-core/src/routes/api/backup.ts @@ -11,7 +11,37 @@ async function backupDatabase(): Promise { }; } +interface DownloadRequest { + query: { filePath?: string }; +} + +interface DownloadResponse { + status(code: number): DownloadResponse; + send(body: string): void; + set(name: string, value: string): DownloadResponse; +} + +async function downloadBackup(req: DownloadRequest, res: DownloadResponse): Promise { + const filePath = req.query.filePath; + if (!filePath) { + res.status(400).send("Missing filePath"); + return; + } + + const content = await getBackup().getBackupContent(filePath); + if (!content) { + res.status(404).send("Backup not found"); + return; + } + + const fileName = filePath.split("/").pop() || "backup.db"; + res.set("Content-Type", "application/x-sqlite3"); + res.set("Content-Disposition", `attachment; filename="${fileName}"`); + res.send(content as unknown as string); +} + export default { getExistingBackups, - backupDatabase + backupDatabase, + downloadBackup }; diff --git a/packages/trilium-core/src/routes/index.ts b/packages/trilium-core/src/routes/index.ts index cd6286de44..f3da081822 100644 --- a/packages/trilium-core/src/routes/index.ts +++ b/packages/trilium-core/src/routes/index.ts @@ -207,6 +207,7 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout // Backup routes asyncApiRoute(GET, "/api/database/backups", backupRoute.getExistingBackups); asyncApiRoute(PST, "/api/database/backup-database", backupRoute.backupDatabase); + asyncRoute(GET, "/api/database/backup/download", [checkApiAuthOrElectron], backupRoute.downloadBackup); apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage); apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown); diff --git a/packages/trilium-core/src/services/backup.ts b/packages/trilium-core/src/services/backup.ts index 5432aac0bd..d1f8dea223 100644 --- a/packages/trilium-core/src/services/backup.ts +++ b/packages/trilium-core/src/services/backup.ts @@ -41,6 +41,12 @@ export default abstract class BackupService { */ abstract getExistingBackups(): Promise; + /** + * Get the content of a backup file. + * Returns null if the backup doesn't exist or access is denied. + */ + abstract getBackupContent(filePath: string): Promise; + /** * Run the scheduled backup checks for daily, weekly, and monthly backups. */