supabase-community / storage-go

Storage util client for Supabase in Go
48 stars 25 forks source link

CreateSignedUrl fails when object content-type is different from "application/json" #24

Open bjuan210302 opened 9 months ago

bjuan210302 commented 9 months ago

Bug report

Describe the bug

CreateSignedUrl fails with body must be object when target object content-type is different from "application/json"

To Reproduce

// Upload file and return signed url
func uploadFile() (string, error) {
    path := "data/file.txt"
        content := "Hello, I'm a file"

         // Upload OK
    fileRes, _ := supaStorage.UploadFile(SUPA_BUCKET_NAME, path, strings.NewReader(content), supa_storage.FileOptions{
        Upsert: makePointer(true),
        ContentType: makePointer("text/plain"), // <-- This line causes the error on the next method call
    })

    urlRes, err := supaStorage.CreateSignedUrl(SUPA_BUCKET_NAME, path, 60 * 30)
    if err != nil {
        log.Default().Println(err) // "body must be object" when setting content type
        return "", err
    }

    return urlRes.SignedURL, nil
}

If you do not set ContentType, or set it to the default "application/json" it works.

        fileRes, _ := supaStorage.UploadFile(SUPA_BUCKET_NAME, path, strings.NewReader(content), supa_storage.FileOptions{
        Upsert: makePointer(true),
                // ContentType: makePointer("text/plain")
    })

    urlRes, err := supaStorage.CreateSignedUrl(SUPA_BUCKET_NAME, path, 60 * 30)
        // No error
    if err != nil {
        log.Default().Println(err)
        return "", err
    }

Expected behavior

Method should return the signed url regardless of content type

System information

Additional context

After some digging, the error seems to be coming from the execution of the request at storage.go@CreateSignedUrl

// storage.go

// CreateSignedUrl create a signed URL. Use a signed URL to share a file for a fixed amount of time.
// bucketId string The bucket id
// filePath path The file path, including the file name. Should be of the format `folder/subfolder/filename.png`
// expiresIn int The number of seconds before the signed URL expires. Defaults to 60 seconds.
func (c *Client) CreateSignedUrl(bucketId string, filePath string, expiresIn int) (SignedUrlResponse, error) {
    signedURL := c.clientTransport.baseUrl.String() + "/object/sign/" + bucketId + "/" + filePath
    jsonBody := map[string]interface{}{
        "expiresIn": expiresIn,
    }

    req, err := c.NewRequest(http.MethodPost, signedURL, &jsonBody)
    if err != nil {
        return SignedUrlResponse{}, err
    }

    var response SignedUrlResponse
    _, err = c.Do(req, &response)
    if err != nil {                //  <---- This is the error being triggered
        return SignedUrlResponse{}, err
    }

    response.SignedURL = c.clientTransport.baseUrl.String() + response.SignedURL

    return response, nil
}

However, I'm really new to Golang and don't really know what could be causing this. Maybe there's an option to parse non-JSON bodies differently? Also, this is not on Supabase's side, as the NodeJS client seems to do this just fine

bjuan210302 commented 9 months ago

I think I found the problem. When you call UploadOrUpdateFile with options, the options permanently affect the headers of all request:

