Files
Matthias Giesselmann b90ff252d1 Add Logbuch: project update blog with admin, media and API
Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
2026-07-31 21:33:42 +02:00

236 lines
7.5 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import sharp from 'sharp'
import { db } from '~/data/db'
import { apiClients, brands, media, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { sanitiseFilename } from '~/lib/media-upload'
import { POST as postMedia } from '~/app/api/v1/media/route'
import { GET as getFile } from '~/app/api/v1/media/file/[...path]/route'
let root: string
let previous: string | undefined
beforeAll(async () => {
previous = process.env.STORAGE_DIR
root = await mkdtemp(join(tmpdir(), 'logbuch-media-'))
process.env.STORAGE_DIR = root
})
afterAll(async () => {
if (previous === undefined) {
delete process.env.STORAGE_DIR
} else {
process.env.STORAGE_DIR = previous
}
await rm(root, { recursive: true, force: true })
})
async function seed() {
const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning()
const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning()
const [orbit] = await db.insert(projects).values({ brandId: brand!.id, slug: 'orbit', name: 'Orbit' }).returning()
await db.insert(apiClients).values([
{ name: 'Schreibclient', tokenHash: hashToken('lb_write'), mode: 'write', scope: 'internal', projectId: trakk!.id },
{ name: 'Leseclient', tokenHash: hashToken('lb_read'), mode: 'read', scope: 'internal', projectId: trakk!.id },
{ name: 'Freier Schreibclient', tokenHash: hashToken('lb_write_all'), mode: 'write', scope: 'internal' },
])
return { trakk: trakk!, orbit: orbit! }
}
async function png(width: number, height: number): Promise<Buffer> {
return sharp({ create: { width, height, channels: 3, background: '#2e7d5b' } }).png().toBuffer()
}
function upload(body: Buffer, options: { name?: string, token?: string, project?: string, alt?: string, type?: string } = {}) {
const form = new FormData()
const file = new File([new Uint8Array(body)], options.name ?? 'Aufmacher Bild.png', {
type: options.type ?? 'image/png',
})
form.set('file', file)
if (options.project) {
form.set('project', options.project)
}
if (options.alt) {
form.set('alt', options.alt)
}
const token = options.token ?? 'lb_write'
return new Request('http://localhost/api/v1/media', {
method: 'POST',
headers: { authorization: `Bearer ${token}` },
body: form,
})
}
function fileRequest(path: string[]) {
return getFile(new Request(`http://localhost/api/v1/media/file/${path.join('/')}`), {
params: Promise.resolve({ path }),
})
}
describe('POST /api/v1/media', () => {
it('legt einen Datensatz mit Fassungen in den erwarteten Breiten an', async () => {
await seed()
const response = await postMedia(upload(await png(2000, 1125), { alt: 'Ein ruhiges Schaubild' }))
const body = await response.json()
expect(response.status).toBe(201)
expect(body.width).toBe(2000)
expect(body.height).toBe(1125)
expect(body.mime).toBe('image/png')
expect(body.alt).toBe('Ein ruhiges Schaubild')
expect(body.variants.map((variant: { width: number }) => variant.width)).toEqual([480, 960, 1600])
expect(body.variants.every((variant: { format: string }) => variant.format === 'webp')).toBe(true)
expect(body.url).toMatch(/^\/api\/v1\/media\/file\//u)
const rows = await db.select().from(media)
expect(rows).toHaveLength(1)
expect(rows[0]!.variants).toHaveLength(3)
expect(rows[0]!.variantError).toBeNull()
})
it('bereinigt den Dateinamen', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { name: '../Über uns/Größe & Maß.png' }))
const body = await response.json()
expect(body.originalFilename).toBe('Groesse-Mass.png')
})
it('liefert die abgelegten Fassungen über die Auslieferung aus', async () => {
await seed()
const created = await (await postMedia(upload(await png(2000, 1125)))).json()
const variant = created.variants.at(-1)
const path = String(variant.url).replace('/api/v1/media/file/', '').split('/')
const response = await fileRequest(path)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/webp')
expect(response.headers.get('cache-control')).toContain('immutable')
expect(Number(response.headers.get('content-length'))).toBeGreaterThan(0)
})
it('weist eine zu grosse Datei ab', async () => {
await seed()
const oversized = Buffer.alloc(15 * 1024 * 1024 + 1, 7)
const response = await postMedia(upload(oversized))
expect(response.status).toBe(413)
expect(await db.select().from(media)).toHaveLength(0)
})
it('weist eine Datei ab, die kein Bild ist', async () => {
await seed()
const response = await postMedia(upload(Buffer.from('nur text, als bild deklariert')))
expect(response.status).toBe(422)
expect(await db.select().from(media)).toHaveLength(0)
})
it('weist ein Bildformat ab, das nicht angenommen wird', async () => {
await seed()
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>')
const response = await postMedia(upload(svg, { name: 'zeichnung.svg', type: 'image/svg+xml' }))
expect(response.status).toBe(415)
expect(await db.select().from(media)).toHaveLength(0)
})
it('antwortet ohne Token mit 401', async () => {
await seed()
const request = new Request('http://localhost/api/v1/media', { method: 'POST', body: new FormData() })
expect((await postMedia(request)).status).toBe(401)
})
it('lässt ein Lesetoken nicht hochladen', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { token: 'lb_read' }))
expect(response.status).toBe(403)
})
it('lässt ein projektgebundenes Token nicht in ein fremdes Projekt schreiben', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { project: 'orbit' }))
expect(response.status).toBe(403)
})
it('verlangt von einem freien Token die Angabe des Projekts', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { token: 'lb_write_all' }))
expect(response.status).toBe(400)
})
it('nimmt von einem freien Token ein benanntes Projekt an', async () => {
const { orbit } = await seed()
const response = await postMedia(upload(await png(600, 400), { token: 'lb_write_all', project: 'orbit' }))
const body = await response.json()
expect(response.status).toBe(201)
expect(body.projectId).toBe(orbit.id)
})
})
describe('GET /api/v1/media/file', () => {
it('weist jeden Pfad-Ausbruch mit 400 ab', async () => {
const escapes = [
['..', '..', 'etc', 'passwd'],
['..', '.env'],
['projekt', '..', '..', 'secret.txt'],
['', 'etc', 'passwd'],
['.', 'a.webp'],
]
for (const path of escapes) {
const response = await fileRequest(path)
expect(response.status, path.join('/')).toBe(400)
}
})
it('antwortet für ein unbekanntes Objekt mit 404', async () => {
const response = await fileRequest(['projekt', 'bild', 'w960.webp'])
expect(response.status).toBe(404)
})
})
describe('sanitiseFilename', () => {
it('nimmt Pfadanteile heraus und ersetzt Umlaute', () => {
expect(sanitiseFilename('/etc/passwd')).toBe('passwd')
expect(sanitiseFilename('..\\..\\Größe.png')).toBe('Groesse.png')
expect(sanitiseFilename('mein bild (1).PNG')).toBe('mein-bild-1.PNG')
})
it('fällt auf einen Ersatznamen zurück, wenn nichts übrig bleibt', () => {
expect(sanitiseFilename('...')).toBe('bild')
expect(sanitiseFilename('')).toBe('bild')
})
})