package main import ( "database/sql" "math/rand/v2" "net/http" "os" "time" "github.com/go-chi/chi/v5" "golang.org/x/crypto/bcrypt" ) // 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. func createUser(username, password string) (int64, error) { now := time.Now().Unix() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return 0, err } // uid ist nie 0: 0 ist Sentinel für "gelöscht"/"anonym" (siehe handleDeleteEntry, // uidFromContext). Bei der seltenen UNIQUE-Kollision auf uid neu würfeln. var lastErr error for i := 0; i < 5; i++ { uid := int64(rand.IntN(99999999)) + 1 _, lastErr = db.Exec( `INSERT INTO account (uid, created_at, username, password) VALUES ($1, $2, $3, $4)`, uid, now, username, hash, ) if lastErr == 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 isUniqueViolation(lastErr, "account_username_key") { return 0, errUsernameTaken } if !isUniqueViolation(lastErr, "account_uid_key") { return 0, lastErr } } return 0, lastErr } func usernameTaken(username string) bool { var taken bool db.QueryRow(`SELECT EXISTS (SELECT 1 FROM account WHERE username = $1)`, username).Scan(&taken) return taken } 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 account WHERE username = $1`, username).Scan(&uid, &createdAt, &avatar) if err != nil { return errUserNotFound } writeJSON(w, http.StatusOK, map[string]any{ "uid": uid, "username": username, "created_at": createdAt, "avatar": avatar, }) return nil } func handleUserInfo(w http.ResponseWriter, r *http.Request) error { uid := uidFromContext(r.Context()) var createdAt, lastLogin sql.NullInt64 var username, avatar string err := db.QueryRow( `SELECT created_at, last_login, username, avatar FROM account WHERE uid = $1`, uid, ).Scan(&createdAt, &lastLogin, &username, &avatar) if err != nil { return errUserNotFound } writeJSON(w, http.StatusOK, map[string]any{ "uid": uid, "username": username, "created_at": createdAt.Int64, "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) error { uid := uidFromContext(r.Context()) name := sanitizeUsername(r.FormValue("user")) if len(name) < 3 { return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user") } var current string if err := db.QueryRow(`SELECT username FROM account WHERE uid = $1`, uid).Scan(¤t); err != nil { return errUserNotFound } if name == current { return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user") } if usernameTaken(name) { return errUsernameTaken } if _, err := db.Exec(`UPDATE account SET username = $1 WHERE uid = $2`, name, uid); err != nil { // Rennen zwischen Vorab-Prüfung und UPDATE: UNIQUE-Constraint -> 409. if isUniqueViolation(err, "account_username_key") { 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) 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 { return errUploadTooLargeOrBroken(err) } if r.MultipartForm == nil || len(r.MultipartForm.File["avatar"]) == 0 { return Invalid("avatar.missing", "Kein Bild hochgeladen.").At("avatar") } stored, err := storeImage(r.MultipartForm.File["avatar"][0]) if err != nil { return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("avatar").WithCause(err) } var old string db.QueryRow(`SELECT avatar FROM account WHERE uid = $1`, uid).Scan(&old) if _, err := db.Exec(`UPDATE account SET avatar = $1 WHERE uid = $2`, stored, uid); err != nil { os.Remove(stored) 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 // durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer // 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) error { uid := uidFromContext(r.Context()) var hash []byte if err := db.QueryRow(`SELECT password FROM account WHERE uid = $1`, uid).Scan(&hash); err != nil { return errUserNotFound } if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass1"))) != nil { return Forbidden("auth.bad_password", "Passwort ist falsch.").At("pass1") } // Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die // Pfade geleert bzw. die user-Zeile gelöscht wird. var media []string if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = $1`, uid); err == nil { for rows.Next() { var fp string if rows.Scan(&fp) == nil && fp != "" { media = append(media, fp) } } rows.Close() } var avatar string if db.QueryRow(`SELECT avatar FROM account WHERE uid = $1`, uid).Scan(&avatar) == nil && avatar != "" { media = append(media, avatar) } tx, err := db.Begin() if err != nil { return Internal(err) } for _, q := range []string{ `DELETE FROM session WHERE uid = $1`, `DELETE FROM vote WHERE uid = $1`, `UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = $1`, `DELETE FROM account WHERE uid = $1`, } { if _, err := tx.Exec(q, uid); err != nil { tx.Rollback() return Internal(err) } } if err := tx.Commit(); err != nil { return Internal(err) } // Erst nach erfolgreichem Commit die Dateien entfernen. for _, fp := range media { os.Remove(fp) } clearSessionCookie(w, r) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) return nil }