Files
webhooker/internal/config/config.go
sneak b04bc2cc7b
All checks were successful
check / check (push) Successful in 3m6s
Add inactivity-based session timeout (closes #66)
Sessions had only a 7-day absolute lifetime, and that cap was enforced
only by the cookie's MaxAge -- i.e. only by the browser. An abandoned
session stayed usable for the full week.

Sessions are now bounded by two independent, server-enforced clocks,
and end at whichever expires first:

- absolute: created_at + 7 days, stamped once by SetUser and never
  rewritten, so no amount of activity can extend it
- idle: last_seen + SESSION_IDLE_TIMEOUT (default 24h), pushed forward
  by the new Session.Touch

Both deadlines are checked in Session.expired, which IsAuthenticated
now consults, so every existing authentication decision honours them
without each call site having to remember.

Activity means a request that passes RequireAuth, which is the only
place Touch is called; an unauthenticated request carrying the cookie
cannot keep a session alive. Touch re-checks authentication itself so
that guarantee does not depend on the call site.

To avoid re-issuing the session cookie on every authenticated request,
Touch rewrites last_seen only once it is older than a tenth of the idle
window. The session therefore expires up to 10% early relative to the
user's true last request, never late.

An authenticated session carrying no timestamps (a cookie minted before
this change) is treated as expired, so the failure mode of the upgrade
is one forced re-login rather than an unbounded session.

Tests use an injected clock rather than sleeps and cover idle expiry,
refresh on activity, an actively used session still dying at the
absolute cap, refusal to refresh unauthenticated or expired sessions,
disabled idle expiry, and startup aborting on an unparseable
SESSION_IDLE_TIMEOUT.
2026-08-09 05:59:54 +00:00

225 lines
6.0 KiB
Go

// Package config loads application configuration from environment variables.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
// Populates the environment from a ./.env file automatically for
// development configuration. Kept in one place only (here).
_ "github.com/joho/godotenv/autoload"
)
const (
// EnvironmentDev represents development environment.
EnvironmentDev = "dev"
// EnvironmentProd represents production environment.
EnvironmentProd = "prod"
// defaultPort is the default HTTP listen port.
defaultPort = 8080
// defaultRetentionSweepInterval is how often the retention
// reaper deletes events older than each webhook's RetentionDays.
defaultRetentionSweepInterval = time.Hour
// defaultSessionIdleTimeout is how long a session may go without
// authenticated activity before it expires.
defaultSessionIdleTimeout = 24 * time.Hour
)
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
// contains an unrecognised value.
var ErrInvalidEnvironment = errors.New("invalid environment")
//nolint:revive // ConfigParams is a standard fx naming convention.
type ConfigParams struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
// Config holds all application configuration loaded from
// environment variables.
type Config struct {
DataDir string
Debug bool
MaintenanceMode bool
Environment string
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
// RetentionSweepInterval is how often the retention reaper runs.
RetentionSweepInterval time.Duration
// SessionIdleTimeout is the sliding inactivity window after
// which a session expires. Non-positive disables idle expiry.
SessionIdleTimeout time.Duration
params *ConfigParams
log *slog.Logger
}
// IsDev returns true if running in development environment.
func (c *Config) IsDev() bool {
return c.Environment == EnvironmentDev
}
// IsProd returns true if running in production environment.
func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd
}
// envString returns the value of the named environment variable,
// or an empty string if not set.
func envString(key string) string {
return os.Getenv(key)
}
// envBool returns the value of the named environment variable
// parsed as a boolean. Returns defaultValue if not set.
func envBool(key string, defaultValue bool) bool {
if v := os.Getenv(key); v != "" {
return strings.EqualFold(v, "true") || v == "1"
}
return defaultValue
}
// envInt returns the value of the named environment variable
// parsed as an integer. Returns defaultValue if not set or
// unparseable.
func envInt(key string, defaultValue int) int {
if v := os.Getenv(key); v != "" {
i, err := strconv.Atoi(v)
if err == nil {
return i
}
}
return defaultValue
}
// envDuration returns the value of the named environment variable
// parsed as a Go duration (e.g. "1h", "30m"). Returns defaultValue if
// not set. If the variable is set but cannot be parsed, it returns a
// wrapped error naming the key and the bad value, so startup fails
// loudly rather than silently falling back to the default.
func envDuration(
key string,
defaultValue time.Duration,
) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return defaultValue, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf(
"invalid duration for %s: %q: %w", key, v, err,
)
}
return d, nil
}
// New creates a Config by reading environment variables.
//
//nolint:revive // lc parameter is required by fx even if unused.
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log := params.Logger.Get()
// Determine environment from WEBHOOKER_ENVIRONMENT env var,
// default to dev
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" {
environment = EnvironmentDev
}
// Validate environment
if environment != EnvironmentDev &&
environment != EnvironmentProd {
return nil, fmt.Errorf(
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
ErrInvalidEnvironment,
EnvironmentDev, EnvironmentProd, environment,
)
}
// Parse the retention sweep interval; a set-but-unparseable value
// is a hard error so fx aborts startup rather than silently using
// the default.
retentionSweepInterval, err := envDuration(
"RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval,
)
if err != nil {
return nil, err
}
// Same fail-loud treatment for the session idle timeout.
sessionIdleTimeout, err := envDuration(
"SESSION_IDLE_TIMEOUT",
defaultSessionIdleTimeout,
)
if err != nil {
return nil, err
}
// Load configuration values from environment variables
s := &Config{
DataDir: envString("DATA_DIR"),
Debug: envBool("DEBUG", false),
MaintenanceMode: envBool("MAINTENANCE_MODE", false),
Environment: environment,
MetricsUsername: envString("METRICS_USERNAME"),
MetricsPassword: envString("METRICS_PASSWORD"),
Port: envInt("PORT", defaultPort),
SentryDSN: envString("SENTRY_DSN"),
RetentionSweepInterval: retentionSweepInterval,
SessionIdleTimeout: sessionIdleTimeout,
log: log,
params: &params,
}
// Set default DataDir. All SQLite databases (main application
// DB and per-webhook event DBs) live here. The same default is
// used regardless of environment; override with DATA_DIR if
// needed.
if s.DataDir == "" {
s.DataDir = "/var/lib/webhooker"
}
if s.Debug {
params.Logger.EnableDebugLogging()
}
// Log configuration summary (without secrets)
log.Info("Configuration loaded",
"environment", s.Environment,
"port", s.Port,
"debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(),
"hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth",
s.MetricsUsername != "" && s.MetricsPassword != "",
)
return s, nil
}