Sezione Campus: layout dedicato, route protette e documentazione
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
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 \
|
||||
PORT=4321 \
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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 anti‑spam, rate‑limit e invio email via SMTP.
|
||||
|
||||
## 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 start` | Server di produzione (`node --env-file=.env`) |
|
||||
| `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
|
||||
|
||||
@@ -69,31 +71,41 @@ Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui conten
|
||||
|
||||
## 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 |
|
||||
|-------|----------|
|
||||
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) |
|
||||
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), Campus |
|
||||
| `superuser` | Pannello contenuti + 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 una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione 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 una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione 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
|
||||
|
||||
```
|
||||
insanitylab-website/
|
||||
├── src/
|
||||
│ ├── pages/ # Route Astro (vetrina, /admin, /api)
|
||||
│ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus)
|
||||
│ ├── components/ # Componenti (home/, content/, Header, Footer, …)
|
||||
│ ├── layouts/ # Layout Base
|
||||
│ ├── layouts/ # Layout Base, Admin, Campus
|
||||
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
|
||||
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
|
||||
│ ├── assets/img/ # Immagini ottimizzate da Astro
|
||||
│ └── styles/ # global.css
|
||||
├── data/ # SQLite (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)
|
||||
├── compose.yaml # Stack Docker + label Traefik
|
||||
├── Dockerfile # Build multi-stage
|
||||
@@ -115,6 +127,7 @@ Il file `.env` (non versionato) richiede:
|
||||
| `CONTACT_TO` | Destinatario dei messaggi del form |
|
||||
| `DB_PATH` | Percorso del database SQLite |
|
||||
| `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`.
|
||||
|
||||
|
||||
+17
-17
@@ -915,9 +915,9 @@
|
||||
"title": "Longevità e Mantenere un Cervello Sano"
|
||||
},
|
||||
"next": {
|
||||
"slug": "accelerator-1-2025-11-11",
|
||||
"area": "live-e-ospiti",
|
||||
"title": "Accelerator 1"
|
||||
"slug": "slide-hrv",
|
||||
"area": "slide",
|
||||
"title": "HRV: Variabilità della frequenza cardiaca"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -954,9 +954,9 @@
|
||||
"chapter": 13,
|
||||
"area": "slide",
|
||||
"prev": {
|
||||
"slug": "lezione-live-4-2026-06-30",
|
||||
"area": "live-e-ospiti",
|
||||
"title": "Lezione live 4"
|
||||
"slug": "lezione-25-focus-microbiota-plus",
|
||||
"area": "corso-base",
|
||||
"title": "FOCUS Microbiota Plus"
|
||||
},
|
||||
"next": {
|
||||
"slug": "slide-dna",
|
||||
@@ -1127,9 +1127,9 @@
|
||||
"title": "Focus Ormoni (Dott.ssa Smeazzetto)"
|
||||
},
|
||||
"next": {
|
||||
"slug": "speaker-dna-focus-moduli",
|
||||
"slug": "accelerator-1-2025-11-11",
|
||||
"area": "live-e-ospiti",
|
||||
"title": "DNA: Focus modulo per modulo"
|
||||
"title": "Accelerator 1"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1166,9 +1166,9 @@
|
||||
"chapter": 11,
|
||||
"area": "live-e-ospiti",
|
||||
"prev": {
|
||||
"slug": "lezione-25-focus-microbiota-plus",
|
||||
"area": "corso-base",
|
||||
"title": "FOCUS Microbiota Plus"
|
||||
"slug": "slide-focus-microbiota-plus",
|
||||
"area": "slide",
|
||||
"title": "Focus Microbiota PLUS"
|
||||
},
|
||||
"next": {
|
||||
"slug": "accelerator-2-2025-12-20",
|
||||
@@ -1459,9 +1459,9 @@
|
||||
"title": "Lezione live 3"
|
||||
},
|
||||
"next": {
|
||||
"slug": "slide-hrv",
|
||||
"area": "slide",
|
||||
"title": "HRV: Variabilità della frequenza cardiaca"
|
||||
"slug": "speaker-dna-focus-moduli",
|
||||
"area": "live-e-ospiti",
|
||||
"title": "DNA: Focus modulo per modulo"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1490,9 +1490,9 @@
|
||||
"chapter": 14,
|
||||
"area": "live-e-ospiti",
|
||||
"prev": {
|
||||
"slug": "slide-focus-microbiota-plus",
|
||||
"area": "slide",
|
||||
"title": "Focus Microbiota PLUS"
|
||||
"slug": "lezione-live-4-2026-06-30",
|
||||
"area": "live-e-ospiti",
|
||||
"title": "Lezione live 4"
|
||||
},
|
||||
"next": {
|
||||
"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).
|
||||
- Middleware: `/campus/*` diventa percorso protetto (oltre a `/admin`,
|
||||
`/api/admin`). Non autenticato → redirect a `/admin/login`.
|
||||
- `landingFor('campus')` → `/campus` (dopo il login l'utente campus atterra
|
||||
direttamente sulla sezione).
|
||||
- `landingFor('campus')` → `/campus`. La pagina di login rimandava sempre a
|
||||
`/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
|
||||
pannello (blog admin, upload): `canAccessAdminPath` ritorna `false` per
|
||||
`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/...`.
|
||||
- Output in `campus-content/` (root repo, committato):
|
||||
- `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;
|
||||
- `assets/` — immagini slide copiate (~37 MB, commit una tantum).
|
||||
- Idempotente: rigenera tutto e rimuove i file orfani.
|
||||
|
||||
@@ -8,8 +8,8 @@ if (!username || !password) {
|
||||
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!['admin', 'superuser', 'user'].includes(role)) {
|
||||
console.error('Ruolo non valido. Ammessi: admin, superuser, user');
|
||||
if (!['admin', 'superuser', 'user', 'campus'].includes(role)) {
|
||||
console.error('Ruolo non valido. Ammessi: admin, superuser, user, campus');
|
||||
process.exit(1);
|
||||
}
|
||||
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||
|
||||
+12
-12
@@ -63,7 +63,6 @@ async function main() {
|
||||
// 1. parsing di tutte le guide, capitolo per capitolo
|
||||
const chapters = [];
|
||||
const written = new Set();
|
||||
const flat = []; // ordine globale di lettura, per prev/next
|
||||
for (const [num, title, sub, slugs] of CHAPTERS) {
|
||||
const lessons = [];
|
||||
for (const [i, slug] of slugs.entries()) {
|
||||
@@ -81,27 +80,28 @@ async function main() {
|
||||
chapter: num,
|
||||
};
|
||||
lessons.push(entry);
|
||||
flat.push(entry);
|
||||
}
|
||||
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 areas = AREAS.map(([slug, title, sub, color, chapterNums]) => ({
|
||||
slug, title, sub, color,
|
||||
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));
|
||||
|
||||
// 3. rimozione dei frammenti orfani
|
||||
|
||||
@@ -37,6 +37,7 @@ const user = Astro.locals.user;
|
||||
<a href="/admin">Articoli</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="/campus">Campus</a>}
|
||||
<a href="/admin/new">Nuovo</a>
|
||||
<a href="/admin/logout">Esci</a>
|
||||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
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';
|
||||
export const prerender = false;
|
||||
|
||||
@@ -17,7 +17,9 @@ if (Astro.request.method === 'POST') {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
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.';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
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';
|
||||
// @ts-expect-error — idem
|
||||
import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs';
|
||||
|
||||
const body = `
|
||||
@@ -104,18 +102,19 @@ describe('rewriteAssets', () => {
|
||||
});
|
||||
|
||||
describe('struttura del corso', () => {
|
||||
const chapterNums = CHAPTERS.map((c) => c[0] as number);
|
||||
|
||||
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);
|
||||
for (const [, , , , chapterNums] of AREAS as [string, string, string, string, number[]][])
|
||||
for (const n of chapterNums) expect(nums).toContain(n);
|
||||
for (const area of AREAS)
|
||||
for (const n of area[4] as number[]) expect(chapterNums).toContain(n);
|
||||
});
|
||||
|
||||
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);
|
||||
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(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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user