ソースを参照

Gestione Sotto Proxy

main
Lorenzo Pollutri 4週間前
コミット
c59d49943e
9個のファイルの変更123行の追加42行の削除
  1. +44
    -2
      README.md
  2. +31
    -30
      app/admin/page.tsx
  3. +2
    -1
      app/layout.tsx
  4. +3
    -2
      components/FullscreenLock.tsx
  5. +8
    -5
      components/HeroBanner.tsx
  6. +14
    -2
      components/PublicGrid.tsx
  7. +5
    -0
      lib/config.ts
  8. +14
    -0
      lib/url.ts
  9. +2
    -0
      next.config.ts

+ 44
- 2
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 `<Location>`:
```apache
<Location "/cards">
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
</Location>
```
Punti chiave:
- **Target con `/cards`** (non `/`): se il proxy strippa il prefisso, gli asset `/cards/_next/...` non vengono mappati e la pagina si rompe.
- **`<Location "/cards">` 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):


+ 31
- 30
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<string | null> {
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() {
<h1 className="text-2xl font-bold">Captive Portal CMS</h1>
<p className="text-sm text-blue-200">Local Administration</p>
</div>
<a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
<a href={withBasePath('/')} target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
View Live Portal ↗
</a>
</div>
@@ -715,8 +716,8 @@ export default function AdminDashboard() {
return <div className="w-16 h-16 bg-gray-200 rounded-md shadow-sm flex items-center justify-center text-gray-400 text-xs shrink-0">No Image</div>;
}
return isVideoUrl(previewUrl)
? <video src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
: <img src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />;
? <video src={withBasePath(previewUrl)} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
: <img src={withBasePath(previewUrl)} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />;
})()}
<div>
<span className="font-semibold text-gray-800 block">{card.title}</span>
@@ -799,7 +800,7 @@ export default function AdminDashboard() {
<style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => `
@font-face {
font-family: '${previewFontFamily(f)}';
src: url('/api/fonts?name=${encodeURIComponent(f)}') format('${fontFormatFromName(f)}');
src: url('${withBasePath('/api/fonts?name=' + encodeURIComponent(f))}') format('${fontFormatFromName(f)}');
font-display: swap;
}`).join('') }} />
<StyledSelect<string>
@@ -825,8 +826,8 @@ export default function AdminDashboard() {
{uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
{portal.logoUrl && (
<div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
<img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" />
<a href={portal.logoUrl} download={extractFileName(portal.logoUrl)} className="absolute -top-2 right-6 bg-gray-700 hover:bg-gray-800 text-white w-6 h-6 rounded-full text-xs font-bold shadow flex items-center justify-center" title="Scarica" aria-label="Scarica logo">⬇</a>
<img src={withBasePath(portal.logoUrl)} className="h-16 object-contain" alt="Logo Preview" />
<a href={withBasePath(portal.logoUrl)} download={extractFileName(portal.logoUrl)} className="absolute -top-2 right-6 bg-gray-700 hover:bg-gray-800 text-white w-6 h-6 rounded-full text-xs font-bold shadow flex items-center justify-center" title="Scarica" aria-label="Scarica logo">⬇</a>
<button onClick={() => setPortal({...portal, logoUrl: ''})} className="absolute -top-2 -right-2 bg-red-500 text-white w-6 h-6 rounded-full text-xs font-bold hover:bg-red-600 shadow">✕</button>
</div>
)}
@@ -839,8 +840,8 @@ export default function AdminDashboard() {
{uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
{portal.heroImageUrl && (
<div className="mt-2 relative rounded shadow border inline-block w-full">
<img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
<a href={portal.heroImageUrl} download={extractFileName(portal.heroImageUrl)} className="absolute top-2 right-12 bg-gray-700 hover:bg-gray-800 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold shadow-lg" title="Scarica" aria-label="Scarica hero">⬇</a>
<img src={withBasePath(portal.heroImageUrl)} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
<a href={withBasePath(portal.heroImageUrl)} download={extractFileName(portal.heroImageUrl)} className="absolute top-2 right-12 bg-gray-700 hover:bg-gray-800 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold shadow-lg" title="Scarica" aria-label="Scarica hero">⬇</a>
<button onClick={() => setPortal({...portal, heroImageUrl: ''})} className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold hover:bg-red-600 shadow-lg">✕</button>
</div>
)}
@@ -1091,12 +1092,12 @@ export default function AdminDashboard() {
{isEditing.imageUrl && (
<div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
{isVideoUrl(isEditing.imageUrl) ? (
<video src={isEditing.imageUrl} className="w-full h-32 object-cover" muted playsInline />
<video src={withBasePath(isEditing.imageUrl)} className="w-full h-32 object-cover" muted playsInline />
) : (
<img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" />
<img src={withBasePath(isEditing.imageUrl)} className="w-full h-32 object-cover" alt="Cover preview" />
)}
<a
href={isEditing.imageUrl}
href={withBasePath(isEditing.imageUrl)}
download={extractFileName(isEditing.imageUrl)}
className="absolute top-2 right-12 bg-gray-700 hover:bg-gray-800 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Scarica"
@@ -1144,11 +1145,11 @@ export default function AdminDashboard() {
<div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0">
{video ? (
<>
<video src={item.url} className="w-full h-full object-cover" muted preload="metadata" />
<video src={withBasePath(item.url)} className="w-full h-full object-cover" muted preload="metadata" />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div>
</>
) : (
<img src={item.url} className="w-full h-full object-cover" alt="" />
<img src={withBasePath(item.url)} className="w-full h-full object-cover" alt="" />
)}
{isTranscoding && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/75 text-white text-[10px] font-semibold leading-tight gap-0.5">
@@ -1200,7 +1201,7 @@ export default function AdminDashboard() {
disabled={i === (isEditing.extraMedia || []).length - 1}
>↓</button>
<a
href={item.url}
href={withBasePath(item.url)}
download={extractFileName(item.url)}
className="bg-gray-500 hover:bg-gray-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0 flex items-center justify-center"
title="Scarica"


+ 2
- 1
app/layout.tsx ファイルの表示

@@ -5,6 +5,7 @@ import fs from "fs/promises";
import path from "path";
import { getPortals } from "@/lib/db";
import { DEFAULT_FONT } from "@/lib/config";
import { withBasePath } from "@/lib/url";
import "./globals.css";

export const metadata: Metadata = {
@@ -55,7 +56,7 @@ export default async function RootLayout({

let fontStyleCss = "";
if (chosenFont) {
const fontUrl = (name: string) => `/api/fonts?name=${encodeURIComponent(name)}`;
const fontUrl = (name: string) => withBasePath(`/api/fonts?name=${encodeURIComponent(name)}`);
const faceBlock = (file: string, weight: 400 | 700, style: 'normal' | 'italic') => `
@font-face {
font-family: 'PortalFont';


+ 3
- 2
components/FullscreenLock.tsx ファイルの表示

@@ -1,11 +1,12 @@
'use client';
import { Card } from '@/types';
import { withBasePath } from '@/lib/url';

const VIDEO_RE = /\.(mp4|m4v|webm|mov|qt|ogv|ogg)(\?|$)/i;

export default function FullscreenLock({ card }: { card: Card }) {
const url = card.imageUrl;
const isVideo = !!url && VIDEO_RE.test(url);
const isVideo = !!card.imageUrl && VIDEO_RE.test(card.imageUrl);
const url = withBasePath(card.imageUrl);

return (
<div


+ 8
- 5
components/HeroBanner.tsx ファイルの表示

@@ -1,13 +1,16 @@
import { Portal } from '@/types';
import { withBasePath } from '@/lib/url';
export default function HeroBanner({ portal }: { portal: Partial<Portal> }) {
const themeColor = portal?.themeColor || '#1e3a8a';
const heroImageUrl = withBasePath(portal?.heroImageUrl);
const logoUrl = withBasePath(portal?.logoUrl);
return (
<div
className="relative text-white text-center py-20 px-4 min-h-[40vh] flex flex-col justify-center"
style={{
backgroundImage: portal?.heroImageUrl ? `url(${portal.heroImageUrl})` : 'none',
backgroundImage: heroImageUrl ? `url(${heroImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: themeColor
@@ -24,10 +27,10 @@ export default function HeroBanner({ portal }: { portal: Partial<Portal> }) {
></div>
<div className="relative z-10 max-w-4xl mx-auto flex flex-col items-center">
{portal?.logoUrl && (
<img
src={portal.logoUrl}
alt="Institution Logo"
{logoUrl && (
<img
src={logoUrl}
alt="Institution Logo"
className="h-24 mb-6 object-contain bg-white/90 p-2 rounded-xl shadow-lg"
/>
)}


+ 14
- 2
components/PublicGrid.tsx ファイルの表示

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Card, MediaItem } from '@/types';
import { withBasePath } from '@/lib/url';
const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|mkv|avi|divx|wmv|asf|flv|f4v|3gp|3gpp|3g2|mts|m2ts|ts|mpg|mpeg|vob|mxf|ogv|ogg';
const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url);
@@ -585,7 +586,18 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void })
);
}
export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
export default function PublicGrid({ cards: rawCards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
// Prefissa una volta tutti gli URL dei media col basePath: copre griglia, carosello,
// fullscreen e flip-book, che derivano tutti da qui. Gli URL salvati restano senza prefisso.
const cards = useMemo(
() => rawCards.map(c => ({
...c,
imageUrl: withBasePath(c.imageUrl),
extraMedia: c.extraMedia?.map(m => ({ ...m, url: withBasePath(m.url) })),
})),
[rawCards],
);
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null);


+ 5
- 0
lib/config.ts ファイルの表示

@@ -1,5 +1,10 @@
// Feature flags compilati nel bundle. Modifica e ricostruisci (npm run build) per applicare.

// Sotto-percorso pubblico del portale. '' = servito sulla radice.
// Es. '/cards' → tutto risponde sotto https://host/cards/ (dietro reverse proxy).
// Build-time: deve combaciare con basePath in next.config.ts (che lo importa da qui).
export const BASE_PATH = '/cards';

export const EXTERNAL_LINK_ENABLED = true;

// Mostra la sezione "Factory Preset" (salva preset + factory reset) nell'admin.


+ 14
- 0
lib/url.ts ファイルの表示

@@ -0,0 +1,14 @@
import { BASE_PATH } from './config';

// Prefissa con BASE_PATH gli URL assoluti gestiti a mano (fetch, src, href, font url).
// Next applica basePath solo a next/link/router/image e agli asset _next/ — non alle
// stringhe URL scritte a mano. Gli URL salvati in data/ restano senza prefisso
// (portabili tra macchine con basePath diverso); il prefisso si aggiunge solo qui, al render.
export function withBasePath(p?: string | null): string {
if (!p) return p ?? '';
if (!BASE_PATH) return p;
if (/^(https?:|data:|blob:|mailto:|tel:)/i.test(p)) return p; // URL esterni/non-path
if (!p.startsWith('/')) return p; // path relativi: invariati
if (p === BASE_PATH || p.startsWith(BASE_PATH + '/')) return p; // già prefissato (idempotente)
return `${BASE_PATH}${p}`;
}

+ 2
- 0
next.config.ts ファイルの表示

@@ -1,6 +1,8 @@
import type { NextConfig } from "next";
import { BASE_PATH } from "./lib/config";

const nextConfig: NextConfig = {
basePath: BASE_PATH || undefined,
allowedDevOrigins: ['10.210.1.225'],
serverExternalPackages: ['sanitize-html', 'file-type'],
};


読み込み中…
キャンセル
保存