feat: Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen
UMBENENNEN gab es gar nicht -- verschieben ja, umbenennen nirgends. Jetzt fuer
Datei und Ordner. Die WARNUNG ist dabei der eigentliche Teil: beim Verschieben
wandert eine Datei, beim Umbenennen aendert sich IHRE ADRESSE, und die steht
womoeglich in einem Skript, das niemand mehr im Kopf hat. Der Dialog zeigt
deshalb die Abrufzahl und beim Ordner die Zahl der betroffenen Dateien, BEVOR
gedrueckt wird -- eine Datei mit viertausend Abrufen umzubenennen ist etwas
anderes als eine mit null, und der Dienst ist die einzige Stelle, die den
Unterschied kennt.
Am echten Fall geprueft, dem Kollisionsfund: items/coiloverss.png (hielt die
+-Fassung) -> items/coiloverss-plus.png, alte Adresse 404, neue 200 mit 4375 B,
Vorschau mitgewandert. Ordner: probe -> beispiele, 5 Dateien, alle Pfade in
einer Transaktion umgeschrieben.
Ein Fallstrick dabei: thumbPath haengt ".webp" an. Fuer eine Datei richtig, fuer
einen ORDNER Unsinn -- der Vorschauordner heisst thumbs/vehicles und nicht
thumbs/vehicles.webp. Dafuer gibt es jetzt moveFolder.
DIE 93 KOLLISIONEN SIND ERLEDIGT. tools/kollisionen.ps1 rechnet dieselbe
Zaehmung auf der Quelle nach und zeigt, was zusammenfaellt -- es aendert nichts,
damit die Entscheidung auf Zahlen steht. Ergebnis: 91 von 93 sind dieselbe Datei
zweimal (WEAPON_SMG.png und weapon_smg.png, byteweise gleich gross), da fehlte
nichts. Echt verloren waren ZWEI, beide mit + im Namen. Beide nachgeholt.
FIVEMANAGE-SPRACHE. POST /api/image|video|audio (v1) und POST /api/v3/file (v3),
Schluessel nackt im Authorization-Kopf. Auf einem laufenden Server stecken die
Fivemanage-Aufrufe in einem Dutzend fremder Ressourcen; sie alle umzuschreiben
tut niemand, und deshalb bliebe dieser Dienst ungenutzt daneben stehen. So ist
der Umzug eine Zeile je Skript: die Adresse.
Beide Formen sind AUS ECHTEM CODE abgelesen und nicht geraten -- fivemanage/sdk
fuer v3, Awleks/Devm-Camera fuer den aelteren Weg ueber screenshot-basic.
Gemessen: v1 -> {url,id,path}, v3 -> {status:"ok",data:{id,url}}, ohne
Schluessel 401, ein Video an /api/image -> 415 mit Grund.
Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar
warnt: die Token-Wache hing an use('*'), und der Einhaengepunkt ist /api -- also
galt sie auch fuer /api/dash daneben. Das Dashboard bekam 401 auf die ANMELDUNG.
Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
schon einmal passiert ist. Gemerkt hat es der Gegentest, nicht der Kopf.
FREIGABE-LINKS. /s/<schluessel> zeigt einen Ordner ohne Anmeldung. Was dabei
ausdruecklich dabeisteht, in der Karte und in der Rueckfrage vor dem
Zurueckziehen: FREIGEGEBEN WIRD DIE LISTE, NICHT DER INHALT. Die Dateien sind
ohnehin oeffentlich; ein zurueckgezogener Link macht sie nicht wieder privat, er
nimmt nur die Uebersicht weg. Ohne diesen Satz zieht jemand einen Link zurueck
und glaubt, etwas sei verschwunden.
Die Antwort ist abgemessen: Name, Groesse, Art, Adresse, Laenge. NICHT Hash,
Token, Zeitpunkte, Abrufzahlen, IDs -- nichts davon braucht, wer einen Katalog
ansieht, und jedes davon waere eine Auskunft ueber den Betrieb. Ein unbekannter
Schluessel und ein zurueckgezogener geben dieselbe Antwort.
ZAHLEN, DIE SCHON DA WAREN. Sortieren nach Abrufen und nach "zuletzt geholt";
die Kachel zeigt dann auch diese Zahl statt Groesse und Datum, denn nach etwas
zu ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begruendung. Und je
Token, was damit abgelegt wurde: media.token_id wird seit dem ersten Tag
geschrieben und war NIRGENDS zu sehen. Gemessen: d4rk_photostudio haelt 810
Dateien / 99,5 MB, 3640 liegen ohne Token da.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,8 +96,22 @@ an einer Filterliste. Hier entsteht nie eine HTML-Zeichenkette — der Text wird
|
|||||||
zu React-Knoten, ein `<script>` in einer hochgeladenen `.md` erscheint als die
|
zu React-Knoten, ein `<script>` in einer hochgeladenen `.md` erscheint als die
|
||||||
acht Zeichen, die es ist.
|
acht Zeichen, die es ist.
|
||||||
|
|
||||||
|
**Umbenennen** — Datei und Ordner, mit der Warnung, die dazugehört: die Adresse
|
||||||
|
ändert sich, und der Dialog sagt vorher, wie oft die Datei geholt wurde bzw.
|
||||||
|
wie viele Dateien im Ordner betroffen sind.
|
||||||
|
|
||||||
**Nachvollziehen** — ein Verlauf: wer hat wann was abgelegt, ersetzt,
|
**Nachvollziehen** — ein Verlauf: wer hat wann was abgelegt, ersetzt,
|
||||||
verschoben oder gelöscht.
|
verschoben oder gelöscht. Dazu: nach Abrufen sortieren, und je Token sehen, was
|
||||||
|
damit abgelegt wurde.
|
||||||
|
|
||||||
|
**Umziehen von Fivemanage** — der Dienst spricht deren Sprache
|
||||||
|
(`/api/image`, `/api/video`, `/api/audio`, `/api/v3/file`, Schlüssel nackt im
|
||||||
|
`Authorization`-Kopf). Fremde Skripte ziehen um, indem eine Zeile getauscht
|
||||||
|
wird. Siehe `docs/API.md`.
|
||||||
|
|
||||||
|
**Freigeben** — `/s/<schlüssel>` zeigt einen Ordner ohne Anmeldung, etwa einen
|
||||||
|
Fahrzeugkatalog. Freigegeben wird die **Liste**, nicht der Inhalt: die Dateien
|
||||||
|
sind ohnehin öffentlich, ein zurückgezogener Link nimmt nur die Übersicht weg.
|
||||||
|
|
||||||
**Anmelden** — über Discord (eine Rolle auf dem Server genügt) oder mit
|
**Anmelden** — über Discord (eine Rolle auf dem Server genügt) oder mit
|
||||||
Passwort. Das Passwort bleibt als Notausgang.
|
Passwort. Das Passwort bleibt als Notausgang.
|
||||||
|
|||||||
+58
@@ -669,6 +669,64 @@ Vorschau erzeugt, `dauer: 8.05` → Kachel zeigt `0:08`.
|
|||||||
Voreinstellung, ist jetzt aber sichtbar und änderbar, samt „neuer Ordner"
|
Voreinstellung, ist jetzt aber sichtbar und änderbar, samt „neuer Ordner"
|
||||||
direkt daneben.
|
direkt daneben.
|
||||||
|
|
||||||
|
### ✅ Fertig — Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen
|
||||||
|
|
||||||
|
**Umbenennen** gab es gar nicht — verschieben ja, umbenennen nirgends. Jetzt für
|
||||||
|
Datei *und* Ordner. Die Warnung ist dabei der eigentliche Teil: beim Umbenennen
|
||||||
|
ändert sich **die Adresse**, und die steht womöglich in einem Skript, das
|
||||||
|
niemand mehr im Kopf hat. Der Dialog zeigt deshalb die Abrufzahl („diese Datei
|
||||||
|
wurde 4000× geholt") und beim Ordner die Zahl der betroffenen Dateien, **bevor**
|
||||||
|
gedrückt wird.
|
||||||
|
|
||||||
|
Am echten Fall geprüft — der Kollisionsfund von oben: `items/coiloverss.png`
|
||||||
|
(hielt die `+`-Fassung) → `items/coiloverss-plus.png`, alte Adresse 404, neue
|
||||||
|
200 mit 4375 B, Vorschau mitgewandert. Ordner: `probe` → `beispiele`, 5 Dateien,
|
||||||
|
alle Datenbankpfade in einer Transaktion umgeschrieben.
|
||||||
|
|
||||||
|
> **Und der Fallstrick beim Ordner:** `thumbPath` hängt `.webp` an. Für eine
|
||||||
|
> Datei richtig, für einen Ordner Unsinn — der Vorschauordner heißt
|
||||||
|
> `thumbs/vehicles` und nicht `thumbs/vehicles.webp`. Dafür gibt es jetzt
|
||||||
|
> `moveFolder`.
|
||||||
|
|
||||||
|
**Die 93 Kollisionen sind erledigt.** `tools/kollisionen.ps1` rechnet dieselbe
|
||||||
|
Zähmung auf der Quelle nach und zeigt, was zusammenfällt. Ergebnis: **91 von 93
|
||||||
|
sind dieselbe Datei zweimal** — `WEAPON_SMG.png` und `weapon_smg.png`, byteweise
|
||||||
|
gleich groß. Da fehlte nichts. **Echt verloren waren zwei**, beide mit `+` im
|
||||||
|
Namen (das `+` wird zum Strich und dann abgeschnitten). Beide nachgeholt.
|
||||||
|
|
||||||
|
**Fivemanage-Sprache.** `POST /api/image|video|audio` (v1) und `POST /api/v3/file`
|
||||||
|
(v3), Schlüssel nackt im `Authorization`-Kopf. Der Umzug fremder Skripte ist
|
||||||
|
damit eine Zeile: die Adresse. Beide Formen sind aus echtem Code abgelesen —
|
||||||
|
`fivemanage/sdk` und `Awleks/Devm-Camera` —, nicht geraten. Gemessen: v1 →
|
||||||
|
`{url,id,path}`, v3 → `{status:"ok",data:{id,url}}`, ohne Schlüssel 401, Video
|
||||||
|
an `/api/image` → 415 mit Grund.
|
||||||
|
|
||||||
|
> **Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar
|
||||||
|
> warnt.** Die Token-Wache hing an `use('*')` — und der Einhängepunkt ist
|
||||||
|
> `/api`, also galt sie auch für `/api/dash` daneben. Das Dashboard bekam 401
|
||||||
|
> auf die *Anmeldung*. Wortwörtlich derselbe Fehler steht seit Monaten in
|
||||||
|
> `upload.ts` als Kommentar, weil er dort schon einmal passiert ist. Gemerkt hat
|
||||||
|
> es der Gegentest, nicht der Kopf.
|
||||||
|
|
||||||
|
**Freigabe-Links.** `/s/<schlüssel>` zeigt einen Ordner ohne Anmeldung — ein
|
||||||
|
Katalog für Spieler. Was dabei ausdrücklich dabeisteht, in der Karte *und* in
|
||||||
|
der Rückfrage vor dem Zurückziehen: **freigegeben wird die Liste, nicht der
|
||||||
|
Inhalt.** Die Dateien sind ohnehin öffentlich; ein zurückgezogener Link macht
|
||||||
|
sie nicht wieder privat, er nimmt nur die Übersicht weg. Ohne diesen Satz zieht
|
||||||
|
jemand einen Link zurück und glaubt, etwas sei verschwunden.
|
||||||
|
|
||||||
|
Die Antwort ist abgemessen: Name, Größe, Art, Adresse, Länge. **Nicht**: Hash,
|
||||||
|
Token, Zeitpunkte, Abrufzahlen, IDs. Ein unbekannter Schlüssel und ein
|
||||||
|
zurückgezogener geben dieselbe Antwort — ein Unterschied wäre eine Auskunft an
|
||||||
|
jemanden, der Schlüssel durchprobiert.
|
||||||
|
|
||||||
|
**Zahlen, die schon da waren.** Sortieren nach Abrufen und nach „zuletzt geholt"
|
||||||
|
(die Kachel zeigt dann auch diese Zahl statt Größe und Datum — nach etwas zu
|
||||||
|
ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begründung). Und je
|
||||||
|
Token, was damit abgelegt wurde: `media.token_id` wird seit dem ersten Tag
|
||||||
|
geschrieben und war **nirgends** zu sehen. Gemessen: `d4rk_photostudio` hält 810
|
||||||
|
Dateien / 99,5 MB, 3640 Dateien liegen ohne Token da (aus dem Dashboard).
|
||||||
|
|
||||||
### ⬜ Als Nächstes
|
### ⬜ Als Nächstes
|
||||||
|
|
||||||
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
|
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
|
||||||
|
|||||||
+52
@@ -85,6 +85,58 @@ dann bleibt es beim Original.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Umziehen von Fivemanage — ohne Lua anzufassen
|
||||||
|
|
||||||
|
Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem Dutzend
|
||||||
|
Ressourcen: Kamera, Handy, MDT, Fahrzeugstudio. Sie alle umzuschreiben heißt,
|
||||||
|
fremde Skripte anzufassen — und beim nächsten Update wieder.
|
||||||
|
|
||||||
|
**Der Dienst spricht deshalb ihre Sprache.** Der Umzug ist eine Zeile je Skript:
|
||||||
|
`api.fivemanage.com` → `fivemanage.d4rkst3r.de`. Der Schlüssel bleibt derselbe
|
||||||
|
Kopf, die Antwort dieselbe Form. Als Schlüssel setzt du einen **Token dieses
|
||||||
|
Dienstes** ein.
|
||||||
|
|
||||||
|
| Fivemanage | hier | Feld | Antwort |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST /api/image` | ✅ gleich | `image` (oder `file`) | `{ "url": …, "id": …, "path": … }` |
|
||||||
|
| `POST /api/video` | ✅ gleich | `video` | dito |
|
||||||
|
| `POST /api/audio` | ✅ gleich | `audio` | dito |
|
||||||
|
| `POST /api/v3/file` | ✅ gleich | `file`, `path?`, `filename?` | `{ "status": "ok", "data": { "id", "url" } }` |
|
||||||
|
|
||||||
|
Der Schlüssel steht **nackt** im `Authorization`-Kopf, ohne `Bearer` — genau so
|
||||||
|
wie bei Fivemanage. `Bearer …` geht weiterhin auch.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- vorher
|
||||||
|
exports['screenshot-basic']:requestScreenshotUpload(
|
||||||
|
'https://api.fivemanage.com/api/image', 'image',
|
||||||
|
{ headers = { Authorization = apiKey } }, cb)
|
||||||
|
|
||||||
|
-- nachher — eine Zeile
|
||||||
|
exports['screenshot-basic']:requestScreenshotUpload(
|
||||||
|
'https://fivemanage.d4rkst3r.de/api/image', 'image',
|
||||||
|
{ headers = { Authorization = apiKey } }, cb)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wo die Dateien landen:** unter dem Präfix des Tokens (sonst `uploads/`), mit
|
||||||
|
dem Anfang des SHA-256 im Namen — `screenshots/shot-841814b4089b.png`. Das ist
|
||||||
|
Absicht: Bildschirmfotos heißen alle `screenshot.png`, und ohne eigenen Namen
|
||||||
|
je Aufruf überschriebe das zweite das erste. Gleicher Inhalt ergibt denselben
|
||||||
|
Namen, verschiedener Inhalt einen anderen — dieselbe Eigenschaft, die
|
||||||
|
Fivemanages ID auch hat.
|
||||||
|
|
||||||
|
**Was NICHT übernommen wird:** `metadata` wird angenommen und ignoriert (es gibt
|
||||||
|
hier keine Spielerdaten an einer Datei), `retentionExempt` ebenso — dieser
|
||||||
|
Dienst löscht nichts von selbst. Presigned URLs gibt es nicht: der Upload geht
|
||||||
|
direkt hierher.
|
||||||
|
|
||||||
|
> Beide Formen sind **aus echtem Code abgelesen**, nicht geraten:
|
||||||
|
> [fivemanage/sdk](https://github.com/fivemanage/sdk) für v3 und
|
||||||
|
> [Awleks/Devm-Camera](https://github.com/Awleks/Devm-Camera) für den älteren
|
||||||
|
> Weg über `screenshot-basic`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Anmeldung: Token
|
## Anmeldung: Token
|
||||||
|
|
||||||
Alle Skript-Wege brauchen einen Token im Kopf:
|
Alle Skript-Wege brauchen einen Token im Kopf:
|
||||||
|
|||||||
+13
-3
@@ -85,12 +85,22 @@ export function tokenHash(token: string): string {
|
|||||||
* bekommt. */
|
* bekommt. */
|
||||||
export function tokenFromHeader(header: string | undefined): Token | null {
|
export function tokenFromHeader(header: string | undefined): Token | null {
|
||||||
if (!header) return null
|
if (!header) return null
|
||||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
|
|
||||||
if (!match?.[1]) return null
|
// "Bearer xyz" ODER nur "xyz" -- und das zweite ist nicht Bequemlichkeit,
|
||||||
|
// sondern Vertraeglichkeit: Fivemanage nimmt den Schluessel NACKT im
|
||||||
|
// Authorization-Kopf entgegen, und genau so steht er in jedem FiveM-Skript,
|
||||||
|
// das dorthin hochlaedt (nachgesehen im SDK und in Devm-Camera). Wer von
|
||||||
|
// dort umzieht, soll nur die Adresse tauschen muessen.
|
||||||
|
//
|
||||||
|
// Ein Risiko ist das keines: geprueft wird ohnehin der Hash gegen die
|
||||||
|
// Tabelle. Ein Kopf, der zu keinem Token gehoert, faellt so oder so durch.
|
||||||
|
const roh = header.trim()
|
||||||
|
const wert = /^Bearer\s+(.+)$/i.exec(roh)?.[1] ?? roh
|
||||||
|
if (!wert) return null
|
||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT * FROM tokens WHERE hash = ?')
|
.prepare('SELECT * FROM tokens WHERE hash = ?')
|
||||||
.get(tokenHash(match[1])) as Token | undefined
|
.get(tokenHash(wert)) as Token | undefined
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
|
|
||||||
db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id)
|
db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id)
|
||||||
|
|||||||
@@ -121,6 +121,30 @@ CREATE TABLE IF NOT EXISTS papierkorb (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS papierkorb_zeit ON papierkorb(geloescht DESC);
|
CREATE INDEX IF NOT EXISTS papierkorb_zeit ON papierkorb(geloescht DESC);
|
||||||
|
|
||||||
|
-- Freigaben: ein Ordner, den man ohne Anmeldung ansehen kann.
|
||||||
|
--
|
||||||
|
-- WOZU. Die Dateien selbst sind ohnehin oeffentlich -- wer die Adresse kennt,
|
||||||
|
-- bekommt das Bild. Was NICHT oeffentlich ist, ist die LISTE: welche Bilder es
|
||||||
|
-- gibt. Genau die will man manchmal zeigen (ein Fahrzeugkatalog fuer Spieler)
|
||||||
|
-- und sonst nicht.
|
||||||
|
--
|
||||||
|
-- Der Schluessel ist eine Zufallszeichenkette in der Adresse und kein Passwort:
|
||||||
|
-- wer den Link hat, sieht die Liste. Das ist dieselbe Zusicherung wie bei einem
|
||||||
|
-- Nextcloud-Freigabelink, und sie steht so auch im Dashboard -- damit niemand
|
||||||
|
-- glaubt, hier haenge eine Anmeldung dran.
|
||||||
|
CREATE TABLE IF NOT EXISTS freigaben (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
schluessel TEXT NOT NULL UNIQUE,
|
||||||
|
ordner TEXT NOT NULL,
|
||||||
|
titel TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
-- Nicht "wie oft geoeffnet" als Selbstzweck: es beantwortet die Frage, ob
|
||||||
|
-- ein alter Link noch benutzt wird, bevor man ihn wegwirft.
|
||||||
|
aufrufe INTEGER NOT NULL DEFAULT 0,
|
||||||
|
zuletzt INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS freigaben_ordner ON freigaben(ordner);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
@@ -284,6 +308,16 @@ export type Media = {
|
|||||||
dauer: number | null
|
dauer: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Freigabe = {
|
||||||
|
id: number
|
||||||
|
schluessel: string
|
||||||
|
ordner: string
|
||||||
|
titel: string
|
||||||
|
created_at: number
|
||||||
|
aufrufe: number
|
||||||
|
zuletzt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export type PapierkorbEintrag = {
|
export type PapierkorbEintrag = {
|
||||||
id: number
|
id: number
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { pruneSessions, schreibeAbrufe, zaehleAbruf } from './db.js'
|
|||||||
import { pruefeSicherung } from './meldung.js'
|
import { pruefeSicherung } from './meldung.js'
|
||||||
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
|
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
|
||||||
import { uploadRoutes } from './routes/upload.js'
|
import { uploadRoutes } from './routes/upload.js'
|
||||||
|
import { fivemanageRoutes } from './routes/fivemanage.js'
|
||||||
|
import { freigabeRoutes } from './routes/freigabe.js'
|
||||||
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
|
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
@@ -209,8 +211,17 @@ app.use('*', async (c, next) => {
|
|||||||
app.get('/health', (c) => c.json({ ok: true }))
|
app.get('/health', (c) => c.json({ ok: true }))
|
||||||
|
|
||||||
app.route('/api', uploadRoutes)
|
app.route('/api', uploadRoutes)
|
||||||
|
// NACH uploadRoutes: unsere eigenen Wege haben Vorrang, falls je ein Name
|
||||||
|
// zusammenfaellt. Derzeit tut er das nicht (/upload, /exists, /media gegen
|
||||||
|
// /image, /video, /audio, /file, /v3/file), aber die Reihenfolge sagt, was
|
||||||
|
// gelten soll, wenn es einmal so kommt.
|
||||||
|
app.route('/api', fivemanageRoutes)
|
||||||
app.route('/api/dash', dashRoutes)
|
app.route('/api/dash', dashRoutes)
|
||||||
|
|
||||||
|
// Die Freigaben. OHNE Anmeldung und mit Absicht kurz: /s/<schluessel> laesst
|
||||||
|
// sich vorlesen und abtippen.
|
||||||
|
app.route('/api/s', freigabeRoutes)
|
||||||
|
|
||||||
app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
|
app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
|
||||||
|
|
||||||
/** Die Vorschaubilder, unter /t/.
|
/** Die Vorschaubilder, unter /t/.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
pruneEvents,
|
pruneEvents,
|
||||||
pruneSessions,
|
pruneSessions,
|
||||||
type Media,
|
type Media,
|
||||||
|
type Freigabe,
|
||||||
type MediaEvent,
|
type MediaEvent,
|
||||||
type PapierkorbEintrag,
|
type PapierkorbEintrag,
|
||||||
type Token,
|
type Token,
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
} from '../settings.js'
|
} from '../settings.js'
|
||||||
import { discordRoutes, redirectUri } from './discord.js'
|
import { discordRoutes, redirectUri } from './discord.js'
|
||||||
import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
||||||
|
import { neuerSchluessel } from './freigabe.js'
|
||||||
import {
|
import {
|
||||||
absolutePath,
|
absolutePath,
|
||||||
checkPath,
|
checkPath,
|
||||||
@@ -60,6 +62,7 @@ import {
|
|||||||
lohntWebp,
|
lohntWebp,
|
||||||
mimeFor,
|
mimeFor,
|
||||||
moveFile,
|
moveFile,
|
||||||
|
moveFolder,
|
||||||
publicUrlFor,
|
publicUrlFor,
|
||||||
safeFilename,
|
safeFilename,
|
||||||
sha256,
|
sha256,
|
||||||
@@ -362,6 +365,11 @@ dashRoutes.get('/media', (c) => {
|
|||||||
angelegt: 'created_at',
|
angelegt: 'created_at',
|
||||||
pfad: 'path',
|
pfad: 'path',
|
||||||
groesse: 'size',
|
groesse: 'size',
|
||||||
|
// Gezaehlt wurde laengst, sortieren konnte man danach nicht -- eine
|
||||||
|
// Zahl, die man nicht ordnen kann, beantwortet die Frage "was wird
|
||||||
|
// eigentlich gebraucht" nur fuer die zehn im Speicherbericht.
|
||||||
|
abrufe: 'abrufe',
|
||||||
|
geholt: 'zuletzt_abgerufen',
|
||||||
}
|
}
|
||||||
const sortSpalte = SPALTEN[c.req.query('sort') ?? ''] ?? 'updated_at'
|
const sortSpalte = SPALTEN[c.req.query('sort') ?? ''] ?? 'updated_at'
|
||||||
const richtung = c.req.query('dir') === 'auf' ? 'ASC' : 'DESC'
|
const richtung = c.req.query('dir') === 'auf' ? 'ASC' : 'DESC'
|
||||||
@@ -1413,6 +1421,138 @@ dashRoutes.post('/media/move', async (c) => {
|
|||||||
return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Eine Datei umbenennen.
|
||||||
|
*
|
||||||
|
* Verschieben gab es, Umbenennen nicht -- und das ist genau derselbe Vorgang
|
||||||
|
* mit einem anderen letzten Stueck. Getrennt gehalten wird es trotzdem, weil
|
||||||
|
* die WARNUNG eine andere ist: beim Verschieben wandert eine Datei, beim
|
||||||
|
* Umbenennen aendert sich IHRE ADRESSE, und die steht womoeglich in einem
|
||||||
|
* Skript, das wir nicht sehen.
|
||||||
|
*
|
||||||
|
* DESHALB SAGT DIE ANTWORT, WAS DIE ALTE ADRESSE WAR und wie oft sie geholt
|
||||||
|
* wurde. Eine Datei mit 4000 Abrufen umzubenennen ist etwas anderes als eine
|
||||||
|
* mit null -- und der Dienst ist die einzige Stelle, die das weiss.
|
||||||
|
*
|
||||||
|
* Nur der NAME wird geaendert, nicht der Ordner. Wer beides will, benennt um
|
||||||
|
* und verschiebt; zwei sichtbare Schritte sind hier besser als einer, der
|
||||||
|
* beides kann und dabei unklar wird. */
|
||||||
|
dashRoutes.post('/media/umbenennen', async (c) => {
|
||||||
|
const body = await jsonBody<{ id?: number; name?: string }>(c)
|
||||||
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
|
if (!body.id || !body.name?.trim()) return c.json({ error: 'id und name noetig' }, 400)
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(body.id) as Media | undefined
|
||||||
|
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
|
||||||
|
|
||||||
|
// Der Name kommt aus einem Formular und wird NICHT gezaehmt, sondern
|
||||||
|
// geprueft: wer hier umbenennt, weiss, welche Adresse er will. Ein
|
||||||
|
// stillschweigend zurechtgebogener Name waere schlimmer als eine
|
||||||
|
// Fehlermeldung -- man merkte es erst, wenn das Bild fehlt.
|
||||||
|
const name = body.name.trim()
|
||||||
|
if (name.includes('/')) {
|
||||||
|
return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten — zum Verschieben gibt es "Verschieben".' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordner = row.path.split('/').slice(0, -1).join('/')
|
||||||
|
let neu: string
|
||||||
|
try {
|
||||||
|
neu = checkPath(ordner ? `${ordner}/${name}` : name)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neu === row.path) return c.json({ error: 'heisst schon so' }, 400)
|
||||||
|
if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu)) {
|
||||||
|
return c.json({ error: `${neu} ist belegt` }, 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveFile(row.path, neu)
|
||||||
|
db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run(neu, now(), row.id)
|
||||||
|
logEvent('move', neu, row.size, 'user', c.get('user').username, row.path)
|
||||||
|
|
||||||
|
return c.json({ alt: row.path, neu, url: publicUrlFor(neu), abrufe: row.abrufe })
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Einen Ordner umbenennen -- mit allem, was darin liegt.
|
||||||
|
*
|
||||||
|
* DAS IST DER GROSSE HEBEL und deshalb der vorsichtigere Weg: hier aendern
|
||||||
|
* sich auf einen Schlag hunderte Adressen. "items" nach "gegenstaende" zu
|
||||||
|
* benennen macht 3576 URLs ungueltig, und keine davon meldet sich.
|
||||||
|
*
|
||||||
|
* Die Datenbank wird in EINER Transaktion umgeschrieben. Nicht aus Eile: wenn
|
||||||
|
* auf halbem Weg etwas schiefgeht, zeigt sonst die Haelfte der Eintraege auf
|
||||||
|
* einen Ordner, den es nicht mehr gibt. Das Verschieben auf der Platte ist ein
|
||||||
|
* einziges `rename` und damit ohnehin unteilbar. */
|
||||||
|
dashRoutes.post('/folders/umbenennen', async (c) => {
|
||||||
|
const body = await jsonBody<{ pfad?: string; name?: string }>(c)
|
||||||
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
|
if (!body.pfad?.trim() || !body.name?.trim()) {
|
||||||
|
return c.json({ error: 'pfad und name noetig' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = body.name.trim()
|
||||||
|
if (name.includes('/')) {
|
||||||
|
return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt: string
|
||||||
|
let neu: string
|
||||||
|
try {
|
||||||
|
alt = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, ''))
|
||||||
|
const oben = alt.split('/').slice(0, -1).join('/')
|
||||||
|
neu = checkPath(oben ? `${oben}/${name}` : name)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neu === alt) return c.json({ error: 'heisst schon so' }, 400)
|
||||||
|
|
||||||
|
// Der Zielordner darf nicht schon belegt sein -- weder in der Datenbank
|
||||||
|
// noch auf der Platte. Sonst schoebe `rename` den einen IN den anderen
|
||||||
|
// hinein, was etwas voellig anderes ist als umbenennen.
|
||||||
|
const maskiert = (s: string) => s.replace(/[\\%_]/g, (z) => `\\${z}`)
|
||||||
|
const drin = db
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
||||||
|
.get(`${maskiert(neu)}/%`) as { n: number }
|
||||||
|
if (drin.n > 0) return c.json({ error: `"${neu}" gibt es schon (${drin.n} Dateien)` }, 409)
|
||||||
|
try {
|
||||||
|
await stat(join(config.filesDir, neu))
|
||||||
|
return c.json({ error: `"${neu}" gibt es schon` }, 409)
|
||||||
|
} catch {
|
||||||
|
/* frei — weiter */
|
||||||
|
}
|
||||||
|
|
||||||
|
const betroffen = db
|
||||||
|
.prepare(`SELECT id, path, size FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
||||||
|
.all(`${maskiert(alt)}/%`) as { id: number; path: string; size: number }[]
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveFolder(alt, neu)
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(
|
||||||
|
{ error: `Verschieben fehlgeschlagen: ${err instanceof Error ? err.message : 'unbekannt'}` },
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wer = c.get('user').username
|
||||||
|
const zeit = now()
|
||||||
|
const schreiben = db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?')
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const r of betroffen) {
|
||||||
|
schreiben.run(neu + r.path.slice(alt.length), zeit, r.id)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// EIN Eintrag im Verlauf und nicht 3576. Wer nachliest, was passiert ist,
|
||||||
|
// will "items -> gegenstaende" lesen und nicht dreitausend Zeilen davon.
|
||||||
|
logEvent('move', neu, 0, 'user', wer, alt)
|
||||||
|
|
||||||
|
return c.json({ alt, neu, dateien: betroffen.length })
|
||||||
|
})
|
||||||
|
|
||||||
/** Einen leeren Ordner anlegen.
|
/** Einen leeren Ordner anlegen.
|
||||||
*
|
*
|
||||||
* Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor
|
* Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor
|
||||||
@@ -1701,6 +1841,64 @@ dashRoutes.post('/papierkorb/leeren', async (c) => {
|
|||||||
return c.json({ geleert: weg })
|
return c.json({ geleert: weg })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Freigaben
|
||||||
|
//
|
||||||
|
// Was hier verwaltet wird, ist die LISTE eines Ordners -- die Dateien selbst
|
||||||
|
// sind ohnehin oeffentlich. Das steht auch in der Oberflaeche, damit niemand
|
||||||
|
// glaubt, eine Freigabe zu widerrufen mache seine Bilder wieder privat.
|
||||||
|
|
||||||
|
dashRoutes.get('/freigaben', (c) => {
|
||||||
|
const items = (
|
||||||
|
db.prepare('SELECT * FROM freigaben ORDER BY created_at DESC').all() as Freigabe[]
|
||||||
|
).map((f) => ({ ...f, url: `${config.publicUrl}/s/${f.schluessel}` }))
|
||||||
|
return c.json({ items })
|
||||||
|
})
|
||||||
|
|
||||||
|
dashRoutes.post('/freigaben', async (c) => {
|
||||||
|
const body = await jsonBody<{ ordner?: string; titel?: string }>(c)
|
||||||
|
if (!body?.ordner?.trim()) return c.json({ error: 'kein Ordner' }, 400)
|
||||||
|
|
||||||
|
let ordner: string
|
||||||
|
try {
|
||||||
|
ordner = checkPath(body.ordner.trim().replace(/^\/+|\/+$/g, ''))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einen Link auf einen Ordner anzulegen, den es nicht gibt, ergibt eine
|
||||||
|
// leere Seite und die Frage, woran es liegt. Lieber hier ablehnen.
|
||||||
|
const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
|
||||||
|
const drin = db
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
||||||
|
.get(`${maskiert}/%`) as { n: number }
|
||||||
|
if (drin.n === 0) return c.json({ error: `In "${ordner}" liegt nichts.` }, 400)
|
||||||
|
|
||||||
|
const schluessel = neuerSchluessel()
|
||||||
|
const eintrag = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO freigaben (schluessel, ordner, titel, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(schluessel, ordner, (body.titel ?? '').trim(), now())
|
||||||
|
|
||||||
|
logEvent('upload', ordner, 0, 'user', c.get('user').username)
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
id: Number(eintrag.lastInsertRowid),
|
||||||
|
schluessel,
|
||||||
|
ordner,
|
||||||
|
dateien: drin.n,
|
||||||
|
url: `${config.publicUrl}/s/${schluessel}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
dashRoutes.delete('/freigaben/:id', (c) => {
|
||||||
|
const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(Number(c.req.param('id')))
|
||||||
|
if (r.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
dashRoutes.get('/speicher', (c) => {
|
dashRoutes.get('/speicher', (c) => {
|
||||||
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
|
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
|
||||||
const z = db
|
const z = db
|
||||||
@@ -1783,6 +1981,26 @@ dashRoutes.get('/stats', (c) => {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------- Tokens
|
// ------------------------------------------------------------------- Tokens
|
||||||
|
|
||||||
|
/** Was mit einem Token abgelegt wurde.
|
||||||
|
*
|
||||||
|
* `media.token_id` wird seit dem ersten Tag geschrieben und war bis hierhin
|
||||||
|
* NIRGENDS zu sehen. Dabei ist es die Auskunft, die vor dem Loeschen eines
|
||||||
|
* Tokens zaehlt: "dieser Token haelt 3576 Dateien" ist etwas anderes als
|
||||||
|
* "dieser Token ist ungenutzt".
|
||||||
|
*
|
||||||
|
* Der Dashboard-Upload traegt bewusst KEINEN Token (dort gibt es eine
|
||||||
|
* Sitzung), diese Dateien stehen deshalb unter `ohneToken`. */
|
||||||
|
function tokenNutzung(): Map<number, { dateien: number; bytes: number; letzte: number | null }> {
|
||||||
|
const zeilen = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT token_id, COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes,
|
||||||
|
MAX(created_at) AS letzte
|
||||||
|
FROM media WHERE token_id IS NOT NULL GROUP BY token_id`,
|
||||||
|
)
|
||||||
|
.all() as { token_id: number; dateien: number; bytes: number; letzte: number }[]
|
||||||
|
return new Map(zeilen.map((z) => [z.token_id, z]))
|
||||||
|
}
|
||||||
|
|
||||||
dashRoutes.get('/tokens', (c) => {
|
dashRoutes.get('/tokens', (c) => {
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -1791,6 +2009,14 @@ dashRoutes.get('/tokens', (c) => {
|
|||||||
FROM tokens ORDER BY created_at DESC`,
|
FROM tokens ORDER BY created_at DESC`,
|
||||||
)
|
)
|
||||||
.all() as Omit<Token, 'hash'>[]
|
.all() as Omit<Token, 'hash'>[]
|
||||||
|
|
||||||
|
const nutzung = tokenNutzung()
|
||||||
|
const ohneToken = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes FROM media WHERE token_id IS NULL',
|
||||||
|
)
|
||||||
|
.get() as { dateien: number; bytes: number }
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
items: rows.map((r) => ({
|
items: rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
@@ -1798,7 +2024,14 @@ dashRoutes.get('/tokens', (c) => {
|
|||||||
// Oberflaeche: sonst steht in zwei Ansichten zweimal dieselbe
|
// Oberflaeche: sonst steht in zwei Ansichten zweimal dieselbe
|
||||||
// Bedingung, und eine davon ist irgendwann falsch.
|
// Bedingung, und eine davon ist irgendwann falsch.
|
||||||
abgelaufen: Boolean(r.expires_at && r.expires_at < now()),
|
abgelaufen: Boolean(r.expires_at && r.expires_at < now()),
|
||||||
|
dateien: nutzung.get(r.id)?.dateien ?? 0,
|
||||||
|
bytes: nutzung.get(r.id)?.bytes ?? 0,
|
||||||
|
// `last_used_at` sagt, wann zuletzt jemand mit diesem Token
|
||||||
|
// ANGEKLOPFT hat -- auch bei einem Loeschversuch oder einem
|
||||||
|
// Fehlschlag. Das hier sagt, wann zuletzt etwas dabei HERAUSKAM.
|
||||||
|
zuletztAbgelegt: nutzung.get(r.id)?.letzte ?? null,
|
||||||
})),
|
})),
|
||||||
|
ohneToken,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
// Der Umzugsweg: sprechen wie Fivemanage.
|
||||||
|
//
|
||||||
|
// WOZU. Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem
|
||||||
|
// Dutzend Ressourcen -- Kamera, Handy, MDT, Fahrzeugstudio, jede mit ihrem
|
||||||
|
// eigenen Autor und ihrer eigenen Fassung. Sie alle auf unsere Schnittstelle
|
||||||
|
// umzuschreiben heisst: ein Dutzend fremde Skripte anfassen, jedes davon
|
||||||
|
// beim naechsten Update wieder. Das tut niemand, und deshalb bliebe der
|
||||||
|
// Dienst ungenutzt neben dem alten stehen.
|
||||||
|
//
|
||||||
|
// SPRICHT DER DIENST DAGEGEN IHRE SPRACHE, ist der Umzug EINE Zeile je Skript:
|
||||||
|
// die Adresse. Der Schluessel bleibt derselbe Kopf, die Antwort dieselbe Form.
|
||||||
|
//
|
||||||
|
// NACHGESEHEN, NICHT GERATEN. Beide Formen stammen aus echtem Code:
|
||||||
|
//
|
||||||
|
// v1 github.com/Awleks/Devm-Camera, client/client.lua:
|
||||||
|
// requestScreenshotUpload('https://api.fivemanage.com/api/image',
|
||||||
|
// 'image', { headers = { Authorization = apiKey } })
|
||||||
|
// -> die Antwort wird als resp.url gelesen
|
||||||
|
//
|
||||||
|
// v3 github.com/fivemanage/sdk, features/images/server/main.ts:
|
||||||
|
// POST https://api.fivemanage.com/api/v3/file
|
||||||
|
// Felder: file, metadata?, path?, filename?, retentionExempt?
|
||||||
|
// -> { status, data: { id, url } }
|
||||||
|
//
|
||||||
|
// Der Schluessel steht bei beiden NACKT im Authorization-Kopf, ohne "Bearer".
|
||||||
|
// Das nimmt tokenFromHeader inzwischen an.
|
||||||
|
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import type { Context, Next } from 'hono'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
||||||
|
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
||||||
|
import {
|
||||||
|
checkPath,
|
||||||
|
kannBewegtVorschau,
|
||||||
|
kannVorschau,
|
||||||
|
lohntWebp,
|
||||||
|
mimeFor,
|
||||||
|
publicUrlFor,
|
||||||
|
safeFilename,
|
||||||
|
sha256,
|
||||||
|
writeBewegtThumb,
|
||||||
|
writeFileAtomic,
|
||||||
|
writeThumb,
|
||||||
|
writeWebp,
|
||||||
|
} from '../storage.js'
|
||||||
|
|
||||||
|
type Vars = { token: Token }
|
||||||
|
|
||||||
|
export const fivemanageRoutes = new Hono<{ Variables: Vars }>()
|
||||||
|
|
||||||
|
const wache = async (c: Context<{ Variables: Vars }>, next: Next) => {
|
||||||
|
const token = tokenFromHeader(c.req.header('authorization'))
|
||||||
|
if (!token) {
|
||||||
|
// Die Form der FEHLERANTWORT ist auch Teil der Vertraeglichkeit: ein
|
||||||
|
// Skript, das auf `status` prueft, soll hier nicht ins Leere greifen.
|
||||||
|
return c.json({ status: 'error', message: 'Token fehlt oder ist unbekannt' }, 401)
|
||||||
|
}
|
||||||
|
if (token.expires_at && token.expires_at < now()) {
|
||||||
|
return c.json({ status: 'error', message: 'Token ist abgelaufen' }, 401)
|
||||||
|
}
|
||||||
|
c.set('token', token)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AN DIE EIGENEN PFADE, NICHT AN '*'.
|
||||||
|
//
|
||||||
|
// Mit '*' galt die Wache fuer alles unterhalb des Einhaengepunkts -- und der
|
||||||
|
// ist /api, also auch fuer /api/dash daneben. Das Dashboard bekam daraufhin
|
||||||
|
// 401 auf die ANMELDUNG, obwohl es nie einen Token haben kann.
|
||||||
|
//
|
||||||
|
// Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
|
||||||
|
// schon einmal passiert ist. Ich bin trotzdem hineingelaufen; gemerkt hat es
|
||||||
|
// der Gegentest, nicht der Kopf.
|
||||||
|
for (const weg of ['/image', '/video', '/audio', '/file', '/v3/file']) {
|
||||||
|
fivemanageRoutes.use(weg, wache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wo eine Datei landet, wenn der Aufrufer nichts sagt.
|
||||||
|
*
|
||||||
|
* UND SIE UEBERSCHREIBT NIE. Das ist der Unterschied zu /api/upload, wo der
|
||||||
|
* Pfad die Absicht ist: hier kommen Bildschirmfotos, die alle "screenshot.png"
|
||||||
|
* heissen. Ohne einen eigenen Namen je Aufruf ueberschriebe das zweite Foto
|
||||||
|
* das erste, und niemand merkte es -- der Aufrufer bekaeme sogar eine gueltige
|
||||||
|
* Adresse zurueck.
|
||||||
|
*
|
||||||
|
* Der Hash macht den Namen: gleicher Inhalt ergibt denselben Namen (zweimal
|
||||||
|
* dasselbe Bild kostet keinen zweiten Platz), verschiedener Inhalt einen
|
||||||
|
* anderen. Genau die Eigenschaft, die Fivemanages ID auch hat. */
|
||||||
|
function pfadFuer(token: Token, mime: string, digest: string, name?: string): string {
|
||||||
|
const basis = token.prefix ? token.prefix.replace(/\/+$/, '') : 'uploads'
|
||||||
|
const kurz = digest.slice(0, 12)
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const sauber = safeFilename(name, kurz)
|
||||||
|
const punkt = sauber.lastIndexOf('.')
|
||||||
|
const stamm = punkt > 0 ? sauber.slice(0, punkt) : sauber
|
||||||
|
const endung = punkt > 0 ? sauber.slice(punkt + 1) : endungFuer(mime)
|
||||||
|
return `${basis}/${stamm}-${kurz}.${endung}`
|
||||||
|
}
|
||||||
|
return `${basis}/${kurz}.${endungFuer(mime)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENDUNGEN: Record<string, string> = {
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
'audio/wav': 'wav',
|
||||||
|
}
|
||||||
|
|
||||||
|
const endungFuer = (mime: string) => ENDUNGEN[mime.split(';')[0]?.trim().toLowerCase() ?? ''] ?? 'bin'
|
||||||
|
|
||||||
|
/** Die Datei aus dem Formular holen.
|
||||||
|
*
|
||||||
|
* DER FELDNAME IST NICHT FEST. screenshot-basic bekommt ihn als Argument
|
||||||
|
* uebergeben ('image' bei Devm-Camera), das SDK schickt 'file', und andere
|
||||||
|
* Skripte nehmen 'video' oder 'audio'. Ein Dienst, der nur eines davon
|
||||||
|
* annimmt, lehnt die Haelfte der Aufrufer mit "Feld fehlt" ab -- und der
|
||||||
|
* Aufrufer sucht den Fehler bei sich. */
|
||||||
|
const FELDER = ['file', 'image', 'video', 'audio', 'data', 'media'] as const
|
||||||
|
|
||||||
|
async function dateiAus(
|
||||||
|
c: Context<{ Variables: Vars }>,
|
||||||
|
): Promise<{ datei: File; pfad?: string; name?: string }> {
|
||||||
|
const typ = c.req.header('content-type') ?? ''
|
||||||
|
if (!typ.includes('multipart/form-data')) {
|
||||||
|
throw new Error('Erwartet wird multipart/form-data')
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBody und NICHT c.req.raw.formData(): letzteres nimmt Hono den Rumpf
|
||||||
|
// weg, und beim Aufraeumen schliesst dann jemand einen Strom, der schon zu
|
||||||
|
// ist. Das hat diesen Dienst schon einmal in eine Neustartschleife
|
||||||
|
// geschickt (siehe dash.ts).
|
||||||
|
const roh = await c.req.parseBody()
|
||||||
|
|
||||||
|
for (const feld of FELDER) {
|
||||||
|
const wert = roh[feld]
|
||||||
|
if (wert && typeof wert !== 'string') {
|
||||||
|
return {
|
||||||
|
datei: wert as unknown as File,
|
||||||
|
pfad: typeof roh['path'] === 'string' ? roh['path'] : undefined,
|
||||||
|
name: typeof roh['filename'] === 'string' ? roh['filename'] : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Keine Datei dabei — erwartet wird eines der Felder: ${FELDER.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was beim Ablegen herauskam: entweder die fertige Adresse, oder eine
|
||||||
|
* Antwort, die der Aufrufer so bekommen soll.
|
||||||
|
*
|
||||||
|
* Ein Entweder-Oder und kein "hat das Ergebnis ein status-Feld?" -- die
|
||||||
|
* Unterscheidung gehoert in den Typ und nicht in eine Ratefunktion. */
|
||||||
|
type Abgelegt =
|
||||||
|
| { ok: true; path: string; url: string; id: string }
|
||||||
|
| { ok: false; antwort: Response }
|
||||||
|
|
||||||
|
/** Der gemeinsame Weg. `art` schraenkt ein, was durchgeht -- /api/image nimmt
|
||||||
|
* eben nur Bilder, so wie es dort auch heisst. */
|
||||||
|
async function ablegen(
|
||||||
|
c: Context<{ Variables: Vars }>,
|
||||||
|
art?: 'image' | 'video' | 'audio',
|
||||||
|
): Promise<Abgelegt> {
|
||||||
|
const token = c.get('token')
|
||||||
|
|
||||||
|
let gefunden: Awaited<ReturnType<typeof dateiAus>>
|
||||||
|
try {
|
||||||
|
gefunden = await dateiAus(c)
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Rumpf unlesbar',
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { datei } = gefunden
|
||||||
|
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
if (datei.size > grenze) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{ status: 'error', message: `zu gross: ${datei.size} > ${grenze} Bytes` },
|
||||||
|
413,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(await datei.arrayBuffer())
|
||||||
|
if (data.length === 0) {
|
||||||
|
return { ok: false, antwort: c.json({ status: 'error', message: 'leere Datei' }, 400) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = sha256(data)
|
||||||
|
const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? ''
|
||||||
|
// Der Name entscheidet, wenn der Absender nur mit den Schultern zuckt --
|
||||||
|
// dieselbe Regel wie beim Dashboard-Upload.
|
||||||
|
const mime =
|
||||||
|
gemeldet && gemeldet !== 'application/octet-stream'
|
||||||
|
? gemeldet
|
||||||
|
: mimeFor(gefunden.name ?? datei.name ?? '')
|
||||||
|
|
||||||
|
if (art && !mime.startsWith(`${art}/`)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: `Hier gehen nur ${art}-Dateien hinein, das hier ist ${mime}`,
|
||||||
|
},
|
||||||
|
415,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein ausdruecklicher Pfad (v3 kennt das Feld) hat Vorrang -- aber er wird
|
||||||
|
// geprueft wie jeder andere, nicht zurechtgebogen.
|
||||||
|
let path: string
|
||||||
|
try {
|
||||||
|
path = gefunden.pfad
|
||||||
|
? checkPath(gefunden.pfad.replace(/^\/+|\/+$/g, ''))
|
||||||
|
: checkPath(pfadFuer(token, mime, digest, gefunden.name ?? datei.name))
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json({ status: 'error', message: (err as Error).message }, 400),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenAllows(token, path)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: `dieser Token darf nur unter "${token.prefix}/" schreiben`,
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.arten) {
|
||||||
|
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
|
||||||
|
const meine = mime.startsWith('image/')
|
||||||
|
? 'bild'
|
||||||
|
: mime.startsWith('video/')
|
||||||
|
? 'video'
|
||||||
|
: mime.startsWith('audio/')
|
||||||
|
? 'ton'
|
||||||
|
: 'andere'
|
||||||
|
if (!erlaubt.includes(meine)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen`,
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
|
||||||
|
| Media
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
await writeFileAtomic(path, data)
|
||||||
|
if (kannVorschau(mime)) await writeThumb(path, data)
|
||||||
|
if (lohntWebp(mime)) await writeWebp(path, 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 = ?, token_id = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(data.length, digest, mime, dauer, token.id, zeit, vorhanden.id)
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(path, data.length, digest, mime, dauer, token.id, zeit, zeit)
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent(vorhanden ? 'replace' : 'upload', path, data.length, 'token', token.name)
|
||||||
|
|
||||||
|
return { ok: true, path, url: publicUrlFor(path), id: digest.slice(0, 12) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Die Wege
|
||||||
|
//
|
||||||
|
// v1: eine Adresse je Art, Antwort NUR { url, id }. Genau das liest
|
||||||
|
// Devm-Camera (resp.url), und genau das lesen die meisten aelteren
|
||||||
|
// Skripte. Zusaetzliche Felder schaden nicht, fehlende schon.
|
||||||
|
|
||||||
|
for (const [weg, art] of [
|
||||||
|
['/image', 'image'],
|
||||||
|
['/video', 'video'],
|
||||||
|
['/audio', 'audio'],
|
||||||
|
] as const) {
|
||||||
|
fivemanageRoutes.post(weg, async (c) => {
|
||||||
|
const r = await ablegen(c, art)
|
||||||
|
if (!r.ok) return r.antwort
|
||||||
|
return c.json({ url: r.url, id: r.id, path: r.path })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ohne Einschraenkung der Art -- der Sammelweg. */
|
||||||
|
fivemanageRoutes.post('/file', async (c) => {
|
||||||
|
const r = await ablegen(c)
|
||||||
|
if (!r.ok) return r.antwort
|
||||||
|
return c.json({ url: r.url, id: r.id, path: r.path })
|
||||||
|
})
|
||||||
|
|
||||||
|
/** v3: dieselbe Arbeit, andere Verpackung.
|
||||||
|
*
|
||||||
|
* { status: "ok", data: { id, url } } -- so prueft es das SDK mit valibot,
|
||||||
|
* und ein fehlendes Feld laesst es die Antwort verwerfen. */
|
||||||
|
fivemanageRoutes.post('/v3/file', async (c) => {
|
||||||
|
const r = await ablegen(c)
|
||||||
|
if (!r.ok) return r.antwort
|
||||||
|
return c.json({ status: 'ok', data: { id: r.id, url: r.url } })
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Freigaben: einen Ordner ansehen, ohne sich anzumelden.
|
||||||
|
//
|
||||||
|
// WAS HIER WIRKLICH FREIGEGEBEN WIRD -- und das ist der Satz, den das Dashboard
|
||||||
|
// auch anzeigt: die DATEIEN sind ohnehin oeffentlich. Wer die Adresse eines
|
||||||
|
// Bildes kennt, bekommt es, mit oder ohne Freigabe. Neu ist die LISTE: welche
|
||||||
|
// Bilder es in einem Ordner gibt. Die war bisher nur hinter der Anmeldung zu
|
||||||
|
// haben.
|
||||||
|
//
|
||||||
|
// Der Schluessel in der Adresse ist KEIN Passwort. Wer den Link hat, sieht die
|
||||||
|
// Liste -- dieselbe Zusicherung wie bei einem Nextcloud-Freigabelink. Was hier
|
||||||
|
// NICHT geht: hochladen, loeschen, umbenennen, einen anderen Ordner sehen.
|
||||||
|
//
|
||||||
|
// Und was hier bewusst FEHLT: eine eigene Oberflaeche mit eigenem Aufbau. Die
|
||||||
|
// Freigabe liefert JSON, und die gebaute Oberflaeche zeigt es an -- eine zweite
|
||||||
|
// Galerie waere eine zweite, die beim naechsten Mal anders aussieht.
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { db, now, type Freigabe, type Media } from '../db.js'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { publicUrlFor } from '../storage.js'
|
||||||
|
|
||||||
|
export const freigabeRoutes = new Hono()
|
||||||
|
|
||||||
|
/** Der Schluessel: 16 Zeichen aus 32 moeglichen, also rund 80 Bit.
|
||||||
|
*
|
||||||
|
* Zu viel, um ihn zu raten, und kurz genug, um ihn vorzulesen. Ohne i, l, o
|
||||||
|
* und die Null -- ein Link, den jemand aus einem Bildschirmfoto abtippt, soll
|
||||||
|
* nicht an einer Verwechslung scheitern. */
|
||||||
|
const ZEICHEN = 'abcdefghjkmnpqrstuvwxyz23456789'
|
||||||
|
|
||||||
|
export function neuerSchluessel(): string {
|
||||||
|
const roh = randomBytes(16)
|
||||||
|
return Array.from(roh, (b) => ZEICHEN[b % ZEICHEN.length]).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Liste eines freigegebenen Ordners.
|
||||||
|
*
|
||||||
|
* OHNE ANMELDUNG, und deshalb wird hier genau abgemessen, was hinausgeht:
|
||||||
|
* Pfad, Groesse, Art, Adresse, Laenge. NICHT: Hash, Token, Zeitpunkte,
|
||||||
|
* Abrufzahlen, IDs. Nichts davon braucht jemand, der einen Katalog ansieht,
|
||||||
|
* und jedes davon waere eine Auskunft ueber den Betrieb dieses Dienstes. */
|
||||||
|
freigabeRoutes.get('/:schluessel', (c) => {
|
||||||
|
const eintrag = db
|
||||||
|
.prepare('SELECT * FROM freigaben WHERE schluessel = ?')
|
||||||
|
.get(c.req.param('schluessel')) as Freigabe | undefined
|
||||||
|
|
||||||
|
// Dieselbe Antwort fuer "gibt es nicht" und "gab es mal": ein Unterschied
|
||||||
|
// waere eine Auskunft an jemanden, der Schluessel durchprobiert.
|
||||||
|
if (!eintrag) return c.json({ error: 'nicht gefunden' }, 404)
|
||||||
|
|
||||||
|
db.prepare('UPDATE freigaben SET aufrufe = aufrufe + 1, zuletzt = ? WHERE id = ?').run(
|
||||||
|
now(),
|
||||||
|
eintrag.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
const maskiert = eintrag.ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
|
||||||
|
const zeilen = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT path, size, mime, dauer FROM media
|
||||||
|
WHERE path LIKE ? ESCAPE '\\' ORDER BY path COLLATE NOCASE LIMIT 2000`,
|
||||||
|
)
|
||||||
|
.all(`${maskiert}/%`) as Pick<Media, 'path' | 'size' | 'mime' | 'dauer'>[]
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
titel: eintrag.titel || eintrag.ordner,
|
||||||
|
ordner: eintrag.ordner,
|
||||||
|
// Der Praefix, den die Oberflaeche abschneidet -- damit dort
|
||||||
|
// "adder.webp" steht und nicht "vehicles/adder.webp". Wer den ganzen
|
||||||
|
// Pfad sehen will, sieht ihn in der Adresse.
|
||||||
|
anzahl: zeilen.length,
|
||||||
|
items: zeilen.map((r) => ({
|
||||||
|
name: r.path.slice(eintrag.ordner.length + 1),
|
||||||
|
size: r.size,
|
||||||
|
mime: r.mime,
|
||||||
|
dauer: r.dauer,
|
||||||
|
url: publicUrlFor(r.path),
|
||||||
|
// Die Vorschau liegt unter /t/ und braucht keine Anmeldung -- das
|
||||||
|
// war schon vorher so, sonst zeigte das Dashboard keine Kacheln.
|
||||||
|
thumb: `${config.publicUrl}/t/${r.path}`,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -505,6 +505,33 @@ export async function writeWebp(path: string, data: Buffer): Promise<number | nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Einen ganzen Ordner an einen anderen Platz legen.
|
||||||
|
*
|
||||||
|
* NICHT dasselbe wie moveFile mit einem Ordner darin, und das ist ein
|
||||||
|
* Fallstrick, der beim Schreiben fast durchgerutscht waere: `thumbPath` haengt
|
||||||
|
* ".webp" an. Fuer eine Datei ist das richtig (vehicles/adder.png ->
|
||||||
|
* thumbs/vehicles/adder.png.webp), fuer einen ORDNER waere es Unsinn -- der
|
||||||
|
* Vorschauordner heisst thumbs/vehicles und nicht thumbs/vehicles.webp.
|
||||||
|
*
|
||||||
|
* Deshalb hier die Ordnerfassung: dieselben drei Baeume, aber ohne Endung. */
|
||||||
|
export async function moveFolder(von: string, nach: string): Promise<void> {
|
||||||
|
await mkdir(dirname(join(config.filesDir, nach)), { recursive: true })
|
||||||
|
await rename(join(config.filesDir, von), join(config.filesDir, nach))
|
||||||
|
|
||||||
|
for (const ordner of ['thumbs', 'webp']) {
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(join(config.dataDir, ordner, nach)), { recursive: true })
|
||||||
|
await rename(
|
||||||
|
join(config.dataDir, ordner, von),
|
||||||
|
join(config.dataDir, ordner, nach),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Gibt es nicht — kein Fehler. Beides ist abgeleitet und laesst
|
||||||
|
// sich mit einem Knopf neu rechnen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteFile(path: string): Promise<void> {
|
export async function deleteFile(path: string): Promise<void> {
|
||||||
await rm(absolutePath(path), { force: true })
|
await rm(absolutePath(path), { force: true })
|
||||||
// Vorschau und sparsame Fassung gehen mit. Sonst bleiben sie liegen, und
|
// Vorschau und sparsame Fassung gehen mit. Sonst bleiben sie liegen, und
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Einstellungen } from './pages/Einstellungen'
|
|||||||
import { Verlauf } from './pages/Verlauf'
|
import { Verlauf } from './pages/Verlauf'
|
||||||
import { Api } from './pages/Api'
|
import { Api } from './pages/Api'
|
||||||
import { Papierkorb } from './pages/Papierkorb'
|
import { Papierkorb } from './pages/Papierkorb'
|
||||||
|
import { Freigabe } from './pages/Freigabe'
|
||||||
import { Meldungen } from './components/Meldungen'
|
import { Meldungen } from './components/Meldungen'
|
||||||
import { gehZu, usePfad } from './router'
|
import { gehZu, usePfad } from './router'
|
||||||
import { Kontomenue } from './components/Kontomenue'
|
import { Kontomenue } from './components/Kontomenue'
|
||||||
@@ -63,6 +64,18 @@ export function App() {
|
|||||||
void checkSession()
|
void checkSession()
|
||||||
}, [checkSession])
|
}, [checkSession])
|
||||||
|
|
||||||
|
// EINE FREIGABE GEHT VOR ALLEM ANDEREN -- vor dem Prüfen der Sitzung, vor
|
||||||
|
// dem Anmeldeformular, vor der Kopfzeile. Wer einen Link bekommen hat, ist
|
||||||
|
// kein Benutzer dieses Dashboards; ihm ein Anmeldeformular zu zeigen wäre
|
||||||
|
// die falsche Antwort auf "sieh dir das an".
|
||||||
|
//
|
||||||
|
// Und deshalb steht das hier VOR dem `checking`-Zweig: sonst blitzt für
|
||||||
|
// einen Moment das Rädchen der Sitzungsprüfung auf, die es gar nicht
|
||||||
|
// braucht.
|
||||||
|
if (pfad.startsWith('/s/')) {
|
||||||
|
return <Freigabe schluessel={decodeURIComponent(pfad.slice(3))} />
|
||||||
|
}
|
||||||
|
|
||||||
// Solange nicht feststeht, ob eine Sitzung besteht, wird NICHTS gezeigt.
|
// Solange nicht feststeht, ob eine Sitzung besteht, wird NICHTS gezeigt.
|
||||||
// Sonst blitzt bei jedem Neuladen kurz das Anmeldeformular auf, obwohl man
|
// Sonst blitzt bei jedem Neuladen kurz das Anmeldeformular auf, obwohl man
|
||||||
// angemeldet ist — und das sieht aus wie ein Fehler.
|
// angemeldet ist — und das sieht aus wie ein Fehler.
|
||||||
|
|||||||
+45
-2
@@ -174,6 +174,12 @@ export type TokenRow = {
|
|||||||
arten: string
|
arten: string
|
||||||
expires_at: number | null
|
expires_at: number | null
|
||||||
abgelaufen: boolean
|
abgelaufen: boolean
|
||||||
|
/** Wieviel mit DIESEM Token abgelegt wurde. */
|
||||||
|
dateien: number
|
||||||
|
bytes: number
|
||||||
|
/** Wann zuletzt etwas dabei herauskam — anders als `last_used_at`, das
|
||||||
|
* auch ein Anklopfen ohne Ergebnis mitzaehlt. */
|
||||||
|
zuletztAbgelegt: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
|
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
|
||||||
@@ -216,6 +222,22 @@ export type Uebersicht = {
|
|||||||
|
|
||||||
export type Vorlage = { name: string; muster: string }
|
export type Vorlage = { name: string; muster: string }
|
||||||
|
|
||||||
|
/** Eine Freigabe: ein Ordner, den man ohne Anmeldung ansehen kann.
|
||||||
|
*
|
||||||
|
* Freigegeben ist die LISTE, nicht der Inhalt — die Dateien sind ohnehin
|
||||||
|
* oeffentlich. Das steht so auch in der Karte, weil das Gegenteil zu glauben
|
||||||
|
* gefaehrlich waere. */
|
||||||
|
export type FreigabeRow = {
|
||||||
|
id: number
|
||||||
|
schluessel: string
|
||||||
|
ordner: string
|
||||||
|
titel: string
|
||||||
|
created_at: number
|
||||||
|
aufrufe: number
|
||||||
|
zuletzt: number | null
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export type VerschiebeErgebnis = {
|
export type VerschiebeErgebnis = {
|
||||||
verschoben: number
|
verschoben: number
|
||||||
fehlgeschlagen: number
|
fehlgeschlagen: number
|
||||||
@@ -225,7 +247,7 @@ export type VerschiebeErgebnis = {
|
|||||||
export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere'
|
export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere'
|
||||||
export type Arten = Record<string, number>
|
export type Arten = Record<string, number>
|
||||||
|
|
||||||
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse'
|
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' | 'abrufe' | 'geholt'
|
||||||
export type Richtung = 'auf' | 'ab'
|
export type Richtung = 'auf' | 'ab'
|
||||||
|
|
||||||
export type Verwaiste = {
|
export type Verwaiste = {
|
||||||
@@ -450,7 +472,28 @@ export const api = {
|
|||||||
{ aktion },
|
{ aktion },
|
||||||
),
|
),
|
||||||
|
|
||||||
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
|
freigaben: () => get<{ items: FreigabeRow[] }>('/api/dash/freigaben'),
|
||||||
|
freigabeAnlegen: (ordner: string, titel: string) =>
|
||||||
|
post<{ id: number; schluessel: string; ordner: string; dateien: number; url: string }>(
|
||||||
|
'/api/dash/freigaben',
|
||||||
|
{ ordner, titel },
|
||||||
|
),
|
||||||
|
freigabeWeg: (id: number) => del<{ ok: true }>(`/api/dash/freigaben/${id}`),
|
||||||
|
|
||||||
|
tokens: () =>
|
||||||
|
get<{ items: TokenRow[]; ohneToken: { dateien: number; bytes: number } }>(
|
||||||
|
'/api/dash/tokens',
|
||||||
|
),
|
||||||
|
umbenennen: (id: number, name: string) =>
|
||||||
|
post<{ alt: string; neu: string; url: string; abrufe: number }>(
|
||||||
|
'/api/dash/media/umbenennen',
|
||||||
|
{ id, name },
|
||||||
|
),
|
||||||
|
ordnerUmbenennen: (pfad: string, name: string) =>
|
||||||
|
post<{ alt: string; neu: string; dateien: number }>('/api/dash/folders/umbenennen', {
|
||||||
|
pfad,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
createToken: (daten: {
|
createToken: (daten: {
|
||||||
name: string
|
name: string
|
||||||
prefix: string
|
prefix: string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
|
PenLine,
|
||||||
Repeat,
|
Repeat,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
@@ -38,6 +39,7 @@ export function Ansehen({
|
|||||||
onNachher,
|
onNachher,
|
||||||
onSchliessen,
|
onSchliessen,
|
||||||
onLoeschen,
|
onLoeschen,
|
||||||
|
onUmbenennen,
|
||||||
}: {
|
}: {
|
||||||
item: MediaItem
|
item: MediaItem
|
||||||
/** Was danach kaeme — gebraucht fuer "weiter zum naechsten Stueck": nur
|
/** Was danach kaeme — gebraucht fuer "weiter zum naechsten Stueck": nur
|
||||||
@@ -49,6 +51,8 @@ export function Ansehen({
|
|||||||
onNachher: () => void
|
onNachher: () => void
|
||||||
onSchliessen: () => void
|
onSchliessen: () => void
|
||||||
onLoeschen: () => void
|
onLoeschen: () => void
|
||||||
|
/** Fehlt in Ansichten, in denen Umbenennen keinen Sinn ergibt. */
|
||||||
|
onUmbenennen?: () => void
|
||||||
}) {
|
}) {
|
||||||
const note = useStore((s) => s.note)
|
const note = useStore((s) => s.note)
|
||||||
const { kopiert, kopieren } = useKopieren()
|
const { kopiert, kopieren } = useKopieren()
|
||||||
@@ -174,6 +178,18 @@ export function Ansehen({
|
|||||||
<ExternalLink size={13} />
|
<ExternalLink size={13} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{onUmbenennen && (
|
||||||
|
<button
|
||||||
|
onClick={onUmbenennen}
|
||||||
|
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||||
|
hover:bg-white/10 hover:text-schrift"
|
||||||
|
aria-label="Umbenennen"
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
<PenLine size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onLoeschen}
|
onClick={onLoeschen}
|
||||||
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function Nachfrage({
|
|||||||
text,
|
text,
|
||||||
bestaetigung,
|
bestaetigung,
|
||||||
wartet,
|
wartet,
|
||||||
|
gesperrt,
|
||||||
onJa,
|
onJa,
|
||||||
onNein,
|
onNein,
|
||||||
}: {
|
}: {
|
||||||
@@ -112,6 +113,11 @@ export function Nachfrage({
|
|||||||
text: ReactNode
|
text: ReactNode
|
||||||
bestaetigung: string
|
bestaetigung: string
|
||||||
wartet?: boolean
|
wartet?: boolean
|
||||||
|
/** Sperrt den Bestaetigungsknopf, solange die Eingabe unvollstaendig ist.
|
||||||
|
* Gebraucht, seit die Nachfrage auch Felder enthaelt (Umbenennen) — ein
|
||||||
|
* Knopf, der bei leerem Feld gedrueckt werden kann, erzeugt einen Fehler,
|
||||||
|
* den der Dialog vorher schon kannte. */
|
||||||
|
gesperrt?: boolean
|
||||||
onJa: () => void
|
onJa: () => void
|
||||||
onNein: () => void
|
onNein: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -141,7 +147,13 @@ export function Nachfrage({
|
|||||||
<div className="px-4 py-4 text-sm text-schrift-leise">{text}</div>
|
<div className="px-4 py-4 text-sm text-schrift-leise">{text}</div>
|
||||||
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
|
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
|
||||||
<Knopf onClick={onNein} kind="Abbrechen" />
|
<Knopf onClick={onNein} kind="Abbrechen" />
|
||||||
<Knopf art="gefahr" wartet={wartet} onClick={onJa} kind={bestaetigung} />
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
wartet={wartet}
|
||||||
|
disabled={gesperrt}
|
||||||
|
onClick={onJa}
|
||||||
|
kind={bestaetigung}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Check, Copy, ExternalLink, Link2, Share2, Trash2 } from 'lucide-react'
|
||||||
|
import { api, type Folders, type FreigabeRow } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { zeit } from '../format'
|
||||||
|
import { Karte, Knopf, Nachfrage, useKopieren } from './Bausteine'
|
||||||
|
|
||||||
|
/** Freigaben: ein Ordner zum Ansehen, ohne Anmeldung.
|
||||||
|
*
|
||||||
|
* WAS HIER WIRKLICH FREIGEGEBEN WIRD, steht groß dabei — sonst entsteht ein
|
||||||
|
* gefährliches Missverständnis in beide Richtungen: die DATEIEN sind ohnehin
|
||||||
|
* öffentlich (wer die Adresse eines Bildes kennt, bekommt es), neu ist nur die
|
||||||
|
* LISTE. Und umgekehrt: einen Link zurückzuziehen macht die Bilder nicht
|
||||||
|
* wieder privat.
|
||||||
|
*
|
||||||
|
* Wer das nicht weiß, zieht einen Link zurück und glaubt, damit sei etwas
|
||||||
|
* verschwunden. */
|
||||||
|
export function FreigabenKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [items, setItems] = useState<FreigabeRow[] | null>(null)
|
||||||
|
const [ordner, setOrdner] = useState<Folders | null>(null)
|
||||||
|
const [wahl, setWahl] = useState('')
|
||||||
|
const [titel, setTitel] = useState('')
|
||||||
|
const [legtAn, setLegtAn] = useState(false)
|
||||||
|
const [weg, setWeg] = useState<FreigabeRow | null>(null)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
const [f, o] = await Promise.all([api.freigaben(), api.folders()])
|
||||||
|
setItems(f.items)
|
||||||
|
setOrdner(o)
|
||||||
|
if (!wahl && o.items[0]) setWahl(o.items[0].folder)
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// Einmal beim Öffnen. Freigaben ändern sich nicht von selbst.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!items) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Freigaben">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Ein Link auf einen Ordner, den jeder ohne Anmeldung ansehen kann — etwa ein
|
||||||
|
Fahrzeugkatalog für Spieler.{' '}
|
||||||
|
<strong className="text-schrift">
|
||||||
|
Freigegeben wird die Liste, nicht der Inhalt:
|
||||||
|
</strong>{' '}
|
||||||
|
die Dateien selbst sind ohnehin öffentlich, wer eine Adresse kennt, bekommt das
|
||||||
|
Bild. Ein zurückgezogener Link macht sie deshalb nicht wieder privat — er nimmt
|
||||||
|
nur die Übersicht weg.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-end gap-2">
|
||||||
|
<label className="min-w-40 flex-1">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Ordner
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={wahl}
|
||||||
|
onChange={(e) => setWahl(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"
|
||||||
|
>
|
||||||
|
{(ordner?.items ?? []).map((o) => (
|
||||||
|
<option key={o.folder} value={o.folder}>
|
||||||
|
{' '.repeat(o.tiefe * 2)}
|
||||||
|
{o.folder.split('/').pop()} ({o.files})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="min-w-40 flex-1">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Überschrift (freiwillig)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={titel}
|
||||||
|
onChange={(e) => setTitel(e.target.value)}
|
||||||
|
placeholder="Fahrzeugkatalog"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={legtAn}
|
||||||
|
disabled={!wahl}
|
||||||
|
onClick={async () => {
|
||||||
|
setLegtAn(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.freigabeAnlegen(wahl, titel),
|
||||||
|
(x) => `Link für ${x.ordner} steht (${x.dateien} Dateien).`,
|
||||||
|
)
|
||||||
|
setLegtAn(false)
|
||||||
|
if (r) {
|
||||||
|
setTitel('')
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Share2 size={15} />
|
||||||
|
Link erzeugen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-schrift-leise">Noch keine Freigabe.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{items.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-md border border-rand
|
||||||
|
bg-grund px-3 py-2"
|
||||||
|
>
|
||||||
|
<Link2 size={14} className="shrink-0 text-schrift-leise" />
|
||||||
|
<span className="w-40 shrink-0 truncate text-sm" title={f.ordner}>
|
||||||
|
{f.titel || f.ordner}
|
||||||
|
</span>
|
||||||
|
<code className="min-w-0 flex-1 truncate font-mono text-xs text-schrift-leise">
|
||||||
|
{f.url}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
{/* Wird der Link noch benutzt? Genau die Frage, die
|
||||||
|
man sich stellt, bevor man ihn wegwirft. */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[11px] text-schrift-leise tabular-nums"
|
||||||
|
title={f.zuletzt ? `zuletzt ${zeit(f.zuletzt)}` : 'nie geöffnet'}
|
||||||
|
>
|
||||||
|
{f.aufrufe}× geöffnet
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await kopieren(f.url, String(f.id))))
|
||||||
|
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Link kopieren"
|
||||||
|
>
|
||||||
|
{kopiert === String(f.id) ? (
|
||||||
|
<Check size={13} className="text-gut" />
|
||||||
|
) : (
|
||||||
|
<Copy size={13} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={f.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setWeg(f)}
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-schlecht/15 hover:text-schlecht"
|
||||||
|
aria-label="Zurückziehen"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={weg !== null}
|
||||||
|
titel="Link zurückziehen?"
|
||||||
|
bestaetigung="Zurückziehen"
|
||||||
|
onNein={() => setWeg(null)}
|
||||||
|
onJa={async () => {
|
||||||
|
const f = weg
|
||||||
|
setWeg(null)
|
||||||
|
if (!f) return
|
||||||
|
await run(() => api.freigabeWeg(f.id), 'Link zurückgezogen.')
|
||||||
|
void laden()
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<p>
|
||||||
|
Der Link auf <code className="font-mono">{weg?.ordner}</code> führt danach
|
||||||
|
ins Leere.{' '}
|
||||||
|
<strong className="text-schrift">
|
||||||
|
Die Dateien bleiben öffentlich erreichbar
|
||||||
|
</strong>{' '}
|
||||||
|
— wer eine ihrer Adressen kennt oder gespeichert hat, kommt weiterhin
|
||||||
|
heran. Weg ist nur die Übersicht.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Check, Folder, FolderOpen, FolderPlus, Layers, X } from 'lucide-react'
|
import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, X } from 'lucide-react'
|
||||||
import { api, type Folders } from '../api'
|
import { api, type Folders } from '../api'
|
||||||
import { bytes } from '../format'
|
import { bytes } from '../format'
|
||||||
import { run } from '../store'
|
import { run } from '../store'
|
||||||
@@ -21,12 +21,14 @@ export function Ordner({
|
|||||||
gewaehlt,
|
gewaehlt,
|
||||||
onWahl,
|
onWahl,
|
||||||
onAngelegt,
|
onAngelegt,
|
||||||
|
onUmbenennen,
|
||||||
}: {
|
}: {
|
||||||
daten: Folders | null
|
daten: Folders | null
|
||||||
gewaehlt: string
|
gewaehlt: string
|
||||||
onWahl: (ordner: string) => void
|
onWahl: (ordner: string) => void
|
||||||
/** Damit die Liste den neuen Ordner auch zeigt. */
|
/** Damit die Liste den neuen Ordner auch zeigt. */
|
||||||
onAngelegt: () => void
|
onAngelegt: () => void
|
||||||
|
onUmbenennen: (pfad: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [neu, setNeu] = useState<string | null>(null)
|
const [neu, setNeu] = useState<string | null>(null)
|
||||||
const [legtAn, setLegtAn] = useState(false)
|
const [legtAn, setLegtAn] = useState(false)
|
||||||
@@ -57,11 +59,11 @@ export function Ordner({
|
|||||||
groesse: number,
|
groesse: number,
|
||||||
tiefe = 0,
|
tiefe = 0,
|
||||||
onClick?: () => void,
|
onClick?: () => void,
|
||||||
|
pfad?: string,
|
||||||
) => (
|
) => (
|
||||||
<button
|
<div
|
||||||
key={name + tiefe}
|
key={name + tiefe}
|
||||||
onClick={onClick}
|
className={`group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-sm
|
||||||
className={`flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left text-sm
|
|
||||||
transition-colors ${
|
transition-colors ${
|
||||||
aktiv
|
aktiv
|
||||||
? 'bg-flaeche-hoch text-schrift'
|
? 'bg-flaeche-hoch text-schrift'
|
||||||
@@ -69,17 +71,38 @@ export function Ordner({
|
|||||||
}`}
|
}`}
|
||||||
style={{ paddingLeft: `${0.5 + tiefe * 0.85}rem` }}
|
style={{ paddingLeft: `${0.5 + tiefe * 0.85}rem` }}
|
||||||
>
|
>
|
||||||
|
<button onClick={onClick} className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||||
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
|
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
|
||||||
<span className="flex-1 truncate font-mono text-xs" title={name}>
|
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={name}>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Erst beim Darueberfahren. Eine Zeile mit drei Zahlen und einem
|
||||||
|
dauerhaft sichtbaren Knopf waere Laerm — und bei einem Dutzend
|
||||||
|
Ordnern zwoelfmal derselbe. */}
|
||||||
|
{pfad !== undefined && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onUmbenennen(pfad)
|
||||||
|
}}
|
||||||
|
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
|
||||||
|
group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100"
|
||||||
|
aria-label={`${pfad} umbenennen`}
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
<PenLine size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
|
<span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
|
||||||
{anzahl}
|
{anzahl}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-schrift-leise">
|
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-schrift-leise">
|
||||||
{bytes(groesse)}
|
{bytes(groesse)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,6 +130,7 @@ export function Ordner({
|
|||||||
o.bytes,
|
o.bytes,
|
||||||
o.tiefe,
|
o.tiefe,
|
||||||
() => onWahl(o.folder),
|
() => onWahl(o.folder),
|
||||||
|
o.folder,
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertTriangle, PenLine } from 'lucide-react'
|
||||||
|
import { api, type MediaItem } from '../api'
|
||||||
|
import { run } from '../store'
|
||||||
|
import { Feld, Nachfrage } from './Bausteine'
|
||||||
|
|
||||||
|
/** Eine Datei umbenennen.
|
||||||
|
*
|
||||||
|
* DER PFAD IST DIE ADRESSE — das ist der Satz, an dem dieses ganze Projekt
|
||||||
|
* haengt, und beim Umbenennen ist er eine Warnung: die alte Adresse steht
|
||||||
|
* womoeglich in einem Skript, das niemand mehr im Kopf hat, und sie meldet
|
||||||
|
* sich nicht, wenn sie ins Leere zeigt.
|
||||||
|
*
|
||||||
|
* Deshalb steht die Zahl der ABRUFE gross daneben. Eine Datei, die noch nie
|
||||||
|
* jemand geholt hat, umzubenennen ist harmlos; eine mit viertausend Abrufen
|
||||||
|
* ist eine Entscheidung. Der Dienst ist die einzige Stelle, die den
|
||||||
|
* Unterschied kennt — also sagt er ihn. */
|
||||||
|
export function Umbenennen({
|
||||||
|
datei,
|
||||||
|
onAbbruch,
|
||||||
|
onFertig,
|
||||||
|
}: {
|
||||||
|
datei: MediaItem
|
||||||
|
onAbbruch: () => void
|
||||||
|
onFertig: () => void
|
||||||
|
}) {
|
||||||
|
const alt = datei.path.split('/').pop() ?? datei.path
|
||||||
|
const ordner = datei.path.split('/').slice(0, -1).join('/')
|
||||||
|
const [name, setName] = useState(alt)
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
|
||||||
|
const geaendert = name.trim() !== alt && name.trim().length > 0
|
||||||
|
|
||||||
|
// Die Endung mitzuaendern ist erlaubt, aber selten gewollt: sie entscheidet
|
||||||
|
// ueber den Dateityp, den der Dienst meldet.
|
||||||
|
const endungAlt = alt.includes('.') ? alt.split('.').pop() : ''
|
||||||
|
const endungNeu = name.includes('.') ? name.split('.').pop() : ''
|
||||||
|
const endungWeg = endungAlt !== endungNeu
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Nachfrage
|
||||||
|
offen
|
||||||
|
titel="Umbenennen"
|
||||||
|
bestaetigung="Umbenennen"
|
||||||
|
wartet={laeuft}
|
||||||
|
gesperrt={!geaendert}
|
||||||
|
onNein={onAbbruch}
|
||||||
|
onJa={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.umbenennen(datei.id, name.trim()),
|
||||||
|
(x) => `Heisst jetzt ${x.neu}.`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
if (r) onFertig()
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Feld
|
||||||
|
label="Neuer Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Der Ordner bleibt:{' '}
|
||||||
|
<code className="font-mono">{ordner ? `${ordner}/` : '(Wurzel)'}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Was daraus wird, STEHT DA — vollstaendig und zum Lesen.
|
||||||
|
Wer eine Adresse aendert, soll die neue sehen, bevor er
|
||||||
|
drueckt, und nicht danach. */}
|
||||||
|
{geaendert && (
|
||||||
|
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
|
||||||
|
{ordner ? `${ordner}/` : ''}
|
||||||
|
<span className="text-akzent">{name.trim()}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{datei.abrufe > 0 && (
|
||||||
|
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Diese Datei wurde <strong>{datei.abrufe}×</strong> geholt. Die
|
||||||
|
alte Adresse gibt danach 404 — wer sie in einem Skript stehen
|
||||||
|
hat, merkt es erst, wenn das Bild fehlt.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{geaendert && endungWeg && (
|
||||||
|
<p className="flex items-start gap-2 text-xs text-schrift-leise">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />
|
||||||
|
<span>
|
||||||
|
Die Endung aendert sich von{' '}
|
||||||
|
<code className="font-mono">.{endungAlt || '—'}</code> auf{' '}
|
||||||
|
<code className="font-mono">.{endungNeu || '—'}</code>. Damit
|
||||||
|
aendert sich auch der Typ, den der Dienst meldet.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Ordner umbenennen — derselbe Vorgang, anderer Hebel.
|
||||||
|
*
|
||||||
|
* Hier aendern sich auf einen Schlag ALLE Adressen darin. Deshalb steht die
|
||||||
|
* Zahl der betroffenen Dateien in der Nachfrage, und zwar bevor gedrueckt
|
||||||
|
* wird. */
|
||||||
|
export function OrdnerUmbenennen({
|
||||||
|
pfad,
|
||||||
|
dateien,
|
||||||
|
onAbbruch,
|
||||||
|
onFertig,
|
||||||
|
}: {
|
||||||
|
pfad: string
|
||||||
|
dateien: number
|
||||||
|
onAbbruch: () => void
|
||||||
|
onFertig: (neu: string) => void
|
||||||
|
}) {
|
||||||
|
const alt = pfad.split('/').pop() ?? pfad
|
||||||
|
const oben = pfad.split('/').slice(0, -1).join('/')
|
||||||
|
const [name, setName] = useState(alt)
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const geaendert = name.trim() !== alt && name.trim().length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Nachfrage
|
||||||
|
offen
|
||||||
|
titel="Ordner umbenennen"
|
||||||
|
bestaetigung="Umbenennen"
|
||||||
|
wartet={laeuft}
|
||||||
|
gesperrt={!geaendert}
|
||||||
|
onNein={onAbbruch}
|
||||||
|
onJa={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.ordnerUmbenennen(pfad, name.trim()),
|
||||||
|
(x) => `${x.alt} heisst jetzt ${x.neu} (${x.dateien} Dateien).`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
if (r) onFertig(r.neu)
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Feld
|
||||||
|
label="Neuer Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{geaendert && (
|
||||||
|
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
|
||||||
|
{oben ? `${oben}/` : ''}
|
||||||
|
<span className="text-akzent">{name.trim()}</span>/
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dateien > 0 && (
|
||||||
|
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>{dateien} Dateien</strong> bekommen damit eine neue
|
||||||
|
Adresse. Jede alte gibt danach 404 — und keine davon meldet sich
|
||||||
|
von selbst.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UmbenennenZeichen = PenLine
|
||||||
@@ -6,6 +6,7 @@ import { bytes, zeit } from '../format'
|
|||||||
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||||
import { DiscordKarte } from '../components/DiscordKarte'
|
import { DiscordKarte } from '../components/DiscordKarte'
|
||||||
import { VorlagenKarte } from '../components/VorlagenKarte'
|
import { VorlagenKarte } from '../components/VorlagenKarte'
|
||||||
|
import { FreigabenKarte } from '../components/FreigabenKarte'
|
||||||
import { MeldungenKarte } from '../components/MeldungenKarte'
|
import { MeldungenKarte } from '../components/MeldungenKarte'
|
||||||
|
|
||||||
/** Alles, was sich einstellen lässt, an einer Stelle.
|
/** Alles, was sich einstellen lässt, an einer Stelle.
|
||||||
@@ -28,6 +29,8 @@ export function Einstellungen() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
|
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
|
||||||
|
|
||||||
|
<FreigabenKarte />
|
||||||
<SicherungKarte />
|
<SicherungKarte />
|
||||||
<MeldungenKarte />
|
<MeldungenKarte />
|
||||||
<DiscordKarte />
|
<DiscordKarte />
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FileQuestion, Film, Image, Loader2, Music, Play } from 'lucide-react'
|
||||||
|
import { bytes, dauerText, istBild } from '../format'
|
||||||
|
|
||||||
|
/** Ein freigegebener Ordner — ohne Anmeldung.
|
||||||
|
*
|
||||||
|
* DIESE SEITE STEHT AUSSERHALB der angemeldeten Oberfläche und weiß nichts von
|
||||||
|
* ihr: kein Store, keine Reiter, kein Kontomenü. Wer hierherkommt, hat einen
|
||||||
|
* Link bekommen und will Bilder sehen.
|
||||||
|
*
|
||||||
|
* Sie zeigt ABSICHTLICH weniger als die Galerie: keine Pfade, keine Zeitpunkte,
|
||||||
|
* keine Abrufzahlen, keine Knöpfe. Was ein Katalog braucht, ist das Bild, der
|
||||||
|
* Name und die Adresse zum Kopieren. */
|
||||||
|
|
||||||
|
type Eintrag = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
dauer: number | null
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Seite = { titel: string; ordner: string; anzahl: number; items: Eintrag[] }
|
||||||
|
|
||||||
|
export function Freigabe({ schluessel }: { schluessel: string }) {
|
||||||
|
const [daten, setDaten] = useState<Seite | null>(null)
|
||||||
|
const [fehler, setFehler] = useState('')
|
||||||
|
const [gross, setGross] = useState<Eintrag | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lebt = true
|
||||||
|
fetch(`/api/s/${encodeURIComponent(schluessel)}`)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(r.status === 404 ? 'nicht gefunden' : `HTTP ${r.status}`)
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
.then((d) => lebt && setDaten(d))
|
||||||
|
.catch((e) => lebt && setFehler(e instanceof Error ? e.message : 'unbekannt'))
|
||||||
|
return () => {
|
||||||
|
lebt = false
|
||||||
|
}
|
||||||
|
}, [schluessel])
|
||||||
|
|
||||||
|
if (fehler) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-grund p-6">
|
||||||
|
<div className="max-w-sm text-center">
|
||||||
|
<FileQuestion size={34} className="mx-auto text-schrift-leise/50" />
|
||||||
|
<p className="mt-3 text-sm text-schrift">Diesen Link gibt es nicht.</p>
|
||||||
|
<p className="mt-1 text-xs text-schrift-leise">
|
||||||
|
Er wurde zurückgezogen, oder er war nie einer. Beides sieht von hier
|
||||||
|
gleich aus — und das ist Absicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!daten) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-grund">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-grund">
|
||||||
|
<header className="border-b border-rand bg-flaeche">
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-4 py-4">
|
||||||
|
<h1 className="text-lg font-semibold text-schrift">{daten.titel}</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-schrift-leise">
|
||||||
|
{daten.anzahl} {daten.anzahl === 1 ? 'Datei' : 'Dateien'} ·{' '}
|
||||||
|
{bytes(daten.items.reduce((s, i) => s + i.size, 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-7xl px-4 py-5">
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
|
||||||
|
{daten.items.map((i) => (
|
||||||
|
<figure
|
||||||
|
key={i.url}
|
||||||
|
className="overflow-hidden rounded-lg border border-rand bg-flaeche"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setGross(i)}
|
||||||
|
className="block w-full"
|
||||||
|
aria-label={`${i.name} groß ansehen`}
|
||||||
|
>
|
||||||
|
<div className="relative flex aspect-square items-center justify-center bg-grund">
|
||||||
|
<Kachel eintrag={i} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<figcaption className="border-t border-rand p-2">
|
||||||
|
<p
|
||||||
|
className="truncate font-mono text-[11px] text-schrift"
|
||||||
|
title={i.name}
|
||||||
|
>
|
||||||
|
{i.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-schrift-leise">{bytes(i.size)}</p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-[11px] text-schrift-leise">
|
||||||
|
Bereitgestellt mit d4rk_media
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{gross && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/85 p-4"
|
||||||
|
onClick={() => setGross(null)}
|
||||||
|
>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="max-h-full">
|
||||||
|
{gross.mime.startsWith('video') ? (
|
||||||
|
<video
|
||||||
|
src={gross.url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="max-h-[80vh] max-w-full rounded"
|
||||||
|
/>
|
||||||
|
) : gross.mime.startsWith('audio') ? (
|
||||||
|
<audio src={gross.url} controls autoPlay className="w-80" />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={gross.url}
|
||||||
|
alt={gross.name}
|
||||||
|
className="max-h-[80vh] max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-xs text-schrift-leise">{gross.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dieselbe Regel wie in der Galerie: erst die Vorschau, dann ein Zeichen, das
|
||||||
|
* die Art benennt. Ein Fragezeichen auf einer gesunden MP4 sieht aus wie ein
|
||||||
|
* Fehler — und hier sieht es ein Gast. */
|
||||||
|
function Kachel({ eintrag }: { eintrag: Eintrag }) {
|
||||||
|
const [weg, setWeg] = useState(false)
|
||||||
|
const bewegt = eintrag.mime.startsWith('video') || eintrag.mime.startsWith('audio')
|
||||||
|
|
||||||
|
if (!weg) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={eintrag.thumb}
|
||||||
|
onError={(e) => {
|
||||||
|
const bild = e.currentTarget
|
||||||
|
if (istBild(eintrag.mime) && !bild.dataset.gefallen) {
|
||||||
|
bild.dataset.gefallen = 'ja'
|
||||||
|
bild.src = eintrag.url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setWeg(true)
|
||||||
|
}}
|
||||||
|
alt={eintrag.name}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
{bewegt && (
|
||||||
|
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="rounded-full bg-black/55 p-2">
|
||||||
|
<Play size={14} className="translate-x-[1px] fill-white text-white" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{eintrag.dauer !== null && eintrag.dauer > 0 && (
|
||||||
|
<span className="pointer-events-none absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[11px] tabular-nums text-white">
|
||||||
|
{dauerText(eintrag.dauer)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Zeichen = eintrag.mime.startsWith('video')
|
||||||
|
? Film
|
||||||
|
: eintrag.mime.startsWith('audio')
|
||||||
|
? Music
|
||||||
|
: istBild(eintrag.mime)
|
||||||
|
? Image
|
||||||
|
: FileQuestion
|
||||||
|
return <Zeichen size={26} className="text-schrift-leise/40" />
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import { Ordner } from '../components/Ordner'
|
|||||||
import { Hochladen } from '../components/Hochladen'
|
import { Hochladen } from '../components/Hochladen'
|
||||||
import { Ansehen } from '../components/Ansehen'
|
import { Ansehen } from '../components/Ansehen'
|
||||||
import { Verschieben } from '../components/Verschieben'
|
import { Verschieben } from '../components/Verschieben'
|
||||||
|
import { OrdnerUmbenennen, Umbenennen } from '../components/Umbenennen'
|
||||||
import { useAuffrischen } from '../auffrischen'
|
import { useAuffrischen } from '../auffrischen'
|
||||||
import { useMerker } from '../merker'
|
import { useMerker } from '../merker'
|
||||||
|
|
||||||
@@ -65,6 +66,8 @@ export function Galerie() {
|
|||||||
// nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
|
// nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
|
||||||
const [ansehen, setAnsehen] = useState<number | null>(null)
|
const [ansehen, setAnsehen] = useState<number | null>(null)
|
||||||
const [verschieben, setVerschieben] = useState<MediaItem[] | null>(null)
|
const [verschieben, setVerschieben] = useState<MediaItem[] | null>(null)
|
||||||
|
const [umbenennen, setUmbenennen] = useState<MediaItem | null>(null)
|
||||||
|
const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
|
||||||
const { kopiert, kopieren } = useKopieren()
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
|
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
|
||||||
@@ -240,6 +243,7 @@ export function Galerie() {
|
|||||||
daten={ordnerDaten}
|
daten={ordnerDaten}
|
||||||
gewaehlt={ordner}
|
gewaehlt={ordner}
|
||||||
onAngelegt={() => void ordnerLaden()}
|
onAngelegt={() => void ordnerLaden()}
|
||||||
|
onUmbenennen={setOrdnerUmbenennen}
|
||||||
onWahl={(o) => {
|
onWahl={(o) => {
|
||||||
setOrdner(o)
|
setOrdner(o)
|
||||||
setSeite(0)
|
setSeite(0)
|
||||||
@@ -370,6 +374,12 @@ export function Galerie() {
|
|||||||
<option value="angelegt">zuerst hochgeladen</option>
|
<option value="angelegt">zuerst hochgeladen</option>
|
||||||
<option value="pfad">Pfad</option>
|
<option value="pfad">Pfad</option>
|
||||||
<option value="groesse">Größe</option>
|
<option value="groesse">Größe</option>
|
||||||
|
{/* Gezählt wurde längst — ordnen konnte man danach
|
||||||
|
nicht, und damit beantwortete die Zahl die Frage
|
||||||
|
„was wird eigentlich gebraucht" nur für die zehn
|
||||||
|
im Speicherbericht. */}
|
||||||
|
<option value="abrufe">Abrufe</option>
|
||||||
|
<option value="geholt">zuletzt geholt</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={proSeite}
|
value={proSeite}
|
||||||
@@ -552,7 +562,23 @@ export function Galerie() {
|
|||||||
{item.path}
|
{item.path}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-[11px] text-schrift-leise">
|
<p className="mt-0.5 text-[11px] text-schrift-leise">
|
||||||
|
{/* Wonach sortiert wird, STEHT AUCH DA.
|
||||||
|
Nach einer Zahl zu ordnen, die man
|
||||||
|
nirgends sieht, ist eine Reihenfolge
|
||||||
|
ohne Begründung — man sieht nur, dass
|
||||||
|
sich etwas geändert hat. */}
|
||||||
|
{sortierung === 'abrufe' || sortierung === 'geholt' ? (
|
||||||
|
<>
|
||||||
|
{item.abrufe}× geholt ·{' '}
|
||||||
|
{item.zuletzt_abgerufen
|
||||||
|
? zeit(item.zuletzt_abgerufen)
|
||||||
|
: 'nie'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{bytes(item.size)} · {zeit(item.updated_at)}
|
{bytes(item.size)} · {zeit(item.updated_at)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 flex gap-1">
|
<div className="mt-2 flex gap-1">
|
||||||
@@ -642,10 +668,45 @@ export function Galerie() {
|
|||||||
setAnsehen(null)
|
setAnsehen(null)
|
||||||
setLoeschen([item])
|
setLoeschen([item])
|
||||||
}}
|
}}
|
||||||
|
onUmbenennen={() => {
|
||||||
|
setAnsehen(null)
|
||||||
|
setUmbenennen(item)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{umbenennen && (
|
||||||
|
<Umbenennen
|
||||||
|
datei={umbenennen}
|
||||||
|
onAbbruch={() => setUmbenennen(null)}
|
||||||
|
onFertig={() => {
|
||||||
|
setUmbenennen(null)
|
||||||
|
allesLaden()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ordnerUmbenennen !== null && (
|
||||||
|
<OrdnerUmbenennen
|
||||||
|
pfad={ordnerUmbenennen}
|
||||||
|
dateien={
|
||||||
|
ordnerDaten?.items.find((o) => o.folder === ordnerUmbenennen)?.files ?? 0
|
||||||
|
}
|
||||||
|
onAbbruch={() => setOrdnerUmbenennen(null)}
|
||||||
|
onFertig={(neu) => {
|
||||||
|
setOrdnerUmbenennen(null)
|
||||||
|
// Steht die Ansicht IM umbenannten Ordner, muss sie
|
||||||
|
// mitwandern -- sonst zeigt sie auf einen Pfad, den es
|
||||||
|
// nicht mehr gibt, und der Inhalt ist leer.
|
||||||
|
if (ordner === ordnerUmbenennen || ordner.startsWith(ordnerUmbenennen + '/')) {
|
||||||
|
setOrdner(neu + ordner.slice(ordnerUmbenennen.length))
|
||||||
|
}
|
||||||
|
allesLaden()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{verschieben && (
|
{verschieben && (
|
||||||
<Verschieben
|
<Verschieben
|
||||||
dateien={verschieben}
|
dateien={verschieben}
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ export function Tokens() {
|
|||||||
const [frisch, setFrisch] = useState<NewToken | null>(null)
|
const [frisch, setFrisch] = useState<NewToken | null>(null)
|
||||||
const [weg, setWeg] = useState<TokenRow | null>(null)
|
const [weg, setWeg] = useState<TokenRow | null>(null)
|
||||||
const [loescht, setLoescht] = useState(false)
|
const [loescht, setLoescht] = useState(false)
|
||||||
|
const [ohneToken, setOhneToken] = useState<{ dateien: number; bytes: number } | null>(null)
|
||||||
const { kopiert, kopieren } = useKopieren()
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
async function laden() {
|
async function laden() {
|
||||||
setLaedt(true)
|
setLaedt(true)
|
||||||
try {
|
try {
|
||||||
const { items } = await api.tokens()
|
const antwort = await api.tokens()
|
||||||
setRows(items)
|
setRows(antwort.items)
|
||||||
|
setOhneToken(antwort.ohneToken)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -212,6 +214,12 @@ export function Tokens() {
|
|||||||
<th className="pb-2 font-medium">Präfix</th>
|
<th className="pb-2 font-medium">Präfix</th>
|
||||||
<th className="pb-2 font-medium">Rechte</th>
|
<th className="pb-2 font-medium">Rechte</th>
|
||||||
<th className="pb-2 font-medium">Grenzen</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">Angelegt</th>
|
||||||
<th className="pb-2 font-medium">Zuletzt benutzt</th>
|
<th className="pb-2 font-medium">Zuletzt benutzt</th>
|
||||||
<th className="pb-2" />
|
<th className="pb-2" />
|
||||||
@@ -256,6 +264,23 @@ export function Tokens() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ') || '—'}
|
.join(' · ') || '—'}
|
||||||
</td>
|
</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">
|
<td className="py-2 text-schrift-leise">
|
||||||
{zeit(t.created_at)}
|
{zeit(t.created_at)}
|
||||||
</td>
|
</td>
|
||||||
@@ -277,6 +302,15 @@ export function Tokens() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</Karte>
|
||||||
|
|
||||||
{/* Der Klartext — genau einmal.
|
{/* Der Klartext — genau einmal.
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Welche Quelldateien beim Uebernehmen KEINEN eigenen Platz bekommen haben.
|
||||||
|
#
|
||||||
|
# Beim Import der Item-Bilder kamen 3669 Dateien an, aber nur 3576 Eintraege
|
||||||
|
# heraus. Die Differenz sind KEINE Fehler -- es sind Namen, die nach der
|
||||||
|
# Zaehmung (safeFilename: Kleinbuchstaben, Fremdes wird zum Strich) auf
|
||||||
|
# denselben Pfad fallen:
|
||||||
|
#
|
||||||
|
# WEAPON_SMG.png -> weapon_smg.png
|
||||||
|
# weapon_smg.png -> weapon_smg.png <- derselbe Platz
|
||||||
|
#
|
||||||
|
# Der Import laedt mit overwrite=false, hat also NICHTS ueberschrieben. Die
|
||||||
|
# zweite Datei blieb schlicht liegen.
|
||||||
|
#
|
||||||
|
# DIESES SKRIPT AENDERT NICHTS. Es liest die Quelle, rechnet dieselbe Zaehmung
|
||||||
|
# nach und zeigt, welche Namen zusammenfallen -- damit die Entscheidung darueber
|
||||||
|
# auf Zahlen steht und nicht auf einer Vermutung.
|
||||||
|
#
|
||||||
|
# pwsh -File tools\kollisionen.ps1 -Von 'CDN/items'
|
||||||
|
# pwsh -File tools\kollisionen.ps1 -Von 'CDN/items' -Csv befund.csv
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Von,
|
||||||
|
[string]$Csv = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ------------------------------------------------- Zugang aus dem Dashboard
|
||||||
|
#
|
||||||
|
# Dieselbe Quelle wie Sicherung und Import: die Werte stehen im Panel unter
|
||||||
|
# Einstellungen und damit in media.db -- so verlaesst das Passwort den Rechner
|
||||||
|
# nicht.
|
||||||
|
function hol($schluessel) {
|
||||||
|
docker exec d4rk-media node -e @"
|
||||||
|
const db = require('better-sqlite3')('/data/media.db', { readonly: true })
|
||||||
|
const z = db.prepare('SELECT value FROM settings WHERE key = ?').get('$schluessel')
|
||||||
|
process.stdout.write(z ? z.value : '')
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
$nc = @{
|
||||||
|
basis = hol 'sicherung.basis'
|
||||||
|
benutzer = hol 'sicherung.benutzer'
|
||||||
|
passwort = hol 'sicherung.passwort'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not ($nc.basis -and $nc.benutzer -and $nc.passwort)) {
|
||||||
|
Write-Host 'Die Nextcloud-Werte fehlen. Dashboard -> Einstellungen -> Sicherung.' -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$auth = "$($nc.benutzer):$($nc.passwort)"
|
||||||
|
$wurzel = "$($nc.basis.TrimEnd('/'))/remote.php/dav/files/$($nc.benutzer)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Quelle lesen
|
||||||
|
Write-Host "Lese $Von ..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$antwort = curl.exe -s -u $auth -X PROPFIND -H 'Depth: infinity' "$wurzel/$Von/"
|
||||||
|
# -join, NICHT direkt auf das Zeilen-Array: curl.exe gibt unter PowerShell ein
|
||||||
|
# Array zurueck, und -match darauf FILTERT, statt zu pruefen. Zweimal darauf
|
||||||
|
# hereingefallen, deshalb steht es hier.
|
||||||
|
$roh = $antwort -join "`n"
|
||||||
|
|
||||||
|
if ($roh -notmatch '<d:multistatus|<D:multistatus') {
|
||||||
|
Write-Host 'Keine brauchbare Antwort von der Nextcloud:' -ForegroundColor Red
|
||||||
|
Write-Host $roh.Substring(0, [Math]::Min(300, $roh.Length))
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$praefix = "/remote.php/dav/files/$($nc.benutzer)/$Von/"
|
||||||
|
$dateien = [regex]::Matches($roh, '<d:href>([^<]+)</d:href>') |
|
||||||
|
ForEach-Object { [uri]::UnescapeDataString($_.Groups[1].Value) } |
|
||||||
|
Where-Object { $_ -notmatch '/$' } |
|
||||||
|
ForEach-Object { $_ -replace [regex]::Escape($praefix), '' }
|
||||||
|
|
||||||
|
Write-Host " $($dateien.Count) Dateien" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# --------------------------------------------- Dieselbe Zaehmung nachrechnen
|
||||||
|
#
|
||||||
|
# Wort fuer Wort aus server/src/storage.ts (safeFilename). Weicht das je
|
||||||
|
# auseinander, zeigt dieses Skript etwas anderes an als der Dienst tut -- und
|
||||||
|
# das waere schlimmer als kein Skript.
|
||||||
|
function Zaehmen([string]$s) {
|
||||||
|
$s = $s.ToLowerInvariant()
|
||||||
|
$s = $s -replace 'ä', 'ae' -replace 'ö', 'oe' -replace 'ü', 'ue' -replace 'ß', 'ss'
|
||||||
|
$s = $s -replace '[^a-z0-9._-]+', '-'
|
||||||
|
$s = $s -replace '-{2,}', '-'
|
||||||
|
$s = $s -replace '^[^a-z0-9]+', ''
|
||||||
|
$s = $s -replace '[-.]+$', ''
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
function SichererName([string]$name) {
|
||||||
|
$punkt = $name.LastIndexOf('.')
|
||||||
|
$stamm = if ($punkt -gt 0) { $name.Substring(0, $punkt) } else { $name }
|
||||||
|
$endung = if ($punkt -gt 0) { $name.Substring($punkt + 1) } else { '' }
|
||||||
|
|
||||||
|
$basis = Zaehmen $stamm
|
||||||
|
$ende = Zaehmen $endung
|
||||||
|
if (-not $basis) { $basis = 'datei' }
|
||||||
|
if ($ende) { return "$basis.$ende" } else { return $basis }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Gruppieren
|
||||||
|
$gruppen = @{}
|
||||||
|
foreach ($d in $dateien) {
|
||||||
|
# Unterordner bleiben erhalten, jedes Stueck einzeln gezaehmt -- genau wie
|
||||||
|
# beim Import.
|
||||||
|
$ziel = ($d -split '/' | ForEach-Object { SichererName $_ }) -join '/'
|
||||||
|
if (-not $gruppen.ContainsKey($ziel)) { $gruppen[$ziel] = @() }
|
||||||
|
$gruppen[$ziel] += $d
|
||||||
|
}
|
||||||
|
|
||||||
|
$kollisionen = $gruppen.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 } | Sort-Object Name
|
||||||
|
|
||||||
|
$verloren = ($kollisionen | ForEach-Object { $_.Value.Count - 1 } | Measure-Object -Sum).Sum
|
||||||
|
if (-not $verloren) { $verloren = 0 }
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Quelldateien : $($dateien.Count)"
|
||||||
|
Write-Host "Verschiedene Ziele: $($gruppen.Count)"
|
||||||
|
Write-Host "Kollisionsgruppen : $($kollisionen.Count)"
|
||||||
|
Write-Host "Ohne eigenen Platz: $verloren" -ForegroundColor $(if ($verloren) { 'Yellow' } else { 'Green' })
|
||||||
|
Write-Host ''
|
||||||
|
|
||||||
|
if ($kollisionen.Count -eq 0) {
|
||||||
|
Write-Host 'Nichts faellt zusammen.' -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Zeigen
|
||||||
|
#
|
||||||
|
# GLEICHER INHALT ODER NICHT -- das ist die Frage, an der die Entscheidung
|
||||||
|
# haengt. Deshalb steht die Groesse dabei: gleiche Groesse heisst mit hoher
|
||||||
|
# Wahrscheinlichkeit dieselbe Datei unter anderer Schreibweise, und dann fehlt
|
||||||
|
# nichts.
|
||||||
|
$groessen = @{}
|
||||||
|
foreach ($m in [regex]::Matches($roh, '(?s)<d:response>.*?</d:response>')) {
|
||||||
|
$t = $m.Value
|
||||||
|
if ($t -match '<d:href>([^<]+)</d:href>' ) {
|
||||||
|
$href = [uri]::UnescapeDataString($matches[1])
|
||||||
|
if ($t -match '<d:getcontentlength>(\d+)</d:getcontentlength>') {
|
||||||
|
$groessen[($href -replace [regex]::Escape($praefix), '')] = [int]$matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zeilen = foreach ($k in $kollisionen) {
|
||||||
|
$gleich = ($k.Value | ForEach-Object { $groessen[$_] } | Select-Object -Unique).Count -eq 1
|
||||||
|
foreach ($q in $k.Value) {
|
||||||
|
[pscustomobject]@{
|
||||||
|
Ziel = $k.Name
|
||||||
|
Quelle = $q
|
||||||
|
Bytes = $groessen[$q]
|
||||||
|
GleicheGroesse = $gleich
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zeilen | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
|
||||||
|
|
||||||
|
$gleicheGruppen = ($kollisionen | Where-Object {
|
||||||
|
($_.Value | ForEach-Object { $groessen[$_] } | Select-Object -Unique).Count -eq 1
|
||||||
|
}).Count
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Davon Gruppen mit DURCHWEG GLEICHER Groesse: $gleicheGruppen von $($kollisionen.Count)"
|
||||||
|
Write-Host 'Gleiche Groesse heisst fast immer: dieselbe Datei, andere Schreibweise. Dann fehlt nichts.'
|
||||||
|
|
||||||
|
if ($Csv) {
|
||||||
|
$zeilen | Export-Csv -Path $Csv -NoTypeInformation -Encoding utf8 -Delimiter ';'
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Geschrieben nach $Csv" -ForegroundColor Green
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user