Match PHP payloads, already-activated Winter 500 HTML, and direct-DB seeding so the corpus can flip those routes to ported without onboarding. Co-authored-by: Cursor <cursoragent@cursor.com>
1333 lines
37 KiB
Go
1333 lines
37 KiB
Go
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
|
|
}
|
|
}
|