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) })