feat: Favicon, Token bearbeiten, und Hochladen per Adresse

FAVICON. /favicon.ico antwortete mit 200 und text/html -- die SPA-Rueckfall-
route lieferte index.html als Icon aus. Ein leeres Blatt im Tab und 2 KB umsonst
bei jedem Aufruf, nachgemessen bevor es hier steht.

Jetzt ein SVG in den Farben der Oberflaeche (Grund #0b0d10, Akzent #4ea3ff,
dieselbe Plattenform wie in der Kopfzeile), dazu PNG in 32, 180 und 512 -- AUS
DERSELBEN SVG-Datei gerechnet und nicht zweimal gezeichnet. Plus ein
web-manifest, damit das Symbol auf einem Telefon-Startbildschirm stimmt.

Der erste Anlauf war kaputt, und zwar fuer JEDEN Parser: im SVG-Kommentar stand
"--color-grund", und XML verbietet den doppelten Bindestrich in Kommentaren.
Aufgefallen beim Rechnen der PNG, nicht erst im Browser.

TOKEN BEARBEITEN. Kontingent, Groessengrenze, Arten, Ablauf und Praefix liessen
sich nur beim ANLEGEN setzen -- wer einem bestehenden Token nachtraeglich eine
Grenze geben wollte, musste ihn neu anlegen und damit den Schluessel in jedem
Skript tauschen. Fuer eine Zahl in einer Tabelle der falsche Preis.

PATCH /api/dash/tokens/:id, jedes Feld einzeln. WEGGELASSEN heisst UNVERAENDERT,
null heisst ausdruecklich "keine Grenze" -- ohne diesen Unterschied liesse sich
eine einmal gesetzte Grenze nie wieder loeswerden. Der Schluessel selbst bleibt
unberuehrt, und das ist keine Vorsicht: in der Tabelle steht nur sein Hash.

HOCHLADEN PER ADRESSE. Der Dienst holt die Datei selbst. Das ist die
gefaehrlichste Funktion in diesem Dienst und steht deshalb in einer eigenen
Datei (holen.ts) -- ein Server, der eine vom Benutzer genannte Adresse abruft,
ist ein Angriff mit eigenem Namen.

Geprueft wird das Schema, der Anschluss und die AUFGELOESTE IP -- nicht der
Name. Ein Namensfilter waere einer fuer den, der ihn nicht umgehen will. Und
jede Umleitung wird SELBST gelaufen (redirect: 'manual') und neu geprueft;
liesse man fetch folgen, waere genau dort die Luecke.

Durchgemessen am laufenden Dienst, fuenfzehn Faelle:

    Erfolgsfall                                     abgelegt, 131956 B
    127.0.0.1 / localhost / [::1] / 0.0.0.0         abgelehnt
    10.x / 172.20.x / 192.168.x / 169.254.169.254   abgelehnt
    127.0.0.1.nip.io  (oeffentlicher Name, private IP)   abgelehnt
    192.168.2.1.nip.io / 169.254.169.254.nip.io         abgelehnt
    file:// und gopher://                           abgelehnt
    Anschluss 8080 / 9101                           abgelehnt

Die nip.io-Faelle sind der eigentliche Beleg: ein oeffentlich aufloesbarer Name,
der auf eine private Adresse zeigt, ist der Standardweg um einen Namensfilter
herum -- und faellt hier durch, weil die IP geprueft wird.

