package api import ( "context" "errors" "net/http" "strings" "time" "gardomatic.kleiax.de/internal/auth" "gardomatic.kleiax.de/internal/platform/validate" "gardomatic.kleiax.de/internal/storage" "github.com/julienschmidt/httprouter" ) type accountSession struct { ID string `json:"id"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` Current bool `json:"current"` } func (app *application) listAccountSessionsHandler(w http.ResponseWriter, r *http.Request) { user, _ := app.contextGetAuthenticatedUser(r) currentID := app.sessions.GetString(r.Context(), accountSessionIDKey) sessions := make([]accountSession, 0) err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error { if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) != user.ID { return nil } id := app.sessions.GetString(ctx, accountSessionIDKey) if id == "" { return nil } sessions = append(sessions, accountSession{ ID: id, CreatedAt: time.Unix(app.sessions.GetInt64(ctx, accountSessionCreatedAtKey), 0).UTC(), ExpiresAt: app.sessions.Deadline(ctx), Current: id == currentID, }) return nil }) if err != nil { app.serverErrorResponse(w, r, err) return } if err = app.writeJSON(w, http.StatusOK, envelope{"sessions": sessions}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) deleteAccountSessionHandler(w http.ResponseWriter, r *http.Request) { user, _ := app.contextGetAuthenticatedUser(r) targetID := httprouter.ParamsFromContext(r.Context()).ByName("sessionID") if targetID == app.sessions.GetString(r.Context(), accountSessionIDKey) { if err := app.sessions.Destroy(r.Context()); err != nil { app.serverErrorResponse(w, r, err) return } w.WriteHeader(http.StatusNoContent) return } found := false err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error { if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) == user.ID && app.sessions.GetString(ctx, accountSessionIDKey) == targetID { found = true return app.sessions.Destroy(ctx) } return nil }) if err != nil { app.serverErrorResponse(w, r, err) return } if !found { app.notFoundResponse(w, r) return } w.WriteHeader(http.StatusNoContent) } func (app *application) updateAccountProfileHandler(w http.ResponseWriter, r *http.Request) { user, _ := app.contextGetAuthenticatedUser(r) var input struct { Name string `json:"name"` Color string `json:"color"` } if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } user.Name = strings.TrimSpace(input.Name) if color := strings.TrimSpace(input.Color); color != "" { user.Color = color } v := validate.New() storage.ValidateUser(v, user) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } updated, err := app.models.Users.Update(user) if err != nil { app.respondToAccountError(w, r, err) return } if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) updateAccountPasswordHandler(w http.ResponseWriter, r *http.Request) { user, _ := app.contextGetAuthenticatedUser(r) var input struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } v := validate.New() auth.ValidatePasswordPlaintext(v, input.CurrentPassword) auth.ValidatePasswordPlaintext(v, input.NewPassword) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } match, err := user.Password.Matches(input.CurrentPassword) if err != nil { app.serverErrorResponse(w, r, err) return } if !match { app.invalidCredentialsResponse(w, r) return } if err = user.Password.Set(input.NewPassword); err != nil { app.serverErrorResponse(w, r, err) return } if _, err = app.models.Users.Update(user); err != nil { app.respondToAccountError(w, r, err) return } if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil { app.serverErrorResponse(w, r, err) return } if err = app.models.Tokens.DeleteAllForUser(auth.ScopePasswordReset, user.ID); err != nil { app.serverErrorResponse(w, r, err) return } if err = app.writeJSON(w, http.StatusOK, envelope{"message": "password updated"}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) requestAccountEmailChangeHandler(w http.ResponseWriter, r *http.Request) { user, _ := app.contextGetAuthenticatedUser(r) var input struct { Email string `json:"email"` CurrentPassword string `json:"current_password"` } if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } input.Email = strings.ToLower(strings.TrimSpace(input.Email)) v := validate.New() storage.ValidateEmail(v, input.Email) auth.ValidatePasswordPlaintext(v, input.CurrentPassword) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } match, err := user.Password.Matches(input.CurrentPassword) if err != nil { app.serverErrorResponse(w, r, err) return } if !match { app.invalidCredentialsResponse(w, r) return } if existing, lookupErr := app.models.Users.GetByEmail(input.Email); lookupErr == nil && existing.ID != user.ID { v.AddError("email", "email address is already registered") app.failedValidationResponse(w, r, v.Errors) return } else if lookupErr != nil && !errors.Is(lookupErr, storage.ErrRecordNotFound) { app.serverErrorResponse(w, r, lookupErr) return } token, err := app.models.Users.CreateEmailChange(user.ID, input.Email, 45*time.Minute) if err != nil { app.respondToAccountError(w, r, err) return } app.background(func() { data := map[string]any{"confirmationURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/account/email-confirm?token=" + token} if sendErr := app.mailer.Send(input.Email, "email_change.tmpl", data); sendErr != nil { app.logger.Error(sendErr.Error()) } }) if err = app.writeJSON(w, http.StatusAccepted, envelope{"message": "confirmation email sent"}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) confirmAccountEmailHandler(w http.ResponseWriter, r *http.Request) { user, _ := app.contextGetAuthenticatedUser(r) var input struct { Token string `json:"token"` } if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } v := validate.New() auth.ValidateTokenPlaintext(v, input.Token) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } updated, err := app.models.Users.ConfirmEmailChange(input.Token, user.ID) if err != nil { app.respondToAccountError(w, r, err) return } if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil { app.serverErrorResponse(w, r, err) return } if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) respondToAccountError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, storage.ErrDuplicateEmail): app.failedValidationResponse(w, r, map[string]string{"email": "email address is already registered"}) case errors.Is(err, storage.ErrRecordNotFound): app.notFoundResponse(w, r) case errors.Is(err, storage.ErrEditConflict): app.editConflictResponse(w, r) default: app.serverErrorResponse(w, r, err) } }