|
- // Logica condivisa fra POST /api/admin/restore (upload) e
- // POST /api/admin/factory-reset (file pre-caricato).
- // Estrae uno zip in staging, valida la struttura, e fa lo swap atomico di data/.
-
- import { spawn } from 'node:child_process';
- import { mkdir, readFile, rename, rm } from 'node:fs/promises';
- import path from 'node:path';
- import crypto from 'node:crypto';
-
- const PROJECT_ROOT = process.cwd();
- const DATA_DIR = path.join(PROJECT_ROOT, 'data');
- const RESTORE_STAGING = path.join(PROJECT_ROOT, '.restore-staging');
-
- export type RestoreResult =
- | { ok: true; cards: number; portals: number; previousBackup: string }
- | { ok: false; status: number; error: string; detail?: string };
-
- function runUnzip(zipPath: string, destDir: string): Promise<{ code: number; stderr: string }> {
- return new Promise(resolve => {
- const child = spawn('unzip', ['-o', '-q', zipPath, '-d', destDir]);
- let stderr = '';
- child.stderr.on('data', d => { stderr += d.toString(); });
- child.on('error', () => resolve({ code: -1, stderr: stderr || 'unzip non avviato' }));
- child.on('exit', code => resolve({ code: code ?? -1, stderr }));
- });
- }
-
- /**
- * Estrae lo zip dal percorso dato, valida cards.txt + portals.txt come JSON array,
- * poi sostituisce atomicamente DATA_DIR. La vecchia data/ viene rinominata
- * data.bak-<ts>/ come safety net (mai cancellata automaticamente).
- */
- export async function restoreFromZipFile(zipPath: string): Promise<RestoreResult> {
- const sessionDir = path.join(RESTORE_STAGING, crypto.randomUUID());
- const extractDir = path.join(sessionDir, 'extract');
- await mkdir(extractDir, { recursive: true });
-
- try {
- const { code, stderr } = await runUnzip(zipPath, extractDir);
- if (code !== 0) {
- return {
- ok: false,
- status: 400,
- error: 'Could not extract the archive. The file may not be a valid ZIP, or it may be corrupted. Verify the file by opening it locally before retrying.',
- detail: stderr.split('\n').slice(0, 3).join(' '),
- };
- }
-
- let cards = 0;
- let portals = 0;
- try {
- const raw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8');
- const parsed = JSON.parse(raw || '[]');
- if (!Array.isArray(parsed)) throw new Error('cards.txt is not a JSON array');
- cards = parsed.length;
- } catch (e) {
- return {
- ok: false,
- status: 400,
- error: `Invalid backup: the archive is missing a valid cards.txt at its root (${(e as Error).message}). Make sure you are uploading a ZIP created by "Save backup (ZIP)", not just any archive.`,
- };
- }
- try {
- const raw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8');
- const parsed = JSON.parse(raw || '[]');
- if (!Array.isArray(parsed)) throw new Error('portals.txt is not a JSON array');
- portals = parsed.length;
- } catch (e) {
- return {
- ok: false,
- status: 400,
- error: `Invalid backup: the archive is missing a valid portals.txt at its root (${(e as Error).message}). Make sure you are uploading a ZIP created by "Save backup (ZIP)", not just any archive.`,
- };
- }
-
- // Atomic swap: data → data.bak-<ts>, extract → data.
- const ts = Date.now();
- const backupOldDir = path.join(PROJECT_ROOT, `data.bak-${ts}`);
- try {
- await rename(DATA_DIR, backupOldDir);
- } catch (err) {
- const e = err as NodeJS.ErrnoException;
- if (e.code !== 'ENOENT') throw err;
- }
- try {
- await rename(extractDir, DATA_DIR);
- } catch (err) {
- try { await rename(backupOldDir, DATA_DIR); } catch { /* ignore */ }
- throw err;
- }
-
- return { ok: true, cards, portals, previousBackup: path.basename(backupOldDir) };
- } finally {
- try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
- }
- }
|