teg/backend/user_settings.go
2026-06-13 10:15:57 +02:00

370 lines
8.7 KiB
Go

// backend/user_settings.go
package main
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"golang.org/x/crypto/bcrypt"
)
type updateSettingsAccountRequest struct {
DisplayName string `json:"displayName"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar"`
}
type updateSettingsPasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
ConfirmPassword string `json:"confirmPassword"`
}
type confirmPasswordRequest struct {
Password string `json:"password"`
}
func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
var payload updateSettingsAccountRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
username := normalizeUsername(payload.Username)
email := normalizeEmail(payload.Email)
displayName := strings.TrimSpace(payload.DisplayName)
avatar := strings.TrimSpace(payload.Avatar)
if username == "" {
writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich")
return
}
if email == "" {
writeSettingsError(w, http.StatusBadRequest, "E-Mail-Adresse ist erforderlich")
return
}
if displayName == "" {
displayName = username
}
commandTag, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
username = $1,
display_name = $2,
email = $3,
avatar = $4,
updated_at = now()
WHERE id = $5
`,
username,
displayName,
email,
avatar,
userID,
)
if isPostgresUniqueViolation(err) {
writeSettingsError(w, http.StatusConflict, "Benutzername oder E-Mail-Adresse wird bereits verwendet")
return
}
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Profil konnte nicht gespeichert werden")
return
}
if commandTag.RowsAffected() == 0 {
writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
return
}
user, err := s.getUserByID(r.Context(), userID)
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Profil wurde gespeichert, konnte aber nicht neu geladen werden")
return
}
s.publishUserProfileEvent("user.updated", user)
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Profil gespeichert",
"user": user,
})
}
func (s *Server) handleUpdateSettingsPassword(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
var payload updateSettingsPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
payload.CurrentPassword = strings.TrimSpace(payload.CurrentPassword)
payload.NewPassword = strings.TrimSpace(payload.NewPassword)
payload.ConfirmPassword = strings.TrimSpace(payload.ConfirmPassword)
if payload.CurrentPassword == "" {
writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist erforderlich")
return
}
if len(payload.NewPassword) < 8 {
writeSettingsError(w, http.StatusBadRequest, "Das neue Passwort muss mindestens 8 Zeichen lang sein")
return
}
if payload.NewPassword != payload.ConfirmPassword {
writeSettingsError(w, http.StatusBadRequest, "Die neuen Passwörter stimmen nicht überein")
return
}
var currentPasswordHash string
err := s.db.QueryRow(
r.Context(),
`
SELECT password_hash
FROM users
WHERE id = $1
LIMIT 1
`,
userID,
).Scan(&currentPasswordHash)
if errors.Is(err, pgx.ErrNoRows) {
writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
return
}
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht geprüft werden")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(currentPasswordHash), []byte(payload.CurrentPassword)); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist nicht korrekt")
return
}
nextPasswordHash, err := bcrypt.GenerateFromPassword([]byte(payload.NewPassword), bcrypt.DefaultCost)
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht vorbereitet werden")
return
}
commandTag, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
password_hash = $1,
updated_at = now()
WHERE id = $2
`,
string(nextPasswordHash),
userID,
)
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
if commandTag.RowsAffected() == 0 {
writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
return
}
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Passwort gespeichert",
})
}
func (s *Server) handleLogoutOtherSessions(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
currentSessionID, ok := s.currentSessionIDFromRequest(r)
if !ok {
writeSettingsError(w, http.StatusUnauthorized, "Aktuelle Sitzung wurde nicht gefunden")
return
}
var payload confirmPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if err := s.verifyUserPassword(r, userID, payload.Password); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt")
return
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE user_sessions
SET revoked_at = now()
WHERE user_id = $1
AND id <> $2
AND revoked_at IS NULL
`,
userID,
currentSessionID,
)
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht abgemeldet werden")
return
}
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Andere Sitzungen wurden abgemeldet",
})
}
func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
var payload confirmPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if err := s.verifyUserPassword(r, userID, payload.Password); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden")
return
}
defer tx.Rollback(r.Context())
commandTag, err := tx.Exec(
r.Context(),
`
DELETE FROM users
WHERE id = $1
`,
userID,
)
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden")
return
}
if commandTag.RowsAffected() == 0 {
writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden")
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: false,
})
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Konto gelöscht",
})
}
func (s *Server) verifyUserPassword(r *http.Request, userID string, password string) error {
password = strings.TrimSpace(password)
if password == "" {
return errors.New("password required")
}
var passwordHash string
err := s.db.QueryRow(
r.Context(),
`
SELECT password_hash
FROM users
WHERE id = $1
LIMIT 1
`,
userID,
).Scan(&passwordHash)
if err != nil {
return err
}
return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
}
func (s *Server) currentUserIDFromRequest(r *http.Request) (string, bool) {
user, ok := r.Context().Value(userContextKey).(User)
if !ok || user.ID == "" {
return "", false
}
return user.ID, true
}
func isPostgresUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func writeSettingsJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeSettingsError(w http.ResponseWriter, status int, message string) {
writeSettingsJSON(w, status, map[string]any{
"error": message,
})
}