feat: Sitzungen beenden, Verwaltungs-Verlauf -- und ein Kaestchen, das log
DER FEHLER ZUERST, denn er ist meiner von gestern: die Liste der
Meldungsanlaesse stand ZWEIMAL da -- als Standard in meldung.ts und noch einmal
als Weissliste beim Speichern in dash.ts. Beim Einbau des Plattenwaechters habe
ich nur die erste angefasst.
Ergebnis: das Kaestchen "Der Platz auf der Platte wird knapp" liess sich
ankreuzen, der Server warf den Wert beim Speichern weg, und beim naechsten
Laden war es wieder aus. Ohne ein Wort dazu. Nachgemessen am laufenden Dienst:
geschickt ["sicherung","verwaiste","platte"]
gespeichert ["sicherung","verwaiste"]
Die Liste steht jetzt an EINER Stelle (ANLAESSE in meldung.ts) und wird von
beiden benutzt. Gegengeprueft: alle drei kommen an.
SITZUNGEN BEENDEN. Wer sich an einem fremden Rechner angemeldet hat und es
spaeter merkt, hatte keine Moeglichkeit das zurueckzunehmen -- ein geaendertes
Passwort half nicht, die Sitzungen haengen an einer eigenen Tabelle und
ueberleben es. Der Knopf steht im Kontomenue und nur dann, wenn es ueberhaupt
eine zweite Sitzung gibt. Gemessen: 87 offen, 86 beendet, meine lebt.
Dabei in die eigene Falle getreten: der Weg lag zuerst bei den anderen
/auth-Wegen -- und die stehen mit Absicht VOR der Wache, weil man sich anmelden
koennen muss, ohne angemeldet zu sein. Dort ist c.get('user') leer, und der Weg
antwortete mit "Cannot read properties of undefined". Hono setzt Middleware und
Handler in der Reihenfolge ihrer Anmeldung zusammen; der Pfad sagt darueber
nichts.
VERWALTUNGS-VERLAUF. Der Datei-Verlauf beantwortet "was ist mit den Dateien
passiert". Wer den Discord-Webhook geaendert, einen Token angelegt oder ein
Konto entfernt hat, stand nirgends. Bei zwei Konten verschmerzbar -- sobald ein
drittes ueber die Discord-Rolle von selbst entsteht, ist es die erste Frage.
Eigene Tabelle und nicht events: dort traegt jede Zeile path und size, und eine
Einstellungsaenderung muesste path mit etwas fuellen, das kein Pfad ist.
WERTE STEHEN DORT NIE, nur Schluesselnamen. Unter den Einstellungen liegen das
Discord-Geheimnis und das Nextcloud-Passwort; ein Verlauf, der sie mitschreibt,
macht aus einer Tabelle mit einem Passwort eine Tabelle mit allen, die es je
gab.
Beim Einbau derselbe Fehlertyp wie gestern beim SVG: im SQL-Kommentar standen
Backticks, und das Schema liegt in einem Template-Literal -- ein Backtick
beendet es. Der Uebersetzer hat es gefangen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -11,7 +11,11 @@ Sortiert nach dem, was ich zuerst täte.
|
||||
|
||||
## 1 · Sicher wertvoll
|
||||
|
||||
### Die Oberfläche auf dem Telefon
|
||||
### ~~Die Oberfläche auf dem Telefon~~ — abgelehnt am 12.08.2026
|
||||
|
||||
> Vom Betreiber verworfen: das Dashboard wird nicht mobil benutzt. Der Rest des
|
||||
> Abschnitts bleibt stehen, damit die Messung nicht verlorengeht, falls sich das
|
||||
> je ändert.
|
||||
|
||||
**Gemessen:** 18 Umbruchpunkte (`sm:`/`lg:`/`xl:`) im ganzen Frontend. Die
|
||||
Galerie ist ein Raster und passt sich an — die **vier Tabellen** in Verlauf,
|
||||
|
||||
@@ -75,6 +75,32 @@ export function userForSession(sessionId: string | undefined): User | null {
|
||||
return row
|
||||
}
|
||||
|
||||
/** Alle Sitzungen eines Benutzers beenden -- ausser der laufenden.
|
||||
*
|
||||
* WOZU: wer sich an einem fremden Rechner angemeldet hat und es spaeter merkt,
|
||||
* hatte bisher keine Moeglichkeit, das zurueckzunehmen. Das Passwort zu
|
||||
* aendern half nicht -- die Sitzungen haengen an einer eigenen Tabelle und
|
||||
* ueberleben es.
|
||||
*
|
||||
* Die EIGENE bleibt stehen: wer auf den Knopf drueckt, will sich nicht selbst
|
||||
* aussperren. */
|
||||
export function destroyOtherSessions(userId: number, behalten: string): number {
|
||||
const r = db
|
||||
.prepare('DELETE FROM sessions WHERE user_id = ? AND id <> ?')
|
||||
.run(userId, behalten)
|
||||
return r.changes
|
||||
}
|
||||
|
||||
/** Wie viele Sitzungen gerade offen sind. Abgelaufene zaehlen nicht mit --
|
||||
* sie sind nur noch nicht weggeraeumt. */
|
||||
export function countSessions(userId: number): number {
|
||||
return (
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND expires_at > ?')
|
||||
.get(userId, now()) as { n: number }
|
||||
).n
|
||||
}
|
||||
|
||||
export function destroySession(sessionId: string) {
|
||||
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId)
|
||||
}
|
||||
|
||||
@@ -145,6 +145,38 @@ CREATE TABLE IF NOT EXISTS freigaben (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS freigaben_ordner ON freigaben(ordner);
|
||||
|
||||
-- Was jemand VERWALTET hat -- Einstellungen, Token, Konten, Freigaben.
|
||||
--
|
||||
-- EINE EIGENE TABELLE und nicht events, obwohl beides ein Verlauf ist. Der
|
||||
-- Grund steht in den Spalten: events traegt path und size und beantwortet
|
||||
-- "was ist mit den DATEIEN passiert". Wer dort eine Einstellungsaenderung
|
||||
-- hineinschriebe, muesste path mit etwas fuellen, das kein Pfad ist -- und
|
||||
--
|
||||
-- (Und in diesem Kommentar stehen keine Backticks, obwohl sie sich hier
|
||||
-- anboeten: das Schema liegt in einem Template-Literal, und ein Backtick
|
||||
-- beendet es. Der Uebersetzer hat es gefangen, der Browser haette es nicht.)
|
||||
-- danach filtert jede Ansicht, die Dateien zeigen will, den Fremdkoerper wieder
|
||||
-- heraus.
|
||||
--
|
||||
-- WAS HIER NIE HINEINGEHOERT: der WERT einer Einstellung. Unter den
|
||||
-- Schluesseln stehen das Discord-Geheimnis und das Nextcloud-Passwort. Ein
|
||||
-- Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort eine
|
||||
-- Tabelle mit allen Passwoertern, die es je gab. Aufgeschrieben wird deshalb
|
||||
-- WELCHER Schluessel angefasst wurde, nicht womit.
|
||||
CREATE TABLE IF NOT EXISTS verwaltung (
|
||||
id INTEGER PRIMARY KEY,
|
||||
at INTEGER NOT NULL,
|
||||
wer TEXT NOT NULL,
|
||||
-- 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner'
|
||||
bereich TEXT NOT NULL,
|
||||
-- 'angelegt' | 'geaendert' | 'entfernt'
|
||||
tat TEXT NOT NULL,
|
||||
ziel TEXT NOT NULL DEFAULT '',
|
||||
-- Kurz und ohne Werte: "webhook, anlaesse" oder "quota_bytes, arten".
|
||||
was TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS verwaltung_zeit ON verwaltung(at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -349,6 +381,16 @@ export type BenutzerZeile = {
|
||||
discord_avatar: string | null
|
||||
}
|
||||
|
||||
export type VerwaltungZeile = {
|
||||
id: number
|
||||
at: number
|
||||
wer: string
|
||||
bereich: string
|
||||
tat: string
|
||||
ziel: string
|
||||
was: string
|
||||
}
|
||||
|
||||
export type PapierkorbEintrag = {
|
||||
id: number
|
||||
path: string
|
||||
@@ -447,6 +489,32 @@ export function schreibeAbrufe(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Verwaltungshandlung festhalten.
|
||||
*
|
||||
* Wirft NIE: ein Verlauf, der die Handlung verhindert, ueber die er berichten
|
||||
* soll, waere die falsche Reihenfolge. Faellt er aus, steht die Zeile eben
|
||||
* nicht da -- die Aenderung selbst ist trotzdem passiert. */
|
||||
export function logVerwaltung(
|
||||
wer: string,
|
||||
bereich: string,
|
||||
tat: string,
|
||||
ziel = '',
|
||||
was = '',
|
||||
): void {
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT INTO verwaltung (at, wer, bereich, tat, ziel, was) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(now(), wer, bereich, tat, ziel, was)
|
||||
} catch (err) {
|
||||
console.error('[verwaltung] nicht geschrieben:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dieselbe Frist wie beim Datei-Verlauf. */
|
||||
export function pruneVerwaltung(tage = 180) {
|
||||
return db.prepare('DELETE FROM verwaltung WHERE at < ?').run(now() - tage * 86_400_000)
|
||||
}
|
||||
|
||||
export function pruneEvents(tage = 180) {
|
||||
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
|
||||
}
|
||||
|
||||
+9
-3
@@ -28,7 +28,7 @@ import { Hono } from 'hono'
|
||||
import { logger } from 'hono/logger'
|
||||
import { config } from './config.js'
|
||||
import { ensureAdmin } from './auth.js'
|
||||
import { pruneEvents, pruneSessions, schreibeAbrufe, zaehleAbruf } from './db.js'
|
||||
import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js'
|
||||
import { pruefePlatte, pruefeSicherung } from './meldung.js'
|
||||
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
|
||||
import { uploadRoutes } from './routes/upload.js'
|
||||
@@ -400,8 +400,14 @@ setInterval(pruneSessions, 3600_000).unref()
|
||||
// Nicht stuendlich wie die Sitzungen: ein DELETE ueber eine Zeitspanne von
|
||||
// einem halben Jahr hat es nicht eilig, und 24 Stunden Verzoegerung beim
|
||||
// Wegraeumen fallen niemandem auf.
|
||||
setTimeout(() => pruneEvents(), 120_000).unref()
|
||||
setInterval(() => pruneEvents(), 86_400_000).unref()
|
||||
setTimeout(() => {
|
||||
pruneEvents()
|
||||
pruneVerwaltung()
|
||||
}, 120_000).unref()
|
||||
setInterval(() => {
|
||||
pruneEvents()
|
||||
pruneVerwaltung()
|
||||
}, 86_400_000).unref()
|
||||
|
||||
// Der Wachhund fuer die Sicherung. Beim Start mit Verzoegerung (der Dienst
|
||||
// soll erst stehen) und danach stuendlich.
|
||||
|
||||
+18
-1
@@ -24,6 +24,23 @@ import { config } from './config.js'
|
||||
import { getSetting, setSetting } from './settings.js'
|
||||
import { now } from './db.js'
|
||||
|
||||
/** Wofuer es Meldungen gibt -- an EINER Stelle.
|
||||
*
|
||||
* Die Liste stand zweimal da: hier als Standard, und in dash.ts noch einmal
|
||||
* als Weissliste beim Speichern. Beim Einbau des Plattenwaechters habe ich nur
|
||||
* die erste angefasst -- Ergebnis: das Kaestchen liess sich ankreuzen, der
|
||||
* Server warf den Wert beim Speichern weg, und beim naechsten Laden war es
|
||||
* wieder aus. Ohne ein Wort dazu.
|
||||
*
|
||||
* Nachgemessen am laufenden Dienst, bevor es hier stand:
|
||||
* geschickt ["sicherung","verwaiste","platte"]
|
||||
* gespeichert ["sicherung","verwaiste"]
|
||||
*
|
||||
* Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch
|
||||
* ist. Jetzt ist es eine.
|
||||
*/
|
||||
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte'] as const
|
||||
|
||||
export const MELDUNG_KEYS = {
|
||||
webhook: 'discord.webhook',
|
||||
/** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste', 'platte'. */
|
||||
@@ -65,7 +82,7 @@ export function meldungConfig(): MeldungConfig {
|
||||
// will Meldungen -- sonst haette er es gelassen.
|
||||
anlaesse: roh
|
||||
? roh.split(',').map((a) => a.trim()).filter(Boolean)
|
||||
: ['sicherung', 'verwaiste', 'platte'],
|
||||
: [...ANLAESSE],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
avatarUrl,
|
||||
db,
|
||||
logEvent,
|
||||
logVerwaltung,
|
||||
now,
|
||||
pruneEvents,
|
||||
pruneSessions,
|
||||
type Media,
|
||||
type BenutzerZeile,
|
||||
type VerwaltungZeile,
|
||||
type Freigabe,
|
||||
type MediaEvent,
|
||||
type PapierkorbEintrag,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
createSession,
|
||||
countSessions,
|
||||
destroyOtherSessions,
|
||||
destroySession,
|
||||
generateToken,
|
||||
hashPassword,
|
||||
@@ -51,7 +55,7 @@ import {
|
||||
type Vorlage,
|
||||
} from '../settings.js'
|
||||
import { discordRoutes, redirectUri } from './discord.js'
|
||||
import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
||||
import { ANLAESSE, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
||||
import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js'
|
||||
import { neuerSchluessel } from './freigabe.js'
|
||||
import {
|
||||
@@ -259,7 +263,9 @@ const zeigen = (user: User) => ({
|
||||
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: zeigen(user) })
|
||||
// Die Zahl der offenen Sitzungen geht MIT: das Kontomenue zeigt sie an, und
|
||||
// eine Zahl, die man erst auf einer anderen Seite findet, sieht niemand.
|
||||
return c.json({ user: zeigen(user), sitzungen: countSessions(user.id) })
|
||||
})
|
||||
|
||||
/** Was die Anmeldeseite wissen muss, BEVOR jemand angemeldet ist.
|
||||
@@ -332,6 +338,41 @@ const ARTEN: Record<string, string> = {
|
||||
"AND mime <> 'application/pdf' AND mime NOT LIKE 'text/%' AND mime NOT LIKE 'application/json%')",
|
||||
}
|
||||
|
||||
/** Alle anderen Sitzungen beenden.
|
||||
*
|
||||
* WOZU: wer sich an einem fremden Rechner angemeldet hat und es spaeter merkt,
|
||||
* hatte keine Moeglichkeit, das zurueckzunehmen -- ein geaendertes Passwort
|
||||
* half nicht, die Sitzungen haengen an einer eigenen Tabelle und ueberleben
|
||||
* es.
|
||||
*
|
||||
* Die EIGENE bleibt: wer drueckt, will sich nicht selbst aussperren.
|
||||
*
|
||||
* UND DIESER WEG STEHT HINTER DER WACHE, obwohl er unter /auth/ liegt.
|
||||
* Zuerst stand er bei den anderen /auth-Wegen -- und die liegen mit Absicht
|
||||
* DAVOR, weil man sich anmelden koennen muss, ohne angemeldet zu sein. Dort
|
||||
* ist `c.get('user')` aber leer, und der Weg antwortete mit
|
||||
* "Cannot read properties of undefined (reading 'id')" statt zu arbeiten.
|
||||
*
|
||||
* Hono setzt Middleware und Handler in der REIHENFOLGE ihrer Anmeldung
|
||||
* zusammen: was vor `use('*')` steht, laeuft ohne sie. Der Pfad sagt darueber
|
||||
* nichts. */
|
||||
dashRoutes.post('/auth/sitzungen/beenden', (c) => {
|
||||
const sid = getCookie(c, SESSION_COOKIE)
|
||||
if (!sid) return c.json({ error: 'keine Sitzung' }, 401)
|
||||
const user = c.get('user')
|
||||
const beendet = destroyOtherSessions(user.id, sid)
|
||||
if (beendet > 0) {
|
||||
logVerwaltung(
|
||||
user.username,
|
||||
'benutzer',
|
||||
'geaendert',
|
||||
user.username,
|
||||
`${beendet} Sitzung(en) beendet`,
|
||||
)
|
||||
}
|
||||
return c.json({ beendet, offen: countSessions(user.id) })
|
||||
})
|
||||
|
||||
dashRoutes.get('/media', (c) => {
|
||||
const query = c.req.query('query')?.trim() ?? ''
|
||||
const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200)
|
||||
@@ -1045,6 +1086,23 @@ dashRoutes.post('/upload-url', async (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Der Verwaltungs-Verlauf: wer hat wann was EINGESTELLT.
|
||||
*
|
||||
* Getrennt vom Datei-Verlauf, weil es eine andere Frage beantwortet -- und
|
||||
* weil `events` mit `path` und `size` die falschen Spalten dafuer hat.
|
||||
*
|
||||
* WERTE STEHEN HIER NIE, nur Schluesselnamen. Unter den Einstellungen liegen
|
||||
* das Discord-Geheimnis und das Nextcloud-Passwort. */
|
||||
dashRoutes.get('/verwaltung', (c) => {
|
||||
const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500)
|
||||
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
|
||||
const items = db
|
||||
.prepare('SELECT * FROM verwaltung ORDER BY at DESC LIMIT ? OFFSET ?')
|
||||
.all(limit, offset) as VerwaltungZeile[]
|
||||
const total = (db.prepare('SELECT COUNT(*) AS n FROM verwaltung').get() as { n: number }).n
|
||||
return c.json({ items, total, limit, offset })
|
||||
})
|
||||
|
||||
dashRoutes.get('/events', (c) => {
|
||||
const limit = Math.min(Number(c.req.query('limit') ?? 50) || 50, 500)
|
||||
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
|
||||
@@ -1201,13 +1259,16 @@ dashRoutes.post('/settings', async (c) => {
|
||||
}
|
||||
}
|
||||
if (Array.isArray(m.anlaesse)) {
|
||||
const erlaubt = ['sicherung', 'verwaiste']
|
||||
// Die Weissliste kommt aus meldung.ts und steht NICHT noch einmal
|
||||
// hier. Genau diese Verdopplung hat den Plattenwaechter
|
||||
// unbenutzbar gemacht: dort ergaenzt, hier vergessen.
|
||||
setSetting(
|
||||
MELDUNG_KEYS.anlaesse,
|
||||
m.anlaesse.filter((a) => erlaubt.includes(a)).join(','),
|
||||
m.anlaesse.filter((a) => (ANLAESSE as readonly string[]).includes(a)).join(','),
|
||||
)
|
||||
}
|
||||
if (!body.discord && !body.sicherung && !body.vorlagen) {
|
||||
logVerwaltung(c.get('user').username, 'einstellung', 'geaendert', 'Meldungen')
|
||||
return c.json({ ok: true, meldungen: meldungConfig() })
|
||||
}
|
||||
}
|
||||
@@ -1252,6 +1313,7 @@ dashRoutes.post('/settings', async (c) => {
|
||||
}
|
||||
setzeVorlagen(sauber)
|
||||
if (!body.discord && !body.sicherung) {
|
||||
logVerwaltung(c.get('user').username, 'einstellung', 'geaendert', 'Adressvorlagen', `${sauber.length} Stueck`)
|
||||
return c.json({ ok: true, vorlagen: sauber })
|
||||
}
|
||||
}
|
||||
@@ -1354,6 +1416,16 @@ dashRoutes.post('/settings', async (c) => {
|
||||
else if (v) setSetting(DISCORD_KEYS.clientSecret, v)
|
||||
}
|
||||
|
||||
// Am Ende angekommen heisst: Discord und/oder Sicherung wurden angefasst.
|
||||
// Aufgeschrieben wird WELCHER Bereich -- nie ein Wert. Darunter liegen das
|
||||
// Discord-Geheimnis und das Nextcloud-Passwort.
|
||||
logVerwaltung(
|
||||
c.get('user').username,
|
||||
'einstellung',
|
||||
'geaendert',
|
||||
[body.discord && 'Discord', body.sicherung && 'Sicherung'].filter(Boolean).join(' + '),
|
||||
)
|
||||
|
||||
const jetzt = discordConfig()
|
||||
return c.json({
|
||||
ok: true,
|
||||
@@ -2037,6 +2109,7 @@ dashRoutes.delete('/benutzer/:id', (c) => {
|
||||
if (!wer) return c.json({ error: 'nicht gefunden' }, 404)
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
logVerwaltung(c.get('user').username, 'benutzer', 'entfernt', wer.username)
|
||||
return c.json({ ok: true, username: wer.username })
|
||||
})
|
||||
|
||||
@@ -2081,7 +2154,7 @@ dashRoutes.post('/freigaben', async (c) => {
|
||||
)
|
||||
.run(schluessel, ordner, (body.titel ?? '').trim(), now())
|
||||
|
||||
logEvent('upload', ordner, 0, 'user', c.get('user').username)
|
||||
logVerwaltung(c.get('user').username, 'freigabe', 'angelegt', ordner, `${drin.n} Dateien`)
|
||||
|
||||
return c.json({
|
||||
id: Number(eintrag.lastInsertRowid),
|
||||
@@ -2093,8 +2166,13 @@ dashRoutes.post('/freigaben', async (c) => {
|
||||
})
|
||||
|
||||
dashRoutes.delete('/freigaben/:id', (c) => {
|
||||
const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(Number(c.req.param('id')))
|
||||
const id = Number(c.req.param('id'))
|
||||
const vorher = db.prepare('SELECT ordner FROM freigaben WHERE id = ?').get(id) as
|
||||
| { ordner: string }
|
||||
| undefined
|
||||
const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(id)
|
||||
if (r.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)
|
||||
logVerwaltung(c.get('user').username, 'freigabe', 'entfernt', vorher?.ordner ?? String(id))
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -2331,6 +2409,8 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
expiresAt,
|
||||
)
|
||||
|
||||
logVerwaltung(c.get('user').username, 'token', 'angelegt', name.trim(), cleanPrefix || 'ueberall')
|
||||
|
||||
// Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in
|
||||
// der Datenbank, und auch wir koennen ihn nicht mehr zeigen.
|
||||
return c.json({
|
||||
@@ -2461,6 +2541,8 @@ dashRoutes.patch('/tokens/:id', async (c) => {
|
||||
FROM tokens WHERE id = ?`,
|
||||
)
|
||||
.get(id) as Omit<Token, 'hash'>
|
||||
logVerwaltung(c.get('user').username, 'token', 'geaendert', neu.name, spalten.join(', '))
|
||||
|
||||
return c.json({
|
||||
...neu,
|
||||
abgelaufen: Boolean(neu.expires_at && neu.expires_at < now()),
|
||||
@@ -2469,8 +2551,15 @@ dashRoutes.patch('/tokens/:id', async (c) => {
|
||||
})
|
||||
|
||||
dashRoutes.delete('/tokens/:id', (c) => {
|
||||
const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(Number(c.req.param('id')))
|
||||
const id = Number(c.req.param('id'))
|
||||
// Erst den Namen holen: nach dem DELETE ist er weg, und "Token 7 entfernt"
|
||||
// sagt in einem halben Jahr niemandem mehr etwas.
|
||||
const vorher = db.prepare('SELECT name FROM tokens WHERE id = ?').get(id) as
|
||||
| { name: string }
|
||||
| undefined
|
||||
const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(id)
|
||||
if (info.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)
|
||||
logVerwaltung(c.get('user').username, 'token', 'entfernt', vorher?.name ?? String(id))
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
|
||||
+22
-1
@@ -340,6 +340,20 @@ export type Einstellungen = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Zeile aus dem Verwaltungs-Verlauf.
|
||||
*
|
||||
* `was` traegt NIE einen Wert, nur Schluesselnamen — unter den Einstellungen
|
||||
* liegen das Discord-Geheimnis und das Nextcloud-Passwort. */
|
||||
export type VerwaltungRow = {
|
||||
id: number
|
||||
at: number
|
||||
wer: string
|
||||
bereich: 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner'
|
||||
tat: 'angelegt' | 'geaendert' | 'entfernt'
|
||||
ziel: string
|
||||
was: string
|
||||
}
|
||||
|
||||
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
|
||||
|
||||
export type MediaEvent = {
|
||||
@@ -406,7 +420,7 @@ export const api = {
|
||||
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
|
||||
/** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */
|
||||
authConfig: () => get<{ discord: boolean; marke: string }>('/api/dash/auth/config'),
|
||||
me: () => get<{ user: User }>('/api/dash/auth/me'),
|
||||
me: () => get<{ user: User; sitzungen?: number }>('/api/dash/auth/me'),
|
||||
changePassword: (current: string, next: string) =>
|
||||
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
|
||||
|
||||
@@ -494,6 +508,13 @@ export const api = {
|
||||
{ aktion },
|
||||
),
|
||||
|
||||
verwaltung: (limit = 100, offset = 0) =>
|
||||
get<{ items: VerwaltungRow[]; total: number; limit: number; offset: number }>(
|
||||
`/api/dash/verwaltung?limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
sitzungenBeenden: () =>
|
||||
post<{ beendet: number; offen: number }>('/api/dash/auth/sitzungen/beenden'),
|
||||
|
||||
benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'),
|
||||
benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`),
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
|
||||
import { ChevronDown, LogOut, UserCog , ShieldOff } from 'lucide-react'
|
||||
import { run, useStore } from '../store'
|
||||
import { gehZu } from '../router'
|
||||
import { Avatar } from './Avatar'
|
||||
import { api } from '../api'
|
||||
|
||||
/** Das Konto hinter dem eigenen Namen.
|
||||
*
|
||||
@@ -16,6 +17,8 @@ import { Avatar } from './Avatar'
|
||||
export function Kontomenue() {
|
||||
const user = useStore((s) => s.user)
|
||||
const logout = useStore((s) => s.logout)
|
||||
const sitzungen = useStore((s) => s.sitzungen)
|
||||
const checkSession = useStore((s) => s.checkSession)
|
||||
const [offen, setOffen] = useState(false)
|
||||
const huelle = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -85,6 +88,33 @@ export function Kontomenue() {
|
||||
Konto und Passwort
|
||||
</button>
|
||||
|
||||
{/* Nur wenn es ueberhaupt eine zweite gibt. Ein Knopf, der
|
||||
"0 andere Sitzungen beendet" meldet, ist ein Knopf, der
|
||||
nichts tut -- und beim naechsten Mal drueckt man ihn
|
||||
wieder, weil man es vergessen hat. */}
|
||||
{sitzungen > 1 && (
|
||||
<button
|
||||
className={`${eintrag} border-t border-rand`}
|
||||
onClick={async () => {
|
||||
setOffen(false)
|
||||
await run(
|
||||
() => api.sitzungenBeenden(),
|
||||
(r) =>
|
||||
`${r.beendet} andere Sitzung(en) beendet. Diese hier bleibt.`,
|
||||
)
|
||||
void checkSession()
|
||||
}}
|
||||
>
|
||||
<ShieldOff size={15} />
|
||||
<span className="flex-1 text-left">
|
||||
Andere Sitzungen beenden
|
||||
<span className="block text-[11px] text-schrift-leise">
|
||||
{sitzungen} offen — an fremden Rechnern auch
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`${eintrag} border-t border-rand hover:text-schlecht`}
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { KeyRound, Link2, Loader2, Settings, UserRound } from 'lucide-react'
|
||||
import { api, type VerwaltungRow } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { zeit } from '../format'
|
||||
import { Leer } from './Bausteine'
|
||||
|
||||
/** Wer hat wann was EINGESTELLT.
|
||||
*
|
||||
* Der Datei-Verlauf daneben beantwortet „was ist mit den Dateien passiert".
|
||||
* Diese Liste beantwortet die andere Frage, die man stellt, wenn etwas anders
|
||||
* ist als gestern: **wer hat daran gedreht?**
|
||||
*
|
||||
* Bei zwei Konten ist das verschmerzbar. Sobald ein drittes dazukommt — und
|
||||
* über die Discord-Rolle entsteht es von selbst — ist es die erste Frage.
|
||||
*
|
||||
* WERTE STEHEN HIER NIE, nur Namen von Schlüsseln und Zielen. Unter den
|
||||
* Einstellungen liegen das Discord-Geheimnis und das Nextcloud-Passwort; ein
|
||||
* Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort
|
||||
* eine Tabelle mit allen, die es je gab. */
|
||||
const BEREICHE = {
|
||||
einstellung: { text: 'Einstellung', kind: Settings, farbe: 'text-akzent' },
|
||||
token: { text: 'Token', kind: KeyRound, farbe: 'text-warn' },
|
||||
benutzer: { text: 'Konto', kind: UserRound, farbe: 'text-akzent' },
|
||||
freigabe: { text: 'Freigabe', kind: Link2, farbe: 'text-gut' },
|
||||
ordner: { text: 'Ordner', kind: Settings, farbe: 'text-schrift-leise' },
|
||||
} as const
|
||||
|
||||
const TATEN = {
|
||||
angelegt: { text: 'angelegt', farbe: 'text-gut' },
|
||||
geaendert: { text: 'geändert', farbe: 'text-warn' },
|
||||
entfernt: { text: 'entfernt', farbe: 'text-schlecht' },
|
||||
} as const
|
||||
|
||||
export function VerwaltungListe() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [daten, setDaten] = useState<VerwaltungRow[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let lebt = true
|
||||
api.verwaltung(200)
|
||||
.then((r) => lebt && setDaten(r.items))
|
||||
.catch((err) => lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
|
||||
return () => {
|
||||
lebt = false
|
||||
}
|
||||
}, [note])
|
||||
|
||||
if (!daten) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="animate-spin text-schrift-leise" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (daten.length === 0) {
|
||||
return (
|
||||
<Leer
|
||||
kind={<Settings size={26} className="text-schrift-leise/50" />}
|
||||
text="Noch nichts verwaltet — oder alles, bevor es diese Liste gab."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<colgroup>
|
||||
<col className="w-36" />
|
||||
<col className="w-28" />
|
||||
<col className="w-24" />
|
||||
<col />
|
||||
<col className="w-32" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-schrift-leise">
|
||||
<th className="px-3 pb-2 font-medium">Wann</th>
|
||||
<th className="px-3 pb-2 font-medium">Bereich</th>
|
||||
<th className="px-3 pb-2 font-medium">Was</th>
|
||||
<th className="px-3 pb-2 font-medium">Ziel</th>
|
||||
<th className="px-3 pb-2 font-medium">Wer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.map((z) => {
|
||||
const b = BEREICHE[z.bereich] ?? BEREICHE.ordner
|
||||
const t = TATEN[z.tat] ?? TATEN.geaendert
|
||||
const Zeichen = b.kind
|
||||
return (
|
||||
<tr key={z.id} className="border-t border-rand">
|
||||
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
|
||||
{zeit(z.at)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span className={`flex items-center gap-1.5 text-xs ${b.farbe}`}>
|
||||
<Zeichen size={13} />
|
||||
{b.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-3 py-1.5 text-xs ${t.farbe}`}>{t.text}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="font-mono text-xs">{z.ziel || '—'}</span>
|
||||
{z.was && (
|
||||
<span className="block text-[11px] text-schrift-leise">
|
||||
{z.was}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="truncate px-3 py-1.5 text-schrift-leise">{z.wer}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Karte, Knopf, Leer } from '../components/Bausteine'
|
||||
import { useAuffrischen } from '../auffrischen'
|
||||
import { useMerker } from '../merker'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import { VerwaltungListe } from '../components/VerwaltungListe'
|
||||
|
||||
const SEITENGROESSEN = [25, 50, 100, 200] as const
|
||||
|
||||
@@ -42,6 +43,10 @@ export function Verlauf() {
|
||||
const [wer, setWer] = useState('')
|
||||
const [proSeite, setProSeite] = useMerker('verlauf.proSeite', 50)
|
||||
const [suche, setSuche] = useState('')
|
||||
// Zwei Verläufe, eine Seite. Der eine beantwortet „was ist mit den Dateien
|
||||
// passiert", der andere „wer hat daran gedreht" — dieselbe Frage aus zwei
|
||||
// Richtungen, und beide gehören dorthin, wo man Verlauf sucht.
|
||||
const [reiter, setReiter] = useState<'dateien' | 'verwaltung'>('dateien')
|
||||
|
||||
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
|
||||
// "vehicles" eine Anfrage je Buchstabe raus.
|
||||
@@ -81,8 +86,20 @@ export function Verlauf() {
|
||||
const bilder = new Map((daten?.akteure ?? []).map((a) => [a.actor, a.avatar]))
|
||||
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
|
||||
|
||||
if (reiter === 'verwaltung') {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Reiter reiter={reiter} setReiter={setReiter} />
|
||||
<Karte>
|
||||
<VerwaltungListe />
|
||||
</Karte>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Reiter reiter={reiter} setReiter={setReiter} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Filter an={art === ''} onClick={() => { setArt(''); setSeite(0) }} text="Alles" />
|
||||
{(Object.keys(arten) as EventKind[]).map((k) => (
|
||||
@@ -342,3 +359,35 @@ function Filter({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die zwei Verläufe nebeneinander.
|
||||
*
|
||||
* Als Reiter und nicht als zweite Seite im Menü: es ist beides ein Verlauf,
|
||||
* und wer einen sucht, sucht nicht zwei Menüeinträge ab. */
|
||||
function Reiter({
|
||||
reiter,
|
||||
setReiter,
|
||||
}: {
|
||||
reiter: 'dateien' | 'verwaltung'
|
||||
setReiter: (r: 'dateien' | 'verwaltung') => void
|
||||
}) {
|
||||
const stil = (an: boolean) =>
|
||||
`rounded-md px-3 py-1.5 text-sm ${
|
||||
an
|
||||
? 'bg-flaeche-hoch text-schrift'
|
||||
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
|
||||
}`
|
||||
return (
|
||||
<div className="flex gap-1 self-start rounded-lg border border-rand bg-flaeche p-1">
|
||||
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
|
||||
Dateien
|
||||
</button>
|
||||
<button
|
||||
className={stil(reiter === 'verwaltung')}
|
||||
onClick={() => setReiter('verwaltung')}
|
||||
>
|
||||
Verwaltung
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ type State = {
|
||||
checking: boolean
|
||||
notes: Note[]
|
||||
|
||||
/** Wie viele Sitzungen dieses Konto offen hat. 0 = noch nicht geladen. */
|
||||
sitzungen: number
|
||||
checkSession: () => Promise<void>
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
@@ -30,12 +32,13 @@ type State = {
|
||||
export const useStore = create<State>((set, get) => ({
|
||||
user: null,
|
||||
checking: true,
|
||||
sitzungen: 0,
|
||||
notes: [],
|
||||
|
||||
async checkSession() {
|
||||
try {
|
||||
const { user } = await api.me()
|
||||
set({ user, checking: false })
|
||||
const { user, sitzungen } = await api.me()
|
||||
set({ user, sitzungen: sitzungen ?? 0, checking: false })
|
||||
} catch (err) {
|
||||
// 401 ist hier kein Fehler, sondern die Antwort "noch nicht
|
||||
// angemeldet". Alles andere ist einer und wird gesagt.
|
||||
|
||||
Reference in New Issue
Block a user