mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge pull request #2654 from lightpanda-io/form-upload-file
form: encode file inputs as multipart/form-data on submit
This commit is contained in:
@@ -4302,6 +4302,8 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
|
||||
// The submitter can be an input box (if enter was entered on the box)
|
||||
// I don't think this is technically correct, but FormData handles it ok
|
||||
const form_data = try FormData.init(form, submitter_, &self.js.execution);
|
||||
// FormData.init acquires file's references. So we must release them once done.
|
||||
defer form_data.deinit(self._page);
|
||||
|
||||
const arena = try self._session.getArena(.medium, "submitForm");
|
||||
errdefer self._session.releaseArena(arena);
|
||||
|
||||
@@ -60,6 +60,18 @@ pub fn init(
|
||||
return file;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *File, page: *Page) void {
|
||||
self._proto.deinit(page);
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *File, page: *Page) void {
|
||||
self._proto.releaseRef(page);
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *File) void {
|
||||
self._proto.acquireRef();
|
||||
}
|
||||
|
||||
pub fn getName(self: *const File) []const u8 {
|
||||
return self._name;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Frame = @import("../../Frame.zig");
|
||||
const Form = @import("../element/html/Form.zig");
|
||||
const Element = @import("../Element.zig");
|
||||
@@ -33,6 +34,8 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
const FormData = @This();
|
||||
|
||||
_rc: lp.RC(u8),
|
||||
|
||||
_arena: Allocator,
|
||||
_entries: std.ArrayList(Entry),
|
||||
|
||||
@@ -41,20 +44,28 @@ pub const Entry = struct {
|
||||
value: Value,
|
||||
|
||||
const Value = union(enum) {
|
||||
no_file: void,
|
||||
file: *File,
|
||||
string: String,
|
||||
|
||||
fn asString(self: *const Value) []const u8 {
|
||||
return switch (self.*) {
|
||||
.string => |*s| s.str(),
|
||||
.file => unreachable, // nothing currently creates this type of value
|
||||
// Per WHATWG, file entries serialized in a non-multipart encoding
|
||||
// (application/x-www-form-urlencoded, text/plain) collapse to the
|
||||
// file's name only — body is dropped.
|
||||
.file => |f| f.getName(),
|
||||
// An unselected file input contributes a File with an empty name,
|
||||
// so it collapses to the empty string.
|
||||
.no_file => "",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn format(self: Value, writer: *std.Io.Writer) !void {
|
||||
return switch (self) {
|
||||
.string => |s| s.format(writer),
|
||||
.file => unreachable, // nothing currently creates this type of value
|
||||
.file => |f| writer.writeAll(f.getName()),
|
||||
.no_file => {},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -63,6 +74,7 @@ pub const Entry = struct {
|
||||
pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormData {
|
||||
const form = form_ orelse {
|
||||
return try exec._factory.create(FormData{
|
||||
._rc = .{},
|
||||
._arena = exec.arena,
|
||||
._entries = .empty,
|
||||
});
|
||||
@@ -82,10 +94,18 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD
|
||||
defer form._constructing_entry_list = false;
|
||||
|
||||
const form_data = try exec._factory.create(FormData{
|
||||
._rc = .{},
|
||||
._arena = exec.arena,
|
||||
._entries = try collectForm(frame.arena, form, submitter, frame),
|
||||
});
|
||||
|
||||
for (form_data._entries.items) |entry| {
|
||||
switch (entry.value) {
|
||||
.file => |file| file.acquireRef(),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
const form_data_event = try (@import("../event/FormDataEvent.zig")).initTrusted(
|
||||
comptime .wrap("formdata"),
|
||||
.{ .bubbles = true, .cancelable = false, .formData = form_data },
|
||||
@@ -96,6 +116,23 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD
|
||||
return form_data;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *FormData, page: *Page) void {
|
||||
for (self._entries.items) |entry| {
|
||||
switch (entry.value) {
|
||||
.file => |file| file.releaseRef(page),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *FormData, page: *Page) void {
|
||||
self._rc.release(self, page);
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *FormData) void {
|
||||
self._rc.acquire();
|
||||
}
|
||||
|
||||
pub fn get(self: *const FormData, name: String) ?[]const u8 {
|
||||
for (self._entries.items) |*entry| {
|
||||
if (entry.name.eql(name)) {
|
||||
@@ -124,8 +161,8 @@ pub fn has(self: *const FormData, name: String) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn set(self: *FormData, name: String, value: []const u8) !void {
|
||||
self.deleteByName(name);
|
||||
pub fn set(self: *FormData, name: String, value: []const u8, exec: *Execution) !void {
|
||||
self.deleteByName(name, exec);
|
||||
return self.append(name.str(), value);
|
||||
}
|
||||
|
||||
@@ -136,15 +173,21 @@ pub fn append(self: *FormData, name: []const u8, value: []const u8) !void {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn delete(self: *FormData, name: String) void {
|
||||
self.deleteByName(name);
|
||||
pub fn delete(self: *FormData, name: String, exec: *Execution) void {
|
||||
self.deleteByName(name, exec);
|
||||
}
|
||||
|
||||
fn deleteByName(self: *FormData, name: String) void {
|
||||
fn deleteByName(self: *FormData, name: String, exec: *Execution) void {
|
||||
var i: usize = 0;
|
||||
while (i < self._entries.items.len) {
|
||||
if (self._entries.items[i].name.eql(name)) {
|
||||
_ = self._entries.swapRemove(i);
|
||||
const entry = self._entries.swapRemove(i);
|
||||
|
||||
switch (entry.value) {
|
||||
.file => |file| file.releaseRef(exec.page),
|
||||
else => {},
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
@@ -244,8 +287,35 @@ fn multipartEncodeEntry(entry: *const Entry, boundary: []const u8, writer: *std.
|
||||
try writer.writeAll(s.str());
|
||||
try writer.writeAll("\r\n");
|
||||
},
|
||||
// File entries need a real payload (filename + bytes + Content-Type) — not yet wired.
|
||||
.file => log.warn(.not_implemented, "FormData.multipart.file", .{}),
|
||||
// Per RFC 7578 + WHATWG FormData: file parts carry a filename in
|
||||
// Content-Disposition, a Content-Type (defaulting to
|
||||
// application/octet-stream when the Blob has no MIME), and the raw bytes.
|
||||
.file => |file| {
|
||||
try writer.writeAll("Content-Disposition: form-data; name=\"");
|
||||
try writeMultipartName(writer, entry.name.str());
|
||||
try writer.writeAll("\"; filename=\"");
|
||||
try writeMultipartName(writer, file.getName());
|
||||
try writer.writeAll("\"\r\n");
|
||||
|
||||
const mime = file._proto._mime;
|
||||
try writer.writeAll("Content-Type: ");
|
||||
try writer.writeAll(if (mime.len == 0) "application/octet-stream" else mime);
|
||||
try writer.writeAll("\r\n\r\n");
|
||||
|
||||
try writer.writeAll(file._proto._slice);
|
||||
try writer.writeAll("\r\n");
|
||||
},
|
||||
// An unselected file input still contributes a part: an empty filename,
|
||||
// the default application/octet-stream Content-Type, and an empty body.
|
||||
// This matches the empty File (no name, no type, no body) the WHATWG
|
||||
// algorithm creates, and Chrome's wire output.
|
||||
.no_file => {
|
||||
try writer.writeAll("Content-Disposition: form-data; name=\"");
|
||||
try writeMultipartName(writer, entry.name.str());
|
||||
try writer.writeAll("\"; filename=\"\"\r\n");
|
||||
try writer.writeAll("Content-Type: application/octet-stream\r\n\r\n");
|
||||
try writer.writeAll("\r\n");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +421,23 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *F
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (input_type == .file) {
|
||||
// WHATWG: a file input with zero selected files contributes a
|
||||
// single entry whose value is an empty File of MIME
|
||||
// application/octet-stream; otherwise, one entry per file.
|
||||
const files = if (input._files) |fl| fl._files else &.{};
|
||||
if (files.len == 0) {
|
||||
try list.append(arena, .{
|
||||
.name = try String.init(arena, name, .{}),
|
||||
.value = .no_file,
|
||||
});
|
||||
} else {
|
||||
for (files) |file| {
|
||||
try appendFile(&list, arena, name, file);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break :blk input.getValue();
|
||||
}
|
||||
|
||||
@@ -396,6 +483,13 @@ fn appendString(list: *std.ArrayList(Entry), arena: Allocator, name: []const u8,
|
||||
});
|
||||
}
|
||||
|
||||
fn appendFile(list: *std.ArrayList(Entry), arena: Allocator, name: []const u8, file: *File) !void {
|
||||
try list.append(arena, .{
|
||||
.name = try String.init(arena, name, .{}),
|
||||
.value = .{ .file = file },
|
||||
});
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(FormData);
|
||||
|
||||
@@ -428,6 +522,7 @@ test "FormData: multipart write" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
@@ -456,6 +551,7 @@ test "FormData: multipart escapes name CR/LF/quote" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
@@ -480,6 +576,7 @@ test "FormData: multipart empty body" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
@@ -493,10 +590,201 @@ 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, false, page);
|
||||
blob.acquireRef();
|
||||
const file = try arena.create(File);
|
||||
file.* = .{
|
||||
._proto = blob,
|
||||
._name = try arena.dupe(u8, name),
|
||||
._last_modified = 0,
|
||||
};
|
||||
return file;
|
||||
}
|
||||
|
||||
test "FormData: multipart with file" {
|
||||
const allocator = testing.arena_allocator;
|
||||
const frame = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
|
||||
const file = try buildTestFile(allocator, frame._page, "hello.txt", "text/plain", "hello");
|
||||
defer file._proto.releaseRef(frame._page);
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
try fd.append("field", "value");
|
||||
try fd._entries.append(allocator, .{
|
||||
.name = try String.init(allocator, "upload", .{}),
|
||||
.value = .{ .file = file },
|
||||
});
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try fd.write(.{
|
||||
.encoding = .{ .formdata = "BOUNDARY" },
|
||||
.allocator = allocator,
|
||||
}, &buf.writer);
|
||||
|
||||
try testing.expectString(
|
||||
"--BOUNDARY\r\n" ++
|
||||
"Content-Disposition: form-data; name=\"field\"\r\n\r\n" ++
|
||||
"value\r\n" ++
|
||||
"--BOUNDARY\r\n" ++
|
||||
"Content-Disposition: form-data; name=\"upload\"; filename=\"hello.txt\"\r\n" ++
|
||||
"Content-Type: text/plain\r\n\r\n" ++
|
||||
"hello\r\n" ++
|
||||
"--BOUNDARY--\r\n",
|
||||
buf.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "FormData: multipart with empty file defaults to octet-stream" {
|
||||
const allocator = testing.arena_allocator;
|
||||
const frame = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
|
||||
const file = try buildTestFile(allocator, frame._page, "", "", "");
|
||||
defer file._proto.releaseRef(frame._page);
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
try fd._entries.append(allocator, .{
|
||||
.name = try String.init(allocator, "upload", .{}),
|
||||
.value = .{ .file = file },
|
||||
});
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try fd.write(.{
|
||||
.encoding = .{ .formdata = "B" },
|
||||
.allocator = allocator,
|
||||
}, &buf.writer);
|
||||
|
||||
try testing.expectString(
|
||||
"--B\r\n" ++
|
||||
"Content-Disposition: form-data; name=\"upload\"; filename=\"\"\r\n" ++
|
||||
"Content-Type: application/octet-stream\r\n\r\n" ++
|
||||
"\r\n" ++
|
||||
"--B--\r\n",
|
||||
buf.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "FormData: multipart escapes file name and filename" {
|
||||
const allocator = testing.arena_allocator;
|
||||
const frame = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
|
||||
const file = try buildTestFile(allocator, frame._page, "a\"b\r\nc.txt", "text/plain", "x");
|
||||
defer file._proto.releaseRef(frame._page);
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
try fd._entries.append(allocator, .{
|
||||
.name = try String.init(allocator, "up\"load", .{}),
|
||||
.value = .{ .file = file },
|
||||
});
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try fd.write(.{
|
||||
.encoding = .{ .formdata = "B" },
|
||||
.allocator = allocator,
|
||||
}, &buf.writer);
|
||||
|
||||
try testing.expectString(
|
||||
"--B\r\n" ++
|
||||
"Content-Disposition: form-data; name=\"up%22load\"; filename=\"a%22b%0D%0Ac.txt\"\r\n" ++
|
||||
"Content-Type: text/plain\r\n\r\n" ++
|
||||
"x\r\n" ++
|
||||
"--B--\r\n",
|
||||
buf.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "FormData: file entry collapses to filename in urlencode" {
|
||||
const allocator = testing.arena_allocator;
|
||||
const frame = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
|
||||
const file = try buildTestFile(allocator, frame._page, "hello.txt", "text/plain", "hello");
|
||||
defer file._proto.releaseRef(frame._page);
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
try fd._entries.append(allocator, .{
|
||||
.name = try String.init(allocator, "upload", .{}),
|
||||
.value = .{ .file = file },
|
||||
});
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try fd.write(.{ .encoding = .urlencode, .allocator = allocator }, &buf.writer);
|
||||
try testing.expectString("upload=hello.txt", buf.written());
|
||||
}
|
||||
|
||||
test "FormData: multipart no_file (unselected file input)" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
try fd._entries.append(allocator, .{
|
||||
.name = try String.init(allocator, "upload", .{}),
|
||||
.value = .no_file,
|
||||
});
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try fd.write(.{
|
||||
.encoding = .{ .formdata = "B" },
|
||||
.allocator = allocator,
|
||||
}, &buf.writer);
|
||||
|
||||
try testing.expectString(
|
||||
"--B\r\n" ++
|
||||
"Content-Disposition: form-data; name=\"upload\"; filename=\"\"\r\n" ++
|
||||
"Content-Type: application/octet-stream\r\n\r\n" ++
|
||||
"\r\n" ++
|
||||
"--B--\r\n",
|
||||
buf.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "FormData: no_file entry collapses to empty in urlencode" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
try fd._entries.append(allocator, .{
|
||||
.name = try String.init(allocator, "upload", .{}),
|
||||
.value = .no_file,
|
||||
});
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try fd.write(.{ .encoding = .urlencode, .allocator = allocator }, &buf.writer);
|
||||
try testing.expectString("upload=", buf.written());
|
||||
}
|
||||
|
||||
test "FormData: plaintext write" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
@@ -522,6 +810,7 @@ test "FormData: plaintext empty body" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var fd = FormData{
|
||||
._rc = .{},
|
||||
._arena = allocator,
|
||||
._entries = .empty,
|
||||
};
|
||||
|
||||
@@ -161,7 +161,7 @@ test "BodyInit: FormData emits multipart with random boundary" {
|
||||
const arena = testing.arena_allocator;
|
||||
|
||||
const fd = try arena.create(FormData);
|
||||
fd.* = .{ ._arena = arena, ._entries = .empty };
|
||||
fd.* = .{ ._rc = .{}, ._arena = arena, ._entries = .empty };
|
||||
try fd.append("username", "alice");
|
||||
try fd.append("email", "alice@example.com");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user