Skip to content

Commit 3429667

Browse files
feat(storage): autodetect narrow down text/plain contentype (#3022)
* feat(storage): autodetect narrow down text/plain contentype based on extension - Add content type detection for JavaScript (text/javascript) - Add content type detection for CSS (text/css) - Add content type detection for SQL (application/x-sql) - Default to text/plain for unrecognized text files like .go * fix: tests for cross platform run * chore: reuse upload object method --------- Co-authored-by: Qiao Han <[email protected]>
1 parent b0c7523 commit 3429667

File tree

4 files changed

+108
-15
lines changed

4 files changed

+108
-15
lines changed

internal/storage/cp/cp.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func Run(ctx context.Context, src, dst string, recursive bool, maxJobs uint, fsy
5252
if recursive {
5353
return UploadStorageObjectAll(ctx, api, dstParsed.Path, localPath, maxJobs, fsys, opts...)
5454
}
55-
return api.UploadObject(ctx, dstParsed.Path, src, fsys, opts...)
55+
return api.UploadObject(ctx, dstParsed.Path, src, utils.NewRootFS(fsys), opts...)
5656
} else if strings.EqualFold(srcParsed.Scheme, client.STORAGE_SCHEME) && strings.EqualFold(dstParsed.Scheme, client.STORAGE_SCHEME) {
5757
return errors.New("Copying between buckets is not supported")
5858
}
@@ -149,7 +149,7 @@ func UploadStorageObjectAll(ctx context.Context, api storage.StorageAPI, remoteP
149149
}
150150
fmt.Fprintln(os.Stderr, "Uploading:", filePath, "=>", dstPath)
151151
job := func() error {
152-
err := api.UploadObject(ctx, dstPath, filePath, fsys, opts...)
152+
err := api.UploadObject(ctx, dstPath, filePath, utils.NewRootFS(fsys), opts...)
153153
if err != nil && strings.Contains(err.Error(), `"error":"Bucket not found"`) {
154154
// Retry after creating bucket
155155
if bucket, prefix := client.SplitBucketPrefix(dstPath); len(prefix) > 0 {
@@ -162,7 +162,7 @@ func UploadStorageObjectAll(ctx context.Context, api storage.StorageAPI, remoteP
162162
if _, err := api.CreateBucket(ctx, body); err != nil {
163163
return err
164164
}
165-
err = api.UploadObject(ctx, dstPath, filePath, fsys, opts...)
165+
err = api.UploadObject(ctx, dstPath, filePath, utils.NewRootFS(fsys), opts...)
166166
}
167167
}
168168
return err

pkg/storage/batch.go

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,9 @@ func (s *StorageAPI) UpsertObjects(ctx context.Context, bucketConfig config.Buck
9191
}
9292
fmt.Fprintln(os.Stderr, "Uploading:", filePath, "=>", dstPath)
9393
job := func() error {
94-
f, err := fsys.Open(filePath)
95-
if err != nil {
96-
return errors.Errorf("failed to open file: %w", err)
97-
}
98-
defer f.Close()
99-
fo, err := ParseFileOptions(f)
100-
if err != nil {
101-
return err
102-
}
103-
fo.Overwrite = true
104-
return s.UploadObjectStream(ctx, dstPath, f, *fo)
94+
return s.UploadObject(ctx, dstPath, filePath, fsys, func(fo *FileOptions) {
95+
fo.Overwrite = true
96+
})
10597
}
10698
return jq.Put(job)
10799
}

pkg/storage/objects.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"context"
55
"io"
66
"io/fs"
7+
"mime"
78
"net/http"
89
"os"
910
"path"
11+
"path/filepath"
1012
"strings"
1113

1214
"github.com/go-errors/errors"
@@ -88,7 +90,7 @@ func ParseFileOptions(f fs.File, opts ...func(*FileOptions)) (*FileOptions, erro
8890
return fo, nil
8991
}
9092

91-
func (s *StorageAPI) UploadObject(ctx context.Context, remotePath, localPath string, fsys afero.Fs, opts ...func(*FileOptions)) error {
93+
func (s *StorageAPI) UploadObject(ctx context.Context, remotePath, localPath string, fsys fs.FS, opts ...func(*FileOptions)) error {
9294
f, err := fsys.Open(localPath)
9395
if err != nil {
9496
return errors.Errorf("failed to open file: %w", err)
@@ -98,6 +100,13 @@ func (s *StorageAPI) UploadObject(ctx context.Context, remotePath, localPath str
98100
if err != nil {
99101
return err
100102
}
103+
// For text/plain content types, we try to determine a more specific type
104+
// based on the file extension, as the initial detection might be too generic
105+
if strings.Contains(fo.ContentType, "text/plain") {
106+
if extensionType := mime.TypeByExtension(filepath.Ext(localPath)); extensionType != "" {
107+
fo.ContentType = extensionType
108+
}
109+
}
101110
return s.UploadObjectStream(ctx, remotePath, f, *fo)
102111
}
103112

pkg/storage/objects_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package storage
2+
3+
import (
4+
"context"
5+
"mime"
6+
"net/http"
7+
"testing"
8+
fs "testing/fstest"
9+
10+
"github.com/h2non/gock"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/supabase/cli/internal/testing/apitest"
13+
"github.com/supabase/cli/pkg/fetcher"
14+
)
15+
16+
var mockApi = StorageAPI{Fetcher: fetcher.NewFetcher(
17+
"http://127.0.0.1",
18+
)}
19+
20+
func TestParseFileOptionsContentTypeDetection(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
content []byte
24+
filename string
25+
opts []func(*FileOptions)
26+
wantMimeType string
27+
wantCacheCtrl string
28+
}{
29+
{
30+
name: "detects PNG image",
31+
content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, // PNG header
32+
filename: "test.image",
33+
wantMimeType: "image/png",
34+
wantCacheCtrl: "max-age=3600",
35+
},
36+
{
37+
name: "detects JavaScript file",
38+
content: []byte("const hello = () => console.log('Hello, World!');"),
39+
filename: "script.js",
40+
wantMimeType: mime.TypeByExtension(".js"),
41+
wantCacheCtrl: "max-age=3600",
42+
},
43+
{
44+
name: "detects CSS file",
45+
content: []byte(".header { color: #333; font-size: 16px; }"),
46+
filename: "styles.css",
47+
wantMimeType: mime.TypeByExtension(".css"),
48+
wantCacheCtrl: "max-age=3600",
49+
},
50+
{
51+
name: "detects SQL file",
52+
content: []byte("SELECT * FROM users WHERE id = 1;"),
53+
filename: "query.sql",
54+
wantMimeType: mime.TypeByExtension(".sql"),
55+
wantCacheCtrl: "max-age=3600",
56+
},
57+
{
58+
name: "use text/plain as fallback for unrecognized extensions",
59+
content: []byte("const hello = () => console.log('Hello, World!');"),
60+
filename: "main.nonexistent",
61+
wantMimeType: "text/plain; charset=utf-8",
62+
wantCacheCtrl: "max-age=3600",
63+
},
64+
{
65+
name: "respects custom content type",
66+
content: []byte("const hello = () => console.log('Hello, World!');"),
67+
filename: "custom.js",
68+
wantMimeType: "application/custom",
69+
wantCacheCtrl: "max-age=3600",
70+
opts: []func(*FileOptions){func(fo *FileOptions) { fo.ContentType = "application/custom" }},
71+
},
72+
}
73+
74+
for _, tt := range tests {
75+
t.Run(tt.name, func(t *testing.T) {
76+
// Create a temporary file with test content
77+
fsys := fs.MapFS{tt.filename: &fs.MapFile{Data: tt.content}}
78+
// Setup mock api
79+
defer gock.OffAll()
80+
gock.New("http://127.0.0.1").
81+
Post("/storage/v1/object/"+tt.filename).
82+
MatchHeader("Content-Type", tt.wantMimeType).
83+
MatchHeader("Cache-Control", tt.wantCacheCtrl).
84+
Reply(http.StatusOK)
85+
// Parse options
86+
err := mockApi.UploadObject(context.Background(), tt.filename, tt.filename, fsys, tt.opts...)
87+
// Assert results
88+
assert.NoError(t, err)
89+
assert.Empty(t, apitest.ListUnmatchedRequests())
90+
})
91+
}
92+
}

0 commit comments

Comments
 (0)