// 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-/ come safety net (mai cancellata automaticamente). */ export async function restoreFromZipFile(zipPath: string): Promise { 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-, 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 */ } } }