fup: implement file uploads!
TODO: tests for this.
This commit is contained in:
parent
a3601c2946
commit
2306915e2c
7 changed files with 365 additions and 3 deletions
|
@ -9,6 +9,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/safehtml"
|
"github.com/google/safehtml"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
@ -19,23 +20,35 @@ import (
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(serveCmd)
|
rootCmd.AddCommand(serveCmd)
|
||||||
|
|
||||||
|
serveCmd.Flags().StringVar(&serveRoot, "root", "http://localhost:8191/", "Application root address.")
|
||||||
|
serveCmd.Flags().StringVar(&serveStaticRoot, "static-root", "/static/", "Root address from which static assets should be referenced.")
|
||||||
serveCmd.Flags().StringVarP(&serveBind, "listen", "l", ":8191", "Bind address for HTTP server.")
|
serveCmd.Flags().StringVarP(&serveBind, "listen", "l", ":8191", "Bind address for HTTP server.")
|
||||||
serveCmd.Flags().BoolVar(&serveDirectOnly, "direct-only", false, "If set, all file serving will be proxied, even if the backend supports signed URLs.")
|
serveCmd.Flags().BoolVar(&serveDirectOnly, "direct-only", false, "If set, all file serving will be proxied, even if the backend supports signed URLs.")
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serveBind string
|
serveBind string
|
||||||
|
serveRoot string
|
||||||
|
serveStaticRoot string
|
||||||
serveDirectOnly bool
|
serveDirectOnly bool
|
||||||
|
|
||||||
serveCmd = &cobra.Command{
|
serveCmd = &cobra.Command{
|
||||||
Use: "serve",
|
Use: "serve",
|
||||||
Short: "Serve HTTP",
|
Short: "Serve HTTP",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !strings.HasSuffix(serveRoot, "/") {
|
||||||
|
return fmt.Errorf("--root flag should end in / (value is %q)", serveRoot)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(serveStaticRoot, "/") {
|
||||||
|
return fmt.Errorf("--static-root flag should end in / (value is %q)", serveStaticRoot)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cfg := &fuphttp.Config{
|
cfg := &fuphttp.Config{
|
||||||
Templates: fupstatic.Templates,
|
Templates: fupstatic.Templates,
|
||||||
Static: fupstatic.Static,
|
Static: fupstatic.Static,
|
||||||
StaticRoot: safehtml.TrustedResourceURLFromConstant("/static/"),
|
StaticRoot: safehtml.TrustedResourceURLFromFlag(cmd.Flag("static-root").Value),
|
||||||
|
AppRoot: serveRoot,
|
||||||
StorageURL: bucketURL,
|
StorageURL: bucketURL,
|
||||||
RedirectToBlobstore: !serveDirectOnly,
|
RedirectToBlobstore: !serveDirectOnly,
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ pkgs.buildGoModule {
|
||||||
|
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
vendorSha256 = "sha256:01q2pqn5j34zsp1al6kidfxd9bj6s1wmz8klywp1mp4lh39ln4sl";
|
vendorSha256 = "sha256:0myd1p61q777ybbwdz8k4nbchh2hv1yr8008061m3gc44s3gsphx";
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
meta = with pkgs.lib; {
|
||||||
description = "Simple file upload manager.";
|
description = "Simple file upload manager.";
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/safehtml"
|
"github.com/google/safehtml"
|
||||||
|
@ -31,6 +32,8 @@ type Config struct {
|
||||||
Static fs.FS
|
Static fs.FS
|
||||||
StaticRoot safehtml.TrustedResourceURL
|
StaticRoot safehtml.TrustedResourceURL
|
||||||
|
|
||||||
|
AppRoot string
|
||||||
|
|
||||||
// If set, redirects to a signed URL if possible instead of serving directly.
|
// If set, redirects to a signed URL if possible instead of serving directly.
|
||||||
RedirectToBlobstore bool
|
RedirectToBlobstore bool
|
||||||
|
|
||||||
|
@ -44,6 +47,9 @@ type Config struct {
|
||||||
|
|
||||||
// FilenameGenerator returns a new filename based on the provided prefix and extension.
|
// FilenameGenerator returns a new filename based on the provided prefix and extension.
|
||||||
FilenameGenerator fngen.FilenameGenerator
|
FilenameGenerator fngen.FilenameGenerator
|
||||||
|
|
||||||
|
// UseDirectDownload decides whether the "pretty" wrapped page or the direct download page is the most appropriate for a given set of parameters.
|
||||||
|
UseDirectDownload func(fileExtension string, mimeType string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
|
@ -51,10 +57,18 @@ type Application struct {
|
||||||
notFoundTmpl *template.Template
|
notFoundTmpl *template.Template
|
||||||
storageBackend *blob.Bucket
|
storageBackend *blob.Bucket
|
||||||
|
|
||||||
|
appRoot string
|
||||||
|
|
||||||
redirectToBlobstore bool
|
redirectToBlobstore bool
|
||||||
redirectExpiry time.Duration
|
redirectExpiry time.Duration
|
||||||
|
|
||||||
filenameGenerator fngen.FilenameGenerator
|
filenameGenerator fngen.FilenameGenerator
|
||||||
|
useDirectDownload func(fileExtension string, mimeType string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultUseDirectDownload(fileExtension, mimeType string) bool {
|
||||||
|
// Only use the pretty page for text/*.
|
||||||
|
return !strings.HasPrefix(mimeType, "text/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Application) Handler() http.Handler {
|
func (a *Application) Handler() http.Handler {
|
||||||
|
@ -71,6 +85,8 @@ func (a *Application) Handler() http.Handler {
|
||||||
r.NotFoundHandler = http.HandlerFunc(a.notFound)
|
r.NotFoundHandler = http.HandlerFunc(a.notFound)
|
||||||
r.HandleFunc("/", renderTemplate(a.indexTmpl))
|
r.HandleFunc("/", renderTemplate(a.indexTmpl))
|
||||||
r.HandleFunc("/raw/{filename}", a.rawDownload)
|
r.HandleFunc("/raw/{filename}", a.rawDownload)
|
||||||
|
r.HandleFunc("/upload", a.upload).Methods("POST")
|
||||||
|
r.HandleFunc("/upload/{filename}", a.upload).Methods("PUT")
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
@ -96,6 +112,10 @@ func (a *Application) badRequest(rw http.ResponseWriter, r *http.Request, err er
|
||||||
fmt.Fprintf(rw, "bad request: %v\n", err.Error())
|
fmt.Fprintf(rw, "bad request: %v\n", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Application) appURL(s string) string {
|
||||||
|
return a.appRoot + s
|
||||||
|
}
|
||||||
|
|
||||||
func parseTemplate(t *template.Template, fsys fs.FS, name string) (*template.Template, error) {
|
func parseTemplate(t *template.Template, fsys fs.FS, name string) (*template.Template, error) {
|
||||||
bs, err := fs.ReadFile(fsys, name)
|
bs, err := fs.ReadFile(fsys, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -124,6 +144,8 @@ func New(ctx context.Context, cfg *Config) (*Application, error) {
|
||||||
redirectToBlobstore: cfg.RedirectToBlobstore,
|
redirectToBlobstore: cfg.RedirectToBlobstore,
|
||||||
redirectExpiry: cfg.RedirectExpiry,
|
redirectExpiry: cfg.RedirectExpiry,
|
||||||
filenameGenerator: cfg.FilenameGenerator,
|
filenameGenerator: cfg.FilenameGenerator,
|
||||||
|
useDirectDownload: cfg.UseDirectDownload,
|
||||||
|
appRoot: cfg.AppRoot,
|
||||||
}
|
}
|
||||||
if a.redirectExpiry == 0 {
|
if a.redirectExpiry == 0 {
|
||||||
a.redirectExpiry = defaultRedirectExpiry
|
a.redirectExpiry = defaultRedirectExpiry
|
||||||
|
@ -131,6 +153,9 @@ func New(ctx context.Context, cfg *Config) (*Application, error) {
|
||||||
if a.filenameGenerator == nil {
|
if a.filenameGenerator == nil {
|
||||||
a.filenameGenerator = fngen.PetnameGenerator
|
a.filenameGenerator = fngen.PetnameGenerator
|
||||||
}
|
}
|
||||||
|
if a.useDirectDownload == nil {
|
||||||
|
a.useDirectDownload = DefaultUseDirectDownload
|
||||||
|
}
|
||||||
|
|
||||||
bkt := cfg.StorageBackend
|
bkt := cfg.StorageBackend
|
||||||
if bkt == nil {
|
if bkt == nil {
|
||||||
|
@ -150,6 +175,9 @@ func New(ctx context.Context, cfg *Config) (*Application, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
|
"app": func(s string) safehtml.URL {
|
||||||
|
return safehtml.URLSanitized(a.appRoot + s)
|
||||||
|
},
|
||||||
"static": func(s string) safehtml.TrustedResourceURL {
|
"static": func(s string) safehtml.TrustedResourceURL {
|
||||||
staticPath := s
|
staticPath := s
|
||||||
if fs, ok := cfg.Static.(*hashfs.FS); ok {
|
if fs, ok := cfg.Static.(*hashfs.FS); ok {
|
||||||
|
|
251
web/fup/fuphttp/httpupload.go
Normal file
251
web/fup/fuphttp/httpupload.go
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
// 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 :(
|
||||||
|
}
|
||||||
|
}
|
46
web/fup/fuphttp/httpupload_test.go
Normal file
46
web/fup/fuphttp/httpupload_test.go
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package fuphttp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFileExt(t *testing.T) {
|
||||||
|
tcs := []struct {
|
||||||
|
inp string
|
||||||
|
wantPrefix string
|
||||||
|
wantSuffix string
|
||||||
|
}{{
|
||||||
|
inp: "foo.txt",
|
||||||
|
wantPrefix: "foo",
|
||||||
|
wantSuffix: ".txt",
|
||||||
|
}, {
|
||||||
|
inp: "foo",
|
||||||
|
wantPrefix: "foo",
|
||||||
|
wantSuffix: "",
|
||||||
|
}, {
|
||||||
|
inp: "foo.bar.tbz2",
|
||||||
|
wantPrefix: "foo.bar",
|
||||||
|
wantSuffix: ".tbz2",
|
||||||
|
}, {
|
||||||
|
inp: "foo.tar.bz2",
|
||||||
|
wantPrefix: "foo",
|
||||||
|
wantSuffix: ".tar.bz2",
|
||||||
|
}, {
|
||||||
|
inp: "foo.tar.ppp",
|
||||||
|
wantPrefix: "foo.tar",
|
||||||
|
wantSuffix: ".ppp",
|
||||||
|
}, {
|
||||||
|
inp: "my-github.deadbeef.zip",
|
||||||
|
wantPrefix: "my-github.deadbeef",
|
||||||
|
wantSuffix: ".zip",
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tc := range tcs {
|
||||||
|
gotPrefix, gotSuffix := fileExt(tc.inp)
|
||||||
|
if gotPrefix != tc.wantPrefix || gotSuffix != tc.wantSuffix {
|
||||||
|
t.Errorf("fileExt(%q) = (%q, %q); want (%q, %q)", tc.inp, gotPrefix, gotSuffix, tc.wantPrefix, tc.wantSuffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,30 @@ type Metadata struct {
|
||||||
Attributes *blob.Attributes
|
Attributes *blob.Attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriterOptions returns a new WriterOptions based on the provided metadata.
|
||||||
|
func (m *Metadata) WriterOptions() *blob.WriterOptions {
|
||||||
|
attrs := m.Attributes
|
||||||
|
if attrs == nil {
|
||||||
|
attrs = &blob.Attributes{}
|
||||||
|
}
|
||||||
|
if attrs.Metadata == nil {
|
||||||
|
attrs.Metadata = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.ExpiresAt.IsZero() {
|
||||||
|
attrs.Metadata["expires-at"] = strconv.FormatInt(m.ExpiresAt.Unix(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &blob.WriterOptions{
|
||||||
|
CacheControl: attrs.CacheControl,
|
||||||
|
ContentDisposition: attrs.ContentDisposition,
|
||||||
|
ContentEncoding: attrs.ContentEncoding,
|
||||||
|
ContentLanguage: attrs.ContentLanguage,
|
||||||
|
ContentType: attrs.ContentType,
|
||||||
|
Metadata: attrs.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// metadata retrieves the Metadata for the object.
|
// metadata retrieves the Metadata for the object.
|
||||||
// Note: if the object is expired, it will delete it.
|
// Note: if the object is expired, it will delete it.
|
||||||
func metadata(ctx context.Context, bucket *blob.Bucket, filename string) (*Metadata, error) {
|
func metadata(ctx context.Context, bucket *blob.Bucket, filename string) (*Metadata, error) {
|
||||||
|
|
|
@ -8,7 +8,7 @@ go 1.16
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0
|
github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0
|
||||||
github.com/gabriel-vasile/mimetype v1.2.0 // indirect
|
github.com/gabriel-vasile/mimetype v1.2.0
|
||||||
github.com/google/safehtml v0.0.2
|
github.com/google/safehtml v0.0.2
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/spf13/cobra v1.1.3
|
github.com/spf13/cobra v1.1.3
|
||||||
|
|
Loading…
Reference in a new issue