/** * Descarga de respaldos por restaurador, con ruta RELATIVA y SIN rutas fijas. * * La base de cada restaurador se DERIVA al vuelo de su Entrada reportada * (`cloudrestore_status.input_folder`): hermana `Procesados`/`Fallados` (layout por defecto del * install). El cliente solo envía `target` + `kind` + `relPath`; el servidor arma `base + relPath` * y lo sirve leyendo la carpeta por filesystem (local o montada como share). CloudRestoreAS no * reporta ni configura nada nuevo. */ import fs from 'node:fs/promises'; import { createReadStream } from 'node:fs'; import { Readable } from 'node:stream'; import { deriveSiblingFolder } from './backup-files'; import { getRestoreTargetForDownload, type RestoreTargetDownload } from './controldesk-pg'; export type BackupKind = 'procesados' | 'fallados'; export class BackupDownloadError extends Error { constructor( public status: number, message: string ) { super(message); this.name = 'BackupDownloadError'; } } function baseSeparator(p: string): '\\' | '/' { return p.includes('\\') ? '\\' : '/'; } /** Carpeta base (procesados/fallados) del restaurador, SIEMPRE derivada de su Entrada. */ function resolveBaseFolder(t: RestoreTargetDownload, kind: BackupKind): string | null { const input = t.input_folder; if (!input) return null; return deriveSiblingFolder(input, kind === 'procesados' ? 'Procesados' : 'Fallados'); } /** Valida la ruta relativa: sin '..', sin absoluto, sin componentes vacíos. */ export function sanitizeRelPath(relPath: string): string | null { const rel = String(relPath ?? '') .replace(/\\/g, '/') .trim(); if (!rel) return null; if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) return null; // absoluto const parts = rel.split('/'); if (parts.some((p) => p === '' || p === '.' || p === '..')) return null; return parts.join('/'); } function localJoin(base: string, rel: string): string { const sep = baseSeparator(base); const b = base.replace(/[\\/]+$/, ''); const relNative = rel.split('/').join(sep); return `${b}${sep}${relNative}`; } export interface BackupDownload { body: ReadableStream; size: number | null; filename: string; cleanup: () => void; } /** * Abre el stream de descarga de un respaldo desde la carpeta de procesados/fallados del * restaurador (por filesystem). Lanza BackupDownloadError con el código HTTP apropiado. */ export async function openBackupDownload( targetId: number, kind: BackupKind, relPathRaw: string ): Promise { const rel = sanitizeRelPath(relPathRaw); if (!rel) throw new BackupDownloadError(400, 'Ruta de archivo inválida'); const target = await getRestoreTargetForDownload(targetId); if (!target) throw new BackupDownloadError(404, 'Restaurador no encontrado'); const base = resolveBaseFolder(target, kind); if (!base) throw new BackupDownloadError(409, 'El restaurador aún no reportó su carpeta de entrada'); const filename = rel.split('/').pop() as string; const localPath = localJoin(base, rel); let st; try { st = await fs.stat(localPath); } catch { throw new BackupDownloadError(404, 'Archivo de respaldo no encontrado o carpeta inaccesible'); } if (!st.isFile()) { throw new BackupDownloadError(404, 'La ruta indicada no es un archivo'); } const node = createReadStream(localPath, { highWaterMark: 1024 * 1024 }); return { body: Readable.toWeb(node) as unknown as ReadableStream, size: st.size, filename, cleanup: () => node.destroy() }; }