@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/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