Luke Granger-Brown
b4e785af8a
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...
252 lines
6.4 KiB
Go
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 :(
|
|
}
|
|
}
|