Files
uncensored-send/internal/server/upload.go
T
2026-09-12 23:48:03 +02:00

437 lines
13 KiB
Go

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
// remember is set by the form's checkbox. It decides whether a token used
// here is stored in a cookie for next time, and unchecking it is how a
// remembered token is cleared from the upload page itself.
remember bool
explicit bool // the token was typed or sent, not read back from the cookie
}
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")),
}
req.explicit = req.token != ""
if req.token == "" {
req.token = cookieCredential(r)
}
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)}
req.explicit = req.token != ""
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()
}
// Nothing explicit was supplied, so fall back to what the browser
// remembered. This happens after the fields precisely so a typed
// token still wins.
if req.token == "" {
req.token = cookieCredential(r)
}
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 v := strings.TrimSpace(value); v != "" {
req.token, req.explicit = v, true
}
case "remember":
req.remember = true
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.updateRemembered(w, r, req, lim)
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)
}
// updateRemembered applies the form's "remember" checkbox to the cookie. It
// only ever acts on a browser form post: an API caller sending a bearer token
// has its own way of keeping credentials and should not be handed a cookie.
func (s *Server) updateRemembered(w http.ResponseWriter, r *http.Request, req uploadRequest, lim auth.Limits) {
if bearer(r) != "" {
return
}
switch {
case req.remember && req.explicit && lim.Name != "":
s.remember(w, r, req.token)
case !req.remember && cookieCredential(r) != "":
s.forget(w, r)
}
}
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,
})
}