diff --git a/src/browser/html/document.zig b/src/browser/html/document.zig index f0b533d4c..85926a65e 100644 --- a/src/browser/html/document.zig +++ b/src/browser/html/document.zig @@ -174,6 +174,10 @@ pub const HTMLDocument = struct { return try parser.documentHTMLGetLocation(Location, self); } + pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void { + return page.navigateFromWebAPI(url, .{ .reason = .script }); + } + pub fn get_designMode(_: *parser.DocumentHTML) []const u8 { return "off"; } diff --git a/src/browser/html/form.zig b/src/browser/html/form.zig index 3cb169d59..cd90589cf 100644 --- a/src/browser/html/form.zig +++ b/src/browser/html/form.zig @@ -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 diff --git a/src/browser/html/location.zig b/src/browser/html/location.zig index 604b9622d..31904db60 100644 --- a/src/browser/html/location.zig +++ b/src/browser/html/location.zig @@ -69,18 +69,17 @@ pub const Location = struct { return ""; } - // TODO - pub fn _assign(_: *Location, url: []const u8) !void { - _ = url; + pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void { + return page.navigateFromWebAPI(url, .{ .reason = .script }); } - // TODO - pub fn _replace(_: *Location, url: []const u8) !void { - _ = url; + pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void { + return page.navigateFromWebAPI(url, .{ .reason = .script }); } - // TODO - pub fn _reload(_: *Location) !void {} + pub fn _reload(_: *const Location, page: *Page) !void { + return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }); + } pub fn _toString(self: *Location, page: *Page) ![]const u8 { return try self.get_href(page); diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index b58f7cbcf..03c2ebea6 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -100,6 +100,10 @@ pub const Window = struct { return &self.location; } + pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void { + return page.navigateFromWebAPI(url, .{ .reason = .script }); + } + pub fn get_console(self: *Window) *Console { return &self.console; } diff --git a/src/browser/page.zig b/src/browser/page.zig index 54639b0d3..efc896fff 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -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, &.{ @@ -541,23 +542,26 @@ pub const Page = struct { .a => { const element: *parser.Element = @ptrCast(node); const href = (try parser.elementGetAttribute(element, "href")) orelse return; - - // We cannot navigate immediately as navigating will delete the DOM tree, which holds this event's node. - // As such we schedule the function to be called as soon as possible. - // NOTE Using the page.arena assumes that the scheduling loop does use this object after invoking the callback - // If that changes we may want to consider storing DelayedNavigation in the session instead. - const arena = self.arena; - const navi = try arena.create(DelayedNavigation); - navi.* = .{ - .session = self.session, - .href = try arena.dupe(u8, href), - }; - _ = try self.loop.timeout(0, &navi.navigate_node); + try self.navigateFromWebAPI(href, .{}); }, else => {}, } } + // 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, 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), + }; + _ = try self.loop.timeout(0, &navi.navigate_node); + } + pub fn getOrCreateNodeWrapper(self: *Page, comptime T: type, node: *parser.Node) !*T { if (try self.getNodeWrapper(T, node)) |wrap| { return wrap; @@ -576,19 +580,46 @@ 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 { - navigate_node: Loop.CallbackNode = .{ .func = DelayedNavigation.delay_navigate }, + url: []const u8, session: *Session, - href: []const u8, + opts: NavigateOpts, + navigate_node: Loop.CallbackNode = .{ .func = delayNavigate }, - fn delay_navigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void { + fn delayNavigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void { _ = repeat_delay; const self: *DelayedNavigation = @fieldParentPtr("navigate_node", node); - self.session.pageNavigate(self.href) catch |err| { - // TODO: should we trigger a specific event here? - log.err(.page, "delayed navigation error", .{ .err = err }); + self.session.pageNavigate(self.url, self.opts) catch |err| { + log.err(.page, "delayed navigation error", .{ .err = err, .url = self.url }); }; } }; @@ -696,11 +727,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 { diff --git a/src/browser/session.zig b/src/browser/session.zig index 28432498a..d71bb27e3 100644 --- a/src/browser/session.zig +++ b/src/browser/session.zig @@ -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); } }; diff --git a/src/browser/xhr/form_data.zig b/src/browser/xhr/form_data.zig index afa2e6992..32d56bc9d 100644 --- a/src/browser/xhr/form_data.zig +++ b/src/browser/xhr/form_data.zig @@ -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); + } +} diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 4059e4cb6..f5a728318 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -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 }); diff --git a/src/url.zig b/src/url.zig index fb28c4afd..e6a738a3d 100644 --- a/src/url.zig +++ b/src/url.zig @@ -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); + } +}