From e8efa2431decb9dcea5baa86294d16c089a66403 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Wed, 12 Aug 2026 01:18:27 +0200 Subject: [PATCH] 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 --- server/src/holen.ts | 198 +++++++++++++++++ server/src/index.ts | 4 + server/src/routes/dash.ts | 221 +++++++++++++++++++ server/ui/index.html | 15 ++ server/ui/public/favicon-180.png | Bin 0 -> 3518 bytes server/ui/public/favicon-32.png | Bin 0 -> 480 bytes server/ui/public/favicon-512.png | Bin 0 -> 21312 bytes server/ui/public/favicon.svg | 25 +++ server/ui/public/site.webmanifest | 12 + server/ui/src/api.ts | 28 +++ server/ui/src/components/Hochladen.tsx | 3 + server/ui/src/components/TokenBearbeiten.tsx | 198 +++++++++++++++++ server/ui/src/components/VonAdresse.tsx | 148 +++++++++++++ server/ui/src/pages/Tokens.tsx | 26 ++- 14 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 server/src/holen.ts create mode 100644 server/ui/public/favicon-180.png create mode 100644 server/ui/public/favicon-32.png create mode 100644 server/ui/public/favicon-512.png create mode 100644 server/ui/public/favicon.svg create mode 100644 server/ui/public/site.webmanifest create mode 100644 server/ui/src/components/TokenBearbeiten.tsx create mode 100644 server/ui/src/components/VonAdresse.tsx diff --git a/server/src/holen.ts b/server/src/holen.ts new file mode 100644 index 0000000..9ad8576 --- /dev/null +++ b/server/src/holen.ts @@ -0,0 +1,198 @@ +// Eine Datei von einer fremden Adresse holen. +// +// WOZU. 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. +// +// UND DAS IST DIE GEFAEHRLICHSTE FUNKTION IN DIESEM DIENST, deshalb steht sie +// in einer eigenen Datei. Ein Server, der eine vom Benutzer genannte Adresse +// abruft, ist ein Angriff mit eigenem Namen: SSRF. Wer ihn dazu bringt, +// +// http://127.0.0.1:9101/api/dash/... die eigene API von innen +// http://169.254.169.254/latest/meta/ die Zugangsdaten der Cloud +// http://192.168.2.1/ den Router im Heimnetz +// +// abzurufen, laesst den Dienst Dinge lesen, die von aussen unerreichbar sind -- +// und bekommt sie als Datei zurueck, unter einer oeffentlichen Adresse. +// +// Dass hier eine Anmeldung davorsteht, ist KEIN Ersatz fuer die Pruefung. Es +// gibt zwei Konten, eines davon entsteht ueber eine Discord-Rolle, die jemand +// anders vergibt. + +import { lookup } from 'node:dns/promises' +import { isIP } from 'node:net' + +/** Wie lange der Dienst auf eine fremde Adresse wartet. */ +const FRIST_MS = 20_000 + +/** Wie oft einer Umleitung gefolgt wird. + * + * Und JEDE davon wird neu geprueft -- das ist der Kern. Ein Server kann mit + * 302 auf http://127.0.0.1 zeigen, und `fetch` folgt von sich aus. Deshalb + * steht unten `redirect: 'manual'`: die Kette wird selbst gelaufen, damit + * jeder Halt durch dieselbe Pruefung geht wie der erste. + */ +const UMLEITUNGEN_MAX = 3 + +export class HolFehler extends Error {} + +/** Adressbereiche, die von hier aus niemanden etwas angehen. + * + * Geprueft wird die AUFGELOESTE IP und nicht der Name: `localtest.me` zeigt + * auf 127.0.0.1, und ein eigener DNS-Eintrag kann auf alles zeigen. Ein + * Namensfilter waere ein Filter fuer den, der ihn nicht umgehen will. */ +function istPrivat(ip: string): boolean { + if (isIP(ip) === 6) { + const v = ip.toLowerCase() + if (v === '::1' || v === '::') return true + // fc00::/7 (eindeutig lokal) und fe80::/10 (verbindungslokal) + if (/^f[cd]/.test(v) || /^fe[89ab]/.test(v)) return true + // IPv4 in IPv6-Schreibweise: ::ffff:127.0.0.1 + const eingebettet = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v) + if (eingebettet?.[1]) return istPrivat(eingebettet[1]) + return false + } + + const t = ip.split('.').map(Number) + if (t.length !== 4 || t.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true + const [a, b] = t as [number, number, number, number] + + if (a === 0) return true // dieses Netz + if (a === 10) return true // privat + if (a === 127) return true // Rueckschleife + if (a === 169 && b === 254) return true // verbindungslokal UND Cloud-Metadaten + if (a === 172 && b >= 16 && b <= 31) return true // privat + if (a === 192 && b === 168) return true // privat + if (a === 100 && b >= 64 && b <= 127) return true // Provider-internes NAT + if (a >= 224) return true // Rundruf und reserviert + return false +} + +/** Eine Adresse pruefen: Schema, Anschluss, aufgeloeste IP. Wirft HolFehler. */ +async function pruefeZiel(roh: string): Promise { + let u: URL + try { + u = new URL(roh) + } catch { + throw new HolFehler('Das ist keine gueltige Adresse.') + } + + if (u.protocol !== 'http:' && u.protocol !== 'https:') { + throw new HolFehler(`Nur http und https — "${u.protocol.replace(':', '')}" nicht.`) + } + + // Ein Anschluss, der nicht der uebliche ist, deutet fast immer auf einen + // Dienst im eigenen Netz. Die Ausnahme waere ein oeffentlicher Server auf + // einem krummen Port; die ist selten genug, um sie abzulehnen. + const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 + if (port !== 80 && port !== 443) { + throw new HolFehler(`Nur die ueblichen Anschluesse 80 und 443 — nicht ${port}.`) + } + + const name = u.hostname.replace(/^\[|\]$/g, '') + let adressen: string[] + if (isIP(name)) { + adressen = [name] + } else { + try { + adressen = (await lookup(name, { all: true })).map((a) => a.address) + } catch { + throw new HolFehler(`"${name}" laesst sich nicht aufloesen.`) + } + } + if (adressen.length === 0) throw new HolFehler(`"${name}" hat keine Adresse.`) + + // JEDE aufgeloeste Adresse muss oeffentlich sein. Ein Name mit zwei + // Eintraegen -- einer oeffentlich, einer auf 127.0.0.1 -- waere sonst ein + // Wuerfelspiel darueber, welchen Node nimmt. + for (const ip of adressen) { + if (istPrivat(ip)) { + throw new HolFehler( + `"${name}" zeigt auf ${ip} und damit ins eigene Netz. Das geht von hier aus nicht.`, + ) + } + } + + return u +} + +export type Geholt = { data: Buffer; mime: string; name: string; von: string } + +/** Die Datei holen. `hoechstens` ist die Grenze in Bytes. + * + * Der Rumpf wird STUECKWEISE gelesen und abgebrochen, sobald die Grenze + * reisst. `content-length` allein zu glauben waere naiv -- die Angabe kommt + * vom fremden Server und muss nicht stimmen. */ +export async function holeVonAdresse(roh: string, hoechstens: number): Promise { + let ziel = await pruefeZiel(roh) + + let antwort: Response | null = null + for (let sprung = 0; sprung <= UMLEITUNGEN_MAX; sprung++) { + const res = await fetch(ziel, { + redirect: 'manual', + signal: AbortSignal.timeout(FRIST_MS), + headers: { 'User-Agent': 'd4rk_media/1.0 (+holen)' }, + }).catch((err) => { + throw new HolFehler( + `Nicht erreichbar: ${err instanceof Error ? (err.cause as { code?: string })?.code ?? err.name : 'unbekannt'}`, + ) + }) + + if (res.status >= 300 && res.status < 400) { + const wohin = res.headers.get('location') + await res.body?.cancel().catch(() => {}) + if (!wohin) throw new HolFehler(`Umleitung ohne Ziel (HTTP ${res.status}).`) + if (sprung === UMLEITUNGEN_MAX) { + throw new HolFehler(`Mehr als ${UMLEITUNGEN_MAX} Umleitungen.`) + } + // Neu pruefen. GENAU HIER waere die Luecke, wenn man `fetch` selbst + // folgen liesse. + ziel = await pruefeZiel(new URL(wohin, ziel).href) + continue + } + + if (!res.ok) { + await res.body?.cancel().catch(() => {}) + throw new HolFehler(`Die Gegenstelle antwortet mit HTTP ${res.status}.`) + } + antwort = res + break + } + if (!antwort) throw new HolFehler('Keine Antwort.') + + const angekuendigt = Number(antwort.headers.get('content-length') ?? NaN) + if (Number.isFinite(angekuendigt) && angekuendigt > hoechstens) { + await antwort.body?.cancel().catch(() => {}) + throw new HolFehler(`Zu gross: ${angekuendigt} > ${hoechstens} Bytes.`) + } + + const stuecke: Buffer[] = [] + let gesamt = 0 + const leser = antwort.body?.getReader() + if (!leser) throw new HolFehler('Die Antwort hat keinen Rumpf.') + + for (;;) { + const { done, value } = await leser.read() + if (done) break + gesamt += value.byteLength + if (gesamt > hoechstens) { + await leser.cancel().catch(() => {}) + throw new HolFehler( + `Zu gross: die Gegenstelle schickt mehr als ${hoechstens} Bytes.`, + ) + } + stuecke.push(Buffer.from(value)) + } + + if (gesamt === 0) throw new HolFehler('Die Gegenstelle hat nichts geschickt.') + + // Der Name aus dem Pfad, sonst aus dem Hostnamen. Zurechtgebogen wird er + // erst beim Ablegen -- hier soll stehen, was dort stand. + const ausPfad = decodeURIComponent(ziel.pathname.split('/').pop() ?? '') + return { + data: Buffer.concat(stuecke), + mime: (antwort.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '', + name: ausPfad || ziel.hostname, + von: ziel.href, + } +} diff --git a/server/src/index.ts b/server/src/index.ts index f335481..3c5083c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -324,6 +324,10 @@ function decodeSafely(raw: string): string { * Stylesheet noch Modul. */ const WEB_MIME: Record = { html: 'text/html; charset=utf-8', + // webmanifest steht hier und nicht in der geteilten Tabelle: es ist eine + // Datei der Oberflaeche. Ohne den Eintrag kaeme sie als + // application/octet-stream, und der Browser wuerde sie verwerfen. + webmanifest: 'application/manifest+json; charset=utf-8', css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index c74ef8c..e954379 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -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 = {} + + 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 + 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) diff --git a/server/ui/index.html b/server/ui/index.html index 7d9a0ee..765022d 100644 --- a/server/ui/index.html +++ b/server/ui/index.html @@ -5,6 +5,21 @@ D4RKST3R Media + + + + + + +
diff --git a/server/ui/public/favicon-180.png b/server/ui/public/favicon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..c67fff0b820f1354416225a66d131c326f405b98 GIT binary patch literal 3518 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 literal 0 HcmV?d00001 diff --git a/server/ui/public/favicon-32.png b/server/ui/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..c1808145b3e4fdd19d3c6aeeda5e40c99c6ada8e GIT binary patch literal 480 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 literal 0 HcmV?d00001 diff --git a/server/ui/public/favicon.svg b/server/ui/public/favicon.svg new file mode 100644 index 0000000..b63491e --- /dev/null +++ b/server/ui/public/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/server/ui/public/site.webmanifest b/server/ui/public/site.webmanifest new file mode 100644 index 0000000..3376849 --- /dev/null +++ b/server/ui/public/site.webmanifest @@ -0,0 +1,12 @@ +{ + "name": "D4RKST3R Media", + "short_name": "Media", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d10", + "theme_color": "#0b0d10", + "icons": [ + { "src": "/favicon-180.png", "sizes": "180x180", "type": "image/png" }, + { "src": "/favicon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 4da8b32..331496f 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -505,6 +505,34 @@ export const api = { ), freigabeWeg: (id: number) => del<{ ok: true }>(`/api/dash/freigaben/${id}`), + tokenAendern: ( + id: number, + daten: { + name?: string + prefix?: string + canDelete?: boolean + maxMb?: string | null + quotaMb?: string | null + arten?: string[] | null + gueltigTage?: string | null + }, + ) => request(`/api/dash/tokens/${id}`, { + method: 'PATCH', + body: JSON.stringify(daten), + }), + + /** Der Dienst holt die Datei selbst. Die Adressprüfung steht im Server + * (holen.ts) — hier kommt nur der Satz zurück, wenn sie greift. */ + hochladenVonAdresse: (url: string, ordner: string, name?: string) => + post<{ + path: string + url: string + size: number + mime: string + replaced: boolean + von: string + }>('/api/dash/upload-url', { url, ordner, name }), + tokens: () => get<{ items: TokenRow[]; ohneToken: { dateien: number; bytes: number } }>( '/api/dash/tokens', diff --git a/server/ui/src/components/Hochladen.tsx b/server/ui/src/components/Hochladen.tsx index 4aacedb..10b7260 100644 --- a/server/ui/src/components/Hochladen.tsx +++ b/server/ui/src/components/Hochladen.tsx @@ -4,6 +4,7 @@ import { api, type Folders, type UploadErgebnis } from '../api' import { run, useStore } from '../store' import { bytes } from '../format' import { Knopf } from './Bausteine' +import { VonAdresse } from './VonAdresse' /** Hochladen aus dem Dashboard. * @@ -267,6 +268,8 @@ export function Hochladen({ )} + + {ergebnisse === null && !laeuft && ( void + onFertig: () => void +}) { + const [name, setName] = useState(token.name) + const [prefix, setPrefix] = useState(token.prefix) + const [canDelete, setCanDelete] = useState(Boolean(token.can_delete)) + const [maxMb, setMaxMb] = useState( + token.max_bytes ? String(Math.round((token.max_bytes / 1048576) * 10) / 10) : '', + ) + const [quotaMb, setQuotaMb] = useState( + token.quota_bytes ? String(Math.round(token.quota_bytes / 1048576)) : '', + ) + const [arten, setArten] = useState( + token.arten ? token.arten.split(',').map((a) => a.trim()).filter(Boolean) : [], + ) + const [gueltigTage, setGueltigTage] = useState('') + const [laeuft, setLaeuft] = useState(false) + + return ( + { + setLaeuft(true) + const r = await run( + () => + api.tokenAendern(token.id, { + name: name.trim(), + prefix, + canDelete, + // Leer heißt ausdrücklich „keine Grenze" und nicht + // „nicht angefasst" — sonst ließe sich eine einmal + // gesetzte Grenze nie wieder loswerden. + maxMb: maxMb.trim() || null, + quotaMb: quotaMb.trim() || null, + arten, + // Nur schicken, wenn etwas dasteht: ein leeres Feld + // soll den Ablauf NICHT löschen, sondern ihn + // unverändert lassen. Zum Löschen gibt es den Knopf + // darunter. + ...(gueltigTage.trim() ? { gueltigTage: gueltigTage.trim() } : {}), + }), + (x) => `${x.name}: ${x.geaendert.length} Angabe(n) geändert.`, + ) + setLaeuft(false) + if (r) onFertig() + }} + text={ +
+ setName(e.target.value)} + autoFocus + /> + + setPrefix(e.target.value)} + placeholder="vehicles" + className="font-mono" + hinweis="Gilt nur für künftige Uploads — was schon liegt, bleibt liegen." + /> + +
+ setMaxMb(e.target.value)} + /> + setQuotaMb(e.target.value)} + /> +
+ + + +
+ + Erlaubte Arten (nichts angekreuzt = alle) + +
+ {ARTEN.map(([wert, text]) => ( + + ))} +
+
+ + setGueltigTage(e.target.value)} + hinweis={ + token.expires_at + ? 'Zählt ab heute, nicht ab dem Anlegen.' + : undefined + } + /> + +

