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

{post?.cover && }

+ +
+ + + + + + + + + +
+
+ + +

+
+ + + + 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 + + + + +
+ InsanityLab · Admin + + {user && <>{`Ciao, ${user.username}`} Articoli Nuovo Esci Vedi sito ↗} + +
+
+ + 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

+ + + + {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.

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

  • voce
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'); + }); +});