From 38b17246fed092ee54297a8b0d617c7b8a175e86 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Mon, 8 Dec 2025 17:55:59 +0000 Subject: [PATCH] lib/rest: add opts.MultipartContentType to explicitly set Content-Type of attachements Before this the standard library set it to application/octet-stream for some reason --- backend/imagekit/client/upload.go | 2 +- backend/pcloud/pcloud.go | 2 +- backend/pikpak/pikpak.go | 2 +- backend/seafile/webapi.go | 2 +- backend/zoho/zoho.go | 2 +- fs/operations/rc_test.go | 4 +-- lib/rest/rest.go | 41 ++++++++++++++++++++++++++----- 7 files changed, 42 insertions(+), 13 deletions(-) diff --git a/backend/imagekit/client/upload.go b/backend/imagekit/client/upload.go index 7964f8c46..630f068c4 100644 --- a/backend/imagekit/client/upload.go +++ b/backend/imagekit/client/upload.go @@ -72,7 +72,7 @@ func (ik *ImageKit) Upload(ctx context.Context, file io.Reader, param UploadPara response := &UploadResult{} - formReader, contentType, _, err := rest.MultipartUpload(ctx, file, formParams, "file", param.FileName) + formReader, contentType, _, err := rest.MultipartUpload(ctx, file, formParams, "file", param.FileName, "application/octet-stream") if err != nil { return nil, nil, fmt.Errorf("failed to make multipart upload: %w", err) diff --git a/backend/pcloud/pcloud.go b/backend/pcloud/pcloud.go index afbc421ef..4b52e391f 100644 --- a/backend/pcloud/pcloud.go +++ b/backend/pcloud/pcloud.go @@ -1459,7 +1459,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op // opts.Body=0), so upload it as a multipart form POST with // Content-Length set. if size == 0 { - formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, opts.Parameters, "content", leaf) + formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, opts.Parameters, "content", leaf, opts.ContentType) if err != nil { return fmt.Errorf("failed to make multipart upload for 0 length file: %w", err) } diff --git a/backend/pikpak/pikpak.go b/backend/pikpak/pikpak.go index 7cce6695e..142905475 100644 --- a/backend/pikpak/pikpak.go +++ b/backend/pikpak/pikpak.go @@ -1384,7 +1384,7 @@ func (f *Fs) uploadByForm(ctx context.Context, in io.Reader, name string, size i for i := range iVal.NumField() { params.Set(iTyp.Field(i).Tag.Get("json"), iVal.Field(i).String()) } - formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, params, "file", name) + formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, params, "file", name, "application/octet-stream") if err != nil { return fmt.Errorf("failed to make multipart upload: %w", err) } diff --git a/backend/seafile/webapi.go b/backend/seafile/webapi.go index 967dbabf0..dc9da9e16 100644 --- a/backend/seafile/webapi.go +++ b/backend/seafile/webapi.go @@ -688,7 +688,7 @@ func (f *Fs) upload(ctx context.Context, in io.Reader, uploadLink, filePath stri "need_idx_progress": {"true"}, "replace": {"1"}, } - formReader, contentType, _, err := rest.MultipartUpload(ctx, in, parameters, "file", f.opt.Enc.FromStandardName(filename)) + formReader, contentType, _, err := rest.MultipartUpload(ctx, in, parameters, "file", f.opt.Enc.FromStandardName(filename), "application/octet-stream") if err != nil { return nil, fmt.Errorf("failed to make multipart upload: %w", err) } diff --git a/backend/zoho/zoho.go b/backend/zoho/zoho.go index 30adb413e..3b40e0828 100644 --- a/backend/zoho/zoho.go +++ b/backend/zoho/zoho.go @@ -817,7 +817,7 @@ func (f *Fs) upload(ctx context.Context, name string, parent string, size int64, params.Set("filename", url.QueryEscape(name)) params.Set("parent_id", parent) params.Set("override-name-exist", strconv.FormatBool(true)) - formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, nil, "content", name) + formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, nil, "content", name, "application/octet-stream") if err != nil { return nil, fmt.Errorf("failed to make multipart upload: %w", err) } diff --git a/fs/operations/rc_test.go b/fs/operations/rc_test.go index 2d8d0f2af..f8b3cb6f3 100644 --- a/fs/operations/rc_test.go +++ b/fs/operations/rc_test.go @@ -561,7 +561,7 @@ func TestUploadFile(t *testing.T) { assert.NoError(t, currentFile.Close()) }() - formReader, contentType, _, err := rest.MultipartUpload(ctx, currentFile, url.Values{}, "file", testFileName) + formReader, contentType, _, err := rest.MultipartUpload(ctx, currentFile, url.Values{}, "file", testFileName, "application/octet-stream") require.NoError(t, err) httpReq := httptest.NewRequest("POST", "/", formReader) @@ -587,7 +587,7 @@ func TestUploadFile(t *testing.T) { assert.NoError(t, currentFile2.Close()) }() - formReader, contentType, _, err = rest.MultipartUpload(ctx, currentFile2, url.Values{}, "file", testFileName) + formReader, contentType, _, err = rest.MultipartUpload(ctx, currentFile2, url.Values{}, "file", testFileName, "application/octet-stream") require.NoError(t, err) httpReq = httptest.NewRequest("POST", "/", formReader) diff --git a/lib/rest/rest.go b/lib/rest/rest.go index 2557b68c3..3e92163a6 100644 --- a/lib/rest/rest.go +++ b/lib/rest/rest.go @@ -14,7 +14,9 @@ import ( "maps" "mime/multipart" "net/http" + "net/textproto" "net/url" + "strings" "sync" "github.com/rclone/rclone/fs" @@ -145,6 +147,7 @@ type Opts struct { MultipartMetadataName string // ..this is used for the name of the metadata form part if set MultipartContentName string // ..name of the parameter which is the attached file MultipartFileName string // ..name of the file for the attached file + MultipartContentType string // ..content type of the attached file Parameters url.Values // any parameters for the final URL TransferEncoding []string // transfer encoding, set to "identity" to disable chunked encoding Trailer *http.Header // set the request trailer @@ -371,6 +374,32 @@ func (api *Client) Call(ctx context.Context, opts *Opts) (resp *http.Response, e return resp, nil } +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +// multipartFileContentDisposition returns the value of a Content-Disposition header +// with the provided field name and file name. +func multipartFileContentDisposition(fieldname, filename string) string { + return fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + escapeQuotes(fieldname), escapeQuotes(filename)) +} + +// CreateFormFile is a convenience wrapper around [Writer.CreatePart]. It creates +// a new form-data header with the provided field name and file name. +func CreateFormFile(w *multipart.Writer, fieldname, filename, contentType string) (io.Writer, error) { + h := make(textproto.MIMEHeader) + // FIXME when go1.24 is no longer supported, change to + // multipart.FileContentDisposition and remove definition above + h.Set("Content-Disposition", multipartFileContentDisposition(fieldname, filename)) + if contentType != "" { + h.Set("Content-Type", contentType) + } + return w.CreatePart(h) +} + // MultipartUpload creates an io.Reader which produces an encoded a // multipart form upload from the params passed in and the passed in // @@ -382,10 +411,10 @@ func (api *Client) Call(ctx context.Context, opts *Opts) (resp *http.Response, e // the int64 returned is the overhead in addition to the file contents, in case Content-Length is required // // NB This doesn't allow setting the content type of the attachment -func MultipartUpload(ctx context.Context, in io.Reader, params url.Values, contentName, fileName string) (io.ReadCloser, string, int64, error) { +func MultipartUpload(ctx context.Context, in io.Reader, params url.Values, contentName, fileName string, contentType string) (io.ReadCloser, string, int64, error) { bodyReader, bodyWriter := io.Pipe() writer := multipart.NewWriter(bodyWriter) - contentType := writer.FormDataContentType() + formContentType := writer.FormDataContentType() // Create a Multipart Writer as base for calculating the Content-Length buf := &bytes.Buffer{} @@ -404,7 +433,7 @@ func MultipartUpload(ctx context.Context, in io.Reader, params url.Values, conte } } if in != nil { - _, err = dummyMultipartWriter.CreateFormFile(contentName, fileName) + _, err = CreateFormFile(dummyMultipartWriter, contentName, fileName, contentType) if err != nil { return nil, "", 0, err } @@ -445,7 +474,7 @@ func MultipartUpload(ctx context.Context, in io.Reader, params url.Values, conte } if in != nil { - part, err := writer.CreateFormFile(contentName, fileName) + part, err := CreateFormFile(writer, contentName, fileName, contentType) if err != nil { _ = bodyWriter.CloseWithError(fmt.Errorf("failed to create form file: %w", err)) return @@ -467,7 +496,7 @@ func MultipartUpload(ctx context.Context, in io.Reader, params url.Values, conte _ = bodyWriter.Close() }() - return bodyReader, contentType, multipartLength, nil + return bodyReader, formContentType, multipartLength, nil } // CallJSON runs Call and decodes the body as a JSON object into response (if not nil) @@ -539,7 +568,7 @@ func (api *Client) callCodec(ctx context.Context, opts *Opts, request any, respo opts = opts.Copy() var overhead int64 - opts.Body, opts.ContentType, overhead, err = MultipartUpload(ctx, opts.Body, params, opts.MultipartContentName, opts.MultipartFileName) + opts.Body, opts.ContentType, overhead, err = MultipartUpload(ctx, opts.Body, params, opts.MultipartContentName, opts.MultipartFileName, opts.MultipartContentType) if err != nil { return nil, err }