feat(standalone): allow downloading backups

This commit is contained in:
Elian Doran
2026-04-12 18:47:43 +03:00
parent 9f24a44e15
commit b46c1e6d57
6 changed files with 67 additions and 20 deletions

View File

@@ -127,35 +127,30 @@ export default class StandaloneBackupService extends BackupService {
}
}
/**
* Download a backup to the user's device.
*/
async downloadBackup(fileName: string): Promise<void> {
override async getBackupContent(filePath: string): Promise<Uint8Array | null> {
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;
}
}
}

View File

@@ -46,4 +46,20 @@ export default class ServerBackupService extends BackupService {
return backupFile;
});
}
override async getBackupContent(filePath: string): Promise<Uint8Array | null> {
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);
}
}

View File

@@ -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);

View File

@@ -11,7 +11,37 @@ async function backupDatabase(): Promise<BackupDatabaseNowResponse> {
};
}
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<void> {
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
};

View File

@@ -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);

View File

@@ -41,6 +41,12 @@ export default abstract class BackupService {
*/
abstract getExistingBackups(): Promise<DatabaseBackup[]>;
/**
* Get the content of a backup file.
* Returns null if the backup doesn't exist or access is denied.
*/
abstract getBackupContent(filePath: string): Promise<Uint8Array | null>;
/**
* Run the scheduled backup checks for daily, weekly, and monthly backups.
*/