mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 15:55:55 +02:00
feat(standalone): allow downloading backups
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user