// Package cooler holds the shared-state services of a SummerCMS application: // a Redis-backed fixed-window rate-limit store that keeps limits across // application instances, with the Redis client built from the one redis.url // key. The framework installs it at boot (surf's serve command), so // applications import nothing. // // cooler imports neither surf nor cabana: surf imports cooler, and the // rate-limit store satisfies surf.Store structurally through Attempter. package cooler import ( "context" "fmt" "log/slog" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" "github.com/redis/go-redis/v9" ) // Config keys and defaults cooler reads. const ( // RedisURLKey is the shared Redis URL (redis://, rediss:// or unix://), // required when a Redis driver is selected. Environment: SUMMER_REDIS__URL. RedisURLKey = "redis.url" // CacheDriverKey selects the cache driver: memory (default) or redis. CacheDriverKey = "cache.driver" // CachePrefixKey is the per-application key prefix. The default derives // from app.name. CachePrefixKey = "cache.prefix" // CacheMaxEntriesKey bounds the memory driver's entry count. CacheMaxEntriesKey = "cache.memory.max_entries" // RateLimitStoreKey selects where rate-limit counters live: memory // (default, per instance) or redis (shared). RateLimitStoreKey = "http.rate_limit.store" // DefaultMaxEntries is the memory driver's size bound when // cache.memory.max_entries is unset. DefaultMaxEntries = 10000 // PingTimeout bounds the start-up Ping that Install sends to Redis. PingTimeout = 3 * time.Second // AttemptTimeout bounds one rate-limit attempt on Redis before the // fallback store answers. AttemptTimeout = 500 * time.Millisecond ) // Driver names accepted by cache.driver and http.rate_limit.store. const ( driverMemory = "memory" driverRedis = "redis" ) // defaultPrefix is the key prefix when neither cache.prefix nor app.name is set. const defaultPrefix = "summer:" // Attempter is one atomic fixed-window admission. It has exactly the method // set of surf.Store, so any surf.Store is an Attempter and a RateLimitStore // is a surf.Store. type Attempter interface { Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration) } // Handle is what Install set up. Close releases the Redis client Install // opened, if any. type Handle struct { client *redis.Client prefix string rateRedis bool log *slog.Logger } // Close closes the Redis client Install opened. It is safe on a nil Handle // and when no Redis driver was selected. func (h *Handle) Close() error { if h == nil || h.client == nil { return nil } err := h.client.Close() h.client = nil return err } // RateLimitRedis reports whether http.rate_limit.store selects the Redis store. func (h *Handle) RateLimitRedis() bool { return h != nil && h.rateRedis && h.client != nil } // RateLimitStore returns the Redis rate-limit store, falling back to fallback // while Redis does not answer. When the Redis store is not selected it // returns fallback itself. func (h *Handle) RateLimitStore(fallback Attempter) Attempter { if !h.RateLimitRedis() { return fallback } s := NewRateLimitStore(h.client, h.prefix, fallback) s.log = h.log return s } // Install reads cooler's configuration and opens what it selects. With // http.rate_limit.store set to redis it builds a client from redis.url and // pings it within PingTimeout. An unreachable Redis is logged as a warning // (the endpoint only, never the URL) and start-up continues: the rate limiter // falls back to per-instance memory until Redis answers. A missing or invalid // redis.url, or an unknown store name, is a boot error naming the key. // // Only the serve command calls Install; route:list and tests never dial Redis. func Install(ctx context.Context, app *backpack.App) (*Handle, error) { if app == nil { return nil, fmt.Errorf("cooler: app is nil") } if ctx == nil { ctx = context.Background() } cfg := app.Config h := &Handle{prefix: keyPrefix(cfg), log: loggerFromApp(app)} store, err := driverName(cfg, RateLimitStoreKey) if err != nil { return nil, err } h.rateRedis = store == driverRedis if h.rateRedis { client, err := openFromConfig(ctx, cfg, RateLimitStoreKey) if err != nil { return nil, err } h.client = client ping(ctx, client, h.log) } return h, nil } // openFromConfig builds the Redis client from redis.url, naming the key that // selected Redis when the URL is missing. func openFromConfig(ctx context.Context, cfg *compass.Config, selectedBy string) (*redis.Client, error) { raw := "" if cfg != nil { raw = strings.TrimSpace(cfg.String(RedisURLKey)) } if raw == "" { return nil, fmt.Errorf("cooler: %s is required when %s is %s (set it in config/redis.yaml or SUMMER_REDIS__URL)", RedisURLKey, selectedBy, driverRedis) } return OpenRedis(ctx, raw) } // ping checks Redis once within PingTimeout and logs a warning on failure. func ping(ctx context.Context, client *redis.Client, log *slog.Logger) { pctx, cancel := context.WithTimeout(ctx, PingTimeout) defer cancel() if err := client.Ping(pctx).Err(); err != nil { log.Warn("cooler: redis did not answer at start-up; rate limits count per instance until it does", "endpoint", redisEndpoint(client), "error", err) } } // driverName reads a memory|redis selector. Empty means memory. func driverName(cfg *compass.Config, key string) (string, error) { if cfg == nil { return driverMemory, nil } v := strings.ToLower(strings.TrimSpace(cfg.String(key))) switch v { case "", driverMemory: return driverMemory, nil case driverRedis: return driverRedis, nil } return "", fmt.Errorf("cooler: %s must be %s or %s, got %q", key, driverMemory, driverRedis, v) } // keyPrefix returns the key prefix every cooler key starts with: cache.prefix // when set, otherwise app.name lower-cased with every run of characters // outside [a-z0-9] replaced by "_" (trimmed at both ends) and a trailing ":", // or "summer:" when app.name is empty too. Cache keys are cache:, // rate-limit keys rl:. func keyPrefix(cfg *compass.Config) string { if cfg == nil { return defaultPrefix } if p := strings.TrimSpace(cfg.String(CachePrefixKey)); p != "" { return p } name := slug(cfg.String("app.name")) if name == "" { return defaultPrefix } return name + ":" } func slug(s string) string { var b strings.Builder under := false for _, r := range strings.ToLower(s) { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { b.WriteRune(r) under = false continue } if !under { b.WriteByte('_') under = true } } return strings.Trim(b.String(), "_") } func loggerFromApp(app *backpack.App) *slog.Logger { if app != nil { if log, ok := app.Lookup[*slog.Logger](); ok && log != nil { return log } } return slog.Default() }