Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 + "/"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user