Projekte anlegen und Clips in Stücken hochladen

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) <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 09:42:19 +00:00
1 parent 6f583c8f5b
commit 782d306cb1
7 files changed
+549 -2

No files matched your search

+47 -1
View File
@@ -8,8 +8,9 @@ import {
readCookie, readCookie,
verifyToken, verifyToken,
} from './auth.js'; } from './auth.js';
import { HttpError, badRequest } from './http-error.js';
export function createApp({ config, secret }) { export function createApp({ config, secret, store }) {
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.set('trust proxy', true); app.set('trust proxy', true);
@@ -55,7 +56,52 @@ export function createApp({ config, secret }) {
next(); 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.' })); 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; return app;
} }
+11
View File
@@ -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);
+4 -1
View File
@@ -5,6 +5,8 @@ import express from 'express';
import { createApp } from './app.js'; import { createApp } from './app.js';
import { loadSessionSecret } from './auth.js'; import { loadSessionSecret } from './auth.js';
import { loadConfig } from './config.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)), '..'); 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.'); 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); const server = http.createServer(app);
if (config.isProduction) { if (config.isProduction) {
+42
View File
@@ -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'),
};
}
+246
View File
@@ -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);
}
},
};
}
+56
View File
@@ -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));
});
+143
View File
@@ -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)));
});