@easy-cms/core 0.45.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 +244 -18
- package/dist/index.js +446 -49
- 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),
|
|
@@ -1273,6 +1292,48 @@ async function filterInput(fields, data, update2) {
|
|
|
1273
1292
|
return result;
|
|
1274
1293
|
}
|
|
1275
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
|
+
|
|
1276
1337
|
// src/auth/cookie.ts
|
|
1277
1338
|
var SESSION_COOKIE = "ecms-session";
|
|
1278
1339
|
|
|
@@ -1289,6 +1350,21 @@ function email(subject, lines, button) {
|
|
|
1289
1350
|
var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
|
|
1290
1351
|
var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
|
|
1291
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
|
+
),
|
|
1292
1368
|
resetPassword: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
|
|
1293
1369
|
"\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
|
|
1294
1370
|
[
|
|
@@ -1339,7 +1415,7 @@ var DEFAULT_PASSWORD_EMAILS = {
|
|
|
1339
1415
|
};
|
|
1340
1416
|
|
|
1341
1417
|
// src/auth/password.ts
|
|
1342
|
-
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as
|
|
1418
|
+
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
1343
1419
|
var MIN_PASSWORD_LENGTH = 8;
|
|
1344
1420
|
var N = 2 ** 17;
|
|
1345
1421
|
var R = 8;
|
|
@@ -1374,7 +1450,7 @@ async function verifyPassword(password, stored) {
|
|
|
1374
1450
|
Number(p),
|
|
1375
1451
|
expected.length
|
|
1376
1452
|
);
|
|
1377
|
-
return key2.length === expected.length &&
|
|
1453
|
+
return key2.length === expected.length && timingSafeEqual3(key2, expected);
|
|
1378
1454
|
}
|
|
1379
1455
|
var dummyHash;
|
|
1380
1456
|
async function fakeVerify(password) {
|
|
@@ -1384,10 +1460,10 @@ async function fakeVerify(password) {
|
|
|
1384
1460
|
}
|
|
1385
1461
|
|
|
1386
1462
|
// src/auth/sso.ts
|
|
1387
|
-
import { createHmac as
|
|
1463
|
+
import { createHmac as createHmac4, randomBytes as randomBytes4 } from "crypto";
|
|
1388
1464
|
|
|
1389
1465
|
// src/auth/tokens.ts
|
|
1390
|
-
import { createHash as createHash2, createHmac as
|
|
1466
|
+
import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
1391
1467
|
function newToken() {
|
|
1392
1468
|
return randomBytes3(32).toString("base64url");
|
|
1393
1469
|
}
|
|
@@ -1395,7 +1471,7 @@ function hashToken(token) {
|
|
|
1395
1471
|
return createHash2("sha256").update(token).digest("hex");
|
|
1396
1472
|
}
|
|
1397
1473
|
function hmac(secret, value) {
|
|
1398
|
-
return
|
|
1474
|
+
return createHmac3("sha256", secret).update(value).digest("base64url");
|
|
1399
1475
|
}
|
|
1400
1476
|
function signToken(secret, token) {
|
|
1401
1477
|
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
@@ -1412,7 +1488,7 @@ function csrfToken(secret, token) {
|
|
|
1412
1488
|
function safeEqual(a, b) {
|
|
1413
1489
|
const left = Buffer.from(a);
|
|
1414
1490
|
const right = Buffer.from(b);
|
|
1415
|
-
return left.length === right.length &&
|
|
1491
|
+
return left.length === right.length && timingSafeEqual4(left, right);
|
|
1416
1492
|
}
|
|
1417
1493
|
function signPreviewToken(secret, target, expiresAt) {
|
|
1418
1494
|
const payload = Buffer.from(
|
|
@@ -1454,7 +1530,7 @@ function readPasswordToken(token) {
|
|
|
1454
1530
|
try {
|
|
1455
1531
|
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1456
1532
|
if (typeof data.u !== "string" || typeof data.e !== "number") return null;
|
|
1457
|
-
if (data.p !== "reset" && data.p !== "invite") return null;
|
|
1533
|
+
if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
|
|
1458
1534
|
return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
|
|
1459
1535
|
} catch {
|
|
1460
1536
|
return null;
|
|
@@ -1471,19 +1547,19 @@ function passwordTokenMatches(secret, token, fingerprint) {
|
|
|
1471
1547
|
var SSO_COOKIE = "ecms-sso";
|
|
1472
1548
|
var PENDING_TTL = 10 * 6e4;
|
|
1473
1549
|
var random = () => randomBytes4(32).toString("base64url");
|
|
1474
|
-
function
|
|
1475
|
-
return
|
|
1550
|
+
function sign2(secret, payload) {
|
|
1551
|
+
return createHmac4("sha256", secret).update(`sso:${payload}`).digest("base64url");
|
|
1476
1552
|
}
|
|
1477
1553
|
function seal(secret, pending) {
|
|
1478
1554
|
const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
|
|
1479
|
-
return `${payload}.${
|
|
1555
|
+
return `${payload}.${sign2(secret, payload)}`;
|
|
1480
1556
|
}
|
|
1481
1557
|
function unseal(secret, value) {
|
|
1482
1558
|
if (!value) return null;
|
|
1483
1559
|
const dot = value.lastIndexOf(".");
|
|
1484
1560
|
if (dot <= 0) return null;
|
|
1485
1561
|
const payload = value.slice(0, dot);
|
|
1486
|
-
if (!safeEqual(value.slice(dot + 1),
|
|
1562
|
+
if (!safeEqual(value.slice(dot + 1), sign2(secret, payload))) return null;
|
|
1487
1563
|
try {
|
|
1488
1564
|
const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1489
1565
|
return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
|
|
@@ -1761,6 +1837,16 @@ var SingleSignOn = class {
|
|
|
1761
1837
|
|
|
1762
1838
|
// src/auth/auth.ts
|
|
1763
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
|
+
}
|
|
1764
1850
|
var RESET_REQUESTS = 3;
|
|
1765
1851
|
var Auth = class {
|
|
1766
1852
|
constructor(cms) {
|
|
@@ -1810,6 +1896,16 @@ var Auth = class {
|
|
|
1810
1896
|
throw new UnauthorizedError(INVALID);
|
|
1811
1897
|
}
|
|
1812
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
|
+
}
|
|
1813
1909
|
if (!this.sso.passwordAllowed(user)) {
|
|
1814
1910
|
await this.cms.audit.record({
|
|
1815
1911
|
action: "login.failed",
|
|
@@ -1852,6 +1948,136 @@ var Auth = class {
|
|
|
1852
1948
|
const raw = await this.db.findById({ collection: USERS, id: user.id });
|
|
1853
1949
|
return this.startSession(raw);
|
|
1854
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
|
+
}
|
|
1855
2081
|
async hasUsers() {
|
|
1856
2082
|
return await this.db.count({ collection: USERS }) > 0;
|
|
1857
2083
|
}
|
|
@@ -2024,7 +2250,7 @@ var Auth = class {
|
|
|
2024
2250
|
}
|
|
2025
2251
|
async passwordTokenUser(token) {
|
|
2026
2252
|
const claims = readPasswordToken(token);
|
|
2027
|
-
if (!claims || claims.expiresAt <= Date.now()) return null;
|
|
2253
|
+
if (!claims || claims.purpose === "verify" || claims.expiresAt <= Date.now()) return null;
|
|
2028
2254
|
const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
|
|
2029
2255
|
const user = await this.db.findById({ collection: USERS, id });
|
|
2030
2256
|
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
|
|
@@ -2043,7 +2269,8 @@ var Auth = class {
|
|
|
2043
2269
|
fingerprint: passwordFingerprint(user.passwordHash)
|
|
2044
2270
|
});
|
|
2045
2271
|
const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
|
|
2046
|
-
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)}`;
|
|
2047
2274
|
const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
|
|
2048
2275
|
const content = await write({
|
|
2049
2276
|
user: await this.toAuthUser(user),
|
|
@@ -2136,7 +2363,8 @@ var Auth = class {
|
|
|
2136
2363
|
}
|
|
2137
2364
|
async toAuthUser(user) {
|
|
2138
2365
|
const fields = this.cms.collection(USERS).fields;
|
|
2139
|
-
|
|
2366
|
+
const stripped = await stripFields(fields, user);
|
|
2367
|
+
return asMember(stripped, this.config.auth.members.roles);
|
|
2140
2368
|
}
|
|
2141
2369
|
async deleteExpiredSessions(userId) {
|
|
2142
2370
|
const expired = await this.db.find({
|
|
@@ -3245,7 +3473,7 @@ function applyFieldTypes(config) {
|
|
|
3245
3473
|
}
|
|
3246
3474
|
|
|
3247
3475
|
// src/local-api.ts
|
|
3248
|
-
import { createHmac as
|
|
3476
|
+
import { createHmac as createHmac6, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
3249
3477
|
|
|
3250
3478
|
// src/localization.ts
|
|
3251
3479
|
var ALL_LOCALES = "all";
|
|
@@ -4610,7 +4838,7 @@ function rank(role, config) {
|
|
|
4610
4838
|
}
|
|
4611
4839
|
|
|
4612
4840
|
// src/webhooks.ts
|
|
4613
|
-
import { createHmac as
|
|
4841
|
+
import { createHmac as createHmac5, randomUUID as randomUUID2 } from "crypto";
|
|
4614
4842
|
var WEBHOOK_EVENTS = [
|
|
4615
4843
|
"create",
|
|
4616
4844
|
"update",
|
|
@@ -4754,7 +4982,7 @@ var Webhooks = class {
|
|
|
4754
4982
|
...hook.headers
|
|
4755
4983
|
};
|
|
4756
4984
|
if (hook.secret) {
|
|
4757
|
-
headers["x-easy-cms-signature"] = `sha256=${
|
|
4985
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac5("sha256", hook.secret).update(body).digest("hex")}`;
|
|
4758
4986
|
}
|
|
4759
4987
|
try {
|
|
4760
4988
|
const response = await this.fetcher(hook.url, {
|
|
@@ -4780,7 +5008,9 @@ var Webhooks = class {
|
|
|
4780
5008
|
}
|
|
4781
5009
|
};
|
|
4782
5010
|
var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
|
|
5011
|
+
var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
|
|
4783
5012
|
function matches(hook, payload) {
|
|
5013
|
+
if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
|
|
4784
5014
|
if (hook.events && !hook.events.includes(payload.event)) return false;
|
|
4785
5015
|
if (payload.collection !== void 0)
|
|
4786
5016
|
return !hook.collections || hook.collections.includes(payload.collection);
|
|
@@ -4857,6 +5087,8 @@ function validateConfig(config) {
|
|
|
4857
5087
|
});
|
|
4858
5088
|
validateEndpoints(config, collectionSlugs, add);
|
|
4859
5089
|
validateCommands(config, add);
|
|
5090
|
+
validateJobs(config, add);
|
|
5091
|
+
validateEvents(config, add);
|
|
4860
5092
|
const apiKeys = config.apiKeys;
|
|
4861
5093
|
if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
|
|
4862
5094
|
collections.forEach((collection2, i) => {
|
|
@@ -5118,6 +5350,7 @@ function validateAuth(config, add) {
|
|
|
5118
5350
|
add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
|
|
5119
5351
|
}
|
|
5120
5352
|
}
|
|
5353
|
+
validateMembers(auth.members, auth.roles ?? ["admin", "editor"], add);
|
|
5121
5354
|
for (const key2 of [
|
|
5122
5355
|
"tokenExpiration",
|
|
5123
5356
|
"maxLoginAttempts",
|
|
@@ -5176,6 +5409,31 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
|
|
|
5176
5409
|
"blocks",
|
|
5177
5410
|
"array"
|
|
5178
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
|
+
}
|
|
5179
5437
|
function validateLocalization(config, add) {
|
|
5180
5438
|
const localization = config.localization;
|
|
5181
5439
|
if (localization !== void 0 && localization !== null) {
|
|
@@ -5257,14 +5515,19 @@ function validateWebhooks(config, add) {
|
|
|
5257
5515
|
...(config.collections ?? []).map((c) => c?.slug)
|
|
5258
5516
|
]);
|
|
5259
5517
|
const globals = new Set((config.globals ?? []).map((g) => g?.slug));
|
|
5518
|
+
const custom = new Set(config.events ?? []);
|
|
5260
5519
|
for (const [i, hook] of hooks.entries()) {
|
|
5261
5520
|
const path = `webhooks[${i}]`;
|
|
5262
5521
|
if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
|
|
5263
5522
|
add(`${path}.url`, "must be an http(s) URL");
|
|
5264
5523
|
}
|
|
5265
5524
|
for (const event of hook?.events ?? []) {
|
|
5266
|
-
if (!WEBHOOK_EVENTS.includes(event))
|
|
5267
|
-
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
|
+
);
|
|
5268
5531
|
}
|
|
5269
5532
|
for (const slug2 of hook?.collections ?? [])
|
|
5270
5533
|
if (!collections.has(slug2)) add(`${path}.collections`, `unknown collection "${slug2}"`);
|
|
@@ -5458,8 +5721,12 @@ function validateContainer(container, path, collectionSlugs, add, { collection:
|
|
|
5458
5721
|
const sidebar = admin.sidebar;
|
|
5459
5722
|
if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
|
|
5460
5723
|
const group = admin.group;
|
|
5461
|
-
if (group !== void 0 && group !== "settings")
|
|
5462
|
-
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
|
+
);
|
|
5463
5730
|
const list = admin.list;
|
|
5464
5731
|
if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
|
|
5465
5732
|
const owner = admin.ownerField;
|
|
@@ -5730,6 +5997,38 @@ function validateCommands(config, add) {
|
|
|
5730
5997
|
if (typeof run !== "function") add(`${path}.run`, "must be a function");
|
|
5731
5998
|
});
|
|
5732
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
|
+
}
|
|
5733
6032
|
function validateEndpoints(config, collectionSlugs, add) {
|
|
5734
6033
|
const endpoints = config.endpoints;
|
|
5735
6034
|
if (endpoints === void 0) return;
|
|
@@ -5909,7 +6208,8 @@ async function resolveConfig(input) {
|
|
|
5909
6208
|
trustedOrigins: config.auth?.trustedOrigins ?? [],
|
|
5910
6209
|
resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
|
|
5911
6210
|
inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
|
|
5912
|
-
emails: config.auth?.emails ?? {}
|
|
6211
|
+
emails: config.auth?.emails ?? {},
|
|
6212
|
+
members: config.auth?.members ?? { roles: [] }
|
|
5913
6213
|
},
|
|
5914
6214
|
audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
|
|
5915
6215
|
collections: [
|
|
@@ -5929,6 +6229,8 @@ async function resolveConfig(input) {
|
|
|
5929
6229
|
globals: config.globals ?? [],
|
|
5930
6230
|
endpoints: config.endpoints ?? [],
|
|
5931
6231
|
commands: config.commands ?? [],
|
|
6232
|
+
events: config.events ?? [],
|
|
6233
|
+
jobs: config.jobs ?? [],
|
|
5932
6234
|
installedPlugins: (plugins ?? []).map(
|
|
5933
6235
|
(plugin) => typeof plugin === "function" && plugin.info ? { ...plugin.info } : {}
|
|
5934
6236
|
),
|
|
@@ -5968,7 +6270,7 @@ async function createEasyCMS(config, options = {}) {
|
|
|
5968
6270
|
interactive: options.interactive ?? false
|
|
5969
6271
|
});
|
|
5970
6272
|
const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
|
|
5971
|
-
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;
|
|
5972
6274
|
if (scheduling && options.scheduler !== false) cms.startScheduler();
|
|
5973
6275
|
return cms;
|
|
5974
6276
|
}
|
|
@@ -6050,8 +6352,10 @@ var EasyCMS = class {
|
|
|
6050
6352
|
user,
|
|
6051
6353
|
cms: this
|
|
6052
6354
|
});
|
|
6355
|
+
const changed = result?.user !== void 0 ? result.user : user;
|
|
6053
6356
|
return {
|
|
6054
|
-
|
|
6357
|
+
// `onRequest` may change the role (e.g. per tenant): membership follows it.
|
|
6358
|
+
user: changed && asMember(changed, this.config.auth.members.roles),
|
|
6055
6359
|
context: result?.context ?? NO_CONTEXT
|
|
6056
6360
|
};
|
|
6057
6361
|
}
|
|
@@ -6317,7 +6621,7 @@ var EasyCMS = class {
|
|
|
6317
6621
|
/** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
|
|
6318
6622
|
sign(purpose, value) {
|
|
6319
6623
|
const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
6320
|
-
const mac =
|
|
6624
|
+
const mac = createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`);
|
|
6321
6625
|
return `${payload}.${mac.digest("base64url")}`;
|
|
6322
6626
|
}
|
|
6323
6627
|
/** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
|
|
@@ -6325,10 +6629,10 @@ var EasyCMS = class {
|
|
|
6325
6629
|
const [payload, signature] = String(token).split(".");
|
|
6326
6630
|
if (!payload || !signature) return void 0;
|
|
6327
6631
|
const expected = Buffer.from(
|
|
6328
|
-
|
|
6632
|
+
createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
|
|
6329
6633
|
);
|
|
6330
6634
|
const given = Buffer.from(signature);
|
|
6331
|
-
if (expected.length !== given.length || !
|
|
6635
|
+
if (expected.length !== given.length || !timingSafeEqual5(expected, given)) return void 0;
|
|
6332
6636
|
try {
|
|
6333
6637
|
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
6334
6638
|
} catch {
|
|
@@ -6447,10 +6751,10 @@ var EasyCMS = class {
|
|
|
6447
6751
|
if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
|
|
6448
6752
|
const expected = Buffer.from(this.mediaSignature(key2, at));
|
|
6449
6753
|
const given = Buffer.from(signature);
|
|
6450
|
-
return expected.length === given.length &&
|
|
6754
|
+
return expected.length === given.length && timingSafeEqual5(expected, given);
|
|
6451
6755
|
}
|
|
6452
6756
|
mediaSignature(key2, expires) {
|
|
6453
|
-
return
|
|
6757
|
+
return createHmac6("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
|
|
6454
6758
|
}
|
|
6455
6759
|
/**
|
|
6456
6760
|
* How many documents use any of these files in a top-level upload field (rich text and fields
|
|
@@ -6574,6 +6878,17 @@ var EasyCMS = class {
|
|
|
6574
6878
|
async update(collection2, id, data, options = {}) {
|
|
6575
6879
|
return await this.updateDocument(collection2, id, asObject(data, collection2), options);
|
|
6576
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
|
+
}
|
|
6577
6892
|
/**
|
|
6578
6893
|
* Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
|
|
6579
6894
|
* `update` with `status: 'draft'` only saves a draft and leaves the published document live;
|
|
@@ -6846,7 +7161,7 @@ var EasyCMS = class {
|
|
|
6846
7161
|
previousDoc: current,
|
|
6847
7162
|
operation: "update"
|
|
6848
7163
|
});
|
|
6849
|
-
this.
|
|
7164
|
+
this.emitChange(config, "draft", draft);
|
|
6850
7165
|
await this.audited(config, "draft", draft, current, guard, mode);
|
|
6851
7166
|
const [out2] = await this.output(config, [draft], guard, {
|
|
6852
7167
|
...options,
|
|
@@ -6874,11 +7189,9 @@ var EasyCMS = class {
|
|
|
6874
7189
|
Object.assign(prepared, moved.patch);
|
|
6875
7190
|
}
|
|
6876
7191
|
}
|
|
6877
|
-
const
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
|
|
6881
|
-
});
|
|
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;
|
|
6882
7195
|
if (moved) await moved.cleanUp();
|
|
6883
7196
|
if (collection2 === MEDIA_FOLDERS) {
|
|
6884
7197
|
this.folders.invalidate();
|
|
@@ -6893,7 +7206,7 @@ var EasyCMS = class {
|
|
|
6893
7206
|
previousDoc: current,
|
|
6894
7207
|
operation: "update"
|
|
6895
7208
|
});
|
|
6896
|
-
this.
|
|
7209
|
+
this.emitChange(config, "update", doc, existing.status);
|
|
6897
7210
|
await this.audited(config, "update", doc, current, guard, mode);
|
|
6898
7211
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
6899
7212
|
return out;
|
|
@@ -6948,7 +7261,7 @@ var EasyCMS = class {
|
|
|
6948
7261
|
id: parsed,
|
|
6949
7262
|
doc: existing
|
|
6950
7263
|
});
|
|
6951
|
-
this.
|
|
7264
|
+
this.emitChange(config, "delete", existing);
|
|
6952
7265
|
await this.audited(config, "delete", existing, existing, guard);
|
|
6953
7266
|
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
6954
7267
|
return out;
|
|
@@ -7106,7 +7419,7 @@ var EasyCMS = class {
|
|
|
7106
7419
|
previousDoc: current,
|
|
7107
7420
|
operation: "update"
|
|
7108
7421
|
});
|
|
7109
|
-
this.
|
|
7422
|
+
this.emitChange(config, "draft", draft);
|
|
7110
7423
|
await this.audited(config, "draft", draft, current, guard, mode);
|
|
7111
7424
|
return this.readGlobal(config, key2, guard, { ...options, draft: true });
|
|
7112
7425
|
}
|
|
@@ -7118,7 +7431,7 @@ var EasyCMS = class {
|
|
|
7118
7431
|
previousDoc: current,
|
|
7119
7432
|
operation: "update"
|
|
7120
7433
|
});
|
|
7121
|
-
this.
|
|
7434
|
+
this.emitChange(config, "update", doc, existing.status);
|
|
7122
7435
|
await this.audited(config, "update", doc, current, guard, mode);
|
|
7123
7436
|
return this.readGlobal(config, key2, guard, { ...options, draft: true });
|
|
7124
7437
|
}
|
|
@@ -7231,10 +7544,45 @@ var EasyCMS = class {
|
|
|
7231
7544
|
return {
|
|
7232
7545
|
...scheduled,
|
|
7233
7546
|
webhooks: await this.retryWebhooks(now),
|
|
7234
|
-
emails: await this.mailer.retry(now)
|
|
7547
|
+
emails: await this.mailer.retry(now),
|
|
7548
|
+
jobs: await this.runConfigJobs(now)
|
|
7235
7549
|
};
|
|
7236
7550
|
});
|
|
7237
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
|
+
}
|
|
7238
7586
|
/**
|
|
7239
7587
|
* Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
|
|
7240
7588
|
* database until they succeed, so they survive restarts.
|
|
@@ -7744,7 +8092,7 @@ var EasyCMS = class {
|
|
|
7744
8092
|
doc,
|
|
7745
8093
|
operation: "create"
|
|
7746
8094
|
});
|
|
7747
|
-
this.
|
|
8095
|
+
this.emitChange(config, "create", doc);
|
|
7748
8096
|
await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
|
|
7749
8097
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
7750
8098
|
return out;
|
|
@@ -7802,7 +8150,7 @@ var EasyCMS = class {
|
|
|
7802
8150
|
});
|
|
7803
8151
|
}
|
|
7804
8152
|
/** Sends webhook events for a change; `before` is the stored status before it, if any. */
|
|
7805
|
-
|
|
8153
|
+
emitChange(config, event, doc, before) {
|
|
7806
8154
|
const isGlobal = !this.config.collections.includes(config);
|
|
7807
8155
|
if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
|
|
7808
8156
|
const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
|
|
@@ -8228,6 +8576,7 @@ var UPLOAD_URL_SECONDS = 15 * 60;
|
|
|
8228
8576
|
var UPLOAD_TICKET_SECONDS = 24 * 60 * 60;
|
|
8229
8577
|
var SNIFF_BYTES = 64 * 1024;
|
|
8230
8578
|
var MAX_FILES_TO_MOVE = 200;
|
|
8579
|
+
var JOB_RUNS = "easy-cms:jobs";
|
|
8231
8580
|
function durationSeconds(value) {
|
|
8232
8581
|
const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
|
|
8233
8582
|
const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
|
|
@@ -8619,7 +8968,9 @@ async function collection(cms, config, user, localized, roles, context) {
|
|
|
8619
8968
|
if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
|
|
8620
8969
|
if (config.icon) result.icon = config.icon;
|
|
8621
8970
|
if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
|
|
8622
|
-
|
|
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;
|
|
8623
8974
|
if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
|
|
8624
8975
|
if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
|
|
8625
8976
|
if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
|
|
@@ -8747,7 +9098,7 @@ function folderIds(cms, user, context) {
|
|
|
8747
9098
|
}
|
|
8748
9099
|
|
|
8749
9100
|
// src/version.ts
|
|
8750
|
-
var VERSION = true ? "0.
|
|
9101
|
+
var VERSION = true ? "0.46.0" : "0.0.0-dev";
|
|
8751
9102
|
|
|
8752
9103
|
// src/rest/admin-status.ts
|
|
8753
9104
|
var DAY2 = 864e5;
|
|
@@ -9120,8 +9471,9 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
9120
9471
|
context: ctx.context,
|
|
9121
9472
|
...parseLocale(ctx.url)
|
|
9122
9473
|
};
|
|
9123
|
-
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";
|
|
9124
9475
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
9476
|
+
if (first === "admin" && ctx.user?.member === true) throw new ForbiddenError();
|
|
9125
9477
|
if (first === "auth") return ssoRoute(cms, ctx, method, segments);
|
|
9126
9478
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
9127
9479
|
switch (`${method} ${second}`) {
|
|
@@ -9194,6 +9546,45 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
9194
9546
|
body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
|
|
9195
9547
|
};
|
|
9196
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
|
+
}
|
|
9197
9588
|
case "POST first-register": {
|
|
9198
9589
|
const body = await readJson(ctx.request);
|
|
9199
9590
|
const session = await cms.auth.registerFirstUser({
|
|
@@ -10318,6 +10709,7 @@ export {
|
|
|
10318
10709
|
anyone,
|
|
10319
10710
|
applyDefaults,
|
|
10320
10711
|
applyPlugins,
|
|
10712
|
+
checkFormToken,
|
|
10321
10713
|
configSignature,
|
|
10322
10714
|
consoleEmail,
|
|
10323
10715
|
consoleLogger,
|
|
@@ -10332,15 +10724,18 @@ export {
|
|
|
10332
10724
|
expandMimeTypes,
|
|
10333
10725
|
fillMissing,
|
|
10334
10726
|
findConfigFile,
|
|
10727
|
+
formToken,
|
|
10335
10728
|
generateSlugs,
|
|
10336
10729
|
generateTypes,
|
|
10337
10730
|
hasRows,
|
|
10338
10731
|
hashPassword,
|
|
10732
|
+
honeypotName,
|
|
10339
10733
|
imageDimensions,
|
|
10340
10734
|
importConfig,
|
|
10341
10735
|
isAdmin,
|
|
10342
10736
|
isHasMany,
|
|
10343
10737
|
isLoggedIn,
|
|
10738
|
+
isSignedIn,
|
|
10344
10739
|
isSystemAdmin,
|
|
10345
10740
|
keyAllows,
|
|
10346
10741
|
loadConfig,
|
|
@@ -10350,6 +10745,7 @@ export {
|
|
|
10350
10745
|
mimeAllowedBy,
|
|
10351
10746
|
parseId,
|
|
10352
10747
|
populate,
|
|
10748
|
+
rateKeys,
|
|
10353
10749
|
readCookie,
|
|
10354
10750
|
resolveAdminModule,
|
|
10355
10751
|
resolveConfig,
|
|
@@ -10363,5 +10759,6 @@ export {
|
|
|
10363
10759
|
validateConfig,
|
|
10364
10760
|
validateFields,
|
|
10365
10761
|
verifyPassword,
|
|
10762
|
+
verifyTurnstile,
|
|
10366
10763
|
writeBackupFile
|
|
10367
10764
|
};
|