Initial commit

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