@easy-cms/core 0.5.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 +170 -10
- package/dist/index.js +593 -79
- 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";
|
|
@@ -654,11 +699,12 @@ function applyDefaults(fields, data, localization = null) {
|
|
|
654
699
|
isPlainObject(value) ? value : {},
|
|
655
700
|
localization
|
|
656
701
|
);
|
|
657
|
-
} else if (field
|
|
702
|
+
} else if (hasRows(field) && !field.localized) {
|
|
658
703
|
if (Array.isArray(value)) {
|
|
659
|
-
result[field.name] = value.map(
|
|
660
|
-
|
|
661
|
-
|
|
704
|
+
result[field.name] = value.map((row) => {
|
|
705
|
+
const fields2 = rowFields(field, row);
|
|
706
|
+
return fields2 && isPlainObject(row) ? applyDefaults(fields2, row, localization) : row;
|
|
707
|
+
});
|
|
662
708
|
}
|
|
663
709
|
} else if (field.localized && localization && field.defaultValue !== void 0) {
|
|
664
710
|
const map = isPlainObject(value) ? value : {};
|
|
@@ -681,7 +727,7 @@ function fillMissing(fields, data) {
|
|
|
681
727
|
if (field.type === "group") {
|
|
682
728
|
result[field.name] = fillMissing(field.fields, isPlainObject(value) ? value : {});
|
|
683
729
|
} else if (value === void 0) {
|
|
684
|
-
result[field.name] = field
|
|
730
|
+
result[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
|
|
685
731
|
}
|
|
686
732
|
}
|
|
687
733
|
return result;
|
|
@@ -711,10 +757,11 @@ function generateSlugs(fields, data, localization = null) {
|
|
|
711
757
|
}
|
|
712
758
|
} else if (field.type === "group" && isPlainObject(result[field.name])) {
|
|
713
759
|
result[field.name] = generateSlugs(field.fields, result[field.name], localization);
|
|
714
|
-
} else if (field
|
|
715
|
-
result[field.name] = result[field.name].map(
|
|
716
|
-
|
|
717
|
-
|
|
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
|
+
});
|
|
718
765
|
}
|
|
719
766
|
}
|
|
720
767
|
return result;
|
|
@@ -779,9 +826,9 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
779
826
|
const localePath = own ? path : `${path}.${locale}`;
|
|
780
827
|
const failLocale = (message) => errors.push({ field: localePath, message });
|
|
781
828
|
const value2 = map[locale];
|
|
782
|
-
if (isEmpty(value2)) {
|
|
829
|
+
if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
|
|
783
830
|
if (own && field.required && !options.skipRequired) failLocale("is required");
|
|
784
|
-
out[locale] = null;
|
|
831
|
+
out[locale] = hasRows(field) || isHasMany(field) ? [] : null;
|
|
785
832
|
if (own && !field.required) await runCustom(field, null, options, failLocale);
|
|
786
833
|
continue;
|
|
787
834
|
}
|
|
@@ -803,7 +850,7 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
803
850
|
}
|
|
804
851
|
if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
|
|
805
852
|
if (field.required && !options.skipRequired) fail("is required");
|
|
806
|
-
data[field.name] = field
|
|
853
|
+
data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
|
|
807
854
|
if (!field.required) await runCustom(field, null, options, fail);
|
|
808
855
|
continue;
|
|
809
856
|
}
|
|
@@ -905,10 +952,36 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
905
952
|
}
|
|
906
953
|
return rows;
|
|
907
954
|
}
|
|
955
|
+
case "blocks":
|
|
956
|
+
return normalizeBlocks(field, raw, path, options, fail, errors, references);
|
|
908
957
|
case "group":
|
|
909
958
|
return raw;
|
|
910
959
|
}
|
|
911
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
|
+
}
|
|
912
985
|
function normalizeSelect(field, raw, fail) {
|
|
913
986
|
const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
|
|
914
987
|
const values = field.hasMany ? raw : [raw];
|
|
@@ -920,29 +993,94 @@ function normalizeSelect(field, raw, fail) {
|
|
|
920
993
|
return field.hasMany ? [...new Set(values)] : values[0];
|
|
921
994
|
}
|
|
922
995
|
|
|
923
|
-
// src/fields.ts
|
|
924
|
-
var FIELD_TYPES = [
|
|
925
|
-
"text",
|
|
926
|
-
"textarea",
|
|
927
|
-
"number",
|
|
928
|
-
"boolean",
|
|
929
|
-
"date",
|
|
930
|
-
"email",
|
|
931
|
-
"json",
|
|
932
|
-
"select",
|
|
933
|
-
"slug",
|
|
934
|
-
"richText",
|
|
935
|
-
"upload",
|
|
936
|
-
"relationship",
|
|
937
|
-
"array",
|
|
938
|
-
"group"
|
|
939
|
-
];
|
|
940
|
-
|
|
941
996
|
// src/load-config.ts
|
|
942
997
|
import { existsSync } from "fs";
|
|
943
998
|
import { resolve } from "path";
|
|
944
999
|
import { createJiti } from "jiti";
|
|
945
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
|
+
|
|
946
1084
|
// src/validate-config.ts
|
|
947
1085
|
var MIN_SECRET_LENGTH = 32;
|
|
948
1086
|
var BUILTIN_COLLECTIONS = ["users", "media"];
|
|
@@ -952,6 +1090,8 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
952
1090
|
"sessions",
|
|
953
1091
|
"login-attempts",
|
|
954
1092
|
"document-versions",
|
|
1093
|
+
"scheduled-jobs",
|
|
1094
|
+
"jobs",
|
|
955
1095
|
"migrations",
|
|
956
1096
|
"access"
|
|
957
1097
|
]);
|
|
@@ -974,6 +1114,7 @@ function validateConfig(config) {
|
|
|
974
1114
|
validateAuth(config, add);
|
|
975
1115
|
validateCors(config.cors, add);
|
|
976
1116
|
validateLocalization(config, add);
|
|
1117
|
+
validateWebhooks(config, add);
|
|
977
1118
|
const collections = asArray(config.collections, "collections", add);
|
|
978
1119
|
const globals = asArray(config.globals, "globals", add);
|
|
979
1120
|
const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
|
|
@@ -1063,7 +1204,9 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
|
|
|
1063
1204
|
"json",
|
|
1064
1205
|
"select",
|
|
1065
1206
|
"upload",
|
|
1066
|
-
"relationship"
|
|
1207
|
+
"relationship",
|
|
1208
|
+
"blocks",
|
|
1209
|
+
"array"
|
|
1067
1210
|
]);
|
|
1068
1211
|
function validateLocalization(config, add) {
|
|
1069
1212
|
const localization = config.localization;
|
|
@@ -1090,7 +1233,7 @@ function validateLocalization(config, add) {
|
|
|
1090
1233
|
}
|
|
1091
1234
|
}
|
|
1092
1235
|
}
|
|
1093
|
-
const walk = (fields, path) => {
|
|
1236
|
+
const walk = (fields, path, insideLocalized = false) => {
|
|
1094
1237
|
if (!Array.isArray(fields)) return;
|
|
1095
1238
|
for (const field of fields) {
|
|
1096
1239
|
if (typeof field?.name !== "string") continue;
|
|
@@ -1108,18 +1251,52 @@ function validateLocalization(config, add) {
|
|
|
1108
1251
|
`${field.type} fields cannot be localized`,
|
|
1109
1252
|
"localize the fields inside it instead"
|
|
1110
1253
|
);
|
|
1111
|
-
} else if (
|
|
1112
|
-
add(
|
|
1254
|
+
} else if (insideLocalized) {
|
|
1255
|
+
add(
|
|
1256
|
+
`${fieldPath}.localized`,
|
|
1257
|
+
"is inside a localized field, which already holds one value per locale"
|
|
1258
|
+
);
|
|
1113
1259
|
}
|
|
1114
1260
|
}
|
|
1261
|
+
const inside = insideLocalized || field.localized === true;
|
|
1115
1262
|
if (field.type === "group" || field.type === "array")
|
|
1116
|
-
walk(field.fields, `${fieldPath}.fields
|
|
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);
|
|
1117
1267
|
}
|
|
1118
1268
|
};
|
|
1119
1269
|
for (const [i, c] of (config.collections ?? []).entries())
|
|
1120
1270
|
walk(c?.fields, `collections[${i}].fields`);
|
|
1121
1271
|
for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
|
|
1122
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
|
+
}
|
|
1123
1300
|
function validateCors(cors, add) {
|
|
1124
1301
|
if (cors === void 0 || cors === "*") return;
|
|
1125
1302
|
if (!Array.isArray(cors)) {
|
|
@@ -1187,6 +1364,9 @@ function validateContainer(container, path, collectionSlugs, add) {
|
|
|
1187
1364
|
add(`${path}.versions.max`, "must be a positive integer");
|
|
1188
1365
|
}
|
|
1189
1366
|
}
|
|
1367
|
+
if (container.schedule && !container.drafts) {
|
|
1368
|
+
add(`${path}.schedule`, "needs `drafts: true`", "scheduling publishes and unpublishes drafts");
|
|
1369
|
+
}
|
|
1190
1370
|
if (container.preview !== void 0 && typeof container.preview !== "function") {
|
|
1191
1371
|
add(
|
|
1192
1372
|
`${path}.preview`,
|
|
@@ -1271,6 +1451,43 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
1271
1451
|
checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
|
|
1272
1452
|
validateFields2(field.fields, `${path}.fields`, /* @__PURE__ */ new Set(["id"]), collectionSlugs, add);
|
|
1273
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
|
+
}
|
|
1274
1491
|
}
|
|
1275
1492
|
}
|
|
1276
1493
|
function checkRange(min, max, minName, maxName, path, add) {
|
|
@@ -1362,7 +1579,8 @@ async function resolveConfig(input) {
|
|
|
1362
1579
|
collections: [
|
|
1363
1580
|
...config.collections ?? [],
|
|
1364
1581
|
...internalCollections,
|
|
1365
|
-
...usesVersions(config) ? [versionsCollection] : []
|
|
1582
|
+
...usesVersions(config) ? [versionsCollection] : [],
|
|
1583
|
+
...usesSchedule(config) ? [scheduledJobsCollection] : []
|
|
1366
1584
|
],
|
|
1367
1585
|
globals: config.globals ?? []
|
|
1368
1586
|
};
|
|
@@ -1372,6 +1590,9 @@ async function resolveConfig(input) {
|
|
|
1372
1590
|
function usesVersions(config) {
|
|
1373
1591
|
return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
|
|
1374
1592
|
}
|
|
1593
|
+
function usesSchedule(config) {
|
|
1594
|
+
return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
|
|
1595
|
+
}
|
|
1375
1596
|
|
|
1376
1597
|
// src/load-config.ts
|
|
1377
1598
|
var CONFIG_FILE_NAMES = [
|
|
@@ -1430,16 +1651,20 @@ function pickLocale(fields, data, locale, localization, fallback) {
|
|
|
1430
1651
|
const result = { ...data };
|
|
1431
1652
|
for (const field of fields) {
|
|
1432
1653
|
const value = result[field.name];
|
|
1433
|
-
if (field.
|
|
1434
|
-
result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
|
|
1435
|
-
} else if (field.type === "array" && Array.isArray(value)) {
|
|
1436
|
-
result[field.name] = value.map(
|
|
1437
|
-
(row) => isPlainObject2(row) ? pickLocale(field.fields, row, locale, localization, fallback) : row
|
|
1438
|
-
);
|
|
1439
|
-
} else if (field.localized) {
|
|
1654
|
+
if (field.localized) {
|
|
1440
1655
|
const map = isPlainObject2(value) ? value : {};
|
|
1441
1656
|
const own = map[locale];
|
|
1442
|
-
|
|
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
|
+
});
|
|
1443
1668
|
}
|
|
1444
1669
|
}
|
|
1445
1670
|
return result;
|
|
@@ -1450,57 +1675,62 @@ function toLocaleMaps(fields, input, current, locale) {
|
|
|
1450
1675
|
if (!Object.hasOwn(input, field.name)) continue;
|
|
1451
1676
|
const value = input[field.name];
|
|
1452
1677
|
const previous = current[field.name];
|
|
1453
|
-
if (field.
|
|
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)) {
|
|
1454
1682
|
result[field.name] = toLocaleMaps(
|
|
1455
1683
|
field.fields,
|
|
1456
1684
|
value,
|
|
1457
1685
|
isPlainObject2(previous) ? previous : {},
|
|
1458
1686
|
locale
|
|
1459
1687
|
);
|
|
1460
|
-
} else if (field
|
|
1688
|
+
} else if (hasRows(field) && Array.isArray(value)) {
|
|
1461
1689
|
const rows = new Map(
|
|
1462
1690
|
(Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
|
|
1463
1691
|
);
|
|
1464
|
-
result[field.name] = value.map(
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
const map = isPlainObject2(previous) ? previous : {};
|
|
1469
|
-
result[field.name] = { ...map, [locale]: value };
|
|
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
|
+
});
|
|
1470
1696
|
}
|
|
1471
1697
|
}
|
|
1472
1698
|
return result;
|
|
1473
1699
|
}
|
|
1474
|
-
function localizePath(fields, path, locale) {
|
|
1700
|
+
function localizePath(fields, path, locale, locales = []) {
|
|
1475
1701
|
const segments = path.split(".");
|
|
1476
1702
|
let list = fields;
|
|
1477
1703
|
for (let i = 0; i < segments.length; i++) {
|
|
1478
1704
|
const field = list.find((f) => f.name === segments[i]);
|
|
1479
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
|
+
}
|
|
1480
1711
|
if (field.type === "group" || field.type === "array") {
|
|
1481
1712
|
list = field.fields;
|
|
1482
1713
|
continue;
|
|
1483
1714
|
}
|
|
1484
|
-
if (field.localized && i === segments.length - 1) return `${path}.${locale}`;
|
|
1485
1715
|
return path;
|
|
1486
1716
|
}
|
|
1487
1717
|
return path;
|
|
1488
1718
|
}
|
|
1489
|
-
function localizeWhere(fields, where, locale) {
|
|
1719
|
+
function localizeWhere(fields, where, locale, locales = []) {
|
|
1490
1720
|
if (!where) return where;
|
|
1491
1721
|
const out = {};
|
|
1492
1722
|
for (const [key2, value] of Object.entries(where)) {
|
|
1493
1723
|
if ((key2 === "and" || key2 === "or") && Array.isArray(value)) {
|
|
1494
|
-
out[key2] = value.map((w) => localizeWhere(fields, w, locale));
|
|
1724
|
+
out[key2] = value.map((w) => localizeWhere(fields, w, locale, locales));
|
|
1495
1725
|
} else {
|
|
1496
|
-
out[localizePath(fields, key2, locale)] = value;
|
|
1726
|
+
out[localizePath(fields, key2, locale, locales)] = value;
|
|
1497
1727
|
}
|
|
1498
1728
|
}
|
|
1499
1729
|
return out;
|
|
1500
1730
|
}
|
|
1501
|
-
function localizeSort(fields, sort, locale) {
|
|
1731
|
+
function localizeSort(fields, sort, locale, locales = []) {
|
|
1502
1732
|
return sort.map(
|
|
1503
|
-
(entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale)}` : localizePath(fields, entry, locale)
|
|
1733
|
+
(entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale, locales)}` : localizePath(fields, entry, locale, locales)
|
|
1504
1734
|
);
|
|
1505
1735
|
}
|
|
1506
1736
|
|
|
@@ -1644,10 +1874,16 @@ function collectSlots(fields, data, bySlug, slots) {
|
|
|
1644
1874
|
if (value === null || value === void 0) continue;
|
|
1645
1875
|
if (field.type === "group") {
|
|
1646
1876
|
if (typeof value === "object") collectSlots(field.fields, value, bySlug, slots);
|
|
1647
|
-
} else if (field
|
|
1648
|
-
if (Array.isArray(value))
|
|
1649
|
-
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
|
+
}
|
|
1650
1885
|
} else if (field.type === "relationship" || field.type === "upload") {
|
|
1886
|
+
if (typeof value === "object" && !Array.isArray(value)) continue;
|
|
1651
1887
|
const collection2 = field.type === "upload" ? "media" : field.to;
|
|
1652
1888
|
if (!bySlug.has(collection2)) continue;
|
|
1653
1889
|
if (Array.isArray(value)) {
|
|
@@ -1862,8 +2098,19 @@ async function createEasyCMS(config, options = {}) {
|
|
|
1862
2098
|
logger,
|
|
1863
2099
|
interactive: options.interactive ?? false
|
|
1864
2100
|
});
|
|
1865
|
-
|
|
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;
|
|
1866
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
|
+
});
|
|
1867
2114
|
var EasyCMS = class {
|
|
1868
2115
|
config;
|
|
1869
2116
|
db;
|
|
@@ -1873,12 +2120,14 @@ var EasyCMS = class {
|
|
|
1873
2120
|
/** Where uploaded files are stored. */
|
|
1874
2121
|
storage;
|
|
1875
2122
|
versions;
|
|
2123
|
+
webhooks;
|
|
1876
2124
|
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
|
|
1877
2125
|
this.config = config;
|
|
1878
2126
|
this.db = db;
|
|
1879
2127
|
this.logger = logger;
|
|
1880
2128
|
this.storage = storage;
|
|
1881
2129
|
this.versions = new VersionStore(db);
|
|
2130
|
+
this.webhooks = new Webhooks(config.webhooks ?? [], logger);
|
|
1882
2131
|
this.auth = new Auth(this);
|
|
1883
2132
|
}
|
|
1884
2133
|
async find(collection2, options = {}) {
|
|
@@ -1899,7 +2148,8 @@ var EasyCMS = class {
|
|
|
1899
2148
|
const sort = locale === void 0 ? requested : localizeSort(
|
|
1900
2149
|
config.fields,
|
|
1901
2150
|
requested,
|
|
1902
|
-
locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale
|
|
2151
|
+
locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale,
|
|
2152
|
+
this.config.localization?.locales ?? []
|
|
1903
2153
|
);
|
|
1904
2154
|
const result = await this.db.find({ collection: collection2, where, sort, limit, page });
|
|
1905
2155
|
const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
|
|
@@ -2268,6 +2518,7 @@ var EasyCMS = class {
|
|
|
2268
2518
|
previousDoc: current,
|
|
2269
2519
|
operation: "update"
|
|
2270
2520
|
});
|
|
2521
|
+
this.emit(config, "draft", draft);
|
|
2271
2522
|
const [out2] = await this.output(config, [draft], guard, {
|
|
2272
2523
|
...options,
|
|
2273
2524
|
draft: true
|
|
@@ -2289,6 +2540,7 @@ var EasyCMS = class {
|
|
|
2289
2540
|
previousDoc: current,
|
|
2290
2541
|
operation: "update"
|
|
2291
2542
|
});
|
|
2543
|
+
this.emit(config, "update", doc, existing.status);
|
|
2292
2544
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
2293
2545
|
return out;
|
|
2294
2546
|
}
|
|
@@ -2306,11 +2558,16 @@ var EasyCMS = class {
|
|
|
2306
2558
|
if (config.slug === USERS) await this.auth.revokeSessions(parsed);
|
|
2307
2559
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
2308
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
|
+
}
|
|
2309
2565
|
await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
|
|
2310
2566
|
...base,
|
|
2311
2567
|
id: parsed,
|
|
2312
2568
|
doc: existing
|
|
2313
2569
|
});
|
|
2570
|
+
this.emit(config, "delete", existing);
|
|
2314
2571
|
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
2315
2572
|
return out;
|
|
2316
2573
|
}
|
|
@@ -2437,6 +2694,7 @@ var EasyCMS = class {
|
|
|
2437
2694
|
previousDoc: current,
|
|
2438
2695
|
operation: "update"
|
|
2439
2696
|
});
|
|
2697
|
+
this.emit(config, "draft", draft);
|
|
2440
2698
|
return this.findGlobal(slug, { ...options, draft: true });
|
|
2441
2699
|
}
|
|
2442
2700
|
const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
|
|
@@ -2447,6 +2705,7 @@ var EasyCMS = class {
|
|
|
2447
2705
|
previousDoc: current,
|
|
2448
2706
|
operation: "update"
|
|
2449
2707
|
});
|
|
2708
|
+
this.emit(config, "update", doc, existing.status);
|
|
2450
2709
|
return this.findGlobal(slug, { ...options, draft: true });
|
|
2451
2710
|
}
|
|
2452
2711
|
/**
|
|
@@ -2468,9 +2727,177 @@ var EasyCMS = class {
|
|
|
2468
2727
|
return { update: await check("update"), delete: await check("delete") };
|
|
2469
2728
|
}
|
|
2470
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
|
+
}
|
|
2471
2737
|
async destroy() {
|
|
2738
|
+
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
|
|
2739
|
+
await this.running;
|
|
2740
|
+
await this.webhooks.flush();
|
|
2472
2741
|
await this.db.destroy();
|
|
2473
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
|
+
}
|
|
2474
2901
|
// -------------------------------------------------------------------------
|
|
2475
2902
|
/** @internal */
|
|
2476
2903
|
collection(slug) {
|
|
@@ -2499,11 +2926,11 @@ var EasyCMS = class {
|
|
|
2499
2926
|
});
|
|
2500
2927
|
if (access === true) return;
|
|
2501
2928
|
if (access !== false) {
|
|
2502
|
-
const
|
|
2929
|
+
const matches2 = await this.db.count({
|
|
2503
2930
|
collection: config.slug,
|
|
2504
2931
|
where: andWhere(access, { id: { equals: id } })
|
|
2505
2932
|
});
|
|
2506
|
-
if (
|
|
2933
|
+
if (matches2 > 0) return;
|
|
2507
2934
|
}
|
|
2508
2935
|
throw deny(guard.user);
|
|
2509
2936
|
}
|
|
@@ -2613,6 +3040,7 @@ var EasyCMS = class {
|
|
|
2613
3040
|
doc,
|
|
2614
3041
|
operation: "create"
|
|
2615
3042
|
});
|
|
3043
|
+
this.emit(config, "create", doc);
|
|
2616
3044
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
2617
3045
|
return out;
|
|
2618
3046
|
}
|
|
@@ -2650,7 +3078,8 @@ var EasyCMS = class {
|
|
|
2650
3078
|
return localizeWhere(
|
|
2651
3079
|
config.fields,
|
|
2652
3080
|
where,
|
|
2653
|
-
locale === ALL_LOCALES ? localization.defaultLocale : locale
|
|
3081
|
+
locale === ALL_LOCALES ? localization.defaultLocale : locale,
|
|
3082
|
+
localization.locales
|
|
2654
3083
|
);
|
|
2655
3084
|
}
|
|
2656
3085
|
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
@@ -2667,6 +3096,24 @@ var EasyCMS = class {
|
|
|
2667
3096
|
return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
|
|
2668
3097
|
});
|
|
2669
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
|
+
}
|
|
2670
3117
|
/** Records a version after a save, when the collection or global keeps versions. */
|
|
2671
3118
|
async saveVersion(config, parent, id, doc, guard) {
|
|
2672
3119
|
const max = versionLimit(config);
|
|
@@ -2951,6 +3398,17 @@ async function serializeFields(fields, update, localized) {
|
|
|
2951
3398
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
2952
3399
|
f.fields = await serializeFields(field.fields, update, localized);
|
|
2953
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
|
+
);
|
|
3411
|
+
break;
|
|
2954
3412
|
case "group":
|
|
2955
3413
|
f.fields = await serializeFields(field.fields, update, localized);
|
|
2956
3414
|
break;
|
|
@@ -2965,6 +3423,7 @@ async function collection(config, user, localized) {
|
|
|
2965
3423
|
drafts: config.drafts === true,
|
|
2966
3424
|
versions: Boolean(config.versions),
|
|
2967
3425
|
preview: typeof config.preview === "function",
|
|
3426
|
+
schedule: config.schedule === true,
|
|
2968
3427
|
fields: await serializeFields(
|
|
2969
3428
|
config.fields,
|
|
2970
3429
|
new FieldAccessChecker("update", { user }),
|
|
@@ -2987,6 +3446,7 @@ async function global(config, user, localized) {
|
|
|
2987
3446
|
drafts: config.drafts === true,
|
|
2988
3447
|
versions: Boolean(config.versions),
|
|
2989
3448
|
preview: typeof config.preview === "function",
|
|
3449
|
+
schedule: config.schedule === true,
|
|
2990
3450
|
fields: await serializeFields(
|
|
2991
3451
|
config.fields,
|
|
2992
3452
|
new FieldAccessChecker("update", { user }),
|
|
@@ -3225,6 +3685,16 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
3225
3685
|
}
|
|
3226
3686
|
throw new HttpError("Not found", 404);
|
|
3227
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
|
+
}
|
|
3228
3698
|
if (first === "admin") {
|
|
3229
3699
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
3230
3700
|
if (!ctx.user) throw new UnauthorizedError();
|
|
@@ -3349,6 +3819,20 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
3349
3819
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
3350
3820
|
return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
|
|
3351
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
|
+
}
|
|
3352
3836
|
if (path.length === 1 && action === "preview") {
|
|
3353
3837
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
3354
3838
|
const body = await readJson(ctx.request);
|
|
@@ -3383,6 +3867,17 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
3383
3867
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
3384
3868
|
return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
|
|
3385
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
|
+
}
|
|
3386
3881
|
if (path.length === 1 && action === "preview") {
|
|
3387
3882
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
3388
3883
|
const body = await readJson(ctx.request);
|
|
@@ -3399,6 +3894,12 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
3399
3894
|
}
|
|
3400
3895
|
throw new HttpError("Not found", 404);
|
|
3401
3896
|
}
|
|
3897
|
+
function scheduleJob(body) {
|
|
3898
|
+
return {
|
|
3899
|
+
action: body.action,
|
|
3900
|
+
at: typeof body.at === "string" ? body.at : ""
|
|
3901
|
+
};
|
|
3902
|
+
}
|
|
3402
3903
|
function parseLocale(url) {
|
|
3403
3904
|
const locale = url.searchParams.get("locale");
|
|
3404
3905
|
const fallback = url.searchParams.get("fallback-locale");
|
|
@@ -3416,8 +3917,8 @@ function withPreviewToken(url, token) {
|
|
|
3416
3917
|
}
|
|
3417
3918
|
function requirePreview(cms, ctx, target) {
|
|
3418
3919
|
const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
|
|
3419
|
-
const
|
|
3420
|
-
if (!
|
|
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");
|
|
3421
3922
|
}
|
|
3422
3923
|
function parsePage(url) {
|
|
3423
3924
|
const { page, limit } = parseListQuery(url);
|
|
@@ -3633,6 +4134,16 @@ ${indent}}`;
|
|
|
3633
4134
|
${indent} id: string
|
|
3634
4135
|
${fieldLines(field.fields, n, `${indent} `).join("\n")}
|
|
3635
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
|
+
}
|
|
3636
4147
|
}
|
|
3637
4148
|
}
|
|
3638
4149
|
function fieldLines(fields, n, indent) {
|
|
@@ -3640,7 +4151,7 @@ function fieldLines(fields, n, indent) {
|
|
|
3640
4151
|
for (const field of fields) {
|
|
3641
4152
|
if (field.hidden) continue;
|
|
3642
4153
|
const type = fieldType(field, n, indent);
|
|
3643
|
-
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;
|
|
3644
4155
|
if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
|
|
3645
4156
|
else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
|
|
3646
4157
|
}
|
|
@@ -3748,6 +4259,7 @@ export {
|
|
|
3748
4259
|
TooManyRequestsError,
|
|
3749
4260
|
UnauthorizedError,
|
|
3750
4261
|
ValidationError,
|
|
4262
|
+
WEBHOOK_EVENTS,
|
|
3751
4263
|
anyone,
|
|
3752
4264
|
applyDefaults,
|
|
3753
4265
|
consoleLogger,
|
|
@@ -3758,6 +4270,7 @@ export {
|
|
|
3758
4270
|
findConfigFile,
|
|
3759
4271
|
generateSlugs,
|
|
3760
4272
|
generateTypes,
|
|
4273
|
+
hasRows,
|
|
3761
4274
|
hashPassword,
|
|
3762
4275
|
imageDimensions,
|
|
3763
4276
|
importConfig,
|
|
@@ -3771,6 +4284,7 @@ export {
|
|
|
3771
4284
|
populate,
|
|
3772
4285
|
readCookie,
|
|
3773
4286
|
resolveConfig,
|
|
4287
|
+
rowFields,
|
|
3774
4288
|
silentLogger,
|
|
3775
4289
|
singularize,
|
|
3776
4290
|
slugify,
|