Umbau-Projekt (SSR und Rest)

This commit is contained in:
2026-09-03 22:20:55 +02:00
parent 13363cb568
commit c66df283d1
37 changed files with 3719 additions and 344 deletions
+143 -34
View File
@@ -4,6 +4,7 @@ import (
crand "crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"regexp"
"strconv"
@@ -53,34 +54,92 @@ func getSession(r *http.Request) (*Session, bool) {
return &s, true
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
// errBadCredentials ist für "Nutzer existiert nicht" und "Passwort falsch"
// absichtlich derselbe Fehler -- sonst wird der Endpunkt zum Nutzernamen-Orakel.
var errBadCredentials = Unauth("auth.bad_credentials", "Nutzername oder Passwort ist falsch.")
// loginForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg.
// Bewusst ohne Passwort: das wird nie zurück ins Formular geschrieben.
type loginForm struct {
User string
}
// showLogin ist die serverseitig gerenderte Login-Seite -- der Weg für Clients
// ohne JavaScript (TOR Browser auf "Safest", I2P).
func showLogin(w http.ResponseWriter, r *http.Request) error {
if _, ok := getSession(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
render(w, r, "login", pageData{Title: "Login"})
return nil
}
// handleLogin bedient beide Adapter: das JS-Frontend (Accept: application/json)
// bekommt JSON, ein abgeschicktes Browser-Formular bekommt einen Redirect bzw.
// das neu gerenderte Formular. Die Logik dazwischen existiert nur einmal.
func handleLogin(w http.ResponseWriter, r *http.Request) error {
json := wantsJSON(r)
// fail wählt die zum Client passende Fehlerdarstellung. Der Rückgabewert
// geht an den Wrapper; bei HTML ist die Antwort schon geschrieben, deshalb
// nil.
fail := func(username string, err error) error {
if json {
return err
}
failForm(w, r, "login", pageData{Title: "Login", Form: loginForm{User: username}}, err)
return nil
}
if _, ok := getSession(r); ok {
if !json {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"})
return
return nil
}
username := sanitizeUsername(r.FormValue("user"))
if len(username) < 3 {
writeError(w, http.StatusBadRequest, "Der Nutzername ist zu kurz.")
return
return fail(username, Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user"))
}
// Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
// wird der Endpunkt zum Nutzernamen-Orakel.
var uid int64
var hash []byte
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash)
if err == sql.ErrNoRows {
writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.")
return
return fail(username, errBadCredentials)
} else if err != nil {
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
return
return Internal(err)
}
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil {
writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.")
return
return fail(username, errBadCredentials)
}
if err := startSession(w, r, uid); err != nil {
return err
}
// POST -> Redirect (303) statt einer gerenderten Seite: sonst zeigt ein
// Reload den "Formular erneut senden?"-Dialog und legt eine zweite Session
// an (Post/Redirect/Get).
if !json {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil
}
// startSession legt eine Session an und setzt den Cookie. Von Login und
// Registrierung gemeinsam genutzt -- die Registrierung ohne JavaScript muss
// direkt einloggen, weil es sonst zwei Formulare hintereinander bräuchte.
func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
now := time.Now().Unix()
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage.
@@ -96,8 +155,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
token, err := newToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
return
return Internal(err)
}
db.Exec(`DELETE FROM session WHERE expires < ?`, now)
@@ -105,8 +163,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
`INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`,
uid, now, expires, "", token,
); err != nil {
writeError(w, http.StatusInternalServerError, "Session konnte nicht erstellt werden")
return
return Internal(err)
}
db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, now, uid)
@@ -119,7 +176,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
@@ -139,43 +196,94 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func handleNewUser(w http.ResponseWriter, r *http.Request) {
var errs []string
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
// abzubrechen -- der Nutzer soll nicht Feld für Feld durchprobieren müssen.
// Deshalb hier Fields (Feld -> Meldung) statt eines einzelnen Field.
func handleNewUser(w http.ResponseWriter, r *http.Request) error {
json := wantsJSON(r)
fields := map[string]string{}
username := sanitizeUsername(r.FormValue("user"))
if len(username) < 3 {
errs = append(errs, "Der Nutzername ist zu kurz.")
}
if idFromUsername(username) != 0 {
errs = append(errs, "Der Nutzername wird bereits verwendet.")
fields["user"] = "Der Nutzername ist zu kurz."
} else if idFromUsername(username) != 0 {
fields["user"] = "Der Nutzername wird bereits verwendet."
}
pass1 := r.FormValue("pass1")
pass2 := r.FormValue("pass2")
switch {
case len(pass1) < 10:
fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen."
// bcrypt verarbeitet maximal 72 Bytes; GenerateFromPassword gäbe darüber
// einen Fehler -> hier abfangen statt als generischer 500 zu enden.
case len(pass1) > 72:
fields["pass1"] = "Das Passwort ist länger als 72 Bytes."
}
if pass1 != pass2 {
errs = append(errs, "Passwort und Passwortbestätigung sind ungleich.")
}
if len(pass1) < 10 {
errs = append(errs, "Das Passwort ist kürzer als 10 Zeichen.")
fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich."
}
if len(errs) > 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"errors": errs})
return
// fail wählt die zum Client passende Fehlerdarstellung -- wie bei
// handleLogin: JSON-Clients bekommen den Fehler über den Wrapper, ein
// Browser-Formular wird mit Meldungen neu gerendert.
fail := func(err error) error {
if json {
return err
}
failForm(w, r, "register", pageData{Title: "Registrieren", Form: registerForm{User: username}}, err)
return nil
}
if err := createUser(username, pass1); err != nil {
writeError(w, http.StatusInternalServerError, "Account konnte nicht erstellt werden")
return
if len(fields) > 0 {
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
e.Fields = fields
return fail(e)
}
uid, err := createUser(username, pass1)
if errors.Is(err, errUsernameTaken) {
// Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat
// gegriffen -> als normale Validierungsmeldung statt 500 melden.
return fail(errUsernameTaken)
} else if err != nil {
return Internal(err)
}
// Ohne JavaScript direkt einloggen und weiterleiten: das JS-Frontend macht
// genau das (zweiter Aufruf auf /auth/login), und ein zweites Formular nur
// zum Anmelden wäre eine unnötige Hürde.
if !json {
if err := startSession(w, r, uid); err != nil {
return err
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
return nil
}
func handleSessionInfo(w http.ResponseWriter, r *http.Request) {
// registerForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg --
// wieder ohne die Passwörter.
type registerForm struct {
User string
}
// showRegister ist die serverseitig gerenderte Registrierungsseite.
func showRegister(w http.ResponseWriter, r *http.Request) error {
if _, ok := getSession(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
render(w, r, "register", pageData{Title: "Registrieren"})
return nil
}
func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
s, ok := getSession(r)
if !ok {
writeError(w, http.StatusUnauthorized, "Invalid session")
return
return Unauth("auth.no_session", "Keine gültige Sitzung.")
}
writeJSON(w, http.StatusOK, map[string]any{
"uid": s.UID,
@@ -183,6 +291,7 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) {
"expires": s.Expires,
"description": s.Description,
})
return nil
}
func handleHeaderbar(w http.ResponseWriter, r *http.Request) {