feat: Discord-Anmeldung -- im Panel einrichtbar, nicht in der .env

Auf Wunsch ueber die Oberflaeche statt ueber Umgebungsvariablen. Der Gewinn ist
nicht nur Bequemlichkeit: das Panel legt die REDIRECT-URI zum Kopieren hin, und
genau daran scheitert ein OAuth-Anlauf fast immer.

  settings.ts   Schluessel/Wert in SQLite, mit Umgebung als Rueckfall. Wer
                schon Stack-Variablen gesetzt hat, behaelt sie; das Panel
                uebersteuert. In der Umgebung bleibt, was den Dienst ueberhaupt
                erreichbar macht (PUBLIC_URL, PORT, DATA_DIR) -- wer das per
                Formular verstellen koennte, sperrt sich mit einem Klick aus.
  discord.ts    Der Weg in drei Schritten, ohne Bibliothek und OHNE BOT-TOKEN:
                die Rolle wird mit dem Merkmal des BENUTZERS geholt
                (guilds.members.read). Kein Bot, keine Bot-Rechte, kein zweites
                Geheimnis, das ablaufen kann.

DAS GEHEIMNIS GEHT NIE HERAUS -- auch nicht an eine angemeldete Sitzung. Das
Panel bekommt nur "hatGeheimnis: true". Ein Formular, das den Client-Schluessel
im Klartext zurueckschickt, streut ihn in jeden Browser-Cache. Ein leeres Feld
heisst "unveraendert", nicht "loeschen"; zum Entfernen gibt es "-".

JEDER ABBRUCH IST SICHTBAR: kein weisser Bildschirm, sondern zurueck aufs
Formular mit dem Grund im Adressfeld -- "Du bist nicht auf diesem Server",
"Dir fehlt die noetige Rolle", oder bei 4xx vom Tausch der Hinweis auf die
Redirect-URI. Die IDs werden schon beim Speichern geprueft (17-20 Ziffern),
damit ein halber Link nicht erst beim Anmelden auffaellt.

Das Passwort bleibt als Notausgang. Ein ueber Discord angelegtes Konto bekommt
KEIN brauchbares Passwort ("nur-ueber-discord"): verifyPassword scheitert an
allem, was nicht dem scrypt-Format entspricht -- nachgesehen, nicht vermutet.

