From 2ca1bbaf07d9011229ac1fbd964d73f4b954b51b Mon Sep 17 00:00:00 2001 From: Hendrik Date: Fri, 17 Apr 2026 15:35:19 +0200 Subject: [PATCH] feat(backup): add ZIP export endpoint (DB + images) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/backup/export.ts | 42 ++++++++++++++++++++++++++ src/routes/api/admin/backup/+server.ts | 18 +++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/lib/server/backup/export.ts create mode 100644 src/routes/api/admin/backup/+server.ts diff --git a/src/lib/server/backup/export.ts b/src/lib/server/backup/export.ts new file mode 100644 index 0000000..93c6206 --- /dev/null +++ b/src/lib/server/backup/export.ts @@ -0,0 +1,42 @@ +import archiver from 'archiver'; +import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +type Options = { + dbPath: string; + imagesDir: string; +}; + +export function createBackupStream({ dbPath, imagesDir }: Options): Readable { + const archive = archiver('zip', { zlib: { level: 6 } }); + + // DB file — include .db plus WAL if present (best-effort) + if (existsSync(dbPath)) { + archive.file(dbPath, { name: 'kochwas.db' }); + const wal = `${dbPath}-wal`; + if (existsSync(wal)) archive.file(wal, { name: 'kochwas.db-wal' }); + const shm = `${dbPath}-shm`; + if (existsSync(shm)) archive.file(shm, { name: 'kochwas.db-shm' }); + } + + // Images + if (existsSync(imagesDir) && statSync(imagesDir).isDirectory()) { + for (const name of readdirSync(imagesDir)) { + const full = join(imagesDir, name); + if (statSync(full).isFile()) { + archive.file(full, { name: `images/${name}` }); + } + } + } + + void archive.finalize(); + return archive; +} + +export function backupFilename(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}`; + return `kochwas-backup_${ts}.zip`; +} diff --git a/src/routes/api/admin/backup/+server.ts b/src/routes/api/admin/backup/+server.ts new file mode 100644 index 0000000..932c621 --- /dev/null +++ b/src/routes/api/admin/backup/+server.ts @@ -0,0 +1,18 @@ +import type { RequestHandler } from './$types'; +import { createBackupStream, backupFilename } from '$lib/server/backup/export'; +import { Readable } from 'node:stream'; + +const DB_PATH = process.env.DATABASE_PATH ?? './data/kochwas.db'; +const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images'; + +export const GET: RequestHandler = async () => { + const archive = createBackupStream({ dbPath: DB_PATH, imagesDir: IMAGE_DIR }); + const filename = backupFilename(); + return new Response(Readable.toWeb(archive) as ReadableStream, { + status: 200, + headers: { + 'content-type': 'application/zip', + 'content-disposition': `attachment; filename="${filename}"` + } + }); +};