Sezione Campus: layout dedicato, route protette e documentazione

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 15:40:07 +02:00
parent 7baebebc09
commit c4107b84a5
15 changed files with 565 additions and 52 deletions
+2
View File
@@ -14,6 +14,8 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY scripts ./scripts COPY scripts ./scripts
# Contenuti della sezione Campus (generati con `npm run sync-campus` e committati)
COPY campus-content ./campus-content
ENV HOST=0.0.0.0 \ ENV HOST=0.0.0.0 \
PORT=4321 \ PORT=4321 \
+21 -8
View File
@@ -26,7 +26,8 @@ Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina. - **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito. - **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo. - **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo.
- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`). - **Gestione utenti** con quattro ruoli (`admin`, `superuser`, `user`, `campus`).
- **Campus**: sezione riservata con le guide di studio del corso Biohacking Campus, accessibile solo dopo il login e con navigazione autonoma.
- **Form contatti** con validazione, honeypot antispam, ratelimit e invio email via SMTP. - **Form contatti** con validazione, honeypot antispam, ratelimit e invio email via SMTP.
## Installazione (sviluppo) ## Installazione (sviluppo)
@@ -53,7 +54,8 @@ Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato aut
| `npm run preview` | Anteprima locale della build | | `npm run preview` | Anteprima locale della build |
| `npm run start` | Server di produzione (`node --env-file=.env`) | | `npm run start` | Server di produzione (`node --env-file=.env`) |
| `npm run test` | Test con Vitest | | `npm run test` | Test con Vitest |
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user]` | | `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user\|campus]` |
| `npm run sync-campus` | Rigenera i contenuti del Campus: `-- <path-progetto-biohacking-campus>` |
## Gestione contenuti ## Gestione contenuti
@@ -69,31 +71,41 @@ Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui conten
## Ruoli e accessi ## Ruoli e accessi
Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`: Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
| Ruolo | Può fare | | Ruolo | Può fare |
|-------|----------| |-------|----------|
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) | | `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), Campus |
| `superuser` | Pannello contenuti + blog | | `superuser` | Pannello contenuti + blog |
| `user` | Solo i **propri** articoli del blog | | `user` | Solo i **propri** articoli del blog |
| `campus` | Solo la sezione riservata `/campus`; nessun accesso al pannello |
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password unatantum, cambio ruolo ed eliminazione, con guardie contro l'autoeliminazione e la rimozione dell'ultimo admin. Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, il Campus per il ruolo omonimo. La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password unatantum, cambio ruolo ed eliminazione, con guardie contro l'autoeliminazione e la rimozione dell'ultimo admin.
## Campus (sezione riservata)
La sezione `/campus` raccoglie le guide di studio del corso Biohacking Campus. È protetta dal middleware come il pannello: un visitatore anonimo viene mandato al login, un utente con ruolo non abilitato torna alla propria pagina iniziale. Le pagine non montano l'header e il footer del sito — la navigazione avviene attraverso la barra laterale delle aree, con un unico richiamo per tornare alla home — ma usano i font, i colori e il CSS globale di InsanityLab.
I contenuti non vengono scritti a mano: lo script `npm run sync-campus` legge le guide sorgente (`*.studio.md`) dal progetto Biohacking Campus, le converte in frammenti HTML e produce la cartella `campus-content/` (indice `nav.json`, pagine e immagini delle slide), che va committata perché il deploy la copia nell'immagine Docker. Lo script è idempotente: rigenerarlo dopo l'aggiunta di nuove lezioni aggiorna indice e pagine e rimuove quelle non più presenti. Le nuove guide vanno prima dichiarate nella struttura dei capitoli in `scripts/campus/structure.mjs`.
Le immagini delle slide sono servite dalla route `/campus/assets/...`, anch'essa protetta: non sono raggiungibili senza una sessione valida.
## Struttura del progetto ## Struttura del progetto
``` ```
insanitylab-website/ insanitylab-website/
├── src/ ├── src/
│ ├── pages/ # Route Astro (vetrina, /admin, /api) │ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus)
│ ├── components/ # Componenti (home/, content/, Header, Footer, …) │ ├── components/ # Componenti (home/, content/, Header, Footer, …)
│ ├── layouts/ # Layout Base │ ├── layouts/ # Layout Base, Admin, Campus
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed) │ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env │ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
│ ├── assets/img/ # Immagini ottimizzate da Astro │ ├── assets/img/ # Immagini ottimizzate da Astro
│ └── styles/ # global.css │ └── styles/ # global.css
├── data/ # SQLite (volume in produzione) ├── data/ # SQLite (volume in produzione)
├── uploads/ # Upload immagini (volume in produzione) ├── uploads/ # Upload immagini (volume in produzione)
├── scripts/ # create-user, smoke test ├── campus-content/ # Contenuti del Campus generati da sync-campus
├── scripts/ # create-user, sync-campus, smoke test
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects) ├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
├── compose.yaml # Stack Docker + label Traefik ├── compose.yaml # Stack Docker + label Traefik
├── Dockerfile # Build multi-stage ├── Dockerfile # Build multi-stage
@@ -115,6 +127,7 @@ Il file `.env` (non versionato) richiede:
| `CONTACT_TO` | Destinatario dei messaggi del form | | `CONTACT_TO` | Destinatario dei messaggi del form |
| `DB_PATH` | Percorso del database SQLite | | `DB_PATH` | Percorso del database SQLite |
| `UPLOADS_DIR` | Cartella degli upload | | `UPLOADS_DIR` | Cartella degli upload |
| `CAMPUS_DIR` | Cartella dei contenuti del Campus (default `./campus-content`) |
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`. La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
+17 -17
View File
@@ -915,9 +915,9 @@
"title": "Longevità e Mantenere un Cervello Sano" "title": "Longevità e Mantenere un Cervello Sano"
}, },
"next": { "next": {
"slug": "accelerator-1-2025-11-11", "slug": "slide-hrv",
"area": "live-e-ospiti", "area": "slide",
"title": "Accelerator 1" "title": "HRV: Variabilità della frequenza cardiaca"
} }
} }
] ]
@@ -954,9 +954,9 @@
"chapter": 13, "chapter": 13,
"area": "slide", "area": "slide",
"prev": { "prev": {
"slug": "lezione-live-4-2026-06-30", "slug": "lezione-25-focus-microbiota-plus",
"area": "live-e-ospiti", "area": "corso-base",
"title": "Lezione live 4" "title": "FOCUS Microbiota Plus"
}, },
"next": { "next": {
"slug": "slide-dna", "slug": "slide-dna",
@@ -1127,9 +1127,9 @@
"title": "Focus Ormoni (Dott.ssa Smeazzetto)" "title": "Focus Ormoni (Dott.ssa Smeazzetto)"
}, },
"next": { "next": {
"slug": "speaker-dna-focus-moduli", "slug": "accelerator-1-2025-11-11",
"area": "live-e-ospiti", "area": "live-e-ospiti",
"title": "DNA: Focus modulo per modulo" "title": "Accelerator 1"
} }
} }
] ]
@@ -1166,9 +1166,9 @@
"chapter": 11, "chapter": 11,
"area": "live-e-ospiti", "area": "live-e-ospiti",
"prev": { "prev": {
"slug": "lezione-25-focus-microbiota-plus", "slug": "slide-focus-microbiota-plus",
"area": "corso-base", "area": "slide",
"title": "FOCUS Microbiota Plus" "title": "Focus Microbiota PLUS"
}, },
"next": { "next": {
"slug": "accelerator-2-2025-12-20", "slug": "accelerator-2-2025-12-20",
@@ -1459,9 +1459,9 @@
"title": "Lezione live 3" "title": "Lezione live 3"
}, },
"next": { "next": {
"slug": "slide-hrv", "slug": "speaker-dna-focus-moduli",
"area": "slide", "area": "live-e-ospiti",
"title": "HRV: Variabilità della frequenza cardiaca" "title": "DNA: Focus modulo per modulo"
} }
} }
] ]
@@ -1490,9 +1490,9 @@
"chapter": 14, "chapter": 14,
"area": "live-e-ospiti", "area": "live-e-ospiti",
"prev": { "prev": {
"slug": "slide-focus-microbiota-plus", "slug": "lezione-live-4-2026-06-30",
"area": "slide", "area": "live-e-ospiti",
"title": "Focus Microbiota PLUS" "title": "Lezione live 4"
}, },
"next": { "next": {
"slug": "speaker-seipel-campi-elettromagnetici", "slug": "speaker-seipel-campi-elettromagnetici",
@@ -35,8 +35,9 @@ un ruolo dedicato. Le pagine devono adottare lo stile grafico del sito
oggi per short-circuit). oggi per short-circuit).
- Middleware: `/campus/*` diventa percorso protetto (oltre a `/admin`, - Middleware: `/campus/*` diventa percorso protetto (oltre a `/admin`,
`/api/admin`). Non autenticato → redirect a `/admin/login`. `/api/admin`). Non autenticato → redirect a `/admin/login`.
- `landingFor('campus')``/campus` (dopo il login l'utente campus atterra - `landingFor('campus')``/campus`. La pagina di login rimandava sempre a
direttamente sulla sezione). `/admin`: ora usa `landingFor` sul ruolo dell'utente appena autenticato,
così ogni ruolo atterra su una pagina che può davvero aprire.
- Il ruolo `campus` NON deve accedere ai percorsi comuni dei loggati del - Il ruolo `campus` NON deve accedere ai percorsi comuni dei loggati del
pannello (blog admin, upload): `canAccessAdminPath` ritorna `false` per pannello (blog admin, upload): `canAccessAdminPath` ritorna `false` per
`campus` su `/admin/*` e `/api/admin/*` non esplicitamente consentiti. `campus` su `/admin/*` e `/api/admin/*` non esplicitamente consentiti.
@@ -54,7 +55,9 @@ un ruolo dedicato. Le pagine devono adottare lo stile grafico del sito
riferimenti alle immagini slide verso `/campus/assets/...`. riferimenti alle immagini slide verso `/campus/assets/...`.
- Output in `campus-content/` (root repo, committato): - Output in `campus-content/` (root repo, committato):
- `nav.json` — aree, capitoli, lezioni ordinate (slug, titolo, sottotitolo, - `nav.json` — aree, capitoli, lezioni ordinate (slug, titolo, sottotitolo,
docente, prev/next); docente, prev/next). La catena prev/next segue l'ordine di navigazione
aree → capitoli → guide, non la numerazione dei capitoli: le aree li
raggruppano in un ordine diverso;
- `pages/<slug>.html` — frammento HTML del corpo di ogni guida; - `pages/<slug>.html` — frammento HTML del corpo di ogni guida;
- `assets/` — immagini slide copiate (~37 MB, commit una tantum). - `assets/` — immagini slide copiate (~37 MB, commit una tantum).
- Idempotente: rigenera tutto e rimuove i file orfani. - Idempotente: rigenera tutto e rimuove i file orfani.
+2 -2
View File
@@ -8,8 +8,8 @@ if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]'); console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1); process.exit(1);
} }
if (!['admin', 'superuser', 'user'].includes(role)) { if (!['admin', 'superuser', 'user', 'campus'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, superuser, user'); console.error('Ruolo non valido. Ammessi: admin, superuser, user, campus');
process.exit(1); process.exit(1);
} }
const path = process.env.DB_PATH ?? 'data/insanitylab.db'; const path = process.env.DB_PATH ?? 'data/insanitylab.db';
+12 -12
View File
@@ -63,7 +63,6 @@ async function main() {
// 1. parsing di tutte le guide, capitolo per capitolo // 1. parsing di tutte le guide, capitolo per capitolo
const chapters = []; const chapters = [];
const written = new Set(); const written = new Set();
const flat = []; // ordine globale di lettura, per prev/next
for (const [num, title, sub, slugs] of CHAPTERS) { for (const [num, title, sub, slugs] of CHAPTERS) {
const lessons = []; const lessons = [];
for (const [i, slug] of slugs.entries()) { for (const [i, slug] of slugs.entries()) {
@@ -81,27 +80,28 @@ async function main() {
chapter: num, chapter: num,
}; };
lessons.push(entry); lessons.push(entry);
flat.push(entry);
} }
chapters.push({ num, title, sub, color: CH_COLORS[num - 1], lessons }); chapters.push({ num, title, sub, color: CH_COLORS[num - 1], lessons });
} }
// 2. prev/next lungo l'ordine globale, e area di appartenenza
const areaOf = new Map();
for (const [slug, , , , chapterNums] of AREAS)
for (const n of chapterNums) areaOf.set(n, slug);
flat.forEach((l, i) => {
l.area = areaOf.get(l.chapter);
l.prev = flat[i - 1] ? { slug: flat[i - 1].slug, area: areaOf.get(flat[i - 1].chapter), title: flat[i - 1].titleShort } : null;
l.next = flat[i + 1] ? { slug: flat[i + 1].slug, area: areaOf.get(flat[i + 1].chapter), title: flat[i + 1].titleShort } : null;
});
const byNum = new Map(chapters.map((c) => [c.num, c])); const byNum = new Map(chapters.map((c) => [c.num, c]));
const areas = AREAS.map(([slug, title, sub, color, chapterNums]) => ({ const areas = AREAS.map(([slug, title, sub, color, chapterNums]) => ({
slug, title, sub, color, slug, title, sub, color,
chapters: chapterNums.map((n) => byNum.get(n)).filter(Boolean), chapters: chapterNums.map((n) => byNum.get(n)).filter(Boolean),
})); }));
// 2. prev/next nell'ordine in cui l'utente naviga: aree → capitoli → guide,
// che non coincide con l'ordine dei capitoli (le aree li raggruppano).
const flat = [];
for (const area of areas)
for (const ch of area.chapters)
for (const l of ch.lessons) { l.area = area.slug; flat.push(l); }
const linkTo = (l) => (l ? { slug: l.slug, area: l.area, title: l.titleShort } : null);
flat.forEach((l, i) => {
l.prev = linkTo(flat[i - 1]);
l.next = linkTo(flat[i + 1]);
});
await writeFile(join(OUT_DIR, 'nav.json'), JSON.stringify({ areas, count: flat.length }, null, 2)); await writeFile(join(OUT_DIR, 'nav.json'), JSON.stringify({ areas, count: flat.length }, null, 2));
// 3. rimozione dei frammenti orfani // 3. rimozione dei frammenti orfani
+1
View File
@@ -37,6 +37,7 @@ const user = Astro.locals.user;
<a href="/admin">Articoli</a> <a href="/admin">Articoli</a>
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>} {(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
{user.role === 'admin' && <a href="/admin/users">Utenti</a>} {user.role === 'admin' && <a href="/admin/users">Utenti</a>}
{user.role === 'admin' && <a href="/campus">Campus</a>}
<a href="/admin/new">Nuovo</a> <a href="/admin/new">Nuovo</a>
<a href="/admin/logout">Esci</a> <a href="/admin/logout">Esci</a>
<a href="/blog" target="_blank">Vedi sito ↗</a> <a href="/blog" target="_blank">Vedi sito ↗</a>
+137
View File
@@ -0,0 +1,137 @@
---
// Layout della sezione riservata Campus. Usa i token e i font del sito, ma
// non ne monta Header e Footer: la navigazione è tutta interna, con un solo
// richiamo alla home.
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import type { CampusNav } from '../lib/campus';
interface Props {
title: string;
nav: CampusNav;
currentArea?: string;
currentLesson?: string;
}
const { title, nav, currentArea, currentLesson } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Campus InsanityLab</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body class="cp">
<input type="checkbox" id="cp-menu" class="cp-menu-toggle" hidden />
<label for="cp-menu" class="cp-menu-btn" aria-label="Apri il menu">☰ Indice</label>
<aside class="cp-side">
<a class="cp-brand" href="/campus">
<span class="eyebrow">InsanityLab</span>
Campus
</a>
<a class="cp-home" href="/">← Torna alla home</a>
<nav class="cp-nav">
{nav.areas.map((area) => (
<div class="cp-area" data-on={area.slug === currentArea ? '' : null}>
<a class="cp-area__link" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>{area.title}</a>
{area.slug === currentArea && (
<ul class="cp-chapters">
{area.chapters.map((ch) => (
<li>
<span class="cp-ch" style={`--c:${ch.color}`}>{ch.num}. {ch.title}</span>
<ul>
{ch.lessons.map((l) => (
<li class={l.slug === currentLesson ? 'on' : undefined}>
<a href={`/campus/${area.slug}/${l.slug}`}>
<span class="cp-label">{l.label}</span> {l.titleShort}
</a>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
))}
</nav>
<a class="cp-logout" href="/admin/logout">Esci</a>
</aside>
<main class="cp-main"><slot /></main>
<style is:global>
body.cp { display: flex; align-items: flex-start; background: var(--c-bg); }
/* --- sidebar --- */
.cp-side {
width: 300px; flex: 0 0 300px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
background: var(--c-dark); color: #cfc6bd; padding: 28px 22px 50px;
}
.cp-brand { display: block; font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1; }
.cp-brand .eyebrow { display: block; color: var(--c-accent); margin-bottom: 4px; }
.cp-home { display: inline-block; margin: 18px 0 24px; font-family: var(--font-heading); font-size: .7rem;
font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent);
text-decoration: none; border-bottom: 1px solid transparent; }
.cp-home:hover { border-bottom-color: var(--c-accent); }
.cp-nav { border-top: 1px solid rgba(255,255,255,.12); padding-top: 18px; }
.cp-area + .cp-area { margin-top: 14px; }
.cp-area__link { display: block; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none;
padding: 8px 10px; border-left: 3px solid var(--c); }
.cp-area__link:hover, .cp-area[data-on] .cp-area__link { background: rgba(255,255,255,.07); }
.cp-chapters { list-style: none; margin: 8px 0 0; padding: 0; }
.cp-chapters > li + li { margin-top: 12px; }
.cp-ch { display: block; font-family: var(--font-heading); font-size: .72rem; letter-spacing: .08em;
text-transform: uppercase; color: var(--c); padding-left: 12px; }
.cp-chapters ul { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; }
.cp-chapters ul a { display: block; text-decoration: none; color: #b6ada3; font-size: .85rem;
line-height: 1.35; padding: 4px 8px; }
.cp-chapters ul a:hover { color: #fff; background: rgba(255,255,255,.06); }
.cp-chapters ul li.on a { color: #fff; background: rgba(184,169,140,.22); }
.cp-label { font-family: var(--font-heading); font-size: .68rem; letter-spacing: .06em; color: var(--c-accent); }
.cp-logout { display: inline-block; margin-top: 30px; font-size: .78rem; letter-spacing: .1em;
text-transform: uppercase; color: #8d8379; text-decoration: none; }
.cp-logout:hover { color: #fff; }
/* --- contenuto --- */
.cp-main { flex: 1; min-width: 0; max-width: 920px; margin: 0 auto; padding: 56px 44px 100px; }
.cp-main h1 { font-size: clamp(1.9rem, 3.4vw, 2.8rem); text-transform: none; letter-spacing: .02em; }
.cp-main h2 { font-size: clamp(1.3rem, 2.2vw, 1.7rem); margin-top: 2em; padding-bottom: .25em;
border-bottom: 1px solid #e8e4de; text-transform: none; letter-spacing: .02em; }
.cp-main h3 { font-size: 1.12rem; margin-top: 1.7em; color: var(--c-accent-dark); }
.cp-main img { margin: 22px 0; border: 1px solid #e8e4de; }
.cp-main table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: .92rem; }
.cp-main th, .cp-main td { border: 1px solid #e8e4de; padding: 8px 12px; text-align: left; vertical-align: top; }
.cp-main th { background: var(--c-bg-alt); font-family: var(--font-heading); font-weight: 600; }
.cp-main blockquote { margin: 20px 0; padding: 4px 20px; border-left: 3px solid var(--c-accent); color: #6a6a6a; }
.cp-main code { background: var(--c-bg-alt); padding: 1px 5px; font-size: .9em; }
.cp-main li { margin-bottom: .35em; }
/* --- menu mobile (solo CSS) --- */
.cp-menu-btn { display: none; }
@media (max-width: 900px) {
body.cp { display: block; }
.cp-menu-btn { display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
font-family: var(--font-heading); font-size: .75rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; padding: 14px 20px; cursor: pointer; }
.cp-side { display: none; width: auto; flex: none; height: auto; position: static; }
.cp-menu-toggle:checked ~ .cp-side { display: block; }
.cp-main { padding: 32px 20px 70px; }
}
</style>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
// Accesso ai contenuti della sezione Campus, generati da `npm run sync-campus`
// in campus-content/. I file sono letti a runtime (non a build time) e tenuti
// in cache in memoria: cambiano solo con un nuovo deploy.
import { readFileSync, existsSync } from 'node:fs';
import { join, normalize, resolve } from 'node:path';
export interface CampusLink { slug: string; area: string; title: string }
export interface CampusLesson {
slug: string;
kind: string;
label: string;
titleFull: string;
titleShort: string;
relatore: string;
concetti: string[];
chapter: number;
area: string;
prev: CampusLink | null;
next: CampusLink | null;
}
export interface CampusChapter {
num: number;
title: string;
sub: string;
color: string;
lessons: CampusLesson[];
}
export interface CampusArea {
slug: string;
title: string;
sub: string;
color: string;
chapters: CampusChapter[];
}
export interface CampusNav { areas: CampusArea[]; count: number }
const CONTENT_DIR = resolve(process.env.CAMPUS_DIR ?? './campus-content');
let navCache: CampusNav | null = null;
/** Struttura della sezione, o null se i contenuti non sono stati generati. */
export function getNav(): CampusNav | null {
if (navCache) return navCache;
const file = join(CONTENT_DIR, 'nav.json');
if (!existsSync(file)) {
console.warn(`Campus: contenuti assenti in ${CONTENT_DIR}. Eseguire "npm run sync-campus".`);
return null;
}
navCache = JSON.parse(readFileSync(file, 'utf8')) as CampusNav;
return navCache;
}
export function findArea(nav: CampusNav, slug: string): CampusArea | null {
return nav.areas.find((a) => a.slug === slug) ?? null;
}
export function findLesson(nav: CampusNav, slug: string): CampusLesson | null {
for (const area of nav.areas)
for (const ch of area.chapters) {
const l = ch.lessons.find((x) => x.slug === slug);
if (l) return l;
}
return null;
}
export function chapterOf(nav: CampusNav, lesson: CampusLesson): CampusChapter | null {
for (const area of nav.areas) {
const ch = area.chapters.find((c) => c.num === lesson.chapter);
if (ch) return ch;
}
return null;
}
/** Corpo HTML di una guida; null se il frammento non esiste. */
export function readLessonHtml(slug: string): string | null {
const file = join(CONTENT_DIR, 'pages', `${slug}.html`);
if (!existsSync(file)) return null;
return readFileSync(file, 'utf8');
}
/**
* Percorso assoluto di un asset dentro campus-content/assets, oppure null se
* il path esce dalla cartella (traversal) o il file non esiste.
*/
export function resolveAsset(relPath: string): string | null {
const base = join(CONTENT_DIR, 'assets');
const full = resolve(base, normalize(relPath));
if (full !== base && !full.startsWith(base + '/')) return null;
return existsSync(full) ? full : null;
}
+4 -2
View File
@@ -1,7 +1,7 @@
--- ---
import Admin from '../../layouts/Admin.astro'; import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { login, SESSION_COOKIE } from '../../lib/auth'; import { login, getSessionUser, landingFor, SESSION_COOKIE } from '../../lib/auth';
import { rateLimit } from '../../lib/rate-limit'; import { rateLimit } from '../../lib/rate-limit';
export const prerender = false; export const prerender = false;
@@ -17,7 +17,9 @@ if (Astro.request.method === 'POST') {
httpOnly: true, sameSite: 'lax', path: '/', httpOnly: true, sameSite: 'lax', path: '/',
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600, secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
}); });
return Astro.redirect('/admin'); // Ogni ruolo atterra sulla sua sezione: campus fuori dal pannello, gli
// altri sulla pagina admin che possono effettivamente aprire.
return Astro.redirect(landingFor(getSessionUser(getDb(), token)?.role ?? 'user'));
} }
error = 'Credenziali non valide.'; error = 'Credenziali non valide.';
} }
+113
View File
@@ -0,0 +1,113 @@
---
// Due forme di URL:
// /campus/<area> → indice dei capitoli dell'area
// /campus/<area>/<guida> → corpo della guida
import Campus from '../../layouts/Campus.astro';
import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
const parts = (Astro.params.slug ?? '').split('/').filter(Boolean);
if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 });
const [areaSlug, lessonSlug] = parts;
const area = findArea(nav, areaSlug);
if (!area) return new Response('Pagina non trovata', { status: 404 });
const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null;
if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 });
const body = lesson ? readLessonHtml(lesson.slug) : null;
if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 });
const chapter = lesson ? chapterOf(nav, lesson) : null;
---
<Campus title={lesson ? lesson.titleShort : area.title} nav={nav} currentArea={areaSlug} currentLesson={lesson?.slug}>
{lesson ? (
<article>
<nav class="cp-crumbs">
<a href="/campus">Campus</a> <span>/</span>
<a href={`/campus/${area.slug}`}>{area.title}</a>
{chapter && <><span>/</span> <span>{chapter.num}. {chapter.title}</span></>}
</nav>
<p class="eyebrow">{lesson.label} · {lesson.relatore}</p>
<h1>{lesson.titleFull}</h1>
{lesson.concetti.length > 0 && (
<ul class="cp-concetti">
{lesson.concetti.map((c) => <li>{c}</li>)}
</ul>
)}
<div class="cp-body" set:html={body} />
<nav class="cp-prevnext">
{lesson.prev
? <a href={`/campus/${lesson.prev.area}/${lesson.prev.slug}`}>← {lesson.prev.title}</a>
: <span />}
{lesson.next && <a class="cp-next" href={`/campus/${lesson.next.area}/${lesson.next.slug}`}>{lesson.next.title} →</a>}
</nav>
</article>
) : (
<>
<nav class="cp-crumbs"><a href="/campus">Campus</a> <span>/</span> <span>{area.title}</span></nav>
<p class="eyebrow">Area di studio</p>
<h1>{area.title}</h1>
<p class="cp-lead">{area.sub}</p>
{area.chapters.map((ch) => (
<section class="cp-chapter" style={`--c:${ch.color}`}>
<h2>{ch.num}. {ch.title}</h2>
<p class="cp-chapter__sub">{ch.sub}</p>
<ul class="cp-list">
{ch.lessons.map((l) => (
<li>
<a href={`/campus/${area.slug}/${l.slug}`}>
<span class="cp-list__label">{l.label}</span>
<span class="cp-list__title">{l.titleShort}</span>
<span class="cp-list__rel">{l.relatore}</span>
</a>
</li>
))}
</ul>
</section>
))}
</>
)}
</Campus>
<style is:global>
.cp-crumbs { font-size: .78rem; letter-spacing: .06em; color: var(--c-text-light); margin-bottom: 22px; }
.cp-crumbs a { color: var(--c-accent-dark); text-decoration: none; }
.cp-crumbs a:hover { text-decoration: underline; }
.cp-crumbs span { margin: 0 4px; }
.cp-lead { font-size: 1.08rem; color: var(--c-text-light); max-width: 62ch; margin-bottom: 40px; }
.cp-concetti { list-style: none; margin: 26px 0 34px; padding: 20px 24px; background: var(--c-bg-alt);
border-left: 3px solid var(--c-accent); }
.cp-concetti li { font-size: .93rem; margin-bottom: .3em; }
.cp-concetti li::before { content: '—'; color: var(--c-accent-dark); margin-right: 8px; }
.cp-chapter { margin-bottom: 42px; }
.cp-chapter h2 { font-size: 1.25rem; text-transform: uppercase; letter-spacing: .08em; border: 0;
padding: 0 0 0 12px; margin-bottom: .3em; border-left: 4px solid var(--c); }
.cp-chapter__sub { color: var(--c-text-light); font-size: .92rem; padding-left: 16px; }
.cp-list { list-style: none; margin: 0; padding: 0; }
.cp-list li { border-bottom: 1px solid #efece7; }
.cp-list a { display: grid; grid-template-columns: 70px 1fr auto; gap: 14px; align-items: baseline;
text-decoration: none; color: inherit; padding: 11px 16px; }
.cp-list a:hover { background: var(--c-bg-alt); }
.cp-list__label { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .06em; color: var(--c-accent-dark); }
.cp-list__rel { font-size: .78rem; color: var(--c-text-light); text-align: right; }
.cp-prevnext { display: flex; justify-content: space-between; gap: 20px; margin-top: 56px;
padding-top: 22px; border-top: 1px solid #e8e4de; font-size: .9rem; }
.cp-prevnext a { color: var(--c-accent-dark); text-decoration: none; }
.cp-prevnext a:hover { text-decoration: underline; }
.cp-prevnext .cp-next { margin-left: auto; text-align: right; }
@media (max-width: 640px) {
.cp-list a { grid-template-columns: 56px 1fr; }
.cp-list__rel { display: none; }
}
</style>
+35
View File
@@ -0,0 +1,35 @@
// Immagini delle slide del Campus. Passano dal middleware come il resto di
// /campus, quindi non sono raggiungibili senza login con ruolo abilitato.
import type { APIRoute } from 'astro';
import { createReadStream, statSync } from 'node:fs';
import { extname } from 'node:path';
import { Readable } from 'node:stream';
import { resolveAsset } from '../../../lib/campus';
export const prerender = false;
const TYPES: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
};
export const GET: APIRoute = ({ params }) => {
const rel = params.path;
if (!rel) return new Response('Not found', { status: 404 });
const type = TYPES[extname(rel).toLowerCase()];
if (!type) return new Response('Tipo non consentito', { status: 400 });
const file = resolveAsset(rel);
if (!file) return new Response('Not found', { status: 404 });
const { size } = statSync(file);
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
return new Response(stream, {
headers: {
'Content-Type': type,
'Content-Length': String(size),
'Cache-Control': 'private, max-age=86400',
},
});
};
+43
View File
@@ -0,0 +1,43 @@
---
import Campus from '../../layouts/Campus.astro';
import { getNav } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
---
<Campus title="Guida allo studio" nav={nav}>
<p class="eyebrow">Biohacking Campus 2025/2026</p>
<h1>Guida allo studio</h1>
<p class="cp-lead">
Le sintesi di tutte le lezioni del corso, organizzate in cinque aree:
{nav.count} guide con concetti chiave, sviluppo esteso e materiali originali.
</p>
<div class="cp-grid">
{nav.areas.map((area) => (
<a class="cp-card" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>
<h2>{area.title}</h2>
<p>{area.sub}</p>
<ul>
{area.chapters.map((ch) => <li>{ch.num}. {ch.title} <span>({ch.lessons.length})</span></li>)}
</ul>
</a>
))}
</div>
</Campus>
<style is:global>
.cp-lead { font-size: 1.1rem; color: var(--c-text-light); max-width: 60ch; margin-bottom: 40px; }
.cp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 20px; }
.cp-card { display: block; text-decoration: none; color: inherit; background: #fff;
border: 1px solid #e8e4de; border-top: 4px solid var(--c); padding: 22px 24px; transition: box-shadow .2s, transform .2s; }
.cp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 28px rgba(58,41,41,.10); }
.cp-card h2 { font-size: 1.15rem; margin: 0 0 .4em; border: 0; padding: 0; letter-spacing: .06em;
text-transform: uppercase; }
.cp-card p { font-size: .92rem; color: var(--c-text-light); margin-bottom: .8em; }
.cp-card ul { list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid #efece7;
font-size: .82rem; color: var(--c-text-light); }
.cp-card ul li { margin: 0; padding: 2px 0; }
.cp-card ul span { color: var(--c-accent-dark); }
</style>
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { getNav, findArea, findLesson, chapterOf, readLessonHtml, resolveAsset } from '../src/lib/campus';
// I test girano sui contenuti reali generati da `npm run sync-campus`.
const nav = getNav();
describe('getNav', () => {
it('carica aree e capitoli dai contenuti generati', () => {
expect(nav).not.toBeNull();
expect(nav!.count).toBeGreaterThan(100);
expect(nav!.areas.map((a) => a.slug)).toEqual(
['corso-base', 'slide', 'live-e-ospiti', 'consulenze-masterclass', 'qa']);
for (const a of nav!.areas) expect(a.chapters.length).toBeGreaterThan(0);
});
});
describe('lookup', () => {
it('trova area e guida, e il capitolo della guida', () => {
const area = findArea(nav!, 'corso-base');
expect(area?.title).toBe('Corso base');
const l = findLesson(nav!, 'lezione-01-ottimizzazione-del-sonno');
expect(l).toMatchObject({ area: 'corso-base', chapter: 1, label: 'L1' });
expect(chapterOf(nav!, l!)?.title).toBe('Sonno e ritmi');
});
it('ritorna null per slug inesistenti', () => {
expect(findArea(nav!, 'inventata')).toBeNull();
expect(findLesson(nav!, 'lezione-99-inventata')).toBeNull();
});
it('la catena prev/next collega tutte le guide in ordine', () => {
const all = nav!.areas.flatMap((a) => a.chapters.flatMap((c) => c.lessons));
expect(all[0].prev).toBeNull();
expect(all.at(-1)!.next).toBeNull();
for (let i = 0; i < all.length - 1; i++) expect(all[i].next?.slug).toBe(all[i + 1].slug);
});
});
describe('readLessonHtml', () => {
it('legge il frammento di una guida esistente', () => {
const html = readLessonHtml('lezione-01-ottimizzazione-del-sonno');
expect(html).toContain('<h2');
expect(html).not.toContain('Guida allo studio</h1>');
});
it('ritorna null per una guida inesistente', () => {
expect(readLessonHtml('non-esiste')).toBeNull();
});
it('le immagini puntano alla route protetta', () => {
const html = readLessonHtml('slide-hrv')!;
expect(html).toContain('src="/campus/assets/');
expect(html).not.toContain('src="assets/');
});
});
describe('resolveAsset', () => {
it('risolve un asset esistente', () => {
expect(resolveAsset('slide/slide-hrv')).toMatch(/campus-content\/assets\/slide\/slide-hrv$/);
});
it('rifiuta i path traversal e i path assoluti', () => {
expect(resolveAsset('../pages/lezione-01-ottimizzazione-del-sonno.html')).toBeNull();
expect(resolveAsset('../../package.json')).toBeNull();
expect(resolveAsset('/etc/passwd')).toBeNull();
});
it('ritorna null per un asset inesistente', () => {
expect(resolveAsset('slide/non-esiste.png')).toBeNull();
});
});
+7 -8
View File
@@ -1,7 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
// @ts-expect-error — modulo JS senza tipi, usato solo dallo script di sync
import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs'; import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs';
// @ts-expect-error — idem
import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs'; import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs';
const body = ` const body = `
@@ -104,18 +102,19 @@ describe('rewriteAssets', () => {
}); });
describe('struttura del corso', () => { describe('struttura del corso', () => {
const chapterNums = CHAPTERS.map((c) => c[0] as number);
it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => { it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => {
const nums = CHAPTERS.map((c: [number, string, string, string[]]) => c[0]);
expect(CH_COLORS.length).toBe(CHAPTERS.length); expect(CH_COLORS.length).toBe(CHAPTERS.length);
for (const [, , , , chapterNums] of AREAS as [string, string, string, string, number[]][]) for (const area of AREAS)
for (const n of chapterNums) expect(nums).toContain(n); for (const n of area[4] as number[]) expect(chapterNums).toContain(n);
}); });
it('nessuno slug duplicato e ogni capitolo è in una sola area', () => { it('nessuno slug duplicato e ogni capitolo è in una sola area', () => {
const slugs = CHAPTERS.flatMap((c: [number, string, string, string[]]) => c[3]); const slugs = CHAPTERS.flatMap((c) => c[3] as string[]);
expect(new Set(slugs).size).toBe(slugs.length); expect(new Set(slugs).size).toBe(slugs.length);
const assigned = (AREAS as [string, string, string, string, number[]][]).flatMap((a) => a[4]); const assigned = AREAS.flatMap((a) => a[4] as number[]);
expect(new Set(assigned).size).toBe(assigned.length); expect(new Set(assigned).size).toBe(assigned.length);
expect(assigned.sort((a, b) => a - b)).toEqual(CHAPTERS.map((c: [number, string, string, string[]]) => c[0])); expect(assigned.sort((a, b) => a - b)).toEqual(chapterNums);
}); });
}); });