370 lines
8.7 KiB
Go
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(¤tPasswordHash)
|
|
|
|
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,
|
|
})
|
|
}
|