426 lines
12 KiB
Go
426 lines
12 KiB
Go
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,
|
|
})
|
|
}
|