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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
87b9fae94b
commit
a04784ca15
@@ -0,0 +1,134 @@
|
||||
#!/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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user