### 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/' }`. - `GET /uploads/` serve da `UPLOADS_DIR`. - [ ] **Step 1: test sanitizzazione (falliranno)** `tests/sanitize.test.ts`: ```ts 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'); }); }); ``` - [ ] **Step 2: verifica che falliscano, poi implementa sanitize.ts** Run: `npx vitest run tests/sanitize.test.ts` — FAIL. Poi: `src/lib/sanitize.ts`: ```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`: ```astro --- interface Props { title: string } const { title } = Astro.props; const user = Astro.locals.user; --- {title} — Admin InsanityLab
InsanityLab · Admin {user && <>Ciao, {user.username} Articoli Nuovo Esci Vedi sito ↗}
``` `src/pages/admin/login.astro` (POST gestito nel frontmatter): ```astro --- 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}

}
``` `src/pages/admin/logout.ts`: ```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`: ```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' }); ---

Articoli

+ Nuovo articolo

{posts.map((p) => ( ))}
TitoloCategoriaStatoAggiornato
{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.

}
``` - [ ] **Step 5: PostForm.astro con TipTap** ```astro --- import { CATEGORIES } from '../../lib/blog-const'; import type { Post } from '../../lib/posts'; interface Props { post?: Post } const { post } = Astro.props; ---

{post?.cover && }

``` `src/pages/admin/new.astro`: ```astro --- import Admin from '../../layouts/Admin.astro'; import PostForm from '../../components/admin/PostForm.astro'; export const prerender = false; ---

Nuovo articolo

``` `src/pages/admin/edit/[id].astro`: ```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'); ---

Modifica articolo

``` - [ ] **Step 6: API CRUD** `src/lib/post-body.ts` (parsing condiviso tra POST e PUT): ```ts 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; } ``` `src/pages/api/admin/posts/index.ts`: ```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; 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`: ```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; 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`: ```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 = { '.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`: ```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 = { '.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/`; modifica; elimina (con conferma). Expected: flusso completo senza errori console. - [ ] **Step 9: commit** ```bash git add -A && git commit -m "feat: area admin con editor tiptap, api crud e upload immagini" ``` ---