diff --git a/server/src/db.ts b/server/src/db.ts index e82bdd9..b0b1b19 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -120,6 +120,15 @@ CREATE TABLE IF NOT EXISTS settings ( // und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe // avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des // Schemas eine Baustelle in jeder Zeile. + const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[] + if (!eSpalten.some((s) => s.name === 'von')) { + // Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese + // Spalte stuende im Verlauf nur das Ziel, und die Frage "wo war die + // vorher" waere nicht mehr zu beantworten. + db.exec('ALTER TABLE events ADD COLUMN von TEXT') + console.log('[db] Spalte events.von angelegt') + } + if (!spalten.some((s) => s.name === 'discord_avatar')) { db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT') db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT') @@ -210,7 +219,7 @@ export type Media = { updated_at: number } -export type EventKind = 'upload' | 'replace' | 'delete' +export type EventKind = 'upload' | 'replace' | 'delete' | 'move' export type MediaEvent = { id: number @@ -220,13 +229,15 @@ export type MediaEvent = { size: number | null actor_kind: 'token' | 'user' | 'unbekannt' actor: string + /** Nur bei 'move': der Pfad, unter dem die Datei vorher lag. */ + von: string | null } export const now = () => Date.now() const eventEinfuegen = db.prepare( - `INSERT INTO events (at, kind, path, size, actor_kind, actor) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO events (at, kind, path, size, actor_kind, actor, von) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ) /** Einen Eintrag in den Verlauf schreiben. @@ -240,9 +251,10 @@ export function logEvent( size: number | null, actorKind: MediaEvent['actor_kind'], actor: string, + von?: string, ): void { try { - eventEinfuegen.run(now(), kind, path, size, actorKind, actor) + eventEinfuegen.run(now(), kind, path, size, actorKind, actor, von ?? null) } catch (err) { console.error('[events] konnte nicht schreiben:', err) } diff --git a/server/src/index.ts b/server/src/index.ts index 1b45b80..4f9a275 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -37,6 +37,28 @@ const app = new Hono() if (config.dev) app.use('*', logger()) +// Ein unerwarteter Fehler bekommt einen TEXT, keinen nackten 500. +// +// Genau das ist der Fehler, wegen dem dieses Projekt existiert: bei Fivemanage +// endete jeder Fehlschlag als "Internal Server Error" ohne ein Wort dazu. Wir +// haben das an den bekannten Stellen einzeln abgefangen -- und prompt hat ein +// Tippfehler in einer SQL-Zeile beim Export genau diese nackte Antwort +// erzeugt. Eine Auffanglinie an EINER Stelle schliesst die Luecke fuer alles, +// woran heute niemand denkt. +// +// Die Meldung geht mit hinaus. Das ist eine bewusste Abwaegung: dieses +// Dashboard hat einen Benutzerkreis von einer Person, und ein Fehler, den man +// nicht lesen kann, kostet mehr als er verbirgt. Die Spur bleibt zusaetzlich +// in der Konsole. +app.onError((err, c) => { + console.error('[fehler]', c.req.method, c.req.path, err) + const text = err instanceof Error ? err.message : String(err) + if (c.req.path.startsWith('/api/')) { + return c.json({ error: text }, 500) + } + return c.text(`Serverfehler: ${text}`, 500) +}) + // -------------------------------------------------------- Dateien ausliefern /** Liefert eine Datei aus, mit ETag und Bereichsanfragen. diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 61d4366..e1493ad 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -31,7 +31,7 @@ import { userForSession, verifyPassword, } from '../auth.js' -import { readdir, readFile, rm, stat } from 'node:fs/promises' +import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' import { join } from 'node:path' import { DISCORD_KEYS, @@ -40,7 +40,10 @@ import { discordConfig, getSetting, setSetting, + setzeVorlagen, sicherungConfig, + vorlagen, + type Vorlage, } from '../settings.js' import { discordRoutes, redirectUri } from './discord.js' import { @@ -49,6 +52,7 @@ import { deleteFile, kannVorschau, mimeFor, + moveFile, publicUrlFor, safeFilename, sha256, @@ -414,7 +418,7 @@ dashRoutes.get('/arten', (c) => { return c.json(out) }) -dashRoutes.get('/folders', (c) => { +dashRoutes.get('/folders', async (c) => { const rows = db.prepare('SELECT path, size FROM media').all() as { path: string size: number @@ -434,6 +438,25 @@ dashRoutes.get('/folders', (c) => { } } + // Leere Ordner gibt es nur auf der Platte -- sie stehen in keiner Tabelle, + // weil dort nur Dateien stehen. Wer einen anlegt, um etwas hineinzulegen, + // soll ihn trotzdem sehen. + async function verzeichnisse(rel: string, tiefe: number) { + let eintraege + try { + eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true }) + } catch { + return + } + for (const e of eintraege) { + if (!e.isDirectory()) continue + const p = rel ? `${rel}/${e.name}` : e.name + if (!ordner.has(p)) ordner.set(p, { files: 0, bytes: 0, tiefe }) + await verzeichnisse(p, tiefe + 1) + } + } + await verzeichnisse('', 0) + const items = [...ordner.entries()] .map(([folder, wert]) => ({ folder, ...wert })) // Alphabetisch und nicht nach Groesse: eine Baumansicht, deren @@ -781,6 +804,7 @@ dashRoutes.get('/settings', (c) => { !ausDatenbank(DISCORD_KEYS.roleIdAlt), }, }, + vorlagen: vorlagen(), sicherung: (() => { const sc = sicherungConfig() let letzte: unknown = null @@ -805,6 +829,7 @@ dashRoutes.post('/settings', async (c) => { guildId?: string roleIds?: string | string[] } + vorlagen?: { name?: string; muster?: string }[] sicherung?: { basis?: string benutzer?: string @@ -815,6 +840,50 @@ dashRoutes.post('/settings', async (c) => { }>(c) if (!body) return c.json(KEIN_JSON, 400) + // -- Adressvorlagen ----------------------------------------------------- + if (body.vorlagen) { + if (!Array.isArray(body.vorlagen)) { + return c.json({ error: 'Vorlagen muessen eine Liste sein' }, 400) + } + if (body.vorlagen.length > 20) { + return c.json({ error: 'Hoechstens 20 Vorlagen' }, 400) + } + + const sauber: Vorlage[] = [] + for (const v of body.vorlagen) { + const name = String(v?.name ?? '').trim().slice(0, 40) + const muster = String(v?.muster ?? '').trim().replace(/^\/+/, '') + if (!name || !muster) continue + + // Das Muster muss einen Platzhalter haben -- sonst ist es keine + // Vorlage, sondern eine einzelne Adresse. + if (!/\{[a-z]+\}/.test(muster)) { + return c.json( + { error: `"${name}": im Muster fehlt ein Platzhalter, z. B. {model}` }, + 400, + ) + } + // Und es muss ein gueltiger Pfad WERDEN koennen. Geprueft wird mit + // eingesetztem Platzhalter, sonst scheitert checkPath an der + // geschweiften Klammer. + try { + checkPath(muster.replace(/\{[a-z]+\}/g, 'x')) + } catch (err) { + return c.json( + { + error: `"${name}": ${err instanceof PathError ? err.message : 'ungueltiges Muster'}`, + }, + 400, + ) + } + sauber.push({ name, muster }) + } + setzeVorlagen(sauber) + if (!body.discord && !body.sicherung) { + return c.json({ ok: true, vorlagen: sauber }) + } + } + // -- Sicherung ---------------------------------------------------------- if (body.sicherung) { const sic = body.sicherung @@ -1009,6 +1078,218 @@ dashRoutes.get('/uebersicht', (c) => { ordner, tokens, discord: discordConfig().bereit, + vorlagen: vorlagen(), + }) +}) + +/** Dateien in einen anderen Ordner legen. + * + * ACHTUNG, UND DIE OBERFLAECHE SAGT ES AUCH: der Pfad IST die oeffentliche + * Adresse. Wer ein Bild verschiebt, macht jede Stelle ungueltig, die die alte + * URL benutzt -- im Handy, in einer Vorlage, in einem Zwischenspeicher. Das + * ist kein Fehler dieser Funktion, sondern der Preis vorhersagbarer Adressen. + * + * Jede Datei bekommt ihr eigenes Ergebnis, und eine Kollision bricht die + * anderen nicht ab. */ +dashRoutes.post('/media/move', async (c) => { + const body = await jsonBody<{ ids?: number[]; ziel?: string }>(c) + if (!body) return c.json(KEIN_JSON, 400) + if (!Array.isArray(body.ids) || body.ids.length === 0) { + return c.json({ error: 'keine Auswahl' }, 400) + } + + let ziel = '' + if (body.ziel && body.ziel.trim()) { + try { + ziel = checkPath(body.ziel.trim().replace(/^\/+|\/+$/g, '')) + } catch (err) { + if (err instanceof PathError) return c.json({ error: `Ziel: ${err.message}` }, 400) + throw err + } + } + + const wer = c.get('user').username + const ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[] = [] + + for (const id of body.ids) { + const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined + if (!row) { + ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' }) + continue + } + + const name = row.path.split('/').pop() as string + const neu = ziel ? `${ziel}/${name}` : name + + if (neu === row.path) { + ergebnisse.push({ path: row.path, ok: false, error: 'liegt schon dort' }) + continue + } + + const belegt = db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu) + if (belegt) { + // Bewusst KEIN stilles Ueberschreiben: hier wuerde eine fremde + // Datei verschwinden, und der Verlauf zeigte nur einen Umzug. + ergebnisse.push({ path: row.path, ok: false, error: `${neu} ist belegt` }) + continue + } + + try { + 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', wer, row.path) + ergebnisse.push({ path: row.path, ok: true, neu }) + } catch (err) { + ergebnisse.push({ + path: row.path, + ok: false, + error: err instanceof Error ? err.message : 'unbekannt', + }) + } + } + + const gut = ergebnisse.filter((e) => e.ok).length + return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse }) +}) + +/** Einen leeren Ordner anlegen. + * + * Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor + * dem letzten Schraegstrich, mehr nicht. Ein leerer Ordner ist deshalb ein + * echtes Verzeichnis auf der Platte und in keiner Tabelle -- /folders liest + * beides und fuehrt es zusammen. */ +dashRoutes.post('/folders', async (c) => { + const body = await jsonBody<{ pfad?: string }>(c) + if (!body?.pfad?.trim()) return c.json({ error: 'kein Pfad' }, 400) + + let pfad: string + try { + pfad = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, '')) + } catch (err) { + if (err instanceof PathError) return c.json({ error: err.message }, 400) + throw err + } + + await mkdir(join(config.filesDir, pfad), { recursive: true }) + return c.json({ ok: true, pfad }) +}) + +/** Einen Ordner entfernen -- nur wenn er leer ist. + * + * Ein Ordner mit Inhalt wird NICHT mitgeloescht: das waere ein Knopf, der + * hunderte Dateien wegraeumt, ohne sie zu zeigen. Wer sie loeschen will, + * waehlt sie in der Galerie aus und sieht dabei, was verschwindet. */ +dashRoutes.delete('/folders/:pfad{.+}', async (c) => { + let pfad: string + try { + pfad = checkPath(c.req.param('pfad')) + } catch (err) { + if (err instanceof PathError) return c.json({ error: err.message }, 400) + throw err + } + + const drin = db + .prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`) + .get(`${pfad.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`) as { n: number } + if (drin.n > 0) { + return c.json({ error: `Der Ordner enthaelt noch ${drin.n} Dateien.` }, 409) + } + + try { + // rmdir und nicht rm: es scheitert VON SELBST, wenn der Ordner nicht + // leer ist -- und genau das ist hier erwuenscht. `rm` ohne recursive + // wirft dagegen EISDIR und sagt damit etwas ganz anderes, als der + // Fall ist (einmal darauf hereingefallen). + await rmdir(join(config.filesDir, pfad)) + } catch (err) { + return c.json( + { error: err instanceof Error ? err.message : 'liess sich nicht entfernen' }, + 400, + ) + } + // Die Vorschauen desselben Ordners hinterher, falls einer da ist. + await rm(join(config.dataDir, 'thumbs', pfad), { recursive: true, force: true }).catch( + () => {}, + ) + return c.json({ ok: true }) +}) + +/** Die Medienliste zum Mitnehmen. + * + * Wofuer: ein Skript, das die Adressen kennen muss, ohne die API zu fragen -- + * eine Vorlage im Handy, eine Liste zum Abgleichen, ein Datensatz fuer eine + * andere Resource. Drei Formen, weil drei verschiedene Werkzeuge sie lesen. + * + * Die Lua-Form ist KEINE Spielerei: eine Datei, die man in eine Resource legt + * und mit `local urls = ...` einliest, spart genau den Zwischenschritt, an dem + * sonst jemand von Hand abtippt. + * + * Ohne Deckel: wer 900 Zeilen exportiert, will 900 Zeilen. Bei 228 Eintraegen + * sind das 40 KB. */ +dashRoutes.get('/export', (c) => { + const form = c.req.query('format') ?? 'json' + const ordner = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' + + const where = ordner ? `WHERE path LIKE ? ESCAPE '\\'` : '' + const params = ordner ? [`${ordner.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`] : [] + + const rows = ( + db.prepare(`SELECT * FROM media ${where} ORDER BY path COLLATE NOCASE`).all(...params) as Media[] + ).map((row) => ({ ...row, url: publicUrlFor(row.path) })) + + const stempel = new Date().toISOString().slice(0, 10) + + if (form === 'csv') { + // Semikolon: Excel in deutscher Einstellung nimmt sonst alles in eine + // Spalte, und dann ist die Datei fuer den haeufigsten Leser wertlos. + const kopf = 'pfad;url;groesse;typ;sha256;geaendert' + const zeilen = rows.map((r) => + [r.path, r.url, r.size, r.mime, r.sha256, new Date(r.updated_at).toISOString()] + .map((f) => `"${String(f).replace(/"/g, '""')}"`) + .join(';'), + ) + // CRLF, weil Excel es so erwartet — mit blossem \n steht die halbe + // Datei in einer Zelle. + return c.body([kopf, ...zeilen].join('\r\n'), 200, { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.csv"`, + }) + } + + if (form === 'lua') { + // Der Dateiname ohne Endung als Schluessel: genau danach sucht ein + // Skript ("adder"), nicht nach "vehicles/adder.webp". + const eintraege = rows.map((r) => { + const name = (r.path.split('/').pop() ?? r.path).replace(/\.[^.]+$/, '') + // "\\'" und nicht "\'": in JavaScript ist Letzteres schlicht ein + // Apostroph, der Backslash faellt weg — und ein Modellname mit + // Apostroph zerlegte damit die erzeugte Lua-Datei. + return ` ['${name.replace(/'/g, "\\'")}'] = '${r.url}',` + }) + const text = [ + '-- Erzeugt von d4rk_media am ' + new Date().toISOString(), + '-- ' + rows.length + ' Eintraege' + (ordner ? ` aus ${ordner}/` : ''), + '--', + '-- Verwendung: local urls = require(...) bzw. in einer Resource', + '-- als shared_script einbinden und Urls. lesen.', + 'Urls = {', + ...eintraege, + '}', + '', + ].join('\n') + return c.body(text, 200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.lua"`, + }) + } + + return c.body(JSON.stringify({ erzeugt: now(), anzahl: rows.length, items: rows }, null, 2), 200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.json"`, }) }) diff --git a/server/src/settings.ts b/server/src/settings.ts index 3b2a9f9..9f66b2d 100644 --- a/server/src/settings.ts +++ b/server/src/settings.ts @@ -44,6 +44,44 @@ export function ausDatenbank(key: string): boolean { return Boolean(zeile && zeile.value !== '') } +// ------------------------------------------------------------ Adressvorlagen +// +// Eine Vorlage ist der Bauplan einer oeffentlichen Adresse: +// +// vehicles/{model}.webp -> https://…/f/vehicles/adder.webp +// +// Wozu speichern? Weil genau diese Zeichenkette an der anderen Seite gebraucht +// wird -- im Handy, im Skript, im Fotostudio -- und weil sie sich jemand sonst +// aus Ordnername plus Endung zusammenreimt. Einmal falsch zusammengereimt +// heisst: das Bild fehlt, und gesucht wird an der falschen Stelle. +// +// Als JSON in EINER Zeile der Einstellungstabelle. Eine eigene Tabelle fuer +// eine Handvoll Zeilen waere eine Wanderung mehr, die niemand braucht. + +export const VORLAGEN_KEY = 'vorlagen' + +export type Vorlage = { name: string; muster: string } + +export function vorlagen(): Vorlage[] { + try { + const roh = getSetting(VORLAGEN_KEY) + if (!roh) return [] + const daten = JSON.parse(roh) + if (!Array.isArray(daten)) return [] + return daten.filter( + (v): v is Vorlage => + v && typeof v.name === 'string' && typeof v.muster === 'string', + ) + } catch { + // Ein kaputter Eintrag darf die Einstellungsseite nicht mitreissen. + return [] + } +} + +export function setzeVorlagen(liste: Vorlage[]): void { + setSetting(VORLAGEN_KEY, JSON.stringify(liste)) +} + // ----------------------------------------------------------------- Sicherung // // Die Werte fuer die Sicherung stehen hier, obwohl das Skript sie braucht und diff --git a/server/src/storage.ts b/server/src/storage.ts index c31fab2..0715cd0 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -221,6 +221,30 @@ export async function writeThumb(path: string, data: Buffer): Promise { } } +/** Eine Datei samt Vorschau an einen anderen Pfad legen. + * + * DER PFAD IST DER SCHLUESSEL — deshalb ist Verschieben hier kein Kopieren + * mit anschliessendem Loeschen, sondern ein `rename`: das ist auf derselben + * Platte unteilbar. Ein abgebrochenes Kopieren hinterliesse die Datei zweimal + * oder gar nicht. + * + * Die Vorschau zieht mit. Bliebe sie liegen, zeigte die Galerie am neuen Ort + * kein Bild und am alten Ort das eines Bildes, das dort nicht mehr liegt. */ +export async function moveFile(von: string, nach: string): Promise { + const ziel = absolutePath(nach) + await mkdir(dirname(ziel), { recursive: true }) + await rename(absolutePath(von), ziel) + + try { + const thumbZiel = thumbPath(nach) + await mkdir(dirname(thumbZiel), { recursive: true }) + await rename(thumbPath(von), thumbZiel) + } catch { + // Keine Vorschau vorhanden — kein Fehler. Sie laesst sich jederzeit + // neu rechnen. + } +} + export async function deleteFile(path: string): Promise { await rm(absolutePath(path), { force: true }) // Die Vorschau geht mit. Sonst bleibt sie liegen, und beim naechsten Bild diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index 7fee275..5005208 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { + Code2, Gauge, HardDrive, History, @@ -19,6 +20,7 @@ import { Tokens } from './pages/Tokens' import { Konto } from './pages/Konto' import { Einstellungen } from './pages/Einstellungen' import { Verlauf } from './pages/Verlauf' +import { Api } from './pages/Api' import { Meldungen } from './components/Meldungen' import { gehZu, usePfad } from './router' import { Avatar } from './components/Avatar' @@ -38,6 +40,7 @@ const seiten = [ { pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf }, { pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher }, { pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens }, + { pfad: '/api', name: 'API', kind: Code2, Inhalt: Api }, { pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto }, { pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen }, ] as const diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 923e373..ce32df4 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -171,6 +171,15 @@ export type Uebersicht = { ordner: { folder: string; files: number; bytes: number }[] tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[] discord: boolean + vorlagen: Vorlage[] +} + +export type Vorlage = { name: string; muster: string } + +export type VerschiebeErgebnis = { + verschoben: number + fehlgeschlagen: number + ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[] } export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere' @@ -197,6 +206,7 @@ export type LetzteSicherung = { } | null export type Einstellungen = { + vorlagen: Vorlage[] sicherung: { basis: string benutzer: string @@ -224,7 +234,7 @@ export type Einstellungen = { } } -export type EventKind = 'upload' | 'replace' | 'delete' +export type EventKind = 'upload' | 'replace' | 'delete' | 'move' export type MediaEvent = { id: number @@ -234,6 +244,8 @@ export type MediaEvent = { size: number | null actor_kind: 'token' | 'user' | 'unbekannt' actor: string + /** Nur bei 'move': wo die Datei vorher lag. */ + von: string | null } export type EventsSeite = { @@ -313,6 +325,11 @@ export const api = { `/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`, ), folders: () => get('/api/dash/folders'), + verschieben: (ids: number[], ziel: string) => + post('/api/dash/media/move', { ids, ziel }), + ordnerAnlegen: (pfad: string) => post<{ ok: true; pfad: string }>('/api/dash/folders', { pfad }), + ordnerEntfernen: (pfad: string) => + del<{ ok: true }>('/api/dash/folders/' + pfad.split('/').map(encodeURIComponent).join('/')), deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`), deleteMediaBulk: (ids: number[]) => post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }), @@ -333,6 +350,8 @@ export const api = { einstellungen: () => get('/api/dash/settings'), sicherungSpeichern: (sicherung: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }), + vorlagenSpeichern: (liste: Vorlage[]) => + post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }), einstellungenSpeichern: (discord: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }), diff --git a/server/ui/src/components/Verschieben.tsx b/server/ui/src/components/Verschieben.tsx new file mode 100644 index 0000000..cec7534 --- /dev/null +++ b/server/ui/src/components/Verschieben.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react' +import { AlertTriangle, FolderInput, Plus } from 'lucide-react' +import { api, type Folders, type MediaItem } from '../api' +import { run } from '../store' +import { Feld, Knopf } from './Bausteine' + +/** Dateien in einen anderen Ordner legen. + * + * DIE WARNUNG STEHT OBEN UND NICHT IM KLEINGEDRUCKTEN. Der Pfad IST die + * öffentliche Adresse: wer ein Bild verschiebt, macht jede Stelle ungültig, + * die die alte URL benutzt — im Handy, in einer Vorlage, in einem + * Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer + * Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */ +export function Verschieben({ + dateien, + ordner, + onFertig, + onAbbruch, +}: { + dateien: MediaItem[] + ordner: Folders | null + onFertig: () => void + onAbbruch: () => void +}) { + const [ziel, setZiel] = useState('') + const [neuerOrdner, setNeuerOrdner] = useState('') + const [laeuft, setLaeuft] = useState(false) + + const beispiel = dateien[0] + const name = beispiel?.path.split('/').pop() ?? '' + const neuerPfad = ziel ? `${ziel}/${name}` : name + + async function verschieben() { + setLaeuft(true) + const r = await run( + () => api.verschieben(dateien.map((d) => d.id), ziel), + (x) => + x.fehlgeschlagen === 0 + ? `${x.verschoben} verschoben.` + : `${x.verschoben} verschoben, ${x.fehlgeschlagen} nicht: ` + + x.ergebnisse + .filter((e) => !e.ok) + .slice(0, 3) + .map((e) => `${e.path} (${e.error})`) + .join('; '), + ) + setLaeuft(false) + if (r) onFertig() + } + + async function ordnerAnlegen() { + const pfad = neuerOrdner.trim() + if (!pfad) return + const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`) + if (r) { + setZiel(r.pfad) + setNeuerOrdner('') + } + } + + return ( +
+
e.stopPropagation()} + > +

