From 782d306cb1efd0f2729f8b6ff20ce35b830bde18 Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Tue, 29 Sep 2026 09:42:19 +0000 Subject: [PATCH] =?UTF-8?q?Projekte=20anlegen=20und=20Clips=20in=20St?= =?UTF-8?q?=C3=BCcken=20hochladen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Projekt hält die ausgewählten Clips samt Absender und Beschriftung. Videos kommen in Stücken bis 16 MB; die Größe der Teildatei ist der Stand, sodass ein abgebrochener Upload dort weitermacht. Nach dem letzten Stück liest ffprobe Länge, Größe, Drehung und Tonspur aus. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/app.js | 48 ++++++++- server/http-error.js | 11 ++ server/index.js | 5 +- server/media.js | 42 ++++++++ server/projects.js | 246 ++++++++++++++++++++++++++++++++++++++++++ test/media.test.js | 56 ++++++++++ test/projects.test.js | 143 ++++++++++++++++++++++++ 7 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 server/http-error.js create mode 100644 server/media.js create mode 100644 server/projects.js create mode 100644 test/media.test.js create mode 100644 test/projects.test.js diff --git a/server/app.js b/server/app.js index 84204b7..0ad421a 100644 --- a/server/app.js +++ b/server/app.js @@ -8,8 +8,9 @@ import { readCookie, verifyToken, } from './auth.js'; +import { HttpError, badRequest } from './http-error.js'; -export function createApp({ config, secret }) { +export function createApp({ config, secret, store }) { const app = express(); app.disable('x-powered-by'); app.set('trust proxy', true); @@ -55,7 +56,52 @@ export function createApp({ config, secret }) { next(); }); + const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res)).catch(next); + + app.get('/api/projects', route(async (req, res) => { + res.json(await store.list()); + })); + + app.post('/api/projects', route(async (req, res) => { + res.status(201).json(await store.create(req.body ?? {})); + })); + + app.get('/api/projects/:id', route(async (req, res) => { + res.json(await store.get(req.params.id)); + })); + + app.delete('/api/projects/:id', route(async (req, res) => { + await store.remove(req.params.id); + res.status(204).end(); + })); + + app.put('/api/projects/:id/clips/:index', route(async (req, res) => { + const offset = Number(req.query.offset); + if (!Number.isSafeInteger(offset) || offset < 0) throw badRequest('Ungültiger Offset.'); + res.json(await store.appendChunk(req.params.id, req.params.index, offset, req)); + })); + + app.get('/api/projects/:id/clips/:index/file', route(async (req, res) => { + const project = await store.get(req.params.id); + res.sendFile(store.clipPath(project, req.params.index)); + })); + app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' })); + // eslint-disable-next-line no-unused-vars -- Express erkennt Fehlerbehandler an vier Parametern + app.use((error, req, res, next) => { + if (error instanceof HttpError) { + return res.status(error.status).json({ error: error.message, ...error.details }); + } + // Fehler aus express.json (kaputtes oder zu großes JSON) + if (error.status >= 400 && error.status < 500) { + return res.status(error.status).json({ error: 'Ungültige Anfrage.' }); + } + // Der Browser hat den Upload abgebrochen; der Stand bleibt gültig. + if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') return; + console.error(error); + if (!res.headersSent) res.status(500).json({ error: 'Interner Fehler.' }); + }); + return app; } diff --git a/server/http-error.js b/server/http-error.js new file mode 100644 index 0000000..b60183e --- /dev/null +++ b/server/http-error.js @@ -0,0 +1,11 @@ +export class HttpError extends Error { + constructor(status, message, details = {}) { + super(message); + this.status = status; + this.details = details; + } +} + +export const badRequest = (message) => new HttpError(400, message); +export const notFound = (message = 'Nicht gefunden.') => new HttpError(404, message); +export const conflict = (message, details) => new HttpError(409, message, details); diff --git a/server/index.js b/server/index.js index 65edbf4..fc9d93d 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,8 @@ import express from 'express'; import { createApp } from './app.js'; import { loadSessionSecret } from './auth.js'; import { loadConfig } from './config.js'; +import { probe } from './media.js'; +import { createProjectStore } from './projects.js'; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -13,7 +15,8 @@ if (!config.pin) { console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.'); } -const app = createApp({ config, secret: loadSessionSecret(config) }); +const store = createProjectStore({ dataDir: config.dataDir, probe }); +const app = createApp({ config, secret: loadSessionSecret(config), store }); const server = http.createServer(app); if (config.isProduction) { diff --git a/server/media.js b/server/media.js new file mode 100644 index 0000000..c9751d5 --- /dev/null +++ b/server/media.js @@ -0,0 +1,42 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +function parseRate(rate) { + const [numerator, denominator] = String(rate || '0/1').split('/').map(Number); + return denominator ? numerator / denominator : 0; +} + +// Handyvideos liegen oft quer im Container und tragen die Drehung als Metadatum. +function readRotation(stream) { + const side = stream.side_data_list?.find((entry) => entry.rotation !== undefined); + if (side) return Number(side.rotation); + if (stream.tags?.rotate) return Number(stream.tags.rotate); + return 0; +} + +// Liefert die Eckdaten eines Videos, Breite und Höhe so, wie es angezeigt wird. +export async function probe(file) { + const { stdout } = await execFileAsync( + 'ffprobe', + ['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', file], + { maxBuffer: 10 * 1024 * 1024 }, + ); + const info = JSON.parse(stdout); + const video = info.streams.find( + (stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic, + ); + if (!video) throw new Error('Keine Videospur gefunden.'); + + const rotation = readRotation(video); + const sideways = Math.abs(rotation) % 180 === 90; + return { + duration: Number(info.format?.duration ?? video.duration ?? 0), + width: sideways ? video.height : video.width, + height: sideways ? video.width : video.height, + rotation, + fps: Math.round(parseRate(video.avg_frame_rate || video.r_frame_rate) * 100) / 100, + hasAudio: info.streams.some((stream) => stream.codec_type === 'audio'), + }; +} diff --git a/server/projects.js b/server/projects.js new file mode 100644 index 0000000..58e2fd4 --- /dev/null +++ b/server/projects.js @@ -0,0 +1,246 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { HttpError, badRequest, conflict, notFound } from './http-error.js'; + +export const MAX_CLIPS = 200; +export const MAX_CLIP_BYTES = 4 * 1024 ** 3; +export const MAX_CHUNK_BYTES = 16 * 1024 * 1024; + +const ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'm4v', '3gp', 'avi', 'mkv', 'webm']); + +function extensionOf(name) { + return name.split('.').pop().toLowerCase(); +} + +function optionalString(value, maxLength) { + if (value === null || value === undefined) return null; + if (typeof value !== 'string') throw badRequest('Ungültige Angabe.'); + return value.slice(0, maxLength); +} + +function validateClip(input, index) { + const file = typeof input?.file === 'string' ? input.file : ''; + if (!file || file.length > 255 || !VIDEO_EXTENSIONS.has(extensionOf(file))) { + throw badRequest(`Clip ${index + 1}: kein unterstütztes Video.`); + } + if (!Number.isSafeInteger(input.size) || input.size < 1 || input.size > MAX_CLIP_BYTES) { + throw badRequest(`Clip ${index + 1}: ungültige Dateigröße.`); + } + if (typeof input.timestamp !== 'string' || !TIMESTAMP_PATTERN.test(input.timestamp)) { + throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`); + } + return { + index, + file, + size: input.size, + timestamp: input.timestamp, + sender: optionalString(input.sender, 200), + caption: optionalString(input.caption, 2000) ?? '', + status: 'pending', + }; +} + +function validateRange(range) { + const from = range?.from ?? null; + const to = range?.to ?? null; + for (const value of [from, to]) { + if (value !== null && (typeof value !== 'string' || !DATE_PATTERN.test(value))) { + throw badRequest('Ungültiger Zeitraum.'); + } + } + return { from, to }; +} + +// Bricht ab, sobald mehr Bytes kommen als erlaubt. +function byteLimit(limit) { + let count = 0; + return new Transform({ + transform(chunk, encoding, callback) { + count += chunk.length; + if (count > limit) return callback(new HttpError(413, 'Zu viele Daten.')); + callback(null, chunk); + }, + }); +} + +async function fileSize(file) { + try { + return (await fsp.stat(file)).size; + } catch (error) { + if (error.code === 'ENOENT') return 0; + throw error; + } +} + +export function createProjectStore({ dataDir, probe }) { + const root = path.join(dataDir, 'projects'); + const queues = new Map(); + const busyClips = new Set(); + + function projectDir(id) { + if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw notFound(); + return path.join(root, id); + } + + const projectFile = (id) => path.join(projectDir(id), 'project.json'); + const clipFile = (id, clip) => path.join(projectDir(id), 'clips', `${clip.index}.${extensionOf(clip.file)}`); + + async function read(id) { + try { + return JSON.parse(await fsp.readFile(projectFile(id), 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') throw notFound(); + throw error; + } + } + + async function write(project) { + const file = projectFile(project.id); + const temp = `${file}.tmp`; + await fsp.writeFile(temp, JSON.stringify(project, null, 2)); + await fsp.rename(temp, file); + } + + // Änderungen an einem Projekt laufen nacheinander, damit keine verloren geht. + function update(id, change) { + const previous = queues.get(id) ?? Promise.resolve(); + const next = previous.then(async () => { + const project = await read(id); + await change(project); + project.updatedAt = new Date().toISOString(); + await write(project); + return project; + }); + queues.set(id, next.catch(() => {})); + return next; + } + + function getClip(project, index) { + const clip = project.clips[Number(index)]; + if (!/^\d+$/.test(String(index)) || !clip) throw notFound('Clip nicht gefunden.'); + return clip; + } + + async function withProgress(project) { + const clips = await Promise.all( + project.clips.map(async (clip) => ({ + ...clip, + received: + clip.status === 'pending' + ? await fileSize(`${clipFile(project.id, clip)}.part`) + : clip.size, + })), + ); + return { ...project, clips }; + } + + return { + async create({ range, clips }) { + if (!Array.isArray(clips) || clips.length === 0) throw badRequest('Keine Clips ausgewählt.'); + if (clips.length > MAX_CLIPS) throw badRequest(`Höchstens ${MAX_CLIPS} Clips.`); + const now = new Date().toISOString(); + const project = { + id: crypto.randomUUID(), + createdAt: now, + updatedAt: now, + range: validateRange(range), + clips: clips.map(validateClip), + }; + await fsp.mkdir(path.join(projectDir(project.id), 'clips'), { recursive: true }); + await write(project); + return withProgress(project); + }, + + async get(id) { + return withProgress(await read(id)); + }, + + async list() { + let entries = []; + try { + entries = await fsp.readdir(root); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const projects = []; + for (const entry of entries) { + if (!ID_PATTERN.test(entry)) continue; + try { + const project = await read(entry); + projects.push({ + id: project.id, + createdAt: project.createdAt, + range: project.range, + clipCount: project.clips.length, + readyCount: project.clips.filter((clip) => clip.status === 'ready').length, + }); + } catch { + // Unvollständig angelegte Projekte überspringen + } + } + return projects.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + }, + + async remove(id) { + const dir = projectDir(id); + await read(id); + await fsp.rm(dir, { recursive: true, force: true }); + }, + + clipPath(project, index) { + const clip = getClip(project, index); + if (clip.status !== 'ready') throw notFound('Clip ist noch nicht hochgeladen.'); + return clipFile(project.id, clip); + }, + + // Hängt ein Stück an. Die Größe der Teildatei ist der maßgebliche Stand: + // bricht eine Übertragung ab, bleibt ein gültiger Anfang liegen. + async appendChunk(id, index, offset, stream) { + const project = await read(id); + const clip = getClip(project, index); + if (clip.status !== 'pending') return { received: clip.size, status: clip.status }; + + const key = `${id}/${clip.index}`; + if (busyClips.has(key)) throw conflict('Upload läuft bereits.', { received: null }); + busyClips.add(key); + try { + const final = clipFile(id, clip); + const part = `${final}.part`; + const received = await fileSize(part); + if (offset !== received) throw conflict('Upload-Stand weicht ab.', { received }); + + // Zu viel Gesendetes wird gar nicht erst geschrieben; der Anfang bleibt gültig. + const allowed = Math.min(MAX_CHUNK_BYTES, clip.size - received); + await pipeline(stream, byteLimit(allowed), fs.createWriteStream(part, { flags: 'a' })); + + const total = await fileSize(part); + if (total < clip.size) return { received: total, status: 'pending' }; + + await fsp.rename(part, final); + let media = null; + let problem = null; + try { + media = await probe(final); + } catch (error) { + problem = error.message; + } + const updated = await update(id, (current) => { + const target = current.clips[clip.index]; + target.status = media ? 'ready' : 'error'; + target.media = media; + if (problem) target.error = problem; + }); + return { received: total, status: updated.clips[clip.index].status }; + } finally { + busyClips.delete(key); + } + }, + }; +} diff --git a/test/media.test.js b/test/media.test.js new file mode 100644 index 0000000..7ee71be --- /dev/null +++ b/test/media.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; +import { probe } from '../server/media.js'; + +const hasFfmpeg = (() => { + try { + execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +})(); + +let dir; + +before(() => { + if (!hasFfmpeg) return; + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fdc-media-')); + const ffmpeg = (...args) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args]); + ffmpeg('-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=25', '-f', 'lavfi', '-i', 'sine', '-t', '2', + '-c:v', 'libx264', '-c:a', 'aac', '-shortest', path.join(dir, 'quer.mp4')); + // Wie ein Hochkant-Handyvideo: quer gespeichert, mit Drehung im Dateikopf + ffmpeg('-i', path.join(dir, 'quer.mp4'), '-an', '-c', 'copy', '-metadata:s:v:0', 'rotate=90', + path.join(dir, 'hochkant.mp4')); +}); + +after(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('ffprobe liefert Länge, Größe, Bildrate und Tonspur', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => { + const media = await probe(path.join(dir, 'quer.mp4')); + assert.ok(Math.abs(media.duration - 2) < 0.1); + assert.equal(media.width, 320); + assert.equal(media.height, 180); + assert.equal(media.fps, 25); + assert.equal(media.hasAudio, true); +}); + +test('Gedrehte Videos werden hochkant erkannt', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => { + const media = await probe(path.join(dir, 'hochkant.mp4')); + assert.equal(Math.abs(media.rotation), 90); + assert.equal(media.width, 180); + assert.equal(media.height, 320); + assert.equal(media.hasAudio, false); +}); + +test('Datei ohne Video wird abgelehnt', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => { + const file = path.join(dir, 'text.mp4'); + fs.writeFileSync(file, 'kein Video'); + await assert.rejects(probe(file)); +}); diff --git a/test/projects.test.js b/test/projects.test.js new file mode 100644 index 0000000..56d8fcc --- /dev/null +++ b/test/projects.test.js @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; +import { createApp } from '../server/app.js'; +import { createProjectStore } from '../server/projects.js'; + +let server; +let baseUrl; +let cookie; +let dataDir; + +const fakeMedia = { duration: 42, width: 1080, height: 1920, rotation: 0, fps: 30, hasAudio: true }; + +before(async () => { + dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fdc-test-')); + const probe = async (file) => { + const content = await fs.readFile(file, 'utf8'); + if (content.startsWith('kaputt')) throw new Error('Keine Videospur gefunden.'); + return fakeMedia; + }; + const store = createProjectStore({ dataDir, probe }); + const app = createApp({ config: { pin: '4711' }, secret: 'geheim', store }); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; + + const res = await fetch(`${baseUrl}/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin: '4711' }), + }); + cookie = res.headers.get('set-cookie').split(';')[0]; +}); + +after(async () => { + server.close(); + await fs.rm(dataDir, { recursive: true, force: true }); +}); + +function call(method, url, body, headers = {}) { + const isJson = body !== undefined && typeof body === 'object' && !(body instanceof Uint8Array); + return fetch(`${baseUrl}/api/${url}`, { + method, + headers: { cookie, ...(isJson ? { 'Content-Type': 'application/json' } : {}), ...headers }, + body: isJson ? JSON.stringify(body) : body, + }); +} + +const upload = (id, index, offset, text) => + call('PUT', `projects/${id}/clips/${index}?offset=${offset}`, new TextEncoder().encode(text), { + 'Content-Type': 'application/octet-stream', + }); + +const clip = (file, size) => ({ + file, + size, + timestamp: '2026-09-21T10:15:30', + sender: 'Anna', + caption: 'Ordnung', +}); + +async function createProject(clips) { + const res = await call('POST', 'projects', { range: { from: '2026-09-21', to: '2026-09-25' }, clips }); + assert.equal(res.status, 201); + return res.json(); +} + +test('Projekt anlegen und abrufen', async () => { + const project = await createProject([clip('a.mp4', 10), clip('b.mov', 20)]); + assert.equal(project.clips.length, 2); + assert.equal(project.clips[0].status, 'pending'); + assert.equal(project.clips[0].received, 0); + + const res = await call('GET', `projects/${project.id}`); + assert.equal((await res.json()).clips[1].file, 'b.mov'); + + const list = await (await call('GET', 'projects')).json(); + assert.ok(list.some((entry) => entry.id === project.id && entry.clipCount === 2)); +}); + +test('Ungültige Clips werden abgelehnt', async () => { + for (const clips of [[], [clip('notiz.txt', 10)], [clip('a.mp4', 0)], [{ ...clip('a.mp4', 5), timestamp: 'gestern' }]]) { + const res = await call('POST', 'projects', { clips }); + assert.equal(res.status, 400); + } +}); + +test('Upload in Stücken, Wiederaufnahme und Abschluss mit ffprobe', async () => { + const project = await createProject([clip('a.mp4', 10)]); + + let res = await upload(project.id, 0, 0, 'abcd'); + assert.deepEqual(await res.json(), { received: 4, status: 'pending' }); + + // Falscher Offset: der Server nennt den richtigen Stand + res = await upload(project.id, 0, 0, 'abcd'); + assert.equal(res.status, 409); + assert.equal((await res.json()).received, 4); + + res = await upload(project.id, 0, 4, 'efghij'); + assert.deepEqual(await res.json(), { received: 10, status: 'ready' }); + + const stored = await (await call('GET', `projects/${project.id}`)).json(); + assert.equal(stored.clips[0].status, 'ready'); + assert.deepEqual(stored.clips[0].media, fakeMedia); + + const file = await call('GET', `projects/${project.id}/clips/0/file`); + assert.equal(await file.text(), 'abcdefghij'); +}); + +test('Mehr Daten als angekündigt werden abgewiesen, der gültige Anfang bleibt', async () => { + const project = await createProject([clip('a.mp4', 5)]); + await upload(project.id, 0, 0, 'ab'); + const res = await upload(project.id, 0, 2, 'cdefgh'); + assert.equal(res.status, 413); + const stored = await (await call('GET', `projects/${project.id}`)).json(); + assert.equal(stored.clips[0].received, 2); +}); + +test('Datei ohne Videospur wird als Fehler markiert', async () => { + const project = await createProject([clip('a.mp4', 6)]); + const res = await upload(project.id, 0, 0, 'kaputt'); + assert.equal((await res.json()).status, 'error'); + const stored = await (await call('GET', `projects/${project.id}`)).json(); + assert.equal(stored.clips[0].error, 'Keine Videospur gefunden.'); +}); + +test('Unbekannte Projekte und Clips ergeben 404, auch bei manipulierten IDs', async () => { + assert.equal((await call('GET', 'projects/00000000-0000-0000-0000-000000000000')).status, 404); + assert.equal((await call('GET', 'projects/..%2F..%2Fetc')).status, 404); + const project = await createProject([clip('a.mp4', 5)]); + assert.equal((await upload(project.id, 7, 0, 'x')).status, 404); + assert.equal((await call('GET', `projects/${project.id}/clips/0/file`)).status, 404); +}); + +test('Projekt löschen entfernt die Dateien', async () => { + const project = await createProject([clip('a.mp4', 5)]); + await upload(project.id, 0, 0, 'ab'); + assert.equal((await call('DELETE', `projects/${project.id}`)).status, 204); + assert.equal((await call('GET', `projects/${project.id}`)).status, 404); + await assert.rejects(fs.stat(path.join(dataDir, 'projects', project.id))); +});