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
+347
View File
@@ -0,0 +1,347 @@
// Package auth manages the named upload tokens and resolves the effective
// limits for a request.
package auth
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"sync"
"time"
"send/internal/config"
)
// tokenFilePerm is deliberately stricter than the rest of the data directory:
// this is the one file holding credential material.
const tokenFilePerm fs.FileMode = 0o600
var (
ErrNotFound = errors.New("no such token")
ErrExists = errors.New("a token with that name already exists")
)
// Token is one named credential. The pointer fields distinguish "not set, so
// inherit the server default" from "set to zero, meaning unlimited".
type Token struct {
Name string `json:"name"`
Hash string `json:"hash"`
MaxSize *string `json:"max_size,omitempty"`
MaxExpiry *string `json:"max_expiry,omitempty"`
DefaultExpiry *string `json:"default_expiry,omitempty"`
AllowVanity bool `json:"allow_vanity"`
Admin bool `json:"admin"`
Created time.Time `json:"created"`
maxSize *int64
maxExpiry *time.Duration
defaultExpiry *time.Duration
}
// resolve parses the human-written limit strings once, at load time, so a
// malformed token file is rejected at startup rather than mid-upload.
func (t *Token) resolve() error {
if t.Name == "" {
return errors.New("token has no name")
}
if _, err := hex.DecodeString(t.Hash); err != nil || len(t.Hash) != sha256.Size*2 {
return fmt.Errorf("token %q: hash is not a sha256 hex digest", t.Name)
}
if t.MaxSize != nil {
n, err := config.ParseSize(*t.MaxSize)
if err != nil {
return fmt.Errorf("token %q: max_size: %w", t.Name, err)
}
t.maxSize = &n
}
if t.MaxExpiry != nil {
d, err := config.ParseDuration(*t.MaxExpiry)
if err != nil {
return fmt.Errorf("token %q: max_expiry: %w", t.Name, err)
}
t.maxExpiry = &d
}
if t.DefaultExpiry != nil {
d, err := config.ParseDuration(*t.DefaultExpiry)
if err != nil {
return fmt.Errorf("token %q: default_expiry: %w", t.Name, err)
}
t.defaultExpiry = &d
}
return nil
}
// Limits is the effective permission set for one request.
type Limits struct {
Name string // "" for an anonymous caller
MaxSize int64
MaxExpiry time.Duration
DefaultExpiry time.Duration
AllowVanity bool
Admin bool
}
func (l Limits) Anonymous() bool { return l.Name == "" }
// Anonymous returns the limits applied to a caller presenting no credentials.
func Anonymous(c *config.Config) Limits {
return Limits{
MaxSize: c.MaxSize,
MaxExpiry: c.MaxExpiry,
DefaultExpiry: c.DefaultExpiry,
}
}
// Limits resolves a token's permissions against the server defaults. A field
// the token does not set is inherited, so "the same as anonymous unless
// configured otherwise" needs no special casing.
func (t *Token) Limits(c *config.Config) Limits {
l := Anonymous(c)
l.Name = t.Name
l.AllowVanity = t.AllowVanity
l.Admin = t.Admin
if t.maxSize != nil {
l.MaxSize = *t.maxSize
}
if t.maxExpiry != nil {
l.MaxExpiry = *t.maxExpiry
}
if t.defaultExpiry != nil {
l.DefaultExpiry = *t.defaultExpiry
}
// An inherited default longer than an explicitly widened maximum would be
// surprising; clamp rather than reject, since the token file is trusted.
if l.MaxExpiry != config.Unlimited &&
(l.DefaultExpiry == config.Unlimited || l.DefaultExpiry > l.MaxExpiry) {
l.DefaultExpiry = l.MaxExpiry
}
return l
}
// HashSecret is the one-way transform applied to every secret this service
// stores, both API tokens and per-object delete tokens. The secrets are 256-bit
// random values, so a plain digest is sufficient - there is nothing to brute
// force - and lookup by digest reveals nothing through timing.
func HashSecret(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// EqualHash compares two digests without an early exit.
func EqualHash(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// File is the token store, backed by a JSON file and reloadable at runtime.
type File struct {
path string
mu sync.RWMutex
byHash map[string]*Token
byName map[string]*Token
modTime time.Time
size int64
}
// Load reads the token file. A missing file is not an error: the service simply
// starts with no credentials and only the anonymous tier available.
func Load(path string) (*File, error) {
f := &File{path: path, byHash: map[string]*Token{}, byName: map[string]*Token{}}
if err := f.Reload(); err != nil {
return nil, err
}
return f, nil
}
func (f *File) Path() string { return f.path }
func (f *File) read() ([]*Token, os.FileInfo, error) {
info, err := os.Stat(f.path)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, nil
}
if err != nil {
return nil, nil, err
}
// Refuse to use credentials the rest of the system can read.
if perm := info.Mode().Perm(); perm&0o077 != 0 {
return nil, nil, fmt.Errorf("%s has mode %#o; it must not be group- or world-accessible (chmod 600)", f.path, perm)
}
b, err := os.ReadFile(f.path)
if err != nil {
return nil, nil, err
}
var tokens []*Token
if err := json.Unmarshal(b, &tokens); err != nil {
return nil, nil, fmt.Errorf("%s: %w", f.path, err)
}
for _, t := range tokens {
if err := t.resolve(); err != nil {
return nil, nil, fmt.Errorf("%s: %w", f.path, err)
}
}
return tokens, info, nil
}
// Reload re-reads the token file unconditionally.
func (f *File) Reload() error {
tokens, info, err := f.read()
if err != nil {
return err
}
byHash := make(map[string]*Token, len(tokens))
byName := make(map[string]*Token, len(tokens))
for _, t := range tokens {
if _, dup := byName[t.Name]; dup {
return fmt.Errorf("%s: duplicate token name %q", f.path, t.Name)
}
byHash[t.Hash] = t
byName[t.Name] = t
}
f.mu.Lock()
defer f.mu.Unlock()
f.byHash, f.byName = byHash, byName
if info != nil {
f.modTime, f.size = info.ModTime(), info.Size()
} else {
f.modTime, f.size = time.Time{}, 0
}
return nil
}
// MaybeReload re-reads the file only if it looks changed. It is cheap enough to
// call on every authenticated request.
func (f *File) MaybeReload() error {
info, err := os.Stat(f.path)
if errors.Is(err, fs.ErrNotExist) {
f.mu.RLock()
empty := len(f.byHash) == 0
f.mu.RUnlock()
if empty {
return nil
}
return f.Reload()
}
if err != nil {
return err
}
f.mu.RLock()
unchanged := info.ModTime().Equal(f.modTime) && info.Size() == f.size
f.mu.RUnlock()
if unchanged {
return nil
}
return f.Reload()
}
// Lookup resolves a presented secret to its token, or nil.
func (f *File) Lookup(secret string) *Token {
if secret == "" {
return nil
}
h := HashSecret(secret)
f.mu.RLock()
defer f.mu.RUnlock()
t, ok := f.byHash[h]
if !ok || !EqualHash(t.Hash, h) {
return nil
}
return t
}
// List returns the tokens, name-sorted, for the CLI.
func (f *File) List() []*Token {
f.mu.RLock()
defer f.mu.RUnlock()
out := make([]*Token, 0, len(f.byName))
for _, t := range f.byName {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Add appends a token and rewrites the file.
func (f *File) Add(t *Token) error {
if err := t.resolve(); err != nil {
return err
}
f.mu.Lock()
defer f.mu.Unlock()
if _, dup := f.byName[t.Name]; dup {
return ErrExists
}
f.byName[t.Name] = t
f.byHash[t.Hash] = t
return f.saveLocked()
}
// Remove deletes a token by name and rewrites the file.
func (f *File) Remove(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.byName[name]
if !ok {
return ErrNotFound
}
delete(f.byName, name)
delete(f.byHash, t.Hash)
return f.saveLocked()
}
// saveLocked writes the token file atomically, with owner-only permissions.
func (f *File) saveLocked() error {
tokens := make([]*Token, 0, len(f.byName))
for _, t := range f.byName {
tokens = append(tokens, t)
}
sort.Slice(tokens, func(i, j int) bool { return tokens[i].Name < tokens[j].Name })
b, err := json.MarshalIndent(tokens, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
dir := filepath.Dir(f.path)
if err := os.MkdirAll(dir, 0o775); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(f.path)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(tokenFilePerm); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmp.Name(), f.path); err != nil {
return err
}
info, err := os.Stat(f.path)
if err == nil {
f.modTime, f.size = info.ModTime(), info.Size()
}
return nil
}
+186
View File
@@ -0,0 +1,186 @@
package auth
import (
"os"
"path/filepath"
"testing"
"time"
"send/internal/config"
)
func defaults() *config.Config {
return &config.Config{
MaxSize: 2 << 30,
MaxExpiry: 72 * time.Hour,
DefaultExpiry: 72 * time.Hour,
}
}
func newFile(t *testing.T) *File {
t.Helper()
f, err := Load(filepath.Join(t.TempDir(), "tokens.json"))
if err != nil {
t.Fatal(err)
}
return f
}
func TestMissingFileIsNotAnError(t *testing.T) {
f := newFile(t)
if len(f.List()) != 0 {
t.Error("a missing token file produced tokens")
}
if f.Lookup("anything") != nil {
t.Error("a missing token file authenticated something")
}
}
func TestAddLookupRemove(t *testing.T) {
f := newFile(t)
secret := "0123456789abcdef0123456789abcdef"
if err := f.Add(&Token{Name: "friend", Hash: HashSecret(secret), AllowVanity: true}); err != nil {
t.Fatal(err)
}
tok := f.Lookup(secret)
if tok == nil || tok.Name != "friend" {
t.Fatalf("Lookup(secret) = %v", tok)
}
if f.Lookup("wrong") != nil || f.Lookup("") != nil {
t.Error("an unknown secret authenticated")
}
// A second token with the same name is refused.
if err := f.Add(&Token{Name: "friend", Hash: HashSecret("other")}); err != ErrExists {
t.Errorf("duplicate name => %v, want ErrExists", err)
}
if err := f.Remove("friend"); err != nil {
t.Fatal(err)
}
if f.Lookup(secret) != nil {
t.Error("a removed token still authenticates")
}
if err := f.Remove("friend"); err != ErrNotFound {
t.Errorf("removing twice => %v, want ErrNotFound", err)
}
}
// The token file holds credential material, so it is the one thing in the data
// directory that must stay owner-only.
func TestFilePermissions(t *testing.T) {
f := newFile(t)
if err := f.Add(&Token{Name: "a", Hash: HashSecret("s")}); err != nil {
t.Fatal(err)
}
info, err := os.Stat(f.Path())
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("token file mode = %#o, want 0600", perm)
}
// A file loosened by hand must be refused rather than silently used.
if err := os.Chmod(f.Path(), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(f.Path()); err == nil {
t.Error("a world-readable token file was accepted")
}
}
func TestLimitsInheritDefaults(t *testing.T) {
c := defaults()
// A token with nothing set behaves like the anonymous tier, except that it
// has a name and may claim vanity names.
bare := &Token{Name: "bare", Hash: HashSecret("bare"), AllowVanity: true}
got := bare.Limits(c)
want := Anonymous(c)
want.Name, want.AllowVanity = "bare", true
if got != want {
t.Errorf("bare token limits = %+v, want %+v", got, want)
}
// Overrides win, including "unlimited".
size, expiry := "8GiB", "never"
rich := &Token{Name: "rich", Hash: HashSecret("rich"), MaxSize: &size, MaxExpiry: &expiry}
if err := rich.resolve(); err != nil {
t.Fatal(err)
}
l := rich.Limits(c)
if l.MaxSize != 8<<30 {
t.Errorf("MaxSize = %d, want 8GiB", l.MaxSize)
}
if l.MaxExpiry != config.Unlimited {
t.Errorf("MaxExpiry = %s, want unlimited", l.MaxExpiry)
}
// The inherited 3d default is still fine under an unlimited maximum.
if l.DefaultExpiry != c.DefaultExpiry {
t.Errorf("DefaultExpiry = %s, want the inherited %s", l.DefaultExpiry, c.DefaultExpiry)
}
}
func TestDefaultExpiryIsClampedToTheMaximum(t *testing.T) {
c := defaults()
short := "1h"
// A token that narrows its maximum below the inherited default must not
// end up handing out the longer inherited lifetime.
tok := &Token{Name: "short", Hash: HashSecret("short"), MaxExpiry: &short}
if err := tok.resolve(); err != nil {
t.Fatal(err)
}
if l := tok.Limits(c); l.DefaultExpiry != time.Hour {
t.Errorf("DefaultExpiry = %s, want it clamped to 1h", l.DefaultExpiry)
}
}
func TestMalformedTokenFileIsRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tokens.json")
for _, body := range []string{
`[{"name":"a","hash":"not-hex"}]`,
`[{"name":"","hash":"` + HashSecret("s") + `"}]`,
`[{"name":"a","hash":"` + HashSecret("s") + `","max_size":"lots"}]`,
`[{"name":"a","hash":"` + HashSecret("s") + `","max_expiry":"soon"}]`,
`[{"name":"a","hash":"` + HashSecret("1") + `"},{"name":"a","hash":"` + HashSecret("2") + `"}]`,
`not json`,
} {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Errorf("accepted a malformed token file: %s", body)
}
}
}
func TestReloadPicksUpChanges(t *testing.T) {
f := newFile(t)
secret := "aaaa"
if err := f.Add(&Token{Name: "a", Hash: HashSecret(secret)}); err != nil {
t.Fatal(err)
}
// Simulate an edit by another process.
body := `[{"name":"b","hash":"` + HashSecret("bbbb") + `","allow_vanity":true}]`
if err := os.WriteFile(f.Path(), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
// Ensure the mtime actually differs on filesystems with coarse timestamps.
future := time.Now().Add(time.Second)
os.Chtimes(f.Path(), future, future)
if err := f.MaybeReload(); err != nil {
t.Fatal(err)
}
if f.Lookup(secret) != nil {
t.Error("a removed token still authenticates after a reload")
}
if tok := f.Lookup("bbbb"); tok == nil || !tok.AllowVanity {
t.Error("the newly written token was not picked up")
}
}
+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()
}
}
+79
View File
@@ -0,0 +1,79 @@
package server
import (
"net/http"
"strings"
"send/internal/auth"
"send/internal/store"
)
// handleDelete removes an object early. Three credentials are accepted: the
// delete token handed to the uploader, the token that owns the object, and any
// admin token.
//
// There is only one delete route, and it is a POST, so the success page's plain
// form works with scripting disabled and no second code path is needed.
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := store.CleanID(r.PathValue("id"))
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
m, err := s.store.Get(id, s.now())
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
secret := bearer(r)
if secret == "" {
// A small form post; the 4 KiB cap keeps this from being a way to
// stream a body into memory.
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
if err := r.ParseForm(); err == nil {
secret = strings.TrimSpace(r.PostFormValue("token"))
}
}
if secret == "" {
s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.")
return
}
if !s.mayDelete(m, secret) {
s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.")
return
}
if err := s.store.Delete(id); err != nil {
s.log.Error("deleting object", "id", id, "err", err)
s.fail(w, r, http.StatusInternalServerError, "Could not delete the file.")
return
}
s.log.Info("deleted", "id", id, "ip", clientIP(r, s.cfg))
if wantsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
return
}
s.render(w, http.StatusOK, "error.html", errorPage{
page: s.page("Deleted", false),
Status: "Deleted",
Message: "The file is gone.",
})
}
// mayDelete checks the presented secret against the object's delete token
// first, then against the token file.
func (s *Server) mayDelete(m *store.Meta, secret string) bool {
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
return true
}
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
t := s.tokens.Lookup(secret)
if t == nil {
return false
}
return t.Admin || (m.Owner != "" && t.Name == m.Owner)
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !unix
package server
// freeBytes has no portable implementation; the byte quota still applies.
func freeBytes(string) (int64, bool) { return 0, false }
+15
View File
@@ -0,0 +1,15 @@
//go:build unix
package server
import "syscall"
// freeBytes reports the space available to an unprivileged writer on the
// filesystem holding path.
func freeBytes(path string) (int64, bool) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, false
}
return int64(st.Bavail) * int64(st.Bsize), true
}
+102
View File
@@ -0,0 +1,102 @@
package server
import (
"net/http"
"strings"
"send/internal/store"
)
// downloadCSP is as inert as a policy gets. Combined with the attachment
// disposition and nosniff, an uploaded HTML file cannot execute anything in
// this origin even if a browser were talked into rendering it.
const downloadCSP = "default-src 'none'; sandbox"
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
id, err := store.CleanID(r.PathValue("id"))
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
// Expiry is checked here, on every read, not just by the sweeper.
m, f, err := s.store.OpenBlob(id, s.now())
if err != nil {
// Missing and expired are answered identically, so the response says
// nothing about what used to exist.
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
defer f.Close()
h := w.Header()
// Set explicitly, which also stops ServeContent from sniffing the content.
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", contentDisposition(m.Filename))
h.Set("Content-Security-Policy", downloadCSP)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cache-Control", "private, no-transform, max-age=0, must-revalidate")
h.Set("ETag", `"`+m.SHA256+`"`)
// ServeContent brings Range, If-Range and If-None-Match with it, which is
// what makes a half-finished 400 MB download resumable. The empty name
// keeps it from guessing a type from the extension.
http.ServeContent(w, r, "", m.Created, f)
}
// contentDisposition builds an attachment header that is safe by construction.
//
// The ASCII form is built from a character whitelist, so no quote, backslash or
// control character can reach the header regardless of what was uploaded. The
// RFC 5987 form carries the real name for anything that survived that filter.
func contentDisposition(name string) string {
ascii := asciiFilename(name)
d := `attachment; filename="` + ascii + `"`
if ascii != name {
d += "; filename*=UTF-8''" + encodeRFC5987(name)
}
return d
}
// asciiFilename reduces a name to printable ASCII minus the characters that
// would need quoting.
func asciiFilename(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r < 0x20 || r > 0x7e, r == '"', r == '\\':
b.WriteByte('_')
default:
b.WriteRune(r)
}
}
out := b.String()
if strings.Trim(out, "_. ") == "" {
return "download.bin"
}
return out
}
// encodeRFC5987 percent-encodes everything outside the attr-char set of
// RFC 5987, which is what the filename* parameter requires.
//
// The loop is over bytes, and each escaped byte is written as hex directly:
// url.PathEscape would widen a byte to a rune first and so encode UTF-8 twice,
// and it leaves several characters unescaped that attr-char does not allow.
func encodeRFC5987(s string) string {
const attrChars = "!#$&+-.^_`|~"
const hexDigits = "0123456789ABCDEF"
var b strings.Builder
for i := range len(s) {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
strings.IndexByte(attrChars, c) >= 0:
b.WriteByte(c)
default:
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
}
}
return b.String()
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"mime"
"strings"
"testing"
)
// Content-Disposition is the header that keeps an uploaded file from being
// rendered, so it has to survive whatever a filename throws at it.
func TestContentDisposition(t *testing.T) {
cases := []struct {
name string
wantASCII string
wantExt bool // expect an RFC 5987 filename* parameter too
}{
{"MyGame.zip", "MyGame.zip", false},
{"notes (draft).txt", "notes (draft).txt", false},
{`quo"te.txt`, "quo_te.txt", true},
{`back\slash.txt`, "back_slash.txt", true},
{"naïve.txt", "na_ve.txt", true},
{"日本語.bin", "___.bin", true},
{"🙂.png", "_.png", true},
}
for _, c := range cases {
got := contentDisposition(c.name)
// It must always parse, and always be an attachment.
disp, params, err := mime.ParseMediaType(got)
if err != nil {
t.Errorf("contentDisposition(%q) = %q: does not parse: %v", c.name, got, err)
continue
}
if disp != "attachment" {
t.Errorf("contentDisposition(%q): disposition = %q", c.name, disp)
}
// Whatever the encoding, the decoded filename must be the real one when
// an extended parameter is present.
if c.wantExt {
if !strings.Contains(got, "filename*=UTF-8''") {
t.Errorf("contentDisposition(%q) = %q: no RFC 5987 parameter", c.name, got)
}
if params["filename"] != c.name {
t.Errorf("contentDisposition(%q): decoded filename = %q", c.name, params["filename"])
}
} else if params["filename"] != c.wantASCII {
t.Errorf("contentDisposition(%q): filename = %q, want %q", c.name, params["filename"], c.wantASCII)
}
// Nothing may break out of the header, in any spelling.
if strings.ContainsAny(got, "\r\n") {
t.Errorf("contentDisposition(%q) = %q: contains a line break", c.name, got)
}
if i := strings.Index(got, `filename="`); i >= 0 {
quoted := got[i+len(`filename="`):]
quoted = quoted[:strings.Index(quoted, `"`)]
if strings.ContainsAny(quoted, `"\`) {
t.Errorf("contentDisposition(%q): unescaped quoting in %q", c.name, quoted)
}
}
}
}
func TestASCIIFilenameNeverEmpty(t *testing.T) {
for _, in := range []string{"", "...", "___", " ", "🙂"} {
if got := asciiFilename(in); strings.Trim(got, "_. ") == "" {
t.Errorf("asciiFilename(%q) = %q, which names nothing", in, got)
}
}
}
func TestEncodeRFC5987(t *testing.T) {
cases := map[string]string{
"plain.txt": "plain.txt",
"a b.txt": "a%20b.txt",
"naïve": "na%C3%AFve",
`q"x`: "q%22x",
}
for in, want := range cases {
if got := encodeRFC5987(in); got != want {
t.Errorf("encodeRFC5987(%q) = %q, want %q", in, got, want)
}
}
}
+141
View File
@@ -0,0 +1,141 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"time"
"send/internal/auth"
"send/internal/config"
"send/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
b, err := json.Marshal(v)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(append(b, '\n'))
}
// absBase is the absolute URL of this service, for links and examples.
//
// It prefers --public-url. Falling back to the request's Host is display-only:
// the header is caller-controlled, so it is never used for anything a third
// party would see.
func (s *Server) absBase(r *http.Request) string {
if s.cfg.PublicURL != "" {
return s.cfg.PublicURL + s.cfg.BasePath
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host + s.cfg.BasePath
}
// objectURL builds the download link handed back to an uploader.
func (s *Server) objectURL(r *http.Request, id string) string {
return s.absBase(r) + "d/" + id
}
type indexPage struct {
page
MaxSize string
MaxExpiry string
DefaultExpiry string
AbsBase string
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// The page always renders the anonymous tier; the script refreshes it from
// /api/limits once a token is entered.
lim := auth.Anonymous(s.cfg)
s.render(w, http.StatusOK, "index.html", indexPage{
page: s.page("Upload", true),
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AbsBase: s.absBase(r),
})
}
type limitsJSON struct {
Name string `json:"name"`
MaxSize *int64 `json:"max_size"` // null means unlimited
MaxExpiry string `json:"max_expiry"`
DefaultExpiry string `json:"default_expiry"`
AllowVanity bool `json:"allow_vanity"`
}
// handleLimits reports the permissions belonging to the presented credential,
// so the page can show what the caller may actually do.
func (s *Server) handleLimits(w http.ResponseWriter, r *http.Request) {
// Rate-limited like an upload: this is the one endpoint that reports
// whether a token is valid, and it should not be a free oracle.
if !s.limiter.allow(clientIP(r, s.cfg), s.now()) {
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
return
}
lim, err := s.limitsFor(bearer(r))
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
}
out := limitsJSON{
Name: lim.Name,
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AllowVanity: lim.AllowVanity,
}
if lim.MaxSize != config.Unlimited {
n := lim.MaxSize
out.MaxSize = &n
}
writeJSON(w, http.StatusOK, out)
}
type objectPage struct {
page
Meta *store.Meta
Size string
Expires string
URL string
DeleteToken string
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
id, err := store.CleanID(r.PathValue("id"))
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
m, err := s.store.Get(id, s.now())
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
s.render(w, http.StatusOK, "info.html", objectPage{
page: s.page(m.Filename, false),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
})
}
// describeExpiry renders a deadline as an absolute time plus how far off it is.
func describeExpiry(t *time.Time, now time.Time) string {
if t == nil {
return "never"
}
d := t.Sub(now).Round(time.Minute)
if d < 0 {
return "expired"
}
return fmt.Sprintf("%s (in %s)", t.UTC().Format("2006-01-02 15:04 MST"),
config.FormatDuration(d))
}
+101
View File
@@ -0,0 +1,101 @@
package server
import (
"net"
"net/http"
"strings"
"sync"
"time"
"send/internal/config"
)
// limiter is a per-key token bucket. It is deliberately small: there is no
// dependency to pull in for this, and uploads are the only thing worth limiting.
type limiter struct {
rate float64 // tokens per second
burst float64
mu sync.Mutex
buckets map[string]*bucket
lastGC time.Time
}
type bucket struct {
tokens float64
last time.Time
}
func newLimiter(perHour, burst int) *limiter {
if burst < 1 {
burst = 1
}
return &limiter{
rate: float64(perHour) / 3600,
burst: float64(burst),
buckets: map[string]*bucket{},
}
}
// allow consumes a token for key, reporting whether one was available.
func (l *limiter) allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
b.tokens = min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.rate)
b.last = now
l.gcLocked(now)
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// gcLocked drops buckets that have refilled completely and so carry no state.
func (l *limiter) gcLocked(now time.Time) {
if now.Sub(l.lastGC) < 10*time.Minute {
return
}
l.lastGC = now
for k, b := range l.buckets {
if b.tokens >= l.burst && now.Sub(b.last) > time.Hour {
delete(l.buckets, k)
}
}
}
// clientIP identifies the caller for rate-limiting and logging.
//
// X-Forwarded-For is trusted only when the immediate peer is a configured
// proxy, and then only to skip past further trusted hops: the first
// untrusted address from the right is the real client. Without this the header
// is pure client input and rate limiting would be trivially bypassed.
func clientIP(r *http.Request, c *config.Config) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer := net.ParseIP(host)
if peer == nil || !c.TrustsProxy(peer) {
return host
}
hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(hops) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(hops[i]))
if ip == nil {
break // malformed: stop trusting the rest of the chain
}
if !c.TrustsProxy(ip) {
return ip.String()
}
}
return host
}
+226
View File
@@ -0,0 +1,226 @@
// Package server wires the HTTP surface onto the store and the token file.
package server
import (
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"strings"
"time"
"send/internal/auth"
"send/internal/config"
"send/internal/store"
"send/web"
)
// Server holds everything the handlers need. It is safe for concurrent use.
type Server struct {
cfg *config.Config
store *store.Store
tokens *auth.File
log *slog.Logger
pages map[string]*template.Template
handler http.Handler
limiter *limiter
slots chan struct{} // bounds uploads in flight
now func() time.Time // swappable in tests
}
func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logger) (*Server, error) {
pages, err := parsePages()
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg,
store: st,
tokens: tokens,
log: log,
pages: pages,
limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst),
slots: make(chan struct{}, cfg.MaxConcurrent),
now: time.Now,
}
s.handler = s.routes()
return s, nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handler.ServeHTTP(w, r) }
// routes builds the mux and mounts it under the configured base path.
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /api/upload", s.handleUpload)
mux.HandleFunc("GET /api/limits", s.handleLimits)
mux.HandleFunc("GET /d/{id}", s.handleDownload)
mux.HandleFunc("GET /i/{id}", s.handleInfo)
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
mux.HandleFunc("/", s.handleNotFound)
var h http.Handler = mux
h = s.securityHeaders(h)
if s.cfg.BasePath == "/" {
return h
}
// Mounted under a prefix: strip it, and send a bare prefix to the slashed
// form so relative links on the page resolve correctly.
prefix := strings.TrimSuffix(s.cfg.BasePath, "/")
outer := http.NewServeMux()
outer.Handle(s.cfg.BasePath, http.StripPrefix(prefix, h))
outer.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.cfg.BasePath, http.StatusMovedPermanently)
})
return outer
}
// staticHandler serves the embedded assets with a long, immutable-ish cache
// window kept short enough that an edit shows up without a cache-buster.
func (s *Server) staticHandler() http.Handler {
fileServer := http.FileServerFS(web.Static())
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=300")
fileServer.ServeHTTP(w, r)
})
}
// appCSP locks the application pages down to their own origin. The frontend has
// no inline script and no third-party anything, so this can be strict.
const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
// The download handler replaces this with a far stricter policy.
h.Set("Content-Security-Policy", appCSP)
next.ServeHTTP(w, r)
})
}
// --- credentials ---------------------------------------------------------
// errBadToken is returned when a credential is presented but not recognised.
// Presenting a wrong token fails the request rather than silently downgrading
// the caller to the anonymous tier, where a lower limit would be confusing.
var errBadToken = errors.New("unrecognised token")
// limitsFor resolves the effective permissions for a presented secret. An empty
// secret yields the anonymous tier.
func (s *Server) limitsFor(secret string) (auth.Limits, error) {
if secret == "" {
return auth.Anonymous(s.cfg), nil
}
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
t := s.tokens.Lookup(secret)
if t == nil {
return auth.Limits{}, errBadToken
}
return t.Limits(s.cfg), nil
}
// bearer extracts a token from the Authorization header, if present.
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
if v, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(v)
}
return ""
}
// --- rendering -----------------------------------------------------------
var pageNames = []string{"index.html", "result.html", "info.html", "error.html"}
// parsePages pairs each page with the shared layout. They cannot all be parsed
// into one template set because every page defines "content".
func parsePages() (map[string]*template.Template, error) {
pages := make(map[string]*template.Template, len(pageNames))
for _, name := range pageNames {
t, err := template.New(name).ParseFS(web.Templates(),
"templates/layout.html", "templates/"+name)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", name, err)
}
pages[name] = t
}
return pages, nil
}
// page carries the fields every template needs. Page-specific structs embed it.
type page struct {
Base string
Title string
Script bool
}
func (s *Server) page(title string, script bool) page {
return page{Base: s.cfg.BasePath, Title: title, Script: script}
}
func (s *Server) render(w http.ResponseWriter, status int, name string, data any) {
t, ok := s.pages[name]
if !ok {
http.Error(w, "template missing", http.StatusInternalServerError)
return
}
// Render to memory first so a template failure cannot emit a half page
// after the status line has already gone out.
var buf strings.Builder
if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
s.log.Error("rendering page", "page", name, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
io.WriteString(w, buf.String())
}
// --- errors --------------------------------------------------------------
// wantsJSON decides the response shape. The JS client asks for JSON explicitly;
// a plain form post from a browser leads with text/html.
func wantsJSON(r *http.Request) bool {
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/json") {
return true
}
return !strings.Contains(accept, "text/html")
}
type errorPage struct {
page
Status string
Message string
}
// fail writes an error in whichever shape the caller asked for.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg string) {
if wantsJSON(r) {
writeJSON(w, status, map[string]string{"error": msg})
return
}
s.render(w, status, "error.html", errorPage{
page: s.page(http.StatusText(status), false),
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Message: msg,
})
}
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, http.StatusNotFound, "No such page.")
}
+649
View File
@@ -0,0 +1,649 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"send/internal/auth"
"send/internal/config"
"send/internal/store"
)
// clock is the fixed instant tests start from; s.now is swapped so expiry can
// be exercised without sleeping.
var clock = time.Date(2026, 9, 12, 10, 0, 0, 0, time.UTC)
type harness struct {
*Server
ts *httptest.Server
dir string
now time.Time
token string // a token allowing vanity names
admin string
}
func newHarness(t *testing.T, tweak func(*config.Config)) *harness {
t.Helper()
dir := t.TempDir()
cfg := config.Config{}
fs := config.NewSet("test", "SEND_TEST_")
cfg.Register(fs)
if err := fs.Parse(nil); err != nil {
t.Fatal(err)
}
cfg.DataDir = dir
cfg.MaxSize = 1 << 20
cfg.MaxExpiry = 72 * time.Hour
cfg.DefaultExpiry = 72 * time.Hour
cfg.MinFreeBytes = 0
cfg.UploadRate = 100000
cfg.UploadBurst = 100000
if tweak != nil {
tweak(&cfg)
}
if err := cfg.Normalise(); err != nil {
t.Fatal(err)
}
st, err := store.Open(cfg.DataDir)
if err != nil {
t.Fatal(err)
}
tokens, err := auth.Load(cfg.TokensPath)
if err != nil {
t.Fatal(err)
}
h := &harness{dir: dir, now: clock}
for _, spec := range []struct {
name string
admin bool
dst *string
}{{"friend", false, &h.token}, {"boss", true, &h.admin}} {
secret, err := store.NewSecret()
if err != nil {
t.Fatal(err)
}
if err := tokens.Add(&auth.Token{
Name: spec.name, Hash: auth.HashSecret(secret),
AllowVanity: true, Admin: spec.admin,
}); err != nil {
t.Fatal(err)
}
*spec.dst = secret
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv, err := New(&cfg, st, tokens, log)
if err != nil {
t.Fatal(err)
}
srv.now = func() time.Time { return h.now }
h.Server = srv
h.ts = httptest.NewServer(srv)
t.Cleanup(h.ts.Close)
return h
}
// upload posts a raw body, the way curl does.
func (h *harness) upload(t *testing.T, body []byte, headers map[string]string) *http.Response {
t.Helper()
return h.uploadReader(t, bytes.NewReader(body), headers)
}
func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]string) *http.Response {
t.Helper()
req, err := http.NewRequest("POST", h.ts.URL+"/api/upload", body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func decode[T any](t *testing.T, resp *http.Response) T {
t.Helper()
defer resp.Body.Close()
var v T
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
t.Fatalf("decoding %s response: %v", resp.Status, err)
}
return v
}
func TestRoundTrip(t *testing.T) {
h := newHarness(t, nil)
payload := bytes.Repeat([]byte("godot"), 4096)
resp := h.upload(t, payload, map[string]string{
"Content-Disposition": `attachment; filename="MyGame.zip"`,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("upload status = %s", resp.Status)
}
res := decode[uploadResult](t, resp)
if res.Filename != "MyGame.zip" {
t.Errorf("filename = %q, want MyGame.zip", res.Filename)
}
if res.Size != int64(len(payload)) {
t.Errorf("size = %d, want %d", res.Size, len(payload))
}
if res.DeleteToken == "" {
t.Error("no delete token returned")
}
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
if err != nil {
t.Fatal(err)
}
defer get.Body.Close()
got, _ := io.ReadAll(get.Body)
if !bytes.Equal(got, payload) {
t.Errorf("downloaded %d bytes, want %d", len(got), len(payload))
}
// An uploaded file must never come back as something a browser will run.
if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q", ct)
}
if get.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Error("missing nosniff")
}
if !strings.Contains(get.Header.Get("Content-Security-Policy"), "sandbox") {
t.Errorf("CSP = %q", get.Header.Get("Content-Security-Policy"))
}
disp, params, err := mime.ParseMediaType(get.Header.Get("Content-Disposition"))
if err != nil || disp != "attachment" || params["filename"] != "MyGame.zip" {
t.Errorf("Content-Disposition = %q (%v)", get.Header.Get("Content-Disposition"), err)
}
}
func TestHTMLUploadIsServedInert(t *testing.T) {
h := newHarness(t, nil)
resp := h.upload(t, []byte("<script>alert(1)</script>"), map[string]string{
"Content-Disposition": `attachment; filename="evil.html"`,
})
res := decode[uploadResult](t, resp)
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
if err != nil {
t.Fatal(err)
}
defer get.Body.Close()
if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("HTML served as %q; it must never be text/html", ct)
}
if !strings.HasPrefix(get.Header.Get("Content-Disposition"), "attachment") {
t.Error("HTML was not served as an attachment")
}
}
func TestRangeRequest(t *testing.T) {
h := newHarness(t, nil)
payload := bytes.Repeat([]byte("abcdefgh"), 1024)
res := decode[uploadResult](t, h.upload(t, payload, nil))
req, _ := http.NewRequest("GET", h.ts.URL+"/d/"+res.ID, nil)
req.Header.Set("Range", "bytes=0-1023")
get, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer get.Body.Close()
if get.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %s, want 206", get.Status)
}
body, _ := io.ReadAll(get.Body)
if len(body) != 1024 || !bytes.Equal(body, payload[:1024]) {
t.Errorf("got %d bytes, want the first 1024", len(body))
}
if cr := get.Header.Get("Content-Range"); cr != fmt.Sprintf("bytes 0-1023/%d", len(payload)) {
t.Errorf("Content-Range = %q", cr)
}
}
// An oversized body must be refused on bytes actually written, never on a
// declared length. This sends a chunked body, so there is no Content-Length to
// consult even if the code wanted to.
func TestOversizeChunkedUploadIsRefused(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.MaxSize = 4096 })
// A plain io.Reader (not a *bytes.Buffer) makes the client use chunked
// encoding with no declared length.
body := io.LimitReader(zeroes{}, 1<<20)
resp := h.uploadReader(t, struct{ io.Reader }{body}, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %s, want 413", resp.Status)
}
if n := h.store.Count(); n != 0 {
t.Errorf("%d objects stored after a refused upload", n)
}
assertNoDebris(t, h.dir)
}
// A body one byte over the cap is refused; exactly at the cap is accepted.
func TestSizeLimitBoundary(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.MaxSize = 1000 })
resp := h.upload(t, bytes.Repeat([]byte("x"), 1000), nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("exactly at the limit: status = %s, want 201", resp.Status)
}
resp.Body.Close()
resp = h.upload(t, bytes.Repeat([]byte("x"), 1001), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("one byte over: status = %s, want 413", resp.Status)
}
}
type zeroes struct{}
func (zeroes) Read(p []byte) (int, error) { return len(p), nil }
func TestAnonymousCannotClaimVanity(t *testing.T) {
h := newHarness(t, nil)
resp := h.upload(t, []byte("hi"), map[string]string{"Vanity": "my-file"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %s, want 403", resp.Status)
}
if h.store.Exists("my-file") {
t.Error("the name was claimed despite the refusal")
}
}
func TestVanityCollision(t *testing.T) {
h := newHarness(t, nil)
hdr := map[string]string{
"Vanity": "my-file",
"Authorization": "Bearer " + h.token,
}
resp := h.upload(t, []byte("first"), hdr)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("first upload: status = %s", resp.Status)
}
resp.Body.Close()
resp = h.upload(t, []byte("second"), hdr)
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("second upload: status = %s, want 409", resp.Status)
}
// The first object must be untouched.
get, err := h.ts.Client().Get(h.ts.URL + "/d/my-file")
if err != nil {
t.Fatal(err)
}
defer get.Body.Close()
body, _ := io.ReadAll(get.Body)
if string(body) != "first" {
t.Errorf("content = %q, want %q", body, "first")
}
}
func TestUnknownTokenIsRejected(t *testing.T) {
h := newHarness(t, nil)
resp := h.upload(t, []byte("hi"), map[string]string{"Authorization": "Bearer nope"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %s, want 401", resp.Status)
}
}
// Expiry is enforced on read, not only by the sweeper, which never runs here.
func TestExpiryIsCheckedOnRead(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("ephemeral"), map[string]string{
"Expiry": "1h",
}))
status := func(when time.Duration) int {
h.now = clock.Add(when)
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
if err != nil {
t.Fatal(err)
}
get.Body.Close()
return get.StatusCode
}
if got := status(59 * time.Minute); got != http.StatusOK {
t.Fatalf("before expiry: status = %d, want 200", got)
}
if got := status(61 * time.Minute); got != http.StatusNotFound {
t.Fatalf("after expiry: status = %d, want 404", got)
}
// The read path also reclaims the space.
if _, err := os.Stat(filepath.Join(h.dir, "objects", res.ID)); !os.IsNotExist(err) {
t.Error("expired object was not removed on read")
}
}
func TestExpiryBeyondLimitIsRefused(t *testing.T) {
h := newHarness(t, nil) // max 72h for anonymous
for _, req := range []string{"30d", "never"} {
resp := h.upload(t, []byte("hi"), map[string]string{"Expiry": req})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expiry: %s => status %s, want 400", req, resp.Status)
}
resp.Body.Close()
}
}
func TestTokenMayOutliveTheAnonymousLimit(t *testing.T) {
h := newHarness(t, nil)
forever := "never"
if err := h.tokens.Remove("friend"); err != nil {
t.Fatal(err)
}
secret, _ := store.NewSecret()
if err := h.tokens.Add(&auth.Token{
Name: "friend", Hash: auth.HashSecret(secret),
MaxExpiry: &forever, DefaultExpiry: &forever, AllowVanity: true,
}); err != nil {
t.Fatal(err)
}
res := decode[uploadResult](t, h.upload(t, []byte("keep me"), map[string]string{
"Authorization": "Bearer " + secret,
"Expiry": "never",
}))
if res.Expires != "" {
t.Errorf("expires = %q, want empty (never)", res.Expires)
}
}
func TestPathTraversalIsRejected(t *testing.T) {
h := newHarness(t, nil)
// As a requested vanity name.
for _, name := range []string{"../etc/passwd", "..", ".", "/absolute", "a/b", `a\b`, "ok..name"} {
resp := h.upload(t, []byte("x"), map[string]string{
"Vanity": name,
"Authorization": "Bearer " + h.token,
})
if resp.StatusCode == http.StatusCreated {
t.Errorf("vanity %q was accepted", name)
}
resp.Body.Close()
}
// As a download path. Some of these are normalised away into a redirect to
// the index, which is harmless; what matters is that no stored bytes are
// ever served, so the check is for an object response rather than a status.
for _, path := range []string{
"/d/..%2f..%2fetc%2fpasswd", "/d/.", "/d/..", "/d/%2e%2e",
"/d/../tokens.json", "/d/%2e%2e%2ftokens.json", "/d/objects",
} {
get, err := h.ts.Client().Get(h.ts.URL + path)
if err != nil {
continue // the client itself may refuse to send it, which is fine
}
get.Body.Close()
if get.Header.Get("Content-Disposition") != "" {
t.Errorf("GET %s served an object", path)
}
}
}
func TestReservedNamesAreRejected(t *testing.T) {
h := newHarness(t, nil)
for _, name := range []string{"api", "static", "d", "i", "robots.txt"} {
resp := h.upload(t, []byte("x"), map[string]string{
"Vanity": name,
"Authorization": "Bearer " + h.token,
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("vanity %q => %s, want 400", name, resp.Status)
}
resp.Body.Close()
}
}
func TestDeleteRequiresTheRightToken(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("delete me"), nil))
del := func(token string) int {
form := strings.NewReader("token=" + token)
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
if got := del("wrong-token"); got != http.StatusForbidden {
t.Errorf("wrong token => %d, want 403", got)
}
if got := del(h.token); got != http.StatusForbidden {
t.Errorf("a non-owning, non-admin token => %d, want 403", got)
}
if got := del(res.DeleteToken); got != http.StatusOK {
t.Errorf("correct token => %d, want 200", got)
}
// The token is single-use because the object it names is gone.
if got := del(res.DeleteToken); got != http.StatusNotFound {
t.Errorf("reused token => %d, want 404", got)
}
assertNoDebris(t, h.dir)
}
func TestAdminMayDeleteAnything(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", nil)
req.Header.Set("Authorization", "Bearer "+h.admin)
req.Header.Set("Accept", "application/json")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin delete => %s, want 200", resp.Status)
}
}
func TestMultipartUpload(t *testing.T) {
h := newHarness(t, nil)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
// Order matters: the server needs these before the file part arrives.
mw.WriteField("token", h.token)
mw.WriteField("expiry", "2h")
mw.WriteField("vanity", "from-the-form")
fw, err := mw.CreateFormFile("file", "notes (draft).txt")
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("hello from a browser"))
mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %s", resp.Status)
}
res := decode[uploadResult](t, resp)
if res.ID != "from-the-form" {
t.Errorf("id = %q, want from-the-form", res.ID)
}
if res.Filename != "notes (draft).txt" {
t.Errorf("filename = %q", res.Filename)
}
}
// A form post with no Accept: application/json gets the HTML success page, so
// the no-JS path works.
func TestFormPostRendersHTML(t *testing.T) {
h := newHarness(t, nil)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, _ := mw.CreateFormFile("file", "thing.bin")
fw.Write([]byte("data"))
mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("Content-Type = %q, want HTML", ct)
}
page, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(page), "Delete token") {
t.Error("the success page does not show the delete token")
}
}
func TestBasePathMounting(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.BasePath = "/send" })
get, err := h.ts.Client().Get(h.ts.URL + "/send/")
if err != nil {
t.Fatal(err)
}
defer get.Body.Close()
if get.StatusCode != http.StatusOK {
t.Fatalf("GET /send/ => %s", get.Status)
}
page, _ := io.ReadAll(get.Body)
if !strings.Contains(string(page), `href="/send/static/style.css"`) {
t.Error("page links do not carry the base path")
}
// The bare prefix redirects to the slashed form.
noRedirect := *h.ts.Client()
noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := noRedirect.Get(h.ts.URL + "/send")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Errorf("GET /send => %s, want 301", resp.Status)
}
}
// An upload that dies mid-flight must leave nothing visible behind, and the
// sweeper must eventually reclaim the directory.
func TestAbandonedUploadIsInvisibleAndSwept(t *testing.T) {
h := newHarness(t, nil)
up, err := h.store.Reserve("half-done")
if err != nil {
t.Fatal(err)
}
up.Write([]byte("partial"))
// Deliberately no Commit and no Abort: this is what a killed process leaves.
get, err := h.ts.Client().Get(h.ts.URL + "/d/half-done")
if err != nil {
t.Fatal(err)
}
get.Body.Close()
if get.StatusCode != http.StatusNotFound {
t.Errorf("an uncommitted object was visible: %s", get.Status)
}
dir := filepath.Join(h.dir, "objects", "half-done")
old := clock.Add(-48 * time.Hour)
if err := os.Chtimes(dir, old, old); err != nil {
t.Fatal(err)
}
h.store.Sweep(clock)
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("abandoned upload directory was not swept")
}
}
func TestQuotaRefusesUploads(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.MaxTotalBytes = 100 })
resp := h.upload(t, bytes.Repeat([]byte("x"), 80), nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("first upload => %s", resp.Status)
}
resp.Body.Close()
// Only 20 bytes of quota remain, so this is truncated to the remainder and
// refused rather than allowed to overshoot.
resp = h.upload(t, bytes.Repeat([]byte("x"), 80), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("over quota => %s, want 413", resp.Status)
}
}
func TestRateLimit(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.UploadRate = 1
c.UploadBurst = 2
})
var last *http.Response
for range 3 {
if last != nil {
last.Body.Close()
}
last = h.upload(t, []byte("x"), nil)
}
defer last.Body.Close()
if last.StatusCode != http.StatusTooManyRequests {
t.Fatalf("third upload => %s, want 429", last.Status)
}
}
// assertNoDebris checks that no object directory was left behind.
func assertNoDebris(t *testing.T, dir string) {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, "objects"))
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
t.Errorf("leftover object directory: %s", e.Name())
}
}
+400
View File
@@ -0,0 +1,400 @@
package server
import (
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"strings"
"time"
"send/internal/auth"
"send/internal/config"
"send/internal/store"
)
const (
// maxFieldBytes and maxFieldCount bound the non-file portion of a multipart
// body. The file part needs no such bound: the store's own limit stops it
// at exactly the caller's cap.
maxFieldBytes = 4 << 10
maxFieldCount = 16
fileFieldName = "file"
// stallTimeout is how long a single read from the body may take. It is
// reset on every successful read, so a slow upload is fine and a stalled
// one is not.
stallTimeout = 2 * time.Minute
)
var errFieldTooLarge = errors.New("form field is too large")
// uploadRequest is the set of knobs a caller may turn, however they arrived.
type uploadRequest struct {
token string
vanity string
expiry string
filename string
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r, s.cfg)
if !s.limiter.allow(ip, s.now()) {
s.fail(w, r, http.StatusTooManyRequests, "Too many uploads; try again shortly.")
return
}
// Bound concurrency so a handful of multi-gigabyte uploads cannot starve
// the disk or the machine.
select {
case s.slots <- struct{}{}:
defer func() { <-s.slots }()
default:
w.Header().Set("Retry-After", "30")
s.fail(w, r, http.StatusServiceUnavailable, "Too many uploads in flight; try again shortly.")
return
}
mediatype, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err == nil && mediatype == "multipart/form-data" {
s.uploadMultipart(w, r, params["boundary"], ip)
return
}
s.uploadRaw(w, r, ip)
}
// uploadRaw handles a body that is nothing but the file, as sent by curl.
// Options ride along in headers.
func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) {
req := uploadRequest{
token: bearer(r),
vanity: strings.TrimSpace(r.Header.Get("Vanity")),
expiry: strings.TrimSpace(r.Header.Get("Expiry")),
filename: filenameFromDisposition(r.Header.Get("Content-Disposition")),
}
s.storeUpload(w, r, req, r.Body, ip)
}
// uploadMultipart streams a browser form post.
//
// The body is read with multipart.Reader rather than ParseMultipartForm: the
// latter spools the whole upload into its own temporary files with its own
// limits, which for a 2 GiB body is exactly what we are trying to avoid. The
// consequence is that fields must arrive before the file part, since the limits
// they select have to be known before the first byte of the file is accepted.
func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundary, ip string) {
if boundary == "" {
s.fail(w, r, http.StatusBadRequest, "Malformed multipart body: no boundary.")
return
}
mr := multipart.NewReader(r.Body, boundary)
req := uploadRequest{token: bearer(r)}
for n := 0; ; n++ {
if n > maxFieldCount {
s.fail(w, r, http.StatusBadRequest, "Too many form fields.")
return
}
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
s.fail(w, r, http.StatusBadRequest, "No file was included in the upload.")
return
}
if err != nil {
s.fail(w, r, http.StatusBadRequest, "Malformed multipart body.")
return
}
if part.FormName() == fileFieldName {
if req.filename == "" {
req.filename = part.FileName()
}
s.storeUpload(w, r, req, part, ip)
return
}
value, err := readField(part)
part.Close()
if err != nil {
s.fail(w, r, http.StatusBadRequest, "A form field was too large.")
return
}
switch part.FormName() {
case "token":
if value != "" {
req.token = value
}
case "vanity":
req.vanity = strings.TrimSpace(value)
case "expiry":
req.expiry = strings.TrimSpace(value)
case "filename":
req.filename = value
}
}
}
func readField(p *multipart.Part) (string, error) {
b, err := io.ReadAll(io.LimitReader(p, maxFieldBytes+1))
if err != nil {
return "", err
}
if len(b) > maxFieldBytes {
return "", errFieldTooLarge
}
return string(b), nil
}
// filenameFromDisposition reads a filename from a request-side
// Content-Disposition header. There is no standard for using the header this
// way, but it is the established convention, and mime.ParseMediaType already
// understands both the plain and the RFC 5987 encoded forms.
func filenameFromDisposition(h string) string {
if h == "" {
return ""
}
_, params, err := mime.ParseMediaType(h)
if err != nil {
return ""
}
return params["filename"]
}
// storeUpload is the common tail of both upload shapes: resolve the caller's
// limits, claim a name, stream the bytes, then publish.
func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadRequest, body io.Reader, ip string) {
now := s.now()
lim, err := s.limitsFor(req.token)
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
}
if req.vanity != "" && !lim.AllowVanity {
s.fail(w, r, http.StatusForbidden, "Custom names require a token.")
return
}
expires, err := resolveExpiry(req.expiry, lim, now)
if err != nil {
s.fail(w, r, http.StatusBadRequest, err.Error())
return
}
limit, err := s.capacity(lim.MaxSize)
if err != nil {
s.fail(w, r, http.StatusInsufficientStorage, err.Error())
return
}
// Claim the name before reading a single byte, so a taken vanity name
// fails instantly rather than after a multi-gigabyte transfer.
up, err := s.reserve(req.vanity)
switch {
case errors.Is(err, store.ErrExists):
s.fail(w, r, http.StatusConflict, "That name is already taken.")
return
case errors.Is(err, store.ErrBadID):
s.fail(w, r, http.StatusBadRequest,
"A custom name must be 2-64 characters of letters, digits, dot, dash or underscore.")
return
case err != nil:
s.log.Error("reserving object", "err", err)
s.fail(w, r, http.StatusInternalServerError, "Could not store the file.")
return
}
committed := false
defer func() {
if !committed {
up.Abort()
}
}()
up.SetLimit(limit)
if _, err := io.Copy(up, guardStalls(w, body)); err != nil {
switch {
case errors.Is(err, store.ErrTooLarge):
s.fail(w, r, http.StatusRequestEntityTooLarge,
fmt.Sprintf("That file is larger than the %s limit.", config.FormatSize(limit)))
default:
// A disconnect mid-upload lands here; there is rarely anyone left
// to read the response.
s.log.Info("upload aborted", "ip", ip, "id", up.ID(), "bytes", up.Size(), "err", err)
s.fail(w, r, http.StatusBadRequest, "The upload did not complete.")
}
return
}
clearDeadline(w)
secret, err := store.NewSecret()
if err != nil {
s.log.Error("generating delete token", "err", err)
s.fail(w, r, http.StatusInternalServerError, "Could not store the file.")
return
}
m := &store.Meta{
Filename: store.SanitizeFilename(req.filename),
Created: now,
Expires: expires,
Owner: lim.Name,
Vanity: req.vanity != "",
DeleteHash: auth.HashSecret(secret),
}
if err := up.Commit(m); err != nil {
s.log.Error("committing object", "id", up.ID(), "err", err)
s.fail(w, r, http.StatusInternalServerError, "Could not store the file.")
return
}
committed = true
s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name),
"ip", ip, "expires", m.Expires)
s.respondUploaded(w, r, m, secret)
}
func orAnonymous(name string) string {
if name == "" {
return "(anonymous)"
}
return name
}
// reserve claims either the requested vanity name or a fresh UUIDv4.
func (s *Server) reserve(vanity string) (*store.Upload, error) {
if vanity == "" {
return s.store.ReserveRandom()
}
id, err := store.CleanID(vanity)
if err != nil {
return nil, err
}
return s.store.Reserve(id)
}
// capacity narrows the caller's own limit to what the store can still hold.
func (s *Server) capacity(callerLimit int64) (int64, error) {
full := errors.New("The service is out of space; try again later.")
limit := callerLimit
if s.cfg.MaxTotalBytes != config.Unlimited {
remaining := s.cfg.MaxTotalBytes - s.store.Total()
if remaining <= 0 {
return 0, full
}
if limit == config.Unlimited || remaining < limit {
limit = remaining
}
}
if s.cfg.MinFreeBytes > 0 {
if free, ok := freeBytes(s.store.DataDir()); ok {
usable := free - s.cfg.MinFreeBytes
if usable <= 0 {
return 0, full
}
if limit == config.Unlimited || usable < limit {
limit = usable
}
}
}
return limit, nil
}
// resolveExpiry turns a requested lifetime into a deadline, refusing anything
// longer than the caller is entitled to.
func resolveExpiry(requested string, lim auth.Limits, now time.Time) (*time.Time, error) {
d := lim.DefaultExpiry
if requested != "" {
var err error
if d, err = config.ParseDuration(requested); err != nil {
return nil, fmt.Errorf("%s; try something like 3d, 12h or 90m", err)
}
}
if d == config.Unlimited {
if lim.MaxExpiry != config.Unlimited {
return nil, fmt.Errorf("files here cannot be kept indefinitely; the longest lifetime available to you is %s",
config.FormatDuration(lim.MaxExpiry))
}
return nil, nil
}
if lim.MaxExpiry != config.Unlimited && d > lim.MaxExpiry {
return nil, fmt.Errorf("the longest lifetime available to you is %s",
config.FormatDuration(lim.MaxExpiry))
}
if d < time.Minute {
return nil, errors.New("the shortest lifetime is one minute")
}
t := now.Add(d)
return &t, nil
}
// guardStalls resets the connection's read deadline before every read, so a
// legitimately slow transfer survives while a stalled one is dropped. The
// server's own ReadTimeout cannot do this job: it would have to be long enough
// for the largest permitted upload, which is no protection at all.
func guardStalls(w http.ResponseWriter, r io.Reader) io.Reader {
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Now().Add(stallTimeout)); err != nil {
return r // not a real connection (tests); nothing to guard
}
return &stallGuard{r: r, rc: rc}
}
type stallGuard struct {
r io.Reader
rc *http.ResponseController
}
func (g *stallGuard) Read(p []byte) (int, error) {
g.rc.SetReadDeadline(time.Now().Add(stallTimeout))
return g.r.Read(p)
}
func clearDeadline(w http.ResponseWriter) {
http.NewResponseController(w).SetReadDeadline(time.Time{})
}
type uploadResult struct {
ID string `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Expires string `json:"expires"` // RFC 3339, or "" for never
URL string `json:"url"`
InfoURL string `json:"info_url"`
DeleteToken string `json:"delete_token"`
DeleteURL string `json:"delete_url"`
}
// respondUploaded answers in whichever shape the caller asked for. The delete
// token appears exactly once, here, and is never recoverable afterwards.
func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *store.Meta, secret string) {
url := s.objectURL(r, m.ID)
if wantsJSON(r) {
expires := ""
if m.Expires != nil {
expires = m.Expires.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusCreated, uploadResult{
ID: m.ID, Filename: m.Filename, Size: m.Size, SHA256: m.SHA256,
Expires: expires,
URL: url,
InfoURL: s.absBase(r) + "i/" + m.ID,
DeleteToken: secret,
DeleteURL: s.absBase(r) + "api/d/" + m.ID + "/delete",
})
return
}
// Rendered directly rather than redirected: a 303 would have to carry the
// delete token in the URL, where it would end up in logs and history.
s.render(w, http.StatusOK, "result.html", objectPage{
page: s.page("Uploaded", false),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
URL: url,
DeleteToken: secret,
})
}
+68
View File
@@ -0,0 +1,68 @@
package store
import (
"crypto/rand"
"encoding/hex"
"errors"
"regexp"
"strings"
)
// vanityRe is deliberately narrow: lowercase alphanumerics plus dot, dash and
// underscore, starting with an alphanumeric, 2-64 characters. Anything that
// could be mistaken for a path element, a dotfile or a traversal is excluded.
var vanityRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`)
// reserved names would shadow a route or a well-known file if they were ever
// allowed into the object namespace.
var reserved = map[string]bool{
"d": true, "i": true, "api": true, "static": true,
"favicon.ico": true, "robots.txt": true, "index.html": true,
"sitemap.xml": true, "tokens.json": true, "objects": true,
}
var ErrBadID = errors.New("invalid name")
// CleanID validates an id arriving from a URL or from a vanity request and
// returns its canonical form. IDs are lowercased so that a case-insensitive
// filesystem cannot be tricked into treating two distinct names as one object.
//
// This is the *only* function permitted to turn caller input into a path
// element; every filesystem path in this package is built from its output.
func CleanID(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
if !vanityRe.MatchString(s) {
return "", ErrBadID
}
// The regexp permits interior dots; a doubled dot or a trailing dot is
// still refused so no spelling of a traversal survives.
if strings.Contains(s, "..") || strings.HasSuffix(s, ".") {
return "", ErrBadID
}
if reserved[s] {
return "", ErrBadID
}
return s, nil
}
// NewUUID returns a random RFC 4122 version 4 UUID.
func NewUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
h := hex.EncodeToString(b[:])
return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:], nil
}
// NewSecret returns a high-entropy URL-safe secret, used for both API tokens
// and per-object delete tokens.
func NewSecret() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
+76
View File
@@ -0,0 +1,76 @@
package store
import (
"strings"
"time"
"unicode/utf8"
)
// Meta is the flat per-object record stored alongside the blob. Its presence on
// disk is what makes an object visible; an object directory without one is
// either mid-upload or crash debris.
type Meta struct {
ID string `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Created time.Time `json:"created"`
Expires *time.Time `json:"expires"` // nil means never
Owner string `json:"owner"` // "" means anonymous
Vanity bool `json:"vanity"`
// DeleteHash is the SHA-256 of the delete token handed to the uploader.
// The token itself is shown once and never stored.
DeleteHash string `json:"delete_hash"`
}
// Expired reports whether the object's lifetime has run out.
func (m *Meta) Expired(now time.Time) bool {
return m.Expires != nil && !now.Before(*m.Expires)
}
const fallbackFilename = "download.bin"
// maxFilenameBytes matches the common filesystem limit; the name is only ever
// metadata here, but keeping it bounded keeps headers and pages sane.
const maxFilenameBytes = 255
// SanitizeFilename reduces a caller-supplied filename to something safe to put
// in a Content-Disposition header and to show on a page.
//
// The result is never used to build a path - paths come from CleanID alone -
// so this guards against header injection and display confusion rather than
// traversal. Separators are stripped regardless, so that a name surviving to
// some future code path cannot carry a directory with it.
func SanitizeFilename(name string) string {
// Take the last element under either separator convention: browsers on
// Windows have historically sent full paths.
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
if !utf8.ValidString(name) {
name = strings.ToValidUTF8(name, "")
}
name = strings.Map(func(r rune) rune {
switch {
case r < 0x20, r == 0x7f: // control characters, CR and LF included
return -1
case r == '/', r == '\\', r == 0:
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
if len(name) > maxFilenameBytes {
name = name[:maxFilenameBytes]
// Do not leave a partial rune at the end.
for len(name) > 0 && !utf8.ValidString(name) {
name = name[:len(name)-1]
}
}
if name == "" || name == "." || name == ".." {
return fallbackFilename
}
return name
}
+406
View File
@@ -0,0 +1,406 @@
// Package store implements the flat-file object store: one directory per
// object, holding the blob and a JSON metadata sidecar. There is no database;
// an in-memory index is rebuilt from disk at startup and kept in sync.
package store
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"io/fs"
"os"
"path/filepath"
"sync"
"time"
)
const (
blobName = "blob"
partName = "blob.part"
metaName = "meta.json"
dirPerm fs.FileMode = 0o775
filePerm fs.FileMode = 0o664
// debrisMaxAge is how long an object directory with no metadata is left
// alone before being treated as the remains of a killed upload.
debrisMaxAge = 24 * time.Hour
)
var (
ErrNotFound = errors.New("object not found")
ErrExists = errors.New("name already taken")
ErrTooLarge = errors.New("upload exceeds the size limit")
)
// Store owns the data directory.
type Store struct {
dir string
objects string
// root confines every object file operation to the objects directory.
// The data directory is group-writable by design, so a symlink planted
// there must not be able to redirect a read or a write outside it.
root *os.Root
mu sync.RWMutex
index map[string]*Meta
total int64
}
// Open prepares the data directory and rebuilds the index from it.
func Open(dir string) (*Store, error) {
s := &Store{
dir: dir,
objects: filepath.Join(dir, "objects"),
index: make(map[string]*Meta),
}
if err := os.MkdirAll(s.objects, dirPerm); err != nil {
return nil, err
}
root, err := os.OpenRoot(s.objects)
if err != nil {
return nil, err
}
s.root = root
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
// DataDir is the directory the store was opened on.
func (s *Store) DataDir() string { return s.dir }
// Close releases the handle on the objects directory.
func (s *Store) Close() error { return s.root.Close() }
// objectDir builds an object's path for display. Actual file operations go
// through s.root instead, which cannot be walked out of.
func (s *Store) objectDir(id string) string { return filepath.Join(s.objects, id) }
// within builds a root-relative path for one of an object's files.
func within(id, name string) string { return id + "/" + name }
func (s *Store) load() error {
entries, err := os.ReadDir(s.objects)
if err != nil {
return err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
id, err := CleanID(e.Name())
if err != nil || id != e.Name() {
// Not a name this service could have created; leave it be.
continue
}
m, err := s.readMeta(id)
if err != nil {
continue // incomplete or unreadable; the debris sweep handles it
}
// A leftover .part in a committed object is always stale at startup.
s.root.Remove(within(id, partName))
s.index[id] = m
s.total += m.Size
}
return nil
}
func (s *Store) readMeta(id string) (*Meta, error) {
b, err := s.root.ReadFile(within(id, metaName))
if err != nil {
return nil, err
}
var m Meta
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
if m.ID != id {
return nil, fmt.Errorf("store: metadata for %q claims id %q", id, m.ID)
}
return &m, nil
}
// Exists reports whether a name is currently taken, expired objects included:
// a name stays claimed until its object is actually removed.
func (s *Store) Exists(id string) bool {
_, err := s.root.Lstat(id)
return err == nil
}
// Reserve claims a name by creating its directory. os.Mkdir is atomic, so this
// is the point at which a vanity collision is detected - before any of the
// caller's body has been read.
func (s *Store) Reserve(id string) (*Upload, error) {
if err := s.root.Mkdir(id, dirPerm); err != nil {
if errors.Is(err, fs.ErrExist) {
return nil, ErrExists
}
return nil, err
}
f, err := s.root.OpenFile(within(id, partName), os.O_WRONLY|os.O_CREATE|os.O_EXCL, filePerm)
if err != nil {
s.root.RemoveAll(id)
return nil, err
}
return &Upload{s: s, id: id, f: f, h: sha256.New()}, nil
}
// ReserveRandom claims a fresh UUIDv4 name.
func (s *Store) ReserveRandom() (*Upload, error) {
for range 8 {
id, err := NewUUID()
if err != nil {
return nil, err
}
u, err := s.Reserve(id)
if errors.Is(err, ErrExists) {
continue // astronomically unlikely; retry regardless
}
return u, err
}
return nil, errors.New("store: could not allocate an unused id")
}
// Upload is an in-flight object. It is an io.Writer so callers can stream a
// request body straight to disk; nothing is ever buffered in memory.
type Upload struct {
s *Store
id string
f *os.File
h hash.Hash
n int64
limit int64 // 0 means unlimited
done bool
}
func (u *Upload) ID() string { return u.id }
func (u *Upload) Size() int64 { return u.n }
// SetLimit caps the number of bytes the upload will accept. The cap is applied
// to bytes actually written, never to a declared Content-Length.
func (u *Upload) SetLimit(n int64) { u.limit = n }
func (u *Upload) Write(p []byte) (int, error) {
if u.limit > 0 && u.n+int64(len(p)) > u.limit {
return 0, ErrTooLarge
}
n, err := u.f.Write(p)
u.n += int64(n)
u.h.Write(p[:n])
return n, err
}
// Commit makes the object visible. The ordering matters: the blob is durable
// and in place before the metadata that advertises it is written, and the
// metadata is renamed into place atomically.
func (u *Upload) Commit(m *Meta) error {
if u.done {
return errors.New("store: upload already finished")
}
m.ID = u.id
m.Size = u.n
m.SHA256 = hex.EncodeToString(u.h.Sum(nil))
if err := u.f.Sync(); err != nil {
return err
}
if err := u.f.Close(); err != nil {
return err
}
if err := u.s.root.Rename(within(u.id, partName), within(u.id, blobName)); err != nil {
return err
}
if err := u.s.writeMetaAtomic(u.id, m); err != nil {
return err
}
if err := u.s.syncDir(u.id); err != nil {
return err
}
u.done = true
u.s.mu.Lock()
u.s.index[u.id] = m
u.s.total += m.Size
u.s.mu.Unlock()
return nil
}
// Abort discards an incomplete upload, releasing its name.
func (u *Upload) Abort() {
if u.done {
return
}
u.done = true
u.f.Close()
u.s.root.RemoveAll(u.id)
}
// writeMetaAtomic serialises m to a temporary file in the object's own
// directory, fsyncs it, and renames it into place. Only once this rename lands
// does the object become visible to a reader.
func (s *Store) writeMetaAtomic(id string, m *Meta) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
suffix, err := NewSecret()
if err != nil {
return err
}
tmpPath := within(id, "."+metaName+"."+suffix[:16])
tmp, err := s.root.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, filePerm)
if err != nil {
return err
}
defer s.root.Remove(tmpPath) // no-op once the rename succeeds
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return s.root.Rename(tmpPath, within(id, metaName))
}
// syncDir flushes a directory entry so a rename survives a power loss.
func (s *Store) syncDir(id string) error {
d, err := s.root.Open(id)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// Get returns an object's metadata, treating an expired object as absent and
// removing it on the spot. Expiry is checked here, on every read, so a stalled
// sweeper can never serve a file past its lifetime.
func (s *Store) Get(id string, now time.Time) (*Meta, error) {
s.mu.RLock()
m, ok := s.index[id]
s.mu.RUnlock()
if !ok {
return nil, ErrNotFound
}
if m.Expired(now) {
s.Delete(id)
return nil, ErrNotFound
}
return m, nil
}
// OpenBlob returns the metadata and an open handle to the object's bytes.
func (s *Store) OpenBlob(id string, now time.Time) (*Meta, *os.File, error) {
m, err := s.Get(id, now)
if err != nil {
return nil, nil, err
}
f, err := s.root.Open(within(id, blobName))
if err != nil {
// Metadata without a blob means the data directory was tampered with.
s.Delete(id)
return nil, nil, ErrNotFound
}
return m, f, nil
}
// Delete removes an object and frees its name.
func (s *Store) Delete(id string) error {
s.mu.Lock()
if m, ok := s.index[id]; ok {
s.total -= m.Size
delete(s.index, id)
}
s.mu.Unlock()
return s.root.RemoveAll(id)
}
// Total reports the number of bytes currently stored.
func (s *Store) Total() int64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.total
}
// Count reports the number of live objects.
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.index)
}
// Sweep removes expired objects and long-abandoned upload directories. It
// returns the number of objects removed.
func (s *Store) Sweep(now time.Time) int {
s.mu.RLock()
var expired []string
for id, m := range s.index {
if m.Expired(now) {
expired = append(expired, id)
}
}
s.mu.RUnlock()
for _, id := range expired {
s.Delete(id)
}
s.sweepDebris(now)
return len(expired)
}
// sweepDebris removes object directories that never gained metadata and are
// older than debrisMaxAge - the remains of an upload killed mid-flight.
func (s *Store) sweepDebris(now time.Time) {
entries, err := os.ReadDir(s.objects)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
s.mu.RLock()
_, live := s.index[e.Name()]
s.mu.RUnlock()
if live {
continue
}
info, err := e.Info()
if err != nil || now.Sub(info.ModTime()) < debrisMaxAge {
continue
}
if _, err := s.root.Stat(within(e.Name(), metaName)); err == nil {
continue // has metadata but is not indexed; leave it for a human
}
s.root.RemoveAll(e.Name())
}
}
// List returns every live object's metadata, for administrative use.
func (s *Store) List() []*Meta {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*Meta, 0, len(s.index))
for _, m := range s.index {
out = append(out, m)
}
return out
}
var _ io.Writer = (*Upload)(nil)
+294
View File
@@ -0,0 +1,294 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCleanID(t *testing.T) {
valid := []string{"my-file", "a1", "godot.zip", "a_b.c-d", "ABC"}
for _, in := range valid {
got, err := CleanID(in)
if err != nil {
t.Errorf("CleanID(%q): %v", in, err)
continue
}
if got != strings.ToLower(in) {
t.Errorf("CleanID(%q) = %q, want it lowercased", in, got)
}
}
// Anything that could escape the objects directory, shadow a route, or
// collide on a case-insensitive filesystem must be refused.
invalid := []string{
"", "a", ".", "..", "...", "../etc/passwd", "a/b", `a\b`, "/abs",
".hidden", "a..b", "trailing.", "api", "static", "d", "i",
"robots.txt", "tokens.json", "with space", "emoji-🙂",
strings.Repeat("x", 65), "a\x00b", "a\nb",
}
for _, in := range invalid {
if got, err := CleanID(in); err == nil {
t.Errorf("CleanID(%q) = %q, want an error", in, got)
}
}
}
func TestCleanIDAcceptsGeneratedUUIDs(t *testing.T) {
for range 100 {
id, err := NewUUID()
if err != nil {
t.Fatal(err)
}
if len(id) != 36 || id[14] != '4' {
t.Fatalf("NewUUID() = %q, not a v4 UUID", id)
}
if got, err := CleanID(id); err != nil || got != id {
t.Fatalf("CleanID(%q) = %q, %v", id, got, err)
}
}
}
func TestSanitizeFilename(t *testing.T) {
cases := map[string]string{
"MyGame.zip": "MyGame.zip",
`C:\Users\me\Desktop\thing.exe`: "thing.exe",
"/etc/passwd": "passwd",
"../../escape.txt": "escape.txt",
"": "download.bin",
".": "download.bin",
"..": "download.bin",
" ": "download.bin",
"with\r\nheader: injected": "withheader: injected",
"null\x00byte": "nullbyte",
"naïve fïle.txt": "naïve fïle.txt",
`quo"te.txt`: `quo"te.txt`,
}
for in, want := range cases {
if got := SanitizeFilename(in); got != want {
t.Errorf("SanitizeFilename(%q) = %q, want %q", in, got, want)
}
}
long := SanitizeFilename(strings.Repeat("é", 400))
if len(long) > maxFilenameBytes {
t.Errorf("a long name was not truncated: %d bytes", len(long))
}
}
func TestUploadIsInvisibleUntilCommitted(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
now := time.Now()
up, err := s.Reserve("thing")
if err != nil {
t.Fatal(err)
}
up.Write([]byte("partial"))
// The name is claimed, but the object does not exist yet.
if !s.Exists("thing") {
t.Error("the name was not claimed")
}
if _, err := s.Get("thing", now); err != ErrNotFound {
t.Errorf("Get on an uncommitted upload = %v, want ErrNotFound", err)
}
if _, err := s.Reserve("thing"); err != ErrExists {
t.Error("a claimed name was handed out twice")
}
if err := up.Commit(&Meta{Created: now}); err != nil {
t.Fatal(err)
}
m, err := s.Get("thing", now)
if err != nil {
t.Fatalf("Get after Commit: %v", err)
}
if m.Size != 7 {
t.Errorf("size = %d, want 7", m.Size)
}
want := sha256.Sum256([]byte("partial"))
if m.SHA256 != hex.EncodeToString(want[:]) {
t.Errorf("SHA256 = %q, want %x", m.SHA256, want)
}
if s.Total() != 7 {
t.Errorf("Total() = %d, want 7", s.Total())
}
}
func TestAbortReleasesTheName(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
up, err := s.Reserve("thing")
if err != nil {
t.Fatal(err)
}
up.Write([]byte("partial"))
up.Abort()
if s.Exists("thing") {
t.Error("Abort did not release the name")
}
if _, err := os.Stat(filepath.Join(dir, "objects", "thing")); !os.IsNotExist(err) {
t.Error("Abort left the directory behind")
}
if _, err := s.Reserve("thing"); err != nil {
t.Errorf("the name could not be reused: %v", err)
}
}
func TestLimitStopsAtTheCap(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
up, err := s.Reserve("thing")
if err != nil {
t.Fatal(err)
}
defer up.Abort()
up.SetLimit(10)
if _, err := up.Write([]byte("0123456789")); err != nil {
t.Fatalf("writing exactly the limit: %v", err)
}
if _, err := up.Write([]byte("x")); err != ErrTooLarge {
t.Errorf("writing past the limit = %v, want ErrTooLarge", err)
}
if up.Size() != 10 {
t.Errorf("Size() = %d, want 10", up.Size())
}
}
func TestIndexIsRebuiltFromDisk(t *testing.T) {
dir := t.TempDir()
now := time.Now()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
up, _ := s.Reserve("survivor")
up.Write([]byte("bytes"))
if err := up.Commit(&Meta{Created: now}); err != nil {
t.Fatal(err)
}
// A .part with no metadata is what a killed upload leaves behind.
stale, _ := s.Reserve("stale")
stale.Write([]byte("half"))
// Reopening is what a restart does.
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
if _, err := s2.Get("survivor", now); err != nil {
t.Errorf("a committed object did not survive a restart: %v", err)
}
if _, err := s2.Get("stale", now); err != ErrNotFound {
t.Error("an uncommitted object became visible after a restart")
}
if s2.Total() != 5 {
t.Errorf("Total() = %d, want 5", s2.Total())
}
}
func TestSweepRemovesExpired(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
now := time.Now()
deadline := now.Add(time.Hour)
up, _ := s.Reserve("temporary")
up.Write([]byte("x"))
up.Commit(&Meta{Created: now, Expires: &deadline})
keep, _ := s.Reserve("permanent")
keep.Write([]byte("x"))
keep.Commit(&Meta{Created: now})
if n := s.Sweep(now); n != 0 {
t.Errorf("swept %d objects before anything expired", n)
}
if n := s.Sweep(now.Add(2 * time.Hour)); n != 1 {
t.Errorf("swept %d objects, want 1", n)
}
if s.Count() != 1 || s.Total() != 1 {
t.Errorf("after sweeping: count = %d, total = %d, want 1 and 1", s.Count(), s.Total())
}
if _, err := s.Get("permanent", now.Add(10*365*24*time.Hour)); err != nil {
t.Error("an object with no expiry was swept")
}
}
// The objects directory is group-writable by design, so a planted symlink is a
// realistic way to try to make the service read or clobber a file elsewhere.
// Every object operation goes through an os.Root, which refuses to follow one
// out of the directory.
func TestSymlinksCannotEscapeTheObjectsDirectory(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(dir, "outside")
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("password"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
defer s.Close()
objects := filepath.Join(dir, "objects")
now := time.Now()
// A blob that is a symlink to a file outside the store.
if err := os.Mkdir(filepath.Join(objects, "sneaky"), 0o775); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(dir, "secret.txt"), filepath.Join(objects, "sneaky", "blob")); err != nil {
t.Fatal(err)
}
meta := []byte(`{"id":"sneaky","filename":"x","size":8,"created":"2026-01-01T00:00:00Z","expires":null}`)
if err := os.WriteFile(filepath.Join(objects, "sneaky", "meta.json"), meta, 0o664); err != nil {
t.Fatal(err)
}
// Reopen so the planted object is indexed, as it would be after a restart.
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if _, _, err := s2.OpenBlob("sneaky", now); err == nil {
t.Error("a blob symlinked outside the store was opened")
}
// A whole object directory that is a symlink elsewhere.
if err := os.Symlink(outside, filepath.Join(objects, "elsewhere")); err != nil {
t.Fatal(err)
}
if _, err := s2.Reserve("elsewhere"); err != ErrExists {
t.Errorf("Reserve over a symlink = %v, want ErrExists", err)
}
// Writing through it must not reach the target directory either.
if err := s2.writeMetaAtomic("elsewhere", &Meta{ID: "elsewhere"}); err == nil {
t.Error("metadata was written through a symlinked directory")
}
if entries, _ := os.ReadDir(outside); len(entries) != 0 {
t.Errorf("%d files were created outside the store", len(entries))
}
}