import { execFile, spawn } from "node:child_process" import { Readable } from "node:stream" const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b@192.168.0.179" const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups" const FOLDER_MAP: Record = { ferme: "bdd_recette/ferme", inventory: "bdd_recette/inventory", sirh: "bdd_recette/sirh", user: "bdd_recette/user", bitwarden: "bitwarden" } const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFile = (value: string) => /^[^/\\]+$/.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: 5 * 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 } function buildContentDisposition(fileName: string) { const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_") return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}` } function speedtestStream(event: any) { const size = 128 * 1024 * 1024 let sent = 0 const stream = new Readable({ read(chunkSize) { if (sent >= size) { this.push(null) return } const remaining = size - sent const chunk = Buffer.alloc(Math.min(chunkSize, remaining), "a") sent += chunk.length this.push(chunk) } }) setHeader(event, "Content-Type", "application/octet-stream") setHeader(event, "Content-Length", size) return stream } export default defineEventHandler(async (event) => { const { folder, file } = getQuery(event) const folderName = typeof folder === "string" ? folder : null const fileName = typeof file === "string" ? file : null // Compat mode: utilisé par le test de débit. if (!folderName || !fileName) { return speedtestStream(event) } if (!isSafeFolder(folderName) || !isSafeFile(fileName)) { throw createError({ statusCode: 400, statusMessage: "Paramètres invalides" }) } const remoteDir = await resolveFolderRemoteDir(folderName) if (!remoteDir) { throw createError({ statusCode: 404, statusMessage: "Dossier introuvable" }) } const remotePath = `${remoteDir}/${fileName}` const existsOutput = await runSsh(`[ -f ${shellQuote(remotePath)} ] && echo yes || echo no`) if (existsOutput.trim() !== "yes") { throw createError({ statusCode: 404, statusMessage: "Fichier introuvable" }) } setHeader(event, "Content-Type", "application/octet-stream") setHeader(event, "Content-Disposition", buildContentDisposition(fileName)) const child = spawn("ssh", [ "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, `cat ${shellQuote(remotePath)}` ]) let stderr = "" child.stderr.on("data", (chunk) => { stderr += chunk.toString() }) child.on("close", (code) => { if (code !== 0) { console.error(`Erreur téléchargement SSH (${code}): ${stderr}`) } }) return sendStream(event, child.stdout) })