diff --git a/src/components/admin/PostForm.astro b/src/components/admin/PostForm.astro
new file mode 100644
index 0000000..4ffdf2a
--- /dev/null
+++ b/src/components/admin/PostForm.astro
@@ -0,0 +1,136 @@
+---
+import { CATEGORIES } from '../../lib/blog-const';
+import type { Post } from '../../lib/posts';
+interface Props { post?: Post }
+const { post } = Astro.props;
+---
+
+
+
+
+
diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro
new file mode 100644
index 0000000..0cb4bd0
--- /dev/null
+++ b/src/layouts/Admin.astro
@@ -0,0 +1,36 @@
+---
+interface Props { title: string }
+const { title } = Astro.props;
+const user = Astro.locals.user;
+---
+
+
+
+
+
+ {title} — Admin InsanityLab
+
+
+
+
+
+
+
+
diff --git a/src/lib/post-body.ts b/src/lib/post-body.ts
new file mode 100644
index 0000000..7d34ee4
--- /dev/null
+++ b/src/lib/post-body.ts
@@ -0,0 +1,21 @@
+import { slugify } from './slug';
+import { sanitizeHtml } from './sanitize';
+import { CATEGORIES } from './blog-const';
+
+export function parsePostBody(data: Record) {
+ const title = String(data.title ?? '').trim();
+ if (!title) return { error: 'Il titolo è obbligatorio.' } as const;
+ const category = String(data.category ?? '');
+ if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const;
+ const slug = slugify(String(data.slug ?? '') || title);
+ if (!slug) return { error: 'Slug non valido.' } as const;
+ return {
+ value: {
+ title, slug, category,
+ excerpt: String(data.excerpt ?? '').trim().slice(0, 300),
+ body_html: sanitizeHtml(String(data.body_html ?? '')),
+ cover: data.cover ? String(data.cover) : null,
+ draft: Boolean(data.draft),
+ },
+ } as const;
+}
diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts
new file mode 100644
index 0000000..b72e440
--- /dev/null
+++ b/src/lib/sanitize.ts
@@ -0,0 +1,11 @@
+import sanitize from 'sanitize-html';
+
+export function sanitizeHtml(html: string): string {
+ return sanitize(html, {
+ allowedTags: ['h2', 'h3', 'p', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'br', 'hr'],
+ allowedAttributes: { a: ['href', 'rel', 'target'], img: ['src', 'alt'] },
+ allowedSchemes: ['https', 'http', 'mailto'],
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
+ allowProtocolRelative: false,
+ });
+}
diff --git a/src/pages/admin/edit/[id].astro b/src/pages/admin/edit/[id].astro
new file mode 100644
index 0000000..7789e84
--- /dev/null
+++ b/src/pages/admin/edit/[id].astro
@@ -0,0 +1,14 @@
+---
+import Admin from '../../../layouts/Admin.astro';
+import PostForm from '../../../components/admin/PostForm.astro';
+import { getDb } from '../../../lib/db';
+import { getPostById } from '../../../lib/posts';
+export const prerender = false;
+const post = getPostById(getDb(), Number(Astro.params.id));
+if (!post) return Astro.redirect('/admin');
+---
+
+ Modifica articolo
+
+
+
diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro
new file mode 100644
index 0000000..a8e0cd3
--- /dev/null
+++ b/src/pages/admin/index.astro
@@ -0,0 +1,37 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { listAllPosts } from '../../lib/posts';
+export const prerender = false;
+const posts = listAllPosts(getDb());
+const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
+---
+
+ Articoli
+ + Nuovo articolo
+
+ | Titolo | Categoria | Stato | Aggiornato | |
+
+ {posts.map((p) => (
+
+ | {p.title} |
+ {p.category} |
+ {p.draft ? 'Bozza' : 'Pubblicato'} |
+ {fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))} |
+ |
+
+ ))}
+
+
+ {posts.length === 0 && Nessun articolo: creane uno nuovo.
}
+
+
+
diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro
new file mode 100644
index 0000000..2f39dd8
--- /dev/null
+++ b/src/pages/admin/login.astro
@@ -0,0 +1,34 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { login, SESSION_COOKIE } from '../../lib/auth';
+import { rateLimit } from '../../lib/rate-limit';
+export const prerender = false;
+
+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('/admin');
+ }
+ error = 'Credenziali non valide.';
+ }
+}
+---
+
+ Accesso
+ {error && {error}
}
+
+
diff --git a/src/pages/admin/logout.ts b/src/pages/admin/logout.ts
new file mode 100644
index 0000000..883170b
--- /dev/null
+++ b/src/pages/admin/logout.ts
@@ -0,0 +1,11 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../lib/db';
+import { logout, SESSION_COOKIE } from '../../lib/auth';
+export const prerender = false;
+
+export const GET: APIRoute = ({ cookies, redirect }) => {
+ const token = cookies.get(SESSION_COOKIE)?.value;
+ if (token) logout(getDb(), token);
+ cookies.delete(SESSION_COOKIE, { path: '/' });
+ return redirect('/admin/login');
+};
diff --git a/src/pages/admin/new.astro b/src/pages/admin/new.astro
new file mode 100644
index 0000000..08163ea
--- /dev/null
+++ b/src/pages/admin/new.astro
@@ -0,0 +1,10 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import PostForm from '../../components/admin/PostForm.astro';
+export const prerender = false;
+---
+
+ Nuovo articolo
+
+
+
diff --git a/src/pages/api/admin/posts/[id].ts b/src/pages/api/admin/posts/[id].ts
new file mode 100644
index 0000000..c7e8a33
--- /dev/null
+++ b/src/pages/api/admin/posts/[id].ts
@@ -0,0 +1,29 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { getPostById, updatePost, deletePost } 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 }) => {
+ const id = Number(params.id);
+ if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
+ let data: Record;
+ 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));
+ return json(200, { ok: true });
+};
diff --git a/src/pages/api/admin/posts/index.ts b/src/pages/api/admin/posts/index.ts
new file mode 100644
index 0000000..16305d7
--- /dev/null
+++ b/src/pages/api/admin/posts/index.ts
@@ -0,0 +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 }) => {
+ let data: Record;
+ 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);
+ 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/upload.ts b/src/pages/api/admin/upload.ts
new file mode 100644
index 0000000..e14487d
--- /dev/null
+++ b/src/pages/api/admin/upload.ts
@@ -0,0 +1,26 @@
+import type { APIRoute } from 'astro';
+import { writeFile, mkdir } from 'node:fs/promises';
+import { randomBytes } from 'node:crypto';
+import { join, extname } from 'node:path';
+import { getEnv } from '../../../lib/env';
+export const prerender = false;
+
+const ALLOWED: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+const MAX_BYTES = 5 * 1024 * 1024;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request }) => {
+ const form = await request.formData().catch(() => null);
+ const file = form?.get('file');
+ if (!(file instanceof File)) return json(400, { error: 'Nessun file.' });
+ const ext = extname(file.name).toLowerCase();
+ if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' });
+ if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' });
+ const dir = getEnv('UPLOADS_DIR', 'uploads');
+ await mkdir(dir, { recursive: true });
+ const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
+ await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer()));
+ return json(200, { url: `/uploads/${name}` });
+};
diff --git a/src/pages/uploads/[...path].ts b/src/pages/uploads/[...path].ts
new file mode 100644
index 0000000..1444dfa
--- /dev/null
+++ b/src/pages/uploads/[...path].ts
@@ -0,0 +1,20 @@
+import type { APIRoute } from 'astro';
+import { readFile } from 'node:fs/promises';
+import { join, normalize, extname } from 'node:path';
+import { getEnv } from '../../lib/env';
+export const prerender = false;
+
+const MIME: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+
+export const GET: APIRoute = async ({ params }) => {
+ const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, '');
+ if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 });
+ const mime = MIME[extname(rel).toLowerCase()];
+ if (!mime) return new Response('Not found', { status: 404 });
+ try {
+ const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel));
+ return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } });
+ } catch {
+ return new Response('Not found', { status: 404 });
+ }
+};
diff --git a/tests/sanitize.test.ts b/tests/sanitize.test.ts
new file mode 100644
index 0000000..0830958
--- /dev/null
+++ b/tests/sanitize.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest';
+import { sanitizeHtml } from '../src/lib/sanitize';
+
+describe('sanitizeHtml', () => {
+ it('mantiene la formattazione consentita', () => {
+ const html = 'Titolo
Testo forte ed enfasi
citazione
';
+ expect(sanitizeHtml(html)).toBe(html);
+ });
+ it('rimuove script e handler', () => {
+ expect(sanitizeHtml('ciao
')).toBe('ciao
');
+ });
+ it('consente immagini locali e https, blocca javascript:', () => {
+ expect(sanitizeHtml('
')).toContain('/uploads/a.jpg');
+ expect(sanitizeHtml('x')).toBe('x');
+ });
+});