feat(lagoon/attach): export UploadWriterOptions for blob writers that bypass Store (quick-261106-d20)

- attach.UploadWriterOptions (was the unexported uploadWriterOptions) gives
  code that writes an upload to the bucket itself the same Content-Type and
  Content-Disposition: attachment for non-inline types that attach.Store stores
- writeBlob calls it; Store behaviour unchanged
- TestUploadWriterOptions table test
- lagoon README, docs/database/attachments.md and docs/services/storage.md
  describe it for writers that bypass attach.Store
This commit is contained in:
Jakub Zych
2026-11-06 09:31:51 +01:00
parent 9fa740e8b7
commit 903859a131
6 changed files with 53 additions and 6 deletions

View File

@@ -33,9 +33,14 @@ func IsInlineContentType(contentType string) bool {
return err == nil && inlineContentTypes[strings.ToLower(mediaType)]
}
// uploadWriterOptions are the blob writer options of an upload: the content
// type, plus the attachment disposition when the type is not inline.
func uploadWriterOptions(contentType string) *blob.WriterOptions {
// UploadWriterOptions returns the blob writer options of an upload: the
// content type, plus Content-Disposition: attachment (no filename parameter)
// for every type IsInlineContentType does not report as inline. Store writes
// with these options. Code that writes an upload to the bucket itself, with
// NewWriter or WriteAll, passes the sniffed type (the value stored in
// content_type) and names the file with DiskExtension, so its blob is served
// like one Store wrote. Each call returns a new value the caller may extend.
func UploadWriterOptions(contentType string) *blob.WriterOptions {
opts := &blob.WriterOptions{ContentType: contentType}
if !IsInlineContentType(contentType) {
opts.ContentDisposition = attachmentDisposition

View File

@@ -1,6 +1,7 @@
package attach
import (
"reflect"
"strings"
"testing"
@@ -40,6 +41,44 @@ func TestIsInlineContentType(t *testing.T) {
}
}
func TestUploadWriterOptions(t *testing.T) {
cases := []struct {
in string
disposition string
}{
{"image/png", ""},
{"image/jpeg", ""},
{"image/gif", ""},
{"image/avif", ""},
{"IMAGE/WEBP; charset=binary", ""},
{"text/html", "attachment"},
{"text/xml", "attachment"},
{"image/svg+xml", "attachment"},
{"application/pdf", "attachment"},
{"text/plain", "attachment"},
{"application/octet-stream", "attachment"},
{"", "attachment"},
}
for _, tc := range cases {
got := UploadWriterOptions(tc.in)
want := &blob.WriterOptions{ContentType: tc.in, ContentDisposition: tc.disposition}
if !reflect.DeepEqual(got, want) {
t.Errorf("UploadWriterOptions(%q) = %+v, want %+v", tc.in, got, want)
}
}
first := UploadWriterOptions("text/html")
second := UploadWriterOptions("text/html")
if first == second {
t.Fatal("UploadWriterOptions returned the same pointer twice")
}
first.ContentDisposition = "inline"
first.CacheControl = "no-store"
if third := UploadWriterOptions("text/html"); third.ContentDisposition != "attachment" || third.CacheControl != "" {
t.Errorf("mutating one result changed the next: %+v", third)
}
}
// TestWriteBlobDisposition is the driver half of the public-bucket fix: a
// public bucket serves the headers Store stores with the object, so the
// attachment disposition must survive the write on the real drivers. The

View File

@@ -205,7 +205,7 @@ func Store(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, in Upload, lim
func writeBlob(ctx context.Context, bucket *blob.Bucket, key string, r io.Reader, contentType string, limit int64) (int64, error) {
writeCtx, cancel := context.WithCancel(ctx)
defer cancel()
w, err := bucket.NewWriter(writeCtx, key, uploadWriterOptions(contentType))
w, err := bucket.NewWriter(writeCtx, key, UploadWriterOptions(contentType))
if err != nil {
return 0, fmt.Errorf("attach: blob writer: %w", err)
}