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 0000000..c67fff0 Binary files /dev/null and b/server/ui/public/favicon-180.png differ diff --git a/server/ui/public/favicon-32.png b/server/ui/public/favicon-32.png new file mode 100644 index 0000000..c180814 Binary files /dev/null and b/server/ui/public/favicon-32.png differ diff --git a/server/ui/public/favicon-512.png b/server/ui/public/favicon-512.png new file mode 100644 index 0000000..bbb15de Binary files /dev/null and b/server/ui/public/favicon-512.png differ 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)} - + +