Dazu der Verlauf, wie gewuenscht: Suche nach Pfad (mit derselben
LIKE-Maskierung wie in der Galerie -- Suche nach "_" findet 0 statt allem),
waehlbare Seitengroesse (25/50/100/200, Standard 50) und mehr Luft zwischen
"Groesse" und "Von wem".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:44:45 +02:00
co-authored by Claude Opus 5
parent 0b295a2302
commit dffec7714c
9 changed files with 746 additions and 13 deletions
+28
View File
@@ -91,8 +91,33 @@ CREATE TABLE IF NOT EXISTS events (
actor TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_at ON events(at DESC);
-- Einstellungen, die zur Laufzeit aenderbar sein sollen.
--
-- Bewusst NICHT alles: was den Dienst ueberhaupt hochfahren laesst (PUBLIC_URL,
-- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den
-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart
-- verstellbar sein soll -- derzeit die Discord-Anmeldung.
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`)
// Nachtraegliche Spalten. SQLite kann kein "ADD COLUMN IF NOT EXISTS", also
// wird nachgesehen — das ist die ganze Wanderung, die dieses Projekt braucht.
{
const spalten = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]
if (!spalten.some((s) => s.name === 'discord_id')) {
db.exec('ALTER TABLE users ADD COLUMN discord_id TEXT')
// Kein UNIQUE in der Spaltendefinition: SQLite erlaubt das bei ADD
// COLUMN nicht. Der Index danach leistet dasselbe.
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_discord ON users(discord_id)')
console.log('[db] Spalte users.discord_id angelegt')
}
}
// Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur
// wenn er leer ist.
//
@@ -123,6 +148,9 @@ export type User = {
username: string
password_hash: string
created_at: number
/** Gesetzt, wenn dieses Konto ueber Discord hereinkommt. Ein solches Konto
* hat kein brauchbares Passwort — verifyPassword scheitert daran sauber. */
discord_id: string | null
}
export type Token = {
+107 -1
View File
@@ -31,6 +31,8 @@ import {
verifyPassword,
} from '../auth.js'
import { readFile, stat } from 'node:fs/promises'
import { DISCORD_KEYS, ausDatenbank, discordConfig, setSetting } from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js'
import {
absolutePath,
checkPath,
@@ -212,6 +214,17 @@ dashRoutes.get('/auth/me', (c) => {
return c.json({ user: { id: user.id, username: user.username } })
})
/** Was die Anmeldeseite wissen muss, BEVOR jemand angemeldet ist.
*
* Nur ein Ja/Nein: ob der Discord-Knopf ueberhaupt sinnvoll ist. Weder
* Client-ID noch Server-ID gehen hier heraus — die braucht das Formular
* nicht, und was nicht herausgeht, kann auch nicht abgeschrieben werden. */
dashRoutes.get('/auth/config', (c) => c.json({ discord: discordConfig().bereit }))
// Der Discord-Weg selbst. MUSS vor der Wache stehen: wer sich anmelden will,
// ist noch nicht angemeldet.
dashRoutes.route('/auth', discordRoutes)
// Ab hier gilt: angemeldet oder nichts.
dashRoutes.use('*', async (c, next) => {
const user = userForSession(getCookie(c, SESSION_COOKIE))
@@ -523,10 +536,11 @@ dashRoutes.post('/upload', async (c) => {
* Nur lesen, nie schreiben — geschrieben wird an den Stellen, an denen es
* passiert. Ein Verlauf, den man von aussen fuellen kann, ist keiner. */
dashRoutes.get('/events', (c) => {
const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500)
const limit = Math.min(Number(c.req.query('limit') ?? 50) || 50, 500)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
const kind = c.req.query('kind')?.trim() ?? ''
const actor = c.req.query('actor')?.trim() ?? ''
const query = c.req.query('query')?.trim() ?? ''
const bedingungen: string[] = []
const params: unknown[] = []
@@ -538,6 +552,12 @@ dashRoutes.get('/events', (c) => {
bedingungen.push('actor = ?')
params.push(actor)
}
if (query) {
// Dieselbe Maskierung wie bei der Mediensuche: ein Pfad mit _ darin
// wuerde sonst auch auf ein beliebiges Zeichen passen.
bedingungen.push(`path LIKE ? ESCAPE '\\'`)
params.push(`%${query.replace(/[\\%_]/g, (z) => `\\${z}`)}%`)
}
const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
const total = db.prepare(`SELECT COUNT(*) AS n FROM events ${where}`).get(...params) as {
@@ -563,6 +583,92 @@ dashRoutes.get('/events', (c) => {
return c.json({ total: total.n, limit, offset, items, akteure, seit: seit.a })
})
// ------------------------------------------------------------- Einstellungen
/** Die Einstellungen zum Anzeigen.
*
* DAS GEHEIMNIS GEHT NIE HERAUS — auch nicht an eine angemeldete Sitzung.
* Zurueck kommt nur, OB eines hinterlegt ist. Ein Formular, das den
* Client-Schluessel im Klartext zurueckschickt, streut ihn in jeden
* Browser-Cache und jedes Bildschirmfoto. */
dashRoutes.get('/settings', (c) => {
const d = discordConfig()
return c.json({
discord: {
clientId: d.clientId,
guildId: d.guildId,
roleId: d.roleId,
hatGeheimnis: Boolean(d.clientSecret),
bereit: d.bereit,
// Die Stelle, an der der erste Anlauf fast immer scheitert.
redirectUri: redirectUri(),
ausDerUmgebung: {
clientId: Boolean(d.clientId) && !ausDatenbank(DISCORD_KEYS.clientId),
clientSecret:
Boolean(d.clientSecret) && !ausDatenbank(DISCORD_KEYS.clientSecret),
guildId: Boolean(d.guildId) && !ausDatenbank(DISCORD_KEYS.guildId),
roleId: Boolean(d.roleId) && !ausDatenbank(DISCORD_KEYS.roleId),
},
},
})
})
dashRoutes.post('/settings', async (c) => {
const body = await jsonBody<{
discord?: {
clientId?: string
clientSecret?: string
guildId?: string
roleId?: string
}
}>(c)
if (!body) return c.json(KEIN_JSON, 400)
const d = body.discord
if (!d) return c.json({ error: 'nichts zu speichern' }, 400)
// Die IDs von Discord sind Snowflakes: 17 bis 20 Ziffern. Wer eine
// Rollen-ID mit Leerzeichen oder einen halben Link einfuegt, soll es HIER
// erfahren und nicht beim ersten Anmeldeversuch.
const istId = (v: string) => /^\d{17,20}$/.test(v)
if (d.clientId !== undefined) {
const v = d.clientId.trim()
if (v && !istId(v)) return c.json({ error: 'Client-ID: 17 bis 20 Ziffern' }, 400)
setSetting(DISCORD_KEYS.clientId, v)
}
if (d.guildId !== undefined) {
const v = d.guildId.trim()
if (v && !istId(v)) return c.json({ error: 'Server-ID: 17 bis 20 Ziffern' }, 400)
setSetting(DISCORD_KEYS.guildId, v)
}
if (d.roleId !== undefined) {
const v = d.roleId.trim()
if (v && !istId(v)) return c.json({ error: 'Rollen-ID: 17 bis 20 Ziffern' }, 400)
setSetting(DISCORD_KEYS.roleId, v)
}
// Ein leeres Feld heisst "unveraendert lassen", nicht "loeschen" — sonst
// wuerde jedes Speichern das Geheimnis wegwerfen, weil es nie im Formular
// steht. Zum Entfernen gibt es den eigenen Wert '-'.
if (d.clientSecret !== undefined) {
const v = d.clientSecret.trim()
if (v === '-') setSetting(DISCORD_KEYS.clientSecret, '')
else if (v) setSetting(DISCORD_KEYS.clientSecret, v)
}
const jetzt = discordConfig()
return c.json({
ok: true,
bereit: jetzt.bereit,
fehlt: [
!jetzt.clientId && 'Client-ID',
!jetzt.clientSecret && 'Client-Geheimnis',
!jetzt.guildId && 'Server-ID',
!jetzt.roleId && 'Rollen-ID',
].filter(Boolean),
})
})
dashRoutes.get('/stats', (c) => {
const media = db
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
+196
View File
@@ -0,0 +1,196 @@
// Anmeldung ueber Discord.
//
// DER ABLAUF, in drei Schritten und ohne Bibliothek:
//
// 1. /api/dash/auth/discord → wir schicken den Browser zu Discord
// 2. Discord fragt den Benutzer → und schickt ihn mit einem `code` zurueck
// 3. /…/discord/callback → wir tauschen den Code gegen ein Zugangs-
// merkmal, holen Benutzer UND Rolle, und
// legen bei Erfolg eine Sitzung an
//
// WARUM OHNE BOT-TOKEN. Die Rolle wird mit dem Merkmal des BENUTZERS geholt
// (`guilds.members.read` → /users/@me/guilds/<server>/member). Damit braucht
// dieser Dienst keinen Bot, keine Bot-Berechtigung und kein zweites Geheimnis,
// das ablaufen kann.
//
// WAS SCHIEFGEHEN KANN, geht sichtbar schief: jeder Abbruch landet wieder auf
// der Anmeldeseite, mit dem Grund im Adressfeld. Ein OAuth-Weg, der bei einem
// Fehler eine weisse Seite zeigt, ist der Grund, warum niemand ihn debuggen
// mag.
import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { randomBytes } from 'node:crypto'
import { config } from '../config.js'
import { db, now, type User } from '../db.js'
import { SESSION_COOKIE, createSession } from '../auth.js'
import { discordConfig } from '../settings.js'
export const discordRoutes = new Hono()
/** Muss ZEICHEN FUER ZEICHEN mit dem uebereinstimmen, was im Discord-Portal
* eingetragen ist — daran scheitert der erste Anlauf fast immer. Deshalb
* zeigt das Panel genau diese Zeichenkette an. */
export const redirectUri = () => `${config.publicUrl}/api/dash/auth/discord/callback`
const STATE_COOKIE = 'dstate'
/** Zurueck zur Anmeldeseite, mit dem Grund im Adressfeld. */
const zurueck = (c: any, grund: string) =>
c.redirect(`/?discord=${encodeURIComponent(grund)}`, 302)
// ------------------------------------------------------------------ Schritt 1
discordRoutes.get('/discord', (c) => {
const d = discordConfig()
if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.')
// Der Zustandswert schuetzt gegen untergeschobene Rueckleitungen: nur wer
// diesen Weg begonnen hat, hat das Cookie dazu.
const state = randomBytes(24).toString('base64url')
setCookie(c, STATE_COOKIE, state, {
httpOnly: true,
secure: !config.dev,
sameSite: 'Lax',
path: '/',
maxAge: 600,
})
const url = new URL('https://discord.com/oauth2/authorize')
url.searchParams.set('client_id', d.clientId)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', redirectUri())
// identify: wer es ist. guilds.members.read: welche Rollen er auf DIESEM
// Server hat. Mehr wird nicht erfragt — was man nicht hat, kann man nicht
// verlieren.
url.searchParams.set('scope', 'identify guilds.members.read')
url.searchParams.set('state', state)
return c.redirect(url.toString(), 302)
})
// ------------------------------------------------------------------ Schritt 3
discordRoutes.get('/discord/callback', async (c) => {
const d = discordConfig()
if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.')
// Discord meldet Ablehnungen selbst — etwa wenn jemand auf "Abbrechen"
// klickt. Das ist kein Fehler, sondern eine Antwort.
const fehler = c.req.query('error_description') ?? c.req.query('error')
if (fehler) return zurueck(c, `Discord: ${fehler}`)
const state = c.req.query('state')
const erwartet = getCookie(c, STATE_COOKIE)
deleteCookie(c, STATE_COOKIE, { path: '/' })
if (!state || !erwartet || state !== erwartet) {
return zurueck(c, 'Der Anmeldeversuch passt nicht zu diesem Browser. Noch einmal.')
}
const code = c.req.query('code')
if (!code) return zurueck(c, 'Discord hat keinen Code mitgeschickt.')
try {
// -- Code gegen Zugangsmerkmal --------------------------------------
const tokenAntwort = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: d.clientId,
client_secret: d.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri(),
}),
})
if (!tokenAntwort.ok) {
const text = (await tokenAntwort.text()).slice(0, 200)
// Der haeufigste Fall steht hier zuerst, weil er sonst eine
// Viertelstunde kostet.
return zurueck(
c,
`Discord lehnt den Tausch ab (${tokenAntwort.status}). Stimmt die Redirect-URI im Portal genau? ${text}`,
)
}
const { access_token } = (await tokenAntwort.json()) as { access_token?: string }
if (!access_token) return zurueck(c, 'Discord hat kein Zugangsmerkmal geliefert.')
const mitMerkmal = { headers: { authorization: `Bearer ${access_token}` } }
// -- Wer ist das ----------------------------------------------------
const meAntwort = await fetch('https://discord.com/api/users/@me', mitMerkmal)
if (!meAntwort.ok) return zurueck(c, 'Discord gibt den Benutzer nicht heraus.')
const me = (await meAntwort.json()) as {
id: string
username: string
global_name?: string | null
}
// -- Und welche Rolle hat er hier -----------------------------------
const memberAntwort = await fetch(
`https://discord.com/api/users/@me/guilds/${d.guildId}/member`,
mitMerkmal,
)
if (memberAntwort.status === 404) {
return zurueck(c, 'Du bist nicht auf diesem Discord-Server.')
}
if (!memberAntwort.ok) {
return zurueck(
c,
`Die Mitgliedschaft liess sich nicht pruefen (${memberAntwort.status}). Stimmt die Server-ID?`,
)
}
const member = (await memberAntwort.json()) as { roles?: string[] }
if (!Array.isArray(member.roles) || !member.roles.includes(d.roleId)) {
// Bewusst ohne den Namen der Rolle: wer sie nicht hat, muss auch
// nicht erfahren, wie sie heisst.
return zurueck(c, 'Dir fehlt die noetige Rolle auf dem Server.')
}
// -- Konto finden oder anlegen --------------------------------------
let user = db.prepare('SELECT * FROM users WHERE discord_id = ?').get(me.id) as
| User
| undefined
if (!user) {
const anzeige = (me.global_name || me.username || `discord-${me.id}`).slice(0, 40)
// Der Name muss eindeutig sein; zwei Leute koennen denselben
// Anzeigenamen haben. Bei Kollision haengt die Discord-ID dran.
const belegt = db.prepare('SELECT 1 FROM users WHERE username = ?').get(anzeige)
const username = belegt ? `${anzeige} (${me.id})` : anzeige
// KEIN brauchbares Passwort: verifyPassword scheitert an einem
// String, der nicht dem scrypt-Format entspricht — dieses Konto
// kommt also ausschliesslich ueber Discord herein.
const info = db
.prepare(
`INSERT INTO users (username, password_hash, created_at, discord_id)
VALUES (?, 'nur-ueber-discord', ?, ?)`,
)
.run(username, now(), me.id)
user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as User
console.log(`[discord] Konto angelegt: ${username} (${me.id})`)
}
const session = createSession(user.id)
setCookie(c, SESSION_COOKIE, session.id, {
httpOnly: true,
secure: !config.dev,
sameSite: 'Lax',
path: '/',
expires: new Date(session.expiresAt),
})
return c.redirect('/', 302)
} catch (err) {
console.error('[discord]', err)
return zurueck(
c,
`Der Weg ueber Discord brach ab: ${err instanceof Error ? err.message : 'unbekannt'}`,
)
}
})
+80
View File
@@ -0,0 +1,80 @@
// Einstellungen, die zur Laufzeit verstellbar sind.
//
// WAS HIER LIEGT UND WAS NICHT. In der Umgebung bleibt, was den Dienst
// ueberhaupt erreichbar macht: PUBLIC_URL, PORT, DATA_DIR, ADMIN_PASSWORD. Wer
// die per Formular verstellen koennte, koennte sich mit einem Klick
// aussperren, und die Reparatur waere ein Eingriff in die Datenbank.
//
// Hier liegt der Rest — derzeit die Discord-Anmeldung. Der Gewinn ist nicht
// nur Bequemlichkeit: das Panel kann die Redirect-URI gleich zum Kopieren
// hinlegen, und die ist die Stelle, an der ein OAuth-Anlauf am haeufigsten
// scheitert.
//
// DAS GEHEIMNIS LIEGT DAMIT IN media.db, und die steckt in jeder Sicherung
// unter C:\backup. Die .env ist dort nicht drin. Beides liegt unverschluesselt
// auf derselben Platte, der Unterschied ist die Zahl der Kopien — wer den
// Client-Schluessel zurueckziehen will, tut das im Discord-Portal, nicht hier.
import { db, now } from './db.js'
const lesen = db.prepare('SELECT value FROM settings WHERE key = ?')
const schreiben = db.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
/** Ein Wert aus der Datenbank — oder, wenn dort nichts steht, aus der
* Umgebung. Die Reihenfolge ist Absicht: wer schon Stack-Variablen gesetzt
* hat, behaelt sie, und wer das Panel benutzt, uebersteuert sie. */
export function getSetting(key: string, envName?: string): string {
const zeile = lesen.get(key) as { value: string } | undefined
if (zeile && zeile.value !== '') return zeile.value
return (envName ? (process.env[envName] ?? '') : '').trim()
}
export function setSetting(key: string, value: string): void {
schreiben.run(key, value, now())
}
/** Ob ein Wert aus der Datenbank kommt (true) oder aus der Umgebung (false).
* Die Oberflaeche sagt das dazu — sonst wundert sich jemand, warum ein
* geleertes Feld weiterhin wirkt. */
export function ausDatenbank(key: string): boolean {
const zeile = lesen.get(key) as { value: string } | undefined
return Boolean(zeile && zeile.value !== '')
}
// ------------------------------------------------------------------- Discord
export const DISCORD_KEYS = {
clientId: 'discord.client_id',
clientSecret: 'discord.client_secret',
guildId: 'discord.guild_id',
roleId: 'discord.role_id',
} as const
export type DiscordConfig = {
clientId: string
clientSecret: string
guildId: string
roleId: string
/** Erst wenn alle vier stehen, hat die Anmeldung ueberhaupt eine Chance.
* Ein halb eingerichteter Weg, der auf halber Strecke abbricht, ist
* schlimmer als ein Knopf, den es nicht gibt. */
bereit: boolean
}
export function discordConfig(): DiscordConfig {
const clientId = getSetting(DISCORD_KEYS.clientId, 'DISCORD_CLIENT_ID')
const clientSecret = getSetting(DISCORD_KEYS.clientSecret, 'DISCORD_CLIENT_SECRET')
const guildId = getSetting(DISCORD_KEYS.guildId, 'DISCORD_GUILD_ID')
const roleId = getSetting(DISCORD_KEYS.roleId, 'DISCORD_ROLE_ID')
return {
clientId,
clientSecret,
guildId,
roleId,
bereit: Boolean(clientId && clientSecret && guildId && roleId),
}
}
+33 -2
View File
@@ -147,6 +147,24 @@ export type Folders = {
wurzel: { files: number; bytes: number }
}
export type Einstellungen = {
discord: {
clientId: string
guildId: string
roleId: string
/** Nur OB eines hinterlegt ist — das Geheimnis selbst geht nie heraus. */
hatGeheimnis: boolean
bereit: boolean
redirectUri: string
ausDerUmgebung: {
clientId: boolean
clientSecret: boolean
guildId: boolean
roleId: boolean
}
}
}
export type EventKind = 'upload' | 'replace' | 'delete'
export type MediaEvent = {
@@ -198,6 +216,8 @@ export const api = {
login: (username: string, password: string) =>
post<{ user: User }>('/api/dash/auth/login', { username, password }),
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
/** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */
authConfig: () => get<{ discord: boolean }>('/api/dash/auth/config'),
me: () => get<{ user: User }>('/api/dash/auth/me'),
changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
@@ -212,12 +232,23 @@ export const api = {
deleteMediaBulk: (ids: number[]) =>
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
events: (kind: string, actor: string, limit: number, offset: number) =>
events: (
kind: string,
actor: string,
query: string,
limit: number,
offset: number,
) =>
get<EventsSeite>(
`/api/dash/events?kind=${encodeURIComponent(kind)}` +
`&actor=${encodeURIComponent(actor)}&limit=${limit}&offset=${offset}`,
`&actor=${encodeURIComponent(actor)}` +
`&query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
),
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
einstellungenSpeichern: (discord: Record<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react'
import { Check, Copy, ExternalLink, Loader2, ShieldCheck, ShieldX } from 'lucide-react'
import { api, type Einstellungen } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf, useKopieren } from '../components/Bausteine'
/** Die Discord-Anmeldung einrichten — im Panel statt in der .env.
*
* Der eigentliche Gewinn steht ganz oben in der Karte: die Redirect-URI zum
* Kopieren. Sie muss ZEICHEN FÜR ZEICHEN mit dem übereinstimmen, was im
* Discord-Portal steht, und daran scheitert der erste Anlauf fast immer. */
export function DiscordKarte() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<Einstellungen | null>(null)
const [laedt, setLaedt] = useState(true)
const [speichert, setSpeichert] = useState(false)
const { kopiert, kopieren } = useKopieren()
const [clientId, setClientId] = useState('')
const [geheimnis, setGeheimnis] = useState('')
const [guildId, setGuildId] = useState('')
const [roleId, setRoleId] = useState('')
async function laden() {
setLaedt(true)
try {
const e = await api.einstellungen()
setDaten(e)
setClientId(e.discord.clientId)
setGuildId(e.discord.guildId)
setRoleId(e.discord.roleId)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function speichern() {
setSpeichert(true)
const ergebnis = await run(
() =>
api.einstellungenSpeichern({
clientId,
// Leer heißt „nicht anfassen" — sonst würde jedes Speichern
// das Geheimnis wegwerfen, weil es nie im Formular steht.
...(geheimnis ? { clientSecret: geheimnis } : {}),
guildId,
roleId,
}),
(r) =>
r.bereit
? 'Gespeichert. Die Discord-Anmeldung ist eingerichtet.'
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
)
setSpeichert(false)
if (ergebnis) {
setGeheimnis('')
void laden()
}
}
if (laedt) {
return (
<Karte titel="Anmeldung über Discord">
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Karte>
)
}
if (!daten) return null
const d = daten.discord
return (
<Karte
titel="Anmeldung über Discord"
rechts={
d.bereit ? (
<span className="flex items-center gap-1.5 text-xs text-gut">
<ShieldCheck size={14} /> eingerichtet
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
<ShieldX size={14} /> nicht eingerichtet
</span>
)
}
>
<div className="flex flex-col gap-4">
<div>
<p className="mb-1 text-xs font-medium text-schrift-leise">
Diese Adresse muss im Discord-Portal unter OAuth2 Redirects stehen
Zeichen für Zeichen:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
{d.redirectUri}
</code>
<button
onClick={async () => {
if (!(await kopieren(d.redirectUri, 'uri')))
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="Adresse kopieren"
>
{kopiert === 'uri' ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noreferrer"
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="Discord-Portal öffnen"
>
<ExternalLink size={14} />
</a>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Client-ID"
value={clientId}
placeholder="17 bis 20 Ziffern"
hinweis={d.ausDerUmgebung.clientId ? 'kommt derzeit aus der Umgebung' : undefined}
onChange={(e) => setClientId(e.target.value)}
/>
<Feld
label="Client-Geheimnis"
type="password"
value={geheimnis}
placeholder={d.hatGeheimnis ? '•••••••• (hinterlegt)' : 'noch keines'}
hinweis={
d.ausDerUmgebung.clientSecret
? 'kommt derzeit aus der Umgebung'
: 'leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
}
onChange={(e) => setGeheimnis(e.target.value)}
/>
<Feld
label="Server-ID"
value={guildId}
placeholder="Rechtsklick auf den Server → ID kopieren"
hinweis={d.ausDerUmgebung.guildId ? 'kommt derzeit aus der Umgebung' : undefined}
onChange={(e) => setGuildId(e.target.value)}
/>
<Feld
label="Rollen-ID"
value={roleId}
placeholder="Wer diese Rolle hat, darf herein"
hinweis={d.ausDerUmgebung.roleId ? 'kommt derzeit aus der Umgebung' : undefined}
onChange={(e) => setRoleId(e.target.value)}
/>
</div>
<p className="text-xs text-schrift-leise">
Die IDs bekommst du in Discord über Einstellungen Erweitert
Entwicklermodus, danach Rechtsklick ID kopieren. Es wird nur{' '}
<code className="font-mono">identify</code> und{' '}
<code className="font-mono">guilds.members.read</code> erfragt kein Bot,
keine Nachrichten, nichts weiter. Das Passwort bleibt als Notausgang
bestehen.
</p>
<div>
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
</div>
</div>
</Karte>
)
}
+47 -1
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import { HardDrive } from 'lucide-react'
import { api } from '../api'
import { useStore } from '../store'
import { Feld, Knopf } from '../components/Bausteine'
@@ -10,6 +11,26 @@ export function Anmelden() {
const [passwort, setPasswort] = useState('')
const [wartet, setWartet] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [discord, setDiscord] = useState(false)
// Ob der Discord-Knopf ueberhaupt Sinn hat, weiss nur der Dienst. Ein
// Knopf, der zu einer Fehlerseite fuehrt, weil nichts eingerichtet ist,
// ist genau die Sorte Knopf, die wir hier nicht bauen.
useEffect(() => {
api.authConfig()
.then((c) => setDiscord(c.discord))
.catch(() => setDiscord(false))
}, [])
// Der Weg ueber Discord endet bei einem Abbruch wieder hier, mit dem Grund
// im Adressfeld. Ohne diese Zeilen laege er dort und niemand saehe ihn.
useEffect(() => {
const grund = new URLSearchParams(window.location.search).get('discord')
if (!grund) return
setFehler(grund)
note('fehler', grund)
window.history.replaceState(null, '', window.location.pathname)
}, [note])
async function absenden(e: FormEvent) {
e.preventDefault()
@@ -71,6 +92,31 @@ export function Anmelden() {
className="mt-5 w-full"
kind="Anmelden"
/>
{discord && (
<>
<div className="my-4 flex items-center gap-3 text-xs text-schrift-leise">
<span className="h-px flex-1 bg-rand" />
oder
<span className="h-px flex-1 bg-rand" />
</div>
{/* Ein echtes <a> und kein fetch: der Weg fuehrt zu
Discord und wieder zurueck, das ist eine
Seitennavigation und keine Anfrage im Hintergrund. */}
<a
href="/api/dash/auth/discord"
className="flex w-full items-center justify-center gap-2 rounded-md
border border-[#5865F2] bg-[#5865F2] px-3 py-1.5 text-sm
font-medium text-white transition-colors hover:bg-[#4752c4]"
>
<svg viewBox="0 0 127 96" className="h-4 w-4" fill="currentColor" aria-hidden>
<path d="M107 8A105 105 0 0 0 81 0l-3 7a97 97 0 0 0-29 0l-4-7a105 105 0 0 0-26 8C2 33-1 57 1 81a106 106 0 0 0 32 16l7-11c-4-1-8-3-11-5l3-2a75 75 0 0 0 64 0l3 2c-4 2-8 4-12 5l7 11a106 106 0 0 0 32-16c3-28-2-52-19-73ZM43 66c-6 0-11-6-11-13s5-13 11-13 12 6 11 13c0 7-5 13-11 13Zm42 0c-6 0-11-6-11-13s5-13 11-13 11 6 11 13c0 7-5 13-11 13Z" />
</svg>
Mit Discord anmelden
</a>
</>
)}
</form>
</div>
)
+4 -1
View File
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte'
export function Konto() {
const user = useStore((s) => s.user)
@@ -37,7 +38,7 @@ export function Konto() {
}
return (
<div className="max-w-md">
<div className="flex max-w-2xl flex-col gap-4">
<Karte titel={`Angemeldet als ${user?.username ?? '—'}`}>
<form onSubmit={wechseln} className="flex flex-col gap-3">
<Feld
@@ -79,6 +80,8 @@ export function Konto() {
</div>
</form>
</Karte>
<DiscordKarte />
</div>
)
}
+68 -8
View File
@@ -6,16 +6,18 @@ import {
KeyRound,
Loader2,
RefreshCw,
Search,
Trash2,
Upload,
UserCog,
X,
} from 'lucide-react'
import { api, type EventKind, type EventsSeite } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Knopf, Leer } from '../components/Bausteine'
const PRO_SEITE = 100
const SEITENGROESSEN = [25, 50, 100, 200] as const
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
@@ -30,24 +32,37 @@ export function Verlauf() {
const [seite, setSeite] = useState(0)
const [art, setArt] = useState<EventKind | ''>('')
const [wer, setWer] = useState('')
const [proSeite, setProSeite] = useState(50)
const [suche, setSuche] = useState('')
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
// "vehicles" eine Anfrage je Buchstabe raus.
const [sucheAktiv, setSucheAktiv] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setSucheAktiv(suche)
setSeite(0)
}, 250)
return () => clearTimeout(t)
}, [suche])
const laden = useCallback(async () => {
setLaedt(true)
try {
setDaten(await api.events(art, wer, PRO_SEITE, seite * PRO_SEITE))
setDaten(await api.events(art, wer, sucheAktiv, proSeite, seite * proSeite))
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLaedt(false)
}
}, [art, wer, seite, note])
}, [art, wer, sucheAktiv, proSeite, seite, note])
useEffect(() => {
void laden()
}, [laden])
const items = daten?.items ?? []
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / PRO_SEITE))
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
return (
<div className="flex flex-col gap-4">
@@ -90,6 +105,51 @@ export function Verlauf() {
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
size={15}
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
/>
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Pfad durchsuchen, z. B. vehicles/adder"
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
focus:outline-none"
/>
{suche && (
<button
onClick={() => setSuche('')}
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
aria-label="Suche leeren"
>
<X size={14} />
</button>
)}
</div>
<label className="flex items-center gap-2 text-xs text-schrift-leise">
pro Seite
<select
value={proSeite}
onChange={(e) => {
setProSeite(Number(e.target.value))
setSeite(0)
}}
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
{SEITENGROESSEN.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
<Karte>
{items.length === 0 && !laedt ? (
<Leer
@@ -103,8 +163,8 @@ export function Verlauf() {
<th className="pb-2 font-medium">Wann</th>
<th className="pb-2 font-medium">Was</th>
<th className="pb-2 font-medium">Pfad</th>
<th className="pb-2 text-right font-medium">Größe</th>
<th className="pb-2 font-medium">Von wem</th>
<th className="pb-2 pr-8 text-right font-medium">Größe</th>
<th className="pb-2 pl-2 font-medium">Von wem</th>
</tr>
</thead>
<tbody>
@@ -125,10 +185,10 @@ export function Verlauf() {
<td className="max-w-0 truncate py-1.5 font-mono text-xs" title={e.path}>
{e.path}
</td>
<td className="py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
<td className="py-1.5 pr-8 text-right whitespace-nowrap text-schrift-leise tabular-nums">
{e.size === null ? '—' : bytes(e.size)}
</td>
<td className="py-1.5 whitespace-nowrap">
<td className="py-1.5 pl-2 whitespace-nowrap">
<span
className={
e.actor_kind === 'unbekannt'