@easy-cms/core 0.32.0 → 0.34.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 +241 -6
- package/dist/index.js +1000 -226
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -253,14 +253,19 @@ function andWhere(where, constraint) {
|
|
|
253
253
|
return where === void 0 ? constraint : { and: [where, constraint] };
|
|
254
254
|
}
|
|
255
255
|
var FieldAccessChecker = class {
|
|
256
|
-
|
|
256
|
+
/** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
|
|
257
|
+
constructor(kind, args, rules) {
|
|
257
258
|
this.kind = kind;
|
|
258
259
|
this.args = args;
|
|
260
|
+
this.rules = rules;
|
|
259
261
|
}
|
|
260
262
|
kind;
|
|
261
263
|
args;
|
|
264
|
+
rules;
|
|
262
265
|
cache = /* @__PURE__ */ new Map();
|
|
263
266
|
async allows(field) {
|
|
267
|
+
const rule = this.rules?.get(field);
|
|
268
|
+
if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
|
|
264
269
|
const fn = field.access?.[this.kind];
|
|
265
270
|
if (!fn) return true;
|
|
266
271
|
let allowed2 = this.cache.get(field);
|
|
@@ -326,6 +331,7 @@ var SCHEDULED_JOBS = "scheduled-jobs";
|
|
|
326
331
|
var WEBHOOK_DELIVERIES = "webhook-deliveries";
|
|
327
332
|
var EMAIL_DELIVERIES = "email-deliveries";
|
|
328
333
|
var DATABASE_BACKUPS = "database-backups";
|
|
334
|
+
var ROLES = "user-roles";
|
|
329
335
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
330
336
|
SESSIONS,
|
|
331
337
|
LOGIN_ATTEMPTS,
|
|
@@ -333,9 +339,27 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
333
339
|
SCHEDULED_JOBS,
|
|
334
340
|
WEBHOOK_DELIVERIES,
|
|
335
341
|
EMAIL_DELIVERIES,
|
|
336
|
-
DATABASE_BACKUPS
|
|
342
|
+
DATABASE_BACKUPS,
|
|
343
|
+
ROLES
|
|
337
344
|
]);
|
|
338
345
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
346
|
+
var CREATED_BY_FIELD = {
|
|
347
|
+
name: "createdBy",
|
|
348
|
+
type: "relationship",
|
|
349
|
+
to: USERS,
|
|
350
|
+
position: "sidebar",
|
|
351
|
+
access: { update: () => false },
|
|
352
|
+
label: { en: "Created by", th: "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E42\u0E14\u0E22" }
|
|
353
|
+
};
|
|
354
|
+
function withCreatedBy(config) {
|
|
355
|
+
if (config.auth?.rbac !== true) return config;
|
|
356
|
+
return {
|
|
357
|
+
...config,
|
|
358
|
+
collections: (config.collections ?? []).map(
|
|
359
|
+
(c) => c.slug === USERS || c.fields?.some((f) => f.name === CREATED_BY_FIELD.name) ? c : { ...c, fields: [...c.fields, CREATED_BY_FIELD] }
|
|
360
|
+
)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
339
363
|
var adminOrSelf = ({ user }) => {
|
|
340
364
|
if (!user) return false;
|
|
341
365
|
if (user.role === "admin") return true;
|
|
@@ -344,7 +368,8 @@ var adminOrSelf = ({ user }) => {
|
|
|
344
368
|
var adminOnly = {
|
|
345
369
|
update: ({ user }) => user?.role === "admin"
|
|
346
370
|
};
|
|
347
|
-
function userFields(roles) {
|
|
371
|
+
function userFields(roles, rbac) {
|
|
372
|
+
const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
|
|
348
373
|
return [
|
|
349
374
|
{
|
|
350
375
|
name: "email",
|
|
@@ -354,12 +379,20 @@ function userFields(roles) {
|
|
|
354
379
|
label: { en: "Email", th: "\u0E2D\u0E35\u0E40\u0E21\u0E25" }
|
|
355
380
|
},
|
|
356
381
|
{ name: "name", type: "text", label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
|
|
357
|
-
|
|
382
|
+
// With roles from the admin (`auth.rbac`), any role in Settings → Roles: checked on save.
|
|
383
|
+
rbac ? {
|
|
384
|
+
name: "role",
|
|
385
|
+
type: "text",
|
|
386
|
+
required: true,
|
|
387
|
+
defaultValue: defaultRole,
|
|
388
|
+
access: adminOnly,
|
|
389
|
+
label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
|
|
390
|
+
} : {
|
|
358
391
|
name: "role",
|
|
359
392
|
type: "select",
|
|
360
393
|
options: roles,
|
|
361
394
|
required: true,
|
|
362
|
-
defaultValue:
|
|
395
|
+
defaultValue: defaultRole,
|
|
363
396
|
access: adminOnly,
|
|
364
397
|
label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
|
|
365
398
|
},
|
|
@@ -382,7 +415,7 @@ function withUsers(config) {
|
|
|
382
415
|
useAsTitle: "email",
|
|
383
416
|
icon: "users",
|
|
384
417
|
...custom,
|
|
385
|
-
fields: [...userFields(roles), ...custom?.fields ?? []],
|
|
418
|
+
fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
|
|
386
419
|
access: {
|
|
387
420
|
read: isLoggedIn,
|
|
388
421
|
create: isAdmin,
|
|
@@ -1350,6 +1383,7 @@ async function runBackup(cms, record) {
|
|
|
1350
1383
|
const filename = filenameFor(cms, started);
|
|
1351
1384
|
const key2 = await storage.get(filename) ? filename.replace(/\.db\.gz$/, `-${row.id}.db.gz`) : filename;
|
|
1352
1385
|
await storage.put(key2, data, { contentType: "application/gzip" });
|
|
1386
|
+
await pruneBackups(cms, 1);
|
|
1353
1387
|
row = await update(cms, row, {
|
|
1354
1388
|
state: "done",
|
|
1355
1389
|
filename: key2,
|
|
@@ -1357,7 +1391,6 @@ async function runBackup(cms, record) {
|
|
|
1357
1391
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1358
1392
|
});
|
|
1359
1393
|
cms.logger.info(`Backup ${key2} written (${data.byteLength} bytes)`);
|
|
1360
|
-
await pruneBackups(cms);
|
|
1361
1394
|
} catch (error) {
|
|
1362
1395
|
await update(cms, row, {
|
|
1363
1396
|
state: "failed",
|
|
@@ -1369,8 +1402,8 @@ async function runBackup(cms, record) {
|
|
|
1369
1402
|
await rm2(dir, { recursive: true, force: true });
|
|
1370
1403
|
}
|
|
1371
1404
|
}
|
|
1372
|
-
async function pruneBackups(cms) {
|
|
1373
|
-
const keep = cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP;
|
|
1405
|
+
async function pruneBackups(cms, room = 0) {
|
|
1406
|
+
const keep = Math.max(0, (cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP) - room);
|
|
1374
1407
|
const storage = await backupStorage(cms);
|
|
1375
1408
|
const { docs } = await cms.db.find({
|
|
1376
1409
|
collection: DATABASE_BACKUPS,
|
|
@@ -1690,7 +1723,7 @@ function parseId(value) {
|
|
|
1690
1723
|
async function validateFields(fields, input, options, prefix = "") {
|
|
1691
1724
|
const data = {};
|
|
1692
1725
|
const errors = [];
|
|
1693
|
-
const
|
|
1726
|
+
const references2 = [];
|
|
1694
1727
|
for (const field of fields) {
|
|
1695
1728
|
const path = prefix + field.name;
|
|
1696
1729
|
const raw = input[field.name];
|
|
@@ -1708,7 +1741,7 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
1708
1741
|
);
|
|
1709
1742
|
data[field.name] = sub.data;
|
|
1710
1743
|
errors.push(...sub.errors);
|
|
1711
|
-
|
|
1744
|
+
references2.push(...sub.references);
|
|
1712
1745
|
continue;
|
|
1713
1746
|
}
|
|
1714
1747
|
const localization = options.localization;
|
|
@@ -1738,7 +1771,7 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
1738
1771
|
options,
|
|
1739
1772
|
failLocale,
|
|
1740
1773
|
errors,
|
|
1741
|
-
|
|
1774
|
+
references2
|
|
1742
1775
|
);
|
|
1743
1776
|
if (normalized === void 0) continue;
|
|
1744
1777
|
out[locale] = normalized;
|
|
@@ -1755,12 +1788,12 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
1755
1788
|
if (!field.required) await runCustom(field, null, options, fail);
|
|
1756
1789
|
continue;
|
|
1757
1790
|
}
|
|
1758
|
-
const value = await normalizeValue(field, raw, path, options, fail, errors,
|
|
1791
|
+
const value = await normalizeValue(field, raw, path, options, fail, errors, references2);
|
|
1759
1792
|
if (value === void 0) continue;
|
|
1760
1793
|
data[field.name] = value;
|
|
1761
1794
|
await runCustom(field, value, options, fail);
|
|
1762
1795
|
}
|
|
1763
|
-
return { data, errors, references };
|
|
1796
|
+
return { data, errors, references: references2 };
|
|
1764
1797
|
}
|
|
1765
1798
|
async function runCustom(field, value, options, fail) {
|
|
1766
1799
|
if (!field.validate) return;
|
|
@@ -1768,7 +1801,7 @@ async function runCustom(field, value, options, fail) {
|
|
|
1768
1801
|
const result = await validate(value, { data: options.root, operation: options.operation });
|
|
1769
1802
|
if (result !== true) fail(typeof result === "string" ? result : "is invalid");
|
|
1770
1803
|
}
|
|
1771
|
-
async function normalizeValue(field, raw, path, options, fail, errors,
|
|
1804
|
+
async function normalizeValue(field, raw, path, options, fail, errors, references2) {
|
|
1772
1805
|
switch (field.type) {
|
|
1773
1806
|
case "text":
|
|
1774
1807
|
case "textarea":
|
|
@@ -1830,7 +1863,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
1830
1863
|
if (id === void 0)
|
|
1831
1864
|
return fail(`must be ${many ? "ids" : "an id"} of "${target}" documents`);
|
|
1832
1865
|
ids.push(id);
|
|
1833
|
-
|
|
1866
|
+
references2.push({
|
|
1834
1867
|
field: path,
|
|
1835
1868
|
collection: target,
|
|
1836
1869
|
id,
|
|
@@ -1854,19 +1887,19 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
1854
1887
|
}
|
|
1855
1888
|
const sub = await validateFields(field.fields, row, options, `${path}.${i}.`);
|
|
1856
1889
|
errors.push(...sub.errors);
|
|
1857
|
-
|
|
1890
|
+
references2.push(...sub.references);
|
|
1858
1891
|
const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
|
|
1859
1892
|
rows.push({ id, ...sub.data });
|
|
1860
1893
|
}
|
|
1861
1894
|
return rows;
|
|
1862
1895
|
}
|
|
1863
1896
|
case "blocks":
|
|
1864
|
-
return normalizeBlocks(field, raw, path, options, fail, errors,
|
|
1897
|
+
return normalizeBlocks(field, raw, path, options, fail, errors, references2);
|
|
1865
1898
|
case "group":
|
|
1866
1899
|
return raw;
|
|
1867
1900
|
}
|
|
1868
1901
|
}
|
|
1869
|
-
async function normalizeBlocks(field, raw, path, options, fail, errors,
|
|
1902
|
+
async function normalizeBlocks(field, raw, path, options, fail, errors, references2) {
|
|
1870
1903
|
if (!Array.isArray(raw)) return fail("must be an array of blocks");
|
|
1871
1904
|
if (field.minRows !== void 0 && raw.length < field.minRows)
|
|
1872
1905
|
return fail(`must have at least ${field.minRows} blocks`);
|
|
@@ -1884,7 +1917,7 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
|
|
|
1884
1917
|
}
|
|
1885
1918
|
const sub = await validateFields(fields, row, options, `${path}.${i}.`);
|
|
1886
1919
|
errors.push(...sub.errors);
|
|
1887
|
-
|
|
1920
|
+
references2.push(...sub.references);
|
|
1888
1921
|
const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
|
|
1889
1922
|
rows.push({ id, blockType: row.blockType, ...sub.data });
|
|
1890
1923
|
}
|
|
@@ -2168,6 +2201,681 @@ import { existsSync } from "fs";
|
|
|
2168
2201
|
import { resolve as resolve2 } from "path";
|
|
2169
2202
|
import { createJiti } from "jiti";
|
|
2170
2203
|
|
|
2204
|
+
// src/versions.ts
|
|
2205
|
+
var DEFAULT_MAX_VERSIONS = 50;
|
|
2206
|
+
function versionLimit(config) {
|
|
2207
|
+
const versions = config.versions;
|
|
2208
|
+
if (!versions) return void 0;
|
|
2209
|
+
return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
|
|
2210
|
+
}
|
|
2211
|
+
var collectionParent = (slug2) => slug2;
|
|
2212
|
+
var globalParent = (slug2) => `global:${slug2}`;
|
|
2213
|
+
function snapshotOf(fields, doc) {
|
|
2214
|
+
const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
|
|
2215
|
+
const snapshot = {};
|
|
2216
|
+
for (const [key2, value] of Object.entries(doc)) {
|
|
2217
|
+
if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
|
|
2218
|
+
snapshot[key2] = value;
|
|
2219
|
+
}
|
|
2220
|
+
return snapshot;
|
|
2221
|
+
}
|
|
2222
|
+
var toSummary = (row) => ({
|
|
2223
|
+
id: row.id,
|
|
2224
|
+
status: row.status ?? null,
|
|
2225
|
+
latest: row.latest === true,
|
|
2226
|
+
author: row.author ?? null,
|
|
2227
|
+
createdAt: String(row.createdAt)
|
|
2228
|
+
});
|
|
2229
|
+
var VersionStore = class {
|
|
2230
|
+
constructor(db) {
|
|
2231
|
+
this.db = db;
|
|
2232
|
+
}
|
|
2233
|
+
db;
|
|
2234
|
+
/** Saves a new latest version and deletes the oldest beyond `max`. */
|
|
2235
|
+
async save(args) {
|
|
2236
|
+
const { parent, doc } = args;
|
|
2237
|
+
const previous = await this.db.find({
|
|
2238
|
+
collection: VERSIONS,
|
|
2239
|
+
where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
|
|
2240
|
+
sort: [],
|
|
2241
|
+
limit: 0,
|
|
2242
|
+
page: 1
|
|
2243
|
+
});
|
|
2244
|
+
for (const row of previous.docs) {
|
|
2245
|
+
await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
|
|
2246
|
+
}
|
|
2247
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2248
|
+
const created = await this.db.create({
|
|
2249
|
+
collection: VERSIONS,
|
|
2250
|
+
data: {
|
|
2251
|
+
parent,
|
|
2252
|
+
doc,
|
|
2253
|
+
status: args.status,
|
|
2254
|
+
latest: true,
|
|
2255
|
+
author: args.author,
|
|
2256
|
+
snapshot: args.snapshot,
|
|
2257
|
+
createdAt: now,
|
|
2258
|
+
updatedAt: now
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2261
|
+
await this.prune(parent, doc, args.max);
|
|
2262
|
+
return toSummary(created);
|
|
2263
|
+
}
|
|
2264
|
+
/** The latest version of each document, keyed by document id. */
|
|
2265
|
+
async latest(parent, docs) {
|
|
2266
|
+
const result = /* @__PURE__ */ new Map();
|
|
2267
|
+
if (docs.length === 0) return result;
|
|
2268
|
+
const rows = await this.db.find({
|
|
2269
|
+
collection: VERSIONS,
|
|
2270
|
+
where: {
|
|
2271
|
+
and: [
|
|
2272
|
+
{ parent: { equals: parent } },
|
|
2273
|
+
{ doc: { in: [...docs] } },
|
|
2274
|
+
{ latest: { equals: true } }
|
|
2275
|
+
]
|
|
2276
|
+
},
|
|
2277
|
+
sort: [],
|
|
2278
|
+
limit: 0,
|
|
2279
|
+
page: 1
|
|
2280
|
+
});
|
|
2281
|
+
for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
|
|
2282
|
+
return result;
|
|
2283
|
+
}
|
|
2284
|
+
async list(parent, doc, limit, page) {
|
|
2285
|
+
const found = await this.db.find({
|
|
2286
|
+
collection: VERSIONS,
|
|
2287
|
+
where: { and: this.of(parent, doc) },
|
|
2288
|
+
sort: ["-id"],
|
|
2289
|
+
limit,
|
|
2290
|
+
page
|
|
2291
|
+
});
|
|
2292
|
+
return { ...found, docs: found.docs.map(toSummary) };
|
|
2293
|
+
}
|
|
2294
|
+
async get(parent, doc, id) {
|
|
2295
|
+
const found = await this.db.find({
|
|
2296
|
+
collection: VERSIONS,
|
|
2297
|
+
where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
|
|
2298
|
+
sort: [],
|
|
2299
|
+
limit: 1,
|
|
2300
|
+
page: 1
|
|
2301
|
+
});
|
|
2302
|
+
const row = found.docs[0];
|
|
2303
|
+
return row ? this.toVersion(row) : null;
|
|
2304
|
+
}
|
|
2305
|
+
async deleteAll(parent, doc) {
|
|
2306
|
+
const rows = await this.db.find({
|
|
2307
|
+
collection: VERSIONS,
|
|
2308
|
+
where: { and: this.of(parent, doc) },
|
|
2309
|
+
sort: [],
|
|
2310
|
+
limit: 0,
|
|
2311
|
+
page: 1
|
|
2312
|
+
});
|
|
2313
|
+
for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
|
|
2314
|
+
}
|
|
2315
|
+
async prune(parent, doc, max) {
|
|
2316
|
+
const rows = await this.db.find({
|
|
2317
|
+
collection: VERSIONS,
|
|
2318
|
+
where: { and: this.of(parent, doc) },
|
|
2319
|
+
sort: ["-id"],
|
|
2320
|
+
limit: 0,
|
|
2321
|
+
page: 1
|
|
2322
|
+
});
|
|
2323
|
+
for (const row of rows.docs.slice(max)) {
|
|
2324
|
+
await this.db.delete({ collection: VERSIONS, id: row.id });
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
of(parent, doc) {
|
|
2328
|
+
return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
|
|
2329
|
+
}
|
|
2330
|
+
toVersion(row) {
|
|
2331
|
+
return { ...toSummary(row), data: row.snapshot ?? {} };
|
|
2332
|
+
}
|
|
2333
|
+
};
|
|
2334
|
+
|
|
2335
|
+
// src/roles.ts
|
|
2336
|
+
var ROLE_OPERATIONS = ["read", "create", "update", "delete", "publish"];
|
|
2337
|
+
var GLOBAL_OPERATIONS = ["read", "update", "publish"];
|
|
2338
|
+
var OWN_OPERATIONS = ["read", "update", "delete", "publish"];
|
|
2339
|
+
var KEY = /^[a-z][a-z0-9-]{0,31}$/;
|
|
2340
|
+
var HISTORY = 50;
|
|
2341
|
+
var TTL = 3e4;
|
|
2342
|
+
var rolesCollection = {
|
|
2343
|
+
slug: ROLES,
|
|
2344
|
+
access: { read: () => false, create: () => false, update: () => false, delete: () => false },
|
|
2345
|
+
fields: [
|
|
2346
|
+
{ name: "key", type: "text", required: true, unique: true },
|
|
2347
|
+
{ name: "name", type: "text" },
|
|
2348
|
+
{ name: "permissions", type: "json" }
|
|
2349
|
+
]
|
|
2350
|
+
};
|
|
2351
|
+
function governed(slug2) {
|
|
2352
|
+
return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
|
|
2353
|
+
}
|
|
2354
|
+
function ruleFields(fields) {
|
|
2355
|
+
return fields.filter((f) => !f.hidden && f.name !== "createdBy");
|
|
2356
|
+
}
|
|
2357
|
+
function references(fields, out = /* @__PURE__ */ new Set()) {
|
|
2358
|
+
for (const field of fields) {
|
|
2359
|
+
if (field.type === "relationship") out.add(field.to);
|
|
2360
|
+
else if (field.type === "upload") out.add("media");
|
|
2361
|
+
else if (field.type === "group" || field.type === "array") references(field.fields, out);
|
|
2362
|
+
else if (field.type === "blocks") for (const b of field.blocks) references(b.fields, out);
|
|
2363
|
+
}
|
|
2364
|
+
return out;
|
|
2365
|
+
}
|
|
2366
|
+
var viewId = {
|
|
2367
|
+
page: (path) => `page:${path}`,
|
|
2368
|
+
widget: (tag) => `widget:${tag}`
|
|
2369
|
+
};
|
|
2370
|
+
var tagOf = (component) => typeof component === "string" ? component : component.tag;
|
|
2371
|
+
var Roles = class {
|
|
2372
|
+
constructor(config, db, log) {
|
|
2373
|
+
this.config = config;
|
|
2374
|
+
this.db = db;
|
|
2375
|
+
this.log = log;
|
|
2376
|
+
this.enabled = config.auth.rbac;
|
|
2377
|
+
this.versions = new VersionStore(db);
|
|
2378
|
+
}
|
|
2379
|
+
config;
|
|
2380
|
+
db;
|
|
2381
|
+
log;
|
|
2382
|
+
enabled;
|
|
2383
|
+
versions;
|
|
2384
|
+
cache;
|
|
2385
|
+
synced;
|
|
2386
|
+
/**
|
|
2387
|
+
* Whether the user's role allows an operation: `true`, `false`, or for their own account
|
|
2388
|
+
* (users without read or update on `users`) a constraint to it.
|
|
2389
|
+
*/
|
|
2390
|
+
async allows(user, target, operation) {
|
|
2391
|
+
if (!this.enabled || !user || user.role === "admin") return true;
|
|
2392
|
+
if ("collection" in target && !governed(target.collection)) return true;
|
|
2393
|
+
const permissions = await this.permissionsOf(user.role);
|
|
2394
|
+
const ops = "collection" in target ? permissions.collections?.[target.collection] : permissions.globals?.[target.global];
|
|
2395
|
+
if (ops?.includes(operation)) {
|
|
2396
|
+
if ("collection" in target && permissions.own?.[target.collection]?.includes(operation)) {
|
|
2397
|
+
const owner = this.ownerOf(target.collection);
|
|
2398
|
+
if (owner && operation !== "create") return { [owner]: { equals: user.id } };
|
|
2399
|
+
}
|
|
2400
|
+
return true;
|
|
2401
|
+
}
|
|
2402
|
+
if ("collection" in target && target.collection === USERS) {
|
|
2403
|
+
if (operation === "read" || operation === "update") return { id: { equals: user.id } };
|
|
2404
|
+
}
|
|
2405
|
+
return false;
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* Whether the user may open an admin page: admins always; with roles, those whose role has
|
|
2409
|
+
* it; without roles, nobody else (`status`, `deliveries`) or everybody (plugin pages).
|
|
2410
|
+
*/
|
|
2411
|
+
async canView(user, id) {
|
|
2412
|
+
if (user.role === "admin" && !user.apiKey) return true;
|
|
2413
|
+
if (user.apiKey) return false;
|
|
2414
|
+
if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
|
|
2415
|
+
return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* The field that names a collection's owner, for "own documents only": `admin.ownerField`, or
|
|
2419
|
+
* `createdBy`. `undefined` without roles, and for users and collections roles don't cover.
|
|
2420
|
+
*/
|
|
2421
|
+
ownerOf(slug2) {
|
|
2422
|
+
if (!this.enabled || slug2 === USERS || !governed(slug2)) return void 0;
|
|
2423
|
+
const config = this.config.collections.find((c) => c.slug === slug2);
|
|
2424
|
+
if (!config) return void 0;
|
|
2425
|
+
return config.admin?.ownerField ?? "createdBy";
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* The user's field rules (top-level fields their role may only read, or not see), for field
|
|
2429
|
+
* access checks. Roles that may update only their own documents can't change the owner field.
|
|
2430
|
+
*/
|
|
2431
|
+
async fieldRules(user) {
|
|
2432
|
+
if (!this.enabled || !user || user.role === "admin") return void 0;
|
|
2433
|
+
await this.load();
|
|
2434
|
+
const cache = this.cache;
|
|
2435
|
+
const cached = cache?.rules.get(user.role);
|
|
2436
|
+
if (cached) return cached;
|
|
2437
|
+
const permissions = await this.permissionsOf(user.role);
|
|
2438
|
+
const rules = /* @__PURE__ */ new Map();
|
|
2439
|
+
const add = (fields, set) => {
|
|
2440
|
+
for (const field of fields) {
|
|
2441
|
+
const rule = set?.[field.name];
|
|
2442
|
+
if (rule === "read" || rule === "hidden") rules.set(field, rule);
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
for (const c of this.config.collections) {
|
|
2446
|
+
if (!governed(c.slug)) continue;
|
|
2447
|
+
add(c.fields, permissions.fields?.collections?.[c.slug]);
|
|
2448
|
+
const ownerField = c.admin?.ownerField;
|
|
2449
|
+
if (ownerField && permissions.own?.[c.slug]?.includes("update")) {
|
|
2450
|
+
const field = c.fields.find((f) => f.name === ownerField);
|
|
2451
|
+
if (field && !rules.has(field)) rules.set(field, "read");
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
for (const g of this.config.globals) add(g.fields, permissions.fields?.globals?.[g.slug]);
|
|
2455
|
+
cache?.rules.set(user.role, rules);
|
|
2456
|
+
return rules;
|
|
2457
|
+
}
|
|
2458
|
+
/** A new document is its creator's: sets `createdBy`, and an empty owner field. */
|
|
2459
|
+
fillOwner(slug2, data, user) {
|
|
2460
|
+
if (!user || !this.ownerOf(slug2)) return;
|
|
2461
|
+
data.createdBy ??= user.id;
|
|
2462
|
+
const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
|
|
2463
|
+
if (ownerField && (data[ownerField] === void 0 || data[ownerField] === null))
|
|
2464
|
+
data[ownerField] = user.id;
|
|
2465
|
+
}
|
|
2466
|
+
/** Documents a user owns, by collection (`createdBy` and owner fields), for deleting them. */
|
|
2467
|
+
async owned(userId) {
|
|
2468
|
+
const out = [];
|
|
2469
|
+
for (const c of this.config.collections) {
|
|
2470
|
+
const columns = this.ownerColumns(c.slug);
|
|
2471
|
+
if (columns.length === 0) continue;
|
|
2472
|
+
const count = await this.db.count({
|
|
2473
|
+
collection: c.slug,
|
|
2474
|
+
where: { or: columns.map((column) => ({ [column]: { equals: userId } })) }
|
|
2475
|
+
});
|
|
2476
|
+
if (count > 0) out.push({ collection: c.slug, count });
|
|
2477
|
+
}
|
|
2478
|
+
return out;
|
|
2479
|
+
}
|
|
2480
|
+
/** Gives a deleted user's documents to another user, or to nobody (`null`). */
|
|
2481
|
+
async transfer(from, to) {
|
|
2482
|
+
for (const c of this.config.collections) {
|
|
2483
|
+
for (const column of this.ownerColumns(c.slug))
|
|
2484
|
+
await this.setWhere(c.slug, { [column]: { equals: from } }, column, to);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
/** Forgets cached permissions, after a role changed. */
|
|
2488
|
+
invalidate() {
|
|
2489
|
+
this.cache = void 0;
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Adds the roles of `auth.roles` that are missing; on the first start, gives them everything.
|
|
2493
|
+
* Runs once, on first use (not on startup: `easy-cms migrate` opens the CMS before the table exists).
|
|
2494
|
+
*/
|
|
2495
|
+
sync() {
|
|
2496
|
+
this.synced ??= this.addMissing().catch((error) => {
|
|
2497
|
+
this.synced = void 0;
|
|
2498
|
+
throw error;
|
|
2499
|
+
});
|
|
2500
|
+
return this.synced;
|
|
2501
|
+
}
|
|
2502
|
+
async addMissing() {
|
|
2503
|
+
if (!this.enabled) return;
|
|
2504
|
+
const rows = await this.rows();
|
|
2505
|
+
const first = rows.length === 0;
|
|
2506
|
+
const known = new Set(rows.map((r) => String(r.key)));
|
|
2507
|
+
for (const key2 of this.config.auth.roles) {
|
|
2508
|
+
if (known.has(key2)) continue;
|
|
2509
|
+
try {
|
|
2510
|
+
await this.insert(key2, "", first && key2 !== "admin" ? this.everything() : {}, null);
|
|
2511
|
+
} catch (error) {
|
|
2512
|
+
if (!(await this.rows()).some((r) => r.key === key2)) throw error;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
this.invalidate();
|
|
2516
|
+
await this.backfillCreators();
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Documents from before roles have no `createdBy`: it is filled from who saved their first
|
|
2520
|
+
* version, for collections with versions. Only while no document of the collection has one,
|
|
2521
|
+
* so it runs once (and finds nothing to do afterwards).
|
|
2522
|
+
*/
|
|
2523
|
+
async backfillCreators() {
|
|
2524
|
+
for (const c of this.config.collections) {
|
|
2525
|
+
if (!c.versions || !this.ownerOf(c.slug)) continue;
|
|
2526
|
+
if (await this.db.count({ collection: c.slug, where: { createdBy: { exists: true } } }) > 0)
|
|
2527
|
+
continue;
|
|
2528
|
+
const versions = await this.db.find({
|
|
2529
|
+
collection: VERSIONS,
|
|
2530
|
+
where: { and: [{ parent: { equals: c.slug } }, { author: { exists: true } }] },
|
|
2531
|
+
sort: ["id"],
|
|
2532
|
+
limit: 0,
|
|
2533
|
+
page: 1
|
|
2534
|
+
});
|
|
2535
|
+
const first = /* @__PURE__ */ new Map();
|
|
2536
|
+
for (const v of versions.docs) {
|
|
2537
|
+
if (!first.has(v.doc) && v.author !== null) first.set(v.doc, v.author);
|
|
2538
|
+
}
|
|
2539
|
+
let filled = 0;
|
|
2540
|
+
for (const [doc, author] of first) {
|
|
2541
|
+
const row = await this.db.findById({ collection: c.slug, id: doc });
|
|
2542
|
+
if (!row || row.createdBy !== null && row.createdBy !== void 0) continue;
|
|
2543
|
+
const { id, ...rest } = row;
|
|
2544
|
+
await this.db.update({ collection: c.slug, id, data: { ...rest, createdBy: author } });
|
|
2545
|
+
filled++;
|
|
2546
|
+
}
|
|
2547
|
+
if (filled > 0) this.log?.(`Set who created ${filled} ${c.slug} from their history`);
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
/** Whether a role exists (users can only be given existing roles). */
|
|
2551
|
+
async exists(key2) {
|
|
2552
|
+
if (!this.enabled) return this.config.auth.roles.includes(key2);
|
|
2553
|
+
return (await this.load()).has(key2);
|
|
2554
|
+
}
|
|
2555
|
+
/** Roles a user can be given, those of `auth.roles` first; `name` may be empty. */
|
|
2556
|
+
async options() {
|
|
2557
|
+
if (!this.enabled) return this.config.auth.roles.map((key2) => ({ key: key2, name: "" }));
|
|
2558
|
+
await this.sync();
|
|
2559
|
+
return (await this.rows()).map((row) => ({ key: String(row.key), name: typeof row.name === "string" ? row.name : "" })).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
|
|
2560
|
+
}
|
|
2561
|
+
// --- Settings → Roles ----------------------------------------------------
|
|
2562
|
+
async list() {
|
|
2563
|
+
await this.sync();
|
|
2564
|
+
const rows = await this.rows();
|
|
2565
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2566
|
+
for (const row of rows) {
|
|
2567
|
+
counts.set(
|
|
2568
|
+
String(row.key),
|
|
2569
|
+
await this.db.count({ collection: USERS, where: { role: { equals: row.key } } })
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
const roles = rows.map((row) => this.toRole(row, counts.get(String(row.key)) ?? 0)).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
|
|
2573
|
+
const decided = roles.filter((r) => r.key !== "admin").map((r) => r.permissions);
|
|
2574
|
+
const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
|
|
2575
|
+
return {
|
|
2576
|
+
roles,
|
|
2577
|
+
collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => {
|
|
2578
|
+
const owner = this.ownerOf(c.slug);
|
|
2579
|
+
return {
|
|
2580
|
+
slug: c.slug,
|
|
2581
|
+
ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
|
|
2582
|
+
new: !seen("collections", c.slug),
|
|
2583
|
+
references: [...references(ruleFields(c.fields))].filter(
|
|
2584
|
+
(s) => s !== c.slug && governed(s)
|
|
2585
|
+
),
|
|
2586
|
+
fields: adminFields(c.fields, c.admin?.ownerField),
|
|
2587
|
+
...owner ? { owner } : {}
|
|
2588
|
+
};
|
|
2589
|
+
}),
|
|
2590
|
+
globals: this.config.globals.map((g) => ({
|
|
2591
|
+
slug: g.slug,
|
|
2592
|
+
ops: GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true),
|
|
2593
|
+
new: !seen("globals", g.slug),
|
|
2594
|
+
fields: adminFields(g.fields, void 0)
|
|
2595
|
+
})),
|
|
2596
|
+
views: this.views()
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
async create(input, author) {
|
|
2600
|
+
await this.sync();
|
|
2601
|
+
const key2 = typeof input.key === "string" ? input.key.trim() : "";
|
|
2602
|
+
if (!KEY.test(key2)) {
|
|
2603
|
+
throw new ValidationError(ROLES, [
|
|
2604
|
+
{ field: "key", message: 'lowercase letters, digits and "-", starting with a letter' }
|
|
2605
|
+
]);
|
|
2606
|
+
}
|
|
2607
|
+
if ((await this.rows()).some((r) => r.key === key2))
|
|
2608
|
+
throw new ValidationError(ROLES, [{ field: "key", message: "is already used" }]);
|
|
2609
|
+
const row = await this.insert(
|
|
2610
|
+
key2,
|
|
2611
|
+
this.cleanName(input.name),
|
|
2612
|
+
this.clean(input.permissions),
|
|
2613
|
+
author.id
|
|
2614
|
+
);
|
|
2615
|
+
return this.toRole(row, 0);
|
|
2616
|
+
}
|
|
2617
|
+
async update(id, input, author) {
|
|
2618
|
+
const row = await this.row(id);
|
|
2619
|
+
const data = { ...row, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2620
|
+
if (input.name !== void 0) data.name = this.cleanName(input.name);
|
|
2621
|
+
if (input.permissions !== void 0) {
|
|
2622
|
+
if (row.key === "admin")
|
|
2623
|
+
throw new EasyCMSError("Admins may always do everything; their role cannot be changed", 400);
|
|
2624
|
+
data.permissions = this.clean(input.permissions);
|
|
2625
|
+
}
|
|
2626
|
+
const { id: _id, ...rest } = data;
|
|
2627
|
+
const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
|
|
2628
|
+
await this.record(saved, author.id);
|
|
2629
|
+
this.invalidate();
|
|
2630
|
+
return this.toRole(saved, await this.users(String(row.key)));
|
|
2631
|
+
}
|
|
2632
|
+
async delete(id) {
|
|
2633
|
+
const row = await this.row(id);
|
|
2634
|
+
const key2 = String(row.key);
|
|
2635
|
+
if (this.config.auth.roles.includes(key2))
|
|
2636
|
+
throw new EasyCMSError(`"${key2}" is in auth.roles and cannot be deleted`, 400);
|
|
2637
|
+
const users = await this.users(key2);
|
|
2638
|
+
if (users > 0)
|
|
2639
|
+
throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
|
|
2640
|
+
await this.db.delete({ collection: ROLES, id: row.id });
|
|
2641
|
+
await this.versions.deleteAll(ROLES, row.id);
|
|
2642
|
+
this.invalidate();
|
|
2643
|
+
}
|
|
2644
|
+
async history(id) {
|
|
2645
|
+
const row = await this.row(id);
|
|
2646
|
+
const list = await this.versions.list(ROLES, row.id, HISTORY, 1);
|
|
2647
|
+
const emails = /* @__PURE__ */ new Map();
|
|
2648
|
+
const out = [];
|
|
2649
|
+
for (const summary of list.docs) {
|
|
2650
|
+
const version = await this.versions.get(ROLES, row.id, summary.id);
|
|
2651
|
+
if (!version) continue;
|
|
2652
|
+
let author = null;
|
|
2653
|
+
if (summary.author !== null) {
|
|
2654
|
+
const k = String(summary.author);
|
|
2655
|
+
if (!emails.has(k)) {
|
|
2656
|
+
const user = await this.db.findById({ collection: USERS, id: summary.author });
|
|
2657
|
+
emails.set(k, typeof user?.email === "string" ? user.email : null);
|
|
2658
|
+
}
|
|
2659
|
+
author = emails.get(k) ?? null;
|
|
2660
|
+
}
|
|
2661
|
+
out.push({
|
|
2662
|
+
id: summary.id,
|
|
2663
|
+
author,
|
|
2664
|
+
createdAt: summary.createdAt,
|
|
2665
|
+
name: String(version.data.name ?? ""),
|
|
2666
|
+
permissions: version.data.permissions ?? {}
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
return out;
|
|
2670
|
+
}
|
|
2671
|
+
// -------------------------------------------------------------------------
|
|
2672
|
+
/** Pages and panels roles can be given. */
|
|
2673
|
+
views() {
|
|
2674
|
+
return [
|
|
2675
|
+
{ id: "status" },
|
|
2676
|
+
...this.config.collections.some(
|
|
2677
|
+
(c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
|
|
2678
|
+
) ? [{ id: "deliveries" }] : [],
|
|
2679
|
+
...this.config.admin.pages.map((page) => ({ id: viewId.page(page.path), label: page.label })),
|
|
2680
|
+
...this.config.admin.dashboard.map((widget) => ({
|
|
2681
|
+
id: viewId.widget(tagOf(widget.component)),
|
|
2682
|
+
...widget.label !== void 0 ? { label: widget.label } : {}
|
|
2683
|
+
}))
|
|
2684
|
+
];
|
|
2685
|
+
}
|
|
2686
|
+
/** Every collection and global, and the plugin pages and panels: what roles had before roles. */
|
|
2687
|
+
everything() {
|
|
2688
|
+
const collections = {};
|
|
2689
|
+
for (const c of this.config.collections) {
|
|
2690
|
+
if (governed(c.slug))
|
|
2691
|
+
collections[c.slug] = ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true);
|
|
2692
|
+
}
|
|
2693
|
+
const globals = {};
|
|
2694
|
+
for (const g of this.config.globals)
|
|
2695
|
+
globals[g.slug] = GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true);
|
|
2696
|
+
const admin = this.views().map((v) => v.id).filter((id) => id.startsWith("page:") || id.startsWith("widget:"));
|
|
2697
|
+
return { collections, globals, admin };
|
|
2698
|
+
}
|
|
2699
|
+
/** Keeps only known collections, globals, operations and pages; `read` comes with the others. */
|
|
2700
|
+
clean(value) {
|
|
2701
|
+
const input = typeof value === "object" && value !== null ? value : {};
|
|
2702
|
+
const pick = (group, allowed2) => {
|
|
2703
|
+
const out = {};
|
|
2704
|
+
if (typeof group !== "object" || group === null) return out;
|
|
2705
|
+
for (const [slug2, ops] of Object.entries(group)) {
|
|
2706
|
+
const valid = allowed2.get(slug2);
|
|
2707
|
+
if (!valid || !Array.isArray(ops)) continue;
|
|
2708
|
+
const picked2 = valid.filter((op) => ops.includes(op));
|
|
2709
|
+
if (picked2.length > 0 && !picked2.includes("read")) picked2.unshift("read");
|
|
2710
|
+
out[slug2] = picked2;
|
|
2711
|
+
}
|
|
2712
|
+
return out;
|
|
2713
|
+
};
|
|
2714
|
+
const collections = new Map(
|
|
2715
|
+
this.config.collections.filter((c) => governed(c.slug)).map((c) => [
|
|
2716
|
+
c.slug,
|
|
2717
|
+
ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true)
|
|
2718
|
+
])
|
|
2719
|
+
);
|
|
2720
|
+
const globals = new Map(
|
|
2721
|
+
this.config.globals.map((g) => [
|
|
2722
|
+
g.slug,
|
|
2723
|
+
GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true)
|
|
2724
|
+
])
|
|
2725
|
+
);
|
|
2726
|
+
const views = new Set(this.views().map((v) => v.id));
|
|
2727
|
+
const admin = Array.isArray(input.admin) ? [
|
|
2728
|
+
...new Set(
|
|
2729
|
+
input.admin.filter((id) => typeof id === "string" && views.has(id))
|
|
2730
|
+
)
|
|
2731
|
+
] : [];
|
|
2732
|
+
const picked = {
|
|
2733
|
+
collections: pick(input.collections, collections),
|
|
2734
|
+
globals: pick(input.globals, globals)
|
|
2735
|
+
};
|
|
2736
|
+
const own = {};
|
|
2737
|
+
if (typeof input.own === "object" && input.own !== null) {
|
|
2738
|
+
for (const [slug2, ops] of Object.entries(input.own)) {
|
|
2739
|
+
const granted = picked.collections[slug2];
|
|
2740
|
+
if (!granted || !this.ownerOf(slug2) || !Array.isArray(ops)) continue;
|
|
2741
|
+
const valid = OWN_OPERATIONS.filter((op) => granted.includes(op) && ops.includes(op));
|
|
2742
|
+
if (valid.length > 0) own[slug2] = valid;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
const fields = {
|
|
2746
|
+
collections: this.cleanRules(
|
|
2747
|
+
input.fields?.collections,
|
|
2748
|
+
this.config.collections.filter((c) => governed(c.slug)),
|
|
2749
|
+
picked.collections
|
|
2750
|
+
),
|
|
2751
|
+
globals: this.cleanRules(
|
|
2752
|
+
input.fields?.globals,
|
|
2753
|
+
this.config.globals,
|
|
2754
|
+
void 0
|
|
2755
|
+
)
|
|
2756
|
+
};
|
|
2757
|
+
return { ...picked, admin, own, fields };
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Keeps rules for known top-level fields. A field that must be filled in (required, without a
|
|
2761
|
+
* default) can't be read-only or hidden for a role that creates documents.
|
|
2762
|
+
*/
|
|
2763
|
+
cleanRules(value, containers, granted) {
|
|
2764
|
+
const out = {};
|
|
2765
|
+
if (typeof value !== "object" || value === null) return out;
|
|
2766
|
+
for (const [slug2, rules] of Object.entries(value)) {
|
|
2767
|
+
const container = containers.find((c) => c.slug === slug2);
|
|
2768
|
+
if (!container || typeof rules !== "object" || rules === null) continue;
|
|
2769
|
+
const set = {};
|
|
2770
|
+
for (const field of ruleFields(container.fields)) {
|
|
2771
|
+
const rule = rules[field.name];
|
|
2772
|
+
if (rule !== "read" && rule !== "hidden") continue;
|
|
2773
|
+
if (granted?.[slug2]?.includes("create") && mustFill(field))
|
|
2774
|
+
throw new ValidationError(ROLES, [
|
|
2775
|
+
{
|
|
2776
|
+
field: `fields.${slug2}.${field.name}`,
|
|
2777
|
+
message: "is required: roles that create documents must be able to fill it"
|
|
2778
|
+
}
|
|
2779
|
+
]);
|
|
2780
|
+
set[field.name] = rule;
|
|
2781
|
+
}
|
|
2782
|
+
if (Object.keys(set).length > 0) out[slug2] = set;
|
|
2783
|
+
}
|
|
2784
|
+
return out;
|
|
2785
|
+
}
|
|
2786
|
+
/** Fields that name a collection's owner (`createdBy`, and `admin.ownerField`). */
|
|
2787
|
+
ownerColumns(slug2) {
|
|
2788
|
+
if (!this.ownerOf(slug2)) return [];
|
|
2789
|
+
const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
|
|
2790
|
+
return ownerField ? ["createdBy", ownerField] : ["createdBy"];
|
|
2791
|
+
}
|
|
2792
|
+
/** Sets one field of every matching document, keeping the rest as stored. */
|
|
2793
|
+
async setWhere(collection2, where, field, value) {
|
|
2794
|
+
const { docs } = await this.db.find({ collection: collection2, where, sort: ["id"], limit: 0, page: 1 });
|
|
2795
|
+
for (const { id, ...rest } of docs)
|
|
2796
|
+
await this.db.update({ collection: collection2, id, data: { ...rest, [field]: value } });
|
|
2797
|
+
}
|
|
2798
|
+
cleanName(value) {
|
|
2799
|
+
if (value === void 0 || value === null) return "";
|
|
2800
|
+
if (typeof value !== "string" || value.trim().length > 60)
|
|
2801
|
+
throw new ValidationError(ROLES, [
|
|
2802
|
+
{ field: "name", message: "must be at most 60 characters" }
|
|
2803
|
+
]);
|
|
2804
|
+
return value.trim();
|
|
2805
|
+
}
|
|
2806
|
+
async permissionsOf(key2) {
|
|
2807
|
+
return (await this.load()).get(key2) ?? {};
|
|
2808
|
+
}
|
|
2809
|
+
async load() {
|
|
2810
|
+
if (this.cache && Date.now() - this.cache.at < TTL) return this.cache.byKey;
|
|
2811
|
+
await this.sync();
|
|
2812
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2813
|
+
for (const row of await this.rows())
|
|
2814
|
+
byKey.set(String(row.key), row.permissions ?? {});
|
|
2815
|
+
this.cache = { at: Date.now(), byKey, rules: /* @__PURE__ */ new Map() };
|
|
2816
|
+
return byKey;
|
|
2817
|
+
}
|
|
2818
|
+
async rows() {
|
|
2819
|
+
return (await this.db.find({ collection: ROLES, sort: ["id"], limit: 0, page: 1 })).docs;
|
|
2820
|
+
}
|
|
2821
|
+
async row(id) {
|
|
2822
|
+
const row = await this.db.findById({ collection: ROLES, id });
|
|
2823
|
+
if (!row) throw new NotFoundError(ROLES, id);
|
|
2824
|
+
return row;
|
|
2825
|
+
}
|
|
2826
|
+
users(key2) {
|
|
2827
|
+
return this.db.count({ collection: USERS, where: { role: { equals: key2 } } });
|
|
2828
|
+
}
|
|
2829
|
+
async insert(key2, name, permissions, author) {
|
|
2830
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2831
|
+
const row = await this.db.create({
|
|
2832
|
+
collection: ROLES,
|
|
2833
|
+
data: { key: key2, name, permissions, createdAt: now, updatedAt: now }
|
|
2834
|
+
});
|
|
2835
|
+
await this.record(row, author);
|
|
2836
|
+
this.invalidate();
|
|
2837
|
+
return row;
|
|
2838
|
+
}
|
|
2839
|
+
/** Keeps the role as saved, for its history. */
|
|
2840
|
+
async record(row, author) {
|
|
2841
|
+
await this.versions.save({
|
|
2842
|
+
parent: ROLES,
|
|
2843
|
+
doc: row.id,
|
|
2844
|
+
status: null,
|
|
2845
|
+
snapshot: { name: row.name ?? "", permissions: row.permissions ?? {} },
|
|
2846
|
+
author,
|
|
2847
|
+
max: HISTORY
|
|
2848
|
+
});
|
|
2849
|
+
}
|
|
2850
|
+
toRole(row, users) {
|
|
2851
|
+
const key2 = String(row.key);
|
|
2852
|
+
return {
|
|
2853
|
+
id: row.id,
|
|
2854
|
+
key: key2,
|
|
2855
|
+
name: typeof row.name === "string" ? row.name : "",
|
|
2856
|
+
system: this.config.auth.roles.includes(key2),
|
|
2857
|
+
users,
|
|
2858
|
+
permissions: row.permissions ?? {},
|
|
2859
|
+
updatedAt: String(row.updatedAt)
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
};
|
|
2863
|
+
function mustFill(field) {
|
|
2864
|
+
return field.required === true && field.defaultValue === void 0;
|
|
2865
|
+
}
|
|
2866
|
+
function adminFields(fields, ownerField) {
|
|
2867
|
+
return ruleFields(fields).map((f) => ({
|
|
2868
|
+
name: f.name,
|
|
2869
|
+
...f.label !== void 0 ? { label: f.label } : {},
|
|
2870
|
+
required: mustFill(f),
|
|
2871
|
+
...f.name === ownerField ? { owner: true } : {}
|
|
2872
|
+
}));
|
|
2873
|
+
}
|
|
2874
|
+
function rank(role, config) {
|
|
2875
|
+
const index = config.auth.roles.indexOf(role.key);
|
|
2876
|
+
return index === -1 ? config.auth.roles.length : index;
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2171
2879
|
// src/webhooks.ts
|
|
2172
2880
|
import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
|
|
2173
2881
|
var WEBHOOK_EVENTS = [
|
|
@@ -2359,6 +3067,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
2359
3067
|
"webhook-deliveries",
|
|
2360
3068
|
"email-deliveries",
|
|
2361
3069
|
"database-backups",
|
|
3070
|
+
"user-roles",
|
|
2362
3071
|
"api-keys",
|
|
2363
3072
|
"jobs",
|
|
2364
3073
|
"migrations",
|
|
@@ -2415,7 +3124,10 @@ function validateConfig(config) {
|
|
|
2415
3124
|
if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
|
|
2416
3125
|
collections.forEach((collection2, i) => {
|
|
2417
3126
|
const path = `collections.${collection2.slug ?? `[${i}]`}`;
|
|
2418
|
-
validateContainer(collection2, path, collectionSlugs, add
|
|
3127
|
+
validateContainer(collection2, path, collectionSlugs, add, {
|
|
3128
|
+
collection: true,
|
|
3129
|
+
rbac: config.auth?.rbac === true
|
|
3130
|
+
});
|
|
2419
3131
|
validateUseAsTitle(collection2, path, add);
|
|
2420
3132
|
validateIcon(collection2, path, add);
|
|
2421
3133
|
const editIn = collection2.editIn;
|
|
@@ -2424,7 +3136,10 @@ function validateConfig(config) {
|
|
|
2424
3136
|
});
|
|
2425
3137
|
globals.forEach((global2, i) => {
|
|
2426
3138
|
const path = `globals.${global2.slug ?? `[${i}]`}`;
|
|
2427
|
-
validateContainer(global2, path, collectionSlugs, add
|
|
3139
|
+
validateContainer(global2, path, collectionSlugs, add, {
|
|
3140
|
+
collection: false,
|
|
3141
|
+
rbac: config.auth?.rbac === true
|
|
3142
|
+
});
|
|
2428
3143
|
validateIcon(global2, path, add);
|
|
2429
3144
|
});
|
|
2430
3145
|
const plugins = config.plugins;
|
|
@@ -2563,6 +3278,7 @@ function validateAdminViews(config, add) {
|
|
|
2563
3278
|
"e.g. dashboard: [{ component: 'ecms-stats-widget' }]"
|
|
2564
3279
|
);
|
|
2565
3280
|
} else {
|
|
3281
|
+
const tags = /* @__PURE__ */ new Set();
|
|
2566
3282
|
widgets2.forEach((widget, i) => {
|
|
2567
3283
|
const path = `admin.dashboard[${i}]`;
|
|
2568
3284
|
if (typeof widget !== "object" || widget === null) {
|
|
@@ -2573,6 +3289,19 @@ function validateAdminViews(config, add) {
|
|
|
2573
3289
|
validateComponent(w.component, `${path}.component`, add);
|
|
2574
3290
|
if (w.width !== void 0 && w.width !== "half" && w.width !== "full")
|
|
2575
3291
|
add(`${path}.width`, 'must be "half" or "full"');
|
|
3292
|
+
if (w.label !== void 0 && !(typeof w.label === "string" && w.label.trim() !== "") && !(typeof w.label === "object" && w.label !== null && !Array.isArray(w.label)))
|
|
3293
|
+
add(`${path}.label`, "must be a string or { en, th }");
|
|
3294
|
+
const component = w.component;
|
|
3295
|
+
const tag = typeof component === "string" ? component : component?.tag;
|
|
3296
|
+
if (config.auth?.rbac === true && typeof tag === "string") {
|
|
3297
|
+
if (tags.has(tag))
|
|
3298
|
+
add(
|
|
3299
|
+
`${path}.component`,
|
|
3300
|
+
`another panel uses "${tag}"`,
|
|
3301
|
+
"with auth.rbac, roles are given panels by their tag: use one tag per panel"
|
|
3302
|
+
);
|
|
3303
|
+
tags.add(tag);
|
|
3304
|
+
}
|
|
2576
3305
|
access(w.access, path);
|
|
2577
3306
|
});
|
|
2578
3307
|
}
|
|
@@ -2586,6 +3315,8 @@ function validateIcon(container, path, add) {
|
|
|
2586
3315
|
function validateAuth(config, add) {
|
|
2587
3316
|
const auth = config.auth;
|
|
2588
3317
|
if (auth === void 0) return;
|
|
3318
|
+
if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
|
|
3319
|
+
add("auth.rbac", "must be true or false");
|
|
2589
3320
|
if (auth.roles !== void 0) {
|
|
2590
3321
|
const roles = auth.roles;
|
|
2591
3322
|
if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
|
|
@@ -2852,7 +3583,7 @@ function checkSlug(container, path, seen, add) {
|
|
|
2852
3583
|
seen.add(slug2);
|
|
2853
3584
|
return slug2;
|
|
2854
3585
|
}
|
|
2855
|
-
function validateContainer(container, path, collectionSlugs, add) {
|
|
3586
|
+
function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac }) {
|
|
2856
3587
|
if (!Array.isArray(container.fields)) {
|
|
2857
3588
|
add(`${path}.fields`, "must be an array");
|
|
2858
3589
|
return;
|
|
@@ -2889,8 +3620,25 @@ function validateContainer(container, path, collectionSlugs, add) {
|
|
|
2889
3620
|
add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
|
|
2890
3621
|
const list = admin.list;
|
|
2891
3622
|
if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
|
|
3623
|
+
const owner = admin.ownerField;
|
|
3624
|
+
if (owner !== void 0) {
|
|
3625
|
+
const field = container.fields.find((f) => f.name === owner);
|
|
3626
|
+
if (!collection2 || container.slug === "users" || field?.type !== "relationship" || field.to !== "users" || field.hasMany)
|
|
3627
|
+
add(
|
|
3628
|
+
`${path}.admin.ownerField`,
|
|
3629
|
+
'must name a relationship field to "users" (not hasMany) of a collection',
|
|
3630
|
+
"e.g. { name: 'author', type: 'relationship', to: 'users' }"
|
|
3631
|
+
);
|
|
3632
|
+
}
|
|
2892
3633
|
}
|
|
2893
3634
|
}
|
|
3635
|
+
const createdBy = container.fields.find((f) => f.name === CREATED_BY_FIELD.name);
|
|
3636
|
+
if (rbac && createdBy && createdBy !== CREATED_BY_FIELD)
|
|
3637
|
+
add(
|
|
3638
|
+
`${path}.fields.createdBy`,
|
|
3639
|
+
'"createdBy" is added by Easy CMS with auth.rbac',
|
|
3640
|
+
"rename the field; to use it as the owner, set admin.ownerField"
|
|
3641
|
+
);
|
|
2894
3642
|
const reserved = new Set(SYSTEM_FIELD_NAMES);
|
|
2895
3643
|
if (container.drafts) reserved.add("status");
|
|
2896
3644
|
validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
|
|
@@ -3244,7 +3992,7 @@ async function resolveConfig(input) {
|
|
|
3244
3992
|
if (resolved.has(input)) return input;
|
|
3245
3993
|
let config = await applyPlugins(input);
|
|
3246
3994
|
if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
|
|
3247
|
-
config = withMedia(withUsers(config));
|
|
3995
|
+
config = withCreatedBy(withMedia(withUsers(config)));
|
|
3248
3996
|
}
|
|
3249
3997
|
const applied = applyFieldTypes(config);
|
|
3250
3998
|
config = applied.config;
|
|
@@ -3280,6 +4028,7 @@ async function resolveConfig(input) {
|
|
|
3280
4028
|
},
|
|
3281
4029
|
auth: {
|
|
3282
4030
|
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
4031
|
+
rbac: config.auth?.rbac === true,
|
|
3283
4032
|
tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
|
|
3284
4033
|
maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
|
|
3285
4034
|
lockWindow: config.auth?.lockWindow ?? 15 * 60,
|
|
@@ -3291,12 +4040,14 @@ async function resolveConfig(input) {
|
|
|
3291
4040
|
collections: [
|
|
3292
4041
|
...config.collections ?? [],
|
|
3293
4042
|
...internalCollections,
|
|
3294
|
-
|
|
4043
|
+
// Roles keep their history as versions.
|
|
4044
|
+
...usesVersions(config) || config.auth?.rbac ? [versionsCollection] : [],
|
|
3295
4045
|
...usesSchedule(config) ? [scheduledJobsCollection] : [],
|
|
3296
4046
|
...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
|
|
3297
4047
|
...config.apiKeys ? [apiKeysCollection] : [],
|
|
3298
4048
|
...config.email ? [emailDeliveriesCollection] : [],
|
|
3299
|
-
databaseBackupsCollection
|
|
4049
|
+
databaseBackupsCollection,
|
|
4050
|
+
...config.auth?.rbac ? [rolesCollection] : []
|
|
3300
4051
|
],
|
|
3301
4052
|
globals: config.globals ?? [],
|
|
3302
4053
|
endpoints: config.endpoints ?? [],
|
|
@@ -3841,137 +4592,6 @@ function read(response, maxBytes, deadline) {
|
|
|
3841
4592
|
});
|
|
3842
4593
|
}
|
|
3843
4594
|
|
|
3844
|
-
// src/versions.ts
|
|
3845
|
-
var DEFAULT_MAX_VERSIONS = 50;
|
|
3846
|
-
function versionLimit(config) {
|
|
3847
|
-
const versions = config.versions;
|
|
3848
|
-
if (!versions) return void 0;
|
|
3849
|
-
return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
|
|
3850
|
-
}
|
|
3851
|
-
var collectionParent = (slug2) => slug2;
|
|
3852
|
-
var globalParent = (slug2) => `global:${slug2}`;
|
|
3853
|
-
function snapshotOf(fields, doc) {
|
|
3854
|
-
const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
|
|
3855
|
-
const snapshot = {};
|
|
3856
|
-
for (const [key2, value] of Object.entries(doc)) {
|
|
3857
|
-
if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
|
|
3858
|
-
snapshot[key2] = value;
|
|
3859
|
-
}
|
|
3860
|
-
return snapshot;
|
|
3861
|
-
}
|
|
3862
|
-
var toSummary = (row) => ({
|
|
3863
|
-
id: row.id,
|
|
3864
|
-
status: row.status ?? null,
|
|
3865
|
-
latest: row.latest === true,
|
|
3866
|
-
author: row.author ?? null,
|
|
3867
|
-
createdAt: String(row.createdAt)
|
|
3868
|
-
});
|
|
3869
|
-
var VersionStore = class {
|
|
3870
|
-
constructor(db) {
|
|
3871
|
-
this.db = db;
|
|
3872
|
-
}
|
|
3873
|
-
db;
|
|
3874
|
-
/** Saves a new latest version and deletes the oldest beyond `max`. */
|
|
3875
|
-
async save(args) {
|
|
3876
|
-
const { parent, doc } = args;
|
|
3877
|
-
const previous = await this.db.find({
|
|
3878
|
-
collection: VERSIONS,
|
|
3879
|
-
where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
|
|
3880
|
-
sort: [],
|
|
3881
|
-
limit: 0,
|
|
3882
|
-
page: 1
|
|
3883
|
-
});
|
|
3884
|
-
for (const row of previous.docs) {
|
|
3885
|
-
await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
|
|
3886
|
-
}
|
|
3887
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3888
|
-
const created = await this.db.create({
|
|
3889
|
-
collection: VERSIONS,
|
|
3890
|
-
data: {
|
|
3891
|
-
parent,
|
|
3892
|
-
doc,
|
|
3893
|
-
status: args.status,
|
|
3894
|
-
latest: true,
|
|
3895
|
-
author: args.author,
|
|
3896
|
-
snapshot: args.snapshot,
|
|
3897
|
-
createdAt: now,
|
|
3898
|
-
updatedAt: now
|
|
3899
|
-
}
|
|
3900
|
-
});
|
|
3901
|
-
await this.prune(parent, doc, args.max);
|
|
3902
|
-
return toSummary(created);
|
|
3903
|
-
}
|
|
3904
|
-
/** The latest version of each document, keyed by document id. */
|
|
3905
|
-
async latest(parent, docs) {
|
|
3906
|
-
const result = /* @__PURE__ */ new Map();
|
|
3907
|
-
if (docs.length === 0) return result;
|
|
3908
|
-
const rows = await this.db.find({
|
|
3909
|
-
collection: VERSIONS,
|
|
3910
|
-
where: {
|
|
3911
|
-
and: [
|
|
3912
|
-
{ parent: { equals: parent } },
|
|
3913
|
-
{ doc: { in: [...docs] } },
|
|
3914
|
-
{ latest: { equals: true } }
|
|
3915
|
-
]
|
|
3916
|
-
},
|
|
3917
|
-
sort: [],
|
|
3918
|
-
limit: 0,
|
|
3919
|
-
page: 1
|
|
3920
|
-
});
|
|
3921
|
-
for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
|
|
3922
|
-
return result;
|
|
3923
|
-
}
|
|
3924
|
-
async list(parent, doc, limit, page) {
|
|
3925
|
-
const found = await this.db.find({
|
|
3926
|
-
collection: VERSIONS,
|
|
3927
|
-
where: { and: this.of(parent, doc) },
|
|
3928
|
-
sort: ["-id"],
|
|
3929
|
-
limit,
|
|
3930
|
-
page
|
|
3931
|
-
});
|
|
3932
|
-
return { ...found, docs: found.docs.map(toSummary) };
|
|
3933
|
-
}
|
|
3934
|
-
async get(parent, doc, id) {
|
|
3935
|
-
const found = await this.db.find({
|
|
3936
|
-
collection: VERSIONS,
|
|
3937
|
-
where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
|
|
3938
|
-
sort: [],
|
|
3939
|
-
limit: 1,
|
|
3940
|
-
page: 1
|
|
3941
|
-
});
|
|
3942
|
-
const row = found.docs[0];
|
|
3943
|
-
return row ? this.toVersion(row) : null;
|
|
3944
|
-
}
|
|
3945
|
-
async deleteAll(parent, doc) {
|
|
3946
|
-
const rows = await this.db.find({
|
|
3947
|
-
collection: VERSIONS,
|
|
3948
|
-
where: { and: this.of(parent, doc) },
|
|
3949
|
-
sort: [],
|
|
3950
|
-
limit: 0,
|
|
3951
|
-
page: 1
|
|
3952
|
-
});
|
|
3953
|
-
for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
|
|
3954
|
-
}
|
|
3955
|
-
async prune(parent, doc, max) {
|
|
3956
|
-
const rows = await this.db.find({
|
|
3957
|
-
collection: VERSIONS,
|
|
3958
|
-
where: { and: this.of(parent, doc) },
|
|
3959
|
-
sort: ["-id"],
|
|
3960
|
-
limit: 0,
|
|
3961
|
-
page: 1
|
|
3962
|
-
});
|
|
3963
|
-
for (const row of rows.docs.slice(max)) {
|
|
3964
|
-
await this.db.delete({ collection: VERSIONS, id: row.id });
|
|
3965
|
-
}
|
|
3966
|
-
}
|
|
3967
|
-
of(parent, doc) {
|
|
3968
|
-
return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
|
|
3969
|
-
}
|
|
3970
|
-
toVersion(row) {
|
|
3971
|
-
return { ...toSummary(row), data: row.snapshot ?? {} };
|
|
3972
|
-
}
|
|
3973
|
-
};
|
|
3974
|
-
|
|
3975
4595
|
// src/local-api.ts
|
|
3976
4596
|
async function createEasyCMS(config, options = {}) {
|
|
3977
4597
|
const resolved3 = await resolveConfig(config);
|
|
@@ -4009,6 +4629,8 @@ var EasyCMS = class {
|
|
|
4009
4629
|
storage;
|
|
4010
4630
|
/** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
|
|
4011
4631
|
cwd;
|
|
4632
|
+
/** Roles and their permissions (`auth.rbac`, Settings → Roles). */
|
|
4633
|
+
roles;
|
|
4012
4634
|
versions;
|
|
4013
4635
|
webhooks;
|
|
4014
4636
|
mailer;
|
|
@@ -4019,6 +4641,7 @@ var EasyCMS = class {
|
|
|
4019
4641
|
this.storage = storage;
|
|
4020
4642
|
this.cwd = cwd;
|
|
4021
4643
|
this.versions = new VersionStore(db);
|
|
4644
|
+
this.roles = new Roles(config, db, (message2) => logger.info(message2));
|
|
4022
4645
|
this.webhooks = new Webhooks(
|
|
4023
4646
|
config.webhooks ?? [],
|
|
4024
4647
|
logger,
|
|
@@ -4045,6 +4668,7 @@ var EasyCMS = class {
|
|
|
4045
4668
|
options
|
|
4046
4669
|
);
|
|
4047
4670
|
const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
4671
|
+
await this.checkQueryFields(config, guard, void 0, requested);
|
|
4048
4672
|
const locale = this.localeOf(options);
|
|
4049
4673
|
const sort = locale === void 0 ? requested : localizeSort(
|
|
4050
4674
|
config.fields,
|
|
@@ -4109,7 +4733,7 @@ var EasyCMS = class {
|
|
|
4109
4733
|
const config = this.collection(MEDIA);
|
|
4110
4734
|
const guard = guardOf(options);
|
|
4111
4735
|
if (guard.enforce) {
|
|
4112
|
-
this.
|
|
4736
|
+
await this.checkGrant(guard, { collection: MEDIA }, "create");
|
|
4113
4737
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
|
|
4114
4738
|
if (allowed2 !== true) throw deny(guard.user);
|
|
4115
4739
|
}
|
|
@@ -4169,7 +4793,7 @@ var EasyCMS = class {
|
|
|
4169
4793
|
const guard = guardOf(options);
|
|
4170
4794
|
const fromURL = this.config.upload.fromURL;
|
|
4171
4795
|
if (guard.enforce) {
|
|
4172
|
-
this.
|
|
4796
|
+
await this.checkGrant(guard, { collection: MEDIA }, "create");
|
|
4173
4797
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
|
|
4174
4798
|
if (allowed2 !== true) throw deny(guard.user);
|
|
4175
4799
|
if (!fromURL)
|
|
@@ -4324,7 +4948,7 @@ var EasyCMS = class {
|
|
|
4324
4948
|
let parsed;
|
|
4325
4949
|
if (id === null) {
|
|
4326
4950
|
if (guard.enforce) {
|
|
4327
|
-
this.
|
|
4951
|
+
await this.checkGrant(guard, { collection: collection2 }, "create");
|
|
4328
4952
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
|
|
4329
4953
|
if (allowed2 !== true) throw deny(guard.user);
|
|
4330
4954
|
}
|
|
@@ -4339,7 +4963,7 @@ var EasyCMS = class {
|
|
|
4339
4963
|
const filtered = await filterInput(
|
|
4340
4964
|
config.fields,
|
|
4341
4965
|
input,
|
|
4342
|
-
this.fieldChecker("update", guard, parsed, input)
|
|
4966
|
+
await this.fieldChecker("update", guard, parsed, input)
|
|
4343
4967
|
);
|
|
4344
4968
|
const localized = this.toMaps(config, filtered, base ?? {}, options);
|
|
4345
4969
|
const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
|
|
@@ -4379,7 +5003,7 @@ var EasyCMS = class {
|
|
|
4379
5003
|
const input = await filterInput(
|
|
4380
5004
|
config.fields,
|
|
4381
5005
|
raw,
|
|
4382
|
-
this.fieldChecker("update", guard, void 0, raw)
|
|
5006
|
+
await this.fieldChecker("update", guard, void 0, raw)
|
|
4383
5007
|
);
|
|
4384
5008
|
const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
|
|
4385
5009
|
const current = await this.globalDraft(config, saved);
|
|
@@ -4444,7 +5068,7 @@ var EasyCMS = class {
|
|
|
4444
5068
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
4445
5069
|
await this.checkDocumentAccess(config, "update", guard, parsed, raw);
|
|
4446
5070
|
if (config.drafts && (mode === "unpublish" || raw.status === "published"))
|
|
4447
|
-
this.
|
|
5071
|
+
await this.checkDocumentGrant(guard, collection2, "publish", parsed);
|
|
4448
5072
|
const [drafted] = await this.withDrafts(config, [existing]);
|
|
4449
5073
|
const live = options.live === true && mode === "save";
|
|
4450
5074
|
const current = live ? existing : drafted;
|
|
@@ -4456,7 +5080,7 @@ var EasyCMS = class {
|
|
|
4456
5080
|
const filtered = await filterInput(
|
|
4457
5081
|
config.fields,
|
|
4458
5082
|
input,
|
|
4459
|
-
this.fieldChecker("update", guard, parsed, input)
|
|
5083
|
+
await this.fieldChecker("update", guard, parsed, input)
|
|
4460
5084
|
);
|
|
4461
5085
|
const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
|
|
4462
5086
|
let merged = mergeForUpdate(config.fields, current, localized);
|
|
@@ -4524,6 +5148,10 @@ var EasyCMS = class {
|
|
|
4524
5148
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
4525
5149
|
return out;
|
|
4526
5150
|
}
|
|
5151
|
+
/**
|
|
5152
|
+
* Deletes a document. Deleting a user with roles from the admin (`auth.rbac`): `transferTo`
|
|
5153
|
+
* gives the documents they own to another user; without it they have no owner.
|
|
5154
|
+
*/
|
|
4527
5155
|
async delete(collection2, id, options = {}) {
|
|
4528
5156
|
const config = this.collection(collection2);
|
|
4529
5157
|
const guard = guardOf(options);
|
|
@@ -4535,7 +5163,19 @@ var EasyCMS = class {
|
|
|
4535
5163
|
await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
|
|
4536
5164
|
const base = this.hookArgs(config, guard);
|
|
4537
5165
|
for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
|
|
4538
|
-
if (config.slug === USERS)
|
|
5166
|
+
if (config.slug === USERS) {
|
|
5167
|
+
const to = options.transferTo;
|
|
5168
|
+
if (to !== void 0 && to !== null) {
|
|
5169
|
+
const target = parseId(to);
|
|
5170
|
+
const heir = target === void 0 ? null : await this.db.findById({ collection: USERS, id: target });
|
|
5171
|
+
if (!heir || String(heir.id) === String(parsed))
|
|
5172
|
+
throw new ValidationError(USERS, [
|
|
5173
|
+
{ field: "transferTo", message: "must be another existing user" }
|
|
5174
|
+
]);
|
|
5175
|
+
await this.roles.transfer(parsed, heir.id);
|
|
5176
|
+
} else await this.roles.transfer(parsed, null);
|
|
5177
|
+
await this.auth.revokeSessions(parsed);
|
|
5178
|
+
}
|
|
4539
5179
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
4540
5180
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
4541
5181
|
if (config.schedule) {
|
|
@@ -4639,11 +5279,11 @@ var EasyCMS = class {
|
|
|
4639
5279
|
const guard = guardOf(options);
|
|
4640
5280
|
await this.checkGlobalAccess(config, "update", guard);
|
|
4641
5281
|
if (config.drafts && (mode === "unpublish" || raw.status === "published"))
|
|
4642
|
-
this.
|
|
5282
|
+
await this.checkGrant(guard, { global: slug2 }, "publish");
|
|
4643
5283
|
const input = await filterInput(
|
|
4644
5284
|
config.fields,
|
|
4645
5285
|
raw,
|
|
4646
|
-
this.fieldChecker("update", guard, void 0, raw)
|
|
5286
|
+
await this.fieldChecker("update", guard, void 0, raw)
|
|
4647
5287
|
);
|
|
4648
5288
|
const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
|
|
4649
5289
|
const current = await this.globalDraft(config, existing);
|
|
@@ -4928,14 +5568,14 @@ var EasyCMS = class {
|
|
|
4928
5568
|
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
4929
5569
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
4930
5570
|
await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
|
|
4931
|
-
this.
|
|
5571
|
+
await this.checkDocumentGrant(guardOf(options), collection2, "publish", parsed);
|
|
4932
5572
|
return { config, parsed };
|
|
4933
5573
|
}
|
|
4934
5574
|
async globalScheduleTarget(slug2, options) {
|
|
4935
5575
|
const config = this.global(slug2);
|
|
4936
5576
|
if (!config.schedule) throw new QueryError(`"${slug2}" has no schedule`);
|
|
4937
5577
|
await this.checkGlobalAccess(config, "update", guardOf(options));
|
|
4938
|
-
this.
|
|
5578
|
+
await this.checkGrant(guardOf(options), { global: slug2 }, "publish");
|
|
4939
5579
|
}
|
|
4940
5580
|
async addJob(parent, doc, job, options) {
|
|
4941
5581
|
const runAt = new Date(job.at);
|
|
@@ -4981,20 +5621,21 @@ var EasyCMS = class {
|
|
|
4981
5621
|
page: 1
|
|
4982
5622
|
});
|
|
4983
5623
|
const allowed2 = /* @__PURE__ */ new Map();
|
|
4984
|
-
const
|
|
5624
|
+
const may2 = async (parent) => {
|
|
4985
5625
|
if (!guard.enforce) return true;
|
|
4986
5626
|
const cached = allowed2.get(parent);
|
|
4987
5627
|
if (cached !== void 0) return cached;
|
|
4988
5628
|
const config = parent.startsWith("global:") ? this.config.globals.find((g) => g.slug === parent.slice("global:".length)) : this.config.collections.find((c) => c.slug === parent);
|
|
4989
5629
|
const access = config ? await evaluateAccess(config.access?.update, { user: guard.user }) : false;
|
|
4990
|
-
const
|
|
5630
|
+
const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
|
|
5631
|
+
const result = access !== false && await this.roles.allows(guard.user, target, "update") !== false;
|
|
4991
5632
|
allowed2.set(parent, result);
|
|
4992
5633
|
return result;
|
|
4993
5634
|
};
|
|
4994
5635
|
const jobs = [];
|
|
4995
5636
|
for (const row of rows.docs) {
|
|
4996
5637
|
const parent = String(row.parent);
|
|
4997
|
-
if (!await
|
|
5638
|
+
if (!await may2(parent)) continue;
|
|
4998
5639
|
const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
|
|
4999
5640
|
jobs.push({ ...toJob(row), ...target, doc: row.doc });
|
|
5000
5641
|
if (jobs.length === limit) break;
|
|
@@ -5079,28 +5720,37 @@ var EasyCMS = class {
|
|
|
5079
5720
|
if (!config) throw new QueryError(`Unknown global "${slug2}"`);
|
|
5080
5721
|
return config;
|
|
5081
5722
|
}
|
|
5082
|
-
/**
|
|
5083
|
-
|
|
5084
|
-
|
|
5723
|
+
/**
|
|
5724
|
+
* Refuses what a request's API key (see `ApiKeyPermissions`) or the user's role (`auth.rbac`)
|
|
5725
|
+
* does not allow. Returns a constraint when the role allows only the user's own account.
|
|
5726
|
+
*/
|
|
5727
|
+
async checkGrant(guard, target, operation) {
|
|
5728
|
+
if (!guard.enforce) return true;
|
|
5085
5729
|
const name = "collection" in target ? target.collection : target.global;
|
|
5086
|
-
|
|
5730
|
+
if (!keyAllows(guard.user, target, operation))
|
|
5731
|
+
throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
|
|
5732
|
+
const role = await this.roles.allows(guard.user, target, operation);
|
|
5733
|
+
if (role === false) throw deny(guard.user);
|
|
5734
|
+
return role;
|
|
5087
5735
|
}
|
|
5088
5736
|
/** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
|
|
5089
5737
|
async readWhere(config, guard, where) {
|
|
5090
5738
|
if (!guard.enforce) return where;
|
|
5091
|
-
this.
|
|
5739
|
+
const own = await this.checkGrant(guard, { collection: config.slug }, "read");
|
|
5092
5740
|
const access = await evaluateAccess(config.access?.read, { user: guard.user });
|
|
5093
5741
|
if (access === false) throw deny(guard.user);
|
|
5094
|
-
|
|
5742
|
+
await this.checkQueryFields(config, guard, where);
|
|
5743
|
+
return andWhere(andWhere(where, access), own);
|
|
5095
5744
|
}
|
|
5096
5745
|
async checkDocumentAccess(config, operation, guard, id, data) {
|
|
5097
5746
|
if (!guard.enforce) return;
|
|
5098
|
-
this.
|
|
5099
|
-
const
|
|
5747
|
+
const own = await this.checkGrant(guard, { collection: config.slug }, operation);
|
|
5748
|
+
const allowed2 = await evaluateAccess(config.access?.[operation], {
|
|
5100
5749
|
user: guard.user,
|
|
5101
5750
|
id,
|
|
5102
5751
|
...data ? { data } : {}
|
|
5103
5752
|
});
|
|
5753
|
+
const access = allowed2 === false ? false : andWhere(own === true ? void 0 : own, allowed2) ?? true;
|
|
5104
5754
|
if (access === true) return;
|
|
5105
5755
|
if (access !== false) {
|
|
5106
5756
|
const matches2 = await this.db.count({
|
|
@@ -5113,23 +5763,58 @@ var EasyCMS = class {
|
|
|
5113
5763
|
}
|
|
5114
5764
|
async checkGlobalAccess(config, operation, guard) {
|
|
5115
5765
|
if (!guard.enforce) return;
|
|
5116
|
-
this.
|
|
5766
|
+
await this.checkGrant(guard, { global: config.slug }, operation);
|
|
5117
5767
|
const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
|
|
5118
5768
|
if (typeof access === "object")
|
|
5119
5769
|
throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
|
|
5120
5770
|
if (!access) throw deny(guard.user);
|
|
5121
5771
|
}
|
|
5122
|
-
fieldChecker(kind, guard, id, data) {
|
|
5772
|
+
async fieldChecker(kind, guard, id, data) {
|
|
5123
5773
|
if (!guard.enforce) return void 0;
|
|
5124
|
-
return new FieldAccessChecker(
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5774
|
+
return new FieldAccessChecker(
|
|
5775
|
+
kind,
|
|
5776
|
+
{
|
|
5777
|
+
user: guard.user,
|
|
5778
|
+
...id !== void 0 ? { id } : {},
|
|
5779
|
+
...data ? { data } : {}
|
|
5780
|
+
},
|
|
5781
|
+
await this.roles.fieldRules(guard.user)
|
|
5782
|
+
);
|
|
5783
|
+
}
|
|
5784
|
+
/**
|
|
5785
|
+
* Refuses filtering or sorting by fields the caller may not read: the results would tell
|
|
5786
|
+
* their values. Checks each field along a dotted path, through groups, arrays and blocks.
|
|
5787
|
+
*/
|
|
5788
|
+
async checkQueryFields(config, guard, where, sort = []) {
|
|
5789
|
+
if (!guard.enforce) return;
|
|
5790
|
+
const paths = [...wherePaths(where), ...sort.map((s) => s.replace(/^-/, ""))];
|
|
5791
|
+
if (paths.length === 0) return;
|
|
5792
|
+
const read2 = await this.fieldChecker("read", guard, void 0, void 0);
|
|
5793
|
+
for (const path of paths) {
|
|
5794
|
+
let fields = config.fields;
|
|
5795
|
+
for (const name of path.split(".")) {
|
|
5796
|
+
if (!fields) break;
|
|
5797
|
+
const field = fields.find((f) => f.name === name);
|
|
5798
|
+
if (!field) break;
|
|
5799
|
+
if (field.hidden || read2 && !await read2.allows(field))
|
|
5800
|
+
throw new ForbiddenError(`You may not filter or sort by "${path}"`);
|
|
5801
|
+
fields = field.type === "group" || field.type === "array" ? field.fields : field.type === "blocks" ? field.blocks.flatMap((b) => b.fields) : void 0;
|
|
5802
|
+
}
|
|
5803
|
+
}
|
|
5804
|
+
}
|
|
5805
|
+
/** `checkGrant` for one document: a role limited to its own documents must own it. */
|
|
5806
|
+
async checkDocumentGrant(guard, collection2, operation, id) {
|
|
5807
|
+
const own = await this.checkGrant(guard, { collection: collection2 }, operation);
|
|
5808
|
+
if (own === true) return;
|
|
5809
|
+
const matches2 = await this.db.count({
|
|
5810
|
+
collection: collection2,
|
|
5811
|
+
where: andWhere(own, { id: { equals: id } })
|
|
5128
5812
|
});
|
|
5813
|
+
if (matches2 === 0) throw deny(guard.user);
|
|
5129
5814
|
}
|
|
5130
5815
|
/** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
|
|
5131
5816
|
async output(config, docs, guard, options) {
|
|
5132
|
-
const read2 = this.fieldChecker("read", guard, void 0, void 0);
|
|
5817
|
+
const read2 = await this.fieldChecker("read", guard, void 0, void 0);
|
|
5133
5818
|
const finish = async (target, doc) => {
|
|
5134
5819
|
const hooked = await this.transform(
|
|
5135
5820
|
target.hooks?.afterRead,
|
|
@@ -5167,9 +5852,9 @@ var EasyCMS = class {
|
|
|
5167
5852
|
const guard = guardOf(options);
|
|
5168
5853
|
const collection2 = config.slug;
|
|
5169
5854
|
if (guard.enforce) {
|
|
5170
|
-
this.
|
|
5855
|
+
await this.checkGrant(guard, { collection: collection2 }, "create");
|
|
5171
5856
|
if (config.drafts && raw.status === "published")
|
|
5172
|
-
this.
|
|
5857
|
+
await this.checkGrant(guard, { collection: collection2 }, "publish");
|
|
5173
5858
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
|
|
5174
5859
|
if (typeof allowed2 === "object")
|
|
5175
5860
|
throw new QueryError(`create access of "${collection2}" must return a boolean`);
|
|
@@ -5179,8 +5864,9 @@ var EasyCMS = class {
|
|
|
5179
5864
|
const filtered = await filterInput(
|
|
5180
5865
|
config.fields,
|
|
5181
5866
|
input,
|
|
5182
|
-
this.fieldChecker("update", guard, void 0, input)
|
|
5867
|
+
await this.fieldChecker("update", guard, void 0, input)
|
|
5183
5868
|
);
|
|
5869
|
+
this.roles.fillOwner(collection2, filtered, guard.user ?? hookGuard?.user ?? null);
|
|
5184
5870
|
const base = this.hookArgs(config, hookGuard ?? guard);
|
|
5185
5871
|
let data = applyDefaults(
|
|
5186
5872
|
config.fields,
|
|
@@ -5388,6 +6074,8 @@ var EasyCMS = class {
|
|
|
5388
6074
|
}
|
|
5389
6075
|
clean2.status = status;
|
|
5390
6076
|
}
|
|
6077
|
+
if (config.slug === USERS && this.roles.enabled && typeof clean2.role === "string" && !await this.roles.exists(clean2.role))
|
|
6078
|
+
errors.push({ field: "role", message: `there is no role "${clean2.role}"` });
|
|
5391
6079
|
const isCollection = this.config.collections.includes(config);
|
|
5392
6080
|
if (isCollection && errors.length === 0) {
|
|
5393
6081
|
await this.makeSlugsUnique(config, clean2, selfId);
|
|
@@ -5474,11 +6162,11 @@ var EasyCMS = class {
|
|
|
5474
6162
|
return await this.db.count({ collection: collection2, where }) > 0;
|
|
5475
6163
|
}
|
|
5476
6164
|
/** Each reference is one its relationship's `filterOptions` allow. */
|
|
5477
|
-
async checkFilterOptions(
|
|
6165
|
+
async checkFilterOptions(references2, selfId, user) {
|
|
5478
6166
|
const errors = [];
|
|
5479
6167
|
const cms = this;
|
|
5480
6168
|
const allowed2 = /* @__PURE__ */ new Map();
|
|
5481
|
-
for (const ref of
|
|
6169
|
+
for (const ref of references2) {
|
|
5482
6170
|
const filter = ref.filterOptions;
|
|
5483
6171
|
if (!filter) continue;
|
|
5484
6172
|
if (!allowed2.has(filter)) allowed2.set(filter, await filter({ id: selfId, user, cms }));
|
|
@@ -5496,9 +6184,9 @@ var EasyCMS = class {
|
|
|
5496
6184
|
}
|
|
5497
6185
|
return errors;
|
|
5498
6186
|
}
|
|
5499
|
-
async checkReferences(
|
|
6187
|
+
async checkReferences(references2) {
|
|
5500
6188
|
const byCollection = /* @__PURE__ */ new Map();
|
|
5501
|
-
for (const ref of
|
|
6189
|
+
for (const ref of references2) {
|
|
5502
6190
|
if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
|
|
5503
6191
|
byCollection.set(ref.collection, [...byCollection.get(ref.collection) ?? [], ref]);
|
|
5504
6192
|
}
|
|
@@ -5549,6 +6237,16 @@ function loadSharp() {
|
|
|
5549
6237
|
);
|
|
5550
6238
|
return sharpModule;
|
|
5551
6239
|
}
|
|
6240
|
+
function wherePaths(where) {
|
|
6241
|
+
if (!where) return [];
|
|
6242
|
+
const out = [];
|
|
6243
|
+
for (const [key2, value] of Object.entries(where)) {
|
|
6244
|
+
if (key2 === "and" || key2 === "or") {
|
|
6245
|
+
for (const sub of value ?? []) out.push(...wherePaths(sub));
|
|
6246
|
+
} else out.push(key2);
|
|
6247
|
+
}
|
|
6248
|
+
return out;
|
|
6249
|
+
}
|
|
5552
6250
|
function guardOf(options) {
|
|
5553
6251
|
return { enforce: options.overrideAccess === false, user: options.user ?? null };
|
|
5554
6252
|
}
|
|
@@ -5820,10 +6518,11 @@ function componentRef(component) {
|
|
|
5820
6518
|
async function allowed(access, user) {
|
|
5821
6519
|
return await evaluateAccess(access, { user }) !== false;
|
|
5822
6520
|
}
|
|
5823
|
-
async function serializeFields(fields,
|
|
6521
|
+
async function serializeFields(fields, checks, localized) {
|
|
6522
|
+
const { update: update2 } = checks;
|
|
5824
6523
|
const out = [];
|
|
5825
6524
|
for (const field of fields) {
|
|
5826
|
-
if (field.hidden) continue;
|
|
6525
|
+
if (field.hidden || !await checks.read.allows(field)) continue;
|
|
5827
6526
|
const f = { name: field.name, type: field.type };
|
|
5828
6527
|
if (field.label !== void 0) f.label = field.label;
|
|
5829
6528
|
if (field.required) f.required = true;
|
|
@@ -5876,7 +6575,7 @@ async function serializeFields(fields, update2, localized) {
|
|
|
5876
6575
|
case "array":
|
|
5877
6576
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
5878
6577
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
5879
|
-
f.fields = await serializeFields(field.fields,
|
|
6578
|
+
f.fields = await serializeFields(field.fields, checks, localized);
|
|
5880
6579
|
break;
|
|
5881
6580
|
case "blocks":
|
|
5882
6581
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -5885,35 +6584,51 @@ async function serializeFields(fields, update2, localized) {
|
|
|
5885
6584
|
field.blocks.map(async (block) => ({
|
|
5886
6585
|
slug: block.slug,
|
|
5887
6586
|
...block.labels ? { labels: block.labels } : {},
|
|
5888
|
-
fields: await serializeFields(block.fields,
|
|
6587
|
+
fields: await serializeFields(block.fields, checks, localized)
|
|
5889
6588
|
}))
|
|
5890
6589
|
);
|
|
5891
6590
|
break;
|
|
5892
6591
|
case "group":
|
|
5893
|
-
f.fields = await serializeFields(field.fields,
|
|
6592
|
+
f.fields = await serializeFields(field.fields, checks, localized);
|
|
5894
6593
|
break;
|
|
5895
6594
|
}
|
|
5896
6595
|
out.push(f);
|
|
5897
6596
|
}
|
|
5898
6597
|
return out;
|
|
5899
6598
|
}
|
|
5900
|
-
async function
|
|
6599
|
+
async function checkers(cms, user) {
|
|
6600
|
+
const rules = await cms.roles.fieldRules(user);
|
|
6601
|
+
return {
|
|
6602
|
+
update: new FieldAccessChecker("update", { user }, rules),
|
|
6603
|
+
read: new FieldAccessChecker("read", { user }, rules)
|
|
6604
|
+
};
|
|
6605
|
+
}
|
|
6606
|
+
async function may(cms, access, user, target, operation) {
|
|
6607
|
+
return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
|
|
6608
|
+
}
|
|
6609
|
+
async function collection(cms, config, user, localized, roles) {
|
|
6610
|
+
const target = { collection: config.slug };
|
|
6611
|
+
const fields = await serializeFields(config.fields, await checkers(cms, user), localized);
|
|
6612
|
+
if (config.slug === USERS && cms.roles.enabled) {
|
|
6613
|
+
const role = fields.find((f) => f.name === "role");
|
|
6614
|
+
if (role) {
|
|
6615
|
+
role.type = "select";
|
|
6616
|
+
role.options = roles.map((r) => ({ label: r.name || r.key, value: r.key }));
|
|
6617
|
+
}
|
|
6618
|
+
}
|
|
5901
6619
|
const result = {
|
|
5902
6620
|
slug: config.slug,
|
|
5903
6621
|
drafts: config.drafts === true,
|
|
5904
6622
|
versions: Boolean(config.versions),
|
|
5905
6623
|
preview: typeof config.preview === "function",
|
|
5906
6624
|
schedule: config.schedule === true,
|
|
5907
|
-
fields
|
|
5908
|
-
config.fields,
|
|
5909
|
-
new FieldAccessChecker("update", { user }),
|
|
5910
|
-
localized
|
|
5911
|
-
),
|
|
6625
|
+
fields,
|
|
5912
6626
|
permissions: {
|
|
5913
|
-
read: await
|
|
5914
|
-
create: await
|
|
5915
|
-
update: await
|
|
5916
|
-
delete: await
|
|
6627
|
+
read: await may(cms, config.access?.read, user, target, "read"),
|
|
6628
|
+
create: await may(cms, config.access?.create, user, target, "create"),
|
|
6629
|
+
update: await may(cms, config.access?.update, user, target, "update"),
|
|
6630
|
+
delete: await may(cms, config.access?.delete, user, target, "delete"),
|
|
6631
|
+
publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
|
|
5917
6632
|
}
|
|
5918
6633
|
};
|
|
5919
6634
|
if (config.labels) result.labels = config.labels;
|
|
@@ -5923,25 +6638,26 @@ async function collection(config, user, localized) {
|
|
|
5923
6638
|
if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
|
|
5924
6639
|
if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
|
|
5925
6640
|
if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
|
|
6641
|
+
const owner = cms.roles.ownerOf(config.slug);
|
|
6642
|
+
if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
|
|
5926
6643
|
if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
|
|
5927
6644
|
result.editIn = "drawer";
|
|
5928
6645
|
return result;
|
|
5929
6646
|
}
|
|
5930
|
-
async function global(config, user, localized) {
|
|
6647
|
+
async function global(cms, config, user, localized) {
|
|
6648
|
+
const target = { global: config.slug };
|
|
6649
|
+
const checks = await checkers(cms, user);
|
|
5931
6650
|
const result = {
|
|
5932
6651
|
slug: config.slug,
|
|
5933
6652
|
drafts: config.drafts === true,
|
|
5934
6653
|
versions: Boolean(config.versions),
|
|
5935
6654
|
preview: typeof config.preview === "function",
|
|
5936
6655
|
schedule: config.schedule === true,
|
|
5937
|
-
fields: await serializeFields(
|
|
5938
|
-
config.fields,
|
|
5939
|
-
new FieldAccessChecker("update", { user }),
|
|
5940
|
-
localized
|
|
5941
|
-
),
|
|
6656
|
+
fields: await serializeFields(config.fields, checks, localized),
|
|
5942
6657
|
permissions: {
|
|
5943
|
-
read: await
|
|
5944
|
-
update: await
|
|
6658
|
+
read: await may(cms, config.access?.read, user, target, "read"),
|
|
6659
|
+
update: await may(cms, config.access?.update, user, target, "update"),
|
|
6660
|
+
publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
|
|
5945
6661
|
}
|
|
5946
6662
|
};
|
|
5947
6663
|
if (config.label !== void 0) result.label = config.label;
|
|
@@ -5953,16 +6669,32 @@ async function adminSchema(cms, user, origin) {
|
|
|
5953
6669
|
const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
|
|
5954
6670
|
const localization = cms.config.localization;
|
|
5955
6671
|
const localized = localization !== null;
|
|
6672
|
+
const admin = user.role === "admin" && !user.apiKey;
|
|
6673
|
+
const deliveries = cms.config.collections.some(
|
|
6674
|
+
(c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
|
|
6675
|
+
);
|
|
6676
|
+
const views = {
|
|
6677
|
+
status: await cms.roles.canView(user, "status"),
|
|
6678
|
+
deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
|
|
6679
|
+
backups: admin,
|
|
6680
|
+
email: admin,
|
|
6681
|
+
roles: admin && cms.roles.enabled
|
|
6682
|
+
};
|
|
6683
|
+
const roles = await cms.roles.options();
|
|
5956
6684
|
return {
|
|
5957
6685
|
locale: cms.config.admin.locale,
|
|
5958
6686
|
passwordLinks: cms.auth.canSendPasswordLinks(origin),
|
|
5959
6687
|
menu: [...cms.config.admin.menu],
|
|
5960
6688
|
localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
|
|
5961
|
-
collections: await Promise.all(
|
|
5962
|
-
|
|
6689
|
+
collections: await Promise.all(
|
|
6690
|
+
collections.map((c) => collection(cms, c, user, localized, roles))
|
|
6691
|
+
),
|
|
6692
|
+
globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
|
|
5963
6693
|
modules: adminModuleUrls(cms),
|
|
5964
6694
|
uploadFromURL: cms.config.upload.fromURL !== void 0,
|
|
5965
|
-
|
|
6695
|
+
views,
|
|
6696
|
+
rbac: cms.roles.enabled,
|
|
6697
|
+
...views.deliveries ? {
|
|
5966
6698
|
deliveries: {
|
|
5967
6699
|
webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
|
|
5968
6700
|
email: cms.config.collections.some((c) => c.slug === EMAIL_DELIVERIES)
|
|
@@ -5983,7 +6715,8 @@ async function shown(access, user) {
|
|
|
5983
6715
|
async function pages(cms, user) {
|
|
5984
6716
|
const out = [];
|
|
5985
6717
|
for (const page of cms.config.admin.pages) {
|
|
5986
|
-
if (!await shown(page.access, user))
|
|
6718
|
+
if (!await shown(page.access, user) || !await cms.roles.canView(user, `page:${page.path}`))
|
|
6719
|
+
continue;
|
|
5987
6720
|
out.push({
|
|
5988
6721
|
path: page.path,
|
|
5989
6722
|
label: page.label,
|
|
@@ -5997,14 +6730,16 @@ async function pages(cms, user) {
|
|
|
5997
6730
|
async function widgets(cms, user) {
|
|
5998
6731
|
const out = [];
|
|
5999
6732
|
for (const widget of cms.config.admin.dashboard) {
|
|
6000
|
-
|
|
6733
|
+
const tag = typeof widget.component === "string" ? widget.component : widget.component.tag;
|
|
6734
|
+
if (!await shown(widget.access, user) || !await cms.roles.canView(user, `widget:${tag}`))
|
|
6735
|
+
continue;
|
|
6001
6736
|
out.push({ component: componentRef(widget.component), width: widget.width ?? "half" });
|
|
6002
6737
|
}
|
|
6003
6738
|
return out;
|
|
6004
6739
|
}
|
|
6005
6740
|
|
|
6006
6741
|
// src/version.ts
|
|
6007
|
-
var VERSION = true ? "0.
|
|
6742
|
+
var VERSION = true ? "0.34.0" : "0.0.0-dev";
|
|
6008
6743
|
|
|
6009
6744
|
// src/rest/admin-status.ts
|
|
6010
6745
|
var DAY = 864e5;
|
|
@@ -6495,9 +7230,44 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
6495
7230
|
}
|
|
6496
7231
|
throw new HttpError("Not found", 404);
|
|
6497
7232
|
}
|
|
6498
|
-
if (first === "admin" && second === "
|
|
7233
|
+
if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
|
|
6499
7234
|
if (!ctx.user) throw new UnauthorizedError();
|
|
6500
7235
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
7236
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7237
|
+
const parsed = parseId(third);
|
|
7238
|
+
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
7239
|
+
return { body: await cms.roles.owned(parsed) };
|
|
7240
|
+
}
|
|
7241
|
+
if (first === "admin" && second === "roles") {
|
|
7242
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
7243
|
+
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
7244
|
+
if (!cms.roles.enabled) throw new HttpError("Not found", 404);
|
|
7245
|
+
const [, , id2, action, extra] = segments;
|
|
7246
|
+
if (extra !== void 0) throw new HttpError("Not found", 404);
|
|
7247
|
+
if (id2 === void 0) {
|
|
7248
|
+
if (method === "GET") return { body: await cms.roles.list() };
|
|
7249
|
+
if (method === "POST")
|
|
7250
|
+
return { status: 201, body: await cms.roles.create(await readJson(ctx.request), ctx.user) };
|
|
7251
|
+
throw methodNotAllowed(ctx, "GET, POST");
|
|
7252
|
+
}
|
|
7253
|
+
const parsed = parseId(id2);
|
|
7254
|
+
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
7255
|
+
if (action === "history") {
|
|
7256
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7257
|
+
return { body: await cms.roles.history(parsed) };
|
|
7258
|
+
}
|
|
7259
|
+
if (action !== void 0) throw new HttpError("Not found", 404);
|
|
7260
|
+
if (method === "PATCH")
|
|
7261
|
+
return { body: await cms.roles.update(parsed, await readJson(ctx.request), ctx.user) };
|
|
7262
|
+
if (method === "DELETE") {
|
|
7263
|
+
await cms.roles.delete(parsed);
|
|
7264
|
+
return { body: { deleted: 1 } };
|
|
7265
|
+
}
|
|
7266
|
+
throw methodNotAllowed(ctx, "PATCH, DELETE");
|
|
7267
|
+
}
|
|
7268
|
+
if (first === "admin" && second === "deliveries") {
|
|
7269
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
7270
|
+
if (!await cms.roles.canView(ctx.user, "deliveries")) throw new ForbiddenError();
|
|
6501
7271
|
const [, , kind, id2, action, extra] = segments;
|
|
6502
7272
|
if (kind === void 0) {
|
|
6503
7273
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
@@ -6547,7 +7317,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
6547
7317
|
return { body: new Response(file.body, { headers }) };
|
|
6548
7318
|
}
|
|
6549
7319
|
if (second === "status" && third === void 0) {
|
|
6550
|
-
if (ctx.user
|
|
7320
|
+
if (!await cms.roles.canView(ctx.user, "status")) throw new ForbiddenError();
|
|
6551
7321
|
return { body: await adminStatus(cms) };
|
|
6552
7322
|
}
|
|
6553
7323
|
if (second === "scheduled" && third === void 0)
|
|
@@ -6672,7 +7442,9 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
6672
7442
|
return { body: doc };
|
|
6673
7443
|
}
|
|
6674
7444
|
if (method === "DELETE") {
|
|
6675
|
-
|
|
7445
|
+
const to = collection2 === USERS ? ctx.url.searchParams.get("transferTo") : null;
|
|
7446
|
+
const transferTo = to === null ? {} : { transferTo: to === "none" ? null : parseId(to) ?? to };
|
|
7447
|
+
return { body: await cms.delete(collection2, id, { ...access, ...transferTo }) };
|
|
6676
7448
|
}
|
|
6677
7449
|
throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
|
|
6678
7450
|
}
|
|
@@ -7157,6 +7929,8 @@ export {
|
|
|
7157
7929
|
NotFoundError,
|
|
7158
7930
|
PayloadTooLargeError,
|
|
7159
7931
|
QueryError,
|
|
7932
|
+
ROLE_OPERATIONS,
|
|
7933
|
+
Roles,
|
|
7160
7934
|
SESSION_COOKIE,
|
|
7161
7935
|
SchemaError,
|
|
7162
7936
|
TooManyRequestsError,
|