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:
+107
-1
@@ -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')
|
||||
|
||||
@@ -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'}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user