// SPDX-FileCopyrightText: 2021 Luke Granger-Brown // // 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 :( } }