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
+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')