feat(07-02): register accounts and extend the user payload

Registration follows activate_mode and hides a disabled or throttled cause in production. Fonoteka adds organisation fields through GetApiArrayEvent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-22 15:11:14 +02:00
parent 6bceb8167c
commit 97a39dc34c
3 changed files with 424 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -15,7 +16,10 @@ import (
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/lagoon"
"git.golem15.com/golem15/summercms/postcard"
"git.golem15.com/golem15/summercms/surf"
"git.golem15.com/golem15/summercms/wire"
"gorm.io/gorm"
)
@@ -188,13 +192,159 @@ func OAuthProviders(app *backpack.App) http.HandlerFunc {
}
}
// Register is filled in by the registration task. The route is declared now.
// 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) {
writeOpaque500(w)
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
allow, throttle, mode, require := registrationSettings(app)
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
if !allow {
writeSafe500(w, app, "Registrations are currently disabled.")
return
}
if throttle && ip != "" && registrationsFromIP(r.Context(), gdb, ip) >= 3 {
writeSafe500(w, app, "Registration is throttled. Please try again later.")
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
if _, exists := fields["password_confirmation"]; !exists {
fields["password_confirmation"] = fields["password"]
}
rules := (models.User{}).Rules()
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, rules, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": firstValidationMessage(errs),
"errors": errs,
})
return
}
password := strField(fields, "password")
cost := 10
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
}
hashed, err := bouncer.HashPassword(cost, password)
if err != nil {
writeOpaque500(w)
return
}
fields["password"] = hashed
if ip != "" {
fields["created_ip_address"] = ip
fields["last_ip_address"] = ip
}
user := &models.User{}
production := app.Config == nil || !app.Config.Bool("app.debug")
if err := lagoon.Fill(user, user.Fillable(), fields, production); err != nil {
writeOpaque500(w)
return
}
if mode == "auto" {
user.IsActivated = true
now := time.Now()
user.ActivatedAt = &now
}
if err := gdb.WithContext(r.Context()).Create(user).Error; err != nil {
writeOpaque500(w)
return
}
if app.Events != nil {
_ = app.Events.Fire(r.Context(), &classes.RegisterEvent{User: user})
}
ctx := r.Context()
if mode == "user" {
sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email)
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
return
}
if mode == "auto" || !require {
token, err := mintFor(app, secret, user, requestURL(r))
if err != nil {
writeOpaque500(w)
return
}
payload, err := apiArray(ctx, app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{})
}
}
func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
allow, throttle, require = true, true, true
mode = "auto"
if app == nil || app.Config == nil {
return allow, throttle, mode, require
}
if app.Config.Has("golem15.user.registration.allow_registration") {
allow = app.Config.Bool("golem15.user.registration.allow_registration")
}
if app.Config.Has("golem15.user.registration.use_register_throttle") {
throttle = app.Config.Bool("golem15.user.registration.use_register_throttle")
}
if app.Config.Has("golem15.user.activation.activate_mode") {
if s := strings.TrimSpace(app.Config.String("golem15.user.activation.activate_mode")); s != "" {
mode = s
}
}
if app.Config.Has("golem15.user.activation.require_activation") {
require = app.Config.Bool("golem15.user.activation.require_activation")
}
return allow, throttle, mode, require
}
func registrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64 {
var n int64
_ = gdb.WithContext(ctx).Model(&models.User{}).
Where("created_ip_address = ? AND created_at > ?", ip, time.Now().Add(-60*time.Minute)).
Count(&n).Error
return n
}
func firstValidationMessage(errs map[string][]string) string {
keys := make([]string, 0, len(errs))
for k := range errs {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 || len(errs[keys[0]]) == 0 {
return "The given data was invalid."
}
return errs[keys[0]][0]
}
func writeSafe500(w http.ResponseWriter, app *backpack.App, message string) {
if app != nil && app.Config != nil && app.Config.Bool("app.debug") {
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": message})
return
}
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
}
func appConfig(app *backpack.App) *compass.Config {
if app == nil {
return nil
}
return app.Config
}
func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {