From ffe463e13034601843446514abbd7c69cbaee081 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 10 Mar 2026 15:02:43 +0100 Subject: [PATCH] fix: bundle latest backup downloads --- pages/backup.vue | 14 +++ server/api/download-latest.get.ts | 139 ++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 server/api/download-latest.get.ts diff --git a/pages/backup.vue b/pages/backup.vue index 5a03de5..1124c75 100644 --- a/pages/backup.vue +++ b/pages/backup.vue @@ -132,6 +132,15 @@ const triggerDownload = (folder: string, file: string) => { link.remove() } +const triggerBatchDownload = (folders: string[]) => { + const link = document.createElement("a") + link.href = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}` + link.style.display = "none" + document.body.appendChild(link) + link.click() + link.remove() +} + const downloadLatestBackup = async (folder: string) => { const latestFile = await fetchLatestBackup(folder) @@ -147,6 +156,11 @@ const handleScriptResult = async (payload: ScriptResult) => { return } + if (payload.downloadFolders.length > 1) { + triggerBatchDownload(payload.downloadFolders) + return + } + for (const folder of payload.downloadFolders) { try { await downloadLatestBackup(folder) diff --git a/server/api/download-latest.get.ts b/server/api/download-latest.get.ts new file mode 100644 index 0000000..fcdae0d --- /dev/null +++ b/server/api/download-latest.get.ts @@ -0,0 +1,139 @@ +import { execFile, spawn } from "node:child_process" +import folderMap from "../config/backup-folders.json" + +const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b" +const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups" +const FOLDER_MAP = folderMap as Record + +const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) +const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'` + +function runSsh(command: string): Promise { + return new Promise((resolve, reject) => { + execFile( + "ssh", + ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command], + { maxBuffer: 10 * 1024 * 1024 }, + (error, stdout, stderr) => { + if (error) { + reject(stderr || error.message) + return + } + resolve(stdout) + } + ) + }) +} + +async function remoteDirExists(remoteDir: string): Promise { + const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`) + return output.trim() === "yes" +} + +async function resolveFolderRemoteDir(folderName: string): Promise { + const mapped = FOLDER_MAP[folderName] + if (mapped) { + return `${REMOTE_ROOT}/${mapped}` + } + + const direct = `${REMOTE_ROOT}/${folderName}` + if (await remoteDirExists(direct)) { + return direct + } + + const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}` + if (await remoteDirExists(nested)) { + return nested + } + + return null +} + +async function getLatestRemoteFile(remoteDir: string): Promise { + const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`) + const fileName = output.trim() + return fileName || null +} + +function buildContentDisposition(fileName: string) { + const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_") + return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}` +} + +export default defineEventHandler(async (event) => { + const { folders } = getQuery(event) + const folderParam = typeof folders === "string" ? folders : "" + const folderNames = folderParam + .split(",") + .map((folder) => folder.trim()) + .filter(Boolean) + + if (folderNames.length === 0) { + throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" }) + } + + if (folderNames.some((folder) => !isSafeFolder(folder))) { + throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" }) + } + + const uniqueFolders = [...new Set(folderNames)] + const archiveEntries: Array<{ remoteDir: string; fileName: string; archiveName: string }> = [] + + for (const folderName of uniqueFolders) { + const remoteDir = await resolveFolderRemoteDir(folderName) + if (!remoteDir) { + continue + } + + const fileName = await getLatestRemoteFile(remoteDir) + if (!fileName) { + continue + } + + archiveEntries.push({ + remoteDir, + fileName, + archiveName: `${folderName}/${fileName}` + }) + } + + if (archiveEntries.length === 0) { + throw createError({ statusCode: 404, statusMessage: "Aucun fichier a telecharger" }) + } + + const dateLabel = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-") + const archiveName = `backup-latest-${dateLabel}.tar.gz` + const tarArgs = archiveEntries.flatMap(({ remoteDir, fileName, archiveName: entryName }) => [ + "--transform", + shellQuote(`s|^${fileName}$|${entryName}|`), + "-C", + shellQuote(remoteDir), + shellQuote(fileName) + ]) + const remoteCommand = `tar -czf - ${tarArgs.join(" ")}` + + setHeader(event, "Content-Type", "application/gzip") + setHeader(event, "Content-Disposition", buildContentDisposition(archiveName)) + + const child = spawn("ssh", [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + REMOTE_HOST, + remoteCommand + ]) + + let stderr = "" + child.stderr.on("data", (chunk) => { + stderr += chunk.toString() + }) + + child.on("close", (code) => { + if (code !== 0) { + console.error(`Erreur archive SSH (${code}): ${stderr}`) + } + }) + + return sendStream(event, child.stdout) +})