Files
uncensored-send/internal/config/config.go
T
2026-09-12 23:48:03 +02:00

169 lines
5.6 KiB
Go

// Package config holds the server's runtime options and the flag plumbing that
// populates them.
package config
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
)
// Config is the fully-resolved server configuration.
type Config struct {
Listen string
Port int // overrides the port in Listen when set
DataDir string
BasePath string // normalised: always "/" or "/prefix/"
PublicURL string // absolute origin+path for generated links; "" => relative
MaxSize int64 // per-upload cap; Unlimited means no cap
MaxExpiry time.Duration // longest lifetime a caller may request
DefaultExpiry time.Duration // lifetime when the caller does not ask
MaxTotalBytes int64 // whole-store quota
MinFreeBytes int64 // refuse uploads below this much free disk
TokensPath string
TrustedProxy string // comma-separated CIDRs allowed to set X-Forwarded-For
SweepInterval time.Duration
UploadRate int // uploads per hour per client
UploadBurst int
MaxConcurrent int
trustedProxyNets []*net.IPNet
}
const EnvPrefix = "SEND_"
// Register wires every option onto s. Short forms exist only for the options
// reached often; everything else is long-only, by design.
func (c *Config) Register(s *Set) {
s.String(&c.Listen, "listen", "l", "127.0.0.1:8080", "ADDR",
"address to listen on; keep it on loopback behind a reverse proxy")
s.Int(&c.Port, "port", "p", 0,
"port to listen on, replacing the one in --listen")
s.String(&c.DataDir, "data", "d", "./data", "DIR",
"directory holding uploaded objects and their metadata")
s.String(&c.BasePath, "base-url", "b", "/", "PATH",
"path prefix this service is mounted under")
s.String(&c.PublicURL, "public-url", "u", "", "URL",
"absolute base URL used in generated links; relative links when empty")
s.Size(&c.MaxSize, "max-size", "s", "2GiB",
"largest upload accepted from an anonymous caller")
s.Duration(&c.MaxExpiry, "max-expiry", "e", "3d",
"longest lifetime an anonymous caller may request")
s.Duration(&c.DefaultExpiry, "default-expiry", "", "3d",
"lifetime applied when the caller does not ask for one")
s.Size(&c.MaxTotalBytes, "max-total-bytes", "", "unlimited",
"refuse uploads once stored data exceeds this total")
s.Size(&c.MinFreeBytes, "min-free-bytes", "", "1GiB",
"refuse uploads when the filesystem has less free space than this")
s.String(&c.TokensPath, "tokens", "", "", "FILE",
"token file location (default <data>/tokens.json)")
s.String(&c.TrustedProxy, "trusted-proxy", "", "", "CIDRS",
"comma-separated networks whose X-Forwarded-For header is believed")
s.Duration(&c.SweepInterval, "sweep-interval", "", "1m",
"how often expired objects are swept from disk")
s.Int(&c.UploadRate, "upload-rate", "", 60,
"uploads permitted per hour per client address")
s.Int(&c.UploadBurst, "upload-burst", "", 10,
"uploads permitted back-to-back before the rate applies")
s.Int(&c.MaxConcurrent, "max-concurrent", "", 8,
"uploads allowed to be in flight at once")
}
// Normalise validates interdependent options and canonicalises the derived
// ones. It must be called after parsing and before the config is used.
func (c *Config) Normalise() error {
c.BasePath = NormalisePath(c.BasePath)
// --port is a convenience over --listen: it replaces only the port, so the
// host stays wherever --listen (or its default) put it.
if c.Port != 0 {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("--port: %d is not a port number", c.Port)
}
host, _, err := net.SplitHostPort(c.Listen)
if err != nil {
// --listen held a bare host, which is fine once a port is supplied.
host = strings.TrimSpace(c.Listen)
}
c.Listen = net.JoinHostPort(host, strconv.Itoa(c.Port))
}
if _, _, err := net.SplitHostPort(c.Listen); err != nil {
return fmt.Errorf("--listen: %q is not an address:port (use --port to set just the port)", c.Listen)
}
if c.PublicURL != "" {
u, err := url.Parse(c.PublicURL)
if err != nil {
return fmt.Errorf("--public-url: %w", err)
}
if !u.IsAbs() {
return fmt.Errorf("--public-url: %q is not absolute", c.PublicURL)
}
c.PublicURL = strings.TrimSuffix(u.String(), "/")
}
if c.TokensPath == "" {
c.TokensPath = c.DataDir + "/tokens.json"
}
if c.MaxExpiry != Unlimited && (c.DefaultExpiry == Unlimited || c.DefaultExpiry > c.MaxExpiry) {
return fmt.Errorf("--default-expiry (%s) exceeds --max-expiry (%s)",
FormatDuration(c.DefaultExpiry), FormatDuration(c.MaxExpiry))
}
if c.SweepInterval <= 0 {
return fmt.Errorf("--sweep-interval must be positive")
}
if c.MaxConcurrent < 1 {
return fmt.Errorf("--max-concurrent must be at least 1")
}
for _, cidr := range strings.Split(c.TrustedProxy, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
// Accept both a bare address and a network.
if ip := net.ParseIP(cidr); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
c.trustedProxyNets = append(c.trustedProxyNets,
&net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
continue
}
_, n, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("--trusted-proxy: %w", err)
}
c.trustedProxyNets = append(c.trustedProxyNets, n)
}
return nil
}
// TrustsProxy reports whether X-Forwarded-For from ip should be believed.
func (c *Config) TrustsProxy(ip net.IP) bool {
for _, n := range c.trustedProxyNets {
if n.Contains(ip) {
return true
}
}
return false
}
// NormalisePath canonicalises a mount prefix to "/" or "/prefix/".
func NormalisePath(p string) string {
p = strings.Trim(strings.TrimSpace(p), "/")
if p == "" {
return "/"
}
return "/" + p + "/"
}