Files
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

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

21 KiB

Task 15: Area admin — login, lista, editor TipTap, API CRUD e upload (TDD su sanitizzazione)

Files:

  • Create: src/lib/sanitize.ts, src/lib/post-body.ts, src/layouts/Admin.astro, src/pages/admin/login.astro, src/pages/admin/logout.ts, src/pages/admin/index.astro, src/pages/admin/new.astro, src/pages/admin/edit/[id].astro, src/components/admin/PostForm.astro, src/pages/api/admin/posts/index.ts, src/pages/api/admin/posts/[id].ts, src/pages/api/admin/upload.ts, src/pages/uploads/[...path].ts
  • Test: tests/sanitize.test.ts

Interfaces:

  • Consumes: auth/middleware (Task 6), posts (Task 5), slugify (Task 4), CATEGORIES (Task 14), rateLimit (Task 4), getEnv (Task 4).

  • Produces:

    • sanitizeHtml(html: string): string in src/lib/sanitize.ts.
    • POST /api/admin/posts body { title, slug?, category, excerpt, body_html, cover?, draft } → 201 { id, slug }.
    • PUT /api/admin/posts/[id] stesso body → 200; DELETE /api/admin/posts/[id] → 200.
    • POST /api/admin/upload (multipart, campo file) → 200 { url: '/uploads/<nome>' }.
    • GET /uploads/<file> serve da UPLOADS_DIR.
  • Step 1: test sanitizzazione (falliranno)

tests/sanitize.test.ts:

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>');
  });
});
  • Step 2: verifica che falliscano, poi implementa sanitize.ts

Run: npx vitest run tests/sanitize.test.ts — FAIL. Poi:

src/lib/sanitize.ts:

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,
  });
}

Run: npm test — PASS. (Se sanitize-html mantiene src relativi di default lo verifichi col test; in caso contrario aggiungi allowedSchemesByTag o normalizza i percorsi.)

  • Step 3: layout Admin e login

src/layouts/Admin.astro:

---
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>

src/pages/admin/login.astro (POST gestito nel frontmatter):

---
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>

src/pages/admin/logout.ts:

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');
};
  • Step 4: lista articoli

src/pages/admin/index.astro:

---
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>
  • Step 5: PostForm.astro con TipTap
---
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>

src/pages/admin/new.astro:

---
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>

src/pages/admin/edit/[id].astro:

---
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>
  • Step 6: API CRUD

src/lib/post-body.ts (parsing condiviso tra POST e PUT):

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;
}

src/pages/api/admin/posts/index.ts:

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;
  }
};

src/pages/api/admin/posts/[id].ts:

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 });
};
  • Step 7: upload e route file

src/pages/api/admin/upload.ts:

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}` });
};

src/pages/uploads/[...path].ts:

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 });
  }
};
  • Step 8: verifica end-to-end manuale

Con dev server attivo e utente creato (npm run create-user -- admin prova1234):

  1. curl -s -o /dev/null -w '%{http_code}' localhost:4321/admin → 302 (redirect a login da non autenticati).
  2. curl -s -X POST localhost:4321/api/admin/posts -H 'Content-Type: application/json' -d '{}' → 401.
  3. Browser: login su /admin/login, crea articolo con immagine nel corpo, salva come pubblicato, verifica su /blog e /blog/<slug>; modifica; elimina (con conferma). Expected: flusso completo senza errori console.
  • Step 9: commit
git add -A && git commit -m "feat: area admin con editor tiptap, api crud e upload immagini"