From 5dab2cddde2a60ab2952520891bdfc731f8fa76b Mon Sep 17 00:00:00 2001 From: Chris Kuehl Date: Fri, 20 Sep 2024 19:10:35 -0500 Subject: [PATCH] Rename a bunch of things again --- Makefile | 4 +- TODO | 2 + server/assets/assets.go | 11 +- server/assets/assets_test.go | 4 +- server/config/config.go | 51 +++++--- server/config/loader/loader.go | 42 +++---- server/config/loader/loader_test.go | 14 +-- server/integration_test.go | 81 +++++++----- server/security/csp.go | 6 +- server/security/csp_test.go | 2 +- server/server.go | 8 +- server/storage/dev.go | 6 +- server/storage/dev_test.go | 22 ++-- server/storage/objects.go | 75 ++++++----- server/storage/storage.go | 50 ++++---- .../templates/upload-details-bk | 6 - server/templates/upload-details.html | 10 ++ server/uploads/uploads.go | 40 ++++-- server/uploads/uploads_test.go | 55 +++++--- server/uploads/uploads_x_test.go | 8 +- server/views/upload.go | 118 ++++++++++++------ server/views/upload_test.go | 2 + testfunc/logging.go | 4 + testfunc/storage.go | 16 +-- tmp/object/.gitignore | 1 - 25 files changed, 390 insertions(+), 248 deletions(-) create mode 100644 TODO rename fluffy/templates/details.html => server/templates/upload-details-bk (92%) create mode 100644 server/templates/upload-details.html delete mode 100644 tmp/object/.gitignore diff --git a/Makefile b/Makefile index 00758ad..3a61b76 100644 --- a/Makefile +++ b/Makefile @@ -15,11 +15,11 @@ bin/fput: .PHONY: dev dev: - go run github.com/cespare/reflex@latest -v -s -r '^server/|^go\.mod$$' -- go run -race ./cmd/server --dev --config ~/tmp/fluffy.toml + go run github.com/cespare/reflex@latest -v -s -r '^server/|^go\.mod$$' -- go run -race ./cmd/server --dev .PHONY: delve delve: - dlv debug ./cmd/server -- --dev --config ~/tmp/fluffy.toml + dlv debug ./cmd/server -- --dev .PHONY: release-cli release-cli: export GORELEASER_CURRENT_TAG ?= 0.0.0 diff --git a/TODO b/TODO new file mode 100644 index 0000000..4687980 --- /dev/null +++ b/TODO @@ -0,0 +1,2 @@ +* Test that tries to upload an HTML file and ensures it's not served as HTML. +* Consolidate StoredFile and StoredHTML somehow. diff --git a/server/assets/assets.go b/server/assets/assets.go index 4703de9..eefbe5f 100644 --- a/server/assets/assets.go +++ b/server/assets/assets.go @@ -62,17 +62,18 @@ func LoadAssets(assetsFS *embed.FS) (*config.Assets, error) { return &assets, nil } -// AssetObjectPath returns the path to the asset in the object store. +// assetKey returns the key for an asset in the file store. // -// Keep in mind the object may not exist yet depending on when this function is called. -func assetObjectPath(path, hash string) string { +// Keep in mind the asset may not exist yet in the file store depending on when this function is +// called. +func assetKey(path, hash string) string { return filepath.Join("static", hash, path) } // AssetURL returns the URL to the asset. // // In development mode, this will return a URL served by the fluffy server itself. In production, -// this will return a URL to the object store. +// this will return a URL to the file store. func AssetURL(conf *config.Config, path string) (string, error) { if conf.DevMode { url := *conf.HomeURL @@ -84,7 +85,7 @@ func AssetURL(conf *config.Config, path string) (string, error) { if !ok { return "", fmt.Errorf("asset not found: %s", path) } - return conf.ObjectURL(assetObjectPath(path, hash)).String(), nil + return conf.FileURL(assetKey(path, hash)).String(), nil } // AssetAsString returns the contents of the asset as a string. diff --git a/server/assets/assets_test.go b/server/assets/assets_test.go index f7066cd..473835a 100644 --- a/server/assets/assets_test.go +++ b/server/assets/assets_test.go @@ -24,11 +24,11 @@ func TestAssetURLDev(t *testing.T) { func TestAssetURLProd(t *testing.T) { conf := testfunc.NewConfig() - url, err := url.ParseRequestURI("https://fancy-cdn.com/:path:") + url, err := url.ParseRequestURI("https://fancy-cdn.com/:key:") if err != nil { t.Fatalf("unexpected error: %v", err) } - conf.ObjectURLPattern = url + conf.FileURLPattern = url conf.DevMode = false got, err := assets.AssetURL(conf, "img/favicon.ico") diff --git a/server/config/config.go b/server/config/config.go index 586a7f1..5fb6576 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -19,19 +19,32 @@ type BaseStoredObject interface { MIMEType() string } -type StoredObject interface { +// StoredFile represents a file to be stored. +type StoredFile interface { BaseStoredObject - // Name returns the human-readable, non-sanitized, non-unique name of the object. + // Name returns the human-readable, non-sanitized, non-unique name of the file. Name() string } +// StoredHTML represents an HTML object to be stored. This object should be stored in such a way +// that it can be served to clients with a text/html MIME type. Additional properties +// (Content-Disposition, links, etc.) may not be available with all storage backends. type StoredHTML interface { BaseStoredObject } type StorageBackend interface { - StoreObject(ctx context.Context, obj StoredObject) error + // StoreFile stores the given file object. This file object should be stored in such a way that + // it is never served as rendered HTML, even if the uploaded file happens to be HTML. + // Additional properties (custom MIME type, Content-Disposition, etc.) may also be stored, but + // support varies by storage backend. + StoreFile(ctx context.Context, file StoredFile) error + + // StoreHTML stores the given HTML object. This HTML object should be stored in such a way that + // it can be served to clients with a text/html MIME type. StoreHTML(ctx context.Context, html StoredHTML) error + + // Validate returns a list of errors if the storage backend's configuration is invalid. Validate() []string } @@ -58,7 +71,7 @@ type Config struct { MaxUploadBytes int64 MaxMultipartMemoryBytes int64 HomeURL *url.URL - ObjectURLPattern *url.URL + FileURLPattern *url.URL HTMLURLPattern *url.URL ForbiddenFileExtensions map[string]struct{} Host string @@ -96,15 +109,15 @@ func (conf *Config) Validate() []string { } else if strings.HasSuffix(conf.HomeURL.Path, "/") { errs = append(errs, "HomeURL must not end with a slash") } - if conf.ObjectURLPattern == nil { - errs = append(errs, "ObjectURLPattern must not be nil") - } else if !strings.Contains(conf.ObjectURLPattern.Path, ":path:") { - errs = append(errs, "ObjectURLPattern must contain a ':path:' placeholder") + if conf.FileURLPattern == nil { + errs = append(errs, "FileURLPattern must not be nil") + } else if !strings.Contains(conf.FileURLPattern.Path, ":key:") { + errs = append(errs, "FileURLPattern must contain a ':key:' placeholder") } if conf.HTMLURLPattern == nil { errs = append(errs, "HTMLURLPattern must not be nil") - } else if !strings.Contains(conf.HTMLURLPattern.Path, ":path:") { - errs = append(errs, "HTMLURLPattern must contain a ':path:' placeholder") + } else if !strings.Contains(conf.HTMLURLPattern.Path, ":key:") { + errs = append(errs, "HTMLURLPattern must contain a ':key:' placeholder") } if conf.ForbiddenFileExtensions == nil { errs = append(errs, "ForbiddenFileExtensions must not be nil") @@ -126,12 +139,16 @@ func (conf *Config) Validate() []string { return errs } -// ObjectURL returns a URL for the given object path. -// -// Typically, the `path` is the key of a stored object and has no slashes in it. This is not always -// true, however, as e.g. assets are also stored as objects. -func (conf *Config) ObjectURL(path string) *url.URL { - url := *conf.ObjectURLPattern - url.Path = strings.Replace(url.Path, ":path:", path, -1) +// FileURL returns a URL for the given stored file. +func (conf *Config) FileURL(key string) *url.URL { + url := *conf.FileURLPattern + url.Path = strings.Replace(url.Path, ":key:", key, -1) + return &url +} + +// HTMLURL returns a URL for the given stored HTML. +func (conf *Config) HTMLURL(key string) *url.URL { + url := *conf.HTMLURLPattern + url.Path = strings.Replace(url.Path, ":key:", key, -1) return &url } diff --git a/server/config/loader/loader.go b/server/config/loader/loader.go index 9478427..6f575be 100644 --- a/server/config/loader/loader.go +++ b/server/config/loader/loader.go @@ -15,15 +15,15 @@ import ( ) type filesystemStorageBackend struct { - ObjectRoot string `toml:"object_root"` - HTMLRoot string `toml:"html_root"` + FileRoot string `toml:"file_root"` + HTMLRoot string `toml:"html_root"` } type s3StorageBackend struct { - Region string `toml:"region"` - Bucket string `toml:"bucket"` - ObjectKeyPrefix string `toml:"object_key_prefix"` - HTMLKeyPrefix string `toml:"html_key_prefix"` + Region string `toml:"region"` + Bucket string `toml:"bucket"` + FileKeyPrefix string `toml:"file_key_prefix"` + HTMLKeyPrefix string `toml:"html_key_prefix"` } type configFile struct { @@ -33,7 +33,7 @@ type configFile struct { MaxUploadBytes int64 `toml:"max_upload_bytes"` MaxMultipartMemoryBytes int64 `toml:"max_multipart_memory_bytes"` HomeURL string `toml:"home_url"` - ObjectURLPattern string `toml:"object_url_pattern"` + FileURLPattern string `toml:"file_url_pattern"` HTMLURLPattern string `toml:"html_url_pattern"` ForbiddenFileExtensions []string `toml:"forbidden_file_extensions"` Host string `toml:"host"` @@ -75,12 +75,12 @@ func LoadConfigTOML(conf *config.Config, path string) error { } conf.HomeURL = u } - if cfg.ObjectURLPattern != "" { - u, err := url.ParseRequestURI(cfg.ObjectURLPattern) + if cfg.FileURLPattern != "" { + u, err := url.ParseRequestURI(cfg.FileURLPattern) if err != nil { - return fmt.Errorf("parsing ObjectURLPattern: %w", err) + return fmt.Errorf("parsing FileURLPattern: %w", err) } - conf.ObjectURLPattern = u + conf.FileURLPattern = u } if cfg.HTMLURLPattern != "" { u, err := url.ParseRequestURI(cfg.HTMLURLPattern) @@ -103,15 +103,15 @@ func LoadConfigTOML(conf *config.Config, path string) error { } if cfg.FilesystemStorageBackend != nil { conf.StorageBackend = &storage.FilesystemBackend{ - ObjectRoot: cfg.FilesystemStorageBackend.ObjectRoot, - HTMLRoot: cfg.FilesystemStorageBackend.HTMLRoot, + FileRoot: cfg.FilesystemStorageBackend.FileRoot, + HTMLRoot: cfg.FilesystemStorageBackend.HTMLRoot, } } if cfg.S3StorageBackend != nil { b, err := storage.NewS3Backend( cfg.S3StorageBackend.Region, cfg.S3StorageBackend.Bucket, - cfg.S3StorageBackend.ObjectKeyPrefix, + cfg.S3StorageBackend.FileKeyPrefix, cfg.S3StorageBackend.HTMLKeyPrefix, func(awsCfg aws.Config, optFn func(*s3.Options)) storage.S3Client { return s3.NewFromConfig(awsCfg, optFn) @@ -133,7 +133,7 @@ func DumpConfigTOML(conf *config.Config) (string, error) { MaxUploadBytes: conf.MaxUploadBytes, MaxMultipartMemoryBytes: conf.MaxMultipartMemoryBytes, HomeURL: conf.HomeURL.String(), - ObjectURLPattern: conf.ObjectURLPattern.String(), + FileURLPattern: conf.FileURLPattern.String(), HTMLURLPattern: conf.HTMLURLPattern.String(), ForbiddenFileExtensions: make([]string, 0, len(conf.ForbiddenFileExtensions)), Host: conf.Host, @@ -145,16 +145,16 @@ func DumpConfigTOML(conf *config.Config) (string, error) { } if fs, ok := conf.StorageBackend.(*storage.FilesystemBackend); ok { cfg.FilesystemStorageBackend = &filesystemStorageBackend{ - ObjectRoot: fs.ObjectRoot, - HTMLRoot: fs.HTMLRoot, + FileRoot: fs.FileRoot, + HTMLRoot: fs.HTMLRoot, } } if s3, ok := conf.StorageBackend.(*storage.S3Backend); ok { cfg.S3StorageBackend = &s3StorageBackend{ - Region: s3.Region, - Bucket: s3.Bucket, - ObjectKeyPrefix: s3.ObjectKeyPrefix, - HTMLKeyPrefix: s3.HTMLKeyPrefix, + Region: s3.Region, + Bucket: s3.Bucket, + FileKeyPrefix: s3.FileKeyPrefix, + HTMLKeyPrefix: s3.HTMLKeyPrefix, } } buf, err := toml.Marshal(cfg) diff --git a/server/config/loader/loader_test.go b/server/config/loader/loader_test.go index 71b1daa..ae71af7 100644 --- a/server/config/loader/loader_test.go +++ b/server/config/loader/loader_test.go @@ -20,15 +20,15 @@ abuse_contact_email = "abuse@foo.com" max_upload_bytes = 123 max_multipart_memory_bytes = 456 home_url = "http://foo.com" -object_url_pattern = "http://i.foo.com/o/:path:" -html_url_pattern = "http://i.foo.com/h/:path:" +file_url_pattern = "http://i.foo.com/o/:key:" +html_url_pattern = "http://i.foo.com/h/:key:" forbidden_file_extensions = ["foo", "bar"] host = "192.168.1.100" port = 5555 global_timeout_ms = 5555 [filesystem_storage_backend] -object_root = "/tmp/objects" +file_root = "/tmp/file" html_root = "/tmp/html" `) @@ -77,15 +77,15 @@ func TestLoadConfigTOMLWithEverything(t *testing.T) { MaxUploadBytes: 123, MaxMultipartMemoryBytes: 456, HomeURL: &url.URL{Scheme: "http", Host: "foo.com"}, - ObjectURLPattern: &url.URL{Scheme: "http", Host: "i.foo.com", Path: "/o/:path:"}, - HTMLURLPattern: &url.URL{Scheme: "http", Host: "i.foo.com", Path: "/h/:path:"}, + FileURLPattern: &url.URL{Scheme: "http", Host: "i.foo.com", Path: "/o/:key:"}, + HTMLURLPattern: &url.URL{Scheme: "http", Host: "i.foo.com", Path: "/h/:key:"}, ForbiddenFileExtensions: map[string]struct{}{"foo": {}, "bar": {}}, Host: "192.168.1.100", Port: 5555, GlobalTimeout: 5555 * time.Millisecond, StorageBackend: &storage.FilesystemBackend{ - ObjectRoot: "/tmp/objects", - HTMLRoot: "/tmp/html", + FileRoot: "/tmp/file", + HTMLRoot: "/tmp/html", }, Version: "(test)", } diff --git a/server/integration_test.go b/server/integration_test.go index cd834d6..35d4986 100644 --- a/server/integration_test.go +++ b/server/integration_test.go @@ -26,8 +26,10 @@ import ( type objectType int const ( - objectTypeObject objectType = iota - objectTypeHTML objectType = iota + objectTypeFile objectType = iota + objectTypeHTML objectType = iota + + doNotCompareContentSentinel = "DO_NOT_COMPARE_CONTENT" ) type CanonicalizedLinks string @@ -55,7 +57,7 @@ func keyFromURL(u *url.URL) string { return s[strings.LastIndex(s, "/")+1:] } -func TestIntegration(t *testing.T) { +func TestIntegrationUpload(t *testing.T) { tests := []struct { name string config func(t *testing.T) *config.Config @@ -75,12 +77,12 @@ func TestIntegration(t *testing.T) { getObject: func(objType objectType, conf *config.Config, key string) (*storedObject, error) { storageBackend := conf.StorageBackend.(*testfunc.MemoryStorageBackend) var obj config.BaseStoredObject - if objType == objectTypeObject { - if o, ok := storageBackend.Objects[key]; ok { + if objType == objectTypeFile { + if o, ok := storageBackend.Files[key]; ok { obj = o } } else { - if o, ok := storageBackend.HTML[key]; ok { + if o, ok := storageBackend.HTMLs[key]; ok { obj = o } } @@ -105,19 +107,19 @@ func TestIntegration(t *testing.T) { config: func(t *testing.T) *config.Config { t.Helper() htmlRoot := t.TempDir() - objectRoot := t.TempDir() + fileRoot := t.TempDir() return testfunc.NewConfig( testfunc.WithStorageBackend(&storage.FilesystemBackend{ - ObjectRoot: objectRoot, - HTMLRoot: htmlRoot, + FileRoot: fileRoot, + HTMLRoot: htmlRoot, }), ) }, getObject: func(objType objectType, conf *config.Config, key string) (*storedObject, error) { storageBackend := conf.StorageBackend.(*storage.FilesystemBackend) var path string - if objType == objectTypeObject { - path = filepath.Join(storageBackend.ObjectRoot, key) + if objType == objectTypeFile { + path = filepath.Join(storageBackend.FileRoot, key) } else { path = filepath.Join(storageBackend.HTMLRoot, key) } @@ -145,7 +147,7 @@ func TestIntegration(t *testing.T) { backend, err := storage.NewS3Backend( "fake-region", "fake-bucket", - "object/", + "file/", "html/", func(awsCfg aws.Config, optFn func(*s3.Options)) storage.S3Client { return testfunc.NewFakeS3Client() @@ -161,8 +163,8 @@ func TestIntegration(t *testing.T) { client := storageBackend.Client.(*testfunc.FakeS3Client) var path string - if objType == objectTypeObject { - path = storageBackend.ObjectKeyPrefix + key + if objType == objectTypeFile { + path = storageBackend.FileKeyPrefix + key } else { path = storageBackend.HTMLKeyPrefix + key } @@ -239,6 +241,7 @@ func TestIntegration(t *testing.T) { var result struct { Success bool `json:"success"` Metadata string `json:"metadata"` + Redirect string `json:"redirect"` UploadedFiles map[string]struct { // TODO: verify the paste by reading the "paste" key here once paste support is // added. @@ -262,8 +265,10 @@ func TestIntegration(t *testing.T) { ) } - // TODO: `redirect` is actually supposed to be a redirect to an HTML page. Update this - // to verify `redirect` once this is in place. + uploadDetailsURL, err := url.ParseRequestURI(result.Redirect) + if err != nil { + t.Fatalf("parsing redirect URL: %v", err) + } rawURL, err := url.ParseRequestURI(result.UploadedFiles["test.txt"].Raw) if err != nil { @@ -274,27 +279,43 @@ func TestIntegration(t *testing.T) { t.Fatalf("parsing metadata URL: %v", err) } - key := keyFromURL(rawURL) + links := []*url.URL{rawURL, metadataURL, uploadDetailsURL} - obj, err := tt.getObject(objectTypeObject, conf, key) - if err != nil { - t.Fatalf("getting object: %v", err) + assertObject := func(objType objectType, key string, want *storedObject) *storedObject { + t.Helper() + obj, err := tt.getObject(objType, conf, key) + if err != nil { + t.Fatalf("getting object: %v", err) + } + if tt.stripUnsupportedFields != nil { + tt.stripUnsupportedFields(want) + } + if want.Content == doNotCompareContentSentinel { + want.Content = obj.Content + } + if diff := cmp.Diff(want, obj); diff != "" { + t.Fatalf("unexpected object (-want +got):\n%s", diff) + } + return obj } - links := []*url.URL{rawURL, metadataURL} - - want := &storedObject{ + assertObject(objectTypeFile, keyFromURL(rawURL), &storedObject{ Content: "test", MIMEType: "text/plain", ContentDisposition: `inline; filename="test.txt"; filename*=utf-8''test.txt`, Links: canonicalizeLinks(links), - MetadataURL: obj.MetadataURL, - } - if tt.stripUnsupportedFields != nil { - tt.stripUnsupportedFields(want) - } - if diff := cmp.Diff(want, obj); diff != "" { - t.Fatalf("unexpected object (-want +got):\n%s", diff) + MetadataURL: metadataURL.String(), + }) + + uploadDetails := assertObject(objectTypeHTML, keyFromURL(uploadDetailsURL), &storedObject{ + Content: doNotCompareContentSentinel, + MIMEType: "text/html; charset=utf-8", + ContentDisposition: "inline", + Links: canonicalizeLinks(links), + MetadataURL: metadataURL.String(), + }) + if !strings.Contains(uploadDetails.Content, " tag:\n%s", uploadDetails.Content) } }) } diff --git a/server/security/csp.go b/server/security/csp.go index ae7f1e5..d9652dc 100644 --- a/server/security/csp.go +++ b/server/security/csp.go @@ -21,8 +21,8 @@ func CSPNonce(ctx context.Context) (string, error) { } func NewCSPMiddleware(conf *config.Config, next http.Handler) http.Handler { - objectURLBase := *conf.ObjectURLPattern - objectURLBase.Path = "" + fileURLBase := *conf.FileURLPattern + fileURLBase.Path = "" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() nonceBytes := make([]byte, 16) @@ -33,7 +33,7 @@ func NewCSPMiddleware(conf *config.Config, next http.Handler) http.Handler { ctx = context.WithValue(ctx, cspNonceKey{}, nonce) csp := fmt.Sprintf( "default-src 'self' %s; script-src https://ajax.googleapis.com 'nonce-%s' %[1]s; style-src 'self' https://fonts.googleapis.com %[1]s; font-src https://fonts.gstatic.com %[1]s", - objectURLBase.String(), + fileURLBase.String(), nonce, ) w.Header().Set("Content-Security-Policy", csp) diff --git a/server/security/csp_test.go b/server/security/csp_test.go index c81843c..3997088 100644 --- a/server/security/csp_test.go +++ b/server/security/csp_test.go @@ -27,7 +27,7 @@ var cspRegexp = regexp.MustCompile( func TestCSP(t *testing.T) { t.Parallel() conf := testfunc.NewConfig() - conf.ObjectURLPattern = &url.URL{Scheme: "https", Host: "fancy-cdn.com", Path: ":path:"} + conf.FileURLPattern = &url.URL{Scheme: "https", Host: "fancy-cdn.com", Path: ":key:"} ts := testfunc.RunningServer(t, conf) defer ts.Cleanup() diff --git a/server/server.go b/server/server.go index 0ba3c92..412ce6c 100644 --- a/server/server.go +++ b/server/server.go @@ -31,16 +31,16 @@ func NewConfig() (*config.Config, error) { } return &config.Config{ StorageBackend: &storage.FilesystemBackend{ - ObjectRoot: filepath.Join("tmp", "object"), - HTMLRoot: filepath.Join("tmp", "html"), + FileRoot: filepath.Join("tmp", "file"), + HTMLRoot: filepath.Join("tmp", "html"), }, Branding: "fluffy", AbuseContactEmail: "abuse@example.com", MaxUploadBytes: 1024 * 1024 * 10, // 10 MiB MaxMultipartMemoryBytes: 1024 * 1024 * 10, // 10 MiB HomeURL: &url.URL{Scheme: "http", Host: "localhost:8080"}, - ObjectURLPattern: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/storage/object/:path:"}, - HTMLURLPattern: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/storage/html/:path:"}, + FileURLPattern: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/storage/file/:key:"}, + HTMLURLPattern: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/storage/html/:key:"}, ForbiddenFileExtensions: make(map[string]struct{}), Host: "127.0.0.1", Port: 8080, diff --git a/server/storage/dev.go b/server/storage/dev.go index 5586075..e27bb87 100644 --- a/server/storage/dev.go +++ b/server/storage/dev.go @@ -32,9 +32,9 @@ func HandleDevStorage(conf *config.Config, logger logging.Logger) http.HandlerFu var root string - if strings.HasPrefix(strippedReq.URL.Path, "/object/") { - root = storageBackend.ObjectRoot - strippedReq.URL.Path = strings.TrimPrefix(strippedReq.URL.Path, "/object/") + if strings.HasPrefix(strippedReq.URL.Path, "/file/") { + root = storageBackend.FileRoot + strippedReq.URL.Path = strings.TrimPrefix(strippedReq.URL.Path, "/file/") } else if strings.HasPrefix(strippedReq.URL.Path, "/html/") { root = storageBackend.HTMLRoot strippedReq.URL.Path = strings.TrimPrefix(strippedReq.URL.Path, "/html/") diff --git a/server/storage/dev_test.go b/server/storage/dev_test.go index 4c0ca89..e6c24cd 100644 --- a/server/storage/dev_test.go +++ b/server/storage/dev_test.go @@ -22,8 +22,8 @@ func TestDevStorageDev(t *testing.T) { wantContent string }{ { - name: "object", - url: "http://localhost:%d/dev/storage/object/test.txt", + name: "file", + url: "http://localhost:%d/dev/storage/file/test.txt", wantContentType: "text/plain; charset=utf-8", wantContent: "test content\n", }, @@ -39,12 +39,12 @@ func TestDevStorageDev(t *testing.T) { t.Parallel() tmp := t.TempDir() - objectRoot := filepath.Join(tmp, "object") - if err := os.MkdirAll(objectRoot, 0755); err != nil { - t.Fatalf("failed to create object root: %v", err) + fileRoot := filepath.Join(tmp, "file") + if err := os.MkdirAll(fileRoot, 0755); err != nil { + t.Fatalf("failed to create file root: %v", err) } - if err := os.WriteFile(filepath.Join(objectRoot, "test.txt"), []byte("test content\n"), 0644); err != nil { - t.Fatalf("failed to write object file: %v", err) + if err := os.WriteFile(filepath.Join(fileRoot, "test.txt"), []byte("test content\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) } htmlRoot := filepath.Join(tmp, "html") @@ -58,8 +58,8 @@ func TestDevStorageDev(t *testing.T) { conf := testfunc.NewConfig() conf.DevMode = true conf.StorageBackend = &storage.FilesystemBackend{ - ObjectRoot: objectRoot, - HTMLRoot: htmlRoot, + FileRoot: fileRoot, + HTMLRoot: htmlRoot, } ts := testfunc.RunningServer(t, conf) defer ts.Cleanup() @@ -89,8 +89,8 @@ func TestDevStorageDev(t *testing.T) { func TestDevStorageProd(t *testing.T) { urls := map[string]string{ - "object": "http://localhost:%d/dev/storage/object/test.txt", - "html": "http://localhost:%d/dev/storage/html/test.html", + "file": "http://localhost:%d/dev/storage/file/test.txt", + "html": "http://localhost:%d/dev/storage/html/test.html", } for name, url := range urls { t.Run(name, func(t *testing.T) { diff --git a/server/storage/objects.go b/server/storage/objects.go index b453eb6..0f2aae8 100644 --- a/server/storage/objects.go +++ b/server/storage/objects.go @@ -26,8 +26,8 @@ func (b *baseStoredObject) MetadataURL() *url.URL { return b.metadataURL } -type StoredObjectOption interface { - applyToStoredObject(*storedObject) +type StoredFileOption interface { + applyToStoredFile(*storedFile) } type StoredHTMLOption interface { @@ -35,13 +35,13 @@ type StoredHTMLOption interface { } type BaseStoredObjectOption interface { - StoredObjectOption + StoredFileOption StoredHTMLOption } type baseStoredObjectOption func(*baseStoredObject) -func (o baseStoredObjectOption) applyToStoredObject(obj *storedObject) { +func (o baseStoredObjectOption) applyToStoredFile(obj *storedFile) { o(&obj.baseStoredObject) } @@ -67,75 +67,75 @@ func WithMetadataURL(metadataURL *url.URL) baseStoredObjectOption { } } -type storedObject struct { +type storedFile struct { baseStoredObject mimeType string contentDisposition string name string } -type storedObjectOption func(*storedObject) +type storedFileOption func(*storedFile) -func (o storedObjectOption) applyToStoredObject(obj *storedObject) { - o(obj) +func (o storedFileOption) applyToStoredFile(file *storedFile) { + o(file) } -func WithMIMEType(mimeType string) storedObjectOption { - return func(o *storedObject) { +func WithMIMEType(mimeType string) storedFileOption { + return func(o *storedFile) { o.mimeType = mimeType } } -func WithContentDisposition(contentDisposition string) storedObjectOption { - return func(o *storedObject) { +func WithContentDisposition(contentDisposition string) storedFileOption { + return func(o *storedFile) { o.contentDisposition = contentDisposition } } -func WithName(name string) storedObjectOption { - return func(o *storedObject) { +func WithName(name string) storedFileOption { + return func(o *storedFile) { o.name = name } } -func NewStoredObject(readSeekCloser io.ReadSeekCloser, opts ...StoredObjectOption) config.StoredObject { - ret := &storedObject{ +func NewStoredFile(readSeekCloser io.ReadSeekCloser, opts ...StoredFileOption) config.StoredFile { + ret := &storedFile{ baseStoredObject: baseStoredObject{ ReadSeekCloser: readSeekCloser, }, } for _, opt := range opts { - opt.applyToStoredObject(ret) + opt.applyToStoredFile(ret) } return ret } -func UpdatedStoredObject(obj config.StoredObject, opts ...StoredObjectOption) config.StoredObject { - ret := &storedObject{ +func UpdatedStoredFile(file config.StoredFile, opts ...StoredFileOption) config.StoredFile { + ret := &storedFile{ baseStoredObject: baseStoredObject{ - ReadSeekCloser: obj, - key: obj.Key(), - links: obj.Links(), - metadataURL: obj.MetadataURL(), + ReadSeekCloser: file, + key: file.Key(), + links: file.Links(), + metadataURL: file.MetadataURL(), }, - mimeType: obj.MIMEType(), - contentDisposition: obj.ContentDisposition(), + mimeType: file.MIMEType(), + contentDisposition: file.ContentDisposition(), } for _, opt := range opts { - opt.applyToStoredObject(ret) + opt.applyToStoredFile(ret) } return ret } -func (o *storedObject) MIMEType() string { +func (o *storedFile) MIMEType() string { return o.mimeType } -func (o *storedObject) ContentDisposition() string { +func (o *storedFile) ContentDisposition() string { return o.contentDisposition } -func (o *storedObject) Name() string { +func (o *storedFile) Name() string { return o.name } @@ -143,7 +143,7 @@ type storedHTML struct { baseStoredObject } -func NewStoredHTML(key string, readSeekCloser io.ReadSeekCloser, opts ...StoredHTMLOption) config.StoredHTML { +func NewStoredHTML(readSeekCloser io.ReadSeekCloser, opts ...StoredHTMLOption) config.StoredHTML { ret := &storedHTML{ baseStoredObject: baseStoredObject{ ReadSeekCloser: readSeekCloser, @@ -155,6 +155,21 @@ func NewStoredHTML(key string, readSeekCloser io.ReadSeekCloser, opts ...StoredH return ret } +func UpdatedStoredHTML(html config.StoredHTML, opts ...StoredHTMLOption) config.StoredHTML { + ret := &storedHTML{ + baseStoredObject: baseStoredObject{ + ReadSeekCloser: html, + key: html.Key(), + links: html.Links(), + metadataURL: html.MetadataURL(), + }, + } + for _, opt := range opts { + opt.applyToStoredHTML(ret) + } + return ret +} + func (h *storedHTML) MIMEType() string { return "text/html; charset=utf-8" } diff --git a/server/storage/storage.go b/server/storage/storage.go index d5c0af2..a4a1ad9 100644 --- a/server/storage/storage.go +++ b/server/storage/storage.go @@ -17,8 +17,8 @@ import ( ) type FilesystemBackend struct { - ObjectRoot string - HTMLRoot string + FileRoot string + HTMLRoot string } func absPath(path string) (string, error) { @@ -61,18 +61,18 @@ func (b *FilesystemBackend) store(root string, obj config.BaseStoredObject) erro return nil } -func (b *FilesystemBackend) StoreObject(ctx context.Context, obj config.StoredObject) error { - return b.store(b.ObjectRoot, obj) +func (b *FilesystemBackend) StoreFile(ctx context.Context, file config.StoredFile) error { + return b.store(b.FileRoot, file) } -func (b *FilesystemBackend) StoreHTML(ctx context.Context, obj config.StoredHTML) error { - return b.store(b.HTMLRoot, obj) +func (b *FilesystemBackend) StoreHTML(ctx context.Context, html config.StoredHTML) error { + return b.store(b.HTMLRoot, html) } func (b *FilesystemBackend) Validate() []string { var errs []string - if b.ObjectRoot == "" { - errs = append(errs, "ObjectRoot must not be empty") + if b.FileRoot == "" { + errs = append(errs, "FileRoot must not be empty") } if b.HTMLRoot == "" { errs = append(errs, "HTMLRoot must not be empty") @@ -81,11 +81,11 @@ func (b *FilesystemBackend) Validate() []string { } type S3Backend struct { - Client S3Client - Region string - Bucket string - ObjectKeyPrefix string - HTMLKeyPrefix string + Client S3Client + Region string + Bucket string + FileKeyPrefix string + HTMLKeyPrefix string } type S3Client interface { @@ -95,7 +95,7 @@ type S3Client interface { func NewS3Backend( region string, bucket string, - objectKeyPrefix string, + fileKeyPrefix string, htmlKeyPrefix string, clientFactory func(aws.Config, func(*s3.Options)) S3Client, ) (*S3Backend, error) { @@ -107,11 +107,11 @@ func NewS3Backend( o.Region = region }) return &S3Backend{ - Client: client, - Region: region, - Bucket: bucket, - ObjectKeyPrefix: objectKeyPrefix, - HTMLKeyPrefix: htmlKeyPrefix, + Client: client, + Region: region, + Bucket: bucket, + FileKeyPrefix: fileKeyPrefix, + HTMLKeyPrefix: htmlKeyPrefix, }, nil } @@ -139,12 +139,12 @@ func (b *S3Backend) store(ctx context.Context, key string, obj config.BaseStored return err } -func (b *S3Backend) StoreObject(ctx context.Context, obj config.StoredObject) error { - return b.store(ctx, b.ObjectKeyPrefix+obj.Key(), obj) +func (b *S3Backend) StoreFile(ctx context.Context, file config.StoredFile) error { + return b.store(ctx, b.FileKeyPrefix+file.Key(), file) } -func (b *S3Backend) StoreHTML(ctx context.Context, obj config.StoredHTML) error { - return b.store(ctx, b.HTMLKeyPrefix+obj.Key(), obj) +func (b *S3Backend) StoreHTML(ctx context.Context, html config.StoredHTML) error { + return b.store(ctx, b.HTMLKeyPrefix+html.Key(), html) } func (b *S3Backend) Validate() []string { @@ -155,8 +155,8 @@ func (b *S3Backend) Validate() []string { if b.Bucket == "" { errs = append(errs, "Bucket must not be empty") } - if b.ObjectKeyPrefix != "" && !strings.HasSuffix(b.ObjectKeyPrefix, "/") { - errs = append(errs, "ObjectKeyPrefix must end with a / if nonempty") + if b.FileKeyPrefix != "" && !strings.HasSuffix(b.FileKeyPrefix, "/") { + errs = append(errs, "FileKeyPrefix must end with a / if nonempty") } if b.HTMLKeyPrefix != "" && !strings.HasSuffix(b.HTMLKeyPrefix, "/") { errs = append(errs, "HTMLKeyPrefix must end with a / if nonempty") diff --git a/fluffy/templates/details.html b/server/templates/upload-details-bk similarity index 92% rename from fluffy/templates/details.html rename to server/templates/upload-details-bk index 2179dcf..3dc0cdd 100644 --- a/fluffy/templates/details.html +++ b/server/templates/upload-details-bk @@ -1,8 +1,3 @@ -{% set page_name = 'details' %} - -{% extends 'layouts/base.html' %} - -{% block content %}
{% for (file, pb) in uploads %}
@@ -38,4 +33,3 @@
{% endfor %}
-{% endblock content %} diff --git a/server/templates/upload-details.html b/server/templates/upload-details.html new file mode 100644 index 0000000..a9e496f --- /dev/null +++ b/server/templates/upload-details.html @@ -0,0 +1,10 @@ +{{define "extraHead"}}{{end}} + +{{define "content"}} +CONTENT HERE +{{end}} + +{{define "inlineJS"}} +{{end}} + +{{template "base.html" .}} diff --git a/server/uploads/uploads.go b/server/uploads/uploads.go index 99d2a5f..53cfe29 100644 --- a/server/uploads/uploads.go +++ b/server/uploads/uploads.go @@ -65,7 +65,8 @@ var ( } ) -func GenUniqueObjectID() (string, error) { +// GenUniqueObjectKey returns a random string for use as object key. +func GenUniqueObjectKey() (string, error) { var s strings.Builder for i := 0; i < storedFileNameLength; i++ { r, err := rand.Int(rand.Reader, big.NewInt(int64(len(storedFileNameChars)))) @@ -109,9 +110,9 @@ func (s SanitizedKey) String() string { func SanitizeUploadName(name string, forbiddenExtensions map[string]struct{}) (*SanitizedKey, error) { name = strings.ReplaceAll(name, string(filepath.Separator), "/") name = name[strings.LastIndex(name, "/")+1:] - id, err := GenUniqueObjectID() + id, err := GenUniqueObjectKey() if err != nil { - return nil, fmt.Errorf("generating unique object ID: %w", err) + return nil, fmt.Errorf("generating unique object key: %w", err) } ext := extractExtension(name) for _, extPart := range strings.Split(ext, ".") { @@ -129,23 +130,36 @@ func UploadObjects( ctx context.Context, logger logging.Logger, conf *config.Config, - objs []config.StoredObject, + files []config.StoredFile, + htmls []config.StoredHTML, ) []error { - results := make(chan error, len(objs)) - for _, obj := range objs { + // TODO: Consider consolidating file uploads and HTML uploads somehow. + results := make(chan error, len(files)+len(htmls)) + for _, file := range files { + go func() { + err := conf.StorageBackend.StoreFile(ctx, file) + if err != nil { + logger.Error(ctx, "storing file", "file", file, "error", err) + } else { + logger.Info(ctx, "successfully stored file", "file", file) + } + results <- err + }() + } + for _, html := range htmls { go func() { - err := conf.StorageBackend.StoreObject(ctx, obj) + err := conf.StorageBackend.StoreHTML(ctx, html) if err != nil { - logger.Error(ctx, "storing object", "obj", obj, "error", err) + logger.Error(ctx, "storing HTML", "html", html, "error", err) } else { - logger.Info(ctx, "successfully stored object", "obj", obj) + logger.Info(ctx, "successfully stored HTML", "html", html) } results <- err }() } - errs := make([]error, 0, len(objs)) - for i := 0; i < len(objs); i++ { + errs := make([]error, 0, len(files)+len(htmls)) + for i := 0; i < len(files)+len(htmls); i++ { select { case err := <-results: if err != nil { @@ -286,7 +300,7 @@ type UploadMetadata struct { // TODO: add PasteDetails once paste support is added. } -func NewUploadMetadata(conf *config.Config, files []config.StoredObject) (*UploadMetadata, error) { +func NewUploadMetadata(conf *config.Config, files []config.StoredFile) (*UploadMetadata, error) { // TODO: probably make this same function work for pastes with additional arguments. ret := UploadMetadata{ ServerVersion: conf.Version, @@ -306,7 +320,7 @@ func NewUploadMetadata(conf *config.Config, files []config.StoredObject) (*Uploa ret.UploadedFiles = append(ret.UploadedFiles, UploadedFile{ Name: file.Name(), Bytes: bytes, - Raw: conf.ObjectURL(file.Key()).String(), + Raw: conf.FileURL(file.Key()).String(), }) } return &ret, nil diff --git a/server/uploads/uploads_test.go b/server/uploads/uploads_test.go index e757ce4..f8d5a6d 100644 --- a/server/uploads/uploads_test.go +++ b/server/uploads/uploads_test.go @@ -141,32 +141,53 @@ func TestUploadObjects(t *testing.T) { testfunc.NewConfig( testfunc.WithStorageBackend(storageBackend), ), - []config.StoredObject{ - storage.NewStoredObject( + []config.StoredFile{ + storage.NewStoredFile( utils.NopReadSeekCloser(bytes.NewReader([]byte("hello, world"))), storage.WithKey("file.txt"), storage.WithName("file.txt"), ), }, + []config.StoredHTML{ + storage.NewStoredHTML( + utils.NopReadSeekCloser(strings.NewReader("hello, world")), + storage.WithKey("file.html"), + ), + }, ) if len(errs) != 0 { t.Fatalf("UploadObjects() = %v, want no errors", errs) } - obj, ok := storageBackend.Objects["file.txt"] + file, ok := storageBackend.Files["file.txt"] if !ok { - t.Fatalf("Object not stored") + t.Fatalf("file not stored") } - buf := new(strings.Builder) - if _, err := io.Copy(buf, obj); err != nil { - t.Fatalf("reading stored object: %v", err) + fileBuf := new(strings.Builder) + if _, err := io.Copy(fileBuf, file); err != nil { + t.Fatalf("reading stored file: %v", err) } - got := buf.String() + got := fileBuf.String() want := "hello, world" if got != want { - t.Fatalf("stored object = %q, want %q", got, want) + t.Fatalf("stored file = %q, want %q", got, want) + } + + html, ok := storageBackend.HTMLs["file.html"] + if !ok { + t.Fatalf("HTML not stored") + } + + htmlBuf := new(strings.Builder) + if _, err := io.Copy(htmlBuf, html); err != nil { + t.Fatalf("reading stored HTML: %v", err) + } + got = htmlBuf.String() + want = "hello, world" + if got != want { + t.Fatalf("stored HTML = %q, want %q", got, want) } } @@ -320,23 +341,23 @@ func TestDetermineContentDisposition(t *testing.T) { func TestNewUploadMetadata(t *testing.T) { tests := []struct { name string - files []config.StoredObject + files []config.StoredFile want uploads.UploadMetadata }{ { name: "several_files", - files: []config.StoredObject{ - storage.NewStoredObject( + files: []config.StoredFile{ + storage.NewStoredFile( utils.NopReadSeekCloser(bytes.NewReader([]byte("abcd"))), storage.WithKey("aaaaaa"), storage.WithName("file"), ), - storage.NewStoredObject( + storage.NewStoredFile( utils.NopReadSeekCloser(bytes.NewReader([]byte("abcd"))), storage.WithKey("bbbbbb.png"), storage.WithName("image.png"), ), - storage.NewStoredObject( + storage.NewStoredFile( utils.NopReadSeekCloser(bytes.NewReader([]byte("abcd"))), storage.WithKey("cccccc.txt"), storage.WithName("text.txt"), @@ -349,17 +370,17 @@ func TestNewUploadMetadata(t *testing.T) { { Name: "file", Bytes: 4, - Raw: "http://localhost:8080/dev/storage/object/aaaaaa", + Raw: "http://localhost:8080/dev/storage/file/aaaaaa", }, { Name: "image.png", Bytes: 4, - Raw: "http://localhost:8080/dev/storage/object/bbbbbb.png", + Raw: "http://localhost:8080/dev/storage/file/bbbbbb.png", }, { Name: "text.txt", Bytes: 4, - Raw: "http://localhost:8080/dev/storage/object/cccccc.txt", + Raw: "http://localhost:8080/dev/storage/file/cccccc.txt", }, }, }, diff --git a/server/uploads/uploads_x_test.go b/server/uploads/uploads_x_test.go index 6221f28..591f96c 100644 --- a/server/uploads/uploads_x_test.go +++ b/server/uploads/uploads_x_test.go @@ -4,14 +4,14 @@ import ( "testing" ) -func TestGetUniqueObjectID(t *testing.T) { - got, err := GenUniqueObjectID() +func TestGetUniqueObjectKey(t *testing.T) { + got, err := GenUniqueObjectKey() if err != nil { - t.Fatalf("genUniqueObjectID() error = %v", err) + t.Fatalf("GenUniqueObjectKey() error = %v", err) } wantLength := 32 if len(got) != wantLength { - t.Fatalf("got genUniqueObjectID() = %q, want len() = %d", got, wantLength) + t.Fatalf("got GenUniqueObjectKey() = %q, want len() = %d", got, wantLength) } } diff --git a/server/views/upload.go b/server/views/upload.go index a2f1c15..acd9fa3 100644 --- a/server/views/upload.go +++ b/server/views/upload.go @@ -12,6 +12,7 @@ import ( "github.com/chriskuehl/fluffy/server/config" "github.com/chriskuehl/fluffy/server/logging" + "github.com/chriskuehl/fluffy/server/meta" "github.com/chriskuehl/fluffy/server/storage" "github.com/chriskuehl/fluffy/server/uploads" "github.com/chriskuehl/fluffy/server/utils" @@ -53,15 +54,15 @@ type uploadResponse struct { UploadedFiles map[string]uploadedFile `json:"uploaded_files"` } -// objectFromFileHeader creates a StoredObject from a multipart.FileHeader. +// storedFileFromFileHeader creates a StoredFile from a multipart.FileHeader. // -// Note: The *caller* is responsible for closing the returned object. -func objectFromFileHeader( +// Note: The *caller* is responsible for closing the returned StoredFile. +func storedFileFromFileHeader( ctx context.Context, conf *config.Config, logger logging.Logger, fileHeader *multipart.FileHeader, -) (config.StoredObject, error) { +) (config.StoredFile, error) { file, err := fileHeader.Open() if err != nil { logger.Error(ctx, "opening file", "error", err) @@ -103,7 +104,7 @@ func objectFromFileHeader( name = fileHeader.Filename } - return storage.NewStoredObject( + return storage.NewStoredFile( file, storage.WithKey(key.String()), storage.WithName(name), @@ -119,6 +120,8 @@ func objectFromFileHeader( } func HandleUpload(conf *config.Config, logger logging.Logger) http.HandlerFunc { + uploadDetailsTmpl := conf.Templates.Must("upload-details.html") + return func(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(conf.MaxMultipartMemoryBytes) if err != nil { @@ -132,10 +135,10 @@ func HandleUpload(conf *config.Config, logger logging.Logger) http.HandlerFunc { jsonResponse = true } - objs := []config.StoredObject{} + files := []config.StoredFile{} for _, fileHeader := range r.MultipartForm.File["file"] { - obj, err := objectFromFileHeader(r.Context(), conf, logger, fileHeader) + file, err := storedFileFromFileHeader(r.Context(), conf, logger, fileHeader) if err != nil { userErr, ok := err.(userError) if !ok { @@ -145,31 +148,31 @@ func HandleUpload(conf *config.Config, logger logging.Logger) http.HandlerFunc { userErr.output(w) return } - defer obj.Close() - objs = append(objs, obj) + defer file.Close() + files = append(files, file) } - if len(objs) == 0 { + if len(files) == 0 { logger.Info(r.Context(), "no files uploaded") userError{http.StatusBadRequest, "No files uploaded."}.output(w) return } - metadataKey, err := uploads.GenUniqueObjectID() + // Metadata + metadataKey, err := uploads.GenUniqueObjectKey() if err != nil { - logger.Error(r.Context(), "generating unique object ID", "error", err) - userError{http.StatusInternalServerError, "Failed to generate unique object ID."}.output(w) + logger.Error(r.Context(), "generating unique object key", "error", err) + userError{http.StatusInternalServerError, "Failed to generate unique object key."}.output(w) return } - metadata, err := uploads.NewUploadMetadata(conf, objs) + metadata, err := uploads.NewUploadMetadata(conf, files) if err != nil { logger.Error(r.Context(), "creating metadata", "error", err) userError{http.StatusInternalServerError, "Failed to create metadata."}.output(w) return } - // Convert the metadata to JSON. var metadataJSON bytes.Buffer if err := json.NewEncoder(&metadataJSON).Encode(metadata); err != nil { logger.Error(r.Context(), "encoding metadata", "error", err) @@ -177,57 +180,96 @@ func HandleUpload(conf *config.Config, logger logging.Logger) http.HandlerFunc { return } - metadataObject := storage.NewStoredObject( + metadataFile := storage.NewStoredFile( utils.NopReadSeekCloser(bytes.NewReader(metadataJSON.Bytes())), storage.WithKey(metadataKey+".json"), - storage.WithName("metadata.json"), storage.WithMIMEType("application/json"), ) - metadataURL := conf.ObjectURL(metadataObject.Key()) + metadataURL := conf.FileURL(metadataFile.Key()) + + // Upload details HTML page + uploadDetailsKey, err := uploads.GenUniqueObjectKey() + if err != nil { + logger.Error(r.Context(), "generating unique object key", "error", err) + userError{http.StatusInternalServerError, "Failed to generate unique object key."}.output(w) + return + } + + uploadDetailsMeta, err := meta.NewMeta(r.Context(), conf, meta.PageConfig{ + ID: "upload-details", + }) + if err != nil { + logger.Error(r.Context(), "creating meta", "error", err) + userError{http.StatusInternalServerError, "Failed to create response."}.output(w) + return + } + + var uploadDetails bytes.Buffer + uploadDetailsData := struct { + Meta *meta.Meta + }{ + Meta: uploadDetailsMeta, + } + if err := uploadDetailsTmpl.ExecuteTemplate(&uploadDetails, "upload-details.html", uploadDetailsData); err != nil { + logger.Error(r.Context(), "executing template", "error", err) + userError{http.StatusInternalServerError, "Failed to create response."}.output(w) + return + } + uploadDetailsHTML := storage.NewStoredHTML( + utils.NopReadSeekCloser(bytes.NewReader(uploadDetails.Bytes())), + storage.WithKey(uploadDetailsKey+".html"), + ) - // uploadObjs includes extra objects like metadata, auto-generated pastes, etc. which - // shouldn't be in the returned JSON. - uploadObjs := []config.StoredObject{metadataObject} - for _, obj := range objs { - uploadObjs = append(uploadObjs, obj) + // Update metadata and links for everything we're about to update. + uploadHTMLs := []config.StoredHTML{uploadDetailsHTML} + uploadFiles := append([]config.StoredFile{metadataFile}, files...) + links := make([]*url.URL, 0, len(uploadFiles)+len(uploadHTMLs)) + for _, file := range uploadFiles { + links = append(links, conf.FileURL(file.Key())) } - links := make([]*url.URL, len(uploadObjs)) - for i, obj := range uploadObjs { - links[i] = conf.ObjectURL(obj.Key()) + for _, html := range uploadHTMLs { + links = append(links, conf.HTMLURL(html.Key())) } - for i := range uploadObjs { - uploadObjs[i] = storage.UpdatedStoredObject( - uploadObjs[i], + for i := range uploadHTMLs { + uploadHTMLs[i] = storage.UpdatedStoredHTML( + uploadHTMLs[i], + storage.WithMetadataURL(metadataURL), + storage.WithLinks(links), + ) + } + for i := range uploadFiles { + uploadFiles[i] = storage.UpdatedStoredFile( + uploadFiles[i], storage.WithMetadataURL(metadataURL), storage.WithLinks(links), ) } - errs := uploads.UploadObjects(r.Context(), logger, conf, uploadObjs) + errs := uploads.UploadObjects(r.Context(), logger, conf, uploadFiles, uploadHTMLs) if len(errs) > 0 { logger.Error(r.Context(), "uploading objects failed", "errors", errs) - userError{http.StatusInternalServerError, "Failed to store object."}.output(w) + userError{http.StatusInternalServerError, "Failed to store objects."}.output(w) return } - logger.Info(r.Context(), "uploaded", "objects", len(objs)) + logger.Info(r.Context(), "uploaded", "files", len(uploadFiles), "htmls", len(uploadHTMLs)) - redirect := conf.ObjectURL(objs[0].Key()).String() + redirect := conf.HTMLURL(uploadDetailsHTML.Key()).String() if jsonResponse { - uploadedFiles := make(map[string]uploadedFile, len(objs)) - for _, obj := range objs { - bytes, err := utils.FileSizeBytes(obj) + uploadedFiles := make(map[string]uploadedFile, len(files)) + for _, file := range files { + bytes, err := utils.FileSizeBytes(file) if err != nil { logger.Error(r.Context(), "getting file size", "error", err) userError{http.StatusInternalServerError, "Failed to get file size."}.output(w) return } - uploadedFiles[obj.Name()] = uploadedFile{ + uploadedFiles[file.Name()] = uploadedFile{ Bytes: bytes, - Raw: conf.ObjectURL(obj.Key()).String(), + Raw: conf.FileURL(file.Key()).String(), // TODO: Paste for text files } } diff --git a/server/views/upload_test.go b/server/views/upload_test.go index c81ac6a..137c074 100644 --- a/server/views/upload_test.go +++ b/server/views/upload_test.go @@ -68,6 +68,8 @@ func TestUpload(t *testing.T) { } // TODO: add assertions based on the redirect location to ensure files were actually uploaded + // TODO: assert metadata was uploaded + // TODO: assert upload details looks right } func TestUploadJSON(t *testing.T) { diff --git a/testfunc/logging.go b/testfunc/logging.go index 5b191fa..88472f6 100644 --- a/testfunc/logging.go +++ b/testfunc/logging.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" ) type Line struct { @@ -13,6 +14,7 @@ type Line struct { type MemoryLogger struct { lines []Line + mu sync.Mutex } func (ml *MemoryLogger) log(level string, msg string, args ...any) { @@ -25,6 +27,8 @@ func (ml *MemoryLogger) log(level string, msg string, args ...any) { line.WriteString(fmt.Sprintf("%v", arg)) } } + ml.mu.Lock() + defer ml.mu.Unlock() ml.lines = append(ml.lines, Line{ Level: level, Message: line.String(), diff --git a/testfunc/storage.go b/testfunc/storage.go index 21de8ac..2e64243 100644 --- a/testfunc/storage.go +++ b/testfunc/storage.go @@ -13,22 +13,22 @@ import ( ) type MemoryStorageBackend struct { - Objects map[string]config.StoredObject - HTML map[string]config.StoredHTML - mu sync.Mutex + Files map[string]config.StoredFile + HTMLs map[string]config.StoredHTML + mu sync.Mutex } -func (b *MemoryStorageBackend) StoreObject(ctx context.Context, obj config.StoredObject) error { +func (b *MemoryStorageBackend) StoreFile(ctx context.Context, obj config.StoredFile) error { b.mu.Lock() defer b.mu.Unlock() - b.Objects[obj.Key()] = obj + b.Files[obj.Key()] = obj return nil } func (b *MemoryStorageBackend) StoreHTML(ctx context.Context, obj config.StoredHTML) error { b.mu.Lock() defer b.mu.Unlock() - b.HTML[obj.Key()] = obj + b.HTMLs[obj.Key()] = obj return nil } @@ -38,8 +38,8 @@ func (b *MemoryStorageBackend) Validate() []string { func NewMemoryStorageBackend() *MemoryStorageBackend { return &MemoryStorageBackend{ - Objects: make(map[string]config.StoredObject), - HTML: make(map[string]config.StoredHTML), + Files: make(map[string]config.StoredFile), + HTMLs: make(map[string]config.StoredHTML), } } diff --git a/tmp/object/.gitignore b/tmp/object/.gitignore deleted file mode 100644 index 72e8ffc..0000000 --- a/tmp/object/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*