Files
sm-user-plugin/controllers/api_controller.go
Jakub Zych 5c1f857d54 feat(07-03): update profile, change password, and marketing consent
Changing a password invalidates older tokens and leaves the token that made the call signed in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:32:05 +02:00

1007 lines
28 KiB
Go

package controllers
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes"
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/lagoon"
"git.golem15.com/golem15/summercms/postcard"
"git.golem15.com/golem15/summercms/surf"
"git.golem15.com/golem15/summercms/wire"
"gorm.io/gorm"
)
const invalidLoginBody = "Invalid email or password"
// Login authenticates email and password and returns a JWT plus the user payload.
func Login(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
email := strField(fields, "email")
password := strField(fields, "password")
ctx := classes.ContextWithThrottle(r.Context(), app)
user, err := classes.LookupByEmail(ctx, gdb, email)
if err != nil {
writeOpaque500(w)
return
}
ip := remoteIP(r)
if user != nil {
if err := classes.RejectIfThrottled(ctx, gdb, user, ip); err != nil {
if classes.IsLoginRejected(err) {
writeInvalidLogin(w)
return
}
writeOpaque500(w)
return
}
}
if user == nil || !bouncer.CheckPassword(user.Password, password) {
if user != nil {
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
writeOpaque500(w)
return
}
}
writeInvalidLogin(w)
return
}
if user.DeletedAt.Valid {
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
writeOpaque500(w)
return
}
sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email)
}
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
if classes.IsLoginRejected(err) {
writeInvalidLogin(w)
return
}
writeOpaque500(w)
return
}
cost := 10
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
}
if bouncer.NeedsRehash(user.Password, cost) {
if next, err := bouncer.HashPassword(cost, password); err == nil {
_ = gdb.WithContext(ctx).Model(user).Update("password", next).Error
}
}
token, err := mintFor(app, secret, user, requestURL(r))
if err != nil {
writeOpaque500(w)
return
}
payload, err := apiArray(ctx, app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
}
}
// Logout forever-blacklists the presented bearer token.
func Logout(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
sub, _, exp, jti, err := bouncer.VerifyClaims(raw, secret)
if err != nil || jti == "" {
writeUnauthorized(w)
return
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
writeUnauthorized(w)
return
}
principal, err := (classes.GormUsers{App: app}).FindByID(r.Context(), uint(id))
if err != nil || principal == nil {
writeUnauthorized(w)
return
}
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil {
writeOpaque500(w)
return
}
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Logged out"})
}
}
// Fetch returns the authenticated user's payload.
func Fetch(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := authenticate(w, r, app)
if !ok {
return
}
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"user": payload})
}
}
// Refresh slides a still-refreshable bearer token.
func Refresh(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
if raw == "" {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"})
return
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
next, err := bouncer.Refresh(secret, raw, refreshTTL(app), bl, blacklistGrace(app), requestURL(r))
if err != nil {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{
"error": "Could not refresh token",
"msg": err.Error(),
})
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"token": next})
}
}
// OAuthProviders reports that this app configures no social providers.
func OAuthProviders(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusOK, map[string]any{
"success": true,
"providers": wire.Slice([]any{}),
})
}
}
const forgotPasswordBody = "If that email exists, a reset link has been sent."
// ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
func ForgotPassword(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, map[string]string{
"email": "required|email|between:6,255",
}, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
ctx := codeContext(r.Context(), app)
user, err := classes.LookupByEmail(ctx, gdb, strField(fields, "email"))
if err != nil {
slog.Default().Error("golem15.user: forgot-password lookup", "err", err)
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
return
}
if user != nil {
code, err := classes.IssueResetCode(ctx, gdb, user)
if err != nil {
slog.Default().Error("golem15.user: issue reset code", "err", err)
} else {
name := ""
if user.Name != nil {
name = *user.Name
}
link := resetLink(app, user.ID, code)
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, map[string]any{
"name": name,
"link": link,
"code": strconv.FormatUint(uint64(user.ID), 10) + "!" + code,
})
}
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
}
}
// ResetPassword consumes a "{id}!{code}" reset code and stores a new password.
func ResetPassword(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, map[string]string{
"code": "required",
"password": "required|between:8,255|confirmed",
}, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
id, code, ok := splitCode(strField(fields, "code"))
if !ok {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Invalid reset code"})
return
}
user, verified := classes.VerifyResetCode(codeContext(r.Context(), app), gdb, id, code)
if !verified || user == nil {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Invalid or expired reset code"})
return
}
cost := 10
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
}
hashed, err := bouncer.HashPassword(cost, strField(fields, "password"))
if err != nil {
writeOpaque500(w)
return
}
now := time.Now()
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"password": hashed,
"tokens_valid_after": now,
}).Error; err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Password has been reset"})
}
}
// Activate calls attempt-activation and always returns the user payload.
// A wrong code leaves is_activated unchanged, matching the PHP handler's missing check.
func Activate(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
user, ok := authenticate(w, r, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
_, _ = classes.VerifyActivationCode(codeContext(r.Context(), app), gdb, user.ID, strField(fields, "code"))
if err := gdb.WithContext(r.Context()).Where("id = ?", user.ID).Take(user).Error; err != nil {
writeOpaque500(w)
return
}
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"user": payload})
}
}
// ActivateByCode activates from a public "{id}!{code}" value and mints a token.
func ActivateByCode(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, map[string]string{
"code": "required",
}, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
id, code, ok := splitCode(strField(fields, "code"))
if !ok {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Invalid activation code"})
return
}
user, verified := classes.VerifyActivationCode(codeContext(r.Context(), app), gdb, id, code)
if !verified || user == nil {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "This activation link is invalid or has expired"})
return
}
token, err := mintFor(app, secret, user, requestURL(r))
if err != nil {
writeOpaque500(w)
return
}
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{
"message": "Account activated",
"token": token,
"user": payload,
})
}
}
// Update changes the signed-in user's name, surname, and email.
func Update(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
user, ok := authenticate(w, r, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
errs, err := lagoon.Validate(r.Context(), gdb, user, map[string]string{
"name": "required|between:2,255",
"surname": "required|between:2,255",
"email": "required|between:6,255|email|unique:users",
}, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
name := strField(fields, "name")
surname := strField(fields, "surname")
if err := gdb.WithContext(r.Context()).Model(user).Updates(map[string]any{
"name": name,
"surname": surname,
"email": strField(fields, "email"),
}).Error; err != nil {
writeOpaque500(w)
return
}
user.Name = &name
user.Surname = &surname
user.Email = strField(fields, "email")
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Profile updated", "user": payload})
}
}
// ChangePassword replaces a self-set password and invalidates older tokens.
// The token that made this call stays valid.
func ChangePassword(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
writeUnauthorized(w)
return
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
writeUnauthorized(w)
return
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
writeUnauthorized(w)
return
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil {
writeUnauthorized(w)
return
}
if !user.HasSelfSetPassword {
slog.Default().Error("golem15.user: change-password requires a self-set password", "user_id", user.ID)
writeOpaque500(w)
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
minLen := 8
if app.Config != nil && app.Config.Int("golem15.user.password.min_length") > 0 {
minLen = app.Config.Int("golem15.user.password.min_length")
}
errs, err := lagoon.Validate(r.Context(), gdb, &user, map[string]string{
"current_password": "required",
"password": "required|between:" + strconv.Itoa(minLen) + ",255|confirmed|different:current_password",
}, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
current := strField(fields, "current_password")
if !bouncer.CheckPassword(user.Password, current) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "Invalid email or password",
"errors": map[string][]string{
"current_password": {"Invalid email or password"},
},
})
return
}
cost := 10
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
}
next := strField(fields, "password")
hashed, err := bouncer.HashPassword(cost, next)
if err != nil {
writeOpaque500(w)
return
}
cutoff := iat.Add(-time.Second)
if err := gdb.WithContext(r.Context()).Model(&user).Updates(map[string]any{
"password": hashed,
"must_change_password": false,
"has_self_set_password": true,
"tokens_valid_after": cutoff,
}).Error; err != nil {
writeOpaque500(w)
return
}
user.MustChangePassword = false
user.HasSelfSetPassword = true
user.TokensValidAfter = &cutoff
payload, err := apiArray(r.Context(), app, &user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Password changed", "user": payload})
}
}
// MarketingConsent sets the signed-in user's marketing_consent flag.
func MarketingConsent(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
user, ok := authenticate(w, r, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
raw, exists := fields["marketing_consent"]
if !exists || raw == nil {
writeValidation(w, map[string][]string{
"marketing_consent": {"The marketing_consent field is required."},
})
return
}
consent, isBool := raw.(bool)
if !isBool {
writeValidation(w, map[string][]string{
"marketing_consent": {"The marketing_consent field must be true or false."},
})
return
}
if err := gdb.WithContext(r.Context()).Model(user).Update("marketing_consent", consent).Error; err != nil {
writeOpaque500(w)
return
}
user.MarketingConsent = consent
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Marketing consent updated", "user": payload})
}
}
// Register creates an account and follows activate_mode: auto, user, or admin.
func Register(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
allow, throttle, mode, require := registrationSettings(app)
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
if !allow {
writeSafe500(w, app, "Registrations are currently disabled.")
return
}
if throttle && ip != "" && registrationsFromIP(r.Context(), gdb, ip) >= 3 {
writeSafe500(w, app, "Registration is throttled. Please try again later.")
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
if _, exists := fields["password_confirmation"]; !exists {
fields["password_confirmation"] = fields["password"]
}
rules := (models.User{}).Rules()
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, rules, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": firstValidationMessage(errs),
"errors": errs,
})
return
}
password := strField(fields, "password")
cost := 10
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
}
hashed, err := bouncer.HashPassword(cost, password)
if err != nil {
writeOpaque500(w)
return
}
fields["password"] = hashed
if ip != "" {
fields["created_ip_address"] = ip
fields["last_ip_address"] = ip
}
user := &models.User{}
production := app.Config == nil || !app.Config.Bool("app.debug")
if err := lagoon.Fill(user, user.Fillable(), fields, production); err != nil {
writeOpaque500(w)
return
}
user.HasSelfSetPassword = true
if mode == "auto" {
user.IsActivated = true
now := time.Now()
user.ActivatedAt = &now
}
if err := gdb.WithContext(r.Context()).Create(user).Error; err != nil {
writeOpaque500(w)
return
}
if app.Events != nil {
_ = app.Events.Fire(r.Context(), &classes.RegisterEvent{User: user})
}
ctx := r.Context()
if mode == "user" {
sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email)
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
return
}
if mode == "auto" || !require {
token, err := mintFor(app, secret, user, requestURL(r))
if err != nil {
writeOpaque500(w)
return
}
payload, err := apiArray(ctx, app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{})
}
}
func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
allow, throttle, require = true, true, true
mode = "auto"
if app == nil || app.Config == nil {
return allow, throttle, mode, require
}
if app.Config.Has("golem15.user.registration.allow_registration") {
allow = app.Config.Bool("golem15.user.registration.allow_registration")
}
if app.Config.Has("golem15.user.registration.use_register_throttle") {
throttle = app.Config.Bool("golem15.user.registration.use_register_throttle")
}
if app.Config.Has("golem15.user.activation.activate_mode") {
if s := strings.TrimSpace(app.Config.String("golem15.user.activation.activate_mode")); s != "" {
mode = s
}
}
if app.Config.Has("golem15.user.activation.require_activation") {
require = app.Config.Bool("golem15.user.activation.require_activation")
}
return allow, throttle, mode, require
}
func registrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64 {
var n int64
_ = gdb.WithContext(ctx).Model(&models.User{}).
Where("created_ip_address = ? AND created_at > ?", ip, time.Now().Add(-60*time.Minute)).
Count(&n).Error
return n
}
func firstValidationMessage(errs map[string][]string) string {
keys := make([]string, 0, len(errs))
for k := range errs {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 || len(errs[keys[0]]) == 0 {
return "The given data was invalid."
}
return errs[keys[0]][0]
}
func writeSafe500(w http.ResponseWriter, app *backpack.App, message string) {
if app != nil && app.Config != nil && app.Config.Bool("app.debug") {
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": message})
return
}
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
}
func appConfig(app *backpack.App) *compass.Config {
if app == nil {
return nil
}
return app.Config
}
func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return nil, false
}
raw := bearerToken(r)
sub, _, _, _, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
writeUnauthorized(w)
return nil, false
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
writeUnauthorized(w)
return nil, false
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
writeUnauthorized(w)
return nil, false
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil {
writeUnauthorized(w)
return nil, false
}
return &user, true
}
func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) {
if app == nil {
writeOpaque500(w)
return nil, "", false
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
writeOpaque500(w)
return nil, "", false
}
secret, err := classes.JWTSecret(app)
if err != nil {
writeOpaque500(w)
return nil, "", false
}
return gdb, secret, true
}
func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[string]any, error) {
name, surname := "", ""
if user.Name != nil {
name = *user.Name
}
if user.Surname != nil {
surname = *user.Surname
}
payload := map[string]any{
"id": user.ID,
"name": name,
"surname": surname,
"email": user.Email,
"is_activated": user.IsActivated,
"permissions": []string{},
"avatar": nil,
"avatar_url": nil,
"has_avatar": false,
"marketing_consent": user.MarketingConsent,
"groups": map[string]string{},
"role": nil,
"is_onboarded": user.IsOnboarded,
"has_self_set_password": user.HasSelfSetPassword,
// feedback_widget_hidden stays false until a feedback plugin listens.
"feedback_widget_hidden": false,
}
if app != nil && app.Events != nil {
extra, err := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user})
if err != nil {
return nil, err
}
for k, v := range extra {
payload[k] = v
}
}
return payload, nil
}
func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) {
ttl := 60 * time.Minute
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 {
ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute
}
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl)
return token, err
}
func refreshTTL(app *backpack.App) time.Duration {
mins := 20160
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
mins = app.Config.Int("golem15.user.jwt.refresh_ttl")
}
return time.Duration(mins) * time.Minute
}
func blacklistGrace(app *backpack.App) time.Duration {
secs := 0
if app != nil && app.Config != nil && app.Config.Has("golem15.user.jwt.blacklist_grace") {
secs = app.Config.Int("golem15.user.jwt.blacklist_grace")
}
return time.Duration(secs) * time.Second
}
func mailName(ctx context.Context, user *models.User, base string) string {
return classes.MailTemplate(base, classes.ResolveMailLocale(ctx, user))
}
func sendTemplate(ctx context.Context, app *backpack.App, template, to string) {
sendTemplateVars(ctx, app, template, to, nil)
}
func sendTemplateVars(ctx context.Context, app *backpack.App, template, to string, vars map[string]any) {
if app == nil || to == "" {
return
}
mailer, ok := app.Lookup[postcard.Mailer]()
if !ok || mailer == nil {
slog.Default().Error("golem15.user: mailer is not configured", "template", template)
return
}
if err := mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}, Vars: vars}); err != nil {
slog.Default().Error("golem15.user: send mail", "template", template, "err", err)
}
}
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": firstValidationMessage(errs),
"errors": errs,
})
}
func splitCode(raw string) (uint, string, bool) {
idPart, code, ok := strings.Cut(raw, "!")
if !ok || idPart == "" || code == "" || strings.Contains(code, "!") {
return 0, "", false
}
id, err := strconv.ParseUint(idPart, 10, 64)
if err != nil || id == 0 {
return 0, "", false
}
return uint(id), code, true
}
func codeContext(ctx context.Context, app *backpack.App) context.Context {
reset := 60 * time.Minute
activation := 72 * time.Hour
if app != nil && app.Config != nil {
if n := app.Config.Int("golem15.user.activation.reset_ttl_minutes"); n > 0 {
reset = time.Duration(n) * time.Minute
}
if n := app.Config.Int("golem15.user.activation.activation_ttl_hours"); n > 0 {
activation = time.Duration(n) * time.Hour
}
}
return classes.ContextWithCodeTTL(ctx, reset, activation)
}
func resetLink(app *backpack.App, id uint, code string) string {
base := "/reset-password"
if app != nil && app.Config != nil {
if v := strings.TrimSpace(app.Config.String("golem15.user.reset_url_base")); v != "" {
base = v
} else if appURL := strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/"); appURL != "" {
base = appURL + "/reset-password"
}
}
q := url.Values{}
q.Set("code", strconv.FormatUint(uint64(id), 10)+"!"+code)
return base + "?" + q.Encode()
}
func readFields(r *http.Request) (map[string]any, error) {
ct := r.Header.Get("Content-Type")
if strings.Contains(ct, "application/json") {
var fields map[string]any
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&fields); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
if fields == nil {
fields = map[string]any{}
}
return fields, nil
}
if err := r.ParseForm(); err != nil {
return nil, err
}
fields := map[string]any{}
for k, v := range r.Form {
if len(v) > 0 {
fields[k] = v[0]
}
}
return fields, nil
}
func strField(fields map[string]any, key string) string {
v, ok := fields[key]
if !ok || v == nil {
return ""
}
s, ok := v.(string)
if !ok {
return strings.TrimSpace(jsonNumber(v))
}
return strings.TrimSpace(s)
}
func jsonNumber(v any) string {
switch n := v.(type) {
case float64:
return strconv.FormatFloat(n, 'f', -1, 64)
default:
return ""
}
}
func bearerToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
token, ok := strings.CutPrefix(h, "Bearer ")
token = strings.TrimSpace(token)
if !ok || token == "" {
return ""
}
return token
}
func requestURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" {
scheme = proto
}
return scheme + "://" + r.Host + r.URL.Path
}
func remoteIP(r *http.Request) string {
if r.RemoteAddr == "" {
return ""
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func writeInvalidLogin(w http.ResponseWriter) {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": invalidLoginBody})
}
func writeUnauthorized(w http.ResponseWriter) {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"})
}
func writeOpaque500(w http.ResponseWriter) {
wire.WriteOpaque500(w)
}