Files
InsanityLab-web/.superpowers/sdd/review-d4cf359..e2bbc90.diff
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

1747 lines
80 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Review package: d4cf3598059fb57d39289d363218202f4921ed28..HEAD
## Commits
e2bbc90 fix(create-user): ruoli aggiornati a superuser/user (gap dal piano)
be77285 test(smoke): login pubblico, badge, protezione /admin/users
c36ad4c feat(users): gestione utenti /admin/users con guardie self/ultimo-admin
0f2b2ba feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab
2bf6b3d feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore
22d72be feat(inline): badge con guid + modal di modifica contenuti in pagina
8423716 feat(showtags): endpoint cookie + toggle nel menu utente
71cd162 feat(header): identità utente SSR, link Accedi / menu utente
6b85872 feat(login): pagina pubblica /login con next sicuro
5ce10da feat(content): guid in resolve/componenti + GET /api/admin/content/[tag]
c3267c9 feat(posts-api): assegna autore in POST, ownership su PUT/DELETE
6d1e260 feat(posts): author_id nel repository + listByAuthor + helper utenti
db92fc9 feat(middleware): autorizzazione a matrice + redirect per ruolo
e87a464 feat(auth): matrice permessi 3 ruoli + landingFor
5cea895 feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id
## Files changed
scripts/create-user.mjs | 4 +-
scripts/smoke.sh | 20 +++
src/components/Header.astro | 47 +++++++
src/components/content/T.astro | 4 +-
src/components/content/TImg.astro | 10 +-
src/layouts/Admin.astro | 13 +-
src/layouts/Base.astro | 169 +++++++++++++++++++----
src/lib/auth.ts | 64 ++++++++-
src/lib/content.ts | 21 +--
src/lib/db.ts | 27 +++-
src/lib/posts.ts | 19 ++-
src/lib/safe-next.ts | 7 +
src/middleware.ts | 4 +-
src/pages/admin/index.astro | 11 +-
src/pages/admin/users.astro | 95 +++++++++++++
src/pages/api/admin/content/[tag].ts | 15 ++
src/pages/api/admin/posts/[id].ts | 16 ++-
src/pages/api/admin/posts/index.ts | 4 +-
src/pages/api/admin/showtags.ts | 18 +++
src/pages/api/admin/users/[id]/index.ts | 19 +++
src/pages/api/admin/users/[id]/reset-password.ts | 16 +++
src/pages/api/admin/users/[id]/role.ts | 21 +++
src/pages/api/admin/users/index.ts | 26 ++++
src/pages/blog/[slug].astro | 5 +-
src/pages/login.astro | 53 +++++++
tests/content-api.test.ts | 13 ++
tests/content-db.test.ts | 29 ++++
tests/content.test.ts | 8 +-
tests/posts-ownership.test.ts | 21 +++
tests/posts.test.ts | 27 +++-
tests/roles.test.ts | 68 ++++++---
tests/safe-next.test.ts | 18 +++
tests/users.test.ts | 49 +++++++
33 files changed, 847 insertions(+), 94 deletions(-)
## Diff
diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs
index 8568b66..4690813 100644
--- a/scripts/create-user.mjs
+++ b/scripts/create-user.mjs
@@ -1,22 +1,22 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const [, , username, password, role = 'admin'] = process.argv;
if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1);
}
-if (!['admin', 'editor'].includes(role)) {
- console.error('Ruolo non valido. Ammessi: admin, editor');
+if (!['admin', 'superuser', 'user'].includes(role)) {
+ console.error('Ruolo non valido. Ammessi: admin, superuser, user');
process.exit(1);
}
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
diff --git a/scripts/smoke.sh b/scripts/smoke.sh
index 226f29b..7db9da2 100755
--- a/scripts/smoke.sh
+++ b/scripts/smoke.sh
@@ -21,11 +21,31 @@ echo "$code /sitemap.xml"
[ "$code" = "200" ] || fail=1
locs=$(curl -s http://localhost:4399/sitemap.xml | grep -c '<loc>')
echo "$locs <loc> in sitemap (atteso >= 12)"
[ "$locs" -ge 12 ] || fail=1
tags=$(curl -s http://localhost:4399/ | grep -c 'data-tag=')
echo "$tags righe con data-tag= in home (atteso >= 1)"
[ "$tags" -ge 1 ] || fail=1
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
[ "$badges" = "0" ] || fail=1
+
+# /login pubblico raggiungibile
+code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
+echo "$code /login"
+[ "$code" = "200" ] || fail=1
+
+# Header mostra "Accedi" da anonimo
+curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
+
+# Nessun badge tag-badge da anonimo sulla home semplice
+badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
+echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
+[ "$badges2" = "0" ] || fail=1
+
+# Rotta gestione utenti protetta (redirect al login da anonimo)
+code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
+echo "$code /admin/users (atteso 302)"
+[ "$code" = "302" ] || fail=1
+
+echo "smoke ruoli/login/inline OK"
exit $fail
diff --git a/src/components/Header.astro b/src/components/Header.astro
index 33a0b4c..bfcbcdc 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -1,23 +1,47 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
+interface Props {
+ user: { username: string; role: string } | null;
+ showTags: boolean;
+ canTags: boolean;
+}
+const { user, canTags, showTags } = Astro.props;
const path = Astro.url.pathname;
+const loginHref = `/login?next=${encodeURIComponent(path)}`;
+const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
---
<header class="hdr" id="site-header">
<div class="container hdr__in">
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item, i) => (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
))}
</nav>
+ <div class="hdr__user">
+ {user ? (
+ <details class="umenu">
+ <summary class="umenu__btn">{user.username} ▾</summary>
+ <div class="umenu__panel">
+ <a href={areaHref}>Area riservata</a>
+ {canTags && (
+ <button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
+ )}
+ <a href="/admin/logout">Esci</a>
+ </div>
+ </details>
+ ) : (
+ <a class="hdr__login" href={loginHref}>Accedi</a>
+ )}
+ </div>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
</header>
<style>
.hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
@@ -25,22 +49,45 @@ const path = Astro.url.pathname;
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@media (max-width: 991px) {
.hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
.hdr__nav.is-open { display: flex; }
.hdr__link { padding: 12px 0; }
.hdr__burger { display: block; }
}
+ .hdr__user { display: flex; align-items: center; }
+ .hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
+ .hdr__login:hover { color: var(--c-accent-dark); }
+ .umenu { position: relative; }
+ .umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
+ .umenu__btn::-webkit-details-marker { display: none; }
+ .umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
+ .umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
+ .umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
</style>
<script>
const header = document.getElementById('site-header')!;
const toggle = document.getElementById('nav-toggle')!;
const nav = document.getElementById('site-nav')!;
addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
toggle.addEventListener('click', () => {
const open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open));
});
+
+ const tagsBtn = document.getElementById('toggle-tags');
+ if (tagsBtn) {
+ const on = tagsBtn.dataset.on === '1';
+ tagsBtn.addEventListener('click', async () => {
+ const res = await fetch('/api/admin/showtags', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ on: !on }),
+ });
+ if (res.ok) location.reload();
+ else alert('Operazione non riuscita.');
+ });
+ }
</script>
diff --git a/src/components/content/T.astro b/src/components/content/T.astro
index 41cefd3..b11d23e 100644
--- a/src/components/content/T.astro
+++ b/src/components/content/T.astro
@@ -1,11 +1,11 @@
---
import { resolveContent } from '../../lib/content';
interface Props { tag: string; as?: string; class?: string }
const { tag, as = 'span', class: cls } = Astro.props;
const Tag = as;
const c = resolveContent(tag);
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
---
{c.type === 'html'
- ? <Tag data-tag={tag} class={classes} set:html={c.value} />
- : <Tag data-tag={tag} class={classes}>{c.value}</Tag>}
+ ? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
+ : <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
diff --git a/src/components/content/TImg.astro b/src/components/content/TImg.astro
index 3fcb915..b1f6ac3 100644
--- a/src/components/content/TImg.astro
+++ b/src/components/content/TImg.astro
@@ -1,14 +1,16 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
-import { getImageOverride } from '../../lib/content';
+import { resolveContent } from '../../lib/content';
interface Props {
tag: string; src: ImageMetadata; alt: string;
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
}
const { tag, src, alt, class: cls, ...rest } = Astro.props;
-const override = getImageOverride(tag);
+const c = resolveContent(tag);
+const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
+const guid = c.guid || undefined;
---
{override
- ? <img src={override} alt={alt} class={cls} data-tag={tag} loading={rest.loading} />
- : <Image src={src} alt={alt} class={cls} data-tag={tag} {...rest} />}
+ ? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
+ : <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro
index e61622f..ab98680 100644
--- a/src/layouts/Admin.astro
+++ b/src/layouts/Admin.astro
@@ -24,17 +24,26 @@ const user = Astro.locals.user;
.pill--draft { background: #f3d9a4; }
.form-msg { font-size: .9rem; margin-top: 8px; }
.form-msg--ok { color: #3c7a3c; }
.form-msg--err { color: #b03030; }
</style>
</head>
<body>
<div class="abar">
<strong>InsanityLab · Admin</strong>
<span>
- {user && user.role === 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin/content">Contenuti</a> <a href="/admin/logout">Esci</a></>}
- {user && user.role !== 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/content">Contenuti</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
+ {user && (
+ <>
+ {`Ciao, ${user.username}`}
+ <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>}
+ <a href="/admin/new">Nuovo</a>
+ <a href="/admin/logout">Esci</a>
+ <a href="/blog" target="_blank">Vedi sito ↗</a>
+ </>
+ )}
</span>
</div>
<div class="awrap"><slot /></div>
</body>
</html>
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 365aabf..4e4263d 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -7,44 +7,43 @@ import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { getDb } from '../lib/db';
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
interface Props { title: string; description?: string }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
-// Overlay annotate: badge coi tag contenuto, solo per utenti loggati con ?annotate=1.
-let annotate = false;
-if (Astro.url.searchParams.get('annotate') === '1') {
- const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
- annotate = Boolean(tok && getSessionUser(getDb(), tok));
-}
+const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
+const user = tok ? getSessionUser(getDb(), tok) : null;
+const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
+const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
+const showTags = canTags && wantTags;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — InsanityLab</title>
<meta name="description" content={description} />
<meta property="og:title" content={`${title} — InsanityLab`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap.xml" />
<script is:inline>document.documentElement.classList.add('js');</script>
</head>
<body>
- <Header />
+ <Header user={user} showTags={showTags} canTags={canTags} />
<main><slot /></main>
<Footer />
<script>
// Reveal allo scroll: trigger a -100px dal viewport come il template Prowess.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-inview');
io.unobserve(e.target);
// A reveal concluso l'attributo va tolto, così la transition del reveal
@@ -67,38 +66,154 @@ if (Astro.url.searchParams.get('annotate') === '1') {
const r = el.getBoundingClientRect();
// Clamp entro il margine di sfondo (inset -120px) per non scoprire i bordi
const y = Math.max(-110, Math.min(110, (r.top + r.height / 2 - innerHeight / 2) * -0.3));
el.style.setProperty('--parallax-y', `${y.toFixed(1)}px`);
}
};
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
update();
}
</script>
- {annotate && (
+ {showTags && (
<style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
- .tag-badge { position: absolute; z-index: 9999; background: #3a2929; color: #fff;
- font: 600 10px/1.6 monospace; padding: 0 6px; text-decoration: none; opacity: .85; }
- .tag-badge:hover { opacity: 1; background: #9c8b70; }
+ .tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
+ background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; }
+ .tag-badge:hover { opacity: 1; }
+ .tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; }
+ .tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
+ .tag-modal[hidden] { display: none; }
+ .tag-modal__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
+ .tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
+ .tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
+ .tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
+ .tag-modal__box select { margin: 6px 8px 6px 0; }
+ .tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
+ .tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
+ .tag-modal__save { background: #3a2d26; color: #fff; }
+ .tag-modal__cancel { background: #ddd; }
+ .tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
</style>
+ <div class="tag-modal" id="tag-modal" hidden>
+ <div class="tag-modal__box">
+ <h3 id="tm-tag"></h3>
+ <p class="guid" id="tm-guid"></p>
+ <div id="tm-body"></div>
+ <div class="tag-modal__msg" id="tm-msg"></div>
+ <div class="tag-modal__row">
+ <button type="button" class="tag-modal__save" id="tm-save">Salva</button>
+ <button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
+ </div>
+ </div>
+ </div>
<script is:inline>
- document.querySelectorAll('[data-tag]').forEach((el) => {
- const a = document.createElement('a');
- a.className = 'tag-badge';
- a.textContent = el.dataset.tag;
- a.href = '/admin/content#' + encodeURIComponent(el.dataset.tag);
- a.target = '_blank';
- document.body.appendChild(a);
- const place = () => {
- const r = el.getBoundingClientRect();
- a.style.top = window.scrollY + r.top - 14 + 'px';
- a.style.left = window.scrollX + r.left + 'px';
- };
- place();
- addEventListener('scroll', place, { passive: true });
- addEventListener('resize', place);
- });
+ (() => {
+ const STYLE_GROUPS = { size: ['s','m','l'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right'] };
+ const modal = document.getElementById('tag-modal');
+ const tmTag = document.getElementById('tm-tag');
+ const tmGuid = document.getElementById('tm-guid');
+ const tmBody = document.getElementById('tm-body');
+ const tmMsg = document.getElementById('tm-msg');
+ const tmSave = document.getElementById('tm-save');
+ let current = null; // { tag, type, el }
+
+ function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
+ document.getElementById('tm-cancel').addEventListener('click', closeModal);
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+ async function openModal(tag, el) {
+ tmMsg.textContent = '';
+ let data;
+ try {
+ const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
+ if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
+ data = await res.json();
+ } catch { alert('Errore di rete.'); return; }
+ current = { tag: data.tag, type: data.type, el };
+ tmTag.textContent = data.tag;
+ tmGuid.textContent = data.guid || '';
+ tmBody.innerHTML = '';
+ if (data.type === 'image') {
+ const inp = document.createElement('input');
+ inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
+ tmBody.appendChild(inp);
+ const del = document.createElement('button');
+ del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
+ del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
+ tmBody.appendChild(del);
+ del.addEventListener('click', async () => {
+ const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
+ if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
+ });
+ } else {
+ const ta = document.createElement('textarea');
+ ta.id = 'tm-value'; ta.value = data.value;
+ tmBody.appendChild(ta);
+ (data.styleOptions || []).forEach((group) => {
+ const opts = STYLE_GROUPS[group];
+ if (!opts) return;
+ const sel = document.createElement('select');
+ sel.dataset.group = group;
+ sel.className = 'tm-style';
+ const none = document.createElement('option');
+ none.value = ''; none.textContent = group + ': —';
+ sel.appendChild(none);
+ opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
+ tmBody.appendChild(sel);
+ });
+ }
+ modal.hidden = false;
+ }
+
+ tmSave.addEventListener('click', async () => {
+ if (!current) return;
+ tmMsg.textContent = '';
+ if (current.type === 'image') {
+ const file = document.getElementById('tm-file').files[0];
+ if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
+ const fd = new FormData(); fd.append('file', file);
+ const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
+ if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
+ return;
+ }
+ const value = document.getElementById('tm-value').value;
+ const styles = {};
+ document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
+ const body = { value };
+ if (Object.keys(styles).length) body.styles = styles;
+ const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
+ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+ });
+ if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
+ const saved = await r.json();
+ // Aggiorna in-place il contenuto nel DOM.
+ if (current.type === 'html') current.el.innerHTML = saved.value;
+ else current.el.textContent = saved.value;
+ closeModal();
+ });
+
+ document.querySelectorAll('[data-tag]').forEach((el) => {
+ const tag = el.dataset.tag;
+ const guid = el.dataset.guid || '';
+ const badge = document.createElement('span');
+ badge.className = 'tag-badge';
+ const label = document.createElement('span');
+ label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
+ const edit = document.createElement('button');
+ edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
+ edit.addEventListener('click', () => openModal(tag, el));
+ badge.appendChild(label); badge.appendChild(edit);
+ document.body.appendChild(badge);
+ const place = () => {
+ const r = el.getBoundingClientRect();
+ badge.style.top = window.scrollY + r.top - 16 + 'px';
+ badge.style.left = window.scrollX + r.left + 'px';
+ };
+ place();
+ addEventListener('scroll', place, { passive: true });
+ addEventListener('resize', place);
+ });
+ })();
</script>
)}
</body>
</html>
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 2dea325..ed02c90 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,18 +1,18 @@
import type Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7;
-export type Role = 'admin' | 'editor';
+export type Role = 'admin' | 'superuser' | 'user';
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12);
}
export function verifyPassword(plain: string, hash: string): boolean {
return bcrypt.compareSync(plain, hash);
}
export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
@@ -42,17 +42,73 @@ export function getSessionUser(db: Database.Database, token: string): { id: numb
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
return null;
}
return { id: row.id, username: row.username, role: row.role };
}
export function logout(db: Database.Database, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
-// Percorsi admin consentiti anche al ruolo editor.
-const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/];
+// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
+// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
+// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
+// utenti già autenticati).
+const RULES: [RegExp, Role[]][] = [
+ [/^\/admin\/users(\/|$)/, ['admin']],
+ [/^\/api\/admin\/users(\/|$)/, ['admin']],
+ [/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
+ [/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
+];
export function canAccessAdminPath(role: string, pathname: string): boolean {
if (role === 'admin') return true;
- return EDITOR_ALLOWED.some((re) => re.test(pathname));
+ for (const [re, roles] of RULES) {
+ if (re.test(pathname)) return roles.includes(role as Role);
+ }
+ return true; // blog, upload, logout, showtags: tutti i loggati
+}
+
+export function getUsernameById(db: Database.Database, id: number): string | null {
+ const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
+ return row?.username ?? null;
+}
+
+export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
+ return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
+ { id: number; username: string; role: string }[];
+}
+
+export function countAdmins(db: Database.Database): number {
+ return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
+}
+
+export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
+ const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
+ { id: number; username: string; role: string } | undefined;
+ return row ?? null;
+}
+
+export function updateUserRole(db: Database.Database, id: number, role: Role): void {
+ db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
+}
+
+export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
+ db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
+}
+
+export function deleteUser(db: Database.Database, id: number): void {
+ db.prepare('DELETE FROM users WHERE id = ?').run(id);
+}
+
+export function randomPassword(): string {
+ return randomBytes(9).toString('base64url'); // ~12 caratteri
+}
+
+export function isRole(v: unknown): v is Role {
+ return v === 'admin' || v === 'superuser' || v === 'user';
+}
+
+export function landingFor(role: string): string {
+ if (role === 'superuser') return '/admin/content';
+ return '/admin';
}
diff --git a/src/lib/content.ts b/src/lib/content.ts
index b873685..db2a539 100644
--- a/src/lib/content.ts
+++ b/src/lib/content.ts
@@ -1,47 +1,48 @@
import type Database from 'better-sqlite3';
+import { randomUUID } from 'node:crypto';
import { getDb } from './db';
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
import { stylesToClasses } from './style-presets';
-interface Entry { value: string; type: ContentType; styles: Record<string, string> }
+interface Entry { value: string; type: ContentType; styles: Record<string, string>; guid: string }
export function syncSeed(db: Database.Database): void {
- const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
- const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
+ const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
+ const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
tx();
}
export function makeContentStore(dbGetter: () => Database.Database) {
let cache: Map<string, Entry> | null = null;
const load = (): Map<string, Entry> => {
if (cache) return cache;
- const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as
- { tag: string; type: ContentType; value: string; styles: string }[];
+ const rows = dbGetter().prepare('SELECT tag, type, value, styles, guid FROM content_blocks').all() as
+ { tag: string; type: ContentType; value: string; styles: string; guid: string | null }[];
cache = new Map(rows.map((r) => {
let styles: Record<string, string> = {};
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof styles !== 'object' || styles === null) styles = {};
- return [r.tag, { value: r.value, type: r.type, styles }];
+ return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }];
}));
return cache;
};
return {
- resolve(tag: string): { value: string; type: ContentType; classes: string } {
+ resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } {
const hit = load().get(tag);
- if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles) };
+ if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid };
const seed = seedByTag.get(tag);
- if (seed) return { value: seed.value, type: seed.type, classes: '' };
+ if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
console.warn(`[content] tag sconosciuto: ${tag}`);
- return { value: '', type: 'text', classes: '' };
+ return { value: '', type: 'text', classes: '', guid: '' };
},
invalidate(): void { cache = null; },
};
}
const store = makeContentStore(getDb);
export const resolveContent = (tag: string) => store.resolve(tag);
export const t = (tag: string): string => store.resolve(tag).value;
export const invalidateContentCache = (): void => store.invalidate();
diff --git a/src/lib/db.ts b/src/lib/db.ts
index cf8c82c..7260f13 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -1,13 +1,14 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
+import { randomUUID } from 'node:crypto';
import { contentSeed } from '../data/content-seed';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
@@ -43,20 +44,34 @@ export function createDb(path?: string): Database.Database {
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
const db = new Database(p);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
// Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role.
const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
if (!userCols.some((c) => c.name === 'role')) {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
}
+ // Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente.
+ db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
+
+ // GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco.
+ const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
+ if (!contentCols.some((c) => c.name === 'guid')) {
+ db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`);
+ }
+
+ // Autore articoli: colonna nullable (post storici restano senza autore).
+ const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[];
+ if (!postCols.some((c) => c.name === 'author_id')) {
+ db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`);
+ }
// Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti
// preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici.
// UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE
// successiva ripulisce le righe vecchie rimaste. Idempotente: al secondo giro nessun match.
const TAG_RENAMES: [string, string][] = [
['home.programs.', 'home.services.'],
['footer.programs.', 'footer.services.'],
['programs.', 'services.'],
['program.', 'service.'],
];
@@ -89,22 +104,30 @@ export function createDb(path?: string): Database.Database {
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
];
const seedValue = new Map(contentSeed.map((e) => [e.tag, e.value]));
const updValue = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
for (const [tag, oldValue] of OLD_SECTION_DEFAULTS) {
const next = seedValue.get(tag);
if (next !== undefined) updValue.run(next, tag, oldValue);
}
});
renameTx();
- const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
- const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
+ const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
+ const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
syncTx();
+ // Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
+ // ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
+ const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
+ const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?');
+ const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); });
+ bfTx();
+ // Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo.
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`);
return db;
}
let singleton: Database.Database | null = null;
export function getDb(): Database.Database {
if (!singleton) singleton = createDb();
return singleton;
}
diff --git a/src/lib/posts.ts b/src/lib/posts.ts
index dad9159..2b346dd 100644
--- a/src/lib/posts.ts
+++ b/src/lib/posts.ts
@@ -1,29 +1,29 @@
import type Database from 'better-sqlite3';
export interface Post {
id: number; title: string; slug: string; category: string; excerpt: string;
- body_html: string; cover: string | null; draft: number;
+ body_html: string; cover: string | null; draft: number; author_id: number | null;
published_at: string | null; created_at: string; updated_at: string;
}
export interface PostInput {
title: string; slug: string; category: string; excerpt: string;
- body_html: string; cover?: string | null; draft: boolean;
+ body_html: string; cover?: string | null; draft: boolean; author_id?: number | null;
}
export function createPost(db: Database.Database, input: PostInput): number {
const res = db.prepare(
- `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
- VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
+ `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
+ VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, @author_id,
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
- ).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
+ ).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 });
return Number(res.lastInsertRowid);
}
export function updatePost(db: Database.Database, id: number, input: PostInput): void {
db.prepare(
`UPDATE posts SET title = @title, slug = @slug, category = @category,
excerpt = @excerpt, body_html = @body_html, cover = @cover, draft = @draft,
published_at = CASE WHEN @draft = 0 AND published_at IS NULL THEN datetime('now') ELSE published_at END,
updated_at = datetime('now')
WHERE id = @id`
@@ -65,10 +65,19 @@ export function listRecent(db: Database.Database, limit = 4): Post[] {
'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
).all(limit) as Post[];
}
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
return db.prepare(
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
).all() as { month: string; count: number }[];
}
+
+export function listByAuthor(db: Database.Database, authorId: number): Post[] {
+ return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[];
+}
+
+export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
+ if (role === 'admin' || role === 'superuser') return true;
+ return post.author_id === userId;
+}
diff --git a/src/lib/safe-next.ts b/src/lib/safe-next.ts
new file mode 100644
index 0000000..b9b9a6c
--- /dev/null
+++ b/src/lib/safe-next.ts
@@ -0,0 +1,7 @@
+// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
+// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
+export function safeNext(next: string | null): string {
+ if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
+ if (next.startsWith('/api/')) return '/';
+ return next;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index bed89db..c8c53b5 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,13 +1,13 @@
import { defineMiddleware } from 'astro:middleware';
import { getDb } from './lib/db';
-import { getSessionUser, SESSION_COOKIE, canAccessAdminPath } from './lib/auth';
+import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth';
export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url;
const isProtected =
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
pathname.startsWith('/api/admin');
if (!isProtected) return next();
const token = context.cookies.get(SESSION_COOKIE)?.value;
const user = token ? getSessionUser(getDb(), token) : null;
@@ -19,16 +19,16 @@ export const onRequest = defineMiddleware((context, next) => {
}
return context.redirect('/admin/login');
}
if (!canAccessAdminPath(user.role, pathname)) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Permessi insufficienti' }), {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
- return context.redirect('/admin/content');
+ return context.redirect(landingFor(user.role));
}
context.locals.user = user;
return next();
});
diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro
index a8e0cd3..24cb0ca 100644
--- a/src/pages/admin/index.astro
+++ b/src/pages/admin/index.astro
@@ -1,28 +1,33 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
-import { listAllPosts } from '../../lib/posts';
+import { listAllPosts, listByAuthor } from '../../lib/posts';
+import { listUsers } from '../../lib/auth';
export const prerender = false;
-const posts = listAllPosts(getDb());
+const user = Astro.locals.user!;
+const isManager = user.role === 'superuser' || user.role === 'admin';
+const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
+const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
---
<Admin title="Articoli">
<h1>Articoli</h1>
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
<table>
- <thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
+ <thead><tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
<tbody>
{posts.map((p) => (
<tr>
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
<td>{p.category}</td>
+ {isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
</tr>
))}
</tbody>
</table>
{posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
</Admin>
diff --git a/src/pages/admin/users.astro b/src/pages/admin/users.astro
new file mode 100644
index 0000000..e88c746
--- /dev/null
+++ b/src/pages/admin/users.astro
@@ -0,0 +1,95 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { listUsers } from '../../lib/auth';
+export const prerender = false;
+const users = listUsers(getDb());
+const me = Astro.locals.user!;
+---
+<Admin title="Utenti">
+ <h1>Utenti</h1>
+
+ <h2>Nuovo utente</h2>
+ <form id="new-user" style="max-width:420px">
+ <input class="afield" name="username" placeholder="Nome utente" required />
+ <input class="afield" name="password" type="text" placeholder="Password (min 8)" required />
+ <select class="afield" name="role">
+ <option value="user">user</option>
+ <option value="superuser">superuser</option>
+ <option value="admin">admin</option>
+ </select>
+ <button class="abtn" type="submit">Crea</button>
+ <span class="form-msg" id="new-msg"></span>
+ </form>
+
+ <h2 style="margin-top:30px">Elenco</h2>
+ <table>
+ <thead><tr><th>Utente</th><th>Ruolo</th><th></th></tr></thead>
+ <tbody>
+ {users.map((u) => (
+ <tr data-id={u.id}>
+ <td>{u.username}{u.id === me.id && ' (tu)'}</td>
+ <td>
+ <select class="role-sel" data-id={u.id}>
+ {['user', 'superuser', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
+ </select>
+ </td>
+ <td>
+ <button class="abtn" data-reset={u.id}>Reset password</button>
+ {u.id !== me.id && <button class="abtn abtn--danger" data-del={u.id}>Elimina</button>}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ <p class="form-msg" id="row-msg"></p>
+</Admin>
+
+<script>
+ const rowMsg = document.getElementById('row-msg')!;
+ const show = (el: HTMLElement, text: string, ok = false) => {
+ el.textContent = text;
+ el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
+ };
+
+ document.getElementById('new-user')!.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const f = e.target as HTMLFormElement;
+ const body = {
+ username: (f.elements.namedItem('username') as HTMLInputElement).value,
+ password: (f.elements.namedItem('password') as HTMLInputElement).value,
+ role: (f.elements.namedItem('role') as HTMLSelectElement).value,
+ };
+ const res = await fetch('/api/admin/users', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+ });
+ const msg = document.getElementById('new-msg') as HTMLElement;
+ if (res.ok) { show(msg, 'Creato ✓', true); setTimeout(() => location.reload(), 600); }
+ else show(msg, (await res.json()).error ?? 'Errore');
+ });
+
+ document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
+ sel.addEventListener('change', async () => {
+ const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
+ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
+ });
+ if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true);
+ else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
+ }));
+
+ document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
+ b.addEventListener('click', async () => {
+ if (!confirm('Generare una nuova password per questo utente?')) return;
+ const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' });
+ if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); }
+ else show(rowMsg, (await res.json()).error ?? 'Errore');
+ }));
+
+ document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
+ b.addEventListener('click', async () => {
+ if (!confirm('Eliminare definitivamente questo utente?')) return;
+ const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
+ if (res.ok) location.reload();
+ else show(rowMsg, (await res.json()).error ?? 'Errore');
+ }));
+</script>
diff --git a/src/pages/api/admin/content/[tag].ts b/src/pages/api/admin/content/[tag].ts
index 1fd7c0b..8ad2488 100644
--- a/src/pages/api/admin/content/[tag].ts
+++ b/src/pages/api/admin/content/[tag].ts
@@ -1,19 +1,34 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
import { applyContentUpdate } from '../../../../lib/content-update';
import { invalidateContentCache } from '../../../../lib/content';
+import { seedByTag } from '../../../../data/content-seed';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+export const GET: APIRoute = ({ params }) => {
+ const tag = params.tag ?? '';
+ const row = getDb().prepare('SELECT tag, type, value, guid FROM content_blocks WHERE tag = ?').get(tag) as
+ { tag: string; type: string; value: string; guid: string | null } | undefined;
+ if (!row) return json(404, { error: 'Tag inesistente.' });
+ return json(200, {
+ tag: row.tag,
+ guid: row.guid ?? '',
+ type: row.type,
+ value: row.value,
+ styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
+ });
+};
+
export const PUT: APIRoute = async ({ params, request, locals }) => {
let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' });
const r = applyContentUpdate(getDb(), params.tag ?? '', data, locals.user!.username);
if (!r.ok) return json(r.status, { error: r.error });
invalidateContentCache();
// Il value salvato può differire da quello inviato (sanitizeInline sui tag html):
// lo restituiamo così il pannello mostra il dato reale.
const row = getDb().prepare('SELECT value FROM content_blocks WHERE tag = ?').get(params.tag) as { value: string };
diff --git a/src/pages/api/admin/posts/[id].ts b/src/pages/api/admin/posts/[id].ts
index c7e8a33..8c7ef21 100644
--- a/src/pages/api/admin/posts/[id].ts
+++ b/src/pages/api/admin/posts/[id].ts
@@ -1,29 +1,35 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
-import { getPostById, updatePost, deletePost } from '../../../../lib/posts';
+import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts';
import { parsePostBody } from '../../../../lib/post-body';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
-export const PUT: APIRoute = async ({ params, request }) => {
+export const PUT: APIRoute = async ({ params, request, locals }) => {
const id = Number(params.id);
- if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
+ const existing = getPostById(getDb(), id);
+ if (!existing) return json(404, { error: 'Articolo non trovato.' });
+ if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi modificare questo articolo.' });
let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const parsed = parsePostBody(data);
if ('error' in parsed) return json(400, { error: parsed.error });
try {
updatePost(getDb(), id, parsed.value);
return json(200, { ok: true });
} catch (err: any) {
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
throw err;
}
};
-export const DELETE: APIRoute = ({ params }) => {
- deletePost(getDb(), Number(params.id));
+export const DELETE: APIRoute = ({ params, locals }) => {
+ const id = Number(params.id);
+ const existing = getPostById(getDb(), id);
+ if (!existing) return json(404, { error: 'Articolo non trovato.' });
+ if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
+ deletePost(getDb(), id);
return json(200, { ok: true });
};
diff --git a/src/pages/api/admin/posts/index.ts b/src/pages/api/admin/posts/index.ts
index 16305d7..6d95c5d 100644
--- a/src/pages/api/admin/posts/index.ts
+++ b/src/pages/api/admin/posts/index.ts
@@ -1,22 +1,22 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
import { createPost } from '../../../../lib/posts';
import { parsePostBody } from '../../../../lib/post-body';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
-export const POST: APIRoute = async ({ request }) => {
+export const POST: APIRoute = async ({ request, locals }) => {
let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const parsed = parsePostBody(data);
if ('error' in parsed) return json(400, { error: parsed.error });
try {
- const id = createPost(getDb(), parsed.value);
+ const id = createPost(getDb(), { ...parsed.value, author_id: locals.user!.id });
return json(201, { id, slug: parsed.value.slug });
} catch (err: any) {
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
throw err;
}
};
diff --git a/src/pages/api/admin/showtags.ts b/src/pages/api/admin/showtags.ts
new file mode 100644
index 0000000..9459624
--- /dev/null
+++ b/src/pages/api/admin/showtags.ts
@@ -0,0 +1,18 @@
+import type { APIRoute } from 'astro';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request, cookies, locals }) => {
+ const role = locals.user!.role;
+ if (role !== 'superuser' && role !== 'admin') return json(403, { error: 'Permessi insufficienti' });
+ let data: { on?: unknown };
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ if (data.on) {
+ cookies.set('showtags', '1', { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD });
+ } else {
+ cookies.delete('showtags', { path: '/' });
+ }
+ return json(200, { on: Boolean(data.on) });
+};
diff --git a/src/pages/api/admin/users/[id]/index.ts b/src/pages/api/admin/users/[id]/index.ts
new file mode 100644
index 0000000..f382e80
--- /dev/null
+++ b/src/pages/api/admin/users/[id]/index.ts
@@ -0,0 +1,19 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../../lib/db';
+import { getUserById, deleteUser, countAdmins } from '../../../../../lib/auth';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const DELETE: APIRoute = ({ params, locals }) => {
+ const id = Number(params.id);
+ const target = getUserById(getDb(), id);
+ if (!target) return json(404, { error: 'Utente inesistente.' });
+ if (id === locals.user!.id) return json(400, { error: 'Non puoi eliminare te stesso.' });
+ if (target.role === 'admin' && countAdmins(getDb()) <= 1) {
+ return json(400, { error: 'Deve restare almeno un admin.' });
+ }
+ deleteUser(getDb(), id);
+ return json(200, { ok: true });
+};
diff --git a/src/pages/api/admin/users/[id]/reset-password.ts b/src/pages/api/admin/users/[id]/reset-password.ts
new file mode 100644
index 0000000..7101e88
--- /dev/null
+++ b/src/pages/api/admin/users/[id]/reset-password.ts
@@ -0,0 +1,16 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../../lib/db';
+import { getUserById, updateUserPassword, hashPassword, randomPassword } from '../../../../../lib/auth';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = ({ params }) => {
+ const id = Number(params.id);
+ if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' });
+ const password = randomPassword();
+ updateUserPassword(getDb(), id, hashPassword(password));
+ // La password in chiaro è restituita UNA sola volta: l'admin la copia e la consegna.
+ return json(200, { password });
+};
diff --git a/src/pages/api/admin/users/[id]/role.ts b/src/pages/api/admin/users/[id]/role.ts
new file mode 100644
index 0000000..673447f
--- /dev/null
+++ b/src/pages/api/admin/users/[id]/role.ts
@@ -0,0 +1,21 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../../lib/db';
+import { getUserById, updateUserRole, countAdmins, isRole } from '../../../../../lib/auth';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const PUT: APIRoute = async ({ params, request }) => {
+ const id = Number(params.id);
+ const target = getUserById(getDb(), id);
+ if (!target) return json(404, { error: 'Utente inesistente.' });
+ let data: { role?: unknown };
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
+ if (target.role === 'admin' && data.role !== 'admin' && countAdmins(getDb()) <= 1) {
+ return json(400, { error: 'Deve restare almeno un admin.' });
+ }
+ updateUserRole(getDb(), id, data.role);
+ return json(200, { ok: true });
+};
diff --git a/src/pages/api/admin/users/index.ts b/src/pages/api/admin/users/index.ts
new file mode 100644
index 0000000..307bbe4
--- /dev/null
+++ b/src/pages/api/admin/users/index.ts
@@ -0,0 +1,26 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { createUser, listUsers, isRole } from '../../../../lib/auth';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const GET: APIRoute = () => json(200, { users: listUsers(getDb()) });
+
+export const POST: APIRoute = async ({ request }) => {
+ let data: { username?: unknown; password?: unknown; role?: unknown };
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const username = String(data.username ?? '').trim();
+ const password = String(data.password ?? '');
+ if (username.length < 2) return json(400, { error: 'Nome utente troppo corto.' });
+ if (password.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
+ if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
+ try {
+ const id = createUser(getDb(), username, password, data.role);
+ return json(201, { id });
+ } catch (err: any) {
+ if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Nome utente già esistente.' });
+ throw err;
+ }
+};
diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro
index 34eda3e..634231e 100644
--- a/src/pages/blog/[slug].astro
+++ b/src/pages/blog/[slug].astro
@@ -1,27 +1,30 @@
---
import Base from '../../layouts/Base.astro';
import Sidebar from '../../components/blog/Sidebar.astro';
import { getDb } from '../../lib/db';
import { getPublishedBySlug } from '../../lib/posts';
+import { getUsernameById } from '../../lib/auth';
export const prerender = false;
const post = getPublishedBySlug(getDb(), Astro.params.slug!);
if (!post) return new Response(null, { status: 404 });
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
+const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null;
+const byline = author ?? 'Staff InsanityLab';
---
<Base title={post.title} description={post.excerpt}>
<section class="section">
<div class="container art">
<article>
{post.cover && <img class="art__cover" src={post.cover} alt="" />}
- <p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p>
+ <p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}</p>
<h1>{post.title}</h1>
<div class="art__body" set:html={post.body_html} />
</article>
<Sidebar />
</div>
</section>
</Base>
<style>
.art { display: grid; grid-template-columns: 2.4fr 1fr; gap: 60px; }
diff --git a/src/pages/login.astro b/src/pages/login.astro
new file mode 100644
index 0000000..2009e39
--- /dev/null
+++ b/src/pages/login.astro
@@ -0,0 +1,53 @@
+---
+import Base from '../layouts/Base.astro';
+import { getDb } from '../lib/db';
+import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth';
+import { rateLimit } from '../lib/rate-limit';
+import { safeNext } from '../lib/safe-next';
+export const prerender = false;
+
+const dest = safeNext(Astro.url.searchParams.get('next'));
+
+// Già loggato → via dal login.
+const existing = Astro.cookies.get(SESSION_COOKIE)?.value;
+if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest);
+
+let error = '';
+if (Astro.request.method === 'POST') {
+ if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
+ error = 'Troppi tentativi: riprova tra 15 minuti.';
+ } else {
+ const form = await Astro.request.formData();
+ const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
+ if (token) {
+ Astro.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true, sameSite: 'lax', path: '/',
+ secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
+ });
+ return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest));
+ }
+ error = 'Credenziali non valide.';
+ }
+}
+---
+<Base title="Accedi" description="Area riservata InsanityLab">
+ <section class="section">
+ <div class="container login-wrap">
+ <h1 class="section-heading">Accedi</h1>
+ {error && <p class="login-err">{error}</p>}
+ <form method="post" class="login-form">
+ <input type="hidden" name="next" value={dest} />
+ <input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
+ <input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
+ <button class="btn btn--dark" type="submit">Entra</button>
+ </form>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .login-wrap { max-width: 380px; margin: 0 auto; }
+ .login-form { display: grid; gap: 14px; margin-top: 20px; }
+ .login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
+ .login-err { color: #a33; }
+</style>
diff --git a/tests/content-api.test.ts b/tests/content-api.test.ts
index 6d61853..66075dc 100644
--- a/tests/content-api.test.ts
+++ b/tests/content-api.test.ts
@@ -28,10 +28,23 @@ describe('applyContentUpdate', () => {
if (htmlTag) {
applyContentUpdate(db, htmlTag, { value: 'ciao <script>alert(1)</script><strong>ok</strong>' }, 'a');
const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any;
expect(row.value).not.toContain('<script>');
expect(row.value).toContain('<strong>ok</strong>');
}
const imgTag = contentSeed.find((e) => e.type === 'image')!.tag;
expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 });
});
});
+
+describe('GET /api/admin/content/[tag]', () => {
+ it('GET /api/admin/content/[tag] ritorna tag, guid, type, value, styleOptions', async () => {
+ const { GET } = await import('../src/pages/api/admin/content/[tag]');
+ const res = await GET({ params: { tag: 'home.hero.cta' } } as any);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.tag).toBe('home.hero.cta');
+ expect(typeof body.guid).toBe('string');
+ expect(body.type).toBeDefined();
+ expect(Array.isArray(body.styleOptions)).toBe(true);
+ });
+});
diff --git a/tests/content-db.test.ts b/tests/content-db.test.ts
index facfe9a..9be071e 100644
--- a/tests/content-db.test.ts
+++ b/tests/content-db.test.ts
@@ -1,15 +1,16 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDb } from '../src/lib/db';
+import { createUser } from '../src/lib/auth';
describe('schema content_blocks e ruoli', () => {
it('crea la tabella content_blocks', () => {
const db = createDb(':memory:');
const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
expect(cols.map((c) => c.name)).toEqual(
expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by'])
);
});
@@ -82,10 +83,38 @@ describe('schema content_blocks e ruoli', () => {
});
it('migra un DB esistente senza colonna role', () => {
// simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
// quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
const db = createDb(':memory:');
expect(() => createDb(':memory:')).not.toThrow();
expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4);
});
});
+
+describe('migrazioni ruoli/guid/autore', () => {
+ it('ogni content_block ha un guid non vuoto e univoco', () => {
+ const db = createDb(':memory:');
+ const rows = db.prepare('SELECT guid FROM content_blocks').all() as { guid: string | null }[];
+ expect(rows.length).toBeGreaterThan(100);
+ expect(rows.every((r) => typeof r.guid === 'string' && r.guid!.length >= 10)).toBe(true);
+ const guids = new Set(rows.map((r) => r.guid));
+ expect(guids.size).toBe(rows.length);
+ });
+
+ it('lo statement di migrazione unifica editor in superuser', () => {
+ // La migrazione gira dentro createDb, ma :memory: non persiste tra due boot: qui
+ // verifichiamo l'invariante SQL applicata dalla migrazione (editor → superuser).
+ // L'end-to-end su DB pre-esistente è coperto dallo smoke di produzione (Task 15).
+ const db = createDb(':memory:');
+ db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('vecchio', 'x', 'editor')").run();
+ db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
+ const row = db.prepare("SELECT role FROM users WHERE username = 'vecchio'").get() as { role: string };
+ expect(row.role).toBe('superuser');
+ });
+
+ it('posts ha la colonna author_id', () => {
+ const db = createDb(':memory:');
+ const cols = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
+ expect(cols.some((c) => c.name === 'author_id')).toBe(true);
+ });
+});
diff --git a/tests/content.test.ts b/tests/content.test.ts
index a5e8e53..40c8773 100644
--- a/tests/content.test.ts
+++ b/tests/content.test.ts
@@ -1,15 +1,15 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed';
-import { makeContentStore, syncSeed } from '../src/lib/content';
+import { makeContentStore, syncSeed, resolveContent } from '../src/lib/content';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('seed', () => {
it('tag univoci, validi e valori non vuoti', () => {
const tags = contentSeed.map((e) => e.tag);
expect(new Set(tags).size).toBe(tags.length);
for (const e of contentSeed) {
expect(e.tag).toMatch(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/);
@@ -51,11 +51,17 @@ describe('content store', () => {
expect(store.resolve(contentSeed[0].tag).value).toBe(contentSeed[0].value);
expect(store.resolve('tag.inesistente').value).toBe('');
});
it('styles corrotto nel DB viene ignorato', () => {
const store = makeContentStore(() => db);
db.prepare(`UPDATE content_blocks SET styles = 'non-json' WHERE tag = ?`).run(contentSeed[0].tag);
store.invalidate();
expect(store.resolve(contentSeed[0].tag).classes).toBe('');
});
+
+ it('resolveContent restituisce il guid dal DB', () => {
+ const r = resolveContent('home.hero.cta');
+ expect(typeof r.guid).toBe('string');
+ expect(r.guid.length).toBeGreaterThan(10);
+ });
});
diff --git a/tests/posts-ownership.test.ts b/tests/posts-ownership.test.ts
new file mode 100644
index 0000000..88f4a40
--- /dev/null
+++ b/tests/posts-ownership.test.ts
@@ -0,0 +1,21 @@
+import { describe, it, expect } from 'vitest';
+import { canModifyPost } from '../src/lib/posts';
+
+describe('canModifyPost', () => {
+ const own = { author_id: 7 };
+ const other = { author_id: 8 };
+ const orphan = { author_id: null };
+
+ it('admin e superuser modificano qualsiasi post', () => {
+ for (const role of ['admin', 'superuser']) {
+ expect(canModifyPost(role, 7, other)).toBe(true);
+ expect(canModifyPost(role, 7, orphan)).toBe(true);
+ }
+ });
+
+ it('user modifica solo i propri', () => {
+ expect(canModifyPost('user', 7, own)).toBe(true);
+ expect(canModifyPost('user', 7, other)).toBe(false);
+ expect(canModifyPost('user', 7, orphan)).toBe(false);
+ });
+});
diff --git a/tests/posts.test.ts b/tests/posts.test.ts
index ad373fa..58734a7 100644
--- a/tests/posts.test.ts
+++ b/tests/posts.test.ts
@@ -1,17 +1,18 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import {
createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
- listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput,
+ listPublished, listAllPosts, listRecent, listArchiveMonths, listByAuthor, type PostInput,
} from '../src/lib/posts';
+import { createUser, getUsernameById, listUsers } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
const base: PostInput = {
title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
};
describe('posts repository', () => {
@@ -65,10 +66,34 @@ describe('posts repository', () => {
it('listRecent e archivio mesi', () => {
createPost(db, base);
expect(listRecent(db, 4)).toHaveLength(1);
const months = listArchiveMonths(db);
expect(months).toHaveLength(1);
expect(months[0].count).toBe(1);
expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
});
});
+
+describe('autore articoli', () => {
+ it('createPost salva author_id e listByAuthor filtra', () => {
+ const aliceId = createUser(db, 'alice', 'segreta123', 'user');
+ const bobId = createUser(db, 'bob', 'segreta123', 'user');
+ createPost(db, { ...base, slug: 'a1', author_id: aliceId });
+ createPost(db, { ...base, slug: 'a2', author_id: aliceId });
+ createPost(db, { ...base, slug: 'b1', author_id: bobId });
+ expect(listByAuthor(db, aliceId).length).toBe(2);
+ expect(listByAuthor(db, bobId).length).toBe(1);
+ });
+
+ it('post senza author_id ha author_id null', () => {
+ const id = createPost(db, { ...base, slug: 'orfano' });
+ expect(getPostById(db, id)!.author_id).toBeNull();
+ });
+
+ it('getUsernameById e listUsers', () => {
+ const id = createUser(db, 'carla', 'segreta123', 'superuser');
+ expect(getUsernameById(db, id)).toBe('carla');
+ expect(getUsernameById(db, 99999)).toBeNull();
+ expect(listUsers(db).some((u) => u.username === 'carla' && u.role === 'superuser')).toBe(true);
+ });
+});
diff --git a/tests/roles.test.ts b/tests/roles.test.ts
index af5c1d9..c14de01 100644
--- a/tests/roles.test.ts
+++ b/tests/roles.test.ts
@@ -1,37 +1,63 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
-import { createUser, login, getSessionUser, canAccessAdminPath } from '../src/lib/auth';
+import { createUser, login, getSessionUser, canAccessAdminPath, landingFor } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('ruoli', () => {
- it('createUser accetta ruolo editor e getSessionUser lo ritorna', () => {
- createUser(db, 'cliente', 'segreta123', 'editor');
- const token = login(db, 'cliente', 'segreta123')!;
- expect(getSessionUser(db, token)).toMatchObject({ username: 'cliente', role: 'editor' });
+ it('createUser accetta i tre ruoli e getSessionUser li ritorna', () => {
+ createUser(db, 'u', 'segreta123', 'user');
+ createUser(db, 's', 'segreta123', 'superuser');
+ expect(getSessionUser(db, login(db, 'u', 'segreta123')!)).toMatchObject({ role: 'user' });
+ expect(getSessionUser(db, login(db, 's', 'segreta123')!)).toMatchObject({ role: 'superuser' });
});
it('createUser senza ruolo crea admin', () => {
createUser(db, 'adriano', 'segreta123');
- const token = login(db, 'adriano', 'segreta123')!;
- expect(getSessionUser(db, token)).toMatchObject({ role: 'admin' });
+ expect(getSessionUser(db, login(db, 'adriano', 'segreta123')!)).toMatchObject({ role: 'admin' });
});
+});
+
+describe('canAccessAdminPath', () => {
+ it('admin accede a tutto', () => {
+ for (const p of ['/admin', '/admin/users', '/admin/content', '/api/admin/posts', '/api/admin/users/1'])
+ expect(canAccessAdminPath('admin', p)).toBe(true);
+ });
+
+ it('superuser: contenuti + blog sì, utenti no', () => {
+ expect(canAccessAdminPath('superuser', '/admin/content')).toBe(true);
+ expect(canAccessAdminPath('superuser', '/admin/content/')).toBe(true);
+ expect(canAccessAdminPath('superuser', '/api/admin/content/home.hero.title')).toBe(true);
+ expect(canAccessAdminPath('superuser', '/admin')).toBe(true);
+ expect(canAccessAdminPath('superuser', '/admin/new')).toBe(true);
+ expect(canAccessAdminPath('superuser', '/api/admin/posts')).toBe(true);
+ expect(canAccessAdminPath('superuser', '/admin/users')).toBe(false);
+ expect(canAccessAdminPath('superuser', '/api/admin/users/1')).toBe(false);
+ });
+
+ it('user: blog sì, contenuti e utenti no', () => {
+ expect(canAccessAdminPath('user', '/admin')).toBe(true);
+ expect(canAccessAdminPath('user', '/admin/new')).toBe(true);
+ expect(canAccessAdminPath('user', '/api/admin/posts')).toBe(true);
+ expect(canAccessAdminPath('user', '/api/admin/upload')).toBe(true);
+ expect(canAccessAdminPath('user', '/admin/logout')).toBe(true);
+ expect(canAccessAdminPath('user', '/admin/content')).toBe(false);
+ expect(canAccessAdminPath('user', '/api/admin/content/x')).toBe(false);
+ expect(canAccessAdminPath('user', '/admin/users')).toBe(false);
+ });
+
+ it('la matrice non confonde prefissi simili', () => {
+ expect(canAccessAdminPath('user', '/admin/contentious')).toBe(true); // NON è /admin/content
+ expect(canAccessAdminPath('superuser', '/admin/users-x')).toBe(true); // NON è /admin/users
+ });
+});
- it('admin accede a tutto, editor solo a contenuti e logout', () => {
- expect(canAccessAdminPath('admin', '/admin')).toBe(true);
- expect(canAccessAdminPath('admin', '/api/admin/posts')).toBe(true);
- expect(canAccessAdminPath('editor', '/admin/content')).toBe(true);
- expect(canAccessAdminPath('editor', '/api/admin/content/home.hero.title')).toBe(true);
- expect(canAccessAdminPath('editor', '/admin/logout')).toBe(true);
- expect(canAccessAdminPath('editor', '/admin')).toBe(false);
- expect(canAccessAdminPath('editor', '/admin/new')).toBe(false);
- expect(canAccessAdminPath('editor', '/api/admin/posts')).toBe(false);
- expect(canAccessAdminPath('editor', '/api/admin/upload')).toBe(false);
- expect(canAccessAdminPath('editor', '/admin/contentious')).toBe(false);
- expect(canAccessAdminPath('editor', '/admin/content-x')).toBe(false);
- expect(canAccessAdminPath('editor', '/admin/content')).toBe(true);
- expect(canAccessAdminPath('editor', '/admin/content/')).toBe(true);
+describe('landingFor', () => {
+ it('instrada ogni ruolo alla sua home', () => {
+ expect(landingFor('superuser')).toBe('/admin/content');
+ expect(landingFor('user')).toBe('/admin');
+ expect(landingFor('admin')).toBe('/admin');
});
});
diff --git a/tests/safe-next.test.ts b/tests/safe-next.test.ts
new file mode 100644
index 0000000..9435af9
--- /dev/null
+++ b/tests/safe-next.test.ts
@@ -0,0 +1,18 @@
+import { describe, it, expect } from 'vitest';
+import { safeNext } from '../src/lib/safe-next';
+
+describe('safeNext', () => {
+ it('accetta path interni', () => {
+ expect(safeNext('/about')).toBe('/about');
+ expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
+ });
+ it('rifiuta esterni, protocol-relative, api e vuoti', () => {
+ expect(safeNext(null)).toBe('/');
+ expect(safeNext('')).toBe('/');
+ expect(safeNext('//evil.com')).toBe('/');
+ expect(safeNext('https://evil.com')).toBe('/');
+ expect(safeNext('/api/admin/posts')).toBe('/');
+ expect(safeNext('javascript:alert(1)')).toBe('/');
+ expect(safeNext('not-a-path')).toBe('/');
+ });
+});
diff --git a/tests/users.test.ts b/tests/users.test.ts
new file mode 100644
index 0000000..e6a8990
--- /dev/null
+++ b/tests/users.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import type Database from 'better-sqlite3';
+import { createDb } from '../src/lib/db';
+import {
+ createUser, countAdmins, getUserById, updateUserRole,
+ updateUserPassword, deleteUser, randomPassword, isRole, hashPassword, verifyPassword,
+} from '../src/lib/auth';
+
+let db: Database.Database;
+beforeEach(() => { db = createDb(':memory:'); });
+
+describe('gestione utenti', () => {
+ it('countAdmins conta i soli admin', () => {
+ createUser(db, 'a1', 'segreta123', 'admin');
+ createUser(db, 's1', 'segreta123', 'superuser');
+ expect(countAdmins(db)).toBe(1);
+ });
+
+ it('updateUserRole e getUserById', () => {
+ const id = createUser(db, 'u1', 'segreta123', 'user');
+ updateUserRole(db, id, 'superuser');
+ expect(getUserById(db, id)!.role).toBe('superuser');
+ });
+
+ it('updateUserPassword cambia l’hash', () => {
+ const id = createUser(db, 'u2', 'vecchia123', 'user');
+ updateUserPassword(db, id, hashPassword('nuova12345'));
+ const hash = (db.prepare('SELECT password_hash AS h FROM users WHERE id = ?').get(id) as { h: string }).h;
+ expect(verifyPassword('nuova12345', hash)).toBe(true);
+ });
+
+ it('deleteUser rimuove', () => {
+ const id = createUser(db, 'u3', 'segreta123', 'user');
+ deleteUser(db, id);
+ expect(getUserById(db, id)).toBeNull();
+ });
+
+ it('randomPassword genera almeno 12 caratteri', () => {
+ expect(randomPassword().length).toBeGreaterThanOrEqual(12);
+ });
+
+ it('isRole valida i ruoli', () => {
+ expect(isRole('admin')).toBe(true);
+ expect(isRole('superuser')).toBe(true);
+ expect(isRole('user')).toBe(true);
+ expect(isRole('editor')).toBe(false);
+ expect(isRole(3)).toBe(false);
+ });
+});