package controllers import ( "context" "crypto/md5" "crypto/rand" _ "embed" "encoding/hex" "encoding/json" "errors" "fmt" "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/lagoon/attach" "git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/postcard" "git.golem15.com/golem15/summercms/surf" "git.golem15.com/golem15/summercms/wire" "gocloud.dev/blob" "gorm.io/gorm" ) const avatarMaxBytes = 4_096_000 //go:embed winter_error_page.html var winterErrorPage []byte var errNoAvatar = errors.New("golem15.user: no avatar") 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, r, app) 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, r, app) return } if user.DeletedAt.Valid { if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil { writeOpaque500(w) return } sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", "")) } if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil { if classes.IsLoginRejected(err) { writeInvalidLogin(w, r, app) 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 } 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 } } 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 } 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 == "" { 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 { writeJSON(w, http.StatusUnauthorized, map[string]any{ "error": "Could not refresh token", "msg": err.Error(), }) return } 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) { 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, appTranslator(app)) 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) 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 { packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed)) } } 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, appTranslator(app)) if err != nil { writeOpaque500(w) return } if len(errs) > 0 { writeValidation(w, errs) return } id, code, ok := splitCode(strField(fields, "code")) if !ok { 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 { 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 } 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 } if user.IsActivated { writeWinterErrorPage(w) 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 } 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, appTranslator(app)) if err != nil { writeOpaque500(w) return } if len(errs) > 0 { writeValidation(w, errs) return } id, code, ok := splitCode(strField(fields, "code")) if !ok { writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Invalid activation code"}) return } already, err := classes.IsAlreadyActivated(codeContext(r.Context(), app), gdb, id) if err != nil { writeOpaque500(w) return } if already { writeWinterErrorPage(w) return } user, verified := classes.VerifyActivationCode(codeContext(r.Context(), app), gdb, id, code) if !verified || user == nil { 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 } 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, appTranslator(app)) 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 } 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, appTranslator(app)) 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) { msg := accountMessage(r.Context(), app, "invalid_login", invalidLoginBody) writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ "error": msg, "errors": map[string][]string{ "current_password": {msg}, }, }) 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 } 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 } errs, err := lagoon.Validate(r.Context(), gdb, user, map[string]string{ "marketing_consent": "required|boolean", }, fields, appTranslator(app)) if err != nil { writeOpaque500(w) return } if len(errs) > 0 { writeValidation(w, errs) return } consent := laravelBool(fields["marketing_consent"]) 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 } 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, appTranslator(app)) if err != nil { writeOpaque500(w) return } if len(errs) > 0 { 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" { vars := mailVars(displayName(user), "", "") code, err := classes.IssueActivationCode(ctx, gdb, user) if err != nil { slog.Default().Error("golem15.user: issue activation code", "err", err) } else { packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed) } sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email, vars) 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 } writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload}) return } 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") { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": message}) return } 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) { payload := map[string]any{ "id": user.ID, "name": user.Name, "surname": user.Surname, "email": user.Email, "is_activated": user.IsActivated, "permissions": nil, "avatar": gravatarURL(user.Email, 25), "avatar_url": gravatarURL(user.Email, 128), "has_avatar": false, "marketing_consent": user.MarketingConsent, "groups": []any{}, "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, } applyAvatar(ctx, app, user, payload) 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 displayName(user *models.User) string { if user == nil || user.Name == nil { return "" } return *user.Name } func mailVars(name, link, code string) map[string]any { return map[string]any{ "name": name, "Name": name, "link": link, "Link": link, "code": code, "Code": code, } } 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) { 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 activationLink(app *backpack.App, id uint, code string) string { base := "/activate" if app != nil && app.Config != nil { if v := strings.TrimSpace(app.Config.String("golem15.user.activation_url_base")); v != "" { base = v } else if appURL := strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/"); appURL != "" { base = appURL + "/activate" } } 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 writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Cache-Control", "no-cache, private") wire.WriteJSON(w, status, v) } func writeInvalidLogin(w http.ResponseWriter, r *http.Request, app *backpack.App) { writeJSON(w, http.StatusUnauthorized, map[string]any{ "error": true, "message": accountMessage(r.Context(), app, "invalid_login", invalidLoginBody), }) } func writeUnauthorized(w http.ResponseWriter) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"}) } func appTranslator(app *backpack.App) *phrasebook.Translator { if app == nil { return nil } tr, _ := app.Lookup[*phrasebook.Translator]() return tr } func requiredField(ctx context.Context, app *backpack.App, field string) string { tr := appTranslator(app) attr := strings.ReplaceAll(field, "_", " ") if tr != nil { s := tr.Get(ctx, "lagoon::validate.required", map[string]string{"attribute": attr}) if s != "" && s != "lagoon::validate.required" { return s } } return "The " + attr + " field is required." } func accountMessage(ctx context.Context, app *backpack.App, key, fallback string) string { tr := appTranslator(app) if tr == nil { return fallback } s := tr.Get(ctx, "golem15.user::lang.account."+key, nil) if s == "" || s == "golem15.user::lang.account."+key { return fallback } return s } func writeWinterErrorPage(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=UTF-8") w.Header().Set("Cache-Control", "no-cache, private") w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write(winterErrorPage) } func writeOpaque500(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-cache, private") wire.WriteOpaque500(w) } // UploadAvatar stores a sniffed image as the signed-in user's avatar. func UploadAvatar(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 } if err := r.ParseMultipartForm(10 << 20); err != nil { writeValidation(w, map[string][]string{"avatar": {requiredField(r.Context(), app, "avatar")}}) return } file, header, err := r.FormFile("avatar") if err != nil { writeValidation(w, map[string][]string{"avatar": {requiredField(r.Context(), app, "avatar")}}) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { writeOpaque500(w) return } sniffed := http.DetectContentType(data) if i := strings.IndexByte(sniffed, ';'); i >= 0 { sniffed = strings.TrimSpace(sniffed[:i]) } ext, allowed := avatarExt(sniffed) if !allowed { writeValidation(w, map[string][]string{ "avatar": {"The avatar must be a file of type: jpeg, jpg, png, webp, gif."}, }) return } size := int64(len(data)) if header != nil && header.Size > size { size = header.Size } if size > avatarMaxBytes { writeValidation(w, map[string][]string{ "avatar": {"The avatar must not be greater than 4000 kilobytes."}, }) return } bucket, ok := app.Lookup[*blob.Bucket]() if !ok || bucket == nil { slog.Default().Error("golem15.user: avatar bucket is not configured") writeOpaque500(w) return } if err := deleteAvatar(r.Context(), app, gdb, user); err != nil && !errors.Is(err, errNoAvatar) { writeOpaque500(w) return } raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { writeOpaque500(w) return } diskName := hex.EncodeToString(raw) + "." + ext wr, err := bucket.NewWriter(r.Context(), attach.BlobKey(diskName), &blob.WriterOptions{ContentType: sniffed}) if err != nil { writeOpaque500(w) return } if _, err := wr.Write(data); err != nil { _ = wr.Close() writeOpaque500(w) return } if err := wr.Close(); err != nil { writeOpaque500(w) return } filename := "avatar." + ext if header != nil && strings.TrimSpace(header.Filename) != "" { filename = header.Filename } row := attach.File{ DiskName: diskName, FileName: filename, FileSize: int64(len(data)), ContentType: sniffed, Field: "avatar", AttachmentID: strconv.FormatUint(uint64(user.ID), 10), AttachmentType: user.MorphName(), } if err := gdb.WithContext(r.Context()).Create(&row).Error; err != nil { writeOpaque500(w) return } payload, err := apiArray(r.Context(), app, user) if err != nil { writeOpaque500(w) return } writeJSON(w, http.StatusOK, map[string]any{"message": "Avatar updated", "user": payload}) } } // RemoveAvatar deletes the signed-in user's display picture. func RemoveAvatar(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 } if err := deleteAvatar(r.Context(), app, gdb, user); err != nil { if errors.Is(err, errNoAvatar) { writeValidation(w, map[string][]string{ "avatar": {accountMessage(r.Context(), app, "no_avatar", "Your account has no display picture to remove.")}, }) return } writeOpaque500(w) return } payload, err := apiArray(r.Context(), app, user) if err != nil { writeOpaque500(w) return } writeJSON(w, http.StatusOK, map[string]any{"message": "Avatar removed", "user": payload}) } } func deleteAvatar(ctx context.Context, app *backpack.App, gdb *gorm.DB, user *models.User) error { id := strconv.FormatUint(uint64(user.ID), 10) var n int64 if err := gdb.WithContext(ctx).Model(&attach.File{}). Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), id, "avatar"). Count(&n).Error; err != nil { return err } if n == 0 { return errNoAvatar } var keys []string err := gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return attach.DeleteForOwner(tx, user, id, func(blobKeys []string) error { keys = append(keys, blobKeys...) return nil }) }) if err != nil { return err } bucket, ok := app.Lookup[*blob.Bucket]() if !ok || bucket == nil || len(keys) == 0 { return nil } return attach.DeleteKeys(ctx, bucket, keys) } func gravatarURL(email string, size int) string { sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))) return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", sum, size) } func laravelBool(v any) bool { switch t := v.(type) { case bool: return t case string: s := strings.ToLower(strings.TrimSpace(t)) return s == "1" || s == "true" case float64: return t != 0 case int: return t != 0 default: s := strings.ToLower(strings.TrimSpace(fmt.Sprint(v))) return s == "1" || s == "true" } } func applyAvatar(ctx context.Context, app *backpack.App, user *models.User, payload map[string]any) { if app == nil || user == nil || user.ID == 0 { return } gdb, ok := app.Lookup[*gorm.DB]() if !ok || gdb == nil { return } var f attach.File err := gdb.WithContext(ctx). Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), "avatar"). Take(&f).Error if err != nil { return } payload["has_avatar"] = true payload["avatar"] = f.DiskName bucket, ok := app.Lookup[*blob.Bucket]() if !ok || bucket == nil { return } url, err := f.Thumb(ctx, bucket, 128, 128, "auto") if err != nil { slog.Default().Error("golem15.user: avatar thumb", "err", err) payload["avatar_url"] = "/" + attach.BlobKey(f.DiskName) return } payload["avatar_url"] = url } func avatarExt(mime string) (string, bool) { switch mime { case "image/jpeg": return "jpg", true case "image/png": return "png", true case "image/gif": return "gif", true case "image/webp": return "webp", true default: return "", false } }