@easy-cms/core 0.35.0 → 0.36.1
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 +528 -357
- package/dist/index.js +912 -318
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -129,197 +129,9 @@ function cleanPermissions(value, known) {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
// src/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
constructor(issues) {
|
|
136
|
-
super(_ConfigError.format(issues));
|
|
137
|
-
this.name = "ConfigError";
|
|
138
|
-
this.issues = issues;
|
|
139
|
-
}
|
|
140
|
-
static format(issues) {
|
|
141
|
-
const lines = issues.map((issue) => {
|
|
142
|
-
const hint = issue.hint ? `
|
|
143
|
-
\u2192 ${issue.hint}` : "";
|
|
144
|
-
return ` \u2022 ${issue.path}: ${issue.message}${hint}`;
|
|
145
|
-
});
|
|
146
|
-
const noun = issues.length === 1 ? "problem" : "problems";
|
|
147
|
-
return `Invalid Easy CMS config (${issues.length} ${noun}):
|
|
148
|
-
${lines.join("\n")}`;
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
var EasyCMSError = class extends Error {
|
|
152
|
-
status;
|
|
153
|
-
constructor(message2, status) {
|
|
154
|
-
super(message2);
|
|
155
|
-
this.name = "EasyCMSError";
|
|
156
|
-
this.status = status;
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
var ValidationError = class extends EasyCMSError {
|
|
160
|
-
errors;
|
|
161
|
-
constructor(collection2, errors) {
|
|
162
|
-
const list = errors.map((e) => `${e.field}: ${e.message}`).join("; ");
|
|
163
|
-
super(`Invalid data for "${collection2}": ${list}`, 400);
|
|
164
|
-
this.name = "ValidationError";
|
|
165
|
-
this.errors = errors;
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
var NotFoundError = class extends EasyCMSError {
|
|
169
|
-
constructor(collection2, id) {
|
|
170
|
-
super(`No document with id ${JSON.stringify(id)} in "${collection2}"`, 404);
|
|
171
|
-
this.name = "NotFoundError";
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
var QueryError = class extends EasyCMSError {
|
|
175
|
-
constructor(message2) {
|
|
176
|
-
super(message2, 400);
|
|
177
|
-
this.name = "QueryError";
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
var SchemaError = class extends EasyCMSError {
|
|
181
|
-
constructor(message2) {
|
|
182
|
-
super(message2, 500);
|
|
183
|
-
this.name = "SchemaError";
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
var UnauthorizedError = class extends EasyCMSError {
|
|
187
|
-
constructor(message2 = "You must be logged in") {
|
|
188
|
-
super(message2, 401);
|
|
189
|
-
this.name = "UnauthorizedError";
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
var ForbiddenError = class extends EasyCMSError {
|
|
193
|
-
constructor(message2 = "You are not allowed to do this") {
|
|
194
|
-
super(message2, 403);
|
|
195
|
-
this.name = "ForbiddenError";
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
var TooManyRequestsError = class extends EasyCMSError {
|
|
199
|
-
constructor(message2) {
|
|
200
|
-
super(message2, 429);
|
|
201
|
-
this.name = "TooManyRequestsError";
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
var PayloadTooLargeError = class extends EasyCMSError {
|
|
205
|
-
constructor(message2) {
|
|
206
|
-
super(message2, 413);
|
|
207
|
-
this.name = "PayloadTooLargeError";
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
// src/fields.ts
|
|
212
|
-
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
213
|
-
function mimeAllowedBy(mimeType, patterns) {
|
|
214
|
-
const type = mimeType.toLowerCase();
|
|
215
|
-
return patterns.some((pattern) => {
|
|
216
|
-
const p = pattern.toLowerCase();
|
|
217
|
-
return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
var hasRows = (field) => field.type === "array" || field.type === "blocks";
|
|
221
|
-
function rowFields(field, row) {
|
|
222
|
-
if (field.type === "array") return field.fields;
|
|
223
|
-
const type = row && typeof row === "object" ? row.blockType : void 0;
|
|
224
|
-
return field.blocks.find((block) => block.slug === type)?.fields;
|
|
225
|
-
}
|
|
226
|
-
var FIELD_TYPES = [
|
|
227
|
-
"text",
|
|
228
|
-
"textarea",
|
|
229
|
-
"number",
|
|
230
|
-
"boolean",
|
|
231
|
-
"date",
|
|
232
|
-
"email",
|
|
233
|
-
"json",
|
|
234
|
-
"select",
|
|
235
|
-
"slug",
|
|
236
|
-
"richText",
|
|
237
|
-
"upload",
|
|
238
|
-
"relationship",
|
|
239
|
-
"array",
|
|
240
|
-
"group",
|
|
241
|
-
"blocks"
|
|
242
|
-
];
|
|
243
|
-
|
|
244
|
-
// src/access-control.ts
|
|
245
|
-
async function evaluateAccess(access, args) {
|
|
246
|
-
const result = await (access ?? isLoggedIn)(args);
|
|
247
|
-
if (typeof result === "boolean") return result;
|
|
248
|
-
if (typeof result === "object" && result !== null) return result;
|
|
249
|
-
throw new QueryError("Access functions must return a boolean or a where query");
|
|
250
|
-
}
|
|
251
|
-
function andWhere(where, constraint) {
|
|
252
|
-
if (constraint === true) return where;
|
|
253
|
-
return where === void 0 ? constraint : { and: [where, constraint] };
|
|
254
|
-
}
|
|
255
|
-
var FieldAccessChecker = class {
|
|
256
|
-
/** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
|
|
257
|
-
constructor(kind, args, rules) {
|
|
258
|
-
this.kind = kind;
|
|
259
|
-
this.args = args;
|
|
260
|
-
this.rules = rules;
|
|
261
|
-
}
|
|
262
|
-
kind;
|
|
263
|
-
args;
|
|
264
|
-
rules;
|
|
265
|
-
cache = /* @__PURE__ */ new Map();
|
|
266
|
-
async allows(field) {
|
|
267
|
-
const rule = this.rules?.get(field);
|
|
268
|
-
if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
|
|
269
|
-
const fn = field.access?.[this.kind];
|
|
270
|
-
if (!fn) return true;
|
|
271
|
-
let allowed2 = this.cache.get(field);
|
|
272
|
-
if (allowed2 === void 0) {
|
|
273
|
-
allowed2 = await fn(this.args) === true;
|
|
274
|
-
this.cache.set(field, allowed2);
|
|
275
|
-
}
|
|
276
|
-
return allowed2;
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
async function stripFields(fields, doc, read2) {
|
|
280
|
-
const result = { ...doc };
|
|
281
|
-
for (const field of fields) {
|
|
282
|
-
if (!(field.name in result)) continue;
|
|
283
|
-
if (field.hidden || read2 && !await read2.allows(field)) {
|
|
284
|
-
delete result[field.name];
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
const value = result[field.name];
|
|
288
|
-
if (field.type === "group" && value && typeof value === "object") {
|
|
289
|
-
result[field.name] = await stripFields(field.fields, value, read2);
|
|
290
|
-
} else if (hasRows(field) && Array.isArray(value)) {
|
|
291
|
-
result[field.name] = await Promise.all(
|
|
292
|
-
value.map((row) => {
|
|
293
|
-
const fields2 = rowFields(field, row);
|
|
294
|
-
return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read2) : row;
|
|
295
|
-
})
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
return result;
|
|
300
|
-
}
|
|
301
|
-
async function filterInput(fields, data, update2) {
|
|
302
|
-
const result = { ...data };
|
|
303
|
-
for (const field of fields) {
|
|
304
|
-
if (!(field.name in result)) continue;
|
|
305
|
-
if (field.hidden || update2 && !await update2.allows(field)) {
|
|
306
|
-
delete result[field.name];
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
const value = result[field.name];
|
|
310
|
-
if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
311
|
-
result[field.name] = await filterInput(field.fields, value, update2);
|
|
312
|
-
} else if (hasRows(field) && Array.isArray(value)) {
|
|
313
|
-
result[field.name] = await Promise.all(
|
|
314
|
-
value.map((row) => {
|
|
315
|
-
const fields2 = rowFields(field, row);
|
|
316
|
-
return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update2) : row;
|
|
317
|
-
})
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
return result;
|
|
322
|
-
}
|
|
132
|
+
// src/audit.ts
|
|
133
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
134
|
+
import { createHmac } from "crypto";
|
|
323
135
|
|
|
324
136
|
// src/builtins.ts
|
|
325
137
|
var USERS = "users";
|
|
@@ -333,6 +145,7 @@ var EMAIL_DELIVERIES = "email-deliveries";
|
|
|
333
145
|
var DATABASE_BACKUPS = "database-backups";
|
|
334
146
|
var ROLES = "user-roles";
|
|
335
147
|
var USER_IDENTITIES = "user-identities";
|
|
148
|
+
var AUDIT_LOGS = "audit-logs";
|
|
336
149
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
337
150
|
SESSIONS,
|
|
338
151
|
LOGIN_ATTEMPTS,
|
|
@@ -342,7 +155,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
342
155
|
EMAIL_DELIVERIES,
|
|
343
156
|
DATABASE_BACKUPS,
|
|
344
157
|
ROLES,
|
|
345
|
-
USER_IDENTITIES
|
|
158
|
+
USER_IDENTITIES,
|
|
159
|
+
AUDIT_LOGS
|
|
346
160
|
]);
|
|
347
161
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
348
162
|
var CREATED_BY_FIELD = {
|
|
@@ -567,66 +381,588 @@ var scheduledJobsCollection = {
|
|
|
567
381
|
{ name: "author", type: "number" }
|
|
568
382
|
]
|
|
569
383
|
};
|
|
570
|
-
var webhookDeliveriesCollection = {
|
|
571
|
-
slug: WEBHOOK_DELIVERIES,
|
|
572
|
-
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
573
|
-
fields: [
|
|
574
|
-
{ name: "url", type: "text", required: true },
|
|
575
|
-
{ name: "event", type: "text", required: true },
|
|
576
|
-
// The JSON body as first sent: retries are byte-for-byte the same, signature included.
|
|
577
|
-
{ name: "body", type: "textarea", required: true },
|
|
578
|
-
{ name: "delivery", type: "text", required: true },
|
|
579
|
-
{ name: "attempts", type: "number", required: true },
|
|
580
|
-
{ name: "nextAttemptAt", type: "text", required: true, index: true },
|
|
581
|
-
// pending | failed
|
|
582
|
-
{ name: "state", type: "text", required: true, index: true },
|
|
583
|
-
{ name: "error", type: "text" }
|
|
584
|
-
]
|
|
384
|
+
var webhookDeliveriesCollection = {
|
|
385
|
+
slug: WEBHOOK_DELIVERIES,
|
|
386
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
387
|
+
fields: [
|
|
388
|
+
{ name: "url", type: "text", required: true },
|
|
389
|
+
{ name: "event", type: "text", required: true },
|
|
390
|
+
// The JSON body as first sent: retries are byte-for-byte the same, signature included.
|
|
391
|
+
{ name: "body", type: "textarea", required: true },
|
|
392
|
+
{ name: "delivery", type: "text", required: true },
|
|
393
|
+
{ name: "attempts", type: "number", required: true },
|
|
394
|
+
{ name: "nextAttemptAt", type: "text", required: true, index: true },
|
|
395
|
+
// pending | failed
|
|
396
|
+
{ name: "state", type: "text", required: true, index: true },
|
|
397
|
+
{ name: "error", type: "text" }
|
|
398
|
+
]
|
|
399
|
+
};
|
|
400
|
+
var databaseBackupsCollection = {
|
|
401
|
+
slug: DATABASE_BACKUPS,
|
|
402
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
403
|
+
fields: [
|
|
404
|
+
// pending | running | done | failed
|
|
405
|
+
{ name: "state", type: "text", required: true, index: true },
|
|
406
|
+
// manual | scheduled
|
|
407
|
+
{ name: "trigger", type: "text", required: true },
|
|
408
|
+
{ name: "filename", type: "text" },
|
|
409
|
+
{ name: "size", type: "number" },
|
|
410
|
+
{ name: "startedAt", type: "text", index: true },
|
|
411
|
+
{ name: "finishedAt", type: "text" },
|
|
412
|
+
{ name: "error", type: "text" },
|
|
413
|
+
// Who started it (email), for manual ones.
|
|
414
|
+
{ name: "author", type: "text" },
|
|
415
|
+
{ name: "downloadedBy", type: "text" },
|
|
416
|
+
{ name: "downloadedAt", type: "text" }
|
|
417
|
+
]
|
|
418
|
+
};
|
|
419
|
+
var userIdentitiesCollection = {
|
|
420
|
+
slug: USER_IDENTITIES,
|
|
421
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
422
|
+
fields: [
|
|
423
|
+
{ name: "user", type: "relationship", to: USERS, required: true, index: true },
|
|
424
|
+
// The provider's id (`google`) and its id for the account (`sub`).
|
|
425
|
+
{ name: "provider", type: "text", required: true, index: true },
|
|
426
|
+
{ name: "subject", type: "text", required: true, index: true },
|
|
427
|
+
{ name: "email", type: "text" },
|
|
428
|
+
{ name: "lastUsedAt", type: "text" }
|
|
429
|
+
]
|
|
430
|
+
};
|
|
431
|
+
var auditLogsCollection = {
|
|
432
|
+
slug: AUDIT_LOGS,
|
|
433
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
434
|
+
fields: [
|
|
435
|
+
{ name: "action", type: "text", required: true, index: true },
|
|
436
|
+
// A collection slug, `global:<slug>`, or an area (auth, roles, backups…).
|
|
437
|
+
{ name: "target", type: "text", index: true },
|
|
438
|
+
{ name: "doc", type: "text", index: true },
|
|
439
|
+
{ name: "title", type: "text" },
|
|
440
|
+
{ name: "actorId", type: "text" },
|
|
441
|
+
{ name: "actorEmail", type: "text", index: true },
|
|
442
|
+
// user | api-key | system | scheduler
|
|
443
|
+
{ name: "via", type: "text" },
|
|
444
|
+
{ name: "ip", type: "text" },
|
|
445
|
+
{ name: "userAgent", type: "text" },
|
|
446
|
+
{ name: "changes", type: "json" },
|
|
447
|
+
{ name: "detail", type: "json" },
|
|
448
|
+
{ name: "signature", type: "text" }
|
|
449
|
+
]
|
|
450
|
+
};
|
|
451
|
+
var emailDeliveriesCollection = {
|
|
452
|
+
slug: EMAIL_DELIVERIES,
|
|
453
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
454
|
+
fields: [
|
|
455
|
+
// The message as JSON.
|
|
456
|
+
{ name: "message", type: "textarea", required: true },
|
|
457
|
+
{ name: "attempts", type: "number", required: true },
|
|
458
|
+
{ name: "nextAttemptAt", type: "text", required: true, index: true },
|
|
459
|
+
// pending | failed
|
|
460
|
+
{ name: "state", type: "text", required: true, index: true },
|
|
461
|
+
{ name: "error", type: "text" }
|
|
462
|
+
]
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// src/audit.ts
|
|
466
|
+
var DEFAULT_AUDIT_KEEP = 365;
|
|
467
|
+
var DEFAULT_FAILED_LOGINS = 20;
|
|
468
|
+
var MAX_VALUE = 500;
|
|
469
|
+
var PAGE = 50;
|
|
470
|
+
var CSV_LIMIT = 1e4;
|
|
471
|
+
var DAY = 864e5;
|
|
472
|
+
var BULKY = /* @__PURE__ */ new Set(["richText", "blocks", "array", "json"]);
|
|
473
|
+
var auditContext = new AsyncLocalStorage();
|
|
474
|
+
function stable(value) {
|
|
475
|
+
if (value === void 0 || value === null) return "null";
|
|
476
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
477
|
+
if (typeof value === "object") {
|
|
478
|
+
const keys = Object.keys(value).filter((k) => value[k] !== void 0).sort();
|
|
479
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
|
|
480
|
+
}
|
|
481
|
+
return JSON.stringify(value);
|
|
482
|
+
}
|
|
483
|
+
var SIGNED = [
|
|
484
|
+
"action",
|
|
485
|
+
"target",
|
|
486
|
+
"doc",
|
|
487
|
+
"title",
|
|
488
|
+
"actorId",
|
|
489
|
+
"actorEmail",
|
|
490
|
+
"via",
|
|
491
|
+
"ip",
|
|
492
|
+
"userAgent",
|
|
493
|
+
"changes",
|
|
494
|
+
"detail",
|
|
495
|
+
"createdAt"
|
|
496
|
+
];
|
|
497
|
+
function cut(value) {
|
|
498
|
+
if (typeof value === "string")
|
|
499
|
+
return value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE)}\u2026` : value;
|
|
500
|
+
if (value !== null && typeof value === "object") {
|
|
501
|
+
const json = JSON.stringify(value);
|
|
502
|
+
return json.length > MAX_VALUE ? "\u2026" : value;
|
|
503
|
+
}
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
var text = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
507
|
+
var AuditLog = class {
|
|
508
|
+
constructor(cms) {
|
|
509
|
+
this.cms = cms;
|
|
510
|
+
}
|
|
511
|
+
cms;
|
|
512
|
+
/** Entries that could not be written since the server started. */
|
|
513
|
+
failures = 0;
|
|
514
|
+
get enabled() {
|
|
515
|
+
return this.cms.config.audit !== false;
|
|
516
|
+
}
|
|
517
|
+
get settings() {
|
|
518
|
+
return this.cms.config.audit || {
|
|
519
|
+
keep: DEFAULT_AUDIT_KEEP,
|
|
520
|
+
values: true,
|
|
521
|
+
failedLogins: DEFAULT_FAILED_LOGINS
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Writes an entry. The user defaults to the request's. Never throws: a failure is logged and
|
|
526
|
+
* counted, and the dashboard says so.
|
|
527
|
+
*/
|
|
528
|
+
async record(entry) {
|
|
529
|
+
if (!this.enabled) return;
|
|
530
|
+
try {
|
|
531
|
+
const context = auditContext.getStore();
|
|
532
|
+
const user = entry.user !== void 0 ? entry.user : context?.user ?? null;
|
|
533
|
+
const apiKey = user?.apiKey;
|
|
534
|
+
const row = {
|
|
535
|
+
action: entry.action,
|
|
536
|
+
target: entry.target ?? null,
|
|
537
|
+
doc: entry.doc === void 0 || entry.doc === null ? null : String(entry.doc),
|
|
538
|
+
title: entry.title ? cut(entry.title) : null,
|
|
539
|
+
actorId: user ? String(user.id) : null,
|
|
540
|
+
actorEmail: user?.email ?? entry.email ?? null,
|
|
541
|
+
via: apiKey ? "api-key" : context?.via === "scheduler" ? "scheduler" : user ? "user" : "system",
|
|
542
|
+
ip: context?.ip ?? null,
|
|
543
|
+
userAgent: context?.userAgent ? context.userAgent.slice(0, 200) : null,
|
|
544
|
+
changes: entry.changes && entry.changes.length > 0 ? entry.changes : null,
|
|
545
|
+
detail: apiKey ? { ...entry.detail, apiKey: apiKey.name ?? "" } : entry.detail ?? null,
|
|
546
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
547
|
+
};
|
|
548
|
+
row.signature = this.sign(row);
|
|
549
|
+
row.updatedAt = row.createdAt;
|
|
550
|
+
await this.cms.db.create({ collection: AUDIT_LOGS, data: row });
|
|
551
|
+
} catch (error) {
|
|
552
|
+
this.failures++;
|
|
553
|
+
this.cms.logger.error(
|
|
554
|
+
`Audit log: could not write "${entry.action}": ${error.message}`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* A change to a document or global: what changed between `before` and `after`. Internal
|
|
560
|
+
* collections (sessions, versions…) are not logged.
|
|
561
|
+
*/
|
|
562
|
+
async content(config, global2, action, after, before, user) {
|
|
563
|
+
if (!this.enabled) return;
|
|
564
|
+
if (!global2 && INTERNAL_COLLECTIONS.has(config.slug)) return;
|
|
565
|
+
const doc = after ?? before;
|
|
566
|
+
const titleField = global2 ? void 0 : config.useAsTitle;
|
|
567
|
+
const rawTitle = titleField ? doc?.[titleField] : void 0;
|
|
568
|
+
const title = typeof rawTitle === "string" ? rawTitle : rawTitle && typeof rawTitle === "object" ? text(
|
|
569
|
+
Object.values(rawTitle).find((v) => typeof v === "string")
|
|
570
|
+
) : null;
|
|
571
|
+
await this.record({
|
|
572
|
+
action,
|
|
573
|
+
target: global2 ? `global:${config.slug}` : config.slug,
|
|
574
|
+
doc: global2 ? null : doc?.id ?? null,
|
|
575
|
+
title,
|
|
576
|
+
// A trusted call without a user keeps the request's (or is the system).
|
|
577
|
+
...user ? { user } : {},
|
|
578
|
+
changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {})
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/** Fields whose stored value changed. Hidden ones never (the password only as "changed"). */
|
|
582
|
+
diff(fields, before, after) {
|
|
583
|
+
const values = this.settings.values;
|
|
584
|
+
const out = [];
|
|
585
|
+
for (const field of fields) {
|
|
586
|
+
if (field.hidden) continue;
|
|
587
|
+
const a = before[field.name];
|
|
588
|
+
const b = after[field.name];
|
|
589
|
+
if (stable(a) === stable(b)) continue;
|
|
590
|
+
if (!values || BULKY.has(field.type)) out.push({ field: field.name });
|
|
591
|
+
else out.push({ field: field.name, before: cut(a ?? null), after: cut(b ?? null) });
|
|
592
|
+
}
|
|
593
|
+
if (before.passwordHash !== after.passwordHash && (before.passwordHash || after.passwordHash))
|
|
594
|
+
out.push({ field: "password" });
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
async list(filter, page = 1) {
|
|
598
|
+
const result = await this.cms.db.find({
|
|
599
|
+
collection: AUDIT_LOGS,
|
|
600
|
+
where: this.where(filter),
|
|
601
|
+
sort: ["-id"],
|
|
602
|
+
limit: PAGE,
|
|
603
|
+
page
|
|
604
|
+
});
|
|
605
|
+
return {
|
|
606
|
+
docs: result.docs.map(toEntry),
|
|
607
|
+
page: result.page,
|
|
608
|
+
totalPages: result.totalPages,
|
|
609
|
+
totalDocs: result.totalDocs
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/** The filtered entries as CSV (the newest 10,000). */
|
|
613
|
+
async csv(filter) {
|
|
614
|
+
const { docs } = await this.cms.db.find({
|
|
615
|
+
collection: AUDIT_LOGS,
|
|
616
|
+
where: this.where(filter),
|
|
617
|
+
sort: ["-id"],
|
|
618
|
+
limit: CSV_LIMIT,
|
|
619
|
+
page: 1
|
|
620
|
+
});
|
|
621
|
+
const cell = (value) => {
|
|
622
|
+
const s = value === null || value === void 0 ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
|
623
|
+
const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
|
|
624
|
+
return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
|
625
|
+
};
|
|
626
|
+
const lines = [
|
|
627
|
+
["at", "action", "target", "doc", "title", "actor", "via", "ip", "changes", "detail"].join(
|
|
628
|
+
","
|
|
629
|
+
),
|
|
630
|
+
...docs.map(toEntry).map(
|
|
631
|
+
(e) => [
|
|
632
|
+
e.at,
|
|
633
|
+
e.action,
|
|
634
|
+
e.target,
|
|
635
|
+
e.doc,
|
|
636
|
+
e.title,
|
|
637
|
+
e.actor.email,
|
|
638
|
+
e.actor.via,
|
|
639
|
+
e.actor.ip,
|
|
640
|
+
e.changes,
|
|
641
|
+
e.detail
|
|
642
|
+
].map(cell).join(",")
|
|
643
|
+
)
|
|
644
|
+
];
|
|
645
|
+
return `${lines.join("\r\n")}\r
|
|
646
|
+
`;
|
|
647
|
+
}
|
|
648
|
+
/** Checks every entry's signature, and that no ids are missing. */
|
|
649
|
+
async verify() {
|
|
650
|
+
const invalid = [];
|
|
651
|
+
let checked = 0;
|
|
652
|
+
let gaps = 0;
|
|
653
|
+
let previous;
|
|
654
|
+
for (let page = 1; ; page++) {
|
|
655
|
+
const result = await this.cms.db.find({
|
|
656
|
+
collection: AUDIT_LOGS,
|
|
657
|
+
sort: ["id"],
|
|
658
|
+
limit: 500,
|
|
659
|
+
page
|
|
660
|
+
});
|
|
661
|
+
for (const row of result.docs) {
|
|
662
|
+
checked++;
|
|
663
|
+
if (row.signature !== this.sign(row)) invalid.push(row.id);
|
|
664
|
+
const id = Number(row.id);
|
|
665
|
+
if (previous !== void 0 && Number.isInteger(id) && id > previous + 1)
|
|
666
|
+
gaps += id - previous - 1;
|
|
667
|
+
previous = id;
|
|
668
|
+
}
|
|
669
|
+
if (page >= result.totalPages) break;
|
|
670
|
+
}
|
|
671
|
+
const verification = { checked, invalid, gaps, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
672
|
+
await this.record({
|
|
673
|
+
action: "audit.verify",
|
|
674
|
+
target: "audit",
|
|
675
|
+
detail: { checked, invalid: invalid.length, gaps }
|
|
676
|
+
});
|
|
677
|
+
return verification;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Daily upkeep from `runJobs`: deletes entries older than `keep` days, then verifies the log.
|
|
681
|
+
* Runs when the last check is more than a day old.
|
|
682
|
+
*/
|
|
683
|
+
async upkeep(now = /* @__PURE__ */ new Date()) {
|
|
684
|
+
if (!this.enabled) return;
|
|
685
|
+
const last = await this.lastVerification();
|
|
686
|
+
if (last && now.getTime() - Date.parse(last.at) < DAY) return;
|
|
687
|
+
const keep = this.settings.keep;
|
|
688
|
+
if (keep > 0) {
|
|
689
|
+
const before = new Date(now.getTime() - keep * DAY).toISOString();
|
|
690
|
+
for (; ; ) {
|
|
691
|
+
const { docs } = await this.cms.db.find({
|
|
692
|
+
collection: AUDIT_LOGS,
|
|
693
|
+
where: { createdAt: { lt: before } },
|
|
694
|
+
sort: ["id"],
|
|
695
|
+
limit: 500,
|
|
696
|
+
page: 1
|
|
697
|
+
});
|
|
698
|
+
for (const row of docs) await this.cms.db.delete({ collection: AUDIT_LOGS, id: row.id });
|
|
699
|
+
if (docs.length < 500) break;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
await auditContext.run({ via: "scheduler", user: null }, () => this.verify());
|
|
703
|
+
}
|
|
704
|
+
/** What the dashboard warns about: many failed logins, entries not written, a failed check. */
|
|
705
|
+
async attention(now = /* @__PURE__ */ new Date()) {
|
|
706
|
+
if (!this.enabled) return [];
|
|
707
|
+
const out = [];
|
|
708
|
+
const hour = new Date(now.getTime() - 36e5).toISOString();
|
|
709
|
+
const failed = await this.cms.db.count({
|
|
710
|
+
collection: AUDIT_LOGS,
|
|
711
|
+
where: {
|
|
712
|
+
and: [{ action: { in: ["login.failed", "sso.failed"] } }, { createdAt: { gte: hour } }]
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
if (failed >= this.settings.failedLogins) out.push({ id: "failed-logins", count: failed });
|
|
716
|
+
if (this.failures > 0) out.push({ id: "audit-failures", count: this.failures });
|
|
717
|
+
const last = await this.lastVerification();
|
|
718
|
+
if (last && last.invalid > 0)
|
|
719
|
+
out.push({ id: "audit-tampered", invalid: last.invalid, at: last.at });
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
// -------------------------------------------------------------------------
|
|
723
|
+
sign(row) {
|
|
724
|
+
const payload = stable(SIGNED.map((key2) => row[key2] ?? null));
|
|
725
|
+
return createHmac("sha256", this.cms.config.secret).update(`audit:${payload}`).digest("base64url");
|
|
726
|
+
}
|
|
727
|
+
async lastVerification() {
|
|
728
|
+
const { docs } = await this.cms.db.find({
|
|
729
|
+
collection: AUDIT_LOGS,
|
|
730
|
+
where: { action: { equals: "audit.verify" } },
|
|
731
|
+
sort: ["-id"],
|
|
732
|
+
limit: 1,
|
|
733
|
+
page: 1
|
|
734
|
+
});
|
|
735
|
+
const row = docs[0];
|
|
736
|
+
if (!row) return null;
|
|
737
|
+
const detail = row.detail ?? {};
|
|
738
|
+
return { at: String(row.createdAt), invalid: detail.invalid ?? 0 };
|
|
739
|
+
}
|
|
740
|
+
where(filter) {
|
|
741
|
+
const parts = [];
|
|
742
|
+
if (filter.action) {
|
|
743
|
+
parts.push({
|
|
744
|
+
or: [{ action: { equals: filter.action } }, { action: { like: `${filter.action}.` } }]
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
if (filter.target) parts.push({ target: { equals: filter.target } });
|
|
748
|
+
if (filter.doc) parts.push({ doc: { equals: filter.doc } });
|
|
749
|
+
if (filter.actor) parts.push({ actorEmail: { like: filter.actor } });
|
|
750
|
+
if (filter.from) parts.push({ createdAt: { gte: filter.from } });
|
|
751
|
+
if (filter.to) parts.push({ createdAt: { lte: filter.to } });
|
|
752
|
+
return parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : { and: parts };
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
function toEntry(row) {
|
|
756
|
+
return {
|
|
757
|
+
id: row.id,
|
|
758
|
+
at: String(row.createdAt),
|
|
759
|
+
action: String(row.action),
|
|
760
|
+
target: text(row.target),
|
|
761
|
+
doc: text(row.doc),
|
|
762
|
+
title: text(row.title),
|
|
763
|
+
actor: {
|
|
764
|
+
id: text(row.actorId),
|
|
765
|
+
email: text(row.actorEmail),
|
|
766
|
+
via: String(row.via ?? "system"),
|
|
767
|
+
ip: text(row.ip),
|
|
768
|
+
userAgent: text(row.userAgent)
|
|
769
|
+
},
|
|
770
|
+
changes: row.changes ?? null,
|
|
771
|
+
detail: row.detail ?? null
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/errors.ts
|
|
776
|
+
var ConfigError = class _ConfigError extends Error {
|
|
777
|
+
issues;
|
|
778
|
+
constructor(issues) {
|
|
779
|
+
super(_ConfigError.format(issues));
|
|
780
|
+
this.name = "ConfigError";
|
|
781
|
+
this.issues = issues;
|
|
782
|
+
}
|
|
783
|
+
static format(issues) {
|
|
784
|
+
const lines = issues.map((issue) => {
|
|
785
|
+
const hint = issue.hint ? `
|
|
786
|
+
\u2192 ${issue.hint}` : "";
|
|
787
|
+
return ` \u2022 ${issue.path}: ${issue.message}${hint}`;
|
|
788
|
+
});
|
|
789
|
+
const noun = issues.length === 1 ? "problem" : "problems";
|
|
790
|
+
return `Invalid Easy CMS config (${issues.length} ${noun}):
|
|
791
|
+
${lines.join("\n")}`;
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
var EasyCMSError = class extends Error {
|
|
795
|
+
status;
|
|
796
|
+
constructor(message2, status) {
|
|
797
|
+
super(message2);
|
|
798
|
+
this.name = "EasyCMSError";
|
|
799
|
+
this.status = status;
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
var ValidationError = class extends EasyCMSError {
|
|
803
|
+
errors;
|
|
804
|
+
constructor(collection2, errors) {
|
|
805
|
+
const list = errors.map((e) => `${e.field}: ${e.message}`).join("; ");
|
|
806
|
+
super(`Invalid data for "${collection2}": ${list}`, 400);
|
|
807
|
+
this.name = "ValidationError";
|
|
808
|
+
this.errors = errors;
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
var NotFoundError = class extends EasyCMSError {
|
|
812
|
+
constructor(collection2, id) {
|
|
813
|
+
super(`No document with id ${JSON.stringify(id)} in "${collection2}"`, 404);
|
|
814
|
+
this.name = "NotFoundError";
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
var QueryError = class extends EasyCMSError {
|
|
818
|
+
constructor(message2) {
|
|
819
|
+
super(message2, 400);
|
|
820
|
+
this.name = "QueryError";
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
var SchemaError = class extends EasyCMSError {
|
|
824
|
+
constructor(message2) {
|
|
825
|
+
super(message2, 500);
|
|
826
|
+
this.name = "SchemaError";
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
var UnauthorizedError = class extends EasyCMSError {
|
|
830
|
+
constructor(message2 = "You must be logged in") {
|
|
831
|
+
super(message2, 401);
|
|
832
|
+
this.name = "UnauthorizedError";
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
var ForbiddenError = class extends EasyCMSError {
|
|
836
|
+
constructor(message2 = "You are not allowed to do this") {
|
|
837
|
+
super(message2, 403);
|
|
838
|
+
this.name = "ForbiddenError";
|
|
839
|
+
}
|
|
585
840
|
};
|
|
586
|
-
var
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
{ name: "state", type: "text", required: true, index: true },
|
|
592
|
-
// manual | scheduled
|
|
593
|
-
{ name: "trigger", type: "text", required: true },
|
|
594
|
-
{ name: "filename", type: "text" },
|
|
595
|
-
{ name: "size", type: "number" },
|
|
596
|
-
{ name: "startedAt", type: "text", index: true },
|
|
597
|
-
{ name: "finishedAt", type: "text" },
|
|
598
|
-
{ name: "error", type: "text" },
|
|
599
|
-
// Who started it (email), for manual ones.
|
|
600
|
-
{ name: "author", type: "text" },
|
|
601
|
-
{ name: "downloadedBy", type: "text" },
|
|
602
|
-
{ name: "downloadedAt", type: "text" }
|
|
603
|
-
]
|
|
841
|
+
var TooManyRequestsError = class extends EasyCMSError {
|
|
842
|
+
constructor(message2) {
|
|
843
|
+
super(message2, 429);
|
|
844
|
+
this.name = "TooManyRequestsError";
|
|
845
|
+
}
|
|
604
846
|
};
|
|
605
|
-
var
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// The provider's id (`google`) and its id for the account (`sub`).
|
|
611
|
-
{ name: "provider", type: "text", required: true, index: true },
|
|
612
|
-
{ name: "subject", type: "text", required: true, index: true },
|
|
613
|
-
{ name: "email", type: "text" },
|
|
614
|
-
{ name: "lastUsedAt", type: "text" }
|
|
615
|
-
]
|
|
847
|
+
var PayloadTooLargeError = class extends EasyCMSError {
|
|
848
|
+
constructor(message2) {
|
|
849
|
+
super(message2, 413);
|
|
850
|
+
this.name = "PayloadTooLargeError";
|
|
851
|
+
}
|
|
616
852
|
};
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
853
|
+
|
|
854
|
+
// src/fields.ts
|
|
855
|
+
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
856
|
+
function mimeAllowedBy(mimeType, patterns) {
|
|
857
|
+
const type = mimeType.toLowerCase();
|
|
858
|
+
return patterns.some((pattern) => {
|
|
859
|
+
const p = pattern.toLowerCase();
|
|
860
|
+
return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
var hasRows = (field) => field.type === "array" || field.type === "blocks";
|
|
864
|
+
function rowFields(field, row) {
|
|
865
|
+
if (field.type === "array") return field.fields;
|
|
866
|
+
const type = row && typeof row === "object" ? row.blockType : void 0;
|
|
867
|
+
return field.blocks.find((block) => block.slug === type)?.fields;
|
|
868
|
+
}
|
|
869
|
+
var FIELD_TYPES = [
|
|
870
|
+
"text",
|
|
871
|
+
"textarea",
|
|
872
|
+
"number",
|
|
873
|
+
"boolean",
|
|
874
|
+
"date",
|
|
875
|
+
"email",
|
|
876
|
+
"json",
|
|
877
|
+
"select",
|
|
878
|
+
"slug",
|
|
879
|
+
"richText",
|
|
880
|
+
"upload",
|
|
881
|
+
"relationship",
|
|
882
|
+
"array",
|
|
883
|
+
"group",
|
|
884
|
+
"blocks"
|
|
885
|
+
];
|
|
886
|
+
|
|
887
|
+
// src/access-control.ts
|
|
888
|
+
async function evaluateAccess(access, args) {
|
|
889
|
+
const result = await (access ?? isLoggedIn)(args);
|
|
890
|
+
if (typeof result === "boolean") return result;
|
|
891
|
+
if (typeof result === "object" && result !== null) return result;
|
|
892
|
+
throw new QueryError("Access functions must return a boolean or a where query");
|
|
893
|
+
}
|
|
894
|
+
function andWhere(where, constraint) {
|
|
895
|
+
if (constraint === true) return where;
|
|
896
|
+
return where === void 0 ? constraint : { and: [where, constraint] };
|
|
897
|
+
}
|
|
898
|
+
var FieldAccessChecker = class {
|
|
899
|
+
/** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
|
|
900
|
+
constructor(kind, args, rules) {
|
|
901
|
+
this.kind = kind;
|
|
902
|
+
this.args = args;
|
|
903
|
+
this.rules = rules;
|
|
904
|
+
}
|
|
905
|
+
kind;
|
|
906
|
+
args;
|
|
907
|
+
rules;
|
|
908
|
+
cache = /* @__PURE__ */ new Map();
|
|
909
|
+
async allows(field) {
|
|
910
|
+
const rule = this.rules?.get(field);
|
|
911
|
+
if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
|
|
912
|
+
const fn = field.access?.[this.kind];
|
|
913
|
+
if (!fn) return true;
|
|
914
|
+
let allowed2 = this.cache.get(field);
|
|
915
|
+
if (allowed2 === void 0) {
|
|
916
|
+
allowed2 = await fn(this.args) === true;
|
|
917
|
+
this.cache.set(field, allowed2);
|
|
918
|
+
}
|
|
919
|
+
return allowed2;
|
|
920
|
+
}
|
|
629
921
|
};
|
|
922
|
+
async function stripFields(fields, doc, read2) {
|
|
923
|
+
const result = { ...doc };
|
|
924
|
+
for (const field of fields) {
|
|
925
|
+
if (!(field.name in result)) continue;
|
|
926
|
+
if (field.hidden || read2 && !await read2.allows(field)) {
|
|
927
|
+
delete result[field.name];
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const value = result[field.name];
|
|
931
|
+
if (field.type === "group" && value && typeof value === "object") {
|
|
932
|
+
result[field.name] = await stripFields(field.fields, value, read2);
|
|
933
|
+
} else if (hasRows(field) && Array.isArray(value)) {
|
|
934
|
+
result[field.name] = await Promise.all(
|
|
935
|
+
value.map((row) => {
|
|
936
|
+
const fields2 = rowFields(field, row);
|
|
937
|
+
return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read2) : row;
|
|
938
|
+
})
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
944
|
+
async function filterInput(fields, data, update2) {
|
|
945
|
+
const result = { ...data };
|
|
946
|
+
for (const field of fields) {
|
|
947
|
+
if (!(field.name in result)) continue;
|
|
948
|
+
if (field.hidden || update2 && !await update2.allows(field)) {
|
|
949
|
+
delete result[field.name];
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const value = result[field.name];
|
|
953
|
+
if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
954
|
+
result[field.name] = await filterInput(field.fields, value, update2);
|
|
955
|
+
} else if (hasRows(field) && Array.isArray(value)) {
|
|
956
|
+
result[field.name] = await Promise.all(
|
|
957
|
+
value.map((row) => {
|
|
958
|
+
const fields2 = rowFields(field, row);
|
|
959
|
+
return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update2) : row;
|
|
960
|
+
})
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return result;
|
|
965
|
+
}
|
|
630
966
|
|
|
631
967
|
// src/auth/emails.ts
|
|
632
968
|
var esc = (value) => value.replace(
|
|
@@ -634,9 +970,9 @@ var esc = (value) => value.replace(
|
|
|
634
970
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
635
971
|
);
|
|
636
972
|
function email(subject, lines, button) {
|
|
637
|
-
const
|
|
973
|
+
const text2 = [...lines, ...button ? ["", button.url] : []].join("\n");
|
|
638
974
|
const html = `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2328">${lines.map((line) => `<p>${esc(line)}</p>`).join("")}${button ? `<p><a href="${esc(button.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#2f6f5e;color:#fff;text-decoration:none;font-weight:600">${esc(button.label)}</a></p><p style="font-size:13px;color:#656d76">${esc(button.url)}</p>` : ""}</div>`;
|
|
639
|
-
return { subject, text, html };
|
|
975
|
+
return { subject, text: text2, html };
|
|
640
976
|
}
|
|
641
977
|
var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
|
|
642
978
|
var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
|
|
@@ -736,10 +1072,10 @@ async function fakeVerify(password) {
|
|
|
736
1072
|
}
|
|
737
1073
|
|
|
738
1074
|
// src/auth/sso.ts
|
|
739
|
-
import { createHmac as
|
|
1075
|
+
import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
|
|
740
1076
|
|
|
741
1077
|
// src/auth/tokens.ts
|
|
742
|
-
import { createHash as createHash2, createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
1078
|
+
import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
743
1079
|
function newToken() {
|
|
744
1080
|
return randomBytes3(32).toString("base64url");
|
|
745
1081
|
}
|
|
@@ -747,7 +1083,7 @@ function hashToken(token) {
|
|
|
747
1083
|
return createHash2("sha256").update(token).digest("hex");
|
|
748
1084
|
}
|
|
749
1085
|
function hmac(secret, value) {
|
|
750
|
-
return
|
|
1086
|
+
return createHmac2("sha256", secret).update(value).digest("base64url");
|
|
751
1087
|
}
|
|
752
1088
|
function signToken(secret, token) {
|
|
753
1089
|
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
@@ -824,7 +1160,7 @@ var SSO_COOKIE = "ecms-sso";
|
|
|
824
1160
|
var PENDING_TTL = 10 * 6e4;
|
|
825
1161
|
var random = () => randomBytes4(32).toString("base64url");
|
|
826
1162
|
function sign(secret, payload) {
|
|
827
|
-
return
|
|
1163
|
+
return createHmac3("sha256", secret).update(`sso:${payload}`).digest("base64url");
|
|
828
1164
|
}
|
|
829
1165
|
function seal(secret, pending) {
|
|
830
1166
|
const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
|
|
@@ -904,12 +1240,21 @@ var SingleSignOn = class {
|
|
|
904
1240
|
async finish(providerId, options) {
|
|
905
1241
|
const admin = this.config.admin.path.replace(/\/+$/, "");
|
|
906
1242
|
const pending = unseal(this.config.secret, options.cookie);
|
|
907
|
-
|
|
1243
|
+
let email2 = null;
|
|
1244
|
+
const back = async (page2, outcome) => {
|
|
1245
|
+
await this.cms.audit.record({
|
|
1246
|
+
action: "sso.failed",
|
|
1247
|
+
target: "auth",
|
|
1248
|
+
...page2 === "login" ? { user: null, email: email2 } : {},
|
|
1249
|
+
detail: { provider: providerId, outcome }
|
|
1250
|
+
});
|
|
1251
|
+
return { redirect: `${admin}/${page2}?sso=${outcome}` };
|
|
1252
|
+
};
|
|
908
1253
|
const page = pending?.u ? "account" : "login";
|
|
909
|
-
if (!pending || pending.p !== providerId) return back(page, "expired");
|
|
1254
|
+
if (!pending || pending.p !== providerId) return await back(page, "expired");
|
|
910
1255
|
const state = options.url.searchParams.get("state") ?? "";
|
|
911
|
-
if (!safeEqual(state, pending.s)) return back(page, "expired");
|
|
912
|
-
if (options.url.searchParams.has("error")) return back(page, "cancelled");
|
|
1256
|
+
if (!safeEqual(state, pending.s)) return await back(page, "expired");
|
|
1257
|
+
if (options.url.searchParams.has("error")) return await back(page, "cancelled");
|
|
913
1258
|
const provider = this.provider(providerId);
|
|
914
1259
|
const redirectUri = this.callbackURL(provider.id, options.origin);
|
|
915
1260
|
let profile;
|
|
@@ -926,15 +1271,22 @@ var SingleSignOn = class {
|
|
|
926
1271
|
this.cms.logger.warn(
|
|
927
1272
|
`Signing in with ${provider.name} failed: ${error.message || String(error)}`
|
|
928
1273
|
);
|
|
929
|
-
return back(page, "failed");
|
|
1274
|
+
return await back(page, "failed");
|
|
930
1275
|
}
|
|
931
1276
|
const subject = String(profile.subject);
|
|
932
|
-
|
|
1277
|
+
email2 = profile.email?.trim().toLowerCase() || null;
|
|
933
1278
|
if (pending.u) {
|
|
934
|
-
if (!options.user || String(options.user.id) !== pending.u)
|
|
1279
|
+
if (!options.user || String(options.user.id) !== pending.u)
|
|
1280
|
+
return await back("account", "failed");
|
|
935
1281
|
const existing = await this.identity(provider.id, subject);
|
|
936
|
-
if (existing && String(existing.user) !== pending.u) return back("account", "taken");
|
|
1282
|
+
if (existing && String(existing.user) !== pending.u) return await back("account", "taken");
|
|
937
1283
|
if (!existing) await this.addIdentity(options.user.id, provider.id, subject, email2);
|
|
1284
|
+
await this.cms.audit.record({
|
|
1285
|
+
action: "sso.link",
|
|
1286
|
+
target: "auth",
|
|
1287
|
+
user: options.user,
|
|
1288
|
+
detail: { provider: provider.id, email: email2 }
|
|
1289
|
+
});
|
|
938
1290
|
return { redirect: `${admin}/account?sso=linked` };
|
|
939
1291
|
}
|
|
940
1292
|
let user = null;
|
|
@@ -943,14 +1295,14 @@ var SingleSignOn = class {
|
|
|
943
1295
|
user = await this.cms.db.findById({ collection: USERS, id: identity.user });
|
|
944
1296
|
} else {
|
|
945
1297
|
if (!email2 || !profile.emailVerified)
|
|
946
|
-
return back("login", email2 ? "unverified" : "no-account");
|
|
1298
|
+
return await back("login", email2 ? "unverified" : "no-account");
|
|
947
1299
|
user = await this.userByEmail(email2);
|
|
948
1300
|
if (!user) user = await this.signUp(email2, profile.name);
|
|
949
|
-
if (!user) return back("login", "no-account");
|
|
1301
|
+
if (!user) return await back("login", "no-account");
|
|
950
1302
|
await this.addIdentity(user.id, provider.id, subject, email2);
|
|
951
1303
|
}
|
|
952
|
-
if (!user) return back("login", "no-account");
|
|
953
|
-
if (user.active === false) return back("login", "inactive");
|
|
1304
|
+
if (!user) return await back("login", "no-account");
|
|
1305
|
+
if (user.active === false) return await back("login", "inactive");
|
|
954
1306
|
const found = identity ?? await this.identity(provider.id, subject);
|
|
955
1307
|
if (found) {
|
|
956
1308
|
const { id, ...rest } = found;
|
|
@@ -960,7 +1312,14 @@ var SingleSignOn = class {
|
|
|
960
1312
|
data: { ...rest, email: email2, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
961
1313
|
});
|
|
962
1314
|
}
|
|
963
|
-
|
|
1315
|
+
const session = await this.cms.auth.createSession(user.id);
|
|
1316
|
+
await this.cms.audit.record({
|
|
1317
|
+
action: "sso.login",
|
|
1318
|
+
target: "auth",
|
|
1319
|
+
user: session.user,
|
|
1320
|
+
detail: { provider: provider.id, email: email2 }
|
|
1321
|
+
});
|
|
1322
|
+
return { redirect: pending.r, session };
|
|
964
1323
|
}
|
|
965
1324
|
/** A user's outside accounts. */
|
|
966
1325
|
async identities(userId) {
|
|
@@ -999,6 +1358,13 @@ var SingleSignOn = class {
|
|
|
999
1358
|
{ field: "provider", message: "is the only way left to sign in to this account" }
|
|
1000
1359
|
]);
|
|
1001
1360
|
await this.cms.db.delete({ collection: USER_IDENTITIES, id: row.id });
|
|
1361
|
+
await this.cms.audit.record({
|
|
1362
|
+
action: "sso.unlink",
|
|
1363
|
+
target: USERS,
|
|
1364
|
+
doc: row.user,
|
|
1365
|
+
title: typeof user?.email === "string" ? user.email : null,
|
|
1366
|
+
detail: { provider: String(row.provider), email: row.email ?? null }
|
|
1367
|
+
});
|
|
1002
1368
|
}
|
|
1003
1369
|
/** Forgets a deleted user's outside accounts. */
|
|
1004
1370
|
async forget(userId) {
|
|
@@ -1103,7 +1469,12 @@ var Auth = class {
|
|
|
1103
1469
|
const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
|
|
1104
1470
|
const password = typeof args.password === "string" ? args.password : "";
|
|
1105
1471
|
const key2 = `${email2}|${args.ip ?? ""}`;
|
|
1106
|
-
|
|
1472
|
+
try {
|
|
1473
|
+
await this.checkRateLimit(key2);
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
await this.cms.audit.record({ action: "login.locked", target: "auth", user: null, email: email2 });
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
1107
1478
|
const [user] = email2 ? (await this.db.find({
|
|
1108
1479
|
collection: USERS,
|
|
1109
1480
|
where: { email: { equals: email2 } },
|
|
@@ -1115,15 +1486,34 @@ var Auth = class {
|
|
|
1115
1486
|
const valid = user && hash && password ? await verifyPassword(password, hash) : await fakeVerify(password);
|
|
1116
1487
|
if (!user || !valid || user.active === false) {
|
|
1117
1488
|
await this.recordFailure(key2);
|
|
1489
|
+
await this.cms.audit.record({
|
|
1490
|
+
action: "login.failed",
|
|
1491
|
+
target: "auth",
|
|
1492
|
+
user: null,
|
|
1493
|
+
email: email2,
|
|
1494
|
+
detail: {
|
|
1495
|
+
reason: !user ? "unknown email" : user.active === false ? "deactivated" : "wrong password"
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1118
1498
|
throw new UnauthorizedError(INVALID);
|
|
1119
1499
|
}
|
|
1120
1500
|
await this.clearFailures(key2);
|
|
1121
|
-
if (!this.sso.passwordAllowed(user))
|
|
1501
|
+
if (!this.sso.passwordAllowed(user)) {
|
|
1502
|
+
await this.cms.audit.record({
|
|
1503
|
+
action: "login.failed",
|
|
1504
|
+
target: "auth",
|
|
1505
|
+
user: null,
|
|
1506
|
+
email: email2,
|
|
1507
|
+
detail: { reason: "passwords are off" }
|
|
1508
|
+
});
|
|
1122
1509
|
throw new UnauthorizedError(
|
|
1123
1510
|
`Sign in with ${this.config.auth.providers.map((p) => p.name).join(" or ")}`
|
|
1124
1511
|
);
|
|
1512
|
+
}
|
|
1125
1513
|
await this.deleteExpiredSessions(user.id);
|
|
1126
|
-
|
|
1514
|
+
const session = await this.startSession(user);
|
|
1515
|
+
await this.cms.audit.record({ action: "login", target: "auth", user: session.user });
|
|
1516
|
+
return session;
|
|
1127
1517
|
}
|
|
1128
1518
|
/** Creates the first admin. Only works while there are no users. */
|
|
1129
1519
|
async registerFirstUser(args) {
|
|
@@ -1146,7 +1536,15 @@ var Auth = class {
|
|
|
1146
1536
|
const token = unsignToken(this.config.secret, signedToken);
|
|
1147
1537
|
if (!token) return;
|
|
1148
1538
|
const session = await this.findSession(token);
|
|
1149
|
-
if (session)
|
|
1539
|
+
if (!session) return;
|
|
1540
|
+
await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
1541
|
+
const user = await this.db.findById({ collection: USERS, id: session.user });
|
|
1542
|
+
if (user)
|
|
1543
|
+
await this.cms.audit.record({
|
|
1544
|
+
action: "logout",
|
|
1545
|
+
target: "auth",
|
|
1546
|
+
user: await this.toAuthUser(user)
|
|
1547
|
+
});
|
|
1150
1548
|
}
|
|
1151
1549
|
/**
|
|
1152
1550
|
* Returns the user for a signed session token, or for an API key (`ecms_…`, with `apiKey` set
|
|
@@ -1220,6 +1618,7 @@ var Auth = class {
|
|
|
1220
1618
|
});
|
|
1221
1619
|
if (recent >= RESET_REQUESTS) return;
|
|
1222
1620
|
await this.recordFailure(key2);
|
|
1621
|
+
await this.cms.audit.record({ action: "password.forgot", target: "auth", user: null, email: email2 });
|
|
1223
1622
|
const user = await this.findByEmail(email2);
|
|
1224
1623
|
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return;
|
|
1225
1624
|
await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
|
|
@@ -1238,6 +1637,13 @@ var Auth = class {
|
|
|
1238
1637
|
if (user.active === false) throw new QueryError("This user is deactivated");
|
|
1239
1638
|
const purpose = user.passwordHash ? "reset" : "invite";
|
|
1240
1639
|
await this.mailPasswordLink(user, purpose, options);
|
|
1640
|
+
await this.cms.audit.record({
|
|
1641
|
+
action: "password.link",
|
|
1642
|
+
target: USERS,
|
|
1643
|
+
doc: user.id,
|
|
1644
|
+
title: String(user.email),
|
|
1645
|
+
detail: { purpose }
|
|
1646
|
+
});
|
|
1241
1647
|
return purpose;
|
|
1242
1648
|
}
|
|
1243
1649
|
/** The user and purpose of a valid, unused password link, or `null`. */
|
|
@@ -1263,6 +1669,12 @@ var Auth = class {
|
|
|
1263
1669
|
await this.clearFailures(key2);
|
|
1264
1670
|
const user = await this.db.findById({ collection: USERS, id: found.user.id });
|
|
1265
1671
|
const session = await this.startSession(user);
|
|
1672
|
+
await this.cms.audit.record({
|
|
1673
|
+
action: "password.reset",
|
|
1674
|
+
target: "auth",
|
|
1675
|
+
user: session.user,
|
|
1676
|
+
detail: { purpose: found.purpose }
|
|
1677
|
+
});
|
|
1266
1678
|
if (found.purpose === "reset") {
|
|
1267
1679
|
const content = await (this.config.auth.emails.passwordChanged ?? DEFAULT_PASSWORD_EMAILS.passwordChanged)({
|
|
1268
1680
|
user: await this.toAuthUser(user),
|
|
@@ -1579,26 +1991,26 @@ async function writeBackupFile(cms, file) {
|
|
|
1579
1991
|
});
|
|
1580
1992
|
}
|
|
1581
1993
|
}
|
|
1582
|
-
var slug = (
|
|
1994
|
+
var slug = (text2) => text2.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
|
|
1583
1995
|
function filenameFor(cms, date) {
|
|
1584
1996
|
const pad = (n) => String(n).padStart(2, "0");
|
|
1585
1997
|
const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
|
|
1586
1998
|
return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
|
|
1587
1999
|
}
|
|
1588
2000
|
function toAdmin(row) {
|
|
1589
|
-
const
|
|
2001
|
+
const text2 = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
1590
2002
|
return {
|
|
1591
2003
|
id: row.id,
|
|
1592
2004
|
state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
|
|
1593
2005
|
trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
|
|
1594
|
-
filename:
|
|
2006
|
+
filename: text2(row.filename),
|
|
1595
2007
|
size: typeof row.size === "number" ? row.size : null,
|
|
1596
|
-
startedAt:
|
|
1597
|
-
finishedAt:
|
|
1598
|
-
error:
|
|
1599
|
-
author:
|
|
1600
|
-
downloadedBy:
|
|
1601
|
-
downloadedAt:
|
|
2008
|
+
startedAt: text2(row.startedAt),
|
|
2009
|
+
finishedAt: text2(row.finishedAt),
|
|
2010
|
+
error: text2(row.error),
|
|
2011
|
+
author: text2(row.author),
|
|
2012
|
+
downloadedBy: text2(row.downloadedBy),
|
|
2013
|
+
downloadedAt: text2(row.downloadedAt)
|
|
1602
2014
|
};
|
|
1603
2015
|
}
|
|
1604
2016
|
async function update(cms, row, data) {
|
|
@@ -1915,8 +2327,8 @@ function itemsOf(field, n) {
|
|
|
1915
2327
|
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
1916
2328
|
}
|
|
1917
2329
|
var isEmpty = (value) => value === void 0 || value === null || value === "";
|
|
1918
|
-
function slugify(
|
|
1919
|
-
return
|
|
2330
|
+
function slugify(text2) {
|
|
2331
|
+
return text2.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
1920
2332
|
}
|
|
1921
2333
|
function applyDefaults(fields, data, localization = null) {
|
|
1922
2334
|
const result = { ...data };
|
|
@@ -2666,16 +3078,18 @@ var viewId = {
|
|
|
2666
3078
|
};
|
|
2667
3079
|
var tagOf = (component) => typeof component === "string" ? component : component.tag;
|
|
2668
3080
|
var Roles = class {
|
|
2669
|
-
constructor(config, db, log) {
|
|
3081
|
+
constructor(config, db, log, audit) {
|
|
2670
3082
|
this.config = config;
|
|
2671
3083
|
this.db = db;
|
|
2672
3084
|
this.log = log;
|
|
3085
|
+
this.audit = audit;
|
|
2673
3086
|
this.enabled = config.auth.rbac;
|
|
2674
3087
|
this.versions = new VersionStore(db);
|
|
2675
3088
|
}
|
|
2676
3089
|
config;
|
|
2677
3090
|
db;
|
|
2678
3091
|
log;
|
|
3092
|
+
audit;
|
|
2679
3093
|
enabled;
|
|
2680
3094
|
versions;
|
|
2681
3095
|
cache;
|
|
@@ -2909,6 +3323,7 @@ var Roles = class {
|
|
|
2909
3323
|
this.clean(input.permissions),
|
|
2910
3324
|
author.id
|
|
2911
3325
|
);
|
|
3326
|
+
await this.audit?.({ action: "role.create", target: "roles", doc: row.id, title: key2 });
|
|
2912
3327
|
return this.toRole(row, 0);
|
|
2913
3328
|
}
|
|
2914
3329
|
async update(id, input, author) {
|
|
@@ -2923,6 +3338,18 @@ var Roles = class {
|
|
|
2923
3338
|
const { id: _id, ...rest } = data;
|
|
2924
3339
|
const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
|
|
2925
3340
|
await this.record(saved, author.id);
|
|
3341
|
+
const changes = [];
|
|
3342
|
+
if ((row.name ?? "") !== (saved.name ?? ""))
|
|
3343
|
+
changes.push({ field: "name", before: row.name ?? "", after: saved.name ?? "" });
|
|
3344
|
+
if (JSON.stringify(row.permissions ?? {}) !== JSON.stringify(saved.permissions ?? {}))
|
|
3345
|
+
changes.push({ field: "permissions" });
|
|
3346
|
+
await this.audit?.({
|
|
3347
|
+
action: "role.update",
|
|
3348
|
+
target: "roles",
|
|
3349
|
+
doc: row.id,
|
|
3350
|
+
title: String(row.key),
|
|
3351
|
+
changes
|
|
3352
|
+
});
|
|
2926
3353
|
this.invalidate();
|
|
2927
3354
|
return this.toRole(saved, await this.users(String(row.key)));
|
|
2928
3355
|
}
|
|
@@ -2936,6 +3363,7 @@ var Roles = class {
|
|
|
2936
3363
|
throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
|
|
2937
3364
|
await this.db.delete({ collection: ROLES, id: row.id });
|
|
2938
3365
|
await this.versions.deleteAll(ROLES, row.id);
|
|
3366
|
+
await this.audit?.({ action: "role.delete", target: "roles", doc: row.id, title: key2 });
|
|
2939
3367
|
this.invalidate();
|
|
2940
3368
|
}
|
|
2941
3369
|
async history(id) {
|
|
@@ -2970,6 +3398,7 @@ var Roles = class {
|
|
|
2970
3398
|
views() {
|
|
2971
3399
|
return [
|
|
2972
3400
|
{ id: "status" },
|
|
3401
|
+
...this.config.audit ? [{ id: "audit" }] : [],
|
|
2973
3402
|
...this.config.collections.some(
|
|
2974
3403
|
(c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
|
|
2975
3404
|
) ? [{ id: "deliveries" }] : [],
|
|
@@ -3174,7 +3603,7 @@ function rank(role, config) {
|
|
|
3174
3603
|
}
|
|
3175
3604
|
|
|
3176
3605
|
// src/webhooks.ts
|
|
3177
|
-
import { createHmac as
|
|
3606
|
+
import { createHmac as createHmac4, randomUUID as randomUUID2 } from "crypto";
|
|
3178
3607
|
var WEBHOOK_EVENTS = [
|
|
3179
3608
|
"create",
|
|
3180
3609
|
"update",
|
|
@@ -3318,7 +3747,7 @@ var Webhooks = class {
|
|
|
3318
3747
|
...hook.headers
|
|
3319
3748
|
};
|
|
3320
3749
|
if (hook.secret) {
|
|
3321
|
-
headers["x-easy-cms-signature"] = `sha256=${
|
|
3750
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac4("sha256", hook.secret).update(body).digest("hex")}`;
|
|
3322
3751
|
}
|
|
3323
3752
|
try {
|
|
3324
3753
|
const response = await this.fetcher(hook.url, {
|
|
@@ -3366,6 +3795,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
3366
3795
|
"database-backups",
|
|
3367
3796
|
"user-roles",
|
|
3368
3797
|
"user-identities",
|
|
3798
|
+
"audit-logs",
|
|
3369
3799
|
"api-keys",
|
|
3370
3800
|
"jobs",
|
|
3371
3801
|
"auth",
|
|
@@ -3390,6 +3820,7 @@ function validateConfig(config) {
|
|
|
3390
3820
|
validateAdminViews(config, add);
|
|
3391
3821
|
validateUpload(config, add);
|
|
3392
3822
|
validateBackups(config, add);
|
|
3823
|
+
validateAudit(config, add);
|
|
3393
3824
|
validateAuth(config, add);
|
|
3394
3825
|
validateCors(config.cors, add);
|
|
3395
3826
|
validateLocalization(config, add);
|
|
@@ -3874,6 +4305,22 @@ function validateUpload(config, add) {
|
|
|
3874
4305
|
add("upload.fromURL.allowPrivate", "must be true or false");
|
|
3875
4306
|
}
|
|
3876
4307
|
var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
|
|
4308
|
+
function validateAudit(config, add) {
|
|
4309
|
+
const audit = config.audit;
|
|
4310
|
+
if (audit !== void 0 && typeof audit !== "boolean") {
|
|
4311
|
+
const a = audit;
|
|
4312
|
+
if (typeof a !== "object" || a === null)
|
|
4313
|
+
add("audit", "must be true or { keep, values, failedLogins }");
|
|
4314
|
+
else {
|
|
4315
|
+
if (a.keep !== void 0 && !(Number.isInteger(a.keep) && a.keep >= 0))
|
|
4316
|
+
add("audit.keep", "must be a number of days (0: keep all)");
|
|
4317
|
+
if (a.values !== void 0 && typeof a.values !== "boolean")
|
|
4318
|
+
add("audit.values", "must be true or false");
|
|
4319
|
+
if (a.failedLogins !== void 0 && !(Number.isInteger(a.failedLogins) && a.failedLogins > 0))
|
|
4320
|
+
add("audit.failedLogins", "must be a positive number");
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
3877
4324
|
function validateBackups(config, add) {
|
|
3878
4325
|
const backups = config.backups;
|
|
3879
4326
|
if (backups === void 0) return;
|
|
@@ -4393,6 +4840,7 @@ async function resolveConfig(input) {
|
|
|
4393
4840
|
inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
|
|
4394
4841
|
emails: config.auth?.emails ?? {}
|
|
4395
4842
|
},
|
|
4843
|
+
audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
|
|
4396
4844
|
collections: [
|
|
4397
4845
|
...config.collections ?? [],
|
|
4398
4846
|
...internalCollections,
|
|
@@ -4404,7 +4852,8 @@ async function resolveConfig(input) {
|
|
|
4404
4852
|
...config.email ? [emailDeliveriesCollection] : [],
|
|
4405
4853
|
databaseBackupsCollection,
|
|
4406
4854
|
...config.auth?.rbac ? [rolesCollection] : [],
|
|
4407
|
-
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : []
|
|
4855
|
+
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
|
|
4856
|
+
...config.audit ? [auditLogsCollection] : []
|
|
4408
4857
|
],
|
|
4409
4858
|
globals: config.globals ?? [],
|
|
4410
4859
|
endpoints: config.endpoints ?? [],
|
|
@@ -4423,6 +4872,13 @@ function usesVersions(config) {
|
|
|
4423
4872
|
function usesSchedule(config) {
|
|
4424
4873
|
return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
|
|
4425
4874
|
}
|
|
4875
|
+
function resolveAudit(audit) {
|
|
4876
|
+
return {
|
|
4877
|
+
keep: audit.keep ?? DEFAULT_AUDIT_KEEP,
|
|
4878
|
+
values: audit.values !== false,
|
|
4879
|
+
failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS
|
|
4880
|
+
};
|
|
4881
|
+
}
|
|
4426
4882
|
|
|
4427
4883
|
// src/load-config.ts
|
|
4428
4884
|
var CONFIG_FILE_NAMES = [
|
|
@@ -4606,13 +5062,13 @@ function sniffMimeType(data) {
|
|
|
4606
5062
|
if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
|
|
4607
5063
|
const head = data.subarray(0, 4096);
|
|
4608
5064
|
if (head.includes(0)) return void 0;
|
|
4609
|
-
let
|
|
5065
|
+
let text2;
|
|
4610
5066
|
try {
|
|
4611
|
-
|
|
5067
|
+
text2 = new TextDecoder("utf-8", { fatal: true }).decode(head);
|
|
4612
5068
|
} catch {
|
|
4613
5069
|
return void 0;
|
|
4614
5070
|
}
|
|
4615
|
-
const trimmed =
|
|
5071
|
+
const trimmed = text2.replace(/^/, "").trimStart();
|
|
4616
5072
|
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
|
|
4617
5073
|
return "image/svg+xml";
|
|
4618
5074
|
return "text/plain";
|
|
@@ -4988,6 +5444,8 @@ var EasyCMS = class {
|
|
|
4988
5444
|
cwd;
|
|
4989
5445
|
/** Roles and their permissions (`auth.rbac`, Settings → Roles). */
|
|
4990
5446
|
roles;
|
|
5447
|
+
/** Who did what (`audit`, Settings → Audit log). */
|
|
5448
|
+
audit;
|
|
4991
5449
|
versions;
|
|
4992
5450
|
webhooks;
|
|
4993
5451
|
mailer;
|
|
@@ -4998,7 +5456,13 @@ var EasyCMS = class {
|
|
|
4998
5456
|
this.storage = storage;
|
|
4999
5457
|
this.cwd = cwd;
|
|
5000
5458
|
this.versions = new VersionStore(db);
|
|
5001
|
-
this.roles = new Roles(
|
|
5459
|
+
this.roles = new Roles(
|
|
5460
|
+
config,
|
|
5461
|
+
db,
|
|
5462
|
+
(message2) => logger.info(message2),
|
|
5463
|
+
(entry) => this.audit.record(entry)
|
|
5464
|
+
);
|
|
5465
|
+
this.audit = new AuditLog(this);
|
|
5002
5466
|
this.webhooks = new Webhooks(
|
|
5003
5467
|
config.webhooks ?? [],
|
|
5004
5468
|
logger,
|
|
@@ -5480,6 +5944,7 @@ var EasyCMS = class {
|
|
|
5480
5944
|
operation: "update"
|
|
5481
5945
|
});
|
|
5482
5946
|
this.emit(config, "draft", draft);
|
|
5947
|
+
await this.audited(config, "draft", draft, current, guard, mode);
|
|
5483
5948
|
const [out2] = await this.output(config, [draft], guard, {
|
|
5484
5949
|
...options,
|
|
5485
5950
|
draft: true
|
|
@@ -5502,6 +5967,7 @@ var EasyCMS = class {
|
|
|
5502
5967
|
operation: "update"
|
|
5503
5968
|
});
|
|
5504
5969
|
this.emit(config, "update", doc, existing.status);
|
|
5970
|
+
await this.audited(config, "update", doc, current, guard, mode);
|
|
5505
5971
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
5506
5972
|
return out;
|
|
5507
5973
|
}
|
|
@@ -5546,6 +6012,7 @@ var EasyCMS = class {
|
|
|
5546
6012
|
doc: existing
|
|
5547
6013
|
});
|
|
5548
6014
|
this.emit(config, "delete", existing);
|
|
6015
|
+
await this.audited(config, "delete", existing, existing, guard);
|
|
5549
6016
|
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
5550
6017
|
return out;
|
|
5551
6018
|
}
|
|
@@ -5675,6 +6142,7 @@ var EasyCMS = class {
|
|
|
5675
6142
|
operation: "update"
|
|
5676
6143
|
});
|
|
5677
6144
|
this.emit(config, "draft", draft);
|
|
6145
|
+
await this.audited(config, "draft", draft, current, guard, mode);
|
|
5678
6146
|
return this.findGlobal(slug2, { ...options, draft: true });
|
|
5679
6147
|
}
|
|
5680
6148
|
const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
|
|
@@ -5686,6 +6154,7 @@ var EasyCMS = class {
|
|
|
5686
6154
|
operation: "update"
|
|
5687
6155
|
});
|
|
5688
6156
|
this.emit(config, "update", doc, existing.status);
|
|
6157
|
+
await this.audited(config, "update", doc, current, guard, mode);
|
|
5689
6158
|
return this.findGlobal(slug2, { ...options, draft: true });
|
|
5690
6159
|
}
|
|
5691
6160
|
/**
|
|
@@ -5787,16 +6256,19 @@ var EasyCMS = class {
|
|
|
5787
6256
|
* scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
|
|
5788
6257
|
*/
|
|
5789
6258
|
async runJobs(now = /* @__PURE__ */ new Date()) {
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
(
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
6259
|
+
return auditContext.run({ via: "scheduler", user: null }, async () => {
|
|
6260
|
+
const scheduled = await this.runScheduled(now);
|
|
6261
|
+
await this.pruneFailedDeliveries(now);
|
|
6262
|
+
await runDueBackups(this, now).catch(
|
|
6263
|
+
(error) => this.logger.error(`Backups: ${error.message}`)
|
|
6264
|
+
);
|
|
6265
|
+
await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`));
|
|
6266
|
+
return {
|
|
6267
|
+
...scheduled,
|
|
6268
|
+
webhooks: await this.retryWebhooks(now),
|
|
6269
|
+
emails: await this.mailer.retry(now)
|
|
6270
|
+
};
|
|
6271
|
+
});
|
|
5800
6272
|
}
|
|
5801
6273
|
/**
|
|
5802
6274
|
* Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
|
|
@@ -5961,6 +6433,13 @@ var EasyCMS = class {
|
|
|
5961
6433
|
updatedAt: now
|
|
5962
6434
|
}
|
|
5963
6435
|
});
|
|
6436
|
+
await this.audit.record({
|
|
6437
|
+
action: "schedule",
|
|
6438
|
+
target: parent,
|
|
6439
|
+
...parent.startsWith("global:") ? {} : { doc },
|
|
6440
|
+
...guardOf(options).user ? { user: guardOf(options).user } : {},
|
|
6441
|
+
detail: { action: job.action, at: runAt.toISOString() }
|
|
6442
|
+
});
|
|
5964
6443
|
return toJob(row);
|
|
5965
6444
|
}
|
|
5966
6445
|
/**
|
|
@@ -6020,6 +6499,12 @@ var EasyCMS = class {
|
|
|
6020
6499
|
const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
|
|
6021
6500
|
if (!job) throw new NotFoundError("scheduled job", jobId);
|
|
6022
6501
|
await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
|
|
6502
|
+
await this.audit.record({
|
|
6503
|
+
action: "unschedule",
|
|
6504
|
+
target: parent,
|
|
6505
|
+
...parent.startsWith("global:") ? {} : { doc },
|
|
6506
|
+
detail: { action: job.action, at: job.runAt }
|
|
6507
|
+
});
|
|
6023
6508
|
}
|
|
6024
6509
|
/**
|
|
6025
6510
|
* @internal One attempt now for a saved webhook delivery or email (the admin's Retry): removed
|
|
@@ -6264,6 +6749,7 @@ var EasyCMS = class {
|
|
|
6264
6749
|
operation: "create"
|
|
6265
6750
|
});
|
|
6266
6751
|
this.emit(config, "create", doc);
|
|
6752
|
+
await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
|
|
6267
6753
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
6268
6754
|
return out;
|
|
6269
6755
|
}
|
|
@@ -6337,6 +6823,31 @@ var EasyCMS = class {
|
|
|
6337
6823
|
if (after !== "published" && before === "published")
|
|
6338
6824
|
this.webhooks.emit("unpublish", target, body);
|
|
6339
6825
|
}
|
|
6826
|
+
/**
|
|
6827
|
+
* Writes the audit log entry for a change (`audit`): publishing, unpublishing and restoring
|
|
6828
|
+
* are told apart from other saves.
|
|
6829
|
+
*/
|
|
6830
|
+
async audited(config, event, doc, previous, guard, mode = "save") {
|
|
6831
|
+
if (!this.audit.enabled) return;
|
|
6832
|
+
let action = event;
|
|
6833
|
+
if (event === "update" || event === "draft") {
|
|
6834
|
+
const was = previous?.status;
|
|
6835
|
+
if (mode === "restore") action = "restore";
|
|
6836
|
+
else if (config.drafts && doc.status === "published" && was !== "published")
|
|
6837
|
+
action = "publish";
|
|
6838
|
+
else if (config.drafts && event === "update" && doc.status !== "published" && was === "published")
|
|
6839
|
+
action = "unpublish";
|
|
6840
|
+
}
|
|
6841
|
+
const global2 = !this.config.collections.includes(config);
|
|
6842
|
+
await this.audit.content(
|
|
6843
|
+
config,
|
|
6844
|
+
global2,
|
|
6845
|
+
action,
|
|
6846
|
+
event === "delete" ? void 0 : doc,
|
|
6847
|
+
event === "create" ? void 0 : previous,
|
|
6848
|
+
guard.user
|
|
6849
|
+
);
|
|
6850
|
+
}
|
|
6340
6851
|
/** Records a version after a save, when the collection or global keeps versions. */
|
|
6341
6852
|
async saveVersion(config, parent, id, doc, guard) {
|
|
6342
6853
|
const max = versionLimit(config);
|
|
@@ -6679,9 +7190,9 @@ var PER_PAGE = 50;
|
|
|
6679
7190
|
var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
|
|
6680
7191
|
var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
|
|
6681
7192
|
var isDeliveryKind = (value) => value === "webhook" || value === "email";
|
|
6682
|
-
function parse(
|
|
7193
|
+
function parse(text2) {
|
|
6683
7194
|
try {
|
|
6684
|
-
const value = JSON.parse(String(
|
|
7195
|
+
const value = JSON.parse(String(text2 ?? ""));
|
|
6685
7196
|
return typeof value === "object" && value !== null ? value : {};
|
|
6686
7197
|
} catch {
|
|
6687
7198
|
return {};
|
|
@@ -6848,7 +7359,7 @@ async function sendTestEmail(cms, user, to, locale) {
|
|
|
6848
7359
|
const th = locale === "th" || locale !== "en" && cms.config.admin.locale === "th";
|
|
6849
7360
|
const time = (/* @__PURE__ */ new Date()).toISOString();
|
|
6850
7361
|
const subject = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E08\u0E32\u0E01 ${site}` : `Test email from ${site}`;
|
|
6851
|
-
const
|
|
7362
|
+
const text2 = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E19\u0E35\u0E49\u0E2A\u0E48\u0E07\u0E08\u0E32\u0E01\u0E2B\u0E19\u0E49\u0E32 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 \u2192 \u0E2D\u0E35\u0E40\u0E21\u0E25 \u0E02\u0E2D\u0E07 ${site} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32
|
|
6852
7363
|
|
|
6853
7364
|
\u0E2A\u0E48\u0E07\u0E42\u0E14\u0E22: ${user.email}
|
|
6854
7365
|
Adapter: ${adapter.name ?? "custom"}
|
|
@@ -6860,7 +7371,7 @@ Adapter: ${adapter.name ?? "custom"}
|
|
|
6860
7371
|
From: ${from}
|
|
6861
7372
|
Time: ${time}`;
|
|
6862
7373
|
try {
|
|
6863
|
-
await adapter.send({ to: address, subject, text, from });
|
|
7374
|
+
await adapter.send({ to: address, subject, text: text2, from });
|
|
6864
7375
|
return { ok: true };
|
|
6865
7376
|
} catch (error) {
|
|
6866
7377
|
return { ok: false, error: message(error) };
|
|
@@ -7037,7 +7548,8 @@ async function adminSchema(cms, user, origin) {
|
|
|
7037
7548
|
backups: admin,
|
|
7038
7549
|
email: admin,
|
|
7039
7550
|
roles: admin && cms.roles.enabled,
|
|
7040
|
-
sso: admin && cms.auth.sso.enabled
|
|
7551
|
+
sso: admin && cms.auth.sso.enabled,
|
|
7552
|
+
audit: cms.audit.enabled && await cms.roles.canView(user, "audit")
|
|
7041
7553
|
};
|
|
7042
7554
|
const roles = await cms.roles.options();
|
|
7043
7555
|
return {
|
|
@@ -7099,16 +7611,16 @@ async function widgets(cms, user) {
|
|
|
7099
7611
|
}
|
|
7100
7612
|
|
|
7101
7613
|
// src/version.ts
|
|
7102
|
-
var VERSION = true ? "0.
|
|
7614
|
+
var VERSION = true ? "0.36.1" : "0.0.0-dev";
|
|
7103
7615
|
|
|
7104
7616
|
// src/rest/admin-status.ts
|
|
7105
|
-
var
|
|
7617
|
+
var DAY2 = 864e5;
|
|
7106
7618
|
var has = (cms, slug2) => cms.config.collections.some((c) => c.slug === slug2);
|
|
7107
7619
|
async function adminStatus(cms) {
|
|
7108
7620
|
const config = cms.config;
|
|
7109
7621
|
const attention = [];
|
|
7110
7622
|
const now = Date.now();
|
|
7111
|
-
const weekAgo = new Date(now - 7 *
|
|
7623
|
+
const weekAgo = new Date(now - 7 * DAY2).toISOString();
|
|
7112
7624
|
if (has(cms, WEBHOOK_DELIVERIES)) {
|
|
7113
7625
|
const failed = await cms.db.find({
|
|
7114
7626
|
collection: WEBHOOK_DELIVERIES,
|
|
@@ -7158,6 +7670,7 @@ async function adminStatus(cms) {
|
|
|
7158
7670
|
}
|
|
7159
7671
|
const backups = await backupAttention(cms);
|
|
7160
7672
|
if (backups) attention.push(backups);
|
|
7673
|
+
attention.push(...await cms.audit.attention());
|
|
7161
7674
|
if (!config.email) attention.push({ id: "no-email" });
|
|
7162
7675
|
if (!config.serverURL && process.env.NODE_ENV === "production")
|
|
7163
7676
|
attention.push({ id: "no-server-url" });
|
|
@@ -7367,7 +7880,15 @@ async function respond(cms, request, url, path, root, options, production) {
|
|
|
7367
7880
|
const ctx = { request, url, user, via: user ? via : null, token, headers };
|
|
7368
7881
|
const method = request.method.toUpperCase();
|
|
7369
7882
|
if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
|
|
7370
|
-
const
|
|
7883
|
+
const audit = {
|
|
7884
|
+
user,
|
|
7885
|
+
ip: options.getClientIp?.(request),
|
|
7886
|
+
userAgent: request.headers.get("user-agent")
|
|
7887
|
+
};
|
|
7888
|
+
const result = await auditContext.run(
|
|
7889
|
+
audit,
|
|
7890
|
+
async () => root ? await customEndpoint(cms, ctx, method, segments, true, options) : await customEndpoint(cms, ctx, method, segments, false, options) ?? await route(cms, ctx, method, segments, options)
|
|
7891
|
+
);
|
|
7371
7892
|
if (!result) throw new HttpError("Not found", 404);
|
|
7372
7893
|
if (result.body instanceof Response) return result.body;
|
|
7373
7894
|
return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
|
|
@@ -7552,8 +8073,11 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7552
8073
|
if (extra !== void 0) throw new HttpError("Not found", 404);
|
|
7553
8074
|
if (id2 === void 0) {
|
|
7554
8075
|
if (method === "GET") return { body: await listBackups(cms) };
|
|
7555
|
-
if (method === "POST")
|
|
7556
|
-
|
|
8076
|
+
if (method === "POST") {
|
|
8077
|
+
const started = await startBackup(cms, "manual", ctx.user.email);
|
|
8078
|
+
await cms.audit.record({ action: "backup.start", target: "backups", doc: started.id });
|
|
8079
|
+
return { status: 202, body: started };
|
|
8080
|
+
}
|
|
7557
8081
|
throw methodNotAllowed(ctx, "GET, POST");
|
|
7558
8082
|
}
|
|
7559
8083
|
const parsed = parseId(id2);
|
|
@@ -7561,6 +8085,12 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7561
8085
|
if (action === "download") {
|
|
7562
8086
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7563
8087
|
const file = await downloadBackup(cms, parsed, ctx.user.email);
|
|
8088
|
+
await cms.audit.record({
|
|
8089
|
+
action: "backup.download",
|
|
8090
|
+
target: "backups",
|
|
8091
|
+
doc: parsed,
|
|
8092
|
+
title: file.filename
|
|
8093
|
+
});
|
|
7564
8094
|
return {
|
|
7565
8095
|
body: new Response(file.body, {
|
|
7566
8096
|
headers: {
|
|
@@ -7575,6 +8105,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7575
8105
|
if (action !== void 0) throw new HttpError("Not found", 404);
|
|
7576
8106
|
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
7577
8107
|
await deleteBackup(cms, parsed);
|
|
8108
|
+
await cms.audit.record({ action: "backup.delete", target: "backups", doc: parsed });
|
|
7578
8109
|
return { body: { deleted: 1 } };
|
|
7579
8110
|
}
|
|
7580
8111
|
if (first === "admin" && second === "email") {
|
|
@@ -7590,7 +8121,9 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7590
8121
|
if (action === "verify") return { body: await verifyEmail(cms) };
|
|
7591
8122
|
if (action === "test") {
|
|
7592
8123
|
const body = await readJson(ctx.request);
|
|
7593
|
-
|
|
8124
|
+
const sent = await sendTestEmail(cms, ctx.user, body.to, body.locale);
|
|
8125
|
+
await cms.audit.record({ action: "email.test", target: "email", detail: { ...sent } });
|
|
8126
|
+
return { body: sent };
|
|
7594
8127
|
}
|
|
7595
8128
|
throw new HttpError("Not found", 404);
|
|
7596
8129
|
}
|
|
@@ -7600,6 +8133,41 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7600
8133
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7601
8134
|
return { body: cms.auth.sso.settings(ctx.url.origin) };
|
|
7602
8135
|
}
|
|
8136
|
+
if (first === "admin" && (second === "audit" || second === "audit.csv")) {
|
|
8137
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
8138
|
+
if (!cms.audit.enabled) throw new HttpError("Not found", 404);
|
|
8139
|
+
if (!await cms.roles.canView(ctx.user, "audit")) throw new ForbiddenError();
|
|
8140
|
+
const q = ctx.url.searchParams;
|
|
8141
|
+
const filter = {
|
|
8142
|
+
action: q.get("action"),
|
|
8143
|
+
target: q.get("target"),
|
|
8144
|
+
doc: q.get("doc"),
|
|
8145
|
+
actor: q.get("actor"),
|
|
8146
|
+
from: q.get("from"),
|
|
8147
|
+
to: q.get("to")
|
|
8148
|
+
};
|
|
8149
|
+
if (second === "audit.csv" && third === void 0) {
|
|
8150
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8151
|
+
return {
|
|
8152
|
+
body: new Response(await cms.audit.csv(filter), {
|
|
8153
|
+
headers: {
|
|
8154
|
+
"content-type": "text/csv; charset=utf-8",
|
|
8155
|
+
"content-disposition": `attachment; filename="audit-log-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv"`,
|
|
8156
|
+
"cache-control": "no-store",
|
|
8157
|
+
"x-content-type-options": "nosniff"
|
|
8158
|
+
}
|
|
8159
|
+
})
|
|
8160
|
+
};
|
|
8161
|
+
}
|
|
8162
|
+
if (second === "audit" && third === "verify" && segments.length === 3) {
|
|
8163
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
8164
|
+
return { body: await cms.audit.verify() };
|
|
8165
|
+
}
|
|
8166
|
+
if (second !== "audit" || third !== void 0) throw new HttpError("Not found", 404);
|
|
8167
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8168
|
+
const page = Math.max(1, Number.parseInt(q.get("page") ?? "1", 10) || 1);
|
|
8169
|
+
return { body: await cms.audit.list(filter, page) };
|
|
8170
|
+
}
|
|
7603
8171
|
if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
|
|
7604
8172
|
if (!ctx.user) throw new UnauthorizedError();
|
|
7605
8173
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
@@ -7650,21 +8218,46 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7650
8218
|
if (!isDeliveryKind(kind) || extra !== void 0) throw new HttpError("Not found", 404);
|
|
7651
8219
|
if (id2 === "retry" && action === void 0) {
|
|
7652
8220
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
7653
|
-
|
|
8221
|
+
const retried = await retryFailedDeliveries(cms, kind);
|
|
8222
|
+
await cms.audit.record({
|
|
8223
|
+
action: "delivery.retry",
|
|
8224
|
+
target: "deliveries",
|
|
8225
|
+
detail: { kind, all: true }
|
|
8226
|
+
});
|
|
8227
|
+
return { body: retried };
|
|
7654
8228
|
}
|
|
7655
8229
|
if (id2 === void 0) {
|
|
7656
8230
|
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
7657
|
-
|
|
8231
|
+
const deleted = await deleteFailedDeliveries(cms, kind);
|
|
8232
|
+
await cms.audit.record({
|
|
8233
|
+
action: "delivery.delete",
|
|
8234
|
+
target: "deliveries",
|
|
8235
|
+
detail: { kind, all: true }
|
|
8236
|
+
});
|
|
8237
|
+
return { body: deleted };
|
|
7658
8238
|
}
|
|
7659
8239
|
const parsed = parseId(id2);
|
|
7660
8240
|
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
7661
8241
|
if (action === "retry") {
|
|
7662
8242
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
7663
|
-
|
|
8243
|
+
const retried = await cms.retryDelivery(kind, parsed);
|
|
8244
|
+
await cms.audit.record({
|
|
8245
|
+
action: "delivery.retry",
|
|
8246
|
+
target: "deliveries",
|
|
8247
|
+
doc: parsed,
|
|
8248
|
+
detail: { kind }
|
|
8249
|
+
});
|
|
8250
|
+
return { body: retried };
|
|
7664
8251
|
}
|
|
7665
8252
|
if (action !== void 0) throw new HttpError("Not found", 404);
|
|
7666
8253
|
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
7667
8254
|
await deleteDelivery(cms, kind, parsed);
|
|
8255
|
+
await cms.audit.record({
|
|
8256
|
+
action: "delivery.delete",
|
|
8257
|
+
target: "deliveries",
|
|
8258
|
+
doc: parsed,
|
|
8259
|
+
detail: { kind }
|
|
8260
|
+
});
|
|
7668
8261
|
return { body: { deleted: 1 } };
|
|
7669
8262
|
}
|
|
7670
8263
|
if (first === "admin") {
|
|
@@ -8050,11 +8643,11 @@ async function readJson(request) {
|
|
|
8050
8643
|
}
|
|
8051
8644
|
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
8052
8645
|
if (declared > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
|
|
8053
|
-
const
|
|
8054
|
-
if (Buffer.byteLength(
|
|
8646
|
+
const text2 = await request.text();
|
|
8647
|
+
if (Buffer.byteLength(text2) > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
|
|
8055
8648
|
let body;
|
|
8056
8649
|
try {
|
|
8057
|
-
body = JSON.parse(
|
|
8650
|
+
body = JSON.parse(text2);
|
|
8058
8651
|
} catch {
|
|
8059
8652
|
throw new HttpError("Request body is not valid JSON", 400);
|
|
8060
8653
|
}
|
|
@@ -8333,6 +8926,7 @@ export {
|
|
|
8333
8926
|
API_KEYS,
|
|
8334
8927
|
API_KEY_GLOBAL_OPERATIONS,
|
|
8335
8928
|
API_KEY_OPERATIONS,
|
|
8929
|
+
AuditLog,
|
|
8336
8930
|
Auth,
|
|
8337
8931
|
BUILTIN_COLLECTIONS,
|
|
8338
8932
|
CONFIG_FILE_NAMES,
|