Files
Flaro/web/server/index.js
T
ScarriffleandClaude Opus 5 a04784ca15 Das Web bekommt ein Fundament und einen eigenen Server
Schritt 2 des Plans, erste Hälfte. Vite mit TypeScript, und ein Server
ohne eine einzige Abhängigkeit — er soll auf einem kleinen Container
laufen, nicht sein halbes Verzeichnis mitschleppen.

Der Server ist nicht Zierde. Er löst zwei Dinge, gegen die keine Zeile im
Frontend etwas ausrichten kann: Anfragen laufen über dieselbe Adresse wie
die Seite, womit CORS erledigt ist, auch beim Streamen. Und läuft der
Medienserver im Klartext, während die Seite verschlüsselt ausgeliefert
wird, blockiert der Browser sonst den Strom.

Geprüft: Weiterreichen mitsamt Autorisierung und richtig gesetztem Host,
Ausliefern gebauter Dateien, unbekannte Wege landen auf der Seite — und
ein Ausbruchsversuch mit "../../../etc/passwd" liefert die Seite statt
der Passwortdatei.

Der Körper wird durchgereicht statt gepuffert: ein Film ist ein Strom,
und ihn erst vollständig einzulesen hieße, ihn zwischenzuspeichern.

Die einzelne Datei bleibt vorerst, sie läuft ja.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:25:11 +02:00

135 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/* Der Flixy-Web-Server.
----------------------------------------------------------------------------
Zwei Aufgaben, und beide lösen etwas, woran die einzelne HTML-Datei
gescheitert ist:
1. **Er reicht die Anfragen weiter.** Damit haben Seite und Daten dieselbe
Herkunft. Kein CORS — auch nicht beim Streamen, wo es am ehesten weh
tut. Und kein Ärger mit gemischtem HTTP und HTTPS: Läuft der
Medienserver im Klartext und die Seite verschlüsselt, blockiert der
Browser den Strom, und keine Zeile im Frontend kann etwas dagegen tun.
2. **Er liefert die gebauten Dateien aus.**
Ohne Abhängigkeiten, nur mit dem, was Node mitbringt — ein Server, der auf
einem kleinen Container läuft, soll nicht sein halbes Verzeichnis
mitschleppen.
Aufruf: FLIXY_SERVER=http://jellyfin:8096 node index.js
*/
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { join, extname, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const HIER = fileURLToPath(new URL('.', import.meta.url));
const DATEIEN = process.env.FLIXY_STATIC ?? join(HIER, '..', 'app', 'dist');
const ZIEL = (process.env.FLIXY_SERVER ?? 'http://localhost:8096').replace(/\/+$/, '');
const PORT = Number(process.env.PORT ?? 8080);
const TYPEN = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
'.map': 'application/json',
};
/* Reicht eine Anfrage an den Medienserver weiter.
----------------------------------------------------------------------------
Der Körper wird **durchgereicht, nicht gepuffert**: ein Film ist ein Strom,
und ihn erst vollständig einzulesen hieße, ihn zwischenzuspeichern. Deshalb
`duplex: 'half'` und das Weiterleiten des Leseendes. */
async function weiterreichen(req, res) {
const pfad = req.url.slice('/api'.length) || '/';
const ziel = ZIEL + pfad;
// Die Kopfzeilen des Browsers gehen mit — bis auf die, die nur für die
// Verbindung hierher galten. `host` insbesondere: mit ihm meldete sich der
// Medienserver unter der falschen Adresse.
const kopf = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (['host', 'connection', 'content-length'].includes(k)) continue;
if (Array.isArray(v)) v.forEach((x) => kopf.append(k, x));
else if (v != null) kopf.set(k, v);
}
try {
const antwort = await fetch(ziel, {
method: req.method,
headers: kopf,
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
duplex: 'half',
redirect: 'manual',
});
const raus = Object.fromEntries(antwort.headers);
// Die Länge stimmt nach dem Durchreichen nicht mehr zwingend, und eine
// falsche Angabe bricht den Strom mitten im Film ab.
delete raus['content-encoding'];
delete raus['content-length'];
res.writeHead(antwort.status, raus);
if (!antwort.body) return res.end();
for await (const stueck of antwort.body) res.write(stueck);
res.end();
} catch (fehler) {
// Eine abgebrochene Verbindung ist kein Serverfehler — der Zuschauer hat
// nur weitergeklickt.
if (res.headersSent) return res.destroy();
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
fehler: 'Der Medienserver ist nicht erreichbar.',
ziel: ZIEL,
ursache: String(fehler?.cause?.code ?? fehler?.message ?? fehler),
}));
}
}
/** Liefert eine gebaute Datei aus — oder die Seite selbst, denn die
* Navigation läuft im Browser und jeder Weg führt auf dieselbe Datei. */
async function ausliefern(req, res) {
const angefragt = decodeURIComponent(new URL(req.url, 'http://x').pathname);
// `normalize` allein genügt nicht — erst das Abschneiden führender Punkte
// verhindert, dass jemand mit „../" aus dem Verzeichnis herausspaziert.
const sicher = normalize(angefragt).replace(/^(\.\.[/\\])+/, '');
let datei = join(DATEIEN, sicher);
try {
const info = await stat(datei);
if (info.isDirectory()) datei = join(datei, 'index.html');
} catch {
datei = join(DATEIEN, 'index.html');
}
try {
const inhalt = await readFile(datei);
res.writeHead(200, {
'content-type': TYPEN[extname(datei)] ?? 'application/octet-stream',
// Die Seite selbst nie zwischenspeichern, alles andere gern: gebaute
// Dateien tragen ihren Fingerabdruck im Namen.
'cache-control': datei.endsWith('index.html') ? 'no-cache' : 'max-age=31536000, immutable',
});
res.end(inhalt);
} catch {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Nicht gefunden. Wurde das Frontend gebaut? (npm run build in web/app)');
}
}
createServer((req, res) => {
if (req.url?.startsWith('/api')) return weiterreichen(req, res);
return ausliefern(req, res);
}).listen(PORT, () => {
console.log(`Flixy Web läuft auf http://localhost:${PORT}`);
console.log(`Medienserver: ${ZIEL}`);
console.log(`Dateien aus: ${DATEIEN}`);
});