@easy-cms/core 0.13.1 → 0.16.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 +1009 -881
- package/dist/index.js +298 -17
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3,6 +3,132 @@ var anyone = () => true;
|
|
|
3
3
|
var isLoggedIn = ({ user }) => user !== null;
|
|
4
4
|
var isAdmin = ({ user }) => user?.role === "admin";
|
|
5
5
|
|
|
6
|
+
// src/api-keys.ts
|
|
7
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
8
|
+
var API_KEYS = "api-keys";
|
|
9
|
+
var API_KEY_OPERATIONS = ["read", "create", "update", "delete", "publish"];
|
|
10
|
+
var API_KEY_GLOBAL_OPERATIONS = ["read", "update", "publish"];
|
|
11
|
+
var API_KEY_EXCLUDED = /* @__PURE__ */ new Set(["users", API_KEYS]);
|
|
12
|
+
var KEY_PATTERN = /^ecms_([0-9a-f]{8})_([A-Za-z0-9_-]{32,})$/;
|
|
13
|
+
function isApiKey(token) {
|
|
14
|
+
return token.startsWith("ecms_");
|
|
15
|
+
}
|
|
16
|
+
function newApiKey() {
|
|
17
|
+
const prefix = randomBytes(4).toString("hex");
|
|
18
|
+
const secret = randomBytes(32).toString("base64url");
|
|
19
|
+
return { key: `ecms_${prefix}_${secret}`, prefix, hash: hashSecret(secret) };
|
|
20
|
+
}
|
|
21
|
+
function hashSecret(secret) {
|
|
22
|
+
return createHash("sha256").update(secret).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
function parseApiKey(token) {
|
|
25
|
+
const match = KEY_PATTERN.exec(token);
|
|
26
|
+
return match ? { prefix: match[1], secret: match[2] } : null;
|
|
27
|
+
}
|
|
28
|
+
function secretMatches(secret, hash) {
|
|
29
|
+
if (typeof hash !== "string") return false;
|
|
30
|
+
const a = Buffer.from(hashSecret(secret), "hex");
|
|
31
|
+
const b = Buffer.from(hash, "hex");
|
|
32
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
33
|
+
}
|
|
34
|
+
function keyAllows(user, target, operation) {
|
|
35
|
+
const key2 = user?.apiKey;
|
|
36
|
+
if (!key2) return true;
|
|
37
|
+
if ("collection" in target) {
|
|
38
|
+
if (API_KEY_EXCLUDED.has(target.collection)) return false;
|
|
39
|
+
return key2.permissions.collections?.[target.collection]?.includes(operation) ?? false;
|
|
40
|
+
}
|
|
41
|
+
const allowed2 = key2.permissions.globals?.[target.global];
|
|
42
|
+
return allowed2?.includes(operation) ?? false;
|
|
43
|
+
}
|
|
44
|
+
var isAdmin2 = (user) => user?.role === "admin";
|
|
45
|
+
var ownKeys = ({ user }) => !user || user.apiKey ? false : isAdmin2(user) ? true : { user: { equals: user.id } };
|
|
46
|
+
var system = { update: () => false };
|
|
47
|
+
var apiKeysCollection = {
|
|
48
|
+
slug: API_KEYS,
|
|
49
|
+
labels: {
|
|
50
|
+
singular: { en: "API key", th: "API key" },
|
|
51
|
+
plural: { en: "API keys", th: "API keys" }
|
|
52
|
+
},
|
|
53
|
+
icon: "key",
|
|
54
|
+
useAsTitle: "name",
|
|
55
|
+
access: {
|
|
56
|
+
read: ownKeys,
|
|
57
|
+
// Through `createApiKey` (POST <api>/api-keys), which generates the secret.
|
|
58
|
+
create: ({ user }) => !!user && !user.apiKey,
|
|
59
|
+
update: ownKeys,
|
|
60
|
+
delete: ownKeys
|
|
61
|
+
},
|
|
62
|
+
fields: [
|
|
63
|
+
{ name: "name", type: "text", required: true, label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
|
|
64
|
+
{
|
|
65
|
+
name: "permissions",
|
|
66
|
+
type: "json",
|
|
67
|
+
label: { en: "Permissions", th: "\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C" },
|
|
68
|
+
admin: { component: "ecms-api-key-permissions" }
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "expiresAt",
|
|
72
|
+
type: "date",
|
|
73
|
+
label: { en: "Expires", th: "\u0E2B\u0E21\u0E14\u0E2D\u0E32\u0E22\u0E38" },
|
|
74
|
+
position: "sidebar"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "prefix",
|
|
78
|
+
type: "text",
|
|
79
|
+
index: true,
|
|
80
|
+
label: { en: "Key starts with", th: "\u0E02\u0E36\u0E49\u0E19\u0E15\u0E49\u0E19\u0E14\u0E49\u0E27\u0E22" },
|
|
81
|
+
position: "sidebar",
|
|
82
|
+
access: system
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "user",
|
|
86
|
+
type: "relationship",
|
|
87
|
+
to: "users",
|
|
88
|
+
label: { en: "Owner", th: "\u0E40\u0E08\u0E49\u0E32\u0E02\u0E2D\u0E07" },
|
|
89
|
+
position: "sidebar",
|
|
90
|
+
access: system
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "lastUsedAt",
|
|
94
|
+
type: "date",
|
|
95
|
+
label: { en: "Last used", th: "\u0E43\u0E0A\u0E49\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14" },
|
|
96
|
+
position: "sidebar",
|
|
97
|
+
access: system
|
|
98
|
+
},
|
|
99
|
+
{ name: "keyHash", type: "text", hidden: true }
|
|
100
|
+
],
|
|
101
|
+
hooks: {
|
|
102
|
+
// Only real collections, globals and operations are kept.
|
|
103
|
+
beforeChange: [
|
|
104
|
+
({ data, cms }) => data.permissions === void 0 ? data : {
|
|
105
|
+
...data,
|
|
106
|
+
permissions: cleanPermissions(data.permissions, {
|
|
107
|
+
collections: cms.config.collections.map((c) => c.slug),
|
|
108
|
+
globals: cms.config.globals.map((g) => g.slug)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
function cleanPermissions(value, known) {
|
|
115
|
+
const input = typeof value === "object" && value !== null ? value : {};
|
|
116
|
+
const pick = (group, slugs, operations) => {
|
|
117
|
+
const out = {};
|
|
118
|
+
if (typeof group !== "object" || group === null) return out;
|
|
119
|
+
for (const [slug, ops] of Object.entries(group)) {
|
|
120
|
+
if (!slugs.includes(slug) || API_KEY_EXCLUDED.has(slug) || !Array.isArray(ops)) continue;
|
|
121
|
+
const valid = operations.filter((op) => ops.includes(op));
|
|
122
|
+
if (valid.length) out[slug] = valid;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
collections: pick(input.collections, known.collections, API_KEY_OPERATIONS),
|
|
128
|
+
globals: pick(input.globals, known.globals, API_KEY_GLOBAL_OPERATIONS)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
6
132
|
// src/errors.ts
|
|
7
133
|
var ConfigError = class _ConfigError extends Error {
|
|
8
134
|
issues;
|
|
@@ -412,7 +538,7 @@ var webhookDeliveriesCollection = {
|
|
|
412
538
|
};
|
|
413
539
|
|
|
414
540
|
// src/auth/password.ts
|
|
415
|
-
import { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
541
|
+
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
416
542
|
var MIN_PASSWORD_LENGTH = 8;
|
|
417
543
|
var N = 2 ** 17;
|
|
418
544
|
var R = 8;
|
|
@@ -431,7 +557,7 @@ function derive(password, salt, n, r, p, length) {
|
|
|
431
557
|
});
|
|
432
558
|
}
|
|
433
559
|
async function hashPassword(password) {
|
|
434
|
-
const salt =
|
|
560
|
+
const salt = randomBytes2(16);
|
|
435
561
|
const key2 = await derive(password, salt, N, R, P, KEY_LENGTH);
|
|
436
562
|
return ["scrypt", N, R, P, salt.toString("base64url"), key2.toString("base64url")].join("$");
|
|
437
563
|
}
|
|
@@ -447,7 +573,7 @@ async function verifyPassword(password, stored) {
|
|
|
447
573
|
Number(p),
|
|
448
574
|
expected.length
|
|
449
575
|
);
|
|
450
|
-
return key2.length === expected.length &&
|
|
576
|
+
return key2.length === expected.length && timingSafeEqual2(key2, expected);
|
|
451
577
|
}
|
|
452
578
|
var dummyHash;
|
|
453
579
|
async function fakeVerify(password) {
|
|
@@ -457,12 +583,12 @@ async function fakeVerify(password) {
|
|
|
457
583
|
}
|
|
458
584
|
|
|
459
585
|
// src/auth/tokens.ts
|
|
460
|
-
import { createHash, createHmac, randomBytes as
|
|
586
|
+
import { createHash as createHash2, createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
461
587
|
function newToken() {
|
|
462
|
-
return
|
|
588
|
+
return randomBytes3(32).toString("base64url");
|
|
463
589
|
}
|
|
464
590
|
function hashToken(token) {
|
|
465
|
-
return
|
|
591
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
466
592
|
}
|
|
467
593
|
function hmac(secret, value) {
|
|
468
594
|
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
@@ -482,7 +608,7 @@ function csrfToken(secret, token) {
|
|
|
482
608
|
function safeEqual(a, b) {
|
|
483
609
|
const left = Buffer.from(a);
|
|
484
610
|
const right = Buffer.from(b);
|
|
485
|
-
return left.length === right.length &&
|
|
611
|
+
return left.length === right.length && timingSafeEqual3(left, right);
|
|
486
612
|
}
|
|
487
613
|
function signPreviewToken(secret, target, expiresAt) {
|
|
488
614
|
const payload = Buffer.from(
|
|
@@ -568,9 +694,13 @@ var Auth = class {
|
|
|
568
694
|
const session = await this.findSession(token);
|
|
569
695
|
if (session) await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
570
696
|
}
|
|
571
|
-
/**
|
|
697
|
+
/**
|
|
698
|
+
* Returns the user for a signed session token, or for an API key (`ecms_…`, with `apiKey` set
|
|
699
|
+
* on the user), or `null` if it is invalid or expired.
|
|
700
|
+
*/
|
|
572
701
|
async verify(signedToken) {
|
|
573
702
|
if (!signedToken) return null;
|
|
703
|
+
if (isApiKey(signedToken)) return this.verifyApiKey(signedToken);
|
|
574
704
|
const token = unsignToken(this.config.secret, signedToken);
|
|
575
705
|
if (!token) return null;
|
|
576
706
|
const session = await this.findSession(token);
|
|
@@ -606,6 +736,40 @@ var Auth = class {
|
|
|
606
736
|
for (const session of sessions.docs)
|
|
607
737
|
await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
608
738
|
}
|
|
739
|
+
/** The owner of an API key, limited by the key; `null` when unknown, expired or disabled. */
|
|
740
|
+
async verifyApiKey(token) {
|
|
741
|
+
if (!this.cms.config.collections.some((c) => c.slug === API_KEYS)) return null;
|
|
742
|
+
const parsed = parseApiKey(token);
|
|
743
|
+
if (!parsed) return null;
|
|
744
|
+
const { docs } = await this.db.find({
|
|
745
|
+
collection: API_KEYS,
|
|
746
|
+
where: { prefix: { equals: parsed.prefix } },
|
|
747
|
+
sort: [],
|
|
748
|
+
limit: 1,
|
|
749
|
+
page: 1
|
|
750
|
+
});
|
|
751
|
+
const key2 = docs[0];
|
|
752
|
+
if (!key2 || !secretMatches(parsed.secret, key2.keyHash)) return null;
|
|
753
|
+
const now = /* @__PURE__ */ new Date();
|
|
754
|
+
if (typeof key2.expiresAt === "string" && key2.expiresAt <= now.toISOString()) return null;
|
|
755
|
+
const owner = await this.db.findById({ collection: USERS, id: key2.user });
|
|
756
|
+
if (!owner || owner.active === false) return null;
|
|
757
|
+
const last = typeof key2.lastUsedAt === "string" ? Date.parse(key2.lastUsedAt) : 0;
|
|
758
|
+
if (now.getTime() - last > 6e4) {
|
|
759
|
+
const { id: _id, ...rest } = key2;
|
|
760
|
+
await this.db.update({
|
|
761
|
+
collection: API_KEYS,
|
|
762
|
+
id: key2.id,
|
|
763
|
+
data: { ...rest, lastUsedAt: now.toISOString() }
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
const apiKey = {
|
|
767
|
+
id: key2.id,
|
|
768
|
+
name: String(key2.name ?? ""),
|
|
769
|
+
permissions: key2.permissions ?? {}
|
|
770
|
+
};
|
|
771
|
+
return { ...await this.toAuthUser(owner), apiKey };
|
|
772
|
+
}
|
|
609
773
|
// -------------------------------------------------------------------------
|
|
610
774
|
async startSession(user) {
|
|
611
775
|
const token = newToken();
|
|
@@ -744,12 +908,51 @@ var ADMIN_ICONS = [
|
|
|
744
908
|
"circle-help",
|
|
745
909
|
"award",
|
|
746
910
|
"ticket",
|
|
747
|
-
"camera"
|
|
911
|
+
"camera",
|
|
912
|
+
"key"
|
|
748
913
|
];
|
|
749
914
|
function defineConfig(config) {
|
|
750
915
|
return config;
|
|
751
916
|
}
|
|
752
917
|
|
|
918
|
+
// src/copy.ts
|
|
919
|
+
var BATCH = 200;
|
|
920
|
+
async function copyDatabase(source, target, options = {}) {
|
|
921
|
+
const from = source.transfer;
|
|
922
|
+
const to = target.transfer;
|
|
923
|
+
if (!from || !to) throw new EasyCMSError("This database adapter cannot copy databases", 400);
|
|
924
|
+
if (from.schemaHash !== to.schemaHash) {
|
|
925
|
+
throw new EasyCMSError(
|
|
926
|
+
"The two configs describe different collections or fields. Copy between configs with the same collections (import one config into the other and change only `db`).",
|
|
927
|
+
400
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
const tables = from.tables();
|
|
931
|
+
for (const table of tables) {
|
|
932
|
+
if (await to.count(table) > 0) {
|
|
933
|
+
throw new EasyCMSError(
|
|
934
|
+
`The target database is not empty ("${table}" has rows). Copy into a new, empty database.`,
|
|
935
|
+
400
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
let total = 0;
|
|
940
|
+
for (const table of tables) {
|
|
941
|
+
let copied = 0;
|
|
942
|
+
for (; ; ) {
|
|
943
|
+
const rows = await from.read(table, copied, BATCH);
|
|
944
|
+
if (rows.length === 0) break;
|
|
945
|
+
await to.write(table, rows);
|
|
946
|
+
copied += rows.length;
|
|
947
|
+
if (rows.length < BATCH) break;
|
|
948
|
+
}
|
|
949
|
+
total += copied;
|
|
950
|
+
options.onTable?.({ table, rows: copied });
|
|
951
|
+
}
|
|
952
|
+
await to.finish();
|
|
953
|
+
return { tables: tables.length, rows: total };
|
|
954
|
+
}
|
|
955
|
+
|
|
753
956
|
// src/document.ts
|
|
754
957
|
import { randomUUID } from "crypto";
|
|
755
958
|
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -1247,6 +1450,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
1247
1450
|
"document-versions",
|
|
1248
1451
|
"scheduled-jobs",
|
|
1249
1452
|
"webhook-deliveries",
|
|
1453
|
+
"api-keys",
|
|
1250
1454
|
"jobs",
|
|
1251
1455
|
"migrations",
|
|
1252
1456
|
"access"
|
|
@@ -1295,6 +1499,8 @@ function validateConfig(config) {
|
|
|
1295
1499
|
checkSlug(global2, `globals[${i}]`, seenGlobals, add);
|
|
1296
1500
|
});
|
|
1297
1501
|
validateEndpoints(config, collectionSlugs, add);
|
|
1502
|
+
const apiKeys = config.apiKeys;
|
|
1503
|
+
if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
|
|
1298
1504
|
collections.forEach((collection2, i) => {
|
|
1299
1505
|
const path = `collections.${collection2.slug ?? `[${i}]`}`;
|
|
1300
1506
|
validateContainer(collection2, path, collectionSlugs, add);
|
|
@@ -1766,7 +1972,7 @@ function validateComponents(components, path, add) {
|
|
|
1766
1972
|
}
|
|
1767
1973
|
var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
1768
1974
|
var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
|
|
1769
|
-
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set(["users", "globals", "admin", "jobs", "media"]);
|
|
1975
|
+
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set(["users", "globals", "admin", "jobs", "media", "api-keys"]);
|
|
1770
1976
|
function validateEndpoints(config, collectionSlugs, add) {
|
|
1771
1977
|
const endpoints = config.endpoints;
|
|
1772
1978
|
if (endpoints === void 0) return;
|
|
@@ -1915,7 +2121,8 @@ async function resolveConfig(input) {
|
|
|
1915
2121
|
...internalCollections,
|
|
1916
2122
|
...usesVersions(config) ? [versionsCollection] : [],
|
|
1917
2123
|
...usesSchedule(config) ? [scheduledJobsCollection] : [],
|
|
1918
|
-
...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : []
|
|
2124
|
+
...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
|
|
2125
|
+
...config.apiKeys ? [apiKeysCollection] : []
|
|
1919
2126
|
],
|
|
1920
2127
|
globals: config.globals ?? [],
|
|
1921
2128
|
endpoints: config.endpoints ?? []
|
|
@@ -1976,7 +2183,7 @@ async function loadConfig(options = {}) {
|
|
|
1976
2183
|
}
|
|
1977
2184
|
|
|
1978
2185
|
// src/local-api.ts
|
|
1979
|
-
import { randomBytes as
|
|
2186
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
1980
2187
|
|
|
1981
2188
|
// src/localization.ts
|
|
1982
2189
|
var ALL_LOCALES = "all";
|
|
@@ -2555,6 +2762,7 @@ var EasyCMS = class {
|
|
|
2555
2762
|
const config = this.collection(MEDIA);
|
|
2556
2763
|
const guard = guardOf(options);
|
|
2557
2764
|
if (guard.enforce) {
|
|
2765
|
+
this.checkKey(guard, { collection: MEDIA }, "create");
|
|
2558
2766
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
|
|
2559
2767
|
if (allowed2 !== true) throw deny(guard.user);
|
|
2560
2768
|
}
|
|
@@ -2573,7 +2781,7 @@ var EasyCMS = class {
|
|
|
2573
2781
|
}
|
|
2574
2782
|
]);
|
|
2575
2783
|
}
|
|
2576
|
-
const random =
|
|
2784
|
+
const random = randomBytes4(4).toString("hex");
|
|
2577
2785
|
const filename = storageKey(file.name, mimeType, random);
|
|
2578
2786
|
const stored = [];
|
|
2579
2787
|
try {
|
|
@@ -2604,6 +2812,41 @@ var EasyCMS = class {
|
|
|
2604
2812
|
throw error;
|
|
2605
2813
|
}
|
|
2606
2814
|
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Creates an API key (needs `apiKeys: true`) and returns it once: only a hash is stored.
|
|
2817
|
+
* With access enforced the key belongs to `user`; trusted calls name the owner in `data.user`.
|
|
2818
|
+
*/
|
|
2819
|
+
async createApiKey(data, options = {}) {
|
|
2820
|
+
if (!this.config.collections.some((c) => c.slug === API_KEYS))
|
|
2821
|
+
throw new QueryError("API keys are off: set `apiKeys: true` in the config");
|
|
2822
|
+
const config = this.collection(API_KEYS);
|
|
2823
|
+
const guard = guardOf(options);
|
|
2824
|
+
if (guard.enforce) {
|
|
2825
|
+
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user });
|
|
2826
|
+
if (allowed2 !== true) throw deny(guard.user);
|
|
2827
|
+
}
|
|
2828
|
+
const owner = guard.enforce ? guard.user?.id : data.user;
|
|
2829
|
+
if (owner === void 0 || owner === null)
|
|
2830
|
+
throw new ValidationError(API_KEYS, [{ field: "user", message: "is required" }]);
|
|
2831
|
+
const { key: key2, prefix, hash } = newApiKey();
|
|
2832
|
+
const expiresAt = data.expiresAt instanceof Date ? data.expiresAt.toISOString() : data.expiresAt;
|
|
2833
|
+
const created = await this.createDocument(
|
|
2834
|
+
config,
|
|
2835
|
+
{
|
|
2836
|
+
name: data.name,
|
|
2837
|
+
permissions: data.permissions ?? {},
|
|
2838
|
+
expiresAt: expiresAt ?? null,
|
|
2839
|
+
prefix,
|
|
2840
|
+
user: owner
|
|
2841
|
+
},
|
|
2842
|
+
{ depth: 0 },
|
|
2843
|
+
guard
|
|
2844
|
+
);
|
|
2845
|
+
const raw = await this.db.findById({ collection: API_KEYS, id: created.id });
|
|
2846
|
+
const { id: _id, ...rest } = raw;
|
|
2847
|
+
await this.db.update({ collection: API_KEYS, id: created.id, data: { ...rest, keyHash: hash } });
|
|
2848
|
+
return { key: key2, doc: created };
|
|
2849
|
+
}
|
|
2607
2850
|
/** Public URL of a stored file. */
|
|
2608
2851
|
mediaURL(key2) {
|
|
2609
2852
|
const custom = this.storage.url?.(key2);
|
|
@@ -2701,6 +2944,7 @@ var EasyCMS = class {
|
|
|
2701
2944
|
let parsed;
|
|
2702
2945
|
if (id === null) {
|
|
2703
2946
|
if (guard.enforce) {
|
|
2947
|
+
this.checkKey(guard, { collection: collection2 }, "create");
|
|
2704
2948
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
|
|
2705
2949
|
if (allowed2 !== true) throw deny(guard.user);
|
|
2706
2950
|
}
|
|
@@ -2775,7 +3019,7 @@ var EasyCMS = class {
|
|
|
2775
3019
|
return { doc: global2, url: this.previewURL(config, global2, options) };
|
|
2776
3020
|
}
|
|
2777
3021
|
/** Coerces preview input like a save would, keeping values that would fail validation. */
|
|
2778
|
-
async previewDoc(config, data,
|
|
3022
|
+
async previewDoc(config, data, system2, options = {}) {
|
|
2779
3023
|
const locale = this.localeOf(options);
|
|
2780
3024
|
const { data: clean } = await validateFields(config.fields, data, {
|
|
2781
3025
|
operation: "update",
|
|
@@ -2788,7 +3032,7 @@ var EasyCMS = class {
|
|
|
2788
3032
|
...data,
|
|
2789
3033
|
...clean,
|
|
2790
3034
|
...config.drafts ? { status: data.status } : {},
|
|
2791
|
-
...
|
|
3035
|
+
...system2,
|
|
2792
3036
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2793
3037
|
};
|
|
2794
3038
|
}
|
|
@@ -2819,6 +3063,8 @@ var EasyCMS = class {
|
|
|
2819
3063
|
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
2820
3064
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
2821
3065
|
await this.checkDocumentAccess(config, "update", guard, parsed, raw);
|
|
3066
|
+
if (config.drafts && (mode === "unpublish" || raw.status === "published"))
|
|
3067
|
+
this.checkKey(guard, { collection: collection2 }, "publish");
|
|
2822
3068
|
const [current] = await this.withDrafts(config, [existing]);
|
|
2823
3069
|
const { input, password } = splitPassword(config, raw);
|
|
2824
3070
|
const filtered = await filterInput(
|
|
@@ -3005,6 +3251,8 @@ var EasyCMS = class {
|
|
|
3005
3251
|
const config = this.global(slug);
|
|
3006
3252
|
const guard = guardOf(options);
|
|
3007
3253
|
await this.checkGlobalAccess(config, "update", guard);
|
|
3254
|
+
if (config.drafts && (mode === "unpublish" || raw.status === "published"))
|
|
3255
|
+
this.checkKey(guard, { global: slug }, "publish");
|
|
3008
3256
|
const input = await filterInput(
|
|
3009
3257
|
config.fields,
|
|
3010
3258
|
raw,
|
|
@@ -3238,12 +3486,14 @@ var EasyCMS = class {
|
|
|
3238
3486
|
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
3239
3487
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
3240
3488
|
await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
|
|
3489
|
+
this.checkKey(guardOf(options), { collection: collection2 }, "publish");
|
|
3241
3490
|
return { config, parsed };
|
|
3242
3491
|
}
|
|
3243
3492
|
async globalScheduleTarget(slug, options) {
|
|
3244
3493
|
const config = this.global(slug);
|
|
3245
3494
|
if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
|
|
3246
3495
|
await this.checkGlobalAccess(config, "update", guardOf(options));
|
|
3496
|
+
this.checkKey(guardOf(options), { global: slug }, "publish");
|
|
3247
3497
|
}
|
|
3248
3498
|
async addJob(parent, doc, job, options) {
|
|
3249
3499
|
const runAt = new Date(job.at);
|
|
@@ -3342,15 +3592,23 @@ var EasyCMS = class {
|
|
|
3342
3592
|
if (!config) throw new QueryError(`Unknown global "${slug}"`);
|
|
3343
3593
|
return config;
|
|
3344
3594
|
}
|
|
3595
|
+
/** Refuses what a request's API key does not allow (see `ApiKeyPermissions`). */
|
|
3596
|
+
checkKey(guard, target, operation) {
|
|
3597
|
+
if (!guard.enforce || keyAllows(guard.user, target, operation)) return;
|
|
3598
|
+
const name = "collection" in target ? target.collection : target.global;
|
|
3599
|
+
throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
|
|
3600
|
+
}
|
|
3345
3601
|
/** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
|
|
3346
3602
|
async readWhere(config, guard, where) {
|
|
3347
3603
|
if (!guard.enforce) return where;
|
|
3604
|
+
this.checkKey(guard, { collection: config.slug }, "read");
|
|
3348
3605
|
const access = await evaluateAccess(config.access?.read, { user: guard.user });
|
|
3349
3606
|
if (access === false) throw deny(guard.user);
|
|
3350
3607
|
return andWhere(where, access);
|
|
3351
3608
|
}
|
|
3352
3609
|
async checkDocumentAccess(config, operation, guard, id, data) {
|
|
3353
3610
|
if (!guard.enforce) return;
|
|
3611
|
+
this.checkKey(guard, { collection: config.slug }, operation);
|
|
3354
3612
|
const access = await evaluateAccess(config.access?.[operation], {
|
|
3355
3613
|
user: guard.user,
|
|
3356
3614
|
id,
|
|
@@ -3368,6 +3626,7 @@ var EasyCMS = class {
|
|
|
3368
3626
|
}
|
|
3369
3627
|
async checkGlobalAccess(config, operation, guard) {
|
|
3370
3628
|
if (!guard.enforce) return;
|
|
3629
|
+
this.checkKey(guard, { global: config.slug }, operation);
|
|
3371
3630
|
const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
|
|
3372
3631
|
if (typeof access === "object")
|
|
3373
3632
|
throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
|
|
@@ -3421,6 +3680,9 @@ var EasyCMS = class {
|
|
|
3421
3680
|
const guard = guardOf(options);
|
|
3422
3681
|
const collection2 = config.slug;
|
|
3423
3682
|
if (guard.enforce) {
|
|
3683
|
+
this.checkKey(guard, { collection: collection2 }, "create");
|
|
3684
|
+
if (config.drafts && raw.status === "published")
|
|
3685
|
+
this.checkKey(guard, { collection: collection2 }, "publish");
|
|
3424
3686
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
|
|
3425
3687
|
if (typeof allowed2 === "object")
|
|
3426
3688
|
throw new QueryError(`create access of "${collection2}" must return a boolean`);
|
|
@@ -3785,7 +4047,7 @@ function asObject(data, name) {
|
|
|
3785
4047
|
}
|
|
3786
4048
|
|
|
3787
4049
|
// src/rest/admin-modules.ts
|
|
3788
|
-
import { createHash as
|
|
4050
|
+
import { createHash as createHash3 } from "crypto";
|
|
3789
4051
|
import { readFile as readFile2 } from "fs/promises";
|
|
3790
4052
|
import { createRequire } from "module";
|
|
3791
4053
|
import { join as join2 } from "path";
|
|
@@ -3826,7 +4088,7 @@ async function readAdminModule(cms, index) {
|
|
|
3826
4088
|
if (!file) return void 0;
|
|
3827
4089
|
const body = await readFile2(file, "utf8").catch(() => void 0);
|
|
3828
4090
|
if (body === void 0) return void 0;
|
|
3829
|
-
return { body, etag: `"${
|
|
4091
|
+
return { body, etag: `"${createHash3("sha1").update(body).digest("base64url")}"` };
|
|
3830
4092
|
}
|
|
3831
4093
|
|
|
3832
4094
|
// src/rest/admin-schema.ts
|
|
@@ -4092,6 +4354,8 @@ function createRestHandler(instance, options = {}) {
|
|
|
4092
4354
|
const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map((s) => decodeURIComponent(s));
|
|
4093
4355
|
const { token, via } = readToken(request);
|
|
4094
4356
|
const user = token ? await cms.auth.verify(token) : null;
|
|
4357
|
+
if (!user && via === "bearer" && token?.startsWith("ecms_"))
|
|
4358
|
+
throw new UnauthorizedError("Invalid or expired API key");
|
|
4095
4359
|
const ctx = { request, url, user, via: user ? via : null, token, headers };
|
|
4096
4360
|
const method = request.method.toUpperCase();
|
|
4097
4361
|
if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
|
|
@@ -4293,6 +4557,18 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
4293
4557
|
body: await cms.upload(file, data, { ...access, ...parseDepth(ctx.url) })
|
|
4294
4558
|
};
|
|
4295
4559
|
}
|
|
4560
|
+
if (first === API_KEYS && second === void 0 && method === "POST") {
|
|
4561
|
+
const body = await readJson(ctx.request);
|
|
4562
|
+
const { key: key2, doc } = await cms.createApiKey(
|
|
4563
|
+
{
|
|
4564
|
+
name: typeof body.name === "string" ? body.name : "",
|
|
4565
|
+
...body.permissions !== void 0 ? { permissions: body.permissions } : {},
|
|
4566
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
4567
|
+
},
|
|
4568
|
+
access
|
|
4569
|
+
);
|
|
4570
|
+
return { status: 201, body: { ...doc, key: key2 } };
|
|
4571
|
+
}
|
|
4296
4572
|
const collection2 = first;
|
|
4297
4573
|
if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
|
|
4298
4574
|
throw new HttpError(`Unknown collection "${collection2}"`, 404);
|
|
@@ -4779,6 +5055,9 @@ ${map(n.globals)}
|
|
|
4779
5055
|
}
|
|
4780
5056
|
export {
|
|
4781
5057
|
ADMIN_ICONS,
|
|
5058
|
+
API_KEYS,
|
|
5059
|
+
API_KEY_GLOBAL_OPERATIONS,
|
|
5060
|
+
API_KEY_OPERATIONS,
|
|
4782
5061
|
Auth,
|
|
4783
5062
|
BUILTIN_COLLECTIONS,
|
|
4784
5063
|
CONFIG_FILE_NAMES,
|
|
@@ -4814,6 +5093,7 @@ export {
|
|
|
4814
5093
|
applyDefaults,
|
|
4815
5094
|
applyPlugins,
|
|
4816
5095
|
consoleLogger,
|
|
5096
|
+
copyDatabase,
|
|
4817
5097
|
createEasyCMS,
|
|
4818
5098
|
createRestHandler,
|
|
4819
5099
|
defineConfig,
|
|
@@ -4827,6 +5107,7 @@ export {
|
|
|
4827
5107
|
importConfig,
|
|
4828
5108
|
isAdmin,
|
|
4829
5109
|
isLoggedIn,
|
|
5110
|
+
keyAllows,
|
|
4830
5111
|
loadConfig,
|
|
4831
5112
|
localStorage,
|
|
4832
5113
|
mergeForUpdate,
|