No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

97 líneas
3.8 KiB

  1. // Logica condivisa fra POST /api/admin/restore (upload) e
  2. // POST /api/admin/factory-reset (file pre-caricato).
  3. // Estrae uno zip in staging, valida la struttura, e fa lo swap atomico di data/.
  4. import { spawn } from 'node:child_process';
  5. import { mkdir, readFile, rename, rm } from 'node:fs/promises';
  6. import path from 'node:path';
  7. import crypto from 'node:crypto';
  8. const PROJECT_ROOT = process.cwd();
  9. const DATA_DIR = path.join(PROJECT_ROOT, 'data');
  10. const RESTORE_STAGING = path.join(PROJECT_ROOT, '.restore-staging');
  11. export type RestoreResult =
  12. | { ok: true; cards: number; portals: number; previousBackup: string }
  13. | { ok: false; status: number; error: string; detail?: string };
  14. function runUnzip(zipPath: string, destDir: string): Promise<{ code: number; stderr: string }> {
  15. return new Promise(resolve => {
  16. const child = spawn('unzip', ['-o', '-q', zipPath, '-d', destDir]);
  17. let stderr = '';
  18. child.stderr.on('data', d => { stderr += d.toString(); });
  19. child.on('error', () => resolve({ code: -1, stderr: stderr || 'unzip non avviato' }));
  20. child.on('exit', code => resolve({ code: code ?? -1, stderr }));
  21. });
  22. }
  23. /**
  24. * Estrae lo zip dal percorso dato, valida cards.txt + portals.txt come JSON array,
  25. * poi sostituisce atomicamente DATA_DIR. La vecchia data/ viene rinominata
  26. * data.bak-<ts>/ come safety net (mai cancellata automaticamente).
  27. */
  28. export async function restoreFromZipFile(zipPath: string): Promise<RestoreResult> {
  29. const sessionDir = path.join(RESTORE_STAGING, crypto.randomUUID());
  30. const extractDir = path.join(sessionDir, 'extract');
  31. await mkdir(extractDir, { recursive: true });
  32. try {
  33. const { code, stderr } = await runUnzip(zipPath, extractDir);
  34. if (code !== 0) {
  35. return {
  36. ok: false,
  37. status: 400,
  38. 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.',
  39. detail: stderr.split('\n').slice(0, 3).join(' '),
  40. };
  41. }
  42. let cards = 0;
  43. let portals = 0;
  44. try {
  45. const raw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8');
  46. const parsed = JSON.parse(raw || '[]');
  47. if (!Array.isArray(parsed)) throw new Error('cards.txt is not a JSON array');
  48. cards = parsed.length;
  49. } catch (e) {
  50. return {
  51. ok: false,
  52. status: 400,
  53. 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.`,
  54. };
  55. }
  56. try {
  57. const raw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8');
  58. const parsed = JSON.parse(raw || '[]');
  59. if (!Array.isArray(parsed)) throw new Error('portals.txt is not a JSON array');
  60. portals = parsed.length;
  61. } catch (e) {
  62. return {
  63. ok: false,
  64. status: 400,
  65. 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.`,
  66. };
  67. }
  68. // Atomic swap: data → data.bak-<ts>, extract → data.
  69. const ts = Date.now();
  70. const backupOldDir = path.join(PROJECT_ROOT, `data.bak-${ts}`);
  71. try {
  72. await rename(DATA_DIR, backupOldDir);
  73. } catch (err) {
  74. const e = err as NodeJS.ErrnoException;
  75. if (e.code !== 'ENOENT') throw err;
  76. }
  77. try {
  78. await rename(extractDir, DATA_DIR);
  79. } catch (err) {
  80. try { await rename(backupOldDir, DATA_DIR); } catch { /* ignore */ }
  81. throw err;
  82. }
  83. return { ok: true, cards, portals, previousBackup: path.basename(backupOldDir) };
  84. } finally {
  85. try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
  86. }
  87. }