@easy-cms/core 1.10.0 → 1.12.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/{chunk-54UFTH6S.js → chunk-244WCY6S.js} +593 -91
- package/dist/{config-DL4beesW.d.ts → config-CHEEPk1d.d.ts} +93 -3
- package/dist/{handler--JrVIdYW.d.ts → handler-adKUq8-U.d.ts} +1 -1
- package/dist/index.d.ts +41 -7
- package/dist/index.js +3 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +1 -1
- package/dist/{plugin-BXHlV5IZ.d.ts → plugin-B8xypoWd.d.ts} +1 -1
- package/dist/plugin.d.ts +2 -2
- package/package.json +1 -1
|
@@ -59,6 +59,7 @@ var JOB_RUNS = "job-runs";
|
|
|
59
59
|
var ROLES = "user-roles";
|
|
60
60
|
var USER_IDENTITIES = "user-identities";
|
|
61
61
|
var AUDIT_LOGS = "audit-logs";
|
|
62
|
+
var OUTBOX = "outbox";
|
|
62
63
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
63
64
|
SESSIONS,
|
|
64
65
|
LOGIN_ATTEMPTS,
|
|
@@ -70,7 +71,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
70
71
|
JOB_RUNS,
|
|
71
72
|
ROLES,
|
|
72
73
|
USER_IDENTITIES,
|
|
73
|
-
AUDIT_LOGS
|
|
74
|
+
AUDIT_LOGS,
|
|
75
|
+
OUTBOX
|
|
74
76
|
]);
|
|
75
77
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
76
78
|
var CREATED_BY_FIELD = {
|
|
@@ -519,6 +521,31 @@ var jobRunsCollection = {
|
|
|
519
521
|
{ name: "changedAt", type: "text" }
|
|
520
522
|
]
|
|
521
523
|
};
|
|
524
|
+
var outboxCollection = {
|
|
525
|
+
slug: OUTBOX,
|
|
526
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
527
|
+
fields: [
|
|
528
|
+
// create | update | draft | delete, as the webhook event.
|
|
529
|
+
{ name: "event", type: "text", required: true },
|
|
530
|
+
// The collection's or the global's slug.
|
|
531
|
+
{ name: "target", type: "text", required: true },
|
|
532
|
+
{ name: "global", type: "boolean" },
|
|
533
|
+
{ name: "docId", type: "text" },
|
|
534
|
+
// The stored status before the save, for publish and unpublish events.
|
|
535
|
+
{ name: "before", type: "text" },
|
|
536
|
+
// JSON: the document as saved (as deleted), the one before, and the user who saved.
|
|
537
|
+
{ name: "doc", type: "textarea", required: true },
|
|
538
|
+
{ name: "previousDoc", type: "textarea" },
|
|
539
|
+
{ name: "user", type: "textarea" },
|
|
540
|
+
// Steps already done (hooks, webhooks, purge), so a retry runs only the rest.
|
|
541
|
+
{ name: "done", type: "text" },
|
|
542
|
+
{ name: "attempts", type: "number", required: true },
|
|
543
|
+
{ name: "nextAttemptAt", type: "text", required: true, index: true },
|
|
544
|
+
// pending | running | failed
|
|
545
|
+
{ name: "state", type: "text", required: true, index: true },
|
|
546
|
+
{ name: "error", type: "text" }
|
|
547
|
+
]
|
|
548
|
+
};
|
|
522
549
|
var emailDeliveriesCollection = {
|
|
523
550
|
slug: EMAIL_DELIVERIES,
|
|
524
551
|
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
@@ -572,8 +599,8 @@ function cut(value) {
|
|
|
572
599
|
if (typeof value === "string")
|
|
573
600
|
return value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE)}\u2026` : value;
|
|
574
601
|
if (value !== null && typeof value === "object") {
|
|
575
|
-
const
|
|
576
|
-
return
|
|
602
|
+
const json2 = JSON.stringify(value);
|
|
603
|
+
return json2.length > MAX_VALUE ? "\u2026" : value;
|
|
577
604
|
}
|
|
578
605
|
return value;
|
|
579
606
|
}
|
|
@@ -3797,7 +3824,8 @@ var VersionStore = class {
|
|
|
3797
3824
|
snapshot: args.snapshot,
|
|
3798
3825
|
createdAt: now,
|
|
3799
3826
|
updatedAt: now
|
|
3800
|
-
}
|
|
3827
|
+
},
|
|
3828
|
+
outbox: args.outbox
|
|
3801
3829
|
});
|
|
3802
3830
|
await this.prune(parent, doc, args.max);
|
|
3803
3831
|
return toSummary(created);
|
|
@@ -3874,7 +3902,7 @@ var VersionStore = class {
|
|
|
3874
3902
|
};
|
|
3875
3903
|
|
|
3876
3904
|
// src/local-api.ts
|
|
3877
|
-
import { createHash as
|
|
3905
|
+
import { createHash as createHash5, createHmac as createHmac6, randomBytes as randomBytes6, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
3878
3906
|
|
|
3879
3907
|
// src/auth/common-passwords.ts
|
|
3880
3908
|
var LIST = `
|
|
@@ -4187,6 +4215,189 @@ function localizeSort(fields, sort, locale, locales = []) {
|
|
|
4187
4215
|
);
|
|
4188
4216
|
}
|
|
4189
4217
|
|
|
4218
|
+
// src/outbox.ts
|
|
4219
|
+
var DELAYS = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 12 * 36e5];
|
|
4220
|
+
var LEASE = 5 * 6e4;
|
|
4221
|
+
var BATCH2 = 50;
|
|
4222
|
+
var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
|
|
4223
|
+
var json = (value) => value === void 0 ? null : JSON.stringify(value);
|
|
4224
|
+
var parse2 = (value) => typeof value === "string" ? JSON.parse(value) : void 0;
|
|
4225
|
+
function withoutHash(value) {
|
|
4226
|
+
if (!value || !("passwordHash" in value)) return value;
|
|
4227
|
+
const { passwordHash: _, ...rest } = value;
|
|
4228
|
+
return rest;
|
|
4229
|
+
}
|
|
4230
|
+
var Outbox = class {
|
|
4231
|
+
constructor(db, logger, runner) {
|
|
4232
|
+
this.db = db;
|
|
4233
|
+
this.logger = logger;
|
|
4234
|
+
this.runner = runner;
|
|
4235
|
+
}
|
|
4236
|
+
db;
|
|
4237
|
+
logger;
|
|
4238
|
+
runner;
|
|
4239
|
+
draining;
|
|
4240
|
+
again = false;
|
|
4241
|
+
/** The row a save writes. For a create, `doc` has no id yet: it gets the written one. */
|
|
4242
|
+
row(entry) {
|
|
4243
|
+
return {
|
|
4244
|
+
collection: OUTBOX,
|
|
4245
|
+
data: (id) => {
|
|
4246
|
+
const docId = entry.global ? null : String(entry.docId ?? id);
|
|
4247
|
+
const doc = entry.event === "create" ? { ...entry.doc, id } : entry.doc;
|
|
4248
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4249
|
+
return {
|
|
4250
|
+
event: entry.event,
|
|
4251
|
+
target: entry.target,
|
|
4252
|
+
global: entry.global,
|
|
4253
|
+
docId,
|
|
4254
|
+
before: entry.before,
|
|
4255
|
+
doc: JSON.stringify(withoutHash(doc)),
|
|
4256
|
+
previousDoc: json(withoutHash(entry.previousDoc)),
|
|
4257
|
+
user: json(withoutHash(entry.user)),
|
|
4258
|
+
done: null,
|
|
4259
|
+
attempts: 0,
|
|
4260
|
+
nextAttemptAt: now,
|
|
4261
|
+
state: "pending",
|
|
4262
|
+
error: null,
|
|
4263
|
+
createdAt: now,
|
|
4264
|
+
updatedAt: now
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
};
|
|
4268
|
+
}
|
|
4269
|
+
/** Runs what is due in the background, e.g. right after a save commits. */
|
|
4270
|
+
kick() {
|
|
4271
|
+
if (this.draining) {
|
|
4272
|
+
this.again = true;
|
|
4273
|
+
return;
|
|
4274
|
+
}
|
|
4275
|
+
this.draining = (async () => {
|
|
4276
|
+
do {
|
|
4277
|
+
this.again = false;
|
|
4278
|
+
await this.run();
|
|
4279
|
+
} while (this.again);
|
|
4280
|
+
})().catch((error) => this.logger.error(`Outbox: ${error.message}`, { error })).finally(() => {
|
|
4281
|
+
this.draining = void 0;
|
|
4282
|
+
});
|
|
4283
|
+
}
|
|
4284
|
+
/** Waits until the background run started by `kick` is over. */
|
|
4285
|
+
async flush() {
|
|
4286
|
+
while (this.draining) await this.draining;
|
|
4287
|
+
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Runs entries that are due: new ones, retries whose time came, and those a stopped worker
|
|
4290
|
+
* left claimed. Runs with scheduled jobs too, which catches what `kick` missed.
|
|
4291
|
+
*/
|
|
4292
|
+
async run(now = /* @__PURE__ */ new Date()) {
|
|
4293
|
+
let ran = 0;
|
|
4294
|
+
let failed = 0;
|
|
4295
|
+
for (; ; ) {
|
|
4296
|
+
const { docs } = await this.db.find({
|
|
4297
|
+
collection: OUTBOX,
|
|
4298
|
+
where: {
|
|
4299
|
+
and: [
|
|
4300
|
+
{ state: { in: ["pending", "running"] } },
|
|
4301
|
+
{ nextAttemptAt: { lte: now.toISOString() } }
|
|
4302
|
+
]
|
|
4303
|
+
},
|
|
4304
|
+
sort: ["nextAttemptAt", "id"],
|
|
4305
|
+
limit: BATCH2,
|
|
4306
|
+
page: 1
|
|
4307
|
+
});
|
|
4308
|
+
let claimed = 0;
|
|
4309
|
+
for (const row of docs) {
|
|
4310
|
+
const result = await this.runRow(row, now);
|
|
4311
|
+
if (result === void 0) continue;
|
|
4312
|
+
claimed++;
|
|
4313
|
+
if (result) ran++;
|
|
4314
|
+
else failed++;
|
|
4315
|
+
}
|
|
4316
|
+
if (docs.length < BATCH2 || claimed === 0) return { ran, failed };
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
/** Puts a failed entry back in line, e.g. from the admin. */
|
|
4320
|
+
async retry(id) {
|
|
4321
|
+
const row = await this.db.findById({ collection: OUTBOX, id });
|
|
4322
|
+
if (!row || row.state !== "failed") return false;
|
|
4323
|
+
const { id: _, ...data } = row;
|
|
4324
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4325
|
+
const updated = await this.db.update({
|
|
4326
|
+
collection: OUTBOX,
|
|
4327
|
+
id,
|
|
4328
|
+
data: { ...data, state: "pending", attempts: 0, nextAttemptAt: now, updatedAt: now },
|
|
4329
|
+
where: { state: { equals: "failed" } }
|
|
4330
|
+
});
|
|
4331
|
+
if (updated) this.kick();
|
|
4332
|
+
return updated !== null;
|
|
4333
|
+
}
|
|
4334
|
+
/** `true` when it ran, `false` when it failed, `undefined` when another worker has it. */
|
|
4335
|
+
async runRow(row, now) {
|
|
4336
|
+
const { id, ...data } = row;
|
|
4337
|
+
const attempts = Number(data.attempts ?? 0) + 1;
|
|
4338
|
+
let current = {
|
|
4339
|
+
...data,
|
|
4340
|
+
state: "running",
|
|
4341
|
+
attempts,
|
|
4342
|
+
nextAttemptAt: later2(now, LEASE),
|
|
4343
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4344
|
+
};
|
|
4345
|
+
const claimed = await this.db.update({
|
|
4346
|
+
collection: OUTBOX,
|
|
4347
|
+
id,
|
|
4348
|
+
data: current,
|
|
4349
|
+
where: {
|
|
4350
|
+
and: [{ state: { equals: data.state } }, { nextAttemptAt: { equals: data.nextAttemptAt } }]
|
|
4351
|
+
}
|
|
4352
|
+
});
|
|
4353
|
+
if (!claimed) return void 0;
|
|
4354
|
+
const done = new Set(
|
|
4355
|
+
String(data.done ?? "").split(",").filter(Boolean)
|
|
4356
|
+
);
|
|
4357
|
+
const mark = async (step) => {
|
|
4358
|
+
done.add(step);
|
|
4359
|
+
current = { ...current, done: [...done].join(","), updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4360
|
+
await this.db.update({ collection: OUTBOX, id, data: current });
|
|
4361
|
+
};
|
|
4362
|
+
try {
|
|
4363
|
+
await this.runner(entryOf(data), done, mark);
|
|
4364
|
+
await this.db.delete({ collection: OUTBOX, id });
|
|
4365
|
+
return true;
|
|
4366
|
+
} catch (error) {
|
|
4367
|
+
const message2 = error instanceof Error ? error.message : String(error);
|
|
4368
|
+
const delay = DELAYS[attempts - 1];
|
|
4369
|
+
await this.db.update({
|
|
4370
|
+
collection: OUTBOX,
|
|
4371
|
+
id,
|
|
4372
|
+
data: {
|
|
4373
|
+
...current,
|
|
4374
|
+
state: delay === void 0 ? "failed" : "pending",
|
|
4375
|
+
nextAttemptAt: later2(now, delay ?? 0),
|
|
4376
|
+
error: message2,
|
|
4377
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4378
|
+
}
|
|
4379
|
+
});
|
|
4380
|
+
const what = `${data.event} of ${data.global ? "global " : ""}"${data.target}"`;
|
|
4381
|
+
if (delay === void 0)
|
|
4382
|
+
this.logger.error(`Outbox gave up on ${what} after ${attempts} attempts: ${message2}`);
|
|
4383
|
+
else this.logger.warn(`Outbox: ${what} failed (${message2}); will retry`);
|
|
4384
|
+
return false;
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
};
|
|
4388
|
+
function entryOf(row) {
|
|
4389
|
+
return {
|
|
4390
|
+
event: row.event,
|
|
4391
|
+
target: String(row.target),
|
|
4392
|
+
global: row.global === true,
|
|
4393
|
+
docId: typeof row.docId === "string" ? row.docId : null,
|
|
4394
|
+
before: typeof row.before === "string" ? row.before : null,
|
|
4395
|
+
doc: parse2(row.doc) ?? {},
|
|
4396
|
+
previousDoc: parse2(row.previousDoc),
|
|
4397
|
+
user: parse2(row.user) ?? null
|
|
4398
|
+
};
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4190
4401
|
// src/remote-file.ts
|
|
4191
4402
|
import { lookup as dnsLookup } from "dns";
|
|
4192
4403
|
import { request as httpRequest } from "http";
|
|
@@ -4426,7 +4637,7 @@ function read(response, maxBytes, deadline) {
|
|
|
4426
4637
|
var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
|
|
4427
4638
|
var CONFIG = keys(`secret db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
|
|
4428
4639
|
upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
|
|
4429
|
-
audit logs countCacheSeconds maxPage plugins`);
|
|
4640
|
+
audit logs countCacheSeconds maxPage httpCache outbox plugins`);
|
|
4430
4641
|
var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
|
|
4431
4642
|
var AUTH = keys(`roles rbac tokenExpiration maxLoginAttempts lockWindow trustedOrigins
|
|
4432
4643
|
resetPasswordExpiration inviteExpiration providers providerSignUp password setupCode members emails`);
|
|
@@ -4436,8 +4647,10 @@ var BACKUPS = keys("frequency at keep dir storage sqlite encryptionKey");
|
|
|
4436
4647
|
var AUDIT = keys("keepDays values failedLogins scope");
|
|
4437
4648
|
var LOGS = keys("format requests");
|
|
4438
4649
|
var COLLECTION = keys(`slug labels fields useAsTitle drafts versions schedule preview access hooks
|
|
4439
|
-
admin`);
|
|
4440
|
-
var GLOBAL = keys(
|
|
4650
|
+
admin httpCache`);
|
|
4651
|
+
var GLOBAL = keys(
|
|
4652
|
+
"slug label fields drafts versions schedule preview access hooks scope admin httpCache"
|
|
4653
|
+
);
|
|
4441
4654
|
var CONTAINER_ADMIN = keys("icon order sidebar layout group");
|
|
4442
4655
|
var COLLECTION_ADMIN = keys(
|
|
4443
4656
|
"icon order sidebar layout group editIn badge count empty list ownerField confirmDelete"
|
|
@@ -4692,6 +4905,23 @@ var Webhooks = class {
|
|
|
4692
4905
|
this.track(this.deliver(hook, payload));
|
|
4693
4906
|
}
|
|
4694
4907
|
}
|
|
4908
|
+
/**
|
|
4909
|
+
* `emit` for the outbox: resolves once each delivery is saved in the queue (sent, without a
|
|
4910
|
+
* queue), so the event can count as done. Throws when saving fails; nothing is sent then.
|
|
4911
|
+
*/
|
|
4912
|
+
async emitSaved(event, target, doc) {
|
|
4913
|
+
const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4914
|
+
for (const hook of this.hooks) {
|
|
4915
|
+
if (!matches(hook, payload)) continue;
|
|
4916
|
+
if (!this.queue) {
|
|
4917
|
+
await this.deliver(hook, payload);
|
|
4918
|
+
continue;
|
|
4919
|
+
}
|
|
4920
|
+
await new Promise(
|
|
4921
|
+
(saved, failed) => this.track(this.deliver(hook, payload, { saved: () => saved(), failed }))
|
|
4922
|
+
);
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4695
4925
|
/** Waits for deliveries in progress, e.g. before a serverless function returns. */
|
|
4696
4926
|
async flush() {
|
|
4697
4927
|
while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
|
|
@@ -4715,7 +4945,7 @@ var Webhooks = class {
|
|
|
4715
4945
|
const claimed = await this.queue.claim(id, queued, {
|
|
4716
4946
|
...queued,
|
|
4717
4947
|
attempts,
|
|
4718
|
-
nextAttemptAt:
|
|
4948
|
+
nextAttemptAt: later3(now, CLAIM2)
|
|
4719
4949
|
});
|
|
4720
4950
|
if (!claimed) continue;
|
|
4721
4951
|
const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
|
|
@@ -4729,7 +4959,7 @@ var Webhooks = class {
|
|
|
4729
4959
|
await this.queue.update(id, {
|
|
4730
4960
|
...queued,
|
|
4731
4961
|
attempts,
|
|
4732
|
-
nextAttemptAt:
|
|
4962
|
+
nextAttemptAt: later3(now, delay ?? 0),
|
|
4733
4963
|
state: delay === void 0 ? "failed" : "pending",
|
|
4734
4964
|
error: result.error
|
|
4735
4965
|
});
|
|
@@ -4752,7 +4982,7 @@ var Webhooks = class {
|
|
|
4752
4982
|
).finally(() => this.pending.delete(tracked));
|
|
4753
4983
|
this.pending.add(tracked);
|
|
4754
4984
|
}
|
|
4755
|
-
async deliver(hook, payload) {
|
|
4985
|
+
async deliver(hook, payload, saving) {
|
|
4756
4986
|
const delivery = randomUUID2();
|
|
4757
4987
|
const body = JSON.stringify(payload);
|
|
4758
4988
|
const { event } = payload;
|
|
@@ -4767,13 +4997,15 @@ var Webhooks = class {
|
|
|
4767
4997
|
error
|
|
4768
4998
|
});
|
|
4769
4999
|
const { queue } = this;
|
|
4770
|
-
const saved = queue ? this.saving.then(() => queue.add(entry(0,
|
|
5000
|
+
const saved = queue ? this.saving.then(() => queue.add(entry(0, later3(/* @__PURE__ */ new Date(), CLAIM2), null))) : Promise.resolve(void 0);
|
|
4771
5001
|
this.saving = saved.catch(() => {
|
|
4772
5002
|
});
|
|
4773
5003
|
let queued;
|
|
4774
5004
|
try {
|
|
4775
5005
|
queued = await saved;
|
|
5006
|
+
saving?.saved();
|
|
4776
5007
|
} catch (error) {
|
|
5008
|
+
if (saving) return saving.failed(error);
|
|
4777
5009
|
this.logger.error(`Could not save webhook delivery: ${error.message}`, { error });
|
|
4778
5010
|
}
|
|
4779
5011
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -4786,7 +5018,7 @@ var Webhooks = class {
|
|
|
4786
5018
|
if (delay === void 0) {
|
|
4787
5019
|
if (queued === void 0 || !this.queue)
|
|
4788
5020
|
return this.giveUp(hook, event, result.error, attempt + 1);
|
|
4789
|
-
const next =
|
|
5021
|
+
const next = later3(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
|
|
4790
5022
|
await this.queue.update(queued, entry(attempt + 1, next, result.error));
|
|
4791
5023
|
this.logger.warn(
|
|
4792
5024
|
`Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
|
|
@@ -4831,7 +5063,7 @@ var Webhooks = class {
|
|
|
4831
5063
|
);
|
|
4832
5064
|
}
|
|
4833
5065
|
};
|
|
4834
|
-
var
|
|
5066
|
+
var later3 = (from, ms) => new Date(from.getTime() + ms).toISOString();
|
|
4835
5067
|
var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
|
|
4836
5068
|
function matches(hook, payload) {
|
|
4837
5069
|
if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
|
|
@@ -4859,6 +5091,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
4859
5091
|
"audit-logs",
|
|
4860
5092
|
"api-keys",
|
|
4861
5093
|
"jobs",
|
|
5094
|
+
"outbox",
|
|
4862
5095
|
"auth",
|
|
4863
5096
|
"migrations",
|
|
4864
5097
|
"access",
|
|
@@ -4890,7 +5123,9 @@ function validateConfig(config) {
|
|
|
4890
5123
|
validateAudit(config, add);
|
|
4891
5124
|
validateLogs(config, add);
|
|
4892
5125
|
validateCountCache(config, add);
|
|
5126
|
+
validateOutbox(config, add);
|
|
4893
5127
|
validateMaxPage(config, add);
|
|
5128
|
+
validateHttpCache(config, add);
|
|
4894
5129
|
validateAuth(config, add);
|
|
4895
5130
|
validateCors(config.cors, add);
|
|
4896
5131
|
validateLocalization(config, add);
|
|
@@ -5513,11 +5748,35 @@ function validateUpload(config, add) {
|
|
|
5513
5748
|
var FOLDER_KEY = /^[a-z0-9][a-z0-9_-]{0,59}$/;
|
|
5514
5749
|
var MIME_PATTERN = /^([\w.+-]+\/(\*|[\w.+-]+)|documents|office|archives)$/i;
|
|
5515
5750
|
var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
|
|
5751
|
+
function validateHttpCache(config, add) {
|
|
5752
|
+
const times = (value, path, purge) => {
|
|
5753
|
+
if (value === void 0) return;
|
|
5754
|
+
if (value === false && !purge) return;
|
|
5755
|
+
if (typeof value !== "object" || value === null) {
|
|
5756
|
+
add(path, purge ? "must be an object" : "must be false or an object");
|
|
5757
|
+
return;
|
|
5758
|
+
}
|
|
5759
|
+
const v = value;
|
|
5760
|
+
for (const key of ["sMaxAge", "staleWhileRevalidate"])
|
|
5761
|
+
if (v[key] !== void 0 && !(Number.isInteger(v[key]) && v[key] >= 0))
|
|
5762
|
+
add(`${path}.${key}`, "must be a whole number of seconds, 0 or more");
|
|
5763
|
+
if (purge && v.purge !== void 0 && typeof v.purge !== "function")
|
|
5764
|
+
add(`${path}.purge`, "must be a function");
|
|
5765
|
+
};
|
|
5766
|
+
times(config.httpCache, "httpCache", true);
|
|
5767
|
+
for (const c of config.collections ?? [])
|
|
5768
|
+
times(c?.httpCache, `collections.${c?.slug}.httpCache`, false);
|
|
5769
|
+
for (const g of config.globals ?? []) times(g?.httpCache, `globals.${g?.slug}.httpCache`, false);
|
|
5770
|
+
}
|
|
5516
5771
|
function validateMaxPage(config, add) {
|
|
5517
5772
|
const max = config.maxPage;
|
|
5518
5773
|
if (max !== void 0 && !(Number.isInteger(max) && max >= 1))
|
|
5519
5774
|
add("maxPage", "must be a whole number, 1 or more");
|
|
5520
5775
|
}
|
|
5776
|
+
function validateOutbox(config, add) {
|
|
5777
|
+
if (config.outbox !== void 0 && typeof config.outbox !== "boolean")
|
|
5778
|
+
add("outbox", "must be true or false");
|
|
5779
|
+
}
|
|
5521
5780
|
function validateCountCache(config, add) {
|
|
5522
5781
|
const seconds = config.countCacheSeconds;
|
|
5523
5782
|
if (seconds !== void 0 && !(typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0))
|
|
@@ -6379,6 +6638,7 @@ async function resolveConfig(input) {
|
|
|
6379
6638
|
...config.apiKeys ? [apiKeysCollection] : [],
|
|
6380
6639
|
...config.email ? [emailDeliveriesCollection] : [],
|
|
6381
6640
|
...(config.jobs?.length ?? 0) > 0 ? [jobRunsCollection] : [],
|
|
6641
|
+
...config.outbox ? [outboxCollection] : [],
|
|
6382
6642
|
databaseBackupsCollection,
|
|
6383
6643
|
...config.auth?.rbac ? [rolesCollection] : [],
|
|
6384
6644
|
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
|
|
@@ -6413,6 +6673,41 @@ function resolveAudit(audit) {
|
|
|
6413
6673
|
};
|
|
6414
6674
|
}
|
|
6415
6675
|
|
|
6676
|
+
// src/rest/http-cache.ts
|
|
6677
|
+
import { createHash as createHash4 } from "crypto";
|
|
6678
|
+
var cacheTags = {
|
|
6679
|
+
collection: (slug2) => ["ecms", `ecms:${slug2}`],
|
|
6680
|
+
document: (slug2, id) => ["ecms", `ecms:${slug2}`, `ecms:${slug2}:${id}`],
|
|
6681
|
+
global: (slug2) => ["ecms", `ecms:global:${slug2}`]
|
|
6682
|
+
};
|
|
6683
|
+
function cacheTimes(config, container) {
|
|
6684
|
+
const own = container.httpCache;
|
|
6685
|
+
if (own === false || own === void 0 && config.httpCache === void 0) return null;
|
|
6686
|
+
return {
|
|
6687
|
+
sMaxAge: own?.sMaxAge ?? config.httpCache?.sMaxAge ?? 60,
|
|
6688
|
+
staleWhileRevalidate: own?.staleWhileRevalidate ?? config.httpCache?.staleWhileRevalidate ?? 300
|
|
6689
|
+
};
|
|
6690
|
+
}
|
|
6691
|
+
function matches2(header, etag) {
|
|
6692
|
+
if (!header) return false;
|
|
6693
|
+
if (header.trim() === "*") return true;
|
|
6694
|
+
const bare = (tag) => tag.trim().replace(/^W\//, "");
|
|
6695
|
+
return header.split(",").some((tag) => bare(tag) === bare(etag));
|
|
6696
|
+
}
|
|
6697
|
+
function cacheableResponse(request, body, status, headers, times, tags) {
|
|
6698
|
+
headers.set(
|
|
6699
|
+
"cache-control",
|
|
6700
|
+
`public, max-age=0, s-maxage=${times.sMaxAge}, stale-while-revalidate=${times.staleWhileRevalidate}`
|
|
6701
|
+
);
|
|
6702
|
+
headers.set("cache-tag", tags.join(","));
|
|
6703
|
+
headers.set("surrogate-key", tags.join(" "));
|
|
6704
|
+
const etag = `W/"${createHash4("sha1").update(body).digest("base64url")}"`;
|
|
6705
|
+
headers.set("etag", etag);
|
|
6706
|
+
if (matches2(request.headers.get("if-none-match"), etag))
|
|
6707
|
+
return new Response(null, { status: 304, headers });
|
|
6708
|
+
return new Response(request.method.toUpperCase() === "HEAD" ? null : body, { status, headers });
|
|
6709
|
+
}
|
|
6710
|
+
|
|
6416
6711
|
// src/local-api.ts
|
|
6417
6712
|
async function createEasyCMS(config, options = {}) {
|
|
6418
6713
|
const resolved3 = await resolveConfig(config);
|
|
@@ -6432,8 +6727,18 @@ async function createEasyCMS(config, options = {}) {
|
|
|
6432
6727
|
logger,
|
|
6433
6728
|
interactive: options.interactive ?? false
|
|
6434
6729
|
});
|
|
6730
|
+
if (resolved3.outbox && db.outbox !== true) {
|
|
6731
|
+
await db.destroy();
|
|
6732
|
+
throw new ConfigError([
|
|
6733
|
+
{
|
|
6734
|
+
path: "outbox",
|
|
6735
|
+
message: `needs a database adapter that writes outbox rows; "${resolved3.db.name}" doesn't`,
|
|
6736
|
+
hint: "use @easy-cms/db-sqlite or @easy-cms/db-postgres 1.12 or later"
|
|
6737
|
+
}
|
|
6738
|
+
]);
|
|
6739
|
+
}
|
|
6435
6740
|
const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
|
|
6436
|
-
const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0;
|
|
6741
|
+
const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0 || resolved3.outbox === true;
|
|
6437
6742
|
if (scheduling && options.scheduler !== false) cms.startScheduler();
|
|
6438
6743
|
if (process.env.NODE_ENV === "production" && !resolved3.auth.setupCode && await cms.auth.hasUsers().catch(() => true) === false)
|
|
6439
6744
|
logger.warn(
|
|
@@ -6473,6 +6778,8 @@ var EasyCMS = class {
|
|
|
6473
6778
|
versions;
|
|
6474
6779
|
webhooks;
|
|
6475
6780
|
mailer;
|
|
6781
|
+
/** With `outbox: true`: runs after hooks and events from rows saves write. */
|
|
6782
|
+
outbox;
|
|
6476
6783
|
/** @internal */
|
|
6477
6784
|
constructor(config, db, logger = consoleLogger, storage = diskStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
|
|
6478
6785
|
this.config = config;
|
|
@@ -6500,6 +6807,7 @@ var EasyCMS = class {
|
|
|
6500
6807
|
logger,
|
|
6501
6808
|
config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
|
|
6502
6809
|
);
|
|
6810
|
+
this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(db, logger, (entry, done, mark) => this.runOutbox(entry, done, mark)) : void 0;
|
|
6503
6811
|
this.auth = new Auth(this);
|
|
6504
6812
|
}
|
|
6505
6813
|
/**
|
|
@@ -7369,14 +7677,21 @@ var EasyCMS = class {
|
|
|
7369
7677
|
]);
|
|
7370
7678
|
}
|
|
7371
7679
|
const draft = { ...prepared, id: parsed, createdAt: existing.createdAt, updatedAt: now };
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
doc: draft,
|
|
7376
|
-
previousDoc: current,
|
|
7377
|
-
operation: "update"
|
|
7680
|
+
const outbox2 = this.outboxRow(config, "draft", guard, draft, {
|
|
7681
|
+
docId: parsed,
|
|
7682
|
+
previousDoc: current
|
|
7378
7683
|
});
|
|
7379
|
-
this.
|
|
7684
|
+
await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard, outbox2);
|
|
7685
|
+
if (outbox2) this.outbox?.kick();
|
|
7686
|
+
else {
|
|
7687
|
+
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
7688
|
+
...base,
|
|
7689
|
+
doc: draft,
|
|
7690
|
+
previousDoc: current,
|
|
7691
|
+
operation: "update"
|
|
7692
|
+
});
|
|
7693
|
+
this.emitChange(config, "draft", draft);
|
|
7694
|
+
}
|
|
7380
7695
|
await this.audited(config, "draft", draft, current, guard, mode);
|
|
7381
7696
|
const [out2] = await this.output(config, [draft], guard, {
|
|
7382
7697
|
...options,
|
|
@@ -7405,7 +7720,18 @@ var EasyCMS = class {
|
|
|
7405
7720
|
}
|
|
7406
7721
|
}
|
|
7407
7722
|
const data = { ...prepared, createdAt: existing.createdAt, updatedAt: now };
|
|
7408
|
-
const
|
|
7723
|
+
const outbox = this.outboxRow(
|
|
7724
|
+
config,
|
|
7725
|
+
"update",
|
|
7726
|
+
guard,
|
|
7727
|
+
{ ...data, id: parsed },
|
|
7728
|
+
{
|
|
7729
|
+
docId: parsed,
|
|
7730
|
+
previousDoc: current,
|
|
7731
|
+
before: existing.status
|
|
7732
|
+
}
|
|
7733
|
+
);
|
|
7734
|
+
const doc = options.where ? await this.db.update({ collection: collection2, id: parsed, data, where: options.where, outbox }) : await this.db.update({ collection: collection2, id: parsed, data, outbox });
|
|
7409
7735
|
if (!doc) return null;
|
|
7410
7736
|
if (moved) await moved.cleanUp();
|
|
7411
7737
|
if (collection2 === MEDIA_FOLDERS) {
|
|
@@ -7415,13 +7741,16 @@ var EasyCMS = class {
|
|
|
7415
7741
|
}
|
|
7416
7742
|
if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
|
|
7417
7743
|
if (password !== void 0) await this.auth.revokeSessions(parsed);
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7744
|
+
if (outbox) this.outbox?.kick();
|
|
7745
|
+
else {
|
|
7746
|
+
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
7747
|
+
...base,
|
|
7748
|
+
doc,
|
|
7749
|
+
previousDoc: current,
|
|
7750
|
+
operation: "update"
|
|
7751
|
+
});
|
|
7752
|
+
this.emitChange(config, "update", doc, existing.status);
|
|
7753
|
+
}
|
|
7425
7754
|
await this.audited(config, "update", doc, current, guard, mode);
|
|
7426
7755
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
7427
7756
|
return out;
|
|
@@ -7459,7 +7788,8 @@ var EasyCMS = class {
|
|
|
7459
7788
|
await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
|
|
7460
7789
|
await this.folders.release(existing);
|
|
7461
7790
|
}
|
|
7462
|
-
|
|
7791
|
+
const outbox = this.outboxRow(config, "delete", guard, existing, { docId: parsed });
|
|
7792
|
+
await this.db.delete({ collection: collection2, id: parsed, outbox });
|
|
7463
7793
|
if (collection2 === MEDIA_FOLDERS) {
|
|
7464
7794
|
this.folders.invalidate();
|
|
7465
7795
|
const parent = existing.parent ?? null;
|
|
@@ -7471,12 +7801,15 @@ var EasyCMS = class {
|
|
|
7471
7801
|
for (const job of await this.pendingJobs(collection2, parsed))
|
|
7472
7802
|
await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
|
|
7473
7803
|
}
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7804
|
+
if (outbox) this.outbox?.kick();
|
|
7805
|
+
else {
|
|
7806
|
+
await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
|
|
7807
|
+
...base,
|
|
7808
|
+
id: parsed,
|
|
7809
|
+
doc: existing
|
|
7810
|
+
});
|
|
7811
|
+
this.emitChange(config, "delete", existing);
|
|
7812
|
+
}
|
|
7480
7813
|
await this.audited(config, "delete", existing, existing, guard);
|
|
7481
7814
|
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
7482
7815
|
return out;
|
|
@@ -7633,26 +7966,38 @@ var EasyCMS = class {
|
|
|
7633
7966
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7634
7967
|
if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
7635
7968
|
const draft = { ...prepared, updatedAt: now };
|
|
7636
|
-
|
|
7969
|
+
const outbox2 = this.outboxRow(config, "draft", guard, draft, { previousDoc: current });
|
|
7970
|
+
await this.saveVersion(config, globalParent(key), 0, draft, guard, outbox2);
|
|
7971
|
+
if (outbox2) this.outbox?.kick();
|
|
7972
|
+
else {
|
|
7973
|
+
await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
|
|
7974
|
+
...base,
|
|
7975
|
+
doc: draft,
|
|
7976
|
+
previousDoc: current,
|
|
7977
|
+
operation: "update"
|
|
7978
|
+
});
|
|
7979
|
+
this.emitChange(config, "draft", draft);
|
|
7980
|
+
}
|
|
7981
|
+
await this.audited(config, "draft", draft, current, guard, mode);
|
|
7982
|
+
return this.readGlobal(config, key, guard, { ...options, draft: true });
|
|
7983
|
+
}
|
|
7984
|
+
const data = { ...prepared, updatedAt: now };
|
|
7985
|
+
const outbox = this.outboxRow(config, "update", guard, data, {
|
|
7986
|
+
previousDoc: current,
|
|
7987
|
+
before: existing.status
|
|
7988
|
+
});
|
|
7989
|
+
const doc = await this.db.updateGlobal({ slug: key, data, outbox });
|
|
7990
|
+
await this.saveVersion(config, globalParent(key), 0, doc, guard);
|
|
7991
|
+
if (outbox) this.outbox?.kick();
|
|
7992
|
+
else {
|
|
7637
7993
|
await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
|
|
7638
7994
|
...base,
|
|
7639
|
-
doc
|
|
7995
|
+
doc,
|
|
7640
7996
|
previousDoc: current,
|
|
7641
7997
|
operation: "update"
|
|
7642
7998
|
});
|
|
7643
|
-
this.emitChange(config, "
|
|
7644
|
-
await this.audited(config, "draft", draft, current, guard, mode);
|
|
7645
|
-
return this.readGlobal(config, key, guard, { ...options, draft: true });
|
|
7999
|
+
this.emitChange(config, "update", doc, existing.status);
|
|
7646
8000
|
}
|
|
7647
|
-
const doc = await this.db.updateGlobal({ slug: key, data: { ...prepared, updatedAt: now } });
|
|
7648
|
-
await this.saveVersion(config, globalParent(key), 0, doc, guard);
|
|
7649
|
-
await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
|
|
7650
|
-
...base,
|
|
7651
|
-
doc,
|
|
7652
|
-
previousDoc: current,
|
|
7653
|
-
operation: "update"
|
|
7654
|
-
});
|
|
7655
|
-
this.emitChange(config, "update", doc, existing.status);
|
|
7656
8001
|
await this.audited(config, "update", doc, current, guard, mode);
|
|
7657
8002
|
return this.readGlobal(config, key, guard, { ...options, draft: true });
|
|
7658
8003
|
}
|
|
@@ -7766,9 +8111,21 @@ var EasyCMS = class {
|
|
|
7766
8111
|
}
|
|
7767
8112
|
};
|
|
7768
8113
|
}
|
|
8114
|
+
/**
|
|
8115
|
+
* Waits until the outbox (`outbox: true`) ran what saves left so far, e.g. before a serverless
|
|
8116
|
+
* function returns. What fails stays queued for its retry.
|
|
8117
|
+
*/
|
|
8118
|
+
flushOutbox() {
|
|
8119
|
+
return this.outbox?.flush() ?? Promise.resolve();
|
|
8120
|
+
}
|
|
8121
|
+
/** @internal Puts a failed outbox entry back in line (Settings → Jobs). */
|
|
8122
|
+
retryOutbox(id) {
|
|
8123
|
+
return this.outbox?.retry(id) ?? Promise.resolve(false);
|
|
8124
|
+
}
|
|
7769
8125
|
async destroy() {
|
|
7770
8126
|
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
|
|
7771
8127
|
await this.running;
|
|
8128
|
+
await this.outbox?.flush();
|
|
7772
8129
|
await this.webhooks.flush();
|
|
7773
8130
|
await this.mailer.flush();
|
|
7774
8131
|
await this.db.destroy();
|
|
@@ -7815,7 +8172,8 @@ var EasyCMS = class {
|
|
|
7815
8172
|
scheduled,
|
|
7816
8173
|
webhooks: await this.retryWebhooks(now),
|
|
7817
8174
|
emails: await this.mailer.retry(now),
|
|
7818
|
-
jobs: await this.runConfigJobs(now)
|
|
8175
|
+
jobs: await this.runConfigJobs(now),
|
|
8176
|
+
outbox: await this.outbox?.run(now) ?? { ran: 0, failed: 0 }
|
|
7819
8177
|
};
|
|
7820
8178
|
const ranAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7821
8179
|
await this.db.updateGlobal({ slug: JOBS_RAN, data: { at: ranAt, updatedAt: ranAt } }).catch((error) => this.logger.error(`Jobs: ${error.message}`, { error }));
|
|
@@ -8391,11 +8749,11 @@ var EasyCMS = class {
|
|
|
8391
8749
|
const access = allowed2 === false ? false : andWhere(own === true ? void 0 : own, allowed2) ?? true;
|
|
8392
8750
|
if (access === true) return;
|
|
8393
8751
|
if (access !== false) {
|
|
8394
|
-
const
|
|
8752
|
+
const matches3 = await this.db.count({
|
|
8395
8753
|
collection: config.slug,
|
|
8396
8754
|
where: andWhere(access, { id: { equals: id } })
|
|
8397
8755
|
});
|
|
8398
|
-
if (
|
|
8756
|
+
if (matches3 > 0) return;
|
|
8399
8757
|
}
|
|
8400
8758
|
throw deny(guard.user);
|
|
8401
8759
|
}
|
|
@@ -8449,11 +8807,11 @@ var EasyCMS = class {
|
|
|
8449
8807
|
await this.hidingDenied(collection2, guard, id, async () => {
|
|
8450
8808
|
const own = await this.checkGrant(guard, { collection: collection2 }, operation);
|
|
8451
8809
|
if (own === true) return;
|
|
8452
|
-
const
|
|
8810
|
+
const matches3 = await this.db.count({
|
|
8453
8811
|
collection: collection2,
|
|
8454
8812
|
where: andWhere(own, { id: { equals: id } })
|
|
8455
8813
|
});
|
|
8456
|
-
if (
|
|
8814
|
+
if (matches3 === 0) throw deny(guard.user);
|
|
8457
8815
|
});
|
|
8458
8816
|
}
|
|
8459
8817
|
/** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
|
|
@@ -8547,18 +8905,20 @@ var EasyCMS = class {
|
|
|
8547
8905
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
8548
8906
|
if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
|
|
8549
8907
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8550
|
-
const
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
});
|
|
8908
|
+
const row = { ...prepared, createdAt: now, updatedAt: now };
|
|
8909
|
+
const outbox = this.outboxRow(config, "create", hookGuard ?? guard, row);
|
|
8910
|
+
const doc = await this.db.create({ collection: collection2, data: row, outbox });
|
|
8554
8911
|
if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
|
|
8555
8912
|
await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
|
|
8556
|
-
|
|
8557
|
-
|
|
8558
|
-
|
|
8559
|
-
|
|
8560
|
-
|
|
8561
|
-
|
|
8913
|
+
if (outbox) this.outbox?.kick();
|
|
8914
|
+
else {
|
|
8915
|
+
await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
|
|
8916
|
+
...base,
|
|
8917
|
+
doc,
|
|
8918
|
+
operation: "create"
|
|
8919
|
+
});
|
|
8920
|
+
this.emitChange(config, "create", doc);
|
|
8921
|
+
}
|
|
8562
8922
|
await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
|
|
8563
8923
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
8564
8924
|
return out;
|
|
@@ -8615,10 +8975,22 @@ var EasyCMS = class {
|
|
|
8615
8975
|
return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
|
|
8616
8976
|
});
|
|
8617
8977
|
}
|
|
8618
|
-
/**
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8978
|
+
/**
|
|
8979
|
+
* The tags `httpCache.purge` gets for a change: what readers see changed (anything, without
|
|
8980
|
+
* drafts; with drafts, a published document saved, published, unpublished or deleted).
|
|
8981
|
+
*/
|
|
8982
|
+
purgeTags(config, event, doc, before) {
|
|
8983
|
+
if (!this.config.httpCache?.purge || config.httpCache === false || event === "draft") return;
|
|
8984
|
+
if (config.drafts && doc.status !== "published" && before !== "published") return;
|
|
8985
|
+
return this.isGlobal(config) ? cacheTags.global(config.slug) : cacheTags.document(config.slug, doc.id);
|
|
8986
|
+
}
|
|
8987
|
+
isGlobal(config) {
|
|
8988
|
+
return !this.config.collections.includes(config);
|
|
8989
|
+
}
|
|
8990
|
+
/** The webhook events of a change; `before` is the stored status before it, if any. */
|
|
8991
|
+
changeEvents(config, event, doc, before) {
|
|
8992
|
+
const isGlobal = this.isGlobal(config);
|
|
8993
|
+
if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return [];
|
|
8622
8994
|
const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
|
|
8623
8995
|
id: doc.id,
|
|
8624
8996
|
...snapshotOf(config.fields, doc),
|
|
@@ -8626,12 +8998,77 @@ var EasyCMS = class {
|
|
|
8626
8998
|
updatedAt: doc.updatedAt
|
|
8627
8999
|
};
|
|
8628
9000
|
const target = isGlobal ? { global: config.slug } : { collection: config.slug, id: doc.id };
|
|
8629
|
-
|
|
8630
|
-
if (event === "draft" || event === "delete" || !config.drafts) return;
|
|
9001
|
+
const events = [{ event, target, body }];
|
|
9002
|
+
if (event === "draft" || event === "delete" || !config.drafts) return events;
|
|
8631
9003
|
const after = doc.status;
|
|
8632
|
-
if (after === "published" && before !== "published")
|
|
9004
|
+
if (after === "published" && before !== "published")
|
|
9005
|
+
events.push({ event: "publish", target, body });
|
|
8633
9006
|
if (after !== "published" && before === "published")
|
|
8634
|
-
|
|
9007
|
+
events.push({ event: "unpublish", target, body });
|
|
9008
|
+
return events;
|
|
9009
|
+
}
|
|
9010
|
+
/**
|
|
9011
|
+
* Sends webhook events for a change and purges the HTTP cache. Not awaited; purge failures
|
|
9012
|
+
* are logged.
|
|
9013
|
+
*/
|
|
9014
|
+
emitChange(config, event, doc, before) {
|
|
9015
|
+
for (const change of this.changeEvents(config, event, doc, before))
|
|
9016
|
+
this.webhooks.emit(change.event, change.target, change.body);
|
|
9017
|
+
const purge = this.config.httpCache?.purge;
|
|
9018
|
+
const tags = this.purgeTags(config, event, doc, before);
|
|
9019
|
+
if (!purge || !tags) return;
|
|
9020
|
+
void Promise.resolve().then(() => purge(tags)).catch(
|
|
9021
|
+
(error) => this.logger.error(`httpCache.purge failed: ${error.message}`, { error })
|
|
9022
|
+
);
|
|
9023
|
+
}
|
|
9024
|
+
/**
|
|
9025
|
+
* The outbox row of a save (`outbox: true`): written in its transaction, then run by the outbox
|
|
9026
|
+
* in place of its after hooks and events. `undefined` without the outbox, or with nothing to run.
|
|
9027
|
+
*/
|
|
9028
|
+
outboxRow(config, event, guard, doc, extra = {}) {
|
|
9029
|
+
if (!this.outbox) return void 0;
|
|
9030
|
+
const hooks = event === "delete" ? config.hooks?.afterDelete : config.hooks?.afterChange;
|
|
9031
|
+
const isGlobal = this.isGlobal(config);
|
|
9032
|
+
const effects = (isGlobal || !INTERNAL_COLLECTIONS.has(config.slug)) && ((this.config.webhooks?.length ?? 0) > 0 || this.config.httpCache?.purge !== void 0);
|
|
9033
|
+
if (!hooks?.length && !effects) return void 0;
|
|
9034
|
+
return this.outbox.row({
|
|
9035
|
+
event,
|
|
9036
|
+
target: config.slug,
|
|
9037
|
+
global: isGlobal,
|
|
9038
|
+
docId: extra.docId,
|
|
9039
|
+
before: typeof extra.before === "string" ? extra.before : null,
|
|
9040
|
+
doc,
|
|
9041
|
+
previousDoc: extra.previousDoc,
|
|
9042
|
+
user: guard.user
|
|
9043
|
+
});
|
|
9044
|
+
}
|
|
9045
|
+
/** Runs an outbox entry: what its save would have run right after it, without the outbox. */
|
|
9046
|
+
async runOutbox(entry, done, mark) {
|
|
9047
|
+
const config = entry.global ? this.config.globals.find((g) => g.slug === entry.target) : this.config.collections.find((c) => c.slug === entry.target);
|
|
9048
|
+
if (!config) return;
|
|
9049
|
+
const { doc, event } = entry;
|
|
9050
|
+
if (!done.has("hooks")) {
|
|
9051
|
+
const base = this.hookArgs(config, { enforce: false, user: entry.user, context: {} });
|
|
9052
|
+
const hooks = event === "delete" ? config.hooks?.afterDelete : config.hooks?.afterChange;
|
|
9053
|
+
const args = event === "delete" ? { ...base, id: parseId(entry.docId ?? void 0), doc } : {
|
|
9054
|
+
...base,
|
|
9055
|
+
doc,
|
|
9056
|
+
...entry.previousDoc ? { previousDoc: entry.previousDoc } : {},
|
|
9057
|
+
operation: event === "create" ? "create" : "update"
|
|
9058
|
+
};
|
|
9059
|
+
for (const hook of hooks ?? [])
|
|
9060
|
+
await hook(args);
|
|
9061
|
+
if (hooks?.length) await mark("hooks");
|
|
9062
|
+
}
|
|
9063
|
+
if (!done.has("webhooks")) {
|
|
9064
|
+
const changes = this.changeEvents(config, event, doc, entry.before);
|
|
9065
|
+
for (const change of changes)
|
|
9066
|
+
await this.webhooks.emitSaved(change.event, change.target, change.body);
|
|
9067
|
+
if (changes.length > 0 && (this.config.webhooks?.length ?? 0) > 0) await mark("webhooks");
|
|
9068
|
+
}
|
|
9069
|
+
const purge = this.config.httpCache?.purge;
|
|
9070
|
+
const tags = this.purgeTags(config, event, doc, entry.before);
|
|
9071
|
+
if (purge && tags) await purge(tags);
|
|
8635
9072
|
}
|
|
8636
9073
|
/**
|
|
8637
9074
|
* Writes the audit log entry for a change (`audit`): publishing, unpublishing and restoring
|
|
@@ -8662,7 +9099,7 @@ var EasyCMS = class {
|
|
|
8662
9099
|
);
|
|
8663
9100
|
}
|
|
8664
9101
|
/** Records a version after a save, when the collection or global keeps versions. */
|
|
8665
|
-
async saveVersion(config, parent, id, doc, guard) {
|
|
9102
|
+
async saveVersion(config, parent, id, doc, guard, outbox) {
|
|
8666
9103
|
const max = versionLimit(config);
|
|
8667
9104
|
if (max === void 0) return;
|
|
8668
9105
|
await this.versions.save({
|
|
@@ -8671,7 +9108,8 @@ var EasyCMS = class {
|
|
|
8671
9108
|
status: config.drafts ? doc.status ?? "draft" : null,
|
|
8672
9109
|
snapshot: snapshotOf(config.fields, doc),
|
|
8673
9110
|
author: guard.user?.id ?? null,
|
|
8674
|
-
max
|
|
9111
|
+
max,
|
|
9112
|
+
outbox
|
|
8675
9113
|
});
|
|
8676
9114
|
}
|
|
8677
9115
|
hookArgs(config, guard) {
|
|
@@ -9076,7 +9514,7 @@ function durationSeconds(value) {
|
|
|
9076
9514
|
}
|
|
9077
9515
|
function cursorQuery(collection2, where, sort, locale, draft) {
|
|
9078
9516
|
const key = JSON.stringify([collection2, where ?? null, sort, locale ?? null, draft]);
|
|
9079
|
-
return
|
|
9517
|
+
return createHash5("sha256").update(key).digest("base64url").slice(0, 16);
|
|
9080
9518
|
}
|
|
9081
9519
|
function writeCursor(keys2, query) {
|
|
9082
9520
|
return Buffer.from(JSON.stringify({ k: keys2, q: query })).toString("base64url");
|
|
@@ -9100,7 +9538,7 @@ function readCursor(cursor, query) {
|
|
|
9100
9538
|
// src/rest/admin-jobs.ts
|
|
9101
9539
|
var has = (config, slug2) => config.collections.some((c) => c.slug === slug2);
|
|
9102
9540
|
function hasJobsPage(config) {
|
|
9103
|
-
return config.jobs.length > 0 || has(config, SCHEDULED_JOBS);
|
|
9541
|
+
return config.jobs.length > 0 || has(config, SCHEDULED_JOBS) || has(config, OUTBOX);
|
|
9104
9542
|
}
|
|
9105
9543
|
var text2 = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
9106
9544
|
async function adminJobs(cms, user) {
|
|
@@ -9133,6 +9571,7 @@ async function adminJobs(cms, user) {
|
|
|
9133
9571
|
...has(config, SCHEDULED_JOBS) ? ["scheduled"] : [],
|
|
9134
9572
|
...has(config, WEBHOOK_DELIVERIES) ? ["webhooks"] : [],
|
|
9135
9573
|
...has(config, EMAIL_DELIVERIES) ? ["emails"] : [],
|
|
9574
|
+
...has(config, OUTBOX) ? ["outbox"] : [],
|
|
9136
9575
|
...config.backups?.frequency ? ["backups"] : [],
|
|
9137
9576
|
...config.audit ? ["audit"] : []
|
|
9138
9577
|
];
|
|
@@ -9141,7 +9580,8 @@ async function adminJobs(cms, user) {
|
|
|
9141
9580
|
lastRanAt: await cms.jobsLastRan(),
|
|
9142
9581
|
builtIn,
|
|
9143
9582
|
timeZone: config.timeZone ?? null,
|
|
9144
|
-
canChange: isSystemAdmin(user)
|
|
9583
|
+
canChange: isSystemAdmin(user),
|
|
9584
|
+
outbox: has(config, OUTBOX) ? await adminOutbox(cms) : null
|
|
9145
9585
|
};
|
|
9146
9586
|
if (!has(config, SCHEDULED_JOBS)) return { ...head, scheduled: null };
|
|
9147
9587
|
const { docs } = await cms.db.find({
|
|
@@ -9166,6 +9606,38 @@ async function adminJobs(cms, user) {
|
|
|
9166
9606
|
});
|
|
9167
9607
|
return { ...head, scheduled };
|
|
9168
9608
|
}
|
|
9609
|
+
async function adminOutbox(cms) {
|
|
9610
|
+
const count = (state) => cms.db.count({ collection: OUTBOX, where: { state: { in: state } } });
|
|
9611
|
+
const { docs } = await cms.db.find({
|
|
9612
|
+
collection: OUTBOX,
|
|
9613
|
+
where: { attempts: { gt: 0 } },
|
|
9614
|
+
sort: ["-updatedAt"],
|
|
9615
|
+
limit: 100,
|
|
9616
|
+
page: 1
|
|
9617
|
+
});
|
|
9618
|
+
return {
|
|
9619
|
+
waiting: await count(["pending", "running"]),
|
|
9620
|
+
failed: await count(["failed"]),
|
|
9621
|
+
entries: docs.filter((row) => text2(row.error) !== null).map((row) => ({
|
|
9622
|
+
id: row.id,
|
|
9623
|
+
event: row.event,
|
|
9624
|
+
...row.global === true ? { global: String(row.target) } : { collection: String(row.target) },
|
|
9625
|
+
doc: text2(row.docId),
|
|
9626
|
+
attempts: Number(row.attempts ?? 0),
|
|
9627
|
+
state: row.state === "failed" || row.state === "running" ? row.state : "pending",
|
|
9628
|
+
error: text2(row.error),
|
|
9629
|
+
nextAttemptAt: String(row.nextAttemptAt)
|
|
9630
|
+
}))
|
|
9631
|
+
};
|
|
9632
|
+
}
|
|
9633
|
+
async function retryOutbox(cms, user, id) {
|
|
9634
|
+
if (!isSystemAdmin(user)) throw new ForbiddenError();
|
|
9635
|
+
if (!has(cms.config, OUTBOX)) throw new NotFoundError(OUTBOX, id);
|
|
9636
|
+
const row = await cms.db.findById({ collection: OUTBOX, id: parseId(id) ?? 0 });
|
|
9637
|
+
if (!row) throw new NotFoundError(OUTBOX, id);
|
|
9638
|
+
if (!await cms.retryOutbox(row.id))
|
|
9639
|
+
throw new EasyCMSError("Only failed entries can be retried", 409);
|
|
9640
|
+
}
|
|
9169
9641
|
var TIME_OF_DAY2 = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
9170
9642
|
async function updateJob(cms, user, name, body) {
|
|
9171
9643
|
if (!isSystemAdmin(user)) throw new ForbiddenError();
|
|
@@ -9827,7 +10299,7 @@ function rank(role, config) {
|
|
|
9827
10299
|
}
|
|
9828
10300
|
|
|
9829
10301
|
// src/rest/admin-modules.ts
|
|
9830
|
-
import { createHash as
|
|
10302
|
+
import { createHash as createHash6 } from "crypto";
|
|
9831
10303
|
import { readFile as readFile3 } from "fs/promises";
|
|
9832
10304
|
import { createRequire } from "module";
|
|
9833
10305
|
import { join as join3 } from "path";
|
|
@@ -9868,11 +10340,11 @@ async function readAdminModule(cms, index) {
|
|
|
9868
10340
|
if (!file) return void 0;
|
|
9869
10341
|
const body = await readFile3(file, "utf8").catch(() => void 0);
|
|
9870
10342
|
if (body === void 0) return void 0;
|
|
9871
|
-
return { body, etag: `"${
|
|
10343
|
+
return { body, etag: `"${createHash6("sha1").update(body).digest("base64url")}"` };
|
|
9872
10344
|
}
|
|
9873
10345
|
|
|
9874
10346
|
// src/version.ts
|
|
9875
|
-
var VERSION = true ? "1.
|
|
10347
|
+
var VERSION = true ? "1.12.0" : "0.0.0-dev";
|
|
9876
10348
|
|
|
9877
10349
|
// src/rest/handler.ts
|
|
9878
10350
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
@@ -9882,7 +10354,7 @@ var PER_PAGE = 50;
|
|
|
9882
10354
|
var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
|
|
9883
10355
|
var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
|
|
9884
10356
|
var isDeliveryKind = (value) => value === "webhook" || value === "email";
|
|
9885
|
-
function
|
|
10357
|
+
function parse3(text3) {
|
|
9886
10358
|
try {
|
|
9887
10359
|
const value = JSON.parse(String(text3 ?? ""));
|
|
9888
10360
|
return typeof value === "object" && value !== null ? value : {};
|
|
@@ -9901,7 +10373,7 @@ function toAdmin2(kind, row) {
|
|
|
9901
10373
|
nextAttemptAt: String(row.nextAttemptAt ?? "")
|
|
9902
10374
|
};
|
|
9903
10375
|
if (kind === "webhook") {
|
|
9904
|
-
const payload =
|
|
10376
|
+
const payload = parse3(row.body);
|
|
9905
10377
|
return {
|
|
9906
10378
|
...base,
|
|
9907
10379
|
kind,
|
|
@@ -9913,7 +10385,7 @@ function toAdmin2(kind, row) {
|
|
|
9913
10385
|
body: String(row.body ?? "")
|
|
9914
10386
|
};
|
|
9915
10387
|
}
|
|
9916
|
-
const message2 =
|
|
10388
|
+
const message2 = parse3(row.message);
|
|
9917
10389
|
const to = message2.to;
|
|
9918
10390
|
return {
|
|
9919
10391
|
...base,
|
|
@@ -10917,7 +11389,18 @@ async function respondNow(cms, request, url, path, root, options, production, re
|
|
|
10917
11389
|
);
|
|
10918
11390
|
if (!result) throw new HttpError("Not found", 404);
|
|
10919
11391
|
if (result.body instanceof Response) return result.body;
|
|
10920
|
-
|
|
11392
|
+
const body = JSON.stringify(result.body);
|
|
11393
|
+
const times = result.cache && !user && !ctx.via && !ctx.token && !url.searchParams.has("draft") ? cacheTimes(cms.config, result.cache.container) : null;
|
|
11394
|
+
if (times && result.cache)
|
|
11395
|
+
return cacheableResponse(
|
|
11396
|
+
request,
|
|
11397
|
+
body,
|
|
11398
|
+
result.status ?? 200,
|
|
11399
|
+
headers,
|
|
11400
|
+
times,
|
|
11401
|
+
result.cache.tags
|
|
11402
|
+
);
|
|
11403
|
+
return new Response(body, { status: result.status ?? 200, headers });
|
|
10921
11404
|
} catch (error) {
|
|
10922
11405
|
return errorResponse(cms, error, headers, production);
|
|
10923
11406
|
}
|
|
@@ -11419,6 +11902,13 @@ async function route(cms, ctx, method, path, options) {
|
|
|
11419
11902
|
await cms.audit.record({ action: "jobs.run", target: "jobs", detail: result });
|
|
11420
11903
|
return { body: result };
|
|
11421
11904
|
}
|
|
11905
|
+
if (third === "outbox" && segments.length === 4) {
|
|
11906
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
11907
|
+
const id2 = segments[3];
|
|
11908
|
+
await retryOutbox(cms, ctx.user, id2);
|
|
11909
|
+
await cms.audit.record({ action: "jobs.outbox.retry", target: "jobs", detail: { id: id2 } });
|
|
11910
|
+
return { body: await adminJobs(cms, ctx.user) };
|
|
11911
|
+
}
|
|
11422
11912
|
if (third !== void 0 && segments.length === 3) {
|
|
11423
11913
|
if (method !== "PATCH") throw methodNotAllowed(ctx, "PATCH");
|
|
11424
11914
|
const { action, detail } = await updateJob(
|
|
@@ -11501,7 +11991,11 @@ async function route(cms, ctx, method, path, options) {
|
|
|
11501
11991
|
})
|
|
11502
11992
|
};
|
|
11503
11993
|
}
|
|
11504
|
-
|
|
11994
|
+
const global2 = cms.config.globals.find((g) => g.slug === second);
|
|
11995
|
+
return {
|
|
11996
|
+
body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }),
|
|
11997
|
+
cache: { container: global2, tags: cacheTags.global(second) }
|
|
11998
|
+
};
|
|
11505
11999
|
}
|
|
11506
12000
|
if (method === "PATCH" || method === "POST") {
|
|
11507
12001
|
if (method === "POST")
|
|
@@ -11598,7 +12092,8 @@ async function route(cms, ctx, method, path, options) {
|
|
|
11598
12092
|
body: await cms.find(
|
|
11599
12093
|
collection2,
|
|
11600
12094
|
query.cursor === void 0 ? { ...options2, page } : options2
|
|
11601
|
-
)
|
|
12095
|
+
),
|
|
12096
|
+
cache: { container: cms.collection(collection2), tags: cacheTags.collection(collection2) }
|
|
11602
12097
|
};
|
|
11603
12098
|
}
|
|
11604
12099
|
if (method === "POST") {
|
|
@@ -11625,7 +12120,13 @@ async function route(cms, ctx, method, path, options) {
|
|
|
11625
12120
|
if (method === "GET") {
|
|
11626
12121
|
const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
|
|
11627
12122
|
if (!doc) throw new NotFoundError(collection2, id);
|
|
11628
|
-
return {
|
|
12123
|
+
return {
|
|
12124
|
+
body: doc,
|
|
12125
|
+
cache: {
|
|
12126
|
+
container: cms.collection(collection2),
|
|
12127
|
+
tags: cacheTags.document(collection2, doc.id)
|
|
12128
|
+
}
|
|
12129
|
+
};
|
|
11629
12130
|
}
|
|
11630
12131
|
if (method === "PATCH") {
|
|
11631
12132
|
const body = await readJson(ctx.request);
|
|
@@ -11784,8 +12285,8 @@ function withPreviewToken(url, token) {
|
|
|
11784
12285
|
}
|
|
11785
12286
|
async function requirePreview(cms, ctx, target) {
|
|
11786
12287
|
const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
|
|
11787
|
-
const
|
|
11788
|
-
if (!
|
|
12288
|
+
const matches3 = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
|
|
12289
|
+
if (!matches3) throw new UnauthorizedError("Invalid or expired preview token");
|
|
11789
12290
|
if (opened.user === void 0) return {};
|
|
11790
12291
|
const id = parseId(opened.user);
|
|
11791
12292
|
const issuer = id === void 0 ? null : await cms.auth.activeUser(id);
|
|
@@ -12293,6 +12794,7 @@ export {
|
|
|
12293
12794
|
DEFAULT_TOKEN_EXPIRATION,
|
|
12294
12795
|
applyPlugins,
|
|
12295
12796
|
resolveConfig,
|
|
12797
|
+
cacheTags,
|
|
12296
12798
|
createEasyCMS,
|
|
12297
12799
|
EasyCMS,
|
|
12298
12800
|
resolveAdminModule,
|