mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Remove all _hz_ prefixes from collection IDs, internal markers, and test fixtures. The default users collection is now _users_auth_ instead of _hz_users_auth_. Internal SQL parser tokens renamed from __hz_join__/__hz_discard__ to __join__/__discard__. Backup prefix changed from @auto_hz_backup_ to @auto_backup_. Test database updated with correct collection IDs and indexes. JWT tokens in test files regenerated with new collection IDs. Add LogoURL field to MetaConfig so the admin UI logo is configurable via app settings instead of hardcoded. FullPage.svelte and App.svelte sidebar now read logo and app name from the settings store, falling back to defaults when not configured.
718 lines
20 KiB
Go
718 lines
20 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
|
"github.com/go-ozzo/ozzo-validation/v4/is"
|
|
"github.com/hanzoai/base/core/validators"
|
|
"github.com/hanzoai/base/tools/cron"
|
|
"github.com/hanzoai/base/tools/hook"
|
|
"github.com/hanzoai/base/tools/mailer"
|
|
"github.com/hanzoai/base/tools/security"
|
|
"github.com/hanzoai/base/tools/types"
|
|
)
|
|
|
|
const (
|
|
paramsTable = "_params"
|
|
|
|
paramsKeySettings = "settings"
|
|
|
|
systemHookIdSettings = "__hzSettingsSystemHook__"
|
|
)
|
|
|
|
func (app *BaseApp) registerSettingsHooks() {
|
|
saveFunc := func(me *ModelEvent) error {
|
|
if err := me.Next(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if me.Model.PK() == paramsKeySettings {
|
|
// auto reload the app settings because we don't know whether
|
|
// the Settings model is the app one or a different one
|
|
return errors.Join(
|
|
me.App.Settings().PostScan(),
|
|
me.App.ReloadSettings(),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
app.OnModelAfterCreateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
|
|
Id: systemHookIdSettings,
|
|
Func: saveFunc,
|
|
Priority: -999,
|
|
})
|
|
|
|
app.OnModelAfterUpdateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
|
|
Id: systemHookIdSettings,
|
|
Func: saveFunc,
|
|
Priority: -999,
|
|
})
|
|
|
|
app.OnModelDelete(paramsTable).Bind(&hook.Handler[*ModelEvent]{
|
|
Id: systemHookIdSettings,
|
|
Func: func(me *ModelEvent) error {
|
|
if me.Model.PK() == paramsKeySettings {
|
|
return errors.New("the app params settings cannot be deleted")
|
|
}
|
|
|
|
return me.Next()
|
|
},
|
|
Priority: -999,
|
|
})
|
|
|
|
app.OnCollectionUpdate().Bind(&hook.Handler[*CollectionEvent]{
|
|
Id: systemHookIdSettings,
|
|
Func: func(e *CollectionEvent) error {
|
|
oldCollection, err := e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to retrieve old cached collection: %w", err)
|
|
}
|
|
|
|
err = e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// update existing rate limit rules on collection rename
|
|
if oldCollection.Name != e.Collection.Name {
|
|
var hasChange bool
|
|
|
|
rules := e.App.Settings().RateLimits.Rules
|
|
for i := 0; i < len(rules); i++ {
|
|
if strings.HasPrefix(rules[i].Label, oldCollection.Name+":") {
|
|
rules[i].Label = strings.Replace(rules[i].Label, oldCollection.Name+":", e.Collection.Name+":", 1)
|
|
hasChange = true
|
|
}
|
|
}
|
|
|
|
if hasChange {
|
|
e.App.Settings().RateLimits.Rules = rules
|
|
err = e.App.Save(e.App.Settings())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
Priority: 99,
|
|
})
|
|
}
|
|
|
|
var (
|
|
_ Model = (*Settings)(nil)
|
|
_ PostValidator = (*Settings)(nil)
|
|
_ DBExporter = (*Settings)(nil)
|
|
)
|
|
|
|
type settings struct {
|
|
SMTP SMTPConfig `form:"smtp" json:"smtp"`
|
|
Backups BackupsConfig `form:"backups" json:"backups"`
|
|
S3 S3Config `form:"s3" json:"s3"`
|
|
Meta MetaConfig `form:"meta" json:"meta"`
|
|
RateLimits RateLimitsConfig `form:"rateLimits" json:"rateLimits"`
|
|
TrustedProxy TrustedProxyConfig `form:"trustedProxy" json:"trustedProxy"`
|
|
Batch BatchConfig `form:"batch" json:"batch"`
|
|
Logs LogsConfig `form:"logs" json:"logs"`
|
|
}
|
|
|
|
// Settings defines the Base app settings.
|
|
type Settings struct {
|
|
settings
|
|
|
|
mu sync.RWMutex
|
|
isNew bool
|
|
}
|
|
|
|
func newDefaultSettings() *Settings {
|
|
return &Settings{
|
|
isNew: true,
|
|
settings: settings{
|
|
Meta: MetaConfig{
|
|
AppName: "Acme",
|
|
AppURL: "http://localhost:8090",
|
|
HideControls: false,
|
|
SenderName: "Support",
|
|
SenderAddress: "[email protected]",
|
|
},
|
|
Logs: LogsConfig{
|
|
MaxDays: 5,
|
|
LogIP: true,
|
|
},
|
|
SMTP: SMTPConfig{
|
|
Enabled: false,
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
Username: "",
|
|
Password: "",
|
|
TLS: false,
|
|
},
|
|
Backups: BackupsConfig{
|
|
CronMaxKeep: 3,
|
|
},
|
|
Batch: BatchConfig{
|
|
Enabled: false,
|
|
MaxRequests: 50,
|
|
Timeout: 3,
|
|
},
|
|
RateLimits: RateLimitsConfig{
|
|
Enabled: false, // @todo once tested enough enable by default for new installations
|
|
Rules: []RateLimitRule{
|
|
{Label: "*:auth", MaxRequests: 2, Duration: 3},
|
|
{Label: "*:create", MaxRequests: 20, Duration: 5},
|
|
{Label: "/api/batch", MaxRequests: 3, Duration: 1},
|
|
{Label: "/api/", MaxRequests: 300, Duration: 10},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TableName implements [Model.TableName] interface method.
|
|
func (s *Settings) TableName() string {
|
|
return paramsTable
|
|
}
|
|
|
|
// PK implements [Model.LastSavedPK] interface method.
|
|
func (s *Settings) LastSavedPK() any {
|
|
return paramsKeySettings
|
|
}
|
|
|
|
// PK implements [Model.PK] interface method.
|
|
func (s *Settings) PK() any {
|
|
return paramsKeySettings
|
|
}
|
|
|
|
// IsNew implements [Model.IsNew] interface method.
|
|
func (s *Settings) IsNew() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return s.isNew
|
|
}
|
|
|
|
// MarkAsNew implements [Model.MarkAsNew] interface method.
|
|
func (s *Settings) MarkAsNew() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.isNew = true
|
|
}
|
|
|
|
// MarkAsNew implements [Model.MarkAsNotNew] interface method.
|
|
func (s *Settings) MarkAsNotNew() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.isNew = false
|
|
}
|
|
|
|
// PostScan implements [Model.PostScan] interface method.
|
|
func (s *Settings) PostScan() error {
|
|
s.MarkAsNotNew()
|
|
return nil
|
|
}
|
|
|
|
// String returns a serialized string representation of the current settings.
|
|
func (s *Settings) String() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
raw, _ := json.Marshal(s)
|
|
return string(raw)
|
|
}
|
|
|
|
// DBExport prepares and exports the current settings for db persistence.
|
|
func (s *Settings) DBExport(app App) (map[string]any, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
now := types.NowDateTime()
|
|
|
|
result := map[string]any{
|
|
"id": s.PK(),
|
|
}
|
|
|
|
if s.IsNew() {
|
|
result["created"] = now
|
|
}
|
|
result["updated"] = now
|
|
|
|
encoded, err := json.Marshal(s.settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
encryptionKey := os.Getenv(app.EncryptionEnv())
|
|
if encryptionKey != "" {
|
|
encryptVal, encryptErr := security.Encrypt(encoded, encryptionKey)
|
|
if encryptErr != nil {
|
|
return nil, encryptErr
|
|
}
|
|
|
|
result["value"] = encryptVal
|
|
} else {
|
|
result["value"] = encoded
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// PostValidate implements the [PostValidator] interface and defines
|
|
// the Settings model validations.
|
|
func (s *Settings) PostValidate(ctx context.Context, app App) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return validation.ValidateStructWithContext(ctx, s,
|
|
validation.Field(&s.Meta),
|
|
validation.Field(&s.Logs),
|
|
validation.Field(&s.SMTP),
|
|
validation.Field(&s.S3),
|
|
validation.Field(&s.Backups),
|
|
validation.Field(&s.Batch),
|
|
validation.Field(&s.RateLimits),
|
|
validation.Field(&s.TrustedProxy),
|
|
)
|
|
}
|
|
|
|
// Merge merges the "other" settings into the current one.
|
|
func (s *Settings) Merge(other *Settings) error {
|
|
other.mu.RLock()
|
|
defer other.mu.RUnlock()
|
|
|
|
raw, err := json.Marshal(other.settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return json.Unmarshal(raw, &s)
|
|
}
|
|
|
|
// Clone creates a new deep copy of the current settings.
|
|
func (s *Settings) Clone() (*Settings, error) {
|
|
clone := &Settings{
|
|
isNew: s.isNew,
|
|
}
|
|
|
|
if err := clone.Merge(s); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return clone, nil
|
|
}
|
|
|
|
// MarshalJSON implements the [json.Marshaler] interface.
|
|
//
|
|
// Note that sensitive fields (S3 secret, SMTP password, etc.) are excluded.
|
|
func (s *Settings) MarshalJSON() ([]byte, error) {
|
|
s.mu.RLock()
|
|
copy := s.settings
|
|
s.mu.RUnlock()
|
|
|
|
sensitiveFields := []*string{
|
|
©.SMTP.Password,
|
|
©.S3.Secret,
|
|
©.Backups.S3.Secret,
|
|
}
|
|
|
|
// mask all sensitive fields
|
|
for _, v := range sensitiveFields {
|
|
if v != nil && *v != "" {
|
|
*v = ""
|
|
}
|
|
}
|
|
|
|
return json.Marshal(copy)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type SMTPConfig struct {
|
|
Enabled bool `form:"enabled" json:"enabled"`
|
|
Port int `form:"port" json:"port"`
|
|
Host string `form:"host" json:"host"`
|
|
Username string `form:"username" json:"username"`
|
|
Password string `form:"password" json:"password,omitempty"`
|
|
|
|
// SMTP AUTH - PLAIN (default) or LOGIN
|
|
AuthMethod string `form:"authMethod" json:"authMethod"`
|
|
|
|
// Whether to enforce TLS encryption for the mail server connection.
|
|
//
|
|
// When set to false StartTLS command is send, leaving the server
|
|
// to decide whether to upgrade the connection or not.
|
|
TLS bool `form:"tls" json:"tls"`
|
|
|
|
// LocalName is optional domain name or IP address used for the
|
|
// EHLO/HELO exchange (if not explicitly set, defaults to "localhost").
|
|
//
|
|
// This is required only by some SMTP servers, such as Gmail SMTP-relay.
|
|
LocalName string `form:"localName" json:"localName"`
|
|
}
|
|
|
|
// Validate makes SMTPConfig validatable by implementing [validation.Validatable] interface.
|
|
func (c SMTPConfig) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(
|
|
&c.Host,
|
|
validation.When(c.Enabled, validation.Required),
|
|
is.Host,
|
|
),
|
|
validation.Field(
|
|
&c.Port,
|
|
validation.When(c.Enabled, validation.Required),
|
|
validation.Min(0),
|
|
),
|
|
validation.Field(
|
|
&c.AuthMethod,
|
|
// don't require it for backward compatibility
|
|
// (fallback internally to PLAIN)
|
|
// validation.When(c.Enabled, validation.Required),
|
|
validation.In(mailer.SMTPAuthLogin, mailer.SMTPAuthPlain),
|
|
),
|
|
validation.Field(&c.LocalName, is.Host),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type S3Config struct {
|
|
Enabled bool `form:"enabled" json:"enabled"`
|
|
Bucket string `form:"bucket" json:"bucket"`
|
|
Region string `form:"region" json:"region"`
|
|
Endpoint string `form:"endpoint" json:"endpoint"`
|
|
AccessKey string `form:"accessKey" json:"accessKey"`
|
|
Secret string `form:"secret" json:"secret,omitempty"`
|
|
ForcePathStyle bool `form:"forcePathStyle" json:"forcePathStyle"`
|
|
}
|
|
|
|
// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
|
|
func (c S3Config) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(&c.Endpoint, is.URL, validation.When(c.Enabled, validation.Required)),
|
|
validation.Field(&c.Bucket, validation.When(c.Enabled, validation.Required)),
|
|
validation.Field(&c.Region, validation.When(c.Enabled, validation.Required)),
|
|
validation.Field(&c.AccessKey, validation.When(c.Enabled, validation.Required)),
|
|
validation.Field(&c.Secret, validation.When(c.Enabled, validation.Required)),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type BatchConfig struct {
|
|
Enabled bool `form:"enabled" json:"enabled"`
|
|
|
|
// MaxRequests is the maximum allowed batch request to execute.
|
|
MaxRequests int `form:"maxRequests" json:"maxRequests"`
|
|
|
|
// Timeout is the max duration in seconds to wait before cancelling the batch transaction.
|
|
Timeout int64 `form:"timeout" json:"timeout"`
|
|
|
|
// MaxBodySize is the maximum allowed batch request body size in bytes.
|
|
//
|
|
// If not set, fallbacks to max ~128MB.
|
|
MaxBodySize int64 `form:"maxBodySize" json:"maxBodySize"`
|
|
}
|
|
|
|
// Validate makes BatchConfig validatable by implementing [validation.Validatable] interface.
|
|
func (c BatchConfig) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(&c.MaxRequests, validation.When(c.Enabled, validation.Required), validation.Min(0)),
|
|
validation.Field(&c.Timeout, validation.When(c.Enabled, validation.Required), validation.Min(0)),
|
|
validation.Field(&c.MaxBodySize, validation.Min(0)),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type BackupsConfig struct {
|
|
// Cron is a cron expression to schedule auto backups, eg. "* * * * *".
|
|
//
|
|
// Leave it empty to disable the auto backups functionality.
|
|
Cron string `form:"cron" json:"cron"`
|
|
|
|
// CronMaxKeep is the max number of cron generated backups to
|
|
// keep before removing older entries.
|
|
//
|
|
// This field works only when the cron config has valid cron expression.
|
|
CronMaxKeep int `form:"cronMaxKeep" json:"cronMaxKeep"`
|
|
|
|
// S3 is an optional S3 storage config specifying where to store the app backups.
|
|
S3 S3Config `form:"s3" json:"s3"`
|
|
}
|
|
|
|
// Validate makes BackupsConfig validatable by implementing [validation.Validatable] interface.
|
|
func (c BackupsConfig) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(&c.S3),
|
|
validation.Field(&c.Cron, validation.By(checkCronExpression)),
|
|
validation.Field(
|
|
&c.CronMaxKeep,
|
|
validation.When(c.Cron != "", validation.Required),
|
|
validation.Min(1),
|
|
),
|
|
)
|
|
}
|
|
|
|
func checkCronExpression(value any) error {
|
|
v, _ := value.(string)
|
|
if v == "" {
|
|
return nil // nothing to check
|
|
}
|
|
|
|
_, err := cron.NewSchedule(v)
|
|
if err != nil {
|
|
return validation.NewError("validation_invalid_cron", err.Error())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type MetaConfig struct {
|
|
AppName string `form:"appName" json:"appName"`
|
|
AppURL string `form:"appURL" json:"appURL"`
|
|
LogoURL string `form:"logoUrl" json:"logoUrl"`
|
|
SenderName string `form:"senderName" json:"senderName"`
|
|
SenderAddress string `form:"senderAddress" json:"senderAddress"`
|
|
HideControls bool `form:"hideControls" json:"hideControls"`
|
|
}
|
|
|
|
// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
|
|
func (c MetaConfig) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
|
|
validation.Field(&c.AppURL, validation.Required, is.URL),
|
|
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
|
|
validation.Field(&c.SenderAddress, is.EmailFormat, validation.Required),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type LogsConfig struct {
|
|
MaxDays int `form:"maxDays" json:"maxDays"`
|
|
MinLevel int `form:"minLevel" json:"minLevel"`
|
|
LogIP bool `form:"logIP" json:"logIP"`
|
|
LogAuthId bool `form:"logAuthId" json:"logAuthId"`
|
|
}
|
|
|
|
// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface.
|
|
func (c LogsConfig) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(&c.MaxDays, validation.Min(0)),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type TrustedProxyConfig struct {
|
|
// Headers is a list of explicit trusted header(s) to check.
|
|
Headers []string `form:"headers" json:"headers"`
|
|
|
|
// UseLeftmostIP specifies to use the left-mostish IP from the trusted headers.
|
|
//
|
|
// Note that this could be insecure when used with X-Forwarded-For header
|
|
// because some proxies like AWS ELB allow users to prepend their own header value
|
|
// before appending the trusted ones.
|
|
UseLeftmostIP bool `form:"useLeftmostIP" json:"useLeftmostIP"`
|
|
}
|
|
|
|
// MarshalJSON implements the [json.Marshaler] interface.
|
|
func (c TrustedProxyConfig) MarshalJSON() ([]byte, error) {
|
|
type alias TrustedProxyConfig
|
|
|
|
// serialize as empty array
|
|
if c.Headers == nil {
|
|
c.Headers = []string{}
|
|
}
|
|
|
|
return json.Marshal(alias(c))
|
|
}
|
|
|
|
// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
|
|
func (c TrustedProxyConfig) Validate() error {
|
|
return nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type RateLimitsConfig struct {
|
|
Rules []RateLimitRule `form:"rules" json:"rules"`
|
|
Enabled bool `form:"enabled" json:"enabled"`
|
|
}
|
|
|
|
// FindRateLimitRule returns the first matching rule based on the provided labels.
|
|
//
|
|
// Optionally you can further specify a list of valid RateLimitRule.Audience values to further filter the matching rule
|
|
// (aka. the rule Audience will have to exist in one of the specified options).
|
|
func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string, optOnlyAudience ...string) (RateLimitRule, bool) {
|
|
var prefixRules []int
|
|
|
|
for i, label := range searchLabels {
|
|
// check for direct match
|
|
for j := range c.Rules {
|
|
if label == c.Rules[j].Label &&
|
|
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[j].Audience)) {
|
|
return c.Rules[j], true
|
|
}
|
|
|
|
if i == 0 && strings.HasSuffix(c.Rules[j].Label, "/") {
|
|
prefixRules = append(prefixRules, j)
|
|
}
|
|
}
|
|
|
|
// check for prefix match
|
|
if len(prefixRules) > 0 {
|
|
for j := range prefixRules {
|
|
if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) &&
|
|
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[prefixRules[j]].Audience)) {
|
|
return c.Rules[prefixRules[j]], true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return RateLimitRule{}, false
|
|
}
|
|
|
|
// MarshalJSON implements the [json.Marshaler] interface.
|
|
func (c RateLimitsConfig) MarshalJSON() ([]byte, error) {
|
|
type alias RateLimitsConfig
|
|
|
|
// serialize as empty array
|
|
if c.Rules == nil {
|
|
c.Rules = []RateLimitRule{}
|
|
}
|
|
|
|
return json.Marshal(alias(c))
|
|
}
|
|
|
|
// Validate makes RateLimitsConfig validatable by implementing [validation.Validatable] interface.
|
|
func (c RateLimitsConfig) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(
|
|
&c.Rules,
|
|
validation.When(c.Enabled, validation.Required),
|
|
validation.By(checkUniqueRuleLabel),
|
|
),
|
|
)
|
|
}
|
|
|
|
func checkUniqueRuleLabel(value any) error {
|
|
rules, ok := value.([]RateLimitRule)
|
|
if !ok {
|
|
return validators.ErrUnsupportedValueType
|
|
}
|
|
|
|
existing := make([]string, 0, len(rules))
|
|
|
|
for i, rule := range rules {
|
|
fullKey := rule.Label + "@@" + rule.Audience
|
|
|
|
var conflicts bool
|
|
for _, key := range existing {
|
|
if strings.HasPrefix(key, fullKey) || strings.HasPrefix(fullKey, key) {
|
|
conflicts = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if conflicts {
|
|
return validation.Errors{
|
|
strconv.Itoa(i): validation.Errors{
|
|
"label": validation.NewError("validation_conflicting_rate_limit_rule", "Rate limit rule configuration with label {{.label}} already exists or conflicts with another rule.").
|
|
SetParams(map[string]any{"label": rule.Label}),
|
|
},
|
|
}
|
|
} else {
|
|
existing = append(existing, fullKey)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var rateLimitRuleLabelRegex = regexp.MustCompile(`^(\w+\ \/[\w\/-]*|\/[\w\/-]*|^\w+\:\w+|\*\:\w+|\w+)$`)
|
|
|
|
// The allowed RateLimitRule.Audience values
|
|
const (
|
|
RateLimitRuleAudienceAll = ""
|
|
RateLimitRuleAudienceGuest = "@guest"
|
|
RateLimitRuleAudienceAuth = "@auth"
|
|
)
|
|
|
|
type RateLimitRule struct {
|
|
// Label is the identifier of the current rule.
|
|
//
|
|
// It could be a tag, complete path or path prerefix (when ends with `/`).
|
|
//
|
|
// Example supported labels:
|
|
// - test_a (plain text "tag")
|
|
// - users:create
|
|
// - *:create
|
|
// - /
|
|
// - /api
|
|
// - POST /api/collections/
|
|
Label string `form:"label" json:"label"`
|
|
|
|
// Audience specifies the auth group the rule should apply for:
|
|
// - "" - both guests and authenticated users (default)
|
|
// - "@guest" - only for guests
|
|
// - "@auth" - only for authenticated users
|
|
Audience string `form:"audience" json:"audience"`
|
|
|
|
// Duration specifies the interval (in seconds) per which to reset
|
|
// the counted/accumulated rate limiter tokens.
|
|
Duration int64 `form:"duration" json:"duration"`
|
|
|
|
// MaxRequests is the max allowed number of requests per Duration.
|
|
MaxRequests int `form:"maxRequests" json:"maxRequests"`
|
|
}
|
|
|
|
// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
|
|
func (c RateLimitRule) Validate() error {
|
|
return validation.ValidateStruct(&c,
|
|
validation.Field(&c.Label, validation.Required, validation.Match(rateLimitRuleLabelRegex)),
|
|
validation.Field(&c.MaxRequests, validation.Required, validation.Min(1)),
|
|
validation.Field(&c.Duration, validation.Required, validation.Min(1)),
|
|
validation.Field(&c.Audience,
|
|
validation.In(RateLimitRuleAudienceAll, RateLimitRuleAudienceGuest, RateLimitRuleAudienceAuth),
|
|
),
|
|
)
|
|
}
|
|
|
|
// DurationTime returns the tag's Duration as [time.Duration].
|
|
func (c RateLimitRule) DurationTime() time.Duration {
|
|
return time.Duration(c.Duration) * time.Second
|
|
}
|
|
|
|
// String returns a string representation of the rule.
|
|
func (c RateLimitRule) String() string {
|
|
raw, err := json.Marshal(c)
|
|
if err != nil {
|
|
return c.Label // extremely rare case
|
|
}
|
|
|
|
return string(raw)
|
|
}
|