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:
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VonAdresse ordner={ziel} ordnerDaten={ordnerDaten} fertig={fertig} />
|
||||
|
||||
{ergebnisse === null && !laeuft && (
|
||||
<Knopf
|
||||
className="self-start lg:hidden"
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import { api, type TokenRow } from '../api'
|
||||
import { run } from '../store'
|
||||
import { Feld, Nachfrage } from './Bausteine'
|
||||
|
||||
const ARTEN = [
|
||||
['bild', 'Bilder'],
|
||||
['video', 'Video'],
|
||||
['ton', 'Ton'],
|
||||
['dokument', 'Dokumente'],
|
||||
['andere', 'Anderes'],
|
||||
] as const
|
||||
|
||||
/** Die Grenzen eines Tokens nachträglich ändern.
|
||||
*
|
||||
* WARUM ES DAS BRAUCHT: Kontingent, Arten, Ablauf und Präfix ließen sich nur
|
||||
* beim **Anlegen** setzen. Wer einem bestehenden Token nachträglich eine
|
||||
* Grenze geben wollte, musste ihn neu anlegen — und damit den Schlüssel in
|
||||
* jedem Skript tauschen, das ihn benutzt. Für eine Zahl in einer Tabelle ist
|
||||
* das der falsche Preis.
|
||||
*
|
||||
* DER SCHLÜSSEL BLEIBT UNBERÜHRT, und das ist keine Vorsicht: in der Datenbank
|
||||
* steht nur sein Hash. Er ließe sich gar nicht ändern, ohne einen neuen
|
||||
* auszugeben — und das wäre dann kein Bearbeiten mehr. Steht auch im Dialog,
|
||||
* damit niemand danach sucht. */
|
||||
export function TokenBearbeiten({
|
||||
token,
|
||||
onAbbruch,
|
||||
onFertig,
|
||||
}: {
|
||||
token: TokenRow
|
||||
onAbbruch: () => 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<string[]>(
|
||||
token.arten ? token.arten.split(',').map((a) => a.trim()).filter(Boolean) : [],
|
||||
)
|
||||
const [gueltigTage, setGueltigTage] = useState('')
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
|
||||
return (
|
||||
<Nachfrage
|
||||
offen
|
||||
titel={`Token „${token.name}" bearbeiten`}
|
||||
bestaetigung="Speichern"
|
||||
wartet={laeuft}
|
||||
gesperrt={!name.trim()}
|
||||
onNein={onAbbruch}
|
||||
onJa={async () => {
|
||||
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={
|
||||
<div className="flex flex-col gap-3">
|
||||
<Feld
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Feld
|
||||
label="Präfix (leer = überall)"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
placeholder="vehicles"
|
||||
className="font-mono"
|
||||
hinweis="Gilt nur für künftige Uploads — was schon liegt, bleibt liegen."
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld
|
||||
label="Größte Datei (MB)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
value={maxMb}
|
||||
placeholder="leer = Grenze des Dienstes"
|
||||
onChange={(e) => setMaxMb(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Kontingent insgesamt (MB)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={quotaMb}
|
||||
placeholder="leer = unbegrenzt"
|
||||
onChange={(e) => setQuotaMb(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={canDelete}
|
||||
onChange={(e) => setCanDelete(e.target.checked)}
|
||||
className="mt-0.5 accent-akzent"
|
||||
/>
|
||||
<span>
|
||||
darf auch <strong className="text-schrift">löschen</strong>
|
||||
<span className="block text-xs text-schrift-leise">
|
||||
Ohne dieses Recht antwortet <code className="font-mono">DELETE</code>{' '}
|
||||
mit 403. Das Fotostudio braucht es nicht.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||
Erlaubte Arten (nichts angekreuzt = alle)
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ARTEN.map(([wert, text]) => (
|
||||
<label
|
||||
key={wert}
|
||||
className="flex items-center gap-1.5 rounded border border-rand
|
||||
px-2 py-1 text-xs text-schrift-leise"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={arten.includes(wert)}
|
||||
onChange={(e) =>
|
||||
setArten((alt) =>
|
||||
e.target.checked
|
||||
? [...alt, wert]
|
||||
: alt.filter((a) => a !== wert),
|
||||
)
|
||||
}
|
||||
className="accent-akzent"
|
||||
/>
|
||||
{text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
label="Gültigkeit verlängern (Tage ab heute)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={gueltigTage}
|
||||
placeholder={
|
||||
token.expires_at
|
||||
? 'leer = so lassen'
|
||||
: 'leer = weiterhin unbefristet'
|
||||
}
|
||||
onChange={(e) => setGueltigTage(e.target.value)}
|
||||
hinweis={
|
||||
token.expires_at
|
||||
? 'Zählt ab heute, nicht ab dem Anlegen.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="flex items-start gap-2 rounded border border-rand bg-grund px-3 py-2 text-xs text-schrift-leise">
|
||||
<KeyRound size={14} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
<strong className="text-schrift">Der Schlüssel bleibt derselbe.</strong>{' '}
|
||||
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.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => {
|
||||
setZiel(ordner)
|
||||
setOffen(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 self-start text-xs text-schrift-leise
|
||||
hover:text-schrift"
|
||||
>
|
||||
<Link2 size={13} />
|
||||
oder von einer Adresse holen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-rand bg-flaeche p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-schrift">
|
||||
<Link2 size={13} /> Von einer Adresse holen
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOffen(false)}
|
||||
className="text-xs text-schrift-leise hover:text-schrift"
|
||||
>
|
||||
zu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-32 flex-1">
|
||||
<span className="mb-1 block text-[11px] text-schrift-leise">Ziel</span>
|
||||
<select
|
||||
value={ziel}
|
||||
onChange={(e) => setZiel(e.target.value)}
|
||||
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
|
||||
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
|
||||
>
|
||||
<option value="">(Wurzel)</option>
|
||||
{(ordnerDaten?.items ?? []).map((o) => (
|
||||
<option key={o.folder} value={o.folder}>
|
||||
{' '.repeat(o.tiefe * 2)}
|
||||
{o.folder.split('/').pop()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="min-w-32 flex-1">
|
||||
<span className="mb-1 block text-[11px] text-schrift-leise">
|
||||
Name (freiwillig)
|
||||
</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="aus der Adresse"
|
||||
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
|
||||
font-mono text-xs text-schrift placeholder:text-schrift-leise/60
|
||||
focus:border-akzent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<Knopf
|
||||
art="haupt"
|
||||
disabled={!url.trim() || laeuft}
|
||||
onClick={() => void holen()}
|
||||
kind={
|
||||
laeuft ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" /> holt …
|
||||
</>
|
||||
) : (
|
||||
'Holen'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-schrift-leise">
|
||||
Nur <code className="font-mono">http</code> und{' '}
|
||||
<code className="font-mono">https</code>, nur die Anschlüsse 80 und 443, und die
|
||||
Adresse darf nicht ins eigene Netz zeigen — auch nicht über eine Umleitung.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user