+ + + Der Schlüssel bleibt derselbe.{' '} + In der Datenbank steht nur sein Hash — er ließe sich gar nicht ändern, + ohne einen neuen auszugeben. Deine Skripte müssen also nichts + anfassen. + +

+
+ } + /> + ) +} diff --git a/server/ui/src/components/VonAdresse.tsx b/server/ui/src/components/VonAdresse.tsx new file mode 100644 index 0000000..b869343 --- /dev/null +++ b/server/ui/src/components/VonAdresse.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react' +import { Link2, Loader2 } from 'lucide-react' +import { api, type Folders } from '../api' +import { run } from '../store' +import { bytes } from '../format' +import { Knopf } from './Bausteine' + +/** Eine Datei holen, statt sie hochzuladen. + * + * Der Dienst ruft die Adresse selbst ab. Das spart den Umweg über die + * Festplatte — herunterladen, im Dateimanager wiederfinden, ins Fenster + * ziehen —, und bei einem einzelnen Bild sind das drei Schritte für nichts. + * + * DASS DAS EINE HEIKLE FUNKTION IST, steht im Server (`holen.ts`): eine + * Adresse, die auf `127.0.0.1` oder ins Heimnetz zeigt, ließe den Dienst Dinge + * lesen, die von außen unerreichbar sind. Geprüft wird dort — hier kommt nur + * der Satz zurück, wenn die Prüfung greift, und der ist zum Lesen gemacht. */ +export function VonAdresse({ + ordner, + ordnerDaten, + fertig, +}: { + ordner: string + ordnerDaten: Folders | null + fertig: () => void +}) { + const [offen, setOffen] = useState(false) + const [url, setUrl] = useState('') + const [ziel, setZiel] = useState(ordner) + const [name, setName] = useState('') + const [laeuft, setLaeuft] = useState(false) + + if (!offen) { + return ( + + ) + } + + async function holen() { + if (!url.trim()) return + setLaeuft(true) + const r = await run( + () => api.hochladenVonAdresse(url.trim(), ziel, name.trim() || undefined), + (x) => + `${x.path} geholt (${bytes(x.size)})` + + (x.replaced ? ' — ersetzt' : '') + + // Nach einer Umleitung ist das nicht mehr die Adresse, die + // eingetippt wurde. Das gehört gesagt. + (x.von !== url.trim() ? `, von ${x.von}` : ''), + ) + setLaeuft(false) + if (r) { + setUrl('') + setName('') + fertig() + } + } + + return ( +
+
+ + Von einer Adresse holen + + +
+ + setUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !laeuft && void holen()} + placeholder="https://…/bild.png" + className="w-full rounded-md border border-rand bg-grund px-2 py-1.5 text-sm + text-schrift placeholder:text-schrift-leise/60 + focus:border-akzent focus:outline-none" + /> + +
+ + + void holen()} + kind={ + laeuft ? ( + <> + holt … + + ) : ( + 'Holen' + ) + } + /> +
+ +

