Files
Flaro/web/server/index.js
T
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +02:00

135 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/* Der Flaro-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: FLARO_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.FLARO_STATIC ?? join(HIER, '..', 'app', 'dist');
const ZIEL = (process.env.FLARO_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(`Flaro Web läuft auf http://localhost:${PORT}`);
console.log(`Medienserver: ${ZIEL}`);
console.log(`Dateien aus: ${DATEIEN}`);
});