@marktiderman/genesis-forms 0.6.0 → 0.7.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/README.md +64 -6
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-DC4YTPdr.d.cts → engine-C5tb3mkf.d.cts} +1 -1
- package/dist/{engine-CpfOz8FN.d.ts → engine-DcNQ-6oJ.d.ts} +1 -1
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -65
- package/dist/index.d.ts +8 -65
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +228 -0
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +199 -6
- package/dist/server.d.ts +199 -6
- package/dist/server.js +221 -1
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -3
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/upload-protocol-Bw0CWRJc.d.cts +122 -0
- package/dist/upload-protocol-PX0jroTV.d.ts +122 -0
- package/dist/{validate-B5WKHbst.d.cts → validate-BFpNJbjf.d.cts} +2 -2
- package/dist/{validate-GpTXPwyl.d.ts → validate-qwkdcr3H.d.ts} +2 -2
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -721,6 +721,7 @@ var structuredFieldTypes = [
|
|
|
721
721
|
|
|
722
722
|
// src/photo.ts
|
|
723
723
|
var PHOTO_TYPE = "photo";
|
|
724
|
+
var UPLOAD_FIELD_TYPES = [PHOTO_TYPE];
|
|
724
725
|
var PHOTO_FORMATS = {
|
|
725
726
|
jpeg: ["image/jpeg"],
|
|
726
727
|
png: ["image/png"],
|
|
@@ -2689,6 +2690,225 @@ async function allow(store, check, options = {}) {
|
|
|
2689
2690
|
return true;
|
|
2690
2691
|
}
|
|
2691
2692
|
|
|
2693
|
+
// src/uploads.ts
|
|
2694
|
+
function uploadFields(definition) {
|
|
2695
|
+
return definition.blocks.filter(
|
|
2696
|
+
(b) => b.kind === "field" && UPLOAD_FIELD_TYPES.includes(b.type)
|
|
2697
|
+
);
|
|
2698
|
+
}
|
|
2699
|
+
function uploadRules(field) {
|
|
2700
|
+
if (field.type !== PHOTO_TYPE) return null;
|
|
2701
|
+
const limits = photoLimits(field);
|
|
2702
|
+
return {
|
|
2703
|
+
maxFiles: limits.maxFiles,
|
|
2704
|
+
maxBytes: limits.maxBytes,
|
|
2705
|
+
mimeTypes: limits.mimeTypes,
|
|
2706
|
+
typeMessage: photoTypeMessage(field),
|
|
2707
|
+
sizeMessage: photoSizeMessage(field),
|
|
2708
|
+
countMessage: photoCountMessage(field)
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
var NOT_UPLOADED_MESSAGE = "A photo didn't finish uploading. Remove it and add it again.";
|
|
2712
|
+
function owns(upload, owner) {
|
|
2713
|
+
if ("attemptHash" in owner) return upload.responseId === null && upload.attemptHash === owner.attemptHash;
|
|
2714
|
+
return upload.responseId === owner.responseId || upload.responseId === null && upload.editOf === owner.responseId;
|
|
2715
|
+
}
|
|
2716
|
+
async function checkUploads(definition, answers, options) {
|
|
2717
|
+
const asked = uploadFields(definition).filter((f) => photoRefs(answers[f.id]).length > 0);
|
|
2718
|
+
if (!asked.length) return { ok: true, answers };
|
|
2719
|
+
const { store, formId, owner } = options;
|
|
2720
|
+
if (!store) throw new Error("Answers that name uploaded files need an upload store.");
|
|
2721
|
+
const ids = asked.flatMap((f) => photoRefs(answers[f.id]).map((r) => r.id));
|
|
2722
|
+
const stored = new Map((await store.uploadRefs(formId, ids)).map((u) => [u.id, u]));
|
|
2723
|
+
const out = { ...answers };
|
|
2724
|
+
const errors = {};
|
|
2725
|
+
for (const field of asked) {
|
|
2726
|
+
const rules = uploadRules(field);
|
|
2727
|
+
const refs = photoRefs(answers[field.id]);
|
|
2728
|
+
if (refs.length > rules.maxFiles) {
|
|
2729
|
+
errors[field.id] = rules.countMessage;
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
const checked = [];
|
|
2733
|
+
for (const ref of refs) {
|
|
2734
|
+
const upload = stored.get(ref.id);
|
|
2735
|
+
if (!upload || upload.fieldId !== field.id || !owns(upload, owner) || !upload.stored) {
|
|
2736
|
+
errors[field.id] = NOT_UPLOADED_MESSAGE;
|
|
2737
|
+
break;
|
|
2738
|
+
}
|
|
2739
|
+
const type = (upload.storedType ?? "").toLowerCase();
|
|
2740
|
+
const size = upload.storedSize ?? 0;
|
|
2741
|
+
if (!rules.mimeTypes.includes(type)) {
|
|
2742
|
+
errors[field.id] = rules.typeMessage;
|
|
2743
|
+
break;
|
|
2744
|
+
}
|
|
2745
|
+
if (size < 1 || size > rules.maxBytes) {
|
|
2746
|
+
errors[field.id] = rules.sizeMessage;
|
|
2747
|
+
break;
|
|
2748
|
+
}
|
|
2749
|
+
checked.push({ id: upload.id, name: upload.name, type, size });
|
|
2750
|
+
}
|
|
2751
|
+
out[field.id] = checked;
|
|
2752
|
+
}
|
|
2753
|
+
return Object.keys(errors).length ? { ok: false, errors } : { ok: true, answers: out };
|
|
2754
|
+
}
|
|
2755
|
+
async function attemptHash(attemptId) {
|
|
2756
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(attemptId)));
|
|
2757
|
+
let hex = "";
|
|
2758
|
+
for (const b of digest) hex += b.toString(16).padStart(2, "0");
|
|
2759
|
+
return hex;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// src/upload-protocol.ts
|
|
2763
|
+
var MAX_UPLOAD_REQUEST_BYTES = 4e3;
|
|
2764
|
+
|
|
2765
|
+
// src/upload-handler.ts
|
|
2766
|
+
var UPLOAD_MESSAGES = {
|
|
2767
|
+
notJson: "Send the request as JSON.",
|
|
2768
|
+
tooLong: "That request is too long.",
|
|
2769
|
+
reload: "Reload the page and try again.",
|
|
2770
|
+
unavailable: "This form isn't available.",
|
|
2771
|
+
notOpen: "This form isn't open right now.",
|
|
2772
|
+
notTakingChanges: "This form isn't taking changes now.",
|
|
2773
|
+
badEditLink: "This edit link doesn't work any more. Use the newest one: from your latest confirmation email, or the one shown after your last change.",
|
|
2774
|
+
notUploadField: "This question doesn't take photos.",
|
|
2775
|
+
rateLimited: "Too many photos from here. Try again in a few minutes.",
|
|
2776
|
+
tooMany: "That's as many photos as one response can take.",
|
|
2777
|
+
failed: "That photo couldn't be added. Please try again."
|
|
2778
|
+
};
|
|
2779
|
+
var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2780
|
+
var json = (body, status = 200) => Response.json(body, { status, headers: { "cache-control": "no-store" } });
|
|
2781
|
+
async function readCapped(request, max) {
|
|
2782
|
+
if (!request.body) return "";
|
|
2783
|
+
const reader = request.body.getReader();
|
|
2784
|
+
const chunks = [];
|
|
2785
|
+
let size = 0;
|
|
2786
|
+
for (; ; ) {
|
|
2787
|
+
const { done, value } = await reader.read();
|
|
2788
|
+
if (done) break;
|
|
2789
|
+
size += value.byteLength;
|
|
2790
|
+
if (size > max) {
|
|
2791
|
+
await reader.cancel().catch(() => {
|
|
2792
|
+
});
|
|
2793
|
+
return null;
|
|
2794
|
+
}
|
|
2795
|
+
chunks.push(value);
|
|
2796
|
+
}
|
|
2797
|
+
const bytes = new Uint8Array(size);
|
|
2798
|
+
let offset = 0;
|
|
2799
|
+
for (const chunk of chunks) {
|
|
2800
|
+
bytes.set(chunk, offset);
|
|
2801
|
+
offset += chunk.byteLength;
|
|
2802
|
+
}
|
|
2803
|
+
return new TextDecoder().decode(bytes);
|
|
2804
|
+
}
|
|
2805
|
+
function createUploadHandler(options) {
|
|
2806
|
+
const say = { ...UPLOAD_MESSAGES, ...options.messages };
|
|
2807
|
+
const log = options.log ?? ((m) => console.error(`[upload] ${m}`));
|
|
2808
|
+
const hash = options.hashAttempt ?? attemptHash;
|
|
2809
|
+
const maxBytes = options.maxRequestBytes ?? MAX_UPLOAD_REQUEST_BYTES;
|
|
2810
|
+
return async (request, params) => {
|
|
2811
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
2812
|
+
if (contentType !== "application/json") return json({ error: say.notJson }, 415);
|
|
2813
|
+
const text = await readCapped(request, maxBytes);
|
|
2814
|
+
if (text === null) return json({ error: say.tooLong }, 413);
|
|
2815
|
+
let body;
|
|
2816
|
+
try {
|
|
2817
|
+
const parsed = JSON.parse(text);
|
|
2818
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
2819
|
+
body = parsed;
|
|
2820
|
+
} catch {
|
|
2821
|
+
return json({ error: say.notJson }, 400);
|
|
2822
|
+
}
|
|
2823
|
+
const fieldId = typeof body["fieldId"] === "string" ? body["fieldId"] : "";
|
|
2824
|
+
const name = typeof body["name"] === "string" ? body["name"].trim().slice(0, 255) : "";
|
|
2825
|
+
const type = typeof body["type"] === "string" ? body["type"].trim().toLowerCase() : "";
|
|
2826
|
+
const size = body["size"];
|
|
2827
|
+
const editing = body["edit"] !== void 0;
|
|
2828
|
+
const attemptId = body["attemptId"];
|
|
2829
|
+
if (!editing && (typeof attemptId !== "string" || !UUID2.test(attemptId))) {
|
|
2830
|
+
return json({ error: say.reload }, 400);
|
|
2831
|
+
}
|
|
2832
|
+
let form;
|
|
2833
|
+
try {
|
|
2834
|
+
form = await options.resolveForm(request, params);
|
|
2835
|
+
} catch (error) {
|
|
2836
|
+
log(error instanceof Error ? error.message : "Loading the form failed.");
|
|
2837
|
+
return json({ error: say.failed }, 503);
|
|
2838
|
+
}
|
|
2839
|
+
if (!form) return json({ error: say.unavailable }, 404);
|
|
2840
|
+
if (!form.open) {
|
|
2841
|
+
return editing ? json({ error: say.notTakingChanges, code: "closed" }, 409) : json({ error: say.notOpen }, 409);
|
|
2842
|
+
}
|
|
2843
|
+
const block = form.definition.blocks.find((b) => b.kind === "field" && b.id === fieldId);
|
|
2844
|
+
const field = block?.kind === "field" ? block : null;
|
|
2845
|
+
const rules = field ? uploadRules(field) : null;
|
|
2846
|
+
if (!field || !rules) return json({ error: say.notUploadField }, 400);
|
|
2847
|
+
if (!rules.mimeTypes.includes(type)) return json({ error: rules.typeMessage }, 415);
|
|
2848
|
+
if (typeof size !== "number" || !Number.isInteger(size) || size < 1 || size > rules.maxBytes) {
|
|
2849
|
+
return json({ error: rules.sizeMessage }, 413);
|
|
2850
|
+
}
|
|
2851
|
+
let editOf = null;
|
|
2852
|
+
if (editing) {
|
|
2853
|
+
const secret = typeof options.edit?.secret === "function" ? options.edit.secret() : options.edit?.secret;
|
|
2854
|
+
const claims = options.edit ? await verifyEditToken(
|
|
2855
|
+
typeof body["edit"] === "string" ? body["edit"] : null,
|
|
2856
|
+
secret,
|
|
2857
|
+
options.now ? { now: options.now().getTime() } : {}
|
|
2858
|
+
) : null;
|
|
2859
|
+
if (!claims || form.allowsEdits !== true) return json({ error: say.badEditLink, code: "bad_link" }, 403);
|
|
2860
|
+
let stored;
|
|
2861
|
+
try {
|
|
2862
|
+
stored = await options.edit.response(claims.id, form);
|
|
2863
|
+
} catch {
|
|
2864
|
+
log(`Loading response ${claims.id} for an upload failed.`);
|
|
2865
|
+
return json({ error: say.failed }, 503);
|
|
2866
|
+
}
|
|
2867
|
+
if (!stored || stored.editNonce !== claims.nonce) return json({ error: say.badEditLink, code: "bad_link" }, 403);
|
|
2868
|
+
editOf = stored.id;
|
|
2869
|
+
}
|
|
2870
|
+
if (options.rateLimit) {
|
|
2871
|
+
const limit = options.rateLimit;
|
|
2872
|
+
const key = await rateLimitKey({
|
|
2873
|
+
kind: "upload",
|
|
2874
|
+
scope: form.scope,
|
|
2875
|
+
ip: clientIp(request.headers, limit.trustedIpHeaders ?? DEFAULT_TRUSTED_IP_HEADERS),
|
|
2876
|
+
secret: limit.secret
|
|
2877
|
+
});
|
|
2878
|
+
const allowed = await allow(
|
|
2879
|
+
limit.store,
|
|
2880
|
+
{
|
|
2881
|
+
key,
|
|
2882
|
+
limit: limit.limit ?? RATE_LIMIT_DEFAULTS.upload.limit,
|
|
2883
|
+
windowSeconds: limit.windowSeconds ?? RATE_LIMIT_DEFAULTS.upload.windowSeconds
|
|
2884
|
+
},
|
|
2885
|
+
{ log, message: "The upload rate limit could not be checked; letting the upload through." }
|
|
2886
|
+
);
|
|
2887
|
+
if (!allowed) return json({ error: say.rateLimited }, 429);
|
|
2888
|
+
}
|
|
2889
|
+
const fileName = name || "photo";
|
|
2890
|
+
try {
|
|
2891
|
+
const begun = await options.store.beginUpload({
|
|
2892
|
+
formId: form.id,
|
|
2893
|
+
fieldId: field.id,
|
|
2894
|
+
name: fileName,
|
|
2895
|
+
contentType: type,
|
|
2896
|
+
size,
|
|
2897
|
+
attemptHash: editOf ? null : await hash(attemptId),
|
|
2898
|
+
editOf
|
|
2899
|
+
});
|
|
2900
|
+
if ("tooMany" in begun) return json({ error: say.tooMany, code: "too_many_photos" }, 429);
|
|
2901
|
+
const signed = await options.store.signUpload(begun.objectName);
|
|
2902
|
+
return json({ ref: { id: begun.id, name: fileName, type, size }, upload: signed });
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
log(
|
|
2905
|
+
`Starting an upload on ${form.label ?? form.id} for ${form.scope} failed: ${error instanceof Error ? error.message : "unexpected error"}`
|
|
2906
|
+
);
|
|
2907
|
+
return json({ error: say.failed }, 500);
|
|
2908
|
+
}
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2692
2912
|
// src/server.ts
|
|
2693
2913
|
var MAX_EVENTS = 1e4;
|
|
2694
2914
|
var MAX_SUBMISSION_VALUES = 2e5;
|
|
@@ -2873,6 +3093,6 @@ function publicDefinition(definition) {
|
|
|
2873
3093
|
return JSON.parse(JSON.stringify(definition));
|
|
2874
3094
|
}
|
|
2875
3095
|
|
|
2876
|
-
export { DEFAULT_TRUSTED_IP_HEADERS, EDIT_LINK_PARAM, EDIT_LINK_TTL_SECONDS, MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, MIN_EDIT_SECRET_BYTES, RATE_LIMIT_DEFAULTS, RAW_EVENTS_PER_EVENT, addressPrefix, allow, answerSnapshots, answersFromSnapshots, clientIp, createEditSecretReader, editLinkUrl, isEditLinkRequest, isUsableEditSecret, publicDefinition, rateLimitKey, signEditToken, validateSubmission, verifyEditToken, withEditLinkHeaders, withEditToken, withHostOptions };
|
|
3096
|
+
export { DEFAULT_TRUSTED_IP_HEADERS, EDIT_LINK_PARAM, EDIT_LINK_TTL_SECONDS, MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, MAX_UPLOAD_REQUEST_BYTES, MIN_EDIT_SECRET_BYTES, NOT_UPLOADED_MESSAGE, RATE_LIMIT_DEFAULTS, RAW_EVENTS_PER_EVENT, UPLOAD_MESSAGES, addressPrefix, allow, answerSnapshots, answersFromSnapshots, attemptHash, checkUploads, clientIp, createEditSecretReader, createUploadHandler, editLinkUrl, isEditLinkRequest, isUsableEditSecret, publicDefinition, rateLimitKey, signEditToken, uploadFields, uploadRules, validateSubmission, verifyEditToken, withEditLinkHeaders, withEditToken, withHostOptions };
|
|
2877
3097
|
//# sourceMappingURL=server.js.map
|
|
2878
3098
|
//# sourceMappingURL=server.js.map
|