b913a2bf90
Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1747 lines
80 KiB
Diff
1747 lines
80 KiB
Diff
# 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);
|
||
+ });
|
||
+});
|