+ + {dateien.length === 1 ? 'Datei verschieben' : `${dateien.length} Dateien verschieben`} +

+ +
+

+ + + Der Pfad ist die öffentliche Adresse. Nach dem + Verschieben führt die alte URL ins Leere — überall, wo sie steht. + +

+ + + +
+ setNeuerOrdner(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void ordnerAnlegen() + } + }} + /> + void ordnerAnlegen()} + kind={} + /> +
+ + {/* Zeigen, WAS daraus wird — nicht nur, wohin es geht. */} + {beispiel && ( +
+
{beispiel.path}
+
{neuerPfad}
+ {dateien.length > 1 && ( +
+ … und {dateien.length - 1} weitere +
+ )} +
+ )} +
+ +
+ + void verschieben()} + kind="Verschieben" + /> +
+
+
+ ) +} diff --git a/server/ui/src/components/VorlagenKarte.tsx b/server/ui/src/components/VorlagenKarte.tsx new file mode 100644 index 0000000..b940f77 --- /dev/null +++ b/server/ui/src/components/VorlagenKarte.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react' +import { Link2, Plus, Trash2 } from 'lucide-react' +import { api, type Vorlage } from '../api' +import { run, useStore } from '../store' +import { Feld, Karte, Knopf } from './Bausteine' + +/** Adressvorlagen verwalten. + * + * Eine Vorlage ist der Bauplan einer öffentlichen Adresse: + * `vehicles/{model}.webp` wird zu `https://…/f/vehicles/adder.webp`. + * + * Wozu speichern? Weil genau diese Zeichenkette auf der anderen Seite + * gebraucht wird — im Handy, im Skript, im Fotostudio — und weil sie sich + * sonst jemand aus Ordnername plus Endung zusammenreimt. Einmal falsch + * zusammengereimt heißt: das Bild fehlt, und gesucht wird an der falschen + * Stelle. */ +export function VorlagenKarte({ publicUrl, filePrefix }: { publicUrl: string; filePrefix: string }) { + const note = useStore((s) => s.note) + const [liste, setListe] = useState(null) + const [speichert, setSpeichert] = useState(false) + const [name, setName] = useState('') + const [muster, setMuster] = useState('') + + useEffect(() => { + api.einstellungen() + .then((e) => setListe(e.vorlagen)) + .catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt')) + }, [note]) + + async function sichern(neu: Vorlage[]) { + setSpeichert(true) + const r = await run(() => api.vorlagenSpeichern(neu), 'Vorlagen gespeichert.') + setSpeichert(false) + if (r) setListe(r.vorlagen) + return Boolean(r) + } + + if (!liste) return null + + return ( + +
+ {liste.length === 0 ? ( +

+ Noch keine. Eine Vorlage sagt, wie die öffentliche Adresse aussieht — + etwa vehicles/{model}.webp. +

+ ) : ( +
    + {liste.map((v, i) => ( +
  • + + {v.name} + + {publicUrl} + {filePrefix}/{v.muster} + + +
  • + ))} +
+ )} + +
+ setName(e.target.value)} + /> + setMuster(e.target.value)} + /> + { + if ( + await sichern([ + ...liste, + { name: name.trim(), muster: muster.trim() }, + ]) + ) { + setName('') + setMuster('') + } + }} + kind={ + <> + + Hinzufügen + + } + /> +
+
+
+ ) +} diff --git a/server/ui/src/pages/Anmelden.tsx b/server/ui/src/pages/Anmelden.tsx index 116ba47..90e120a 100644 --- a/server/ui/src/pages/Anmelden.tsx +++ b/server/ui/src/pages/Anmelden.tsx @@ -52,15 +52,34 @@ export function Anmelden() { } return ( -
+
+ {/* Zwei weiche Lichter im Hintergrund. Sie kosten nichts, brauchen + kein Bild und machen aus dem leeren Dunkel eine Seite. */} +
+
+
-
- +
+ + +

d4rk_media

+

+ Bilder, Videos und Ton für die eigenen Ressourcen. +

+

+ Nach fünf Fehlversuchen wird kurz gesperrt. +

+ {discord && ( <>
diff --git a/server/ui/src/pages/Api.tsx b/server/ui/src/pages/Api.tsx new file mode 100644 index 0000000..be315e2 --- /dev/null +++ b/server/ui/src/pages/Api.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from 'react' +import { Check, Copy, Download, FileJson, FileSpreadsheet, FileCode2 } from 'lucide-react' +import { api, type Uebersicht } from '../api' +import { useStore } from '../store' +import { Karte, useKopieren } from '../components/Bausteine' + +/** Anleitung und Export für die eigenen Skripte. + * + * Der Punkt dieser Seite: die Beispiele stehen hier MIT DER ECHTEN ADRESSE + * drin, nicht mit `https://example.com`. Wer etwas abschreibt, schreibt damit + * auch keinen Tippfehler ab — und das ist die häufigste Fehlerquelle beim + * Anbinden eines Dienstes. + * + * Der Token steht bewusst NICHT hier. Er ist nach dem Anlegen nicht mehr + * lesbar, auch nicht für den Dienst; im Beispiel steht deshalb ein + * Platzhalter. */ +export function Api() { + const note = useStore((s) => s.note) + const [daten, setDaten] = useState(null) + const { kopiert, kopieren } = useKopieren() + + useEffect(() => { + api.uebersicht() + .then(setDaten) + .catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt')) + }, [note]) + + if (!daten) return null + + const B = daten.publicUrl + const F = `${daten.publicUrl}${daten.filePrefix}` + + async function kopiereBlock(text: string, marke: string) { + if (!(await kopieren(text, marke))) note('fehler', 'Die Zwischenablage ist gesperrt.') + } + + const block = (marke: string, text: string) => ( +
+
+                {text}
+            
+ +
+ ) + + return ( +
+ +
+ {( + [ + ['json', 'JSON', FileJson, 'Alles, was der Dienst weiß'], + ['csv', 'CSV', FileSpreadsheet, 'für Tabellen (Semikolon, für Excel)'], + ['lua', 'Lua', FileCode2, 'fertige Tabelle Name → Adresse'], + ] as const + ).map(([f, text, Symbol, hinweis]) => ( + + + {text} + + + ))} +

+ {daten.files} Einträge. Die Lua-Fassung liest man in einer Resource als{' '} + Urls.adder aus. +

+
+
+ + +

+ Server-Seite, nicht NUI: die NUI läuft unter https und dürfte eine + http-Adresse gar nicht aufrufen. Base64, weil{' '} + PerformHttpRequest den Rumpf als + Lua-Zeichenkette nimmt und ein Bild voller Nullbytes ist. +

+ {block( + 'lua-upload', + `local TOKEN = 'd4rk_…' -- im Dashboard unter Token anlegen + +local function hochladen(pfad, bytes, cb) + PerformHttpRequest('${B}/api/upload', function(status, body) + if status ~= 200 then return cb(nil, body) end + cb(json.decode(body).url) + end, 'POST', b64(bytes), { + ['Authorization'] = 'Bearer ' .. TOKEN, + ['X-Path'] = pfad, -- z. B. vehicles/adder.webp + ['X-Encoding'] = 'base64', + ['Content-Type'] = 'text/plain; charset=utf-8', + }) +end`, + )} +
+ + +
+ {block( + 'curl-upload', + `# Hochladen — rohe Bytes, X-Path bestimmt die spätere Adresse +curl -X POST ${B}/api/upload \\ + -H "Authorization: Bearer d4rk_…" \\ + -H "X-Path: vehicles/adder.webp" \\ + --data-binary @adder.webp + +# → {"url":"${F}/vehicles/adder.webp","size":…,"sha256":…,"replaced":false}`, + )} + {block( + 'curl-exists', + `# Gibt es das schon? — für "nur fehlende" in einem Serienlauf +curl -H "Authorization: Bearer d4rk_…" \\ + ${B}/api/exists/vehicles/adder.webp + +# → {"exists":true,"url":"${F}/vehicles/adder.webp","size":…}`, + )} + {block( + 'curl-delete', + `# Löschen — der Token braucht dafür das Recht "darf löschen" +curl -X DELETE -H "Authorization: Bearer d4rk_…" \\ + ${B}/api/media/vehicles/adder.webp`, + )} +
+
+ + + + + + + + + {( + [ + ['X-Path', 'Wohin die Datei kommt. Ohne ihn wird aus dem Hash ein Pfad gebaut — dann ist die Adresse nicht mehr vorhersagbar.'], + ['X-Encoding', '„base64", wenn der Rumpf base64-Text ist. Data-URL-Vorspann wird abgeschnitten.'], + ['X-Overwrite', '„false" verweigert das Überschreiben und antwortet mit 409.'], + ['X-Mime', 'Überschreibt die Art. Sonst entscheidet die Endung.'], + ] as const + ).map(([k, t]) => ( + + + + + ))} + +
{k}{t}
+
+ + +
+ {daten.vorlagen.length === 0 && ( +

+ Noch keine Vorlage angelegt — das geht unter Einstellungen. +

+ )} + {daten.vorlagen.map((v, i) => ( +
+ + {v.name} + + + {F}/{v.muster} + + +
+ ))} +
+
+
+ ) +} diff --git a/server/ui/src/pages/Einstellungen.tsx b/server/ui/src/pages/Einstellungen.tsx index 9dca259..d4406f2 100644 --- a/server/ui/src/pages/Einstellungen.tsx +++ b/server/ui/src/pages/Einstellungen.tsx @@ -5,6 +5,7 @@ import { run, useStore } from '../store' import { bytes, zeit } from '../format' import { Feld, Karte, Knopf } from '../components/Bausteine' import { DiscordKarte } from '../components/DiscordKarte' +import { VorlagenKarte } from '../components/VorlagenKarte' /** Alles, was sich einstellen lässt, an einer Stelle. * @@ -13,8 +14,19 @@ import { DiscordKarte } from '../components/DiscordKarte' * als Aufräumen: eine Textdatei sagt niemandem, ob die Sicherung heute Nacht * gelaufen ist. Diese Seite tut es. */ export function Einstellungen() { + const [urls, setUrls] = useState({ publicUrl: '', filePrefix: '' }) + + // Die Vorlagenkarte zeigt die FERTIGE Adresse und nicht nur das Muster — + // dafuer braucht sie den Ursprung, und der steht in der Uebersicht. + useEffect(() => { + api.uebersicht() + .then((u) => setUrls({ publicUrl: u.publicUrl, filePrefix: u.filePrefix })) + .catch(() => {}) + }, []) + return (
+
diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx index 11a02ca..4374071 100644 --- a/server/ui/src/pages/Galerie.tsx +++ b/server/ui/src/pages/Galerie.tsx @@ -9,6 +9,7 @@ import { ArrowDownUp, FileText, Film, + FolderInput, Layers, Music, ImageOff, @@ -35,6 +36,7 @@ import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { Ordner } from '../components/Ordner' import { Hochladen } from '../components/Hochladen' import { Ansehen } from '../components/Ansehen' +import { Verschieben } from '../components/Verschieben' import { useAuffrischen } from '../auffrischen' const PRO_SEITE = 60 @@ -57,6 +59,7 @@ export function Galerie() { // Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt: // nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz. const [ansehen, setAnsehen] = useState(null) + const [verschieben, setVerschieben] = useState(null) const { kopiert, kopieren } = useKopieren() // Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck @@ -384,6 +387,18 @@ export function Galerie() { /> )} + {gewaehlt.size > 0 && ( + setVerschieben(items.filter((i) => gewaehlt.has(i.id)))} + kind={ + <> + + verschieben + + } + /> + )} + {gewaehlt.size > 0 && ( setVerschieben(null)} + onFertig={() => { + setVerschieben(null) + setGewaehlt(new Set()) + void laden() + void ordnerLaden() + void artenLaden() + }} + /> + )} + - const vorlage = `${daten.publicUrl}${daten.filePrefix}/vehicles/{model}.webp` + // Die eingetragenen Vorlagen — und solange keine da ist, die eine, die + // dieses Projekt ohnehin benutzt. Eine leere Karte waere hier nur ein + // Loch. + const vorlagen = + daten.vorlagen.length > 0 + ? daten.vorlagen + : [{ name: 'Fahrzeuge', muster: 'vehicles/{model}.webp' }] return (
@@ -106,25 +114,45 @@ export function Start() {
{/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */} - -
- - {vorlage} - + { - if (!(await kopieren(vorlage, 'v'))) - note('fehler', 'Die Zwischenablage ist gesperrt.') - }} - className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift" - aria-label="Vorlage kopieren" + onClick={() => gehZu('/einstellungen')} + className="text-xs text-schrift-leise hover:text-schrift" > - {kopiert === 'v' ? ( - - ) : ( - - )} + verwalten + } + > +
+ {vorlagen.map((v, i) => { + const voll = `${daten.publicUrl}${daten.filePrefix}/${v.muster}` + return ( +
+ + {v.name} + + + {voll} + + +
+ ) + })}
diff --git a/server/ui/src/pages/Verlauf.tsx b/server/ui/src/pages/Verlauf.tsx index b945bbf..6e84bd1 100644 --- a/server/ui/src/pages/Verlauf.tsx +++ b/server/ui/src/pages/Verlauf.tsx @@ -6,6 +6,7 @@ import { Loader2, RefreshCw, Search, + FolderInput, Trash2, Upload, X, @@ -23,6 +24,7 @@ const arten: Record ) : ( - +
+ {/* Die Spaltenbreiten stehen HIER und nicht verteilt an + den Zellen: vorher hatten Kopf und Koerper + verschiedene Abstaende, und der Pfad wurde + abgeschnitten, obwohl daneben Platz frei war. */} + + + + + + + - - - - - + + + + + @@ -214,22 +227,30 @@ export function Verlauf() { const Kind = a.kind return ( - - - - -
WannWasPfadGrößeVon wemWannWasPfadGrößeVon wem
+ {zeit(e.at)} + {a.text} + + {e.von && ( + + {e.von} →{' '} + + )} {e.path} + {e.size === null ? '—' : bytes(e.size)} + {e.actor_kind !== 'unbekannt' && (