@marktiderman/genesis-forms 0.5.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 +187 -1
- package/dist/destinations.cjs +55 -3
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +1 -1
- package/dist/destinations.d.ts +1 -1
- package/dist/destinations.js +55 -3
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-Qzp6bX6O.d.ts → engine-C5tb3mkf.d.cts} +10 -3
- package/dist/{engine-BDq0XJ21.d.cts → engine-DcNQ-6oJ.d.ts} +10 -3
- package/dist/index.cjs +488 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -66
- package/dist/index.d.ts +31 -66
- package/dist/index.js +464 -63
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +769 -21
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +386 -3
- package/dist/server.d.ts +386 -3
- package/dist/server.js +742 -22
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +314 -21
- 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 +314 -21
- package/dist/testing.js.map +1 -1
- package/dist/{types-BhYUbLJ-.d.cts → types-oAckGR_Z.d.cts} +10 -0
- package/dist/{types-BhYUbLJ-.d.ts → types-oAckGR_Z.d.ts} +10 -0
- package/dist/upload-protocol-Bw0CWRJc.d.cts +122 -0
- package/dist/upload-protocol-PX0jroTV.d.ts +122 -0
- package/dist/validate-BFpNJbjf.d.cts +322 -0
- package/dist/validate-qwkdcr3H.d.ts +322 -0
- package/package.json +1 -1
- package/dist/validate-Bs1lyoOl.d.ts +0 -131
- package/dist/validate-BsZ0YTQS.d.cts +0 -131
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"],
|
|
@@ -829,6 +830,65 @@ function summarizeValue(value, field) {
|
|
|
829
830
|
if (Array.isArray(value)) return value.join(", ");
|
|
830
831
|
return String(value);
|
|
831
832
|
}
|
|
833
|
+
var OPTION_LIMITS = {
|
|
834
|
+
/** Characters in a description. */
|
|
835
|
+
description: 500,
|
|
836
|
+
/** Characters in an image URL. */
|
|
837
|
+
image: 2048,
|
|
838
|
+
/** Attributes on one option. */
|
|
839
|
+
attributes: 20,
|
|
840
|
+
/** Characters in an attribute's name. */
|
|
841
|
+
attributeName: 64,
|
|
842
|
+
/** Characters in one attribute value. */
|
|
843
|
+
attributeValue: 200,
|
|
844
|
+
/** Values in one attribute's list. */
|
|
845
|
+
attributeItems: 50
|
|
846
|
+
};
|
|
847
|
+
function isHttpsUrl(url) {
|
|
848
|
+
return typeof url === "string" && url.length <= OPTION_LIMITS.image && /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s]*)?$/i.test(url) && // No control characters anywhere (a line break would end a CSV row or an HTML attribute).
|
|
849
|
+
![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
|
|
850
|
+
}
|
|
851
|
+
function attributeName(name) {
|
|
852
|
+
return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
|
|
853
|
+
}
|
|
854
|
+
function readAttributes(raw) {
|
|
855
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
856
|
+
const out = {};
|
|
857
|
+
let count = 0;
|
|
858
|
+
for (const name of Object.keys(raw)) {
|
|
859
|
+
if (count >= OPTION_LIMITS.attributes) break;
|
|
860
|
+
if (!attributeName(name)) continue;
|
|
861
|
+
const v = raw[name];
|
|
862
|
+
const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
|
|
863
|
+
if (fits(v)) out[name] = v;
|
|
864
|
+
else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
|
|
865
|
+
else continue;
|
|
866
|
+
count++;
|
|
867
|
+
}
|
|
868
|
+
return count ? out : void 0;
|
|
869
|
+
}
|
|
870
|
+
function readOption(raw) {
|
|
871
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
872
|
+
const o = raw;
|
|
873
|
+
if (typeof o.value !== "string") return null;
|
|
874
|
+
const out = { value: o.value };
|
|
875
|
+
if (typeof o.label === "string") out.label = o.label;
|
|
876
|
+
if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
|
|
877
|
+
out.description = o.description;
|
|
878
|
+
}
|
|
879
|
+
if (isHttpsUrl(o.image)) out.image = o.image;
|
|
880
|
+
const attributes = readAttributes(o.attributes);
|
|
881
|
+
if (attributes) out.attributes = attributes;
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
function optionJson(option) {
|
|
885
|
+
const out = { value: option.value };
|
|
886
|
+
if (option.label !== void 0) out.label = option.label;
|
|
887
|
+
if (option.description !== void 0) out.description = option.description;
|
|
888
|
+
if (option.image !== void 0) out.image = option.image;
|
|
889
|
+
if (option.attributes !== void 0) out.attributes = { ...option.attributes };
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
832
892
|
var OTHER_MAX_LENGTH = 200;
|
|
833
893
|
function otherOption(field) {
|
|
834
894
|
const other = field.props?.other;
|
|
@@ -846,14 +906,21 @@ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !=
|
|
|
846
906
|
function choiceOptions(field) {
|
|
847
907
|
const raw = field.props?.options;
|
|
848
908
|
if (!Array.isArray(raw)) return [];
|
|
849
|
-
return raw.flatMap(
|
|
850
|
-
|
|
851
|
-
|
|
909
|
+
return raw.flatMap((o) => {
|
|
910
|
+
const option = readOption(o);
|
|
911
|
+
return option ? [option] : [];
|
|
912
|
+
});
|
|
852
913
|
}
|
|
853
914
|
function optionsSource(field) {
|
|
854
915
|
const raw = field.props?.optionsFrom;
|
|
855
916
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
856
|
-
|
|
917
|
+
if (raw.source !== "host" || typeof raw.key !== "string" || !raw.key) return void 0;
|
|
918
|
+
const out = { source: "host", key: raw.key };
|
|
919
|
+
const filter = raw.filter;
|
|
920
|
+
if (filter && typeof filter === "object" && !Array.isArray(filter) && typeof filter.attribute === "string" && filter.attribute && typeof filter.field === "string" && filter.field) {
|
|
921
|
+
out.filter = { attribute: filter.attribute, field: filter.field };
|
|
922
|
+
}
|
|
923
|
+
return out;
|
|
857
924
|
}
|
|
858
925
|
function hostList(hostOptions, key) {
|
|
859
926
|
if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
|
|
@@ -862,11 +929,10 @@ function hostList(hostOptions, key) {
|
|
|
862
929
|
const seen = /* @__PURE__ */ new Set();
|
|
863
930
|
const out = [];
|
|
864
931
|
for (const o of list) {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
out.push(typeof label === "string" ? { value, label } : { value });
|
|
932
|
+
const option = readOption(o);
|
|
933
|
+
if (!option || !option.value || seen.has(option.value)) continue;
|
|
934
|
+
seen.add(option.value);
|
|
935
|
+
out.push(option);
|
|
870
936
|
}
|
|
871
937
|
return out;
|
|
872
938
|
}
|
|
@@ -881,7 +947,7 @@ function withHostOptions(definition, hostOptions) {
|
|
|
881
947
|
const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
|
|
882
948
|
if (!added.length) return block;
|
|
883
949
|
changed = true;
|
|
884
|
-
const options = [...own2, ...added].map(
|
|
950
|
+
const options = [...own2, ...added].map(optionJson);
|
|
885
951
|
return { ...block, props: { ...block.props, options } };
|
|
886
952
|
});
|
|
887
953
|
return changed ? { ...definition, blocks } : definition;
|
|
@@ -1361,6 +1427,205 @@ function fieldMap(def) {
|
|
|
1361
1427
|
return map;
|
|
1362
1428
|
}
|
|
1363
1429
|
|
|
1430
|
+
// src/options.ts
|
|
1431
|
+
var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
|
|
1432
|
+
var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
|
|
1433
|
+
function put(record, key, value) {
|
|
1434
|
+
Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
|
|
1435
|
+
}
|
|
1436
|
+
function optionFilter(field) {
|
|
1437
|
+
return optionsSource(field)?.filter;
|
|
1438
|
+
}
|
|
1439
|
+
function answerValues(answer) {
|
|
1440
|
+
const one = (v) => typeof v === "string" ? [v] : typeof v === "number" || typeof v === "boolean" ? [String(v)] : [];
|
|
1441
|
+
if (answer === void 0 || answer === null) return [];
|
|
1442
|
+
return Array.isArray(answer) ? answer.flatMap(one) : one(answer);
|
|
1443
|
+
}
|
|
1444
|
+
function optionMatches(option, attribute, answer) {
|
|
1445
|
+
const have = option.attributes && Object.prototype.hasOwnProperty.call(option.attributes, attribute) ? option.attributes[attribute] : void 0;
|
|
1446
|
+
if (have === void 0) return false;
|
|
1447
|
+
const wanted = answerValues(answer);
|
|
1448
|
+
return (Array.isArray(have) ? have : [have]).some((v) => wanted.includes(v));
|
|
1449
|
+
}
|
|
1450
|
+
function filterSource(definition, key) {
|
|
1451
|
+
return definition.blocks.find((b) => b.kind === "field" && b.key === key);
|
|
1452
|
+
}
|
|
1453
|
+
function visibleOptions(definition, field, values) {
|
|
1454
|
+
const options = choiceOptions(field);
|
|
1455
|
+
const filter = optionFilter(field);
|
|
1456
|
+
if (!filter) return options;
|
|
1457
|
+
const source = filterSource(definition, filter.field);
|
|
1458
|
+
if (!source || source.id === field.id) return options;
|
|
1459
|
+
const answer = own(values, source.id);
|
|
1460
|
+
if (isEmptyValue(answer, source)) return options;
|
|
1461
|
+
return options.filter((o) => optionMatches(o, filter.attribute, answer));
|
|
1462
|
+
}
|
|
1463
|
+
function answersOnPath(state) {
|
|
1464
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
1465
|
+
for (const id of state.path) out[id] = own(state.values, id);
|
|
1466
|
+
return out;
|
|
1467
|
+
}
|
|
1468
|
+
var OUTSIDE_FILTER = "Choose one of the options.";
|
|
1469
|
+
function filteredChoiceError(definition, field, value, values) {
|
|
1470
|
+
if (!optionFilter(field) || isEmptyValue(value, field)) return null;
|
|
1471
|
+
const all = new Set(choiceOptions(field).map((o) => o.value));
|
|
1472
|
+
const shown2 = new Set(visibleOptions(definition, field, values).map((o) => o.value));
|
|
1473
|
+
const picked = answerValues(value);
|
|
1474
|
+
return picked.some((v) => all.has(v) && !shown2.has(v)) ? OUTSIDE_FILTER : null;
|
|
1475
|
+
}
|
|
1476
|
+
function hasOptionFilters(definition) {
|
|
1477
|
+
return definition.blocks.some((b) => isChoice(b) && optionFilter(b) !== void 0);
|
|
1478
|
+
}
|
|
1479
|
+
function clearFilteredAnswers(definition, values, changedId) {
|
|
1480
|
+
const changed = definition.blocks.find((b) => b.kind === "field" && b.id === changedId);
|
|
1481
|
+
if (!changed) return values;
|
|
1482
|
+
const keys = /* @__PURE__ */ new Set([changed.key]);
|
|
1483
|
+
let out = values;
|
|
1484
|
+
for (const block of definition.blocks) {
|
|
1485
|
+
if (!isChoice(block)) continue;
|
|
1486
|
+
const filter = optionFilter(block);
|
|
1487
|
+
if (!filter || !keys.has(filter.field)) continue;
|
|
1488
|
+
const value = own(out, block.id);
|
|
1489
|
+
if (value === void 0 || isEmptyValue(value, block)) continue;
|
|
1490
|
+
const all = new Set(choiceOptions(block).map((o) => o.value));
|
|
1491
|
+
const shown2 = new Set(visibleOptions(definition, block, out).map((o) => o.value));
|
|
1492
|
+
const keep = (v) => typeof v !== "string" || !all.has(v) || shown2.has(v);
|
|
1493
|
+
let next = value;
|
|
1494
|
+
if (Array.isArray(value)) {
|
|
1495
|
+
const kept = value.filter(keep);
|
|
1496
|
+
if (kept.length !== value.length) next = kept.length ? kept : void 0;
|
|
1497
|
+
} else if (!keep(value)) next = void 0;
|
|
1498
|
+
if (next === value) continue;
|
|
1499
|
+
if (out === values) out = { ...values };
|
|
1500
|
+
put(out, block.id, next);
|
|
1501
|
+
keys.add(block.key);
|
|
1502
|
+
}
|
|
1503
|
+
return out;
|
|
1504
|
+
}
|
|
1505
|
+
function fold(text) {
|
|
1506
|
+
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
|
1507
|
+
}
|
|
1508
|
+
var MAX_PREFILL_PARAM_LENGTH = 64;
|
|
1509
|
+
function isPrefillParam(param) {
|
|
1510
|
+
return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
|
|
1511
|
+
}
|
|
1512
|
+
function prefillSetting(field) {
|
|
1513
|
+
if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
|
|
1514
|
+
const raw = field.props?.prefill;
|
|
1515
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
|
|
1516
|
+
return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
|
|
1517
|
+
}
|
|
1518
|
+
function paramValues(params, name) {
|
|
1519
|
+
let raw;
|
|
1520
|
+
if (typeof params.getAll === "function") {
|
|
1521
|
+
raw = params.getAll(name);
|
|
1522
|
+
} else if (Object.prototype.hasOwnProperty.call(params, name)) {
|
|
1523
|
+
raw = params[name];
|
|
1524
|
+
}
|
|
1525
|
+
const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
|
|
1526
|
+
return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
|
|
1527
|
+
}
|
|
1528
|
+
function prefillFromLink(definition, params, values = {}) {
|
|
1529
|
+
const out = {};
|
|
1530
|
+
if (!params || typeof params !== "object") return out;
|
|
1531
|
+
const seen = {};
|
|
1532
|
+
for (const id of Object.keys(values)) put(seen, id, values[id]);
|
|
1533
|
+
for (const block of definition.blocks) {
|
|
1534
|
+
if (!isChoice(block)) continue;
|
|
1535
|
+
const setting = prefillSetting(block);
|
|
1536
|
+
if (!setting || own(seen, block.id) !== void 0) continue;
|
|
1537
|
+
const raw = paramValues(params, setting.param);
|
|
1538
|
+
if (!raw.length) continue;
|
|
1539
|
+
const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
|
|
1540
|
+
let value;
|
|
1541
|
+
if (block.type === "multi-choice") {
|
|
1542
|
+
const picked = [];
|
|
1543
|
+
for (const r of raw) {
|
|
1544
|
+
for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
|
|
1545
|
+
if (shown2.has(v) && !picked.includes(v)) picked.push(v);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (picked.length) value = picked;
|
|
1549
|
+
} else value = raw.find((v) => shown2.has(v));
|
|
1550
|
+
if (value === void 0) continue;
|
|
1551
|
+
put(out, block.id, value);
|
|
1552
|
+
put(seen, block.id, value);
|
|
1553
|
+
}
|
|
1554
|
+
return out;
|
|
1555
|
+
}
|
|
1556
|
+
function hiddenByPrefill(definition, state) {
|
|
1557
|
+
const hideable = /* @__PURE__ */ new Set();
|
|
1558
|
+
for (const b of definition.blocks) {
|
|
1559
|
+
if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
|
|
1560
|
+
}
|
|
1561
|
+
if (!hideable.size) return [];
|
|
1562
|
+
const prefilled = /* @__PURE__ */ new Set();
|
|
1563
|
+
for (const e of state.events) {
|
|
1564
|
+
if (e.type !== "answer" || !hideable.has(e.field)) continue;
|
|
1565
|
+
if (e.source === "prefill") prefilled.add(e.field);
|
|
1566
|
+
else prefilled.delete(e.field);
|
|
1567
|
+
}
|
|
1568
|
+
if (!prefilled.size) return [];
|
|
1569
|
+
const fields = new Map(definition.blocks.map((b) => [b.id, b]));
|
|
1570
|
+
return state.path.filter((id) => {
|
|
1571
|
+
if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
|
|
1572
|
+
const field = fields.get(id);
|
|
1573
|
+
return !isEmptyValue(own(state.values, id), field);
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
function snapshotOf(byValue, value) {
|
|
1577
|
+
const option = byValue.get(value);
|
|
1578
|
+
if (option) return { value, label: option.label ?? value };
|
|
1579
|
+
return { value, label: value, other: true };
|
|
1580
|
+
}
|
|
1581
|
+
function answerSnapshots(definition, answers) {
|
|
1582
|
+
const out = {};
|
|
1583
|
+
for (const block of definition.blocks) {
|
|
1584
|
+
if (!isChoice(block) || !optionsSource(block)) continue;
|
|
1585
|
+
const value = own(answers, block.id);
|
|
1586
|
+
if (isEmptyValue(value, block)) continue;
|
|
1587
|
+
const byValue = new Map(choiceOptions(block).map((o) => [o.value, o]));
|
|
1588
|
+
let snapshot;
|
|
1589
|
+
if (Array.isArray(value)) {
|
|
1590
|
+
const items = value.filter((v) => typeof v === "string").map((v) => snapshotOf(byValue, v));
|
|
1591
|
+
if (items.length) snapshot = items;
|
|
1592
|
+
} else if (typeof value === "string") snapshot = snapshotOf(byValue, value);
|
|
1593
|
+
if (snapshot) put(out, block.id, snapshot);
|
|
1594
|
+
}
|
|
1595
|
+
return out;
|
|
1596
|
+
}
|
|
1597
|
+
function answersFromSnapshots(definition, snapshots) {
|
|
1598
|
+
const answers = {};
|
|
1599
|
+
const missing = {};
|
|
1600
|
+
for (const block of definition.blocks) {
|
|
1601
|
+
if (!isChoice(block)) continue;
|
|
1602
|
+
const raw = own(snapshots, block.id);
|
|
1603
|
+
const list = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).flatMap((s) => {
|
|
1604
|
+
const o = readOption(s);
|
|
1605
|
+
if (!o) return [];
|
|
1606
|
+
const label = o.label ?? o.value;
|
|
1607
|
+
return [s.other === true ? { value: o.value, label, other: true } : { value: o.value, label }];
|
|
1608
|
+
});
|
|
1609
|
+
if (!list.length) continue;
|
|
1610
|
+
const options = choiceOptions(block);
|
|
1611
|
+
const byValue = new Set(options.map((o) => o.value));
|
|
1612
|
+
const allowsOther = block.props?.other !== void 0 && block.props?.other !== false;
|
|
1613
|
+
const found = [];
|
|
1614
|
+
const lost = [];
|
|
1615
|
+
for (const s of list) {
|
|
1616
|
+
let value;
|
|
1617
|
+
if (byValue.has(s.value)) value = s.value;
|
|
1618
|
+
else if (s.other) value = allowsOther ? s.value : void 0;
|
|
1619
|
+
else value = options.find((o) => fold(o.label ?? o.value) === fold(s.label))?.value;
|
|
1620
|
+
if (value === void 0) lost.push(s);
|
|
1621
|
+
else if (!found.includes(value)) found.push(value);
|
|
1622
|
+
}
|
|
1623
|
+
if (found.length) put(answers, block.id, block.type === "multi-choice" ? found : found[0]);
|
|
1624
|
+
if (lost.length) put(missing, block.id, lost);
|
|
1625
|
+
}
|
|
1626
|
+
return { answers, missing };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1364
1629
|
// src/recall.ts
|
|
1365
1630
|
var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
|
|
1366
1631
|
function recall(text, scope) {
|
|
@@ -1482,16 +1747,34 @@ function build(given, options, replaying = false, budget) {
|
|
|
1482
1747
|
return at === void 0 ? event : { ...event, at };
|
|
1483
1748
|
};
|
|
1484
1749
|
const isVisibleIn = (s, id) => s.path.includes(id);
|
|
1750
|
+
const filters = hasOptionFilters(definition);
|
|
1751
|
+
const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
|
|
1485
1752
|
const errorsFor = (s, ids) => {
|
|
1486
1753
|
const errors = {};
|
|
1754
|
+
let shown2;
|
|
1487
1755
|
for (const id of ids) {
|
|
1488
1756
|
const field = fields.get(id);
|
|
1489
1757
|
if (!field || !isVisibleIn(s, id)) continue;
|
|
1490
|
-
const
|
|
1758
|
+
const value = own(s.values, id);
|
|
1759
|
+
let message = validateField(field, value, { custom: options.validators?.[id] });
|
|
1760
|
+
if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
|
|
1491
1761
|
if (message) errors[id] = message;
|
|
1492
1762
|
}
|
|
1493
1763
|
return errors;
|
|
1494
1764
|
};
|
|
1765
|
+
const skipped = (s) => new Set(hiddenByPrefill(definition, s));
|
|
1766
|
+
const shownFrom = (s, from, skip) => {
|
|
1767
|
+
for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
|
|
1768
|
+
return void 0;
|
|
1769
|
+
};
|
|
1770
|
+
const showCurrent = () => {
|
|
1771
|
+
if (!state.current) return;
|
|
1772
|
+
const skip = skipped(state);
|
|
1773
|
+
if (!skip.has(state.current)) return;
|
|
1774
|
+
const at = state.path.indexOf(state.current);
|
|
1775
|
+
const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
|
|
1776
|
+
if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
|
|
1777
|
+
};
|
|
1495
1778
|
let unsettled = false;
|
|
1496
1779
|
let formulasStale = false;
|
|
1497
1780
|
const settleNow = (needValues = true) => {
|
|
@@ -1520,13 +1803,13 @@ function build(given, options, replaying = false, budget) {
|
|
|
1520
1803
|
const errors = { ...state.errors };
|
|
1521
1804
|
delete errors[id];
|
|
1522
1805
|
if (batch) {
|
|
1523
|
-
state.values = { ...state.values, [id]: value };
|
|
1806
|
+
state.values = clearFiltered({ ...state.values, [id]: value }, id);
|
|
1524
1807
|
state.errors = errors;
|
|
1525
1808
|
log(event);
|
|
1526
1809
|
unsettled = true;
|
|
1527
1810
|
return;
|
|
1528
1811
|
}
|
|
1529
|
-
const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
|
|
1812
|
+
const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
|
|
1530
1813
|
emit({ ...state, values, path, errors, events: log(event) });
|
|
1531
1814
|
};
|
|
1532
1815
|
const engine = {
|
|
@@ -1557,7 +1840,7 @@ function build(given, options, replaying = false, budget) {
|
|
|
1557
1840
|
return false;
|
|
1558
1841
|
}
|
|
1559
1842
|
const at = state.path.indexOf(state.current);
|
|
1560
|
-
const target = state
|
|
1843
|
+
const target = shownFrom(state, at + 1, skipped(state));
|
|
1561
1844
|
if (!target) {
|
|
1562
1845
|
emit({ ...state, events });
|
|
1563
1846
|
return true;
|
|
@@ -1569,8 +1852,9 @@ function build(given, options, replaying = false, budget) {
|
|
|
1569
1852
|
back() {
|
|
1570
1853
|
if (state.status === "completed") return;
|
|
1571
1854
|
const history = [...state.history];
|
|
1855
|
+
const skip = skipped(state);
|
|
1572
1856
|
let previous = history.pop();
|
|
1573
|
-
while (previous && !state.path.includes(previous)) previous = history.pop();
|
|
1857
|
+
while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
|
|
1574
1858
|
if (!previous) return;
|
|
1575
1859
|
emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
|
|
1576
1860
|
},
|
|
@@ -1598,15 +1882,18 @@ function build(given, options, replaying = false, budget) {
|
|
|
1598
1882
|
return out;
|
|
1599
1883
|
}
|
|
1600
1884
|
};
|
|
1601
|
-
|
|
1885
|
+
const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
|
|
1886
|
+
const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
|
|
1887
|
+
if (initialValues) {
|
|
1602
1888
|
const wasBatch = batch;
|
|
1603
1889
|
batch = true;
|
|
1604
|
-
for (const [id, value] of Object.entries(
|
|
1890
|
+
for (const [id, value] of Object.entries(initialValues)) {
|
|
1605
1891
|
if (!fields.has(id) || value === void 0) continue;
|
|
1606
1892
|
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
1607
1893
|
}
|
|
1608
1894
|
settleNow();
|
|
1609
1895
|
batch = wasBatch;
|
|
1896
|
+
showCurrent();
|
|
1610
1897
|
}
|
|
1611
1898
|
const resumeAt = options.resumeAt;
|
|
1612
1899
|
if (resumeAt && !replaying && state.path.includes(resumeAt)) {
|
|
@@ -1625,18 +1912,22 @@ function build(given, options, replaying = false, budget) {
|
|
|
1625
1912
|
}
|
|
1626
1913
|
} else if (event.type === "next") {
|
|
1627
1914
|
settleNow(false);
|
|
1915
|
+
showCurrent();
|
|
1628
1916
|
if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
|
|
1629
1917
|
engine.next();
|
|
1630
1918
|
} else if (event.type === "back") {
|
|
1631
1919
|
settleNow(false);
|
|
1920
|
+
showCurrent();
|
|
1632
1921
|
engine.back();
|
|
1633
1922
|
} else if (event.type === "submit") {
|
|
1634
1923
|
settleNow();
|
|
1924
|
+
showCurrent();
|
|
1635
1925
|
engine.submit();
|
|
1636
1926
|
}
|
|
1637
1927
|
};
|
|
1638
1928
|
const finish = () => {
|
|
1639
1929
|
settleNow();
|
|
1930
|
+
showCurrent();
|
|
1640
1931
|
batch = false;
|
|
1641
1932
|
state = { ...state, events: [...state.events] };
|
|
1642
1933
|
};
|
|
@@ -1644,7 +1935,12 @@ function build(given, options, replaying = false, budget) {
|
|
|
1644
1935
|
}
|
|
1645
1936
|
function replayWithin(definition, events, options, maxWork) {
|
|
1646
1937
|
const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
|
|
1647
|
-
const { engine, apply, finish } = build(
|
|
1938
|
+
const { engine, apply, finish } = build(
|
|
1939
|
+
definition,
|
|
1940
|
+
{ ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
|
|
1941
|
+
true,
|
|
1942
|
+
budget
|
|
1943
|
+
);
|
|
1648
1944
|
try {
|
|
1649
1945
|
for (const event of events) apply(event);
|
|
1650
1946
|
finish();
|
|
@@ -1716,6 +2012,31 @@ function parseForm(input) {
|
|
|
1716
2012
|
const optionalBoolean = (v, path) => {
|
|
1717
2013
|
if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
|
|
1718
2014
|
};
|
|
2015
|
+
const optionDetails = (o, at) => {
|
|
2016
|
+
if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
|
|
2017
|
+
issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
|
|
2018
|
+
}
|
|
2019
|
+
if (o.image !== void 0 && !isHttpsUrl(o.image)) {
|
|
2020
|
+
issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
|
|
2021
|
+
}
|
|
2022
|
+
if (o.attributes === void 0) return;
|
|
2023
|
+
const attributes = o.attributes;
|
|
2024
|
+
if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
|
|
2025
|
+
issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
|
|
2029
|
+
for (const [name2, v] of Object.entries(attributes)) {
|
|
2030
|
+
if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
|
|
2031
|
+
issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
|
|
2032
|
+
} else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
|
|
2033
|
+
issue(
|
|
2034
|
+
`${at}.attributes.${name2}`,
|
|
2035
|
+
`Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
1719
2040
|
const name = (v, path, what) => {
|
|
1720
2041
|
if (typeof v !== "string" || !v) {
|
|
1721
2042
|
issue(path, `${what} must be a non-empty string.`);
|
|
@@ -1888,7 +2209,31 @@ function parseForm(input) {
|
|
|
1888
2209
|
} else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
|
|
1889
2210
|
warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
|
|
1890
2211
|
}
|
|
2212
|
+
const filter = isObject2(from) ? from.filter : void 0;
|
|
2213
|
+
if (filter !== void 0) {
|
|
2214
|
+
const at = `${path}.props.optionsFrom.filter`;
|
|
2215
|
+
if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
|
|
2216
|
+
issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
|
|
2217
|
+
} else if (!keys.has(filter.field)) {
|
|
2218
|
+
issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
1891
2221
|
}
|
|
2222
|
+
const prefill = isObject2(block.props) ? block.props.prefill : void 0;
|
|
2223
|
+
if (prefill !== void 0) {
|
|
2224
|
+
if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
|
|
2225
|
+
issue(
|
|
2226
|
+
`${path}.props.prefill`,
|
|
2227
|
+
`Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
|
|
2228
|
+
);
|
|
2229
|
+
} else {
|
|
2230
|
+
optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
|
|
2231
|
+
if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
|
|
2232
|
+
warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
|
|
1892
2237
|
if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
|
|
1893
2238
|
else if (block.props.options !== void 0) {
|
|
1894
2239
|
const options = block.props.options;
|
|
@@ -1897,7 +2242,10 @@ function parseForm(input) {
|
|
|
1897
2242
|
options.forEach((o, j) => {
|
|
1898
2243
|
const at = `${path}.props.options[${j}]`;
|
|
1899
2244
|
if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
|
|
1900
|
-
else
|
|
2245
|
+
else {
|
|
2246
|
+
optionalText(o.label, `${at}.label`);
|
|
2247
|
+
optionDetails(o, at);
|
|
2248
|
+
}
|
|
1901
2249
|
});
|
|
1902
2250
|
if (from === void 0) {
|
|
1903
2251
|
choices.set(
|
|
@@ -2190,6 +2538,377 @@ function parseForm(input) {
|
|
|
2190
2538
|
return issues.length ? fail() : { ok: true, form: input, warnings };
|
|
2191
2539
|
}
|
|
2192
2540
|
|
|
2541
|
+
// src/edit-link.ts
|
|
2542
|
+
var EDIT_LINK_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
2543
|
+
var MIN_EDIT_SECRET_BYTES = 32;
|
|
2544
|
+
var EDIT_LINK_PARAM = "edit";
|
|
2545
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2546
|
+
var NONCE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
2547
|
+
var EXPIRES = /^[1-9][0-9]{0,11}$/;
|
|
2548
|
+
var SIG = /^[A-Za-z0-9_-]{43}$/;
|
|
2549
|
+
var DOMAIN = "edit-link:v2:";
|
|
2550
|
+
var MAX_TOKEN_LENGTH = 300;
|
|
2551
|
+
var encoder = new TextEncoder();
|
|
2552
|
+
function toBase64Url(bytes) {
|
|
2553
|
+
let binary = "";
|
|
2554
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
2555
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2556
|
+
}
|
|
2557
|
+
function fromBase64Url(text) {
|
|
2558
|
+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
2559
|
+
const bytes = new Uint8Array(binary.length);
|
|
2560
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
2561
|
+
return bytes;
|
|
2562
|
+
}
|
|
2563
|
+
function isUsableEditSecret(secret) {
|
|
2564
|
+
return typeof secret === "string" && secret.trim().length > 0 && encoder.encode(secret).length >= MIN_EDIT_SECRET_BYTES;
|
|
2565
|
+
}
|
|
2566
|
+
function hmacKey(secret, usage) {
|
|
2567
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
2568
|
+
}
|
|
2569
|
+
var payload = (id, nonce, expiresAt) => encoder.encode(`${DOMAIN}${id}.${nonce}.${expiresAt}`);
|
|
2570
|
+
async function signEditToken(link, secret, options = {}) {
|
|
2571
|
+
if (!isUsableEditSecret(secret) || !UUID.test(link.id) || !NONCE.test(link.nonce)) return null;
|
|
2572
|
+
const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
|
|
2573
|
+
if (!Number.isFinite(ttl) || ttl <= 0) return null;
|
|
2574
|
+
const id = link.id.toLowerCase();
|
|
2575
|
+
const expiresAt = Math.floor((options.now ?? Date.now()) / 1e3) + Math.floor(ttl);
|
|
2576
|
+
const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), payload(id, link.nonce, expiresAt));
|
|
2577
|
+
return `${id}.${link.nonce}.${expiresAt}.${toBase64Url(new Uint8Array(sig))}`;
|
|
2578
|
+
}
|
|
2579
|
+
async function verifyEditToken(token, secret, options = {}) {
|
|
2580
|
+
if (!isUsableEditSecret(secret) || typeof token !== "string" || token.length > MAX_TOKEN_LENGTH) return null;
|
|
2581
|
+
const parts = token.split(".");
|
|
2582
|
+
if (parts.length !== 4) return null;
|
|
2583
|
+
const [id, nonce, expires, sig] = parts;
|
|
2584
|
+
if (!UUID.test(id) || id !== id.toLowerCase() || !NONCE.test(nonce) || !EXPIRES.test(expires)) return null;
|
|
2585
|
+
if (!SIG.test(sig)) return null;
|
|
2586
|
+
const expiresAt = Number(expires);
|
|
2587
|
+
const now = Math.floor((options.now ?? Date.now()) / 1e3);
|
|
2588
|
+
const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
|
|
2589
|
+
if (expiresAt <= now || expiresAt > now + ttl) return null;
|
|
2590
|
+
let bytes;
|
|
2591
|
+
try {
|
|
2592
|
+
bytes = fromBase64Url(sig);
|
|
2593
|
+
} catch {
|
|
2594
|
+
return null;
|
|
2595
|
+
}
|
|
2596
|
+
const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret, "verify"), bytes, payload(id, nonce, expiresAt));
|
|
2597
|
+
return ok ? { id, nonce, expiresAt } : null;
|
|
2598
|
+
}
|
|
2599
|
+
function withEditToken(formUrl, token, param = EDIT_LINK_PARAM) {
|
|
2600
|
+
return `${formUrl}${formUrl.includes("?") ? "&" : "?"}${param}=${encodeURIComponent(token)}`;
|
|
2601
|
+
}
|
|
2602
|
+
async function editLinkUrl(formUrl, link, secret, options = {}) {
|
|
2603
|
+
const token = await signEditToken(link, secret, options);
|
|
2604
|
+
return token ? withEditToken(formUrl, token, options.param) : null;
|
|
2605
|
+
}
|
|
2606
|
+
function createEditSecretReader(options) {
|
|
2607
|
+
const { variable } = options;
|
|
2608
|
+
let warned = false;
|
|
2609
|
+
const read = (env, log) => {
|
|
2610
|
+
const secret = env[variable];
|
|
2611
|
+
if (isUsableEditSecret(secret)) return secret;
|
|
2612
|
+
if (!warned) {
|
|
2613
|
+
warned = true;
|
|
2614
|
+
(log ?? options.log ?? ((m) => console.warn(m)))(
|
|
2615
|
+
`${variable} is ${secret ? `shorter than ${MIN_EDIT_SECRET_BYTES} bytes` : "not set"}: edit links are off.`
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
return null;
|
|
2619
|
+
};
|
|
2620
|
+
return Object.assign(read, {
|
|
2621
|
+
reset: () => {
|
|
2622
|
+
warned = false;
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
function isEditLinkRequest(url, options = {}) {
|
|
2627
|
+
const isFormPage = options.isFormPage ?? (() => true);
|
|
2628
|
+
return isFormPage(url) && url.searchParams.has(options.param ?? EDIT_LINK_PARAM);
|
|
2629
|
+
}
|
|
2630
|
+
function withEditLinkHeaders(request, response, options = {}) {
|
|
2631
|
+
let url;
|
|
2632
|
+
try {
|
|
2633
|
+
url = new URL(request.url);
|
|
2634
|
+
} catch {
|
|
2635
|
+
return response;
|
|
2636
|
+
}
|
|
2637
|
+
if (!isEditLinkRequest(url, options)) return response;
|
|
2638
|
+
const headers = new Headers(response.headers);
|
|
2639
|
+
headers.set("referrer-policy", "no-referrer");
|
|
2640
|
+
headers.set("cache-control", "private, no-store");
|
|
2641
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// src/rate-limit.ts
|
|
2645
|
+
var DEFAULT_TRUSTED_IP_HEADERS = ["x-vercel-forwarded-for", "x-forwarded-for", "x-real-ip"];
|
|
2646
|
+
var RATE_LIMIT_DEFAULTS = {
|
|
2647
|
+
submit: { limit: 60, windowSeconds: 600 },
|
|
2648
|
+
progress: { limit: 900, windowSeconds: 600 },
|
|
2649
|
+
upload: { limit: 120, windowSeconds: 600 }
|
|
2650
|
+
};
|
|
2651
|
+
function clientIp(headers, trusted = DEFAULT_TRUSTED_IP_HEADERS) {
|
|
2652
|
+
for (const name of trusted) {
|
|
2653
|
+
const first = headers.get(name)?.split(",")[0]?.trim();
|
|
2654
|
+
if (first) return first;
|
|
2655
|
+
}
|
|
2656
|
+
return "unknown";
|
|
2657
|
+
}
|
|
2658
|
+
function addressPrefix(ip) {
|
|
2659
|
+
const bare = ip.replace(/^\[|\](:\d+)?$/g, "").split("%")[0].toLowerCase();
|
|
2660
|
+
if (!bare.includes(":")) return bare;
|
|
2661
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
|
|
2662
|
+
if (mapped) return mapped[1];
|
|
2663
|
+
const [head = "", tail = ""] = bare.split("::");
|
|
2664
|
+
const left = head ? head.split(":") : [];
|
|
2665
|
+
const right = bare.includes("::") && tail ? tail.split(":") : [];
|
|
2666
|
+
const groups = bare.includes("::") ? [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill("0"), ...right] : left;
|
|
2667
|
+
return `${groups.slice(0, 4).map((g) => (g || "0").replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
2668
|
+
}
|
|
2669
|
+
async function rateLimitKey(input) {
|
|
2670
|
+
const encoder2 = new TextEncoder();
|
|
2671
|
+
const key = await crypto.subtle.importKey("raw", encoder2.encode(input.secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
2672
|
+
"sign"
|
|
2673
|
+
]);
|
|
2674
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder2.encode(addressPrefix(input.ip))));
|
|
2675
|
+
let hex = "";
|
|
2676
|
+
for (const b of mac) hex += b.toString(16).padStart(2, "0");
|
|
2677
|
+
return `${input.kind}:${input.scope}:${hex}`;
|
|
2678
|
+
}
|
|
2679
|
+
async function allow(store, check, options = {}) {
|
|
2680
|
+
let allowed;
|
|
2681
|
+
try {
|
|
2682
|
+
allowed = await store.hit(check.key, check.limit, check.windowSeconds);
|
|
2683
|
+
} catch {
|
|
2684
|
+
allowed = null;
|
|
2685
|
+
}
|
|
2686
|
+
if (allowed !== null) return allowed;
|
|
2687
|
+
(options.log ?? ((m) => console.warn(m)))(
|
|
2688
|
+
options.message ?? "The rate limit could not be checked; letting the request through."
|
|
2689
|
+
);
|
|
2690
|
+
return true;
|
|
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
|
+
|
|
2193
2912
|
// src/server.ts
|
|
2194
2913
|
var MAX_EVENTS = 1e4;
|
|
2195
2914
|
var MAX_SUBMISSION_VALUES = 2e5;
|
|
@@ -2327,11 +3046,12 @@ function validateSubmission(published, submission, options = {}) {
|
|
|
2327
3046
|
}
|
|
2328
3047
|
} else {
|
|
2329
3048
|
const errors = {};
|
|
2330
|
-
|
|
3049
|
+
const kept = engine.answers();
|
|
3050
|
+
for (const [id, value] of Object.entries(kept)) {
|
|
2331
3051
|
const message = validateField(fields.get(id), value, {
|
|
2332
3052
|
requireAnswers: false,
|
|
2333
3053
|
custom: options.validators?.[id]
|
|
2334
|
-
});
|
|
3054
|
+
}) ?? filteredChoiceError(definition, fields.get(id), value, kept);
|
|
2335
3055
|
if (message) errors[id] = message;
|
|
2336
3056
|
}
|
|
2337
3057
|
if (Object.keys(errors).length) return { ok: false, errors };
|
|
@@ -2373,6 +3093,6 @@ function publicDefinition(definition) {
|
|
|
2373
3093
|
return JSON.parse(JSON.stringify(definition));
|
|
2374
3094
|
}
|
|
2375
3095
|
|
|
2376
|
-
export { MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, RAW_EVENTS_PER_EVENT, publicDefinition, validateSubmission };
|
|
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 };
|
|
2377
3097
|
//# sourceMappingURL=server.js.map
|
|
2378
3098
|
//# sourceMappingURL=server.js.map
|