@getquick/content 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{client-ip-cnqPLdDa.js → client-ip-NKt34dBM.js} +8 -3
- package/dist/consent-html.js +1 -1
- package/dist/consent-server.js +1 -1
- package/dist/consent.js +1 -1
- package/dist/{copy-DLto_bWj.js → copy-CfNr6M73.js} +22 -5
- package/dist/{descriptor-B-e7gHi1.d.ts → descriptor-BF5Y9a1J.d.ts} +33 -3
- package/dist/{descriptor-x8kHFQ79.js → descriptor-DXmLmjEa.js} +67 -3
- package/dist/forms-astro.d.ts +4 -2
- package/dist/forms-astro.js +40 -7
- package/dist/forms-server.d.ts +13 -2
- package/dist/forms-server.js +72 -18
- package/dist/forms-testing.d.ts +60 -4
- package/dist/forms-testing.js +317 -8
- package/dist/forms.d.ts +41 -3
- package/dist/forms.js +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/{state-CsMFLUUO.d.ts → state-Bw-HSBeO.d.ts} +12 -2
- package/dist/{validate-wRgYUNxN.js → validate-CqP_F0bk.js} +110 -2
- package/dist/{version-7v3zP5aF.js → version-CcJhZgHO.js} +1 -1
- package/dist/{wordpress-D6eSi2Ff.d.ts → wordpress-CZ4JCdXF.d.ts} +1 -1
- package/package.json +1 -1
package/dist/forms-server.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { i as readBoundedBody, n as rateLimitKey, r as isSameOrigin, t as clientAddressResolver } from "./client-ip-
|
|
3
|
-
import { n as honeypotFilled, r as validateSubmission } from "./validate-
|
|
4
|
-
import { c as formStateLocation, n as formsCopyLanguage, o as formStateCookie, t as FORMS_COPY } from "./copy-
|
|
1
|
+
import { d as hasUploads, l as formDiagnostics, t as FORMS_PATH } from "./descriptor-DXmLmjEa.js";
|
|
2
|
+
import { a as readBoundedBytes, i as readBoundedBody, n as rateLimitKey, r as isSameOrigin, t as clientAddressResolver } from "./client-ip-NKt34dBM.js";
|
|
3
|
+
import { n as honeypotFilled, r as validateSubmission, s as validateUploads } from "./validate-CqP_F0bk.js";
|
|
4
|
+
import { c as formStateLocation, n as formsCopyLanguage, o as formStateCookie, t as FORMS_COPY } from "./copy-CfNr6M73.js";
|
|
5
5
|
//#region frontend/src/forms/server.ts
|
|
6
6
|
/** The largest form body accepted (bytes); larger posts are refused unread. */
|
|
7
7
|
const MAX_FORM_BYTES = 65536;
|
|
8
|
+
/** The largest multipart (upload) body accepted by default (bytes), files included. */
|
|
9
|
+
const MAX_UPLOAD_BYTES = 10485760;
|
|
10
|
+
/** The most `maxUploadBytes` may be: the Worker holds a post in memory. */
|
|
11
|
+
const MAX_UPLOAD_BYTES_LIMIT = 33554432;
|
|
8
12
|
/** Cloudflare's server-side Turnstile verification. */
|
|
9
13
|
const TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
10
14
|
/** The field Turnstile's widget posts its token in. */
|
|
@@ -26,6 +30,9 @@ function createFormsServer(config) {
|
|
|
26
30
|
const origins = new Set((config.siteOrigins ?? []).map((origin) => new URL(origin).origin));
|
|
27
31
|
const clientAddress = clientAddressResolver(config.trustedProxies);
|
|
28
32
|
const timeoutMs = config.timeoutMs ?? 15e3;
|
|
33
|
+
const uploadTimeoutMs = config.timeoutMs ?? 6e4;
|
|
34
|
+
const maxUploadBytes = config.maxUploadBytes ?? 10485760;
|
|
35
|
+
if (!Number.isInteger(maxUploadBytes) || maxUploadBytes < 65536 || maxUploadBytes > 33554432) throw new Error(`maxUploadBytes must be a whole number of bytes from ${MAX_FORM_BYTES} to ${MAX_UPLOAD_BYTES_LIMIT}`);
|
|
29
36
|
const fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
30
37
|
const copy = FORMS_COPY[formsCopyLanguage(config.language)];
|
|
31
38
|
const turnstileRequired = config.spam?.turnstile === true;
|
|
@@ -64,6 +71,7 @@ function createFormsServer(config) {
|
|
|
64
71
|
invalid: 422,
|
|
65
72
|
verification: 422,
|
|
66
73
|
"too-many": 429,
|
|
74
|
+
"too-large": 413,
|
|
67
75
|
failed: 502
|
|
68
76
|
}[full.status], {
|
|
69
77
|
status: full.status,
|
|
@@ -98,12 +106,15 @@ function createFormsServer(config) {
|
|
|
98
106
|
return false;
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
|
-
/** Gravity Forms' field messages, by field id. */
|
|
109
|
+
/** Gravity Forms' field messages, by field id; a multiple file upload's list is joined. */
|
|
102
110
|
function fieldMessages(value) {
|
|
103
111
|
if (!value || typeof value !== "object") return {};
|
|
104
|
-
return Object.fromEntries(Object.entries(value).flatMap(([id, message]) =>
|
|
112
|
+
return Object.fromEntries(Object.entries(value).flatMap(([id, message]) => {
|
|
113
|
+
const text = (Array.isArray(message) ? message : [message]).filter((each) => typeof each === "string" && each !== "").map(htmlText).filter(Boolean).join("\n");
|
|
114
|
+
return /^\d+$/.test(id) && text ? [[Number(id), text]] : [];
|
|
115
|
+
}));
|
|
105
116
|
}
|
|
106
|
-
async function submit(request, formId, source, params) {
|
|
117
|
+
async function submit(request, formId, source, params, files) {
|
|
107
118
|
const bindings = await config.bindings();
|
|
108
119
|
let descriptor;
|
|
109
120
|
try {
|
|
@@ -134,10 +145,15 @@ function createFormsServer(config) {
|
|
|
134
145
|
};
|
|
135
146
|
if (honeypotFilled(descriptor, params)) return outcome(request, sent, source);
|
|
136
147
|
const checked = validateSubmission(descriptor, params);
|
|
137
|
-
|
|
148
|
+
const uploads = await validateUploads(descriptor, files);
|
|
149
|
+
const errors = {
|
|
150
|
+
...checked.errors,
|
|
151
|
+
...uploads.errors
|
|
152
|
+
};
|
|
153
|
+
if (Object.keys(errors).length) return outcome(request, {
|
|
138
154
|
formId,
|
|
139
155
|
status: "invalid",
|
|
140
|
-
errors
|
|
156
|
+
errors,
|
|
141
157
|
values: checked.values
|
|
142
158
|
}, source);
|
|
143
159
|
if (turnstileRequired) {
|
|
@@ -166,18 +182,25 @@ function createFormsServer(config) {
|
|
|
166
182
|
values: checked.values
|
|
167
183
|
}, source);
|
|
168
184
|
}
|
|
185
|
+
const multipart = hasUploads(descriptor);
|
|
186
|
+
let body = JSON.stringify(checked.input);
|
|
187
|
+
if (multipart) {
|
|
188
|
+
body = new FormData();
|
|
189
|
+
for (const [name, value] of Object.entries(checked.input)) body.append(name, value);
|
|
190
|
+
for (const each of uploads.files) body.append(each.name, new Blob([each.file], { type: each.type }), each.filename);
|
|
191
|
+
}
|
|
169
192
|
let response;
|
|
170
193
|
try {
|
|
171
194
|
response = await fetcher(`${restRoot}/gf/v2/forms/${formId}/submissions`, {
|
|
172
195
|
method: "POST",
|
|
173
196
|
headers: {
|
|
174
|
-
"Content-Type": "application/json",
|
|
197
|
+
...multipart ? {} : { "Content-Type": "application/json" },
|
|
175
198
|
Accept: "application/json",
|
|
176
199
|
...access
|
|
177
200
|
},
|
|
178
|
-
body
|
|
201
|
+
body,
|
|
179
202
|
redirect: "manual",
|
|
180
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
203
|
+
signal: AbortSignal.timeout(multipart ? uploadTimeoutMs : timeoutMs)
|
|
181
204
|
});
|
|
182
205
|
} catch (error) {
|
|
183
206
|
console.error(`Forms: form ${formId} couldn't reach the CMS: ${error.name}`);
|
|
@@ -241,15 +264,45 @@ function createFormsServer(config) {
|
|
|
241
264
|
values: checked.values
|
|
242
265
|
}, source);
|
|
243
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* A post over its cap. A form with uploads also names its page in the action
|
|
269
|
+
* (`?gq_source=`), so the visitor is sent back to it with the outcome.
|
|
270
|
+
*/
|
|
271
|
+
function tooLarge(request, formId) {
|
|
272
|
+
const source = new URL(request.url).searchParams.get("gq_source") ?? "";
|
|
273
|
+
if (SOURCE.test(source) && source.length <= 2048) return outcome(request, {
|
|
274
|
+
formId,
|
|
275
|
+
status: "too-large"
|
|
276
|
+
}, source);
|
|
277
|
+
return json(413, { error: "The form is too large." });
|
|
278
|
+
}
|
|
244
279
|
/** `POST /gq/forms/<id>`. */
|
|
245
280
|
async function handlePost(request, formId) {
|
|
246
281
|
if (request.method !== "POST") return json(405, { error: "Send forms with POST." }, { Allow: "POST" });
|
|
247
282
|
if (!isSameOrigin(request, origins)) return json(403, { error: "Forms are accepted from this Site only." });
|
|
248
|
-
|
|
283
|
+
const type = request.headers.get("Content-Type") ?? "";
|
|
284
|
+
const multipart = /^multipart\/form-data\b/i.test(type);
|
|
285
|
+
if (!multipart && !/^application\/x-www-form-urlencoded\b/i.test(type)) return json(415, { error: "Send the form as application/x-www-form-urlencoded or multipart/form-data." });
|
|
249
286
|
const bindings = await config.bindings();
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
let params;
|
|
288
|
+
let files = { getAll: () => [] };
|
|
289
|
+
if (multipart) {
|
|
290
|
+
const bytes = await readBoundedBytes(request, maxUploadBytes);
|
|
291
|
+
if (bytes === null) return tooLarge(request, formId);
|
|
292
|
+
let data;
|
|
293
|
+
try {
|
|
294
|
+
data = await new Response(bytes, { headers: { "Content-Type": type } }).formData();
|
|
295
|
+
} catch {
|
|
296
|
+
return json(400, { error: "The form isn't valid multipart/form-data." });
|
|
297
|
+
}
|
|
298
|
+
params = new URLSearchParams();
|
|
299
|
+
for (const [name, value] of data) if (typeof value === "string") params.append(name, value);
|
|
300
|
+
files = data;
|
|
301
|
+
} else {
|
|
302
|
+
const text = await readBoundedBody(request, MAX_FORM_BYTES);
|
|
303
|
+
if (text === null) return tooLarge(request, formId);
|
|
304
|
+
params = new URLSearchParams(text);
|
|
305
|
+
}
|
|
253
306
|
const source = params.get("gq_source") ?? "";
|
|
254
307
|
if (!SOURCE.test(source) || source.length > 2048) return json(400, { error: "The form's page (gq_source) isn't a path on this Site." });
|
|
255
308
|
const limiter = bindings.FORMS_RATE_LIMITER;
|
|
@@ -260,7 +313,7 @@ function createFormsServer(config) {
|
|
|
260
313
|
status: "too-many"
|
|
261
314
|
}, source);
|
|
262
315
|
}
|
|
263
|
-
return submit(request, formId, source, params);
|
|
316
|
+
return submit(request, formId, source, params, files);
|
|
264
317
|
}
|
|
265
318
|
/** Routes a form post; null when the path isn't a form endpoint. */
|
|
266
319
|
async function handle(request) {
|
|
@@ -283,6 +336,7 @@ function createFormsServer(config) {
|
|
|
283
336
|
turnstileSecret: Boolean(bindings.TURNSTILE_SECRET_KEY?.trim()),
|
|
284
337
|
access: access === "partial" ? "partial" : Object.keys(access).length ? "configured" : "none",
|
|
285
338
|
rateLimit: Boolean(bindings.FORMS_RATE_LIMITER),
|
|
339
|
+
maxUploadBytes,
|
|
286
340
|
ready: (!turnstileRequired || Boolean(bindings.TURNSTILE_SECRET_KEY?.trim())) && access !== "partial"
|
|
287
341
|
};
|
|
288
342
|
}
|
|
@@ -292,4 +346,4 @@ function createFormsServer(config) {
|
|
|
292
346
|
};
|
|
293
347
|
}
|
|
294
348
|
//#endregion
|
|
295
|
-
export { FORMS_PATH, MAX_FORM_BYTES, TURNSTILE_RESPONSE_FIELD, TURNSTILE_SITEVERIFY_URL, createFormsServer, htmlText };
|
|
349
|
+
export { FORMS_PATH, MAX_FORM_BYTES, MAX_UPLOAD_BYTES, MAX_UPLOAD_BYTES_LIMIT, TURNSTILE_RESPONSE_FIELD, TURNSTILE_SITEVERIFY_URL, createFormsServer, htmlText };
|
package/dist/forms-testing.d.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import { o as FormDescriptor } from "./descriptor-
|
|
1
|
+
import { o as FormDescriptor } from "./descriptor-BF5Y9a1J.js";
|
|
2
2
|
//#region frontend/src/forms/testing.d.ts
|
|
3
3
|
/** A contact form like Nomia's form 1: name, email, subject, message and privacy consent. */
|
|
4
4
|
export declare const CONTACT_FORM: FormDescriptor;
|
|
5
|
-
/** A form readiness must fail: Gravity Forms' CAPTCHA,
|
|
5
|
+
/** A form readiness must fail: Gravity Forms' CAPTCHA, an upload of files the Frontend can't verify, a payment field and a page break. */
|
|
6
6
|
export declare const UNSUPPORTED_FORM: FormDescriptor;
|
|
7
|
+
/** The upload settings Sites use for images and PDFs: 5 MB per file, up to 5 files. */
|
|
8
|
+
export declare const IMAGE_AND_PDF_UPLOAD: {
|
|
9
|
+
allowedExtensions: string[];
|
|
10
|
+
maxFileSize: number;
|
|
11
|
+
multipleFiles: boolean;
|
|
12
|
+
maxFiles: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A public submission form like Nomia's /partilhe/: name, email, a content
|
|
16
|
+
* type, title, content, image and PDF attachments, and privacy consent.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SUBMISSION_FORM: FormDescriptor;
|
|
7
19
|
/** A referenced form that is missing, inactive or trashed. */
|
|
8
20
|
export declare const UNAVAILABLE_FORM: FormDescriptor;
|
|
9
21
|
/** Keys that must never appear anywhere in a delivered descriptor. */
|
|
@@ -20,24 +32,43 @@ export declare function assertPublicDescriptor(value: unknown): FormDescriptor;
|
|
|
20
32
|
* an alert and a confirmation is a status.
|
|
21
33
|
*/
|
|
22
34
|
export declare function assertAccessibleForm(html: string): void;
|
|
35
|
+
/** A file Gravity Forms' stand-in received. */
|
|
36
|
+
export interface RecordedFile {
|
|
37
|
+
/** The file name it was posted with. */
|
|
38
|
+
filename: string;
|
|
39
|
+
/** The Content-Type it was posted with. */
|
|
40
|
+
type: string;
|
|
41
|
+
size: number;
|
|
42
|
+
/** Its first bytes, hex (for signature checks). */
|
|
43
|
+
head: string;
|
|
44
|
+
}
|
|
23
45
|
/** A request Gravity Forms' stand-in received. */
|
|
24
46
|
export interface RecordedRequest {
|
|
25
47
|
method: string;
|
|
26
48
|
url: string;
|
|
27
49
|
headers: Record<string, string>;
|
|
50
|
+
/** JSON's values, or a multipart body's text values. */
|
|
28
51
|
body: Record<string, string> | null;
|
|
52
|
+
/** A multipart body's files, by the name they were posted under (`input_6[]`). */
|
|
53
|
+
files?: Record<string, RecordedFile[]>;
|
|
29
54
|
}
|
|
30
55
|
export interface StoredEntry {
|
|
31
56
|
formId: number;
|
|
32
57
|
values: Record<string, string>;
|
|
58
|
+
/** Files stored with the entry, by field input name (`input_6`). */
|
|
59
|
+
files: Record<string, RecordedFile[]>;
|
|
33
60
|
spam: boolean;
|
|
34
61
|
}
|
|
35
62
|
/**
|
|
36
63
|
* A stand-in for Gravity Forms' REST API on a CMS: `POST
|
|
37
64
|
* /wp-json/gf/v2/forms/<id>/submissions` as Gravity Forms 3.2 answers it,
|
|
38
65
|
* validating required fields and storing entries for the given descriptors.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
66
|
+
* It takes JSON, or multipart/form-data whose files are `input_<id>` (one) or
|
|
67
|
+
* `input_<id>[]` (several), as Gravity Forms reads `$_FILES`, and checks their
|
|
68
|
+
* count, size and extension as Gravity Forms does (a multiple upload's
|
|
69
|
+
* messages are a list). A filled honeypot is stored as spam, Gravity Forms'
|
|
70
|
+
* default honeypot action. Anything else is a 404. `requests` records every
|
|
71
|
+
* request.
|
|
41
72
|
*/
|
|
42
73
|
export declare function gravityFormsStub(forms: readonly FormDescriptor[]): {
|
|
43
74
|
entries: StoredEntry[];
|
|
@@ -52,6 +83,31 @@ export declare function assertNoEntryReadsOrCredentials(requests: readonly Recor
|
|
|
52
83
|
export declare function assertNoEntryExports(module: Record<string, unknown>): void;
|
|
53
84
|
/** A form body as a browser posts it (application/x-www-form-urlencoded). */
|
|
54
85
|
export declare function formBody(values: Record<string, string>): string;
|
|
86
|
+
/** Minimal files of each format the form endpoint verifies, as their signatures start. */
|
|
87
|
+
export declare const SAMPLE_FILES: {
|
|
88
|
+
readonly jpg: readonly [255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1];
|
|
89
|
+
readonly png: readonly [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
|
|
90
|
+
readonly gif: readonly [...number[], 1, 0, 1, 0, 0, 0];
|
|
91
|
+
readonly webp: readonly number[];
|
|
92
|
+
readonly pdf: readonly number[];
|
|
93
|
+
/** A Windows executable: what a renamed attachment can really be. */
|
|
94
|
+
readonly exe: readonly [77, 90, 144, 0, 3, 0, 0, 0, 4, 0, 0, 0];
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* A file as a browser posts it: `kind`'s signature, padded to `size` bytes,
|
|
98
|
+
* with `type` as the client's claimed MIME type (never trusted).
|
|
99
|
+
*/
|
|
100
|
+
export declare function sampleFile(filename: string, kind: keyof typeof SAMPLE_FILES, options?: {
|
|
101
|
+
size?: number;
|
|
102
|
+
type?: string;
|
|
103
|
+
}): File;
|
|
104
|
+
/**
|
|
105
|
+
* A multipart form body as a browser posts it: the values, then each field's
|
|
106
|
+
* files under its input name (several under the same name, as `multiple` does).
|
|
107
|
+
*/
|
|
108
|
+
export declare function multipartBody(values: Record<string, string>, files?: Record<string, File | readonly File[]>): FormData;
|
|
109
|
+
/** A filled-in, valid submission of SUBMISSION_FORM from `source` (attach files with multipartBody). */
|
|
110
|
+
export declare function submissionValues(source?: string): Record<string, string>;
|
|
55
111
|
/** A filled-in, valid submission of CONTACT_FORM from `source`. */
|
|
56
112
|
export declare function contactSubmission(source?: string): Record<string, string>;
|
|
57
113
|
/** Cloudflare's Turnstile test keys: always pass, always fail. */
|
package/dist/forms-testing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { p as parseFormDescriptor } from "./descriptor-DXmLmjEa.js";
|
|
2
2
|
//#region frontend/src/forms/testing.ts
|
|
3
3
|
/** A contact form like Nomia's form 1: name, email, subject, message and privacy consent. */
|
|
4
4
|
const CONTACT_FORM = {
|
|
@@ -106,7 +106,7 @@ const CONTACT_FORM = {
|
|
|
106
106
|
},
|
|
107
107
|
diagnostics: []
|
|
108
108
|
};
|
|
109
|
-
/** A form readiness must fail: Gravity Forms' CAPTCHA,
|
|
109
|
+
/** A form readiness must fail: Gravity Forms' CAPTCHA, an upload of files the Frontend can't verify, a payment field and a page break. */
|
|
110
110
|
const UNSUPPORTED_FORM = {
|
|
111
111
|
...CONTACT_FORM,
|
|
112
112
|
id: 2,
|
|
@@ -123,7 +123,7 @@ const UNSUPPORTED_FORM = {
|
|
|
123
123
|
code: "unsupported-file-upload",
|
|
124
124
|
fieldId: 11,
|
|
125
125
|
type: "fileupload",
|
|
126
|
-
message: "
|
|
126
|
+
message: "The Frontend can't verify docx files: allow only jpg, jpeg, png, gif, webp, pdf."
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
code: "unsupported-payment",
|
|
@@ -139,6 +139,167 @@ const UNSUPPORTED_FORM = {
|
|
|
139
139
|
}
|
|
140
140
|
]
|
|
141
141
|
};
|
|
142
|
+
/** The upload settings Sites use for images and PDFs: 5 MB per file, up to 5 files. */
|
|
143
|
+
const IMAGE_AND_PDF_UPLOAD = {
|
|
144
|
+
allowedExtensions: [
|
|
145
|
+
"jpg",
|
|
146
|
+
"jpeg",
|
|
147
|
+
"png",
|
|
148
|
+
"webp",
|
|
149
|
+
"gif",
|
|
150
|
+
"pdf"
|
|
151
|
+
],
|
|
152
|
+
maxFileSize: 5242880,
|
|
153
|
+
multipleFiles: true,
|
|
154
|
+
maxFiles: 5
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* A public submission form like Nomia's /partilhe/: name, email, a content
|
|
158
|
+
* type, title, content, image and PDF attachments, and privacy consent.
|
|
159
|
+
*/
|
|
160
|
+
const SUBMISSION_FORM = {
|
|
161
|
+
schema: 1,
|
|
162
|
+
id: 6,
|
|
163
|
+
available: true,
|
|
164
|
+
title: "Partilhe",
|
|
165
|
+
description: "Envie-nos conteúdos para publicação.",
|
|
166
|
+
submitLabel: "Enviar",
|
|
167
|
+
fields: [
|
|
168
|
+
{
|
|
169
|
+
id: 1,
|
|
170
|
+
type: "text",
|
|
171
|
+
label: "Nome",
|
|
172
|
+
description: "",
|
|
173
|
+
required: true,
|
|
174
|
+
visibility: "visible",
|
|
175
|
+
inputs: [{
|
|
176
|
+
id: "1",
|
|
177
|
+
name: "input_1",
|
|
178
|
+
label: "Nome"
|
|
179
|
+
}]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 2,
|
|
183
|
+
type: "email",
|
|
184
|
+
label: "Email",
|
|
185
|
+
description: "",
|
|
186
|
+
required: true,
|
|
187
|
+
visibility: "visible",
|
|
188
|
+
inputs: [{
|
|
189
|
+
id: "2",
|
|
190
|
+
name: "input_2",
|
|
191
|
+
label: "Email"
|
|
192
|
+
}]
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 3,
|
|
196
|
+
type: "select",
|
|
197
|
+
label: "Tipo de conteúdo",
|
|
198
|
+
description: "",
|
|
199
|
+
required: true,
|
|
200
|
+
visibility: "visible",
|
|
201
|
+
choices: [
|
|
202
|
+
{
|
|
203
|
+
text: "Notícia",
|
|
204
|
+
value: "noticia",
|
|
205
|
+
selected: false
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
text: "Evento",
|
|
209
|
+
value: "evento",
|
|
210
|
+
selected: false
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
text: "Publicação",
|
|
214
|
+
value: "publicacao",
|
|
215
|
+
selected: false
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
inputs: [{
|
|
219
|
+
id: "3",
|
|
220
|
+
name: "input_3",
|
|
221
|
+
label: "Tipo de conteúdo"
|
|
222
|
+
}]
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 4,
|
|
226
|
+
type: "text",
|
|
227
|
+
label: "Título",
|
|
228
|
+
description: "",
|
|
229
|
+
required: true,
|
|
230
|
+
visibility: "visible",
|
|
231
|
+
maxLength: 200,
|
|
232
|
+
inputs: [{
|
|
233
|
+
id: "4",
|
|
234
|
+
name: "input_4",
|
|
235
|
+
label: "Título"
|
|
236
|
+
}]
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 5,
|
|
240
|
+
type: "textarea",
|
|
241
|
+
label: "Conteúdo",
|
|
242
|
+
description: "",
|
|
243
|
+
required: true,
|
|
244
|
+
visibility: "visible",
|
|
245
|
+
inputs: [{
|
|
246
|
+
id: "5",
|
|
247
|
+
name: "input_5",
|
|
248
|
+
label: "Conteúdo"
|
|
249
|
+
}]
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 6,
|
|
253
|
+
type: "fileupload",
|
|
254
|
+
label: "Anexos",
|
|
255
|
+
description: "Imagens ou documentos PDF.",
|
|
256
|
+
required: false,
|
|
257
|
+
visibility: "visible",
|
|
258
|
+
inputs: [{
|
|
259
|
+
id: "6",
|
|
260
|
+
name: "input_6",
|
|
261
|
+
label: "Anexos"
|
|
262
|
+
}],
|
|
263
|
+
upload: IMAGE_AND_PDF_UPLOAD
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: 7,
|
|
267
|
+
type: "consent",
|
|
268
|
+
label: "Privacidade",
|
|
269
|
+
description: "",
|
|
270
|
+
required: true,
|
|
271
|
+
visibility: "visible",
|
|
272
|
+
inputs: [
|
|
273
|
+
{
|
|
274
|
+
id: "7.1",
|
|
275
|
+
name: "input_7_1",
|
|
276
|
+
label: "Consentimento"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: "7.2",
|
|
280
|
+
name: "input_7_2",
|
|
281
|
+
label: "Texto"
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "7.3",
|
|
285
|
+
name: "input_7_3",
|
|
286
|
+
label: "Descrição"
|
|
287
|
+
}
|
|
288
|
+
],
|
|
289
|
+
consent: {
|
|
290
|
+
checkboxLabel: "Aceito o tratamento dos meus dados para analisar este conteúdo.",
|
|
291
|
+
description: "",
|
|
292
|
+
revisionId: "2"
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
],
|
|
296
|
+
honeypot: { name: "input_8" },
|
|
297
|
+
confirmation: {
|
|
298
|
+
type: "message",
|
|
299
|
+
message: "Obrigado. Vamos analisar o seu conteúdo."
|
|
300
|
+
},
|
|
301
|
+
diagnostics: []
|
|
302
|
+
};
|
|
142
303
|
/** A referenced form that is missing, inactive or trashed. */
|
|
143
304
|
const UNAVAILABLE_FORM = {
|
|
144
305
|
...CONTACT_FORM,
|
|
@@ -217,14 +378,32 @@ function assertAccessibleForm(html) {
|
|
|
217
378
|
if (/gq-form__errors/.test(html) && !/class="gq-form__errors" role="alert"/.test(html)) problems.push("the error summary isn't an alert");
|
|
218
379
|
if (/gq-form__status--sent/.test(html) && !/gq-form__status--sent" role="status"/.test(html)) problems.push("the confirmation isn't a status message");
|
|
219
380
|
if (/<form\b/.test(html) && !/<button type="submit"/.test(html)) problems.push("the form has no submit button");
|
|
381
|
+
for (const match of html.matchAll(/<input type="file"([^>]*)>/g)) {
|
|
382
|
+
const id = /\sid="([^"]+)"/.exec(match[1])?.[1];
|
|
383
|
+
if (!/aria-describedby="[^"]*-hint\b/.test(match[1])) problems.push(`${id} doesn't describe which files it accepts`);
|
|
384
|
+
}
|
|
385
|
+
if (/<input type="file"/.test(html) && !/<form\b[^>]*\senctype="multipart\/form-data"/.test(html)) problems.push("a file input is in a form that doesn't send files");
|
|
220
386
|
if (problems.length) throw new Error(`Inaccessible form: ${problems.join("; ")}`);
|
|
221
387
|
}
|
|
388
|
+
async function recordFile(file) {
|
|
389
|
+
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
|
|
390
|
+
return {
|
|
391
|
+
filename: file.name ?? "",
|
|
392
|
+
type: file.type,
|
|
393
|
+
size: file.size,
|
|
394
|
+
head: [...head].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
395
|
+
};
|
|
396
|
+
}
|
|
222
397
|
/**
|
|
223
398
|
* A stand-in for Gravity Forms' REST API on a CMS: `POST
|
|
224
399
|
* /wp-json/gf/v2/forms/<id>/submissions` as Gravity Forms 3.2 answers it,
|
|
225
400
|
* validating required fields and storing entries for the given descriptors.
|
|
226
|
-
*
|
|
227
|
-
*
|
|
401
|
+
* It takes JSON, or multipart/form-data whose files are `input_<id>` (one) or
|
|
402
|
+
* `input_<id>[]` (several), as Gravity Forms reads `$_FILES`, and checks their
|
|
403
|
+
* count, size and extension as Gravity Forms does (a multiple upload's
|
|
404
|
+
* messages are a list). A filled honeypot is stored as spam, Gravity Forms'
|
|
405
|
+
* default honeypot action. Anything else is a 404. `requests` records every
|
|
406
|
+
* request.
|
|
228
407
|
*/
|
|
229
408
|
function gravityFormsStub(forms) {
|
|
230
409
|
const entries = [];
|
|
@@ -232,8 +411,15 @@ function gravityFormsStub(forms) {
|
|
|
232
411
|
async function handle(request) {
|
|
233
412
|
const url = new URL(request.url);
|
|
234
413
|
let body = null;
|
|
414
|
+
let files;
|
|
235
415
|
try {
|
|
236
|
-
|
|
416
|
+
if (request.method !== "POST") body = null;
|
|
417
|
+
else if (/^multipart\/form-data\b/i.test(request.headers.get("Content-Type") ?? "")) {
|
|
418
|
+
body = {};
|
|
419
|
+
files = {};
|
|
420
|
+
for (const [name, value] of await request.formData()) if (typeof value === "string") body[name] = value;
|
|
421
|
+
else (files[name] ??= []).push(await recordFile(value));
|
|
422
|
+
} else body = await request.json();
|
|
237
423
|
} catch {
|
|
238
424
|
body = null;
|
|
239
425
|
}
|
|
@@ -241,7 +427,8 @@ function gravityFormsStub(forms) {
|
|
|
241
427
|
method: request.method,
|
|
242
428
|
url: url.pathname + url.search,
|
|
243
429
|
headers: Object.fromEntries(request.headers),
|
|
244
|
-
body
|
|
430
|
+
body,
|
|
431
|
+
...files ? { files } : {}
|
|
245
432
|
});
|
|
246
433
|
const match = /^\/wp-json\/gf\/v2\/forms\/(\d+)\/submissions$/.exec(url.pathname);
|
|
247
434
|
const form = match && forms.find((each) => each.id === Number(match[1]));
|
|
@@ -252,7 +439,31 @@ function gravityFormsStub(forms) {
|
|
|
252
439
|
}, { status: 400 });
|
|
253
440
|
const values = body ?? {};
|
|
254
441
|
const messages = {};
|
|
442
|
+
const stored = {};
|
|
255
443
|
for (const field of form.fields) {
|
|
444
|
+
if (field.type === "fileupload") {
|
|
445
|
+
const name = field.inputs[0]?.name ?? `input_${field.id}`;
|
|
446
|
+
const posted = [...files?.[name] ?? [], ...files?.[`${name}[]`] ?? []];
|
|
447
|
+
const upload = field.upload;
|
|
448
|
+
if (posted.length === 0) {
|
|
449
|
+
if (field.required) messages[field.id] = "This field is required.";
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const limit = upload?.multipleFiles ? upload.maxFiles : 1;
|
|
453
|
+
if (limit && posted.length > limit) {
|
|
454
|
+
messages[field.id] = `Number of files (${posted.length}) exceeds limit (${limit}).`;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const invalid = posted.flatMap((file) => {
|
|
458
|
+
const extension = /\.([^.]+)$/.exec(file.filename)?.[1]?.toLowerCase() ?? "";
|
|
459
|
+
if (upload && !upload.allowedExtensions.includes(extension)) return [`${file.filename} - The uploaded file type is not allowed.`];
|
|
460
|
+
if (upload && file.size > upload.maxFileSize) return [`${file.filename} - File exceeds size limit.`];
|
|
461
|
+
return [];
|
|
462
|
+
});
|
|
463
|
+
if (invalid.length) messages[field.id] = upload?.multipleFiles ? invalid : invalid[0];
|
|
464
|
+
else stored[name] = posted;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
256
467
|
if (!field.required) continue;
|
|
257
468
|
if ((field.type === "consent" ? [field.inputs[0].name] : field.inputs.map((input) => input.name)).every((name) => !values[name])) messages[field.id] = "This field is required.";
|
|
258
469
|
}
|
|
@@ -266,6 +477,7 @@ function gravityFormsStub(forms) {
|
|
|
266
477
|
entries.push({
|
|
267
478
|
formId: form.id,
|
|
268
479
|
values,
|
|
480
|
+
files: stored,
|
|
269
481
|
spam
|
|
270
482
|
});
|
|
271
483
|
return Response.json({
|
|
@@ -308,6 +520,103 @@ function assertNoEntryExports(module) {
|
|
|
308
520
|
function formBody(values) {
|
|
309
521
|
return new URLSearchParams(values).toString();
|
|
310
522
|
}
|
|
523
|
+
/** Minimal files of each format the form endpoint verifies, as their signatures start. */
|
|
524
|
+
const SAMPLE_FILES = {
|
|
525
|
+
jpg: [
|
|
526
|
+
255,
|
|
527
|
+
216,
|
|
528
|
+
255,
|
|
529
|
+
224,
|
|
530
|
+
0,
|
|
531
|
+
16,
|
|
532
|
+
74,
|
|
533
|
+
70,
|
|
534
|
+
73,
|
|
535
|
+
70,
|
|
536
|
+
0,
|
|
537
|
+
1
|
|
538
|
+
],
|
|
539
|
+
png: [
|
|
540
|
+
137,
|
|
541
|
+
80,
|
|
542
|
+
78,
|
|
543
|
+
71,
|
|
544
|
+
13,
|
|
545
|
+
10,
|
|
546
|
+
26,
|
|
547
|
+
10,
|
|
548
|
+
0,
|
|
549
|
+
0,
|
|
550
|
+
0,
|
|
551
|
+
13
|
|
552
|
+
],
|
|
553
|
+
gif: [
|
|
554
|
+
...new TextEncoder().encode("GIF89a"),
|
|
555
|
+
1,
|
|
556
|
+
0,
|
|
557
|
+
1,
|
|
558
|
+
0,
|
|
559
|
+
0,
|
|
560
|
+
0
|
|
561
|
+
],
|
|
562
|
+
webp: [
|
|
563
|
+
...new TextEncoder().encode("RIFF"),
|
|
564
|
+
36,
|
|
565
|
+
0,
|
|
566
|
+
0,
|
|
567
|
+
0,
|
|
568
|
+
...new TextEncoder().encode("WEBPVP8 ")
|
|
569
|
+
],
|
|
570
|
+
pdf: [...new TextEncoder().encode("%PDF-1.7\n%âã")],
|
|
571
|
+
/** A Windows executable: what a renamed attachment can really be. */
|
|
572
|
+
exe: [
|
|
573
|
+
77,
|
|
574
|
+
90,
|
|
575
|
+
144,
|
|
576
|
+
0,
|
|
577
|
+
3,
|
|
578
|
+
0,
|
|
579
|
+
0,
|
|
580
|
+
0,
|
|
581
|
+
4,
|
|
582
|
+
0,
|
|
583
|
+
0,
|
|
584
|
+
0
|
|
585
|
+
]
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* A file as a browser posts it: `kind`'s signature, padded to `size` bytes,
|
|
589
|
+
* with `type` as the client's claimed MIME type (never trusted).
|
|
590
|
+
*/
|
|
591
|
+
function sampleFile(filename, kind, options = {}) {
|
|
592
|
+
const head = Uint8Array.from(SAMPLE_FILES[kind]);
|
|
593
|
+
const bytes = new Uint8Array(Math.max(options.size ?? 256, head.length));
|
|
594
|
+
bytes.set(head);
|
|
595
|
+
return new File([bytes], filename, { type: options.type ?? "application/octet-stream" });
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* A multipart form body as a browser posts it: the values, then each field's
|
|
599
|
+
* files under its input name (several under the same name, as `multiple` does).
|
|
600
|
+
*/
|
|
601
|
+
function multipartBody(values, files = {}) {
|
|
602
|
+
const body = new FormData();
|
|
603
|
+
for (const [name, value] of Object.entries(values)) body.append(name, value);
|
|
604
|
+
for (const [name, chosen] of Object.entries(files)) for (const file of Array.isArray(chosen) ? chosen : [chosen]) body.append(name, file);
|
|
605
|
+
return body;
|
|
606
|
+
}
|
|
607
|
+
/** A filled-in, valid submission of SUBMISSION_FORM from `source` (attach files with multipartBody). */
|
|
608
|
+
function submissionValues(source = "/partilhe/") {
|
|
609
|
+
return {
|
|
610
|
+
gq_source: source,
|
|
611
|
+
input_1: "Ana Silva",
|
|
612
|
+
input_2: "ana@example.com",
|
|
613
|
+
input_3: "evento",
|
|
614
|
+
input_4: "Feira da bioeconomia",
|
|
615
|
+
input_5: "Um evento sobre bioeconomia circular.",
|
|
616
|
+
input_7_1: "1",
|
|
617
|
+
input_8: ""
|
|
618
|
+
};
|
|
619
|
+
}
|
|
311
620
|
/** A filled-in, valid submission of CONTACT_FORM from `source`. */
|
|
312
621
|
function contactSubmission(source = "/contactos/") {
|
|
313
622
|
return {
|
|
@@ -331,4 +640,4 @@ const TURNSTILE_TEST_KEYS = {
|
|
|
331
640
|
dummyToken: "XXXX.DUMMY.TOKEN.XXXX"
|
|
332
641
|
};
|
|
333
642
|
//#endregion
|
|
334
|
-
export { CONTACT_FORM, PRIVATE_FORM_KEYS, TURNSTILE_TEST_KEYS, UNAVAILABLE_FORM, UNSUPPORTED_FORM, assertAccessibleForm, assertNoEntryExports, assertNoEntryReadsOrCredentials, assertPublicDescriptor, contactSubmission, formBody, gravityFormsStub };
|
|
643
|
+
export { CONTACT_FORM, IMAGE_AND_PDF_UPLOAD, PRIVATE_FORM_KEYS, SAMPLE_FILES, SUBMISSION_FORM, TURNSTILE_TEST_KEYS, UNAVAILABLE_FORM, UNSUPPORTED_FORM, assertAccessibleForm, assertNoEntryExports, assertNoEntryReadsOrCredentials, assertPublicDescriptor, contactSubmission, formBody, gravityFormsStub, multipartBody, sampleFile, submissionValues };
|