@easy-cms/core 0.34.0 → 0.36.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 +528 -188
- package/dist/index.js +1284 -258
- 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";
|
|
@@ -332,6 +144,8 @@ var WEBHOOK_DELIVERIES = "webhook-deliveries";
|
|
|
332
144
|
var EMAIL_DELIVERIES = "email-deliveries";
|
|
333
145
|
var DATABASE_BACKUPS = "database-backups";
|
|
334
146
|
var ROLES = "user-roles";
|
|
147
|
+
var USER_IDENTITIES = "user-identities";
|
|
148
|
+
var AUDIT_LOGS = "audit-logs";
|
|
335
149
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
336
150
|
SESSIONS,
|
|
337
151
|
LOGIN_ATTEMPTS,
|
|
@@ -340,7 +154,9 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
340
154
|
WEBHOOK_DELIVERIES,
|
|
341
155
|
EMAIL_DELIVERIES,
|
|
342
156
|
DATABASE_BACKUPS,
|
|
343
|
-
ROLES
|
|
157
|
+
ROLES,
|
|
158
|
+
USER_IDENTITIES,
|
|
159
|
+
AUDIT_LOGS
|
|
344
160
|
]);
|
|
345
161
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
346
162
|
var CREATED_BY_FIELD = {
|
|
@@ -600,6 +416,38 @@ var databaseBackupsCollection = {
|
|
|
600
416
|
{ name: "downloadedAt", type: "text" }
|
|
601
417
|
]
|
|
602
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
|
+
};
|
|
603
451
|
var emailDeliveriesCollection = {
|
|
604
452
|
slug: EMAIL_DELIVERIES,
|
|
605
453
|
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
@@ -614,15 +462,517 @@ var emailDeliveriesCollection = {
|
|
|
614
462
|
]
|
|
615
463
|
};
|
|
616
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
|
+
}
|
|
840
|
+
};
|
|
841
|
+
var TooManyRequestsError = class extends EasyCMSError {
|
|
842
|
+
constructor(message2) {
|
|
843
|
+
super(message2, 429);
|
|
844
|
+
this.name = "TooManyRequestsError";
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
var PayloadTooLargeError = class extends EasyCMSError {
|
|
848
|
+
constructor(message2) {
|
|
849
|
+
super(message2, 413);
|
|
850
|
+
this.name = "PayloadTooLargeError";
|
|
851
|
+
}
|
|
852
|
+
};
|
|
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
|
+
}
|
|
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
|
+
}
|
|
966
|
+
|
|
617
967
|
// src/auth/emails.ts
|
|
618
968
|
var esc = (value) => value.replace(
|
|
619
969
|
/[&<>"']/g,
|
|
620
970
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
621
971
|
);
|
|
622
972
|
function email(subject, lines, button) {
|
|
623
|
-
const
|
|
973
|
+
const text2 = [...lines, ...button ? ["", button.url] : []].join("\n");
|
|
624
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>`;
|
|
625
|
-
return { subject, text, html };
|
|
975
|
+
return { subject, text: text2, html };
|
|
626
976
|
}
|
|
627
977
|
var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
|
|
628
978
|
var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
|
|
@@ -642,7 +992,17 @@ var DEFAULT_PASSWORD_EMAILS = {
|
|
|
642
992
|
],
|
|
643
993
|
{ label: "Set a new password", url }
|
|
644
994
|
),
|
|
645
|
-
invite: ({ url = "", locale, expiresAt, user }) => locale === "th" ? email(
|
|
995
|
+
invite: ({ url = "", locale, expiresAt, user, sso }) => sso ? locale === "th" ? email(
|
|
996
|
+
"\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
|
|
997
|
+
[`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A\u0E14\u0E49\u0E27\u0E22 ${sso.join(" \u0E2B\u0E23\u0E37\u0E2D ")} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`],
|
|
998
|
+
{ label: "\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A", url }
|
|
999
|
+
) : email(
|
|
1000
|
+
"You're invited to the content admin",
|
|
1001
|
+
[
|
|
1002
|
+
`An account for ${user.email} is waiting for you. Sign in with ${sso.join(" or ")} to start.`
|
|
1003
|
+
],
|
|
1004
|
+
{ label: "Sign in", url }
|
|
1005
|
+
) : locale === "th" ? email(
|
|
646
1006
|
"\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
|
|
647
1007
|
[
|
|
648
1008
|
`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`,
|
|
@@ -711,8 +1071,11 @@ async function fakeVerify(password) {
|
|
|
711
1071
|
return false;
|
|
712
1072
|
}
|
|
713
1073
|
|
|
1074
|
+
// src/auth/sso.ts
|
|
1075
|
+
import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
|
|
1076
|
+
|
|
714
1077
|
// src/auth/tokens.ts
|
|
715
|
-
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";
|
|
716
1079
|
function newToken() {
|
|
717
1080
|
return randomBytes3(32).toString("base64url");
|
|
718
1081
|
}
|
|
@@ -720,7 +1083,7 @@ function hashToken(token) {
|
|
|
720
1083
|
return createHash2("sha256").update(token).digest("hex");
|
|
721
1084
|
}
|
|
722
1085
|
function hmac(secret, value) {
|
|
723
|
-
return
|
|
1086
|
+
return createHmac2("sha256", secret).update(value).digest("base64url");
|
|
724
1087
|
}
|
|
725
1088
|
function signToken(secret, token) {
|
|
726
1089
|
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
@@ -792,14 +1155,309 @@ function passwordTokenMatches(secret, token, fingerprint) {
|
|
|
792
1155
|
return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
|
|
793
1156
|
}
|
|
794
1157
|
|
|
1158
|
+
// src/auth/sso.ts
|
|
1159
|
+
var SSO_COOKIE = "ecms-sso";
|
|
1160
|
+
var PENDING_TTL = 10 * 6e4;
|
|
1161
|
+
var random = () => randomBytes4(32).toString("base64url");
|
|
1162
|
+
function sign(secret, payload) {
|
|
1163
|
+
return createHmac3("sha256", secret).update(`sso:${payload}`).digest("base64url");
|
|
1164
|
+
}
|
|
1165
|
+
function seal(secret, pending) {
|
|
1166
|
+
const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
|
|
1167
|
+
return `${payload}.${sign(secret, payload)}`;
|
|
1168
|
+
}
|
|
1169
|
+
function unseal(secret, value) {
|
|
1170
|
+
if (!value) return null;
|
|
1171
|
+
const dot = value.lastIndexOf(".");
|
|
1172
|
+
if (dot <= 0) return null;
|
|
1173
|
+
const payload = value.slice(0, dot);
|
|
1174
|
+
if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
|
|
1175
|
+
try {
|
|
1176
|
+
const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1177
|
+
return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
|
|
1178
|
+
} catch {
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
var SingleSignOn = class {
|
|
1183
|
+
constructor(cms) {
|
|
1184
|
+
this.cms = cms;
|
|
1185
|
+
}
|
|
1186
|
+
cms;
|
|
1187
|
+
get config() {
|
|
1188
|
+
return this.cms.config;
|
|
1189
|
+
}
|
|
1190
|
+
get enabled() {
|
|
1191
|
+
return this.config.auth.providers.length > 0;
|
|
1192
|
+
}
|
|
1193
|
+
providers() {
|
|
1194
|
+
return this.config.auth.providers.map((p) => ({
|
|
1195
|
+
id: p.id,
|
|
1196
|
+
name: p.name,
|
|
1197
|
+
icon: p.icon ?? "key"
|
|
1198
|
+
}));
|
|
1199
|
+
}
|
|
1200
|
+
/** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
|
|
1201
|
+
passwordAllowed(user) {
|
|
1202
|
+
return this.config.auth.password || user.role === "admin";
|
|
1203
|
+
}
|
|
1204
|
+
/** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
|
|
1205
|
+
callbackURL(provider, origin) {
|
|
1206
|
+
const base = this.cms.auth.passwordLinkBase(origin);
|
|
1207
|
+
return base ? `${base}${this.config.routes.api}/auth/${provider}/callback` : null;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Starts signing in (or, with `user`, linking an account): the provider's URL to send the
|
|
1211
|
+
* browser to, and the sealed value of the cookie that must come back with it.
|
|
1212
|
+
*/
|
|
1213
|
+
async start(providerId, options) {
|
|
1214
|
+
const provider = this.provider(providerId);
|
|
1215
|
+
const redirectUri = this.callbackURL(provider.id, options.origin);
|
|
1216
|
+
if (!redirectUri)
|
|
1217
|
+
throw new QueryError("Signing in with providers needs `serverURL` in production");
|
|
1218
|
+
const pending = {
|
|
1219
|
+
p: provider.id,
|
|
1220
|
+
s: random(),
|
|
1221
|
+
v: random(),
|
|
1222
|
+
n: random(),
|
|
1223
|
+
r: this.adminPage(options.redirect),
|
|
1224
|
+
...options.user ? { u: String(options.user.id) } : {},
|
|
1225
|
+
e: Date.now() + PENDING_TTL
|
|
1226
|
+
};
|
|
1227
|
+
const url = await provider.authorizationURL({
|
|
1228
|
+
redirectUri,
|
|
1229
|
+
state: pending.s,
|
|
1230
|
+
codeVerifier: pending.v,
|
|
1231
|
+
nonce: pending.n
|
|
1232
|
+
});
|
|
1233
|
+
return { url: url.toString(), cookie: seal(this.config.secret, pending) };
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Finishes at `<api>/auth/<id>/callback`: checks the response, finds (or makes) the user and
|
|
1237
|
+
* starts a session. Returns where to send the browser; failures end on the login page (or the
|
|
1238
|
+
* account page, when linking) with `?sso=<outcome>`.
|
|
1239
|
+
*/
|
|
1240
|
+
async finish(providerId, options) {
|
|
1241
|
+
const admin = this.config.admin.path.replace(/\/+$/, "");
|
|
1242
|
+
const pending = unseal(this.config.secret, options.cookie);
|
|
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
|
+
};
|
|
1253
|
+
const page = pending?.u ? "account" : "login";
|
|
1254
|
+
if (!pending || pending.p !== providerId) return await back(page, "expired");
|
|
1255
|
+
const state = options.url.searchParams.get("state") ?? "";
|
|
1256
|
+
if (!safeEqual(state, pending.s)) return await back(page, "expired");
|
|
1257
|
+
if (options.url.searchParams.has("error")) return await back(page, "cancelled");
|
|
1258
|
+
const provider = this.provider(providerId);
|
|
1259
|
+
const redirectUri = this.callbackURL(provider.id, options.origin);
|
|
1260
|
+
let profile;
|
|
1261
|
+
try {
|
|
1262
|
+
if (!redirectUri) throw new Error("no callback URL");
|
|
1263
|
+
profile = await provider.callback({
|
|
1264
|
+
redirectUri,
|
|
1265
|
+
state: pending.s,
|
|
1266
|
+
codeVerifier: pending.v,
|
|
1267
|
+
nonce: pending.n,
|
|
1268
|
+
url: options.url
|
|
1269
|
+
});
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
this.cms.logger.warn(
|
|
1272
|
+
`Signing in with ${provider.name} failed: ${error.message || String(error)}`
|
|
1273
|
+
);
|
|
1274
|
+
return await back(page, "failed");
|
|
1275
|
+
}
|
|
1276
|
+
const subject = String(profile.subject);
|
|
1277
|
+
email2 = profile.email?.trim().toLowerCase() || null;
|
|
1278
|
+
if (pending.u) {
|
|
1279
|
+
if (!options.user || String(options.user.id) !== pending.u)
|
|
1280
|
+
return await back("account", "failed");
|
|
1281
|
+
const existing = await this.identity(provider.id, subject);
|
|
1282
|
+
if (existing && String(existing.user) !== pending.u) return await back("account", "taken");
|
|
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
|
+
});
|
|
1290
|
+
return { redirect: `${admin}/account?sso=linked` };
|
|
1291
|
+
}
|
|
1292
|
+
let user = null;
|
|
1293
|
+
const identity = await this.identity(provider.id, subject);
|
|
1294
|
+
if (identity) {
|
|
1295
|
+
user = await this.cms.db.findById({ collection: USERS, id: identity.user });
|
|
1296
|
+
} else {
|
|
1297
|
+
if (!email2 || !profile.emailVerified)
|
|
1298
|
+
return await back("login", email2 ? "unverified" : "no-account");
|
|
1299
|
+
user = await this.userByEmail(email2);
|
|
1300
|
+
if (!user) user = await this.signUp(email2, profile.name);
|
|
1301
|
+
if (!user) return await back("login", "no-account");
|
|
1302
|
+
await this.addIdentity(user.id, provider.id, subject, email2);
|
|
1303
|
+
}
|
|
1304
|
+
if (!user) return await back("login", "no-account");
|
|
1305
|
+
if (user.active === false) return await back("login", "inactive");
|
|
1306
|
+
const found = identity ?? await this.identity(provider.id, subject);
|
|
1307
|
+
if (found) {
|
|
1308
|
+
const { id, ...rest } = found;
|
|
1309
|
+
await this.cms.db.update({
|
|
1310
|
+
collection: USER_IDENTITIES,
|
|
1311
|
+
id,
|
|
1312
|
+
data: { ...rest, email: email2, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
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 };
|
|
1323
|
+
}
|
|
1324
|
+
/** A user's outside accounts. */
|
|
1325
|
+
async identities(userId) {
|
|
1326
|
+
if (!this.enabled) return [];
|
|
1327
|
+
const { docs } = await this.cms.db.find({
|
|
1328
|
+
collection: USER_IDENTITIES,
|
|
1329
|
+
where: { user: { equals: userId } },
|
|
1330
|
+
sort: ["id"],
|
|
1331
|
+
limit: 0,
|
|
1332
|
+
page: 1
|
|
1333
|
+
});
|
|
1334
|
+
return docs.map((row) => ({
|
|
1335
|
+
id: row.id,
|
|
1336
|
+
provider: String(row.provider),
|
|
1337
|
+
name: this.config.auth.providers.find((p) => p.id === row.provider)?.name ?? String(row.provider),
|
|
1338
|
+
email: typeof row.email === "string" ? row.email : null,
|
|
1339
|
+
createdAt: String(row.createdAt),
|
|
1340
|
+
lastUsedAt: typeof row.lastUsedAt === "string" ? row.lastUsedAt : null
|
|
1341
|
+
}));
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Unlinks an outside account: the user's own, or anyone's for admins. Refuses to unlink the
|
|
1345
|
+
* last way a user has to sign in.
|
|
1346
|
+
*/
|
|
1347
|
+
async unlink(actor, identityId) {
|
|
1348
|
+
if (!this.enabled) throw new NotFoundError(USER_IDENTITIES, identityId);
|
|
1349
|
+
const row = await this.cms.db.findById({ collection: USER_IDENTITIES, id: identityId });
|
|
1350
|
+
if (!row) throw new NotFoundError(USER_IDENTITIES, identityId);
|
|
1351
|
+
const own = String(row.user) === String(actor.id);
|
|
1352
|
+
if (!own && (actor.role !== "admin" || actor.apiKey)) throw new ForbiddenError();
|
|
1353
|
+
const user = await this.cms.db.findById({ collection: USERS, id: row.user });
|
|
1354
|
+
const others = (await this.identities(row.user)).length - 1;
|
|
1355
|
+
const password = !!user?.passwordHash && this.passwordAllowed(user);
|
|
1356
|
+
if (others === 0 && !password)
|
|
1357
|
+
throw new ValidationError(USER_IDENTITIES, [
|
|
1358
|
+
{ field: "provider", message: "is the only way left to sign in to this account" }
|
|
1359
|
+
]);
|
|
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
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
/** Forgets a deleted user's outside accounts. */
|
|
1370
|
+
async forget(userId) {
|
|
1371
|
+
if (!this.enabled) return;
|
|
1372
|
+
for (const identity of await this.identities(userId))
|
|
1373
|
+
await this.cms.db.delete({ collection: USER_IDENTITIES, id: identity.id });
|
|
1374
|
+
}
|
|
1375
|
+
settings(origin) {
|
|
1376
|
+
const { allowSignUp } = this.config.auth;
|
|
1377
|
+
return {
|
|
1378
|
+
providers: this.config.auth.providers.map((p) => ({
|
|
1379
|
+
id: p.id,
|
|
1380
|
+
name: p.name,
|
|
1381
|
+
icon: p.icon ?? "key",
|
|
1382
|
+
callbackURL: this.callbackURL(p.id, origin),
|
|
1383
|
+
details: { ...p.describe?.() ?? {} }
|
|
1384
|
+
})),
|
|
1385
|
+
password: this.config.auth.password ? "everyone" : "admins",
|
|
1386
|
+
signUp: allowSignUp.domains.length > 0 ? { domains: [...allowSignUp.domains], role: allowSignUp.role ?? this.defaultRole() } : null,
|
|
1387
|
+
serverURL: !!this.config.serverURL
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
// -------------------------------------------------------------------------
|
|
1391
|
+
provider(id) {
|
|
1392
|
+
const provider = this.config.auth.providers.find((p) => p.id === id);
|
|
1393
|
+
if (!provider) throw new NotFoundError("auth providers", id);
|
|
1394
|
+
return provider;
|
|
1395
|
+
}
|
|
1396
|
+
/** Only pages of the admin: anything else (another site) goes to the admin's home. */
|
|
1397
|
+
adminPage(redirect) {
|
|
1398
|
+
const admin = this.config.admin.path.replace(/\/+$/, "");
|
|
1399
|
+
if (typeof redirect === "string" && (redirect === admin || redirect.startsWith(`${admin}/`)) && !redirect.startsWith("//") && !redirect.includes("\\"))
|
|
1400
|
+
return redirect;
|
|
1401
|
+
return `${admin}/`;
|
|
1402
|
+
}
|
|
1403
|
+
async identity(provider, subject) {
|
|
1404
|
+
const { docs } = await this.cms.db.find({
|
|
1405
|
+
collection: USER_IDENTITIES,
|
|
1406
|
+
where: { and: [{ provider: { equals: provider } }, { subject: { equals: subject } }] },
|
|
1407
|
+
sort: [],
|
|
1408
|
+
limit: 1,
|
|
1409
|
+
page: 1
|
|
1410
|
+
});
|
|
1411
|
+
return docs[0];
|
|
1412
|
+
}
|
|
1413
|
+
async addIdentity(user, provider, subject, email2) {
|
|
1414
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1415
|
+
await this.cms.db.create({
|
|
1416
|
+
collection: USER_IDENTITIES,
|
|
1417
|
+
data: { user, provider, subject, email: email2, lastUsedAt: now, createdAt: now, updatedAt: now }
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
async userByEmail(email2) {
|
|
1421
|
+
const { docs } = await this.cms.db.find({
|
|
1422
|
+
collection: USERS,
|
|
1423
|
+
where: { email: { equals: email2 } },
|
|
1424
|
+
sort: [],
|
|
1425
|
+
limit: 1,
|
|
1426
|
+
page: 1
|
|
1427
|
+
});
|
|
1428
|
+
return docs[0] ?? null;
|
|
1429
|
+
}
|
|
1430
|
+
/** A new account for people from `allowSignUp.domains`. */
|
|
1431
|
+
async signUp(email2, name) {
|
|
1432
|
+
const { domains, role } = this.config.auth.allowSignUp;
|
|
1433
|
+
const domain = email2.slice(email2.lastIndexOf("@") + 1);
|
|
1434
|
+
if (!domains.some((d) => d.toLowerCase() === domain)) return null;
|
|
1435
|
+
const created = await this.cms.create(USERS, {
|
|
1436
|
+
email: email2,
|
|
1437
|
+
role: role ?? this.defaultRole(),
|
|
1438
|
+
active: true,
|
|
1439
|
+
...name ? { name } : {}
|
|
1440
|
+
});
|
|
1441
|
+
this.cms.logger.info(`${email2} signed up with a provider`);
|
|
1442
|
+
return this.cms.db.findById({ collection: USERS, id: created.id });
|
|
1443
|
+
}
|
|
1444
|
+
defaultRole() {
|
|
1445
|
+
const roles = this.config.auth.roles;
|
|
1446
|
+
return roles.includes("editor") ? "editor" : roles[roles.length - 1];
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
|
|
795
1450
|
// src/auth/auth.ts
|
|
796
1451
|
var INVALID = "Invalid email or password";
|
|
797
1452
|
var RESET_REQUESTS = 3;
|
|
798
1453
|
var Auth = class {
|
|
799
1454
|
constructor(cms) {
|
|
800
1455
|
this.cms = cms;
|
|
1456
|
+
this.sso = new SingleSignOn(cms);
|
|
801
1457
|
}
|
|
802
1458
|
cms;
|
|
1459
|
+
/** Signing in with outside accounts (`auth.providers`). */
|
|
1460
|
+
sso;
|
|
803
1461
|
get db() {
|
|
804
1462
|
return this.cms.db;
|
|
805
1463
|
}
|
|
@@ -811,7 +1469,12 @@ var Auth = class {
|
|
|
811
1469
|
const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
|
|
812
1470
|
const password = typeof args.password === "string" ? args.password : "";
|
|
813
1471
|
const key2 = `${email2}|${args.ip ?? ""}`;
|
|
814
|
-
|
|
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
|
+
}
|
|
815
1478
|
const [user] = email2 ? (await this.db.find({
|
|
816
1479
|
collection: USERS,
|
|
817
1480
|
where: { email: { equals: email2 } },
|
|
@@ -823,11 +1486,34 @@ var Auth = class {
|
|
|
823
1486
|
const valid = user && hash && password ? await verifyPassword(password, hash) : await fakeVerify(password);
|
|
824
1487
|
if (!user || !valid || user.active === false) {
|
|
825
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
|
+
});
|
|
826
1498
|
throw new UnauthorizedError(INVALID);
|
|
827
1499
|
}
|
|
828
1500
|
await this.clearFailures(key2);
|
|
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
|
+
});
|
|
1509
|
+
throw new UnauthorizedError(
|
|
1510
|
+
`Sign in with ${this.config.auth.providers.map((p) => p.name).join(" or ")}`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
829
1513
|
await this.deleteExpiredSessions(user.id);
|
|
830
|
-
|
|
1514
|
+
const session = await this.startSession(user);
|
|
1515
|
+
await this.cms.audit.record({ action: "login", target: "auth", user: session.user });
|
|
1516
|
+
return session;
|
|
831
1517
|
}
|
|
832
1518
|
/** Creates the first admin. Only works while there are no users. */
|
|
833
1519
|
async registerFirstUser(args) {
|
|
@@ -850,7 +1536,15 @@ var Auth = class {
|
|
|
850
1536
|
const token = unsignToken(this.config.secret, signedToken);
|
|
851
1537
|
if (!token) return;
|
|
852
1538
|
const session = await this.findSession(token);
|
|
853
|
-
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
|
+
});
|
|
854
1548
|
}
|
|
855
1549
|
/**
|
|
856
1550
|
* Returns the user for a signed session token, or for an API key (`ecms_…`, with `apiKey` set
|
|
@@ -924,8 +1618,9 @@ var Auth = class {
|
|
|
924
1618
|
});
|
|
925
1619
|
if (recent >= RESET_REQUESTS) return;
|
|
926
1620
|
await this.recordFailure(key2);
|
|
1621
|
+
await this.cms.audit.record({ action: "password.forgot", target: "auth", user: null, email: email2 });
|
|
927
1622
|
const user = await this.findByEmail(email2);
|
|
928
|
-
if (!user || user.active === false) return;
|
|
1623
|
+
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return;
|
|
929
1624
|
await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
|
|
930
1625
|
}
|
|
931
1626
|
/**
|
|
@@ -942,6 +1637,13 @@ var Auth = class {
|
|
|
942
1637
|
if (user.active === false) throw new QueryError("This user is deactivated");
|
|
943
1638
|
const purpose = user.passwordHash ? "reset" : "invite";
|
|
944
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
|
+
});
|
|
945
1647
|
return purpose;
|
|
946
1648
|
}
|
|
947
1649
|
/** The user and purpose of a valid, unused password link, or `null`. */
|
|
@@ -967,6 +1669,12 @@ var Auth = class {
|
|
|
967
1669
|
await this.clearFailures(key2);
|
|
968
1670
|
const user = await this.db.findById({ collection: USERS, id: found.user.id });
|
|
969
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
|
+
});
|
|
970
1678
|
if (found.purpose === "reset") {
|
|
971
1679
|
const content = await (this.config.auth.emails.passwordChanged ?? DEFAULT_PASSWORD_EMAILS.passwordChanged)({
|
|
972
1680
|
user: await this.toAuthUser(user),
|
|
@@ -981,7 +1689,7 @@ var Auth = class {
|
|
|
981
1689
|
if (!claims || claims.expiresAt <= Date.now()) return null;
|
|
982
1690
|
const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
|
|
983
1691
|
const user = await this.db.findById({ collection: USERS, id });
|
|
984
|
-
if (!user || user.active === false) return null;
|
|
1692
|
+
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
|
|
985
1693
|
const fingerprint = passwordFingerprint(user.passwordHash);
|
|
986
1694
|
if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
|
|
987
1695
|
return { user, purpose: claims.purpose };
|
|
@@ -996,13 +1704,14 @@ var Auth = class {
|
|
|
996
1704
|
expiresAt,
|
|
997
1705
|
fingerprint: passwordFingerprint(user.passwordHash)
|
|
998
1706
|
});
|
|
999
|
-
const
|
|
1707
|
+
const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
|
|
1708
|
+
const url = sso ? `${base}${this.config.admin.path}/login` : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
|
|
1000
1709
|
const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
|
|
1001
1710
|
const content = await write({
|
|
1002
1711
|
user: await this.toAuthUser(user),
|
|
1003
1712
|
url,
|
|
1004
1713
|
locale: this.localeOf(options.locale),
|
|
1005
|
-
expiresAt: new Date(expiresAt)
|
|
1714
|
+
...sso ? { sso } : { expiresAt: new Date(expiresAt) }
|
|
1006
1715
|
});
|
|
1007
1716
|
await this.cms.sendEmail({ to: String(user.email), ...content });
|
|
1008
1717
|
}
|
|
@@ -1282,26 +1991,26 @@ async function writeBackupFile(cms, file) {
|
|
|
1282
1991
|
});
|
|
1283
1992
|
}
|
|
1284
1993
|
}
|
|
1285
|
-
var slug = (
|
|
1994
|
+
var slug = (text2) => text2.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
|
|
1286
1995
|
function filenameFor(cms, date) {
|
|
1287
1996
|
const pad = (n) => String(n).padStart(2, "0");
|
|
1288
1997
|
const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
|
|
1289
1998
|
return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
|
|
1290
1999
|
}
|
|
1291
2000
|
function toAdmin(row) {
|
|
1292
|
-
const
|
|
2001
|
+
const text2 = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
1293
2002
|
return {
|
|
1294
2003
|
id: row.id,
|
|
1295
2004
|
state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
|
|
1296
2005
|
trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
|
|
1297
|
-
filename:
|
|
2006
|
+
filename: text2(row.filename),
|
|
1298
2007
|
size: typeof row.size === "number" ? row.size : null,
|
|
1299
|
-
startedAt:
|
|
1300
|
-
finishedAt:
|
|
1301
|
-
error:
|
|
1302
|
-
author:
|
|
1303
|
-
downloadedBy:
|
|
1304
|
-
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)
|
|
1305
2014
|
};
|
|
1306
2015
|
}
|
|
1307
2016
|
async function update(cms, row, data) {
|
|
@@ -1618,8 +2327,8 @@ function itemsOf(field, n) {
|
|
|
1618
2327
|
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
1619
2328
|
}
|
|
1620
2329
|
var isEmpty = (value) => value === void 0 || value === null || value === "";
|
|
1621
|
-
function slugify(
|
|
1622
|
-
return
|
|
2330
|
+
function slugify(text2) {
|
|
2331
|
+
return text2.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
1623
2332
|
}
|
|
1624
2333
|
function applyDefaults(fields, data, localization = null) {
|
|
1625
2334
|
const result = { ...data };
|
|
@@ -2369,16 +3078,18 @@ var viewId = {
|
|
|
2369
3078
|
};
|
|
2370
3079
|
var tagOf = (component) => typeof component === "string" ? component : component.tag;
|
|
2371
3080
|
var Roles = class {
|
|
2372
|
-
constructor(config, db, log) {
|
|
3081
|
+
constructor(config, db, log, audit) {
|
|
2373
3082
|
this.config = config;
|
|
2374
3083
|
this.db = db;
|
|
2375
3084
|
this.log = log;
|
|
3085
|
+
this.audit = audit;
|
|
2376
3086
|
this.enabled = config.auth.rbac;
|
|
2377
3087
|
this.versions = new VersionStore(db);
|
|
2378
3088
|
}
|
|
2379
3089
|
config;
|
|
2380
3090
|
db;
|
|
2381
3091
|
log;
|
|
3092
|
+
audit;
|
|
2382
3093
|
enabled;
|
|
2383
3094
|
versions;
|
|
2384
3095
|
cache;
|
|
@@ -2612,6 +3323,7 @@ var Roles = class {
|
|
|
2612
3323
|
this.clean(input.permissions),
|
|
2613
3324
|
author.id
|
|
2614
3325
|
);
|
|
3326
|
+
await this.audit?.({ action: "role.create", target: "roles", doc: row.id, title: key2 });
|
|
2615
3327
|
return this.toRole(row, 0);
|
|
2616
3328
|
}
|
|
2617
3329
|
async update(id, input, author) {
|
|
@@ -2626,6 +3338,18 @@ var Roles = class {
|
|
|
2626
3338
|
const { id: _id, ...rest } = data;
|
|
2627
3339
|
const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
|
|
2628
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
|
+
});
|
|
2629
3353
|
this.invalidate();
|
|
2630
3354
|
return this.toRole(saved, await this.users(String(row.key)));
|
|
2631
3355
|
}
|
|
@@ -2639,6 +3363,7 @@ var Roles = class {
|
|
|
2639
3363
|
throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
|
|
2640
3364
|
await this.db.delete({ collection: ROLES, id: row.id });
|
|
2641
3365
|
await this.versions.deleteAll(ROLES, row.id);
|
|
3366
|
+
await this.audit?.({ action: "role.delete", target: "roles", doc: row.id, title: key2 });
|
|
2642
3367
|
this.invalidate();
|
|
2643
3368
|
}
|
|
2644
3369
|
async history(id) {
|
|
@@ -2673,6 +3398,7 @@ var Roles = class {
|
|
|
2673
3398
|
views() {
|
|
2674
3399
|
return [
|
|
2675
3400
|
{ id: "status" },
|
|
3401
|
+
...this.config.audit ? [{ id: "audit" }] : [],
|
|
2676
3402
|
...this.config.collections.some(
|
|
2677
3403
|
(c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
|
|
2678
3404
|
) ? [{ id: "deliveries" }] : [],
|
|
@@ -2877,7 +3603,7 @@ function rank(role, config) {
|
|
|
2877
3603
|
}
|
|
2878
3604
|
|
|
2879
3605
|
// src/webhooks.ts
|
|
2880
|
-
import { createHmac as
|
|
3606
|
+
import { createHmac as createHmac4, randomUUID as randomUUID2 } from "crypto";
|
|
2881
3607
|
var WEBHOOK_EVENTS = [
|
|
2882
3608
|
"create",
|
|
2883
3609
|
"update",
|
|
@@ -3021,7 +3747,7 @@ var Webhooks = class {
|
|
|
3021
3747
|
...hook.headers
|
|
3022
3748
|
};
|
|
3023
3749
|
if (hook.secret) {
|
|
3024
|
-
headers["x-easy-cms-signature"] = `sha256=${
|
|
3750
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac4("sha256", hook.secret).update(body).digest("hex")}`;
|
|
3025
3751
|
}
|
|
3026
3752
|
try {
|
|
3027
3753
|
const response = await this.fetcher(hook.url, {
|
|
@@ -3068,8 +3794,11 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
3068
3794
|
"email-deliveries",
|
|
3069
3795
|
"database-backups",
|
|
3070
3796
|
"user-roles",
|
|
3797
|
+
"user-identities",
|
|
3798
|
+
"audit-logs",
|
|
3071
3799
|
"api-keys",
|
|
3072
3800
|
"jobs",
|
|
3801
|
+
"auth",
|
|
3073
3802
|
"migrations",
|
|
3074
3803
|
"access"
|
|
3075
3804
|
]);
|
|
@@ -3091,6 +3820,7 @@ function validateConfig(config) {
|
|
|
3091
3820
|
validateAdminViews(config, add);
|
|
3092
3821
|
validateUpload(config, add);
|
|
3093
3822
|
validateBackups(config, add);
|
|
3823
|
+
validateAudit(config, add);
|
|
3094
3824
|
validateAuth(config, add);
|
|
3095
3825
|
validateCors(config.cors, add);
|
|
3096
3826
|
validateLocalization(config, add);
|
|
@@ -3317,6 +4047,52 @@ function validateAuth(config, add) {
|
|
|
3317
4047
|
if (auth === void 0) return;
|
|
3318
4048
|
if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
|
|
3319
4049
|
add("auth.rbac", "must be true or false");
|
|
4050
|
+
if (auth.password !== void 0 && typeof auth.password !== "boolean")
|
|
4051
|
+
add("auth.password", "must be true or false");
|
|
4052
|
+
const providers = auth.providers;
|
|
4053
|
+
if (providers !== void 0) {
|
|
4054
|
+
if (!Array.isArray(providers)) {
|
|
4055
|
+
add(
|
|
4056
|
+
"auth.providers",
|
|
4057
|
+
"must be an array",
|
|
4058
|
+
"e.g. providers: [google({ clientId, clientSecret })]"
|
|
4059
|
+
);
|
|
4060
|
+
} else {
|
|
4061
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4062
|
+
providers.forEach((provider, i) => {
|
|
4063
|
+
const p = provider;
|
|
4064
|
+
const path = `auth.providers[${i}]`;
|
|
4065
|
+
if (typeof p !== "object" || p === null || typeof p.authorizationURL !== "function" || typeof p.callback !== "function") {
|
|
4066
|
+
add(
|
|
4067
|
+
path,
|
|
4068
|
+
"must be a provider",
|
|
4069
|
+
"e.g. google({ clientId, clientSecret }) from @easy-cms/auth-oauth"
|
|
4070
|
+
);
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
4073
|
+
if (typeof p.id !== "string" || !PAGE_PATH.test(p.id))
|
|
4074
|
+
add(`${path}.id`, 'must be lowercase letters, digits and "-"');
|
|
4075
|
+
else if (ids.has(p.id))
|
|
4076
|
+
add(`${path}.id`, `"${p.id}" is used by another provider`, "give one of them its own id");
|
|
4077
|
+
else ids.add(p.id);
|
|
4078
|
+
if (typeof p.name !== "string" || p.name.trim() === "")
|
|
4079
|
+
add(`${path}.name`, "must be a name");
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
const signUp = auth.allowSignUp;
|
|
4084
|
+
if (signUp !== void 0) {
|
|
4085
|
+
if (typeof signUp !== "object" || signUp === null || !Array.isArray(signUp.domains) || signUp.domains.some((d) => typeof d !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d)))
|
|
4086
|
+
add("auth.allowSignUp.domains", "must be email domains", "e.g. domains: ['example.com']");
|
|
4087
|
+
if (signUp?.role === "admin")
|
|
4088
|
+
add("auth.allowSignUp.role", `can't be "admin"`, "make admins by hand, in Settings \u2192 Users");
|
|
4089
|
+
else if (signUp?.role !== void 0 && (typeof signUp.role !== "string" || !auth.rbac && !(auth.roles ?? ["admin", "editor"]).includes(signUp.role)))
|
|
4090
|
+
add("auth.allowSignUp.role", "must be one of auth.roles");
|
|
4091
|
+
if (!Array.isArray(providers) || providers.length === 0)
|
|
4092
|
+
add("auth.allowSignUp", "needs auth.providers");
|
|
4093
|
+
}
|
|
4094
|
+
if (auth.password === false && (!Array.isArray(providers) || providers.length === 0))
|
|
4095
|
+
add("auth.password", "false needs auth.providers", "otherwise only admins could sign in");
|
|
3320
4096
|
if (auth.roles !== void 0) {
|
|
3321
4097
|
const roles = auth.roles;
|
|
3322
4098
|
if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
|
|
@@ -3529,6 +4305,22 @@ function validateUpload(config, add) {
|
|
|
3529
4305
|
add("upload.fromURL.allowPrivate", "must be true or false");
|
|
3530
4306
|
}
|
|
3531
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
|
+
}
|
|
3532
4324
|
function validateBackups(config, add) {
|
|
3533
4325
|
const backups = config.backups;
|
|
3534
4326
|
if (backups === void 0) return;
|
|
@@ -3843,7 +4635,15 @@ function validateComponents(components, path, add) {
|
|
|
3843
4635
|
}
|
|
3844
4636
|
var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
3845
4637
|
var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
|
|
3846
|
-
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
|
|
4638
|
+
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
|
|
4639
|
+
"users",
|
|
4640
|
+
"globals",
|
|
4641
|
+
"admin",
|
|
4642
|
+
"jobs",
|
|
4643
|
+
"media",
|
|
4644
|
+
"api-keys",
|
|
4645
|
+
"auth"
|
|
4646
|
+
]);
|
|
3847
4647
|
function validateCommands(config, add) {
|
|
3848
4648
|
const commands = config.commands;
|
|
3849
4649
|
if (commands === void 0) return;
|
|
@@ -4029,6 +4829,9 @@ async function resolveConfig(input) {
|
|
|
4029
4829
|
auth: {
|
|
4030
4830
|
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
4031
4831
|
rbac: config.auth?.rbac === true,
|
|
4832
|
+
providers: config.auth?.providers ?? [],
|
|
4833
|
+
allowSignUp: config.auth?.allowSignUp ?? { domains: [] },
|
|
4834
|
+
password: config.auth?.password !== false,
|
|
4032
4835
|
tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
|
|
4033
4836
|
maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
|
|
4034
4837
|
lockWindow: config.auth?.lockWindow ?? 15 * 60,
|
|
@@ -4037,6 +4840,7 @@ async function resolveConfig(input) {
|
|
|
4037
4840
|
inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
|
|
4038
4841
|
emails: config.auth?.emails ?? {}
|
|
4039
4842
|
},
|
|
4843
|
+
audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
|
|
4040
4844
|
collections: [
|
|
4041
4845
|
...config.collections ?? [],
|
|
4042
4846
|
...internalCollections,
|
|
@@ -4047,7 +4851,9 @@ async function resolveConfig(input) {
|
|
|
4047
4851
|
...config.apiKeys ? [apiKeysCollection] : [],
|
|
4048
4852
|
...config.email ? [emailDeliveriesCollection] : [],
|
|
4049
4853
|
databaseBackupsCollection,
|
|
4050
|
-
...config.auth?.rbac ? [rolesCollection] : []
|
|
4854
|
+
...config.auth?.rbac ? [rolesCollection] : [],
|
|
4855
|
+
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
|
|
4856
|
+
...config.audit ? [auditLogsCollection] : []
|
|
4051
4857
|
],
|
|
4052
4858
|
globals: config.globals ?? [],
|
|
4053
4859
|
endpoints: config.endpoints ?? [],
|
|
@@ -4066,6 +4872,13 @@ function usesVersions(config) {
|
|
|
4066
4872
|
function usesSchedule(config) {
|
|
4067
4873
|
return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
|
|
4068
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
|
+
}
|
|
4069
4882
|
|
|
4070
4883
|
// src/load-config.ts
|
|
4071
4884
|
var CONFIG_FILE_NAMES = [
|
|
@@ -4113,7 +4926,7 @@ async function loadConfig(options = {}) {
|
|
|
4113
4926
|
}
|
|
4114
4927
|
|
|
4115
4928
|
// src/local-api.ts
|
|
4116
|
-
import { randomBytes as
|
|
4929
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
4117
4930
|
|
|
4118
4931
|
// src/localization.ts
|
|
4119
4932
|
var ALL_LOCALES = "all";
|
|
@@ -4249,13 +5062,13 @@ function sniffMimeType(data) {
|
|
|
4249
5062
|
if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
|
|
4250
5063
|
const head = data.subarray(0, 4096);
|
|
4251
5064
|
if (head.includes(0)) return void 0;
|
|
4252
|
-
let
|
|
5065
|
+
let text2;
|
|
4253
5066
|
try {
|
|
4254
|
-
|
|
5067
|
+
text2 = new TextDecoder("utf-8", { fatal: true }).decode(head);
|
|
4255
5068
|
} catch {
|
|
4256
5069
|
return void 0;
|
|
4257
5070
|
}
|
|
4258
|
-
const trimmed =
|
|
5071
|
+
const trimmed = text2.replace(/^/, "").trimStart();
|
|
4259
5072
|
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
|
|
4260
5073
|
return "image/svg+xml";
|
|
4261
5074
|
return "text/plain";
|
|
@@ -4310,10 +5123,10 @@ function imageDimensions(data, type) {
|
|
|
4310
5123
|
}
|
|
4311
5124
|
return void 0;
|
|
4312
5125
|
}
|
|
4313
|
-
function storageKey(originalName, type,
|
|
5126
|
+
function storageKey(originalName, type, random2) {
|
|
4314
5127
|
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
4315
5128
|
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
4316
|
-
return `${base}-${
|
|
5129
|
+
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
4317
5130
|
}
|
|
4318
5131
|
|
|
4319
5132
|
// src/populate.ts
|
|
@@ -4631,6 +5444,8 @@ var EasyCMS = class {
|
|
|
4631
5444
|
cwd;
|
|
4632
5445
|
/** Roles and their permissions (`auth.rbac`, Settings → Roles). */
|
|
4633
5446
|
roles;
|
|
5447
|
+
/** Who did what (`audit`, Settings → Audit log). */
|
|
5448
|
+
audit;
|
|
4634
5449
|
versions;
|
|
4635
5450
|
webhooks;
|
|
4636
5451
|
mailer;
|
|
@@ -4641,7 +5456,13 @@ var EasyCMS = class {
|
|
|
4641
5456
|
this.storage = storage;
|
|
4642
5457
|
this.cwd = cwd;
|
|
4643
5458
|
this.versions = new VersionStore(db);
|
|
4644
|
-
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);
|
|
4645
5466
|
this.webhooks = new Webhooks(
|
|
4646
5467
|
config.webhooks ?? [],
|
|
4647
5468
|
logger,
|
|
@@ -4752,8 +5573,8 @@ var EasyCMS = class {
|
|
|
4752
5573
|
}
|
|
4753
5574
|
]);
|
|
4754
5575
|
}
|
|
4755
|
-
const
|
|
4756
|
-
const filename = storageKey(file.name, mimeType,
|
|
5576
|
+
const random2 = randomBytes5(4).toString("hex");
|
|
5577
|
+
const filename = storageKey(file.name, mimeType, random2);
|
|
4757
5578
|
const stored = [];
|
|
4758
5579
|
try {
|
|
4759
5580
|
await this.storage.put(filename, file.data, { contentType: mimeType });
|
|
@@ -5123,6 +5944,7 @@ var EasyCMS = class {
|
|
|
5123
5944
|
operation: "update"
|
|
5124
5945
|
});
|
|
5125
5946
|
this.emit(config, "draft", draft);
|
|
5947
|
+
await this.audited(config, "draft", draft, current, guard, mode);
|
|
5126
5948
|
const [out2] = await this.output(config, [draft], guard, {
|
|
5127
5949
|
...options,
|
|
5128
5950
|
draft: true
|
|
@@ -5145,6 +5967,7 @@ var EasyCMS = class {
|
|
|
5145
5967
|
operation: "update"
|
|
5146
5968
|
});
|
|
5147
5969
|
this.emit(config, "update", doc, existing.status);
|
|
5970
|
+
await this.audited(config, "update", doc, current, guard, mode);
|
|
5148
5971
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
5149
5972
|
return out;
|
|
5150
5973
|
}
|
|
@@ -5175,6 +5998,7 @@ var EasyCMS = class {
|
|
|
5175
5998
|
await this.roles.transfer(parsed, heir.id);
|
|
5176
5999
|
} else await this.roles.transfer(parsed, null);
|
|
5177
6000
|
await this.auth.revokeSessions(parsed);
|
|
6001
|
+
await this.auth.sso.forget(parsed);
|
|
5178
6002
|
}
|
|
5179
6003
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
5180
6004
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
@@ -5188,6 +6012,7 @@ var EasyCMS = class {
|
|
|
5188
6012
|
doc: existing
|
|
5189
6013
|
});
|
|
5190
6014
|
this.emit(config, "delete", existing);
|
|
6015
|
+
await this.audited(config, "delete", existing, existing, guard);
|
|
5191
6016
|
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
5192
6017
|
return out;
|
|
5193
6018
|
}
|
|
@@ -5317,6 +6142,7 @@ var EasyCMS = class {
|
|
|
5317
6142
|
operation: "update"
|
|
5318
6143
|
});
|
|
5319
6144
|
this.emit(config, "draft", draft);
|
|
6145
|
+
await this.audited(config, "draft", draft, current, guard, mode);
|
|
5320
6146
|
return this.findGlobal(slug2, { ...options, draft: true });
|
|
5321
6147
|
}
|
|
5322
6148
|
const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
|
|
@@ -5328,6 +6154,7 @@ var EasyCMS = class {
|
|
|
5328
6154
|
operation: "update"
|
|
5329
6155
|
});
|
|
5330
6156
|
this.emit(config, "update", doc, existing.status);
|
|
6157
|
+
await this.audited(config, "update", doc, current, guard, mode);
|
|
5331
6158
|
return this.findGlobal(slug2, { ...options, draft: true });
|
|
5332
6159
|
}
|
|
5333
6160
|
/**
|
|
@@ -5429,16 +6256,19 @@ var EasyCMS = class {
|
|
|
5429
6256
|
* scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
|
|
5430
6257
|
*/
|
|
5431
6258
|
async runJobs(now = /* @__PURE__ */ new Date()) {
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
(
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
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
|
+
});
|
|
5442
6272
|
}
|
|
5443
6273
|
/**
|
|
5444
6274
|
* Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
|
|
@@ -5603,6 +6433,13 @@ var EasyCMS = class {
|
|
|
5603
6433
|
updatedAt: now
|
|
5604
6434
|
}
|
|
5605
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
|
+
});
|
|
5606
6443
|
return toJob(row);
|
|
5607
6444
|
}
|
|
5608
6445
|
/**
|
|
@@ -5662,6 +6499,12 @@ var EasyCMS = class {
|
|
|
5662
6499
|
const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
|
|
5663
6500
|
if (!job) throw new NotFoundError("scheduled job", jobId);
|
|
5664
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
|
+
});
|
|
5665
6508
|
}
|
|
5666
6509
|
/**
|
|
5667
6510
|
* @internal One attempt now for a saved webhook delivery or email (the admin's Retry): removed
|
|
@@ -5906,6 +6749,7 @@ var EasyCMS = class {
|
|
|
5906
6749
|
operation: "create"
|
|
5907
6750
|
});
|
|
5908
6751
|
this.emit(config, "create", doc);
|
|
6752
|
+
await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
|
|
5909
6753
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
5910
6754
|
return out;
|
|
5911
6755
|
}
|
|
@@ -5979,6 +6823,31 @@ var EasyCMS = class {
|
|
|
5979
6823
|
if (after !== "published" && before === "published")
|
|
5980
6824
|
this.webhooks.emit("unpublish", target, body);
|
|
5981
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
|
+
}
|
|
5982
6851
|
/** Records a version after a save, when the collection or global keeps versions. */
|
|
5983
6852
|
async saveVersion(config, parent, id, doc, guard) {
|
|
5984
6853
|
const max = versionLimit(config);
|
|
@@ -6321,9 +7190,9 @@ var PER_PAGE = 50;
|
|
|
6321
7190
|
var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
|
|
6322
7191
|
var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
|
|
6323
7192
|
var isDeliveryKind = (value) => value === "webhook" || value === "email";
|
|
6324
|
-
function parse(
|
|
7193
|
+
function parse(text2) {
|
|
6325
7194
|
try {
|
|
6326
|
-
const value = JSON.parse(String(
|
|
7195
|
+
const value = JSON.parse(String(text2 ?? ""));
|
|
6327
7196
|
return typeof value === "object" && value !== null ? value : {};
|
|
6328
7197
|
} catch {
|
|
6329
7198
|
return {};
|
|
@@ -6490,7 +7359,7 @@ async function sendTestEmail(cms, user, to, locale) {
|
|
|
6490
7359
|
const th = locale === "th" || locale !== "en" && cms.config.admin.locale === "th";
|
|
6491
7360
|
const time = (/* @__PURE__ */ new Date()).toISOString();
|
|
6492
7361
|
const subject = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E08\u0E32\u0E01 ${site}` : `Test email from ${site}`;
|
|
6493
|
-
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
|
|
6494
7363
|
|
|
6495
7364
|
\u0E2A\u0E48\u0E07\u0E42\u0E14\u0E22: ${user.email}
|
|
6496
7365
|
Adapter: ${adapter.name ?? "custom"}
|
|
@@ -6502,7 +7371,7 @@ Adapter: ${adapter.name ?? "custom"}
|
|
|
6502
7371
|
From: ${from}
|
|
6503
7372
|
Time: ${time}`;
|
|
6504
7373
|
try {
|
|
6505
|
-
await adapter.send({ to: address, subject, text, from });
|
|
7374
|
+
await adapter.send({ to: address, subject, text: text2, from });
|
|
6506
7375
|
return { ok: true };
|
|
6507
7376
|
} catch (error) {
|
|
6508
7377
|
return { ok: false, error: message(error) };
|
|
@@ -6678,7 +7547,9 @@ async function adminSchema(cms, user, origin) {
|
|
|
6678
7547
|
deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
|
|
6679
7548
|
backups: admin,
|
|
6680
7549
|
email: admin,
|
|
6681
|
-
roles: admin && cms.roles.enabled
|
|
7550
|
+
roles: admin && cms.roles.enabled,
|
|
7551
|
+
sso: admin && cms.auth.sso.enabled,
|
|
7552
|
+
audit: cms.audit.enabled && await cms.roles.canView(user, "audit")
|
|
6682
7553
|
};
|
|
6683
7554
|
const roles = await cms.roles.options();
|
|
6684
7555
|
return {
|
|
@@ -6694,6 +7565,7 @@ async function adminSchema(cms, user, origin) {
|
|
|
6694
7565
|
uploadFromURL: cms.config.upload.fromURL !== void 0,
|
|
6695
7566
|
views,
|
|
6696
7567
|
rbac: cms.roles.enabled,
|
|
7568
|
+
providers: cms.auth.sso.providers(),
|
|
6697
7569
|
...views.deliveries ? {
|
|
6698
7570
|
deliveries: {
|
|
6699
7571
|
webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
|
|
@@ -6739,16 +7611,16 @@ async function widgets(cms, user) {
|
|
|
6739
7611
|
}
|
|
6740
7612
|
|
|
6741
7613
|
// src/version.ts
|
|
6742
|
-
var VERSION = true ? "0.
|
|
7614
|
+
var VERSION = true ? "0.36.0" : "0.0.0-dev";
|
|
6743
7615
|
|
|
6744
7616
|
// src/rest/admin-status.ts
|
|
6745
|
-
var
|
|
7617
|
+
var DAY2 = 864e5;
|
|
6746
7618
|
var has = (cms, slug2) => cms.config.collections.some((c) => c.slug === slug2);
|
|
6747
7619
|
async function adminStatus(cms) {
|
|
6748
7620
|
const config = cms.config;
|
|
6749
7621
|
const attention = [];
|
|
6750
7622
|
const now = Date.now();
|
|
6751
|
-
const weekAgo = new Date(now - 7 *
|
|
7623
|
+
const weekAgo = new Date(now - 7 * DAY2).toISOString();
|
|
6752
7624
|
if (has(cms, WEBHOOK_DELIVERIES)) {
|
|
6753
7625
|
const failed = await cms.db.find({
|
|
6754
7626
|
collection: WEBHOOK_DELIVERIES,
|
|
@@ -6798,6 +7670,7 @@ async function adminStatus(cms) {
|
|
|
6798
7670
|
}
|
|
6799
7671
|
const backups = await backupAttention(cms);
|
|
6800
7672
|
if (backups) attention.push(backups);
|
|
7673
|
+
attention.push(...await cms.audit.attention());
|
|
6801
7674
|
if (!config.email) attention.push({ id: "no-email" });
|
|
6802
7675
|
if (!config.serverURL && process.env.NODE_ENV === "production")
|
|
6803
7676
|
attention.push({ id: "no-server-url" });
|
|
@@ -7007,7 +7880,15 @@ async function respond(cms, request, url, path, root, options, production) {
|
|
|
7007
7880
|
const ctx = { request, url, user, via: user ? via : null, token, headers };
|
|
7008
7881
|
const method = request.method.toUpperCase();
|
|
7009
7882
|
if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
|
|
7010
|
-
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
|
+
);
|
|
7011
7892
|
if (!result) throw new HttpError("Not found", 404);
|
|
7012
7893
|
if (result.body instanceof Response) return result.body;
|
|
7013
7894
|
return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
|
|
@@ -7089,6 +7970,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7089
7970
|
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
7090
7971
|
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
7091
7972
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
7973
|
+
if (first === "auth") return ssoRoute(cms, ctx, method, segments);
|
|
7092
7974
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
7093
7975
|
switch (`${method} ${second}`) {
|
|
7094
7976
|
case "POST login": {
|
|
@@ -7119,7 +8001,10 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7119
8001
|
body: {
|
|
7120
8002
|
hasUsers: await cms.auth.hasUsers(),
|
|
7121
8003
|
// The login page offers "Forgot password?" only when the email can be sent.
|
|
7122
|
-
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
|
|
8004
|
+
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
|
|
8005
|
+
// Buttons to sign in with outside accounts; `password: false`: only admins use one.
|
|
8006
|
+
providers: cms.auth.sso.providers(),
|
|
8007
|
+
password: cms.config.auth.password
|
|
7123
8008
|
}
|
|
7124
8009
|
};
|
|
7125
8010
|
// Forgot password: the same answer whether or not the email has an account.
|
|
@@ -7188,8 +8073,11 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7188
8073
|
if (extra !== void 0) throw new HttpError("Not found", 404);
|
|
7189
8074
|
if (id2 === void 0) {
|
|
7190
8075
|
if (method === "GET") return { body: await listBackups(cms) };
|
|
7191
|
-
if (method === "POST")
|
|
7192
|
-
|
|
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
|
+
}
|
|
7193
8081
|
throw methodNotAllowed(ctx, "GET, POST");
|
|
7194
8082
|
}
|
|
7195
8083
|
const parsed = parseId(id2);
|
|
@@ -7197,6 +8085,12 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7197
8085
|
if (action === "download") {
|
|
7198
8086
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7199
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
|
+
});
|
|
7200
8094
|
return {
|
|
7201
8095
|
body: new Response(file.body, {
|
|
7202
8096
|
headers: {
|
|
@@ -7211,6 +8105,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7211
8105
|
if (action !== void 0) throw new HttpError("Not found", 404);
|
|
7212
8106
|
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
7213
8107
|
await deleteBackup(cms, parsed);
|
|
8108
|
+
await cms.audit.record({ action: "backup.delete", target: "backups", doc: parsed });
|
|
7214
8109
|
return { body: { deleted: 1 } };
|
|
7215
8110
|
}
|
|
7216
8111
|
if (first === "admin" && second === "email") {
|
|
@@ -7226,10 +8121,53 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7226
8121
|
if (action === "verify") return { body: await verifyEmail(cms) };
|
|
7227
8122
|
if (action === "test") {
|
|
7228
8123
|
const body = await readJson(ctx.request);
|
|
7229
|
-
|
|
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 };
|
|
7230
8127
|
}
|
|
7231
8128
|
throw new HttpError("Not found", 404);
|
|
7232
8129
|
}
|
|
8130
|
+
if (first === "admin" && second === "sso" && third === void 0) {
|
|
8131
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
8132
|
+
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
8133
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8134
|
+
return { body: cms.auth.sso.settings(ctx.url.origin) };
|
|
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
|
+
}
|
|
7233
8171
|
if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
|
|
7234
8172
|
if (!ctx.user) throw new UnauthorizedError();
|
|
7235
8173
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
@@ -7280,21 +8218,46 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7280
8218
|
if (!isDeliveryKind(kind) || extra !== void 0) throw new HttpError("Not found", 404);
|
|
7281
8219
|
if (id2 === "retry" && action === void 0) {
|
|
7282
8220
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
7283
|
-
|
|
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 };
|
|
7284
8228
|
}
|
|
7285
8229
|
if (id2 === void 0) {
|
|
7286
8230
|
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
7287
|
-
|
|
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 };
|
|
7288
8238
|
}
|
|
7289
8239
|
const parsed = parseId(id2);
|
|
7290
8240
|
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
7291
8241
|
if (action === "retry") {
|
|
7292
8242
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
7293
|
-
|
|
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 };
|
|
7294
8251
|
}
|
|
7295
8252
|
if (action !== void 0) throw new HttpError("Not found", 404);
|
|
7296
8253
|
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
7297
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
|
+
});
|
|
7298
8261
|
return { body: { deleted: 1 } };
|
|
7299
8262
|
}
|
|
7300
8263
|
if (first === "admin") {
|
|
@@ -7680,11 +8643,11 @@ async function readJson(request) {
|
|
|
7680
8643
|
}
|
|
7681
8644
|
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
7682
8645
|
if (declared > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
|
|
7683
|
-
const
|
|
7684
|
-
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);
|
|
7685
8648
|
let body;
|
|
7686
8649
|
try {
|
|
7687
|
-
body = JSON.parse(
|
|
8650
|
+
body = JSON.parse(text2);
|
|
7688
8651
|
} catch {
|
|
7689
8652
|
throw new HttpError("Request body is not valid JSON", 400);
|
|
7690
8653
|
}
|
|
@@ -7703,6 +8666,67 @@ function cookie(ctx, name, value, opts) {
|
|
|
7703
8666
|
if (opts.maxAge !== void 0) parts.push(`Max-Age=${opts.maxAge}`);
|
|
7704
8667
|
return parts.join("; ");
|
|
7705
8668
|
}
|
|
8669
|
+
async function ssoRoute(cms, ctx, method, segments) {
|
|
8670
|
+
const sso = cms.auth.sso;
|
|
8671
|
+
if (!sso.enabled) throw new HttpError("Not found", 404);
|
|
8672
|
+
const [, second, third, extra] = segments;
|
|
8673
|
+
if (extra !== void 0 || second === void 0) throw new HttpError("Not found", 404);
|
|
8674
|
+
if (second === "identities") {
|
|
8675
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
8676
|
+
if (third === void 0) {
|
|
8677
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8678
|
+
const asked = ctx.url.searchParams.get("user");
|
|
8679
|
+
const target = asked === null ? ctx.user.id : parseId(asked) ?? asked;
|
|
8680
|
+
if (String(target) !== String(ctx.user.id) && (ctx.user.role !== "admin" || ctx.user.apiKey))
|
|
8681
|
+
throw new ForbiddenError();
|
|
8682
|
+
return { body: await sso.identities(target) };
|
|
8683
|
+
}
|
|
8684
|
+
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
8685
|
+
const id = parseId(third);
|
|
8686
|
+
if (id === void 0) throw new HttpError("Not found", 404);
|
|
8687
|
+
await sso.unlink(ctx.user, id);
|
|
8688
|
+
return { body: { deleted: 1 } };
|
|
8689
|
+
}
|
|
8690
|
+
const pendingCookie = (value, maxAge) => cookie(ctx, SSO_COOKIE, value, { httpOnly: true, maxAge });
|
|
8691
|
+
if (third === "login") {
|
|
8692
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8693
|
+
const started = await sso.start(second, {
|
|
8694
|
+
origin: ctx.url.origin,
|
|
8695
|
+
redirect: ctx.url.searchParams.get("redirect")
|
|
8696
|
+
});
|
|
8697
|
+
ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
|
|
8698
|
+
return { body: redirectResponse(ctx, started.url) };
|
|
8699
|
+
}
|
|
8700
|
+
if (third === "link") {
|
|
8701
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
8702
|
+
if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
|
|
8703
|
+
const started = await sso.start(second, {
|
|
8704
|
+
origin: ctx.url.origin,
|
|
8705
|
+
redirect: null,
|
|
8706
|
+
user: ctx.user
|
|
8707
|
+
});
|
|
8708
|
+
ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
|
|
8709
|
+
return { body: { url: started.url } };
|
|
8710
|
+
}
|
|
8711
|
+
if (third === "callback") {
|
|
8712
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8713
|
+
const finished = await sso.finish(second, {
|
|
8714
|
+
url: ctx.url,
|
|
8715
|
+
cookie: readCookie(ctx.request, SSO_COOKIE),
|
|
8716
|
+
origin: ctx.url.origin,
|
|
8717
|
+
user: ctx.user
|
|
8718
|
+
});
|
|
8719
|
+
ctx.headers.append("set-cookie", pendingCookie("", 0));
|
|
8720
|
+
if (finished.session) setSessionCookies(cms, ctx, finished.session);
|
|
8721
|
+
return { body: redirectResponse(ctx, finished.redirect) };
|
|
8722
|
+
}
|
|
8723
|
+
throw new HttpError("Not found", 404);
|
|
8724
|
+
}
|
|
8725
|
+
function redirectResponse(ctx, location) {
|
|
8726
|
+
const headers = new Headers({ location, "cache-control": "no-store" });
|
|
8727
|
+
for (const value of ctx.headers.getSetCookie()) headers.append("set-cookie", value);
|
|
8728
|
+
return new Response(null, { status: 302, headers });
|
|
8729
|
+
}
|
|
7706
8730
|
function setSessionCookies(cms, ctx, session) {
|
|
7707
8731
|
const maxAge = cms.config.auth.tokenExpiration;
|
|
7708
8732
|
ctx.headers.append(
|
|
@@ -7902,6 +8926,7 @@ export {
|
|
|
7902
8926
|
API_KEYS,
|
|
7903
8927
|
API_KEY_GLOBAL_OPERATIONS,
|
|
7904
8928
|
API_KEY_OPERATIONS,
|
|
8929
|
+
AuditLog,
|
|
7905
8930
|
Auth,
|
|
7906
8931
|
BUILTIN_COLLECTIONS,
|
|
7907
8932
|
CONFIG_FILE_NAMES,
|
|
@@ -7932,6 +8957,7 @@ export {
|
|
|
7932
8957
|
ROLE_OPERATIONS,
|
|
7933
8958
|
Roles,
|
|
7934
8959
|
SESSION_COOKIE,
|
|
8960
|
+
SSO_COOKIE,
|
|
7935
8961
|
SchemaError,
|
|
7936
8962
|
TooManyRequestsError,
|
|
7937
8963
|
UnauthorizedError,
|