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>
440 lines
22 KiB
TypeScript
440 lines
22 KiB
TypeScript
import { useEffect, useState, type FormEvent } from '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)
|
|
const [rows, setRows] = useState<TokenRow[] | null>(null)
|
|
const [laedt, setLaedt] = useState(true)
|
|
|
|
const [name, setName] = useState('')
|
|
const [prefix, setPrefix] = useState('')
|
|
const [darfLoeschen, setDarfLoeschen] = useState(false)
|
|
const [maxMb, setMaxMb] = useState('')
|
|
const [quotaMb, setQuotaMb] = useState('')
|
|
const [gueltigTage, setGueltigTage] = useState('')
|
|
const [arten, setArten] = useState<string[]>([])
|
|
const [legtAn, setLegtAn] = useState(false)
|
|
|
|
const [frisch, setFrisch] = useState<NewToken | null>(null)
|
|
const [weg, setWeg] = useState<TokenRow | null>(null)
|
|
const [bearbeiten, setBearbeiten] = useState<TokenRow | null>(null)
|
|
const [loescht, setLoescht] = useState(false)
|
|
const [ohneToken, setOhneToken] = useState<{ dateien: number; bytes: number } | null>(null)
|
|
const { kopiert, kopieren } = useKopieren()
|
|
|
|
async function laden() {
|
|
setLaedt(true)
|
|
try {
|
|
const antwort = await api.tokens()
|
|
setRows(antwort.items)
|
|
setOhneToken(antwort.ohneToken)
|
|
} catch (err) {
|
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
|
} finally {
|
|
setLaedt(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void laden()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
async function anlegen(e: FormEvent) {
|
|
e.preventDefault()
|
|
setLegtAn(true)
|
|
const neu = await run(
|
|
() =>
|
|
api.createToken({
|
|
name: name.trim(),
|
|
prefix: prefix.trim(),
|
|
canDelete: darfLoeschen,
|
|
maxMb: maxMb.trim(),
|
|
quotaMb: quotaMb.trim(),
|
|
arten,
|
|
gueltigTage: gueltigTage.trim(),
|
|
}),
|
|
(t) => `Token "${t.name}" angelegt.`,
|
|
)
|
|
setLegtAn(false)
|
|
if (neu) {
|
|
setFrisch(neu)
|
|
setName('')
|
|
setPrefix('')
|
|
setDarfLoeschen(false)
|
|
setMaxMb('')
|
|
setGueltigTage('')
|
|
setArten([])
|
|
void laden()
|
|
}
|
|
}
|
|
|
|
async function loeschen() {
|
|
if (!weg) return
|
|
setLoescht(true)
|
|
const ok = await run(() => api.deleteToken(weg.id), `Token "${weg.name}" gelöscht.`)
|
|
setLoescht(false)
|
|
setWeg(null)
|
|
if (ok) void laden()
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<Karte titel="Neuen Token anlegen">
|
|
<form onSubmit={anlegen} className="flex flex-col gap-3">
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Feld
|
|
label="Name"
|
|
value={name}
|
|
placeholder="d4rk_photostudio"
|
|
hinweis="Nur zur Wiedererkennung in dieser Liste."
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
<Feld
|
|
label="Präfix (optional)"
|
|
value={prefix}
|
|
placeholder="vehicles"
|
|
hinweis="Der Token darf dann nur darunter schreiben."
|
|
onChange={(e) => setPrefix(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Feld
|
|
label="Größte Datei (MB, optional)"
|
|
type="number"
|
|
min={0}
|
|
step="0.5"
|
|
value={maxMb}
|
|
placeholder="leer = Grenze des Dienstes"
|
|
hinweis="Ein Token für Fahrzeugbilder braucht keine 64 MB."
|
|
onChange={(e) => setMaxMb(e.target.value)}
|
|
/>
|
|
<Feld
|
|
label="Kontingent insgesamt (MB, optional)"
|
|
type="number"
|
|
min={0}
|
|
step="1"
|
|
value={quotaMb}
|
|
placeholder="leer = unbegrenzt"
|
|
hinweis="Die Grenze links gilt je Datei. Diese hier gilt für alles zusammen — ohne sie kann ein Token die Platte füllen, es braucht nur genug kleine Dateien."
|
|
onChange={(e) => setQuotaMb(e.target.value)}
|
|
/>
|
|
<Feld
|
|
label="Gültig für (Tage, optional)"
|
|
type="number"
|
|
min={1}
|
|
value={gueltigTage}
|
|
placeholder="leer = unbefristet"
|
|
hinweis="Danach antwortet der Dienst mit einer Meldung, die das sagt."
|
|
onChange={(e) => setGueltigTage(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
|
Erlaubte Arten (optional)
|
|
</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{(
|
|
[
|
|
['bild', 'Bilder'],
|
|
['video', 'Video'],
|
|
['ton', 'Ton'],
|
|
['dokument', 'Dokumente'],
|
|
['andere', 'Anderes'],
|
|
] as const
|
|
).map(([wert, text]) => {
|
|
const an = arten.includes(wert)
|
|
return (
|
|
<button
|
|
key={wert}
|
|
type="button"
|
|
onClick={() =>
|
|
setArten((a) =>
|
|
an ? a.filter((x) => x !== wert) : [...a, wert],
|
|
)
|
|
}
|
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
|
an
|
|
? 'border-akzent bg-akzent/15 text-schrift'
|
|
: 'border-rand text-schrift-leise hover:text-schrift'
|
|
}`}
|
|
>
|
|
{text}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<span className="mt-1 block text-xs text-schrift-leise">
|
|
Nichts gewählt heißt: alles erlaubt. Ein Token, der nur Bilder darf,
|
|
kann kein Video hochladen — auch wenn er abhandenkommt.
|
|
</span>
|
|
</div>
|
|
|
|
<label className="flex items-start gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={darfLoeschen}
|
|
onChange={(e) => setDarfLoeschen(e.target.checked)}
|
|
className="mt-0.5 accent-akzent"
|
|
/>
|
|
<span>
|
|
darf löschen
|
|
<span className="block text-xs text-schrift-leise">
|
|
Ohne dieses Häkchen kann der Token nur schreiben. Ein Werkzeug,
|
|
das nur Bilder ablegt, braucht es nicht.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
|
|
<div>
|
|
<Knopf
|
|
art="haupt"
|
|
type="submit"
|
|
wartet={legtAn}
|
|
disabled={!name.trim()}
|
|
kind={
|
|
<>
|
|
<Plus size={15} />
|
|
Anlegen
|
|
</>
|
|
}
|
|
/>
|
|
</div>
|
|
</form>
|
|
</Karte>
|
|
|
|
<Karte titel="Vorhandene Token">
|
|
{laedt ? (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="animate-spin text-schrift-leise" />
|
|
</div>
|
|
) : !rows || rows.length === 0 ? (
|
|
<Leer
|
|
kind={<KeyRound size={26} className="text-schrift-leise/50" />}
|
|
text="Noch kein Token angelegt."
|
|
/>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-xs text-schrift-leise">
|
|
<th className="pb-2 font-medium">Name</th>
|
|
<th className="pb-2 font-medium">Präfix</th>
|
|
<th className="pb-2 font-medium">Rechte</th>
|
|
<th className="pb-2 font-medium">Grenzen</th>
|
|
{/* Was mit dem Token wirklich abgelegt wurde.
|
|
`token_id` steht seit dem ersten Tag in jeder
|
|
Zeile und war bis hierhin NIRGENDS zu sehen —
|
|
dabei ist es die Auskunft, die vor dem
|
|
Löschen zählt. */}
|
|
<th className="pb-2 text-right font-medium">Abgelegt</th>
|
|
<th className="pb-2 font-medium">Angelegt</th>
|
|
<th className="pb-2 font-medium">Zuletzt benutzt</th>
|
|
<th className="pb-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((t) => (
|
|
<tr
|
|
key={t.id}
|
|
className={`border-t border-rand ${t.abgelaufen ? 'opacity-50' : ''}`}
|
|
>
|
|
<td className="py-2 font-medium">
|
|
{t.name}
|
|
{t.abgelaufen && (
|
|
<span className="ml-2 rounded bg-schlecht/15 px-1.5 py-0.5 text-[11px] text-schlecht">
|
|
abgelaufen
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 font-mono text-schrift-leise">
|
|
{t.prefix ? `${t.prefix}/` : '— (überall)'}
|
|
</td>
|
|
<td className="py-2">
|
|
{t.can_delete ? (
|
|
<span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn">
|
|
schreiben + löschen
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-schrift-leise">
|
|
nur schreiben
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 text-xs text-schrift-leise">
|
|
{[
|
|
t.max_bytes ? `max ${bytes(t.max_bytes)}/Datei` : null,
|
|
t.quota_bytes
|
|
? `${bytes(t.bytes)} von ${bytes(t.quota_bytes)}`
|
|
: null,
|
|
t.arten ? t.arten.replaceAll(',', ', ') : null,
|
|
t.expires_at
|
|
? `bis ${zeit(t.expires_at).slice(0, 10)}`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ') || '—'}
|
|
</td>
|
|
<td className="py-2 text-right whitespace-nowrap">
|
|
{t.dateien === 0 ? (
|
|
<span className="text-xs text-schrift-leise">—</span>
|
|
) : (
|
|
<span title={
|
|
t.zuletztAbgelegt
|
|
? `zuletzt ${zeit(t.zuletztAbgelegt)}`
|
|
: undefined
|
|
}>
|
|
<span className="tabular-nums">{t.dateien}</span>
|
|
<span className="text-xs text-schrift-leise">
|
|
{' '}
|
|
· {bytes(t.bytes)}
|
|
</span>
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 text-schrift-leise">
|
|
{zeit(t.created_at)}
|
|
</td>
|
|
<td className="py-2 text-schrift-leise">
|
|
{zeit(t.last_used_at)}
|
|
</td>
|
|
<td className="py-2 text-right whitespace-nowrap">
|
|
<button
|
|
onClick={() => setBearbeiten(t)}
|
|
className="mr-1 rounded border border-rand px-2 py-1 text-schrift-leise
|
|
hover:bg-flaeche-hoch hover:text-schrift"
|
|
aria-label={`Token ${t.name} bearbeiten`}
|
|
title="Grenzen bearbeiten"
|
|
>
|
|
<PenLine size={13} />
|
|
</button>
|
|
<button
|
|
onClick={() => setWeg(t)}
|
|
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
|
hover:bg-schlecht/15 hover:text-schlecht"
|
|
aria-label={`Token ${t.name} löschen`}
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{ohneToken && ohneToken.dateien > 0 && (
|
|
<p className="mt-3 border-t border-rand pt-3 text-xs text-schrift-leise">
|
|
Dazu kommen <strong className="text-schrift">{ohneToken.dateien}</strong>{' '}
|
|
Dateien ({bytes(ohneToken.bytes)}) <strong>ohne</strong> Token — aus dem
|
|
Dashboard hochgeladen. Dort gibt es eine Sitzung und keinen Token;
|
|
ohne diese Zeile ginge die Summe nicht auf.
|
|
</p>
|
|
)}
|
|
</Karte>
|
|
|
|
{/* Der Klartext — genau einmal.
|
|
Deshalb ein Dialog, den man bewusst schließen muss, und kein
|
|
Zettel, der nach vier Sekunden verschwindet. */}
|
|
{frisch && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
|
<div className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl">
|
|
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
|
|
<KeyRound size={16} className="text-akzent" />
|
|
Token „{frisch.name}" angelegt
|
|
</h3>
|
|
|
|
<div className="px-4 py-4">
|
|
<p className="mb-3 flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm text-warn">
|
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
|
<span className="text-schrift">
|
|
Dies ist das einzige Mal, dass der Token im Klartext zu sehen
|
|
ist. Danach steht nur noch sein Hash in der Datenbank — auch
|
|
der Dienst kann ihn nicht mehr zeigen.
|
|
</span>
|
|
</p>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-2 font-mono text-sm">
|
|
{frisch.token}
|
|
</code>
|
|
<Knopf
|
|
art="haupt"
|
|
onClick={async () => {
|
|
const ok = await kopieren(frisch.token, 'neu')
|
|
if (!ok)
|
|
note(
|
|
'fehler',
|
|
'Die Zwischenablage ist gesperrt — bitte von Hand markieren.',
|
|
)
|
|
}}
|
|
kind={
|
|
kopiert === 'neu' ? (
|
|
<>
|
|
<Check size={15} />
|
|
kopiert
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy size={15} />
|
|
Kopieren
|
|
</>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<p className="mt-3 text-xs text-schrift-leise">
|
|
Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '}
|
|
· Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-end border-t border-rand px-4 py-3">
|
|
<Knopf
|
|
onClick={() => setFrisch(null)}
|
|
kind="Habe ich mir notiert"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{bearbeiten && (
|
|
<TokenBearbeiten
|
|
token={bearbeiten}
|
|
onAbbruch={() => setBearbeiten(null)}
|
|
onFertig={() => {
|
|
setBearbeiten(null)
|
|
void laden()
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<Nachfrage
|
|
offen={weg !== null}
|
|
titel="Token löschen?"
|
|
bestaetigung="Token löschen"
|
|
wartet={loescht}
|
|
onNein={() => setWeg(null)}
|
|
onJa={() => void loeschen()}
|
|
text={
|
|
<>
|
|
<p>
|
|
„{weg?.name}" wird sofort ungültig. Was ihn benutzt — etwa der
|
|
Fotostudio — bekommt ab dann 401 und lädt nichts mehr hoch.
|
|
</p>
|
|
<p className="mt-2">Hochgeladene Dateien bleiben.</p>
|
|
</>
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|