From c59d49943e32f1f4d7501b2f471a224a16f5a0ac Mon Sep 17 00:00:00 2001 From: pollutri Date: Fri, 29 May 2026 12:30:38 +0200 Subject: [PATCH] Gestione Sotto Proxy --- README.md | 46 ++++++++++++++++++++++++-- app/admin/page.tsx | 61 ++++++++++++++++++----------------- app/layout.tsx | 3 +- components/FullscreenLock.tsx | 5 +-- components/HeroBanner.tsx | 13 +++++--- components/PublicGrid.tsx | 16 +++++++-- lib/config.ts | 5 +++ lib/url.ts | 14 ++++++++ next.config.ts | 2 ++ 9 files changed, 123 insertions(+), 42 deletions(-) create mode 100644 lib/url.ts diff --git a/README.md b/README.md index 83f8309..4074977 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ CMS per portali captive: gestione di card informative, gallerie, flip-book e con 9. [Backup e ripristino](#backup-e-ripristino) 10. [Factory Preset (developer)](#factory-preset-developer) 11. [Font](#font) -12. [Prerequisiti di sistema](#prerequisiti-di-sistema) -13. [Risoluzione problemi](#risoluzione-problemi) +12. [Deploy sotto sotto-percorso (basePath) dietro Apache](#deploy-sotto-sotto-percorso-basepath-dietro-apache) +13. [Prerequisiti di sistema](#prerequisiti-di-sistema) +14. [Risoluzione problemi](#risoluzione-problemi) --- @@ -260,6 +261,47 @@ I font vanno collocati in `data/fonts/` nei formati `.woff2`, `.woff`, `.ttf`, ` --- +## Deploy sotto sotto-percorso (basePath) dietro Apache + +Il portale può essere servito sotto un sotto-percorso (es. `https://host/cards/`) tramite reverse proxy. Servono **due cose insieme**: il `basePath` nel codice e il proxy configurato per **non** strippare il prefisso. + +### Lato codice +Imposta il percorso in [`lib/config.ts`](lib/config.ts) e ricostruisci: +```ts +export const BASE_PATH = '/cards'; // '' = servito sulla radice +``` +È l'unica modifica necessaria: [`next.config.ts`](next.config.ts) lo importa per `basePath`, e l'helper [`withBasePath`](lib/url.ts) lo applica a tutte le URL costruite a mano (chiamate API, sorgenti media, font, link). Gli URL salvati in `data/` restano **senza** prefisso, quindi i backup restano portabili anche tra macchine con `BASE_PATH` diverso. Poi: +```bash +npm run build && npm start +``` +Verifica diretta su Next (senza proxy): le pagine rispondono su `http://localhost:3000/cards` e `…/cards/admin`. + +### Lato Apache (reverse proxy) +Con `basePath` attivo, Next emette asset e API sotto `/cards/...`: il proxy deve **preservare** il prefisso (non strippare). Esempio di ``: +```apache + + Header always unset X-Frame-Options + Header set X-Frame-Options "ALLOWALL" + Header always set Content-Security-Policy "frame-ancestors 'self' *" + + # Target CON /cards: il prefisso viene preservato (niente stripping) + ProxyPass "http://localhost:3000/cards" connectiontimeout=5 timeout=600 keepalive=on + ProxyPassReverse "http://localhost:3000/cards" + + RewriteCond %{HTTPS} on + RewriteRule .* - [E=DASH_PROTO:https] + RewriteCond %{HTTPS} off + RewriteRule .* - [E=DASH_PROTO:http] + + RequestHeader set X-Forwarded-Proto %{DASH_PROTO}e + +``` +Punti chiave: +- **Target con `/cards`** (non `/`): se il proxy strippa il prefisso, gli asset `/cards/_next/...` non vengono mappati e la pagina si rompe. +- **`` senza slash finale**: intercetta sia `/cards` (home canonica) sia `/cards/...`, evitando il redirect `308` di `/cards/` → `/cards`. +- **`timeout=600`**: upload video fino a 1 GB e download dei backup ZIP possono superare i 30s. +- `BASE_PATH` e il target del proxy devono combaciare. Per tornare alla radice: `BASE_PATH = ''` + proxy verso `http://localhost:3000/`. + ## Prerequisiti di sistema Sul server servono alcuni binari di sistema (richiamati direttamente, non via npm): diff --git a/app/admin/page.tsx b/app/admin/page.tsx index fbbd641..e880311 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react'; import { Card, Portal, MediaItem, CardType } from '@/types'; import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT, FACTORY_RESET_ENABLED } from '@/lib/config'; import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation'; +import { withBasePath } from '@/lib/url'; type CharCounterProps = { value: string | undefined; limit: number }; function CharCounter({ value, limit }: CharCounterProps) { @@ -168,7 +169,7 @@ const extractFileName = (url: string): string => { async function uploadBlobAsImage(blob: Blob, name: string): Promise { const formData = new FormData(); formData.append('file', new File([blob], name, { type: blob.type || 'image/png' })); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData }); const data = await res.json(); return data.url || null; } @@ -275,9 +276,9 @@ export default function AdminDashboard() { }; useEffect(() => { - fetch('/api/cards').then(res => res.json()).then(setCards); - fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data)); - fetch('/api/fonts').then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([])); + fetch(withBasePath('/api/cards')).then(res => res.json()).then(setCards); + fetch(withBasePath('/api/portals')).then(res => res.json()).then(data => data && setPortal(data)); + fetch(withBasePath('/api/fonts')).then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([])); }, []); // Poll pending transcode jobs every 2s. On 'done' we drop the entry from the @@ -294,7 +295,7 @@ export default function AdminDashboard() { for (const [url, j] of pendingEntries) { if (cancelled) return; try { - const res = await fetch(`/api/transcode/${j.jobId}`); + const res = await fetch(withBasePath(`/api/transcode/${j.jobId}`)); if (!res.ok) continue; const data = await res.json(); if (cancelled) return; @@ -341,7 +342,7 @@ export default function AdminDashboard() { const formData = new FormData(); formData.append('file', e.target.files[0]); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData }); const data = await res.json(); if (data.url) { @@ -406,7 +407,7 @@ export default function AdminDashboard() { } else { const formData = new FormData(); formData.append('file', file); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData }); const data = await res.json(); if (!data.url) continue; @@ -501,7 +502,7 @@ export default function AdminDashboard() { const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card; - const res = await fetch('/api/cards', { + const res = await fetch(withBasePath('/api/cards'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard) }); @@ -534,7 +535,7 @@ export default function AdminDashboard() { setConfirmDialog({ message: 'Are you sure you want to delete this card? This action cannot be undone.', onConfirm: async () => { - await fetch(`/api/cards?id=${id}`, { method: 'DELETE' }); + await fetch(withBasePath(`/api/cards?id=${id}`), { method: 'DELETE' }); setCards(prev => prev.filter(c => c.id !== id)); setConfirmDialog(null); showToast('Card successfully deleted.'); @@ -560,7 +561,7 @@ export default function AdminDashboard() { setCards(updatedCards); // Persist the new order to the backend - await fetch('/api/cards', { + await fetch(withBasePath('/api/cards'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedCards) @@ -569,7 +570,7 @@ export default function AdminDashboard() { const handleSavePortal = async () => { setSavingPortal(true); - await fetch('/api/portals', { + await fetch(withBasePath('/api/portals'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal) }); setSavingPortal(false); @@ -577,7 +578,7 @@ export default function AdminDashboard() { }; const handleBackupDownload = () => { - window.location.href = '/api/admin/backup'; + window.location.href = withBasePath('/api/admin/backup'); }; const [restoring, setRestoring] = useState(false); @@ -591,7 +592,7 @@ export default function AdminDashboard() { try { const fd = new FormData(); fd.append('file', file); - const res = await fetch('/api/admin/restore', { method: 'POST', body: fd }); + const res = await fetch(withBasePath('/api/admin/restore'), { method: 'POST', body: fd }); const data = await res.json().catch(() => ({})); if (!res.ok) { showToast(data?.error || `Errore ripristino (${res.status})`, 'error'); @@ -613,7 +614,7 @@ export default function AdminDashboard() { const refreshFactoryPreset = async () => { try { - const res = await fetch('/api/admin/factory-preset'); + const res = await fetch(withBasePath('/api/admin/factory-preset')); if (res.ok) setFactoryPreset(await res.json()); } catch { /* ignore */ } }; @@ -628,7 +629,7 @@ export default function AdminDashboard() { if (!window.confirm(msg)) return; setSavingPreset(true); try { - const res = await fetch('/api/admin/factory-preset', { method: 'POST' }); + const res = await fetch(withBasePath('/api/admin/factory-preset'), { method: 'POST' }); const data = await res.json().catch(() => ({})); if (!res.ok) { showToast(data?.error || `Errore (${res.status})`, 'error'); @@ -647,7 +648,7 @@ export default function AdminDashboard() { 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 res = await fetch(withBasePath('/api/admin/factory-reset'), { method: 'POST' }); const data = await res.json().catch(() => ({})); if (!res.ok) { showToast(data?.error || `Errore (${res.status})`, 'error'); @@ -674,7 +675,7 @@ export default function AdminDashboard() {

Captive Portal CMS

Local Administration

- + View Live Portal ↗ @@ -715,8 +716,8 @@ export default function AdminDashboard() { return
No Image
; } return isVideoUrl(previewUrl) - ?