Nicht abschliessend geprueft: eine Umleitung, die ins Private zeigt. Mir fehlt
ein oeffentlicher Umleiter, der das tut (httpbingo lehnt es mit 403 ab). Der
Code laeuft die Kette selbst und ruft je Sprung dieselbe Pruefung -- belegt ist
also die Pruefung, nicht die Kette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 01:18:27 +02:00
co-authored by Claude Opus 5
parent 463a821199
commit e8efa2431d
14 changed files with 876 additions and 2 deletions
+221
View File
@@ -52,6 +52,7 @@ import {
} from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js'
import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js'
import { neuerSchluessel } from './freigabe.js'
import {
absolutePath,
@@ -947,6 +948,103 @@ dashRoutes.post('/upload', async (c) => {
*
* Nur lesen, nie schreiben — geschrieben wird an den Stellen, an denen es
* passiert. Ein Verlauf, den man von aussen fuellen kann, ist keiner. */
/** Hochladen, indem der Dienst die Datei SELBST holt.
*
* Ein einzelnes Bild von woanders zu uebernehmen hiess bisher: herunterladen,
* im Dateimanager wiederfinden, ins Fenster ziehen. Drei Schritte fuer etwas,
* das der Dienst selbst tun kann.
*
* Die Pruefung der Adresse steht in `holen.ts` und ist dort ausfuehrlich
* begruendet -- ein Server, der eine vom Benutzer genannte Adresse abruft, ist
* ein Angriff mit eigenem Namen. Hier bleibt nur das Ablegen, und zwar durch
* DENSELBEN Weg wie jeder andere Upload: gleiche Namensregeln, gleiche
* Vorschau, gleicher Eintrag im Verlauf. */
dashRoutes.post('/upload-url', async (c) => {
const body = await jsonBody<{ url?: string; ordner?: string; name?: string }>(c)
if (!body?.url?.trim()) return c.json({ error: 'Keine Adresse angegeben' }, 400)
let ordner = ''
if (body.ordner?.trim()) {
try {
ordner = checkPath(body.ordner.trim().replace(/^\/+|\/+$/g, ''))
} catch (err) {
if (err instanceof PathError) return c.json({ error: `Ordner: ${err.message}` }, 400)
throw err
}
}
let geholt: Geholt
try {
geholt = await holeVonAdresse(body.url.trim(), config.maxUploadBytes)
} catch (err) {
// HolFehler traegt einen Satz, den man lesen kann -- alles andere
// koennte eine Innenansicht sein und bekommt deshalb einen eigenen.
if (err instanceof HolFehler) return c.json({ error: err.message }, 400)
console.error('[holen]', err)
return c.json({ error: 'Die Adresse liess sich nicht abrufen.' }, 502)
}
const digest = sha256(geholt.data)
// Der gewuenschte Name schlaegt den aus der Adresse. Gezaehmt wird beides.
const roh = body.name?.trim() || geholt.name
const dateiname = safeFilename(roh, digest.slice(0, 8))
let path: string
try {
path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname)
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
// Was die Gegenstelle ueber die Art sagt, gilt -- aber nicht ihr
// Achselzucken. Dieselbe Regel wie beim Formular-Upload.
const mime =
geholt.mime && geholt.mime !== 'application/octet-stream' ? geholt.mime : mimeFor(path)
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
| Media
| undefined
await writeFileAtomic(path, geholt.data)
if (kannVorschau(mime)) await writeThumb(path, geholt.data)
if (lohntWebp(mime)) await writeWebp(path, geholt.data)
let dauer: number | null = null
if (kannBewegtVorschau(mime)) dauer = (await writeBewegtThumb(path, mime)).dauer
const zeit = now()
if (vorhanden) {
db.prepare(
`UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, updated_at = ?
WHERE id = ?`,
).run(geholt.data.length, digest, mime, dauer, zeit, vorhanden.id)
} else {
db.prepare(
`INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
).run(path, geholt.data.length, digest, mime, dauer, zeit, zeit)
}
logEvent(
vorhanden ? 'replace' : 'upload',
path,
geholt.data.length,
'user',
c.get('user').username,
)
return c.json({
path,
url: publicUrlFor(path),
size: geholt.data.length,
mime,
replaced: Boolean(vorhanden),
// Woher sie kam -- nach Umleitungen ist das nicht mehr die Adresse, die
// eingetippt wurde, und das soll man sehen.
von: geholt.von,
})
})
dashRoutes.get('/events', (c) => {
const limit = Math.min(Number(c.req.query('limit') ?? 50) || 50, 500)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
@@ -2247,6 +2345,129 @@ dashRoutes.post('/tokens', async (c) => {
})
})
/** Die Grenzen eines Tokens nachtraeglich aendern.
*
* WARUM ES DAS BRAUCHT: Kontingent, Arten, Ablauf und Praefix liessen sich nur
* beim ANLEGEN setzen. Wer einem bestehenden Token nachtraeglich eine Grenze
* geben wollte, musste ihn neu anlegen -- und damit den Schluessel in jedem
* Skript tauschen, das ihn benutzt. Fuer eine Zahl, die sich in der Datenbank
* aendern laesst, ist das der falsche Preis.
*
* DER SCHLUESSEL SELBST BLEIBT UNBERUEHRT, und zwar nicht aus Vorsicht: in der
* Tabelle steht nur sein Hash. Wir koennten ihn gar nicht aendern, ohne einen
* neuen auszugeben -- und genau das waere dann kein Bearbeiten mehr.
*
* Jedes Feld ist einzeln, und WEGGELASSEN heisst UNVERAENDERT. `null` dagegen
* heisst ausdruecklich "keine Grenze" -- ohne diesen Unterschied liesse sich
* eine einmal gesetzte Grenze nie wieder loeswerden. */
dashRoutes.patch('/tokens/:id', async (c) => {
const id = Number(c.req.param('id'))
const token = db.prepare('SELECT * FROM tokens WHERE id = ?').get(id) as Token | undefined
if (!token) return c.json({ error: 'nicht gefunden' }, 404)
const body = await jsonBody<{
name?: string
prefix?: string
canDelete?: boolean
maxMb?: number | string | null
quotaMb?: number | string | null
arten?: string[] | null
gueltigTage?: number | string | null
}>(c)
if (!body) return c.json(KEIN_JSON, 400)
const feld: Record<string, unknown> = {}
if (body.name !== undefined) {
if (!body.name.trim()) return c.json({ error: 'Der Name darf nicht leer sein' }, 400)
feld.name = body.name.trim()
}
if (body.prefix !== undefined) {
const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '')
if (roh) {
try {
feld.prefix = checkPath(roh)
} catch (err) {
if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400)
throw err
}
} else {
feld.prefix = ''
}
}
if (body.canDelete !== undefined) feld.can_delete = body.canDelete ? 1 : 0
// -- Die Zahlen. null loescht die Grenze, eine Zahl setzt sie. ----------
const zahl = (wert: unknown, was: string, deckel?: number) => {
if (wert === null || String(wert).trim() === '') return null
const n = Number(wert)
if (!Number.isFinite(n) || n <= 0) throw new Error(`${was}: eine Zahl groesser als 0`)
const bytes = Math.round(n * 1024 * 1024)
if (deckel && bytes > deckel) {
throw new Error(
`Der Dienst nimmt hoechstens ${Math.floor(deckel / 1024 / 1024)} MB — mehr kann auch dieser Token nicht.`,
)
}
return bytes
}
try {
if (body.maxMb !== undefined) {
feld.max_bytes = zahl(body.maxMb, 'Groessengrenze', config.maxUploadBytes)
}
if (body.quotaMb !== undefined) feld.quota_bytes = zahl(body.quotaMb, 'Kontingent')
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : 'ungueltige Zahl' }, 400)
}
if (body.arten !== undefined) {
const ERLAUBT = ['bild', 'video', 'ton', 'dokument', 'andere']
const liste = (body.arten ?? []).filter((a) => ERLAUBT.includes(a))
// Alle anzukreuzen ist dasselbe wie keine Einschraenkung -- und als
// leeres Feld gespeichert liest es sich spaeter auch so.
feld.arten = liste.length === 0 || liste.length === ERLAUBT.length ? '' : liste.join(',')
}
if (body.gueltigTage !== undefined) {
if (body.gueltigTage === null || String(body.gueltigTage).trim() === '') {
feld.expires_at = null
} else {
const tage = Number(body.gueltigTage)
if (!Number.isFinite(tage) || tage <= 0) {
return c.json({ error: 'Gueltigkeit: eine Zahl groesser als 0' }, 400)
}
// AB JETZT und nicht ab dem Anlegen: "noch 30 Tage" ist das, was
// jemand meint, der das Feld heute anfasst.
feld.expires_at = now() + tage * 86_400_000
}
}
const spalten = Object.keys(feld)
if (spalten.length === 0) return c.json({ error: 'nichts zu aendern' }, 400)
// Die Spaltennamen kommen aus DIESER Funktion und nie vom Aufrufer -- der
// liefert nur die Werte, und die gehen als Parameter hinein.
db.prepare(`UPDATE tokens SET ${spalten.map((s) => `${s} = ?`).join(', ')} WHERE id = ?`).run(
...spalten.map((s) => feld[s]),
id,
)
const neu = db
.prepare(
`SELECT id, name, prefix, can_delete, created_at, last_used_at,
max_bytes, quota_bytes, arten, expires_at
FROM tokens WHERE id = ?`,
)
.get(id) as Omit<Token, 'hash'>
return c.json({
...neu,
abgelaufen: Boolean(neu.expires_at && neu.expires_at < now()),
geaendert: spalten,
})
})
dashRoutes.delete('/tokens/:id', (c) => {
const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(Number(c.req.param('id')))
if (info.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)