Initial commit
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"send/internal/auth"
|
||||
"send/internal/store"
|
||||
)
|
||||
|
||||
// handleDelete removes an object early. Three credentials are accepted: the
|
||||
// delete token handed to the uploader, the token that owns the object, and any
|
||||
// admin token.
|
||||
//
|
||||
// There is only one delete route, and it is a POST, so the success page's plain
|
||||
// form works with scripting disabled and no second code path is needed.
|
||||
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := store.CleanID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
m, err := s.store.Get(id, s.now())
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
|
||||
secret := bearer(r)
|
||||
if secret == "" {
|
||||
// A small form post; the 4 KiB cap keeps this from being a way to
|
||||
// stream a body into memory.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
|
||||
if err := r.ParseForm(); err == nil {
|
||||
secret = strings.TrimSpace(r.PostFormValue("token"))
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.mayDelete(m, secret) {
|
||||
s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.")
|
||||
return
|
||||
}
|
||||
if err := s.store.Delete(id); err != nil {
|
||||
s.log.Error("deleting object", "id", id, "err", err)
|
||||
s.fail(w, r, http.StatusInternalServerError, "Could not delete the file.")
|
||||
return
|
||||
}
|
||||
s.log.Info("deleted", "id", id, "ip", clientIP(r, s.cfg))
|
||||
|
||||
if wantsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "error.html", errorPage{
|
||||
page: s.page("Deleted", false),
|
||||
Status: "Deleted",
|
||||
Message: "The file is gone.",
|
||||
})
|
||||
}
|
||||
|
||||
// mayDelete checks the presented secret against the object's delete token
|
||||
// first, then against the token file.
|
||||
func (s *Server) mayDelete(m *store.Meta, secret string) bool {
|
||||
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
|
||||
return true
|
||||
}
|
||||
if err := s.tokens.MaybeReload(); err != nil {
|
||||
s.log.Error("reloading token file", "err", err)
|
||||
}
|
||||
t := s.tokens.Lookup(secret)
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
return t.Admin || (m.Owner != "" && t.Name == m.Owner)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !unix
|
||||
|
||||
package server
|
||||
|
||||
// freeBytes has no portable implementation; the byte quota still applies.
|
||||
func freeBytes(string) (int64, bool) { return 0, false }
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build unix
|
||||
|
||||
package server
|
||||
|
||||
import "syscall"
|
||||
|
||||
// freeBytes reports the space available to an unprivileged writer on the
|
||||
// filesystem holding path.
|
||||
func freeBytes(path string) (int64, bool) {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return int64(st.Bavail) * int64(st.Bsize), true
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"send/internal/store"
|
||||
)
|
||||
|
||||
// downloadCSP is as inert as a policy gets. Combined with the attachment
|
||||
// disposition and nosniff, an uploaded HTML file cannot execute anything in
|
||||
// this origin even if a browser were talked into rendering it.
|
||||
const downloadCSP = "default-src 'none'; sandbox"
|
||||
|
||||
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := store.CleanID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
// Expiry is checked here, on every read, not just by the sweeper.
|
||||
m, f, err := s.store.OpenBlob(id, s.now())
|
||||
if err != nil {
|
||||
// Missing and expired are answered identically, so the response says
|
||||
// nothing about what used to exist.
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := w.Header()
|
||||
// Set explicitly, which also stops ServeContent from sniffing the content.
|
||||
h.Set("Content-Type", "application/octet-stream")
|
||||
h.Set("Content-Disposition", contentDisposition(m.Filename))
|
||||
h.Set("Content-Security-Policy", downloadCSP)
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Cache-Control", "private, no-transform, max-age=0, must-revalidate")
|
||||
h.Set("ETag", `"`+m.SHA256+`"`)
|
||||
|
||||
// ServeContent brings Range, If-Range and If-None-Match with it, which is
|
||||
// what makes a half-finished 400 MB download resumable. The empty name
|
||||
// keeps it from guessing a type from the extension.
|
||||
http.ServeContent(w, r, "", m.Created, f)
|
||||
}
|
||||
|
||||
// contentDisposition builds an attachment header that is safe by construction.
|
||||
//
|
||||
// The ASCII form is built from a character whitelist, so no quote, backslash or
|
||||
// control character can reach the header regardless of what was uploaded. The
|
||||
// RFC 5987 form carries the real name for anything that survived that filter.
|
||||
func contentDisposition(name string) string {
|
||||
ascii := asciiFilename(name)
|
||||
d := `attachment; filename="` + ascii + `"`
|
||||
if ascii != name {
|
||||
d += "; filename*=UTF-8''" + encodeRFC5987(name)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// asciiFilename reduces a name to printable ASCII minus the characters that
|
||||
// would need quoting.
|
||||
func asciiFilename(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r < 0x20 || r > 0x7e, r == '"', r == '\\':
|
||||
b.WriteByte('_')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if strings.Trim(out, "_. ") == "" {
|
||||
return "download.bin"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// encodeRFC5987 percent-encodes everything outside the attr-char set of
|
||||
// RFC 5987, which is what the filename* parameter requires.
|
||||
//
|
||||
// The loop is over bytes, and each escaped byte is written as hex directly:
|
||||
// url.PathEscape would widen a byte to a rune first and so encode UTF-8 twice,
|
||||
// and it leaves several characters unescaped that attr-char does not allow.
|
||||
func encodeRFC5987(s string) string {
|
||||
const attrChars = "!#$&+-.^_`|~"
|
||||
const hexDigits = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
|
||||
strings.IndexByte(attrChars, c) >= 0:
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hexDigits[c>>4])
|
||||
b.WriteByte(hexDigits[c&0x0f])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Content-Disposition is the header that keeps an uploaded file from being
|
||||
// rendered, so it has to survive whatever a filename throws at it.
|
||||
func TestContentDisposition(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
wantASCII string
|
||||
wantExt bool // expect an RFC 5987 filename* parameter too
|
||||
}{
|
||||
{"MyGame.zip", "MyGame.zip", false},
|
||||
{"notes (draft).txt", "notes (draft).txt", false},
|
||||
{`quo"te.txt`, "quo_te.txt", true},
|
||||
{`back\slash.txt`, "back_slash.txt", true},
|
||||
{"naïve.txt", "na_ve.txt", true},
|
||||
{"日本語.bin", "___.bin", true},
|
||||
{"🙂.png", "_.png", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := contentDisposition(c.name)
|
||||
|
||||
// It must always parse, and always be an attachment.
|
||||
disp, params, err := mime.ParseMediaType(got)
|
||||
if err != nil {
|
||||
t.Errorf("contentDisposition(%q) = %q: does not parse: %v", c.name, got, err)
|
||||
continue
|
||||
}
|
||||
if disp != "attachment" {
|
||||
t.Errorf("contentDisposition(%q): disposition = %q", c.name, disp)
|
||||
}
|
||||
// Whatever the encoding, the decoded filename must be the real one when
|
||||
// an extended parameter is present.
|
||||
if c.wantExt {
|
||||
if !strings.Contains(got, "filename*=UTF-8''") {
|
||||
t.Errorf("contentDisposition(%q) = %q: no RFC 5987 parameter", c.name, got)
|
||||
}
|
||||
if params["filename"] != c.name {
|
||||
t.Errorf("contentDisposition(%q): decoded filename = %q", c.name, params["filename"])
|
||||
}
|
||||
} else if params["filename"] != c.wantASCII {
|
||||
t.Errorf("contentDisposition(%q): filename = %q, want %q", c.name, params["filename"], c.wantASCII)
|
||||
}
|
||||
|
||||
// Nothing may break out of the header, in any spelling.
|
||||
if strings.ContainsAny(got, "\r\n") {
|
||||
t.Errorf("contentDisposition(%q) = %q: contains a line break", c.name, got)
|
||||
}
|
||||
if i := strings.Index(got, `filename="`); i >= 0 {
|
||||
quoted := got[i+len(`filename="`):]
|
||||
quoted = quoted[:strings.Index(quoted, `"`)]
|
||||
if strings.ContainsAny(quoted, `"\`) {
|
||||
t.Errorf("contentDisposition(%q): unescaped quoting in %q", c.name, quoted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASCIIFilenameNeverEmpty(t *testing.T) {
|
||||
for _, in := range []string{"", "...", "___", " ", "🙂"} {
|
||||
if got := asciiFilename(in); strings.Trim(got, "_. ") == "" {
|
||||
t.Errorf("asciiFilename(%q) = %q, which names nothing", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRFC5987(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"plain.txt": "plain.txt",
|
||||
"a b.txt": "a%20b.txt",
|
||||
"naïve": "na%C3%AFve",
|
||||
`q"x`: "q%22x",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := encodeRFC5987(in); got != want {
|
||||
t.Errorf("encodeRFC5987(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"send/internal/auth"
|
||||
"send/internal/config"
|
||||
"send/internal/store"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
w.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
// absBase is the absolute URL of this service, for links and examples.
|
||||
//
|
||||
// It prefers --public-url. Falling back to the request's Host is display-only:
|
||||
// the header is caller-controlled, so it is never used for anything a third
|
||||
// party would see.
|
||||
func (s *Server) absBase(r *http.Request) string {
|
||||
if s.cfg.PublicURL != "" {
|
||||
return s.cfg.PublicURL + s.cfg.BasePath
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host + s.cfg.BasePath
|
||||
}
|
||||
|
||||
// objectURL builds the download link handed back to an uploader.
|
||||
func (s *Server) objectURL(r *http.Request, id string) string {
|
||||
return s.absBase(r) + "d/" + id
|
||||
}
|
||||
|
||||
type indexPage struct {
|
||||
page
|
||||
MaxSize string
|
||||
MaxExpiry string
|
||||
DefaultExpiry string
|
||||
AbsBase string
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// The page always renders the anonymous tier; the script refreshes it from
|
||||
// /api/limits once a token is entered.
|
||||
lim := auth.Anonymous(s.cfg)
|
||||
s.render(w, http.StatusOK, "index.html", indexPage{
|
||||
page: s.page("Upload", true),
|
||||
MaxSize: config.FormatSize(lim.MaxSize),
|
||||
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
|
||||
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
|
||||
AbsBase: s.absBase(r),
|
||||
})
|
||||
}
|
||||
|
||||
type limitsJSON struct {
|
||||
Name string `json:"name"`
|
||||
MaxSize *int64 `json:"max_size"` // null means unlimited
|
||||
MaxExpiry string `json:"max_expiry"`
|
||||
DefaultExpiry string `json:"default_expiry"`
|
||||
AllowVanity bool `json:"allow_vanity"`
|
||||
}
|
||||
|
||||
// handleLimits reports the permissions belonging to the presented credential,
|
||||
// so the page can show what the caller may actually do.
|
||||
func (s *Server) handleLimits(w http.ResponseWriter, r *http.Request) {
|
||||
// Rate-limited like an upload: this is the one endpoint that reports
|
||||
// whether a token is valid, and it should not be a free oracle.
|
||||
if !s.limiter.allow(clientIP(r, s.cfg), s.now()) {
|
||||
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
|
||||
return
|
||||
}
|
||||
lim, err := s.limitsFor(bearer(r))
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
||||
return
|
||||
}
|
||||
out := limitsJSON{
|
||||
Name: lim.Name,
|
||||
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
|
||||
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
|
||||
AllowVanity: lim.AllowVanity,
|
||||
}
|
||||
if lim.MaxSize != config.Unlimited {
|
||||
n := lim.MaxSize
|
||||
out.MaxSize = &n
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type objectPage struct {
|
||||
page
|
||||
Meta *store.Meta
|
||||
Size string
|
||||
Expires string
|
||||
URL string
|
||||
DeleteToken string
|
||||
}
|
||||
|
||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := store.CleanID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
m, err := s.store.Get(id, s.now())
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "info.html", objectPage{
|
||||
page: s.page(m.Filename, false),
|
||||
Meta: m,
|
||||
Size: config.FormatSize(m.Size),
|
||||
Expires: describeExpiry(m.Expires, s.now()),
|
||||
})
|
||||
}
|
||||
|
||||
// describeExpiry renders a deadline as an absolute time plus how far off it is.
|
||||
func describeExpiry(t *time.Time, now time.Time) string {
|
||||
if t == nil {
|
||||
return "never"
|
||||
}
|
||||
d := t.Sub(now).Round(time.Minute)
|
||||
if d < 0 {
|
||||
return "expired"
|
||||
}
|
||||
return fmt.Sprintf("%s (in %s)", t.UTC().Format("2006-01-02 15:04 MST"),
|
||||
config.FormatDuration(d))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"send/internal/config"
|
||||
)
|
||||
|
||||
// limiter is a per-key token bucket. It is deliberately small: there is no
|
||||
// dependency to pull in for this, and uploads are the only thing worth limiting.
|
||||
type limiter struct {
|
||||
rate float64 // tokens per second
|
||||
burst float64
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newLimiter(perHour, burst int) *limiter {
|
||||
if burst < 1 {
|
||||
burst = 1
|
||||
}
|
||||
return &limiter{
|
||||
rate: float64(perHour) / 3600,
|
||||
burst: float64(burst),
|
||||
buckets: map[string]*bucket{},
|
||||
}
|
||||
}
|
||||
|
||||
// allow consumes a token for key, reporting whether one was available.
|
||||
func (l *limiter) allow(key string, now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &bucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
b.tokens = min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.rate)
|
||||
b.last = now
|
||||
|
||||
l.gcLocked(now)
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// gcLocked drops buckets that have refilled completely and so carry no state.
|
||||
func (l *limiter) gcLocked(now time.Time) {
|
||||
if now.Sub(l.lastGC) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
l.lastGC = now
|
||||
for k, b := range l.buckets {
|
||||
if b.tokens >= l.burst && now.Sub(b.last) > time.Hour {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP identifies the caller for rate-limiting and logging.
|
||||
//
|
||||
// X-Forwarded-For is trusted only when the immediate peer is a configured
|
||||
// proxy, and then only to skip past further trusted hops: the first
|
||||
// untrusted address from the right is the real client. Without this the header
|
||||
// is pure client input and rate limiting would be trivially bypassed.
|
||||
func clientIP(r *http.Request, c *config.Config) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer := net.ParseIP(host)
|
||||
if peer == nil || !c.TrustsProxy(peer) {
|
||||
return host
|
||||
}
|
||||
hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
ip := net.ParseIP(strings.TrimSpace(hops[i]))
|
||||
if ip == nil {
|
||||
break // malformed: stop trusting the rest of the chain
|
||||
}
|
||||
if !c.TrustsProxy(ip) {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package server wires the HTTP surface onto the store and the token file.
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"send/internal/auth"
|
||||
"send/internal/config"
|
||||
"send/internal/store"
|
||||
"send/web"
|
||||
)
|
||||
|
||||
// Server holds everything the handlers need. It is safe for concurrent use.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
tokens *auth.File
|
||||
log *slog.Logger
|
||||
|
||||
pages map[string]*template.Template
|
||||
handler http.Handler
|
||||
limiter *limiter
|
||||
slots chan struct{} // bounds uploads in flight
|
||||
|
||||
now func() time.Time // swappable in tests
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logger) (*Server, error) {
|
||||
pages, err := parsePages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
tokens: tokens,
|
||||
log: log,
|
||||
pages: pages,
|
||||
limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst),
|
||||
slots: make(chan struct{}, cfg.MaxConcurrent),
|
||||
now: time.Now,
|
||||
}
|
||||
s.handler = s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handler.ServeHTTP(w, r) }
|
||||
|
||||
// routes builds the mux and mounts it under the configured base path.
|
||||
func (s *Server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||
mux.HandleFunc("POST /api/upload", s.handleUpload)
|
||||
mux.HandleFunc("GET /api/limits", s.handleLimits)
|
||||
mux.HandleFunc("GET /d/{id}", s.handleDownload)
|
||||
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
||||
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
||||
mux.HandleFunc("/", s.handleNotFound)
|
||||
|
||||
var h http.Handler = mux
|
||||
h = s.securityHeaders(h)
|
||||
|
||||
if s.cfg.BasePath == "/" {
|
||||
return h
|
||||
}
|
||||
// Mounted under a prefix: strip it, and send a bare prefix to the slashed
|
||||
// form so relative links on the page resolve correctly.
|
||||
prefix := strings.TrimSuffix(s.cfg.BasePath, "/")
|
||||
outer := http.NewServeMux()
|
||||
outer.Handle(s.cfg.BasePath, http.StripPrefix(prefix, h))
|
||||
outer.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, s.cfg.BasePath, http.StatusMovedPermanently)
|
||||
})
|
||||
return outer
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets with a long, immutable-ish cache
|
||||
// window kept short enough that an edit shows up without a cache-buster.
|
||||
func (s *Server) staticHandler() http.Handler {
|
||||
fileServer := http.FileServerFS(web.Static())
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// appCSP locks the application pages down to their own origin. The frontend has
|
||||
// no inline script and no third-party anything, so this can be strict.
|
||||
const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " +
|
||||
"img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
// The download handler replaces this with a far stricter policy.
|
||||
h.Set("Content-Security-Policy", appCSP)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- credentials ---------------------------------------------------------
|
||||
|
||||
// errBadToken is returned when a credential is presented but not recognised.
|
||||
// Presenting a wrong token fails the request rather than silently downgrading
|
||||
// the caller to the anonymous tier, where a lower limit would be confusing.
|
||||
var errBadToken = errors.New("unrecognised token")
|
||||
|
||||
// limitsFor resolves the effective permissions for a presented secret. An empty
|
||||
// secret yields the anonymous tier.
|
||||
func (s *Server) limitsFor(secret string) (auth.Limits, error) {
|
||||
if secret == "" {
|
||||
return auth.Anonymous(s.cfg), nil
|
||||
}
|
||||
if err := s.tokens.MaybeReload(); err != nil {
|
||||
s.log.Error("reloading token file", "err", err)
|
||||
}
|
||||
t := s.tokens.Lookup(secret)
|
||||
if t == nil {
|
||||
return auth.Limits{}, errBadToken
|
||||
}
|
||||
return t.Limits(s.cfg), nil
|
||||
}
|
||||
|
||||
// bearer extracts a token from the Authorization header, if present.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if v, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- rendering -----------------------------------------------------------
|
||||
|
||||
var pageNames = []string{"index.html", "result.html", "info.html", "error.html"}
|
||||
|
||||
// parsePages pairs each page with the shared layout. They cannot all be parsed
|
||||
// into one template set because every page defines "content".
|
||||
func parsePages() (map[string]*template.Template, error) {
|
||||
pages := make(map[string]*template.Template, len(pageNames))
|
||||
for _, name := range pageNames {
|
||||
t, err := template.New(name).ParseFS(web.Templates(),
|
||||
"templates/layout.html", "templates/"+name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", name, err)
|
||||
}
|
||||
pages[name] = t
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// page carries the fields every template needs. Page-specific structs embed it.
|
||||
type page struct {
|
||||
Base string
|
||||
Title string
|
||||
Script bool
|
||||
}
|
||||
|
||||
func (s *Server) page(title string, script bool) page {
|
||||
return page{Base: s.cfg.BasePath, Title: title, Script: script}
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, status int, name string, data any) {
|
||||
t, ok := s.pages[name]
|
||||
if !ok {
|
||||
http.Error(w, "template missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Render to memory first so a template failure cannot emit a half page
|
||||
// after the status line has already gone out.
|
||||
var buf strings.Builder
|
||||
if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
s.log.Error("rendering page", "page", name, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, buf.String())
|
||||
}
|
||||
|
||||
// --- errors --------------------------------------------------------------
|
||||
|
||||
// wantsJSON decides the response shape. The JS client asks for JSON explicitly;
|
||||
// a plain form post from a browser leads with text/html.
|
||||
func wantsJSON(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
if strings.Contains(accept, "application/json") {
|
||||
return true
|
||||
}
|
||||
return !strings.Contains(accept, "text/html")
|
||||
}
|
||||
|
||||
type errorPage struct {
|
||||
page
|
||||
Status string
|
||||
Message string
|
||||
}
|
||||
|
||||
// fail writes an error in whichever shape the caller asked for.
|
||||
func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
if wantsJSON(r) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.render(w, status, "error.html", errorPage{
|
||||
page: s.page(http.StatusText(status), false),
|
||||
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, http.StatusNotFound, "No such page.")
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"send/internal/auth"
|
||||
"send/internal/config"
|
||||
"send/internal/store"
|
||||
)
|
||||
|
||||
// clock is the fixed instant tests start from; s.now is swapped so expiry can
|
||||
// be exercised without sleeping.
|
||||
var clock = time.Date(2026, 9, 12, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
type harness struct {
|
||||
*Server
|
||||
ts *httptest.Server
|
||||
dir string
|
||||
now time.Time
|
||||
token string // a token allowing vanity names
|
||||
admin string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, tweak func(*config.Config)) *harness {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
cfg := config.Config{}
|
||||
fs := config.NewSet("test", "SEND_TEST_")
|
||||
cfg.Register(fs)
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.DataDir = dir
|
||||
cfg.MaxSize = 1 << 20
|
||||
cfg.MaxExpiry = 72 * time.Hour
|
||||
cfg.DefaultExpiry = 72 * time.Hour
|
||||
cfg.MinFreeBytes = 0
|
||||
cfg.UploadRate = 100000
|
||||
cfg.UploadBurst = 100000
|
||||
if tweak != nil {
|
||||
tweak(&cfg)
|
||||
}
|
||||
if err := cfg.Normalise(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokens, err := auth.Load(cfg.TokensPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &harness{dir: dir, now: clock}
|
||||
for _, spec := range []struct {
|
||||
name string
|
||||
admin bool
|
||||
dst *string
|
||||
}{{"friend", false, &h.token}, {"boss", true, &h.admin}} {
|
||||
secret, err := store.NewSecret()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tokens.Add(&auth.Token{
|
||||
Name: spec.name, Hash: auth.HashSecret(secret),
|
||||
AllowVanity: true, Admin: spec.admin,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*spec.dst = secret
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv, err := New(&cfg, st, tokens, log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.now = func() time.Time { return h.now }
|
||||
|
||||
h.Server = srv
|
||||
h.ts = httptest.NewServer(srv)
|
||||
t.Cleanup(h.ts.Close)
|
||||
return h
|
||||
}
|
||||
|
||||
// upload posts a raw body, the way curl does.
|
||||
func (h *harness) upload(t *testing.T, body []byte, headers map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
return h.uploadReader(t, bytes.NewReader(body), headers)
|
||||
}
|
||||
|
||||
func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest("POST", h.ts.URL+"/api/upload", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, resp *http.Response) T {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
var v T
|
||||
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
|
||||
t.Fatalf("decoding %s response: %v", resp.Status, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
payload := bytes.Repeat([]byte("godot"), 4096)
|
||||
|
||||
resp := h.upload(t, payload, map[string]string{
|
||||
"Content-Disposition": `attachment; filename="MyGame.zip"`,
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload status = %s", resp.Status)
|
||||
}
|
||||
res := decode[uploadResult](t, resp)
|
||||
|
||||
if res.Filename != "MyGame.zip" {
|
||||
t.Errorf("filename = %q, want MyGame.zip", res.Filename)
|
||||
}
|
||||
if res.Size != int64(len(payload)) {
|
||||
t.Errorf("size = %d, want %d", res.Size, len(payload))
|
||||
}
|
||||
if res.DeleteToken == "" {
|
||||
t.Error("no delete token returned")
|
||||
}
|
||||
|
||||
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer get.Body.Close()
|
||||
got, _ := io.ReadAll(get.Body)
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Errorf("downloaded %d bytes, want %d", len(got), len(payload))
|
||||
}
|
||||
|
||||
// An uploaded file must never come back as something a browser will run.
|
||||
if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
if get.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("missing nosniff")
|
||||
}
|
||||
if !strings.Contains(get.Header.Get("Content-Security-Policy"), "sandbox") {
|
||||
t.Errorf("CSP = %q", get.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
disp, params, err := mime.ParseMediaType(get.Header.Get("Content-Disposition"))
|
||||
if err != nil || disp != "attachment" || params["filename"] != "MyGame.zip" {
|
||||
t.Errorf("Content-Disposition = %q (%v)", get.Header.Get("Content-Disposition"), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLUploadIsServedInert(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
resp := h.upload(t, []byte("<script>alert(1)</script>"), map[string]string{
|
||||
"Content-Disposition": `attachment; filename="evil.html"`,
|
||||
})
|
||||
res := decode[uploadResult](t, resp)
|
||||
|
||||
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer get.Body.Close()
|
||||
if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Errorf("HTML served as %q; it must never be text/html", ct)
|
||||
}
|
||||
if !strings.HasPrefix(get.Header.Get("Content-Disposition"), "attachment") {
|
||||
t.Error("HTML was not served as an attachment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeRequest(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
payload := bytes.Repeat([]byte("abcdefgh"), 1024)
|
||||
res := decode[uploadResult](t, h.upload(t, payload, nil))
|
||||
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/d/"+res.ID, nil)
|
||||
req.Header.Set("Range", "bytes=0-1023")
|
||||
get, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer get.Body.Close()
|
||||
if get.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("status = %s, want 206", get.Status)
|
||||
}
|
||||
body, _ := io.ReadAll(get.Body)
|
||||
if len(body) != 1024 || !bytes.Equal(body, payload[:1024]) {
|
||||
t.Errorf("got %d bytes, want the first 1024", len(body))
|
||||
}
|
||||
if cr := get.Header.Get("Content-Range"); cr != fmt.Sprintf("bytes 0-1023/%d", len(payload)) {
|
||||
t.Errorf("Content-Range = %q", cr)
|
||||
}
|
||||
}
|
||||
|
||||
// An oversized body must be refused on bytes actually written, never on a
|
||||
// declared length. This sends a chunked body, so there is no Content-Length to
|
||||
// consult even if the code wanted to.
|
||||
func TestOversizeChunkedUploadIsRefused(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { c.MaxSize = 4096 })
|
||||
|
||||
// A plain io.Reader (not a *bytes.Buffer) makes the client use chunked
|
||||
// encoding with no declared length.
|
||||
body := io.LimitReader(zeroes{}, 1<<20)
|
||||
resp := h.uploadReader(t, struct{ io.Reader }{body}, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %s, want 413", resp.Status)
|
||||
}
|
||||
if n := h.store.Count(); n != 0 {
|
||||
t.Errorf("%d objects stored after a refused upload", n)
|
||||
}
|
||||
assertNoDebris(t, h.dir)
|
||||
}
|
||||
|
||||
// A body one byte over the cap is refused; exactly at the cap is accepted.
|
||||
func TestSizeLimitBoundary(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { c.MaxSize = 1000 })
|
||||
|
||||
resp := h.upload(t, bytes.Repeat([]byte("x"), 1000), nil)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("exactly at the limit: status = %s, want 201", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp = h.upload(t, bytes.Repeat([]byte("x"), 1001), nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("one byte over: status = %s, want 413", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type zeroes struct{}
|
||||
|
||||
func (zeroes) Read(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func TestAnonymousCannotClaimVanity(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
resp := h.upload(t, []byte("hi"), map[string]string{"Vanity": "my-file"})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %s, want 403", resp.Status)
|
||||
}
|
||||
if h.store.Exists("my-file") {
|
||||
t.Error("the name was claimed despite the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVanityCollision(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
hdr := map[string]string{
|
||||
"Vanity": "my-file",
|
||||
"Authorization": "Bearer " + h.token,
|
||||
}
|
||||
resp := h.upload(t, []byte("first"), hdr)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first upload: status = %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp = h.upload(t, []byte("second"), hdr)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("second upload: status = %s, want 409", resp.Status)
|
||||
}
|
||||
|
||||
// The first object must be untouched.
|
||||
get, err := h.ts.Client().Get(h.ts.URL + "/d/my-file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer get.Body.Close()
|
||||
body, _ := io.ReadAll(get.Body)
|
||||
if string(body) != "first" {
|
||||
t.Errorf("content = %q, want %q", body, "first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownTokenIsRejected(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
resp := h.upload(t, []byte("hi"), map[string]string{"Authorization": "Bearer nope"})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %s, want 401", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry is enforced on read, not only by the sweeper, which never runs here.
|
||||
func TestExpiryIsCheckedOnRead(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("ephemeral"), map[string]string{
|
||||
"Expiry": "1h",
|
||||
}))
|
||||
|
||||
status := func(when time.Duration) int {
|
||||
h.now = clock.Add(when)
|
||||
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get.Body.Close()
|
||||
return get.StatusCode
|
||||
}
|
||||
|
||||
if got := status(59 * time.Minute); got != http.StatusOK {
|
||||
t.Fatalf("before expiry: status = %d, want 200", got)
|
||||
}
|
||||
if got := status(61 * time.Minute); got != http.StatusNotFound {
|
||||
t.Fatalf("after expiry: status = %d, want 404", got)
|
||||
}
|
||||
// The read path also reclaims the space.
|
||||
if _, err := os.Stat(filepath.Join(h.dir, "objects", res.ID)); !os.IsNotExist(err) {
|
||||
t.Error("expired object was not removed on read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryBeyondLimitIsRefused(t *testing.T) {
|
||||
h := newHarness(t, nil) // max 72h for anonymous
|
||||
for _, req := range []string{"30d", "never"} {
|
||||
resp := h.upload(t, []byte("hi"), map[string]string{"Expiry": req})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expiry: %s => status %s, want 400", req, resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMayOutliveTheAnonymousLimit(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
forever := "never"
|
||||
if err := h.tokens.Remove("friend"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, _ := store.NewSecret()
|
||||
if err := h.tokens.Add(&auth.Token{
|
||||
Name: "friend", Hash: auth.HashSecret(secret),
|
||||
MaxExpiry: &forever, DefaultExpiry: &forever, AllowVanity: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("keep me"), map[string]string{
|
||||
"Authorization": "Bearer " + secret,
|
||||
"Expiry": "never",
|
||||
}))
|
||||
if res.Expires != "" {
|
||||
t.Errorf("expires = %q, want empty (never)", res.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalIsRejected(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// As a requested vanity name.
|
||||
for _, name := range []string{"../etc/passwd", "..", ".", "/absolute", "a/b", `a\b`, "ok..name"} {
|
||||
resp := h.upload(t, []byte("x"), map[string]string{
|
||||
"Vanity": name,
|
||||
"Authorization": "Bearer " + h.token,
|
||||
})
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
t.Errorf("vanity %q was accepted", name)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// As a download path. Some of these are normalised away into a redirect to
|
||||
// the index, which is harmless; what matters is that no stored bytes are
|
||||
// ever served, so the check is for an object response rather than a status.
|
||||
for _, path := range []string{
|
||||
"/d/..%2f..%2fetc%2fpasswd", "/d/.", "/d/..", "/d/%2e%2e",
|
||||
"/d/../tokens.json", "/d/%2e%2e%2ftokens.json", "/d/objects",
|
||||
} {
|
||||
get, err := h.ts.Client().Get(h.ts.URL + path)
|
||||
if err != nil {
|
||||
continue // the client itself may refuse to send it, which is fine
|
||||
}
|
||||
get.Body.Close()
|
||||
if get.Header.Get("Content-Disposition") != "" {
|
||||
t.Errorf("GET %s served an object", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedNamesAreRejected(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
for _, name := range []string{"api", "static", "d", "i", "robots.txt"} {
|
||||
resp := h.upload(t, []byte("x"), map[string]string{
|
||||
"Vanity": name,
|
||||
"Authorization": "Bearer " + h.token,
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("vanity %q => %s, want 400", name, resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRequiresTheRightToken(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("delete me"), nil))
|
||||
|
||||
del := func(token string) int {
|
||||
form := strings.NewReader("token=" + token)
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
if got := del("wrong-token"); got != http.StatusForbidden {
|
||||
t.Errorf("wrong token => %d, want 403", got)
|
||||
}
|
||||
if got := del(h.token); got != http.StatusForbidden {
|
||||
t.Errorf("a non-owning, non-admin token => %d, want 403", got)
|
||||
}
|
||||
if got := del(res.DeleteToken); got != http.StatusOK {
|
||||
t.Errorf("correct token => %d, want 200", got)
|
||||
}
|
||||
// The token is single-use because the object it names is gone.
|
||||
if got := del(res.DeleteToken); got != http.StatusNotFound {
|
||||
t.Errorf("reused token => %d, want 404", got)
|
||||
}
|
||||
assertNoDebris(t, h.dir)
|
||||
}
|
||||
|
||||
func TestAdminMayDeleteAnything(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
|
||||
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+h.admin)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin delete => %s, want 200", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartUpload(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
// Order matters: the server needs these before the file part arrives.
|
||||
mw.WriteField("token", h.token)
|
||||
mw.WriteField("expiry", "2h")
|
||||
mw.WriteField("vanity", "from-the-form")
|
||||
fw, err := mw.CreateFormFile("file", "notes (draft).txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write([]byte("hello from a browser"))
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %s", resp.Status)
|
||||
}
|
||||
res := decode[uploadResult](t, resp)
|
||||
if res.ID != "from-the-form" {
|
||||
t.Errorf("id = %q, want from-the-form", res.ID)
|
||||
}
|
||||
if res.Filename != "notes (draft).txt" {
|
||||
t.Errorf("filename = %q", res.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
// A form post with no Accept: application/json gets the HTML success page, so
|
||||
// the no-JS path works.
|
||||
func TestFormPostRendersHTML(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, _ := mw.CreateFormFile("file", "thing.bin")
|
||||
fw.Write([]byte("data"))
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Fatalf("Content-Type = %q, want HTML", ct)
|
||||
}
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(page), "Delete token") {
|
||||
t.Error("the success page does not show the delete token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasePathMounting(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { c.BasePath = "/send" })
|
||||
|
||||
get, err := h.ts.Client().Get(h.ts.URL + "/send/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer get.Body.Close()
|
||||
if get.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /send/ => %s", get.Status)
|
||||
}
|
||||
page, _ := io.ReadAll(get.Body)
|
||||
if !strings.Contains(string(page), `href="/send/static/style.css"`) {
|
||||
t.Error("page links do not carry the base path")
|
||||
}
|
||||
|
||||
// The bare prefix redirects to the slashed form.
|
||||
noRedirect := *h.ts.Client()
|
||||
noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
resp, err := noRedirect.Get(h.ts.URL + "/send")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMovedPermanently {
|
||||
t.Errorf("GET /send => %s, want 301", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// An upload that dies mid-flight must leave nothing visible behind, and the
|
||||
// sweeper must eventually reclaim the directory.
|
||||
func TestAbandonedUploadIsInvisibleAndSwept(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
up, err := h.store.Reserve("half-done")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Write([]byte("partial"))
|
||||
// Deliberately no Commit and no Abort: this is what a killed process leaves.
|
||||
|
||||
get, err := h.ts.Client().Get(h.ts.URL + "/d/half-done")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get.Body.Close()
|
||||
if get.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an uncommitted object was visible: %s", get.Status)
|
||||
}
|
||||
|
||||
dir := filepath.Join(h.dir, "objects", "half-done")
|
||||
old := clock.Add(-48 * time.Hour)
|
||||
if err := os.Chtimes(dir, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.store.Sweep(clock)
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Error("abandoned upload directory was not swept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaRefusesUploads(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { c.MaxTotalBytes = 100 })
|
||||
|
||||
resp := h.upload(t, bytes.Repeat([]byte("x"), 80), nil)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first upload => %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Only 20 bytes of quota remain, so this is truncated to the remainder and
|
||||
// refused rather than allowed to overshoot.
|
||||
resp = h.upload(t, bytes.Repeat([]byte("x"), 80), nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("over quota => %s, want 413", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimit(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
c.UploadRate = 1
|
||||
c.UploadBurst = 2
|
||||
})
|
||||
var last *http.Response
|
||||
for range 3 {
|
||||
if last != nil {
|
||||
last.Body.Close()
|
||||
}
|
||||
last = h.upload(t, []byte("x"), nil)
|
||||
}
|
||||
defer last.Body.Close()
|
||||
if last.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("third upload => %s, want 429", last.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoDebris checks that no object directory was left behind.
|
||||
func assertNoDebris(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(filepath.Join(dir, "objects"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
t.Errorf("leftover object directory: %s", e.Name())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"send/internal/auth"
|
||||
"send/internal/config"
|
||||
"send/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxFieldBytes and maxFieldCount bound the non-file portion of a multipart
|
||||
// body. The file part needs no such bound: the store's own limit stops it
|
||||
// at exactly the caller's cap.
|
||||
maxFieldBytes = 4 << 10
|
||||
maxFieldCount = 16
|
||||
|
||||
fileFieldName = "file"
|
||||
|
||||
// stallTimeout is how long a single read from the body may take. It is
|
||||
// reset on every successful read, so a slow upload is fine and a stalled
|
||||
// one is not.
|
||||
stallTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
var errFieldTooLarge = errors.New("form field is too large")
|
||||
|
||||
// uploadRequest is the set of knobs a caller may turn, however they arrived.
|
||||
type uploadRequest struct {
|
||||
token string
|
||||
vanity string
|
||||
expiry string
|
||||
filename string
|
||||
}
|
||||
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r, s.cfg)
|
||||
if !s.limiter.allow(ip, s.now()) {
|
||||
s.fail(w, r, http.StatusTooManyRequests, "Too many uploads; try again shortly.")
|
||||
return
|
||||
}
|
||||
|
||||
// Bound concurrency so a handful of multi-gigabyte uploads cannot starve
|
||||
// the disk or the machine.
|
||||
select {
|
||||
case s.slots <- struct{}{}:
|
||||
defer func() { <-s.slots }()
|
||||
default:
|
||||
w.Header().Set("Retry-After", "30")
|
||||
s.fail(w, r, http.StatusServiceUnavailable, "Too many uploads in flight; try again shortly.")
|
||||
return
|
||||
}
|
||||
|
||||
mediatype, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err == nil && mediatype == "multipart/form-data" {
|
||||
s.uploadMultipart(w, r, params["boundary"], ip)
|
||||
return
|
||||
}
|
||||
s.uploadRaw(w, r, ip)
|
||||
}
|
||||
|
||||
// uploadRaw handles a body that is nothing but the file, as sent by curl.
|
||||
// Options ride along in headers.
|
||||
func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) {
|
||||
req := uploadRequest{
|
||||
token: bearer(r),
|
||||
vanity: strings.TrimSpace(r.Header.Get("Vanity")),
|
||||
expiry: strings.TrimSpace(r.Header.Get("Expiry")),
|
||||
filename: filenameFromDisposition(r.Header.Get("Content-Disposition")),
|
||||
}
|
||||
s.storeUpload(w, r, req, r.Body, ip)
|
||||
}
|
||||
|
||||
// uploadMultipart streams a browser form post.
|
||||
//
|
||||
// The body is read with multipart.Reader rather than ParseMultipartForm: the
|
||||
// latter spools the whole upload into its own temporary files with its own
|
||||
// limits, which for a 2 GiB body is exactly what we are trying to avoid. The
|
||||
// consequence is that fields must arrive before the file part, since the limits
|
||||
// they select have to be known before the first byte of the file is accepted.
|
||||
func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundary, ip string) {
|
||||
if boundary == "" {
|
||||
s.fail(w, r, http.StatusBadRequest, "Malformed multipart body: no boundary.")
|
||||
return
|
||||
}
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
req := uploadRequest{token: bearer(r)}
|
||||
|
||||
for n := 0; ; n++ {
|
||||
if n > maxFieldCount {
|
||||
s.fail(w, r, http.StatusBadRequest, "Too many form fields.")
|
||||
return
|
||||
}
|
||||
part, err := mr.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
s.fail(w, r, http.StatusBadRequest, "No file was included in the upload.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusBadRequest, "Malformed multipart body.")
|
||||
return
|
||||
}
|
||||
|
||||
if part.FormName() == fileFieldName {
|
||||
if req.filename == "" {
|
||||
req.filename = part.FileName()
|
||||
}
|
||||
s.storeUpload(w, r, req, part, ip)
|
||||
return
|
||||
}
|
||||
|
||||
value, err := readField(part)
|
||||
part.Close()
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusBadRequest, "A form field was too large.")
|
||||
return
|
||||
}
|
||||
switch part.FormName() {
|
||||
case "token":
|
||||
if value != "" {
|
||||
req.token = value
|
||||
}
|
||||
case "vanity":
|
||||
req.vanity = strings.TrimSpace(value)
|
||||
case "expiry":
|
||||
req.expiry = strings.TrimSpace(value)
|
||||
case "filename":
|
||||
req.filename = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readField(p *multipart.Part) (string, error) {
|
||||
b, err := io.ReadAll(io.LimitReader(p, maxFieldBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(b) > maxFieldBytes {
|
||||
return "", errFieldTooLarge
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// filenameFromDisposition reads a filename from a request-side
|
||||
// Content-Disposition header. There is no standard for using the header this
|
||||
// way, but it is the established convention, and mime.ParseMediaType already
|
||||
// understands both the plain and the RFC 5987 encoded forms.
|
||||
func filenameFromDisposition(h string) string {
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(h)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return params["filename"]
|
||||
}
|
||||
|
||||
// storeUpload is the common tail of both upload shapes: resolve the caller's
|
||||
// limits, claim a name, stream the bytes, then publish.
|
||||
func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadRequest, body io.Reader, ip string) {
|
||||
now := s.now()
|
||||
|
||||
lim, err := s.limitsFor(req.token)
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
||||
return
|
||||
}
|
||||
if req.vanity != "" && !lim.AllowVanity {
|
||||
s.fail(w, r, http.StatusForbidden, "Custom names require a token.")
|
||||
return
|
||||
}
|
||||
expires, err := resolveExpiry(req.expiry, lim, now)
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
limit, err := s.capacity(lim.MaxSize)
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusInsufficientStorage, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Claim the name before reading a single byte, so a taken vanity name
|
||||
// fails instantly rather than after a multi-gigabyte transfer.
|
||||
up, err := s.reserve(req.vanity)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrExists):
|
||||
s.fail(w, r, http.StatusConflict, "That name is already taken.")
|
||||
return
|
||||
case errors.Is(err, store.ErrBadID):
|
||||
s.fail(w, r, http.StatusBadRequest,
|
||||
"A custom name must be 2-64 characters of letters, digits, dot, dash or underscore.")
|
||||
return
|
||||
case err != nil:
|
||||
s.log.Error("reserving object", "err", err)
|
||||
s.fail(w, r, http.StatusInternalServerError, "Could not store the file.")
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
up.Abort()
|
||||
}
|
||||
}()
|
||||
|
||||
up.SetLimit(limit)
|
||||
if _, err := io.Copy(up, guardStalls(w, body)); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrTooLarge):
|
||||
s.fail(w, r, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("That file is larger than the %s limit.", config.FormatSize(limit)))
|
||||
default:
|
||||
// A disconnect mid-upload lands here; there is rarely anyone left
|
||||
// to read the response.
|
||||
s.log.Info("upload aborted", "ip", ip, "id", up.ID(), "bytes", up.Size(), "err", err)
|
||||
s.fail(w, r, http.StatusBadRequest, "The upload did not complete.")
|
||||
}
|
||||
return
|
||||
}
|
||||
clearDeadline(w)
|
||||
|
||||
secret, err := store.NewSecret()
|
||||
if err != nil {
|
||||
s.log.Error("generating delete token", "err", err)
|
||||
s.fail(w, r, http.StatusInternalServerError, "Could not store the file.")
|
||||
return
|
||||
}
|
||||
|
||||
m := &store.Meta{
|
||||
Filename: store.SanitizeFilename(req.filename),
|
||||
Created: now,
|
||||
Expires: expires,
|
||||
Owner: lim.Name,
|
||||
Vanity: req.vanity != "",
|
||||
DeleteHash: auth.HashSecret(secret),
|
||||
}
|
||||
if err := up.Commit(m); err != nil {
|
||||
s.log.Error("committing object", "id", up.ID(), "err", err)
|
||||
s.fail(w, r, http.StatusInternalServerError, "Could not store the file.")
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name),
|
||||
"ip", ip, "expires", m.Expires)
|
||||
s.respondUploaded(w, r, m, secret)
|
||||
}
|
||||
|
||||
func orAnonymous(name string) string {
|
||||
if name == "" {
|
||||
return "(anonymous)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// reserve claims either the requested vanity name or a fresh UUIDv4.
|
||||
func (s *Server) reserve(vanity string) (*store.Upload, error) {
|
||||
if vanity == "" {
|
||||
return s.store.ReserveRandom()
|
||||
}
|
||||
id, err := store.CleanID(vanity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.Reserve(id)
|
||||
}
|
||||
|
||||
// capacity narrows the caller's own limit to what the store can still hold.
|
||||
func (s *Server) capacity(callerLimit int64) (int64, error) {
|
||||
full := errors.New("The service is out of space; try again later.")
|
||||
limit := callerLimit
|
||||
|
||||
if s.cfg.MaxTotalBytes != config.Unlimited {
|
||||
remaining := s.cfg.MaxTotalBytes - s.store.Total()
|
||||
if remaining <= 0 {
|
||||
return 0, full
|
||||
}
|
||||
if limit == config.Unlimited || remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
}
|
||||
if s.cfg.MinFreeBytes > 0 {
|
||||
if free, ok := freeBytes(s.store.DataDir()); ok {
|
||||
usable := free - s.cfg.MinFreeBytes
|
||||
if usable <= 0 {
|
||||
return 0, full
|
||||
}
|
||||
if limit == config.Unlimited || usable < limit {
|
||||
limit = usable
|
||||
}
|
||||
}
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
// resolveExpiry turns a requested lifetime into a deadline, refusing anything
|
||||
// longer than the caller is entitled to.
|
||||
func resolveExpiry(requested string, lim auth.Limits, now time.Time) (*time.Time, error) {
|
||||
d := lim.DefaultExpiry
|
||||
if requested != "" {
|
||||
var err error
|
||||
if d, err = config.ParseDuration(requested); err != nil {
|
||||
return nil, fmt.Errorf("%s; try something like 3d, 12h or 90m", err)
|
||||
}
|
||||
}
|
||||
if d == config.Unlimited {
|
||||
if lim.MaxExpiry != config.Unlimited {
|
||||
return nil, fmt.Errorf("files here cannot be kept indefinitely; the longest lifetime available to you is %s",
|
||||
config.FormatDuration(lim.MaxExpiry))
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if lim.MaxExpiry != config.Unlimited && d > lim.MaxExpiry {
|
||||
return nil, fmt.Errorf("the longest lifetime available to you is %s",
|
||||
config.FormatDuration(lim.MaxExpiry))
|
||||
}
|
||||
if d < time.Minute {
|
||||
return nil, errors.New("the shortest lifetime is one minute")
|
||||
}
|
||||
t := now.Add(d)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// guardStalls resets the connection's read deadline before every read, so a
|
||||
// legitimately slow transfer survives while a stalled one is dropped. The
|
||||
// server's own ReadTimeout cannot do this job: it would have to be long enough
|
||||
// for the largest permitted upload, which is no protection at all.
|
||||
func guardStalls(w http.ResponseWriter, r io.Reader) io.Reader {
|
||||
rc := http.NewResponseController(w)
|
||||
if err := rc.SetReadDeadline(time.Now().Add(stallTimeout)); err != nil {
|
||||
return r // not a real connection (tests); nothing to guard
|
||||
}
|
||||
return &stallGuard{r: r, rc: rc}
|
||||
}
|
||||
|
||||
type stallGuard struct {
|
||||
r io.Reader
|
||||
rc *http.ResponseController
|
||||
}
|
||||
|
||||
func (g *stallGuard) Read(p []byte) (int, error) {
|
||||
g.rc.SetReadDeadline(time.Now().Add(stallTimeout))
|
||||
return g.r.Read(p)
|
||||
}
|
||||
|
||||
func clearDeadline(w http.ResponseWriter) {
|
||||
http.NewResponseController(w).SetReadDeadline(time.Time{})
|
||||
}
|
||||
|
||||
type uploadResult struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Expires string `json:"expires"` // RFC 3339, or "" for never
|
||||
URL string `json:"url"`
|
||||
InfoURL string `json:"info_url"`
|
||||
DeleteToken string `json:"delete_token"`
|
||||
DeleteURL string `json:"delete_url"`
|
||||
}
|
||||
|
||||
// respondUploaded answers in whichever shape the caller asked for. The delete
|
||||
// token appears exactly once, here, and is never recoverable afterwards.
|
||||
func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *store.Meta, secret string) {
|
||||
url := s.objectURL(r, m.ID)
|
||||
if wantsJSON(r) {
|
||||
expires := ""
|
||||
if m.Expires != nil {
|
||||
expires = m.Expires.UTC().Format(time.RFC3339)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, uploadResult{
|
||||
ID: m.ID, Filename: m.Filename, Size: m.Size, SHA256: m.SHA256,
|
||||
Expires: expires,
|
||||
URL: url,
|
||||
InfoURL: s.absBase(r) + "i/" + m.ID,
|
||||
DeleteToken: secret,
|
||||
DeleteURL: s.absBase(r) + "api/d/" + m.ID + "/delete",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Rendered directly rather than redirected: a 303 would have to carry the
|
||||
// delete token in the URL, where it would end up in logs and history.
|
||||
s.render(w, http.StatusOK, "result.html", objectPage{
|
||||
page: s.page("Uploaded", false),
|
||||
Meta: m,
|
||||
Size: config.FormatSize(m.Size),
|
||||
Expires: describeExpiry(m.Expires, s.now()),
|
||||
URL: url,
|
||||
DeleteToken: secret,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user