Fix S3 filename header escaping
Use an ASCII-only fallback for Content-Disposition filename and RFC 5987 path escaping for filename* so presigned S3 responses keep spaces and Unicode filenames interoperable. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Ludovic <ludovic@probo.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
@@ -118,11 +119,10 @@ func (s *Service) GeneratePresignedURL(
|
|||||||
) (string, error) {
|
) (string, error) {
|
||||||
presignClient := s3.NewPresignClient(s.s3Client)
|
presignClient := s3.NewPresignClient(s.s3Client)
|
||||||
|
|
||||||
encodedFilename := url.QueryEscape(file.FileName)
|
|
||||||
contentDisposition := fmt.Sprintf(
|
contentDisposition := fmt.Sprintf(
|
||||||
"attachment; filename=%q; filename*=UTF-8''%s",
|
"attachment; filename=%q; filename*=UTF-8''%s",
|
||||||
encodedFilename,
|
asciiFilename(file.FileName),
|
||||||
encodedFilename,
|
url.PathEscape(file.FileName),
|
||||||
)
|
)
|
||||||
|
|
||||||
presignedReq, err := presignClient.PresignGetObject(
|
presignedReq, err := presignClient.PresignGetObject(
|
||||||
@@ -145,6 +145,22 @@ func (s *Service) GeneratePresignedURL(
|
|||||||
return presignedReq.URL, nil
|
return presignedReq.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func asciiFilename(filename string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(filename))
|
||||||
|
|
||||||
|
for _, r := range filename {
|
||||||
|
if r < 0x20 || r > 0x7e {
|
||||||
|
b.WriteByte('_')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// GetFileSize determines the byte size of a seekable io.Reader by seeking to
|
// GetFileSize determines the byte size of a seekable io.Reader by seeking to
|
||||||
// the end and back. Returns an error if content is not seekable.
|
// the end and back. Returns an error if content is not seekable.
|
||||||
func GetFileSize(content io.Reader) (int64, error) {
|
func GetFileSize(content io.Reader) (int64, error) {
|
||||||
|
|||||||
@@ -15,9 +15,16 @@
|
|||||||
package filemanager_test
|
package filemanager_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
@@ -65,3 +72,33 @@ func TestGenerateFileURL_PrivateFile(t *testing.T) {
|
|||||||
svc.GenerateFileURL(file),
|
svc.GenerateFileURL(file),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratePresignedFileURL_EscapesContentDispositionFilename(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s3Client := awss3.NewFromConfig(
|
||||||
|
aws.Config{
|
||||||
|
Region: "us-east-1",
|
||||||
|
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
svc := filemanager.NewService(nil, nil, s3Client)
|
||||||
|
file := &coredata.File{
|
||||||
|
BucketName: "uploads",
|
||||||
|
FileKey: "tenant/file",
|
||||||
|
FileName: `report "Q2"/résumé 100%.pdf`,
|
||||||
|
MimeType: "application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
rawURL, err := svc.GeneratePresignedFileURL(context.Background(), file, time.Hour)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(rawURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
`attachment; filename="report \"Q2\"/r_sum_ 100%.pdf"; filename*=UTF-8''report%20%22Q2%22%2Fr%C3%A9sum%C3%A9%20100%25.pdf`,
|
||||||
|
parsedURL.Query().Get("response-content-disposition"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user