From a180fe4b52cefde49bf21dab34122402e6ee2e6b Mon Sep 17 00:00:00 2001 From: Thayol Date: Sat, 12 Sep 2026 23:26:07 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 188 +++++++++ go.mod | 3 + internal/auth/tokens.go | 347 +++++++++++++++++ internal/auth/tokens_test.go | 186 +++++++++ internal/config/config.go | 147 +++++++ internal/config/config_test.go | 167 ++++++++ internal/config/flags.go | 230 +++++++++++ internal/config/parse.go | 118 ++++++ internal/server/delete.go | 79 ++++ internal/server/disk_other.go | 6 + internal/server/disk_unix.go | 15 + internal/server/download.go | 102 +++++ internal/server/download_test.go | 84 ++++ internal/server/pages.go | 141 +++++++ internal/server/ratelimit.go | 101 +++++ internal/server/server.go | 226 +++++++++++ internal/server/server_test.go | 649 +++++++++++++++++++++++++++++++ internal/server/upload.go | 400 +++++++++++++++++++ internal/store/id.go | 68 ++++ internal/store/meta.go | 76 ++++ internal/store/store.go | 406 +++++++++++++++++++ internal/store/store_test.go | 294 ++++++++++++++ main.go | 174 +++++++++ token.go | 163 ++++++++ umask_other.go | 5 + umask_unix.go | 10 + web/embed.go | 27 ++ web/static/app.js | 242 ++++++++++++ web/static/style.css | 144 +++++++ web/templates/error.html | 7 + web/templates/index.html | 55 +++ web/templates/info.html | 11 + web/templates/layout.html | 19 + web/templates/result.html | 29 ++ 35 files changed, 4921 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 internal/auth/tokens.go create mode 100644 internal/auth/tokens_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/flags.go create mode 100644 internal/config/parse.go create mode 100644 internal/server/delete.go create mode 100644 internal/server/disk_other.go create mode 100644 internal/server/disk_unix.go create mode 100644 internal/server/download.go create mode 100644 internal/server/download_test.go create mode 100644 internal/server/pages.go create mode 100644 internal/server/ratelimit.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/upload.go create mode 100644 internal/store/id.go create mode 100644 internal/store/meta.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 main.go create mode 100644 token.go create mode 100644 umask_other.go create mode 100644 umask_unix.go create mode 100644 web/embed.go create mode 100644 web/static/app.js create mode 100644 web/static/style.css create mode 100644 web/templates/error.html create mode 100644 web/templates/index.html create mode 100644 web/templates/info.html create mode 100644 web/templates/layout.html create mode 100644 web/templates/result.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8c1e8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/send +/data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..62d30f8 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# Uncensored Send + +A small self-hosted file drop. Uploads land in a flat data directory, expire on +their own, and are served back as inert attachments. + +Built for sharing large binaries with friends — a 400 MB Godot export is a +normal day here — without a `tmp/` folder that grows forever. + +- One static Go binary. No database, no dependencies outside the standard + library, no CSS or JavaScript build step. +- Anyone who can reach the page may upload, within a size cap and a lifetime. +- Named tokens raise those limits and unlock custom URLs. +- Everything expires unless a token says otherwise. + +## Building + +``` +go build -o send . +``` + +Go 1.25 or newer. There is nothing to install: the frontend is embedded in the +binary. + +## Running + +``` +./send --data /var/lib/send --listen 127.0.0.1:8080 +``` + +It binds to loopback by default and expects to sit behind a reverse proxy that +terminates TLS. Make sure the proxy does not buffer request bodies and does not +impose its own upload size limit, or large uploads will die before they arrive. + +Options take **one hyphen with a single letter** and **two with a full word**: +`-s 4GiB` and `--max-size=4GiB` are the same option, `-max-size` is an error. +Every option can also be set from the environment as `SEND_MAX_SIZE` and so on. +`./send --help` lists them all. + +| Option | Default | Meaning | +|---|---|---| +| `-l`, `--listen` | `127.0.0.1:8080` | address to listen on | +| `-d`, `--data` | `./data` | data directory | +| `-b`, `--base-url` | `/` | path prefix when mounted under a subdirectory | +| `-u`, `--public-url` | — | absolute base URL used in generated links | +| `-s`, `--max-size` | `2GiB` | per-upload cap; `unlimited` to remove it | +| `-e`, `--max-expiry` | `3d` | longest lifetime a caller may ask for; `never` to remove the cap | +| `--default-expiry` | `3d` | lifetime applied when the caller does not ask | +| `--max-total-bytes` | `unlimited` | whole-store quota | +| `--min-free-bytes` | `1GiB` | refuse uploads below this much free disk | +| `--tokens` | `/tokens.json` | token file | +| `--trusted-proxy` | — | networks whose `X-Forwarded-For` is believed | +| `--sweep-interval` | `1m` | how often expired files are removed | +| `--upload-rate` | `60` | uploads per hour per client | +| `--upload-burst` | `10` | uploads allowed back-to-back | +| `--max-concurrent` | `8` | uploads in flight at once | + +Sizes accept `2GiB`, `500MB`, `4G` or a plain byte count. Durations accept `3d`, +`12h`, `90m`, `1w` or `never`. + +## Tokens + +A token grants its own limits. Anything left unset is inherited from the +server's defaults, so a bare token behaves exactly like the anonymous tier +except that it may claim custom names. + +``` +./send token add thayol --vanity --max-size 8GiB --max-expiry never +./send token list +./send token rm thayol +``` + +The token is printed once and never again; only its SHA-256 is stored. Send it +as `Authorization: Bearer `, or paste it into the form's token field. + +`tokens.json` may also be edited by hand; the server picks up changes on its own +and on `SIGHUP`. It must stay mode `0600` — the server refuses to start +otherwise, since it holds credential material. + +## Uploading + +From the browser, just use the page. It works with JavaScript disabled; with it +enabled you get a progress bar, drag-and-drop and a copy-link button. + +From the command line, `POST /api/upload` with the file as the whole body: + +``` +curl --data-binary @MyGame.zip \ + -H 'Content-Disposition: attachment; filename="MyGame.zip"' \ + -H 'Authorization: Bearer ' \ + -H 'Vanity: my-game' \ + -H 'Expiry: 7d' \ + -H 'Accept: application/json' \ + https://send.example.com/api/upload +``` + +`Content-Disposition`, `Vanity` and `Expiry` are all optional. Without a vanity +name you get a UUIDv4; vanity names require a token. The reply carries the +download URL and a **delete token**, shown exactly once: + +```json +{ + "id": "my-game", + "url": "https://send.example.com/d/my-game", + "expires": "2026-09-19T10:00:00Z", + "delete_token": "…", + "delete_url": "https://send.example.com/api/d/my-game/delete" +} +``` + +The same endpoint accepts `multipart/form-data` from the web form. In that shape +the `token`, `expiry` and `vanity` fields **must precede the file part** — the +body is streamed, so the limits have to be known before the first byte of the +file is accepted. + +## Downloading and deleting + +| Route | | +|---|---| +| `GET /d/{id}` | the file, as an attachment; supports resuming | +| `GET /i/{id}` | a page showing name, size, expiry and digest | +| `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` | + +Deleting accepts the object's delete token, the token that uploaded it, or any +admin token. + +## Data directory + +``` +/ + objects//blob the bytes 0664 + objects//meta.json name, size, digest, expiry 0664 + tokens.json credential hashes 0600 +``` + +Objects and their metadata are group-writable and world-readable, so a second +account or a cleanup script can manage them. `tokens.json` is the deliberate +exception. + +There is no index to corrupt: the in-memory index is rebuilt from `meta.json` +files at startup, and an object directory without one is either mid-upload or +the remains of a killed one, invisible either way and swept after 24 hours. + +## Notes on the security posture + +Worth knowing if you are going to run this somewhere real. + +- **Uploads are never sized by what the client claims.** `Content-Length` is + not consulted anywhere; the cap is enforced on bytes actually written, and the + transfer is cut off the moment it is exceeded. +- **Nothing served from `/d/` can execute.** `Content-Type` is always + `application/octet-stream`, the disposition is always `attachment`, and the + response carries `nosniff` and `default-src 'none'; sandbox`. Upload an HTML + file and a browser will download it, not render it. +- **Filenames are metadata, never paths.** Every path is built from a validated + ID: lowercase, 2–64 characters, no separators, no traversal, no route names. + The uploaded filename only reaches the `Content-Disposition` header, where the + ASCII form is built from a character whitelist and the real name rides along + in an RFC 5987 parameter. +- **Object files are opened through an `os.Root`.** Because the data directory + is group-writable, a planted symlink could otherwise redirect a read or a + write outside it. It cannot. +- **Expiry is checked on every read**, not only by the sweeper, so a stalled + sweeper can never serve a file past its lifetime. A missing file and an + expired one give identical 404s. +- **Uploads are published atomically**: the blob is fsynced and renamed into + place before the metadata that advertises it is written, also by rename. A + crash at any point leaves something invisible rather than something broken. +- **Vanity names are claimed before the body is read**, so a collision costs one + round trip rather than a 2 GiB transfer. +- **Slow uploads are fine, stalled ones are not.** There is no server-wide read + timeout — any value large enough for a legitimate 2 GiB upload would be no + protection — so the upload handler maintains a per-read deadline instead. +- **`X-Forwarded-For` is ignored** unless the peer is a configured + `--trusted-proxy`, and then only to skip further trusted hops. +- Rate limiting is per client address, with a separate bound on uploads in + flight. Both are in memory and reset on restart. + +## Development + +``` +go test ./... +go vet ./... +``` + +The tests cover the parts where a mistake would be expensive: size limits +against a chunked body with no declared length, vanity collisions, expiry on +read, traversal and reserved names, `Content-Disposition` for hostile filenames, +delete authorisation, symlink escapes and crash debris. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..10d92b9 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module send + +go 1.25 diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..f1f9c23 --- /dev/null +++ b/internal/auth/tokens.go @@ -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 +} diff --git a/internal/auth/tokens_test.go b/internal/auth/tokens_test.go new file mode 100644 index 0000000..f1c6e9e --- /dev/null +++ b/internal/auth/tokens_test.go @@ -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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..81d4e2b --- /dev/null +++ b/internal/config/config.go @@ -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 /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 + "/" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a195f7c --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 0000000..5443aad --- /dev/null +++ b/internal/config/flags.go @@ -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)) + } +} diff --git a/internal/config/parse.go b/internal/config/parse.go new file mode 100644 index 0000000..f1351bc --- /dev/null +++ b/internal/config/parse.go @@ -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() + } +} diff --git a/internal/server/delete.go b/internal/server/delete.go new file mode 100644 index 0000000..fa084cd --- /dev/null +++ b/internal/server/delete.go @@ -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) +} diff --git a/internal/server/disk_other.go b/internal/server/disk_other.go new file mode 100644 index 0000000..4fa5999 --- /dev/null +++ b/internal/server/disk_other.go @@ -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 } diff --git a/internal/server/disk_unix.go b/internal/server/disk_unix.go new file mode 100644 index 0000000..aa765cb --- /dev/null +++ b/internal/server/disk_unix.go @@ -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 +} diff --git a/internal/server/download.go b/internal/server/download.go new file mode 100644 index 0000000..70825b3 --- /dev/null +++ b/internal/server/download.go @@ -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() +} diff --git a/internal/server/download_test.go b/internal/server/download_test.go new file mode 100644 index 0000000..1000fcf --- /dev/null +++ b/internal/server/download_test.go @@ -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) + } + } +} diff --git a/internal/server/pages.go b/internal/server/pages.go new file mode 100644 index 0000000..a34b8f1 --- /dev/null +++ b/internal/server/pages.go @@ -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)) +} diff --git a/internal/server/ratelimit.go b/internal/server/ratelimit.go new file mode 100644 index 0000000..1f449f4 --- /dev/null +++ b/internal/server/ratelimit.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..9fb04e6 --- /dev/null +++ b/internal/server/server.go @@ -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.") +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..d00a80c --- /dev/null +++ b/internal/server/server_test.go @@ -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(""), 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()) + } +} diff --git a/internal/server/upload.go b/internal/server/upload.go new file mode 100644 index 0000000..b931a7f --- /dev/null +++ b/internal/server/upload.go @@ -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, + }) +} diff --git a/internal/store/id.go b/internal/store/id.go new file mode 100644 index 0000000..5df3c38 --- /dev/null +++ b/internal/store/id.go @@ -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 +} diff --git a/internal/store/meta.go b/internal/store/meta.go new file mode 100644 index 0000000..3873f73 --- /dev/null +++ b/internal/store/meta.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..468ea56 --- /dev/null +++ b/internal/store/store.go @@ -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) diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..d40a68a --- /dev/null +++ b/internal/store/store_test.go @@ -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)) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..69c8a95 --- /dev/null +++ b/main.go @@ -0,0 +1,174 @@ +// Command send is a small self-hosted file drop: uploads land in a flat data +// directory, expire on their own, and are served back as inert attachments. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "send/internal/auth" + "send/internal/config" + "send/internal/server" + "send/internal/store" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, "send: "+err.Error()) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) > 0 && args[0] == "token" { + return tokenCommand(args[1:]) + } + return serve(args) +} + +const serveUsage = `send - a small self-hosted file drop + +Usage: + send [options] run the server + send token add [opts] mint a token, printing it once + send token list list tokens + send token rm remove a token + +Options take one hyphen with a single letter (-s) and two with a full +word (--max-size). Every option can also be set from the environment. + +Options: +` + +func serve(args []string) error { + var cfg config.Config + fs := config.NewSet("send", config.EnvPrefix) + fs.SetOutput(os.Stderr) + cfg.Register(fs) + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + fs.PrintUsage(os.Stdout, serveUsage) + return flag.ErrHelp + } + return err + } + if err := cfg.Normalise(); err != nil { + return err + } + + setUmask() + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + + st, err := store.Open(cfg.DataDir) + if err != nil { + return fmt.Errorf("opening data directory: %w", err) + } + defer st.Close() + + tokens, err := auth.Load(cfg.TokensPath) + if err != nil { + return fmt.Errorf("loading tokens: %w", err) + } + srv, err := server.New(&cfg, st, tokens, log) + if err != nil { + return err + } + + httpSrv := &http.Server{ + Addr: cfg.Listen, + Handler: srv, + // ReadTimeout and WriteTimeout are deliberately left unset: a + // legitimate multi-gigabyte upload on a slow line would trip any value + // large enough to be safe. Stalled connections are dropped by the + // per-read deadline the upload handler maintains instead. + ReadHeaderTimeout: 20 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 1 << 16, + ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go sweep(ctx, st, cfg.SweepInterval, log) + go reloadOnHUP(ctx, tokens, log) + + log.Info("listening", + "addr", cfg.Listen, "data", cfg.DataDir, "base", cfg.BasePath, + "objects", st.Count(), "stored", config.FormatBytes(st.Total()), + "max_size", config.FormatSize(cfg.MaxSize), + "max_expiry", config.FormatDuration(cfg.MaxExpiry)) + + errc := make(chan error, 1) + go func() { + err := httpSrv.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + errc <- err + }() + + select { + case err := <-errc: + return err + case <-ctx.Done(): + log.Info("shutting down") + // Uploads in flight get a moment to finish; the store's own crash + // recovery handles whatever does not. + shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + return httpSrv.Shutdown(shutdownCtx) + } +} + +// sweep removes expired objects on a timer. Reads check expiry independently, +// so this is about reclaiming disk, not about correctness. +func sweep(ctx context.Context, st *store.Store, every time.Duration, log *slog.Logger) { + if n := st.Sweep(time.Now()); n > 0 { + log.Info("swept expired objects", "count", n) + } + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-t.C: + if n := st.Sweep(now); n > 0 { + log.Info("swept expired objects", "count", n) + } + } + } +} + +// reloadOnHUP re-reads the token file on SIGHUP, so credentials can be changed +// without dropping uploads in flight. +func reloadOnHUP(ctx context.Context, tokens *auth.File, log *slog.Logger) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGHUP) + defer signal.Stop(ch) + for { + select { + case <-ctx.Done(): + return + case <-ch: + if err := tokens.Reload(); err != nil { + log.Error("reloading tokens", "err", err) + continue + } + log.Info("reloaded tokens", "count", len(tokens.List())) + } + } +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..043d11a --- /dev/null +++ b/token.go @@ -0,0 +1,163 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "text/tabwriter" + "time" + + "send/internal/auth" + "send/internal/config" + "send/internal/store" +) + +const tokenUsage = `send token - manage upload credentials + +Usage: + send token add [options] + send token list [options] + send token rm [options] + +A token grants its own size and lifetime limits. Any limit left unset is +inherited from the running server's defaults, so a token with no options +behaves exactly like the anonymous tier but may claim vanity names. + +Options: +` + +func tokenCommand(args []string) error { + if len(args) == 0 { + return errors.New("token: expected add, list or rm") + } + sub, rest := args[0], args[1:] + + // The name is positional and must come first; stdlib flag stops parsing at + // the first non-flag argument. + name := "" + if len(rest) > 0 && (len(rest[0]) == 0 || rest[0][0] != '-') { + name, rest = rest[0], rest[1:] + } + + var ( + dataDir, tokensPath string + maxSize, maxExpiry, defExpiry string + vanity, admin bool + ) + fs := config.NewSet("send token", config.EnvPrefix) + fs.SetOutput(os.Stderr) + fs.String(&dataDir, "data", "d", "./data", "DIR", "directory holding the data") + fs.String(&tokensPath, "tokens", "", "", "FILE", "token file location (default /tokens.json)") + fs.String(&maxSize, "max-size", "s", "", "SIZE", "per-upload cap for this token; 'unlimited' to remove it") + fs.String(&maxExpiry, "max-expiry", "e", "", "DURATION", "longest lifetime this token may request; 'never' to remove the cap") + fs.String(&defExpiry, "default-expiry", "", "", "DURATION", "lifetime applied when this token does not ask for one") + fs.Bool(&vanity, "vanity", "", false, "allow this token to claim custom names") + fs.Bool(&admin, "admin", "", false, "allow this token to delete anyone's files") + + if err := fs.Parse(rest); err != nil { + if errors.Is(err, flag.ErrHelp) { + fs.PrintUsage(os.Stdout, tokenUsage) + return flag.ErrHelp + } + return err + } + if tokensPath == "" { + tokensPath = dataDir + "/tokens.json" + } + + // Match the server's permissions for anything this command has to create. + setUmask() + + file, err := auth.Load(tokensPath) + if err != nil { + return err + } + + switch sub { + case "add": + if name == "" { + return errors.New("token add: a name is required") + } + return tokenAdd(file, name, maxSize, maxExpiry, defExpiry, vanity, admin) + case "list": + return tokenList(file) + case "rm", "remove", "delete": + if name == "" { + return errors.New("token rm: a name is required") + } + if err := file.Remove(name); err != nil { + return err + } + fmt.Printf("Removed token %q.\n", name) + return nil + default: + return fmt.Errorf("token: unknown subcommand %q", sub) + } +} + +func tokenAdd(file *auth.File, name, maxSize, maxExpiry, defExpiry string, vanity, admin bool) error { + secret, err := store.NewSecret() + if err != nil { + return err + } + t := &auth.Token{ + Name: name, + Hash: auth.HashSecret(secret), + AllowVanity: vanity, + Admin: admin, + Created: time.Now().UTC().Truncate(time.Second), + } + // Only options actually given are recorded; everything else stays absent + // so it keeps tracking the server's defaults. + if maxSize != "" { + t.MaxSize = &maxSize + } + if maxExpiry != "" { + t.MaxExpiry = &maxExpiry + } + if defExpiry != "" { + t.DefaultExpiry = &defExpiry + } + if err := file.Add(t); err != nil { + return err + } + + fmt.Printf("Added token %q to %s\n\n", name, file.Path()) + fmt.Printf(" %s\n\n", secret) + fmt.Println("This is the only time it is shown; only its hash is stored.") + fmt.Println("Send it as: Authorization: Bearer ") + return nil +} + +func tokenList(file *auth.File) error { + tokens := file.List() + if len(tokens) == 0 { + fmt.Printf("No tokens in %s\n", file.Path()) + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tMAX SIZE\tMAX EXPIRY\tDEFAULT\tVANITY\tADMIN\tCREATED") + for _, t := range tokens { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + t.Name, + inherited(t.MaxSize), inherited(t.MaxExpiry), inherited(t.DefaultExpiry), + yesNo(t.AllowVanity), yesNo(t.Admin), + t.Created.Format("2006-01-02")) + } + return w.Flush() +} + +func inherited(s *string) string { + if s == nil { + return "(default)" + } + return *s +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/umask_other.go b/umask_other.go new file mode 100644 index 0000000..4c0c993 --- /dev/null +++ b/umask_other.go @@ -0,0 +1,5 @@ +//go:build !unix + +package main + +func setUmask() {} diff --git a/umask_unix.go b/umask_unix.go new file mode 100644 index 0000000..a75a080 --- /dev/null +++ b/umask_unix.go @@ -0,0 +1,10 @@ +//go:build unix + +package main + +import "syscall" + +// setUmask makes the process's default permissions match what the data +// directory is documented to use: group-writable, world-readable. The token +// file overrides this explicitly, since it is the one thing that is secret. +func setUmask() { syscall.Umask(0o002) } diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..4c637be --- /dev/null +++ b/web/embed.go @@ -0,0 +1,27 @@ +// Package web holds the frontend, embedded into the binary. There is no build +// step: the templates and assets are hand-written and shipped as-is. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// Templates returns the raw template files, rooted at "templates". +func Templates() fs.FS { return templatesFS } + +// Static returns the assets to be served under /static/, with the directory +// prefix stripped. +func Static() fs.FS { + sub, err := fs.Sub(staticFS, "static") + if err != nil { + panic(err) // impossible: the directory is embedded above + } + return sub +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..efca6d0 --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,242 @@ +// Progressive enhancement for the upload form. With JavaScript off the plain +// multipart POST still works; everything here is additive. +(function () { + 'use strict'; + + var base = document.body.dataset.base || '/'; + var form = document.getElementById('upload'); + if (!form) return; + + var fileInput = document.getElementById('file'); + var tokenInput = document.getElementById('token'); + var drop = document.getElementById('drop'); + var dropHint = document.getElementById('drop-hint'); + var progress = document.getElementById('progress'); + var barFill = document.getElementById('bar-fill'); + var progressText = document.getElementById('progress-text'); + var submit = document.getElementById('submit'); + var errorBox = document.getElementById('error'); + + // Limits as rendered for an anonymous caller; refreshed when a token is typed. + var limits = { max_size: null, allow_vanity: false }; + + function formatSize(n) { + if (n === null || n === undefined) return 'unlimited'; + var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'], i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return (i === 0 ? n : n.toFixed(2).replace(/\.00$/, '')) + ' ' + units[i]; + } + + function showError(msg) { + errorBox.textContent = msg; + errorBox.hidden = !msg; + } + + // --- token memory ------------------------------------------------------- + // Kept in localStorage only so the field survives a reload; it is never sent + // anywhere but this origin's upload endpoint. + try { + var saved = localStorage.getItem('send.token'); + if (saved && tokenInput) tokenInput.value = saved; + } catch (e) { /* private mode; not important */ } + + function refreshLimits() { + var token = tokenInput ? tokenInput.value.trim() : ''; + try { + if (token) localStorage.setItem('send.token', token); + else localStorage.removeItem('send.token'); + } catch (e) { /* ignore */ } + + var xhr = new XMLHttpRequest(); + xhr.open('GET', base + 'api/limits'); + xhr.setRequestHeader('Accept', 'application/json'); + if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token); + xhr.onload = function () { + if (xhr.status !== 200) return; + var l; + try { l = JSON.parse(xhr.responseText); } catch (e) { return; } + limits = l; + set('limit-size', l.max_size === null ? 'unlimited' : formatSize(l.max_size)); + set('limit-expiry', l.max_expiry || 'never'); + set('limit-default', l.default_expiry || 'never'); + set('limit-vanity', l.allow_vanity ? 'allowed' : 'requires a token'); + var vanity = document.getElementById('vanity'); + if (vanity) vanity.disabled = !l.allow_vanity; + var expiry = document.getElementById('expiry'); + if (expiry) expiry.placeholder = l.default_expiry || 'never'; + }; + xhr.send(); + } + + function set(id, text) { + var el = document.getElementById(id); + if (el) el.textContent = text; + } + + if (tokenInput) { + var debounce; + tokenInput.addEventListener('input', function () { + clearTimeout(debounce); + debounce = setTimeout(refreshLimits, 400); + }); + } + refreshLimits(); + + // --- drag and drop ------------------------------------------------------ + function describeSelection() { + var f = fileInput.files[0]; + dropHint.textContent = f ? f.name + ' — ' + formatSize(f.size) + : 'Choose a file, or drop one here.'; + } + + ['dragenter', 'dragover'].forEach(function (name) { + drop.addEventListener(name, function (e) { + e.preventDefault(); + drop.classList.add('over'); + }); + }); + ['dragleave', 'drop'].forEach(function (name) { + drop.addEventListener(name, function () { drop.classList.remove('over'); }); + }); + drop.addEventListener('drop', function (e) { + e.preventDefault(); + if (!e.dataTransfer.files.length) return; + // DataTransfer is the only way to put a dropped file into a file input. + var dt = new DataTransfer(); + dt.items.add(e.dataTransfer.files[0]); + fileInput.files = dt.files; + describeSelection(); + }); + fileInput.addEventListener('change', describeSelection); + + // --- upload ------------------------------------------------------------- + form.addEventListener('submit', function (e) { + var file = fileInput.files[0]; + if (!file) return; // let the browser's own validation speak + + if (limits.max_size && file.size > limits.max_size) { + e.preventDefault(); + showError('That file is ' + formatSize(file.size) + '; the limit is ' + + formatSize(limits.max_size) + '.'); + return; + } + + e.preventDefault(); + showError(''); + submit.disabled = true; + progress.hidden = false; + + // FormData follows DOM order, so the token, expiry and vanity fields all + // precede the file part — which is exactly what the server requires. + var data = new FormData(form); + var started = Date.now(); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', form.action); + xhr.setRequestHeader('Accept', 'application/json'); + + xhr.upload.onprogress = function (ev) { + if (!ev.lengthComputable) return; + var pct = ev.loaded / ev.total; + barFill.style.width = (pct * 100).toFixed(1) + '%'; + var elapsed = (Date.now() - started) / 1000; + var rate = elapsed > 0 ? ev.loaded / elapsed : 0; + var eta = rate > 0 ? (ev.total - ev.loaded) / rate : 0; + progressText.textContent = + (pct * 100).toFixed(0) + '% — ' + formatSize(ev.loaded) + ' of ' + + formatSize(ev.total) + ' — ' + formatSize(rate) + '/s' + + (eta > 1 ? ', ' + formatTime(eta) + ' left' : ''); + }; + + xhr.onload = function () { + submit.disabled = false; + var body; + try { body = JSON.parse(xhr.responseText); } catch (err) { body = null; } + if (xhr.status >= 200 && xhr.status < 300 && body) { + showResult(body); + } else { + progress.hidden = true; + showError(body && body.error ? body.error : 'Upload failed (' + xhr.status + ').'); + } + }; + xhr.onerror = function () { + submit.disabled = false; + progress.hidden = true; + showError('Upload failed: the connection was lost.'); + }; + + xhr.send(data); + }); + + function formatTime(s) { + s = Math.round(s); + if (s < 60) return s + 's'; + var m = Math.floor(s / 60); + if (m < 60) return m + 'm ' + (s % 60) + 's'; + return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'; + } + + function showResult(r) { + var card = document.createElement('section'); + card.className = 'card result'; + + var h = document.createElement('h1'); + h.textContent = 'Uploaded'; + card.appendChild(h); + + var field = document.createElement('label'); + field.className = 'field'; + var span = document.createElement('span'); + span.textContent = 'Download link'; + var input = document.createElement('input'); + input.type = 'text'; + input.readOnly = true; + input.value = r.url; + field.appendChild(span); + field.appendChild(input); + card.appendChild(field); + + var copy = document.createElement('button'); + copy.type = 'button'; + copy.textContent = 'Copy link'; + copy.addEventListener('click', function () { + input.select(); + var done = function () { copy.textContent = 'Copied'; }; + if (navigator.clipboard) navigator.clipboard.writeText(r.url).then(done, function () {}); + else { document.execCommand('copy'); done(); } + }); + card.appendChild(copy); + + var dl = document.createElement('dl'); + addRow(dl, 'Expires', r.expires || 'never'); + addRow(dl, 'SHA-256', r.sha256, 'mono wrap'); + card.appendChild(dl); + + var warn = document.createElement('div'); + warn.className = 'warn'; + var wh = document.createElement('h2'); + wh.textContent = 'Delete token'; + var wp = document.createElement('p'); + wp.textContent = 'Shown once. Keep it if you want to remove the file early.'; + var wt = document.createElement('p'); + wt.className = 'mono wrap'; + wt.textContent = r.delete_token; + warn.appendChild(wh); + warn.appendChild(wp); + warn.appendChild(wt); + card.appendChild(warn); + + form.parentNode.insertBefore(card, form); + form.hidden = true; + } + + function addRow(dl, label, value, cls) { + var dt = document.createElement('dt'); + dt.textContent = label; + var dd = document.createElement('dd'); + dd.textContent = value; + if (cls) dd.className = cls; + dl.appendChild(dt); + dl.appendChild(dd); + } +})(); diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..3038b96 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,144 @@ +/* Hand-written, no build step, no framework. */ + +:root { + --bg: #f6f6f4; + --fg: #1b1b18; + --muted: #6b6b63; + --card: #ffffff; + --line: #dcdcd5; + --accent: #2f6f4f; + --accent-fg: #ffffff; + --danger: #9b2c2c; + --warn-bg: #fdf6e3; + --radius: 10px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16161a; + --fg: #e8e8e3; + --muted: #9a9a92; + --card: #1f1f25; + --line: #33333c; + --accent: #5fae86; + --accent-fg: #10241a; + --danger: #e07070; + --warn-bg: #2a2418; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0 1rem 3rem; + background: var(--bg); + color: var(--fg); + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; +} + +header, main, footer { max-width: 42rem; margin: 0 auto; } +header { padding: 1.5rem 0; } +footer { padding-top: 2rem; color: var(--muted); font-size: .875rem; } + +.brand { + font-weight: 600; + font-size: 1.125rem; + color: var(--fg); + text-decoration: none; +} + +h1 { font-size: 1.375rem; margin: 0 0 .75rem; } +h2 { font-size: 1rem; margin: 0 0 .5rem; } +a { color: var(--accent); } + +.card, .limits, .cli { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +.field { display: block; margin-bottom: 1rem; } +.field > span { display: block; font-size: .8125rem; color: var(--muted); margin-bottom: .25rem; } +.field em { font-style: normal; opacity: .7; } + +input[type=text], input[type=password] { + width: 100%; + padding: .5rem .625rem; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; +} + +.row { display: flex; gap: 1rem; flex-wrap: wrap; } +.row > .field { flex: 1 1 12rem; } + +.drop { + border: 2px dashed var(--line); + border-radius: var(--radius); + padding: 1.5rem; + text-align: center; + margin-bottom: 1rem; + transition: border-color .15s, background .15s; +} +.drop.over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.drop input[type=file] { max-width: 100%; } + +button, .button { + display: inline-block; + padding: .5rem 1rem; + font: inherit; + font-weight: 500; + color: var(--accent-fg); + background: var(--accent); + border: 0; + border-radius: 6px; + cursor: pointer; + text-decoration: none; +} +button:disabled { opacity: .55; cursor: default; } +button.danger { background: var(--danger); color: #fff; } + +.progress .bar { + height: .5rem; + background: var(--line); + border-radius: 999px; + overflow: hidden; + margin-bottom: .375rem; +} +.progress .fill { height: 100%; width: 0; background: var(--accent); transition: width .1s linear; } + +.hint { color: var(--muted); font-size: .875rem; margin: .25rem 0 0; } +#error:not([hidden]) { color: var(--danger); } + +dl { display: grid; grid-template-columns: auto 1fr; gap: .25rem 1rem; margin: 0; } +dt { color: var(--muted); font-size: .875rem; } +dd { margin: 0; } + +.mono, pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8125rem; } +.wrap { overflow-wrap: anywhere; } +.filename { overflow-wrap: anywhere; } + +pre { + margin: 0; + padding: .75rem; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; + overflow-x: auto; +} + +.warn { + margin: 1.25rem 0; + padding: 1rem; + background: var(--warn-bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} +.warn p { margin: .25rem 0 .75rem; font-size: .875rem; } + +.result .field { margin-top: 1rem; } diff --git a/web/templates/error.html b/web/templates/error.html new file mode 100644 index 0000000..f59a6b5 --- /dev/null +++ b/web/templates/error.html @@ -0,0 +1,7 @@ +{{define "content"}} +
+

