depot/web/fup/fuphttp/httpupload.go
Luke Granger-Brown b4e785af8a fup: fix minor issue with shorter-than-512 byte file uploads
Reader.Read is permitted to return EOF on short reads, which had not been
anticipated by this code. There's probably more instances of this lurking...
2021-03-22 02:42:34 +00:00

252 lines
6.4 KiB
Go

// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
//
// SPDX-License-Identifier: Apache-2.0
package fuphttp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/gorilla/mux"
"gocloud.dev/blob"
"hg.lukegb.com/lukegb/depot/web/fup/fuphttp/fngen"
)
// parseExpiry parses an expiry string.
// This is one of:
// 1) The empty string - this returns the zero time.
// 2) An explicit RFC3339 time.
// 3) A string accepted by ParseDuration.
func parseExpiry(expStr string) (time.Time, error) {
if expStr == "" {
return time.Time{}, nil
}
t, err := time.Parse(time.RFC3339, expStr)
if err == nil {
return t, nil
}
duration, err := time.ParseDuration(expStr)
if err == nil {
return time.Now().Add(duration), nil
}
return time.Time{}, fmt.Errorf("unable to parse %q as RFC3339 or Go duration", expStr)
}
// metaField returns the metadata field named by name.
// It tries the following, in order, returning the first one:
// 1) HTTP Header Fup-{name}
// 2) POST/PUT body parameter {name}
// 3) Query string parameter {name}
func metaField(r *http.Request, name string) string {
v := r.Header.Get(fmt.Sprintf("Fup-%s", name))
if v != "" {
return v
}
return r.FormValue("expiry")
}
var (
// knownArchives is a list of known non-archiving compression programs.
// This list is mostly taken from https://www.gnu.org/software/tar/manual/html_node/gzip.html.
knownArchives = map[string]bool{
".gz": true,
".bz2": true,
".Z": true,
".lz": true,
".lzma": true,
".tlz": true,
".lzo": true,
".xz": true,
".zst": true,
}
)
func splitExtOnce(fn string) (string, string) {
ext := path.Ext(fn)
return fn[:len(fn)-len(ext)], ext
}
// fileExt splits a filename into its "prefix" and its "file extension".
// It has special behaviour for known multipart extensions, like tar.gz.
func fileExt(fn string) (string, string) {
firstPre, firstExt := splitExtOnce(fn)
if !knownArchives[firstExt] {
return firstPre, firstExt
}
secondPre, secondExt := splitExtOnce(firstPre)
return secondPre, secondExt + firstExt
}
// UploadResponse is the JSON object returned when an upload completes.
type UploadResponse struct {
URL string `json:"url"`
DirectURL string `json:"direct_url"`
DisplayURL string `json:"display_url"`
Filename string `json:"filename"`
Expiry *time.Time `json:"expiry"`
}
func (a *Application) upload(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
meta := &Metadata{
Attributes: &blob.Attributes{},
}
var err error
meta.ExpiresAt, err = parseExpiry(metaField(r, "expiry"))
if err != nil {
log.Printf("upload: parsing expiry: %v", err)
a.badRequest(rw, r, fmt.Errorf("parsing expiry: %v", err))
return
}
var contentType string
if r.Header.Get("Content-Type") != "" {
contentType, _, err = mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
log.Printf("upload: parsing content-type header: %v", err)
a.badRequest(rw, r, fmt.Errorf("parsing content-type %q: %v", r.Header.Get("Content-Type"), err))
return
}
}
var rdr io.Reader = r.Body
defer r.Body.Close()
origContentType := contentType
var origFilename string
var origExt string
if r.Method == "PUT" {
vars := mux.Vars(r)
origFilename, origExt = fileExt(vars["filename"])
} else if contentType == "multipart/form-data" {
file, hdrs, err := r.FormFile("file")
if err != nil {
log.Printf("upload: multipart: failed to get file: %v", err)
a.badRequest(rw, r, fmt.Errorf("parsing multipart data: %v", err))
return
}
rdr = file
defer file.Close()
origFilename, origExt = fileExt(hdrs.Filename)
origContentType = hdrs.Header.Get("Content-Type")
} else {
if r.PostFormValue("content") == "" {
log.Printf("upload: empty content")
a.badRequest(rw, r, errors.New("empty content"))
return
}
origContentType = ""
origExt = r.PostFormValue("extension")
if origExt == "" {
origExt = ".txt"
} else if origExt[0] != '.' {
origExt = "." + origExt
}
rdr = strings.NewReader(r.PostFormValue("content"))
}
// Find a mime type for the uploaded file.
mimeType := origContentType
fileExt := origExt
if mimeType == "" && origExt != "" {
// Try from the file extension instead...
mimeType = mime.TypeByExtension(origExt)
}
if mimeType == "" {
// We'll need to sniff it...
buf := make([]byte, 512)
if _, err := r.Body.Read(buf); err != nil && !errors.Is(err, io.EOF) {
log.Printf("upload: Read for MIME sniffing: %v", err)
a.internalError(rw, r)
return
}
m := mimetype.Detect(buf)
mimeType = m.String()
if fileExt == "" {
fileExt = m.Extension()
}
rdr = io.MultiReader(bytes.NewReader(buf), rdr)
}
// Compute the new filename.
newName, err := fngen.UniqueName(ctx, a.storageBackend.Exists, origFilename, fileExt, a.filenameGenerator)
if err != nil {
log.Printf("upload: UniqueName: %v", err)
a.internalError(rw, r)
return
}
// Now we build the upload options...
meta.Attributes.ContentType = mimeType
wctx, wcancel := context.WithCancel(ctx)
defer wcancel()
w, err := a.storageBackend.NewWriter(wctx, newName, meta.WriterOptions())
if err != nil {
log.Printf("upload: NewWriter(%q): %v", newName, err)
return
}
if _, err := io.Copy(w, rdr); err != nil {
log.Printf("upload: Copy for %q: %v", newName, err)
wcancel()
w.Close()
a.internalError(rw, r)
return
}
if err := w.Close(); err != nil {
log.Printf("upload: Close(%q): %v", newName, err)
a.internalError(rw, r)
return
}
resp := UploadResponse{
URL: a.appURL(url.PathEscape(newName)),
DirectURL: a.appURL("raw/" + url.PathEscape(newName)),
}
if a.useDirectDownload(newName, mimeType) {
resp.DisplayURL = resp.DirectURL
} else {
resp.DisplayURL = resp.URL
}
resp.Filename = newName
if !meta.ExpiresAt.IsZero() {
resp.Expiry = &meta.ExpiresAt
}
// This is technically wrong: we don't implement content negotiation properly. Oh well.
if strings.ToLower(r.Header.Get("Accept")) != "application/json" {
http.Redirect(rw, r, resp.DisplayURL, http.StatusSeeOther)
return
}
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(rw).Encode(&resp); err != nil {
log.Printf("upload: writing JSON response: %v", err)
// It's too late to return a proper error :(
}
}