Umbau-Projekt (SSR und Rest)
This commit is contained in:
@@ -5,18 +5,32 @@ import (
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func createUser(username, password string) error {
|
||||
// errUsernameTaken: der UNIQUE-Constraint auf username hat beim INSERT
|
||||
// gegriffen (Rennen zwischen Vorab-Prüfung und Anlegen). Als Sentinel nutzbar
|
||||
// (errors.Is) und zugleich direkt an den Adapter zurückgebbar.
|
||||
var errUsernameTaken = Conflict("user.name_taken", "Der Nutzername wird bereits verwendet.")
|
||||
|
||||
// errUserNotFound deckt sowohl "gibt es nicht" als auch den DB-Fehler beim
|
||||
// Lesen ab: für den Aufrufer ist beides dasselbe, und die Unterscheidung würde
|
||||
// nur die Existenz von Accounts verraten.
|
||||
var errUserNotFound = NotFound("user.not_found", "Nutzer nicht gefunden")
|
||||
|
||||
// createUser legt den Account an und liefert dessen uid zurück -- der Aufrufer
|
||||
// braucht sie, um direkt eine Session zu eröffnen (Registrierung ohne
|
||||
// JavaScript loggt in einem Schritt ein).
|
||||
func createUser(username, password string) (int64, error) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// uid ist nie 0: 0 ist Sentinel für "gelöscht"/"anonym" (siehe handleDeleteEntry,
|
||||
@@ -29,12 +43,15 @@ func createUser(username, password string) error {
|
||||
uid, now, username, hash,
|
||||
)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
return uid, nil
|
||||
}
|
||||
// username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der
|
||||
// Vorab-Prüfung des Aufrufers), hilft kein Retry -> gezielt melden.
|
||||
if strings.Contains(lastErr.Error(), "user.username") {
|
||||
return 0, errUsernameTaken
|
||||
}
|
||||
// username ist ebenfalls UNIQUE und wird vom Aufrufer vorab geprüft;
|
||||
// eine Kollision hier kann also nur die uid sein -> erneut versuchen.
|
||||
}
|
||||
return lastErr
|
||||
return 0, lastErr
|
||||
}
|
||||
|
||||
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
|
||||
@@ -52,15 +69,14 @@ func serveUserPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "web/user.html")
|
||||
}
|
||||
|
||||
func handleUserPage(w http.ResponseWriter, r *http.Request) {
|
||||
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
||||
username := chi.URLParam(r, "username")
|
||||
|
||||
var uid, createdAt int64
|
||||
var avatar string
|
||||
err := db.QueryRow(`SELECT uid, created_at, avatar FROM user WHERE username = ?`, username).Scan(&uid, &createdAt, &avatar)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||
return
|
||||
return errUserNotFound
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"uid": uid,
|
||||
@@ -68,6 +84,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) {
|
||||
"created_at": createdAt,
|
||||
"avatar": avatar,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
|
||||
@@ -82,7 +99,7 @@ func handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
var createdAt, lastLogin sql.NullInt64
|
||||
@@ -91,8 +108,7 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
`SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid,
|
||||
).Scan(&createdAt, &lastLogin, &username, &avatar)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||
return
|
||||
return errUserNotFound
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"uid": uid,
|
||||
@@ -101,61 +117,59 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
"last_login": lastLogin.Int64,
|
||||
"avatar": avatar,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleUserRename benennt den eingeloggten Account um. Nichts außer der user-Zeile
|
||||
// referenziert den username (alles hängt an der uid), daher genügt ein UPDATE.
|
||||
func handleUserRename(w http.ResponseWriter, r *http.Request) {
|
||||
func handleUserRename(w http.ResponseWriter, r *http.Request) error {
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
name := sanitizeUsername(r.FormValue("user"))
|
||||
if len(name) < 3 {
|
||||
writeError(w, http.StatusBadRequest, "Der Nutzername ist zu kurz.")
|
||||
return
|
||||
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
|
||||
}
|
||||
|
||||
var current string
|
||||
if err := db.QueryRow(`SELECT username FROM user WHERE uid = ?`, uid).Scan(¤t); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||
return
|
||||
return errUserNotFound
|
||||
}
|
||||
if name == current {
|
||||
writeError(w, http.StatusBadRequest, "Das ist bereits dein Name.")
|
||||
return
|
||||
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
|
||||
}
|
||||
if idFromUsername(name) != 0 {
|
||||
writeError(w, http.StatusConflict, "Der Nutzername wird bereits verwendet.")
|
||||
return
|
||||
return errUsernameTaken
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE user SET username = ? WHERE uid = ?`, name, uid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Umbenennen fehlgeschlagen")
|
||||
return
|
||||
// Rennen zwischen Vorab-Prüfung und UPDATE: UNIQUE-Constraint -> 409.
|
||||
if strings.Contains(err.Error(), "user.username") {
|
||||
return errUsernameTaken
|
||||
}
|
||||
return Internal(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"username": name})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleSetAvatar speichert ein hochgeladenes Profilbild des eingeloggten
|
||||
// Nutzers (über storeImage skaliert) und ersetzt ein eventuell vorhandenes.
|
||||
func handleSetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
// Wie bei handleCreateEntry: Request-Größe hart deckeln, bevor Multipart
|
||||
// in Temp-Dateien streamen darf.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
return errUploadTooLargeOrBroken(err)
|
||||
}
|
||||
if r.MultipartForm == nil || len(r.MultipartForm.File["avatar"]) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "Kein Bild hochgeladen.")
|
||||
return
|
||||
return Invalid("avatar.missing", "Kein Bild hochgeladen.").At("avatar")
|
||||
}
|
||||
|
||||
stored, err := storeImage(r.MultipartForm.File["avatar"][0])
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Bild konnte nicht verarbeitet werden.")
|
||||
return
|
||||
return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("avatar").WithCause(err)
|
||||
}
|
||||
|
||||
var old string
|
||||
@@ -163,13 +177,13 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if _, err := db.Exec(`UPDATE user SET avatar = ? WHERE uid = ?`, stored, uid); err != nil {
|
||||
os.Remove(stored)
|
||||
writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
if old != "" && old != stored {
|
||||
os.Remove(old)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"avatar": stored})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
||||
@@ -177,18 +191,16 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als
|
||||
// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen.
|
||||
// Zugehörige Mediendateien werden best effort entfernt.
|
||||
func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
var hash []byte
|
||||
if err := db.QueryRow(`SELECT password FROM user WHERE uid = ?`, uid).Scan(&hash); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||
return
|
||||
return errUserNotFound
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass1"))) != nil {
|
||||
writeError(w, http.StatusForbidden, "Passwort ist falsch.")
|
||||
return
|
||||
return Forbidden("auth.bad_password", "Passwort ist falsch.").At("pass1")
|
||||
}
|
||||
|
||||
// Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die
|
||||
@@ -210,8 +222,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM session WHERE uid = ?`,
|
||||
@@ -221,13 +232,11 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
} {
|
||||
if _, err := tx.Exec(q, uid); err != nil {
|
||||
tx.Rollback()
|
||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
|
||||
// Erst nach erfolgreichem Commit die Dateien entfernen.
|
||||
@@ -246,4 +255,5 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user