| @@ -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 “di fabbrica” 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'} | |||
| @@ -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(), | |||
| }); | |||
| } | |||
| @@ -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, | |||
| }); | |||
| } | |||
| @@ -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 */ } | |||
| } | |||
| } | |||
| @@ -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 */ } | |||
| } | |||
| } | |||