// storage.go
func (c *Client) UploadOrUpdateFile(
    bucketId string,
    relativePath string,
    data io.Reader,
    update bool,
    options ...FileOptions,
) (FileUploadResponse, error) {
    path := removeEmptyFolderName(bucketId + "/" + relativePath)
    uploadURL := c.clientTransport.baseUrl.String() + "/object/" + path

    // Check on file options
    if len(options) > 0 {
        if options[0].CacheControl != nil {
            c.clientTransport.header.Set("cache-control", *options[0].CacheControl)
        }
        if options[0].ContentType != nil {
            c.clientTransport.header.Set("content-type", *options[0].ContentType) // <-- content-type is set to whatever you put
        }
        if options[0].Upsert != nil {
            c.clientTransport.header.Set("x-upsert", strconv.FormatBool(*options[0].Upsert))
        }
    }

This header doesn't get reset back to "application/json", so when you fire the next request (in my case to generate a signed URL to the object) it gets sent with the header you just set. You can see this dumping the request, responses:

// Here I'm uploading a file with the default content type

REQUEST:
%s POST /storage/v1/object/otc-data/orders/20583240451453857792/chat.txt HTTP/1.1
Host: rqvnhdsauyuopdhfgoou.supabase.co
Accept: application/json
Authorization: Bearer xxxxxxx
Content-Type: application/json      // <-- unchanged
X-Client-Info: storage-go/v0.7.0
X-Upsert: true

2024/01/24 18:15:34 RESPONSE:
%s HTTP/2.0 200 OK
Access-Control-Allow-Origin: *
Alt-Svc: h3=":443"; ma=86400
Cf-Cache-Status: DYNAMIC
Cf-Ray: 84abfaac5a1a226f-MIA
Content-Type: application/json; charset=utf-8
Date: Wed, 24 Jan 2024 23:15:34 GMT
Sb-Gateway-Mode: direct
Sb-Gateway-Version: 1
Server: cloudflare
Strict-Transport-Security: max-age=2592000; includeSubDomains
Vary: Accept-Encoding

{"Id":"ad7b3555-8b16-4428-aa1c-9cbb9a3f82df","Key":"otc-data/orders/20583240451453857792/chat.txt"}

// And then generating a signed URL

2024/01/24 18:15:34 REQUEST:
%s POST /storage/v1/object/sign/otc-data/orders/20583240451453857792/chat.txt HTTP/1.1
Host: rqvnhdsauyuopdhfgoou.supabase.co
Accept: application/json
Authorization: Bearer xxxx
Content-Type: application/json     // <-- unchanged
X-Client-Info: storage-go/v0.7.0
X-Upsert: true

2024/01/24 18:15:34 RESPONSE:
%s HTTP/2.0 200 OK
Access-Control-Allow-Origin: *
Alt-Svc: h3=":443"; ma=86400
Cf-Cache-Status: DYNAMIC
Cf-Ray: 84abfaaedddd226f-MIA
Content-Type: application/json; charset=utf-8
Date: Wed, 24 Jan 2024 23:15:34 GMT
Sb-Gateway-Mode: direct
Sb-Gateway-Version: 1
Server: cloudflare
Strict-Transport-Security: max-age=2592000; includeSubDomains
Vary: Accept-Encoding

{"signedURL":"/object/sign/otc-data/orders/20583240451453857792/chat.txt?token=xxxxxx"}

This happens when you set the content-type

2024/01/24 18:15:35 REQUEST:
%s POST /storage/v1/object/otc-data/orders/20583240451453857792/0.jpg HTTP/1.1
Host: rqvnhdsauyuopdhfgoou.supabase.co
Accept: application/json
Authorization: Bearer xxxx
Content-Type: text/plain              // <-- THIS CHANGED TO MATCH FILE
X-Client-Info: storage-go/v0.7.0
X-Upsert: true

// First response is OK

2024/01/24 18:15:35 RESPONSE:
%s HTTP/2.0 200 OK
Access-Control-Allow-Origin: *
Alt-Svc: h3=":443"; ma=86400
Cf-Cache-Status: DYNAMIC
Cf-Ray: 84abfab22a44226f-MIA
Content-Type: application/json; charset=utf-8
Date: Wed, 24 Jan 2024 23:15:36 GMT
Sb-Gateway-Mode: direct
Sb-Gateway-Version: 1
Server: cloudflare
Strict-Transport-Security: max-age=2592000; includeSubDomains
Vary: Accept-Encoding

{"Id":"6d488ac9-0472-4449-9e5a-f843199de539","Key":"otc-data/orders/20583240451453857792/0.jpg"}
2024/01/24 18:15:35 {otc-data/orders/20583240451453857792/0.jpg  []  }
2024/01/24 18:15:35 

// But when you try to generate the signed URL

2024/01/24 18:15:35 REQUEST:
%s POST /storage/v1/object/sign/otc-data/orders/20583240451453857792/0.jpg HTTP/1.1
Host: rqvnhdsauyuopdhfgoou.supabase.co
Accept: application/json
Authorization: Bearer xxxx
Content-Type: text/plain                // <-- and DID NOT reset 
X-Client-Info: storage-go/v0.7.0
X-Upsert: true

// So Supabase says something is wrong with your request body

RESPONSE:
%s HTTP/2.0 400 Bad Request
Content-Length: 68
Access-Control-Allow-Origin: *
Alt-Svc: h3=":443"; ma=86400
Cf-Cache-Status: DYNAMIC
Cf-Ray: 84abfab6f975226f-MIA
Content-Type: application/json; charset=utf-8
Date: Wed, 24 Jan 2024 23:15:36 GMT
Sb-Gateway-Mode: direct
Sb-Gateway-Version: 1
Server: cloudflare
Strict-Transport-Security: max-age=2592000; includeSubDomains
Vary: Accept-Encoding

{"statusCode":"400","error":"Error","message":"body must be object"}

If you add c.clientTransport.header.Set("content-type", "application/json") right after you fire UploadFile request, the subsequent request work as expected.