diff --git a/packages/bun-types/s3.d.ts b/packages/bun-types/s3.d.ts index 0dd0f653d1..1a8c4a088f 100644 --- a/packages/bun-types/s3.d.ts +++ b/packages/bun-types/s3.d.ts @@ -281,6 +281,24 @@ declare module "bun" { */ type?: string; + /** + * The Content-Disposition header value. + * Controls how the file is presented when downloaded. + * + * @example + * // Setting attachment disposition with filename + * const file = s3.file("report.pdf", { + * contentDisposition: "attachment; filename=\"quarterly-report.pdf\"" + * }); + * + * @example + * // Setting inline disposition + * await s3.write("image.png", imageData, { + * contentDisposition: "inline" + * }); + */ + contentDisposition?: string | undefined; + /** * By default, Amazon S3 uses the STANDARD Storage Class to store newly created objects. * diff --git a/src/bun.js/webcore/Blob.zig b/src/bun.js/webcore/Blob.zig index 0ec23eb690..25754b6811 100644 --- a/src/bun.js/webcore/Blob.zig +++ b/src/bun.js/webcore/Blob.zig @@ -968,6 +968,7 @@ fn writeFileWithEmptySourceToDestination(ctx: *jsc.JSGlobalObject, destination_b s3.path(), "", destination_blob.contentTypeOrMimeType(), + aws_options.content_disposition, aws_options.acl, proxy_url, aws_options.storage_class, @@ -1116,6 +1117,7 @@ pub fn writeFileWithSourceDestination(ctx: *jsc.JSGlobalObject, source_blob: *Bl aws_options.acl, aws_options.storage_class, destination_blob.contentTypeOrMimeType(), + aws_options.content_disposition, proxy_url, null, undefined, @@ -1154,6 +1156,7 @@ pub fn writeFileWithSourceDestination(ctx: *jsc.JSGlobalObject, source_blob: *Bl s3.path(), bytes.slice(), destination_blob.contentTypeOrMimeType(), + aws_options.content_disposition, aws_options.acl, proxy_url, aws_options.storage_class, @@ -1183,6 +1186,7 @@ pub fn writeFileWithSourceDestination(ctx: *jsc.JSGlobalObject, source_blob: *Bl aws_options.acl, aws_options.storage_class, destination_blob.contentTypeOrMimeType(), + aws_options.content_disposition, proxy_url, null, undefined, @@ -1387,6 +1391,7 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr aws_options.acl, aws_options.storage_class, destination_blob.contentTypeOrMimeType(), + aws_options.content_disposition, proxy_url, null, undefined, @@ -1447,6 +1452,7 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr aws_options.acl, aws_options.storage_class, destination_blob.contentTypeOrMimeType(), + aws_options.content_disposition, proxy_url, null, undefined, @@ -2402,6 +2408,7 @@ pub fn pipeReadableStreamToBlob(this: *Blob, globalThis: *jsc.JSGlobalObject, re aws_options.acl, aws_options.storage_class, this.contentTypeOrMimeType(), + aws_options.content_disposition, proxy_url, null, undefined, @@ -2629,6 +2636,14 @@ pub fn getWriter( } } } + var content_disposition_str: ?ZigString.Slice = null; + defer if (content_disposition_str) |cd| cd.deinit(); + if (try options.getTruthy(globalThis, "contentDisposition")) |content_disposition| { + if (!content_disposition.isString()) { + return globalThis.throwInvalidArgumentType("write", "options.contentDisposition", "string"); + } + content_disposition_str = try content_disposition.toSlice(globalThis, bun.default_allocator); + } const credentialsWithOptions = try s3.getCredentialsWithOptions(options, globalThis); return try S3.writableStream( credentialsWithOptions.credentials.dupe(), @@ -2636,6 +2651,7 @@ pub fn getWriter( globalThis, credentialsWithOptions.options, this.contentTypeOrMimeType(), + if (content_disposition_str) |cd| cd.slice() else null, proxy_url, credentialsWithOptions.storage_class, ); @@ -2647,6 +2663,7 @@ pub fn getWriter( globalThis, .{}, this.contentTypeOrMimeType(), + null, proxy_url, null, ); diff --git a/src/bun.js/webcore/fetch.zig b/src/bun.js/webcore/fetch.zig index beb726c7c5..82fedebeaf 100644 --- a/src/bun.js/webcore/fetch.zig +++ b/src/bun.js/webcore/fetch.zig @@ -1301,6 +1301,7 @@ pub fn Bun__fetch_( credentialsWithOptions.acl, credentialsWithOptions.storage_class, if (headers) |h| (h.getContentType()) else null, + if (headers) |h| h.getContentDisposition() else null, proxy_url, @ptrCast(&Wrapper.resolve), s3_stream, diff --git a/src/http/Headers.zig b/src/http/Headers.zig index f1b57f1595..f9a41ad546 100644 --- a/src/http/Headers.zig +++ b/src/http/Headers.zig @@ -75,20 +75,12 @@ pub fn deinit(this: *Headers) void { this.entries.deinit(this.allocator); this.buf.clearAndFree(this.allocator); } -pub fn getContentType(this: *const Headers) ?[]const u8 { - if (this.entries.len == 0 or this.buf.items.len == 0) { - return null; - } - const header_entries = this.entries.slice(); - const header_names = header_entries.items(.name); - const header_values = header_entries.items(.value); - for (header_names, 0..header_names.len) |name, i| { - if (bun.strings.eqlCaseInsensitiveASCII(this.asStr(name), "content-type", true)) { - return this.asStr(header_values[i]); - } - } - return null; +pub fn getContentDisposition(this: *const Headers) ?[]const u8 { + return this.get("content-disposition"); +} +pub fn getContentType(this: *const Headers) ?[]const u8 { + return this.get("content-type"); } pub fn asStr(this: *const Headers, ptr: api.StringPointer) []const u8 { return if (ptr.offset + ptr.length <= this.buf.items.len) diff --git a/src/s3/client.zig b/src/s3/client.zig index 3726d046eb..6adf666a4a 100644 --- a/src/s3/client.zig +++ b/src/s3/client.zig @@ -229,6 +229,7 @@ pub fn upload( path: []const u8, content: []const u8, content_type: ?[]const u8, + content_disposition: ?[]const u8, acl: ?ACL, proxy_url: ?[]const u8, storage_class: ?StorageClass, @@ -241,6 +242,7 @@ pub fn upload( .proxy_url = proxy_url, .body = content, .content_type = content_type, + .content_disposition = content_disposition, .acl = acl, .storage_class = storage_class, }, .{ .upload = callback }, callback_context); @@ -252,6 +254,7 @@ pub fn writableStream( globalThis: *jsc.JSGlobalObject, options: MultiPartUploadOptions, content_type: ?[]const u8, + content_disposition: ?[]const u8, proxy: ?[]const u8, storage_class: ?StorageClass, ) bun.JSError!jsc.JSValue { @@ -295,6 +298,7 @@ pub fn writableStream( .path = bun.handleOom(bun.default_allocator.dupe(u8, path)), .proxy = if (proxy_url.len > 0) bun.handleOom(bun.default_allocator.dupe(u8, proxy_url)) else "", .content_type = if (content_type) |ct| bun.handleOom(bun.default_allocator.dupe(u8, ct)) else null, + .content_disposition = if (content_disposition) |cd| bun.handleOom(bun.default_allocator.dupe(u8, cd)) else null, .storage_class = storage_class, .callback = @ptrCast(&Wrapper.callback), @@ -434,6 +438,7 @@ pub fn uploadStream( acl: ?ACL, storage_class: ?StorageClass, content_type: ?[]const u8, + content_disposition: ?[]const u8, proxy: ?[]const u8, callback: ?*const fn (S3UploadResult, *anyopaque) void, callback_context: *anyopaque, @@ -470,6 +475,7 @@ pub fn uploadStream( .path = bun.handleOom(bun.default_allocator.dupe(u8, path)), .proxy = if (proxy_url.len > 0) bun.handleOom(bun.default_allocator.dupe(u8, proxy_url)) else "", .content_type = if (content_type) |ct| bun.handleOom(bun.default_allocator.dupe(u8, ct)) else null, + .content_disposition = if (content_disposition) |cd| bun.handleOom(bun.default_allocator.dupe(u8, cd)) else null, .callback = @ptrCast(&S3UploadStreamWrapper.resolve), .callback_context = undefined, .globalThis = globalThis, diff --git a/src/s3/credentials.zig b/src/s3/credentials.zig index 82eee07062..05668dc780 100644 --- a/src/s3/credentials.zig +++ b/src/s3/credentials.zig @@ -212,6 +212,21 @@ pub const S3Credentials = struct { if (try opts.getOptionalEnum(globalObject, "storageClass", StorageClass)) |storage_class| { new_credentials.storage_class = storage_class; } + + if (try opts.getTruthyComptime(globalObject, "contentDisposition")) |js_value| { + if (!js_value.isEmptyOrUndefinedOrNull()) { + if (js_value.isString()) { + const str = try bun.String.fromJS(js_value, globalObject); + defer str.deref(); + if (str.tag != .Empty and str.tag != .Dead) { + new_credentials._contentDispositionSlice = str.toUTF8(bun.default_allocator); + new_credentials.content_disposition = new_credentials._contentDispositionSlice.?.slice(); + } + } else { + return globalObject.throwInvalidArgumentTypeValue("contentDisposition", "string", js_value); + } + } + } } } return new_credentials; @@ -879,17 +894,15 @@ pub const S3Credentials = struct { break :brk try std.fmt.allocPrint(bun.default_allocator, "{s}://{s}{s}?{s}", .{ protocol, host, normalizedPath, url_query_string.items }); } else { - var encoded_content_disposition_buffer: [255]u8 = undefined; - const encoded_content_disposition: []const u8 = if (content_disposition) |cd| encodeURIComponent(cd, &encoded_content_disposition_buffer, true) catch return error.ContentTypeIsTooLong else ""; const canonical = brk_canonical: { if (content_md5) |content_md5_value| { if (storage_class) |storage_class_value| { if (acl) |acl_value| { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -899,11 +912,11 @@ pub const S3Credentials = struct { } } } else { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -915,11 +928,11 @@ pub const S3Credentials = struct { } } else { if (acl) |acl_value| { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -929,11 +942,11 @@ pub const S3Credentials = struct { } } } else { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, content_md5_value, host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -957,11 +970,11 @@ pub const S3Credentials = struct { } else { if (storage_class) |storage_class_value| { if (acl) |acl_value| { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -971,11 +984,11 @@ pub const S3Credentials = struct { } } } else { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -987,11 +1000,11 @@ pub const S3Credentials = struct { } } else { if (acl) |acl_value| { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -1001,11 +1014,11 @@ pub const S3Credentials = struct { } } } else { - if (content_disposition != null) { + if (content_disposition) |disposition| { if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", disposition, host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); } } else { if (session_token) |token| { @@ -1079,16 +1092,15 @@ pub const S3Credentials = struct { result._headers[result._headers_len] = .{ .name = "x-amz-security-token", .value = session_token_value }; result._headers_len += 1; } + if (storage_class) |storage_class_value| { + result._headers[result._headers_len] = .{ .name = "x-amz-storage-class", .value = storage_class_value }; + result._headers_len += 1; + } if (content_disposition) |cd| { const content_disposition_value = bun.handleOom(bun.default_allocator.dupe(u8, cd)); result.content_disposition = content_disposition_value; - result._headers[result._headers_len] = .{ .name = "Content-Disposition", .value = content_disposition_value }; - result._headers_len += 1; - } - - if (storage_class) |storage_class_value| { - result._headers[result._headers_len] = .{ .name = "x-amz-storage-class", .value = storage_class_value }; + result._headers[result._headers_len] = .{ .name = "content-disposition", .value = content_disposition_value }; result._headers_len += 1; } @@ -1108,6 +1120,7 @@ pub const S3CredentialsWithOptions = struct { options: MultiPartUploadOptions = .{}, acl: ?ACL = null, storage_class: ?StorageClass = null, + content_disposition: ?[]const u8 = null, /// indicates if the credentials have changed changed_credentials: bool = false, /// indicates if the virtual hosted style is used @@ -1118,6 +1131,7 @@ pub const S3CredentialsWithOptions = struct { _endpointSlice: ?jsc.ZigString.Slice = null, _bucketSlice: ?jsc.ZigString.Slice = null, _sessionTokenSlice: ?jsc.ZigString.Slice = null, + _contentDispositionSlice: ?jsc.ZigString.Slice = null, pub fn deinit(this: *@This()) void { if (this._accessKeyIdSlice) |slice| slice.deinit(); @@ -1126,6 +1140,7 @@ pub const S3CredentialsWithOptions = struct { if (this._endpointSlice) |slice| slice.deinit(); if (this._bucketSlice) |slice| slice.deinit(); if (this._sessionTokenSlice) |slice| slice.deinit(); + if (this._contentDispositionSlice) |slice| slice.deinit(); } }; diff --git a/src/s3/multipart.zig b/src/s3/multipart.zig index 90afe0c7e5..17bcfe70a5 100644 --- a/src/s3/multipart.zig +++ b/src/s3/multipart.zig @@ -115,6 +115,7 @@ pub const MultiPartUpload = struct { path: []const u8, proxy: []const u8, content_type: ?[]const u8 = null, + content_disposition: ?[]const u8 = null, upload_id: []const u8 = "", uploadid_buffer: bun.MutableString = .{ .allocator = bun.default_allocator, .list = .{} }, @@ -276,6 +277,11 @@ pub const MultiPartUpload = struct { bun.default_allocator.free(ct); } } + if (this.content_disposition) |cd| { + if (cd.len > 0) { + bun.default_allocator.free(cd); + } + } this.credentials.deref(); this.uploadid_buffer.deinit(); for (this.multipart_etags.items) |tag| { @@ -301,6 +307,7 @@ pub const MultiPartUpload = struct { .proxy_url = this.proxyUrl(), .body = this.buffered.slice(), .content_type = this.content_type, + .content_disposition = this.content_disposition, .acl = this.acl, .storage_class = this.storage_class, }, .{ .upload = @ptrCast(&singleSendUploadResponse) }, this); @@ -589,6 +596,7 @@ pub const MultiPartUpload = struct { .body = "", .search_params = "?uploads=", .content_type = this.content_type, + .content_disposition = this.content_disposition, .acl = this.acl, .storage_class = this.storage_class, }, .{ .download = @ptrCast(&startMultiPartRequestResult) }, this); @@ -665,6 +673,7 @@ pub const MultiPartUpload = struct { .proxy_url = this.proxyUrl(), .body = this.buffered.slice(), .content_type = this.content_type, + .content_disposition = this.content_disposition, .acl = this.acl, .storage_class = this.storage_class, }, .{ .upload = @ptrCast(&singleSendUploadResponse) }, this) catch {}; // TODO: properly propagate exception upwards diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index 8c8034785a..68c6ef034b 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -408,6 +408,43 @@ for (let credentials of allCredentials) { } }); + it("should be able to set content-disposition", async () => { + await using tmpfile = await tmp(); + { + const s3file = bucket.file(tmpfile.name, options!); + await s3file.write("Hello Bun!", { contentDisposition: 'attachment; filename="test.txt"' }); + const response = await fetch(s3file.presign()); + expect(response.headers.get("content-disposition")).toBe('attachment; filename="test.txt"'); + } + { + const s3file = bucket.file(tmpfile.name, options!); + await s3file.write("Hello Bun!", { contentDisposition: "inline" }); + const response = await fetch(s3file.presign()); + expect(response.headers.get("content-disposition")).toBe("inline"); + } + { + await bucket.write(tmpfile.name, "Hello Bun!", { + ...options, + contentDisposition: 'attachment; filename="report.pdf"', + }); + const response = await fetch(bucket.file(tmpfile.name, options!).presign()); + expect(response.headers.get("content-disposition")).toBe('attachment; filename="report.pdf"'); + } + }); + it("should be able to set content-disposition in writer", async () => { + await using tmpfile = await tmp(); + { + const s3file = bucket.file(tmpfile.name, options!); + const writer = s3file.writer({ + contentDisposition: 'attachment; filename="test.txt"', + }); + writer.write("Hello Bun!!"); + await writer.end(); + const response = await fetch(s3file.presign()); + expect(response.headers.get("content-disposition")).toBe('attachment; filename="test.txt"'); + } + }); + it("should be able to upload large files using bucket.write + readable Request", async () => { await using tmpfile = await tmp(); {