Merge pull request #723 from lightpanda-io/form_submit

Form submit
This commit is contained in:
Karl Seguin
2025-05-31 07:23:49 +08:00
committed by GitHub
9 changed files with 231 additions and 52 deletions

View File

@@ -175,7 +175,7 @@ pub const HTMLDocument = struct {
}
pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url);
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn get_designMode(_: *parser.DocumentHTML) []const u8 {

View File

@@ -19,6 +19,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const HTMLElement = @import("elements.zig").HTMLElement;
const FormData = @import("../xhr/form_data.zig").FormData;
@@ -27,6 +28,10 @@ pub const HTMLFormElement = struct {
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn _submit(self: *parser.Form, page: *Page) !void {
return page.submitForm(self, null);
}
pub fn _requestSubmit(self: *parser.Form) !void {
try parser.formElementSubmit(self);
}
@@ -35,20 +40,3 @@ pub const HTMLFormElement = struct {
try parser.formElementReset(self);
}
};
pub const Submission = struct {
method: ?[]const u8,
form_data: FormData,
};
pub fn processSubmission(arena: Allocator, form: *parser.Form) !?Submission {
const form_element: *parser.Element = @ptrCast(form);
const method = try parser.elementGetAttribute(form_element, "method");
return .{
.method = method,
.form_data = try FormData.fromForm(arena, form),
};
}
// Check xhr/form_data.zig for tests

View File

@@ -70,15 +70,15 @@ pub const Location = struct {
}
pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url);
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url);
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn _reload(_: *const Location, page: *Page) !void {
return page.navigateFromWebAPI(page.url.raw);
return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script });
}
pub fn _toString(self: *Location, page: *Page) ![]const u8 {

View File

@@ -101,7 +101,7 @@ pub const Window = struct {
}
pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url);
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn get_console(self: *Window) *Console {

View File

@@ -199,8 +199,9 @@ pub const Page = struct {
self.url = request_url;
// load the data
var request = try self.newHTTPRequest(.GET, &self.url, .{ .navigation = true });
var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true });
defer request.deinit();
request.body = opts.body;
request.notification = notification;
notification.dispatch(.page_navigate, &.{
@@ -550,7 +551,7 @@ pub const Page = struct {
.a => {
const element: *parser.Element = @ptrCast(node);
const href = (try parser.elementGetAttribute(element, "href")) orelse return;
try self.navigateFromWebAPI(href);
try self.navigateFromWebAPI(href, .{});
},
else => {},
}
@@ -559,10 +560,11 @@ pub const Page = struct {
// As such we schedule the function to be called as soon as possible.
// The page.arena is safe to use here, but the transfer_arena exists
// specifically for this type of lifetime.
pub fn navigateFromWebAPI(self: *Page, url: []const u8) !void {
pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void {
const arena = self.session.transfer_arena;
const navi = try arena.create(DelayedNavigation);
navi.* = .{
.opts = opts,
.session = self.session,
.url = try arena.dupe(u8, url),
};
@@ -587,17 +589,45 @@ pub const Page = struct {
}
return null;
}
pub fn submitForm(self: *Page, form: *parser.Form, submitter: ?*parser.ElementHTML) !void {
const FormData = @import("xhr/form_data.zig").FormData;
const transfer_arena = self.session.transfer_arena;
var form_data = try FormData.fromForm(form, submitter, self);
const encoding = try parser.elementGetAttribute(@ptrCast(form), "enctype");
var buf: std.ArrayListUnmanaged(u8) = .empty;
try form_data.write(encoding, buf.writer(transfer_arena));
const method = try parser.elementGetAttribute(@ptrCast(form), "method") orelse "";
var action = try parser.elementGetAttribute(@ptrCast(form), "action") orelse self.url.raw;
var opts = NavigateOpts{
.reason = .form,
};
if (std.ascii.eqlIgnoreCase(method, "post")) {
opts.method = .POST;
opts.body = buf.items;
} else {
action = try URL.concatQueryString(transfer_arena, action, buf.items);
}
try self.navigateFromWebAPI(action, opts);
}
};
const DelayedNavigation = struct {
url: []const u8,
session: *Session,
opts: NavigateOpts,
navigate_node: Loop.CallbackNode = .{ .func = delayNavigate },
fn delayNavigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
_ = repeat_delay;
const self: *DelayedNavigation = @fieldParentPtr("navigate_node", node);
self.session.pageNavigate(self.url) catch |err| {
self.session.pageNavigate(self.url, self.opts) catch |err| {
log.err(.page, "delayed navigation error", .{ .err = err, .url = self.url });
};
}
@@ -766,11 +796,15 @@ const Script = struct {
pub const NavigateReason = enum {
anchor,
address_bar,
form,
script,
};
pub const NavigateOpts = struct {
cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar,
method: http.Request.Method = .GET,
body: ?[]const u8 = null,
};
fn timestamp() u32 {

View File

@@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator;
const Env = @import("env.zig").Env;
const Page = @import("page.zig").Page;
const Browser = @import("browser.zig").Browser;
const NavigateOpts = @import("page.zig").NavigateOpts;
const log = @import("../log.zig");
const parser = @import("netsurf.zig");
@@ -122,7 +123,7 @@ pub const Session = struct {
return &(self.page orelse return null);
}
pub fn pageNavigate(self: *Session, url_string: []const u8) !void {
pub fn pageNavigate(self: *Session, url_string: []const u8, opts: NavigateOpts) !void {
// currently, this is only called from the page, so let's hope
// it isn't null!
std.debug.assert(self.page != null);
@@ -136,8 +137,6 @@ pub const Session = struct {
self.removePage();
var page = try self.createPage();
return page.navigate(url, .{
.reason = .anchor,
});
return page.navigate(url, opts);
}
};

View File

@@ -51,21 +51,15 @@ pub const Interfaces = .{
// https://xhr.spec.whatwg.org/#interface-formdata
pub const FormData = struct {
entries: std.ArrayListUnmanaged(Entry),
entries: Entry.List,
pub fn constructor(form_: ?*parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
const form = form_ orelse return .{ .entries = .empty };
return fromForm(form, submitter_, page, .{});
return fromForm(form, submitter_, page);
}
const FromFormOpts = struct {
// Uses the page.arena if null. This is needed for when we're handling
// form submission from the Page, and we want to capture the form within
// the session's transfer_arena.
allocator: ?Allocator = null,
};
pub fn fromForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page, opts: FromFormOpts) !FormData {
const entries = try collectForm(opts.allocator orelse page.arena, form, submitter_, page);
pub fn fromForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
const entries = try collectForm(form, submitter_, page);
return .{ .entries = entries };
}
@@ -144,11 +138,78 @@ pub const FormData = struct {
}
return null;
}
pub fn write(self: *const FormData, encoding_: ?[]const u8, writer: anytype) !void {
const encoding = encoding_ orelse {
return urlEncode(self, writer);
};
if (std.ascii.eqlIgnoreCase(encoding, "application/x-www-form-urlencoded")) {
return urlEncode(self, writer);
}
log.warn(.form_data, "encoding not supported", .{ .encoding = encoding });
return error.EncodingNotSupported;
}
};
fn urlEncode(data: *const FormData, writer: anytype) !void {
const entries = data.entries.items;
if (entries.len == 0) {
return;
}
try urlEncodeEntry(entries[0], writer);
for (entries[1..]) |entry| {
try writer.writeByte('&');
try urlEncodeEntry(entry, writer);
}
}
fn urlEncodeEntry(entry: Entry, writer: anytype) !void {
try urlEncodeValue(entry.key, writer);
try writer.writeByte('=');
try urlEncodeValue(entry.value, writer);
}
fn urlEncodeValue(value: []const u8, writer: anytype) !void {
if (!urlEncodeShouldEscape(value)) {
return writer.writeAll(value);
}
for (value) |b| {
if (urlEncodeUnreserved(b)) {
try writer.writeByte(b);
} else if (b == ' ') {
// for form submission, space should be encoded as '+', not '%20'
try writer.writeByte('+');
} else {
try writer.print("%{X:0>2}", .{b});
}
}
}
fn urlEncodeShouldEscape(value: []const u8) bool {
for (value) |b| {
if (!urlEncodeUnreserved(b)) {
return true;
}
}
return false;
}
fn urlEncodeUnreserved(b: u8) bool {
return switch (b) {
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
else => false,
};
}
const Entry = struct {
key: []const u8,
value: []const u8,
pub const List = std.ArrayListUnmanaged(Entry);
};
const KeyIterable = iterator.Iterable(KeyIterator, "FormDataKeyIterator");
@@ -157,7 +218,7 @@ const EntryIterable = iterator.Iterable(EntryIterator, "FormDataEntryIterator");
const KeyIterator = struct {
index: usize = 0,
entries: *const std.ArrayListUnmanaged(Entry),
entries: *const Entry.List,
pub fn _next(self: *KeyIterator) ?[]const u8 {
const index = self.index;
@@ -171,7 +232,7 @@ const KeyIterator = struct {
const ValueIterator = struct {
index: usize = 0,
entries: *const std.ArrayListUnmanaged(Entry),
entries: *const Entry.List,
pub fn _next(self: *ValueIterator) ?[]const u8 {
const index = self.index;
@@ -185,7 +246,7 @@ const ValueIterator = struct {
const EntryIterator = struct {
index: usize = 0,
entries: *const std.ArrayListUnmanaged(Entry),
entries: *const Entry.List,
pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } {
const index = self.index;
@@ -198,11 +259,13 @@ const EntryIterator = struct {
}
};
fn collectForm(arena: Allocator, form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !std.ArrayListUnmanaged(Entry) {
// TODO: handle disabled fieldsets
fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !Entry.List {
const arena = page.arena;
const collection = try parser.formGetCollection(form);
const len = try parser.htmlCollectionGetLength(collection);
var entries: std.ArrayListUnmanaged(Entry) = .empty;
var entries: Entry.List = .empty;
try entries.ensureTotalCapacity(arena, len);
const submitter_name_ = try getSubmitterName(submitter_);
@@ -275,7 +338,7 @@ fn collectForm(arena: Allocator, form: *parser.Form, submitter_: ?*parser.Elemen
return entries;
}
fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *std.ArrayListUnmanaged(Entry), page: *Page) !void {
fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *Entry.List, page: *Page) !void {
const HTMLSelectElement = @import("../html/select.zig").HTMLSelectElement;
// Go through the HTMLSelectElement because it has specific logic for handling
@@ -456,3 +519,34 @@ test "Browser.FormData" {
},
}, .{});
}
test "Browser.FormData: urlEncode" {
var arr: std.ArrayListUnmanaged(u8) = .empty;
defer arr.deinit(testing.allocator);
{
var fd = FormData{ .entries = .empty };
try testing.expectError(error.EncodingNotSupported, fd.write("unknown", arr.writer(testing.allocator)));
try fd.write(null, arr.writer(testing.allocator));
try testing.expectEqual("", arr.items);
try fd.write("application/x-www-form-urlencoded", arr.writer(testing.allocator));
try testing.expectEqual("", arr.items);
}
{
var fd = FormData{ .entries = Entry.List.fromOwnedSlice(@constCast(&[_]Entry{
.{ .key = "a", .value = "1" },
.{ .key = "it's over", .value = "9000 !!!" },
.{ .key = "emot", .value = "ok: ☺" },
})) };
const expected = "a=1&it%27s+over=9000+%21%21%21&emot=ok%3A+%E2%98%BA";
try fd.write(null, arr.writer(testing.allocator));
try testing.expectEqual(expected, arr.items);
arr.clearRetainingCapacity();
try fd.write("application/x-www-form-urlencoded", arr.writer(testing.allocator));
try testing.expectEqual(expected, arr.items);
}
}

View File

@@ -169,18 +169,27 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
bc.reset();
const is_anchor = event.opts.reason == .anchor;
if (is_anchor) {
const reason_: ?[]const u8 = switch (event.opts.reason) {
.anchor => "anchorClick",
.script => "scriptInitiated",
.form => switch (event.opts.method) {
.GET => "formSubmissionGet",
.POST => "formSubmissionPost",
else => unreachable,
},
.address_bar => null,
};
if (reason_) |reason| {
try cdp.sendEvent("Page.frameScheduledNavigation", .{
.frameId = target_id,
.delay = 0,
.reason = "anchorClick",
.reason = reason,
.url = event.url.raw,
}, .{ .session_id = session_id });
try cdp.sendEvent("Page.frameRequestedNavigation", .{
.frameId = target_id,
.reason = "anchorClick",
.reason = reason,
.url = event.url.raw,
.disposition = "currentTab",
}, .{ .session_id = session_id });
@@ -224,7 +233,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
}, .{ .session_id = session_id });
}
if (is_anchor) {
if (reason_ != null) {
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
.frameId = target_id,
}, .{ .session_id = session_id });

View File

@@ -87,7 +87,7 @@ pub const URL = struct {
///
/// For URLs with a path, it will replace the last entry with the src.
/// For URLs without a path, it will add src as the path.
pub fn stitch(allocator: std.mem.Allocator, src: []const u8, base: []const u8) ![]const u8 {
pub fn stitch(allocator: Allocator, src: []const u8, base: []const u8) ![]const u8 {
if (base.len == 0) {
return src;
}
@@ -111,6 +111,31 @@ pub const URL = struct {
return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base, src });
}
}
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![]const u8 {
std.debug.assert(url.len != 0);
if (query_string.len == 0) {
return url;
}
var buf: std.ArrayListUnmanaged(u8) = .empty;
// the most space well need is the url + ('?' or '&') + the query_string
try buf.ensureTotalCapacity(arena, url.len + 1 + query_string.len);
buf.appendSliceAssumeCapacity(url);
if (std.mem.indexOfScalar(u8, url, '?')) |index| {
const last_index = url.len - 1;
if (index != last_index and url[last_index] != '&') {
buf.appendAssumeCapacity('&');
}
} else {
buf.appendAssumeCapacity('?');
}
buf.appendSliceAssumeCapacity(query_string);
return buf.items;
}
};
test "Url resolve size" {
@@ -170,3 +195,33 @@ test "URL: Stiching Base & Src URLs (Both Local)" {
defer allocator.free(result);
try testing.expectString("./abcdef/something.js", result);
}
test "URL: concatQueryString" {
defer testing.reset();
const arena = testing.arena_allocator;
{
const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/", "");
try testing.expectEqual("https://www.lightpanda.io/", url);
}
{
const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?", "");
try testing.expectEqual("https://www.lightpanda.io/index?", url);
}
{
const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?", "a=b");
try testing.expectEqual("https://www.lightpanda.io/index?a=b", url);
}
{
const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?1=2", "a=b");
try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url);
}
{
const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?1=2&", "a=b");
try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url);
}
}