POST /api/dash/upload, eigener Weg neben /api/upload. 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 mit Pfad,
Groesse und Grund im Fehlerfall. Ein "3 von 5 hochgeladen" ohne die Namen der
zwei anderen ist genau die Sorte Rueckmeldung, wegen der wir hier neu bauen.
Eine kaputte Datei bricht die anderen nicht ab.
parseBody({ all: true }) ist noetig: ohne das liefert Hono bei mehreren Feldern
gleichen Namens nur das LETZTE, und aus fuenf Dateien wuerde eine.
DATEINAMEN WERDEN ZURECHTGEBOGEN, und zwar sichtbar. checkPath laesst
"Mein Video (final) 2.MP4" mit Recht nicht durch, also macht safeFilename
daraus "mein-video-final-2.mp4" -- Umlaute werden umschrieben und nicht
weggeworfen ("Grüße" -> "gruesse", nicht "gr-e"). Die Ergebnisliste zeigt den
neuen Pfad neben dem alten Namen.
BEIM MESSEN GEFUNDEN: bleibt vom Namen nichts uebrig -- "ЖУРНАЛ.txt",
" .png", "★★★.txt" --, hiessen bisher ALLE "datei.txt", und die zweite
Datei ueberschrieb die erste stillschweigend. Jetzt haengen die ersten acht
Stellen des SHA-256 daran: gleicher Inhalt ergibt denselben Namen,
verschiedener Inhalt verschiedene. Nachgemessen mit drei solchen Namen.
Der Fortschrittsbalken laeuft ueber XMLHttpRequest und nicht fetch: nur der
kennt upload.onprogress, und bei einem 40-MB-Video ist ein Balken der
Unterschied zwischen "es tut sich was" und "ist es abgestuerzt?".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
629 lines
22 KiB
TypeScript
629 lines
22 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 type { Context } from 'hono'
|
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
import { config } from '../config.js'
|
|
import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js'
|
|
import {
|
|
SESSION_COOKIE,
|
|
createSession,
|
|
destroySession,
|
|
generateToken,
|
|
hashPassword,
|
|
tokenHash,
|
|
userForSession,
|
|
verifyPassword,
|
|
} from '../auth.js'
|
|
import { readFile, stat } from 'node:fs/promises'
|
|
import {
|
|
absolutePath,
|
|
checkPath,
|
|
deleteFile,
|
|
kannVorschau,
|
|
mimeFor,
|
|
publicUrlFor,
|
|
safeFilename,
|
|
sha256,
|
|
thumbPath,
|
|
writeFileAtomic,
|
|
writeThumb,
|
|
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
|
|
|
|
// ------------------------------------------------------------ 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: { id: user.id, username: user.username } })
|
|
})
|
|
|
|
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 })
|
|
})
|
|
|
|
dashRoutes.get('/auth/me', (c) => {
|
|
const user = userForSession(getCookie(c, SESSION_COOKIE))
|
|
if (!user) return c.json({ error: 'nicht angemeldet' }, 401)
|
|
return c.json({ user: { id: user.id, username: user.username } })
|
|
})
|
|
|
|
// 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
|
|
|
|
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)}/%`)
|
|
}
|
|
|
|
const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
|
|
|
|
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 updated_at 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. */
|
|
dashRoutes.get('/folders', (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)
|
|
}
|
|
}
|
|
|
|
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) },
|
|
})
|
|
})
|
|
|
|
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 deleteFile(row.path)
|
|
db.prepare('DELETE FROM media WHERE id = ?').run(id)
|
|
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 remove = db.prepare('DELETE FROM media WHERE id = ?')
|
|
const deleted: string[] = []
|
|
|
|
for (const id of ids) {
|
|
const row = select.get(id) as Media | undefined
|
|
if (!row) continue
|
|
await deleteFile(row.path)
|
|
remove.run(id)
|
|
deleted.push(row.path)
|
|
}
|
|
|
|
return c.json({ deleted, count: deleted.length })
|
|
})
|
|
|
|
/** Hochladen aus dem Dashboard.
|
|
*
|
|
* 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)
|
|
}
|
|
|
|
// { all: true } ist noetig: ohne das liefert Hono bei mehreren Feldern
|
|
// gleichen Namens nur das letzte, und aus fuenf Dateien wuerde eine.
|
|
const form = await c.req.parseBody({ all: true })
|
|
|
|
const roheDateien = form['file']
|
|
const dateien = (Array.isArray(roheDateien) ? roheDateien : [roheDateien]).filter(
|
|
(f): f is File => f instanceof File,
|
|
)
|
|
if (dateien.length === 0) return c.json({ error: 'Keine Datei dabei' }, 400)
|
|
|
|
const ordnerRoh = typeof form['folder'] === 'string' ? form['folder'].trim() : ''
|
|
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 ueberschreiben = form['overwrite'] !== 'false'
|
|
const ergebnisse: {
|
|
name: string
|
|
ok: boolean
|
|
path?: string
|
|
url?: string
|
|
size?: number
|
|
replaced?: boolean
|
|
error?: string
|
|
}[] = []
|
|
|
|
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 (data.length === 0) {
|
|
ergebnisse.push({ name, ok: false, error: 'leere Datei' })
|
|
continue
|
|
}
|
|
|
|
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 = 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' })
|
|
continue
|
|
}
|
|
|
|
const mime = datei.type || mimeFor(path)
|
|
await writeFileAtomic(path, data)
|
|
if (kannVorschau(mime)) await writeThumb(path, data)
|
|
|
|
const zeit = now()
|
|
if (vorhanden) {
|
|
db.prepare(
|
|
`UPDATE media SET size = ?, sha256 = ?, mime = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
).run(data.length, digest, mime, zeit, vorhanden.id)
|
|
} else {
|
|
db.prepare(
|
|
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
|
|
).run(path, data.length, digest, mime, zeit, zeit)
|
|
}
|
|
|
|
ergebnisse.push({
|
|
name,
|
|
ok: true,
|
|
path,
|
|
url: publicUrlFor(path),
|
|
size: data.length,
|
|
replaced: Boolean(vorhanden),
|
|
})
|
|
} 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 })
|
|
})
|
|
|
|
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
|
|
|
|
dashRoutes.get('/tokens', (c) => {
|
|
const rows = db
|
|
.prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC')
|
|
.all() as Omit<Token, 'hash'>[]
|
|
return c.json({ items: rows })
|
|
})
|
|
|
|
dashRoutes.post('/tokens', async (c) => {
|
|
const body = await jsonBody<{
|
|
name?: string
|
|
prefix?: string
|
|
canDelete?: boolean
|
|
}>(c)
|
|
if (!body) return c.json(KEIN_JSON, 400)
|
|
const { name, prefix, canDelete } = body
|
|
|
|
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
|
|
|
|
// 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)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now())
|
|
|
|
// 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),
|
|
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 })
|
|
})
|
|
|
|
dashRoutes.post('/maintenance/prune-sessions', (c) => {
|
|
pruneSessions()
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
/** 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. */
|
|
dashRoutes.post('/maintenance/thumbs', async (c) => {
|
|
const rows = db.prepare('SELECT path, mime FROM media').all() as Pick<
|
|
Media,
|
|
'path' | 'mime'
|
|
>[]
|
|
|
|
let erzeugt = 0
|
|
let vorhanden = 0
|
|
let uebersprungen = 0
|
|
let fehler = 0
|
|
|
|
for (const row of rows) {
|
|
if (!kannVorschau(row.mime)) {
|
|
uebersprungen++
|
|
continue
|
|
}
|
|
try {
|
|
await stat(thumbPath(row.path))
|
|
vorhanden++
|
|
continue
|
|
} catch {
|
|
/* gibt es noch nicht — gleich */
|
|
}
|
|
|
|
try {
|
|
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, gesamt: rows.length })
|
|
})
|