diff --git a/server/src/db.ts b/server/src/db.ts index 25d674a..e82bdd9 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -116,6 +116,15 @@ CREATE TABLE IF NOT EXISTS settings ( db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_discord ON users(discord_id)') console.log('[db] Spalte users.discord_id angelegt') } + // Der Bildstempel, NICHT die fertige Adresse: Discord baut die URL aus ID + // 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. + 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') + console.log('[db] Spalten users.discord_avatar und users.discord_name angelegt') + } } // Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur @@ -151,6 +160,33 @@ export type User = { /** Gesetzt, wenn dieses Konto ueber Discord hereinkommt. Ein solches Konto * hat kein brauchbares Passwort — verifyPassword scheitert daran sauber. */ discord_id: string | null + /** Der Bildstempel von Discord, nicht die Adresse. Null heisst: kein + * eigenes Bild, dann gilt Discords Ersatzbild. */ + discord_avatar: string | null + discord_name: string | null +} + +/** Die Adresse des Discord-Bildes — an EINER Stelle gebaut. + * + * Faengt der Stempel mit "a_" an, ist es ein bewegtes Bild und muss als .gif + * angefragt werden; sonst kommt ein Standbild. Ohne eigenen Stempel gibt es + * Discords Ersatzbild, dessen Nummer sich aus der ID ergibt (die neue + * Rechnung mit >> 22, nicht die alte ueber den Diskriminator). + * + * Null nur, wenn gar keine Discord-ID da ist — dann zeigt die Oberflaeche + * Initialen. */ +export function avatarUrl( + discordId: string | null, + stempel: string | null, + groesse = 64, +): string | null { + if (!discordId) return null + if (stempel) { + const endung = stempel.startsWith('a_') ? 'gif' : 'png' + return `https://cdn.discordapp.com/avatars/${discordId}/${stempel}.${endung}?size=${groesse}` + } + const nummer = Number((BigInt(discordId) >> 22n) % 6n) + return `https://cdn.discordapp.com/embed/avatars/${nummer}.png` } export type Token = { diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 79bc118..4c944ea 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -10,6 +10,7 @@ import type { Context } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { config } from '../config.js' import { + avatarUrl, db, logEvent, now, @@ -198,7 +199,7 @@ dashRoutes.post('/auth/login', async (c) => { expires: new Date(session.expiresAt), }) - return c.json({ user: { id: user.id, username: user.username } }) + return c.json({ user: zeigen(user) }) }) dashRoutes.post('/auth/logout', (c) => { @@ -208,10 +209,23 @@ dashRoutes.post('/auth/logout', (c) => { return c.json({ ok: true }) }) +/** Wer gerade angemeldet ist — samt Bild, wenn es eines gibt. + * + * Die Adresse wird HIER gebaut und nicht in der Oberflaeche: Discords + * URL-Schema (gif bei bewegten Bildern, Ersatzbild aus der ID gerechnet) + * gehoert an eine Stelle und nicht in jede Ansicht, die ein Bild zeigt. */ +const zeigen = (user: User) => ({ + id: user.id, + username: user.username, + discord: Boolean(user.discord_id), + discordName: user.discord_name, + avatar: avatarUrl(user.discord_id, user.discord_avatar, 64), +}) + dashRoutes.get('/auth/me', (c) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) if (!user) return c.json({ error: 'nicht angemeldet' }, 401) - return c.json({ user: { id: user.id, username: user.username } }) + return c.json({ user: zeigen(user) }) }) /** Was die Anmeldeseite wissen muss, BEVOR jemand angemeldet ist. @@ -569,12 +583,32 @@ dashRoutes.get('/events', (c) => { // Wer ueberhaupt vorkommt -- damit die Oberflaeche einen Filter anbieten // kann, ohne alle Eintraege durchzublaettern. - const akteure = db + const roheAkteure = db .prepare( - `SELECT actor, actor_kind, COUNT(*) AS n, MAX(at) AS zuletzt - FROM events GROUP BY actor, actor_kind ORDER BY n DESC`, + `SELECT e.actor, e.actor_kind, COUNT(*) AS n, MAX(e.at) AS zuletzt, + u.discord_id, u.discord_avatar + FROM events e + LEFT JOIN users u ON u.username = e.actor AND e.actor_kind = 'user' + GROUP BY e.actor, e.actor_kind ORDER BY n DESC`, ) - .all() as { actor: string; actor_kind: string; n: number; zuletzt: number }[] + .all() as { + actor: string + actor_kind: string + n: number + zuletzt: number + discord_id: string | null + discord_avatar: string | null + }[] + + // Das Bild nur fuer Menschen. Ein Token hat kein Gesicht, und ein + // Platzhalterbild daneben waere eine Behauptung. + const akteure = roheAkteure.map((a) => ({ + actor: a.actor, + actor_kind: a.actor_kind, + n: a.n, + zuletzt: a.zuletzt, + avatar: avatarUrl(a.discord_id, a.discord_avatar, 32), + })) // Ab wann aufgezeichnet wird. Alles davor steht nur in der Galerie, und // das gehoert dazugesagt statt verschwiegen. diff --git a/server/src/routes/discord.ts b/server/src/routes/discord.ts index dafb968..daa869c 100644 --- a/server/src/routes/discord.ts +++ b/server/src/routes/discord.ts @@ -126,6 +126,7 @@ discordRoutes.get('/discord/callback', async (c) => { id: string username: string global_name?: string | null + avatar?: string | null } // -- Und welche Rolle hat er hier ----------------------------------- @@ -160,8 +161,9 @@ discordRoutes.get('/discord/callback', async (c) => { | User | undefined + const anzeige = (me.global_name || me.username || `discord-${me.id}`).slice(0, 40) + 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) @@ -172,13 +174,22 @@ discordRoutes.get('/discord/callback', async (c) => { // kommt also ausschliesslich ueber Discord herein. const info = db .prepare( - `INSERT INTO users (username, password_hash, created_at, discord_id) - VALUES (?, 'nur-ueber-discord', ?, ?)`, + `INSERT INTO users (username, password_hash, created_at, discord_id, + discord_avatar, discord_name) + VALUES (?, 'nur-ueber-discord', ?, ?, ?, ?)`, ) - .run(username, now(), me.id) + .run(username, now(), me.id, me.avatar ?? null, anzeige) user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as User console.log(`[discord] Konto angelegt: ${username} (${me.id})`) + } else { + // BEI JEDER ANMELDUNG nachziehen. Wer sein Bild wechselt, sieht + // sonst monatelang das alte — und wundert sich, ob er im richtigen + // Konto sitzt. Der ANZEIGENAME im Dashboard bleibt dagegen, wie er + // ist: an ihm haengen die Eintraege im Verlauf. + db.prepare( + 'UPDATE users SET discord_avatar = ?, discord_name = ? WHERE id = ?', + ).run(me.avatar ?? null, anzeige, user.id) } const session = createSession(user.id) diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index 028827e..c0bf0ad 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -8,6 +8,7 @@ import { Tokens } from './pages/Tokens' import { Konto } from './pages/Konto' import { Verlauf } from './pages/Verlauf' import { Meldungen } from './components/Meldungen' +import { Avatar } from './components/Avatar' // Ein winziger Router über die History-API statt einer Bibliothek. // @@ -120,14 +121,21 @@ export function App() { })} - +
{user?.username ?? '—'}
++ {user?.discord ? herkunft : 'mit Passwort angemeldet'} +
++ Dieses Konto hat kein Passwort — es kommt ausschließlich über Discord + herein. Das Formular unten wirkt nur für Konten mit Passwort. +
+ )} +