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.
This commit is contained in:
Matthias Giesselmann
2026-07-31 21:33:42 +02:00
commit b90ff252d1
291 changed files with 43671 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
contentTypeForPath,
createLocalStorage,
isSafeObjectPath,
resolveObjectPath,
StoragePathError,
type Storage,
} from '~/lib/storage'
let root: string
let storage: Storage
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'logbuch-storage-'))
storage = createLocalStorage(root)
})
afterAll(async () => {
await rm(root, { recursive: true, force: true })
})
const escapes = [
'../secret.txt',
'..',
'a/../../secret.txt',
'/etc/passwd',
'./a.webp',
'a//b.webp',
'a/./b.webp',
'..\\secret.txt',
'a\\b.webp',
'',
'C:/windows/win.ini',
]
describe('isSafeObjectPath', () => {
it('nimmt gewöhnliche Objektpfade an', () => {
expect(isSafeObjectPath('project/media/w960.webp')).toBe(true)
expect(isSafeObjectPath('a.webp')).toBe(true)
})
it('weist jeden Ausbruch aus dem Speicher ab', () => {
for (const path of escapes) {
expect(isSafeObjectPath(path), path).toBe(false)
}
})
it('weist Nullbytes und Steuerzeichen ab', () => {
expect(isSafeObjectPath('a\u0000b.webp')).toBe(false)
expect(isSafeObjectPath('a\nb.webp')).toBe(false)
})
})
describe('resolveObjectPath', () => {
it('bleibt innerhalb des Speicherverzeichnisses', () => {
expect(resolveObjectPath(root, 'a/b.webp')).toBe(resolve(root, 'a/b.webp'))
})
it('wirft bei jedem Ausbruchsversuch', () => {
for (const path of escapes) {
expect(() => resolveObjectPath(root, path), path).toThrow(StoragePathError)
}
})
})
describe('lokaler Speicher', () => {
it('legt ab, liest zurück und meldet den Inhaltstyp', async () => {
await storage.put('projekt/bild/w480.webp', Buffer.from('inhalt'), 'image/webp')
const object = await storage.read('projekt/bild/w480.webp')
expect(object?.body.toString()).toBe('inhalt')
expect(object?.contentType).toBe('image/webp')
expect(object?.byteSize).toBe(6)
})
it('meldet fehlende Objekte als undefined', async () => {
expect(await storage.read('projekt/bild/gibtsnicht.webp')).toBeUndefined()
})
it('legt keine Datei ausserhalb des Verzeichnisses an', async () => {
await expect(storage.put('../ausbruch.webp', Buffer.from('nein'), 'image/webp')).rejects.toThrow(StoragePathError)
})
it('liest keine Datei ausserhalb des Verzeichnisses', async () => {
const outside = resolve(root, '..', 'logbuch-outside.txt')
await writeFile(outside, 'geheim')
try {
await expect(storage.read('../logbuch-outside.txt')).rejects.toThrow(StoragePathError)
expect(await readFile(outside, 'utf8')).toBe('geheim')
} finally {
await rm(outside, { force: true })
}
})
it('entfernt Objekte und meldet ihre Abwesenheit', async () => {
await storage.put('projekt/bild/weg.webp', Buffer.from('x'), 'image/webp')
expect(await storage.exists('projekt/bild/weg.webp')).toBe(true)
await storage.remove('projekt/bild/weg.webp')
expect(await storage.exists('projekt/bild/weg.webp')).toBe(false)
})
it('baut eine Auslieferungs-URL unter dem Medienpfad', () => {
expect(storage.url('projekt/bild/w960.webp')).toBe('/api/v1/media/file/projekt/bild/w960.webp')
})
})
describe('contentTypeForPath', () => {
it('kennt die Bildformate', () => {
expect(contentTypeForPath('a/b.webp')).toBe('image/webp')
expect(contentTypeForPath('a/b.PNG')).toBe('image/png')
expect(contentTypeForPath('a/b.jpg')).toBe('image/jpeg')
})
it('fällt bei Unbekanntem auf Bytes zurück', () => {
expect(contentTypeForPath('a/b.bin')).toBe('application/octet-stream')
expect(contentTypeForPath('ohnepunkt')).toBe('application/octet-stream')
})
})