Initial commit

This commit is contained in:
2026-09-12 23:26:07 +02:00
commit a180fe4b52
35 changed files with 4921 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
// Package config holds the server's runtime options and the flag plumbing that
// populates them.
package config
import (
"fmt"
"net"
"net/url"
"strings"
"time"
)
// Config is the fully-resolved server configuration.
type Config struct {
Listen string
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.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)
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 + "/"
}
+167
View File
@@ -0,0 +1,167 @@
package config
import (
"strings"
"testing"
"time"
)
func TestParseSize(t *testing.T) {
cases := map[string]int64{
"": Unlimited,
"0": Unlimited,
"unlimited": Unlimited,
"512": 512,
"512B": 512,
"2GiB": 2 << 30,
"2 GiB": 2 << 30,
"2G": 2 << 30,
"500MB": 500_000_000,
"1.5MiB": 1572864,
"2gib": 2 << 30,
}
for in, want := range cases {
got, err := ParseSize(in)
if err != nil {
t.Errorf("ParseSize(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("ParseSize(%q) = %d, want %d", in, got, want)
}
}
for _, in := range []string{"-1", "lots", "2XiB", "GiB2"} {
if _, err := ParseSize(in); err == nil {
t.Errorf("ParseSize(%q) accepted an invalid size", in)
}
}
}
func TestParseDuration(t *testing.T) {
cases := map[string]time.Duration{
"": Unlimited,
"never": Unlimited,
"0": Unlimited,
"90m": 90 * time.Minute,
"72h": 72 * time.Hour,
"3d": 72 * time.Hour,
"1w": 168 * time.Hour,
"1.5d": 36 * time.Hour,
}
for in, want := range cases {
got, err := ParseDuration(in)
if err != nil {
t.Errorf("ParseDuration(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("ParseDuration(%q) = %s, want %s", in, got, want)
}
}
for _, in := range []string{"-3d", "soon", "3days"} {
if _, err := ParseDuration(in); err == nil {
t.Errorf("ParseDuration(%q) accepted an invalid duration", in)
}
}
}
// The formatters are human-facing and round to two decimals, so they only
// round-trip exactly for values that land on a unit boundary. Nothing in the
// program re-parses their output; this pins the cases that are shown most.
func TestFormatRoundTrips(t *testing.T) {
for _, n := range []int64{0, 512, 2 << 30, 100 << 20, 1536} {
if got, err := ParseSize(FormatSize(n)); err != nil || got != n {
t.Errorf("FormatSize(%d) = %q, which parses back to %d (%v)", n, FormatSize(n), got, err)
}
}
for _, d := range []time.Duration{0, time.Hour, 72 * time.Hour, 90 * time.Minute} {
if got, err := ParseDuration(FormatDuration(d)); err != nil || got != d {
t.Errorf("FormatDuration(%s) = %q, which parses back to %s (%v)", d, FormatDuration(d), got, err)
}
}
}
// The convention is: one hyphen for a letter, two for a word. It is enforced
// here because the stdlib flag package treats both forms as the same.
func TestHyphenConvention(t *testing.T) {
newSet := func() (*Set, *string) {
var listen string
s := NewSet("test", "SEND_TEST_")
s.String(&listen, "listen", "l", "default", "ADDR", "listen address")
return s, &listen
}
for _, args := range [][]string{{"--listen", "x"}, {"-l", "x"}, {"--listen=x"}, {"-l=x"}} {
s, listen := newSet()
if err := s.Parse(args); err != nil {
t.Errorf("%v: %v", args, err)
continue
}
if *listen != "x" {
t.Errorf("%v: listen = %q", args, *listen)
}
}
for _, args := range [][]string{{"-listen", "x"}, {"--l", "x"}, {"-listen=x"}} {
s, _ := newSet()
err := s.Parse(args)
if err == nil {
t.Errorf("%v was accepted; it breaks the hyphen convention", args)
continue
}
if !strings.Contains(err.Error(), "hyphen") {
t.Errorf("%v: error does not explain the convention: %v", args, err)
}
}
}
func TestEnvFallback(t *testing.T) {
t.Setenv("SEND_TEST_MAX_SIZE", "4GiB")
var size int64
s := NewSet("test", "SEND_TEST_")
s.Size(&size, "max-size", "s", "2GiB", "cap")
if err := s.Parse(nil); err != nil {
t.Fatal(err)
}
if size != 4<<30 {
t.Errorf("size = %d, want 4GiB from the environment", size)
}
// An explicit flag still wins over the environment.
s = NewSet("test", "SEND_TEST_")
s.Size(&size, "max-size", "s", "2GiB", "cap")
if err := s.Parse([]string{"--max-size", "1GiB"}); err != nil {
t.Fatal(err)
}
if size != 1<<30 {
t.Errorf("size = %d, want the flag to win", size)
}
}
func TestNormalisePath(t *testing.T) {
cases := map[string]string{
"": "/",
"/": "/",
"send": "/send/",
"/send": "/send/",
"/send/": "/send/",
"a/b": "/a/b/",
}
for in, want := range cases {
if got := NormalisePath(in); got != want {
t.Errorf("NormalisePath(%q) = %q, want %q", in, got, want)
}
}
}
func TestDefaultExpiryMustFitWithinMax(t *testing.T) {
c := &Config{BasePath: "/", DataDir: "d", SweepInterval: time.Minute, MaxConcurrent: 1,
MaxExpiry: time.Hour, DefaultExpiry: 24 * time.Hour}
if err := c.Normalise(); err == nil {
t.Error("a default longer than the maximum was accepted")
}
c.DefaultExpiry = Unlimited
if err := c.Normalise(); err == nil {
t.Error("an unlimited default under a finite maximum was accepted")
}
}
+230
View File
@@ -0,0 +1,230 @@
package config
import (
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
// Set wraps flag.FlagSet to enforce this project's option convention:
// a single hyphen always introduces a single letter (-s), a double hyphen
// always introduces a full word (--max-size). The stdlib flag package treats
// the two forms as interchangeable, so the convention is enforced here rather
// than by the package.
type Set struct {
fs *flag.FlagSet
envPrefix string
specs []*spec
longs map[string]bool
shorts map[string]bool
}
type spec struct {
long, short string
placeholder string
usage string
def string
}
func NewSet(name, envPrefix string) *Set {
s := &Set{
fs: flag.NewFlagSet(name, flag.ContinueOnError),
envPrefix: envPrefix,
longs: map[string]bool{},
shorts: map[string]bool{},
}
s.fs.Usage = func() {}
return s
}
func (s *Set) Args() []string { return s.fs.Args() }
func (s *Set) SetOutput(w io.Writer) { s.fs.SetOutput(w) }
func (s *Set) register(sp *spec) { s.specs = append(s.specs, sp) }
func (s *Set) note(long, short string) {
s.longs[long] = true
if short != "" {
s.shorts[short] = true
}
}
// envName maps --max-size to SEND_MAX_SIZE.
func (s *Set) envName(long string) string {
return s.envPrefix + strings.ToUpper(strings.ReplaceAll(long, "-", "_"))
}
func (s *Set) String(p *string, long, short, def, placeholder, usage string) {
*p = def
s.fs.StringVar(p, long, def, usage)
if short != "" {
s.fs.StringVar(p, short, def, usage)
}
s.note(long, short)
s.register(&spec{long, short, placeholder, usage, def})
}
func (s *Set) Bool(p *bool, long, short string, def bool, usage string) {
*p = def
s.fs.BoolVar(p, long, def, usage)
if short != "" {
s.fs.BoolVar(p, short, def, usage)
}
s.note(long, short)
s.register(&spec{long, short, "", usage, fmt.Sprint(def)})
}
func (s *Set) Int(p *int, long, short string, def int, usage string) {
*p = def
s.fs.IntVar(p, long, def, usage)
if short != "" {
s.fs.IntVar(p, short, def, usage)
}
s.note(long, short)
s.register(&spec{long, short, "N", usage, fmt.Sprint(def)})
}
type sizeValue struct{ p *int64 }
func (v sizeValue) String() string {
if v.p == nil {
return ""
}
return FormatSize(*v.p)
}
func (v sizeValue) Set(s string) error {
n, err := ParseSize(s)
if err != nil {
return err
}
*v.p = n
return nil
}
// Size registers a byte-count option accepting "2GiB", "500MB" or "unlimited".
func (s *Set) Size(p *int64, long, short, def, usage string) {
n, err := ParseSize(def)
if err != nil {
panic("config: bad default size for --" + long + ": " + err.Error())
}
*p = n
v := sizeValue{p}
s.fs.Var(v, long, usage)
if short != "" {
s.fs.Var(v, short, usage)
}
s.note(long, short)
s.register(&spec{long, short, "SIZE", usage, def})
}
type durationValue struct{ p *time.Duration }
func (v durationValue) String() string {
if v.p == nil {
return ""
}
return FormatDuration(*v.p)
}
func (v durationValue) Set(s string) error {
d, err := ParseDuration(s)
if err != nil {
return err
}
*v.p = d
return nil
}
// Duration registers a time option accepting "3d", "90m" or "never".
func (s *Set) Duration(p *time.Duration, long, short, def, usage string) {
d, err := ParseDuration(def)
if err != nil {
panic("config: bad default duration for --" + long + ": " + err.Error())
}
*p = d
v := durationValue{p}
s.fs.Var(v, long, usage)
if short != "" {
s.fs.Var(v, short, usage)
}
s.note(long, short)
s.register(&spec{long, short, "DURATION", usage, def})
}
// checkConvention rejects a long name given with one hyphen, or a short name
// given with two. Only names actually registered are considered, so an option
// *value* that happens to start with a hyphen is never mistaken for a flag.
func (s *Set) checkConvention(args []string) error {
for _, a := range args {
if a == "--" {
return nil
}
if len(a) < 2 || a[0] != '-' {
continue
}
hyphens := 1
name := a[1:]
if name[0] == '-' {
hyphens = 2
name = name[1:]
}
name, _, _ = strings.Cut(name, "=")
switch {
case hyphens == 1 && len(name) > 1 && s.longs[name]:
return fmt.Errorf("use --%s, not -%s: long options take two hyphens", name, name)
case hyphens == 2 && len(name) == 1 && s.shorts[name]:
return fmt.Errorf("use -%s, not --%s: short options take one hyphen", name, name)
}
}
return nil
}
// applyEnv seeds every option from its SEND_-prefixed environment variable.
// Called before parsing so an explicit command-line flag always wins.
func (s *Set) applyEnv() error {
for _, sp := range s.specs {
v, ok := os.LookupEnv(s.envName(sp.long))
if !ok {
continue
}
if err := s.fs.Set(sp.long, v); err != nil {
return fmt.Errorf("%s: %w", s.envName(sp.long), err)
}
}
return nil
}
// Parse applies environment fallbacks, checks the hyphen convention and then
// parses args (which must exclude the program name).
func (s *Set) Parse(args []string) error {
if err := s.applyEnv(); err != nil {
return err
}
if err := s.checkConvention(args); err != nil {
return err
}
return s.fs.Parse(args)
}
// PrintUsage renders the options in the "-x, --xxx" form the convention implies.
func (s *Set) PrintUsage(w io.Writer, header string) {
fmt.Fprint(w, header)
for _, sp := range s.specs {
lead := " "
if sp.short != "" {
lead = " -" + sp.short + ","
}
name := lead + " --" + sp.long
if sp.placeholder != "" {
name += " " + sp.placeholder
}
fmt.Fprintf(w, "%s\n", name)
fmt.Fprintf(w, " %s", sp.usage)
if sp.def != "" && sp.def != "false" {
fmt.Fprintf(w, " (default %s)", sp.def)
}
fmt.Fprintf(w, "\n [%s]\n", s.envName(sp.long))
}
}
+118
View File
@@ -0,0 +1,118 @@
package config
import (
"fmt"
"strconv"
"strings"
"time"
)
// Unlimited is the zero value used by both size and duration limits to mean
// "no limit". Callers must therefore always compare against 0 before enforcing.
const Unlimited = 0
var sizeUnits = []struct {
suffix string
mult int64
}{
{"KIB", 1 << 10}, {"MIB", 1 << 20}, {"GIB", 1 << 30}, {"TIB", 1 << 40},
{"KB", 1e3}, {"MB", 1e6}, {"GB", 1e9}, {"TB", 1e12},
{"K", 1 << 10}, {"M", 1 << 20}, {"G", 1 << 30}, {"T", 1 << 40},
{"B", 1},
}
// ParseSize accepts "2GiB", "500MB", "1048576", "unlimited" or "0". Binary
// units (KiB) and bare "K"/"M"/"G" are powers of 1024; "KB"/"MB"/"GB" are
// powers of 1000, per SI.
func ParseSize(s string) (int64, error) {
t := strings.ToUpper(strings.TrimSpace(s))
if t == "" || t == "0" || t == "UNLIMITED" || t == "NONE" {
return Unlimited, nil
}
mult := int64(1)
for _, u := range sizeUnits {
if strings.HasSuffix(t, u.suffix) {
t = strings.TrimSpace(strings.TrimSuffix(t, u.suffix))
mult = u.mult
break
}
}
n, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, fmt.Errorf("invalid size %q", s)
}
if n < 0 {
return 0, fmt.Errorf("invalid size %q: negative", s)
}
v := int64(n * float64(mult))
if v < 0 {
return 0, fmt.Errorf("invalid size %q: overflow", s)
}
return v, nil
}
// FormatSize renders a *limit* for humans, where zero means "no limit". Use
// FormatBytes for an actual quantity, where zero means zero.
func FormatSize(n int64) string {
if n == Unlimited {
return "unlimited"
}
return FormatBytes(n)
}
// FormatBytes renders a byte count for humans.
func FormatBytes(n int64) string {
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
f, i := float64(n), 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i == 0 {
return fmt.Sprintf("%d B", n)
}
return strings.TrimSuffix(strconv.FormatFloat(f, 'f', 2, 64), ".00") + " " + units[i]
}
// ParseDuration extends time.ParseDuration with "d" (day) and "w" (week)
// suffixes, and maps "never"/"0" to Unlimited. Durations are the one place the
// stdlib's vocabulary is genuinely too small: "72h" is a poor way to say "3d".
func ParseDuration(s string) (time.Duration, error) {
t := strings.ToLower(strings.TrimSpace(s))
if t == "" || t == "0" || t == "never" || t == "unlimited" || t == "none" {
return Unlimited, nil
}
// Expand a trailing d/w into hours so time.ParseDuration can finish the job.
for suffix, hours := range map[string]float64{"d": 24, "w": 168} {
if num, ok := strings.CutSuffix(t, suffix); ok {
n, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, fmt.Errorf("invalid duration %q", s)
}
t = strconv.FormatFloat(n*hours, 'f', -1, 64) + "h"
break
}
}
d, err := time.ParseDuration(t)
if err != nil {
return 0, fmt.Errorf("invalid duration %q", s)
}
if d < 0 {
return 0, fmt.Errorf("invalid duration %q: negative", s)
}
return d, nil
}
// FormatDuration is the human-facing inverse of ParseDuration.
func FormatDuration(d time.Duration) string {
switch {
case d == Unlimited:
return "never"
case d%(24*time.Hour) == 0:
return fmt.Sprintf("%dd", d/(24*time.Hour))
case d%time.Hour == 0:
return fmt.Sprintf("%dh", d/time.Hour)
default:
return d.String()
}
}