| @@ -581,6 +581,59 @@ export default function AdminDashboard() { | |||||
| }; | }; | ||||
| const [restoring, setRestoring] = useState(false); | 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 handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
| const file = e.target.files?.[0]; | const file = e.target.files?.[0]; | ||||
| e.target.value = ''; | e.target.value = ''; | ||||
| @@ -840,6 +893,39 @@ export default function AdminDashboard() { | |||||
| </div> | </div> | ||||
| </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"> | <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"> | <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'} | {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 { 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 path from 'node:path'; | ||||
| import crypto from 'node:crypto'; | import crypto from 'node:crypto'; | ||||
| import { checkSystemBin } from '@/lib/system-bins'; | import { checkSystemBin } from '@/lib/system-bins'; | ||||
| import { restoreFromZipFile } from '@/lib/restore-zip'; | |||||
| export const dynamic = 'force-dynamic'; | export const dynamic = 'force-dynamic'; | ||||
| export const maxDuration = 600; | export const maxDuration = 600; | ||||
| const PROJECT_ROOT = process.cwd(); | 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) { | export async function POST(request: Request) { | ||||
| if (!(await checkSystemBin('unzip', '-v'))) { | if (!(await checkSystemBin('unzip', '-v'))) { | ||||
| @@ -28,9 +17,8 @@ export async function POST(request: Request) { | |||||
| } | } | ||||
| const sessionId = crypto.randomUUID(); | 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 zipPath = path.join(sessionDir, 'backup.zip'); | ||||
| const extractDir = path.join(sessionDir, 'extract'); | |||||
| try { | try { | ||||
| const formData = await request.formData(); | const formData = await request.formData(); | ||||
| @@ -39,74 +27,22 @@ export async function POST(request: Request) { | |||||
| return NextResponse.json({ error: 'Nessun file ricevuto.' }, { status: 400 }); | 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({ | return NextResponse.json({ | ||||
| ok: true, | ok: true, | ||||
| restored: { cards: cardsCount, portals: portalsCount }, | |||||
| previousDataBackup: path.basename(backupOldDir), | |||||
| restored: { cards: result.cards, portals: result.portals }, | |||||
| previousDataBackup: result.previousBackup, | |||||
| }); | }); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Restore error:', 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 }); | 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 */ } | |||||
| } | |||||
| } | |||||