@easy-cms/core 0.4.0 → 0.6.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/index.d.ts +249 -14
- package/dist/index.js +1056 -113
- package/dist/live-preview.d.ts +8 -1
- package/dist/live-preview.js +7 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -82,6 +82,31 @@ var PayloadTooLargeError = class extends EasyCMSError {
|
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
+
// src/fields.ts
|
|
86
|
+
var hasRows = (field) => field.type === "array" || field.type === "blocks";
|
|
87
|
+
function rowFields(field, row) {
|
|
88
|
+
if (field.type === "array") return field.fields;
|
|
89
|
+
const type = row && typeof row === "object" ? row.blockType : void 0;
|
|
90
|
+
return field.blocks.find((block) => block.slug === type)?.fields;
|
|
91
|
+
}
|
|
92
|
+
var FIELD_TYPES = [
|
|
93
|
+
"text",
|
|
94
|
+
"textarea",
|
|
95
|
+
"number",
|
|
96
|
+
"boolean",
|
|
97
|
+
"date",
|
|
98
|
+
"email",
|
|
99
|
+
"json",
|
|
100
|
+
"select",
|
|
101
|
+
"slug",
|
|
102
|
+
"richText",
|
|
103
|
+
"upload",
|
|
104
|
+
"relationship",
|
|
105
|
+
"array",
|
|
106
|
+
"group",
|
|
107
|
+
"blocks"
|
|
108
|
+
];
|
|
109
|
+
|
|
85
110
|
// src/access-control.ts
|
|
86
111
|
async function evaluateAccess(access, args) {
|
|
87
112
|
const result = await (access ?? isLoggedIn)(args);
|
|
@@ -123,11 +148,12 @@ async function stripFields(fields, doc, read) {
|
|
|
123
148
|
const value = result[field.name];
|
|
124
149
|
if (field.type === "group" && value && typeof value === "object") {
|
|
125
150
|
result[field.name] = await stripFields(field.fields, value, read);
|
|
126
|
-
} else if (field
|
|
151
|
+
} else if (hasRows(field) && Array.isArray(value)) {
|
|
127
152
|
result[field.name] = await Promise.all(
|
|
128
|
-
value.map(
|
|
129
|
-
|
|
130
|
-
|
|
153
|
+
value.map((row) => {
|
|
154
|
+
const fields2 = rowFields(field, row);
|
|
155
|
+
return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read) : row;
|
|
156
|
+
})
|
|
131
157
|
);
|
|
132
158
|
}
|
|
133
159
|
}
|
|
@@ -144,11 +170,12 @@ async function filterInput(fields, data, update) {
|
|
|
144
170
|
const value = result[field.name];
|
|
145
171
|
if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
146
172
|
result[field.name] = await filterInput(field.fields, value, update);
|
|
147
|
-
} else if (field
|
|
173
|
+
} else if (hasRows(field) && Array.isArray(value)) {
|
|
148
174
|
result[field.name] = await Promise.all(
|
|
149
|
-
value.map(
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
value.map((row) => {
|
|
176
|
+
const fields2 = rowFields(field, row);
|
|
177
|
+
return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update) : row;
|
|
178
|
+
})
|
|
152
179
|
);
|
|
153
180
|
}
|
|
154
181
|
}
|
|
@@ -161,10 +188,12 @@ var MEDIA = "media";
|
|
|
161
188
|
var SESSIONS = "sessions";
|
|
162
189
|
var LOGIN_ATTEMPTS = "login-attempts";
|
|
163
190
|
var VERSIONS = "document-versions";
|
|
191
|
+
var SCHEDULED_JOBS = "scheduled-jobs";
|
|
164
192
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
165
193
|
SESSIONS,
|
|
166
194
|
LOGIN_ATTEMPTS,
|
|
167
|
-
VERSIONS
|
|
195
|
+
VERSIONS,
|
|
196
|
+
SCHEDULED_JOBS
|
|
168
197
|
]);
|
|
169
198
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
170
199
|
var adminOrSelf = ({ user }) => {
|
|
@@ -345,6 +374,22 @@ var versionsCollection = {
|
|
|
345
374
|
{ name: "snapshot", type: "json", required: true }
|
|
346
375
|
]
|
|
347
376
|
};
|
|
377
|
+
var scheduledJobsCollection = {
|
|
378
|
+
slug: SCHEDULED_JOBS,
|
|
379
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
380
|
+
fields: [
|
|
381
|
+
// Collection slug, or `global:<slug>`.
|
|
382
|
+
{ name: "parent", type: "text", required: true, index: true },
|
|
383
|
+
// Document id; 0 for globals.
|
|
384
|
+
{ name: "doc", type: "number", required: true, index: true },
|
|
385
|
+
{ name: "action", type: "text", required: true },
|
|
386
|
+
{ name: "runAt", type: "text", required: true, index: true },
|
|
387
|
+
// pending | done | failed
|
|
388
|
+
{ name: "state", type: "text", required: true, index: true },
|
|
389
|
+
{ name: "error", type: "text" },
|
|
390
|
+
{ name: "author", type: "number" }
|
|
391
|
+
]
|
|
392
|
+
};
|
|
348
393
|
|
|
349
394
|
// src/auth/password.ts
|
|
350
395
|
import { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
@@ -419,6 +464,30 @@ function safeEqual(a, b) {
|
|
|
419
464
|
const right = Buffer.from(b);
|
|
420
465
|
return left.length === right.length && timingSafeEqual2(left, right);
|
|
421
466
|
}
|
|
467
|
+
function signPreviewToken(secret, target, expiresAt) {
|
|
468
|
+
const payload = Buffer.from(
|
|
469
|
+
JSON.stringify(
|
|
470
|
+
"global" in target ? { g: target.global, e: expiresAt } : { c: target.collection, i: target.id, e: expiresAt }
|
|
471
|
+
)
|
|
472
|
+
).toString("base64url");
|
|
473
|
+
return `${payload}.${hmac(secret, `preview:${payload}`)}`;
|
|
474
|
+
}
|
|
475
|
+
function verifyPreviewToken(secret, token, now = Date.now()) {
|
|
476
|
+
const dot = token.lastIndexOf(".");
|
|
477
|
+
if (dot <= 0) return null;
|
|
478
|
+
const payload = token.slice(0, dot);
|
|
479
|
+
if (!safeEqual(token.slice(dot + 1), hmac(secret, `preview:${payload}`))) return null;
|
|
480
|
+
try {
|
|
481
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
482
|
+
if (typeof data.e !== "number" || data.e <= now) return null;
|
|
483
|
+
if (typeof data.g === "string") return { global: data.g, expiresAt: data.e };
|
|
484
|
+
if (typeof data.c === "string" && typeof data.i === "string")
|
|
485
|
+
return { collection: data.c, id: data.i, expiresAt: data.e };
|
|
486
|
+
return null;
|
|
487
|
+
} catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
422
491
|
|
|
423
492
|
// src/auth/auth.ts
|
|
424
493
|
var INVALID = "Invalid email or password";
|
|
@@ -620,17 +689,30 @@ var isEmpty = (value) => value === void 0 || value === null || value === "";
|
|
|
620
689
|
function slugify(text) {
|
|
621
690
|
return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
622
691
|
}
|
|
623
|
-
function applyDefaults(fields, data) {
|
|
692
|
+
function applyDefaults(fields, data, localization = null) {
|
|
624
693
|
const result = { ...data };
|
|
625
694
|
for (const field of fields) {
|
|
626
695
|
const value = result[field.name];
|
|
627
696
|
if (field.type === "group") {
|
|
628
|
-
result[field.name] = applyDefaults(
|
|
629
|
-
|
|
697
|
+
result[field.name] = applyDefaults(
|
|
698
|
+
field.fields,
|
|
699
|
+
isPlainObject(value) ? value : {},
|
|
700
|
+
localization
|
|
701
|
+
);
|
|
702
|
+
} else if (hasRows(field) && !field.localized) {
|
|
630
703
|
if (Array.isArray(value)) {
|
|
631
|
-
result[field.name] = value.map(
|
|
632
|
-
|
|
633
|
-
|
|
704
|
+
result[field.name] = value.map((row) => {
|
|
705
|
+
const fields2 = rowFields(field, row);
|
|
706
|
+
return fields2 && isPlainObject(row) ? applyDefaults(fields2, row, localization) : row;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
} else if (field.localized && localization && field.defaultValue !== void 0) {
|
|
710
|
+
const map = isPlainObject(value) ? value : {};
|
|
711
|
+
if (map[localization.defaultLocale] === void 0) {
|
|
712
|
+
result[field.name] = {
|
|
713
|
+
...map,
|
|
714
|
+
[localization.defaultLocale]: structuredClone(field.defaultValue)
|
|
715
|
+
};
|
|
634
716
|
}
|
|
635
717
|
} else if (value === void 0 && field.defaultValue !== void 0) {
|
|
636
718
|
result[field.name] = structuredClone(field.defaultValue);
|
|
@@ -645,28 +727,41 @@ function fillMissing(fields, data) {
|
|
|
645
727
|
if (field.type === "group") {
|
|
646
728
|
result[field.name] = fillMissing(field.fields, isPlainObject(value) ? value : {});
|
|
647
729
|
} else if (value === void 0) {
|
|
648
|
-
result[field.name] = field
|
|
730
|
+
result[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
|
|
649
731
|
}
|
|
650
732
|
}
|
|
651
733
|
return result;
|
|
652
734
|
}
|
|
653
|
-
function generateSlugs(fields, data) {
|
|
735
|
+
function generateSlugs(fields, data, localization = null) {
|
|
654
736
|
const result = { ...data };
|
|
655
737
|
for (const field of fields) {
|
|
656
|
-
if (field.type === "slug") {
|
|
738
|
+
if (field.type === "slug" && field.localized && localization) {
|
|
739
|
+
const map = isPlainObject(result[field.name]) ? result[field.name] : {};
|
|
740
|
+
const source = field.from ? result[field.from] : void 0;
|
|
741
|
+
const next = { ...map };
|
|
742
|
+
for (const locale of localization.locales) {
|
|
743
|
+
const current = map[locale];
|
|
744
|
+
const from = isPlainObject(source) ? source[locale] : source;
|
|
745
|
+
if (typeof current === "string" && current !== "") next[locale] = slugify(current);
|
|
746
|
+
else if (typeof from === "string" && from.trim() !== "") next[locale] = slugify(from);
|
|
747
|
+
}
|
|
748
|
+
result[field.name] = next;
|
|
749
|
+
} else if (field.type === "slug") {
|
|
657
750
|
const current = result[field.name];
|
|
658
751
|
if (typeof current === "string" && current !== "") {
|
|
659
752
|
result[field.name] = slugify(current);
|
|
660
753
|
} else if (field.from) {
|
|
661
|
-
const
|
|
754
|
+
const raw = result[field.from];
|
|
755
|
+
const source = isPlainObject(raw) && localization ? raw[localization.defaultLocale] : raw;
|
|
662
756
|
if (typeof source === "string" && source.trim() !== "") result[field.name] = slugify(source);
|
|
663
757
|
}
|
|
664
758
|
} else if (field.type === "group" && isPlainObject(result[field.name])) {
|
|
665
|
-
result[field.name] = generateSlugs(field.fields, result[field.name]);
|
|
666
|
-
} else if (field
|
|
667
|
-
result[field.name] = result[field.name].map(
|
|
668
|
-
|
|
669
|
-
|
|
759
|
+
result[field.name] = generateSlugs(field.fields, result[field.name], localization);
|
|
760
|
+
} else if (hasRows(field) && Array.isArray(result[field.name])) {
|
|
761
|
+
result[field.name] = result[field.name].map((row) => {
|
|
762
|
+
const fields2 = rowFields(field, row);
|
|
763
|
+
return fields2 && isPlainObject(row) ? generateSlugs(fields2, row, localization) : row;
|
|
764
|
+
});
|
|
670
765
|
}
|
|
671
766
|
}
|
|
672
767
|
return result;
|
|
@@ -717,9 +812,45 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
717
812
|
references.push(...sub.references);
|
|
718
813
|
continue;
|
|
719
814
|
}
|
|
815
|
+
const localization = options.localization;
|
|
816
|
+
if (field.localized && localization) {
|
|
817
|
+
if (!isEmpty(raw) && !isPlainObject(raw)) {
|
|
818
|
+
fail("must be an object with one value per locale");
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
const map = isPlainObject(raw) ? raw : {};
|
|
822
|
+
const written = options.locale ?? localization.defaultLocale;
|
|
823
|
+
const out = {};
|
|
824
|
+
for (const locale of localization.locales) {
|
|
825
|
+
const own = locale === written;
|
|
826
|
+
const localePath = own ? path : `${path}.${locale}`;
|
|
827
|
+
const failLocale = (message) => errors.push({ field: localePath, message });
|
|
828
|
+
const value2 = map[locale];
|
|
829
|
+
if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
|
|
830
|
+
if (own && field.required && !options.skipRequired) failLocale("is required");
|
|
831
|
+
out[locale] = hasRows(field) || isHasMany(field) ? [] : null;
|
|
832
|
+
if (own && !field.required) await runCustom(field, null, options, failLocale);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const normalized = await normalizeValue(
|
|
836
|
+
field,
|
|
837
|
+
value2,
|
|
838
|
+
localePath,
|
|
839
|
+
options,
|
|
840
|
+
failLocale,
|
|
841
|
+
errors,
|
|
842
|
+
references
|
|
843
|
+
);
|
|
844
|
+
if (normalized === void 0) continue;
|
|
845
|
+
out[locale] = normalized;
|
|
846
|
+
if (own) await runCustom(field, normalized, options, failLocale);
|
|
847
|
+
}
|
|
848
|
+
data[field.name] = out;
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
720
851
|
if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
|
|
721
852
|
if (field.required && !options.skipRequired) fail("is required");
|
|
722
|
-
data[field.name] = field
|
|
853
|
+
data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
|
|
723
854
|
if (!field.required) await runCustom(field, null, options, fail);
|
|
724
855
|
continue;
|
|
725
856
|
}
|
|
@@ -821,10 +952,36 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
821
952
|
}
|
|
822
953
|
return rows;
|
|
823
954
|
}
|
|
955
|
+
case "blocks":
|
|
956
|
+
return normalizeBlocks(field, raw, path, options, fail, errors, references);
|
|
824
957
|
case "group":
|
|
825
958
|
return raw;
|
|
826
959
|
}
|
|
827
960
|
}
|
|
961
|
+
async function normalizeBlocks(field, raw, path, options, fail, errors, references) {
|
|
962
|
+
if (!Array.isArray(raw)) return fail("must be an array of blocks");
|
|
963
|
+
if (field.minRows !== void 0 && raw.length < field.minRows)
|
|
964
|
+
return fail(`must have at least ${field.minRows} blocks`);
|
|
965
|
+
if (field.maxRows !== void 0 && raw.length > field.maxRows)
|
|
966
|
+
return fail(`must have at most ${field.maxRows} blocks`);
|
|
967
|
+
const rows = [];
|
|
968
|
+
for (const [i, row] of raw.entries()) {
|
|
969
|
+
const fields = rowFields(field, row);
|
|
970
|
+
if (!isPlainObject(row) || !fields) {
|
|
971
|
+
errors.push({
|
|
972
|
+
field: `${path}.${i}.blockType`,
|
|
973
|
+
message: `must be one of: ${field.blocks.map((b) => b.slug).join(", ")}`
|
|
974
|
+
});
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
const sub = await validateFields(fields, row, options, `${path}.${i}.`);
|
|
978
|
+
errors.push(...sub.errors);
|
|
979
|
+
references.push(...sub.references);
|
|
980
|
+
const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
|
|
981
|
+
rows.push({ id, blockType: row.blockType, ...sub.data });
|
|
982
|
+
}
|
|
983
|
+
return rows;
|
|
984
|
+
}
|
|
828
985
|
function normalizeSelect(field, raw, fail) {
|
|
829
986
|
const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
|
|
830
987
|
const values = field.hasMany ? raw : [raw];
|
|
@@ -836,29 +993,94 @@ function normalizeSelect(field, raw, fail) {
|
|
|
836
993
|
return field.hasMany ? [...new Set(values)] : values[0];
|
|
837
994
|
}
|
|
838
995
|
|
|
839
|
-
// src/fields.ts
|
|
840
|
-
var FIELD_TYPES = [
|
|
841
|
-
"text",
|
|
842
|
-
"textarea",
|
|
843
|
-
"number",
|
|
844
|
-
"boolean",
|
|
845
|
-
"date",
|
|
846
|
-
"email",
|
|
847
|
-
"json",
|
|
848
|
-
"select",
|
|
849
|
-
"slug",
|
|
850
|
-
"richText",
|
|
851
|
-
"upload",
|
|
852
|
-
"relationship",
|
|
853
|
-
"array",
|
|
854
|
-
"group"
|
|
855
|
-
];
|
|
856
|
-
|
|
857
996
|
// src/load-config.ts
|
|
858
997
|
import { existsSync } from "fs";
|
|
859
998
|
import { resolve } from "path";
|
|
860
999
|
import { createJiti } from "jiti";
|
|
861
1000
|
|
|
1001
|
+
// src/webhooks.ts
|
|
1002
|
+
import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
|
|
1003
|
+
var WEBHOOK_EVENTS = [
|
|
1004
|
+
"create",
|
|
1005
|
+
"update",
|
|
1006
|
+
"delete",
|
|
1007
|
+
"publish",
|
|
1008
|
+
"unpublish",
|
|
1009
|
+
"draft"
|
|
1010
|
+
];
|
|
1011
|
+
var RETRY_DELAYS = [1e3, 5e3];
|
|
1012
|
+
var TIMEOUT = 1e4;
|
|
1013
|
+
var Webhooks = class {
|
|
1014
|
+
constructor(hooks, logger, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
|
|
1015
|
+
this.hooks = hooks;
|
|
1016
|
+
this.logger = logger;
|
|
1017
|
+
this.fetcher = fetcher;
|
|
1018
|
+
this.retryDelays = retryDelays;
|
|
1019
|
+
}
|
|
1020
|
+
hooks;
|
|
1021
|
+
logger;
|
|
1022
|
+
fetcher;
|
|
1023
|
+
retryDelays;
|
|
1024
|
+
pending = /* @__PURE__ */ new Set();
|
|
1025
|
+
emit(event, target, doc) {
|
|
1026
|
+
if (this.hooks.length === 0) return;
|
|
1027
|
+
const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1028
|
+
for (const hook of this.hooks) {
|
|
1029
|
+
if (!matches(hook, payload)) continue;
|
|
1030
|
+
const delivery = this.deliver(hook, payload).finally(() => this.pending.delete(delivery));
|
|
1031
|
+
this.pending.add(delivery);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** Waits for deliveries in progress, e.g. before a serverless function returns. */
|
|
1035
|
+
async flush() {
|
|
1036
|
+
while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
|
|
1037
|
+
}
|
|
1038
|
+
async deliver(hook, payload) {
|
|
1039
|
+
const body = JSON.stringify(payload);
|
|
1040
|
+
const headers = {
|
|
1041
|
+
"content-type": "application/json",
|
|
1042
|
+
"user-agent": "easy-cms-webhooks",
|
|
1043
|
+
"x-easy-cms-event": payload.event,
|
|
1044
|
+
"x-easy-cms-delivery": randomUUID2(),
|
|
1045
|
+
...hook.headers
|
|
1046
|
+
};
|
|
1047
|
+
if (hook.secret) {
|
|
1048
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
|
|
1049
|
+
}
|
|
1050
|
+
for (let attempt = 0; ; attempt++) {
|
|
1051
|
+
try {
|
|
1052
|
+
const response = await this.fetcher(hook.url, {
|
|
1053
|
+
method: "POST",
|
|
1054
|
+
headers,
|
|
1055
|
+
body,
|
|
1056
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
1057
|
+
});
|
|
1058
|
+
if (response.ok || response.status < 500 && response.status !== 429) {
|
|
1059
|
+
if (!response.ok)
|
|
1060
|
+
this.logger.warn(`Webhook ${hook.url} answered ${response.status} to ${payload.event}`);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
throw new Error(`status ${response.status}`);
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
const delay = this.retryDelays[attempt];
|
|
1066
|
+
if (delay === void 0) {
|
|
1067
|
+
this.logger.error(
|
|
1068
|
+
`Webhook ${hook.url} failed for ${payload.event}: ${error.message}`
|
|
1069
|
+
);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
await new Promise((resolve3) => setTimeout(resolve3, delay));
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
function matches(hook, payload) {
|
|
1078
|
+
if (hook.events && !hook.events.includes(payload.event)) return false;
|
|
1079
|
+
if (payload.collection !== void 0)
|
|
1080
|
+
return !hook.collections || hook.collections.includes(payload.collection);
|
|
1081
|
+
return !hook.globals || hook.globals.includes(payload.global);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
862
1084
|
// src/validate-config.ts
|
|
863
1085
|
var MIN_SECRET_LENGTH = 32;
|
|
864
1086
|
var BUILTIN_COLLECTIONS = ["users", "media"];
|
|
@@ -868,6 +1090,8 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
868
1090
|
"sessions",
|
|
869
1091
|
"login-attempts",
|
|
870
1092
|
"document-versions",
|
|
1093
|
+
"scheduled-jobs",
|
|
1094
|
+
"jobs",
|
|
871
1095
|
"migrations",
|
|
872
1096
|
"access"
|
|
873
1097
|
]);
|
|
@@ -889,6 +1113,8 @@ function validateConfig(config) {
|
|
|
889
1113
|
validateUpload(config, add);
|
|
890
1114
|
validateAuth(config, add);
|
|
891
1115
|
validateCors(config.cors, add);
|
|
1116
|
+
validateLocalization(config, add);
|
|
1117
|
+
validateWebhooks(config, add);
|
|
892
1118
|
const collections = asArray(config.collections, "collections", add);
|
|
893
1119
|
const globals = asArray(config.globals, "globals", add);
|
|
894
1120
|
const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
|
|
@@ -965,6 +1191,112 @@ function validateAuth(config, add) {
|
|
|
965
1191
|
}
|
|
966
1192
|
}
|
|
967
1193
|
}
|
|
1194
|
+
var LOCALE_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
1195
|
+
var LOCALIZABLE = /* @__PURE__ */ new Set([
|
|
1196
|
+
"text",
|
|
1197
|
+
"textarea",
|
|
1198
|
+
"email",
|
|
1199
|
+
"slug",
|
|
1200
|
+
"richText",
|
|
1201
|
+
"number",
|
|
1202
|
+
"boolean",
|
|
1203
|
+
"date",
|
|
1204
|
+
"json",
|
|
1205
|
+
"select",
|
|
1206
|
+
"upload",
|
|
1207
|
+
"relationship",
|
|
1208
|
+
"blocks",
|
|
1209
|
+
"array"
|
|
1210
|
+
]);
|
|
1211
|
+
function validateLocalization(config, add) {
|
|
1212
|
+
const localization = config.localization;
|
|
1213
|
+
if (localization !== void 0 && localization !== null) {
|
|
1214
|
+
const locales = localization?.locales;
|
|
1215
|
+
if (!Array.isArray(locales) || locales.length === 0) {
|
|
1216
|
+
add("localization.locales", "must be a non-empty list of locales", "locales: ['th', 'en']");
|
|
1217
|
+
} else {
|
|
1218
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1219
|
+
for (const [i, locale] of locales.entries()) {
|
|
1220
|
+
if (typeof locale !== "string" || !LOCALE_PATTERN.test(locale)) {
|
|
1221
|
+
add(
|
|
1222
|
+
`localization.locales[${i}]`,
|
|
1223
|
+
`must be a locale code like "th" or "en-US" (got ${JSON.stringify(locale)})`
|
|
1224
|
+
);
|
|
1225
|
+
} else if (seen.has(locale)) {
|
|
1226
|
+
add(`localization.locales[${i}]`, `duplicate locale "${locale}"`);
|
|
1227
|
+
}
|
|
1228
|
+
seen.add(locale);
|
|
1229
|
+
}
|
|
1230
|
+
const fallbackDefault = localization.defaultLocale;
|
|
1231
|
+
if (fallbackDefault !== void 0 && !locales.includes(fallbackDefault)) {
|
|
1232
|
+
add("localization.defaultLocale", `must be one of the locales (got "${fallbackDefault}")`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const walk = (fields, path, insideLocalized = false) => {
|
|
1237
|
+
if (!Array.isArray(fields)) return;
|
|
1238
|
+
for (const field of fields) {
|
|
1239
|
+
if (typeof field?.name !== "string") continue;
|
|
1240
|
+
const fieldPath = `${path}.${field.name}`;
|
|
1241
|
+
if (field.localized) {
|
|
1242
|
+
if (!localization) {
|
|
1243
|
+
add(
|
|
1244
|
+
`${fieldPath}.localized`,
|
|
1245
|
+
"needs `localization` in the config",
|
|
1246
|
+
"localization: { locales: ['th', 'en'] }"
|
|
1247
|
+
);
|
|
1248
|
+
} else if (!LOCALIZABLE.has(field.type)) {
|
|
1249
|
+
add(
|
|
1250
|
+
`${fieldPath}.localized`,
|
|
1251
|
+
`${field.type} fields cannot be localized`,
|
|
1252
|
+
"localize the fields inside it instead"
|
|
1253
|
+
);
|
|
1254
|
+
} else if (insideLocalized) {
|
|
1255
|
+
add(
|
|
1256
|
+
`${fieldPath}.localized`,
|
|
1257
|
+
"is inside a localized field, which already holds one value per locale"
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const inside = insideLocalized || field.localized === true;
|
|
1262
|
+
if (field.type === "group" || field.type === "array")
|
|
1263
|
+
walk(field.fields, `${fieldPath}.fields`, inside);
|
|
1264
|
+
if (field.type === "blocks")
|
|
1265
|
+
for (const block of field.blocks ?? [])
|
|
1266
|
+
walk(block?.fields, `${fieldPath}.blocks.${block?.slug}`, inside);
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
for (const [i, c] of (config.collections ?? []).entries())
|
|
1270
|
+
walk(c?.fields, `collections[${i}].fields`);
|
|
1271
|
+
for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
|
|
1272
|
+
}
|
|
1273
|
+
function validateWebhooks(config, add) {
|
|
1274
|
+
const hooks = config.webhooks;
|
|
1275
|
+
if (hooks === void 0) return;
|
|
1276
|
+
if (!Array.isArray(hooks)) {
|
|
1277
|
+
add("webhooks", "must be a list", "webhooks: [{ url: 'https://example.com/hook' }]");
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const collections = /* @__PURE__ */ new Set([
|
|
1281
|
+
...BUILTIN_COLLECTIONS,
|
|
1282
|
+
...(config.collections ?? []).map((c) => c?.slug)
|
|
1283
|
+
]);
|
|
1284
|
+
const globals = new Set((config.globals ?? []).map((g) => g?.slug));
|
|
1285
|
+
for (const [i, hook] of hooks.entries()) {
|
|
1286
|
+
const path = `webhooks[${i}]`;
|
|
1287
|
+
if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
|
|
1288
|
+
add(`${path}.url`, "must be an http(s) URL");
|
|
1289
|
+
}
|
|
1290
|
+
for (const event of hook?.events ?? []) {
|
|
1291
|
+
if (!WEBHOOK_EVENTS.includes(event))
|
|
1292
|
+
add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
|
|
1293
|
+
}
|
|
1294
|
+
for (const slug of hook?.collections ?? [])
|
|
1295
|
+
if (!collections.has(slug)) add(`${path}.collections`, `unknown collection "${slug}"`);
|
|
1296
|
+
for (const slug of hook?.globals ?? [])
|
|
1297
|
+
if (!globals.has(slug)) add(`${path}.globals`, `unknown global "${slug}"`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
968
1300
|
function validateCors(cors, add) {
|
|
969
1301
|
if (cors === void 0 || cors === "*") return;
|
|
970
1302
|
if (!Array.isArray(cors)) {
|
|
@@ -1032,10 +1364,14 @@ function validateContainer(container, path, collectionSlugs, add) {
|
|
|
1032
1364
|
add(`${path}.versions.max`, "must be a positive integer");
|
|
1033
1365
|
}
|
|
1034
1366
|
}
|
|
1367
|
+
if (container.schedule && !container.drafts) {
|
|
1368
|
+
add(`${path}.schedule`, "needs `drafts: true`", "scheduling publishes and unpublishes drafts");
|
|
1369
|
+
}
|
|
1035
1370
|
if (container.preview !== void 0 && typeof container.preview !== "function") {
|
|
1036
1371
|
add(
|
|
1037
1372
|
`${path}.preview`,
|
|
1038
1373
|
"must be a function ({ doc }) => url",
|
|
1374
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: an example shown to the user
|
|
1039
1375
|
"preview: ({ doc }) => `/posts/${doc.slug}`"
|
|
1040
1376
|
);
|
|
1041
1377
|
}
|
|
@@ -1115,6 +1451,43 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
1115
1451
|
checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
|
|
1116
1452
|
validateFields2(field.fields, `${path}.fields`, /* @__PURE__ */ new Set(["id"]), collectionSlugs, add);
|
|
1117
1453
|
break;
|
|
1454
|
+
case "blocks": {
|
|
1455
|
+
if (!Array.isArray(field.blocks) || field.blocks.length === 0) {
|
|
1456
|
+
add(
|
|
1457
|
+
`${path}.blocks`,
|
|
1458
|
+
"must contain at least one block",
|
|
1459
|
+
"blocks: [{ slug: 'hero', fields: [...] }]"
|
|
1460
|
+
);
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
|
|
1464
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1465
|
+
for (const [i, block] of field.blocks.entries()) {
|
|
1466
|
+
const blockPath = `${path}.blocks[${i}]`;
|
|
1467
|
+
if (typeof block?.slug !== "string" || !SLUG_PATTERN.test(block.slug)) {
|
|
1468
|
+
add(
|
|
1469
|
+
`${blockPath}.slug`,
|
|
1470
|
+
'must be lowercase letters, digits, "-" or "_", starting with a letter'
|
|
1471
|
+
);
|
|
1472
|
+
} else if (seen.has(block.slug)) {
|
|
1473
|
+
add(`${blockPath}.slug`, `duplicate block "${block.slug}"`);
|
|
1474
|
+
} else {
|
|
1475
|
+
seen.add(block.slug);
|
|
1476
|
+
}
|
|
1477
|
+
if (!Array.isArray(block?.fields)) {
|
|
1478
|
+
add(`${blockPath}.fields`, "must be an array");
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
validateFields2(
|
|
1482
|
+
block.fields,
|
|
1483
|
+
`${blockPath}.fields`,
|
|
1484
|
+
/* @__PURE__ */ new Set(["id", "blockType"]),
|
|
1485
|
+
collectionSlugs,
|
|
1486
|
+
add
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1118
1491
|
}
|
|
1119
1492
|
}
|
|
1120
1493
|
function checkRange(min, max, minName, maxName, path, add) {
|
|
@@ -1179,6 +1552,11 @@ async function resolveConfig(input) {
|
|
|
1179
1552
|
const result = {
|
|
1180
1553
|
...rest,
|
|
1181
1554
|
cors: config.cors ?? [],
|
|
1555
|
+
localization: config.localization ? {
|
|
1556
|
+
locales: config.localization.locales,
|
|
1557
|
+
defaultLocale: config.localization.defaultLocale ?? config.localization.locales[0] ?? "",
|
|
1558
|
+
fallback: config.localization.fallback ?? true
|
|
1559
|
+
} : null,
|
|
1182
1560
|
routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
|
|
1183
1561
|
admin: {
|
|
1184
1562
|
path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
|
|
@@ -1201,7 +1579,8 @@ async function resolveConfig(input) {
|
|
|
1201
1579
|
collections: [
|
|
1202
1580
|
...config.collections ?? [],
|
|
1203
1581
|
...internalCollections,
|
|
1204
|
-
...usesVersions(config) ? [versionsCollection] : []
|
|
1582
|
+
...usesVersions(config) ? [versionsCollection] : [],
|
|
1583
|
+
...usesSchedule(config) ? [scheduledJobsCollection] : []
|
|
1205
1584
|
],
|
|
1206
1585
|
globals: config.globals ?? []
|
|
1207
1586
|
};
|
|
@@ -1211,6 +1590,9 @@ async function resolveConfig(input) {
|
|
|
1211
1590
|
function usesVersions(config) {
|
|
1212
1591
|
return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
|
|
1213
1592
|
}
|
|
1593
|
+
function usesSchedule(config) {
|
|
1594
|
+
return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
|
|
1595
|
+
}
|
|
1214
1596
|
|
|
1215
1597
|
// src/load-config.ts
|
|
1216
1598
|
var CONFIG_FILE_NAMES = [
|
|
@@ -1260,6 +1642,98 @@ async function loadConfig(options = {}) {
|
|
|
1260
1642
|
// src/local-api.ts
|
|
1261
1643
|
import { randomBytes as randomBytes3 } from "crypto";
|
|
1262
1644
|
|
|
1645
|
+
// src/localization.ts
|
|
1646
|
+
var ALL_LOCALES = "all";
|
|
1647
|
+
var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1648
|
+
var isEmpty2 = (value) => value === void 0 || value === null || value === "";
|
|
1649
|
+
function pickLocale(fields, data, locale, localization, fallback) {
|
|
1650
|
+
if (locale === ALL_LOCALES) return data;
|
|
1651
|
+
const result = { ...data };
|
|
1652
|
+
for (const field of fields) {
|
|
1653
|
+
const value = result[field.name];
|
|
1654
|
+
if (field.localized) {
|
|
1655
|
+
const map = isPlainObject2(value) ? value : {};
|
|
1656
|
+
const own = map[locale];
|
|
1657
|
+
const blank = isEmpty2(own) || Array.isArray(own) && own.length === 0;
|
|
1658
|
+
const picked = blank && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
|
|
1659
|
+
const list = hasRows(field) || "hasMany" in field && field.hasMany === true;
|
|
1660
|
+
result[field.name] = picked === null && list ? [] : picked;
|
|
1661
|
+
} else if (field.type === "group" && isPlainObject2(value)) {
|
|
1662
|
+
result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
|
|
1663
|
+
} else if (hasRows(field) && Array.isArray(value)) {
|
|
1664
|
+
result[field.name] = value.map((row) => {
|
|
1665
|
+
const fields2 = rowFields(field, row);
|
|
1666
|
+
return fields2 && isPlainObject2(row) ? pickLocale(fields2, row, locale, localization, fallback) : row;
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return result;
|
|
1671
|
+
}
|
|
1672
|
+
function toLocaleMaps(fields, input, current, locale) {
|
|
1673
|
+
const result = { ...input };
|
|
1674
|
+
for (const field of fields) {
|
|
1675
|
+
if (!Object.hasOwn(input, field.name)) continue;
|
|
1676
|
+
const value = input[field.name];
|
|
1677
|
+
const previous = current[field.name];
|
|
1678
|
+
if (field.localized) {
|
|
1679
|
+
const map = isPlainObject2(previous) ? previous : {};
|
|
1680
|
+
result[field.name] = { ...map, [locale]: value };
|
|
1681
|
+
} else if (field.type === "group" && isPlainObject2(value)) {
|
|
1682
|
+
result[field.name] = toLocaleMaps(
|
|
1683
|
+
field.fields,
|
|
1684
|
+
value,
|
|
1685
|
+
isPlainObject2(previous) ? previous : {},
|
|
1686
|
+
locale
|
|
1687
|
+
);
|
|
1688
|
+
} else if (hasRows(field) && Array.isArray(value)) {
|
|
1689
|
+
const rows = new Map(
|
|
1690
|
+
(Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
|
|
1691
|
+
);
|
|
1692
|
+
result[field.name] = value.map((row) => {
|
|
1693
|
+
const fields2 = rowFields(field, row);
|
|
1694
|
+
return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows.get(String(row.id)) ?? {}, locale) : row;
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return result;
|
|
1699
|
+
}
|
|
1700
|
+
function localizePath(fields, path, locale, locales = []) {
|
|
1701
|
+
const segments = path.split(".");
|
|
1702
|
+
let list = fields;
|
|
1703
|
+
for (let i = 0; i < segments.length; i++) {
|
|
1704
|
+
const field = list.find((f) => f.name === segments[i]);
|
|
1705
|
+
if (!field) return path;
|
|
1706
|
+
if (field.localized) {
|
|
1707
|
+
const next = segments[i + 1];
|
|
1708
|
+
if (next !== void 0 && locales.includes(next)) return path;
|
|
1709
|
+
return [...segments.slice(0, i + 1), locale, ...segments.slice(i + 1)].join(".");
|
|
1710
|
+
}
|
|
1711
|
+
if (field.type === "group" || field.type === "array") {
|
|
1712
|
+
list = field.fields;
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
return path;
|
|
1716
|
+
}
|
|
1717
|
+
return path;
|
|
1718
|
+
}
|
|
1719
|
+
function localizeWhere(fields, where, locale, locales = []) {
|
|
1720
|
+
if (!where) return where;
|
|
1721
|
+
const out = {};
|
|
1722
|
+
for (const [key2, value] of Object.entries(where)) {
|
|
1723
|
+
if ((key2 === "and" || key2 === "or") && Array.isArray(value)) {
|
|
1724
|
+
out[key2] = value.map((w) => localizeWhere(fields, w, locale, locales));
|
|
1725
|
+
} else {
|
|
1726
|
+
out[localizePath(fields, key2, locale, locales)] = value;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return out;
|
|
1730
|
+
}
|
|
1731
|
+
function localizeSort(fields, sort, locale, locales = []) {
|
|
1732
|
+
return sort.map(
|
|
1733
|
+
(entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale, locales)}` : localizePath(fields, entry, locale, locales)
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1263
1737
|
// src/logger.ts
|
|
1264
1738
|
var consoleLogger = {
|
|
1265
1739
|
info: (message) => console.info(`[easy-cms] ${message}`),
|
|
@@ -1400,10 +1874,16 @@ function collectSlots(fields, data, bySlug, slots) {
|
|
|
1400
1874
|
if (value === null || value === void 0) continue;
|
|
1401
1875
|
if (field.type === "group") {
|
|
1402
1876
|
if (typeof value === "object") collectSlots(field.fields, value, bySlug, slots);
|
|
1403
|
-
} else if (field
|
|
1404
|
-
if (Array.isArray(value))
|
|
1405
|
-
for (const row of value)
|
|
1877
|
+
} else if (hasRows(field)) {
|
|
1878
|
+
if (Array.isArray(value)) {
|
|
1879
|
+
for (const row of value) {
|
|
1880
|
+
const fields2 = rowFields(field, row);
|
|
1881
|
+
if (fields2 && row && typeof row === "object")
|
|
1882
|
+
collectSlots(fields2, row, bySlug, slots);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1406
1885
|
} else if (field.type === "relationship" || field.type === "upload") {
|
|
1886
|
+
if (typeof value === "object" && !Array.isArray(value)) continue;
|
|
1407
1887
|
const collection2 = field.type === "upload" ? "media" : field.to;
|
|
1408
1888
|
if (!bySlug.has(collection2)) continue;
|
|
1409
1889
|
if (Array.isArray(value)) {
|
|
@@ -1618,8 +2098,19 @@ async function createEasyCMS(config, options = {}) {
|
|
|
1618
2098
|
logger,
|
|
1619
2099
|
interactive: options.interactive ?? false
|
|
1620
2100
|
});
|
|
1621
|
-
|
|
2101
|
+
const cms = new EasyCMS(resolved2, db, logger, storage);
|
|
2102
|
+
const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule);
|
|
2103
|
+
if (scheduling && options.scheduler !== false) cms.startScheduler();
|
|
2104
|
+
return cms;
|
|
1622
2105
|
}
|
|
2106
|
+
var toJob = (row) => ({
|
|
2107
|
+
id: row.id,
|
|
2108
|
+
action: row.action,
|
|
2109
|
+
runAt: String(row.runAt),
|
|
2110
|
+
state: row.state,
|
|
2111
|
+
error: row.error ?? null,
|
|
2112
|
+
author: row.author ?? null
|
|
2113
|
+
});
|
|
1623
2114
|
var EasyCMS = class {
|
|
1624
2115
|
config;
|
|
1625
2116
|
db;
|
|
@@ -1629,12 +2120,14 @@ var EasyCMS = class {
|
|
|
1629
2120
|
/** Where uploaded files are stored. */
|
|
1630
2121
|
storage;
|
|
1631
2122
|
versions;
|
|
2123
|
+
webhooks;
|
|
1632
2124
|
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
|
|
1633
2125
|
this.config = config;
|
|
1634
2126
|
this.db = db;
|
|
1635
2127
|
this.logger = logger;
|
|
1636
2128
|
this.storage = storage;
|
|
1637
2129
|
this.versions = new VersionStore(db);
|
|
2130
|
+
this.webhooks = new Webhooks(config.webhooks ?? [], logger);
|
|
1638
2131
|
this.auth = new Auth(this);
|
|
1639
2132
|
}
|
|
1640
2133
|
async find(collection2, options = {}) {
|
|
@@ -1645,12 +2138,19 @@ var EasyCMS = class {
|
|
|
1645
2138
|
if (!Number.isInteger(limit) || limit < 0)
|
|
1646
2139
|
throw new QueryError("limit must be a non-negative integer");
|
|
1647
2140
|
if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
|
|
1648
|
-
const where =
|
|
2141
|
+
const where = this.whereFor(
|
|
1649
2142
|
config,
|
|
1650
|
-
options.draft,
|
|
1651
|
-
|
|
2143
|
+
draftWhere(config, options.draft, await this.readWhere(config, guard, options.where)),
|
|
2144
|
+
options
|
|
2145
|
+
);
|
|
2146
|
+
const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
2147
|
+
const locale = this.localeOf(options);
|
|
2148
|
+
const sort = locale === void 0 ? requested : localizeSort(
|
|
2149
|
+
config.fields,
|
|
2150
|
+
requested,
|
|
2151
|
+
locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale,
|
|
2152
|
+
this.config.localization?.locales ?? []
|
|
1652
2153
|
);
|
|
1653
|
-
const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
1654
2154
|
const result = await this.db.find({ collection: collection2, where, sort, limit, page });
|
|
1655
2155
|
const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
|
|
1656
2156
|
const docs = await this.output(config, found, guard, options);
|
|
@@ -1661,7 +2161,11 @@ var EasyCMS = class {
|
|
|
1661
2161
|
const guard = guardOf(options);
|
|
1662
2162
|
const parsed = parseId(id);
|
|
1663
2163
|
if (parsed === void 0) return null;
|
|
1664
|
-
const where =
|
|
2164
|
+
const where = this.whereFor(
|
|
2165
|
+
config,
|
|
2166
|
+
draftWhere(config, options.draft, await this.readWhere(config, guard, void 0)),
|
|
2167
|
+
options
|
|
2168
|
+
);
|
|
1665
2169
|
const doc = where ? (await this.db.find({
|
|
1666
2170
|
collection: collection2,
|
|
1667
2171
|
where: andWhere(where, { id: { equals: parsed } }),
|
|
@@ -1676,10 +2180,14 @@ var EasyCMS = class {
|
|
|
1676
2180
|
}
|
|
1677
2181
|
async count(collection2, options = {}) {
|
|
1678
2182
|
const config = this.collection(collection2);
|
|
1679
|
-
const where =
|
|
2183
|
+
const where = this.whereFor(
|
|
1680
2184
|
config,
|
|
1681
|
-
|
|
1682
|
-
|
|
2185
|
+
draftWhere(
|
|
2186
|
+
config,
|
|
2187
|
+
options.draft,
|
|
2188
|
+
await this.readWhere(config, guardOf(options), options.where)
|
|
2189
|
+
),
|
|
2190
|
+
options
|
|
1683
2191
|
);
|
|
1684
2192
|
return this.db.count({ collection: collection2, where });
|
|
1685
2193
|
}
|
|
@@ -1829,7 +2337,8 @@ var EasyCMS = class {
|
|
|
1829
2337
|
collection2,
|
|
1830
2338
|
parsed,
|
|
1831
2339
|
config.drafts ? { ...data, status: "draft" } : data,
|
|
1832
|
-
options
|
|
2340
|
+
options,
|
|
2341
|
+
"restore"
|
|
1833
2342
|
);
|
|
1834
2343
|
}
|
|
1835
2344
|
/**
|
|
@@ -1861,14 +2370,34 @@ var EasyCMS = class {
|
|
|
1861
2370
|
input,
|
|
1862
2371
|
this.fieldChecker("update", guard, parsed, input)
|
|
1863
2372
|
);
|
|
1864
|
-
const
|
|
2373
|
+
const localized = this.toMaps(config, filtered, base ?? {}, options);
|
|
2374
|
+
const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
|
|
1865
2375
|
if (config.drafts) merged.status = filtered.status ?? base?.status ?? "draft";
|
|
1866
|
-
const doc = await this.previewDoc(
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
2376
|
+
const doc = await this.previewDoc(
|
|
2377
|
+
config,
|
|
2378
|
+
generateSlugs(config.fields, merged, this.config.localization),
|
|
2379
|
+
{
|
|
2380
|
+
id: parsed ?? 0,
|
|
2381
|
+
createdAt: base?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
2382
|
+
},
|
|
2383
|
+
options
|
|
2384
|
+
);
|
|
1870
2385
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
1871
|
-
return { doc: out, url: this.previewURL(config, out) };
|
|
2386
|
+
return { doc: out, url: this.previewURL(config, out, options) };
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* A token that lets a page read one document's current draft for a while (default one hour),
|
|
2390
|
+
* without a login: `GET /api/cms/:collection/:id?preview=<token>`. The admin adds one to the
|
|
2391
|
+
* preview URL as `easy-cms-preview`, for sites on another origin.
|
|
2392
|
+
*/
|
|
2393
|
+
createPreviewToken(target, options = {}) {
|
|
2394
|
+
const seconds = options.expiresIn ?? 60 * 60;
|
|
2395
|
+
const normalized = "global" in target ? target : { collection: target.collection, id: String(target.id) };
|
|
2396
|
+
return signPreviewToken(this.config.secret, normalized, Date.now() + seconds * 1e3);
|
|
2397
|
+
}
|
|
2398
|
+
/** What a preview token opens, or `null` when it is invalid or expired. */
|
|
2399
|
+
verifyPreviewToken(token) {
|
|
2400
|
+
return token ? verifyPreviewToken(this.config.secret, token) : null;
|
|
1872
2401
|
}
|
|
1873
2402
|
/** Live preview of a global. See `preview`. */
|
|
1874
2403
|
async previewGlobal(slug, data, options = {}) {
|
|
@@ -1885,20 +2414,28 @@ var EasyCMS = class {
|
|
|
1885
2414
|
const current = await this.globalDraft(config, saved);
|
|
1886
2415
|
const merged = generateSlugs(
|
|
1887
2416
|
config.fields,
|
|
1888
|
-
applyDefaults(
|
|
2417
|
+
applyDefaults(
|
|
2418
|
+
config.fields,
|
|
2419
|
+
mergeForUpdate(config.fields, current, this.toMaps(config, input, current, options)),
|
|
2420
|
+
this.config.localization
|
|
2421
|
+
),
|
|
2422
|
+
this.config.localization
|
|
1889
2423
|
);
|
|
1890
2424
|
if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
|
|
1891
|
-
const doc = await this.previewDoc(config, merged, { id: 0 });
|
|
2425
|
+
const doc = await this.previewDoc(config, merged, { id: 0 }, options);
|
|
1892
2426
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
1893
2427
|
const { id: _id, ...global2 } = out;
|
|
1894
|
-
return { doc: global2, url: this.previewURL(config, global2) };
|
|
2428
|
+
return { doc: global2, url: this.previewURL(config, global2, options) };
|
|
1895
2429
|
}
|
|
1896
2430
|
/** Coerces preview input like a save would, keeping values that would fail validation. */
|
|
1897
|
-
async previewDoc(config, data, system) {
|
|
2431
|
+
async previewDoc(config, data, system, options = {}) {
|
|
2432
|
+
const locale = this.localeOf(options);
|
|
1898
2433
|
const { data: clean } = await validateFields(config.fields, data, {
|
|
1899
2434
|
operation: "update",
|
|
1900
2435
|
root: data,
|
|
1901
|
-
skipRequired: true
|
|
2436
|
+
skipRequired: true,
|
|
2437
|
+
localization: this.config.localization,
|
|
2438
|
+
...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
|
|
1902
2439
|
});
|
|
1903
2440
|
return {
|
|
1904
2441
|
...data,
|
|
@@ -1908,10 +2445,12 @@ var EasyCMS = class {
|
|
|
1908
2445
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1909
2446
|
};
|
|
1910
2447
|
}
|
|
1911
|
-
previewURL(config, doc) {
|
|
2448
|
+
previewURL(config, doc, options) {
|
|
1912
2449
|
if (!config.preview) return null;
|
|
2450
|
+
const content = this.localeOf(options);
|
|
2451
|
+
const locale = content && content !== ALL_LOCALES ? content : this.config.admin.locale;
|
|
1913
2452
|
try {
|
|
1914
|
-
return config.preview({ doc, locale
|
|
2453
|
+
return config.preview({ doc, locale }) ?? null;
|
|
1915
2454
|
} catch (error) {
|
|
1916
2455
|
this.logger.error(`preview URL of "${config.slug}" failed: ${error.message}`);
|
|
1917
2456
|
return null;
|
|
@@ -1940,7 +2479,8 @@ var EasyCMS = class {
|
|
|
1940
2479
|
input,
|
|
1941
2480
|
this.fieldChecker("update", guard, parsed, input)
|
|
1942
2481
|
);
|
|
1943
|
-
|
|
2482
|
+
const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
|
|
2483
|
+
let merged = mergeForUpdate(config.fields, current, localized);
|
|
1944
2484
|
if (config.drafts)
|
|
1945
2485
|
merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
|
|
1946
2486
|
const base = this.hookArgs(config, guard);
|
|
@@ -1952,9 +2492,10 @@ var EasyCMS = class {
|
|
|
1952
2492
|
);
|
|
1953
2493
|
let prepared = await this.prepare(
|
|
1954
2494
|
config,
|
|
1955
|
-
generateSlugs(config.fields, merged),
|
|
2495
|
+
generateSlugs(config.fields, merged, this.config.localization),
|
|
1956
2496
|
"update",
|
|
1957
|
-
parsed
|
|
2497
|
+
parsed,
|
|
2498
|
+
options
|
|
1958
2499
|
);
|
|
1959
2500
|
prepared = await this.transform(
|
|
1960
2501
|
config.hooks?.beforeChange,
|
|
@@ -1963,7 +2504,7 @@ var EasyCMS = class {
|
|
|
1963
2504
|
prepared
|
|
1964
2505
|
);
|
|
1965
2506
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1966
|
-
if (mode
|
|
2507
|
+
if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
1967
2508
|
if (password !== void 0) {
|
|
1968
2509
|
throw new ValidationError(collection2, [
|
|
1969
2510
|
{ field: "password", message: "cannot be changed in a draft; publish instead" }
|
|
@@ -1977,6 +2518,7 @@ var EasyCMS = class {
|
|
|
1977
2518
|
previousDoc: current,
|
|
1978
2519
|
operation: "update"
|
|
1979
2520
|
});
|
|
2521
|
+
this.emit(config, "draft", draft);
|
|
1980
2522
|
const [out2] = await this.output(config, [draft], guard, {
|
|
1981
2523
|
...options,
|
|
1982
2524
|
draft: true
|
|
@@ -1998,6 +2540,7 @@ var EasyCMS = class {
|
|
|
1998
2540
|
previousDoc: current,
|
|
1999
2541
|
operation: "update"
|
|
2000
2542
|
});
|
|
2543
|
+
this.emit(config, "update", doc, existing.status);
|
|
2001
2544
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
2002
2545
|
return out;
|
|
2003
2546
|
}
|
|
@@ -2015,11 +2558,16 @@ var EasyCMS = class {
|
|
|
2015
2558
|
if (config.slug === USERS) await this.auth.revokeSessions(parsed);
|
|
2016
2559
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
2017
2560
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
2561
|
+
if (config.schedule) {
|
|
2562
|
+
for (const job of await this.pendingJobs(collection2, parsed))
|
|
2563
|
+
await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
|
|
2564
|
+
}
|
|
2018
2565
|
await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
|
|
2019
2566
|
...base,
|
|
2020
2567
|
id: parsed,
|
|
2021
2568
|
doc: existing
|
|
2022
2569
|
});
|
|
2570
|
+
this.emit(config, "delete", existing);
|
|
2023
2571
|
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
2024
2572
|
return out;
|
|
2025
2573
|
}
|
|
@@ -2029,7 +2577,10 @@ var EasyCMS = class {
|
|
|
2029
2577
|
await this.checkGlobalAccess(config, "read", guard);
|
|
2030
2578
|
const saved = await this.db.findGlobal({ slug });
|
|
2031
2579
|
const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
|
|
2032
|
-
const data = fillMissing(
|
|
2580
|
+
const data = fillMissing(
|
|
2581
|
+
config.fields,
|
|
2582
|
+
applyDefaults(config.fields, stored ?? {}, this.config.localization)
|
|
2583
|
+
);
|
|
2033
2584
|
if (!stored) {
|
|
2034
2585
|
data.updatedAt = null;
|
|
2035
2586
|
if (config.drafts) data.status = "draft";
|
|
@@ -2066,7 +2617,10 @@ var EasyCMS = class {
|
|
|
2066
2617
|
const config = await this.globalVersionTarget(slug, options);
|
|
2067
2618
|
const version = await this.versions.get(globalParent(slug), 0, versionId);
|
|
2068
2619
|
if (!version) return null;
|
|
2069
|
-
const data = fillMissing(
|
|
2620
|
+
const data = fillMissing(
|
|
2621
|
+
config.fields,
|
|
2622
|
+
applyDefaults(config.fields, version.data, this.config.localization)
|
|
2623
|
+
);
|
|
2070
2624
|
const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
|
|
2071
2625
|
...options,
|
|
2072
2626
|
draft: true
|
|
@@ -2083,7 +2637,8 @@ var EasyCMS = class {
|
|
|
2083
2637
|
return this.saveGlobal(
|
|
2084
2638
|
slug,
|
|
2085
2639
|
config.drafts ? { ...data, status: "draft" } : data,
|
|
2086
|
-
options
|
|
2640
|
+
options,
|
|
2641
|
+
"restore"
|
|
2087
2642
|
);
|
|
2088
2643
|
}
|
|
2089
2644
|
async globalVersionTarget(slug, options) {
|
|
@@ -2110,13 +2665,19 @@ var EasyCMS = class {
|
|
|
2110
2665
|
);
|
|
2111
2666
|
const existing = await this.db.findGlobal({ slug }) ?? {};
|
|
2112
2667
|
const current = await this.globalDraft(config, existing);
|
|
2668
|
+
const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
|
|
2113
2669
|
const merged = generateSlugs(
|
|
2114
2670
|
config.fields,
|
|
2115
|
-
applyDefaults(
|
|
2671
|
+
applyDefaults(
|
|
2672
|
+
config.fields,
|
|
2673
|
+
mergeForUpdate(config.fields, current, localized),
|
|
2674
|
+
this.config.localization
|
|
2675
|
+
),
|
|
2676
|
+
this.config.localization
|
|
2116
2677
|
);
|
|
2117
2678
|
if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
|
|
2118
2679
|
const base = this.hookArgs(config, guard);
|
|
2119
|
-
let prepared = await this.prepare(config, merged, "update", void 0);
|
|
2680
|
+
let prepared = await this.prepare(config, merged, "update", void 0, options);
|
|
2120
2681
|
prepared = await this.transform(
|
|
2121
2682
|
config.hooks?.beforeChange,
|
|
2122
2683
|
"data",
|
|
@@ -2124,7 +2685,7 @@ var EasyCMS = class {
|
|
|
2124
2685
|
prepared
|
|
2125
2686
|
);
|
|
2126
2687
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2127
|
-
if (mode
|
|
2688
|
+
if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
2128
2689
|
const draft = { ...prepared, updatedAt: now };
|
|
2129
2690
|
await this.saveVersion(config, globalParent(slug), 0, draft, guard);
|
|
2130
2691
|
await this.notify(config.hooks?.afterChange, "afterChange", slug, {
|
|
@@ -2133,6 +2694,7 @@ var EasyCMS = class {
|
|
|
2133
2694
|
previousDoc: current,
|
|
2134
2695
|
operation: "update"
|
|
2135
2696
|
});
|
|
2697
|
+
this.emit(config, "draft", draft);
|
|
2136
2698
|
return this.findGlobal(slug, { ...options, draft: true });
|
|
2137
2699
|
}
|
|
2138
2700
|
const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
|
|
@@ -2143,6 +2705,7 @@ var EasyCMS = class {
|
|
|
2143
2705
|
previousDoc: current,
|
|
2144
2706
|
operation: "update"
|
|
2145
2707
|
});
|
|
2708
|
+
this.emit(config, "update", doc, existing.status);
|
|
2146
2709
|
return this.findGlobal(slug, { ...options, draft: true });
|
|
2147
2710
|
}
|
|
2148
2711
|
/**
|
|
@@ -2164,9 +2727,177 @@ var EasyCMS = class {
|
|
|
2164
2727
|
return { update: await check("update"), delete: await check("delete") };
|
|
2165
2728
|
}
|
|
2166
2729
|
/** Closes the database connection. */
|
|
2730
|
+
/**
|
|
2731
|
+
* Waits until webhook deliveries in progress are done. Call it before a serverless function
|
|
2732
|
+
* returns, which may otherwise stop them.
|
|
2733
|
+
*/
|
|
2734
|
+
flushWebhooks() {
|
|
2735
|
+
return this.webhooks.flush();
|
|
2736
|
+
}
|
|
2167
2737
|
async destroy() {
|
|
2738
|
+
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
|
|
2739
|
+
await this.running;
|
|
2740
|
+
await this.webhooks.flush();
|
|
2168
2741
|
await this.db.destroy();
|
|
2169
2742
|
}
|
|
2743
|
+
// --- Scheduled publishing --------------------------------------------------
|
|
2744
|
+
schedulerTimer;
|
|
2745
|
+
running = Promise.resolve();
|
|
2746
|
+
/** Runs due jobs every `interval` ms in this process (`createEasyCMS` does this by default). */
|
|
2747
|
+
startScheduler(interval = 6e4) {
|
|
2748
|
+
if (this.schedulerTimer) return;
|
|
2749
|
+
this.schedulerTimer = setInterval(() => {
|
|
2750
|
+
this.running = this.running.then(
|
|
2751
|
+
() => this.runScheduled().catch(
|
|
2752
|
+
(error) => this.logger.error(`Scheduled jobs failed: ${error.message}`)
|
|
2753
|
+
)
|
|
2754
|
+
);
|
|
2755
|
+
}, interval);
|
|
2756
|
+
this.schedulerTimer.unref?.();
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Publishes or unpublishes documents whose scheduled time has come. Safe to call often, e.g.
|
|
2760
|
+
* from a cron job (`GET <api>/jobs/run`) where no process keeps running.
|
|
2761
|
+
*/
|
|
2762
|
+
async runScheduled(now = /* @__PURE__ */ new Date()) {
|
|
2763
|
+
if (!this.config.collections.some((c) => c.slug === SCHEDULED_JOBS))
|
|
2764
|
+
return { ran: 0, failed: 0 };
|
|
2765
|
+
const due = await this.db.find({
|
|
2766
|
+
collection: SCHEDULED_JOBS,
|
|
2767
|
+
where: {
|
|
2768
|
+
and: [{ state: { equals: "pending" } }, { runAt: { lte: now.toISOString() } }]
|
|
2769
|
+
},
|
|
2770
|
+
sort: ["runAt"],
|
|
2771
|
+
limit: 100,
|
|
2772
|
+
page: 1
|
|
2773
|
+
});
|
|
2774
|
+
let failed = 0;
|
|
2775
|
+
for (const row of due.docs) {
|
|
2776
|
+
const parent = String(row.parent);
|
|
2777
|
+
const action = row.action;
|
|
2778
|
+
try {
|
|
2779
|
+
if (parent.startsWith("global:")) {
|
|
2780
|
+
const slug = parent.slice("global:".length);
|
|
2781
|
+
if (action === "publish") await this.saveGlobal(slug, { status: "published" }, {});
|
|
2782
|
+
else await this.saveGlobal(slug, { status: "draft" }, {}, "unpublish");
|
|
2783
|
+
} else if (action === "publish") {
|
|
2784
|
+
await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
|
|
2785
|
+
} else {
|
|
2786
|
+
await this.updateDocument(
|
|
2787
|
+
parent,
|
|
2788
|
+
row.doc,
|
|
2789
|
+
{ status: "draft" },
|
|
2790
|
+
{ depth: 0 },
|
|
2791
|
+
"unpublish"
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
await this.db.update({
|
|
2795
|
+
collection: SCHEDULED_JOBS,
|
|
2796
|
+
id: row.id,
|
|
2797
|
+
data: { ...row, state: "done" }
|
|
2798
|
+
});
|
|
2799
|
+
} catch (error) {
|
|
2800
|
+
failed++;
|
|
2801
|
+
const message = error.message;
|
|
2802
|
+
this.logger.error(`Scheduled ${action} of ${parent} ${row.doc} failed: ${message}`);
|
|
2803
|
+
await this.db.update({
|
|
2804
|
+
collection: SCHEDULED_JOBS,
|
|
2805
|
+
id: row.id,
|
|
2806
|
+
data: { ...row, state: "failed", error: message.slice(0, 500) }
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
return { ran: due.docs.length - failed, failed };
|
|
2811
|
+
}
|
|
2812
|
+
/** Schedules a publish or unpublish, replacing a pending job with the same action. */
|
|
2813
|
+
async schedule(collection2, id, job, options = {}) {
|
|
2814
|
+
const { parsed } = await this.scheduleTarget(collection2, id, options);
|
|
2815
|
+
return this.addJob(collection2, parsed, job, options);
|
|
2816
|
+
}
|
|
2817
|
+
/** Pending jobs of a document, soonest first. */
|
|
2818
|
+
async scheduled(collection2, id, options = {}) {
|
|
2819
|
+
const { parsed } = await this.scheduleTarget(collection2, id, options);
|
|
2820
|
+
return this.pendingJobs(collection2, parsed);
|
|
2821
|
+
}
|
|
2822
|
+
async cancelSchedule(collection2, id, jobId, options = {}) {
|
|
2823
|
+
const { parsed } = await this.scheduleTarget(collection2, id, options);
|
|
2824
|
+
await this.removeJob(collection2, parsed, jobId);
|
|
2825
|
+
}
|
|
2826
|
+
async scheduleGlobal(slug, job, options = {}) {
|
|
2827
|
+
await this.globalScheduleTarget(slug, options);
|
|
2828
|
+
return this.addJob(`global:${slug}`, 0, job, options);
|
|
2829
|
+
}
|
|
2830
|
+
async scheduledGlobal(slug, options = {}) {
|
|
2831
|
+
await this.globalScheduleTarget(slug, options);
|
|
2832
|
+
return this.pendingJobs(`global:${slug}`, 0);
|
|
2833
|
+
}
|
|
2834
|
+
async cancelGlobalSchedule(slug, jobId, options = {}) {
|
|
2835
|
+
await this.globalScheduleTarget(slug, options);
|
|
2836
|
+
await this.removeJob(`global:${slug}`, 0, jobId);
|
|
2837
|
+
}
|
|
2838
|
+
async scheduleTarget(collection2, id, options) {
|
|
2839
|
+
const config = this.collection(collection2);
|
|
2840
|
+
if (!config.schedule) throw new QueryError(`"${collection2}" has no schedule`);
|
|
2841
|
+
const parsed = parseId(id);
|
|
2842
|
+
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
2843
|
+
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
2844
|
+
await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
|
|
2845
|
+
return { config, parsed };
|
|
2846
|
+
}
|
|
2847
|
+
async globalScheduleTarget(slug, options) {
|
|
2848
|
+
const config = this.global(slug);
|
|
2849
|
+
if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
|
|
2850
|
+
await this.checkGlobalAccess(config, "update", guardOf(options));
|
|
2851
|
+
}
|
|
2852
|
+
async addJob(parent, doc, job, options) {
|
|
2853
|
+
const runAt = new Date(job.at);
|
|
2854
|
+
const errors = [];
|
|
2855
|
+
if (job.action !== "publish" && job.action !== "unpublish")
|
|
2856
|
+
errors.push({ field: "action", message: 'must be "publish" or "unpublish"' });
|
|
2857
|
+
if (Number.isNaN(runAt.getTime())) errors.push({ field: "at", message: "must be a valid date" });
|
|
2858
|
+
if (errors.length > 0) throw new ValidationError(SCHEDULED_JOBS, errors);
|
|
2859
|
+
for (const pending of await this.pendingJobs(parent, doc)) {
|
|
2860
|
+
if (pending.action === job.action)
|
|
2861
|
+
await this.db.delete({ collection: SCHEDULED_JOBS, id: pending.id });
|
|
2862
|
+
}
|
|
2863
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2864
|
+
const row = await this.db.create({
|
|
2865
|
+
collection: SCHEDULED_JOBS,
|
|
2866
|
+
data: {
|
|
2867
|
+
parent,
|
|
2868
|
+
doc,
|
|
2869
|
+
action: job.action,
|
|
2870
|
+
runAt: runAt.toISOString(),
|
|
2871
|
+
state: "pending",
|
|
2872
|
+
error: null,
|
|
2873
|
+
author: guardOf(options).user?.id ?? null,
|
|
2874
|
+
createdAt: now,
|
|
2875
|
+
updatedAt: now
|
|
2876
|
+
}
|
|
2877
|
+
});
|
|
2878
|
+
return toJob(row);
|
|
2879
|
+
}
|
|
2880
|
+
async pendingJobs(parent, doc) {
|
|
2881
|
+
const rows = await this.db.find({
|
|
2882
|
+
collection: SCHEDULED_JOBS,
|
|
2883
|
+
where: {
|
|
2884
|
+
and: [
|
|
2885
|
+
{ parent: { equals: parent } },
|
|
2886
|
+
{ doc: { equals: doc } },
|
|
2887
|
+
{ state: { equals: "pending" } }
|
|
2888
|
+
]
|
|
2889
|
+
},
|
|
2890
|
+
sort: ["runAt"],
|
|
2891
|
+
limit: 0,
|
|
2892
|
+
page: 1
|
|
2893
|
+
});
|
|
2894
|
+
return rows.docs.map(toJob);
|
|
2895
|
+
}
|
|
2896
|
+
async removeJob(parent, doc, jobId) {
|
|
2897
|
+
const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
|
|
2898
|
+
if (!job) throw new NotFoundError("scheduled job", jobId);
|
|
2899
|
+
await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
|
|
2900
|
+
}
|
|
2170
2901
|
// -------------------------------------------------------------------------
|
|
2171
2902
|
/** @internal */
|
|
2172
2903
|
collection(slug) {
|
|
@@ -2195,11 +2926,11 @@ var EasyCMS = class {
|
|
|
2195
2926
|
});
|
|
2196
2927
|
if (access === true) return;
|
|
2197
2928
|
if (access !== false) {
|
|
2198
|
-
const
|
|
2929
|
+
const matches2 = await this.db.count({
|
|
2199
2930
|
collection: config.slug,
|
|
2200
2931
|
where: andWhere(access, { id: { equals: id } })
|
|
2201
2932
|
});
|
|
2202
|
-
if (
|
|
2933
|
+
if (matches2 > 0) return;
|
|
2203
2934
|
}
|
|
2204
2935
|
throw deny(guard.user);
|
|
2205
2936
|
}
|
|
@@ -2238,16 +2969,19 @@ var EasyCMS = class {
|
|
|
2238
2969
|
if (where === null) return [];
|
|
2239
2970
|
const found = await this.db.find({
|
|
2240
2971
|
collection: target.slug,
|
|
2241
|
-
where: draftWhere(target, options.draft, where),
|
|
2972
|
+
where: this.whereFor(target, draftWhere(target, options.draft, where), options),
|
|
2242
2973
|
sort: [],
|
|
2243
2974
|
limit: 0,
|
|
2244
2975
|
page: 1
|
|
2245
2976
|
});
|
|
2246
2977
|
const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
|
|
2247
|
-
return Promise.all(
|
|
2978
|
+
return Promise.all(
|
|
2979
|
+
docs2.map((d) => finish(target, this.pick(target, d, options)))
|
|
2980
|
+
);
|
|
2248
2981
|
};
|
|
2249
2982
|
const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
|
|
2250
|
-
const
|
|
2983
|
+
const localized = docs.map((d) => this.pick(config, d, options));
|
|
2984
|
+
const populated = await populate(load, this.config.collections, config.fields, localized, depth);
|
|
2251
2985
|
return Promise.all(populated.map((d) => finish(config, d)));
|
|
2252
2986
|
}
|
|
2253
2987
|
/** Creates a document: access, input filtering, hooks, validation, then afterChange. */
|
|
@@ -2270,7 +3004,11 @@ var EasyCMS = class {
|
|
|
2270
3004
|
this.fieldChecker("update", guard, void 0, input)
|
|
2271
3005
|
);
|
|
2272
3006
|
const base = this.hookArgs(config, hookGuard ?? guard);
|
|
2273
|
-
let data = applyDefaults(
|
|
3007
|
+
let data = applyDefaults(
|
|
3008
|
+
config.fields,
|
|
3009
|
+
this.toMaps(config, filtered, {}, options),
|
|
3010
|
+
this.config.localization
|
|
3011
|
+
);
|
|
2274
3012
|
data = await this.transform(
|
|
2275
3013
|
config.hooks?.beforeValidate,
|
|
2276
3014
|
"data",
|
|
@@ -2279,9 +3017,10 @@ var EasyCMS = class {
|
|
|
2279
3017
|
);
|
|
2280
3018
|
let prepared = await this.prepare(
|
|
2281
3019
|
config,
|
|
2282
|
-
generateSlugs(config.fields, data),
|
|
3020
|
+
generateSlugs(config.fields, data, this.config.localization),
|
|
2283
3021
|
"create",
|
|
2284
|
-
void 0
|
|
3022
|
+
void 0,
|
|
3023
|
+
options
|
|
2285
3024
|
);
|
|
2286
3025
|
prepared = await this.transform(
|
|
2287
3026
|
config.hooks?.beforeChange,
|
|
@@ -2301,9 +3040,48 @@ var EasyCMS = class {
|
|
|
2301
3040
|
doc,
|
|
2302
3041
|
operation: "create"
|
|
2303
3042
|
});
|
|
3043
|
+
this.emit(config, "create", doc);
|
|
2304
3044
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
2305
3045
|
return out;
|
|
2306
3046
|
}
|
|
3047
|
+
/** The locale for an operation; `undefined` without localization. Throws on unknown locales. */
|
|
3048
|
+
localeOf(options) {
|
|
3049
|
+
const localization = this.config.localization;
|
|
3050
|
+
if (!localization) return void 0;
|
|
3051
|
+
const locale = options.locale ?? localization.defaultLocale;
|
|
3052
|
+
if (locale !== ALL_LOCALES && !localization.locales.includes(locale)) {
|
|
3053
|
+
throw new QueryError(
|
|
3054
|
+
`Unknown locale "${locale}" (use one of: ${localization.locales.join(", ")}, ${ALL_LOCALES})`
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
return locale;
|
|
3058
|
+
}
|
|
3059
|
+
/** Stored localized maps → the values of the requested locale. */
|
|
3060
|
+
pick(config, doc, options) {
|
|
3061
|
+
const localization = this.config.localization;
|
|
3062
|
+
const locale = this.localeOf(options);
|
|
3063
|
+
if (!localization || locale === void 0) return doc;
|
|
3064
|
+
const fallback = options.fallbackLocale ?? localization.fallback;
|
|
3065
|
+
return pickLocale(config.fields, doc, locale, localization, fallback);
|
|
3066
|
+
}
|
|
3067
|
+
/** Input for one locale → stored localized maps, keeping the other locales from `current`. */
|
|
3068
|
+
toMaps(config, input, current, options) {
|
|
3069
|
+
const locale = this.localeOf(options);
|
|
3070
|
+
if (locale === void 0 || locale === ALL_LOCALES) return input;
|
|
3071
|
+
return toLocaleMaps(config.fields, input, current, locale);
|
|
3072
|
+
}
|
|
3073
|
+
/** Makes `where` match localized fields in the operation's locale. */
|
|
3074
|
+
whereFor(config, where, options) {
|
|
3075
|
+
const locale = this.localeOf(options);
|
|
3076
|
+
const localization = this.config.localization;
|
|
3077
|
+
if (locale === void 0 || !localization) return where;
|
|
3078
|
+
return localizeWhere(
|
|
3079
|
+
config.fields,
|
|
3080
|
+
where,
|
|
3081
|
+
locale === ALL_LOCALES ? localization.defaultLocale : locale,
|
|
3082
|
+
localization.locales
|
|
3083
|
+
);
|
|
3084
|
+
}
|
|
2307
3085
|
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
2308
3086
|
separateDrafts(config) {
|
|
2309
3087
|
return config.drafts === true && versionLimit(config) !== void 0;
|
|
@@ -2318,6 +3096,24 @@ var EasyCMS = class {
|
|
|
2318
3096
|
return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
|
|
2319
3097
|
});
|
|
2320
3098
|
}
|
|
3099
|
+
/** Sends webhook events for a change; `before` is the stored status before it, if any. */
|
|
3100
|
+
emit(config, event, doc, before) {
|
|
3101
|
+
const isGlobal = !this.config.collections.includes(config);
|
|
3102
|
+
if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
|
|
3103
|
+
const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
|
|
3104
|
+
id: doc.id,
|
|
3105
|
+
...snapshotOf(config.fields, doc),
|
|
3106
|
+
createdAt: doc.createdAt,
|
|
3107
|
+
updatedAt: doc.updatedAt
|
|
3108
|
+
};
|
|
3109
|
+
const target = isGlobal ? { global: config.slug } : { collection: config.slug, id: doc.id };
|
|
3110
|
+
this.webhooks.emit(event, target, body);
|
|
3111
|
+
if (event === "draft" || event === "delete" || !config.drafts) return;
|
|
3112
|
+
const after = doc.status;
|
|
3113
|
+
if (after === "published" && before !== "published") this.webhooks.emit("publish", target, body);
|
|
3114
|
+
if (after !== "published" && before === "published")
|
|
3115
|
+
this.webhooks.emit("unpublish", target, body);
|
|
3116
|
+
}
|
|
2321
3117
|
/** Records a version after a save, when the collection or global keeps versions. */
|
|
2322
3118
|
async saveVersion(config, parent, id, doc, guard) {
|
|
2323
3119
|
const max = versionLimit(config);
|
|
@@ -2394,12 +3190,15 @@ var EasyCMS = class {
|
|
|
2394
3190
|
return result;
|
|
2395
3191
|
}
|
|
2396
3192
|
/** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
|
|
2397
|
-
async prepare(config, data, operation, selfId) {
|
|
3193
|
+
async prepare(config, data, operation, selfId, options = {}) {
|
|
2398
3194
|
const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
|
|
3195
|
+
const locale = this.localeOf(options);
|
|
2399
3196
|
const result = await validateFields(config.fields, data, {
|
|
2400
3197
|
operation,
|
|
2401
3198
|
root: data,
|
|
2402
|
-
skipRequired: isDraft
|
|
3199
|
+
skipRequired: isDraft,
|
|
3200
|
+
localization: this.config.localization,
|
|
3201
|
+
...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
|
|
2403
3202
|
});
|
|
2404
3203
|
const errors = [...result.errors];
|
|
2405
3204
|
const clean = { ...result.data };
|
|
@@ -2441,14 +3240,26 @@ var EasyCMS = class {
|
|
|
2441
3240
|
}
|
|
2442
3241
|
}
|
|
2443
3242
|
async makeSlugsUnique(config, data, selfId) {
|
|
2444
|
-
|
|
2445
|
-
const base = data[field.name];
|
|
2446
|
-
if (field.type !== "slug" || typeof base !== "string" || base === "") continue;
|
|
3243
|
+
const unique = async (path, base) => {
|
|
2447
3244
|
let candidate = base;
|
|
2448
|
-
for (let n = 2; await this.isTaken(config.slug,
|
|
3245
|
+
for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId); n++) {
|
|
2449
3246
|
candidate = `${base}-${n}`;
|
|
2450
3247
|
}
|
|
2451
|
-
|
|
3248
|
+
return candidate;
|
|
3249
|
+
};
|
|
3250
|
+
for (const field of config.fields) {
|
|
3251
|
+
if (field.type !== "slug") continue;
|
|
3252
|
+
const value = data[field.name];
|
|
3253
|
+
if (field.localized && value && typeof value === "object") {
|
|
3254
|
+
const map = { ...value };
|
|
3255
|
+
for (const [locale, slug] of Object.entries(map)) {
|
|
3256
|
+
if (typeof slug === "string" && slug !== "")
|
|
3257
|
+
map[locale] = await unique(`${field.name}.${locale}`, slug);
|
|
3258
|
+
}
|
|
3259
|
+
data[field.name] = map;
|
|
3260
|
+
} else if (typeof value === "string" && value !== "") {
|
|
3261
|
+
data[field.name] = await unique(field.name, value);
|
|
3262
|
+
}
|
|
2452
3263
|
}
|
|
2453
3264
|
}
|
|
2454
3265
|
async checkUnique(config, data, selfId) {
|
|
@@ -2456,8 +3267,16 @@ var EasyCMS = class {
|
|
|
2456
3267
|
for (const field of config.fields) {
|
|
2457
3268
|
const value = data[field.name];
|
|
2458
3269
|
if (!field.unique || field.type === "slug" || value === null || value === void 0) continue;
|
|
2459
|
-
|
|
2460
|
-
|
|
3270
|
+
const checks = field.localized && typeof value === "object" ? Object.entries(value).map(([locale, v]) => [
|
|
3271
|
+
`${field.name}.${locale}`,
|
|
3272
|
+
locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
|
|
3273
|
+
v
|
|
3274
|
+
]) : [[field.name, field.name, value]];
|
|
3275
|
+
for (const [path, errorField, v] of checks) {
|
|
3276
|
+
if (v === null || v === void 0) continue;
|
|
3277
|
+
if (await this.isTaken(config.slug, path, v, selfId)) {
|
|
3278
|
+
errors.push({ field: errorField, message: "must be unique" });
|
|
3279
|
+
}
|
|
2461
3280
|
}
|
|
2462
3281
|
}
|
|
2463
3282
|
return errors;
|
|
@@ -2537,7 +3356,7 @@ function asObject(data, name) {
|
|
|
2537
3356
|
async function allowed(access, user) {
|
|
2538
3357
|
return await evaluateAccess(access, { user }) !== false;
|
|
2539
3358
|
}
|
|
2540
|
-
async function serializeFields(fields, update) {
|
|
3359
|
+
async function serializeFields(fields, update, localized) {
|
|
2541
3360
|
const out = [];
|
|
2542
3361
|
for (const field of fields) {
|
|
2543
3362
|
if (field.hidden) continue;
|
|
@@ -2547,6 +3366,7 @@ async function serializeFields(fields, update) {
|
|
|
2547
3366
|
if (field.unique) f.unique = true;
|
|
2548
3367
|
if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
|
|
2549
3368
|
if (!await update.allows(field)) f.readOnly = true;
|
|
3369
|
+
if (field.localized && localized) f.localized = true;
|
|
2550
3370
|
switch (field.type) {
|
|
2551
3371
|
case "text":
|
|
2552
3372
|
case "textarea":
|
|
@@ -2576,23 +3396,39 @@ async function serializeFields(fields, update) {
|
|
|
2576
3396
|
case "array":
|
|
2577
3397
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
2578
3398
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
2579
|
-
f.fields = await serializeFields(field.fields, update);
|
|
3399
|
+
f.fields = await serializeFields(field.fields, update, localized);
|
|
3400
|
+
break;
|
|
3401
|
+
case "blocks":
|
|
3402
|
+
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
3403
|
+
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
3404
|
+
f.blocks = await Promise.all(
|
|
3405
|
+
field.blocks.map(async (block) => ({
|
|
3406
|
+
slug: block.slug,
|
|
3407
|
+
...block.labels ? { labels: block.labels } : {},
|
|
3408
|
+
fields: await serializeFields(block.fields, update, localized)
|
|
3409
|
+
}))
|
|
3410
|
+
);
|
|
2580
3411
|
break;
|
|
2581
3412
|
case "group":
|
|
2582
|
-
f.fields = await serializeFields(field.fields, update);
|
|
3413
|
+
f.fields = await serializeFields(field.fields, update, localized);
|
|
2583
3414
|
break;
|
|
2584
3415
|
}
|
|
2585
3416
|
out.push(f);
|
|
2586
3417
|
}
|
|
2587
3418
|
return out;
|
|
2588
3419
|
}
|
|
2589
|
-
async function collection(config, user) {
|
|
3420
|
+
async function collection(config, user, localized) {
|
|
2590
3421
|
const result = {
|
|
2591
3422
|
slug: config.slug,
|
|
2592
3423
|
drafts: config.drafts === true,
|
|
2593
3424
|
versions: Boolean(config.versions),
|
|
2594
3425
|
preview: typeof config.preview === "function",
|
|
2595
|
-
|
|
3426
|
+
schedule: config.schedule === true,
|
|
3427
|
+
fields: await serializeFields(
|
|
3428
|
+
config.fields,
|
|
3429
|
+
new FieldAccessChecker("update", { user }),
|
|
3430
|
+
localized
|
|
3431
|
+
),
|
|
2596
3432
|
permissions: {
|
|
2597
3433
|
read: await allowed(config.access?.read, user),
|
|
2598
3434
|
create: await allowed(config.access?.create, user),
|
|
@@ -2604,13 +3440,18 @@ async function collection(config, user) {
|
|
|
2604
3440
|
if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
|
|
2605
3441
|
return result;
|
|
2606
3442
|
}
|
|
2607
|
-
async function global(config, user) {
|
|
3443
|
+
async function global(config, user, localized) {
|
|
2608
3444
|
const result = {
|
|
2609
3445
|
slug: config.slug,
|
|
2610
3446
|
drafts: config.drafts === true,
|
|
2611
3447
|
versions: Boolean(config.versions),
|
|
2612
3448
|
preview: typeof config.preview === "function",
|
|
2613
|
-
|
|
3449
|
+
schedule: config.schedule === true,
|
|
3450
|
+
fields: await serializeFields(
|
|
3451
|
+
config.fields,
|
|
3452
|
+
new FieldAccessChecker("update", { user }),
|
|
3453
|
+
localized
|
|
3454
|
+
),
|
|
2614
3455
|
permissions: {
|
|
2615
3456
|
read: await allowed(config.access?.read, user),
|
|
2616
3457
|
update: await allowed(config.access?.update, user)
|
|
@@ -2621,10 +3462,13 @@ async function global(config, user) {
|
|
|
2621
3462
|
}
|
|
2622
3463
|
async function adminSchema(cms, user) {
|
|
2623
3464
|
const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
|
|
3465
|
+
const localization = cms.config.localization;
|
|
3466
|
+
const localized = localization !== null;
|
|
2624
3467
|
return {
|
|
2625
3468
|
locale: cms.config.admin.locale,
|
|
2626
|
-
|
|
2627
|
-
|
|
3469
|
+
localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
|
|
3470
|
+
collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
|
|
3471
|
+
globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized)))
|
|
2628
3472
|
};
|
|
2629
3473
|
}
|
|
2630
3474
|
|
|
@@ -2795,7 +3639,7 @@ function withHeaders(response, extra) {
|
|
|
2795
3639
|
}
|
|
2796
3640
|
async function route(cms, ctx, method, segments, options) {
|
|
2797
3641
|
const [first, second, third] = segments;
|
|
2798
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3642
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2799
3643
|
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
2800
3644
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
2801
3645
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
@@ -2841,6 +3685,16 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2841
3685
|
}
|
|
2842
3686
|
throw new HttpError("Not found", 404);
|
|
2843
3687
|
}
|
|
3688
|
+
if (first === "jobs" && second === "run" && third === void 0) {
|
|
3689
|
+
if (method !== "GET" && method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
|
|
3690
|
+
const secret = cms.config.cronSecret ?? process.env.CRON_SECRET;
|
|
3691
|
+
const header = ctx.request.headers.get("authorization") ?? "";
|
|
3692
|
+
const byCron = !!secret && safeEqual(header, `Bearer ${secret}`);
|
|
3693
|
+
if (!byCron && ctx.user?.role !== "admin") {
|
|
3694
|
+
throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
|
|
3695
|
+
}
|
|
3696
|
+
return { body: await cms.runScheduled() };
|
|
3697
|
+
}
|
|
2844
3698
|
if (first === "admin") {
|
|
2845
3699
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
2846
3700
|
if (!ctx.user) throw new UnauthorizedError();
|
|
@@ -2860,8 +3714,19 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2860
3714
|
if (!cms.config.globals.some((g) => g.slug === second))
|
|
2861
3715
|
throw new HttpError(`Unknown global "${second}"`, 404);
|
|
2862
3716
|
if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
|
|
2863
|
-
if (method === "GET")
|
|
3717
|
+
if (method === "GET") {
|
|
3718
|
+
if (ctx.url.searchParams.has("preview")) {
|
|
3719
|
+
requirePreview(cms, ctx, { global: second });
|
|
3720
|
+
return {
|
|
3721
|
+
body: await cms.findGlobal(second, {
|
|
3722
|
+
...parseDepth(ctx.url),
|
|
3723
|
+
...parseLocale(ctx.url),
|
|
3724
|
+
draft: true
|
|
3725
|
+
})
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
2864
3728
|
return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
|
|
3729
|
+
}
|
|
2865
3730
|
if (method === "POST") {
|
|
2866
3731
|
const body = await readJson(ctx.request);
|
|
2867
3732
|
return { body: await cms.updateGlobal(second, body, { ...access, ...parseDepth(ctx.url) }) };
|
|
@@ -2908,6 +3773,16 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2908
3773
|
throw methodNotAllowed(ctx, "GET, POST");
|
|
2909
3774
|
}
|
|
2910
3775
|
const id = second;
|
|
3776
|
+
if (method === "GET" && ctx.url.searchParams.has("preview")) {
|
|
3777
|
+
requirePreview(cms, ctx, { collection: collection2, id });
|
|
3778
|
+
const doc = await cms.findById(collection2, id, {
|
|
3779
|
+
...parseDepth(ctx.url),
|
|
3780
|
+
...parseLocale(ctx.url),
|
|
3781
|
+
draft: true
|
|
3782
|
+
});
|
|
3783
|
+
if (!doc) throw new NotFoundError(collection2, id);
|
|
3784
|
+
return { body: doc };
|
|
3785
|
+
}
|
|
2911
3786
|
if (method === "GET") {
|
|
2912
3787
|
const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
|
|
2913
3788
|
if (!doc) throw new NotFoundError(collection2, id);
|
|
@@ -2927,7 +3802,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2927
3802
|
throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
|
|
2928
3803
|
}
|
|
2929
3804
|
async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
2930
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3805
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2931
3806
|
const depth = parseDepth(ctx.url);
|
|
2932
3807
|
const [action, versionId, extra] = path;
|
|
2933
3808
|
if (action === "versions" && versionId === void 0) {
|
|
@@ -2944,10 +3819,26 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
2944
3819
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2945
3820
|
return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
|
|
2946
3821
|
}
|
|
3822
|
+
if (action === "schedule" && path.length <= 2) {
|
|
3823
|
+
if (path.length === 2) {
|
|
3824
|
+
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
3825
|
+
await cms.cancelSchedule(collection2, id, versionId, access);
|
|
3826
|
+
return { body: { message: "Cancelled" } };
|
|
3827
|
+
}
|
|
3828
|
+
if (method === "GET") return { body: await cms.scheduled(collection2, id, access) };
|
|
3829
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
|
|
3830
|
+
const body = await readJson(ctx.request);
|
|
3831
|
+
return {
|
|
3832
|
+
status: 201,
|
|
3833
|
+
body: await cms.schedule(collection2, id, scheduleJob(body), access)
|
|
3834
|
+
};
|
|
3835
|
+
}
|
|
2947
3836
|
if (path.length === 1 && action === "preview") {
|
|
2948
3837
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2949
3838
|
const body = await readJson(ctx.request);
|
|
2950
|
-
|
|
3839
|
+
const result = await cms.preview(collection2, id, body, { ...access, ...depth });
|
|
3840
|
+
const token = cms.createPreviewToken({ collection: collection2, id });
|
|
3841
|
+
return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
|
|
2951
3842
|
}
|
|
2952
3843
|
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2953
3844
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
@@ -2959,7 +3850,7 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
2959
3850
|
throw new HttpError("Not found", 404);
|
|
2960
3851
|
}
|
|
2961
3852
|
async function globalAction(cms, ctx, method, slug, path) {
|
|
2962
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3853
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2963
3854
|
const depth = parseDepth(ctx.url);
|
|
2964
3855
|
const [action, versionId, extra] = path;
|
|
2965
3856
|
if (action === "versions" && versionId === void 0) {
|
|
@@ -2976,10 +3867,23 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
2976
3867
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2977
3868
|
return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
|
|
2978
3869
|
}
|
|
3870
|
+
if (action === "schedule" && path.length <= 2) {
|
|
3871
|
+
if (path.length === 2) {
|
|
3872
|
+
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
3873
|
+
await cms.cancelGlobalSchedule(slug, versionId, access);
|
|
3874
|
+
return { body: { message: "Cancelled" } };
|
|
3875
|
+
}
|
|
3876
|
+
if (method === "GET") return { body: await cms.scheduledGlobal(slug, access) };
|
|
3877
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
|
|
3878
|
+
const body = await readJson(ctx.request);
|
|
3879
|
+
return { status: 201, body: await cms.scheduleGlobal(slug, scheduleJob(body), access) };
|
|
3880
|
+
}
|
|
2979
3881
|
if (path.length === 1 && action === "preview") {
|
|
2980
3882
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2981
3883
|
const body = await readJson(ctx.request);
|
|
2982
|
-
|
|
3884
|
+
const result = await cms.previewGlobal(slug, body, { ...access, ...depth });
|
|
3885
|
+
const token = cms.createPreviewToken({ global: slug });
|
|
3886
|
+
return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
|
|
2983
3887
|
}
|
|
2984
3888
|
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2985
3889
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
@@ -2990,6 +3894,32 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
2990
3894
|
}
|
|
2991
3895
|
throw new HttpError("Not found", 404);
|
|
2992
3896
|
}
|
|
3897
|
+
function scheduleJob(body) {
|
|
3898
|
+
return {
|
|
3899
|
+
action: body.action,
|
|
3900
|
+
at: typeof body.at === "string" ? body.at : ""
|
|
3901
|
+
};
|
|
3902
|
+
}
|
|
3903
|
+
function parseLocale(url) {
|
|
3904
|
+
const locale = url.searchParams.get("locale");
|
|
3905
|
+
const fallback = url.searchParams.get("fallback-locale");
|
|
3906
|
+
return {
|
|
3907
|
+
...locale ? { locale } : {},
|
|
3908
|
+
...fallback === "false" || fallback === "true" ? { fallbackLocale: fallback === "true" } : {}
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
var PREVIEW_PARAM = "easy-cms-preview";
|
|
3912
|
+
function withPreviewToken(url, token) {
|
|
3913
|
+
const hash = url.indexOf("#");
|
|
3914
|
+
const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
|
|
3915
|
+
const separator = base.includes("?") ? "&" : "?";
|
|
3916
|
+
return `${base}${separator}${PREVIEW_PARAM}=${encodeURIComponent(token)}${fragment}`;
|
|
3917
|
+
}
|
|
3918
|
+
function requirePreview(cms, ctx, target) {
|
|
3919
|
+
const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
|
|
3920
|
+
const matches2 = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
|
|
3921
|
+
if (!matches2) throw new UnauthorizedError("Invalid or expired preview token");
|
|
3922
|
+
}
|
|
2993
3923
|
function parsePage(url) {
|
|
2994
3924
|
const { page, limit } = parseListQuery(url);
|
|
2995
3925
|
return { page, limit };
|
|
@@ -3204,6 +4134,16 @@ ${indent}}`;
|
|
|
3204
4134
|
${indent} id: string
|
|
3205
4135
|
${fieldLines(field.fields, n, `${indent} `).join("\n")}
|
|
3206
4136
|
${indent}}[]`;
|
|
4137
|
+
case "blocks": {
|
|
4138
|
+
const kinds = field.blocks.map(
|
|
4139
|
+
(block) => `{
|
|
4140
|
+
${indent} id: string
|
|
4141
|
+
${indent} blockType: ${quote(block.slug)}
|
|
4142
|
+
${fieldLines(block.fields, n, `${indent} `).join("\n")}
|
|
4143
|
+
${indent}}`
|
|
4144
|
+
);
|
|
4145
|
+
return `(${kinds.join(" | ")})[]`;
|
|
4146
|
+
}
|
|
3207
4147
|
}
|
|
3208
4148
|
}
|
|
3209
4149
|
function fieldLines(fields, n, indent) {
|
|
@@ -3211,7 +4151,7 @@ function fieldLines(fields, n, indent) {
|
|
|
3211
4151
|
for (const field of fields) {
|
|
3212
4152
|
if (field.hidden) continue;
|
|
3213
4153
|
const type = fieldType(field, n, indent);
|
|
3214
|
-
const alwaysPresent = field.type === "array" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
|
|
4154
|
+
const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
|
|
3215
4155
|
if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
|
|
3216
4156
|
else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
|
|
3217
4157
|
}
|
|
@@ -3319,6 +4259,7 @@ export {
|
|
|
3319
4259
|
TooManyRequestsError,
|
|
3320
4260
|
UnauthorizedError,
|
|
3321
4261
|
ValidationError,
|
|
4262
|
+
WEBHOOK_EVENTS,
|
|
3322
4263
|
anyone,
|
|
3323
4264
|
applyDefaults,
|
|
3324
4265
|
consoleLogger,
|
|
@@ -3329,6 +4270,7 @@ export {
|
|
|
3329
4270
|
findConfigFile,
|
|
3330
4271
|
generateSlugs,
|
|
3331
4272
|
generateTypes,
|
|
4273
|
+
hasRows,
|
|
3332
4274
|
hashPassword,
|
|
3333
4275
|
imageDimensions,
|
|
3334
4276
|
importConfig,
|
|
@@ -3342,6 +4284,7 @@ export {
|
|
|
3342
4284
|
populate,
|
|
3343
4285
|
readCookie,
|
|
3344
4286
|
resolveConfig,
|
|
4287
|
+
rowFields,
|
|
3345
4288
|
silentLogger,
|
|
3346
4289
|
singularize,
|
|
3347
4290
|
slugify,
|