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
+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,
})
}