@easy-cms/core 0.44.0 → 0.46.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 +325 -24
- package/dist/index.js +597 -95
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// src/access.ts
|
|
2
2
|
var isSystemAdmin = (user) => user?.role === "admin" && !user.apiKey && user.scoped !== true;
|
|
3
3
|
var anyone = () => true;
|
|
4
|
-
var isLoggedIn = ({ user }) => user !== null;
|
|
4
|
+
var isLoggedIn = ({ user }) => user !== null && user.member !== true;
|
|
5
|
+
var isSignedIn = ({ user }) => user !== null;
|
|
5
6
|
var isAdmin = ({ user }) => user?.role === "admin";
|
|
6
7
|
|
|
7
8
|
// src/api-keys.ts
|
|
@@ -192,7 +193,12 @@ var adminOrSelf = ({ user }) => {
|
|
|
192
193
|
var adminOnly = {
|
|
193
194
|
update: ({ user }) => isSystemAdmin(user)
|
|
194
195
|
};
|
|
195
|
-
|
|
196
|
+
var staffOrSelf = ({ user }) => {
|
|
197
|
+
if (!user) return false;
|
|
198
|
+
if (user.member !== true) return true;
|
|
199
|
+
return { id: { equals: user.id } };
|
|
200
|
+
};
|
|
201
|
+
function userFields(roles, rbac, members) {
|
|
196
202
|
const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
|
|
197
203
|
return [
|
|
198
204
|
{
|
|
@@ -227,6 +233,16 @@ function userFields(roles, rbac) {
|
|
|
227
233
|
access: adminOnly,
|
|
228
234
|
label: { en: "Active", th: "\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19" }
|
|
229
235
|
},
|
|
236
|
+
// Accounts made by signing up (`auth.members.signup`) are `false` until the email is confirmed.
|
|
237
|
+
...members ? [
|
|
238
|
+
{
|
|
239
|
+
name: "emailVerified",
|
|
240
|
+
type: "boolean",
|
|
241
|
+
access: adminOnly,
|
|
242
|
+
position: "sidebar",
|
|
243
|
+
label: { en: "Email confirmed", th: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E41\u0E25\u0E49\u0E27" }
|
|
244
|
+
}
|
|
245
|
+
] : [],
|
|
230
246
|
{ name: "passwordHash", type: "text", hidden: true }
|
|
231
247
|
];
|
|
232
248
|
}
|
|
@@ -239,9 +255,12 @@ function withUsers(config) {
|
|
|
239
255
|
useAsTitle: "email",
|
|
240
256
|
icon: "users",
|
|
241
257
|
...custom,
|
|
242
|
-
fields: [
|
|
258
|
+
fields: [
|
|
259
|
+
...userFields(roles, config.auth?.rbac === true, config.auth?.members?.signup !== void 0),
|
|
260
|
+
...custom?.fields ?? []
|
|
261
|
+
],
|
|
243
262
|
access: {
|
|
244
|
-
read:
|
|
263
|
+
read: staffOrSelf,
|
|
245
264
|
create: ({ user }) => isSystemAdmin(user),
|
|
246
265
|
update: adminOrSelf,
|
|
247
266
|
delete: ({ user }) => isSystemAdmin(user),
|
|
@@ -427,7 +446,16 @@ function withMedia(config) {
|
|
|
427
446
|
const customFolders = config.collections?.find((c) => c.slug === MEDIA_FOLDERS);
|
|
428
447
|
const mediaFolders = customFolders ? {
|
|
429
448
|
...mediaFoldersCollection,
|
|
430
|
-
|
|
449
|
+
// A field named like a built-in one changes it (e.g. `key` unique per tenant).
|
|
450
|
+
fields: [
|
|
451
|
+
...mediaFoldersCollection.fields.map((field) => {
|
|
452
|
+
const own = customFolders.fields.find((f) => f.name === field.name);
|
|
453
|
+
return own ? { ...field, ...own } : field;
|
|
454
|
+
}),
|
|
455
|
+
...customFolders.fields.filter(
|
|
456
|
+
(f) => !mediaFoldersCollection.fields.some((b) => b.name === f.name)
|
|
457
|
+
)
|
|
458
|
+
],
|
|
431
459
|
access: { ...mediaFoldersCollection.access, ...customFolders.access },
|
|
432
460
|
hooks: {
|
|
433
461
|
...customFolders.hooks,
|
|
@@ -562,7 +590,9 @@ var auditLogsCollection = {
|
|
|
562
590
|
{ name: "userAgent", type: "text" },
|
|
563
591
|
{ name: "changes", type: "json" },
|
|
564
592
|
{ name: "detail", type: "json" },
|
|
565
|
-
{ name: "signature", type: "text" }
|
|
593
|
+
{ name: "signature", type: "text" },
|
|
594
|
+
// The part of the site, e.g. the tenant (`audit.scope`).
|
|
595
|
+
{ name: "scope", type: "text", index: true }
|
|
566
596
|
]
|
|
567
597
|
};
|
|
568
598
|
var emailDeliveriesCollection = {
|
|
@@ -635,7 +665,8 @@ var AuditLog = class {
|
|
|
635
665
|
return this.cms.config.audit || {
|
|
636
666
|
keep: DEFAULT_AUDIT_KEEP,
|
|
637
667
|
values: true,
|
|
638
|
-
failedLogins: DEFAULT_FAILED_LOGINS
|
|
668
|
+
failedLogins: DEFAULT_FAILED_LOGINS,
|
|
669
|
+
scope: null
|
|
639
670
|
};
|
|
640
671
|
}
|
|
641
672
|
/**
|
|
@@ -646,6 +677,7 @@ var AuditLog = class {
|
|
|
646
677
|
if (!this.enabled) return;
|
|
647
678
|
try {
|
|
648
679
|
const context = auditContext.getStore();
|
|
680
|
+
const scope = this.scopeOf(entry.context ?? context?.context);
|
|
649
681
|
const user = entry.user !== void 0 ? entry.user : context?.user ?? null;
|
|
650
682
|
const apiKey = user?.apiKey;
|
|
651
683
|
const row = {
|
|
@@ -660,7 +692,8 @@ var AuditLog = class {
|
|
|
660
692
|
userAgent: context?.userAgent ? context.userAgent.slice(0, 200) : null,
|
|
661
693
|
changes: entry.changes && entry.changes.length > 0 ? entry.changes : null,
|
|
662
694
|
detail: apiKey ? { ...entry.detail, apiKey: apiKey.name ?? "" } : entry.detail ?? null,
|
|
663
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
695
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
696
|
+
...scope ? { scope } : {}
|
|
664
697
|
};
|
|
665
698
|
row.signature = this.sign(row);
|
|
666
699
|
row.updatedAt = row.createdAt;
|
|
@@ -676,7 +709,7 @@ var AuditLog = class {
|
|
|
676
709
|
* A change to a document or global: what changed between `before` and `after`. Internal
|
|
677
710
|
* collections (sessions, versions…) are not logged.
|
|
678
711
|
*/
|
|
679
|
-
async content(config, global2, action, after, before, user) {
|
|
712
|
+
async content(config, global2, action, after, before, user, context) {
|
|
680
713
|
if (!this.enabled) return;
|
|
681
714
|
if (!global2 && INTERNAL_COLLECTIONS.has(config.slug)) return;
|
|
682
715
|
const doc = after ?? before;
|
|
@@ -692,7 +725,8 @@ var AuditLog = class {
|
|
|
692
725
|
title,
|
|
693
726
|
// A trusted call without a user keeps the request's (or is the system).
|
|
694
727
|
...user ? { user } : {},
|
|
695
|
-
changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {})
|
|
728
|
+
changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {}),
|
|
729
|
+
...context && Object.keys(context).length > 0 ? { context } : {}
|
|
696
730
|
});
|
|
697
731
|
}
|
|
698
732
|
/** Fields whose stored value changed. Hidden ones never (the password only as "changed"). */
|
|
@@ -837,8 +871,15 @@ var AuditLog = class {
|
|
|
837
871
|
return out;
|
|
838
872
|
}
|
|
839
873
|
// -------------------------------------------------------------------------
|
|
874
|
+
/** The part of the site a context is in (`scope`), or `null`. */
|
|
875
|
+
scopeOf(context) {
|
|
876
|
+
const scope = this.settings.scope;
|
|
877
|
+
return (scope && context ? scope(context) : null) ?? null;
|
|
878
|
+
}
|
|
840
879
|
sign(row) {
|
|
841
|
-
const
|
|
880
|
+
const values = SIGNED.map((key2) => row[key2] ?? null);
|
|
881
|
+
if (typeof row.scope === "string" && row.scope) values.push(row.scope);
|
|
882
|
+
const payload = stable(values);
|
|
842
883
|
return createHmac("sha256", this.cms.config.secret).update(`audit:${payload}`).digest("base64url");
|
|
843
884
|
}
|
|
844
885
|
async lastVerification() {
|
|
@@ -866,6 +907,7 @@ var AuditLog = class {
|
|
|
866
907
|
if (filter.actor) parts.push({ actorEmail: { like: filter.actor } });
|
|
867
908
|
if (filter.from) parts.push({ createdAt: { gte: filter.from } });
|
|
868
909
|
if (filter.to) parts.push({ createdAt: { lte: filter.to } });
|
|
910
|
+
if (filter.scope) parts.push({ scope: { equals: filter.scope } });
|
|
869
911
|
return parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : { and: parts };
|
|
870
912
|
}
|
|
871
913
|
};
|
|
@@ -1137,6 +1179,7 @@ function sizeKey(filename, size) {
|
|
|
1137
1179
|
var PRIVATE_KEY = /^[\p{L}\p{M}\p{N}-]+\.private(-[\p{L}\p{N}_-]+)?\.[a-z0-9]+$/u;
|
|
1138
1180
|
|
|
1139
1181
|
// src/fields.ts
|
|
1182
|
+
var uniqueWithinOf = (field) => field.uniqueWithin === void 0 ? [] : [field.uniqueWithin].flat();
|
|
1140
1183
|
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
1141
1184
|
function mimeAllowedBy(mimeType, patterns) {
|
|
1142
1185
|
const type = mimeType.toLowerCase();
|
|
@@ -1249,6 +1292,48 @@ async function filterInput(fields, data, update2) {
|
|
|
1249
1292
|
return result;
|
|
1250
1293
|
}
|
|
1251
1294
|
|
|
1295
|
+
// src/spam.ts
|
|
1296
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1297
|
+
var MAX_AGE = 24 * 36e5;
|
|
1298
|
+
var sign = (secret, value) => createHmac2("sha256", secret).update(value).digest("base64url");
|
|
1299
|
+
function formToken(secret, purpose, now = Date.now()) {
|
|
1300
|
+
return `${now}.${sign(secret, `${purpose}:${now}`)}`;
|
|
1301
|
+
}
|
|
1302
|
+
function checkFormToken(secret, purpose, token, minTime, now = Date.now()) {
|
|
1303
|
+
if (typeof token !== "string") return "invalid";
|
|
1304
|
+
const [time, signature] = token.split(".");
|
|
1305
|
+
const issued = Number(time);
|
|
1306
|
+
if (!Number.isFinite(issued) || !signature) return "invalid";
|
|
1307
|
+
const expected = Buffer.from(sign(secret, `${purpose}:${issued}`));
|
|
1308
|
+
const given = Buffer.from(signature);
|
|
1309
|
+
if (expected.length !== given.length || !timingSafeEqual2(expected, given)) return "invalid";
|
|
1310
|
+
if (now - issued < minTime) return "too-fast";
|
|
1311
|
+
if (now - issued > MAX_AGE) return "expired";
|
|
1312
|
+
return "ok";
|
|
1313
|
+
}
|
|
1314
|
+
function rateKeys(secret, subject, window, now = Date.now()) {
|
|
1315
|
+
const bucket = Math.floor(now / (window * 1e3));
|
|
1316
|
+
const key2 = (b) => sign(secret, `${subject}:${b}`).slice(0, 32);
|
|
1317
|
+
return { current: key2(bucket), previous: key2(bucket - 1) };
|
|
1318
|
+
}
|
|
1319
|
+
function honeypotName(names2) {
|
|
1320
|
+
return ["website", "homepage", "url_address"].find((n) => !names2.includes(n)) ?? "hp_field";
|
|
1321
|
+
}
|
|
1322
|
+
async function verifyTurnstile(secretKey, response, ip, fetcher = fetch) {
|
|
1323
|
+
if (typeof response !== "string" || !response) return false;
|
|
1324
|
+
const body = new URLSearchParams({ secret: secretKey, response, ...ip ? { remoteip: ip } : {} });
|
|
1325
|
+
try {
|
|
1326
|
+
const result = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
1327
|
+
method: "POST",
|
|
1328
|
+
body,
|
|
1329
|
+
signal: AbortSignal.timeout(1e4)
|
|
1330
|
+
});
|
|
1331
|
+
return (await result.json()).success === true;
|
|
1332
|
+
} catch {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1252
1337
|
// src/auth/cookie.ts
|
|
1253
1338
|
var SESSION_COOKIE = "ecms-session";
|
|
1254
1339
|
|
|
@@ -1265,6 +1350,21 @@ function email(subject, lines, button) {
|
|
|
1265
1350
|
var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
|
|
1266
1351
|
var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
|
|
1267
1352
|
var DEFAULT_PASSWORD_EMAILS = {
|
|
1353
|
+
verifyEmail: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
|
|
1354
|
+
"\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",
|
|
1355
|
+
[
|
|
1356
|
+
"\u0E02\u0E2D\u0E1A\u0E04\u0E38\u0E13\u0E17\u0E35\u0E48\u0E2A\u0E21\u0E31\u0E04\u0E23\u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01 \u0E01\u0E14\u0E1B\u0E38\u0E48\u0E21\u0E14\u0E49\u0E32\u0E19\u0E25\u0E48\u0E32\u0E07\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E41\u0E25\u0E30\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A",
|
|
1357
|
+
`\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E20\u0E32\u0E22\u0E43\u0E19 ${days(expiresAt)} \u0E27\u0E31\u0E19 \u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E2A\u0E21\u0E31\u0E04\u0E23 \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E17\u0E33\u0E2D\u0E30\u0E44\u0E23`
|
|
1358
|
+
],
|
|
1359
|
+
{ label: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25", url }
|
|
1360
|
+
) : email(
|
|
1361
|
+
"Confirm your email",
|
|
1362
|
+
[
|
|
1363
|
+
"Thanks for signing up. Confirm your email to sign in.",
|
|
1364
|
+
`The link works within ${days(expiresAt)} day(s). If you didn't sign up, ignore this email.`
|
|
1365
|
+
],
|
|
1366
|
+
{ label: "Confirm email", url }
|
|
1367
|
+
),
|
|
1268
1368
|
resetPassword: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
|
|
1269
1369
|
"\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
|
|
1270
1370
|
[
|
|
@@ -1315,7 +1415,7 @@ var DEFAULT_PASSWORD_EMAILS = {
|
|
|
1315
1415
|
};
|
|
1316
1416
|
|
|
1317
1417
|
// src/auth/password.ts
|
|
1318
|
-
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as
|
|
1418
|
+
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
1319
1419
|
var MIN_PASSWORD_LENGTH = 8;
|
|
1320
1420
|
var N = 2 ** 17;
|
|
1321
1421
|
var R = 8;
|
|
@@ -1350,7 +1450,7 @@ async function verifyPassword(password, stored) {
|
|
|
1350
1450
|
Number(p),
|
|
1351
1451
|
expected.length
|
|
1352
1452
|
);
|
|
1353
|
-
return key2.length === expected.length &&
|
|
1453
|
+
return key2.length === expected.length && timingSafeEqual3(key2, expected);
|
|
1354
1454
|
}
|
|
1355
1455
|
var dummyHash;
|
|
1356
1456
|
async function fakeVerify(password) {
|
|
@@ -1360,10 +1460,10 @@ async function fakeVerify(password) {
|
|
|
1360
1460
|
}
|
|
1361
1461
|
|
|
1362
1462
|
// src/auth/sso.ts
|
|
1363
|
-
import { createHmac as
|
|
1463
|
+
import { createHmac as createHmac4, randomBytes as randomBytes4 } from "crypto";
|
|
1364
1464
|
|
|
1365
1465
|
// src/auth/tokens.ts
|
|
1366
|
-
import { createHash as createHash2, createHmac as
|
|
1466
|
+
import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
1367
1467
|
function newToken() {
|
|
1368
1468
|
return randomBytes3(32).toString("base64url");
|
|
1369
1469
|
}
|
|
@@ -1371,7 +1471,7 @@ function hashToken(token) {
|
|
|
1371
1471
|
return createHash2("sha256").update(token).digest("hex");
|
|
1372
1472
|
}
|
|
1373
1473
|
function hmac(secret, value) {
|
|
1374
|
-
return
|
|
1474
|
+
return createHmac3("sha256", secret).update(value).digest("base64url");
|
|
1375
1475
|
}
|
|
1376
1476
|
function signToken(secret, token) {
|
|
1377
1477
|
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
@@ -1388,7 +1488,7 @@ function csrfToken(secret, token) {
|
|
|
1388
1488
|
function safeEqual(a, b) {
|
|
1389
1489
|
const left = Buffer.from(a);
|
|
1390
1490
|
const right = Buffer.from(b);
|
|
1391
|
-
return left.length === right.length &&
|
|
1491
|
+
return left.length === right.length && timingSafeEqual4(left, right);
|
|
1392
1492
|
}
|
|
1393
1493
|
function signPreviewToken(secret, target, expiresAt) {
|
|
1394
1494
|
const payload = Buffer.from(
|
|
@@ -1430,7 +1530,7 @@ function readPasswordToken(token) {
|
|
|
1430
1530
|
try {
|
|
1431
1531
|
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1432
1532
|
if (typeof data.u !== "string" || typeof data.e !== "number") return null;
|
|
1433
|
-
if (data.p !== "reset" && data.p !== "invite") return null;
|
|
1533
|
+
if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
|
|
1434
1534
|
return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
|
|
1435
1535
|
} catch {
|
|
1436
1536
|
return null;
|
|
@@ -1447,19 +1547,19 @@ function passwordTokenMatches(secret, token, fingerprint) {
|
|
|
1447
1547
|
var SSO_COOKIE = "ecms-sso";
|
|
1448
1548
|
var PENDING_TTL = 10 * 6e4;
|
|
1449
1549
|
var random = () => randomBytes4(32).toString("base64url");
|
|
1450
|
-
function
|
|
1451
|
-
return
|
|
1550
|
+
function sign2(secret, payload) {
|
|
1551
|
+
return createHmac4("sha256", secret).update(`sso:${payload}`).digest("base64url");
|
|
1452
1552
|
}
|
|
1453
1553
|
function seal(secret, pending) {
|
|
1454
1554
|
const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
|
|
1455
|
-
return `${payload}.${
|
|
1555
|
+
return `${payload}.${sign2(secret, payload)}`;
|
|
1456
1556
|
}
|
|
1457
1557
|
function unseal(secret, value) {
|
|
1458
1558
|
if (!value) return null;
|
|
1459
1559
|
const dot = value.lastIndexOf(".");
|
|
1460
1560
|
if (dot <= 0) return null;
|
|
1461
1561
|
const payload = value.slice(0, dot);
|
|
1462
|
-
if (!safeEqual(value.slice(dot + 1),
|
|
1562
|
+
if (!safeEqual(value.slice(dot + 1), sign2(secret, payload))) return null;
|
|
1463
1563
|
try {
|
|
1464
1564
|
const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1465
1565
|
return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
|
|
@@ -1737,6 +1837,16 @@ var SingleSignOn = class {
|
|
|
1737
1837
|
|
|
1738
1838
|
// src/auth/auth.ts
|
|
1739
1839
|
var INVALID = "Invalid email or password";
|
|
1840
|
+
var SIGNUP_FORM = "easy-cms-signup";
|
|
1841
|
+
var SIGNUP_MIN_TIME = 2e3;
|
|
1842
|
+
var SIGNUP_HONEYPOT = "website";
|
|
1843
|
+
function asMember(user, memberRoles) {
|
|
1844
|
+
const member = memberRoles.includes(String(user.role));
|
|
1845
|
+
if (member === (user.member === true)) return user;
|
|
1846
|
+
if (member) return { ...user, member: true };
|
|
1847
|
+
const { member: _member, ...rest } = user;
|
|
1848
|
+
return rest;
|
|
1849
|
+
}
|
|
1740
1850
|
var RESET_REQUESTS = 3;
|
|
1741
1851
|
var Auth = class {
|
|
1742
1852
|
constructor(cms) {
|
|
@@ -1786,6 +1896,16 @@ var Auth = class {
|
|
|
1786
1896
|
throw new UnauthorizedError(INVALID);
|
|
1787
1897
|
}
|
|
1788
1898
|
await this.clearFailures(key2);
|
|
1899
|
+
if (user.emailVerified === false) {
|
|
1900
|
+
await this.cms.audit.record({
|
|
1901
|
+
action: "login.failed",
|
|
1902
|
+
target: "auth",
|
|
1903
|
+
user: null,
|
|
1904
|
+
email: email2,
|
|
1905
|
+
detail: { reason: "email not confirmed" }
|
|
1906
|
+
});
|
|
1907
|
+
throw new UnauthorizedError("Confirm your email first: follow the link we sent you");
|
|
1908
|
+
}
|
|
1789
1909
|
if (!this.sso.passwordAllowed(user)) {
|
|
1790
1910
|
await this.cms.audit.record({
|
|
1791
1911
|
action: "login.failed",
|
|
@@ -1828,6 +1948,136 @@ var Auth = class {
|
|
|
1828
1948
|
const raw = await this.db.findById({ collection: USERS, id: user.id });
|
|
1829
1949
|
return this.startSession(raw);
|
|
1830
1950
|
}
|
|
1951
|
+
// --- Members signing up (`auth.members.signup`) ---------------------------------------------
|
|
1952
|
+
/** What a sign-up form needs: a token proving when it was loaded, and Turnstile's site key. */
|
|
1953
|
+
signupForm() {
|
|
1954
|
+
const signup = this.config.auth.members.signup;
|
|
1955
|
+
if (!signup) throw new NotFoundError(USERS, "signup");
|
|
1956
|
+
return {
|
|
1957
|
+
token: formToken(this.config.secret, SIGNUP_FORM),
|
|
1958
|
+
turnstile: signup.turnstile?.siteKey ?? null,
|
|
1959
|
+
verifyEmail: signup.verifyEmail !== false
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Creates a member's account (`auth.members.signup`). With `verifyEmail` (the default), emails
|
|
1964
|
+
* a link to confirm the address and answers the same whether or not the email has an account;
|
|
1965
|
+
* otherwise starts a session. Throws `ValidationError` for spam checks and bad input.
|
|
1966
|
+
*/
|
|
1967
|
+
async signup(args) {
|
|
1968
|
+
const signup = this.config.auth.members.signup;
|
|
1969
|
+
if (!signup) throw new NotFoundError(USERS, "signup");
|
|
1970
|
+
const key2 = `signup|${args.ip ?? ""}`;
|
|
1971
|
+
await this.checkRateLimit(key2);
|
|
1972
|
+
const refuse = (field, message2) => new ValidationError(USERS, [{ field, message: message2 }]);
|
|
1973
|
+
if (typeof args.honeypot === "string" && args.honeypot !== "") {
|
|
1974
|
+
await this.recordFailure(key2);
|
|
1975
|
+
throw refuse("form", "could not be sent");
|
|
1976
|
+
}
|
|
1977
|
+
const checked = checkFormToken(this.config.secret, SIGNUP_FORM, args.token, SIGNUP_MIN_TIME);
|
|
1978
|
+
if (checked !== "ok")
|
|
1979
|
+
throw refuse(
|
|
1980
|
+
"form",
|
|
1981
|
+
checked === "expired" ? "was open too long: reload the page" : "could not be sent"
|
|
1982
|
+
);
|
|
1983
|
+
if (signup.turnstile && !await verifyTurnstile(signup.turnstile.secretKey, args.turnstile, args.ip))
|
|
1984
|
+
throw refuse("turnstile", "is not complete");
|
|
1985
|
+
await this.recordFailure(key2);
|
|
1986
|
+
const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
|
|
1987
|
+
const verify = signup.verifyEmail !== false;
|
|
1988
|
+
if (verify && !this.canSendPasswordLinks(args.origin))
|
|
1989
|
+
throw new QueryError("Signing up needs `email` in the config, and a site URL in production");
|
|
1990
|
+
const existing = email2 ? await this.findByEmail(email2) : void 0;
|
|
1991
|
+
if (existing && verify) {
|
|
1992
|
+
if (existing.emailVerified === false && existing.active !== false)
|
|
1993
|
+
await this.mailVerifyLink(existing, args);
|
|
1994
|
+
return { verify: true };
|
|
1995
|
+
}
|
|
1996
|
+
const created = await this.cms.create(
|
|
1997
|
+
USERS,
|
|
1998
|
+
{
|
|
1999
|
+
email: email2,
|
|
2000
|
+
password: args.password,
|
|
2001
|
+
role: signup.role,
|
|
2002
|
+
active: true,
|
|
2003
|
+
emailVerified: !verify,
|
|
2004
|
+
...typeof args.name === "string" && args.name.trim() ? { name: args.name.trim() } : {}
|
|
2005
|
+
},
|
|
2006
|
+
{ context: args.context ?? {} }
|
|
2007
|
+
);
|
|
2008
|
+
const user = await this.db.findById({ collection: USERS, id: created.id });
|
|
2009
|
+
await this.cms.audit.record({
|
|
2010
|
+
action: "signup",
|
|
2011
|
+
target: "auth",
|
|
2012
|
+
user: await this.toAuthUser(user)
|
|
2013
|
+
});
|
|
2014
|
+
if (verify) {
|
|
2015
|
+
await this.mailVerifyLink(user, args);
|
|
2016
|
+
return { verify: true };
|
|
2017
|
+
}
|
|
2018
|
+
return { ...await this.startSession(user), verify: false };
|
|
2019
|
+
}
|
|
2020
|
+
/** Confirms a member's email from the link, and signs them in. */
|
|
2021
|
+
async verifyEmail(args) {
|
|
2022
|
+
const key2 = `verify-token|${args.ip ?? ""}`;
|
|
2023
|
+
await this.checkRateLimit(key2);
|
|
2024
|
+
const token = String(args.token ?? "");
|
|
2025
|
+
const claims = readPasswordToken(token);
|
|
2026
|
+
const id = claims && (/^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId);
|
|
2027
|
+
const user = claims && claims.purpose === "verify" && claims.expiresAt > Date.now() && id !== null ? await this.db.findById({ collection: USERS, id }) : null;
|
|
2028
|
+
if (!user || user.active === false || !passwordTokenMatches(this.config.secret, token, passwordFingerprint(user.passwordHash))) {
|
|
2029
|
+
await this.recordFailure(key2);
|
|
2030
|
+
throw new ValidationError(USERS, [
|
|
2031
|
+
{ field: "token", message: "This link has expired or is not valid" }
|
|
2032
|
+
]);
|
|
2033
|
+
}
|
|
2034
|
+
await this.clearFailures(key2);
|
|
2035
|
+
if (user.emailVerified === false) {
|
|
2036
|
+
const { id: _id, ...rest } = user;
|
|
2037
|
+
await this.db.update({
|
|
2038
|
+
collection: USERS,
|
|
2039
|
+
id: user.id,
|
|
2040
|
+
data: { ...rest, emailVerified: true }
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
const session = await this.startSession(
|
|
2044
|
+
await this.db.findById({ collection: USERS, id: user.id })
|
|
2045
|
+
);
|
|
2046
|
+
await this.cms.audit.record({ action: "email.verified", target: "auth", user: session.user });
|
|
2047
|
+
return session;
|
|
2048
|
+
}
|
|
2049
|
+
async mailVerifyLink(user, options) {
|
|
2050
|
+
const expiresAt = Date.now() + this.config.auth.inviteExpiration * 1e3;
|
|
2051
|
+
const token = signPasswordToken(this.config.secret, {
|
|
2052
|
+
userId: String(user.id),
|
|
2053
|
+
purpose: "verify",
|
|
2054
|
+
expiresAt,
|
|
2055
|
+
fingerprint: passwordFingerprint(user.passwordHash)
|
|
2056
|
+
});
|
|
2057
|
+
const url = this.memberLink("verifyEmail", token, options.origin);
|
|
2058
|
+
const write = this.config.auth.members.emails?.verifyEmail ?? DEFAULT_PASSWORD_EMAILS.verifyEmail;
|
|
2059
|
+
const content = await write({
|
|
2060
|
+
user: await this.toAuthUser(user),
|
|
2061
|
+
url,
|
|
2062
|
+
locale: this.localeOf(options.locale),
|
|
2063
|
+
expiresAt: new Date(expiresAt)
|
|
2064
|
+
});
|
|
2065
|
+
await this.cms.sendEmail({ to: String(user.email), ...content });
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* A link to one of the site's pages for members (`auth.members.pages`), else to the admin's
|
|
2069
|
+
* page of the same purpose.
|
|
2070
|
+
*/
|
|
2071
|
+
memberLink(page, token, origin) {
|
|
2072
|
+
const target = this.config.auth.members.pages?.[page];
|
|
2073
|
+
const query = `token=${encodeURIComponent(token)}`;
|
|
2074
|
+
if (target && /^https?:\/\//.test(target))
|
|
2075
|
+
return `${target}${target.includes("?") ? "&" : "?"}${query}`;
|
|
2076
|
+
const site = (this.config.admin.siteUrl || "").replace(/\/+$/, "");
|
|
2077
|
+
const base = site || this.passwordLinkBase(origin);
|
|
2078
|
+
const path = target ?? `${this.config.admin.path}/${page === "verifyEmail" ? "verify-email" : "reset-password"}`;
|
|
2079
|
+
return `${base}${path.startsWith("/") ? "" : "/"}${path}${path.includes("?") ? "&" : "?"}${query}`;
|
|
2080
|
+
}
|
|
1831
2081
|
async hasUsers() {
|
|
1832
2082
|
return await this.db.count({ collection: USERS }) > 0;
|
|
1833
2083
|
}
|
|
@@ -2000,7 +2250,7 @@ var Auth = class {
|
|
|
2000
2250
|
}
|
|
2001
2251
|
async passwordTokenUser(token) {
|
|
2002
2252
|
const claims = readPasswordToken(token);
|
|
2003
|
-
if (!claims || claims.expiresAt <= Date.now()) return null;
|
|
2253
|
+
if (!claims || claims.purpose === "verify" || claims.expiresAt <= Date.now()) return null;
|
|
2004
2254
|
const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
|
|
2005
2255
|
const user = await this.db.findById({ collection: USERS, id });
|
|
2006
2256
|
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
|
|
@@ -2019,7 +2269,8 @@ var Auth = class {
|
|
|
2019
2269
|
fingerprint: passwordFingerprint(user.passwordHash)
|
|
2020
2270
|
});
|
|
2021
2271
|
const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
|
|
2022
|
-
const
|
|
2272
|
+
const member = this.config.auth.members.roles.includes(String(user.role));
|
|
2273
|
+
const url = sso ? `${base}${this.config.admin.path}/login` : member ? this.memberLink("resetPassword", token, options.origin) : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
|
|
2023
2274
|
const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
|
|
2024
2275
|
const content = await write({
|
|
2025
2276
|
user: await this.toAuthUser(user),
|
|
@@ -2112,7 +2363,8 @@ var Auth = class {
|
|
|
2112
2363
|
}
|
|
2113
2364
|
async toAuthUser(user) {
|
|
2114
2365
|
const fields = this.cms.collection(USERS).fields;
|
|
2115
|
-
|
|
2366
|
+
const stripped = await stripFields(fields, user);
|
|
2367
|
+
return asMember(stripped, this.config.auth.members.roles);
|
|
2116
2368
|
}
|
|
2117
2369
|
async deleteExpiredSessions(userId) {
|
|
2118
2370
|
const expired = await this.db.find({
|
|
@@ -2890,7 +3142,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
2890
3142
|
field: path,
|
|
2891
3143
|
collection: target,
|
|
2892
3144
|
id,
|
|
2893
|
-
...field.
|
|
3145
|
+
...field.filterOptions ? { filterOptions: field.filterOptions } : {},
|
|
2894
3146
|
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {},
|
|
2895
3147
|
...field.type === "upload" && field.folder && field.folderOnly ? { folderOnly: field.folder } : {}
|
|
2896
3148
|
});
|
|
@@ -3221,7 +3473,7 @@ function applyFieldTypes(config) {
|
|
|
3221
3473
|
}
|
|
3222
3474
|
|
|
3223
3475
|
// src/local-api.ts
|
|
3224
|
-
import { createHmac as
|
|
3476
|
+
import { createHmac as createHmac6, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
3225
3477
|
|
|
3226
3478
|
// src/localization.ts
|
|
3227
3479
|
var ALL_LOCALES = "all";
|
|
@@ -3376,7 +3628,8 @@ var MediaFolders = class {
|
|
|
3376
3628
|
name: String(doc.name ?? ""),
|
|
3377
3629
|
key: typeof doc.key === "string" && doc.key ? doc.key : null,
|
|
3378
3630
|
private: doc.private === true,
|
|
3379
|
-
permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
|
|
3631
|
+
permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null,
|
|
3632
|
+
doc
|
|
3380
3633
|
});
|
|
3381
3634
|
}
|
|
3382
3635
|
this.cache = { at: Date.now(), rows };
|
|
@@ -3446,18 +3699,15 @@ var MediaFolders = class {
|
|
|
3446
3699
|
});
|
|
3447
3700
|
return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
|
|
3448
3701
|
}
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
* there is none yet.
|
|
3452
|
-
*/
|
|
3453
|
-
async keyed(key2) {
|
|
3454
|
-
const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2);
|
|
3702
|
+
async keyed(key2, scope = {}, create = true) {
|
|
3703
|
+
const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2 && inScope(r.doc, scope));
|
|
3455
3704
|
let found = await find();
|
|
3456
3705
|
if (!found) {
|
|
3457
3706
|
this.invalidate();
|
|
3458
3707
|
found = await find();
|
|
3459
3708
|
}
|
|
3460
3709
|
if (found) return found.id;
|
|
3710
|
+
if (!create) return null;
|
|
3461
3711
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3462
3712
|
try {
|
|
3463
3713
|
const doc = await this.db.create({
|
|
@@ -3468,6 +3718,7 @@ var MediaFolders = class {
|
|
|
3468
3718
|
parent: null,
|
|
3469
3719
|
permissions: null,
|
|
3470
3720
|
private: false,
|
|
3721
|
+
...scope,
|
|
3471
3722
|
createdAt: now,
|
|
3472
3723
|
updatedAt: now
|
|
3473
3724
|
}
|
|
@@ -3482,10 +3733,10 @@ var MediaFolders = class {
|
|
|
3482
3733
|
}
|
|
3483
3734
|
}
|
|
3484
3735
|
/** Whether a file in `folder` is in the folder with this key, or below it. */
|
|
3485
|
-
async inKeyed(key2, folder) {
|
|
3736
|
+
async inKeyed(key2, folder, scope = {}) {
|
|
3486
3737
|
if (folder === null) return false;
|
|
3487
3738
|
const rows = await this.rows();
|
|
3488
|
-
const root = [...rows.values()].find((r) => r.key === key2);
|
|
3739
|
+
const root = [...rows.values()].find((r) => r.key === key2 && inScope(r.doc, scope));
|
|
3489
3740
|
return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
|
|
3490
3741
|
}
|
|
3491
3742
|
/** The user's level in a folder (`null`: the top level). */
|
|
@@ -3610,6 +3861,11 @@ var MediaFolders = class {
|
|
|
3610
3861
|
return this.rows();
|
|
3611
3862
|
}
|
|
3612
3863
|
};
|
|
3864
|
+
function inScope(row, scope) {
|
|
3865
|
+
return Object.entries(scope).every(
|
|
3866
|
+
([name, value]) => value === null || value === void 0 ? row[name] === null || row[name] === void 0 : String(row[name]) === String(value)
|
|
3867
|
+
);
|
|
3868
|
+
}
|
|
3613
3869
|
|
|
3614
3870
|
// src/populate.ts
|
|
3615
3871
|
var DEFAULT_DEPTH = 1;
|
|
@@ -4099,6 +4355,8 @@ var Roles = class {
|
|
|
4099
4355
|
async canView(user, id) {
|
|
4100
4356
|
if (isSystemAdmin(user)) return true;
|
|
4101
4357
|
if (user.apiKey) return false;
|
|
4358
|
+
if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
|
|
4359
|
+
return true;
|
|
4102
4360
|
if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
|
|
4103
4361
|
return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
|
|
4104
4362
|
}
|
|
@@ -4580,7 +4838,7 @@ function rank(role, config) {
|
|
|
4580
4838
|
}
|
|
4581
4839
|
|
|
4582
4840
|
// src/webhooks.ts
|
|
4583
|
-
import { createHmac as
|
|
4841
|
+
import { createHmac as createHmac5, randomUUID as randomUUID2 } from "crypto";
|
|
4584
4842
|
var WEBHOOK_EVENTS = [
|
|
4585
4843
|
"create",
|
|
4586
4844
|
"update",
|
|
@@ -4724,7 +4982,7 @@ var Webhooks = class {
|
|
|
4724
4982
|
...hook.headers
|
|
4725
4983
|
};
|
|
4726
4984
|
if (hook.secret) {
|
|
4727
|
-
headers["x-easy-cms-signature"] = `sha256=${
|
|
4985
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac5("sha256", hook.secret).update(body).digest("hex")}`;
|
|
4728
4986
|
}
|
|
4729
4987
|
try {
|
|
4730
4988
|
const response = await this.fetcher(hook.url, {
|
|
@@ -4750,7 +5008,9 @@ var Webhooks = class {
|
|
|
4750
5008
|
}
|
|
4751
5009
|
};
|
|
4752
5010
|
var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
|
|
5011
|
+
var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
|
|
4753
5012
|
function matches(hook, payload) {
|
|
5013
|
+
if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
|
|
4754
5014
|
if (hook.events && !hook.events.includes(payload.event)) return false;
|
|
4755
5015
|
if (payload.collection !== void 0)
|
|
4756
5016
|
return !hook.collections || hook.collections.includes(payload.collection);
|
|
@@ -4827,6 +5087,8 @@ function validateConfig(config) {
|
|
|
4827
5087
|
});
|
|
4828
5088
|
validateEndpoints(config, collectionSlugs, add);
|
|
4829
5089
|
validateCommands(config, add);
|
|
5090
|
+
validateJobs(config, add);
|
|
5091
|
+
validateEvents(config, add);
|
|
4830
5092
|
const apiKeys = config.apiKeys;
|
|
4831
5093
|
if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
|
|
4832
5094
|
collections.forEach((collection2, i) => {
|
|
@@ -5088,6 +5350,7 @@ function validateAuth(config, add) {
|
|
|
5088
5350
|
add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
|
|
5089
5351
|
}
|
|
5090
5352
|
}
|
|
5353
|
+
validateMembers(auth.members, auth.roles ?? ["admin", "editor"], add);
|
|
5091
5354
|
for (const key2 of [
|
|
5092
5355
|
"tokenExpiration",
|
|
5093
5356
|
"maxLoginAttempts",
|
|
@@ -5146,6 +5409,31 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
|
|
|
5146
5409
|
"blocks",
|
|
5147
5410
|
"array"
|
|
5148
5411
|
]);
|
|
5412
|
+
function validateMembers(members, roles, add) {
|
|
5413
|
+
if (members === void 0) return;
|
|
5414
|
+
const { roles: memberRoles, signup, pages: pages2 } = members ?? {};
|
|
5415
|
+
if (!Array.isArray(memberRoles) || memberRoles.some((r) => typeof r !== "string")) {
|
|
5416
|
+
add("auth.members.roles", "must be an array of roles", "e.g. roles: ['customer']");
|
|
5417
|
+
return;
|
|
5418
|
+
}
|
|
5419
|
+
for (const role of memberRoles) {
|
|
5420
|
+
if (role === "admin") add("auth.members.roles", `can't include "admin"`);
|
|
5421
|
+
else if (!roles.includes(role))
|
|
5422
|
+
add("auth.members.roles", `"${role}" must be one of auth.roles too`);
|
|
5423
|
+
}
|
|
5424
|
+
if (signup !== void 0) {
|
|
5425
|
+
const { role, verifyEmail: verifyEmail2, turnstile } = signup ?? {};
|
|
5426
|
+
if (typeof role !== "string" || !memberRoles.includes(role))
|
|
5427
|
+
add("auth.members.signup.role", "must be one of auth.members.roles");
|
|
5428
|
+
if (verifyEmail2 !== void 0 && typeof verifyEmail2 !== "boolean")
|
|
5429
|
+
add("auth.members.signup.verifyEmail", "must be true or false");
|
|
5430
|
+
if (turnstile !== void 0 && (typeof turnstile?.siteKey !== "string" || typeof turnstile?.secretKey !== "string"))
|
|
5431
|
+
add("auth.members.signup.turnstile", "needs siteKey and secretKey");
|
|
5432
|
+
}
|
|
5433
|
+
for (const [key2, value] of Object.entries(pages2 ?? {}))
|
|
5434
|
+
if (value !== void 0 && typeof value !== "string")
|
|
5435
|
+
add(`auth.members.pages.${key2}`, "must be a path or URL", "e.g. '/account/verify'");
|
|
5436
|
+
}
|
|
5149
5437
|
function validateLocalization(config, add) {
|
|
5150
5438
|
const localization = config.localization;
|
|
5151
5439
|
if (localization !== void 0 && localization !== null) {
|
|
@@ -5227,14 +5515,19 @@ function validateWebhooks(config, add) {
|
|
|
5227
5515
|
...(config.collections ?? []).map((c) => c?.slug)
|
|
5228
5516
|
]);
|
|
5229
5517
|
const globals = new Set((config.globals ?? []).map((g) => g?.slug));
|
|
5518
|
+
const custom = new Set(config.events ?? []);
|
|
5230
5519
|
for (const [i, hook] of hooks.entries()) {
|
|
5231
5520
|
const path = `webhooks[${i}]`;
|
|
5232
5521
|
if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
|
|
5233
5522
|
add(`${path}.url`, "must be an http(s) URL");
|
|
5234
5523
|
}
|
|
5235
5524
|
for (const event of hook?.events ?? []) {
|
|
5236
|
-
if (!WEBHOOK_EVENTS.includes(event))
|
|
5237
|
-
add(
|
|
5525
|
+
if (!WEBHOOK_EVENTS.includes(event) && !custom.has(event))
|
|
5526
|
+
add(
|
|
5527
|
+
`${path}.events`,
|
|
5528
|
+
`unknown event "${event}"`,
|
|
5529
|
+
`use: ${[...WEBHOOK_EVENTS, ...custom].join(", ")}`
|
|
5530
|
+
);
|
|
5238
5531
|
}
|
|
5239
5532
|
for (const slug2 of hook?.collections ?? [])
|
|
5240
5533
|
if (!collections.has(slug2)) add(`${path}.collections`, `unknown collection "${slug2}"`);
|
|
@@ -5428,8 +5721,12 @@ function validateContainer(container, path, collectionSlugs, add, { collection:
|
|
|
5428
5721
|
const sidebar = admin.sidebar;
|
|
5429
5722
|
if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
|
|
5430
5723
|
const group = admin.group;
|
|
5431
|
-
if (group !== void 0 && group !== "settings")
|
|
5432
|
-
add(
|
|
5724
|
+
if (group !== void 0 && group !== "settings" && !(collection2 && typeof group === "string" && group !== "") && !(collection2 && typeof group === "object" && group !== null && Object.values(group).every((v) => typeof v === "string")))
|
|
5725
|
+
add(
|
|
5726
|
+
`${path}.admin.group`,
|
|
5727
|
+
`must be 'settings' or a label (got ${JSON.stringify(group)})`,
|
|
5728
|
+
"e.g. group: { en: 'Shop', th: '\u0E23\u0E49\u0E32\u0E19\u0E04\u0E49\u0E32' }"
|
|
5729
|
+
);
|
|
5433
5730
|
const list = admin.list;
|
|
5434
5731
|
if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
|
|
5435
5732
|
const owner = admin.ownerField;
|
|
@@ -5528,17 +5825,20 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
5528
5825
|
}
|
|
5529
5826
|
}
|
|
5530
5827
|
if (field.uniqueWithin !== void 0) {
|
|
5531
|
-
|
|
5532
|
-
if (!field.unique && field.type !== "slug") {
|
|
5828
|
+
if (!field.unique && field.type !== "slug")
|
|
5533
5829
|
add(`${path}.uniqueWithin`, "needs `unique: true` (or a slug field)");
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5830
|
+
else
|
|
5831
|
+
for (const name of [field.uniqueWithin].flat()) {
|
|
5832
|
+
const scope = siblings.get(name);
|
|
5833
|
+
if (!scope || scope === field) {
|
|
5834
|
+
add(`${path}.uniqueWithin`, `no sibling field named "${name}"`);
|
|
5835
|
+
} else if (!["relationship", "select", "text", "number"].includes(scope.type) || "hasMany" in scope && scope.hasMany || scope.localized) {
|
|
5836
|
+
add(
|
|
5837
|
+
`${path}.uniqueWithin`,
|
|
5838
|
+
`"${name}" must be a single, unlocalized relationship, select, text or number field`
|
|
5839
|
+
);
|
|
5840
|
+
}
|
|
5841
|
+
}
|
|
5542
5842
|
}
|
|
5543
5843
|
switch (field.type) {
|
|
5544
5844
|
case "text":
|
|
@@ -5697,6 +5997,38 @@ function validateCommands(config, add) {
|
|
|
5697
5997
|
if (typeof run !== "function") add(`${path}.run`, "must be a function");
|
|
5698
5998
|
});
|
|
5699
5999
|
}
|
|
6000
|
+
function validateJobs(config, add) {
|
|
6001
|
+
const jobs = config.jobs;
|
|
6002
|
+
if (jobs === void 0) return;
|
|
6003
|
+
if (!Array.isArray(jobs)) {
|
|
6004
|
+
add("jobs", "must be an array", "e.g. jobs: [{ name: 'shop:clean-up', every: 3600, run }]");
|
|
6005
|
+
return;
|
|
6006
|
+
}
|
|
6007
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6008
|
+
jobs.forEach((job, i) => {
|
|
6009
|
+
const path = `jobs[${i}]`;
|
|
6010
|
+
const { name, every, run } = job ?? {};
|
|
6011
|
+
if (typeof name !== "string" || !/^[a-z][a-z0-9-]*(:[a-z0-9-]+)*$/.test(name))
|
|
6012
|
+
add(`${path}.name`, "must be lowercase letters, digits, - and :", "e.g. 'shop:clean-up'");
|
|
6013
|
+
else if (seen.has(name)) add(`${path}.name`, `"${name}" is already used by another job`);
|
|
6014
|
+
else seen.add(name);
|
|
6015
|
+
if (every !== void 0 && (typeof every !== "number" || !(every > 0)))
|
|
6016
|
+
add(`${path}.every`, "must be a number of seconds above 0");
|
|
6017
|
+
if (typeof run !== "function") add(`${path}.run`, "must be a function");
|
|
6018
|
+
});
|
|
6019
|
+
}
|
|
6020
|
+
function validateEvents(config, add) {
|
|
6021
|
+
const events = config.events;
|
|
6022
|
+
if (events === void 0) return;
|
|
6023
|
+
if (!Array.isArray(events)) {
|
|
6024
|
+
add("events", "must be an array", "e.g. events: ['order.paid']");
|
|
6025
|
+
return;
|
|
6026
|
+
}
|
|
6027
|
+
events.forEach((event, i) => {
|
|
6028
|
+
if (typeof event !== "string" || !/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(event))
|
|
6029
|
+
add(`events[${i}]`, "must be named <area>.<what> in lowercase", "e.g. 'order.paid'");
|
|
6030
|
+
});
|
|
6031
|
+
}
|
|
5700
6032
|
function validateEndpoints(config, collectionSlugs, add) {
|
|
5701
6033
|
const endpoints = config.endpoints;
|
|
5702
6034
|
if (endpoints === void 0) return;
|
|
@@ -5876,7 +6208,8 @@ async function resolveConfig(input) {
|
|
|
5876
6208
|
trustedOrigins: config.auth?.trustedOrigins ?? [],
|
|
5877
6209
|
resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
|
|
5878
6210
|
inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
|
|
5879
|
-
emails: config.auth?.emails ?? {}
|
|
6211
|
+
emails: config.auth?.emails ?? {},
|
|
6212
|
+
members: config.auth?.members ?? { roles: [] }
|
|
5880
6213
|
},
|
|
5881
6214
|
audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
|
|
5882
6215
|
collections: [
|
|
@@ -5896,6 +6229,8 @@ async function resolveConfig(input) {
|
|
|
5896
6229
|
globals: config.globals ?? [],
|
|
5897
6230
|
endpoints: config.endpoints ?? [],
|
|
5898
6231
|
commands: config.commands ?? [],
|
|
6232
|
+
events: config.events ?? [],
|
|
6233
|
+
jobs: config.jobs ?? [],
|
|
5899
6234
|
installedPlugins: (plugins ?? []).map(
|
|
5900
6235
|
(plugin) => typeof plugin === "function" && plugin.info ? { ...plugin.info } : {}
|
|
5901
6236
|
),
|
|
@@ -5914,7 +6249,8 @@ function resolveAudit(audit) {
|
|
|
5914
6249
|
return {
|
|
5915
6250
|
keep: audit.keep ?? DEFAULT_AUDIT_KEEP,
|
|
5916
6251
|
values: audit.values !== false,
|
|
5917
|
-
failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS
|
|
6252
|
+
failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS,
|
|
6253
|
+
scope: audit.scope ?? null
|
|
5918
6254
|
};
|
|
5919
6255
|
}
|
|
5920
6256
|
|
|
@@ -5934,7 +6270,7 @@ async function createEasyCMS(config, options = {}) {
|
|
|
5934
6270
|
interactive: options.interactive ?? false
|
|
5935
6271
|
});
|
|
5936
6272
|
const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
|
|
5937
|
-
const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0;
|
|
6273
|
+
const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0;
|
|
5938
6274
|
if (scheduling && options.scheduler !== false) cms.startScheduler();
|
|
5939
6275
|
return cms;
|
|
5940
6276
|
}
|
|
@@ -6016,8 +6352,10 @@ var EasyCMS = class {
|
|
|
6016
6352
|
user,
|
|
6017
6353
|
cms: this
|
|
6018
6354
|
});
|
|
6355
|
+
const changed = result?.user !== void 0 ? result.user : user;
|
|
6019
6356
|
return {
|
|
6020
|
-
|
|
6357
|
+
// `onRequest` may change the role (e.g. per tenant): membership follows it.
|
|
6358
|
+
user: changed && asMember(changed, this.config.auth.members.roles),
|
|
6021
6359
|
context: result?.context ?? NO_CONTEXT
|
|
6022
6360
|
};
|
|
6023
6361
|
}
|
|
@@ -6283,7 +6621,7 @@ var EasyCMS = class {
|
|
|
6283
6621
|
/** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
|
|
6284
6622
|
sign(purpose, value) {
|
|
6285
6623
|
const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
6286
|
-
const mac =
|
|
6624
|
+
const mac = createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`);
|
|
6287
6625
|
return `${payload}.${mac.digest("base64url")}`;
|
|
6288
6626
|
}
|
|
6289
6627
|
/** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
|
|
@@ -6291,10 +6629,10 @@ var EasyCMS = class {
|
|
|
6291
6629
|
const [payload, signature] = String(token).split(".");
|
|
6292
6630
|
if (!payload || !signature) return void 0;
|
|
6293
6631
|
const expected = Buffer.from(
|
|
6294
|
-
|
|
6632
|
+
createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
|
|
6295
6633
|
);
|
|
6296
6634
|
const given = Buffer.from(signature);
|
|
6297
|
-
if (expected.length !== given.length || !
|
|
6635
|
+
if (expected.length !== given.length || !timingSafeEqual5(expected, given)) return void 0;
|
|
6298
6636
|
try {
|
|
6299
6637
|
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
6300
6638
|
} catch {
|
|
@@ -6413,10 +6751,10 @@ var EasyCMS = class {
|
|
|
6413
6751
|
if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
|
|
6414
6752
|
const expected = Buffer.from(this.mediaSignature(key2, at));
|
|
6415
6753
|
const given = Buffer.from(signature);
|
|
6416
|
-
return expected.length === given.length &&
|
|
6754
|
+
return expected.length === given.length && timingSafeEqual5(expected, given);
|
|
6417
6755
|
}
|
|
6418
6756
|
mediaSignature(key2, expires) {
|
|
6419
|
-
return
|
|
6757
|
+
return createHmac6("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
|
|
6420
6758
|
}
|
|
6421
6759
|
/**
|
|
6422
6760
|
* How many documents use any of these files in a top-level upload field (rich text and fields
|
|
@@ -6540,6 +6878,17 @@ var EasyCMS = class {
|
|
|
6540
6878
|
async update(collection2, id, data, options = {}) {
|
|
6541
6879
|
return await this.updateDocument(collection2, id, asObject(data, collection2), options);
|
|
6542
6880
|
}
|
|
6881
|
+
/**
|
|
6882
|
+
* Adds `by` (negative to take away) to a top-level number field in one database statement, so
|
|
6883
|
+
* concurrent calls never lose a change, e.g. stock or a counter. Skips hooks, validation, access
|
|
6884
|
+
* and versions. Returns the new value, or `null` when `min` / `max` would be passed.
|
|
6885
|
+
*/
|
|
6886
|
+
async increment(collection2, id, field, by, options = {}) {
|
|
6887
|
+
this.collection(collection2);
|
|
6888
|
+
const parsed = parseId(id);
|
|
6889
|
+
if (parsed === void 0) throw new NotFoundError(collection2, id);
|
|
6890
|
+
return this.db.increment({ collection: collection2, id: parsed, field, by, ...options });
|
|
6891
|
+
}
|
|
6543
6892
|
/**
|
|
6544
6893
|
* Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
|
|
6545
6894
|
* `update` with `status: 'draft'` only saves a draft and leaves the published document live;
|
|
@@ -6812,7 +7161,7 @@ var EasyCMS = class {
|
|
|
6812
7161
|
previousDoc: current,
|
|
6813
7162
|
operation: "update"
|
|
6814
7163
|
});
|
|
6815
|
-
this.
|
|
7164
|
+
this.emitChange(config, "draft", draft);
|
|
6816
7165
|
await this.audited(config, "draft", draft, current, guard, mode);
|
|
6817
7166
|
const [out2] = await this.output(config, [draft], guard, {
|
|
6818
7167
|
...options,
|
|
@@ -6840,11 +7189,9 @@ var EasyCMS = class {
|
|
|
6840
7189
|
Object.assign(prepared, moved.patch);
|
|
6841
7190
|
}
|
|
6842
7191
|
}
|
|
6843
|
-
const
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
|
|
6847
|
-
});
|
|
7192
|
+
const data = { ...prepared, createdAt: existing.createdAt, updatedAt: now };
|
|
7193
|
+
const doc = options.where ? await this.db.update({ collection: collection2, id: parsed, data, where: options.where }) : await this.db.update({ collection: collection2, id: parsed, data });
|
|
7194
|
+
if (!doc) return null;
|
|
6848
7195
|
if (moved) await moved.cleanUp();
|
|
6849
7196
|
if (collection2 === MEDIA_FOLDERS) {
|
|
6850
7197
|
this.folders.invalidate();
|
|
@@ -6859,7 +7206,7 @@ var EasyCMS = class {
|
|
|
6859
7206
|
previousDoc: current,
|
|
6860
7207
|
operation: "update"
|
|
6861
7208
|
});
|
|
6862
|
-
this.
|
|
7209
|
+
this.emitChange(config, "update", doc, existing.status);
|
|
6863
7210
|
await this.audited(config, "update", doc, current, guard, mode);
|
|
6864
7211
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
6865
7212
|
return out;
|
|
@@ -6914,7 +7261,7 @@ var EasyCMS = class {
|
|
|
6914
7261
|
id: parsed,
|
|
6915
7262
|
doc: existing
|
|
6916
7263
|
});
|
|
6917
|
-
this.
|
|
7264
|
+
this.emitChange(config, "delete", existing);
|
|
6918
7265
|
await this.audited(config, "delete", existing, existing, guard);
|
|
6919
7266
|
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
6920
7267
|
return out;
|
|
@@ -7072,7 +7419,7 @@ var EasyCMS = class {
|
|
|
7072
7419
|
previousDoc: current,
|
|
7073
7420
|
operation: "update"
|
|
7074
7421
|
});
|
|
7075
|
-
this.
|
|
7422
|
+
this.emitChange(config, "draft", draft);
|
|
7076
7423
|
await this.audited(config, "draft", draft, current, guard, mode);
|
|
7077
7424
|
return this.readGlobal(config, key2, guard, { ...options, draft: true });
|
|
7078
7425
|
}
|
|
@@ -7084,7 +7431,7 @@ var EasyCMS = class {
|
|
|
7084
7431
|
previousDoc: current,
|
|
7085
7432
|
operation: "update"
|
|
7086
7433
|
});
|
|
7087
|
-
this.
|
|
7434
|
+
this.emitChange(config, "update", doc, existing.status);
|
|
7088
7435
|
await this.audited(config, "update", doc, current, guard, mode);
|
|
7089
7436
|
return this.readGlobal(config, key2, guard, { ...options, draft: true });
|
|
7090
7437
|
}
|
|
@@ -7197,10 +7544,45 @@ var EasyCMS = class {
|
|
|
7197
7544
|
return {
|
|
7198
7545
|
...scheduled,
|
|
7199
7546
|
webhooks: await this.retryWebhooks(now),
|
|
7200
|
-
emails: await this.mailer.retry(now)
|
|
7547
|
+
emails: await this.mailer.retry(now),
|
|
7548
|
+
jobs: await this.runConfigJobs(now)
|
|
7201
7549
|
};
|
|
7202
7550
|
});
|
|
7203
7551
|
}
|
|
7552
|
+
/** `jobs` of the config that are due; when each last ran is kept in the database. */
|
|
7553
|
+
async runConfigJobs(now) {
|
|
7554
|
+
const { jobs } = this.config;
|
|
7555
|
+
if (jobs.length === 0) return { ran: 0, failed: 0 };
|
|
7556
|
+
const last = await this.db.findGlobal({ slug: JOB_RUNS }) ?? {};
|
|
7557
|
+
const runs = {};
|
|
7558
|
+
for (const [name, at] of Object.entries(last)) if (typeof at === "string") runs[name] = at;
|
|
7559
|
+
let ran = 0;
|
|
7560
|
+
let failed = 0;
|
|
7561
|
+
for (const job of jobs) {
|
|
7562
|
+
const previous = runs[job.name];
|
|
7563
|
+
if (job.every !== void 0 && previous !== void 0 && now.getTime() - Date.parse(previous) < job.every * 1e3)
|
|
7564
|
+
continue;
|
|
7565
|
+
runs[job.name] = now.toISOString();
|
|
7566
|
+
try {
|
|
7567
|
+
await job.run({ cms: this, now });
|
|
7568
|
+
ran++;
|
|
7569
|
+
} catch (error) {
|
|
7570
|
+
failed++;
|
|
7571
|
+
this.logger.error(`Job ${job.name} failed: ${error.message}`);
|
|
7572
|
+
}
|
|
7573
|
+
}
|
|
7574
|
+
await this.db.updateGlobal({ slug: JOB_RUNS, data: { ...runs, updatedAt: now.toISOString() } });
|
|
7575
|
+
return { ran, failed };
|
|
7576
|
+
}
|
|
7577
|
+
/**
|
|
7578
|
+
* Sends an event of the app or a plugin (`events` in the config, e.g. `order.paid`) to the
|
|
7579
|
+
* webhooks that list it. Returns at once; deliveries are queued and retried like content changes.
|
|
7580
|
+
*/
|
|
7581
|
+
emit(event, data, about) {
|
|
7582
|
+
if (!this.config.events.includes(event))
|
|
7583
|
+
throw new QueryError(`Unknown event "${event}": add it to \`events\` in the config`);
|
|
7584
|
+
this.webhooks.emit(event, about ?? {}, data);
|
|
7585
|
+
}
|
|
7204
7586
|
/**
|
|
7205
7587
|
* Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
|
|
7206
7588
|
* database until they succeed, so they survive restarts.
|
|
@@ -7710,7 +8092,7 @@ var EasyCMS = class {
|
|
|
7710
8092
|
doc,
|
|
7711
8093
|
operation: "create"
|
|
7712
8094
|
});
|
|
7713
|
-
this.
|
|
8095
|
+
this.emitChange(config, "create", doc);
|
|
7714
8096
|
await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
|
|
7715
8097
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
7716
8098
|
return out;
|
|
@@ -7768,7 +8150,7 @@ var EasyCMS = class {
|
|
|
7768
8150
|
});
|
|
7769
8151
|
}
|
|
7770
8152
|
/** Sends webhook events for a change; `before` is the stored status before it, if any. */
|
|
7771
|
-
|
|
8153
|
+
emitChange(config, event, doc, before) {
|
|
7772
8154
|
const isGlobal = !this.config.collections.includes(config);
|
|
7773
8155
|
if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
|
|
7774
8156
|
const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
|
|
@@ -7809,7 +8191,8 @@ var EasyCMS = class {
|
|
|
7809
8191
|
action,
|
|
7810
8192
|
event === "delete" ? void 0 : doc,
|
|
7811
8193
|
event === "create" ? void 0 : previous,
|
|
7812
|
-
guard.user
|
|
8194
|
+
guard.user,
|
|
8195
|
+
guard.context
|
|
7813
8196
|
);
|
|
7814
8197
|
}
|
|
7815
8198
|
/** Records a version after a save, when the collection or global keeps versions. */
|
|
@@ -7919,7 +8302,7 @@ var EasyCMS = class {
|
|
|
7919
8302
|
await this.makeSlugsUnique(config, clean2, selfId);
|
|
7920
8303
|
errors.push(...await this.checkUnique(config, clean2, selfId));
|
|
7921
8304
|
}
|
|
7922
|
-
errors.push(...await this.checkReferences(result.references));
|
|
8305
|
+
errors.push(...await this.checkReferences(result.references, guard));
|
|
7923
8306
|
if (errors.length === 0)
|
|
7924
8307
|
errors.push(...await this.checkFilterOptions(result.references, selfId, guard));
|
|
7925
8308
|
if (errors.length > 0) throw new ValidationError(config.slug, errors);
|
|
@@ -7949,7 +8332,11 @@ var EasyCMS = class {
|
|
|
7949
8332
|
async makeSlugsUnique(config, data, selfId) {
|
|
7950
8333
|
for (const field of config.fields) {
|
|
7951
8334
|
if (field.type !== "slug") continue;
|
|
7952
|
-
const scope = field.uniqueWithin === void 0 ? void 0 : {
|
|
8335
|
+
const scope = field.uniqueWithin === void 0 ? void 0 : {
|
|
8336
|
+
and: uniqueWithinOf(field).map((name) => ({
|
|
8337
|
+
[name]: { equals: data[name] ?? null }
|
|
8338
|
+
}))
|
|
8339
|
+
};
|
|
7953
8340
|
const unique = async (path, base) => {
|
|
7954
8341
|
let candidate = base;
|
|
7955
8342
|
for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId, scope); n++) {
|
|
@@ -7980,7 +8367,11 @@ var EasyCMS = class {
|
|
|
7980
8367
|
locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
|
|
7981
8368
|
v
|
|
7982
8369
|
]) : [[field.name, field.name, value]];
|
|
7983
|
-
const scope = field.uniqueWithin === void 0 ? void 0 : {
|
|
8370
|
+
const scope = field.uniqueWithin === void 0 ? void 0 : {
|
|
8371
|
+
and: uniqueWithinOf(field).map((name) => ({
|
|
8372
|
+
[name]: { equals: data[name] ?? null }
|
|
8373
|
+
}))
|
|
8374
|
+
};
|
|
7984
8375
|
for (const [path, errorField, v] of checks) {
|
|
7985
8376
|
if (v === null || v === void 0) continue;
|
|
7986
8377
|
if (await this.isTaken(config.slug, path, v, selfId, scope)) {
|
|
@@ -8000,6 +8391,46 @@ var EasyCMS = class {
|
|
|
8000
8391
|
};
|
|
8001
8392
|
return await this.db.count({ collection: collection2, where }) > 0;
|
|
8002
8393
|
}
|
|
8394
|
+
/**
|
|
8395
|
+
* What folder keys are unique within for a call (`uniqueWithin` of the `key` field of media
|
|
8396
|
+
* folders, e.g. the tenant): the value a folder made by this call would get from its hooks.
|
|
8397
|
+
*/
|
|
8398
|
+
async folderScope(guard) {
|
|
8399
|
+
return this.uniqueScope(MEDIA_FOLDERS, "key", guard);
|
|
8400
|
+
}
|
|
8401
|
+
/**
|
|
8402
|
+
* Where a unique field's values must differ for a call (`uniqueWithin`, e.g. the tenant): the
|
|
8403
|
+
* value a document created by this call would get there from its hooks, as `{ [field]: value }`;
|
|
8404
|
+
* `{}` when the field is unique everywhere. For finding documents by such a field, e.g. a page
|
|
8405
|
+
* by its path in the current tenant.
|
|
8406
|
+
*/
|
|
8407
|
+
async uniqueScope(collection2, field, options = {}) {
|
|
8408
|
+
const config = this.config.collections.find((c) => c.slug === collection2);
|
|
8409
|
+
const target = config?.fields.find((f) => f.name === field);
|
|
8410
|
+
const within = target ? uniqueWithinOf(target) : [];
|
|
8411
|
+
if (!config || within.length === 0) return {};
|
|
8412
|
+
const guard = {
|
|
8413
|
+
enforce: false,
|
|
8414
|
+
user: options.user ?? null,
|
|
8415
|
+
context: options.context ?? NO_CONTEXT
|
|
8416
|
+
};
|
|
8417
|
+
const data = await this.transform(
|
|
8418
|
+
config.hooks?.beforeValidate,
|
|
8419
|
+
"data",
|
|
8420
|
+
{ ...this.hookArgs(config, guard), operation: "create" },
|
|
8421
|
+
{}
|
|
8422
|
+
).catch(() => ({}));
|
|
8423
|
+
return Object.fromEntries(within.map((name) => [name, data[name] ?? null]));
|
|
8424
|
+
}
|
|
8425
|
+
/**
|
|
8426
|
+
* @internal The id of the media folder with this key (`folder` of upload fields), made when
|
|
8427
|
+
* needed; `null` when the call has no scope to make it in (e.g. all tenants) and none exists.
|
|
8428
|
+
*/
|
|
8429
|
+
async keyedFolder(key2, options = {}) {
|
|
8430
|
+
const scope = await this.folderScope(guardOf(options));
|
|
8431
|
+
const complete = Object.values(scope).every((v) => v !== null && v !== void 0);
|
|
8432
|
+
return this.folders.keyed(key2, scope, complete);
|
|
8433
|
+
}
|
|
8003
8434
|
/** Each reference is one its relationship's `filterOptions` allow. */
|
|
8004
8435
|
async checkFilterOptions(references2, selfId, guard) {
|
|
8005
8436
|
const errors = [];
|
|
@@ -8025,7 +8456,7 @@ var EasyCMS = class {
|
|
|
8025
8456
|
}
|
|
8026
8457
|
return errors;
|
|
8027
8458
|
}
|
|
8028
|
-
async checkReferences(references2) {
|
|
8459
|
+
async checkReferences(references2, guard) {
|
|
8029
8460
|
const byCollection = /* @__PURE__ */ new Map();
|
|
8030
8461
|
for (const ref of references2) {
|
|
8031
8462
|
if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
|
|
@@ -8054,7 +8485,8 @@ var EasyCMS = class {
|
|
|
8054
8485
|
}
|
|
8055
8486
|
if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
|
|
8056
8487
|
ref.folderOnly,
|
|
8057
|
-
doc.folder ?? null
|
|
8488
|
+
doc.folder ?? null,
|
|
8489
|
+
await this.folderScope(guard)
|
|
8058
8490
|
))
|
|
8059
8491
|
errors.push({
|
|
8060
8492
|
field: ref.field,
|
|
@@ -8144,6 +8576,7 @@ var UPLOAD_URL_SECONDS = 15 * 60;
|
|
|
8144
8576
|
var UPLOAD_TICKET_SECONDS = 24 * 60 * 60;
|
|
8145
8577
|
var SNIFF_BYTES = 64 * 1024;
|
|
8146
8578
|
var MAX_FILES_TO_MOVE = 200;
|
|
8579
|
+
var JOB_RUNS = "easy-cms:jobs";
|
|
8147
8580
|
function durationSeconds(value) {
|
|
8148
8581
|
const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
|
|
8149
8582
|
const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
|
|
@@ -8400,7 +8833,7 @@ function componentRef(component) {
|
|
|
8400
8833
|
async function allowed(access, user, context) {
|
|
8401
8834
|
return await evaluateAccess(access, { user, context }) !== false;
|
|
8402
8835
|
}
|
|
8403
|
-
async function serializeFields(fields, checks, localized, folderId) {
|
|
8836
|
+
async function serializeFields(fields, checks, localized, folderId, who) {
|
|
8404
8837
|
const { update: update2 } = checks;
|
|
8405
8838
|
const out = [];
|
|
8406
8839
|
for (const field of fields) {
|
|
@@ -8410,6 +8843,13 @@ async function serializeFields(fields, checks, localized, folderId) {
|
|
|
8410
8843
|
if (field.required) f.required = true;
|
|
8411
8844
|
if (field.unique) f.unique = true;
|
|
8412
8845
|
if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
|
|
8846
|
+
if (field.admin?.defaultValue) {
|
|
8847
|
+
const value = await field.admin.defaultValue(who);
|
|
8848
|
+
if (value !== void 0) f.defaultValue = value;
|
|
8849
|
+
}
|
|
8850
|
+
const column = field.admin?.column;
|
|
8851
|
+
if (column === true || typeof column === "function" && await column(who)) f.column = true;
|
|
8852
|
+
if (field.admin?.allowCreate === false) f.noCreate = true;
|
|
8413
8853
|
if (!await update2.allows(field)) f.readOnly = true;
|
|
8414
8854
|
if (field.localized && localized) f.localized = true;
|
|
8415
8855
|
if (field.position === "sidebar") f.position = "sidebar";
|
|
@@ -8450,16 +8890,19 @@ async function serializeFields(fields, checks, localized, folderId) {
|
|
|
8450
8890
|
case "upload":
|
|
8451
8891
|
f.to = "media";
|
|
8452
8892
|
if (field.hasMany) f.hasMany = true;
|
|
8893
|
+
if (field.filterOptions) f.filtered = true;
|
|
8453
8894
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
8454
8895
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
8455
8896
|
if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
|
|
8456
|
-
if (field.folder && folderId)
|
|
8457
|
-
|
|
8897
|
+
if (field.folder && folderId) {
|
|
8898
|
+
const id = await folderId(field.folder);
|
|
8899
|
+
if (id !== null) f.folder = { id, only: field.folderOnly === true };
|
|
8900
|
+
}
|
|
8458
8901
|
break;
|
|
8459
8902
|
case "array":
|
|
8460
8903
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
8461
8904
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
8462
|
-
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
8905
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId, who);
|
|
8463
8906
|
break;
|
|
8464
8907
|
case "blocks":
|
|
8465
8908
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -8468,12 +8911,12 @@ async function serializeFields(fields, checks, localized, folderId) {
|
|
|
8468
8911
|
field.blocks.map(async (block) => ({
|
|
8469
8912
|
slug: block.slug,
|
|
8470
8913
|
...block.labels ? { labels: block.labels } : {},
|
|
8471
|
-
fields: await serializeFields(block.fields, checks, localized, folderId)
|
|
8914
|
+
fields: await serializeFields(block.fields, checks, localized, folderId, who)
|
|
8472
8915
|
}))
|
|
8473
8916
|
);
|
|
8474
8917
|
break;
|
|
8475
8918
|
case "group":
|
|
8476
|
-
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
8919
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId, who);
|
|
8477
8920
|
break;
|
|
8478
8921
|
}
|
|
8479
8922
|
out.push(f);
|
|
@@ -8496,7 +8939,8 @@ async function collection(cms, config, user, localized, roles, context) {
|
|
|
8496
8939
|
config.fields,
|
|
8497
8940
|
await checkers(cms, user, context),
|
|
8498
8941
|
localized,
|
|
8499
|
-
folderIds(cms)
|
|
8942
|
+
folderIds(cms, user, context),
|
|
8943
|
+
{ user, context }
|
|
8500
8944
|
);
|
|
8501
8945
|
if (config.slug === USERS && cms.roles.enabled) {
|
|
8502
8946
|
const role = fields.find((f) => f.name === "role");
|
|
@@ -8524,9 +8968,12 @@ async function collection(cms, config, user, localized, roles, context) {
|
|
|
8524
8968
|
if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
|
|
8525
8969
|
if (config.icon) result.icon = config.icon;
|
|
8526
8970
|
if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
|
|
8527
|
-
|
|
8971
|
+
const group = config.admin?.group;
|
|
8972
|
+
if (group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
|
|
8973
|
+
else if (group !== void 0) result.section = group;
|
|
8528
8974
|
if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
|
|
8529
8975
|
if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
|
|
8976
|
+
if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
|
|
8530
8977
|
const owner = cms.roles.ownerOf(config.slug);
|
|
8531
8978
|
if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
|
|
8532
8979
|
if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
|
|
@@ -8542,7 +8989,10 @@ async function global(cms, config, user, localized, context) {
|
|
|
8542
8989
|
versions: Boolean(config.versions),
|
|
8543
8990
|
preview: typeof config.preview === "function",
|
|
8544
8991
|
schedule: config.schedule === true,
|
|
8545
|
-
fields: await serializeFields(config.fields, checks, localized, folderIds(cms)
|
|
8992
|
+
fields: await serializeFields(config.fields, checks, localized, folderIds(cms, user, context), {
|
|
8993
|
+
user,
|
|
8994
|
+
context
|
|
8995
|
+
}),
|
|
8546
8996
|
permissions: {
|
|
8547
8997
|
read: await may(cms, config.access?.read, user, target, "read", context),
|
|
8548
8998
|
update: await may(cms, config.access?.update, user, target, "update", context),
|
|
@@ -8643,12 +9093,12 @@ async function widgets(cms, user) {
|
|
|
8643
9093
|
}
|
|
8644
9094
|
return out;
|
|
8645
9095
|
}
|
|
8646
|
-
function folderIds(cms) {
|
|
8647
|
-
return cms.folders.enabled ? (key2) => cms.
|
|
9096
|
+
function folderIds(cms, user, context) {
|
|
9097
|
+
return cms.folders.enabled ? (key2) => cms.keyedFolder(key2, { user, context }) : void 0;
|
|
8648
9098
|
}
|
|
8649
9099
|
|
|
8650
9100
|
// src/version.ts
|
|
8651
|
-
var VERSION = true ? "0.
|
|
9101
|
+
var VERSION = true ? "0.46.0" : "0.0.0-dev";
|
|
8652
9102
|
|
|
8653
9103
|
// src/rest/admin-status.ts
|
|
8654
9104
|
var DAY2 = 864e5;
|
|
@@ -8745,7 +9195,8 @@ async function pickerFilter(cms, url, target, user, context = {}) {
|
|
|
8745
9195
|
const slug2 = dot > 0 ? spec.slice(0, dot) : "";
|
|
8746
9196
|
const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug2) : cms.config.globals.find((g) => g.slug === slug2);
|
|
8747
9197
|
const field = owner ? fieldAt(owner.fields, spec.slice(dot + 1)) : void 0;
|
|
8748
|
-
|
|
9198
|
+
const to = field?.type === "upload" ? "media" : field?.type === "relationship" ? field.to : null;
|
|
9199
|
+
if (!field || field.type !== "relationship" && field.type !== "upload" || to !== target)
|
|
8749
9200
|
throw new QueryError(`filterFor: no relationship to "${target}" at "${spec}"`);
|
|
8750
9201
|
if (!field.filterOptions) return void 0;
|
|
8751
9202
|
const rawId = url.searchParams.get("filterId");
|
|
@@ -8927,6 +9378,7 @@ async function respond(cms, request, url, path, root, options, production) {
|
|
|
8927
9378
|
if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
|
|
8928
9379
|
const audit = {
|
|
8929
9380
|
user,
|
|
9381
|
+
context,
|
|
8930
9382
|
ip: options.getClientIp?.(request),
|
|
8931
9383
|
userAgent: request.headers.get("user-agent")
|
|
8932
9384
|
};
|
|
@@ -9019,8 +9471,9 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
9019
9471
|
context: ctx.context,
|
|
9020
9472
|
...parseLocale(ctx.url)
|
|
9021
9473
|
};
|
|
9022
|
-
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
9474
|
+
const draft = ctx.user !== null && ctx.user.member !== true && ctx.url.searchParams.get("draft") === "true";
|
|
9023
9475
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
9476
|
+
if (first === "admin" && ctx.user?.member === true) throw new ForbiddenError();
|
|
9024
9477
|
if (first === "auth") return ssoRoute(cms, ctx, method, segments);
|
|
9025
9478
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
9026
9479
|
switch (`${method} ${second}`) {
|
|
@@ -9093,6 +9546,45 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
9093
9546
|
body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
|
|
9094
9547
|
};
|
|
9095
9548
|
}
|
|
9549
|
+
// Members signing up (`auth.members.signup`): the form's token, then the account.
|
|
9550
|
+
case "GET signup":
|
|
9551
|
+
return { body: cms.auth.signupForm() };
|
|
9552
|
+
case "POST signup": {
|
|
9553
|
+
const body = await readJson(ctx.request);
|
|
9554
|
+
const result = await cms.auth.signup({
|
|
9555
|
+
email: String(body.email ?? ""),
|
|
9556
|
+
password: String(body.password ?? ""),
|
|
9557
|
+
name: typeof body.name === "string" ? body.name : void 0,
|
|
9558
|
+
token: body.token,
|
|
9559
|
+
honeypot: body[SIGNUP_HONEYPOT],
|
|
9560
|
+
turnstile: body.turnstile,
|
|
9561
|
+
ip: options.getClientIp?.(ctx.request),
|
|
9562
|
+
origin: ctx.url.origin,
|
|
9563
|
+
locale: typeof body.locale === "string" ? body.locale : void 0,
|
|
9564
|
+
context: ctx.context
|
|
9565
|
+
});
|
|
9566
|
+
if (result.verify)
|
|
9567
|
+
return {
|
|
9568
|
+
status: 202,
|
|
9569
|
+
body: { verify: true, message: "Check your email for a link to confirm it." }
|
|
9570
|
+
};
|
|
9571
|
+
setSessionCookies(cms, ctx, result);
|
|
9572
|
+
return {
|
|
9573
|
+
status: 201,
|
|
9574
|
+
body: { user: result.user, exp: result.expiresAt, csrfToken: result.csrfToken }
|
|
9575
|
+
};
|
|
9576
|
+
}
|
|
9577
|
+
case "POST verify-email": {
|
|
9578
|
+
const body = await readJson(ctx.request);
|
|
9579
|
+
const session = await cms.auth.verifyEmail({
|
|
9580
|
+
token: String(body.token ?? ""),
|
|
9581
|
+
ip: options.getClientIp?.(ctx.request)
|
|
9582
|
+
});
|
|
9583
|
+
setSessionCookies(cms, ctx, session);
|
|
9584
|
+
return {
|
|
9585
|
+
body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
|
|
9586
|
+
};
|
|
9587
|
+
}
|
|
9096
9588
|
case "POST first-register": {
|
|
9097
9589
|
const body = await readJson(ctx.request);
|
|
9098
9590
|
const session = await cms.auth.registerFirstUser({
|
|
@@ -9199,8 +9691,11 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
9199
9691
|
doc: q.get("doc"),
|
|
9200
9692
|
actor: q.get("actor"),
|
|
9201
9693
|
from: q.get("from"),
|
|
9202
|
-
to: q.get("to")
|
|
9694
|
+
to: q.get("to"),
|
|
9695
|
+
// Users of a part of the site (e.g. one tenant) see its entries; others the chosen part's.
|
|
9696
|
+
scope: cms.audit.scopeOf(ctx.context)
|
|
9203
9697
|
};
|
|
9698
|
+
if (ctx.user.scoped && !filter.scope) throw new ForbiddenError();
|
|
9204
9699
|
if (second === "audit.csv" && third === void 0) {
|
|
9205
9700
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
9206
9701
|
return {
|
|
@@ -10214,6 +10709,7 @@ export {
|
|
|
10214
10709
|
anyone,
|
|
10215
10710
|
applyDefaults,
|
|
10216
10711
|
applyPlugins,
|
|
10712
|
+
checkFormToken,
|
|
10217
10713
|
configSignature,
|
|
10218
10714
|
consoleEmail,
|
|
10219
10715
|
consoleLogger,
|
|
@@ -10228,15 +10724,18 @@ export {
|
|
|
10228
10724
|
expandMimeTypes,
|
|
10229
10725
|
fillMissing,
|
|
10230
10726
|
findConfigFile,
|
|
10727
|
+
formToken,
|
|
10231
10728
|
generateSlugs,
|
|
10232
10729
|
generateTypes,
|
|
10233
10730
|
hasRows,
|
|
10234
10731
|
hashPassword,
|
|
10732
|
+
honeypotName,
|
|
10235
10733
|
imageDimensions,
|
|
10236
10734
|
importConfig,
|
|
10237
10735
|
isAdmin,
|
|
10238
10736
|
isHasMany,
|
|
10239
10737
|
isLoggedIn,
|
|
10738
|
+
isSignedIn,
|
|
10240
10739
|
isSystemAdmin,
|
|
10241
10740
|
keyAllows,
|
|
10242
10741
|
loadConfig,
|
|
@@ -10246,6 +10745,7 @@ export {
|
|
|
10246
10745
|
mimeAllowedBy,
|
|
10247
10746
|
parseId,
|
|
10248
10747
|
populate,
|
|
10748
|
+
rateKeys,
|
|
10249
10749
|
readCookie,
|
|
10250
10750
|
resolveAdminModule,
|
|
10251
10751
|
resolveConfig,
|
|
@@ -10255,8 +10755,10 @@ export {
|
|
|
10255
10755
|
singularize,
|
|
10256
10756
|
slugify,
|
|
10257
10757
|
sniffMimeType,
|
|
10758
|
+
uniqueWithinOf,
|
|
10258
10759
|
validateConfig,
|
|
10259
10760
|
validateFields,
|
|
10260
10761
|
verifyPassword,
|
|
10762
|
+
verifyTurnstile,
|
|
10261
10763
|
writeBackupFile
|
|
10262
10764
|
};
|