Prefix replacement of the old application-nested module path in go.mod and every import. No identifier, string, table, migration ID, config key or comment changes.
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/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/postcard"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"git.golem15.com/golem15/summercms/modules/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
|
|
}
|
|
}
|