DER NAME KAM VIERMAL FEST AUS DEM QUELLTEXT: Kopfzeile, Anmeldeseite, Fusszeile
einer Freigabe, Tab-Titel -- und zwar als "d4rk_media", was der Name des
Repositorys ist und nicht der des Dienstes. Wer ihn aendern wollte, musste vier
Stellen finden und eine davon uebersehen.
Er kommt jetzt vom Server, aus der Einstellung marke.name -- DERSELBEN, die
schon in der Fusszeile der Discord-Meldungen steht ("D4RKST3R // MEDIA"). Ein
zweiter Name fuer die Oberflaeche waere ein zweiter, der beim naechsten Mal
anders lautet als der erste.
Ausgeliefert wird er ueber /api/dash/auth/config (oeffentlich, die
Anmeldeseite hat noch keine Sitzung) und in der Freigabe-Antwort (die Seite ist
oeffentlich und holt sonst nichts, was ihn kennen koennte). Angezeigt wird
"<marke> Media" -- die Marke allein waere falsch, sie steht ueber allem, was
hier laeuft.
EINE STELLE BLEIBT ABSICHTLICH: der Schluessel im Speicher des Browsers
(merker.ts) heisst weiter "d4rk_media.". Wer ihn umbenennt, wirft still alle
gemerkten Ansichtseinstellungen weg, und niemand versteht, warum die Galerie
ploetzlich wieder bei 60 Kacheln steht. Steht als Kommentar daneben.
---
UND DER NEUE NAME HAENGT HINTER CLOUDFLARE, was die WebP-Auslieferung bricht.
NPM ist sauber, sogar besser als vorgeschlagen: beide Namen auf DEMSELBEN Host,
die drei ^~-Bloecke gelten damit fuer beide. Der DNS-Eintrag geht aber ueber
Cloudflare (188.114.97.3, Server: cloudflare), waehrend fivemanage direkt auf
88.218.224.10 zeigt.
Gemessen, dieselbe Datei:
fivemanage (direkt) ohne Accept -> image/png 12295 B richtig
mit Accept -> image/webp 2270 B richtig
media (Cloudflare) 1. mit Accept -> image/webp 2270 B MISS
2. mit Accept -> image/webp 2270 B HIT
3. OHNE Accept -> image/webp 2270 B HIT FALSCH
Und andersherum genauso -- wer zuerst kommt, praegt den Zwischenspeicher fuer
alle. Es sind unsere eigenen Bytes (2270 = unsere WebP-Fassung), also kein
Polish, sondern ein Cache-Schluessel ohne Accept. Vary: Accept wird an den
Client durchgereicht, aber nicht zum Unterscheiden benutzt.
Fuer ein Lua-Skript heisst das: items/foo.png liefert WebP-Bytes unter einem
.png-Namen, und das faellt erst auf, wenn irgendwo ein Bild nicht laedt.
PUBLIC_URL ist deshalb NICHT umgestellt. Genau davor stand die Warnung im
letzten Commit, und sie war keine Vorsicht, sondern eine Vorhersage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2609 lines
100 KiB
TypeScript
2609 lines
100 KiB
TypeScript
// Die Schnittstelle fuer das Dashboard.
|
|
//
|
|
// Eine Regel zieht sich durch alles hier: JEDE Antwort sagt, was passiert ist
|
|
// — auch die erfolgreiche. Bei Fivemanage hat genau das gefehlt, und das
|
|
// Ergebnis waren neunzehn gleichnamige Organisationen, weil ein Knopf ohne
|
|
// Rueckmeldung wie ein kaputter Knopf aussieht.
|
|
|
|
import { Hono } from 'hono'
|
|
import { unzipSync } from 'fflate'
|
|
import type { Context } from 'hono'
|
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
import { config } from '../config.js'
|
|
import {
|
|
avatarUrl,
|
|
db,
|
|
logEvent,
|
|
now,
|
|
pruneEvents,
|
|
pruneSessions,
|
|
type Media,
|
|
type BenutzerZeile,
|
|
type Freigabe,
|
|
type MediaEvent,
|
|
type PapierkorbEintrag,
|
|
type Token,
|
|
type User,
|
|
} from '../db.js'
|
|
import {
|
|
SESSION_COOKIE,
|
|
createSession,
|
|
destroySession,
|
|
generateToken,
|
|
hashPassword,
|
|
tokenHash,
|
|
userForSession,
|
|
verifyPassword,
|
|
} from '../auth.js'
|
|
import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import {
|
|
DISCORD_KEYS,
|
|
MARKE_KEY,
|
|
SICHERUNG_KEYS,
|
|
ausDatenbank,
|
|
discordConfig,
|
|
getSetting,
|
|
setSetting,
|
|
setzeVorlagen,
|
|
sicherungConfig,
|
|
vorlagen,
|
|
type Vorlage,
|
|
} from '../settings.js'
|
|
import { discordRoutes, redirectUri } from './discord.js'
|
|
import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
|
import { neuerSchluessel } from './freigabe.js'
|
|
import {
|
|
absolutePath,
|
|
checkPath,
|
|
ausDemPapierkorb,
|
|
deleteFile,
|
|
inDenPapierkorb,
|
|
kannBewegtVorschau,
|
|
kannVorschau,
|
|
lohntWebp,
|
|
mimeFor,
|
|
moveFile,
|
|
moveFolder,
|
|
publicUrlFor,
|
|
safeFilename,
|
|
sha256,
|
|
papierkorbPfad,
|
|
thumbPath,
|
|
webpPfad,
|
|
writeBewegtThumb,
|
|
writeFileAtomic,
|
|
writeThumb,
|
|
writeWebp,
|
|
PathError,
|
|
} from '../storage.js'
|
|
|
|
type Vars = { user: User }
|
|
|
|
export const dashRoutes = new Hono<{ Variables: Vars }>()
|
|
|
|
/** Den JSON-Rumpf lesen — oder null, wenn keiner ankam.
|
|
*
|
|
* c.req.json() wirft bei kaputtem JSON, und Hono macht daraus einen nackten
|
|
* "Internal Server Error" ohne ein Wort dazu. Genau diese Sorte Antwort ist
|
|
* der Grund, warum wir hier neu bauen. */
|
|
async function jsonBody<T>(c: Context<any>): Promise<T | null> {
|
|
try {
|
|
return await c.req.json<T>()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
|
|
|
|
/** Wie lange Geloeschtes im Papierkorb bleibt. 30 Tage: lang genug, dass ein
|
|
* Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */
|
|
const PAPIERKORB_TAGE = 30
|
|
|
|
// ------------------------------------------------------------ Die Bremse
|
|
//
|
|
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
|
|
// teuer, aber teuer ist nicht selten: ohne Bremse darf jemand beliebig oft
|
|
// raten, und irgendwann ist "sommer2024" dran.
|
|
//
|
|
// Bewusst im Speicher und nicht in der Datenbank: die Sperre soll einen
|
|
// Neustart NICHT ueberleben. Wer sich selbst ausgesperrt hat, startet den
|
|
// Container neu und kommt rein — bei einem Dienst, den eine Person betreibt,
|
|
// ist das die richtige Notbremse.
|
|
|
|
const FREI_VERSUCHE = 5
|
|
const GRUNDSPERRE_MS = 30_000
|
|
const HOECHSTSPERRE_MS = 15 * 60_000
|
|
|
|
type Versuche = { fehl: number; gesperrtBis: number }
|
|
const versuche = new Map<string, Versuche>()
|
|
|
|
/** Wer da klopft.
|
|
*
|
|
* X-Real-IP zuerst: NPM setzt den Kopf mit `$remote_addr` und UEBERSCHREIBT
|
|
* damit, was der Aufrufer selbst hineinschreibt. Das gilt aber nur fuer den
|
|
* Weg durch den Proxy — wer den Container direkt auf seinem Port erreicht,
|
|
* kann sich jede Adresse ausdenken. Genau deshalb gehoert dieser Port hinter
|
|
* die Firewall, und das steht so in der ROADMAP. */
|
|
function woher(c: Context<any>): string {
|
|
const real = c.req.header('x-real-ip')?.trim()
|
|
if (real) return real
|
|
const weiter = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
|
|
if (weiter) return weiter
|
|
// @hono/node-server reicht die Node-Anfrage durch; ohne Proxy ist das die
|
|
// einzige Quelle, die nicht vom Aufrufer stammt.
|
|
const socket = (c.env as any)?.incoming?.socket?.remoteAddress
|
|
return typeof socket === 'string' && socket ? socket : 'unbekannt'
|
|
}
|
|
|
|
/** Wie lange noch gesperrt, in Sekunden — oder 0. */
|
|
function gesperrt(key: string): number {
|
|
const eintrag = versuche.get(key)
|
|
if (!eintrag) return 0
|
|
const rest = eintrag.gesperrtBis - Date.now()
|
|
return rest > 0 ? Math.ceil(rest / 1000) : 0
|
|
}
|
|
|
|
function fehlversuch(key: string): void {
|
|
const eintrag = versuche.get(key) ?? { fehl: 0, gesperrtBis: 0 }
|
|
eintrag.fehl += 1
|
|
|
|
if (eintrag.fehl > FREI_VERSUCHE) {
|
|
// Verdopplung ab dem sechsten Fehlversuch, gedeckelt. Aus 30 Sekunden
|
|
// werden schnell Minuten — fuer einen Tippfehler unmerklich, fuer eine
|
|
// Liste mit Millionen Passwoertern das Ende.
|
|
const stufe = eintrag.fehl - FREI_VERSUCHE - 1
|
|
const dauer = Math.min(GRUNDSPERRE_MS * 2 ** stufe, HOECHSTSPERRE_MS)
|
|
eintrag.gesperrtBis = Date.now() + dauer
|
|
}
|
|
|
|
versuche.set(key, eintrag)
|
|
}
|
|
|
|
const geschafft = (key: string) => versuche.delete(key)
|
|
|
|
// Aufraeumen, damit die Karte nicht endlos waechst. Stuendlich reicht: was
|
|
// laenger als eine Stunde nicht mehr geklopft hat, ist kein laufender Angriff.
|
|
setInterval(
|
|
() => {
|
|
const jetzt = Date.now()
|
|
for (const [key, eintrag] of versuche) {
|
|
if (eintrag.gesperrtBis < jetzt - 3600_000) versuche.delete(key)
|
|
}
|
|
},
|
|
3600_000,
|
|
).unref()
|
|
|
|
// ---------------------------------------------------------------- Anmeldung
|
|
|
|
dashRoutes.post('/auth/login', async (c) => {
|
|
const key = woher(c)
|
|
|
|
// Erst die Sperre, dann alles andere: ein gesperrter Versuch soll nicht
|
|
// einmal scrypt kosten.
|
|
const rest = gesperrt(key)
|
|
if (rest > 0) {
|
|
return c.json(
|
|
{
|
|
error: `Zu viele Fehlversuche. Noch ${rest} Sekunden warten.`,
|
|
},
|
|
429,
|
|
{ 'Retry-After': String(rest) },
|
|
)
|
|
}
|
|
|
|
const body = await jsonBody<{ username?: string; password?: string }>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
const { username, password } = body
|
|
|
|
// Ein leeres Formular ist ein Bedienfehler und kein Rateversuch — der
|
|
// zaehlt nicht mit.
|
|
if (!username || !password) {
|
|
return c.json({ error: 'Benutzername und Passwort noetig' }, 400)
|
|
}
|
|
|
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
|
|
| User
|
|
| undefined
|
|
|
|
// Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches
|
|
// Passwort": alles andere verraet, welche Namen es gibt.
|
|
if (!user || !verifyPassword(password, user.password_hash)) {
|
|
fehlversuch(key)
|
|
const jetztRest = gesperrt(key)
|
|
return c.json(
|
|
{
|
|
error: jetztRest
|
|
? `Benutzername oder Passwort stimmt nicht. Zu viele Fehlversuche — noch ${jetztRest} Sekunden warten.`
|
|
: 'Benutzername oder Passwort stimmt nicht',
|
|
},
|
|
jetztRest ? 429 : 401,
|
|
)
|
|
}
|
|
|
|
geschafft(key)
|
|
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.json({ user: zeigen(user) })
|
|
})
|
|
|
|
dashRoutes.post('/auth/logout', (c) => {
|
|
const sid = getCookie(c, SESSION_COOKIE)
|
|
if (sid) destroySession(sid)
|
|
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
|
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: zeigen(user) })
|
|
})
|
|
|
|
/** 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 Name gehoert MIT hier hinein, obwohl es eine Anmeldeauskunft ist:
|
|
// die Anmeldeseite braucht ihn, und die hat noch keine Sitzung. Ohne
|
|
// das muesste sie ihn erraten oder fest eingebaut mitbringen.
|
|
marke: getSetting(MARKE_KEY) || 'D4RKST3R',
|
|
}))
|
|
|
|
// 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))
|
|
if (!user) return c.json({ error: 'nicht angemeldet' }, 401)
|
|
c.set('user', user)
|
|
await next()
|
|
})
|
|
|
|
dashRoutes.post('/auth/password', async (c) => {
|
|
const body = await jsonBody<{ current?: string; next?: string }>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
const { current, next: fresh } = body
|
|
const user = c.get('user')
|
|
|
|
if (!current || !fresh) return c.json({ error: 'beide Passwoerter noetig' }, 400)
|
|
if (fresh.length < 8) return c.json({ error: 'mindestens 8 Zeichen' }, 400)
|
|
if (!verifyPassword(current, user.password_hash)) {
|
|
return c.json({ error: 'aktuelles Passwort stimmt nicht' }, 401)
|
|
}
|
|
|
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(
|
|
hashPassword(fresh),
|
|
user.id,
|
|
)
|
|
// Alle anderen Sitzungen beenden — ein Passwortwechsel, der alte
|
|
// Anmeldungen weiterlaufen laesst, ist keiner.
|
|
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(
|
|
user.id,
|
|
getCookie(c, SESSION_COOKIE),
|
|
)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
// ------------------------------------------------------------------- Medien
|
|
|
|
/** Die Medienarten, wie sie im Dashboard heissen -- und was sie in SQL sind.
|
|
*
|
|
* An EINER Stelle, weil zwei Stellen zwangslaeufig auseinanderlaufen: die
|
|
* Liste zeigte dann etwas anderes als der Zaehler daneben behauptet.
|
|
*
|
|
* "andere" ist bewusst die Verneinung der uebrigen und keine eigene Liste:
|
|
* sonst faellt ein neuer Typ (z. B. application/zip) durch alle Filter und
|
|
* ist nirgends zu finden. */
|
|
const ARTEN: Record<string, string> = {
|
|
bild: "mime LIKE 'image/%'",
|
|
video: "mime LIKE 'video/%'",
|
|
ton: "mime LIKE 'audio/%'",
|
|
dokument: "(mime = 'application/pdf' OR mime LIKE 'text/%' OR mime LIKE 'application/json%')",
|
|
andere: "(mime NOT LIKE 'image/%' AND mime NOT LIKE 'video/%' AND mime NOT LIKE 'audio/%' " +
|
|
"AND mime <> 'application/pdf' AND mime NOT LIKE 'text/%' AND mime NOT LIKE 'application/json%')",
|
|
}
|
|
|
|
dashRoutes.get('/media', (c) => {
|
|
const query = c.req.query('query')?.trim() ?? ''
|
|
const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200)
|
|
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
|
|
|
|
// Der Ordner ist ein EIGENER Filter und nicht bloss eine Suche nach
|
|
// "vehicles/": die Suche findet mit LIKE %…% auch "alt/vehicles/adder.webp",
|
|
// und dann zeigt der Ordner Dinge, die nicht darin liegen. Hier wird der
|
|
// Anfang verankert.
|
|
//
|
|
// Die Sonderzeichen von LIKE werden maskiert: ein Ordner mit _ im Namen
|
|
// (etwa "fotos_alt") wuerde sonst auch "fotosXalt" treffen — _ steht in
|
|
// LIKE fuer ein beliebiges Zeichen.
|
|
const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
|
|
const maskiert = (s: string) => s.replace(/[\\%_]/g, (z) => `\\${z}`)
|
|
|
|
const bedingungen: string[] = []
|
|
const params: unknown[] = []
|
|
|
|
if (query) {
|
|
bedingungen.push(`path LIKE ? ESCAPE '\\'`)
|
|
params.push(`%${maskiert(query)}%`)
|
|
}
|
|
if (folder) {
|
|
bedingungen.push(`path LIKE ? ESCAPE '\\'`)
|
|
params.push(`${maskiert(folder)}/%`)
|
|
}
|
|
|
|
// Die Art kommt aus der Adresszeile und geht in SQL -- also gegen die
|
|
// feste Tabelle getauscht und niemals durchgereicht.
|
|
const art = c.req.query('art') ?? ''
|
|
if (ARTEN[art]) bedingungen.push(ARTEN[art])
|
|
|
|
const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
|
|
|
|
// Die Sortierung kommt vom Aufrufer und geht in SQL -- also NIEMALS
|
|
// durchgereicht, sondern gegen eine feste Liste getauscht. Ein Feldname
|
|
// aus einer Adresszeile ist die aelteste Einladung ueberhaupt.
|
|
const SPALTEN: Record<string, string> = {
|
|
geaendert: 'updated_at',
|
|
angelegt: 'created_at',
|
|
pfad: 'path',
|
|
groesse: 'size',
|
|
// Gezaehlt wurde laengst, sortieren konnte man danach nicht -- eine
|
|
// Zahl, die man nicht ordnen kann, beantwortet die Frage "was wird
|
|
// eigentlich gebraucht" nur fuer die zehn im Speicherbericht.
|
|
abrufe: 'abrufe',
|
|
geholt: 'zuletzt_abgerufen',
|
|
}
|
|
const sortSpalte = SPALTEN[c.req.query('sort') ?? ''] ?? 'updated_at'
|
|
const richtung = c.req.query('dir') === 'auf' ? 'ASC' : 'DESC'
|
|
// Nach dem Pfad wird ohne Ruecksicht auf Gross- und Kleinschreibung
|
|
// sortiert: sonst stuende "Adder" vor "adder" und dazwischen nichts.
|
|
const nachSpalte = sortSpalte === 'path' ? 'path COLLATE NOCASE' : sortSpalte
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as {
|
|
n: number
|
|
}
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT * FROM media ${where} ORDER BY ${nachSpalte} ${richtung}, id DESC
|
|
LIMIT ? OFFSET ?`,
|
|
)
|
|
.all(...params, limit, offset) as Media[]
|
|
|
|
return c.json({
|
|
total: total.n,
|
|
limit,
|
|
offset,
|
|
items: rows.map((row) => ({ ...row, url: publicUrlFor(row.path) })),
|
|
})
|
|
})
|
|
|
|
/** Alle Ordner, auf allen Ebenen, mit Anzahl und Groesse.
|
|
*
|
|
* In JavaScript zusammengezaehlt und nicht in SQL: fuer eine Baumansicht
|
|
* braucht es JEDE Ebene ("vehicles" und "vehicles/showroom"), und das waere
|
|
* in SQLite eine rekursive Abfrage, die niemand mehr liest. Bei ein paar
|
|
* tausend Zeilen ist die Schleife hier schneller getippt und schneller
|
|
* verstanden — und wenn es je Millionen werden, faellt es beim Messen auf und
|
|
* nicht vorher.
|
|
*
|
|
* Die Zahlen sind KUMULATIV: "vehicles" enthaelt, was in "vehicles/showroom"
|
|
* liegt. Alles andere ueberrascht in einer Baumansicht. */
|
|
/** Wie viele Dateien es je Art gibt -- fuer die Filterknoepfe.
|
|
*
|
|
* Mit den gerade geltenden Einschraenkungen (Ordner, Suche), sonst stuende an
|
|
* einem Knopf eine Zahl, die nach dem Klick nicht stimmt. */
|
|
dashRoutes.get('/arten', (c) => {
|
|
const query = c.req.query('query')?.trim() ?? ''
|
|
const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
|
|
const maskiert = (t: string) => t.replace(/[\\%_]/g, (z) => `\\${z}`)
|
|
|
|
const grund: string[] = []
|
|
const params: unknown[] = []
|
|
if (query) {
|
|
grund.push(`path LIKE ? ESCAPE '\\'`)
|
|
params.push(`%${maskiert(query)}%`)
|
|
}
|
|
if (folder) {
|
|
grund.push(`path LIKE ? ESCAPE '\\'`)
|
|
params.push(`${maskiert(folder)}/%`)
|
|
}
|
|
|
|
const zaehle = (zusatz?: string) => {
|
|
const alle = zusatz ? [...grund, zusatz] : grund
|
|
const where = alle.length ? `WHERE ${alle.join(' AND ')}` : ''
|
|
return (
|
|
db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as {
|
|
n: number
|
|
}
|
|
).n
|
|
}
|
|
|
|
const out: Record<string, number> = { alle: zaehle() }
|
|
for (const [name, bedingung] of Object.entries(ARTEN)) out[name] = zaehle(bedingung)
|
|
return c.json(out)
|
|
})
|
|
|
|
dashRoutes.get('/folders', async (c) => {
|
|
const rows = db.prepare('SELECT path, size FROM media').all() as {
|
|
path: string
|
|
size: number
|
|
}[]
|
|
|
|
const ordner = new Map<string, { files: number; bytes: number; tiefe: number }>()
|
|
|
|
for (const row of rows) {
|
|
const teile = row.path.split('/')
|
|
// Das letzte Stueck ist der Dateiname und kein Ordner.
|
|
for (let i = 1; i < teile.length; i++) {
|
|
const pfad = teile.slice(0, i).join('/')
|
|
const eintrag = ordner.get(pfad) ?? { files: 0, bytes: 0, tiefe: i - 1 }
|
|
eintrag.files += 1
|
|
eintrag.bytes += row.size
|
|
ordner.set(pfad, eintrag)
|
|
}
|
|
}
|
|
|
|
// Leere Ordner gibt es nur auf der Platte -- sie stehen in keiner Tabelle,
|
|
// weil dort nur Dateien stehen. Wer einen anlegt, um etwas hineinzulegen,
|
|
// soll ihn trotzdem sehen.
|
|
async function verzeichnisse(rel: string, tiefe: number) {
|
|
let eintraege
|
|
try {
|
|
eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
for (const e of eintraege) {
|
|
if (!e.isDirectory()) continue
|
|
const p = rel ? `${rel}/${e.name}` : e.name
|
|
if (!ordner.has(p)) ordner.set(p, { files: 0, bytes: 0, tiefe })
|
|
await verzeichnisse(p, tiefe + 1)
|
|
}
|
|
}
|
|
await verzeichnisse('', 0)
|
|
|
|
const items = [...ordner.entries()]
|
|
.map(([folder, wert]) => ({ folder, ...wert }))
|
|
// Alphabetisch und nicht nach Groesse: eine Baumansicht, deren
|
|
// Reihenfolge sich beim naechsten Upload aendert, ist keine.
|
|
.sort((a, b) => a.folder.localeCompare(b.folder, 'de'))
|
|
|
|
// Dateien direkt an der Wurzel haben keinen Ordner und wuerden sonst
|
|
// unsichtbar. Sie stehen im Dashboard unter "Alle".
|
|
const wurzel = rows.filter((r) => !r.path.includes('/'))
|
|
|
|
return c.json({
|
|
items,
|
|
gesamt: { files: rows.length, bytes: rows.reduce((s, r) => s + r.size, 0) },
|
|
wurzel: { files: wurzel.length, bytes: wurzel.reduce((s, r) => s + r.size, 0) },
|
|
})
|
|
})
|
|
|
|
/** Nur die IDs zu einer Auswahl — fuer "alle Treffer waehlen".
|
|
*
|
|
* Ohne das muesste jemand fuenfzehn Seiten durchblaettern und auf jeder
|
|
* "Seite waehlen" druecken, um einen Ordner mit 900 Bildern zu leeren.
|
|
*
|
|
* Gedeckelt auf 5000: das ist mehr, als hier je auf einmal ausgewaehlt wird,
|
|
* und verhindert, dass eine Antwort in die Megabyte laeuft. Wer daran stoesst,
|
|
* bekommt es GESAGT (gekappt: true) statt still eine halbe Auswahl.
|
|
*/
|
|
dashRoutes.get('/media/ids', (c) => {
|
|
const query = c.req.query('query')?.trim() ?? ''
|
|
const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
|
|
const maskiert = (t: string) => t.replace(/[\\%_]/g, (z) => `\\${z}`)
|
|
|
|
const bedingungen: string[] = []
|
|
const params: unknown[] = []
|
|
if (query) {
|
|
bedingungen.push(`path LIKE ? ESCAPE '\\'`)
|
|
params.push(`%${maskiert(query)}%`)
|
|
}
|
|
if (folder) {
|
|
bedingungen.push(`path LIKE ? ESCAPE '\\'`)
|
|
params.push(`${maskiert(folder)}/%`)
|
|
}
|
|
// MUSS dieselbe Einschraenkung haben wie die Liste: sonst waehlt "alle
|
|
// waehlen" mehr aus, als der Filter zeigt -- und geloescht wird dann
|
|
// Unsichtbares.
|
|
const art = c.req.query('art') ?? ''
|
|
if (ARTEN[art]) bedingungen.push(ARTEN[art])
|
|
|
|
const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
|
|
|
|
const DECKEL = 5000
|
|
const rows = db
|
|
.prepare(`SELECT id FROM media ${where} ORDER BY updated_at DESC LIMIT ?`)
|
|
.all(...params, DECKEL + 1) as { id: number }[]
|
|
|
|
return c.json({
|
|
ids: rows.slice(0, DECKEL).map((r) => r.id),
|
|
gekappt: rows.length > DECKEL,
|
|
})
|
|
})
|
|
|
|
dashRoutes.delete('/media/:id', async (c) => {
|
|
const id = Number(c.req.param('id'))
|
|
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
|
|
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
|
|
|
|
await inPapierkorb(row, c.get('user').username)
|
|
return c.json({ deleted: row.path })
|
|
})
|
|
|
|
/** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */
|
|
dashRoutes.post('/media/delete', async (c) => {
|
|
const body = await jsonBody<{ ids?: number[] }>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
const { ids } = body
|
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
return c.json({ error: 'keine Auswahl' }, 400)
|
|
}
|
|
|
|
const select = db.prepare('SELECT * FROM media WHERE id = ?')
|
|
const deleted: string[] = []
|
|
|
|
for (const id of ids) {
|
|
const row = select.get(id) as Media | undefined
|
|
if (!row) continue
|
|
await inPapierkorb(row, c.get('user').username)
|
|
deleted.push(row.path)
|
|
}
|
|
|
|
return c.json({ deleted, count: deleted.length })
|
|
})
|
|
|
|
// ------------------------------------------------------------------- Archive
|
|
//
|
|
// WOZU. 3670 Item-Bilder einzeln ins Fenster zu ziehen ist keine Bedienung,
|
|
// das ist eine Strafe. Ein ZIP ist die Form, in der solche Sammlungen ohnehin
|
|
// unterwegs sind -- und es bringt die ORDNER MIT, was der wichtigere Teil ist:
|
|
// items/foo.png im Archiv landet unter items/foo.png im Dienst.
|
|
//
|
|
// fflate und nicht adm-zip: reines JavaScript, keine native Bibliothek. Dieses
|
|
// Projekt hat schon einmal einen halben Abend an einer ABI-Nummer verloren
|
|
// (better-sqlite3 gegen Node 24), und das soll sich nicht wiederholen.
|
|
|
|
/** Wie viele Eintraege ein Archiv hoechstens haben darf. */
|
|
const ZIP_MAX_EINTRAEGE = 5000
|
|
|
|
/** Und wieviel es ENTPACKT hoechstens ergeben darf.
|
|
*
|
|
* Das ist die Zip-Bombe: ein Archiv von 40 KB kann zu Gigabyte aufgehen, und
|
|
* entpackt wird in den Arbeitsspeicher. Geprueft wird deshalb VOR dem
|
|
* Entpacken -- die Groesse des Originals steht im Kopf jedes Eintrags, und
|
|
* fflate laesst uns darauf filtern. Eine Pruefung danach waere keine. */
|
|
const ZIP_MAX_GESAMT = 256 * 1024 * 1024
|
|
|
|
const istArchiv = (name: string, typ: string) =>
|
|
/\.zip$/i.test(name) || /^application\/(x-)?zip(-compressed)?$/i.test(typ.split(';')[0] ?? '')
|
|
|
|
/** Einen Pfad aus dem Archiv in einen zulaessigen Pfad verwandeln.
|
|
*
|
|
* Jedes Segment einzeln durch dieselbe Zaehmung wie ein Dateiname aus dem
|
|
* Fenster. Gibt null zurueck, wenn nichts Brauchbares uebrig bleibt.
|
|
*
|
|
* ".." FLIEGT RAUS, und zwar hier schon: ein Archiv ist eine Datei von
|
|
* draussen, und "../../etc/passwd" als Eintragsname ist ein Angriff mit
|
|
* eigenem Namen (Zip Slip). checkPath faengt es zwar auch, aber eine Abwehr,
|
|
* die nur an EINER Stelle steht, ist eine Abwehr, die beim Umbauen verschwindet. */
|
|
function archivPfad(roh: string): string | null {
|
|
const teile = roh
|
|
.replace(/\\/g, '/')
|
|
.split('/')
|
|
.filter((t) => t && t !== '.')
|
|
if (teile.some((t) => t === '..')) return null
|
|
|
|
const sauber = teile.map((t) => safeFilename(t)).filter(Boolean)
|
|
return sauber.length ? sauber.join('/') : null
|
|
}
|
|
|
|
/** Ein Archiv auspacken. Was uebersprungen wird, steht mit Grund im Ergebnis --
|
|
* ein stilles Weglassen waere hier das Schlimmste: der Aufrufer glaubt, seine
|
|
* 900 Bilder seien da, und merkt bei Bild 314, dass sie es nicht sind. */
|
|
function auspacken(
|
|
data: Buffer,
|
|
ergebnisse: { name: string; ok: boolean; error?: string }[],
|
|
): [string, Uint8Array][] {
|
|
let gesamt = 0
|
|
let anzahl = 0
|
|
|
|
const inhalt = unzipSync(data, {
|
|
filter: (eintrag) => {
|
|
// Ordnereintraege und der Beifang, den macOS in jedes Archiv legt.
|
|
if (eintrag.name.endsWith('/')) return false
|
|
if (/(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$)/i.test(eintrag.name)) return false
|
|
if (eintrag.originalSize === 0) return false
|
|
|
|
if (++anzahl > ZIP_MAX_EINTRAEGE) return false
|
|
gesamt += eintrag.originalSize
|
|
return gesamt <= ZIP_MAX_GESAMT
|
|
},
|
|
})
|
|
|
|
if (anzahl > ZIP_MAX_EINTRAEGE) {
|
|
ergebnisse.push({
|
|
name: 'Archiv',
|
|
ok: false,
|
|
error: `mehr als ${ZIP_MAX_EINTRAEGE} Eintraege — der Rest wurde nicht ausgepackt`,
|
|
})
|
|
}
|
|
if (gesamt > ZIP_MAX_GESAMT) {
|
|
ergebnisse.push({
|
|
name: 'Archiv',
|
|
ok: false,
|
|
error:
|
|
`ergibt entpackt mehr als ${Math.round(ZIP_MAX_GESAMT / 1024 / 1024)} MB ` +
|
|
'— der Rest wurde nicht ausgepackt',
|
|
})
|
|
}
|
|
|
|
const raus: [string, Uint8Array][] = []
|
|
for (const [roh, bytes] of Object.entries(inhalt)) {
|
|
const pfad = archivPfad(roh)
|
|
if (!pfad) {
|
|
ergebnisse.push({ name: roh, ok: false, error: 'unbrauchbarer Name im Archiv' })
|
|
continue
|
|
}
|
|
raus.push([pfad, bytes])
|
|
}
|
|
return raus
|
|
}
|
|
|
|
/** Hochladen aus dem Dashboard.
|
|
*
|
|
* Ein ZIP wird dabei AUSGEPACKT statt abgelegt: es ist so gut wie nie das
|
|
* Archiv, das jemand oeffentlich haben will, sondern sein Inhalt. Die Ordner
|
|
* im Archiv bleiben erhalten und haengen sich hinter den gewaehlten Zielordner.
|
|
*
|
|
* Ein eigener Weg neben /api/upload, und zwar aus einem Grund: der dortige
|
|
* haengt an einem Bearer-Token. Wer im Dashboard sitzt, hat eine SITZUNG und
|
|
* keinen Token — und einen Token nur zum Hochladen anzulegen waere ein
|
|
* Geheimnis mehr, das jemand verlieren kann.
|
|
*
|
|
* Mehrere Dateien auf einmal, und JEDE bekommt ihr eigenes Ergebnis. Ein
|
|
* "3 von 5 hochgeladen" ohne die Namen der zwei anderen ist genau die Sorte
|
|
* Rueckmeldung, wegen der wir hier neu bauen. */
|
|
dashRoutes.post('/upload', async (c) => {
|
|
const contentType = c.req.header('content-type') ?? ''
|
|
if (!contentType.includes('multipart/form-data')) {
|
|
return c.json({ error: 'Erwartet wird multipart/form-data' }, 400)
|
|
}
|
|
|
|
// Das Formular lesen -- und dabei eine Strenge ausbuegeln, die uns fast
|
|
// eine Anbindung gekostet haette.
|
|
//
|
|
// Manche Aufrufer setzen die Grenzmarkierung in ANFUEHRUNGSZEICHEN:
|
|
//
|
|
// multipart/form-data; boundary="3c5a305b-cb7d-..."
|
|
//
|
|
// Nodes Parser lehnt das ab, obwohl RFC 2046 es ausdruecklich erlaubt.
|
|
// Also nehmen wir sie weg und lesen selbst.
|
|
//
|
|
// ACHTUNG, EINE HALBE WAHRHEIT WAERE HIER TEURER ALS KEINE: das allein
|
|
// rettet PowerShells `Invoke-RestMethod -Form` NICHT. Mitgeschnitten hat
|
|
// dessen Rumpf naemlich noch eine zweite Abweichung:
|
|
//
|
|
// Content-Disposition: form-data; name=file; filename=a.png
|
|
//
|
|
// RFC 7578 verlangt name="file" MIT Anfuehrungszeichen, .NET laesst sie
|
|
// weg. Das steht im RUMPF, nicht im Kopf -- dafuer braeuchte es einen
|
|
// eigenen Multipart-Parser, und der waere fuer diesen Dienst
|
|
// unverhaeltnismaessig. Werkzeuge auf dieser Maschine nehmen deshalb
|
|
// `curl.exe -F` (siehe tools/uebernehmen.ps1); curl setzt beides korrekt.
|
|
let dateien: File[]
|
|
let ordnerRoh = ''
|
|
let ueberschreiben = true
|
|
|
|
try {
|
|
const typ = c.req.header('content-type') ?? ''
|
|
if (/boundary="/.test(typ)) {
|
|
const gerade = typ.replace(/boundary="([^"]+)"/, 'boundary=$1')
|
|
const rumpf = await c.req.arrayBuffer()
|
|
const form = await new Request('http://formular.invalid/', {
|
|
method: 'POST',
|
|
headers: { 'content-type': gerade },
|
|
body: rumpf,
|
|
}).formData()
|
|
dateien = form.getAll('file').filter((f) => typeof f !== 'string') as unknown as File[]
|
|
ordnerRoh = ((form.get('folder') as string | null) ?? '').trim()
|
|
ueberschreiben = form.get('overwrite') !== 'false'
|
|
} else {
|
|
// DER NORMALFALL GEHT UEBER HONO, und das ist kein Geschmack:
|
|
// c.req.raw.formData() direkt aufzurufen nimmt Hono den Rumpf weg,
|
|
// den es selbst verwaltet. Beim Aufraeumen der Antwort schliesst
|
|
// dann jemand einen Strom, der schon zu ist -- "ReadableStream is
|
|
// already closed", eine Ausnahme AUSSERHALB jedes try/catch, und
|
|
// der Prozess ist weg.
|
|
//
|
|
// Genau das ist beim Uebernehmen von 3670 Dateien passiert: der
|
|
// Dienst lief in eine Neustartschleife, und NPM meldete 502. Beim
|
|
// Test mit 57 Dateien war es nicht aufgefallen.
|
|
const roh = await c.req.parseBody({ all: true })
|
|
const feld = roh['file']
|
|
dateien = (Array.isArray(feld) ? feld : [feld]).filter(
|
|
(f) => f && typeof f !== 'string',
|
|
) as unknown as File[]
|
|
ordnerRoh = typeof roh['folder'] === 'string' ? roh['folder'].trim() : ''
|
|
ueberschreiben = roh['overwrite'] !== 'false'
|
|
}
|
|
} catch (err) {
|
|
// Ein kaputter Rumpf ergab bisher einen 500, aufgefangen nur von der
|
|
// Auffanglinie. Wer ein Formular falsch zusammenbaut, soll 400 samt
|
|
// Grund bekommen und nicht "Serverfehler".
|
|
return c.json(
|
|
{
|
|
error: `Der Formular-Rumpf liess sich nicht lesen: ${err instanceof Error ? err.message : 'unbekannt'}`,
|
|
},
|
|
400,
|
|
)
|
|
}
|
|
|
|
if (dateien.length === 0) return c.json({ error: 'Keine Datei dabei' }, 400)
|
|
let ordner = ''
|
|
if (ordnerRoh) {
|
|
try {
|
|
ordner = checkPath(ordnerRoh.replace(/\/+$/, ''))
|
|
} catch (err) {
|
|
if (err instanceof PathError) {
|
|
return c.json({ error: `Ordner: ${err.message}` }, 400)
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const ergebnisse: {
|
|
name: string
|
|
ok: boolean
|
|
path?: string
|
|
url?: string
|
|
size?: number
|
|
replaced?: boolean
|
|
error?: string
|
|
}[] = []
|
|
|
|
/** Eine Datei ablegen -- EIN Weg fuer alles.
|
|
*
|
|
* Ob der Inhalt aus einem Formularteil kam oder aus einem Archiv, macht ab
|
|
* hier keinen Unterschied mehr: gleiche Namensregeln, gleiche Pruefung auf
|
|
* Belegtsein, gleiche Vorschau, gleicher Eintrag im Verlauf. Ein zweiter
|
|
* Weg fuer ZIP-Inhalte waere ein zweiter Weg, der beim naechsten Mal
|
|
* auseinanderlaeuft.
|
|
*
|
|
* `zielpfad` ist gesetzt, wenn der Name schon ein Pfad ist -- bei
|
|
* Archiveintraegen naemlich, die ihre Ordner mitbringen. */
|
|
async function ablegen(
|
|
name: string,
|
|
data: Buffer,
|
|
gemeldeterTyp: string,
|
|
zielpfad?: string,
|
|
): Promise<void> {
|
|
if (data.length === 0) {
|
|
ergebnisse.push({ name, ok: false, error: 'leere Datei' })
|
|
return
|
|
}
|
|
if (data.length > config.maxUploadBytes) {
|
|
ergebnisse.push({
|
|
name,
|
|
ok: false,
|
|
error: `zu gross (${data.length} > ${config.maxUploadBytes} Bytes)`,
|
|
})
|
|
return
|
|
}
|
|
|
|
const digest = sha256(data)
|
|
// Der Hash als Rettungsanker fuer Namen, von denen nichts uebrig
|
|
// bleibt (kyrillisch, nur Leerzeichen). Ohne ihn hiessen zwei
|
|
// verschiedene Dateien beide "datei.txt", und die zweite
|
|
// ueberschriebe die erste still. Beim Messen genau so passiert.
|
|
const dateiname = zielpfad ?? safeFilename(name, digest.slice(0, 8))
|
|
const path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname)
|
|
|
|
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
|
|
| Media
|
|
| undefined
|
|
if (vorhanden && !ueberschreiben) {
|
|
ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' })
|
|
return
|
|
}
|
|
|
|
// Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein
|
|
// Achselzucken. Ein Formularteil ohne erkannte Art traegt
|
|
// "application/octet-stream", und das ist weniger wert als unsere
|
|
// Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und
|
|
// handbuch.pdf allesamt unter "Anderes". Aus einem Archiv kommt gar
|
|
// keine Angabe -- dort entscheidet die Endung ohnehin allein.
|
|
const gemeldet = (gemeldeterTyp || '').split(';')[0]?.trim().toLowerCase() ?? ''
|
|
const mime =
|
|
gemeldet && gemeldet !== 'application/octet-stream' ? gemeldeterTyp : mimeFor(path)
|
|
await writeFileAtomic(path, data)
|
|
if (kannVorschau(mime)) await writeThumb(path, data)
|
|
if (lohntWebp(mime)) await writeWebp(path, data)
|
|
|
|
// Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und
|
|
// deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge
|
|
// faellt beim selben Blick in die Datei mit ab.
|
|
let dauer: number | null = null
|
|
if (kannBewegtVorschau(mime)) {
|
|
dauer = (await writeBewegtThumb(path, mime)).dauer
|
|
}
|
|
|
|
const zeit = now()
|
|
if (vorhanden) {
|
|
db.prepare(
|
|
`UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
).run(data.length, digest, mime, dauer, zeit, vorhanden.id)
|
|
} else {
|
|
db.prepare(
|
|
`INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
|
).run(path, data.length, digest, mime, dauer, zeit, zeit)
|
|
}
|
|
|
|
logEvent(
|
|
vorhanden ? 'replace' : 'upload',
|
|
path,
|
|
data.length,
|
|
'user',
|
|
c.get('user').username,
|
|
)
|
|
|
|
ergebnisse.push({
|
|
name,
|
|
ok: true,
|
|
path,
|
|
url: publicUrlFor(path),
|
|
size: data.length,
|
|
replaced: Boolean(vorhanden),
|
|
})
|
|
}
|
|
|
|
for (const datei of dateien) {
|
|
const name = datei.name || 'datei'
|
|
try {
|
|
if (datei.size > config.maxUploadBytes) {
|
|
ergebnisse.push({
|
|
name,
|
|
ok: false,
|
|
error: `zu gross (${datei.size} > ${config.maxUploadBytes} Bytes)`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const data = Buffer.from(await datei.arrayBuffer())
|
|
|
|
if (istArchiv(name, datei.type)) {
|
|
for (const [eintrag, inhalt] of auspacken(data, ergebnisse)) {
|
|
// EIGENES try JE EINTRAG. Sonst reisst ein einziger
|
|
// unbrauchbarer Name -- zu tief, zu lang -- die restlichen
|
|
// tausend mit, und der Aufrufer sieht eine Fehlermeldung
|
|
// statt neunhundertneunundneunzig Erfolgen.
|
|
try {
|
|
await ablegen(`${name} → ${eintrag}`, Buffer.from(inhalt), '', eintrag)
|
|
} catch (err) {
|
|
ergebnisse.push({
|
|
name: `${name} → ${eintrag}`,
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : 'unbekannter Fehler',
|
|
})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
await ablegen(name, data, datei.type)
|
|
} catch (err) {
|
|
// Eine kaputte Datei bricht die anderen NICHT ab. Wer fuenf Dateien
|
|
// zieht und bei der zweiten alles verliert, zieht sie danach
|
|
// einzeln — und das ist unsere Schuld, nicht seine.
|
|
ergebnisse.push({
|
|
name,
|
|
ok: false,
|
|
error:
|
|
err instanceof PathError
|
|
? err.message
|
|
: err instanceof Error
|
|
? err.message
|
|
: 'unbekannter Fehler',
|
|
})
|
|
}
|
|
}
|
|
|
|
const gut = ergebnisse.filter((e) => e.ok).length
|
|
// 207 waere formal richtiger, aber jede Zwischenschicht behandelt es wie
|
|
// einen Sonderfall. 200 mit ehrlichen Zahlen im Rumpf ist hier nuetzlicher.
|
|
return c.json({ hochgeladen: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
|
})
|
|
|
|
/** Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht.
|
|
*
|
|
* 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') ?? 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[] = []
|
|
if (kind && ['upload', 'replace', 'delete', 'move', 'rename'].includes(kind)) {
|
|
bedingungen.push('kind = ?')
|
|
params.push(kind)
|
|
}
|
|
if (actor) {
|
|
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 {
|
|
n: number
|
|
}
|
|
const items = db
|
|
.prepare(`SELECT * FROM events ${where} ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`)
|
|
.all(...params, limit, offset) as MediaEvent[]
|
|
|
|
// Wer ueberhaupt vorkommt -- damit die Oberflaeche einen Filter anbieten
|
|
// kann, ohne alle Eintraege durchzublaettern.
|
|
const roheAkteure = db
|
|
.prepare(
|
|
`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
|
|
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.
|
|
const seit = db.prepare('SELECT MIN(at) AS a FROM events').get() as { a: number | null }
|
|
|
|
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,
|
|
roleIds: d.roleIds,
|
|
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),
|
|
roleIds:
|
|
d.roleIds.length > 0 &&
|
|
!ausDatenbank(DISCORD_KEYS.roleIds) &&
|
|
!ausDatenbank(DISCORD_KEYS.roleIdAlt),
|
|
},
|
|
},
|
|
vorlagen: vorlagen(),
|
|
meldungen: meldungConfig(),
|
|
sicherung: (() => {
|
|
const sc = sicherungConfig()
|
|
let letzte: unknown = null
|
|
try {
|
|
const roh = getSetting(SICHERUNG_KEYS.letzte)
|
|
letzte = roh ? JSON.parse(roh) : null
|
|
} catch {
|
|
// Ein kaputter Eintrag ist kein Grund, die ganze Seite
|
|
// scheitern zu lassen -- dann steht dort eben "unbekannt".
|
|
letzte = null
|
|
}
|
|
return { ...sc, letzte }
|
|
})(),
|
|
})
|
|
})
|
|
|
|
dashRoutes.post('/settings', async (c) => {
|
|
const body = await jsonBody<{
|
|
discord?: {
|
|
clientId?: string
|
|
clientSecret?: string
|
|
guildId?: string
|
|
roleIds?: string | string[]
|
|
}
|
|
vorlagen?: { name?: string; muster?: string }[]
|
|
meldungen?: { webhook?: string; anlaesse?: string[] }
|
|
sicherung?: {
|
|
basis?: string
|
|
benutzer?: string
|
|
passwort?: string
|
|
ordner?: string
|
|
behalten?: number | string
|
|
}
|
|
}>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
|
|
// -- Meldungen ----------------------------------------------------------
|
|
if (body.meldungen) {
|
|
const m = body.meldungen
|
|
if (m.webhook !== undefined) {
|
|
const v = m.webhook.trim()
|
|
if (v === '-') setSetting(MELDUNG_KEYS.webhook, '')
|
|
else if (v) {
|
|
// Nur echte Discord-Webhooks. Wer hier versehentlich eine
|
|
// andere Adresse eintraegt, schickt seine Meldungen sonst
|
|
// irgendwohin.
|
|
if (!/^https:\/\/(canary\.|ptb\.)?discord(app)?\.com\/api\/webhooks\//.test(v)) {
|
|
return c.json(
|
|
{ error: 'Das sieht nicht nach einer Discord-Webhook-Adresse aus.' },
|
|
400,
|
|
)
|
|
}
|
|
setSetting(MELDUNG_KEYS.webhook, v)
|
|
}
|
|
}
|
|
if (Array.isArray(m.anlaesse)) {
|
|
const erlaubt = ['sicherung', 'verwaiste']
|
|
setSetting(
|
|
MELDUNG_KEYS.anlaesse,
|
|
m.anlaesse.filter((a) => erlaubt.includes(a)).join(','),
|
|
)
|
|
}
|
|
if (!body.discord && !body.sicherung && !body.vorlagen) {
|
|
return c.json({ ok: true, meldungen: meldungConfig() })
|
|
}
|
|
}
|
|
|
|
// -- Adressvorlagen -----------------------------------------------------
|
|
if (body.vorlagen) {
|
|
if (!Array.isArray(body.vorlagen)) {
|
|
return c.json({ error: 'Vorlagen muessen eine Liste sein' }, 400)
|
|
}
|
|
if (body.vorlagen.length > 20) {
|
|
return c.json({ error: 'Hoechstens 20 Vorlagen' }, 400)
|
|
}
|
|
|
|
const sauber: Vorlage[] = []
|
|
for (const v of body.vorlagen) {
|
|
const name = String(v?.name ?? '').trim().slice(0, 40)
|
|
const muster = String(v?.muster ?? '').trim().replace(/^\/+/, '')
|
|
if (!name || !muster) continue
|
|
|
|
// Das Muster muss einen Platzhalter haben -- sonst ist es keine
|
|
// Vorlage, sondern eine einzelne Adresse.
|
|
if (!/\{[a-z]+\}/.test(muster)) {
|
|
return c.json(
|
|
{ error: `"${name}": im Muster fehlt ein Platzhalter, z. B. {model}` },
|
|
400,
|
|
)
|
|
}
|
|
// Und es muss ein gueltiger Pfad WERDEN koennen. Geprueft wird mit
|
|
// eingesetztem Platzhalter, sonst scheitert checkPath an der
|
|
// geschweiften Klammer.
|
|
try {
|
|
checkPath(muster.replace(/\{[a-z]+\}/g, 'x'))
|
|
} catch (err) {
|
|
return c.json(
|
|
{
|
|
error: `"${name}": ${err instanceof PathError ? err.message : 'ungueltiges Muster'}`,
|
|
},
|
|
400,
|
|
)
|
|
}
|
|
sauber.push({ name, muster })
|
|
}
|
|
setzeVorlagen(sauber)
|
|
if (!body.discord && !body.sicherung) {
|
|
return c.json({ ok: true, vorlagen: sauber })
|
|
}
|
|
}
|
|
|
|
// -- Sicherung ----------------------------------------------------------
|
|
if (body.sicherung) {
|
|
const sic = body.sicherung
|
|
if (sic.basis !== undefined) {
|
|
const v = sic.basis.trim().replace(/\/+$/, '')
|
|
if (v && !/^https?:\/\//.test(v)) {
|
|
return c.json({ error: 'Adresse muss mit http:// oder https:// anfangen' }, 400)
|
|
}
|
|
setSetting(SICHERUNG_KEYS.basis, v)
|
|
}
|
|
if (sic.benutzer !== undefined) setSetting(SICHERUNG_KEYS.benutzer, sic.benutzer.trim())
|
|
if (sic.ordner !== undefined) {
|
|
// Kein fuehrender Schraegstrich: der Pfad haengt hinter der
|
|
// WebDAV-Wurzel, und "//Sicherungen" ergibt dort einen Ordner
|
|
// ohne Namen.
|
|
setSetting(SICHERUNG_KEYS.ordner, sic.ordner.trim().replace(/^\/+|\/+$/g, ''))
|
|
}
|
|
if (sic.behalten !== undefined) {
|
|
const n = Number(sic.behalten)
|
|
if (!Number.isFinite(n) || n < 1 || n > 365) {
|
|
return c.json({ error: 'Behalten: zwischen 1 und 365' }, 400)
|
|
}
|
|
setSetting(SICHERUNG_KEYS.behalten, String(Math.round(n)))
|
|
}
|
|
// Wie beim Discord-Geheimnis: leer heisst unveraendert, "-" entfernt.
|
|
if (sic.passwort !== undefined) {
|
|
const v = sic.passwort.trim()
|
|
if (v === '-') setSetting(SICHERUNG_KEYS.passwort, '')
|
|
else if (v) setSetting(SICHERUNG_KEYS.passwort, v)
|
|
}
|
|
|
|
if (!body.discord) {
|
|
const jetzt = sicherungConfig()
|
|
return c.json({
|
|
ok: true,
|
|
bereit: jetzt.bereit,
|
|
fehlt: [
|
|
!jetzt.basis && 'Adresse',
|
|
!jetzt.benutzer && 'Benutzername',
|
|
!jetzt.hatPasswort && 'App-Passwort',
|
|
].filter(Boolean),
|
|
})
|
|
}
|
|
}
|
|
|
|
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.roleIds !== undefined) {
|
|
// Komma, Semikolon, Leerzeichen, Zeilenumbruch — alles trennt. Wer
|
|
// drei IDs untereinander einfuegt, soll sie nicht erst zu einer Zeile
|
|
// zusammenschieben muessen.
|
|
const liste = (Array.isArray(d.roleIds) ? d.roleIds.join(',') : d.roleIds)
|
|
.split(/[\s,;]+/)
|
|
.map((t) => t.trim())
|
|
.filter(Boolean)
|
|
|
|
// JEDE einzeln pruefen und die schlechte BEIM NAMEN nennen. "Rollen-ID
|
|
// ungueltig" bei fuenf Eintraegen ist eine Suchaufgabe, keine Meldung.
|
|
for (const eintrag of liste) {
|
|
if (!istId(eintrag)) {
|
|
return c.json(
|
|
{ error: `"${eintrag}" ist keine Rollen-ID (17 bis 20 Ziffern)` },
|
|
400,
|
|
)
|
|
}
|
|
}
|
|
|
|
const eindeutig = [...new Set(liste)]
|
|
setSetting(DISCORD_KEYS.roleIds, eindeutig.join(','))
|
|
// Den alten Einzelwert raeumen wir weg, sobald die Liste gepflegt
|
|
// wird: zwei Quellen fuer dieselbe Frage laufen sonst auseinander.
|
|
setSetting(DISCORD_KEYS.roleIdAlt, '')
|
|
}
|
|
// 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.roleIds.length === 0 && 'mindestens eine Rollen-ID',
|
|
].filter(Boolean),
|
|
})
|
|
})
|
|
|
|
/** Der Fuehler fuer die automatische Auffrischung.
|
|
*
|
|
* Drei Zahlen, drei Indexzugriffe -- billig genug, um ihn alle paar Sekunden
|
|
* zu fragen. Die Oberflaeche laedt erst dann RICHTIG nach, wenn sich hier
|
|
* etwas geaendert hat.
|
|
*
|
|
* Warum nicht Server-Sent Events: die waeren schoener, brauchen hinter NPM
|
|
* aber zusaetzlich `proxy_buffering off` -- also einen weiteren Handgriff im
|
|
* Proxy, der vergessen werden kann und dann zu einer Verbindung fuehrt, die
|
|
* scheinbar haengt. Ein Zaehler, den man abfragt, hat diese Fussangel nicht.
|
|
*
|
|
* `neuest` ist noetig NEBEN `medien`: wird eine Datei ersetzt, bleibt die
|
|
* Anzahl gleich und nur der Zeitstempel wandert. Ohne ihn saehe man das neue
|
|
* Bild nicht. */
|
|
dashRoutes.get('/stand', (c) => {
|
|
const m = db
|
|
.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), 0) AS neuest FROM media')
|
|
.get() as { n: number; neuest: number }
|
|
const e = db.prepare('SELECT COALESCE(MAX(id), 0) AS letzte FROM events').get() as {
|
|
letzte: number
|
|
}
|
|
return c.json({ medien: m.n, neuest: m.neuest, ereignis: e.letzte })
|
|
})
|
|
|
|
/** Alles fuer die Startseite in EINER Antwort.
|
|
*
|
|
* Vier Einzelabfragen beim Oeffnen waeren vier Runden ueber den Proxy, nur um
|
|
* eine Uebersicht zu zeigen. Und sie koennten auseinanderlaufen: die Zahl der
|
|
* Dateien aus der einen und die letzten Ereignisse aus der naechsten Antwort
|
|
* sind zwei verschiedene Zeitpunkte. */
|
|
dashRoutes.get('/uebersicht', (c) => {
|
|
const m = db
|
|
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
|
|
.get() as { files: number; bytes: number }
|
|
|
|
const heute = now() - 86_400_000
|
|
const woche = now() - 7 * 86_400_000
|
|
|
|
const neu = db.prepare('SELECT COUNT(*) AS n FROM events WHERE kind = ? AND at > ?')
|
|
const heuteHoch = (neu.get('upload', heute) as { n: number }).n
|
|
const wocheHoch = (neu.get('upload', woche) as { n: number }).n
|
|
|
|
const letzte = db
|
|
.prepare('SELECT * FROM events ORDER BY at DESC, id DESC LIMIT 8')
|
|
.all() as MediaEvent[]
|
|
|
|
// Die letzten Bilder fuer eine Streifenvorschau. Nur Bilder: ein PDF als
|
|
// graue Kachel in einer Uebersicht ist Fuellmaterial.
|
|
const bilder = (
|
|
db
|
|
.prepare(
|
|
`SELECT * FROM media WHERE mime LIKE 'image/%'
|
|
ORDER BY updated_at DESC LIMIT 12`,
|
|
)
|
|
.all() as Media[]
|
|
).map((row) => ({ ...row, url: publicUrlFor(row.path) }))
|
|
|
|
const ordner = db
|
|
.prepare(
|
|
`SELECT CASE WHEN instr(path, '/') > 0
|
|
THEN substr(path, 1, instr(path, '/') - 1)
|
|
ELSE '(Wurzel)' END AS folder,
|
|
COUNT(*) AS files, SUM(size) AS bytes
|
|
FROM media GROUP BY folder ORDER BY bytes DESC LIMIT 5`,
|
|
)
|
|
.all() as { folder: string; files: number; bytes: number }[]
|
|
|
|
const tokens = db
|
|
.prepare('SELECT id, name, prefix, can_delete, last_used_at FROM tokens ORDER BY last_used_at DESC')
|
|
.all() as Pick<Token, 'id' | 'name' | 'prefix' | 'can_delete' | 'last_used_at'>[]
|
|
|
|
return c.json({
|
|
files: m.files,
|
|
bytes: m.bytes,
|
|
heuteHoch,
|
|
wocheHoch,
|
|
publicUrl: config.publicUrl,
|
|
filePrefix: config.filePrefix,
|
|
letzte,
|
|
bilder,
|
|
ordner,
|
|
tokens,
|
|
discord: discordConfig().bereit,
|
|
vorlagen: vorlagen(),
|
|
})
|
|
})
|
|
|
|
/** Dateien in einen anderen Ordner legen.
|
|
*
|
|
* ACHTUNG, UND DIE OBERFLAECHE SAGT ES AUCH: der Pfad IST die oeffentliche
|
|
* Adresse. Wer ein Bild verschiebt, macht jede Stelle ungueltig, die die alte
|
|
* URL benutzt -- im Handy, in einer Vorlage, in einem Zwischenspeicher. Das
|
|
* ist kein Fehler dieser Funktion, sondern der Preis vorhersagbarer Adressen.
|
|
*
|
|
* Jede Datei bekommt ihr eigenes Ergebnis, und eine Kollision bricht die
|
|
* anderen nicht ab. */
|
|
dashRoutes.post('/media/move', async (c) => {
|
|
const body = await jsonBody<{ ids?: number[]; ziel?: string }>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
if (!Array.isArray(body.ids) || body.ids.length === 0) {
|
|
return c.json({ error: 'keine Auswahl' }, 400)
|
|
}
|
|
|
|
let ziel = ''
|
|
if (body.ziel && body.ziel.trim()) {
|
|
try {
|
|
ziel = checkPath(body.ziel.trim().replace(/^\/+|\/+$/g, ''))
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: `Ziel: ${err.message}` }, 400)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const wer = c.get('user').username
|
|
const ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[] = []
|
|
|
|
for (const id of body.ids) {
|
|
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
|
|
if (!row) {
|
|
ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' })
|
|
continue
|
|
}
|
|
|
|
const name = row.path.split('/').pop() as string
|
|
const neu = ziel ? `${ziel}/${name}` : name
|
|
|
|
if (neu === row.path) {
|
|
ergebnisse.push({ path: row.path, ok: false, error: 'liegt schon dort' })
|
|
continue
|
|
}
|
|
|
|
const belegt = db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu)
|
|
if (belegt) {
|
|
// Bewusst KEIN stilles Ueberschreiben: hier wuerde eine fremde
|
|
// Datei verschwinden, und der Verlauf zeigte nur einen Umzug.
|
|
ergebnisse.push({ path: row.path, ok: false, error: `${neu} ist belegt` })
|
|
continue
|
|
}
|
|
|
|
try {
|
|
await moveFile(row.path, neu)
|
|
db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run(
|
|
neu,
|
|
now(),
|
|
row.id,
|
|
)
|
|
logEvent('move', neu, row.size, 'user', wer, row.path)
|
|
ergebnisse.push({ path: row.path, ok: true, neu })
|
|
} catch (err) {
|
|
ergebnisse.push({
|
|
path: row.path,
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : 'unbekannt',
|
|
})
|
|
}
|
|
}
|
|
|
|
const gut = ergebnisse.filter((e) => e.ok).length
|
|
return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
|
})
|
|
|
|
/** Eine Datei umbenennen.
|
|
*
|
|
* Verschieben gab es, Umbenennen nicht -- und das ist genau derselbe Vorgang
|
|
* mit einem anderen letzten Stueck. Getrennt gehalten wird es trotzdem, weil
|
|
* die WARNUNG eine andere ist: beim Verschieben wandert eine Datei, beim
|
|
* Umbenennen aendert sich IHRE ADRESSE, und die steht womoeglich in einem
|
|
* Skript, das wir nicht sehen.
|
|
*
|
|
* DESHALB SAGT DIE ANTWORT, WAS DIE ALTE ADRESSE WAR und wie oft sie geholt
|
|
* wurde. Eine Datei mit 4000 Abrufen umzubenennen ist etwas anderes als eine
|
|
* mit null -- und der Dienst ist die einzige Stelle, die das weiss.
|
|
*
|
|
* Nur der NAME wird geaendert, nicht der Ordner. Wer beides will, benennt um
|
|
* und verschiebt; zwei sichtbare Schritte sind hier besser als einer, der
|
|
* beides kann und dabei unklar wird. */
|
|
dashRoutes.post('/media/umbenennen', async (c) => {
|
|
const body = await jsonBody<{ id?: number; name?: string }>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
if (!body.id || !body.name?.trim()) return c.json({ error: 'id und name noetig' }, 400)
|
|
|
|
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(body.id) as Media | undefined
|
|
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
|
|
|
|
// Der Name kommt aus einem Formular und wird NICHT gezaehmt, sondern
|
|
// geprueft: wer hier umbenennt, weiss, welche Adresse er will. Ein
|
|
// stillschweigend zurechtgebogener Name waere schlimmer als eine
|
|
// Fehlermeldung -- man merkte es erst, wenn das Bild fehlt.
|
|
const name = body.name.trim()
|
|
if (name.includes('/')) {
|
|
return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten — zum Verschieben gibt es "Verschieben".' }, 400)
|
|
}
|
|
|
|
const ordner = row.path.split('/').slice(0, -1).join('/')
|
|
let neu: string
|
|
try {
|
|
neu = checkPath(ordner ? `${ordner}/${name}` : name)
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
|
|
if (neu === row.path) return c.json({ error: 'heisst schon so' }, 400)
|
|
if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu)) {
|
|
return c.json({ error: `${neu} ist belegt` }, 409)
|
|
}
|
|
|
|
await moveFile(row.path, neu)
|
|
db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run(neu, now(), row.id)
|
|
logEvent('rename', neu, row.size, 'user', c.get('user').username, row.path)
|
|
|
|
return c.json({ alt: row.path, neu, url: publicUrlFor(neu), abrufe: row.abrufe })
|
|
})
|
|
|
|
/** Einen Ordner umbenennen -- mit allem, was darin liegt.
|
|
*
|
|
* DAS IST DER GROSSE HEBEL und deshalb der vorsichtigere Weg: hier aendern
|
|
* sich auf einen Schlag hunderte Adressen. "items" nach "gegenstaende" zu
|
|
* benennen macht 3576 URLs ungueltig, und keine davon meldet sich.
|
|
*
|
|
* Die Datenbank wird in EINER Transaktion umgeschrieben. Nicht aus Eile: wenn
|
|
* auf halbem Weg etwas schiefgeht, zeigt sonst die Haelfte der Eintraege auf
|
|
* einen Ordner, den es nicht mehr gibt. Das Verschieben auf der Platte ist ein
|
|
* einziges `rename` und damit ohnehin unteilbar. */
|
|
dashRoutes.post('/folders/umbenennen', async (c) => {
|
|
const body = await jsonBody<{ pfad?: string; name?: string }>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
if (!body.pfad?.trim() || !body.name?.trim()) {
|
|
return c.json({ error: 'pfad und name noetig' }, 400)
|
|
}
|
|
|
|
const name = body.name.trim()
|
|
if (name.includes('/')) {
|
|
return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten' }, 400)
|
|
}
|
|
|
|
let alt: string
|
|
let neu: string
|
|
try {
|
|
alt = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, ''))
|
|
const oben = alt.split('/').slice(0, -1).join('/')
|
|
neu = checkPath(oben ? `${oben}/${name}` : name)
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
|
|
if (neu === alt) return c.json({ error: 'heisst schon so' }, 400)
|
|
|
|
// Der Zielordner darf nicht schon belegt sein -- weder in der Datenbank
|
|
// noch auf der Platte. Sonst schoebe `rename` den einen IN den anderen
|
|
// hinein, was etwas voellig anderes ist als umbenennen.
|
|
const maskiert = (s: string) => s.replace(/[\\%_]/g, (z) => `\\${z}`)
|
|
const drin = db
|
|
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
|
.get(`${maskiert(neu)}/%`) as { n: number }
|
|
if (drin.n > 0) return c.json({ error: `"${neu}" gibt es schon (${drin.n} Dateien)` }, 409)
|
|
try {
|
|
await stat(join(config.filesDir, neu))
|
|
return c.json({ error: `"${neu}" gibt es schon` }, 409)
|
|
} catch {
|
|
/* frei — weiter */
|
|
}
|
|
|
|
const betroffen = db
|
|
.prepare(`SELECT id, path, size FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
|
.all(`${maskiert(alt)}/%`) as { id: number; path: string; size: number }[]
|
|
|
|
try {
|
|
await moveFolder(alt, neu)
|
|
} catch (err) {
|
|
return c.json(
|
|
{ error: `Verschieben fehlgeschlagen: ${err instanceof Error ? err.message : 'unbekannt'}` },
|
|
500,
|
|
)
|
|
}
|
|
|
|
const wer = c.get('user').username
|
|
const zeit = now()
|
|
const schreiben = db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?')
|
|
db.transaction(() => {
|
|
for (const r of betroffen) {
|
|
schreiben.run(neu + r.path.slice(alt.length), zeit, r.id)
|
|
}
|
|
})()
|
|
|
|
// EIN Eintrag im Verlauf und nicht 3576. Wer nachliest, was passiert ist,
|
|
// will "items -> gegenstaende" lesen und nicht dreitausend Zeilen davon.
|
|
logEvent('rename', neu, 0, 'user', wer, alt)
|
|
|
|
return c.json({ alt, neu, dateien: betroffen.length })
|
|
})
|
|
|
|
/** Einen leeren Ordner anlegen.
|
|
*
|
|
* Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor
|
|
* dem letzten Schraegstrich, mehr nicht. Ein leerer Ordner ist deshalb ein
|
|
* echtes Verzeichnis auf der Platte und in keiner Tabelle -- /folders liest
|
|
* beides und fuehrt es zusammen. */
|
|
dashRoutes.post('/folders', async (c) => {
|
|
const body = await jsonBody<{ pfad?: string }>(c)
|
|
if (!body?.pfad?.trim()) return c.json({ error: 'kein Pfad' }, 400)
|
|
|
|
let pfad: string
|
|
try {
|
|
pfad = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, ''))
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
|
|
await mkdir(join(config.filesDir, pfad), { recursive: true })
|
|
return c.json({ ok: true, pfad })
|
|
})
|
|
|
|
/** Einen Ordner entfernen -- nur wenn er leer ist.
|
|
*
|
|
* Ein Ordner mit Inhalt wird NICHT mitgeloescht: das waere ein Knopf, der
|
|
* hunderte Dateien wegraeumt, ohne sie zu zeigen. Wer sie loeschen will,
|
|
* waehlt sie in der Galerie aus und sieht dabei, was verschwindet. */
|
|
dashRoutes.delete('/folders/:pfad{.+}', async (c) => {
|
|
let pfad: string
|
|
try {
|
|
pfad = checkPath(c.req.param('pfad'))
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
|
|
const drin = db
|
|
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
|
.get(`${pfad.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`) as { n: number }
|
|
if (drin.n > 0) {
|
|
return c.json({ error: `Der Ordner enthaelt noch ${drin.n} Dateien.` }, 409)
|
|
}
|
|
|
|
try {
|
|
// rmdir und nicht rm: es scheitert VON SELBST, wenn der Ordner nicht
|
|
// leer ist -- und genau das ist hier erwuenscht. `rm` ohne recursive
|
|
// wirft dagegen EISDIR und sagt damit etwas ganz anderes, als der
|
|
// Fall ist (einmal darauf hereingefallen).
|
|
await rmdir(join(config.filesDir, pfad))
|
|
} catch (err) {
|
|
return c.json(
|
|
{ error: err instanceof Error ? err.message : 'liess sich nicht entfernen' },
|
|
400,
|
|
)
|
|
}
|
|
// Das Abgeleitete desselben Ordners hinterher, falls es da ist. HIER mit
|
|
// recursive: anders als oben geht es nicht um eine Sicherung gegen
|
|
// versehentliches Loeschen -- der Ordner mit den Originalen ist zu diesem
|
|
// Zeitpunkt schon weg, und was hier noch liegt, ist nachrechenbar.
|
|
for (const ordner of ['thumbs', 'webp']) {
|
|
await rm(join(config.dataDir, ordner, pfad), { recursive: true, force: true }).catch(
|
|
() => {},
|
|
)
|
|
}
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
/** Die Medienliste zum Mitnehmen.
|
|
*
|
|
* Wofuer: ein Skript, das die Adressen kennen muss, ohne die API zu fragen --
|
|
* eine Vorlage im Handy, eine Liste zum Abgleichen, ein Datensatz fuer eine
|
|
* andere Resource. Drei Formen, weil drei verschiedene Werkzeuge sie lesen.
|
|
*
|
|
* Die Lua-Form ist KEINE Spielerei: eine Datei, die man in eine Resource legt
|
|
* und mit `local urls = ...` einliest, spart genau den Zwischenschritt, an dem
|
|
* sonst jemand von Hand abtippt.
|
|
*
|
|
* Ohne Deckel: wer 900 Zeilen exportiert, will 900 Zeilen. Bei 228 Eintraegen
|
|
* sind das 40 KB. */
|
|
dashRoutes.get('/export', (c) => {
|
|
const form = c.req.query('format') ?? 'json'
|
|
const ordner = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
|
|
|
|
const where = ordner ? `WHERE path LIKE ? ESCAPE '\\'` : ''
|
|
const params = ordner ? [`${ordner.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`] : []
|
|
|
|
const rows = (
|
|
db.prepare(`SELECT * FROM media ${where} ORDER BY path COLLATE NOCASE`).all(...params) as Media[]
|
|
).map((row) => ({ ...row, url: publicUrlFor(row.path) }))
|
|
|
|
const stempel = new Date().toISOString().slice(0, 10)
|
|
|
|
if (form === 'csv') {
|
|
// Semikolon: Excel in deutscher Einstellung nimmt sonst alles in eine
|
|
// Spalte, und dann ist die Datei fuer den haeufigsten Leser wertlos.
|
|
const kopf = 'pfad;url;groesse;typ;sha256;geaendert'
|
|
const zeilen = rows.map((r) =>
|
|
[r.path, r.url, r.size, r.mime, r.sha256, new Date(r.updated_at).toISOString()]
|
|
.map((f) => `"${String(f).replace(/"/g, '""')}"`)
|
|
.join(';'),
|
|
)
|
|
// CRLF, weil Excel es so erwartet — mit blossem \n steht die halbe
|
|
// Datei in einer Zelle.
|
|
return c.body([kopf, ...zeilen].join('\r\n'), 200, {
|
|
'Content-Type': 'text/csv; charset=utf-8',
|
|
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.csv"`,
|
|
})
|
|
}
|
|
|
|
if (form === 'lua') {
|
|
// Der Dateiname ohne Endung als Schluessel: genau danach sucht ein
|
|
// Skript ("adder"), nicht nach "vehicles/adder.webp".
|
|
const eintraege = rows.map((r) => {
|
|
const name = (r.path.split('/').pop() ?? r.path).replace(/\.[^.]+$/, '')
|
|
// "\\'" und nicht "\'": in JavaScript ist Letzteres schlicht ein
|
|
// Apostroph, der Backslash faellt weg — und ein Modellname mit
|
|
// Apostroph zerlegte damit die erzeugte Lua-Datei.
|
|
return ` ['${name.replace(/'/g, "\\'")}'] = '${r.url}',`
|
|
})
|
|
const text = [
|
|
'-- Erzeugt von d4rk_media am ' + new Date().toISOString(),
|
|
'-- ' + rows.length + ' Eintraege' + (ordner ? ` aus ${ordner}/` : ''),
|
|
'--',
|
|
'-- Verwendung: local urls = require(...) bzw. in einer Resource',
|
|
'-- als shared_script einbinden und Urls.<name> lesen.',
|
|
'Urls = {',
|
|
...eintraege,
|
|
'}',
|
|
'',
|
|
].join('\n')
|
|
return c.body(text, 200, {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.lua"`,
|
|
})
|
|
}
|
|
|
|
return c.body(JSON.stringify({ erzeugt: now(), anzahl: rows.length, items: rows }, null, 2), 200, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.json"`,
|
|
})
|
|
})
|
|
|
|
/** Speicher nach Medienart, groesste Dateien, und was fuer eine spaetere
|
|
* Aufbewahrungsregel wichtig waere.
|
|
*
|
|
* ERST SEHEN, DANN ENTSCHEIDEN. Automatisches Loeschen zu bauen, bevor man
|
|
* weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route
|
|
* loescht nichts -- sie zeigt.
|
|
*/
|
|
/** Eine Datei in den Papierkorb legen: Datei verschieben, Datensatz umziehen.
|
|
*
|
|
* An EINER Stelle, weil es zwei Loeschwege gibt (einzeln und im Block) und
|
|
* die beim naechsten Mal sonst auseinanderlaufen. */
|
|
async function inPapierkorb(row: Media, wer: string): Promise<void> {
|
|
const eintrag = db
|
|
.prepare(
|
|
`INSERT INTO papierkorb (path, size, sha256, mime, geloescht, von, ablage)
|
|
VALUES (?, ?, ?, ?, ?, ?, '')`,
|
|
)
|
|
.run(row.path, row.size, row.sha256, row.mime, now(), wer)
|
|
|
|
const id = Number(eintrag.lastInsertRowid)
|
|
try {
|
|
const ablage = await inDenPapierkorb(row.path, id)
|
|
db.prepare('UPDATE papierkorb SET ablage = ? WHERE id = ?').run(ablage, id)
|
|
} catch (err) {
|
|
// Konnte die Datei nicht verschoben werden, darf der Datensatz nicht
|
|
// stehen bleiben -- sonst zeigt der Papierkorb etwas an, das sich nicht
|
|
// zurueckholen laesst.
|
|
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id)
|
|
throw err
|
|
}
|
|
|
|
db.prepare('DELETE FROM media WHERE id = ?').run(row.id)
|
|
logEvent('delete', row.path, row.size, 'user', wer)
|
|
}
|
|
|
|
/** Was zu lange im Papierkorb liegt, ist endgueltig weg.
|
|
*
|
|
* Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag
|
|
* aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */
|
|
export async function papierkorbAusleeren(): Promise<number> {
|
|
const alt = db
|
|
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
|
|
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
|
|
for (const e of alt) {
|
|
await rm(papierkorbPfad(e.ablage), { force: true })
|
|
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
|
|
}
|
|
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege endgueltig entfernt`)
|
|
return alt.length
|
|
}
|
|
|
|
dashRoutes.get('/papierkorb', async (c) => {
|
|
const roh = db
|
|
.prepare('SELECT * FROM papierkorb ORDER BY geloescht DESC LIMIT 500')
|
|
.all() as PapierkorbEintrag[]
|
|
|
|
// OB DIE DATEI WIRKLICH NOCH DA IST -- ein stat je Zeile, hoechstens 500.
|
|
//
|
|
// Der Eintrag steht in media.db, die Datei liegt daneben auf der Platte:
|
|
// zwei Wahrheiten, die auseinanderlaufen koennen. Genau das ist passiert,
|
|
// solange die Sicherung /data/papierkorb ausliess -- nach einem
|
|
// Zurueckspielen zeigte der Papierkorb Zeilen, deren "zurueckholen" ins
|
|
// Leere griff. Die Sicherung nimmt ihn inzwischen mit; DIESE Pruefung
|
|
// bleibt trotzdem, weil ein Papierkorb, der etwas verspricht, es auch
|
|
// halten koennen muss -- und wenn nicht, es sagen soll, statt es erst beim
|
|
// Druecken herauszufinden.
|
|
const items = await Promise.all(
|
|
roh.map(async (e) => {
|
|
try {
|
|
await stat(papierkorbPfad(e.ablage))
|
|
return { ...e, dateiDa: true }
|
|
} catch {
|
|
return { ...e, dateiDa: false }
|
|
}
|
|
}),
|
|
)
|
|
|
|
const summe = db
|
|
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM papierkorb')
|
|
.get() as { n: number; bytes: number }
|
|
return c.json({
|
|
items,
|
|
...summe,
|
|
haltbarTage: PAPIERKORB_TAGE,
|
|
verwaist: items.filter((e) => !e.dateiDa).length,
|
|
})
|
|
})
|
|
|
|
/** Zurueck an ihren Platz.
|
|
*
|
|
* Ist der Pfad inzwischen wieder belegt, wird NICHT ueberschrieben: dann
|
|
* verschwaende eine Datei, die jemand nach dem Loeschen bewusst dort abgelegt
|
|
* hat. */
|
|
dashRoutes.post('/papierkorb/zurueck', async (c) => {
|
|
const body = await jsonBody<{ ids?: number[] }>(c)
|
|
if (!Array.isArray(body?.ids) || body.ids.length === 0) {
|
|
return c.json({ error: 'keine Auswahl' }, 400)
|
|
}
|
|
|
|
const wer = c.get('user').username
|
|
const ergebnisse: { path: string; ok: boolean; error?: string }[] = []
|
|
|
|
for (const id of body.ids) {
|
|
const e = db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id) as
|
|
| PapierkorbEintrag
|
|
| undefined
|
|
if (!e) {
|
|
ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' })
|
|
continue
|
|
}
|
|
if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(e.path)) {
|
|
ergebnisse.push({ path: e.path, ok: false, error: 'Pfad ist wieder belegt' })
|
|
continue
|
|
}
|
|
|
|
try {
|
|
await ausDemPapierkorb(e.ablage, e.path)
|
|
const zeit = now()
|
|
db.prepare(
|
|
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
|
|
).run(e.path, e.size, e.sha256, e.mime, zeit, zeit)
|
|
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id)
|
|
|
|
// Vorschau und sparsame Fassung wurden beim Loeschen weggeworfen --
|
|
// beides ist abgeleitet. Jetzt neu rechnen, sonst zeigt die Galerie
|
|
// ein Loch und die Datei kommt wieder in voller Groesse heraus.
|
|
if (kannVorschau(e.mime) || lohntWebp(e.mime)) {
|
|
try {
|
|
const daten = await readFile(absolutePath(e.path))
|
|
if (kannVorschau(e.mime)) await writeThumb(e.path, daten)
|
|
if (lohntWebp(e.mime)) await writeWebp(e.path, daten)
|
|
} catch {
|
|
/* die Galerie faellt aufs Vollbild zurueck */
|
|
}
|
|
}
|
|
|
|
logEvent('upload', e.path, e.size, 'user', wer)
|
|
ergebnisse.push({ path: e.path, ok: true })
|
|
} catch (err) {
|
|
ergebnisse.push({
|
|
path: e.path,
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : 'unbekannt',
|
|
})
|
|
}
|
|
}
|
|
|
|
const gut = ergebnisse.filter((e) => e.ok).length
|
|
return c.json({ zurueck: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
|
})
|
|
|
|
/** Endgueltig weg. Hier gibt es kein Zurueck mehr, und die Oberflaeche sagt
|
|
* das auch. */
|
|
dashRoutes.post('/papierkorb/leeren', async (c) => {
|
|
const body = await jsonBody<{ ids?: number[] }>(c)
|
|
const eintraege = (
|
|
Array.isArray(body?.ids) && body.ids.length > 0
|
|
? body.ids
|
|
.map((id) => db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id))
|
|
.filter(Boolean)
|
|
: db.prepare('SELECT * FROM papierkorb').all()
|
|
) as PapierkorbEintrag[]
|
|
|
|
let weg = 0
|
|
for (const e of eintraege) {
|
|
await rm(papierkorbPfad(e.ablage), { force: true })
|
|
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
|
|
weg++
|
|
}
|
|
return c.json({ geleert: weg })
|
|
})
|
|
|
|
// ------------------------------------------------------------ Benutzer
|
|
//
|
|
// WOZU. Es gab zwei Konten und keine Stelle, an der man sie sehen konnte --
|
|
// "admin" und eines aus Discord. Und weil jeder mit der Rolle auf dem Server
|
|
// beim ersten Anmelden STILL ein Konto bekommt, waere aus zwei unbemerkt
|
|
// zwanzig geworden. Wer hereinkommt, gehoert auf einen Bildschirm.
|
|
|
|
dashRoutes.get('/benutzer', (c) => {
|
|
const items = (
|
|
db
|
|
.prepare(
|
|
`SELECT id, username, created_at, zuletzt_da, discord_id, discord_name,
|
|
discord_avatar
|
|
FROM users ORDER BY created_at`,
|
|
)
|
|
.all() as BenutzerZeile[]
|
|
).map((u) => ({
|
|
id: u.id,
|
|
username: u.username,
|
|
created_at: u.created_at,
|
|
zuletzt_da: u.zuletzt_da,
|
|
// Ueber welchen Weg jemand hereinkommt, ist die wichtigere Auskunft als
|
|
// sein Name: ein Discord-Konto haengt an einer Rolle, die jemand anders
|
|
// vergibt.
|
|
weg: u.discord_id ? 'discord' : 'passwort',
|
|
discordName: u.discord_name,
|
|
avatar: avatarUrl(u.discord_id, u.discord_avatar, 32),
|
|
// Wieviele Sitzungen gerade offen sind -- die Zahl, die nach dem
|
|
// Entfernen auf 0 steht.
|
|
sitzungen: (
|
|
db
|
|
.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND expires_at > ?')
|
|
.get(u.id, now()) as { n: number }
|
|
).n,
|
|
}))
|
|
return c.json({ items, ich: c.get('user').id })
|
|
})
|
|
|
|
/** Ein Konto entfernen.
|
|
*
|
|
* SICH SELBST NICHT -- das ist keine Bevormundung, sondern die Vermeidung des
|
|
* einen Klicks, nach dem niemand mehr hereinkommt. Und den LETZTEN nicht: ein
|
|
* Dienst ohne Konto laesst sich nur noch ueber die Datenbank reparieren.
|
|
*
|
|
* Die Sitzungen gehen mit (ON DELETE CASCADE), der Zugang ist also sofort weg
|
|
* und nicht erst in 30 Tagen. */
|
|
dashRoutes.delete('/benutzer/:id', (c) => {
|
|
const id = Number(c.req.param('id'))
|
|
if (id === c.get('user').id) {
|
|
return c.json({ error: 'Das eigene Konto laesst sich hier nicht entfernen.' }, 400)
|
|
}
|
|
const wieviele = (db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n
|
|
if (wieviele <= 1) {
|
|
return c.json({ error: 'Das letzte Konto bleibt — sonst kommt niemand mehr herein.' }, 400)
|
|
}
|
|
|
|
const wer = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as
|
|
| { username: string }
|
|
| undefined
|
|
if (!wer) return c.json({ error: 'nicht gefunden' }, 404)
|
|
|
|
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
|
return c.json({ ok: true, username: wer.username })
|
|
})
|
|
|
|
// ------------------------------------------------------------- Freigaben
|
|
//
|
|
// Was hier verwaltet wird, ist die LISTE eines Ordners -- die Dateien selbst
|
|
// sind ohnehin oeffentlich. Das steht auch in der Oberflaeche, damit niemand
|
|
// glaubt, eine Freigabe zu widerrufen mache seine Bilder wieder privat.
|
|
|
|
dashRoutes.get('/freigaben', (c) => {
|
|
const items = (
|
|
db.prepare('SELECT * FROM freigaben ORDER BY created_at DESC').all() as Freigabe[]
|
|
).map((f) => ({ ...f, url: `${config.publicUrl}/s/${f.schluessel}` }))
|
|
return c.json({ items })
|
|
})
|
|
|
|
dashRoutes.post('/freigaben', async (c) => {
|
|
const body = await jsonBody<{ ordner?: string; titel?: string }>(c)
|
|
if (!body?.ordner?.trim()) return c.json({ error: 'kein Ordner' }, 400)
|
|
|
|
let ordner: string
|
|
try {
|
|
ordner = checkPath(body.ordner.trim().replace(/^\/+|\/+$/g, ''))
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
|
|
// Einen Link auf einen Ordner anzulegen, den es nicht gibt, ergibt eine
|
|
// leere Seite und die Frage, woran es liegt. Lieber hier ablehnen.
|
|
const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
|
|
const drin = db
|
|
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
|
.get(`${maskiert}/%`) as { n: number }
|
|
if (drin.n === 0) return c.json({ error: `In "${ordner}" liegt nichts.` }, 400)
|
|
|
|
const schluessel = neuerSchluessel()
|
|
const eintrag = db
|
|
.prepare(
|
|
`INSERT INTO freigaben (schluessel, ordner, titel, created_at)
|
|
VALUES (?, ?, ?, ?)`,
|
|
)
|
|
.run(schluessel, ordner, (body.titel ?? '').trim(), now())
|
|
|
|
logEvent('upload', ordner, 0, 'user', c.get('user').username)
|
|
|
|
return c.json({
|
|
id: Number(eintrag.lastInsertRowid),
|
|
schluessel,
|
|
ordner,
|
|
dateien: drin.n,
|
|
url: `${config.publicUrl}/s/${schluessel}`,
|
|
})
|
|
})
|
|
|
|
dashRoutes.delete('/freigaben/:id', (c) => {
|
|
const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(Number(c.req.param('id')))
|
|
if (r.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
dashRoutes.get('/speicher', (c) => {
|
|
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
|
|
const z = db
|
|
.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM media WHERE ${bedingung}`)
|
|
.get() as { n: number; bytes: number }
|
|
return { art: name, files: z.n, bytes: z.bytes }
|
|
})
|
|
|
|
const groesste = (
|
|
db.prepare('SELECT * FROM media ORDER BY size DESC LIMIT 10').all() as Media[]
|
|
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
|
|
|
|
const aelteste = (
|
|
db.prepare('SELECT * FROM media ORDER BY created_at ASC LIMIT 10').all() as Media[]
|
|
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
|
|
|
|
// Derselbe Inhalt unter zwei Pfaden. Kein Fehler -- aber jedes Doppel ist
|
|
// Platz, den niemand gewaehlt hat.
|
|
const doppelte = db
|
|
.prepare(
|
|
`SELECT sha256, COUNT(*) AS n, SUM(size) AS bytes,
|
|
GROUP_CONCAT(path, '|') AS pfade
|
|
FROM media GROUP BY sha256 HAVING n > 1 ORDER BY bytes DESC LIMIT 20`,
|
|
)
|
|
.all() as { sha256: string; n: number; bytes: number; pfade: string }[]
|
|
|
|
const verschwendet = doppelte.reduce((s, d) => s + (d.bytes / d.n) * (d.n - 1), 0)
|
|
|
|
// Wie alt der Bestand ist, in Stufen. Das ist die Zahl, die eine
|
|
// Aufbewahrungsregel braucht: "aelter als 90 Tage" ist eine Behauptung,
|
|
// solange niemand weiss, wie viel das waere.
|
|
const stufen = [7, 30, 90, 365].map((tage) => {
|
|
const z = db
|
|
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE created_at < ?')
|
|
.get(now() - tage * 86_400_000) as { n: number; bytes: number }
|
|
return { tage, files: z.n, bytes: z.bytes }
|
|
})
|
|
|
|
// Die Frage, die der Bericht bisher nicht beantworten konnte.
|
|
const nieGeholt = db
|
|
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE abrufe = 0')
|
|
.get() as { n: number; bytes: number }
|
|
const beliebteste = (
|
|
db.prepare('SELECT * FROM media WHERE abrufe > 0 ORDER BY abrufe DESC LIMIT 10').all() as Media[]
|
|
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
|
|
|
|
return c.json({
|
|
nachArt,
|
|
groesste,
|
|
aelteste,
|
|
doppelte,
|
|
verschwendet,
|
|
stufen,
|
|
nieGeholt,
|
|
beliebteste,
|
|
})
|
|
})
|
|
|
|
dashRoutes.get('/stats', (c) => {
|
|
const media = db
|
|
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
|
|
.get() as { files: number; bytes: number }
|
|
const tokens = db.prepare('SELECT COUNT(*) AS n FROM tokens').get() as { n: number }
|
|
|
|
// Die groessten Ordner — die erste Frage bei "wo ist mein Platz hin".
|
|
const folders = db
|
|
.prepare(
|
|
`SELECT
|
|
CASE WHEN instr(path, '/') > 0
|
|
THEN substr(path, 1, instr(path, '/') - 1)
|
|
ELSE '(Wurzel)' END AS folder,
|
|
COUNT(*) AS files,
|
|
SUM(size) AS bytes
|
|
FROM media GROUP BY folder ORDER BY bytes DESC LIMIT 10`,
|
|
)
|
|
.all() as { folder: string; files: number; bytes: number }[]
|
|
|
|
return c.json({ ...media, tokens: tokens.n, folders, publicUrl: config.publicUrl })
|
|
})
|
|
|
|
// ------------------------------------------------------------------- Tokens
|
|
|
|
/** Was mit einem Token abgelegt wurde.
|
|
*
|
|
* `media.token_id` wird seit dem ersten Tag geschrieben und war bis hierhin
|
|
* NIRGENDS zu sehen. Dabei ist es die Auskunft, die vor dem Loeschen eines
|
|
* Tokens zaehlt: "dieser Token haelt 3576 Dateien" ist etwas anderes als
|
|
* "dieser Token ist ungenutzt".
|
|
*
|
|
* Der Dashboard-Upload traegt bewusst KEINEN Token (dort gibt es eine
|
|
* Sitzung), diese Dateien stehen deshalb unter `ohneToken`. */
|
|
function tokenNutzung(): Map<number, { dateien: number; bytes: number; letzte: number | null }> {
|
|
const zeilen = db
|
|
.prepare(
|
|
`SELECT token_id, COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes,
|
|
MAX(created_at) AS letzte
|
|
FROM media WHERE token_id IS NOT NULL GROUP BY token_id`,
|
|
)
|
|
.all() as { token_id: number; dateien: number; bytes: number; letzte: number }[]
|
|
return new Map(zeilen.map((z) => [z.token_id, z]))
|
|
}
|
|
|
|
dashRoutes.get('/tokens', (c) => {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT id, name, prefix, can_delete, created_at, last_used_at,
|
|
max_bytes, quota_bytes, arten, expires_at
|
|
FROM tokens ORDER BY created_at DESC`,
|
|
)
|
|
.all() as Omit<Token, 'hash'>[]
|
|
|
|
const nutzung = tokenNutzung()
|
|
const ohneToken = db
|
|
.prepare(
|
|
'SELECT COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes FROM media WHERE token_id IS NULL',
|
|
)
|
|
.get() as { dateien: number; bytes: number }
|
|
|
|
return c.json({
|
|
items: rows.map((r) => ({
|
|
...r,
|
|
// Abgelaufen ist eine Eigenschaft, keine Rechnung fuer die
|
|
// Oberflaeche: sonst steht in zwei Ansichten zweimal dieselbe
|
|
// Bedingung, und eine davon ist irgendwann falsch.
|
|
abgelaufen: Boolean(r.expires_at && r.expires_at < now()),
|
|
dateien: nutzung.get(r.id)?.dateien ?? 0,
|
|
bytes: nutzung.get(r.id)?.bytes ?? 0,
|
|
// `last_used_at` sagt, wann zuletzt jemand mit diesem Token
|
|
// ANGEKLOPFT hat -- auch bei einem Loeschversuch oder einem
|
|
// Fehlschlag. Das hier sagt, wann zuletzt etwas dabei HERAUSKAM.
|
|
zuletztAbgelegt: nutzung.get(r.id)?.letzte ?? null,
|
|
})),
|
|
ohneToken,
|
|
})
|
|
})
|
|
|
|
dashRoutes.post('/tokens', async (c) => {
|
|
const body = await jsonBody<{
|
|
name?: string
|
|
prefix?: string
|
|
canDelete?: boolean
|
|
maxMb?: number | string
|
|
quotaMb?: number | string
|
|
arten?: string[]
|
|
gueltigTage?: number | string
|
|
}>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
const { name, prefix, canDelete } = body
|
|
|
|
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
|
|
|
|
// -- Die drei Grenzen --------------------------------------------------
|
|
let maxBytes: number | null = null
|
|
if (body.maxMb !== undefined && String(body.maxMb).trim() !== '') {
|
|
const mb = Number(body.maxMb)
|
|
if (!Number.isFinite(mb) || mb <= 0) {
|
|
return c.json({ error: 'Groessengrenze: eine Zahl groesser als 0' }, 400)
|
|
}
|
|
// Groesser als die Grenze des Dienstes zu erlauben waere eine
|
|
// Behauptung: durchgelassen wird ohnehin die schaerfere.
|
|
if (mb * 1024 * 1024 > config.maxUploadBytes) {
|
|
return c.json(
|
|
{
|
|
error: `Der Dienst nimmt hoechstens ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB — mehr kann auch dieser Token nicht.`,
|
|
},
|
|
400,
|
|
)
|
|
}
|
|
maxBytes = Math.round(mb * 1024 * 1024)
|
|
}
|
|
|
|
// DAS GESAMTKONTINGENT -- etwas anderes als die Groesse EINER Datei, und
|
|
// deshalb ohne Deckelung durch config.maxUploadBytes: "hoechstens 2 MB je
|
|
// Bild, aber insgesamt 5 GB" ist eine voellig sinnvolle Angabe.
|
|
let quotaBytes: number | null = null
|
|
if (body.quotaMb !== undefined && String(body.quotaMb).trim() !== '') {
|
|
const mb = Number(body.quotaMb)
|
|
if (!Number.isFinite(mb) || mb <= 0) {
|
|
return c.json({ error: 'Kontingent: eine Zahl groesser als 0' }, 400)
|
|
}
|
|
quotaBytes = Math.round(mb * 1024 * 1024)
|
|
}
|
|
|
|
const ERLAUBTE_ARTEN = ['bild', 'video', 'ton', 'dokument', 'andere']
|
|
let arten = ''
|
|
if (Array.isArray(body.arten) && body.arten.length > 0) {
|
|
const falsch = body.arten.filter((a) => !ERLAUBTE_ARTEN.includes(a))
|
|
if (falsch.length) return c.json({ error: `Unbekannte Art: ${falsch.join(', ')}` }, 400)
|
|
// Alle fuenf gewaehlt heisst dasselbe wie keine Einschraenkung -- dann
|
|
// steht in der Datenbank auch nichts, sonst muesste jede spaetere
|
|
// Erweiterung der Artenliste alte Token nachziehen.
|
|
arten = body.arten.length === ERLAUBTE_ARTEN.length ? '' : body.arten.join(',')
|
|
}
|
|
|
|
let expiresAt: number | null = null
|
|
if (body.gueltigTage !== undefined && String(body.gueltigTage).trim() !== '') {
|
|
const tage = Number(body.gueltigTage)
|
|
if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) {
|
|
return c.json({ error: 'Gueltigkeit: 1 bis 3650 Tage' }, 400)
|
|
}
|
|
expiresAt = now() + Math.round(tage) * 86_400_000
|
|
}
|
|
|
|
// Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft.
|
|
let cleanPrefix = ''
|
|
if (prefix?.trim()) {
|
|
try {
|
|
cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, ''))
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const token = generateToken()
|
|
const info = db
|
|
.prepare(
|
|
`INSERT INTO tokens (name, hash, prefix, can_delete, created_at,
|
|
max_bytes, quota_bytes, arten, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
name.trim(),
|
|
tokenHash(token),
|
|
cleanPrefix,
|
|
canDelete ? 1 : 0,
|
|
now(),
|
|
maxBytes,
|
|
quotaBytes,
|
|
arten,
|
|
expiresAt,
|
|
)
|
|
|
|
// 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({
|
|
id: Number(info.lastInsertRowid),
|
|
name: name.trim(),
|
|
prefix: cleanPrefix,
|
|
canDelete: Boolean(canDelete),
|
|
maxBytes,
|
|
arten,
|
|
expiresAt,
|
|
token,
|
|
})
|
|
})
|
|
|
|
dashRoutes.delete('/tokens/:id', (c) => {
|
|
const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(Number(c.req.param('id')))
|
|
if (info.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
/** Eine Testnachricht schicken.
|
|
*
|
|
* Ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall
|
|
* ausprobiert, ist ein Webhook, der dann nicht funktioniert. */
|
|
dashRoutes.post('/maintenance/melden', async (c) => {
|
|
// Mit echten Zahlen statt "Hallo Welt": so sieht man nicht nur, DASS etwas
|
|
// ankommt, sondern auch, ob es lesbar ist.
|
|
const b = db
|
|
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size),0) AS bytes FROM media')
|
|
.get() as { files: number; bytes: number }
|
|
const ok = await testMeldung(c.get('user').username, b)
|
|
if (!ok) {
|
|
return c.json(
|
|
{ error: 'Nichts geschickt. Ist ein Webhook eingetragen, und stimmt die Adresse?' },
|
|
400,
|
|
)
|
|
}
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
dashRoutes.post('/maintenance/prune-sessions', (c) => {
|
|
pruneSessions()
|
|
pruneEvents()
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
/** Was nicht zusammenpasst: Dateien ohne Datensatz, Datensaetze ohne Datei,
|
|
* Vorschauen ohne Bild.
|
|
*
|
|
* ERST ZAEHLEN, DANN FRAGEN. Diese Route veraendert NICHTS -- sie sagt nur,
|
|
* was sie sieht, mit Beispielen. Ein Knopf, der ungefragt aufraeumt, ist bei
|
|
* einer Ablage ohne Papierkorb der falsche Knopf.
|
|
*
|
|
* Wie es dazu kommt: jemand loescht im Volume von Hand, ein Upload bricht
|
|
* zwischen Datei und Datensatz ab, oder ein Bild wird ausserhalb des Dienstes
|
|
* hineinkopiert. Das Sicherungsskript zaehlt beides schon gegeneinander und
|
|
* meldet die Abweichung -- reparieren konnte es sie bisher nicht. */
|
|
async function verwaisteSuchen() {
|
|
const rows = db.prepare('SELECT id, path, size FROM media').all() as {
|
|
id: number
|
|
path: string
|
|
size: number
|
|
}[]
|
|
const bekannt = new Map(rows.map((r) => [r.path, r]))
|
|
|
|
// Alles, was wirklich auf der Platte liegt.
|
|
const aufPlatte: string[] = []
|
|
async function durchlaufen(rel: string) {
|
|
let eintraege
|
|
try {
|
|
eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
for (const e of eintraege) {
|
|
const p = rel ? `${rel}/${e.name}` : e.name
|
|
if (e.isDirectory()) await durchlaufen(p)
|
|
else if (e.isFile()) aufPlatte.push(p)
|
|
}
|
|
}
|
|
await durchlaufen('')
|
|
|
|
const dateiOhneDatensatz = aufPlatte.filter((p) => !bekannt.has(p))
|
|
const datensatzOhneDatei: typeof rows = []
|
|
for (const r of rows) {
|
|
try {
|
|
await stat(absolutePath(r.path))
|
|
} catch {
|
|
datensatzOhneDatei.push(r)
|
|
}
|
|
}
|
|
|
|
// Abgeleitetes ohne Bild -- Vorschauen UND sparsame Fassungen. Beide liegen
|
|
// im selben Baum wie das Original mit ".webp" hintendran, deshalb wird die
|
|
// Endung hier abgeschnitten, um zurueckzurechnen, und deshalb reicht EINE
|
|
// Schleife fuer beide Ordner.
|
|
const abgeleitetOhneBild: string[] = []
|
|
async function abgeleitetDurchlaufen(ordner: string, rel: string) {
|
|
let eintraege
|
|
try {
|
|
eintraege = await readdir(join(config.dataDir, ordner, rel), {
|
|
withFileTypes: true,
|
|
})
|
|
} catch {
|
|
return
|
|
}
|
|
for (const e of eintraege) {
|
|
const p = rel ? `${rel}/${e.name}` : e.name
|
|
if (e.isDirectory()) await abgeleitetDurchlaufen(ordner, p)
|
|
else if (e.isFile() && !bekannt.has(p.replace(/\.webp$/, ''))) {
|
|
// MIT Ordner davor: der Aufraeumer muss wissen, welche der
|
|
// beiden Dateien er wegwerfen soll -- der Pfad allein ist in
|
|
// thumbs/ und webp/ derselbe.
|
|
abgeleitetOhneBild.push(`${ordner}/${p}`)
|
|
}
|
|
}
|
|
}
|
|
await abgeleitetDurchlaufen('thumbs', '')
|
|
await abgeleitetDurchlaufen('webp', '')
|
|
|
|
return { rows, dateiOhneDatensatz, datensatzOhneDatei, abgeleitetOhneBild }
|
|
}
|
|
|
|
dashRoutes.get('/maintenance/orphans', async (c) => {
|
|
const f = await verwaisteSuchen()
|
|
return c.json({
|
|
dateiOhneDatensatz: {
|
|
anzahl: f.dateiOhneDatensatz.length,
|
|
beispiele: f.dateiOhneDatensatz.slice(0, 20),
|
|
},
|
|
datensatzOhneDatei: {
|
|
anzahl: f.datensatzOhneDatei.length,
|
|
beispiele: f.datensatzOhneDatei.slice(0, 20).map((r) => r.path),
|
|
},
|
|
abgeleitetOhneBild: {
|
|
anzahl: f.abgeleitetOhneBild.length,
|
|
beispiele: f.abgeleitetOhneBild.slice(0, 20),
|
|
},
|
|
})
|
|
})
|
|
|
|
/** Aufraeumen -- in zwei Richtungen, und die schonende ist die Voreinstellung.
|
|
*
|
|
* 'aufnehmen' Dateien ohne Datensatz kommen in die Datenbank. Sie sind da,
|
|
* sie sind ausgeliefert -- sie fehlen nur in der Liste. Das ist
|
|
* fast immer das Richtige und loescht NICHTS.
|
|
* 'aufraeumen' Datensaetze ohne Datei und verwaistes Abgeleitetes fliegen
|
|
* raus. Hier geht etwas weg, aber nur Verweise auf Dateien, die
|
|
* es nicht mehr gibt, und Bilder, die sich neu rechnen lassen --
|
|
* deshalb geht das hier AN DER PAPIERKORB VORBEI. Ein Papierkorb
|
|
* voller Vorschaubilder waere keine Sicherheit, nur Ballast. */
|
|
dashRoutes.post('/maintenance/orphans', async (c) => {
|
|
const body = await jsonBody<{ aktion?: string }>(c)
|
|
const aktion = body?.aktion ?? 'aufnehmen'
|
|
const f = await verwaisteSuchen()
|
|
const wer = c.get('user').username
|
|
|
|
if (aktion === 'aufnehmen') {
|
|
let aufgenommen = 0
|
|
for (const pfad of f.dateiOhneDatensatz) {
|
|
try {
|
|
const daten = await readFile(absolutePath(pfad))
|
|
const zeit = now()
|
|
db.prepare(
|
|
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
|
|
).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit)
|
|
if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten)
|
|
if (lohntWebp(mimeFor(pfad))) await writeWebp(pfad, daten)
|
|
logEvent('upload', pfad, daten.length, 'user', wer)
|
|
aufgenommen++
|
|
} catch (err) {
|
|
console.error('[verwaiste] ' + pfad, err)
|
|
}
|
|
}
|
|
return c.json({ aktion, aufgenommen })
|
|
}
|
|
|
|
if (aktion === 'aufraeumen') {
|
|
let datensaetze = 0
|
|
for (const r of f.datensatzOhneDatei) {
|
|
db.prepare('DELETE FROM media WHERE id = ?').run(r.id)
|
|
logEvent('delete', r.path, r.size, 'user', wer)
|
|
datensaetze++
|
|
}
|
|
let vorschauen = 0
|
|
for (const p of f.abgeleitetOhneBild) {
|
|
await rm(join(config.dataDir, p), { force: true })
|
|
vorschauen++
|
|
}
|
|
return c.json({ aktion, datensaetze, vorschauen })
|
|
}
|
|
|
|
return c.json({ error: 'unbekannte Aktion — "aufnehmen" oder "aufraeumen"' }, 400)
|
|
})
|
|
|
|
/** Fehlende Vorschaubilder nachziehen.
|
|
*
|
|
* Braucht es, weil die Vorschauen erst spaeter dazukamen: was vorher
|
|
* hochgeladen wurde, hat keine. Und als Reparatur, falls eine je fehlschlaegt.
|
|
*
|
|
* Das Ergebnis nennt Zahlen und nicht nur "ok" — ein Knopf, der nach zwanzig
|
|
* Sekunden "fertig" sagt, ohne zu sagen WAS fertig ist, ist der Anfang der
|
|
* Frage, ob er ueberhaupt etwas getan hat. */
|
|
const THUMB_STAPEL = 300
|
|
|
|
dashRoutes.post('/maintenance/thumbs', async (c) => {
|
|
const rows = db.prepare('SELECT id, path, mime FROM media').all() as Pick<
|
|
Media,
|
|
'id' | 'path' | 'mime'
|
|
>[]
|
|
|
|
let erzeugt = 0
|
|
let vorhanden = 0
|
|
let uebersprungen = 0
|
|
let fehler = 0
|
|
let offen = 0
|
|
/** Ton ohne eingebettetes Titelbild. Kein Fehler — der Normalfall. */
|
|
let ohneBild = 0
|
|
|
|
for (const row of rows) {
|
|
const bewegt = kannBewegtVorschau(row.mime)
|
|
if (!kannVorschau(row.mime) && !bewegt) {
|
|
uebersprungen++
|
|
continue
|
|
}
|
|
try {
|
|
await stat(thumbPath(row.path))
|
|
vorhanden++
|
|
continue
|
|
} catch {
|
|
/* gibt es noch nicht — gleich */
|
|
}
|
|
|
|
// Stapelweise, aus demselben Grund wie unten bei den sparsamen
|
|
// Fassungen: nach 90 Sekunden gibt der Proxy auf, und ein 504 auf einen
|
|
// Knopf, dessen Arbeit weiterlaeuft, ist eine Luege.
|
|
//
|
|
// Ein Standbild aus einem Video kostet DEUTLICH mehr als eines aus
|
|
// einem PNG -- ffmpeg muss dafuer einen Container oeffnen und
|
|
// hineinspringen. Es zaehlt hier trotzdem als eins; der Stapel ist mit
|
|
// 300 klein genug, dass auch 300 Videos in die neunzig Sekunden passen.
|
|
if (erzeugt + fehler + ohneBild >= THUMB_STAPEL) {
|
|
offen++
|
|
continue
|
|
}
|
|
|
|
try {
|
|
if (bewegt) {
|
|
const { ergebnis, dauer } = await writeBewegtThumb(row.path, row.mime)
|
|
if (ergebnis === 'ok') erzeugt++
|
|
else if (ergebnis === 'keinBild') ohneBild++
|
|
else fehler++
|
|
// Die Laenge faellt beim selben Blick in die Datei mit ab --
|
|
// auch dann, wenn es kein Titelbild gab.
|
|
db.prepare('UPDATE media SET dauer = ? WHERE id = ?').run(dauer, row.id)
|
|
} else {
|
|
const data = await readFile(absolutePath(row.path))
|
|
if (await writeThumb(row.path, data)) erzeugt++
|
|
else fehler++
|
|
}
|
|
} catch {
|
|
// Ein Datensatz ohne Datei. Kommt vor, wenn jemand im Volume
|
|
// aufgeraeumt hat; hier ist es ein Zaehler und kein Absturz.
|
|
fehler++
|
|
}
|
|
}
|
|
|
|
return c.json({
|
|
erzeugt,
|
|
vorhanden,
|
|
uebersprungen,
|
|
fehler,
|
|
ohneBild,
|
|
gesamt: rows.length,
|
|
offen,
|
|
fertig: offen === 0,
|
|
})
|
|
})
|
|
|
|
/** Die sparsamen Fassungen nachziehen.
|
|
*
|
|
* Dasselbe Muster wie bei den Vorschauen und aus demselben Grund: die
|
|
* uebernommenen Bilder liegen laengst da, die Umkodierung kam spaeter. Ohne
|
|
* diesen Knopf haetten nur neue Uploads eine sparsame Fassung — und genau die
|
|
* 3670 Item-Bilder, um die es geht, keine.
|
|
*
|
|
* DAS ERGEBNIS NENNT BYTES, nicht nur Stueckzahlen. "1240 erzeugt" beantwortet
|
|
* die Frage nicht, die man beim Druecken hatte; "spart 118 MB" schon. Dass
|
|
* einige NICHT lohnen, steht als eigene Zahl da und ist kein Fehler: bei
|
|
* kleinen PNG faellt WebP groesser aus, und dann bleibt es beim Original.
|
|
*
|
|
* UND ES LAEUFT IN STAPELN -- das ist nicht Vorsicht, das ist nachgemessen.
|
|
* Der erste Lauf ueber 3861 Dateien brauchte weit ueber neunzig Sekunden, und
|
|
* bei genau neunzig gibt Nginx Proxy Manager auf: `proxy_read_timeout`. Der
|
|
* Aufrufer sah einen 504, die Arbeit lief unbemerkt weiter, und der Knopf log
|
|
* damit den Anwender an. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer
|
|
* als einer ohne Rueckmeldung.
|
|
*
|
|
* Also: hoechstens WEBP_STAPEL Stueck je Aufruf, der Rest wird gezaehlt und
|
|
* als `offen` gemeldet. Die Oberflaeche ruft erneut auf, solange `fertig`
|
|
* falsch ist -- und kann dabei endlich sagen, wie weit sie ist. */
|
|
const WEBP_STAPEL = 300
|
|
|
|
dashRoutes.post('/maintenance/webp', async (c) => {
|
|
const rows = db.prepare('SELECT path, mime, size FROM media').all() as Pick<
|
|
Media,
|
|
'path' | 'mime' | 'size'
|
|
>[]
|
|
|
|
let erzeugt = 0
|
|
let vorhanden = 0
|
|
let uebersprungen = 0
|
|
let lohntNicht = 0
|
|
let fehler = 0
|
|
let vorher = 0
|
|
let nachher = 0
|
|
|
|
let offen = 0
|
|
|
|
for (const row of rows) {
|
|
if (!lohntWebp(row.mime)) {
|
|
uebersprungen++
|
|
continue
|
|
}
|
|
try {
|
|
const info = await stat(webpPfad(row.path))
|
|
vorhanden++
|
|
vorher += row.size
|
|
nachher += info.size
|
|
continue
|
|
} catch {
|
|
/* gibt es noch nicht — gleich */
|
|
}
|
|
|
|
// DER STAPEL IST VOLL: ab hier wird nur noch GEZAEHLT, nicht mehr
|
|
// gerechnet. Das kostet ein stat() je Datei und liefert dafuer eine
|
|
// ehrliche Restzahl -- besser als ein "fertig?", das raten muss.
|
|
if (erzeugt + lohntNicht + fehler >= WEBP_STAPEL) {
|
|
offen++
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const data = await readFile(absolutePath(row.path))
|
|
const groesse = await writeWebp(row.path, data)
|
|
if (groesse === null) {
|
|
lohntNicht++
|
|
} else {
|
|
erzeugt++
|
|
vorher += row.size
|
|
nachher += groesse
|
|
}
|
|
} catch {
|
|
// Ein Datensatz ohne Datei — wie oben ein Zaehler, kein Absturz.
|
|
fehler++
|
|
}
|
|
}
|
|
|
|
return c.json({
|
|
erzeugt,
|
|
vorhanden,
|
|
uebersprungen,
|
|
lohntNicht,
|
|
fehler,
|
|
gesamt: rows.length,
|
|
offen,
|
|
fertig: offen === 0,
|
|
vorher,
|
|
nachher,
|
|
gespart: vorher - nachher,
|
|
})
|
|
})
|