Просмотр исходного кода

implementato factory reset

main
Lorenzo Pollutri 1 месяц назад
Родитель
Сommit
2314317f96
5 измененных файлов: 293 добавлений и 77 удалений
  1. +86
    -0
      app/admin/page.tsx
  2. +71
    -0
      app/api/admin/factory-preset/route.ts
  3. +35
    -0
      app/api/admin/factory-reset/route.ts
  4. +13
    -77
      app/api/admin/restore/route.ts
  5. +88
    -0
      lib/restore-zip.ts

+ 86
- 0
app/admin/page.tsx Просмотреть файл

@@ -581,6 +581,59 @@ export default function AdminDashboard() {
};
const [restoring, setRestoring] = useState(false);
const [factoryPreset, setFactoryPreset] = useState<{ exists: boolean; sizeBytes?: number; modifiedAt?: string } | null>(null);
const [savingPreset, setSavingPreset] = useState(false);
const [factoryResetting, setFactoryResetting] = useState(false);
const refreshFactoryPreset = async () => {
try {
const res = await fetch('/api/admin/factory-preset');
if (res.ok) setFactoryPreset(await res.json());
} catch { /* ignore */ }
};
useEffect(() => { void refreshFactoryPreset(); }, []);
const handleSaveFactoryPreset = async () => {
const msg = factoryPreset?.exists
? 'Sovrascrivere il factory preset esistente con lo stato attuale?'
: 'Salvare lo stato attuale come factory preset?';
if (!window.confirm(msg)) return;
setSavingPreset(true);
try {
const res = await fetch('/api/admin/factory-preset', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore (${res.status})`, 'error');
return;
}
showToast('Factory preset aggiornato.');
await refreshFactoryPreset();
} catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
} finally {
setSavingPreset(false);
}
};
const handleFactoryReset = async () => {
if (!window.confirm('FACTORY RESET — tutti i dati attuali verranno sostituiti col factory preset. Continuare?')) return;
setFactoryResetting(true);
try {
const res = await fetch('/api/admin/factory-reset', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore (${res.status})`, 'error');
return;
}
showToast(`Factory reset eseguito: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
} finally {
setFactoryResetting(false);
}
};
const handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
@@ -840,6 +893,39 @@ export default function AdminDashboard() {
</div>
</div>
<div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset</h3>
<p className="text-xs text-gray-500 mb-2">
Stato &ldquo;di fabbrica&rdquo; sempre ripristinabile con un click. Utile per preparare preset standard da distribuire alle macchine MajorNet:
configura il portale come vuoi, salvalo come preset, poi copia <code>factory/preset.zip</code> sulle altre macchine.
</p>
<p className="text-xs text-gray-700 mb-4">
Preset attuale: {factoryPreset === null ? '…'
: factoryPreset.exists
? <span className="text-green-700 font-medium">presente · {((factoryPreset.sizeBytes ?? 0) / (1024 * 1024)).toFixed(1)} MB · {factoryPreset.modifiedAt ? new Date(factoryPreset.modifiedAt).toLocaleString('it-IT') : '?'}</span>
: <span className="text-gray-400 italic">nessun preset configurato</span>}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSaveFactoryPreset}
disabled={savingPreset}
className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60"
>
{savingPreset ? 'Salvataggio…' : '💾 Salva stato attuale come Factory Preset'}
</button>
<button
type="button"
onClick={handleFactoryReset}
disabled={factoryResetting || !factoryPreset?.exists}
title={factoryPreset?.exists ? undefined : 'Nessun preset configurato'}
className="bg-red-700 text-white px-5 py-2.5 rounded-lg hover:bg-red-800 font-medium shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
>
{factoryResetting ? 'Reset in corso…' : '🏭 Factory Reset'}
</button>
</div>
</div>
<div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
<button onClick={handleSavePortal} disabled={savingPortal} className="bg-blue-600 text-white px-10 py-3 rounded-lg hover:bg-blue-700 font-bold shadow disabled:opacity-50 transition-colors">
{savingPortal ? 'Saving...' : 'Save Portal Settings'}


+ 71
- 0
app/api/admin/factory-preset/route.ts Просмотреть файл

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server';
import { spawn } from 'node:child_process';
import { mkdir, stat, unlink, rename } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { checkSystemBin } from '@/lib/system-bins';

export const dynamic = 'force-dynamic';
export const maxDuration = 600;

const PROJECT_ROOT = process.cwd();
const DATA_DIR = path.join(PROJECT_ROOT, 'data');
const FACTORY_DIR = path.join(PROJECT_ROOT, 'factory');
const PRESET_PATH = path.join(FACTORY_DIR, 'preset.zip');

// GET — stato del preset (per UI: presente o no, dimensione, data).
export async function GET() {
try {
const s = await stat(PRESET_PATH);
return NextResponse.json({
exists: true,
sizeBytes: s.size,
modifiedAt: s.mtime.toISOString(),
});
} catch {
return NextResponse.json({ exists: false });
}
}

// POST — crea il preset zippando lo stato corrente di data/.
export async function POST() {
if (!(await checkSystemBin('zip', '-v'))) {
return NextResponse.json({ error: "Binario 'zip' non disponibile sul server." }, { status: 503 });
}
await mkdir(FACTORY_DIR, { recursive: true });

// Scrive prima su .tmp poi rename atomico → mai un preset.zip parziale leggibile.
const tmpPath = path.join(FACTORY_DIR, `preset.zip.${crypto.randomUUID()}.tmp`);

const child = spawn(
'zip',
['-r', '-', 'cards.txt', 'portals.txt', 'uploads', 'fonts', '-x', 'uploads/.tmp/*'],
{ cwd: DATA_DIR },
);

const out = createWriteStream(tmpPath);
child.stdout.pipe(out);

const exit: number = await new Promise(resolve => {
let exitCode = -1;
child.on('error', () => resolve(-1));
child.on('exit', code => { exitCode = code ?? -1; });
out.on('finish', () => resolve(exitCode));
out.on('error', () => resolve(-1));
});

// 0 = ok, 12 = "nothing to do" (data/ vuoto: accettiamo, sarà uno zip minimale)
if (exit !== 0 && exit !== 12) {
try { await unlink(tmpPath); } catch { /* ignore */ }
return NextResponse.json({ error: `zip terminato con codice ${exit}` }, { status: 500 });
}

await rename(tmpPath, PRESET_PATH);
const s = await stat(PRESET_PATH);
return NextResponse.json({
ok: true,
sizeBytes: s.size,
modifiedAt: s.mtime.toISOString(),
});
}

+ 35
- 0
app/api/admin/factory-reset/route.ts Просмотреть файл

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { checkSystemBin } from '@/lib/system-bins';
import { restoreFromZipFile } from '@/lib/restore-zip';

export const dynamic = 'force-dynamic';
export const maxDuration = 600;

const PRESET_PATH = path.join(process.cwd(), 'factory', 'preset.zip');

export async function POST() {
if (!(await checkSystemBin('unzip', '-v'))) {
return NextResponse.json({ error: "Binario 'unzip' non disponibile sul server." }, { status: 503 });
}

try {
await stat(PRESET_PATH);
} catch {
return NextResponse.json(
{ error: 'Nessun factory preset configurato. Imposta prima lo stato attuale come preset.' },
{ status: 404 },
);
}

const result = await restoreFromZipFile(PRESET_PATH);
if (!result.ok) {
return NextResponse.json({ error: result.error, detail: result.detail }, { status: result.status });
}
return NextResponse.json({
ok: true,
restored: { cards: result.cards, portals: result.portals },
previousDataBackup: result.previousBackup,
});
}

+ 13
- 77
app/api/admin/restore/route.ts Просмотреть файл

@@ -1,26 +1,15 @@
import { NextResponse } from 'next/server';
import { spawn } from 'node:child_process';
import { mkdir, writeFile, readFile, rename, rm } from 'node:fs/promises';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { checkSystemBin } from '@/lib/system-bins';
import { restoreFromZipFile } from '@/lib/restore-zip';

export const dynamic = 'force-dynamic';
export const maxDuration = 600;

const PROJECT_ROOT = process.cwd();
const DATA_DIR = path.join(PROJECT_ROOT, 'data');
const RESTORE_STAGING = path.join(PROJECT_ROOT, '.restore-staging');

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 }));
});
}
const UPLOAD_STAGING = path.join(PROJECT_ROOT, '.restore-staging');

export async function POST(request: Request) {
if (!(await checkSystemBin('unzip', '-v'))) {
@@ -28,9 +17,8 @@ export async function POST(request: Request) {
}

const sessionId = crypto.randomUUID();
const sessionDir = path.join(RESTORE_STAGING, sessionId);
const sessionDir = path.join(UPLOAD_STAGING, `upload-${sessionId}`);
const zipPath = path.join(sessionDir, 'backup.zip');
const extractDir = path.join(sessionDir, 'extract');

try {
const formData = await request.formData();
@@ -39,74 +27,22 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Nessun file ricevuto.' }, { status: 400 });
}

await mkdir(extractDir, { recursive: true });
const buf = Buffer.from(await file.arrayBuffer());
await writeFile(zipPath, buf);

const { code, stderr } = await runUnzip(zipPath, extractDir);
if (code !== 0) {
return NextResponse.json(
{ error: 'Impossibile estrarre lo ZIP.', detail: stderr.split('\n').slice(0, 3).join(' ') },
{ status: 400 },
);
}

// Validazione struttura: cards.txt e portals.txt devono esistere E essere JSON validi.
let cardsCount = 0;
let portalsCount = 0;
try {
const cardsRaw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8');
const cardsParsed = JSON.parse(cardsRaw || '[]');
if (!Array.isArray(cardsParsed)) throw new Error('cards.txt non è un array JSON');
cardsCount = cardsParsed.length;
} catch (e) {
return NextResponse.json(
{ error: `Backup non valido: cards.txt assente o malformato (${(e as Error).message}).` },
{ status: 400 },
);
}
try {
const portalsRaw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8');
const portalsParsed = JSON.parse(portalsRaw || '[]');
if (!Array.isArray(portalsParsed)) throw new Error('portals.txt non è un array JSON');
portalsCount = portalsParsed.length;
} catch (e) {
return NextResponse.json(
{ error: `Backup non valido: portals.txt assente o malformato (${(e as Error).message}).` },
{ status: 400 },
);
}
await mkdir(sessionDir, { recursive: true });
await writeFile(zipPath, Buffer.from(await file.arrayBuffer()));

// 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;
// data/ non esisteva: ok, andiamo avanti
const result = await restoreFromZipFile(zipPath);
if (!result.ok) {
return NextResponse.json({ error: result.error, detail: result.detail }, { status: result.status });
}
try {
await rename(extractDir, DATA_DIR);
} catch (err) {
// Revert: rimettiamo data/ al suo posto.
try { await rename(backupOldDir, DATA_DIR); } catch { /* ignore */ }
throw err;
}

// Cleanup zip + cartella staging della sessione (l'extract è già stato spostato).
try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }

return NextResponse.json({
ok: true,
restored: { cards: cardsCount, portals: portalsCount },
previousDataBackup: path.basename(backupOldDir),
restored: { cards: result.cards, portals: result.portals },
previousDataBackup: result.previousBackup,
});
} catch (error) {
console.error('Restore error:', error);
// Cleanup staging in caso di errore non gestito sopra.
try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
return NextResponse.json({ error: 'Errore durante il ripristino.' }, { status: 500 });
} finally {
try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}

+ 88
- 0
lib/restore-zip.ts Просмотреть файл

@@ -0,0 +1,88 @@
// 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: 'Impossibile estrarre lo ZIP.',
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 non è un array JSON');
cards = parsed.length;
} catch (e) {
return { ok: false, status: 400, error: `Backup non valido: cards.txt assente o malformato (${(e as Error).message}).` };
}
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 non è un array JSON');
portals = parsed.length;
} catch (e) {
return { ok: false, status: 400, error: `Backup non valido: portals.txt assente o malformato (${(e as Error).message}).` };
}

// 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 */ }
}
}

Загрузка…
Отмена
Сохранить