package server import ( "errors" "fmt" "io" "mime" "mime/multipart" "net/http" "strings" "time" "uncensored-send/internal/auth" "uncensored-send/internal/config" "uncensored-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")), } 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) // Headers seed the request even here, so that a caller mixing the two // shapes, curl -F with a Vanity header, say, is not silently given a // UUID instead of the name they asked for. A non-empty form field of the // same meaning overrides them. req := uploadRequest{ token: bearer(r), vanity: strings.TrimSpace(r.Header.Get("Vanity")), expiry: strings.TrimSpace(r.Header.Get("Expiry")), } 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 = v } case "vanity": if v := strings.TrimSpace(value); v != "" { req.vanity = v } case "expiry": if v := strings.TrimSpace(value); v != "" { req.expiry = v } 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(r, 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{ // The script is loaded here only to enable the copy buttons, which stay // hidden without it rather than sitting there dead. page: s.page(r, "Uploaded", true), Meta: m, Size: config.FormatSize(m.Size), Expires: describeExpiry(m.Expires, s.now()), URL: url, InfoURL: s.absBase(r) + "i/" + m.ID, DeleteToken: secret, }) }