mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 01:36:15 -04:00
net: accept Blob and File values in FormData.append/set
The JS-facing append() and set() took `value: []const u8`, so js.Bridge coerced any object argument through toString(). Passing a Blob or File stored the literal text "[object Blob]" / "[object File]" and the bytes never reached the wire: a FormData carrying a 200 KB File produced a 209-byte request body, with no filename parameter and no per-part Content-Type. The request still succeeded with a 200, so nothing surfaced the loss. Only the binding was string-typed. Entry.Value already carries a `file: *File` variant with refcounting, and the multipart writer already emits filename= and a per-part Content-Type for it — that is how <input type=file> submissions work today. Take a tagged union instead, following the shape body_init.zig already uses for BodyInit (`bytes` last, so js.Bridge only falls back to a string once the JsApi classes have been tried), and implement the entry-creation steps from https://xhr.spec.whatwg.org/#create-an-entry: a Blob that is not a File becomes a File named "blob", and an explicit filename produces a new File over the same bytes rather than renaming the caller's object. The internal append(name, value) helper keeps its string signature — it backs form collection and the existing unit tests, neither of which goes through JS. Closes #3050
This commit is contained in:
@@ -898,3 +898,33 @@
|
||||
testing.expectEqual('by-listener', fd.get('added'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=appendBlobAndFileValues>
|
||||
{
|
||||
// https://xhr.spec.whatwg.org/#create-an-entry
|
||||
// A Blob value becomes a File named "blob"; a File keeps its own name; an
|
||||
// explicit filename overrides both. Before this was implemented the value
|
||||
// was coerced with toString() and every entry read back "[object Blob]".
|
||||
const fd = new FormData();
|
||||
|
||||
fd.append('plain', 'still-a-string');
|
||||
fd.append('fromBlob', new Blob(['blob-bytes'], { type: 'text/plain' }));
|
||||
fd.append('fromFile', new File(['file-bytes'], 'notes.txt', { type: 'text/plain' }));
|
||||
fd.append('renamed', new Blob(['renamed-bytes'], { type: 'text/plain' }), 'given.txt');
|
||||
|
||||
testing.expectEqual('still-a-string', fd.get('plain'));
|
||||
|
||||
// A file entry read through get() collapses to its name, per WHATWG.
|
||||
testing.expectEqual('blob', fd.get('fromBlob'));
|
||||
testing.expectEqual('notes.txt', fd.get('fromFile'));
|
||||
testing.expectEqual('given.txt', fd.get('renamed'));
|
||||
|
||||
// set() goes through the same path.
|
||||
fd.set('fromFile', new File(['replaced'], 'replaced.txt', { type: 'text/plain' }));
|
||||
testing.expectEqual('replaced.txt', fd.get('fromFile'));
|
||||
|
||||
// Overwriting a file entry with a string still yields a string.
|
||||
fd.set('fromBlob', 'back-to-string');
|
||||
testing.expectEqual('back-to-string', fd.get('fromBlob'));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -25,6 +25,7 @@ const Frame = @import("../../Frame.zig");
|
||||
const Form = @import("../element/html/Form.zig");
|
||||
const Element = @import("../Element.zig");
|
||||
const File = @import("../File.zig");
|
||||
const Blob = @import("../Blob.zig");
|
||||
const KeyValueList = @import("../KeyValueList.zig");
|
||||
|
||||
const log = lp.log;
|
||||
@@ -165,9 +166,65 @@ pub fn has(self: *const FormData, name: String) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn set(self: *FormData, name: String, value: []const u8, exec: *Execution) !void {
|
||||
// The value half of the JS-facing append()/set(). `bytes` must stay last:
|
||||
// js.Bridge tries the JsApi classes first and maps anything else to a string,
|
||||
// so an earlier position would swallow File and Blob arguments.
|
||||
pub const EntryValue = union(enum) {
|
||||
file: *File,
|
||||
blob: *Blob,
|
||||
bytes: []const u8,
|
||||
};
|
||||
|
||||
pub fn jsSet(self: *FormData, name: String, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void {
|
||||
self.deleteByName(name, exec);
|
||||
return self.append(name.str(), value);
|
||||
return self.jsAppend(name.str(), value, filename, exec);
|
||||
}
|
||||
|
||||
// https://xhr.spec.whatwg.org/#create-an-entry
|
||||
pub fn jsAppend(self: *FormData, name: []const u8, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void {
|
||||
const entry_value: Entry.Value = switch (value) {
|
||||
// A Blob that is not a File becomes a File named "blob", unless a
|
||||
// filename was supplied.
|
||||
.blob => |blob| blk: {
|
||||
if (blob._type == .file and filename == null) {
|
||||
const file = blob._type.file;
|
||||
file.acquireRef();
|
||||
break :blk .{ .file = file };
|
||||
}
|
||||
break :blk .{ .file = try fileFrom(blob, filename orelse "blob", exec.page) };
|
||||
},
|
||||
// A File keeps its bytes; a supplied filename means a new File over
|
||||
// the same bytes rather than a rename of the caller's object.
|
||||
.file => |file| blk: {
|
||||
if (filename) |n| break :blk .{ .file = try fileFrom(file._proto, n, exec.page) };
|
||||
file.acquireRef();
|
||||
break :blk .{ .file = file };
|
||||
},
|
||||
.bytes => |b| .{ .string = try String.init(self._arena, b, .{}) },
|
||||
};
|
||||
|
||||
try self._entries.append(self._arena, .{
|
||||
.name = try String.init(self._arena, name, .{}),
|
||||
.value = entry_value,
|
||||
});
|
||||
}
|
||||
|
||||
// Mirrors File.init — a Blob and File sharing one reference-counted arena —
|
||||
// but over bytes we already hold rather than JS parts. Returned at refcount 1:
|
||||
// the entry owns that reference and deleteByName releases it.
|
||||
fn fileFrom(source: *Blob, name: []const u8, page: *Page) !*File {
|
||||
const blob = try Blob.initFromBytes(source._slice, source._mime, page);
|
||||
errdefer blob.deinit(page);
|
||||
|
||||
const file = try blob._arena.create(File);
|
||||
file.* = .{
|
||||
._proto = blob,
|
||||
._name = try blob._arena.dupe(u8, name),
|
||||
._last_modified = std.Io.Clock.now(.real, lp.io).toMilliseconds(),
|
||||
};
|
||||
blob._type = .{ .file = file };
|
||||
blob.acquireRef();
|
||||
return file;
|
||||
}
|
||||
|
||||
pub fn append(self: *FormData, name: []const u8, value: []const u8) !void {
|
||||
@@ -511,8 +568,8 @@ pub const JsApi = struct {
|
||||
pub const constructor = bridge.constructor(FormData.init, .{});
|
||||
pub const has = bridge.function(FormData.has, .{});
|
||||
pub const get = bridge.function(FormData.get, .{});
|
||||
pub const set = bridge.function(FormData.set, .{});
|
||||
pub const append = bridge.function(FormData.append, .{});
|
||||
pub const set = bridge.function(FormData.jsSet, .{});
|
||||
pub const append = bridge.function(FormData.jsAppend, .{});
|
||||
pub const getAll = bridge.function(FormData.getAll, .{});
|
||||
pub const delete = bridge.function(FormData.delete, .{});
|
||||
pub const keys = bridge.function(FormData.keys, .{});
|
||||
@@ -599,8 +656,6 @@ test "FormData: multipart empty body" {
|
||||
try testing.expectString("--B--\r\n", buf.written());
|
||||
}
|
||||
|
||||
const Blob = @import("../Blob.zig");
|
||||
|
||||
fn buildTestFile(arena: Allocator, page: *@import("../../Page.zig"), name: []const u8, mime: []const u8, body: []const u8) !*File {
|
||||
const blob = try Blob.initFromBytes(body, mime, page);
|
||||
blob.acquireRef();
|
||||
|
||||
Reference in New Issue
Block a user