diff --git a/src/lib/content-update.ts b/src/lib/content-update.ts new file mode 100644 index 0000000..1214d54 --- /dev/null +++ b/src/lib/content-update.ts @@ -0,0 +1,31 @@ +import type Database from 'better-sqlite3'; +import { validateStyles } from './style-presets'; +import { sanitizeInline } from './sanitize'; + +interface Body { value?: unknown; styles?: unknown } + +export function applyContentUpdate(db: Database.Database, tag: string, body: Body, username: string): + { ok: true } | { ok: false; status: number; error: string } { + const row = db.prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined; + if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' }; + + const sets: string[] = []; + const args: unknown[] = []; + if (body.value !== undefined) { + if (typeof body.value !== 'string') return { ok: false, status: 400, error: 'Valore non valido.' }; + if (row.type === 'image') return { ok: false, status: 400, error: 'Le immagini si aggiornano via upload.' }; + sets.push('value = ?'); + args.push(row.type === 'html' ? sanitizeInline(body.value) : body.value); + } + if (body.styles !== undefined) { + const v = validateStyles(body.styles); + if (!v.ok) return { ok: false, status: 400, error: v.error }; + sets.push('styles = ?'); + args.push(JSON.stringify(v.styles)); + } + if (!sets.length) return { ok: false, status: 400, error: 'Nessuna modifica.' }; + sets.push(`updated_at = datetime('now')`, 'updated_by = ?'); + args.push(username, tag); + db.prepare(`UPDATE content_blocks SET ${sets.join(', ')} WHERE tag = ?`).run(...args); + return { ok: true }; +} diff --git a/src/lib/upload-rules.ts b/src/lib/upload-rules.ts new file mode 100644 index 0000000..e51fd75 --- /dev/null +++ b/src/lib/upload-rules.ts @@ -0,0 +1,16 @@ +import { extname } from 'node:path'; + +export const ALLOWED: Record = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', +}; +export const MAX_BYTES = 5 * 1024 * 1024; + +export function validateUploadFile(file: unknown): + | { ok: true; file: File; ext: string } + | { ok: false; error: string } { + if (!(file instanceof File)) return { ok: false, error: 'Nessun file.' }; + const ext = extname(file.name).toLowerCase(); + if (!ALLOWED[ext]) return { ok: false, error: 'Formato non consentito (jpg, png, webp, gif).' }; + if (file.size > MAX_BYTES) return { ok: false, error: 'File troppo grande (max 5 MB).' }; + return { ok: true, file, ext }; +} diff --git a/src/pages/api/admin/content/[tag].ts b/src/pages/api/admin/content/[tag].ts new file mode 100644 index 0000000..66a8503 --- /dev/null +++ b/src/pages/api/admin/content/[tag].ts @@ -0,0 +1,17 @@ +import type { APIRoute } from 'astro'; +import { getDb } from '../../../../lib/db'; +import { applyContentUpdate } from '../../../../lib/content-update'; +import { invalidateContentCache } from '../../../../lib/content'; +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, locals }) => { + let data: Record; + try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } + const r = applyContentUpdate(getDb(), params.tag ?? '', data, locals.user!.username); + if (!r.ok) return json(r.status, { error: r.error }); + invalidateContentCache(); + return json(200, { ok: true }); +}; diff --git a/src/pages/api/admin/content/[tag]/image.ts b/src/pages/api/admin/content/[tag]/image.ts new file mode 100644 index 0000000..da8d9db --- /dev/null +++ b/src/pages/api/admin/content/[tag]/image.ts @@ -0,0 +1,47 @@ +import type { APIRoute } from 'astro'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getDb } from '../../../../../lib/db'; +import { getEnv } from '../../../../../lib/env'; +import { invalidateContentCache } from '../../../../../lib/content'; +import { validateUploadFile } from '../../../../../lib/upload-rules'; +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +function findImageTag(tag: string): { ok: true } | { ok: false; status: number; error: string } { + const row = getDb().prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined; + if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' }; + if (row.type !== 'image') return { ok: false, status: 400, error: 'Il tag non รจ di tipo immagine.' }; + return { ok: true }; +} + +export const POST: APIRoute = async ({ params, request, locals }) => { + const tag = params.tag ?? ''; + const found = findImageTag(tag); + if (!found.ok) return json(found.status, { error: found.error }); + const form = await request.formData().catch(() => null); + const checked = validateUploadFile(form?.get('file')); + if (!checked.ok) return json(400, { error: checked.error }); + const dir = join(getEnv('UPLOADS_DIR', 'uploads'), 'content'); + await mkdir(dir, { recursive: true }); + const name = `${tag.replaceAll('.', '-')}-${Date.now()}${checked.ext}`; + await writeFile(join(dir, name), Buffer.from(await checked.file.arrayBuffer())); + getDb().prepare( + `UPDATE content_blocks SET value = ?, updated_at = datetime('now'), updated_by = ? WHERE tag = ?` + ).run(`content/${name}`, locals.user!.username, tag); + invalidateContentCache(); + return json(200, { url: `/uploads/content/${name}` }); +}; + +export const DELETE: APIRoute = ({ params, locals }) => { + const tag = params.tag ?? ''; + const found = findImageTag(tag); + if (!found.ok) return json(found.status, { error: found.error }); + getDb().prepare( + `UPDATE content_blocks SET value = '', updated_at = datetime('now'), updated_by = ? WHERE tag = ?` + ).run(locals.user!.username, tag); + invalidateContentCache(); + return json(200, { ok: true }); +}; diff --git a/src/pages/api/admin/content/index.ts b/src/pages/api/admin/content/index.ts new file mode 100644 index 0000000..4af7ef0 --- /dev/null +++ b/src/pages/api/admin/content/index.ts @@ -0,0 +1,28 @@ +import type { APIRoute } from 'astro'; +import { getDb } from '../../../../lib/db'; +import { seedByTag } from '../../../../data/content-seed'; +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const GET: APIRoute = () => { + const rows = getDb().prepare( + 'SELECT tag, type, value, styles, updated_at, updated_by FROM content_blocks ORDER BY tag' + ).all() as { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }[]; + const items = rows.map((r) => { + let styles: Record = {}; + try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ } + if (typeof styles !== 'object' || styles === null) styles = {}; + return { + tag: r.tag, + type: r.type, + value: r.value, + styles, + styleOptions: seedByTag.get(r.tag)?.styleOptions ?? [], + updatedAt: r.updated_at, + updatedBy: r.updated_by, + }; + }); + return json(200, { items }); +}; diff --git a/src/pages/api/admin/upload.ts b/src/pages/api/admin/upload.ts index e14487d..6219dbc 100644 --- a/src/pages/api/admin/upload.ts +++ b/src/pages/api/admin/upload.ts @@ -1,26 +1,21 @@ import type { APIRoute } from 'astro'; import { writeFile, mkdir } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; -import { join, extname } from 'node:path'; +import { join } from 'node:path'; import { getEnv } from '../../../lib/env'; +import { validateUploadFile } from '../../../lib/upload-rules'; 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 checked = validateUploadFile(form?.get('file')); + if (!checked.ok) return json(400, { error: checked.error }); 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())); + const name = `${Date.now()}-${randomBytes(4).toString('hex')}${checked.ext}`; + await writeFile(join(dir, name), Buffer.from(await checked.file.arrayBuffer())); return json(200, { url: `/uploads/${name}` }); }; diff --git a/tests/content-api.test.ts b/tests/content-api.test.ts new file mode 100644 index 0000000..6d61853 --- /dev/null +++ b/tests/content-api.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { contentSeed } from '../src/data/content-seed'; +import { applyContentUpdate } from '../src/lib/content-update'; + +let db: Database.Database; +const textTag = contentSeed.find((e) => e.type === 'text')!.tag; +const htmlTag = contentSeed.find((e) => e.type === 'html')?.tag; +beforeEach(() => { db = createDb(':memory:'); }); + +describe('applyContentUpdate', () => { + it('aggiorna value e styles validi', () => { + const r = applyContentUpdate(db, textTag, { value: 'Nuovo testo', styles: { size: 'l' } }, 'admin'); + expect(r).toEqual({ ok: true }); + const row = db.prepare('SELECT value, styles, updated_by FROM content_blocks WHERE tag = ?').get(textTag) as any; + expect(row.value).toBe('Nuovo testo'); + expect(JSON.parse(row.styles)).toEqual({ size: 'l' }); + expect(row.updated_by).toBe('admin'); + }); + + it('404 su tag inesistente, 400 su styles fuori whitelist', () => { + expect(applyContentUpdate(db, 'no.tag', { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 404 }); + expect(applyContentUpdate(db, textTag, { styles: { size: 'xxl' } }, 'a')).toMatchObject({ ok: false, status: 400 }); + }); + + it('sanifica i tag html e rifiuta value su tag image', () => { + if (htmlTag) { + applyContentUpdate(db, htmlTag, { value: 'ciao ok' }, 'a'); + const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any; + expect(row.value).not.toContain('