feat: eigener Medien-Dienst statt Fivemanage Lite — Server-Grundgeruest
Ein Container statt vier: Hono, SQLite, Dateien auf einer Platte. Kein
PostgreSQL, kein MinIO, kein ClickHouse. Das ist nicht die kleine Loesung,
sondern die, die man nicht pflegt — eine Sicherung ist ein cp -a.
Der Ausloeser war nicht der Funktionsumfang von Lite, sondern dessen
Zustand. web/src/utils/http-util.ts wirft jeden Fehler als new Error(...),
die Hooks pruefen auf "instanceof ApiError" — damit wird jeder Fehlschlag
lautlos verschluckt. "Create organization" meldet weder Erfolg noch
Misserfolg; das Ergebnis waren 19 gleichnamige Organisationen, die sich auch
nicht loeschen lassen, weil die Route fehlt. Von allem, was Lite darueber
hinaus kann, haben wir nichts gebraucht.
Zwei Hostnamen, ein Prozess: unter FILES_HOST gibt es ausschliesslich
Dateien — kein Dashboard, keine API, nichts anzumelden. Das haelt die
oeffentliche Adresse frei von Angriffsflaeche und die URLs huebsch, ohne
/f/-Praefix. Die Vorlage im Handy bleibt {model}.webp.
Der Pfad ist der Schluessel: X-Path legt die Datei genau dort ab, wo der
Aufrufer sie erwartet. Bei Lite haetten wir die zurueckgegebenen Adressen in
einer urls.json mitschleppen muessen — vorhersagbare URLs sind fuer das
Fotostudio die ganze Voraussetzung.
storage.ts bereinigt Pfade NICHT, es lehnt sie ab. Ein zurechtgebogener Pfad
legt die Datei unter einem anderen Namen ab als dem erwarteten, und das faellt
erst auf, wenn das Bild fehlt.
Typprueft. Noch nicht gestartet, noch nicht getestet — das ist der naechste
Schritt und steht in ROADMAP.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// Der Einstiegspunkt.
|
||||
//
|
||||
// ZWEI HOSTNAMEN, EIN PROZESS. Kommt eine Anfrage unter FILES_HOST herein,
|
||||
// gibt es nur Dateien — kein Dashboard, keine API, kein Anmeldeformular. Das
|
||||
// hat zwei Gruende:
|
||||
//
|
||||
// 1. Die oeffentliche Adresse bleibt huebsch. Das Handy fragt
|
||||
// https://fivecdn.d4rkst3r.de/vehicles/adder.webp ab und nicht
|
||||
// irgendein /f/-Praefix — die Vorlage lautet weiterhin schlicht
|
||||
// {model}.webp.
|
||||
// 2. Der Name, der im Internet steht und den jeder Spieler kennt, hat
|
||||
// ueberhaupt keine Angriffsflaeche. Es gibt dort nichts anzumelden.
|
||||
//
|
||||
// Unter dem anderen Namen laeuft das Dashboard samt API, und die Dateien sind
|
||||
// dort zusaetzlich unter /f/ erreichbar — praktisch fuer die Galerie, wenn
|
||||
// jemand den zweiten Hostnamen noch nicht eingerichtet hat.
|
||||
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { logger } from 'hono/logger'
|
||||
import { config } from './config.js'
|
||||
import { ensureAdmin } from './auth.js'
|
||||
import { pruneSessions } from './db.js'
|
||||
import { absolutePath, checkPath, mimeFor } from './storage.js'
|
||||
import { uploadRoutes } from './routes/upload.js'
|
||||
import { dashRoutes } from './routes/dash.js'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
if (config.dev) app.use('*', logger())
|
||||
|
||||
// -------------------------------------------------------- Dateien ausliefern
|
||||
|
||||
/** Liefert eine Datei aus, mit ETag und Bereichsanfragen.
|
||||
*
|
||||
* Bereichsanfragen sind kein Luxus: ohne sie kann ein Browser in einem Video
|
||||
* nicht springen, und ein abgebrochener Download faengt von vorn an. */
|
||||
async function serveFile(c: any, rawPath: string) {
|
||||
let path: string
|
||||
try {
|
||||
path = checkPath(decodeURIComponent(rawPath))
|
||||
} catch {
|
||||
return c.text('nicht gefunden', 404)
|
||||
}
|
||||
|
||||
const absolute = absolutePath(path)
|
||||
let info
|
||||
try {
|
||||
info = await stat(absolute)
|
||||
if (!info.isFile()) return c.text('nicht gefunden', 404)
|
||||
} catch {
|
||||
return c.text('nicht gefunden', 404)
|
||||
}
|
||||
|
||||
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': mimeFor(path),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
ETag: etag,
|
||||
'Last-Modified': info.mtime.toUTCString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
// Kein Verlassen auf den Dateinamen: der Browser soll nicht raten,
|
||||
// was er da vor sich hat.
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
|
||||
if (c.req.header('if-none-match') === etag) return c.body(null, 304, headers)
|
||||
if (c.req.method === 'HEAD') {
|
||||
return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) })
|
||||
}
|
||||
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(c.req.header('range') ?? '')
|
||||
if (range) {
|
||||
const start = range[1] ? Number(range[1]) : 0
|
||||
const end = range[2] ? Number(range[2]) : info.size - 1
|
||||
if (start >= info.size || end >= info.size || start > end) {
|
||||
return c.body(null, 416, { 'Content-Range': `bytes */${info.size}` })
|
||||
}
|
||||
return c.body(createReadStream(absolute, { start, end }) as any, 206, {
|
||||
...headers,
|
||||
'Content-Range': `bytes ${start}-${end}/${info.size}`,
|
||||
'Content-Length': String(end - start + 1),
|
||||
})
|
||||
}
|
||||
|
||||
return c.body(createReadStream(absolute) as any, 200, {
|
||||
...headers,
|
||||
'Content-Length': String(info.size),
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Der Dateiwirt
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const host = (c.req.header('host') ?? '').split(':')[0]?.toLowerCase()
|
||||
if (host !== config.filesHost.split(':')[0]?.toLowerCase()) return next()
|
||||
|
||||
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
|
||||
return c.text('hier gibt es nur Dateien', 405)
|
||||
}
|
||||
if (c.req.path === '/') return c.text('d4rk_media', 200)
|
||||
|
||||
return serveFile(c, c.req.path.slice(1))
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- Dashboard und API
|
||||
|
||||
app.get('/health', (c) => c.json({ ok: true }))
|
||||
|
||||
app.route('/api', uploadRoutes)
|
||||
app.route('/api/dash', dashRoutes)
|
||||
|
||||
app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
|
||||
|
||||
// Die gebaute Oberflaeche. Alles, was keine API ist und keine Datei, bekommt
|
||||
// index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router.
|
||||
// fileURLToPath und nicht .pathname: unter Windows liefert letzteres
|
||||
// "/C:/..." und jedes stat() darauf schlaegt fehl.
|
||||
const webDir = fileURLToPath(new URL('../web/', import.meta.url))
|
||||
app.get('*', async (c) => {
|
||||
if (c.req.path.startsWith('/api/')) return c.json({ error: 'unbekannt' }, 404)
|
||||
|
||||
const candidate = c.req.path === '/' ? '/index.html' : c.req.path
|
||||
try {
|
||||
const target = join(webDir, candidate.replace(/^\//, ''))
|
||||
const info = await stat(target)
|
||||
if (info.isFile()) {
|
||||
return c.body(createReadStream(target) as any, 200, {
|
||||
'Content-Type': mimeFor(candidate),
|
||||
'Cache-Control': candidate.includes('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* faellt unten auf index.html */
|
||||
}
|
||||
|
||||
try {
|
||||
return c.body(createReadStream(join(webDir, 'index.html')) as any, 200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
})
|
||||
} catch {
|
||||
return c.text(
|
||||
'Die Oberflaeche ist nicht gebaut. Der Dienst laeuft trotzdem — ' +
|
||||
'die API steht unter /api.',
|
||||
200,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------- Start
|
||||
|
||||
ensureAdmin()
|
||||
pruneSessions()
|
||||
setInterval(pruneSessions, 3600_000).unref()
|
||||
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
console.log(`[d4rk_media] Port ${info.port}`)
|
||||
console.log(`[d4rk_media] Dateien oeffentlich unter ${config.publicUrl}`)
|
||||
console.log(`[d4rk_media] Dateiwirt hoert auf Host "${config.filesHost}"`)
|
||||
console.log(`[d4rk_media] Daten in ${config.dataDir}`)
|
||||
})
|
||||
Reference in New Issue
Block a user