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