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