# Utenti, ruoli, login pubblico, GUID contenuti e modifica inline — Design Data: 2026-07-05 Progetto: sito InsanityLab (Astro 7 SSR, SQLite/better-sqlite3, live su https://insanitylab.tielogic.xyz) ## Obiettivo Portare il sito da due ruoli (`admin` | `editor`) a un sistema a tre ruoli con login pubblico, identità utente visibile nell'header, blog con ownership per autore, un identificatore GUID stabile per ogni contenuto taggato, e la modifica dei contenuti direttamente in pagina tramite un overlay attivabile con toggle. Nessuna dipendenza frontend nuova: tutto vanilla, in continuità con l'esistente. ## Ruoli e matrice permessi Tre ruoli su `users.role`: `admin`, `superuser`, `user`. Il vecchio ruolo `editor` viene migrato a `superuser`. | Capacità | user | superuser | admin | |---|:---:|:---:|:---:| | Login + nome nell'header + logout | ✓ | ✓ | ✓ | | Blog: CRUD **solo dei propri** articoli | ✓ | ✓ | ✓ | | Blog: gestione articoli **di tutti gli autori** | | ✓ | ✓ | | Pannello `/admin/content` | | ✓ | ✓ | | Toggle "Mostra tag" + modifica inline in pagina | | ✓ | ✓ | | Gestione utenti `/admin/users` (crea, reset psw, ruolo, elimina) | | | ✓ | La regola: ogni ruolo può fare tutto ciò che può il ruolo inferiore, più le proprie capacità. ## Modello dati (migrazioni idempotenti in `src/lib/db.ts`, prima di `syncSeed`) Tutte additive, nello stile delle migrazioni esistenti (PRAGMA table_info + ALTER, transazione). 1. **Ruolo `editor` → `superuser`** `UPDATE users SET role = 'superuser' WHERE role = 'editor'`. Idempotente: al secondo giro nessuna riga `editor` resta. 2. **GUID contenuti** — colonna `content_blocks.guid TEXT`. - `ALTER TABLE content_blocks ADD COLUMN guid TEXT` se assente (PRAGMA table_info). - Backfill: per ogni riga con `guid IS NULL OR guid = ''`, assegna `crypto.randomUUID()`. - Indice univoco: `CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)` (creato dopo il backfill, così non fallisce su righe pre-esistenti a guid nullo). - `syncSeed` (in `db.ts` e in `content.ts`): l'INSERT delle righe seed nuove valorizza il guid con `crypto.randomUUID()` alla creazione. Le righe già presenti non vengono toccate (INSERT OR IGNORE), quindi il loro guid è quello del backfill. - Il **tag semantico resta la chiave primaria** e resta l'identificatore usato nel codice (`t('...')`, ``) e nel pannello. Il GUID si affianca: è l'ID stabile esposto verso l'esterno (attributo `data-guid` nel DOM, badge, eventuale API/link). Nessun tag cambia nome. 3. **Autore articoli** — colonna `posts.author_id INTEGER` (nullable). - `ALTER TABLE posts ADD COLUMN author_id INTEGER` se assente. - Nessun vincolo FK stretto a livello schema (coerente con lo stile del repo, dove le FK esplicite sono solo su `sessions`); l'integrità è gestita nel codice. - Post storici: `author_id` resta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico. ## Auth e sessioni (`src/lib/auth.ts`) - `Role` diventa `'admin' | 'superuser' | 'user'`. `createUser` accetta i tre ruoli. - `SESSION_COOKIE`, `login`, `getSessionUser`, `logout` restano invariati. - Nuova funzione di autorizzazione che sostituisce `EDITOR_ALLOWED`/`canAccessAdminPath` con una **matrice esplicita per prefisso di rotta**: ``` /admin/users, /api/admin/users* → admin /admin/content, /api/admin/content* → superuser, admin /admin (blog: index/new/edit/posts), /api/admin/posts*, /api/admin/upload → user, superuser, admin /admin/logout, /admin/login → chiunque (login pubblico) ``` Firma proposta: `canAccessAdminPath(role, pathname): boolean`. La landing per redirect dipende dal ruolo (funzione `landingFor(role)`): `user → /admin` (lista blog), `superuser → /admin/content`, `admin → /admin`. - L'hashing password è già in `hashPassword`/`verifyPassword`; `scripts/create-user.mjs` continua a funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni di `auth.ts`. ## Middleware (`src/middleware.ts`) Struttura invariata (protegge `/admin` e `/api/admin`, esclude `/admin/login`). Cambia solo il ramo autorizzazione: usa la nuova matrice. Utente loggato ma senza permesso su una rotta pagina → `redirect(landingFor(role))`; su una rotta API → `403` JSON. `context.locals.user` continua a esporre `{ id, username, role }`. ## Login pubblico e header ### Pagina `/login` (`src/pages/login.astro`) - Layout `Base` (stile sito pubblico), **non** `Admin`. - Form username+password. Riusa la logica di `/admin/login`: stesso `rateLimit`, stesso `login()`, stesso set del cookie `session` (httpOnly, sameSite lax, secure in prod, maxAge 7 giorni). - Query `?next=`: dopo login valido redirige a `next` se è un path interno sicuro (inizia con `/`, non `//`, non `/api/`); altrimenti alla home `/`. - Se già loggato: redirige a `next` sicuro o a `/`. - `/admin/login` resta come alias funzionante (nessuna rimozione). ### Header (`src/components/Header.astro`, già incluso da `Base.astro`) Reso SSR con lettura sessione dal cookie: - **Anonimo**: link "Accedi" → `/login?next=`. - **Loggato**: nome utente + menu a tendina: - "Area riservata" → `landingFor(role)`. - Toggle "Mostra tag" (solo superuser/admin) — vedi sezione successiva. - "Esci" → `/admin/logout`. - Il menu è vanilla (dettaglio/summary o piccolo script già in stile repo), senza librerie. ## Toggle "Mostra tag" e modifica inline ### Attivazione - Cookie `showtags=1` (non httpOnly, così lo script client può leggerlo; path `/`), impostato/rimosso dal toggle nel menu header. Solo superuser/admin possono attivarlo (l'endpoint che lo setta verifica il ruolo). - `Base.astro` calcola `showTags = (cookie showtags === '1') && sessione con ruolo superuser|admin` e passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più `?annotate=1` da trascinare nell'URL). - Compat: `?annotate=1` continua a funzionare attivando il cookie/flag per la richiesta corrente. ### Badge - Quando `showTags` è attivo, lo script badge esistente in `Base.astro` (overlay assoluto sugli elementi con `data-tag`) viene esteso per: mostrare il **nome tag**, esporre il **`data-guid`**, e aggiungere un pulsante matita ✎ cliccabile. - Nessun badge per i visitatori anonimi o per il ruolo `user` (lo script e il markup non vengono nemmeno emessi). ### Modal di modifica inline Click sulla matita → apre un modal vanilla sovrapposto alla pagina corrente (nessun redirect al pannello): - **Nuovo endpoint** `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }` (`styleOptions` da `seedByTag`/style-presets, come già fa `GET /api/admin/content`). - Il form si costruisce in base a `type`: - `text` / `html` → textarea + eventuali select di stile dagli `styleOptions`; salva con **`PUT /api/admin/content/[tag]`** (esistente, ritorna `{ ok, value }` post-sanitizzazione). - `image` → input file → **`POST /api/admin/content/[tag]/image`** (esistente) e rimozione via **`DELETE`** (esistente). - Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal `value` ritornato) e si chiude, senza reload. Errori mostrati dentro il modal. - Il codice del modal (markup + script) è caricato **solo** quando `showTags` è attivo: zero peso per chi non è superuser/admin con toggle attivo. ### Riuso vs nuovo Le API di scrittura contenuti (PUT, POST/DELETE image) restano **invariate** — sono le stesse usate dal pannello `/admin/content`. L'unica aggiunta backend è il `GET /api/admin/content/[tag]` singolo. Il modal è l'unico componente frontend nuovo. ## Gestione utenti (`/admin/users`, solo admin) ### Pagina `src/pages/admin/users.astro` (layout `Admin`) Lista utenti: username, ruolo, ultimo accesso (se ricavabile dalle sessioni). Form/azioni per: crea, reset password, cambia ruolo, elimina. ### API `src/pages/api/admin/users/*` (admin-only via middleware) - **`POST /api/admin/users`** → crea utente: `username` + `password` + `role` (`user`|`superuser`|`admin`). Rifiuta username duplicato (409/400 con messaggio). Usa `createUser`. - **`POST /api/admin/users/[id]/reset-password`** → genera password casuale, la ritorna **una sola volta** in chiaro nella risposta JSON (l'admin la copia e la consegna). Aggiorna `password_hash`. Non viene mai ri-mostrata né loggata. - **`PUT /api/admin/users/[id]/role`** → cambia ruolo. - **`DELETE /api/admin/users/[id]`** → elimina l'utente. ### Guardie (invarianti di sicurezza) - Non puoi **eliminare te stesso** (confronto con `locals.user.id`). - Non puoi eliminare **l'ultimo admin** rimasto, né declassarne il ruolo se è l'ultimo admin (query di conteggio `role='admin'` nella stessa transazione). - Eliminazione utente → cascade sulle sue sessioni (già `ON DELETE CASCADE` su `sessions.user_id`). Gli articoli con `author_id` di un utente eliminato: `author_id` resta valorizzato ma "orfano" → firma di fallback "Staff InsanityLab" nel pubblico (nessuna cancellazione a cascata dei post). ## Blog ownership ### API `/api/admin/posts*` e `/api/admin/posts/[id]` - **`user`**: la lista in `/admin` è filtrata sui propri `author_id`; `POST` forza `author_id = locals.user.id`; `PUT`/`DELETE` su un post con `author_id` diverso → **403**. - **`superuser` / `admin`**: vedono e gestiscono tutti i post; il `POST` valorizza comunque `author_id = locals.user.id` (l'autore è chi crea). - Implementazione: `createPost` accetta `author_id`; `updatePost`/`deletePost` restano invariati ma le route caricano il post e verificano la ownership prima di procedere (già caricano via `getPostById` su PUT; aggiungere il caricamento su DELETE che oggi non lo fa). ### Pannello blog (`/admin`, `/admin/new`, `/admin/edit/[id]`) - `user` vede solo i propri articoli; superuser/admin vedono tutti con una colonna "Autore". - Editing di un post altrui bloccato lato UI per `user` (e comunque 403 lato API). ### Blog pubblico - Firma autore sotto il titolo dell'articolo: nome dell'utente `author_id`; fallback "Staff InsanityLab" quando `author_id` è nullo o orfano. ## Gestione errori - API: JSON `{ error }` con status coerente (400 input non valido, 401 non autenticato — dal middleware, 403 permesso/ownership, 404 risorsa inesistente, 409/400 duplicati). - `next` non sicuro nel login → ignorato, redirect a `/` (niente open redirect). - Cookie `showtags` presente ma ruolo insufficiente → nessun badge (il flag è ricalcolato server-side con il ruolo reale, il cookie da solo non basta). - Reset password / creazione: password mostrata una sola volta, mai persistita in chiaro. ## Testing - **Unit** (`tests/roles.test.ts` esteso): matrice `canAccessAdminPath` per i tre ruoli su tutti i prefissi; `landingFor`. - **Unit ownership**: `user` non può PUT/DELETE post altrui; `POST` imposta `author_id`; guardie gestione utenti (no self-delete, no last-admin delete/demote). - **Unit migrazioni**: su DB in-memory con righe `editor` e `content_blocks` senza guid → dopo `createDb`: ruoli migrati, guid non nulli e univoci, colonna `posts.author_id` presente. - **Smoke** (`scripts/smoke.sh` esteso): `/login` 200; header mostra "Accedi" da anonimo; rotte admin → redirect/403 da `user`; badge con `data-guid` presenti da superuser+ con toggle; modal/badge assenti da anonimo. ## Fuori scope (YAGNI) - Nessun framework/reattività frontend (no Alpine/petite-vue). - Nessuna tabella permessi configurabile: i tre ruoli sono fissi in codice. - Nessuna registrazione self-service: gli utenti li crea l'admin. - Nessun cambio del nome tag semantico: il GUID si affianca, non sostituisce. - Nessuna gestione avatar/profilo utente oltre username + ruolo.