FEHLENDE BILDER. Die Frage, die bisher niemand beantwortet hat: das Fotostudio weiss, was es aufgenommen hat, der Dienst weiss, was liegt, und die Differenz rechnet keiner aus -- weshalb "das Handy zeigt bei manchen Autos kein Bild" erst im Spiel auffaellt. POST /api/dash/fehlende nimmt ein Muster mit genau EINEM Platzhalter und eine Liste (als Array oder als Textblock aus einem Textfeld) und gibt beide Richtungen zurueck: was fehlt, und was daliegt ohne in der Liste zu stehen. Beides bedeutet etwas anderes -- fehlende sind Arbeit fuers Fotostudio, ueberzaehlige sind Platz, den man zurueckbekommt. Gesucht wird KLEINGESCHRIEBEN, weil so abgelegt wird: wer "WEAPON_SMG" hereinreicht, meint weapon_smg.png und bekaeme sonst ein "fehlt", das gelogen waere. Gemessen an echten Namen: 6 geprueft, 4 da, 2 fehlen, 985 ueberzaehlig. MASSEN-UMBENENNEN mit Vorschau. Bei 3578 Items ist eine Umbenennung von Hand keine. DIE VORSCHAU IST DER EIGENTLICHE TEIL: was hier passiert, ist dieselbe Warnung wie beim Ordner-Umbenennen -- jede alte Adresse gibt danach 404 --, nur mal tausend. Geprueft wird VOR dem ersten Schritt: Kollisionen untereinander, belegte Ziele, unzulaessige Pfade. Und EIN Problem verhindert ALLES; eine halbe Umbenennung ist der Zustand, aus dem niemand mehr herausfindet, weil die Grenze nirgends steht. Gemessen: Vorschau ueber 134 Dateien fasst nichts an; eine erzwungene Kollision gibt 409 und laesst alle drei Dateien stehen; der saubere Lauf benennt zwei um, Vorschau wandert mit, ein Eintrag im Verlauf statt zwei. BILDER BEIM ABLEGEN VERKLEINERN, je Token einstellbar. NICHT als Schutz -- das waere gemessen falsch, ein 12000x12000-PNG kostet 374 ms und 47 MB, weil libvips kachelweise arbeitet. Es geht um Plattenplatz und Ladezeit: ein Bildschirmfoto ist 3840 breit und wird auf einem Handy auf 400 gezeigt. withoutEnlargement ist dabei entscheidend, sonst blaeht die Einstellung kleine Icons auf. DABEI WAERE MIR EIN ECHTER FEHLER UNTERLAUFEN: der sha256 wird weiter oben aus dem Original gerechnet (fuer den Ersatzpfad, wenn kein X-Path kam). Wird das Bild verkleinert, ist das eine ANDERE Datei -- ein Datensatz mit dem Hash des Originals waere schlicht falsch, und jede spaetere Gegenprobe verglichen gegen etwas, das nirgends liegt. Gemessen nach der Korrektur: 3000x2000 -> 512x341, 82 KB -> 3 KB, und Datenbank und Platte stimmen in Groesse und Hash ueberein. EINSTELLUNGEN MITNEHMEN. Export als JSON, Import zurueck. GEHEIMNISSE GEHEN STANDARDMAESSIG NICHT MIT: unter den Schluesseln liegen das Discord-Client-Geheimnis und das Nextcloud-Passwort, und eine Datei, die man "mal eben" exportiert und in einen Chat zieht, ist genau der Weg, auf dem so etwas abhandenkommt. Wer sie braucht, sagt ?geheim=1 -- und der Hinweis steht dann IN der Datei, nicht nur auf dem Bildschirm, von dem er beim Weiterreichen abfaellt. Der Export mit Geheimnissen steht im Verwaltungs-Verlauf. Beim Import ueberschreiben LEERE WERTE NICHTS: eine Datei ohne Geheimnisse -- der Normalfall -- traegt sie als leere Zeichenkette, und die darf ein vorhandenes Passwort nicht loeschen. Gemessen: nach einem Import mit leerem client_secret stehen die 32 Zeichen unveraendert da. Nicht mitgenommen wird sicherung.letzte -- das ist der Bericht des letzten Laufs, kein Einstellungswert. In eine frische Anlage eingespielt behauptete er dem Wachhund eine Sicherung, die es dort nie gab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
536 lines
21 KiB
TypeScript
536 lines
21 KiB
TypeScript
// Die Ablage: SQLite, eine Datei neben den Bildern.
|
|
//
|
|
// Warum nicht PostgreSQL: weil hier eine Handvoll Tabellen liegt, auf die ein
|
|
// Server schreibt. SQLite ist dafuer nicht die kleine Loesung, sondern die
|
|
// angemessene — sie kostet keinen Container, keine Verbindung, kein Passwort,
|
|
// und eine Sicherung ist ein Dateikopie. Wenn daraus je Millionen Zeilen
|
|
// werden, tauschen wir dieses Modul aus; die Aufrufer sehen davon nichts.
|
|
|
|
import Database from 'better-sqlite3'
|
|
import { config } from './config.js'
|
|
|
|
export const db = new Database(config.dbPath)
|
|
|
|
// WAL: Lesen blockiert Schreiben nicht. Bei einem Dashboard, das waehrend
|
|
// eines laufenden Serienuploads die Galerie zeigt, ist genau das der Fall.
|
|
db.pragma('journal_mode = WAL')
|
|
db.pragma('foreign_keys = ON')
|
|
db.pragma('busy_timeout = 5000')
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
-- Nur der Hash. Der Token selbst wird genau einmal gezeigt und ist danach
|
|
-- auch fuer uns nicht mehr lesbar — wer ihn verliert, legt einen neuen an.
|
|
hash TEXT NOT NULL UNIQUE,
|
|
-- Optionale Fessel: dieser Token darf nur unterhalb dieses Praefix
|
|
-- schreiben. Leer heisst ueberall.
|
|
prefix TEXT NOT NULL DEFAULT '',
|
|
can_delete INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
last_used_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media (
|
|
id INTEGER PRIMARY KEY,
|
|
-- Der Pfad IST der Schluessel. Fuer das Fotostudio ist das der ganze
|
|
-- Punkt: vehicles/adder.webp bleibt vehicles/adder.webp, damit die
|
|
-- Vorlage im Handy {model}.webp lauten kann und niemand eine
|
|
-- Uebersetzungstabelle mitschleppen muss.
|
|
path TEXT NOT NULL UNIQUE,
|
|
size INTEGER NOT NULL,
|
|
sha256 TEXT NOT NULL,
|
|
mime TEXT NOT NULL,
|
|
token_id INTEGER REFERENCES tokens(id) ON DELETE SET NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC);
|
|
|
|
-- Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht.
|
|
--
|
|
-- WARUM EINE EIGENE TABELLE und nicht einfach media.created_at. Drei Gruende,
|
|
-- und jeder allein wuerde reichen:
|
|
--
|
|
-- 1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg --
|
|
-- samt der Frage, wer es geloescht hat.
|
|
-- 2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
|
|
-- Auskunft, wann die Datei urspruenglich kam.
|
|
-- 3. media.token_id ist bei einem Upload aus dem Dashboard NULL: dort hat
|
|
-- niemand einen Token, sondern eine Sitzung. Das "von wem" stand also
|
|
-- nirgends.
|
|
--
|
|
-- Das ist NICHT das Logging, das in der ROADMAP abgelehnt wird -- dort geht es
|
|
-- um Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je
|
|
-- Upload in derselben SQLite-Datei.
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY,
|
|
at INTEGER NOT NULL,
|
|
-- 'upload' | 'replace' | 'delete'
|
|
kind TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
size INTEGER,
|
|
-- 'token' | 'user' | 'unbekannt'
|
|
actor_kind TEXT NOT NULL,
|
|
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.
|
|
-- Der Papierkorb.
|
|
--
|
|
-- Loeschen war bis hierhin endgueltig: die Datei war von der Platte weg, und
|
|
-- die Sicherung half nur bis zum letzten naechtlichen Lauf. Bei einem Knopf
|
|
-- "alle 3670 waehlen" direkt neben "loeschen" ist das die falsche Haerte.
|
|
--
|
|
-- Die Datei wandert deshalb nach /data/papierkorb und der Datensatz hierher.
|
|
-- Was hier steht, ist alles, was zum Zurueckholen noetig ist -- der Pfad ist
|
|
-- der Schluessel, und den behalten wir.
|
|
CREATE TABLE IF NOT EXISTS papierkorb (
|
|
id INTEGER PRIMARY KEY,
|
|
path TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
sha256 TEXT NOT NULL,
|
|
mime TEXT NOT NULL,
|
|
geloescht INTEGER NOT NULL,
|
|
von TEXT NOT NULL,
|
|
-- Der Name IM Papierkorb. Nicht der Pfad: zwei Dateien koennen nacheinander
|
|
-- unter demselben Pfad gelegen haben, und beide sollen zurueckholbar sein.
|
|
ablage TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS papierkorb_zeit ON papierkorb(geloescht DESC);
|
|
|
|
-- Freigaben: ein Ordner, den man ohne Anmeldung ansehen kann.
|
|
--
|
|
-- WOZU. Die Dateien selbst sind ohnehin oeffentlich -- wer die Adresse kennt,
|
|
-- bekommt das Bild. Was NICHT oeffentlich ist, ist die LISTE: welche Bilder es
|
|
-- gibt. Genau die will man manchmal zeigen (ein Fahrzeugkatalog fuer Spieler)
|
|
-- und sonst nicht.
|
|
--
|
|
-- Der Schluessel ist eine Zufallszeichenkette in der Adresse und kein Passwort:
|
|
-- wer den Link hat, sieht die Liste. Das ist dieselbe Zusicherung wie bei einem
|
|
-- Nextcloud-Freigabelink, und sie steht so auch im Dashboard -- damit niemand
|
|
-- glaubt, hier haenge eine Anmeldung dran.
|
|
CREATE TABLE IF NOT EXISTS freigaben (
|
|
id INTEGER PRIMARY KEY,
|
|
schluessel TEXT NOT NULL UNIQUE,
|
|
ordner TEXT NOT NULL,
|
|
titel TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL,
|
|
-- Nicht "wie oft geoeffnet" als Selbstzweck: es beantwortet die Frage, ob
|
|
-- ein alter Link noch benutzt wird, bevor man ihn wegwirft.
|
|
aufrufe INTEGER NOT NULL DEFAULT 0,
|
|
zuletzt INTEGER
|
|
);
|
|
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,
|
|
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')
|
|
}
|
|
// 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.
|
|
// Grenzen je Token. Sie beantworten die Frage, die "Token-Arten" nur
|
|
// scheinbar beantwortet: was passiert, wenn DIESER Token abhandenkommt.
|
|
// Das GESAMTKONTINGENT, nicht die Groesse EINER Datei -- max_bytes gab es
|
|
// laengst, aber es galt je Upload. Ein Token mit "hoechstens 2 MB je Bild"
|
|
// konnte trotzdem die Platte fuellen, es brauchte nur genug Bilder.
|
|
const kSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[]
|
|
// Groesste erlaubte Kantenlaenge fuer Bilder ueber diesen Token. Null =
|
|
// unveraendert ablegen.
|
|
if (!kSpalten.some((s) => s.name === 'max_kante')) {
|
|
db.exec('ALTER TABLE tokens ADD COLUMN max_kante INTEGER')
|
|
console.log('[db] Spalte tokens.max_kante angelegt')
|
|
}
|
|
|
|
if (!kSpalten.some((s) => s.name === 'quota_bytes')) {
|
|
db.exec('ALTER TABLE tokens ADD COLUMN quota_bytes INTEGER')
|
|
console.log('[db] Spalte tokens.quota_bytes angelegt')
|
|
}
|
|
|
|
const tSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[]
|
|
if (!tSpalten.some((s) => s.name === 'max_bytes')) {
|
|
db.exec('ALTER TABLE tokens ADD COLUMN max_bytes INTEGER')
|
|
// Komma-getrennte Artnamen ('bild,video'). Leer heisst: alles, was der
|
|
// Dienst kennt.
|
|
db.exec("ALTER TABLE tokens ADD COLUMN arten TEXT NOT NULL DEFAULT ''")
|
|
db.exec('ALTER TABLE tokens ADD COLUMN expires_at INTEGER')
|
|
console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt')
|
|
}
|
|
|
|
const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[]
|
|
if (!eSpalten.some((s) => s.name === 'von')) {
|
|
// Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese
|
|
// Spalte stuende im Verlauf nur das Ziel, und die Frage "wo war die
|
|
// vorher" waere nicht mehr zu beantworten.
|
|
db.exec('ALTER TABLE events ADD COLUMN von TEXT')
|
|
console.log('[db] Spalte events.von angelegt')
|
|
}
|
|
|
|
// Abrufe. Die Frage "was braucht eigentlich noch jemand" laesst sich von
|
|
// innen nicht beantworten -- von aussen schon: wer wird geholt, wie oft,
|
|
// wann zuletzt. Das ist die Zahl, die dem Aufbewahrungsbericht fehlt.
|
|
// Die Laenge in Sekunden -- nur bei Video und Ton gefuellt. Beantwortet
|
|
// auf der Kachel die Frage, die ein Standbild nicht beantwortet: WIE LANG.
|
|
const dSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
|
|
if (!dSpalten.some((s) => s.name === 'dauer')) {
|
|
db.exec('ALTER TABLE media ADD COLUMN dauer REAL')
|
|
console.log('[db] Spalte media.dauer angelegt')
|
|
}
|
|
|
|
const mSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
|
|
if (!mSpalten.some((s) => s.name === 'abrufe')) {
|
|
db.exec('ALTER TABLE media ADD COLUMN abrufe INTEGER NOT NULL DEFAULT 0')
|
|
db.exec('ALTER TABLE media ADD COLUMN zuletzt_abgerufen INTEGER')
|
|
console.log('[db] Spalten media.abrufe und zuletzt_abgerufen angelegt')
|
|
}
|
|
|
|
// Wann war der zuletzt da? Steht sonst nirgends: sessions weiss es, aber
|
|
// eine abgelaufene Sitzung ist weg, und dann sieht ein Konto, das seit
|
|
// einem halben Jahr niemand benutzt, aus wie eines von gestern.
|
|
if (!spalten.some((s) => s.name === 'zuletzt_da')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN zuletzt_da INTEGER')
|
|
console.log('[db] Spalte users.zuletzt_da angelegt')
|
|
}
|
|
|
|
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
|
|
// wenn er leer ist.
|
|
//
|
|
// Ohne das stuende der Tab am ersten Tag leer da, obwohl 46 Bilder in der
|
|
// Ablage liegen; das sieht aus wie ein kaputter Tab. Die nachgetragenen
|
|
// Eintraege sind ehrlich als solche gekennzeichnet: sie tragen den
|
|
// urspruenglichen Zeitpunkt, aber ueber Loeschungen und Ersetzungen von
|
|
// frueher weiss niemand mehr etwas.
|
|
{
|
|
const vorhanden = db.prepare('SELECT COUNT(*) AS n FROM events').get() as { n: number }
|
|
const medien = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }
|
|
|
|
if (vorhanden.n === 0 && medien.n > 0) {
|
|
const nachtragen = db.prepare(
|
|
`INSERT INTO events (at, kind, path, size, actor_kind, actor)
|
|
SELECT m.created_at, 'upload', m.path, m.size,
|
|
CASE WHEN t.name IS NULL THEN 'unbekannt' ELSE 'token' END,
|
|
COALESCE(t.name, 'vor der Aufzeichnung')
|
|
FROM media m LEFT JOIN tokens t ON t.id = m.token_id`,
|
|
)
|
|
const info = nachtragen.run()
|
|
console.log(`[db] Verlauf aus dem Bestand nachgetragen: ${info.changes} Eintraege`)
|
|
}
|
|
}
|
|
|
|
export type User = {
|
|
zuletzt_da?: number | null
|
|
id: number
|
|
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
|
|
/** 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 = {
|
|
id: number
|
|
name: string
|
|
hash: string
|
|
prefix: string
|
|
can_delete: number
|
|
created_at: number
|
|
last_used_at: number | null
|
|
/** Groesste erlaubte Datei fuer DIESEN Token. Null heisst: die Grenze des
|
|
* Dienstes gilt (MAX_UPLOAD_MB). */
|
|
/** Groesste erlaubte EINZELNE Datei. */
|
|
max_bytes: number | null
|
|
/** Wieviel dieser Token INSGESAMT halten darf. Null = so viel er will. */
|
|
quota_bytes: number | null
|
|
/** Groesste Kantenlaenge fuer Bilder. Null = ablegen, wie es kommt. */
|
|
max_kante: number | null
|
|
/** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */
|
|
arten: string
|
|
/** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */
|
|
expires_at: number | null
|
|
}
|
|
|
|
export type Media = {
|
|
id: number
|
|
path: string
|
|
size: number
|
|
sha256: string
|
|
mime: string
|
|
token_id: number | null
|
|
created_at: number
|
|
updated_at: number
|
|
/** Wie oft die Datei oeffentlich geholt wurde. */
|
|
abrufe: number
|
|
zuletzt_abgerufen: number | null
|
|
/** Laenge in Sekunden — nur bei Video und Ton, sonst null. */
|
|
dauer: number | null
|
|
}
|
|
|
|
export type Freigabe = {
|
|
id: number
|
|
schluessel: string
|
|
ordner: string
|
|
titel: string
|
|
created_at: number
|
|
aufrufe: number
|
|
zuletzt: number | null
|
|
}
|
|
|
|
export type BenutzerZeile = {
|
|
id: number
|
|
username: string
|
|
created_at: number
|
|
zuletzt_da: number | null
|
|
discord_id: string | null
|
|
discord_name: string | null
|
|
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
|
|
size: number
|
|
sha256: string
|
|
mime: string
|
|
geloescht: number
|
|
von: string
|
|
ablage: string
|
|
}
|
|
|
|
// 'rename' steht neben 'move', obwohl es technisch dasselbe ist: eine Datei
|
|
// bekommt einen anderen Pfad. Fuer den, der den Verlauf LIEST, ist es aber
|
|
// etwas anderes -- verschoben heisst "liegt woanders", umbenannt heisst "heisst
|
|
// anders, liegt noch da". Ein Verlauf, der beides "verschoben" nennt, laesst
|
|
// genau die Frage offen, wegen der man ihn aufschlaegt.
|
|
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
|
|
|
|
export type MediaEvent = {
|
|
id: number
|
|
at: number
|
|
kind: EventKind
|
|
path: string
|
|
size: number | null
|
|
actor_kind: 'token' | 'user' | 'unbekannt'
|
|
actor: string
|
|
/** Bei 'move' und 'rename': der Pfad, unter dem die Datei vorher lag. */
|
|
von: string | null
|
|
}
|
|
|
|
export const now = () => Date.now()
|
|
|
|
const eventEinfuegen = db.prepare(
|
|
`INSERT INTO events (at, kind, path, size, actor_kind, actor, von)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
|
|
/** Einen Eintrag in den Verlauf schreiben.
|
|
*
|
|
* Wirft NICHT: ein Verlauf, der einen Upload scheitern laesst, waere die
|
|
* Buchhaltung, die das Geschaeft verhindert. Wenn hier etwas schiefgeht,
|
|
* steht es in der Konsole und die Datei liegt trotzdem richtig. */
|
|
export function logEvent(
|
|
kind: EventKind,
|
|
path: string,
|
|
size: number | null,
|
|
actorKind: MediaEvent['actor_kind'],
|
|
actor: string,
|
|
von?: string,
|
|
): void {
|
|
try {
|
|
eventEinfuegen.run(now(), kind, path, size, actorKind, actor, von ?? null)
|
|
} catch (err) {
|
|
console.error('[events] konnte nicht schreiben:', err)
|
|
}
|
|
}
|
|
|
|
/** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das
|
|
* 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu
|
|
* behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */
|
|
// -- Abrufe zaehlen, ohne die Auslieferung zu bremsen -------------------------
|
|
//
|
|
// Ein UPDATE je Bildabruf waere bei einer Galerieseite mit 120 Kacheln ein
|
|
// Schreibvorgang je Kachel. Stattdessen wird im Speicher gezaehlt und
|
|
// gelegentlich weggeschrieben -- wer wissen will, ob eine Datei benutzt wird,
|
|
// braucht keine Genauigkeit auf die Sekunde.
|
|
//
|
|
// Was bei einem Absturz verlorengeht, sind hoechstens 30 Sekunden Zaehlerei.
|
|
|
|
const offeneAbrufe = new Map<string, { anzahl: number; zuletzt: number }>()
|
|
|
|
export function zaehleAbruf(path: string): void {
|
|
const bisher = offeneAbrufe.get(path)
|
|
if (bisher) {
|
|
bisher.anzahl++
|
|
bisher.zuletzt = now()
|
|
} else {
|
|
offeneAbrufe.set(path, { anzahl: 1, zuletzt: now() })
|
|
}
|
|
}
|
|
|
|
const abrufSchreiben = db.prepare(
|
|
'UPDATE media SET abrufe = abrufe + ?, zuletzt_abgerufen = ? WHERE path = ?',
|
|
)
|
|
|
|
export function schreibeAbrufe(): void {
|
|
if (offeneAbrufe.size === 0) return
|
|
const stapel = [...offeneAbrufe.entries()]
|
|
offeneAbrufe.clear()
|
|
try {
|
|
db.transaction(() => {
|
|
for (const [path, w] of stapel) abrufSchreiben.run(w.anzahl, w.zuletzt, path)
|
|
})()
|
|
} catch (err) {
|
|
console.error('[abrufe] konnten nicht geschrieben werden:', err)
|
|
}
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
|
|
/** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich —
|
|
* sonst waechst die Tabelle ewig, und niemand merkt es. */
|
|
export function pruneSessions() {
|
|
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now())
|
|
}
|