Files
PANEL_BASES_ANEXO24/src/lib/server/restore-fetch.ts
hreyes 1b198953d4
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
feature/asignacion-masiva-restauradores (#13)
integraciones de panel de respaldos fallidos, asignacion masiva de restaurador, trackeo de baks para nuevo sistema de cloudrestore

Reviewed-on: #13
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
2026-07-02 14:57:45 +00:00

104 lines
3.6 KiB
TypeScript

/**
* 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<Uint8Array>;
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<BackupDownload> {
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<Uint8Array>,
size: st.size,
filename,
cleanup: () => node.destroy()
};
}