CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 6: the media repository. Clients upload files, reserve an mxc:// URI and upload to it later, download and ask for thumbnails, all through the authenticated media endpoints of Matrix 1.11; other servers download this server's media through the federation media endpoints, and media uploaded elsewhere is fetched over federation on first use and kept. internal/mediaapi is the component. Content goes into a BlobStore named by its SHA-256, so the same file uploaded twice is stored once and the hash doubles as the ETag; the metadata that maps an mxc:// URI onto a blob is in media_repository and media_thumbnails. The local-filesystem store writes to a temporary file and renames it into place once hashed, so nothing ever refers to half a file. A storage driver can bring its own store through storage.BlobProvider, which is where PocketBase's filesystem abstraction (local or S3) plugs in with Phase 10. Reserved uploads belong to the user who made them, take one upload, lapse after a day, and are capped per user; a download of one waits for its content, woken in memory the moment it arrives, for as long as timeout_ms allows. Thumbnails come from a fixed set of configured sizes, so a thousand requested sizes cannot mean a thousand resizes. They are made on first request and kept, never enlarge, honour a JPEG's EXIF orientation, and point at the original when it already fits. The pixel count is checked from the header before anything is decoded, at most four resizes run at once, and concurrent requests for the same thumbnail share one. Remote media is fetched through the signed client: the multipart/mixed answer of Matrix 1.11, with a fallback to the legacy download endpoint for servers that answer M_UNRECOGNIZED, and Location redirects followed by a dialer that refuses loopback, private and link-local addresses. Concurrent requests share one fetch, detached from the request that started it. With federation off nothing is fetched, and what was cached before is still served. Downloads carry a sandboxing CSP, nosniff, inline Content-Disposition only for the types the spec lists as safe, and private immutable caching. The unauthenticated legacy download endpoints are off by default behind media.legacy_unauthenticated_downloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
629 lines
24 KiB
Go
629 lines
24 KiB
Go
// Package config loads and validates the Warp server configuration: a single
|
||
// YAML file, overridable per-field by WARP_* environment variables.
|
||
package config
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Config is the whole server configuration.
|
||
type Config struct {
|
||
// Version is the config schema version; bumped only on breaking changes.
|
||
Version int `yaml:"version"`
|
||
|
||
Global Global `yaml:"global"`
|
||
Listen Listen `yaml:"listen"`
|
||
Database Database `yaml:"database"`
|
||
ClientAPI ClientAPI `yaml:"client_api"`
|
||
Sync Sync `yaml:"sync"`
|
||
Federation Federation `yaml:"federation"`
|
||
Media Media `yaml:"media"`
|
||
Registration Registration `yaml:"registration"`
|
||
Logging Logging `yaml:"logging"`
|
||
Metrics Metrics `yaml:"metrics"`
|
||
|
||
// path is the file this config was read from, if any.
|
||
path string
|
||
}
|
||
|
||
// Global holds identity settings shared by every component.
|
||
type Global struct {
|
||
// ServerName is the Matrix server name, e.g. "warp.example". It becomes
|
||
// part of every user, room and event ID and cannot be changed later.
|
||
ServerName string `yaml:"server_name" env:"WARP_SERVER_NAME"`
|
||
|
||
// PrivateKeyPath points at the ed25519 signing key written by
|
||
// `warp generate-keys`.
|
||
PrivateKeyPath string `yaml:"private_key" env:"WARP_PRIVATE_KEY"`
|
||
|
||
WellKnown WellKnown `yaml:"well_known"`
|
||
}
|
||
|
||
// WellKnown configures the delegation documents served under /.well-known.
|
||
type WellKnown struct {
|
||
// ClientBaseURL is advertised to clients as m.homeserver.base_url. It
|
||
// defaults to https://<server_name>.
|
||
ClientBaseURL string `yaml:"client_base_url" env:"WARP_WELL_KNOWN_CLIENT_BASE_URL"`
|
||
|
||
// IdentityServerBaseURL is optional and advertised as m.identity_server.
|
||
IdentityServerBaseURL string `yaml:"identity_server_base_url" env:"WARP_WELL_KNOWN_IDENTITY_SERVER_BASE_URL"`
|
||
|
||
// ServerAddress is advertised to other homeservers as "m.server", e.g.
|
||
// "warp.example:8448". Only served while federation is enabled; empty
|
||
// means /.well-known/matrix/server returns 404 and other servers fall
|
||
// back to SRV/A record resolution.
|
||
ServerAddress string `yaml:"server_address" env:"WARP_WELL_KNOWN_SERVER_ADDRESS"`
|
||
}
|
||
|
||
// Listen configures the single HTTP listener.
|
||
type Listen struct {
|
||
Address string `yaml:"address" env:"WARP_LISTEN_ADDRESS"`
|
||
ReadHeaderTimeout time.Duration `yaml:"read_header_timeout" env:"WARP_READ_HEADER_TIMEOUT"`
|
||
ReadTimeout time.Duration `yaml:"read_timeout" env:"WARP_READ_TIMEOUT"`
|
||
WriteTimeout time.Duration `yaml:"write_timeout" env:"WARP_WRITE_TIMEOUT"`
|
||
IdleTimeout time.Duration `yaml:"idle_timeout" env:"WARP_IDLE_TIMEOUT"`
|
||
MaxHeaderBytes int `yaml:"max_header_bytes" env:"WARP_MAX_HEADER_BYTES"`
|
||
|
||
// ShutdownTimeout bounds graceful shutdown before connections are cut.
|
||
ShutdownTimeout time.Duration `yaml:"shutdown_timeout" env:"WARP_SHUTDOWN_TIMEOUT"`
|
||
|
||
// FederationAddress, when set, opens a second listener serving the
|
||
// same API over TLS. It is the port other servers connect to when
|
||
// nothing delegates them elsewhere — 8448 — and the one deployment
|
||
// shape where Warp terminates TLS itself rather than sitting behind a
|
||
// reverse proxy. Empty means no TLS listener: federation traffic then
|
||
// arrives through whatever the well-known document or SRV record
|
||
// points at.
|
||
FederationAddress string `yaml:"federation_address" env:"WARP_LISTEN_FEDERATION_ADDRESS"`
|
||
|
||
// TLSCertFile and TLSKeyFile are the PEM certificate chain and key for
|
||
// the federation listener. Both are required when it is enabled.
|
||
TLSCertFile string `yaml:"tls_cert_file" env:"WARP_TLS_CERT_FILE"`
|
||
TLSKeyFile string `yaml:"tls_key_file" env:"WARP_TLS_KEY_FILE"`
|
||
}
|
||
|
||
// Database selects and configures the storage driver.
|
||
type Database struct {
|
||
// Driver is one of "sqlite", "postgres", "pocketbase".
|
||
Driver string `yaml:"driver" env:"WARP_DB_DRIVER"`
|
||
|
||
// ConnectionString is driver specific: a DSN for postgres, a file URI
|
||
// for sqlite, a directory for pocketbase.
|
||
ConnectionString string `yaml:"connection_string" env:"WARP_DB_CONNECTION_STRING"`
|
||
|
||
MaxOpenConns int `yaml:"max_open_conns" env:"WARP_DB_MAX_OPEN_CONNS"`
|
||
MaxIdleConns int `yaml:"max_idle_conns" env:"WARP_DB_MAX_IDLE_CONNS"`
|
||
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime" env:"WARP_DB_CONN_MAX_LIFETIME"`
|
||
|
||
// AutoMigrate runs pending migrations on startup. Turn it off to gate
|
||
// schema changes behind an explicit `warp migrate`.
|
||
AutoMigrate bool `yaml:"auto_migrate" env:"WARP_DB_AUTO_MIGRATE"`
|
||
}
|
||
|
||
// ClientAPI configures the Client-Server API surface.
|
||
type ClientAPI struct {
|
||
// CORSAllowedOrigins lists origins allowed to call the C-S API from a
|
||
// browser. The Matrix spec expects "*"; narrow it if the only client is
|
||
// your own web app on a known origin.
|
||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins" env:"WARP_CORS_ALLOWED_ORIGINS"`
|
||
|
||
// TrustXForwardedFor takes the client address from the X-Forwarded-For
|
||
// header instead of the socket. Turn it on only behind a reverse proxy
|
||
// that overwrites the header: anyone can send it, and the rate limiter
|
||
// keys its buckets on the result.
|
||
TrustXForwardedFor bool `yaml:"trust_x_forwarded_for" env:"WARP_TRUST_X_FORWARDED_FOR"`
|
||
|
||
// AccessTokenLifetime expires access tokens after this long. Zero — the
|
||
// default — issues tokens that never expire, which is what clients
|
||
// without refresh-token support need.
|
||
AccessTokenLifetime time.Duration `yaml:"access_token_lifetime" env:"WARP_ACCESS_TOKEN_LIFETIME"`
|
||
|
||
// RefreshTokenLifetime bounds how long a refresh token may be exchanged
|
||
// for a new access token. Zero means no expiry.
|
||
RefreshTokenLifetime time.Duration `yaml:"refresh_token_lifetime" env:"WARP_REFRESH_TOKEN_LIFETIME"`
|
||
|
||
RateLimit RateLimit `yaml:"rate_limit"`
|
||
}
|
||
|
||
// RateLimit configures the per-endpoint token buckets in front of the C-S
|
||
// API. Login and registration get their own, tighter, buckets because they
|
||
// are the two endpoints worth brute-forcing.
|
||
//
|
||
// Only `enabled` has an environment override: the three buckets share one Go
|
||
// type, so per-bucket `env` tags would all collide on the same variable.
|
||
// Turning the whole limiter off (WARP_RATE_LIMIT_ENABLED=false) is what test
|
||
// harnesses such as Complement need anyway.
|
||
type RateLimit struct {
|
||
Enabled bool `yaml:"enabled" env:"WARP_RATE_LIMIT_ENABLED"`
|
||
|
||
// Default applies to every rate-limited endpoint without its own bucket.
|
||
Default RateLimitBucket `yaml:"default"`
|
||
|
||
// Login covers /login and /refresh.
|
||
Login RateLimitBucket `yaml:"login"`
|
||
|
||
// Register covers /register and /register/available.
|
||
Register RateLimitBucket `yaml:"register"`
|
||
|
||
// RoomCreate covers /createRoom, which writes a dozen events per call.
|
||
RoomCreate RateLimitBucket `yaml:"room_create"`
|
||
|
||
// Join covers the two join endpoints. A join writes a membership event
|
||
// into a room that everybody in it then sees, so it is worth a tighter
|
||
// bucket than a read.
|
||
Join RateLimitBucket `yaml:"join"`
|
||
|
||
// Sync covers /sync and the chatty writes behind it: typing,
|
||
// receipts, to-device messages and presence. It is the loosest bucket
|
||
// of the lot on purpose — a client is in /sync all the time, and a
|
||
// long poll that is refused leaves it reconnecting in a loop, which is
|
||
// the one case where rate limiting makes the load worse.
|
||
Sync RateLimitBucket `yaml:"sync"`
|
||
|
||
// Media covers downloads and thumbnails. A client opening a room asks
|
||
// for every member's avatar at once, so it is as loose as Sync.
|
||
// Uploads stay in the default bucket.
|
||
Media RateLimitBucket `yaml:"media"`
|
||
}
|
||
|
||
// RateLimitBucket is one token bucket: Burst requests may arrive back to
|
||
// back, and one slot returns every Interval.
|
||
type RateLimitBucket struct {
|
||
Burst int `yaml:"burst"`
|
||
Interval time.Duration `yaml:"interval"`
|
||
}
|
||
|
||
// Sync configures the read model: /sync and the streams it carries.
|
||
type Sync struct {
|
||
// Presence turns the presence stream on. It is off by default
|
||
// (PLAN.md open question 3): presence is the noisiest thing in Matrix
|
||
// — every user's state change goes to everybody they share a room
|
||
// with — and a private homeserver gains little from it. With it off
|
||
// the presence endpoints answer M_FORBIDDEN and the presence section
|
||
// of a sync is empty.
|
||
Presence bool `yaml:"presence" env:"WARP_SYNC_PRESENCE"`
|
||
|
||
// TypingTimeout is how long a typing notification lasts without being
|
||
// renewed, and the cap on what a client may ask for.
|
||
TypingTimeout time.Duration `yaml:"typing_timeout" env:"WARP_SYNC_TYPING_TIMEOUT"`
|
||
|
||
// LongPollTimeout caps a /sync's ?timeout=, whatever the client asks
|
||
// for. Holding a connection longer buys nothing — a sync is woken the
|
||
// moment anything happens — and a client asking for ten minutes would
|
||
// hold a connection for ten minutes.
|
||
LongPollTimeout time.Duration `yaml:"long_poll_timeout" env:"WARP_SYNC_LONG_POLL_TIMEOUT"`
|
||
}
|
||
|
||
// Federation controls the server-to-server surface. See PLAN.md section 3: it
|
||
// is a first-class mode, not an afterthought.
|
||
type Federation struct {
|
||
Enabled bool `yaml:"enabled" env:"WARP_FEDERATION_ENABLED"`
|
||
|
||
// Allowlist, when non-empty, restricts federation to these server names.
|
||
Allowlist []string `yaml:"allowlist" env:"WARP_FEDERATION_ALLOWLIST"`
|
||
|
||
// Denylist blocks these server names even in allowlist mode.
|
||
Denylist []string `yaml:"denylist" env:"WARP_FEDERATION_DENYLIST"`
|
||
|
||
// AllowRuntimeToggle lets the admin API flip Enabled without a restart.
|
||
// When true the persisted setting wins over the value above on boot.
|
||
AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_FEDERATION_ALLOW_RUNTIME_TOGGLE"`
|
||
|
||
// TrustedKeyServers are the notaries asked for another server's
|
||
// signing key when that server cannot be reached directly. Empty —
|
||
// the default — means keys are only ever fetched from their own
|
||
// server, which trusts nobody but requires everybody to be online.
|
||
TrustedKeyServers []string `yaml:"trusted_key_servers" env:"WARP_FEDERATION_TRUSTED_KEY_SERVERS"`
|
||
|
||
// KeyValidityPeriod is how long this server's key response tells
|
||
// others they may cache its key. Shorter means a rotated key is
|
||
// noticed sooner; longer means fewer fetches. The spec caps what a
|
||
// receiver believes at a week.
|
||
KeyValidityPeriod time.Duration `yaml:"key_validity_period" env:"WARP_FEDERATION_KEY_VALIDITY_PERIOD"`
|
||
|
||
// TLSCAFile adds a PEM bundle of certificate authorities to the system
|
||
// roots for outbound federation, for a private federation whose
|
||
// servers have certificates from an internal CA. The system roots
|
||
// still apply.
|
||
TLSCAFile string `yaml:"tls_ca_file" env:"WARP_FEDERATION_TLS_CA_FILE"`
|
||
}
|
||
|
||
// Media configures the media repository: uploads, downloads, thumbnails
|
||
// and the cache of other servers' media.
|
||
type Media struct {
|
||
// BasePath is the directory media content is stored under. It is
|
||
// created on the first upload. A storage driver with its own file
|
||
// storage (PocketBase, when it lands) ignores it.
|
||
BasePath string `yaml:"base_path" env:"WARP_MEDIA_BASE_PATH"`
|
||
|
||
// MaxUploadBytes caps one upload, and one file fetched from another
|
||
// server. It is what /media/config reports as m.upload.size.
|
||
MaxUploadBytes int64 `yaml:"max_upload_bytes" env:"WARP_MEDIA_MAX_UPLOAD_BYTES"`
|
||
|
||
// MaxThumbnailPixels is the largest image, in pixels, that will be
|
||
// decoded to make a thumbnail. Decoding allocates four bytes a pixel
|
||
// whatever the file size, and a 100 KiB PNG can claim to be 30000 ×
|
||
// 30000.
|
||
MaxThumbnailPixels int64 `yaml:"max_thumbnail_pixels" env:"WARP_MEDIA_MAX_THUMBNAIL_PIXELS"`
|
||
|
||
// ThumbnailSizes are the thumbnails this server makes. A request is
|
||
// answered with the smallest configured size that is at least as big
|
||
// as the one asked for, so arbitrary sizes cannot be used to make the
|
||
// server resize the same image a thousand times.
|
||
ThumbnailSizes []ThumbnailSize `yaml:"thumbnail_sizes"`
|
||
|
||
// PendingUploadExpiry is how long an ID from POST /media/v1/create
|
||
// waits for its content before it is thrown away.
|
||
PendingUploadExpiry time.Duration `yaml:"pending_upload_expiry" env:"WARP_MEDIA_PENDING_UPLOAD_EXPIRY"`
|
||
|
||
// MaxPendingUploads caps the IDs one user may hold without content.
|
||
MaxPendingUploads int `yaml:"max_pending_uploads" env:"WARP_MEDIA_MAX_PENDING_UPLOADS"`
|
||
|
||
// LegacyUnauthenticatedDownloads keeps the deprecated
|
||
// /_matrix/media/v3/download and /thumbnail endpoints, which need no
|
||
// access token. Off by default: anybody who learns an mxc:// URI can
|
||
// read the file through them, which is why Matrix 1.11 replaced them
|
||
// with authenticated endpoints. Turn it on only for clients too old
|
||
// to know those.
|
||
LegacyUnauthenticatedDownloads bool `yaml:"legacy_unauthenticated_downloads" env:"WARP_MEDIA_LEGACY_UNAUTHENTICATED_DOWNLOADS"`
|
||
}
|
||
|
||
// ThumbnailSize is one configured thumbnail.
|
||
type ThumbnailSize struct {
|
||
Width int `yaml:"width"`
|
||
Height int `yaml:"height"`
|
||
|
||
// Method is "crop" (fill the box exactly, cutting the edges off) or
|
||
// "scale" (fit inside the box, keeping the aspect ratio).
|
||
Method string `yaml:"method"`
|
||
}
|
||
|
||
// Registration controls open registration.
|
||
type Registration struct {
|
||
Enabled bool `yaml:"enabled" env:"WARP_REGISTRATION_ENABLED"`
|
||
AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE"`
|
||
|
||
// Tokens, when non-empty, adds m.login.registration_token as a required
|
||
// stage to the registration flow and lists the tokens that satisfy it.
|
||
//
|
||
// Phase 1 keeps the tokens in the config file: per-token use counts and
|
||
// expiry belong to the admin token management of Phase 9, and a table
|
||
// nothing can write to yet would be dead weight.
|
||
Tokens []string `yaml:"tokens" env:"WARP_REGISTRATION_TOKENS"`
|
||
}
|
||
|
||
// RequiresToken reports whether the registration flow includes the
|
||
// m.login.registration_token stage.
|
||
func (r Registration) RequiresToken() bool { return len(r.Tokens) > 0 }
|
||
|
||
// TokenValid reports whether token is one of the configured registration
|
||
// tokens. Every candidate is compared in constant time, and the loop does
|
||
// not stop early, so neither the value nor the position of a token leaks
|
||
// through the time this takes.
|
||
func (r Registration) TokenValid(token string) bool {
|
||
found := 0
|
||
for _, want := range r.Tokens {
|
||
found |= subtle.ConstantTimeCompare([]byte(token), []byte(want))
|
||
}
|
||
return found == 1
|
||
}
|
||
|
||
// Logging configures slog.
|
||
type Logging struct {
|
||
Level string `yaml:"level" env:"WARP_LOG_LEVEL"`
|
||
Format string `yaml:"format" env:"WARP_LOG_FORMAT"`
|
||
AddSource bool `yaml:"add_source" env:"WARP_LOG_ADD_SOURCE"`
|
||
}
|
||
|
||
// Metrics configures the Prometheus endpoint.
|
||
type Metrics struct {
|
||
Enabled bool `yaml:"enabled" env:"WARP_METRICS_ENABLED"`
|
||
Path string `yaml:"path" env:"WARP_METRICS_PATH"`
|
||
}
|
||
|
||
// Path returns the file this config was loaded from, or "" for defaults.
|
||
func (c *Config) Path() string { return c.path }
|
||
|
||
// Default returns a config with every optional field populated. Only
|
||
// global.server_name is left empty: there is no sane default for it.
|
||
func Default() *Config {
|
||
return &Config{
|
||
Version: 1,
|
||
Global: Global{
|
||
PrivateKeyPath: "./keys/signing.key",
|
||
},
|
||
Listen: Listen{
|
||
Address: ":8008",
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
ReadTimeout: 60 * time.Second,
|
||
WriteTimeout: 120 * time.Second,
|
||
IdleTimeout: 120 * time.Second,
|
||
MaxHeaderBytes: 1 << 20,
|
||
ShutdownTimeout: 30 * time.Second,
|
||
},
|
||
Database: Database{
|
||
Driver: "sqlite",
|
||
ConnectionString: "file:warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)",
|
||
MaxOpenConns: 10,
|
||
MaxIdleConns: 5,
|
||
ConnMaxLifetime: time.Hour,
|
||
AutoMigrate: true,
|
||
},
|
||
ClientAPI: ClientAPI{
|
||
CORSAllowedOrigins: []string{"*"},
|
||
RateLimit: RateLimit{
|
||
Enabled: true,
|
||
// Generous enough that a chatty client never notices,
|
||
// tight enough that a password-guessing script does.
|
||
Default: RateLimitBucket{Burst: 30, Interval: 100 * time.Millisecond},
|
||
Login: RateLimitBucket{Burst: 10, Interval: time.Second},
|
||
Register: RateLimitBucket{Burst: 5, Interval: 2 * time.Second},
|
||
// Room creation and joining are deliberately slow. Both
|
||
// are rare for a real client and expensive for the server,
|
||
// and a client that hits either limit is a script.
|
||
RoomCreate: RateLimitBucket{Burst: 10, Interval: time.Second},
|
||
Join: RateLimitBucket{Burst: 10, Interval: time.Second},
|
||
// Loose enough that a client in a long poll with typing
|
||
// and receipts on top never notices it.
|
||
Sync: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
|
||
// Avatars: a client opening a room asks for dozens at once.
|
||
Media: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
|
||
},
|
||
},
|
||
Sync: Sync{
|
||
Presence: false,
|
||
TypingTimeout: 30 * time.Second,
|
||
LongPollTimeout: 30 * time.Second,
|
||
},
|
||
Federation: Federation{
|
||
Enabled: false,
|
||
AllowRuntimeToggle: true,
|
||
KeyValidityPeriod: 24 * time.Hour,
|
||
},
|
||
Media: Media{
|
||
BasePath: "./media_store",
|
||
MaxUploadBytes: 50 << 20,
|
||
MaxThumbnailPixels: 32_000_000,
|
||
ThumbnailSizes: DefaultThumbnailSizes(),
|
||
// The spec's own example, and long enough for a client that
|
||
// reserves an ID before a slow upload.
|
||
PendingUploadExpiry: 24 * time.Hour,
|
||
MaxPendingUploads: 10,
|
||
},
|
||
Registration: Registration{
|
||
Enabled: false,
|
||
AllowRuntimeToggle: true,
|
||
},
|
||
Logging: Logging{Level: "info", Format: "text"},
|
||
Metrics: Metrics{Enabled: true, Path: "/metrics"},
|
||
}
|
||
}
|
||
|
||
// DefaultThumbnailSizes are the thumbnails made when the config names
|
||
// none: two square crops for avatars, three scaled sizes for images in the
|
||
// timeline. They are the sizes Element and most other clients ask for.
|
||
func DefaultThumbnailSizes() []ThumbnailSize {
|
||
return []ThumbnailSize{
|
||
{Width: 32, Height: 32, Method: "crop"},
|
||
{Width: 96, Height: 96, Method: "crop"},
|
||
{Width: 320, Height: 240, Method: "scale"},
|
||
{Width: 640, Height: 480, Method: "scale"},
|
||
{Width: 800, Height: 600, Method: "scale"},
|
||
}
|
||
}
|
||
|
||
// ClientBaseURL returns the URL advertised to clients, falling back to
|
||
// https://<server_name> when not configured.
|
||
func (c *Config) ClientBaseURL() string {
|
||
if c.Global.WellKnown.ClientBaseURL != "" {
|
||
return strings.TrimRight(c.Global.WellKnown.ClientBaseURL, "/")
|
||
}
|
||
return "https://" + c.Global.ServerName
|
||
}
|
||
|
||
// Validate reports every problem it finds, not just the first.
|
||
func (c *Config) Validate() error {
|
||
var errs []error
|
||
bad := func(format string, args ...any) {
|
||
errs = append(errs, fmt.Errorf(format, args...))
|
||
}
|
||
|
||
if c.Version != 1 {
|
||
bad("version: unsupported config version %d (expected 1)", c.Version)
|
||
}
|
||
if err := validateServerName(c.Global.ServerName); err != nil {
|
||
bad("global.server_name: %w", err)
|
||
}
|
||
if c.Global.PrivateKeyPath == "" {
|
||
bad("global.private_key: must not be empty")
|
||
}
|
||
for _, u := range []struct{ name, raw string }{
|
||
{"global.well_known.client_base_url", c.Global.WellKnown.ClientBaseURL},
|
||
{"global.well_known.identity_server_base_url", c.Global.WellKnown.IdentityServerBaseURL},
|
||
} {
|
||
if u.raw == "" {
|
||
continue
|
||
}
|
||
parsed, err := url.Parse(u.raw)
|
||
if err != nil {
|
||
bad("%s: %w", u.name, err)
|
||
} else if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||
bad("%s: must be an http(s) URL, got %q", u.name, u.raw)
|
||
}
|
||
}
|
||
if c.Listen.Address == "" {
|
||
bad("listen.address: must not be empty")
|
||
}
|
||
if c.Listen.ShutdownTimeout <= 0 {
|
||
bad("listen.shutdown_timeout: must be positive")
|
||
}
|
||
|
||
switch c.Database.Driver {
|
||
case "sqlite", "postgres", "pocketbase":
|
||
case "":
|
||
bad("database.driver: must not be empty")
|
||
default:
|
||
bad("database.driver: unknown driver %q (want sqlite, postgres or pocketbase)", c.Database.Driver)
|
||
}
|
||
if c.Database.ConnectionString == "" {
|
||
bad("database.connection_string: must not be empty")
|
||
}
|
||
if c.Database.MaxOpenConns < 0 {
|
||
bad("database.max_open_conns: must not be negative")
|
||
}
|
||
|
||
switch strings.ToLower(c.Logging.Level) {
|
||
case "debug", "info", "warn", "warning", "error":
|
||
default:
|
||
bad("logging.level: unknown level %q", c.Logging.Level)
|
||
}
|
||
switch strings.ToLower(c.Logging.Format) {
|
||
case "text", "json":
|
||
default:
|
||
bad("logging.format: unknown format %q (want text or json)", c.Logging.Format)
|
||
}
|
||
|
||
if c.Metrics.Enabled && !strings.HasPrefix(c.Metrics.Path, "/") {
|
||
bad("metrics.path: must start with /")
|
||
}
|
||
|
||
if c.ClientAPI.AccessTokenLifetime < 0 {
|
||
bad("client_api.access_token_lifetime: must not be negative")
|
||
}
|
||
if c.ClientAPI.RefreshTokenLifetime < 0 {
|
||
bad("client_api.refresh_token_lifetime: must not be negative")
|
||
}
|
||
if c.ClientAPI.RateLimit.Enabled {
|
||
for _, b := range []struct {
|
||
name string
|
||
bucket RateLimitBucket
|
||
}{
|
||
{"default", c.ClientAPI.RateLimit.Default},
|
||
{"login", c.ClientAPI.RateLimit.Login},
|
||
{"register", c.ClientAPI.RateLimit.Register},
|
||
{"room_create", c.ClientAPI.RateLimit.RoomCreate},
|
||
{"join", c.ClientAPI.RateLimit.Join},
|
||
{"sync", c.ClientAPI.RateLimit.Sync},
|
||
{"media", c.ClientAPI.RateLimit.Media},
|
||
} {
|
||
if b.bucket.Burst < 1 {
|
||
bad("client_api.rate_limit.%s.burst: must be at least 1", b.name)
|
||
}
|
||
if b.bucket.Interval <= 0 {
|
||
bad("client_api.rate_limit.%s.interval: must be positive", b.name)
|
||
}
|
||
}
|
||
}
|
||
if c.Sync.TypingTimeout <= 0 {
|
||
bad("sync.typing_timeout: must be positive")
|
||
}
|
||
if c.Sync.LongPollTimeout <= 0 {
|
||
bad("sync.long_poll_timeout: must be positive")
|
||
}
|
||
|
||
if c.Media.BasePath == "" {
|
||
bad("media.base_path: must not be empty")
|
||
}
|
||
if c.Media.MaxUploadBytes < 1 {
|
||
bad("media.max_upload_bytes: must be positive")
|
||
}
|
||
if c.Media.MaxThumbnailPixels < 1 {
|
||
bad("media.max_thumbnail_pixels: must be positive")
|
||
}
|
||
for i, size := range c.Media.ThumbnailSizes {
|
||
if size.Width < 1 || size.Height < 1 || size.Width > 4096 || size.Height > 4096 {
|
||
bad("media.thumbnail_sizes[%d]: width and height must be between 1 and 4096", i)
|
||
}
|
||
if size.Method != "crop" && size.Method != "scale" {
|
||
bad("media.thumbnail_sizes[%d].method: must be crop or scale, got %q", i, size.Method)
|
||
}
|
||
}
|
||
if c.Media.PendingUploadExpiry <= 0 {
|
||
bad("media.pending_upload_expiry: must be positive")
|
||
}
|
||
if c.Media.MaxPendingUploads < 1 {
|
||
bad("media.max_pending_uploads: must be at least 1")
|
||
}
|
||
|
||
for i, token := range c.Registration.Tokens {
|
||
if strings.TrimSpace(token) == "" {
|
||
bad("registration.tokens[%d]: must not be empty", i)
|
||
}
|
||
}
|
||
|
||
for i, s := range c.Federation.Allowlist {
|
||
if err := validateServerName(s); err != nil {
|
||
bad("federation.allowlist[%d]: %w", i, err)
|
||
}
|
||
}
|
||
for i, s := range c.Federation.Denylist {
|
||
if err := validateServerName(s); err != nil {
|
||
bad("federation.denylist[%d]: %w", i, err)
|
||
}
|
||
}
|
||
for i, s := range c.Federation.TrustedKeyServers {
|
||
if err := validateServerName(s); err != nil {
|
||
bad("federation.trusted_key_servers[%d]: %w", i, err)
|
||
}
|
||
}
|
||
if c.Federation.KeyValidityPeriod <= 0 || c.Federation.KeyValidityPeriod > 7*24*time.Hour {
|
||
bad("federation.key_validity_period: must be positive and at most a week (168h)")
|
||
}
|
||
if c.Listen.FederationAddress != "" && (c.Listen.TLSCertFile == "" || c.Listen.TLSKeyFile == "") {
|
||
bad("listen.federation_address: needs listen.tls_cert_file and listen.tls_key_file")
|
||
}
|
||
|
||
return errors.Join(errs...)
|
||
}
|
||
|
||
// validateServerName applies a cheap sanity check. The full grammar (IPv6
|
||
// literals, ports, IDNA) lands with the event core in Phase 2.
|
||
func validateServerName(s string) error {
|
||
switch {
|
||
case s == "":
|
||
return errors.New("must not be empty")
|
||
case strings.ContainsAny(s, " \t\r\n"):
|
||
return errors.New("must not contain whitespace")
|
||
case strings.Contains(s, "/"):
|
||
return errors.New("must be a host[:port], not a URL")
|
||
case strings.HasPrefix(s, ":"), strings.HasSuffix(s, ":"):
|
||
return fmt.Errorf("malformed host:port %q", s)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Load reads path, applies environment overrides and validates the result.
|
||
// An empty path yields the defaults plus environment overrides, which is how
|
||
// container deployments configured purely through env vars start up.
|
||
func Load(path string) (*Config, error) {
|
||
cfg := Default()
|
||
if path != "" {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read config: %w", err)
|
||
}
|
||
if err := unmarshalStrict(data, cfg); err != nil {
|
||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||
}
|
||
abs, err := filepath.Abs(path)
|
||
if err != nil {
|
||
abs = path
|
||
}
|
||
cfg.path = abs
|
||
}
|
||
if err := ApplyEnv(cfg, os.Getenv); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := cfg.Validate(); err != nil {
|
||
return nil, fmt.Errorf("invalid configuration:\n%w", err)
|
||
}
|
||
return cfg, nil
|
||
}
|