{{.Status}}

+

{{.Message}}

+

Back to the upload page

+
+{{end}} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..b6c45a5 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,55 @@ +{{define "content"}} +
+ + + +
+ + +
+ +
+ +

Choose a file, or drop one here.

+
+ + + + + +
+ +
+

Current limits

+
+
Maximum size
{{.MaxSize}}
+
Longest lifetime
{{.MaxExpiry}}
+
Default lifetime
{{.DefaultExpiry}}
+
Vanity names
requires a token
+
+
+ +
+

From the command line

+
curl --data-binary @file.zip \
+     -H 'Content-Disposition: attachment; filename="file.zip"' \
+     -H 'Authorization: Bearer <token>' \
+     -H 'Vanity: my-file' \
+     -H 'Expiry: 3d' \
+     {{.AbsBase}}api/upload
+
+{{end}} diff --git a/web/templates/info.html b/web/templates/info.html new file mode 100644 index 0000000..ef09302 --- /dev/null +++ b/web/templates/info.html @@ -0,0 +1,11 @@ +{{define "content"}} +
+

{{.Meta.Filename}}

+
+
Size
{{.Size}}
+
Expires
{{.Expires}}
+
SHA-256
{{.Meta.SHA256}}
+
+

Download

+
+{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..8dd7c17 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,19 @@ +{{define "layout"}} + + + + +{{.Title}} · Uncensored Send + + + +
+ Uncensored Send +
+
+{{template "content" .}} +
+
Files are removed automatically when they expire.
+{{if .Script}}{{end}} + +{{end}} diff --git a/web/templates/result.html b/web/templates/result.html new file mode 100644 index 0000000..178ec4c --- /dev/null +++ b/web/templates/result.html @@ -0,0 +1,29 @@ +{{define "content"}} +
+

Uploaded

+

{{.Meta.Filename}}

+ + + +
+
Size
{{.Size}}
+
Expires
{{.Expires}}
+
SHA-256
{{.Meta.SHA256}}
+
+ +
+

Delete token

+

Shown once. Keep it if you want to remove the file before it expires.

+

{{.DeleteToken}}

+
+ + +
+
+ +

Upload another

+
+{{end}}