feat: Sicherung, Anmeldebremse, Vorschaubilder -- und Schritt 5
SICHERUNG (tools/sichern.ps1, taeglich 04:30 als geplante Aufgabe). Die Datenbank wird nicht kopiert, sondern ueber SQLites eigene Sicherungsschnittstelle herausgeholt (server/src/backup.ts): im WAL-Modus liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu kopieren ist nicht sicher, wenn waehrenddessen geschrieben wird. Die Bilder kommen aus einem NUR LESEND eingehaengten Volume dazu. Und sie prueft sich selbst: auspacken, Datenbank oeffnen, Medien/Token/ Benutzer zaehlen, mit dem laufenden Dienst vergleichen, sonst Fehler. Einmal wirklich zurueckgespielt -- leeres Volume, zweiter Dienst, Anmeldung mit dem echten Passwort, Bild abgerufen, Byte fuer Byte identisch. Eine Sicherung, die nie zurueckgespielt wurde, ist eine Hoffnung. ANMELDEBREMSE. Das Formular steht oeffentlich, und scrypt macht einen Versuch teuer -- aber teuer ist nicht selten. Fuenf freie Versuche je Adresse, dann Sperre ab 30 s mit Verdopplung bis 15 min, 429 samt Retry-After und einem Text, der sagt wie lange. Erfolg setzt zurueck. Nur im Speicher: wer sich aussperrt, startet den Container neu. Durchgemessen bis zur Erholung. VORSCHAUBILDER. sharp erzeugt beim Upload eine 320er WebP-Fassung unter /data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen: 131502 -> 13078 Bytes, Faktor 10; eine Galerieseite faellt von 7,5 MB auf 766 KB. KEINE Spalte in der Datenbank -- ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie faellt bei 404 aufs Vollbild zurueck. Eine zweite Wahrheit, die auseinander- laufen kann, gibt es damit gar nicht erst. Ein Knopf zieht Fehlendes nach und nennt Zahlen statt "fertig". SCHRITT 5. Die Fivemanage-Dateien liegen in legacy/ mit Erklaerung; unsere docker-compose.media.yml heisst jetzt docker-compose.yml. Drei Volumes und drei Abbilder geloescht, rund 670 MB -- nachgezaehlt war vorher, dass kein Bild darin lag. Nebenbefund: `npm i sharp` scheitert auf diesem Rechner nicht an sharp, sondern an better-sqlite3 -- der Host laeuft auf Node 24 (ABI 137), node-gyp uebernimmt und findet keine Bauwerkzeuge. Genau die Falle aus dem Dockerfile. Eingetragen wurde mit --package-lock-only, gebaut wird im Abbild auf Node 22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+159
-2
@@ -20,7 +20,17 @@ import {
|
||||
userForSession,
|
||||
verifyPassword,
|
||||
} from '../auth.js'
|
||||
import { checkPath, deleteFile, publicUrlFor, PathError } from '../storage.js'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import {
|
||||
absolutePath,
|
||||
checkPath,
|
||||
deleteFile,
|
||||
kannVorschau,
|
||||
publicUrlFor,
|
||||
thumbPath,
|
||||
writeThumb,
|
||||
PathError,
|
||||
} from '../storage.js'
|
||||
|
||||
type Vars = { user: User }
|
||||
|
||||
@@ -41,13 +51,104 @@ async function jsonBody<T>(c: Context<any>): Promise<T | 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)
|
||||
}
|
||||
@@ -59,9 +160,19 @@ dashRoutes.post('/auth/login', async (c) => {
|
||||
// Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches
|
||||
// Passwort": alles andere verraet, welche Namen es gibt.
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
return c.json({ error: 'Benutzername oder Passwort stimmt nicht' }, 401)
|
||||
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,
|
||||
@@ -263,3 +374,49 @@ 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 })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user