+ Nur http und{' '} + https, nur die Anschlüsse 80 und 443, und die + Adresse darf nicht ins eigene Netz zeigen — auch nicht über eine Umleitung. +

+
+ ) +} diff --git a/server/ui/src/pages/Tokens.tsx b/server/ui/src/pages/Tokens.tsx index 51ab971..c1e2910 100644 --- a/server/ui/src/pages/Tokens.tsx +++ b/server/ui/src/pages/Tokens.tsx @@ -1,9 +1,10 @@ import { useEffect, useState, type FormEvent } from 'react' -import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react' +import { AlertTriangle, Check, Copy, KeyRound, Loader2, PenLine, Plus, Trash2 } from 'lucide-react' import { api, type NewToken, type TokenRow } from '../api' import { run, useStore } from '../store' import { bytes, zeit } from '../format' import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' +import { TokenBearbeiten } from '../components/TokenBearbeiten' export function Tokens() { const note = useStore((s) => s.note) @@ -21,6 +22,7 @@ export function Tokens() { const [frisch, setFrisch] = useState(null) const [weg, setWeg] = useState(null) + const [bearbeiten, setBearbeiten] = useState(null) const [loescht, setLoescht] = useState(false) const [ohneToken, setOhneToken] = useState<{ dateien: number; bytes: number } | null>(null) const { kopiert, kopieren } = useKopieren() @@ -302,7 +304,16 @@ export function Tokens() { {zeit(t.last_used_at)} - + +