Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Form Encoding #128

Merged
merged 1 commit into from
Jan 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/core/drive/form_submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export enum FormSubmissionState {
stopped,
}

enum FormEnctype {
urlEncoded = "application/x-www-form-urlencoded",
multipart = "multipart/form-data",
plain = "text/plain"
}

function formEnctypeFromString(encoding: string): FormEnctype {
switch(encoding.toLowerCase()) {
seanpdoyle marked this conversation as resolved.
Show resolved Hide resolved
case FormEnctype.multipart: return FormEnctype.multipart
case FormEnctype.plain: return FormEnctype.plain
default: return FormEnctype.urlEncoded
}
}

export class FormSubmission {
readonly delegate: FormSubmissionDelegate
readonly formElement: HTMLFormElement
Expand All @@ -39,7 +53,7 @@ export class FormSubmission {
this.formElement = formElement
this.formData = buildFormData(formElement, submitter)
this.submitter = submitter
this.fetchRequest = new FetchRequest(this, this.method, this.location, this.formData)
this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body)
this.mustRedirect = mustRedirect
}

Expand All @@ -56,6 +70,18 @@ export class FormSubmission {
return expandURL(this.action)
}

get body() {
if (this.enctype == FormEnctype.urlEncoded || this.method == FetchMethod.get) {
return new URLSearchParams(this.formData as any)
} else {
return this.formData
}
}

get enctype(): FormEnctype {
return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.formElement.enctype)
}

// The submission process

async start() {
Expand All @@ -79,7 +105,7 @@ export class FormSubmission {

additionalHeadersForRequest(request: FetchRequest) {
const headers: FetchRequestHeaders = {}
if (this.method != FetchMethod.get) {
if (!request.isIdempotent) {
const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token")
if (token) {
headers["X-CSRF-Token"] = token
Expand Down
14 changes: 9 additions & 5 deletions src/http/fetch_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function fetchMethodFromString(method: string) {
}
}

export type FetchRequestBody = FormData
export type FetchRequestBody = FormData | URLSearchParams

export type FetchRequestHeaders = { [header: string]: string }

Expand All @@ -46,11 +46,15 @@ export class FetchRequest {
readonly body?: FetchRequestBody
readonly abortController = new AbortController

constructor(delegate: FetchRequestDelegate, method: FetchMethod, location: URL, body?: FetchRequestBody) {
constructor(delegate: FetchRequestDelegate, method: FetchMethod, location: URL, body: FetchRequestBody = new URLSearchParams) {
this.delegate = delegate
this.method = method
this.body = body
this.url = mergeFormDataEntries(location, this.entries)
if (this.isIdempotent) {
this.url = mergeFormDataEntries(location, [ ...body.entries() ])
} else {
this.body = body
this.url = location
}
}

get location(): URL {
Expand Down Expand Up @@ -103,7 +107,7 @@ export class FetchRequest {
credentials: "same-origin",
headers: this.headers,
redirect: "follow",
body: this.isIdempotent ? undefined : this.body,
body: this.body,
signal: this.abortSignal
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/tests/fixtures/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,18 @@
<input type="hidden" name="status" value="204">
<input type="submit" style="">
</form>
<form action="/__turbo/redirect" method="post" class="no-enctype">
<input type="submit">
</form>
<form action="/__turbo/redirect" method="post" enctype="multipart/form-data">
<input type="hidden" name="path" value="/src/tests/fixtures/form.html">
<input type="submit">
</form>
<form action="/__turbo/redirect" method="get" enctype="multipart/form-data">
<input type="submit">
</form>
</div>
<hr>
<div id="reject">
<form class="unprocessable_entity" action="/__turbo/reject" method="post">
<input type="hidden" name="status" value="422">
Expand All @@ -43,6 +54,10 @@
<button type="submit" formmethod="post" formaction="/__turbo/redirect"
name="path" value="/src/tests/fixtures/two.html">Submit</button>
</form>
<form action="/__turbo/redirect" method="post">
<input type="hidden" name="path" value="/src/tests/fixtures/form.html">
<input type="submit" formenctype="multipart/form-data">
</form>
</div>
<hr>
<div id="disabled">
Expand Down
32 changes: 32 additions & 0 deletions src/tests/functional/form_submission_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(htmlAfter, htmlBefore)
}

async "test standard POST form submission with multipart/form-data enctype"() {
await this.clickSelector("#standard form[method=post][enctype] input[type=submit]")
await this.nextBeat

const enctype = (await this.searchParams).get("enctype")
this.assert.ok(enctype?.startsWith("multipart/form-data"), "submits a multipart/form-data request")
}

async "test standard GET form submission ignores enctype"() {
await this.clickSelector("#standard form[method=get][enctype] input[type=submit]")
await this.nextBeat

const enctype = (await this.searchParams).get("enctype")
this.assert.notOk(enctype, "GET form submissions ignore enctype")
}

async "test standard POST form submission without an enctype"() {
await this.clickSelector("#standard form[method=post].no-enctype input[type=submit]")
await this.nextBeat

const enctype = (await this.searchParams).get("enctype")
this.assert.ok(enctype?.startsWith("application/x-www-form-urlencoded"), "submits a application/x-www-form-urlencoded request")
}

async "test invalid form submission with unprocessable entity status"() {
await this.clickSelector("#reject form.unprocessable_entity input[type=submit]")
await this.nextBody
Expand Down Expand Up @@ -63,6 +87,14 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(await this.visitAction, "advance")
}

async "test submitter POST form submission with multipart/form-data formenctype"() {
await this.clickSelector("#submitter form[method=post]:not([enctype]) input[formenctype]")
await this.nextBeat

const enctype = (await this.searchParams).get("enctype")
this.assert.ok(enctype?.startsWith("multipart/form-data"), "submits a multipart/form-data request")
}

async "test frame form submission with redirect response"() {
const button = await this.querySelector("#frame form.redirect input[type=submit]")
await button.click()
Expand Down
4 changes: 4 additions & 0 deletions src/tests/helpers/functional_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export class FunctionalTestCase extends InternTestCase {
return this.evaluate(() => location.pathname)
}

get searchParams(): Promise<URLSearchParams> {
return this.evaluate(() => location.search).then(search => new URLSearchParams(search))
}

get hash(): Promise<string> {
return this.evaluate(() => location.hash)
}
Expand Down
14 changes: 12 additions & 2 deletions src/tests/server.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { Response, Router } from "express"
import multer from "multer"
import path from "path"
import url from "url"

const router = Router()
const streamResponses: Set<Response> = new Set

router.use(multer().none())

router.post("/redirect", (request, response) => {
const path = request.body.path ?? "/src/tests/fixtures/one.html"
response.redirect(303, path)
const pathname = request.body.path ?? "/src/tests/fixtures/one.html"
const enctype = request.get("Content-Type")
const query = enctype ? { enctype } : {}
response.redirect(303, url.format({ pathname, query }))
})

router.get("/redirect", (request, response) => {
const pathname = (request.query as any).path ?? "/src/tests/fixtures/one.html"
const enctype = request.get("Content-Type")
const query = enctype ? { enctype } : {}
response.redirect(301, url.format({ pathname, query }))
})

router.post("/reject", (request, response) => {
Expand Down