251 lines
6.3 KiB
Go
251 lines
6.3 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
|
|
}
|
|
|
|
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 {
|
|
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 :(
|
|
}
|
|
}
|