b913a2bf90
Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
531 lines
22 KiB
Diff
531 lines
22 KiB
Diff
# Review package: f3fb2fe..HEAD
|
|
|
|
## Commits
|
|
f0ad162 feat: area admin con editor tiptap, api crud e upload immagini
|
|
|
|
## Files changed
|
|
src/components/admin/PostForm.astro | 136 ++++++++++++++++++++++++++++++++++++
|
|
src/layouts/Admin.astro | 36 ++++++++++
|
|
src/lib/post-body.ts | 21 ++++++
|
|
src/lib/sanitize.ts | 11 +++
|
|
src/pages/admin/edit/[id].astro | 14 ++++
|
|
src/pages/admin/index.astro | 37 ++++++++++
|
|
src/pages/admin/login.astro | 34 +++++++++
|
|
src/pages/admin/logout.ts | 11 +++
|
|
src/pages/admin/new.astro | 10 +++
|
|
src/pages/api/admin/posts/[id].ts | 29 ++++++++
|
|
src/pages/api/admin/posts/index.ts | 22 ++++++
|
|
src/pages/api/admin/upload.ts | 26 +++++++
|
|
src/pages/uploads/[...path].ts | 20 ++++++
|
|
tests/sanitize.test.ts | 16 +++++
|
|
14 files changed, 423 insertions(+)
|
|
|
|
## Diff
|
|
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;
|
|
+---
|
|
+<form id="post-form" data-id={post?.id ?? ''}>
|
|
+ <input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
|
|
+ <input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
|
|
+ <select class="afield" name="category">
|
|
+ {CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
|
|
+ </select>
|
|
+ <textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
|
|
+
|
|
+ <label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
|
|
+ <input type="hidden" name="cover" value={post?.cover ?? ''} />
|
|
+ <p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
|
|
+
|
|
+ <div class="ed-toolbar" id="ed-toolbar">
|
|
+ <button type="button" data-cmd="h2">H2</button>
|
|
+ <button type="button" data-cmd="h3">H3</button>
|
|
+ <button type="button" data-cmd="bold"><b>B</b></button>
|
|
+ <button type="button" data-cmd="italic"><i>I</i></button>
|
|
+ <button type="button" data-cmd="bullet">• Elenco</button>
|
|
+ <button type="button" data-cmd="ordered">1. Elenco</button>
|
|
+ <button type="button" data-cmd="quote">" Citazione</button>
|
|
+ <button type="button" data-cmd="link">Link</button>
|
|
+ <button type="button" data-cmd="image">Immagine</button>
|
|
+ </div>
|
|
+ <div id="editor" class="ed-body"></div>
|
|
+
|
|
+ <label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
|
|
+ <p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
|
|
+</form>
|
|
+
|
|
+<style>
|
|
+ .ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
|
|
+ .ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
|
|
+ .ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
|
|
+ .ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
|
|
+ .ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
|
|
+</style>
|
|
+
|
|
+<script>
|
|
+ import { Editor } from '@tiptap/core';
|
|
+ import StarterKit from '@tiptap/starter-kit';
|
|
+ import Image from '@tiptap/extension-image';
|
|
+ import Link from '@tiptap/extension-link';
|
|
+
|
|
+ const form = document.getElementById('post-form') as HTMLFormElement;
|
|
+ const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
|
|
+
|
|
+ const editor = new Editor({
|
|
+ element: document.getElementById('editor')!,
|
|
+ extensions: [
|
|
+ StarterKit.configure({ heading: { levels: [2, 3] } }),
|
|
+ Image,
|
|
+ Link.configure({ openOnClick: false }),
|
|
+ ],
|
|
+ content: initial,
|
|
+ });
|
|
+
|
|
+ async function uploadFile(file: File): Promise<string | null> {
|
|
+ const fd = new FormData();
|
|
+ fd.append('file', file);
|
|
+ const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
|
|
+ if (!res.ok) { alert('Upload non riuscito.'); return null; }
|
|
+ return (await res.json()).url;
|
|
+ }
|
|
+
|
|
+ document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
|
|
+ const btn = (e.target as HTMLElement).closest('button');
|
|
+ if (!btn) return;
|
|
+ const c = editor.chain().focus();
|
|
+ switch (btn.dataset.cmd) {
|
|
+ case 'h2': c.toggleHeading({ level: 2 }).run(); break;
|
|
+ case 'h3': c.toggleHeading({ level: 3 }).run(); break;
|
|
+ case 'bold': c.toggleBold().run(); break;
|
|
+ case 'italic': c.toggleItalic().run(); break;
|
|
+ case 'bullet': c.toggleBulletList().run(); break;
|
|
+ case 'ordered': c.toggleOrderedList().run(); break;
|
|
+ case 'quote': c.toggleBlockquote().run(); break;
|
|
+ case 'link': {
|
|
+ const url = prompt('URL del link:');
|
|
+ if (url) c.setLink({ href: url }).run();
|
|
+ break;
|
|
+ }
|
|
+ case 'image': {
|
|
+ const input = document.createElement('input');
|
|
+ input.type = 'file'; input.accept = 'image/*';
|
|
+ input.onchange = async () => {
|
|
+ const url = input.files?.[0] && await uploadFile(input.files[0]);
|
|
+ if (url) editor.chain().focus().setImage({ src: url }).run();
|
|
+ };
|
|
+ input.click();
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+ });
|
|
+
|
|
+ document.getElementById('cover-file')!.addEventListener('change', async (e) => {
|
|
+ const file = (e.target as HTMLInputElement).files?.[0];
|
|
+ if (!file) return;
|
|
+ const url = await uploadFile(file);
|
|
+ if (url) {
|
|
+ (form.elements.namedItem('cover') as HTMLInputElement).value = url;
|
|
+ document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
|
|
+ }
|
|
+ });
|
|
+
|
|
+ form.addEventListener('submit', async (e) => {
|
|
+ e.preventDefault();
|
|
+ const id = form.dataset.id;
|
|
+ const body = {
|
|
+ title: (form.elements.namedItem('title') as HTMLInputElement).value,
|
|
+ slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
|
|
+ category: (form.elements.namedItem('category') as HTMLSelectElement).value,
|
|
+ excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
|
|
+ cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
|
|
+ draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
|
|
+ body_html: editor.getHTML(),
|
|
+ };
|
|
+ const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
|
|
+ method: id ? 'PUT' : 'POST',
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
+ body: JSON.stringify(body),
|
|
+ });
|
|
+ const msg = document.getElementById('save-msg')!;
|
|
+ if (res.ok) {
|
|
+ msg.textContent = 'Salvato ✓';
|
|
+ if (!id) location.href = '/admin';
|
|
+ } else {
|
|
+ msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
|
|
+ }
|
|
+ });
|
|
+</script>
|
|
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;
|
|
+---
|
|
+<!doctype html>
|
|
+<html lang="it">
|
|
+ <head>
|
|
+ <meta charset="utf-8" />
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
+ <title>{title} — Admin InsanityLab</title>
|
|
+ <meta name="robots" content="noindex" />
|
|
+ <style is:global>
|
|
+ body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
|
|
+ .abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
|
+ .abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
|
|
+ .awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
|
|
+ .abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
|
|
+ .abtn--danger { background: #a33; }
|
|
+ .afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
|
|
+ table { width: 100%; border-collapse: collapse; background: #fff; }
|
|
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
|
|
+ .pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
|
|
+ .pill--draft { background: #f3d9a4; }
|
|
+ </style>
|
|
+ </head>
|
|
+ <body>
|
|
+ <div class="abar">
|
|
+ <strong>InsanityLab · Admin</strong>
|
|
+ <span>
|
|
+ {user && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</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/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<string, unknown>) {
|
|
+ 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');
|
|
+---
|
|
+<Admin title={`Modifica: ${post.title}`}>
|
|
+ <h1>Modifica articolo</h1>
|
|
+ <script id="initial-body" type="text/plain" set:html={post.body_html}></script>
|
|
+ <PostForm post={post} />
|
|
+</Admin>
|
|
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' });
|
|
+---
|
|
+<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>
|
|
+ <tbody>
|
|
+ {posts.map((p) => (
|
|
+ <tr>
|
|
+ <td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
|
|
+ <td>{p.category}</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>
|
|
+
|
|
+<script>
|
|
+ document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
|
+ b.addEventListener('click', async () => {
|
|
+ if (!confirm('Eliminare definitivamente questo articolo?')) return;
|
|
+ const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
|
|
+ if (res.ok) location.reload();
|
|
+ else alert('Eliminazione non riuscita.');
|
|
+ }));
|
|
+</script>
|
|
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.';
|
|
+ }
|
|
+}
|
|
+---
|
|
+<Admin title="Login">
|
|
+ <h1>Accesso</h1>
|
|
+ {error && <p style="color:#a33">{error}</p>}
|
|
+ <form method="post" style="max-width:360px">
|
|
+ <input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
|
|
+ <input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
|
+ <button class="abtn" type="submit">Entra</button>
|
|
+ </form>
|
|
+</Admin>
|
|
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;
|
|
+---
|
|
+<Admin title="Nuovo articolo">
|
|
+ <h1>Nuovo articolo</h1>
|
|
+ <script id="initial-body" type="text/plain"></script>
|
|
+ <PostForm />
|
|
+</Admin>
|
|
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<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));
|
|
+ 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<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);
|
|
+ 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<string, string> = { '.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<string, string> = { '.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 = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
|
|
+ expect(sanitizeHtml(html)).toBe(html);
|
|
+ });
|
|
+ it('rimuove script e handler', () => {
|
|
+ expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
|
|
+ });
|
|
+ it('consente immagini locali e https, blocca javascript:', () => {
|
|
+ expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
|
|
+ expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
|
|
+ });
|
|
+});
|