@easy-cms/core 1.31.0 → 1.33.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-ZA5KIRYX.js → chunk-KUEGUD2X.js} +1690 -237
- package/dist/{config-BUySbadL.d.ts → config-BZ4fsHL4.d.ts} +1027 -491
- package/dist/{handler-rhJ8-GMQ.d.ts → handler-CMTJ99sx.d.ts} +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.js +3 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +1 -1
- package/dist/{plugin-DmTHvlFA.d.ts → plugin-GcNICm6a.d.ts} +4 -2
- package/dist/plugin.d.ts +2 -2
- package/package.json +1 -1
|
@@ -50,99 +50,6 @@ var isStaff = ({ user }) => user !== null && user.member !== true;
|
|
|
50
50
|
var isSignedIn = ({ user }) => user !== null;
|
|
51
51
|
var isAdmin = ({ user }) => user?.role === "admin" && user.scoped !== true;
|
|
52
52
|
|
|
53
|
-
// src/auth/tokens.ts
|
|
54
|
-
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
55
|
-
function newToken() {
|
|
56
|
-
return randomBytes(32).toString("base64url");
|
|
57
|
-
}
|
|
58
|
-
function hashToken(token) {
|
|
59
|
-
return createHash("sha256").update(token).digest("hex");
|
|
60
|
-
}
|
|
61
|
-
function hmac(secret, value) {
|
|
62
|
-
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
63
|
-
}
|
|
64
|
-
function secretsOf(config) {
|
|
65
|
-
return [config.secret, ...config.previousSecrets ?? []];
|
|
66
|
-
}
|
|
67
|
-
function signedWith(secrets, value, signature) {
|
|
68
|
-
return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
|
|
69
|
-
}
|
|
70
|
-
function signToken(secret, token) {
|
|
71
|
-
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
72
|
-
}
|
|
73
|
-
function unsignToken(secrets, value) {
|
|
74
|
-
const dot = value.lastIndexOf(".");
|
|
75
|
-
if (dot <= 0) return void 0;
|
|
76
|
-
const token = value.slice(0, dot);
|
|
77
|
-
return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
|
|
78
|
-
}
|
|
79
|
-
function csrfToken(secret, token) {
|
|
80
|
-
return hmac(secret, `csrf:${token}`);
|
|
81
|
-
}
|
|
82
|
-
function csrfMatches(secrets, token, given) {
|
|
83
|
-
return signedWith(secrets, `csrf:${token}`, given);
|
|
84
|
-
}
|
|
85
|
-
function safeEqual(a, b) {
|
|
86
|
-
const left = Buffer.from(a);
|
|
87
|
-
const right = Buffer.from(b);
|
|
88
|
-
return left.length === right.length && timingSafeEqual(left, right);
|
|
89
|
-
}
|
|
90
|
-
function signPreviewToken(secret, target, expiresAt, user) {
|
|
91
|
-
const payload = Buffer.from(
|
|
92
|
-
JSON.stringify({
|
|
93
|
-
..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
|
|
94
|
-
e: expiresAt,
|
|
95
|
-
...user !== void 0 ? { u: user } : {}
|
|
96
|
-
})
|
|
97
|
-
).toString("base64url");
|
|
98
|
-
return `${payload}.${hmac(secret, `preview:${payload}`)}`;
|
|
99
|
-
}
|
|
100
|
-
function verifyPreviewToken(secrets, token, now = Date.now()) {
|
|
101
|
-
const dot = token.lastIndexOf(".");
|
|
102
|
-
if (dot <= 0) return null;
|
|
103
|
-
const payload = token.slice(0, dot);
|
|
104
|
-
if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
|
|
105
|
-
try {
|
|
106
|
-
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
107
|
-
if (typeof data.e !== "number" || data.e <= now) return null;
|
|
108
|
-
const user = typeof data.u === "string" ? { user: data.u } : {};
|
|
109
|
-
if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
|
|
110
|
-
if (typeof data.c === "string" && typeof data.i === "string")
|
|
111
|
-
return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
|
|
112
|
-
return null;
|
|
113
|
-
} catch {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
function passwordFingerprint(passwordHash) {
|
|
118
|
-
return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
|
|
119
|
-
}
|
|
120
|
-
function signPasswordToken(secret, args) {
|
|
121
|
-
const payload = Buffer.from(
|
|
122
|
-
JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
|
|
123
|
-
).toString("base64url");
|
|
124
|
-
return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
|
|
125
|
-
}
|
|
126
|
-
function readPasswordToken(token) {
|
|
127
|
-
const dot = token.lastIndexOf(".");
|
|
128
|
-
if (dot <= 0) return null;
|
|
129
|
-
const payload = token.slice(0, dot);
|
|
130
|
-
try {
|
|
131
|
-
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
132
|
-
if (typeof data.u !== "string" || typeof data.e !== "number") return null;
|
|
133
|
-
if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
|
|
134
|
-
return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function passwordTokenMatches(secrets, token, fingerprint) {
|
|
140
|
-
const dot = token.lastIndexOf(".");
|
|
141
|
-
if (dot <= 0) return false;
|
|
142
|
-
const payload = token.slice(0, dot);
|
|
143
|
-
return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
53
|
// src/builtins.ts
|
|
147
54
|
var USERS = "users";
|
|
148
55
|
var MEDIA = "media";
|
|
@@ -162,6 +69,8 @@ var OUTBOX = "outbox";
|
|
|
162
69
|
var RATE_LIMITS = "rate-limits";
|
|
163
70
|
var BULK_OPERATIONS = "bulk-operations";
|
|
164
71
|
var SAVED_VIEWS = "saved-views";
|
|
72
|
+
var CONNECTIONS = "connections";
|
|
73
|
+
var AI_USAGE = "ai-usage";
|
|
165
74
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
166
75
|
SESSIONS,
|
|
167
76
|
LOGIN_ATTEMPTS,
|
|
@@ -177,7 +86,9 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
177
86
|
OUTBOX,
|
|
178
87
|
RATE_LIMITS,
|
|
179
88
|
BULK_OPERATIONS,
|
|
180
|
-
SAVED_VIEWS
|
|
89
|
+
SAVED_VIEWS,
|
|
90
|
+
CONNECTIONS,
|
|
91
|
+
AI_USAGE
|
|
181
92
|
]);
|
|
182
93
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
183
94
|
var CREATED_BY_FIELD = {
|
|
@@ -581,6 +492,40 @@ var savedViewsCollection = {
|
|
|
581
492
|
{ name: "target", type: "text" }
|
|
582
493
|
]
|
|
583
494
|
};
|
|
495
|
+
var connectionsCollection = {
|
|
496
|
+
slug: CONNECTIONS,
|
|
497
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
498
|
+
fields: [
|
|
499
|
+
// connection | integration | pending
|
|
500
|
+
{ name: "kind", type: "text", required: true },
|
|
501
|
+
{ name: "integration", type: "text", required: true, index: true },
|
|
502
|
+
{ name: "name", type: "text" },
|
|
503
|
+
{ name: "isDefault", type: "boolean" },
|
|
504
|
+
{ name: "values", type: "json" },
|
|
505
|
+
// AES-256-GCM, with a key derived from the secret (see integrations.ts).
|
|
506
|
+
{ name: "secret", type: "text" },
|
|
507
|
+
{ name: "account", type: "text" },
|
|
508
|
+
{ name: "lastUsedAt", type: "text" },
|
|
509
|
+
{ name: "lastError", type: "text" },
|
|
510
|
+
{ name: "enabled", type: "boolean" }
|
|
511
|
+
]
|
|
512
|
+
};
|
|
513
|
+
var aiUsageCollection = {
|
|
514
|
+
slug: AI_USAGE,
|
|
515
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
516
|
+
fields: [
|
|
517
|
+
{ name: "user", type: "text", index: true },
|
|
518
|
+
{ name: "feature", type: "text", required: true },
|
|
519
|
+
{ name: "integration", type: "text", required: true },
|
|
520
|
+
{ name: "connection", type: "text" },
|
|
521
|
+
{ name: "input", type: "number" },
|
|
522
|
+
{ name: "output", type: "number" },
|
|
523
|
+
{ name: "ok", type: "boolean" },
|
|
524
|
+
{ name: "error", type: "text" },
|
|
525
|
+
// The document it was for, e.g. the image of alt text.
|
|
526
|
+
{ name: "about", type: "text" }
|
|
527
|
+
]
|
|
528
|
+
};
|
|
584
529
|
var webhookDeliveriesCollection = {
|
|
585
530
|
slug: WEBHOOK_DELIVERIES,
|
|
586
531
|
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
@@ -720,6 +665,602 @@ var emailDeliveriesCollection = {
|
|
|
720
665
|
]
|
|
721
666
|
};
|
|
722
667
|
|
|
668
|
+
// src/time-of-day.ts
|
|
669
|
+
var DAY = 864e5;
|
|
670
|
+
function isTimeZone(zone) {
|
|
671
|
+
try {
|
|
672
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
673
|
+
return true;
|
|
674
|
+
} catch {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
var formatters = /* @__PURE__ */ new Map();
|
|
679
|
+
function wallClock(instant, zone) {
|
|
680
|
+
let format = formatters.get(zone);
|
|
681
|
+
if (!format) {
|
|
682
|
+
format = new Intl.DateTimeFormat("en-US", {
|
|
683
|
+
timeZone: zone,
|
|
684
|
+
hourCycle: "h23",
|
|
685
|
+
year: "numeric",
|
|
686
|
+
month: "numeric",
|
|
687
|
+
day: "numeric",
|
|
688
|
+
hour: "numeric",
|
|
689
|
+
minute: "numeric",
|
|
690
|
+
second: "numeric"
|
|
691
|
+
});
|
|
692
|
+
formatters.set(zone, format);
|
|
693
|
+
}
|
|
694
|
+
const parts = {};
|
|
695
|
+
for (const part of format.formatToParts(instant))
|
|
696
|
+
if (part.type !== "literal") parts[part.type] = Number(part.value);
|
|
697
|
+
return Date.UTC(
|
|
698
|
+
parts.year,
|
|
699
|
+
parts.month - 1,
|
|
700
|
+
parts.day,
|
|
701
|
+
parts.hour,
|
|
702
|
+
parts.minute,
|
|
703
|
+
parts.second
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
function instantOf(wall, zone) {
|
|
707
|
+
let instant = wall - (wallClock(wall, zone) - wall);
|
|
708
|
+
instant = wall - (wallClock(instant, zone) - instant);
|
|
709
|
+
return instant;
|
|
710
|
+
}
|
|
711
|
+
var parse = (at) => at.split(":").map(Number);
|
|
712
|
+
function lastSlot(now, at, zone) {
|
|
713
|
+
const [hours2, minutes] = parse(at);
|
|
714
|
+
if (!zone) {
|
|
715
|
+
const slot2 = new Date(now);
|
|
716
|
+
slot2.setHours(hours2, minutes, 0, 0);
|
|
717
|
+
if (slot2.getTime() > now.getTime()) slot2.setDate(slot2.getDate() - 1);
|
|
718
|
+
return slot2;
|
|
719
|
+
}
|
|
720
|
+
const today = wallClock(now.getTime(), zone);
|
|
721
|
+
const midnight = today - today % DAY;
|
|
722
|
+
let slot = instantOf(midnight + hours2 * 36e5 + minutes * 6e4, zone);
|
|
723
|
+
if (slot > now.getTime())
|
|
724
|
+
slot = instantOf(midnight - DAY + hours2 * 36e5 + minutes * 6e4, zone);
|
|
725
|
+
return new Date(slot);
|
|
726
|
+
}
|
|
727
|
+
function nextSlot(after, at, zone) {
|
|
728
|
+
const last = lastSlot(after, at, zone);
|
|
729
|
+
return lastSlot(new Date(last.getTime() + DAY + 2 * 36e5), at, zone);
|
|
730
|
+
}
|
|
731
|
+
function dueAt(now, at, every, lastRun, zone) {
|
|
732
|
+
if (lastRun === void 0) return true;
|
|
733
|
+
const last = Date.parse(lastRun);
|
|
734
|
+
if (Number.isNaN(last)) return true;
|
|
735
|
+
const slot = lastSlot(now, at, zone).getTime();
|
|
736
|
+
return last < slot - (every * 1e3 - DAY);
|
|
737
|
+
}
|
|
738
|
+
function nextAt(at, every, lastRun, zone) {
|
|
739
|
+
return nextSlot(new Date(Date.parse(lastRun) + (every * 1e3 - DAY)), at, zone);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/ai.ts
|
|
743
|
+
var AiProviderError = class extends Error {
|
|
744
|
+
status;
|
|
745
|
+
constructor(message2, status) {
|
|
746
|
+
super(message2);
|
|
747
|
+
this.name = "AiProviderError";
|
|
748
|
+
this.status = status;
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
var SETTINGS = "ai";
|
|
752
|
+
var DEFAULT_LIMIT = 100;
|
|
753
|
+
var TIMEOUT = 6e4;
|
|
754
|
+
var MAX_IMAGE = 4 * 1024 * 1024;
|
|
755
|
+
var KEEP = 90 * 24 * 36e5;
|
|
756
|
+
var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
757
|
+
var AI_TEST_FEATURE = "test";
|
|
758
|
+
var ref = (integration, name) => `${integration}:${name}`;
|
|
759
|
+
var Ai = class {
|
|
760
|
+
constructor(cms, ai) {
|
|
761
|
+
this.cms = cms;
|
|
762
|
+
this.ai = ai;
|
|
763
|
+
}
|
|
764
|
+
cms;
|
|
765
|
+
ai;
|
|
766
|
+
get config() {
|
|
767
|
+
return this.cms.config;
|
|
768
|
+
}
|
|
769
|
+
get defaultLimit() {
|
|
770
|
+
return this.ai.limits?.perUserPerDay ?? DEFAULT_LIMIT;
|
|
771
|
+
}
|
|
772
|
+
/** The features of the config. */
|
|
773
|
+
get features() {
|
|
774
|
+
return this.config.aiFeatures;
|
|
775
|
+
}
|
|
776
|
+
async settingsRow() {
|
|
777
|
+
const found = await this.cms.db.find({
|
|
778
|
+
collection: CONNECTIONS,
|
|
779
|
+
where: { and: [{ kind: { equals: "settings" } }, { integration: { equals: SETTINGS } }] },
|
|
780
|
+
sort: ["id"],
|
|
781
|
+
limit: 1,
|
|
782
|
+
page: 1
|
|
783
|
+
});
|
|
784
|
+
return found.docs[0];
|
|
785
|
+
}
|
|
786
|
+
async settings() {
|
|
787
|
+
return (await this.settingsRow())?.values ?? {};
|
|
788
|
+
}
|
|
789
|
+
/** Every connection of every provider, in the providers' order. */
|
|
790
|
+
async connections() {
|
|
791
|
+
const out = [];
|
|
792
|
+
for (const provider of this.ai.providers)
|
|
793
|
+
for (const conn of await this.cms.integrations.all(provider.integration))
|
|
794
|
+
out.push({ ...conn, integration: provider.integration });
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
/** Whether a feature is on: admins' choice, else its `default`. */
|
|
798
|
+
async enabled(feature) {
|
|
799
|
+
const def = this.features.find((f) => f.id === feature);
|
|
800
|
+
if (!def) return false;
|
|
801
|
+
return (await this.settings()).features?.[feature]?.enabled ?? def.default ?? true;
|
|
802
|
+
}
|
|
803
|
+
/** Requests a user may make today: their role's, else everyone's. */
|
|
804
|
+
async limitFor(user) {
|
|
805
|
+
const role = typeof user.role === "string" ? user.role : void 0;
|
|
806
|
+
if (role) {
|
|
807
|
+
const fromRoles = await this.cms.roles.aiLimit(role);
|
|
808
|
+
if (fromRoles !== void 0) return fromRoles;
|
|
809
|
+
const fromConfig = this.ai.limits?.roles?.[role];
|
|
810
|
+
if (fromConfig !== void 0) return fromConfig;
|
|
811
|
+
}
|
|
812
|
+
return (await this.settings()).perUserPerDay ?? this.defaultLimit;
|
|
813
|
+
}
|
|
814
|
+
/** Requests the user made today (in `timeZone`) and how many are left. */
|
|
815
|
+
async remaining(user, now = /* @__PURE__ */ new Date()) {
|
|
816
|
+
const since = lastSlot(now, "00:00", this.config.timeZone).toISOString();
|
|
817
|
+
const used = await this.cms.db.count({
|
|
818
|
+
collection: AI_USAGE,
|
|
819
|
+
where: {
|
|
820
|
+
and: [
|
|
821
|
+
{ user: { equals: String(user.id) } },
|
|
822
|
+
{ ok: { equals: true } },
|
|
823
|
+
{ createdAt: { gte: since } }
|
|
824
|
+
]
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
return { used, limit: await this.limitFor(user) };
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Asks the feature's connection (or the main one). With `user`, the request counts toward their
|
|
831
|
+
* daily limit; it is refused once they reach it. Usage is recorded, never the prompt.
|
|
832
|
+
*/
|
|
833
|
+
async generate(request, options = {}) {
|
|
834
|
+
const feature = this.features.find((f) => f.id === request.feature);
|
|
835
|
+
if (!feature)
|
|
836
|
+
throw new QueryError(
|
|
837
|
+
`Unknown AI feature "${request.feature}": add it to \`aiFeatures\` in the config`
|
|
838
|
+
);
|
|
839
|
+
if (!await this.enabled(feature.id))
|
|
840
|
+
throw new QueryError(`The AI feature "${feature.id}" is turned off`);
|
|
841
|
+
const user = options.user ?? null;
|
|
842
|
+
if (user) {
|
|
843
|
+
const { used, limit } = await this.remaining(user);
|
|
844
|
+
if (used >= limit)
|
|
845
|
+
throw new TooManyRequestsError(
|
|
846
|
+
limit === 0 ? "Your role may not use AI" : `You used AI ${limit} times today, the most allowed`
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
const conn = await this.connectionFor(feature.id);
|
|
850
|
+
const images = await Promise.all((request.images ?? []).map((i) => this.image(i)));
|
|
851
|
+
return this.ask(conn, {
|
|
852
|
+
feature: feature.id,
|
|
853
|
+
user,
|
|
854
|
+
about: options.about,
|
|
855
|
+
request: {
|
|
856
|
+
...request.system ? { system: request.system } : {},
|
|
857
|
+
prompt: request.prompt,
|
|
858
|
+
images,
|
|
859
|
+
...request.json ? { json: request.json } : {},
|
|
860
|
+
maxTokens: request.maxTokens ?? 1024
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* The Test button of a provider's connection: a short prompt; its tokens count on the card,
|
|
866
|
+
* not toward anyone's limit.
|
|
867
|
+
*/
|
|
868
|
+
async test(integration, values, name) {
|
|
869
|
+
try {
|
|
870
|
+
await this.ask(
|
|
871
|
+
{ integration, name: name ?? "", values, env: false },
|
|
872
|
+
{
|
|
873
|
+
feature: AI_TEST_FEATURE,
|
|
874
|
+
user: null,
|
|
875
|
+
markConnection: false,
|
|
876
|
+
request: { prompt: "Reply with the word OK.", images: [], maxTokens: 16 }
|
|
877
|
+
}
|
|
878
|
+
);
|
|
879
|
+
return { ok: true, ...values.model ? { account: values.model } : {} };
|
|
880
|
+
} catch (error) {
|
|
881
|
+
return { ok: false, message: error.message };
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
async connectionFor(feature) {
|
|
885
|
+
const settings = await this.settings();
|
|
886
|
+
const wanted = settings.features?.[feature]?.connection ?? settings.main;
|
|
887
|
+
const all = await this.connections();
|
|
888
|
+
const conn = wanted && all.find((c) => ref(c.integration, c.name) === wanted) || all[0];
|
|
889
|
+
if (!conn)
|
|
890
|
+
throw new QueryError("No AI provider is connected: connect one in Settings \u2192 Integrations");
|
|
891
|
+
return conn;
|
|
892
|
+
}
|
|
893
|
+
async ask(conn, args) {
|
|
894
|
+
const provider = this.ai.providers.find((p) => p.integration === conn.integration);
|
|
895
|
+
if (!provider) throw new QueryError(`No AI provider answers for "${conn.integration}"`);
|
|
896
|
+
const record = (data) => this.cms.db.create({
|
|
897
|
+
collection: AI_USAGE,
|
|
898
|
+
data: {
|
|
899
|
+
user: args.user ? String(args.user.id) : null,
|
|
900
|
+
feature: args.feature,
|
|
901
|
+
integration: conn.integration,
|
|
902
|
+
connection: conn.name || null,
|
|
903
|
+
input: 0,
|
|
904
|
+
output: 0,
|
|
905
|
+
error: null,
|
|
906
|
+
about: args.about ? `${args.about.collection}:${args.about.id}` : null,
|
|
907
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
908
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
909
|
+
...data
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
let result;
|
|
913
|
+
try {
|
|
914
|
+
result = await this.call(provider, conn.values, args.request);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
const status = error instanceof AiProviderError ? error.status : void 0;
|
|
917
|
+
const message2 = error.message;
|
|
918
|
+
await record({ ok: false, error: message2.slice(0, 500) });
|
|
919
|
+
if ((status === 401 || status === 403) && args.markConnection !== false && conn.name)
|
|
920
|
+
await this.cms.integrations.markError(conn.integration, message2, conn.name);
|
|
921
|
+
throw new QueryError(message2);
|
|
922
|
+
}
|
|
923
|
+
await record({ ok: true, input: result.usage.input, output: result.usage.output });
|
|
924
|
+
if (args.markConnection !== false && conn.name)
|
|
925
|
+
await this.cms.integrations.markUsed(conn.integration, conn.name);
|
|
926
|
+
let json2;
|
|
927
|
+
if (args.request.json) {
|
|
928
|
+
try {
|
|
929
|
+
json2 = JSON.parse(stripFence(result.text));
|
|
930
|
+
} catch {
|
|
931
|
+
throw new QueryError("The AI answered with something other than JSON; try again");
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
text: result.text,
|
|
936
|
+
...args.request.json ? { json: json2 } : {},
|
|
937
|
+
usage: result.usage,
|
|
938
|
+
connection: ref(conn.integration, conn.name)
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/** One try, and another after a second when the provider is busy or failing (429, 5xx). */
|
|
942
|
+
async call(provider, values, request) {
|
|
943
|
+
for (let attempt = 0; ; attempt++) {
|
|
944
|
+
try {
|
|
945
|
+
return await provider.generate(values, { ...request, signal: AbortSignal.timeout(TIMEOUT) });
|
|
946
|
+
} catch (error) {
|
|
947
|
+
const status = error instanceof AiProviderError ? error.status : void 0;
|
|
948
|
+
const busy = status === 429 || status !== void 0 && status >= 500;
|
|
949
|
+
if (!busy || attempt > 0) {
|
|
950
|
+
if (error.name === "TimeoutError")
|
|
951
|
+
throw new AiProviderError("The AI provider took longer than 60 seconds");
|
|
952
|
+
throw error;
|
|
953
|
+
}
|
|
954
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/** An image as base64: given, or read from a media document. */
|
|
959
|
+
async image(image) {
|
|
960
|
+
if (image.data !== void 0) {
|
|
961
|
+
if (!image.mediaType || !IMAGE_TYPES.has(image.mediaType))
|
|
962
|
+
throw new ValidationError(AI_USAGE, [
|
|
963
|
+
{ field: "images", message: "must be JPEG, PNG, WebP or GIF" }
|
|
964
|
+
]);
|
|
965
|
+
return { data: image.data, mediaType: image.mediaType };
|
|
966
|
+
}
|
|
967
|
+
if (image.media === void 0)
|
|
968
|
+
throw new ValidationError(AI_USAGE, [{ field: "images", message: "needs media or data" }]);
|
|
969
|
+
const doc = await this.cms.db.findById({ collection: MEDIA, id: image.media });
|
|
970
|
+
if (!doc) throw new ValidationError(AI_USAGE, [{ field: "images", message: "media not found" }]);
|
|
971
|
+
const sizes = Object.values(doc.sizes ?? {}).filter((s) => typeof s?.filename === "string" && Number(s.width) >= 1024).sort((a, b) => Number(a.width) - Number(b.width));
|
|
972
|
+
const filename = String(sizes[0]?.filename ?? doc.filename);
|
|
973
|
+
const mediaType = String(doc.mimeType ?? "");
|
|
974
|
+
if (!IMAGE_TYPES.has(mediaType))
|
|
975
|
+
throw new ValidationError(AI_USAGE, [
|
|
976
|
+
{ field: "images", message: `${mediaType || "this file"} is not an image AI can read` }
|
|
977
|
+
]);
|
|
978
|
+
const file = await this.cms.storageFor(doc.private === true).get(filename);
|
|
979
|
+
if (!file) throw new ValidationError(AI_USAGE, [{ field: "images", message: "file not found" }]);
|
|
980
|
+
if (file.body.byteLength > MAX_IMAGE)
|
|
981
|
+
throw new ValidationError(AI_USAGE, [
|
|
982
|
+
{ field: "images", message: "is larger than 4 MB; add an image size of about 1024 pixels" }
|
|
983
|
+
]);
|
|
984
|
+
return { data: Buffer.from(file.body).toString("base64"), mediaType };
|
|
985
|
+
}
|
|
986
|
+
// --- Admins ----------------------------------------------------------------------------------
|
|
987
|
+
/** Settings → Integrations, AI: the main connection, the features, the limit and this month. */
|
|
988
|
+
async overview(now = /* @__PURE__ */ new Date()) {
|
|
989
|
+
const settings = await this.settings();
|
|
990
|
+
const conns = await this.connections();
|
|
991
|
+
const labels = new Map(this.config.integrations.map((i) => [i.id, i.label]));
|
|
992
|
+
const month = monthStart(now, this.config.timeZone).toISOString();
|
|
993
|
+
const rows2 = (await this.cms.db.find({
|
|
994
|
+
collection: AI_USAGE,
|
|
995
|
+
where: { createdAt: { gte: month } },
|
|
996
|
+
sort: ["id"],
|
|
997
|
+
limit: 0,
|
|
998
|
+
page: 1
|
|
999
|
+
})).docs;
|
|
1000
|
+
const sum = (key) => {
|
|
1001
|
+
const totals = /* @__PURE__ */ new Map();
|
|
1002
|
+
for (const row of rows2) {
|
|
1003
|
+
if (key === "user" && !row.user) continue;
|
|
1004
|
+
const k = String(row[key]);
|
|
1005
|
+
const t = totals.get(k) ?? { requests: 0, input: 0, output: 0 };
|
|
1006
|
+
t.requests++;
|
|
1007
|
+
t.input += Number(row.input ?? 0);
|
|
1008
|
+
t.output += Number(row.output ?? 0);
|
|
1009
|
+
totals.set(k, t);
|
|
1010
|
+
}
|
|
1011
|
+
return [...totals].sort((a, b) => b[1].requests - a[1].requests);
|
|
1012
|
+
};
|
|
1013
|
+
const users = sum("user");
|
|
1014
|
+
const emails = /* @__PURE__ */ new Map();
|
|
1015
|
+
if (users.length > 0) {
|
|
1016
|
+
const found = await this.cms.db.findByIds({
|
|
1017
|
+
collection: "users",
|
|
1018
|
+
ids: users.map(([id]) => /^\d+$/.test(id) ? Number(id) : id)
|
|
1019
|
+
});
|
|
1020
|
+
for (const u of found) emails.set(String(u.id), String(u.email ?? u.id));
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
main: settings.main ?? null,
|
|
1024
|
+
perUserPerDay: settings.perUserPerDay ?? this.defaultLimit,
|
|
1025
|
+
defaultPerUserPerDay: this.defaultLimit,
|
|
1026
|
+
connections: conns.map((c) => ({
|
|
1027
|
+
id: ref(c.integration, c.name),
|
|
1028
|
+
integration: c.integration,
|
|
1029
|
+
name: c.name,
|
|
1030
|
+
label: labels.get(c.integration) ?? c.integration
|
|
1031
|
+
})),
|
|
1032
|
+
features: await Promise.all(
|
|
1033
|
+
this.features.map(async (f) => ({
|
|
1034
|
+
id: f.id,
|
|
1035
|
+
label: f.label,
|
|
1036
|
+
...f.description ? { description: f.description } : {},
|
|
1037
|
+
enabled: await this.enabled(f.id),
|
|
1038
|
+
connection: settings.features?.[f.id]?.connection ?? null
|
|
1039
|
+
}))
|
|
1040
|
+
),
|
|
1041
|
+
usage: {
|
|
1042
|
+
byUser: users.map(([id, t]) => ({ user: emails.get(id) ?? id, ...t })),
|
|
1043
|
+
byFeature: sum("feature").map(([feature, t]) => ({ feature, ...t }))
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
/** Tokens and requests of each connection this month, for the cards. */
|
|
1048
|
+
async usageByConnection(now = /* @__PURE__ */ new Date()) {
|
|
1049
|
+
const month = monthStart(now, this.config.timeZone).toISOString();
|
|
1050
|
+
const rows2 = (await this.cms.db.find({
|
|
1051
|
+
collection: AI_USAGE,
|
|
1052
|
+
where: { createdAt: { gte: month } },
|
|
1053
|
+
sort: ["id"],
|
|
1054
|
+
limit: 0,
|
|
1055
|
+
page: 1
|
|
1056
|
+
})).docs;
|
|
1057
|
+
const out = /* @__PURE__ */ new Map();
|
|
1058
|
+
for (const row of rows2) {
|
|
1059
|
+
const k = ref(String(row.integration), String(row.connection ?? ""));
|
|
1060
|
+
const t = out.get(k) ?? { requests: 0, input: 0, output: 0 };
|
|
1061
|
+
t.requests++;
|
|
1062
|
+
t.input += Number(row.input ?? 0);
|
|
1063
|
+
t.output += Number(row.output ?? 0);
|
|
1064
|
+
out.set(k, t);
|
|
1065
|
+
}
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|
|
1068
|
+
/** Saves admins' choices; audit-logged. */
|
|
1069
|
+
async update(body, user) {
|
|
1070
|
+
const input = body ?? {};
|
|
1071
|
+
const current = await this.settings();
|
|
1072
|
+
const conns = new Set((await this.connections()).map((c) => ref(c.integration, c.name)));
|
|
1073
|
+
const next = { ...current };
|
|
1074
|
+
const fail = (field, message2) => {
|
|
1075
|
+
throw new ValidationError(AI_USAGE, [{ field, message: message2 }]);
|
|
1076
|
+
};
|
|
1077
|
+
if (input.main !== void 0) {
|
|
1078
|
+
if (input.main === null) delete next.main;
|
|
1079
|
+
else if (typeof input.main === "string" && conns.has(input.main)) next.main = input.main;
|
|
1080
|
+
else fail("main", "is not a connection");
|
|
1081
|
+
}
|
|
1082
|
+
if (input.perUserPerDay !== void 0) {
|
|
1083
|
+
if (input.perUserPerDay === null) delete next.perUserPerDay;
|
|
1084
|
+
else if (Number.isInteger(input.perUserPerDay) && input.perUserPerDay >= 0)
|
|
1085
|
+
next.perUserPerDay = input.perUserPerDay;
|
|
1086
|
+
else fail("perUserPerDay", "must be a whole number, 0 or more");
|
|
1087
|
+
}
|
|
1088
|
+
if (input.features !== void 0) {
|
|
1089
|
+
if (typeof input.features !== "object" || input.features === null)
|
|
1090
|
+
fail("features", "must be an object");
|
|
1091
|
+
const features = { ...current.features ?? {} };
|
|
1092
|
+
for (const [id, raw] of Object.entries(input.features)) {
|
|
1093
|
+
if (!this.features.some((f) => f.id === id)) fail("features", `unknown feature "${id}"`);
|
|
1094
|
+
const change = raw ?? {};
|
|
1095
|
+
const entry = { ...features[id] ?? {} };
|
|
1096
|
+
if (typeof change.enabled === "boolean") entry.enabled = change.enabled;
|
|
1097
|
+
if (change.connection === null) delete entry.connection;
|
|
1098
|
+
else if (change.connection !== void 0) {
|
|
1099
|
+
if (typeof change.connection !== "string" || !conns.has(change.connection))
|
|
1100
|
+
fail("features", "connection is not a connection");
|
|
1101
|
+
entry.connection = change.connection;
|
|
1102
|
+
}
|
|
1103
|
+
features[id] = entry;
|
|
1104
|
+
}
|
|
1105
|
+
next.features = features;
|
|
1106
|
+
}
|
|
1107
|
+
const row = await this.settingsRow();
|
|
1108
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1109
|
+
if (row)
|
|
1110
|
+
await this.cms.db.update({
|
|
1111
|
+
collection: CONNECTIONS,
|
|
1112
|
+
id: row.id,
|
|
1113
|
+
data: { ...row, values: next, updatedAt: now }
|
|
1114
|
+
});
|
|
1115
|
+
else
|
|
1116
|
+
await this.cms.db.create({
|
|
1117
|
+
collection: CONNECTIONS,
|
|
1118
|
+
data: {
|
|
1119
|
+
kind: "settings",
|
|
1120
|
+
integration: SETTINGS,
|
|
1121
|
+
name: null,
|
|
1122
|
+
isDefault: false,
|
|
1123
|
+
values: next,
|
|
1124
|
+
secret: null,
|
|
1125
|
+
account: null,
|
|
1126
|
+
lastUsedAt: null,
|
|
1127
|
+
lastError: null,
|
|
1128
|
+
enabled: null,
|
|
1129
|
+
createdAt: now,
|
|
1130
|
+
updatedAt: now
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
await this.cms.audit.record({
|
|
1134
|
+
action: "integration.ai",
|
|
1135
|
+
target: SETTINGS,
|
|
1136
|
+
user,
|
|
1137
|
+
detail: { ...next }
|
|
1138
|
+
});
|
|
1139
|
+
return this.overview();
|
|
1140
|
+
}
|
|
1141
|
+
/** Forgets usage older than 90 days (with the scheduled jobs). */
|
|
1142
|
+
async upkeep(now = /* @__PURE__ */ new Date()) {
|
|
1143
|
+
const before = new Date(now.getTime() - KEEP).toISOString();
|
|
1144
|
+
for (; ; ) {
|
|
1145
|
+
const old = await this.cms.db.find({
|
|
1146
|
+
collection: AI_USAGE,
|
|
1147
|
+
where: { createdAt: { lt: before } },
|
|
1148
|
+
sort: ["id"],
|
|
1149
|
+
limit: 500,
|
|
1150
|
+
page: 1
|
|
1151
|
+
});
|
|
1152
|
+
for (const row of old.docs) await this.cms.db.delete({ collection: AI_USAGE, id: row.id });
|
|
1153
|
+
if (old.docs.length < 500) return;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
function stripFence(text4) {
|
|
1158
|
+
const trimmed = text4.trim();
|
|
1159
|
+
const match = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/.exec(trimmed);
|
|
1160
|
+
return match ? match[1] : trimmed;
|
|
1161
|
+
}
|
|
1162
|
+
function monthStart(now, zone) {
|
|
1163
|
+
let day = lastSlot(now, "00:00", zone);
|
|
1164
|
+
const dayOf = (d) => Number(new Intl.DateTimeFormat("en-US", { day: "numeric", timeZone: zone }).format(d));
|
|
1165
|
+
for (let i = 0; i < 31 && dayOf(day) > 1; i++)
|
|
1166
|
+
day = lastSlot(new Date(day.getTime() - 1), "00:00", zone);
|
|
1167
|
+
return day;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/auth/tokens.ts
|
|
1171
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
1172
|
+
function newToken() {
|
|
1173
|
+
return randomBytes(32).toString("base64url");
|
|
1174
|
+
}
|
|
1175
|
+
function hashToken(token) {
|
|
1176
|
+
return createHash("sha256").update(token).digest("hex");
|
|
1177
|
+
}
|
|
1178
|
+
function hmac(secret, value) {
|
|
1179
|
+
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
1180
|
+
}
|
|
1181
|
+
var signValue = (secret, value) => hmac(secret, value);
|
|
1182
|
+
function secretsOf(config) {
|
|
1183
|
+
return [config.secret, ...config.previousSecrets ?? []];
|
|
1184
|
+
}
|
|
1185
|
+
function signedWith(secrets, value, signature) {
|
|
1186
|
+
return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
|
|
1187
|
+
}
|
|
1188
|
+
function signToken(secret, token) {
|
|
1189
|
+
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
1190
|
+
}
|
|
1191
|
+
function unsignToken(secrets, value) {
|
|
1192
|
+
const dot = value.lastIndexOf(".");
|
|
1193
|
+
if (dot <= 0) return void 0;
|
|
1194
|
+
const token = value.slice(0, dot);
|
|
1195
|
+
return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
|
|
1196
|
+
}
|
|
1197
|
+
function csrfToken(secret, token) {
|
|
1198
|
+
return hmac(secret, `csrf:${token}`);
|
|
1199
|
+
}
|
|
1200
|
+
function csrfMatches(secrets, token, given) {
|
|
1201
|
+
return signedWith(secrets, `csrf:${token}`, given);
|
|
1202
|
+
}
|
|
1203
|
+
function safeEqual(a, b) {
|
|
1204
|
+
const left = Buffer.from(a);
|
|
1205
|
+
const right = Buffer.from(b);
|
|
1206
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
1207
|
+
}
|
|
1208
|
+
function signPreviewToken(secret, target, expiresAt, user) {
|
|
1209
|
+
const payload = Buffer.from(
|
|
1210
|
+
JSON.stringify({
|
|
1211
|
+
..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
|
|
1212
|
+
e: expiresAt,
|
|
1213
|
+
...user !== void 0 ? { u: user } : {}
|
|
1214
|
+
})
|
|
1215
|
+
).toString("base64url");
|
|
1216
|
+
return `${payload}.${hmac(secret, `preview:${payload}`)}`;
|
|
1217
|
+
}
|
|
1218
|
+
function verifyPreviewToken(secrets, token, now = Date.now()) {
|
|
1219
|
+
const dot = token.lastIndexOf(".");
|
|
1220
|
+
if (dot <= 0) return null;
|
|
1221
|
+
const payload = token.slice(0, dot);
|
|
1222
|
+
if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
|
|
1223
|
+
try {
|
|
1224
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1225
|
+
if (typeof data.e !== "number" || data.e <= now) return null;
|
|
1226
|
+
const user = typeof data.u === "string" ? { user: data.u } : {};
|
|
1227
|
+
if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
|
|
1228
|
+
if (typeof data.c === "string" && typeof data.i === "string")
|
|
1229
|
+
return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
|
|
1230
|
+
return null;
|
|
1231
|
+
} catch {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function passwordFingerprint(passwordHash) {
|
|
1236
|
+
return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
|
|
1237
|
+
}
|
|
1238
|
+
function signPasswordToken(secret, args) {
|
|
1239
|
+
const payload = Buffer.from(
|
|
1240
|
+
JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
|
|
1241
|
+
).toString("base64url");
|
|
1242
|
+
return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
|
|
1243
|
+
}
|
|
1244
|
+
function readPasswordToken(token) {
|
|
1245
|
+
const dot = token.lastIndexOf(".");
|
|
1246
|
+
if (dot <= 0) return null;
|
|
1247
|
+
const payload = token.slice(0, dot);
|
|
1248
|
+
try {
|
|
1249
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1250
|
+
if (typeof data.u !== "string" || typeof data.e !== "number") return null;
|
|
1251
|
+
if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
|
|
1252
|
+
return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
|
|
1253
|
+
} catch {
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
function passwordTokenMatches(secrets, token, fingerprint) {
|
|
1258
|
+
const dot = token.lastIndexOf(".");
|
|
1259
|
+
if (dot <= 0) return false;
|
|
1260
|
+
const payload = token.slice(0, dot);
|
|
1261
|
+
return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
|
|
1262
|
+
}
|
|
1263
|
+
|
|
723
1264
|
// src/audit.ts
|
|
724
1265
|
import { AsyncLocalStorage } from "async_hooks";
|
|
725
1266
|
import { createHmac as createHmac2 } from "crypto";
|
|
@@ -737,7 +1278,7 @@ var DEFAULT_FAILED_LOGINS = 20;
|
|
|
737
1278
|
var MAX_VALUE = 500;
|
|
738
1279
|
var PAGE = 50;
|
|
739
1280
|
var CSV_LIMIT = 1e4;
|
|
740
|
-
var
|
|
1281
|
+
var DAY2 = 864e5;
|
|
741
1282
|
var BULKY = /* @__PURE__ */ new Set(["richText", "blocks", "array", "json"]);
|
|
742
1283
|
var auditContext = new AsyncLocalStorage();
|
|
743
1284
|
var SIGN_IN_FAILURES = /* @__PURE__ */ new Set(["login.failed", "login.locked", "sso.failed"]);
|
|
@@ -1012,10 +1553,10 @@ var AuditLog = class {
|
|
|
1012
1553
|
async upkeep(now = /* @__PURE__ */ new Date()) {
|
|
1013
1554
|
if (!this.enabled) return;
|
|
1014
1555
|
const last = await this.lastVerification();
|
|
1015
|
-
if (last && now.getTime() - Date.parse(last.at) <
|
|
1556
|
+
if (last && now.getTime() - Date.parse(last.at) < DAY2) return;
|
|
1016
1557
|
const keep = this.settings.keepDays;
|
|
1017
1558
|
if (keep > 0) {
|
|
1018
|
-
const before = new Date(now.getTime() - keep *
|
|
1559
|
+
const before = new Date(now.getTime() - keep * DAY2).toISOString();
|
|
1019
1560
|
for (; ; ) {
|
|
1020
1561
|
const { docs } = await this.cms.db.find({
|
|
1021
1562
|
collection: AUDIT_LOGS,
|
|
@@ -3023,6 +3564,8 @@ var Mailer = class {
|
|
|
3023
3564
|
logger;
|
|
3024
3565
|
queue;
|
|
3025
3566
|
pending = /* @__PURE__ */ new Set();
|
|
3567
|
+
/** Told when an email is given up on (`delivery.failed`). */
|
|
3568
|
+
onGiveUp;
|
|
3026
3569
|
get configured() {
|
|
3027
3570
|
return this.adapter !== void 0;
|
|
3028
3571
|
}
|
|
@@ -3080,6 +3623,8 @@ var Mailer = class {
|
|
|
3080
3623
|
if (delay === void 0) {
|
|
3081
3624
|
failed++;
|
|
3082
3625
|
this.logger.error(`Email gave up after ${attempts} attempts: ${error}`);
|
|
3626
|
+
const sent2 = JSON.parse(queued.message);
|
|
3627
|
+
this.onGiveUp?.({ to: [sent2.to].flat().join(", "), error, attempts });
|
|
3083
3628
|
}
|
|
3084
3629
|
await this.queue.update(id, {
|
|
3085
3630
|
...queued,
|
|
@@ -3276,92 +3821,16 @@ function diskStorage(options = {}) {
|
|
|
3276
3821
|
async delete(key) {
|
|
3277
3822
|
await rm(pathOf(key), { force: true });
|
|
3278
3823
|
}
|
|
3279
|
-
};
|
|
3280
|
-
}
|
|
3281
|
-
|
|
3282
|
-
// src/backups.ts
|
|
3283
|
-
import { createCipheriv, createDecipheriv, createHash as createHash3, randomBytes as randomBytes5 } from "crypto";
|
|
3284
|
-
import { mkdtemp, readFile as readFile2, rm as rm2 } from "fs/promises";
|
|
3285
|
-
import { tmpdir } from "os";
|
|
3286
|
-
import { join as join2 } from "path";
|
|
3287
|
-
import { promisify } from "util";
|
|
3288
|
-
import { gzip } from "zlib";
|
|
3289
|
-
|
|
3290
|
-
// src/time-of-day.ts
|
|
3291
|
-
var DAY2 = 864e5;
|
|
3292
|
-
function isTimeZone(zone) {
|
|
3293
|
-
try {
|
|
3294
|
-
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
3295
|
-
return true;
|
|
3296
|
-
} catch {
|
|
3297
|
-
return false;
|
|
3298
|
-
}
|
|
3299
|
-
}
|
|
3300
|
-
var formatters = /* @__PURE__ */ new Map();
|
|
3301
|
-
function wallClock(instant, zone) {
|
|
3302
|
-
let format = formatters.get(zone);
|
|
3303
|
-
if (!format) {
|
|
3304
|
-
format = new Intl.DateTimeFormat("en-US", {
|
|
3305
|
-
timeZone: zone,
|
|
3306
|
-
hourCycle: "h23",
|
|
3307
|
-
year: "numeric",
|
|
3308
|
-
month: "numeric",
|
|
3309
|
-
day: "numeric",
|
|
3310
|
-
hour: "numeric",
|
|
3311
|
-
minute: "numeric",
|
|
3312
|
-
second: "numeric"
|
|
3313
|
-
});
|
|
3314
|
-
formatters.set(zone, format);
|
|
3315
|
-
}
|
|
3316
|
-
const parts = {};
|
|
3317
|
-
for (const part of format.formatToParts(instant))
|
|
3318
|
-
if (part.type !== "literal") parts[part.type] = Number(part.value);
|
|
3319
|
-
return Date.UTC(
|
|
3320
|
-
parts.year,
|
|
3321
|
-
parts.month - 1,
|
|
3322
|
-
parts.day,
|
|
3323
|
-
parts.hour,
|
|
3324
|
-
parts.minute,
|
|
3325
|
-
parts.second
|
|
3326
|
-
);
|
|
3327
|
-
}
|
|
3328
|
-
function instantOf(wall, zone) {
|
|
3329
|
-
let instant = wall - (wallClock(wall, zone) - wall);
|
|
3330
|
-
instant = wall - (wallClock(instant, zone) - instant);
|
|
3331
|
-
return instant;
|
|
3332
|
-
}
|
|
3333
|
-
var parse = (at) => at.split(":").map(Number);
|
|
3334
|
-
function lastSlot(now, at, zone) {
|
|
3335
|
-
const [hours2, minutes] = parse(at);
|
|
3336
|
-
if (!zone) {
|
|
3337
|
-
const slot2 = new Date(now);
|
|
3338
|
-
slot2.setHours(hours2, minutes, 0, 0);
|
|
3339
|
-
if (slot2.getTime() > now.getTime()) slot2.setDate(slot2.getDate() - 1);
|
|
3340
|
-
return slot2;
|
|
3341
|
-
}
|
|
3342
|
-
const today = wallClock(now.getTime(), zone);
|
|
3343
|
-
const midnight = today - today % DAY2;
|
|
3344
|
-
let slot = instantOf(midnight + hours2 * 36e5 + minutes * 6e4, zone);
|
|
3345
|
-
if (slot > now.getTime())
|
|
3346
|
-
slot = instantOf(midnight - DAY2 + hours2 * 36e5 + minutes * 6e4, zone);
|
|
3347
|
-
return new Date(slot);
|
|
3348
|
-
}
|
|
3349
|
-
function nextSlot(after, at, zone) {
|
|
3350
|
-
const last = lastSlot(after, at, zone);
|
|
3351
|
-
return lastSlot(new Date(last.getTime() + DAY2 + 2 * 36e5), at, zone);
|
|
3352
|
-
}
|
|
3353
|
-
function dueAt(now, at, every, lastRun, zone) {
|
|
3354
|
-
if (lastRun === void 0) return true;
|
|
3355
|
-
const last = Date.parse(lastRun);
|
|
3356
|
-
if (Number.isNaN(last)) return true;
|
|
3357
|
-
const slot = lastSlot(now, at, zone).getTime();
|
|
3358
|
-
return last < slot - (every * 1e3 - DAY2);
|
|
3359
|
-
}
|
|
3360
|
-
function nextAt(at, every, lastRun, zone) {
|
|
3361
|
-
return nextSlot(new Date(Date.parse(lastRun) + (every * 1e3 - DAY2)), at, zone);
|
|
3824
|
+
};
|
|
3362
3825
|
}
|
|
3363
3826
|
|
|
3364
3827
|
// src/backups.ts
|
|
3828
|
+
import { createCipheriv, createDecipheriv, createHash as createHash3, randomBytes as randomBytes5 } from "crypto";
|
|
3829
|
+
import { mkdtemp, readFile as readFile2, rm as rm2 } from "fs/promises";
|
|
3830
|
+
import { tmpdir } from "os";
|
|
3831
|
+
import { join as join2 } from "path";
|
|
3832
|
+
import { promisify } from "util";
|
|
3833
|
+
import { gzip } from "zlib";
|
|
3365
3834
|
var gzipAsync = promisify(gzip);
|
|
3366
3835
|
var MAGIC = Buffer.from("ECMSBAK1");
|
|
3367
3836
|
var IV_BYTES = 12;
|
|
@@ -3559,11 +4028,13 @@ async function runBackup(cms, record) {
|
|
|
3559
4028
|
});
|
|
3560
4029
|
cms.logger.info(`Backup ${key} written (${data.byteLength} bytes)`);
|
|
3561
4030
|
} catch (error) {
|
|
4031
|
+
const message2 = (error.message || String(error)).slice(0, 500);
|
|
3562
4032
|
await update(cms, row, {
|
|
3563
4033
|
state: "failed",
|
|
3564
|
-
error:
|
|
4034
|
+
error: message2,
|
|
3565
4035
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3566
4036
|
});
|
|
4037
|
+
cms.systemEvent("backup.failed", { backup: row.id, error: message2 });
|
|
3567
4038
|
throw error;
|
|
3568
4039
|
} finally {
|
|
3569
4040
|
await rm2(dir, { recursive: true, force: true });
|
|
@@ -4309,7 +4780,7 @@ var VersionStore = class {
|
|
|
4309
4780
|
};
|
|
4310
4781
|
|
|
4311
4782
|
// src/local-api.ts
|
|
4312
|
-
import { createHash as
|
|
4783
|
+
import { createHash as createHash6, createHmac as createHmac8, randomBytes as randomBytes7, randomUUID as randomUUID3 } from "crypto";
|
|
4313
4784
|
|
|
4314
4785
|
// src/auth/common-passwords.ts
|
|
4315
4786
|
var LIST = `
|
|
@@ -4826,6 +5297,685 @@ function toOperation(row) {
|
|
|
4826
5297
|
};
|
|
4827
5298
|
}
|
|
4828
5299
|
|
|
5300
|
+
// src/integrations.ts
|
|
5301
|
+
import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, createHash as createHash4, hkdfSync, randomBytes as randomBytes6 } from "crypto";
|
|
5302
|
+
var ENV_NAME = "Environment";
|
|
5303
|
+
var PENDING_MS = 10 * 60 * 1e3;
|
|
5304
|
+
var keyOf2 = (secret) => Buffer.from(hkdfSync("sha256", secret, "", "easy-cms:connections", 32));
|
|
5305
|
+
var keyId = (key) => createHash4("sha256").update(key).digest("hex").slice(0, 12);
|
|
5306
|
+
function encryptValues(secret, values) {
|
|
5307
|
+
const key = keyOf2(secret);
|
|
5308
|
+
const iv = randomBytes6(12);
|
|
5309
|
+
const cipher = createCipheriv2("aes-256-gcm", key, iv);
|
|
5310
|
+
const data = Buffer.concat([cipher.update(JSON.stringify(values), "utf8"), cipher.final()]);
|
|
5311
|
+
const box = {
|
|
5312
|
+
k: keyId(key),
|
|
5313
|
+
iv: iv.toString("base64"),
|
|
5314
|
+
t: cipher.getAuthTag().toString("base64"),
|
|
5315
|
+
d: data.toString("base64")
|
|
5316
|
+
};
|
|
5317
|
+
return Buffer.from(JSON.stringify(box)).toString("base64url");
|
|
5318
|
+
}
|
|
5319
|
+
function decryptValues(secrets, sealed) {
|
|
5320
|
+
const box = JSON.parse(Buffer.from(sealed, "base64url").toString("utf8"));
|
|
5321
|
+
for (const [i, secret] of secrets.entries()) {
|
|
5322
|
+
const key = keyOf2(secret);
|
|
5323
|
+
if (keyId(key) !== box.k) continue;
|
|
5324
|
+
const decipher = createDecipheriv2("aes-256-gcm", key, Buffer.from(String(box.iv), "base64"));
|
|
5325
|
+
decipher.setAuthTag(Buffer.from(String(box.t), "base64"));
|
|
5326
|
+
const text4 = Buffer.concat([
|
|
5327
|
+
decipher.update(Buffer.from(String(box.d), "base64")),
|
|
5328
|
+
decipher.final()
|
|
5329
|
+
]).toString("utf8");
|
|
5330
|
+
return { values: JSON.parse(text4), old: i > 0 };
|
|
5331
|
+
}
|
|
5332
|
+
throw new Error("A connection was encrypted with a secret that is no longer configured");
|
|
5333
|
+
}
|
|
5334
|
+
var keyIdOf = (sealed) => {
|
|
5335
|
+
try {
|
|
5336
|
+
return JSON.parse(Buffer.from(String(sealed), "base64url").toString("utf8")).k;
|
|
5337
|
+
} catch {
|
|
5338
|
+
return void 0;
|
|
5339
|
+
}
|
|
5340
|
+
};
|
|
5341
|
+
function signState(secret, payload) {
|
|
5342
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
5343
|
+
return `${body}.${signValue(secret, `integration:${body}`)}`;
|
|
5344
|
+
}
|
|
5345
|
+
function readState(secrets, state) {
|
|
5346
|
+
const [body, signature] = state.split(".");
|
|
5347
|
+
if (!body || !signature || !signedWith(secrets, `integration:${body}`, signature)) return null;
|
|
5348
|
+
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
5349
|
+
return typeof payload.exp === "number" && payload.exp > Date.now() ? payload : null;
|
|
5350
|
+
}
|
|
5351
|
+
var OFFICIAL_PLUGINS = [
|
|
5352
|
+
{
|
|
5353
|
+
name: "@easy-cms/plugin-social",
|
|
5354
|
+
category: "social",
|
|
5355
|
+
description: {
|
|
5356
|
+
en: "Post to Facebook, LINE and webhooks from the admin",
|
|
5357
|
+
th: "\u0E42\u0E1E\u0E2A\u0E15\u0E4C\u0E44\u0E1B Facebook, LINE \u0E41\u0E25\u0E30 webhook \u0E08\u0E32\u0E01 admin"
|
|
5358
|
+
},
|
|
5359
|
+
docs: "https://easycms.dev/docs/integrations"
|
|
5360
|
+
},
|
|
5361
|
+
{
|
|
5362
|
+
name: "@easy-cms/plugin-notify",
|
|
5363
|
+
category: "notifications",
|
|
5364
|
+
description: { en: "Telegram notifications for admins", th: "\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E17\u0E32\u0E07 Telegram" },
|
|
5365
|
+
docs: "https://easycms.dev/docs/notifications"
|
|
5366
|
+
},
|
|
5367
|
+
{
|
|
5368
|
+
name: "@easy-cms/plugin-ai",
|
|
5369
|
+
category: "ai",
|
|
5370
|
+
description: {
|
|
5371
|
+
en: "Claude, GPT and Gemini for alt text, SEO and translation",
|
|
5372
|
+
th: "Claude, GPT \u0E41\u0E25\u0E30 Gemini \u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A alt text, SEO \u0E41\u0E25\u0E30\u0E41\u0E1B\u0E25\u0E20\u0E32\u0E29\u0E32"
|
|
5373
|
+
},
|
|
5374
|
+
docs: "https://easycms.dev/docs/ai"
|
|
5375
|
+
},
|
|
5376
|
+
{
|
|
5377
|
+
name: "@easy-cms/plugin-ecommerce",
|
|
5378
|
+
category: "payments",
|
|
5379
|
+
description: { en: "A shop with Stripe payments", th: "\u0E23\u0E49\u0E32\u0E19\u0E04\u0E49\u0E32\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E0A\u0E33\u0E23\u0E30\u0E40\u0E07\u0E34\u0E19\u0E1C\u0E48\u0E32\u0E19 Stripe" },
|
|
5380
|
+
docs: "https://easycms.dev/docs/ecommerce"
|
|
5381
|
+
}
|
|
5382
|
+
];
|
|
5383
|
+
var mask = (value) => value.length > 4 ? `\u2022\u2022\u2022\u2022${value.slice(-4)}` : "\u2022\u2022\u2022\u2022";
|
|
5384
|
+
var Integrations = class {
|
|
5385
|
+
constructor(cms) {
|
|
5386
|
+
this.cms = cms;
|
|
5387
|
+
}
|
|
5388
|
+
cms;
|
|
5389
|
+
get config() {
|
|
5390
|
+
return this.cms.config;
|
|
5391
|
+
}
|
|
5392
|
+
get secrets() {
|
|
5393
|
+
return secretsOf(this.config);
|
|
5394
|
+
}
|
|
5395
|
+
/** The integrations plugins declared. */
|
|
5396
|
+
get definitions() {
|
|
5397
|
+
return this.config.integrations;
|
|
5398
|
+
}
|
|
5399
|
+
definition(id) {
|
|
5400
|
+
const found = this.definitions.find((d) => d.id === id);
|
|
5401
|
+
if (!found) throw new NotFoundError("integrations", id);
|
|
5402
|
+
return found;
|
|
5403
|
+
}
|
|
5404
|
+
async rows(id, kind) {
|
|
5405
|
+
const found = await this.cms.db.find({
|
|
5406
|
+
collection: CONNECTIONS,
|
|
5407
|
+
where: { and: [{ integration: { equals: id } }, { kind: { equals: kind } }] },
|
|
5408
|
+
sort: ["id"],
|
|
5409
|
+
limit: 0,
|
|
5410
|
+
page: 1
|
|
5411
|
+
});
|
|
5412
|
+
return found.docs;
|
|
5413
|
+
}
|
|
5414
|
+
/** The values of the fields set in the environment. */
|
|
5415
|
+
envValues(def) {
|
|
5416
|
+
const out = {};
|
|
5417
|
+
for (const [field, name] of Object.entries(def.env ?? {})) {
|
|
5418
|
+
const value = process.env[name];
|
|
5419
|
+
if (value) out[field] = value;
|
|
5420
|
+
}
|
|
5421
|
+
return out;
|
|
5422
|
+
}
|
|
5423
|
+
/** Every field is in the environment: an "Environment" connection. */
|
|
5424
|
+
envComplete(def) {
|
|
5425
|
+
const env = this.envValues(def);
|
|
5426
|
+
const fields = def.fields ?? [];
|
|
5427
|
+
return fields.length > 0 && fields.every((f) => env[f.name] || !f.required) && Object.keys(env).length > 0;
|
|
5428
|
+
}
|
|
5429
|
+
async enabled(id) {
|
|
5430
|
+
const [row] = await this.rows(id, "integration");
|
|
5431
|
+
return row ? row.enabled !== false : true;
|
|
5432
|
+
}
|
|
5433
|
+
decrypt(row) {
|
|
5434
|
+
const plain = row.values ?? {};
|
|
5435
|
+
if (!row.secret) return { ...plain };
|
|
5436
|
+
const { values, old } = decryptValues(this.secrets, String(row.secret));
|
|
5437
|
+
if (old) void this.reseal(row, values);
|
|
5438
|
+
return { ...plain, ...values };
|
|
5439
|
+
}
|
|
5440
|
+
async reseal(row, secrets) {
|
|
5441
|
+
await this.cms.db.update({
|
|
5442
|
+
collection: CONNECTIONS,
|
|
5443
|
+
id: row.id,
|
|
5444
|
+
data: { ...row, secret: encryptValues(this.config.secret, secrets) }
|
|
5445
|
+
});
|
|
5446
|
+
}
|
|
5447
|
+
/** Splits values into the plain ones and the secret ones (encrypted). */
|
|
5448
|
+
seal(def, values) {
|
|
5449
|
+
const secretNames = new Set((def.fields ?? []).filter((f) => f.secret).map((f) => f.name));
|
|
5450
|
+
const plain = {};
|
|
5451
|
+
const secret = {};
|
|
5452
|
+
for (const [name, value] of Object.entries(values))
|
|
5453
|
+
(secretNames.has(name) || def.kind === "oauth" ? secret : plain)[name] = value;
|
|
5454
|
+
return {
|
|
5455
|
+
values: plain,
|
|
5456
|
+
secret: Object.keys(secret).length ? encryptValues(this.config.secret, secret) : null
|
|
5457
|
+
};
|
|
5458
|
+
}
|
|
5459
|
+
/**
|
|
5460
|
+
* A connection for a plugin: the default one, or the one named; `null` when there is none
|
|
5461
|
+
* or the integration is turned off. Values from the environment win.
|
|
5462
|
+
*/
|
|
5463
|
+
async get(id, options = {}) {
|
|
5464
|
+
const def = this.definition(id);
|
|
5465
|
+
if (!await this.enabled(id)) return null;
|
|
5466
|
+
const env = this.envValues(def);
|
|
5467
|
+
if (this.envComplete(def) && (!options.name || options.name === ENV_NAME))
|
|
5468
|
+
return { name: ENV_NAME, values: env, env: true };
|
|
5469
|
+
const rows2 = await this.rows(id, "connection");
|
|
5470
|
+
const row = options.name ? rows2.find((r) => r.name === options.name) : rows2.find((r) => r.isDefault === true) ?? rows2[0];
|
|
5471
|
+
if (!row) return null;
|
|
5472
|
+
return { name: String(row.name), values: { ...this.decrypt(row), ...env }, env: false };
|
|
5473
|
+
}
|
|
5474
|
+
/**
|
|
5475
|
+
* Every connection of an integration (the environment's first), e.g. to notify each chat;
|
|
5476
|
+
* none when it is turned off.
|
|
5477
|
+
*/
|
|
5478
|
+
async all(id) {
|
|
5479
|
+
const def = this.definition(id);
|
|
5480
|
+
if (!await this.enabled(id)) return [];
|
|
5481
|
+
const env = this.envValues(def);
|
|
5482
|
+
const out = this.envComplete(def) ? [{ name: ENV_NAME, values: env, env: true }] : [];
|
|
5483
|
+
for (const row of await this.rows(id, "connection"))
|
|
5484
|
+
out.push({ name: String(row.name), values: { ...this.decrypt(row), ...env }, env: false });
|
|
5485
|
+
return out;
|
|
5486
|
+
}
|
|
5487
|
+
/** The connection was used: its card says when, and an earlier error is gone. */
|
|
5488
|
+
async markUsed(id, name) {
|
|
5489
|
+
await this.mark(id, name, { lastUsedAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null });
|
|
5490
|
+
}
|
|
5491
|
+
/** It failed: the card and the dashboard's "needs attention" say why. */
|
|
5492
|
+
async markError(id, message2, name) {
|
|
5493
|
+
await this.mark(id, name, { lastError: message2.slice(0, 500) });
|
|
5494
|
+
this.cms.systemEvent("integration.failed", {
|
|
5495
|
+
integration: id,
|
|
5496
|
+
name: name ?? null,
|
|
5497
|
+
error: message2
|
|
5498
|
+
});
|
|
5499
|
+
}
|
|
5500
|
+
async mark(id, name, patch) {
|
|
5501
|
+
const rows2 = await this.rows(id, "connection");
|
|
5502
|
+
const row = name ? rows2.find((r) => r.name === name) : rows2.find((r) => r.isDefault === true) ?? rows2[0];
|
|
5503
|
+
if (!row) return;
|
|
5504
|
+
await this.cms.db.update({
|
|
5505
|
+
collection: CONNECTIONS,
|
|
5506
|
+
id: row.id,
|
|
5507
|
+
data: { ...row, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
5508
|
+
});
|
|
5509
|
+
}
|
|
5510
|
+
// --- The admin's page ------------------------------------------------------------------------
|
|
5511
|
+
async overview() {
|
|
5512
|
+
const integrations = [];
|
|
5513
|
+
const usage = await this.cms.ai?.usageByConnection() ?? /* @__PURE__ */ new Map();
|
|
5514
|
+
const aiProviders = new Set(this.config.ai?.providers.map((p) => p.integration) ?? []);
|
|
5515
|
+
const current = keyId(keyOf2(this.config.secret));
|
|
5516
|
+
let stale = 0;
|
|
5517
|
+
for (const def of this.definitions) {
|
|
5518
|
+
const rows2 = await this.rows(def.id, "connection");
|
|
5519
|
+
const env = this.envValues(def);
|
|
5520
|
+
const connections = rows2.map((row) => {
|
|
5521
|
+
if (row.secret && keyIdOf(row.secret) !== current) stale++;
|
|
5522
|
+
const secretNames = (def.fields ?? []).filter((f) => f.secret).map((f) => f.name);
|
|
5523
|
+
let values = { ...row.values ?? {} };
|
|
5524
|
+
try {
|
|
5525
|
+
const all = this.decrypt(row);
|
|
5526
|
+
for (const name of secretNames) if (all[name]) values[name] = mask(all[name]);
|
|
5527
|
+
} catch {
|
|
5528
|
+
values = { ...values };
|
|
5529
|
+
}
|
|
5530
|
+
return {
|
|
5531
|
+
id: row.id,
|
|
5532
|
+
name: String(row.name),
|
|
5533
|
+
isDefault: row.isDefault === true,
|
|
5534
|
+
env: false,
|
|
5535
|
+
account: row.account ?? null,
|
|
5536
|
+
lastUsedAt: row.lastUsedAt ?? null,
|
|
5537
|
+
lastError: row.lastError ?? null,
|
|
5538
|
+
values
|
|
5539
|
+
};
|
|
5540
|
+
});
|
|
5541
|
+
if (this.envComplete(def)) {
|
|
5542
|
+
for (const c of connections) c.isDefault = false;
|
|
5543
|
+
connections.unshift({
|
|
5544
|
+
id: "env",
|
|
5545
|
+
name: ENV_NAME,
|
|
5546
|
+
isDefault: true,
|
|
5547
|
+
env: true,
|
|
5548
|
+
account: null,
|
|
5549
|
+
lastUsedAt: null,
|
|
5550
|
+
lastError: null,
|
|
5551
|
+
values: Object.fromEntries(
|
|
5552
|
+
Object.entries(env).map(
|
|
5553
|
+
([k, v]) => (def.fields ?? []).find((f) => f.name === k)?.secret ? [k, mask(v)] : [k, v]
|
|
5554
|
+
)
|
|
5555
|
+
)
|
|
5556
|
+
});
|
|
5557
|
+
}
|
|
5558
|
+
integrations.push({
|
|
5559
|
+
id: def.id,
|
|
5560
|
+
label: def.label,
|
|
5561
|
+
category: def.category,
|
|
5562
|
+
...def.description ? { description: def.description } : {},
|
|
5563
|
+
...def.icon ? { icon: def.icon } : {},
|
|
5564
|
+
...def.docs ? { docs: def.docs } : {},
|
|
5565
|
+
...def.plugin ? { plugin: def.plugin } : {},
|
|
5566
|
+
kind: def.kind,
|
|
5567
|
+
// Functions don't travel: `findable` says a field has a Find button.
|
|
5568
|
+
fields: (def.fields ?? []).map(({ lookup, ...field }) => ({
|
|
5569
|
+
...field,
|
|
5570
|
+
...lookup ? { findable: true } : {}
|
|
5571
|
+
})),
|
|
5572
|
+
enabled: await this.enabled(def.id),
|
|
5573
|
+
envFields: Object.keys(env),
|
|
5574
|
+
testable: typeof def.test === "function",
|
|
5575
|
+
connections: aiProviders.has(def.id) ? connections.map((c) => ({
|
|
5576
|
+
...c,
|
|
5577
|
+
usage: usage.get(`${def.id}:${c.name}`) ?? { requests: 0, input: 0, output: 0 }
|
|
5578
|
+
})) : connections,
|
|
5579
|
+
...def.consent ? { consent: def.consent, consented: await this.consented(def.id) } : {}
|
|
5580
|
+
});
|
|
5581
|
+
}
|
|
5582
|
+
const installed = new Set(this.config.installedPlugins.map((p) => p.name));
|
|
5583
|
+
return {
|
|
5584
|
+
integrations,
|
|
5585
|
+
configured: this.configured(),
|
|
5586
|
+
catalog: OFFICIAL_PLUGINS.filter((p) => !installed.has(p.name)),
|
|
5587
|
+
staleSecrets: stale
|
|
5588
|
+
};
|
|
5589
|
+
}
|
|
5590
|
+
/** The adapters the config sets: email, storage, search, sign-in, payments. */
|
|
5591
|
+
configured() {
|
|
5592
|
+
const out = [];
|
|
5593
|
+
const { config } = this;
|
|
5594
|
+
if (config.email) out.push({ id: "email", kind: "email", name: config.email.name ?? "email" });
|
|
5595
|
+
out.push({ id: "storage", kind: "storage", name: config.upload.storage?.name ?? "disk" });
|
|
5596
|
+
if (config.search)
|
|
5597
|
+
out.push({ id: "search", kind: "search", name: config.search.name ?? "search" });
|
|
5598
|
+
for (const provider of config.auth.providers ?? [])
|
|
5599
|
+
out.push({ id: `sign-in:${provider.id}`, kind: "sign-in", name: provider.id });
|
|
5600
|
+
if (config.installedPlugins.some((p) => p.name === "@easy-cms/plugin-ecommerce"))
|
|
5601
|
+
out.push({ id: "payments", kind: "payments", name: "Stripe" });
|
|
5602
|
+
return out;
|
|
5603
|
+
}
|
|
5604
|
+
async audit(user, action, id, detail = {}) {
|
|
5605
|
+
await this.cms.audit.record({ action: `integration.${action}`, target: id, user, detail });
|
|
5606
|
+
}
|
|
5607
|
+
checkFields(def, values, partial) {
|
|
5608
|
+
const errors = (def.fields ?? []).filter((f) => f.required && !partial && !values[f.name]).map((f) => ({ field: f.name, message: "is required" }));
|
|
5609
|
+
if (errors.length) throw new ValidationError(CONNECTIONS, errors);
|
|
5610
|
+
}
|
|
5611
|
+
cleanValues(def, raw) {
|
|
5612
|
+
const input = raw && typeof raw === "object" ? raw : {};
|
|
5613
|
+
const out = {};
|
|
5614
|
+
for (const field of def.fields ?? []) {
|
|
5615
|
+
const raw2 = input[field.name];
|
|
5616
|
+
const value = Array.isArray(raw2) ? raw2.filter((v) => typeof v === "string").join(",") : raw2;
|
|
5617
|
+
if (typeof value === "string" && value.trim() !== "") out[field.name] = value.trim();
|
|
5618
|
+
}
|
|
5619
|
+
return out;
|
|
5620
|
+
}
|
|
5621
|
+
async uniqueName(id, name, self) {
|
|
5622
|
+
const trimmed = name.trim();
|
|
5623
|
+
if (!trimmed || trimmed.length > 60)
|
|
5624
|
+
throw new ValidationError(CONNECTIONS, [
|
|
5625
|
+
{ field: "name", message: "must be 1 to 60 characters" }
|
|
5626
|
+
]);
|
|
5627
|
+
if (trimmed === ENV_NAME || (await this.rows(id, "connection")).some((r) => r.name === trimmed && r.id !== self))
|
|
5628
|
+
throw new ValidationError(CONNECTIONS, [
|
|
5629
|
+
{ field: "name", message: `there is already a connection named "${trimmed}"` }
|
|
5630
|
+
]);
|
|
5631
|
+
return trimmed;
|
|
5632
|
+
}
|
|
5633
|
+
async create(def, name, values, account) {
|
|
5634
|
+
const existing = await this.rows(def.id, "connection");
|
|
5635
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5636
|
+
return await this.cms.db.create({
|
|
5637
|
+
collection: CONNECTIONS,
|
|
5638
|
+
data: {
|
|
5639
|
+
kind: "connection",
|
|
5640
|
+
integration: def.id,
|
|
5641
|
+
name,
|
|
5642
|
+
isDefault: existing.length === 0,
|
|
5643
|
+
...this.seal(def, values),
|
|
5644
|
+
account,
|
|
5645
|
+
lastUsedAt: null,
|
|
5646
|
+
lastError: null,
|
|
5647
|
+
enabled: null,
|
|
5648
|
+
createdAt: now,
|
|
5649
|
+
updatedAt: now
|
|
5650
|
+
}
|
|
5651
|
+
});
|
|
5652
|
+
}
|
|
5653
|
+
/** Connects with pasted values (`kind: 'fields'`); tested first when the plugin can. */
|
|
5654
|
+
async connect(id, body, user) {
|
|
5655
|
+
const def = this.definition(id);
|
|
5656
|
+
if (def.kind !== "fields") throw new QueryError(`${id} connects with its Connect button`);
|
|
5657
|
+
await this.checkConsent(def);
|
|
5658
|
+
const input = body ?? {};
|
|
5659
|
+
const name = await this.uniqueName(id, String(input.name ?? ""));
|
|
5660
|
+
const values = this.cleanValues(def, input.values);
|
|
5661
|
+
this.checkFields(def, values, false);
|
|
5662
|
+
const result = def.test ? await def.test(values, { cms: this.cms, name }) : null;
|
|
5663
|
+
if (result && !result.ok)
|
|
5664
|
+
throw new ValidationError(CONNECTIONS, [
|
|
5665
|
+
{ field: "values", message: result.message ?? "the provider refused these values" }
|
|
5666
|
+
]);
|
|
5667
|
+
const row = await this.create(def, name, values, result?.account ?? null);
|
|
5668
|
+
await this.audit(user, "connect", id, { name });
|
|
5669
|
+
return { id: row.id };
|
|
5670
|
+
}
|
|
5671
|
+
async row(id, cid) {
|
|
5672
|
+
const row = await this.cms.db.findById({ collection: CONNECTIONS, id: cid });
|
|
5673
|
+
if (!row || row.integration !== id || row.kind !== "connection")
|
|
5674
|
+
throw new NotFoundError(CONNECTIONS, cid);
|
|
5675
|
+
return row;
|
|
5676
|
+
}
|
|
5677
|
+
/** A new name or values (secrets left empty are kept), or the default. */
|
|
5678
|
+
async update(id, cid, body, user) {
|
|
5679
|
+
const def = this.definition(id);
|
|
5680
|
+
const row = await this.row(id, cid);
|
|
5681
|
+
const input = body ?? {};
|
|
5682
|
+
const next = { ...row, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5683
|
+
if (input.name !== void 0) next.name = await this.uniqueName(id, String(input.name), cid);
|
|
5684
|
+
if (input.values !== void 0) {
|
|
5685
|
+
const values = { ...this.decrypt(row), ...this.cleanValues(def, input.values) };
|
|
5686
|
+
this.checkFields(def, values, false);
|
|
5687
|
+
Object.assign(next, this.seal(def, values), { lastError: null });
|
|
5688
|
+
}
|
|
5689
|
+
if (input.isDefault === true) {
|
|
5690
|
+
for (const other of await this.rows(id, "connection"))
|
|
5691
|
+
if (other.id !== cid && other.isDefault === true)
|
|
5692
|
+
await this.cms.db.update({
|
|
5693
|
+
collection: CONNECTIONS,
|
|
5694
|
+
id: other.id,
|
|
5695
|
+
data: { ...other, isDefault: false }
|
|
5696
|
+
});
|
|
5697
|
+
next.isDefault = true;
|
|
5698
|
+
}
|
|
5699
|
+
await this.cms.db.update({ collection: CONNECTIONS, id: cid, data: next });
|
|
5700
|
+
await this.audit(user, input.isDefault === true ? "default" : "update", id, { name: next.name });
|
|
5701
|
+
return { ok: true };
|
|
5702
|
+
}
|
|
5703
|
+
/** Removes the connection and its secrets; the oldest left becomes the default. */
|
|
5704
|
+
async disconnect(id, cid, user) {
|
|
5705
|
+
const row = await this.row(id, cid);
|
|
5706
|
+
await this.cms.db.delete({ collection: CONNECTIONS, id: cid });
|
|
5707
|
+
if (row.isDefault === true) {
|
|
5708
|
+
const [oldest] = await this.rows(id, "connection");
|
|
5709
|
+
if (oldest)
|
|
5710
|
+
await this.cms.db.update({
|
|
5711
|
+
collection: CONNECTIONS,
|
|
5712
|
+
id: oldest.id,
|
|
5713
|
+
data: { ...oldest, isDefault: true }
|
|
5714
|
+
});
|
|
5715
|
+
}
|
|
5716
|
+
await this.audit(user, "disconnect", id, { name: row.name });
|
|
5717
|
+
return { deleted: 1 };
|
|
5718
|
+
}
|
|
5719
|
+
/** Turns the integration on or off; its connections are kept. */
|
|
5720
|
+
async setEnabled(id, enabled, user) {
|
|
5721
|
+
this.definition(id);
|
|
5722
|
+
await this.saveIntegration(id, { enabled });
|
|
5723
|
+
await this.audit(user, enabled ? "enable" : "disable", id);
|
|
5724
|
+
return { enabled };
|
|
5725
|
+
}
|
|
5726
|
+
/** Who accepted the integration's `consent`, and when; `null` before anyone did. */
|
|
5727
|
+
async consented(id) {
|
|
5728
|
+
const [row] = await this.rows(id, "integration");
|
|
5729
|
+
const consent = (row?.values ?? {}).consent;
|
|
5730
|
+
return consent ?? null;
|
|
5731
|
+
}
|
|
5732
|
+
async checkConsent(def) {
|
|
5733
|
+
if (def.consent && !await this.consented(def.id))
|
|
5734
|
+
throw new ValidationError(CONNECTIONS, [
|
|
5735
|
+
{ field: "consent", message: "must be accepted before connecting" }
|
|
5736
|
+
]);
|
|
5737
|
+
}
|
|
5738
|
+
/** An admin accepts the integration's `consent`; audit-logged. */
|
|
5739
|
+
async consent(id, user) {
|
|
5740
|
+
const def = this.definition(id);
|
|
5741
|
+
if (!def.consent) throw new QueryError(`${id} asks for no consent`);
|
|
5742
|
+
const [row] = await this.rows(id, "integration");
|
|
5743
|
+
const consent = { at: (/* @__PURE__ */ new Date()).toISOString(), by: String(user.email ?? user.id) };
|
|
5744
|
+
await this.saveIntegration(id, { values: { ...row?.values ?? {}, consent } });
|
|
5745
|
+
await this.audit(user, "consent", id);
|
|
5746
|
+
return { consented: consent };
|
|
5747
|
+
}
|
|
5748
|
+
/** The integration's own row (on or off, consent), made when first needed. */
|
|
5749
|
+
async saveIntegration(id, patch) {
|
|
5750
|
+
const [row] = await this.rows(id, "integration");
|
|
5751
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5752
|
+
if (row)
|
|
5753
|
+
await this.cms.db.update({
|
|
5754
|
+
collection: CONNECTIONS,
|
|
5755
|
+
id: row.id,
|
|
5756
|
+
data: { ...row, ...patch, updatedAt: now }
|
|
5757
|
+
});
|
|
5758
|
+
else
|
|
5759
|
+
await this.cms.db.create({
|
|
5760
|
+
collection: CONNECTIONS,
|
|
5761
|
+
data: {
|
|
5762
|
+
kind: "integration",
|
|
5763
|
+
integration: id,
|
|
5764
|
+
name: null,
|
|
5765
|
+
isDefault: false,
|
|
5766
|
+
values: null,
|
|
5767
|
+
secret: null,
|
|
5768
|
+
account: null,
|
|
5769
|
+
lastUsedAt: null,
|
|
5770
|
+
lastError: null,
|
|
5771
|
+
enabled: null,
|
|
5772
|
+
createdAt: now,
|
|
5773
|
+
updatedAt: now,
|
|
5774
|
+
...patch
|
|
5775
|
+
}
|
|
5776
|
+
});
|
|
5777
|
+
}
|
|
5778
|
+
/** Checks a connection's values with the provider; the card shows the result. */
|
|
5779
|
+
async test(id, cid) {
|
|
5780
|
+
const def = this.definition(id);
|
|
5781
|
+
if (!def.test) throw new QueryError(`${id} can't be tested`);
|
|
5782
|
+
const values = cid === "env" ? this.envValues(def) : { ...this.decrypt(await this.row(id, cid)), ...this.envValues(def) };
|
|
5783
|
+
const name = cid === "env" ? ENV_NAME : String((await this.row(id, cid)).name);
|
|
5784
|
+
let result;
|
|
5785
|
+
try {
|
|
5786
|
+
result = await def.test(values, { cms: this.cms, name });
|
|
5787
|
+
} catch (error) {
|
|
5788
|
+
result = { ok: false, message: error.message };
|
|
5789
|
+
}
|
|
5790
|
+
if (cid !== "env") {
|
|
5791
|
+
const row = await this.row(id, cid);
|
|
5792
|
+
await this.cms.db.update({
|
|
5793
|
+
collection: CONNECTIONS,
|
|
5794
|
+
id: cid,
|
|
5795
|
+
data: {
|
|
5796
|
+
...row,
|
|
5797
|
+
...result.ok ? { lastError: null, ...result.account ? { account: result.account } : {} } : { lastError: result.message ?? "Test failed" },
|
|
5798
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5799
|
+
}
|
|
5800
|
+
});
|
|
5801
|
+
if (!result.ok)
|
|
5802
|
+
this.cms.systemEvent("integration.failed", {
|
|
5803
|
+
integration: id,
|
|
5804
|
+
name: row.name ?? null,
|
|
5805
|
+
error: result.message ?? "Test failed"
|
|
5806
|
+
});
|
|
5807
|
+
}
|
|
5808
|
+
return result;
|
|
5809
|
+
}
|
|
5810
|
+
/**
|
|
5811
|
+
* A field's choices from the provider, with the values typed (and, editing a connection, its
|
|
5812
|
+
* stored secrets for the fields left empty).
|
|
5813
|
+
*/
|
|
5814
|
+
async lookup(id, fieldName, body) {
|
|
5815
|
+
const def = this.definition(id);
|
|
5816
|
+
const field = (def.fields ?? []).find((f) => f.name === fieldName);
|
|
5817
|
+
if (!field?.lookup) throw new QueryError(`${id}.${fieldName} has nothing to look up`);
|
|
5818
|
+
const input = body ?? {};
|
|
5819
|
+
const stored = input.connection !== void 0 && input.connection !== null ? this.decrypt(await this.row(id, input.connection)) : {};
|
|
5820
|
+
const values = { ...stored, ...this.envValues(def), ...this.cleanValues(def, input.values) };
|
|
5821
|
+
try {
|
|
5822
|
+
return { choices: [...await field.lookup(values, { cms: this.cms })].slice(0, 100) };
|
|
5823
|
+
} catch (error) {
|
|
5824
|
+
throw new ValidationError(CONNECTIONS, [
|
|
5825
|
+
{ field: fieldName, message: error.message }
|
|
5826
|
+
]);
|
|
5827
|
+
}
|
|
5828
|
+
}
|
|
5829
|
+
// --- OAuth -----------------------------------------------------------------------------------
|
|
5830
|
+
/** The provider's sign-in page to send the admin to. */
|
|
5831
|
+
async authorize(id, user, redirectUri, name) {
|
|
5832
|
+
const def = this.definition(id);
|
|
5833
|
+
if (def.kind !== "oauth" || !def.oauth)
|
|
5834
|
+
throw new QueryError(`${id} connects with pasted values`);
|
|
5835
|
+
await this.checkConsent(def);
|
|
5836
|
+
const state = signState(this.config.secret, {
|
|
5837
|
+
i: id,
|
|
5838
|
+
u: String(user.id),
|
|
5839
|
+
n: name,
|
|
5840
|
+
exp: Date.now() + PENDING_MS
|
|
5841
|
+
});
|
|
5842
|
+
return { url: def.oauth.authorizeUrl({ redirectUri, state }) };
|
|
5843
|
+
}
|
|
5844
|
+
/**
|
|
5845
|
+
* Back from the provider: one account is connected at once; several wait (encrypted, 10
|
|
5846
|
+
* minutes) for the admin to choose. Returns where the admin goes next.
|
|
5847
|
+
*/
|
|
5848
|
+
async callback(id, code, stateText, redirectUri, user) {
|
|
5849
|
+
const def = this.definition(id);
|
|
5850
|
+
const state = readState(this.secrets, stateText);
|
|
5851
|
+
if (!state || state.i !== id || state.u !== String(user.id) || !def.oauth)
|
|
5852
|
+
throw new ForbiddenError(
|
|
5853
|
+
"This connection link has expired: start again from the Integrations page"
|
|
5854
|
+
);
|
|
5855
|
+
const result = await def.oauth.exchange({ code, redirectUri });
|
|
5856
|
+
const name = typeof state.n === "string" && state.n ? state.n : String(def.label);
|
|
5857
|
+
if (result.accounts && result.accounts.length > 0) {
|
|
5858
|
+
if (result.accounts.length === 1) {
|
|
5859
|
+
const [account] = result.accounts;
|
|
5860
|
+
await this.create(
|
|
5861
|
+
def,
|
|
5862
|
+
await this.uniqueName(id, name),
|
|
5863
|
+
{ ...account.values },
|
|
5864
|
+
account.name
|
|
5865
|
+
);
|
|
5866
|
+
await this.audit(user, "connect", id, { name });
|
|
5867
|
+
return { connected: true };
|
|
5868
|
+
}
|
|
5869
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5870
|
+
const pending = await this.cms.db.create({
|
|
5871
|
+
collection: CONNECTIONS,
|
|
5872
|
+
data: {
|
|
5873
|
+
kind: "pending",
|
|
5874
|
+
integration: id,
|
|
5875
|
+
name,
|
|
5876
|
+
isDefault: false,
|
|
5877
|
+
values: {
|
|
5878
|
+
user: String(user.id),
|
|
5879
|
+
expires: String(Date.now() + PENDING_MS),
|
|
5880
|
+
accounts: JSON.stringify(result.accounts.map((a) => ({ id: a.id, name: a.name })))
|
|
5881
|
+
},
|
|
5882
|
+
secret: encryptValues(
|
|
5883
|
+
this.config.secret,
|
|
5884
|
+
Object.fromEntries(result.accounts.map((a) => [a.id, JSON.stringify(a.values)]))
|
|
5885
|
+
),
|
|
5886
|
+
account: null,
|
|
5887
|
+
lastUsedAt: null,
|
|
5888
|
+
lastError: null,
|
|
5889
|
+
enabled: null,
|
|
5890
|
+
createdAt: now,
|
|
5891
|
+
updatedAt: now
|
|
5892
|
+
}
|
|
5893
|
+
});
|
|
5894
|
+
return { pending: pending.id };
|
|
5895
|
+
}
|
|
5896
|
+
await this.create(
|
|
5897
|
+
def,
|
|
5898
|
+
await this.uniqueName(id, name),
|
|
5899
|
+
{ ...result.values ?? {} },
|
|
5900
|
+
result.account ?? null
|
|
5901
|
+
);
|
|
5902
|
+
await this.audit(user, "connect", id, { name });
|
|
5903
|
+
return { connected: true };
|
|
5904
|
+
}
|
|
5905
|
+
/** The accounts waiting to be chosen from, by name only. */
|
|
5906
|
+
async pending(id, pid, user) {
|
|
5907
|
+
const row = await this.pendingRow(id, pid, user);
|
|
5908
|
+
const values = row.values;
|
|
5909
|
+
return {
|
|
5910
|
+
name: String(row.name),
|
|
5911
|
+
accounts: JSON.parse(values.accounts ?? "[]")
|
|
5912
|
+
};
|
|
5913
|
+
}
|
|
5914
|
+
async pendingRow(id, pid, user) {
|
|
5915
|
+
const row = await this.cms.db.findById({ collection: CONNECTIONS, id: pid });
|
|
5916
|
+
const values = row?.values ?? {};
|
|
5917
|
+
if (!row || row.kind !== "pending" || row.integration !== id || values.user !== String(user.id) || Number(values.expires) < Date.now())
|
|
5918
|
+
throw new NotFoundError(CONNECTIONS, pid);
|
|
5919
|
+
return row;
|
|
5920
|
+
}
|
|
5921
|
+
/** Connects the account chosen. */
|
|
5922
|
+
async choose(id, pid, accountId, user) {
|
|
5923
|
+
const def = this.definition(id);
|
|
5924
|
+
const row = await this.pendingRow(id, pid, user);
|
|
5925
|
+
const secrets = decryptValues(this.secrets, String(row.secret)).values;
|
|
5926
|
+
const chosen = secrets[accountId];
|
|
5927
|
+
const accounts = JSON.parse(
|
|
5928
|
+
String(row.values.accounts ?? "[]")
|
|
5929
|
+
);
|
|
5930
|
+
const account = accounts.find((a) => a.id === accountId);
|
|
5931
|
+
if (!chosen || !account) throw new NotFoundError("accounts", accountId);
|
|
5932
|
+
await this.cms.db.delete({ collection: CONNECTIONS, id: pid });
|
|
5933
|
+
const name = await this.uniqueName(id, String(row.name));
|
|
5934
|
+
const created = await this.create(
|
|
5935
|
+
def,
|
|
5936
|
+
name,
|
|
5937
|
+
JSON.parse(chosen),
|
|
5938
|
+
account.name
|
|
5939
|
+
);
|
|
5940
|
+
await this.audit(user, "connect", id, { name });
|
|
5941
|
+
return { id: created.id };
|
|
5942
|
+
}
|
|
5943
|
+
// --- Rotating the secret ---------------------------------------------------------------------
|
|
5944
|
+
/** Encrypts again, with the current secret, every connection still on an older one. */
|
|
5945
|
+
async rotate() {
|
|
5946
|
+
const current = keyId(keyOf2(this.config.secret));
|
|
5947
|
+
const found = await this.cms.db.find({
|
|
5948
|
+
collection: CONNECTIONS,
|
|
5949
|
+
limit: 0,
|
|
5950
|
+
page: 1,
|
|
5951
|
+
sort: ["id"]
|
|
5952
|
+
});
|
|
5953
|
+
let resealed = 0;
|
|
5954
|
+
for (const row of found.docs) {
|
|
5955
|
+
if (!row.secret || keyIdOf(row.secret) === current) continue;
|
|
5956
|
+
const { values } = decryptValues(this.secrets, String(row.secret));
|
|
5957
|
+
await this.reseal(row, values);
|
|
5958
|
+
resealed++;
|
|
5959
|
+
}
|
|
5960
|
+
return { resealed };
|
|
5961
|
+
}
|
|
5962
|
+
/** Connections whose test or last use failed, for the dashboard's "needs attention". */
|
|
5963
|
+
async broken() {
|
|
5964
|
+
const found = await this.cms.db.find({
|
|
5965
|
+
collection: CONNECTIONS,
|
|
5966
|
+
where: { and: [{ kind: { equals: "connection" } }, { lastError: { exists: true } }] },
|
|
5967
|
+
limit: 0,
|
|
5968
|
+
page: 1,
|
|
5969
|
+
sort: ["id"]
|
|
5970
|
+
});
|
|
5971
|
+
return found.docs.filter((r) => this.definitions.some((d) => d.id === r.integration)).map((r) => ({
|
|
5972
|
+
integration: String(r.integration),
|
|
5973
|
+
name: String(r.name),
|
|
5974
|
+
error: String(r.lastError)
|
|
5975
|
+
}));
|
|
5976
|
+
}
|
|
5977
|
+
};
|
|
5978
|
+
|
|
4829
5979
|
// src/localization.ts
|
|
4830
5980
|
var ALL_LOCALES = "all";
|
|
4831
5981
|
var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -5480,7 +6630,7 @@ function read(response, maxBytes, deadline) {
|
|
|
5480
6630
|
|
|
5481
6631
|
// src/config-keys.ts
|
|
5482
6632
|
var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
|
|
5483
|
-
var CONFIG = keys(`secret previousSecrets db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
|
|
6633
|
+
var CONFIG = keys(`secret previousSecrets integrations listeners ai aiFeatures db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
|
|
5484
6634
|
upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
|
|
5485
6635
|
audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
|
|
5486
6636
|
var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
|
|
@@ -5663,7 +6813,8 @@ var BUILTIN_NAV = [
|
|
|
5663
6813
|
children: [
|
|
5664
6814
|
{ id: "site", label: { en: "Site", th: "\u0E40\u0E27\u0E47\u0E1A\u0E44\u0E0B\u0E15\u0E4C" }, icon: "globe" },
|
|
5665
6815
|
{ id: "people", label: { en: "Users & access", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E25\u0E30\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C" }, icon: "users" },
|
|
5666
|
-
{ id: "system", label: { en: "System", th: "\u0E23\u0E30\u0E1A\u0E1A" }, icon: "sliders-horizontal" }
|
|
6816
|
+
{ id: "system", label: { en: "System", th: "\u0E23\u0E30\u0E1A\u0E1A" }, icon: "sliders-horizontal" },
|
|
6817
|
+
{ id: "integrations", label: { en: "Integrations", th: "\u0E01\u0E32\u0E23\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E15\u0E48\u0E2D" }, icon: "link" }
|
|
5667
6818
|
]
|
|
5668
6819
|
}
|
|
5669
6820
|
];
|
|
@@ -6016,6 +7167,12 @@ function verifyWebhook(body, headers, secret, options = {}) {
|
|
|
6016
7167
|
}
|
|
6017
7168
|
|
|
6018
7169
|
// src/webhooks.ts
|
|
7170
|
+
var SYSTEM_EVENTS = [
|
|
7171
|
+
"scheduled.failed",
|
|
7172
|
+
"delivery.failed",
|
|
7173
|
+
"backup.failed",
|
|
7174
|
+
"integration.failed"
|
|
7175
|
+
];
|
|
6019
7176
|
var WEBHOOK_EVENTS = [
|
|
6020
7177
|
"create",
|
|
6021
7178
|
"update",
|
|
@@ -6027,7 +7184,7 @@ var WEBHOOK_EVENTS = [
|
|
|
6027
7184
|
var RETRY_DELAYS = [1e3, 5e3];
|
|
6028
7185
|
var QUEUE_DELAYS2 = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
|
|
6029
7186
|
var CLAIM2 = 5 * 6e4;
|
|
6030
|
-
var
|
|
7187
|
+
var TIMEOUT2 = 1e4;
|
|
6031
7188
|
var Webhooks = class {
|
|
6032
7189
|
constructor(hooks, logger, queue, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
|
|
6033
7190
|
this.hooks = hooks;
|
|
@@ -6195,7 +7352,7 @@ var Webhooks = class {
|
|
|
6195
7352
|
method: "POST",
|
|
6196
7353
|
headers,
|
|
6197
7354
|
body,
|
|
6198
|
-
signal: AbortSignal.timeout(
|
|
7355
|
+
signal: AbortSignal.timeout(TIMEOUT2)
|
|
6199
7356
|
});
|
|
6200
7357
|
if (response.ok || response.status < 500 && response.status !== 429) {
|
|
6201
7358
|
if (!response.ok)
|
|
@@ -6207,10 +7364,13 @@ var Webhooks = class {
|
|
|
6207
7364
|
return { ok: false, error: error.message };
|
|
6208
7365
|
}
|
|
6209
7366
|
}
|
|
7367
|
+
/** Told when a delivery is given up on (`delivery.failed`). */
|
|
7368
|
+
onGiveUp;
|
|
6210
7369
|
giveUp(hook, event, error, attempts) {
|
|
6211
7370
|
this.logger.error(
|
|
6212
7371
|
`Webhook ${hook.url} failed for ${event} after ${attempts} attempts: ${error}`
|
|
6213
7372
|
);
|
|
7373
|
+
this.onGiveUp?.({ url: hook.url, event, error, attempts });
|
|
6214
7374
|
}
|
|
6215
7375
|
};
|
|
6216
7376
|
var later4 = (from, ms) => new Date(from.getTime() + ms).toISOString();
|
|
@@ -6299,6 +7459,9 @@ function validateConfig(config) {
|
|
|
6299
7459
|
validateEndpoints(config, collectionSlugs, add);
|
|
6300
7460
|
validateCommands(config, add);
|
|
6301
7461
|
validateJobs(config, add);
|
|
7462
|
+
validateIntegrations(config, add);
|
|
7463
|
+
validateListeners(config, add);
|
|
7464
|
+
validateAi(config, add);
|
|
6302
7465
|
validateTimeZone(config, add);
|
|
6303
7466
|
validateEvents(config, add);
|
|
6304
7467
|
const apiKeys = config.apiKeys;
|
|
@@ -6826,7 +7989,7 @@ function validateWebhooks(config, add) {
|
|
|
6826
7989
|
...(config.collections ?? []).map((c) => c?.slug)
|
|
6827
7990
|
]);
|
|
6828
7991
|
const globals = new Set((config.globals ?? []).map((g) => g?.slug));
|
|
6829
|
-
const custom = new Set(config.events ?? []);
|
|
7992
|
+
const custom = /* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS]);
|
|
6830
7993
|
for (const [i, hook] of hooks.entries()) {
|
|
6831
7994
|
const path = `webhooks[${i}]`;
|
|
6832
7995
|
if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
|
|
@@ -7916,6 +9079,111 @@ function validateAdapterVersions(config, add) {
|
|
|
7916
9079
|
);
|
|
7917
9080
|
}
|
|
7918
9081
|
}
|
|
9082
|
+
function validateIntegrations(config, add) {
|
|
9083
|
+
const list = config.integrations;
|
|
9084
|
+
if (list === void 0) return;
|
|
9085
|
+
if (!Array.isArray(list)) {
|
|
9086
|
+
add("integrations", "must be an array");
|
|
9087
|
+
return;
|
|
9088
|
+
}
|
|
9089
|
+
const ids = /* @__PURE__ */ new Set();
|
|
9090
|
+
for (const [i, raw] of list.entries()) {
|
|
9091
|
+
const at = `integrations[${i}]`;
|
|
9092
|
+
const item = raw ?? {};
|
|
9093
|
+
if (typeof item.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(item.id))
|
|
9094
|
+
add(`${at}.id`, 'must be lowercase letters, digits and "-"', "e.g. id: 'telegram'");
|
|
9095
|
+
else if (ids.has(item.id)) add(`${at}.id`, `two integrations are named "${item.id}"`);
|
|
9096
|
+
else ids.add(item.id);
|
|
9097
|
+
if (!isLabel(item.label)) add(`${at}.label`, "must be a string or { en, th }");
|
|
9098
|
+
if (!["social", "ai", "notifications", "automation"].includes(item.category))
|
|
9099
|
+
add(`${at}.category`, "must be 'social', 'ai', 'notifications' or 'automation'");
|
|
9100
|
+
if (item.kind === "fields") {
|
|
9101
|
+
if (!Array.isArray(item.fields) || item.fields.length === 0)
|
|
9102
|
+
add(`${at}.fields`, "needs at least one field to fill");
|
|
9103
|
+
} else if (item.kind === "oauth") {
|
|
9104
|
+
const oauth = item.oauth ?? {};
|
|
9105
|
+
if (typeof oauth.authorizeUrl !== "function" || typeof oauth.exchange !== "function")
|
|
9106
|
+
add(`${at}.oauth`, "needs authorizeUrl and exchange functions");
|
|
9107
|
+
} else add(`${at}.kind`, "must be 'fields' or 'oauth'");
|
|
9108
|
+
if (item.test !== void 0 && typeof item.test !== "function")
|
|
9109
|
+
add(`${at}.test`, "must be a function");
|
|
9110
|
+
if (item.icon !== void 0 && typeof item.icon !== "string") {
|
|
9111
|
+
const logo = item.icon ?? {};
|
|
9112
|
+
const hex = (v) => typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v);
|
|
9113
|
+
const paths = Array.isArray(logo.paths) ? logo.paths : [];
|
|
9114
|
+
if (paths.length === 0 || paths.some((p) => {
|
|
9115
|
+
const path = p ?? {};
|
|
9116
|
+
return typeof path.d !== "string" || !/^[MmLlHhVvCcSsQqTtAaZzEe0-9.,\s+-]+$/.test(path.d) || path.fill !== void 0 && !hex(path.fill);
|
|
9117
|
+
}))
|
|
9118
|
+
add(
|
|
9119
|
+
`${at}.icon`,
|
|
9120
|
+
"must be an admin icon name, or { paths: [{ d, fill? }], color? } with SVG path data"
|
|
9121
|
+
);
|
|
9122
|
+
if (logo.color !== void 0 && !hex(logo.color)) add(`${at}.icon.color`, "must be #rrggbb");
|
|
9123
|
+
if (logo.viewBox !== void 0 && !(typeof logo.viewBox === "string" && /^-?[\d.]+( -?[\d.]+){3}$/.test(logo.viewBox)))
|
|
9124
|
+
add(`${at}.icon.viewBox`, "must be four numbers, e.g. '0 0 24 24'");
|
|
9125
|
+
}
|
|
9126
|
+
for (const [f, field] of (Array.isArray(item.fields) ? item.fields : []).entries()) {
|
|
9127
|
+
const fieldItem = field ?? {};
|
|
9128
|
+
if (fieldItem.lookup !== void 0 && typeof fieldItem.lookup !== "function")
|
|
9129
|
+
add(`${at}.fields[${f}].lookup`, "must be a function");
|
|
9130
|
+
}
|
|
9131
|
+
}
|
|
9132
|
+
}
|
|
9133
|
+
function validateListeners(config, add) {
|
|
9134
|
+
const list = config.listeners;
|
|
9135
|
+
if (list === void 0) return;
|
|
9136
|
+
if (!Array.isArray(list)) {
|
|
9137
|
+
add("listeners", "must be an array");
|
|
9138
|
+
return;
|
|
9139
|
+
}
|
|
9140
|
+
for (const [i, raw] of list.entries()) {
|
|
9141
|
+
const item = raw ?? {};
|
|
9142
|
+
const events = item.events;
|
|
9143
|
+
if (events !== "*" && !(Array.isArray(events) && events.every((e) => typeof e === "string" && e.length > 0)))
|
|
9144
|
+
add(`listeners[${i}].events`, "must be event names or '*'", "e.g. events: ['order.created']");
|
|
9145
|
+
if (typeof item.handler !== "function") add(`listeners[${i}].handler`, "must be a function");
|
|
9146
|
+
}
|
|
9147
|
+
}
|
|
9148
|
+
function validateAi(config, add) {
|
|
9149
|
+
const ai = config.ai;
|
|
9150
|
+
if (ai !== void 0) {
|
|
9151
|
+
const providers = ai?.providers;
|
|
9152
|
+
if (!Array.isArray(providers)) add("ai.providers", "must be an array");
|
|
9153
|
+
else {
|
|
9154
|
+
const ids = new Set((config.integrations ?? []).map((i) => i?.id));
|
|
9155
|
+
for (const [i, raw] of providers.entries()) {
|
|
9156
|
+
const provider = raw ?? {};
|
|
9157
|
+
if (typeof provider.integration !== "string" || !ids.has(provider.integration))
|
|
9158
|
+
add(`ai.providers[${i}].integration`, "must be the id of an integration");
|
|
9159
|
+
if (typeof provider.generate !== "function")
|
|
9160
|
+
add(`ai.providers[${i}].generate`, "must be a function");
|
|
9161
|
+
}
|
|
9162
|
+
}
|
|
9163
|
+
const limits = ai?.limits;
|
|
9164
|
+
const whole = (v) => Number.isInteger(v) && v >= 0;
|
|
9165
|
+
if (limits?.perUserPerDay !== void 0 && !whole(limits.perUserPerDay))
|
|
9166
|
+
add("ai.limits.perUserPerDay", "must be a whole number, 0 or more");
|
|
9167
|
+
for (const [role, value] of Object.entries(limits?.roles ?? {}))
|
|
9168
|
+
if (!whole(value)) add(`ai.limits.roles.${role}`, "must be a whole number, 0 or more");
|
|
9169
|
+
}
|
|
9170
|
+
const features = config.aiFeatures;
|
|
9171
|
+
if (features === void 0) return;
|
|
9172
|
+
if (!Array.isArray(features)) {
|
|
9173
|
+
add("aiFeatures", "must be an array");
|
|
9174
|
+
return;
|
|
9175
|
+
}
|
|
9176
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9177
|
+
for (const [i, raw] of features.entries()) {
|
|
9178
|
+
const feature = raw ?? {};
|
|
9179
|
+
if (typeof feature.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(feature.id))
|
|
9180
|
+
add(`aiFeatures[${i}].id`, 'must be lowercase letters, digits and "-"', "e.g. id: 'alt-text'");
|
|
9181
|
+
else if (seen.has(feature.id) || feature.id === "test")
|
|
9182
|
+
add(`aiFeatures[${i}].id`, `"${feature.id}" is taken`);
|
|
9183
|
+
else seen.add(feature.id);
|
|
9184
|
+
if (!isLabel(feature.label)) add(`aiFeatures[${i}].label`, "must be a string or { en, th }");
|
|
9185
|
+
}
|
|
9186
|
+
}
|
|
7919
9187
|
|
|
7920
9188
|
// src/resolve-config.ts
|
|
7921
9189
|
var DEFAULT_ADMIN_PATH = "/admin";
|
|
@@ -8026,14 +9294,19 @@ async function resolveConfig(input) {
|
|
|
8026
9294
|
databaseBackupsCollection,
|
|
8027
9295
|
bulkOperationsCollection,
|
|
8028
9296
|
savedViewsCollection,
|
|
9297
|
+
...resolvedIntegrations(config).length > 0 ? [connectionsCollection] : [],
|
|
9298
|
+
...config.ai ? [aiUsageCollection] : [],
|
|
8029
9299
|
...config.auth?.rbac ? [rolesCollection] : [],
|
|
8030
9300
|
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
|
|
8031
9301
|
...config.audit ? [auditLogsCollection] : []
|
|
8032
9302
|
],
|
|
8033
9303
|
globals: config.globals ?? [],
|
|
8034
9304
|
endpoints: config.endpoints ?? [],
|
|
9305
|
+
integrations: resolvedIntegrations(config),
|
|
9306
|
+
aiFeatures: config.ai ? config.aiFeatures ?? [] : [],
|
|
8035
9307
|
cliCommands: config.cliCommands ?? [],
|
|
8036
|
-
|
|
9308
|
+
// Easy CMS's own events (#356), for webhooks and `cms.on`.
|
|
9309
|
+
events: [.../* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS])],
|
|
8037
9310
|
jobs: config.jobs ?? [],
|
|
8038
9311
|
warnings,
|
|
8039
9312
|
installedPlugins: (plugins ?? []).map(
|
|
@@ -8109,9 +9382,12 @@ function resolveAudit(audit) {
|
|
|
8109
9382
|
scope: audit.scope ?? false
|
|
8110
9383
|
};
|
|
8111
9384
|
}
|
|
9385
|
+
function resolvedIntegrations(config) {
|
|
9386
|
+
return config.integrations ?? [];
|
|
9387
|
+
}
|
|
8112
9388
|
|
|
8113
9389
|
// src/rest/http-cache.ts
|
|
8114
|
-
import { createHash as
|
|
9390
|
+
import { createHash as createHash5 } from "crypto";
|
|
8115
9391
|
var cacheTags = {
|
|
8116
9392
|
collection: (slug2) => ["ecms", `ecms:${slug2}`],
|
|
8117
9393
|
document: (slug2, id) => ["ecms", `ecms:${slug2}`, `ecms:${slug2}:${id}`],
|
|
@@ -8138,7 +9414,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
|
|
|
8138
9414
|
);
|
|
8139
9415
|
headers.set("cache-tag", tags.join(","));
|
|
8140
9416
|
headers.set("surrogate-key", tags.join(" "));
|
|
8141
|
-
const etag = `W/"${
|
|
9417
|
+
const etag = `W/"${createHash5("sha1").update(body).digest("base64url")}"`;
|
|
8142
9418
|
headers.set("etag", etag);
|
|
8143
9419
|
if (matches2(request.headers.get("if-none-match"), etag))
|
|
8144
9420
|
return new Response(null, { status: 304, headers });
|
|
@@ -8146,7 +9422,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
|
|
|
8146
9422
|
}
|
|
8147
9423
|
|
|
8148
9424
|
// src/version.ts
|
|
8149
|
-
var VERSION = true ? "1.
|
|
9425
|
+
var VERSION = true ? "1.33.0" : "0.0.0-dev";
|
|
8150
9426
|
|
|
8151
9427
|
// src/telemetry.ts
|
|
8152
9428
|
var DB_CALLS = /* @__PURE__ */ new Set([
|
|
@@ -8423,6 +9699,10 @@ var EasyCMS = class {
|
|
|
8423
9699
|
audit;
|
|
8424
9700
|
/** Folders of the media library and their permissions (`upload.folders`). */
|
|
8425
9701
|
folders;
|
|
9702
|
+
/** Accounts connected to plugins' integrations (`integrations`, Settings → Integrations). */
|
|
9703
|
+
integrations;
|
|
9704
|
+
/** AI for the admin's features (#357): `null` without an AI plugin (`ai` in the config). */
|
|
9705
|
+
ai;
|
|
8426
9706
|
versions;
|
|
8427
9707
|
webhooks;
|
|
8428
9708
|
mailer;
|
|
@@ -8450,6 +9730,13 @@ var EasyCMS = class {
|
|
|
8450
9730
|
(entry) => this.audit.record(entry)
|
|
8451
9731
|
);
|
|
8452
9732
|
this.audit = new AuditLog(this);
|
|
9733
|
+
this.integrations = new Integrations(this);
|
|
9734
|
+
this.ai = config.ai ? new Ai(this, config.ai) : null;
|
|
9735
|
+
for (const listener of config.listeners ?? []) {
|
|
9736
|
+
const run = (message2) => listener.handler(message2, { cms: this });
|
|
9737
|
+
if (listener.events === "*") this.anyListeners.add(run);
|
|
9738
|
+
else for (const event of listener.events) this.on(event, run);
|
|
9739
|
+
}
|
|
8453
9740
|
this.folders = new MediaFolders(config, db, this.roles);
|
|
8454
9741
|
this.webhooks = new Webhooks(
|
|
8455
9742
|
config.webhooks ?? [],
|
|
@@ -8461,6 +9748,8 @@ var EasyCMS = class {
|
|
|
8461
9748
|
logger,
|
|
8462
9749
|
config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
|
|
8463
9750
|
);
|
|
9751
|
+
this.webhooks.onGiveUp = ({ url, event, error, attempts }) => this.systemEvent("delivery.failed", { kind: "webhook", url, event, error, attempts });
|
|
9752
|
+
this.mailer.onGiveUp = ({ to, error, attempts }) => this.systemEvent("delivery.failed", { kind: "email", to, error, attempts });
|
|
8464
9753
|
this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(
|
|
8465
9754
|
db,
|
|
8466
9755
|
logger,
|
|
@@ -8653,7 +9942,7 @@ var EasyCMS = class {
|
|
|
8653
9942
|
}
|
|
8654
9943
|
]);
|
|
8655
9944
|
}
|
|
8656
|
-
const random2 =
|
|
9945
|
+
const random2 = randomBytes7(4).toString("hex");
|
|
8657
9946
|
const isPrivate = await this.folders.isPrivate(folderOf(MEDIA, data));
|
|
8658
9947
|
const storage = this.storageFor(isPrivate);
|
|
8659
9948
|
const filename = withPrivacy(storageKey(file.name, mimeType, random2), isPrivate);
|
|
@@ -8721,7 +10010,7 @@ var EasyCMS = class {
|
|
|
8721
10010
|
]);
|
|
8722
10011
|
const isPrivate = await this.folders.isPrivate(folder);
|
|
8723
10012
|
const storage = this.storageFor(isPrivate);
|
|
8724
|
-
const key = withPrivacy(storageKey(name, type,
|
|
10013
|
+
const key = withPrivacy(storageKey(name, type, randomBytes7(4).toString("hex")), isPrivate);
|
|
8725
10014
|
const upload = storage.uploadURL ? await storage.uploadURL(key, { contentType: type, size, expiresIn: UPLOAD_URL_SECONDS }) : null;
|
|
8726
10015
|
const ticket = this.sign("upload", {
|
|
8727
10016
|
key,
|
|
@@ -9970,6 +11259,7 @@ var EasyCMS = class {
|
|
|
9970
11259
|
(error) => this.logger.error(`Backups: ${error.message}`, { error })
|
|
9971
11260
|
);
|
|
9972
11261
|
await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`, { error }));
|
|
11262
|
+
await this.ai?.upkeep(now).catch((error) => this.logger.error(`AI usage: ${error.message}`, { error }));
|
|
9973
11263
|
await this.rateLimiter?.clear(now).catch((error) => this.logger.error(`Rate limits: ${error.message}`, { error }));
|
|
9974
11264
|
const result = {
|
|
9975
11265
|
scheduled,
|
|
@@ -10125,6 +11415,37 @@ var EasyCMS = class {
|
|
|
10125
11415
|
if (!this.config.events.includes(event))
|
|
10126
11416
|
throw new QueryError(`Unknown event "${event}": add it to \`events\` in the config`);
|
|
10127
11417
|
this.webhooks.emit(event, about ?? {}, data);
|
|
11418
|
+
this.dispatch(event, data, about);
|
|
11419
|
+
}
|
|
11420
|
+
listeners = /* @__PURE__ */ new Map();
|
|
11421
|
+
/** Listeners of every event (`events: '*'` in the config's `listeners`). */
|
|
11422
|
+
anyListeners = /* @__PURE__ */ new Set();
|
|
11423
|
+
/**
|
|
11424
|
+
* Runs `handler` after each `event` on this server (#356): the config's and plugins' events, the
|
|
11425
|
+
* content changes webhooks get (`create`, `publish`…), and Easy CMS's own (`scheduled.failed`,
|
|
11426
|
+
* `delivery.failed`, `backup.failed`, `integration.failed`). It runs after the change, without
|
|
11427
|
+
* being waited for; its errors are logged. Returns a function that stops it.
|
|
11428
|
+
*/
|
|
11429
|
+
on(event, handler) {
|
|
11430
|
+
const set = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
11431
|
+
set.add(handler);
|
|
11432
|
+
this.listeners.set(event, set);
|
|
11433
|
+
return () => set.delete(handler);
|
|
11434
|
+
}
|
|
11435
|
+
dispatch(event, data, about) {
|
|
11436
|
+
const set = [...this.listeners.get(event) ?? [], ...this.anyListeners];
|
|
11437
|
+
if (set.length === 0) return;
|
|
11438
|
+
for (const handler of set)
|
|
11439
|
+
queueMicrotask(() => {
|
|
11440
|
+
Promise.resolve().then(() => handler({ event, data, ...about ? { about } : {} })).catch(
|
|
11441
|
+
(error) => this.logger.error(`A listener of ${event} failed: ${errorText(error)}`, { error })
|
|
11442
|
+
);
|
|
11443
|
+
});
|
|
11444
|
+
}
|
|
11445
|
+
/** @internal Easy CMS's own events (`SYSTEM_EVENTS`): to webhooks that list them and `cms.on`. */
|
|
11446
|
+
systemEvent(event, data) {
|
|
11447
|
+
this.webhooks.emit(event, {}, data);
|
|
11448
|
+
this.dispatch(event, data);
|
|
10128
11449
|
}
|
|
10129
11450
|
/**
|
|
10130
11451
|
* Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
|
|
@@ -10283,6 +11604,13 @@ var EasyCMS = class {
|
|
|
10283
11604
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
10284
11605
|
}
|
|
10285
11606
|
});
|
|
11607
|
+
this.systemEvent("scheduled.failed", {
|
|
11608
|
+
job: row.id,
|
|
11609
|
+
action,
|
|
11610
|
+
...row.collection ? { collection: row.collection } : { global: row.global },
|
|
11611
|
+
doc: row.doc ?? null,
|
|
11612
|
+
error: message2
|
|
11613
|
+
});
|
|
10286
11614
|
}
|
|
10287
11615
|
}
|
|
10288
11616
|
return { ran, failed };
|
|
@@ -10911,8 +12239,10 @@ var EasyCMS = class {
|
|
|
10911
12239
|
* are logged.
|
|
10912
12240
|
*/
|
|
10913
12241
|
emitChange(config, event, doc, before) {
|
|
10914
|
-
for (const change of this.changeEvents(config, event, doc, before))
|
|
12242
|
+
for (const change of this.changeEvents(config, event, doc, before)) {
|
|
10915
12243
|
this.webhooks.emit(change.event, change.target, change.body);
|
|
12244
|
+
this.dispatch(change.event, change.body, change.target);
|
|
12245
|
+
}
|
|
10916
12246
|
if (this.searches(config, event)) this.searchIndex?.syncLater(config.slug, doc.id);
|
|
10917
12247
|
const purge = this.config.httpCache?.purge;
|
|
10918
12248
|
const tags = this.purgeTags(config, event, doc, before);
|
|
@@ -10962,8 +12292,10 @@ var EasyCMS = class {
|
|
|
10962
12292
|
}
|
|
10963
12293
|
if (!done.has("webhooks")) {
|
|
10964
12294
|
const changes = this.changeEvents(config, event, doc, entry.before);
|
|
10965
|
-
for (const change of changes)
|
|
12295
|
+
for (const change of changes) {
|
|
10966
12296
|
await this.webhooks.emitSaved(change.event, change.target, change.body);
|
|
12297
|
+
this.dispatch(change.event, change.body, change.target);
|
|
12298
|
+
}
|
|
10967
12299
|
if (changes.length > 0 && (this.config.webhooks?.length ?? 0) > 0) await mark("webhooks");
|
|
10968
12300
|
}
|
|
10969
12301
|
if (!done.has("search") && this.searchIndex && this.searches(config, event)) {
|
|
@@ -11259,51 +12591,51 @@ var EasyCMS = class {
|
|
|
11259
12591
|
const cms = this;
|
|
11260
12592
|
const { user, context } = guard;
|
|
11261
12593
|
const allowed2 = /* @__PURE__ */ new Map();
|
|
11262
|
-
for (const
|
|
11263
|
-
const filter =
|
|
12594
|
+
for (const ref2 of references2) {
|
|
12595
|
+
const filter = ref2.filterOptions;
|
|
11264
12596
|
if (!filter) continue;
|
|
11265
12597
|
const byCollection = allowed2.get(filter) ?? /* @__PURE__ */ new Map();
|
|
11266
12598
|
allowed2.set(filter, byCollection);
|
|
11267
|
-
if (!byCollection.has(
|
|
12599
|
+
if (!byCollection.has(ref2.collection))
|
|
11268
12600
|
byCollection.set(
|
|
11269
|
-
|
|
11270
|
-
await filter({ id: selfId, relationTo:
|
|
12601
|
+
ref2.collection,
|
|
12602
|
+
await filter({ id: selfId, relationTo: ref2.collection, user, cms, context })
|
|
11271
12603
|
);
|
|
11272
|
-
const where = byCollection.get(
|
|
12604
|
+
const where = byCollection.get(ref2.collection);
|
|
11273
12605
|
if (where === true) continue;
|
|
11274
|
-
const target = this.config.collections.find((c) => c.slug ===
|
|
12606
|
+
const target = this.config.collections.find((c) => c.slug === ref2.collection);
|
|
11275
12607
|
const localization = this.config.localization;
|
|
11276
12608
|
const scoped = target ? this.whereFor(target, where, localization ? { locale: localization.defaultLocale } : {}) : where;
|
|
11277
12609
|
const count = await this.db.count({
|
|
11278
|
-
collection:
|
|
11279
|
-
where: { and: [{ id: { equals:
|
|
12610
|
+
collection: ref2.collection,
|
|
12611
|
+
where: { and: [{ id: { equals: ref2.id } }, ...scoped ? [scoped] : []] }
|
|
11280
12612
|
});
|
|
11281
12613
|
if (count === 0)
|
|
11282
|
-
errors.push({ field:
|
|
12614
|
+
errors.push({ field: ref2.field, message: "is not one of the allowed choices" });
|
|
11283
12615
|
}
|
|
11284
12616
|
return errors;
|
|
11285
12617
|
}
|
|
11286
12618
|
async checkReferences(references2, guard) {
|
|
11287
12619
|
const byCollection = /* @__PURE__ */ new Map();
|
|
11288
12620
|
const errors = [];
|
|
11289
|
-
for (const
|
|
11290
|
-
const target = this.config.collections.find((c) => c.slug ===
|
|
11291
|
-
if (
|
|
12621
|
+
for (const ref2 of references2) {
|
|
12622
|
+
const target = this.config.collections.find((c) => c.slug === ref2.collection);
|
|
12623
|
+
if (ref2.link && (!target || INTERNAL_COLLECTIONS.has(ref2.collection))) {
|
|
11292
12624
|
errors.push({
|
|
11293
|
-
field:
|
|
11294
|
-
message: `links to "${
|
|
12625
|
+
field: ref2.field,
|
|
12626
|
+
message: `links to "${ref2.collection}", which is not a collection`
|
|
11295
12627
|
});
|
|
11296
12628
|
continue;
|
|
11297
12629
|
}
|
|
11298
|
-
if (
|
|
12630
|
+
if (ref2.link && typeof target?.preview !== "function") {
|
|
11299
12631
|
errors.push({
|
|
11300
|
-
field:
|
|
11301
|
-
message: `links to "${
|
|
12632
|
+
field: ref2.field,
|
|
12633
|
+
message: `links to "${ref2.collection}", whose documents have no URL (set its preview)`
|
|
11302
12634
|
});
|
|
11303
12635
|
continue;
|
|
11304
12636
|
}
|
|
11305
12637
|
if (!target) continue;
|
|
11306
|
-
byCollection.set(
|
|
12638
|
+
byCollection.set(ref2.collection, [...byCollection.get(ref2.collection) ?? [], ref2]);
|
|
11307
12639
|
}
|
|
11308
12640
|
for (const [collection2, refs] of byCollection) {
|
|
11309
12641
|
const found = await this.db.findByIds({
|
|
@@ -11311,28 +12643,28 @@ var EasyCMS = class {
|
|
|
11311
12643
|
ids: [...new Set(refs.map((r) => r.id))]
|
|
11312
12644
|
});
|
|
11313
12645
|
const byId = new Map(found.map((d) => [d.id, d]));
|
|
11314
|
-
for (const
|
|
11315
|
-
const doc = byId.get(
|
|
12646
|
+
for (const ref2 of refs) {
|
|
12647
|
+
const doc = byId.get(ref2.id);
|
|
11316
12648
|
if (!doc) {
|
|
11317
|
-
errors.push({ field:
|
|
12649
|
+
errors.push({ field: ref2.field, message: `${collection2} ${ref2.id} does not exist` });
|
|
11318
12650
|
continue;
|
|
11319
12651
|
}
|
|
11320
12652
|
const type = typeof doc.mimeType === "string" ? doc.mimeType : "";
|
|
11321
|
-
if (
|
|
11322
|
-
const images =
|
|
12653
|
+
if (ref2.mimeTypes && !mimeAllowedBy(type, ref2.mimeTypes)) {
|
|
12654
|
+
const images = ref2.mimeTypes.every((t) => t.toLowerCase() === "image/*");
|
|
11323
12655
|
errors.push({
|
|
11324
|
-
field:
|
|
11325
|
-
message: images ? `must be an image (${doc.filename ??
|
|
12656
|
+
field: ref2.field,
|
|
12657
|
+
message: images ? `must be an image (${doc.filename ?? ref2.id} is ${type || "not one"})` : `must be ${ref2.mimeTypes.join(", ")} (${doc.filename ?? ref2.id} is ${type || "unknown"})`
|
|
11326
12658
|
});
|
|
11327
12659
|
}
|
|
11328
|
-
if (
|
|
11329
|
-
|
|
12660
|
+
if (ref2.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
|
|
12661
|
+
ref2.folderOnly,
|
|
11330
12662
|
doc.folder ?? null,
|
|
11331
12663
|
await this.folderScope(guard)
|
|
11332
12664
|
))
|
|
11333
12665
|
errors.push({
|
|
11334
|
-
field:
|
|
11335
|
-
message: `must be a file from the "${
|
|
12666
|
+
field: ref2.field,
|
|
12667
|
+
message: `must be a file from the "${ref2.folderOnly}" folder (${doc.filename ?? ref2.id} is not)`
|
|
11336
12668
|
});
|
|
11337
12669
|
}
|
|
11338
12670
|
}
|
|
@@ -11454,7 +12786,7 @@ function durationSeconds(value) {
|
|
|
11454
12786
|
}
|
|
11455
12787
|
function cursorQuery(collection2, where, sort, locale, draft) {
|
|
11456
12788
|
const key = JSON.stringify([collection2, where ?? null, sort, locale ?? null, draft]);
|
|
11457
|
-
return
|
|
12789
|
+
return createHash6("sha256").update(key).digest("base64url").slice(0, 16);
|
|
11458
12790
|
}
|
|
11459
12791
|
function writeCursor(keys2, query) {
|
|
11460
12792
|
return Buffer.from(JSON.stringify({ k: keys2, q: query })).toString("base64url");
|
|
@@ -11764,7 +13096,8 @@ var Roles = class {
|
|
|
11764
13096
|
async canView(user, id) {
|
|
11765
13097
|
if (isSystemAdmin(user)) return true;
|
|
11766
13098
|
if (user.apiKey) return false;
|
|
11767
|
-
if ((id === "deliveries" || id === "jobs") && user.scoped === true)
|
|
13099
|
+
if ((id === "deliveries" || id === "jobs" || id === "integrations") && user.scoped === true)
|
|
13100
|
+
return false;
|
|
11768
13101
|
if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
|
|
11769
13102
|
return true;
|
|
11770
13103
|
if (!this.enabled)
|
|
@@ -11953,7 +13286,8 @@ var Roles = class {
|
|
|
11953
13286
|
new: !seen("globals", g.slug),
|
|
11954
13287
|
fields: adminFields(g.fields, void 0)
|
|
11955
13288
|
})),
|
|
11956
|
-
views: this.views()
|
|
13289
|
+
views: this.views(),
|
|
13290
|
+
ai: Boolean(this.config.ai)
|
|
11957
13291
|
};
|
|
11958
13292
|
}
|
|
11959
13293
|
async create(input, author) {
|
|
@@ -12052,6 +13386,7 @@ var Roles = class {
|
|
|
12052
13386
|
(c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
|
|
12053
13387
|
) ? [{ id: "deliveries" }] : [],
|
|
12054
13388
|
...hasJobsPage(this.config) ? [{ id: "jobs" }] : [],
|
|
13389
|
+
...this.config.integrations.length > 0 ? [{ id: "integrations" }] : [],
|
|
12055
13390
|
...hasCalendarPage(this.config) ? [{ id: "calendar" }] : [],
|
|
12056
13391
|
...this.config.admin.pages.map((page) => ({ id: viewId.page(page.path), label: page.label })),
|
|
12057
13392
|
...this.config.admin.dashboard.map((widget) => ({
|
|
@@ -12131,7 +13466,8 @@ var Roles = class {
|
|
|
12131
13466
|
void 0
|
|
12132
13467
|
)
|
|
12133
13468
|
};
|
|
12134
|
-
|
|
13469
|
+
const aiPerDay = this.config.ai && Number.isInteger(input.aiPerDay) && input.aiPerDay >= 0 ? { aiPerDay: input.aiPerDay } : {};
|
|
13470
|
+
return { ...picked, admin, own, fields, ...aiPerDay };
|
|
12135
13471
|
}
|
|
12136
13472
|
/**
|
|
12137
13473
|
* Keeps rules for known top-level fields. A field that must be filled in (required, without a
|
|
@@ -12180,6 +13516,11 @@ var Roles = class {
|
|
|
12180
13516
|
]);
|
|
12181
13517
|
return value.trim();
|
|
12182
13518
|
}
|
|
13519
|
+
/** The role's daily AI limit set in Settings → Roles, if any (`auth.rbac`). */
|
|
13520
|
+
async aiLimit(key) {
|
|
13521
|
+
if (!this.enabled) return void 0;
|
|
13522
|
+
return (await this.permissionsOf(key)).aiPerDay;
|
|
13523
|
+
}
|
|
12183
13524
|
async permissionsOf(key) {
|
|
12184
13525
|
return (await this.load()).get(key) ?? {};
|
|
12185
13526
|
}
|
|
@@ -12254,7 +13595,7 @@ function rank(role, config) {
|
|
|
12254
13595
|
}
|
|
12255
13596
|
|
|
12256
13597
|
// src/rest/admin-modules.ts
|
|
12257
|
-
import { createHash as
|
|
13598
|
+
import { createHash as createHash7 } from "crypto";
|
|
12258
13599
|
import { readFile as readFile3 } from "fs/promises";
|
|
12259
13600
|
import { createRequire } from "module";
|
|
12260
13601
|
import { join as join3 } from "path";
|
|
@@ -12295,7 +13636,7 @@ async function readAdminModule(cms, index) {
|
|
|
12295
13636
|
if (!file) return void 0;
|
|
12296
13637
|
const body = await readFile3(file, "utf8").catch(() => void 0);
|
|
12297
13638
|
if (body === void 0) return void 0;
|
|
12298
|
-
return { body, etag: `"${
|
|
13639
|
+
return { body, etag: `"${createHash7("sha1").update(body).digest("base64url")}"` };
|
|
12299
13640
|
}
|
|
12300
13641
|
|
|
12301
13642
|
// src/rest/handler.ts
|
|
@@ -12750,7 +14091,8 @@ async function adminSchema(cms, user, origin, context = {}) {
|
|
|
12750
14091
|
roles: admin && cms.roles.enabled,
|
|
12751
14092
|
sso: admin && cms.auth.sso.enabled,
|
|
12752
14093
|
audit: cms.audit.enabled && await cms.roles.canView(user, "audit"),
|
|
12753
|
-
calendar: hasCalendarPage(cms.config) && await cms.roles.canView(user, "calendar")
|
|
14094
|
+
calendar: hasCalendarPage(cms.config) && await cms.roles.canView(user, "calendar"),
|
|
14095
|
+
integrations: cms.config.integrations.length > 0 && await cms.roles.canView(user, "integrations")
|
|
12754
14096
|
};
|
|
12755
14097
|
const roles = await cms.roles.options();
|
|
12756
14098
|
const adminCollections = await Promise.all(
|
|
@@ -12835,10 +14177,10 @@ function folderIds(cms, user, context) {
|
|
|
12835
14177
|
return cms.folders.enabled ? (key) => cms.keyedFolder(key, { user, context }) : void 0;
|
|
12836
14178
|
}
|
|
12837
14179
|
function panelRef(panel) {
|
|
12838
|
-
const
|
|
14180
|
+
const ref2 = componentRef(panel);
|
|
12839
14181
|
if (typeof panel === "object" && "position" in panel && panel.position === "top")
|
|
12840
|
-
|
|
12841
|
-
return
|
|
14182
|
+
ref2.position = "top";
|
|
14183
|
+
return ref2;
|
|
12842
14184
|
}
|
|
12843
14185
|
function layoutOf(layout, fields) {
|
|
12844
14186
|
const shown2 = new Set(fields.filter((f) => f.position !== "sidebar").map((f) => f.name));
|
|
@@ -12890,7 +14232,8 @@ var VIEW_GROUPS = [
|
|
|
12890
14232
|
["email", "settings.system"],
|
|
12891
14233
|
["deliveries", "settings.system"],
|
|
12892
14234
|
["jobs", "settings.system"],
|
|
12893
|
-
["audit", "settings.system"]
|
|
14235
|
+
["audit", "settings.system"],
|
|
14236
|
+
["integrations", "settings.integrations"]
|
|
12894
14237
|
];
|
|
12895
14238
|
function buildNav(cms, collections, globals, pageRefs, views) {
|
|
12896
14239
|
const groups = navGroups(cms.config.admin.nav);
|
|
@@ -13123,6 +14466,15 @@ async function adminStatus(cms) {
|
|
|
13123
14466
|
if (failed.length > 0) attention.push({ id: "jobs", count: failed.length, names: failed });
|
|
13124
14467
|
const paused = await pausedJobs(cms);
|
|
13125
14468
|
if (paused.length > 0) attention.push({ id: "jobs-paused", count: paused.length, names: paused });
|
|
14469
|
+
if (config.integrations.length > 0) {
|
|
14470
|
+
const broken = await cms.integrations.broken();
|
|
14471
|
+
if (broken.length > 0)
|
|
14472
|
+
attention.push({
|
|
14473
|
+
id: "integrations",
|
|
14474
|
+
count: broken.length,
|
|
14475
|
+
names: broken.map((b) => `${b.integration}: ${b.name}`)
|
|
14476
|
+
});
|
|
14477
|
+
}
|
|
13126
14478
|
const backups = await backupAttention(cms);
|
|
13127
14479
|
if (backups) attention.push(backups);
|
|
13128
14480
|
attention.push(...await cms.audit.attention());
|
|
@@ -14294,6 +15646,106 @@ async function route(cms, ctx, method, path, options) {
|
|
|
14294
15646
|
}
|
|
14295
15647
|
throw methodNotAllowed(ctx, "PATCH, DELETE");
|
|
14296
15648
|
}
|
|
15649
|
+
if (first === "admin" && second === "ai" && segments.length === 2) {
|
|
15650
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
15651
|
+
if (!cms.ai) throw new HttpError("Not found", 404);
|
|
15652
|
+
if (!await cms.roles.canView(ctx.user, "integrations")) throw new ForbiddenError();
|
|
15653
|
+
if (method === "GET") return { body: await cms.ai.overview() };
|
|
15654
|
+
if (method === "PUT")
|
|
15655
|
+
return { body: await cms.ai.update(await readJson(ctx.request), ctx.user) };
|
|
15656
|
+
throw methodNotAllowed(ctx, "GET, PUT");
|
|
15657
|
+
}
|
|
15658
|
+
if (first === "admin" && second === "integrations") {
|
|
15659
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
15660
|
+
if (cms.config.integrations.length === 0) throw new HttpError("Not found", 404);
|
|
15661
|
+
if (!await cms.roles.canView(ctx.user, "integrations")) throw new ForbiddenError();
|
|
15662
|
+
const user = ctx.user;
|
|
15663
|
+
const [, , id2, part, cid, action, extra] = segments;
|
|
15664
|
+
const integrations = cms.integrations;
|
|
15665
|
+
if (id2 === void 0) {
|
|
15666
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
15667
|
+
return { body: await integrations.overview() };
|
|
15668
|
+
}
|
|
15669
|
+
const callbackUrl = `${cms.config.serverURL ?? ctx.url.origin}${cms.config.routes.api}/admin/integrations/${id2}/callback`;
|
|
15670
|
+
if (part === void 0) {
|
|
15671
|
+
if (method !== "PUT") throw methodNotAllowed(ctx, "PUT");
|
|
15672
|
+
const body = await readJson(ctx.request);
|
|
15673
|
+
return { body: await integrations.setEnabled(id2, body.enabled !== false, user) };
|
|
15674
|
+
}
|
|
15675
|
+
if (part === "authorize" && cid === void 0) {
|
|
15676
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15677
|
+
const body = await readJson(ctx.request);
|
|
15678
|
+
return {
|
|
15679
|
+
body: await integrations.authorize(id2, user, callbackUrl, String(body.name ?? ""))
|
|
15680
|
+
};
|
|
15681
|
+
}
|
|
15682
|
+
if (part === "callback" && cid === void 0) {
|
|
15683
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
15684
|
+
const page = new URL(`${cms.config.admin.path}/integrations`, ctx.url.origin);
|
|
15685
|
+
page.searchParams.set("integration", id2);
|
|
15686
|
+
try {
|
|
15687
|
+
const error = ctx.url.searchParams.get("error_description") ?? ctx.url.searchParams.get("error");
|
|
15688
|
+
if (error) throw new Error(error);
|
|
15689
|
+
const result = await integrations.callback(
|
|
15690
|
+
id2,
|
|
15691
|
+
ctx.url.searchParams.get("code") ?? "",
|
|
15692
|
+
ctx.url.searchParams.get("state") ?? "",
|
|
15693
|
+
callbackUrl,
|
|
15694
|
+
user
|
|
15695
|
+
);
|
|
15696
|
+
if ("pending" in result) page.searchParams.set("pending", String(result.pending));
|
|
15697
|
+
else page.searchParams.set("connected", "1");
|
|
15698
|
+
} catch (error) {
|
|
15699
|
+
page.searchParams.set("error", error.message.slice(0, 300));
|
|
15700
|
+
}
|
|
15701
|
+
return { body: new Response(null, { status: 302, headers: { location: page.toString() } }) };
|
|
15702
|
+
}
|
|
15703
|
+
if (part === "consent" && cid === void 0) {
|
|
15704
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15705
|
+
return { body: await integrations.consent(id2, user) };
|
|
15706
|
+
}
|
|
15707
|
+
if (part === "lookup" && cid !== void 0 && action === void 0) {
|
|
15708
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15709
|
+
return { body: await integrations.lookup(id2, cid, await readJson(ctx.request)) };
|
|
15710
|
+
}
|
|
15711
|
+
if (part === "pending" && cid !== void 0 && action === void 0) {
|
|
15712
|
+
const pid = parseId(cid);
|
|
15713
|
+
if (pid === void 0) throw new HttpError("Not found", 404);
|
|
15714
|
+
if (method === "GET") return { body: await integrations.pending(id2, pid, user) };
|
|
15715
|
+
if (method === "POST") {
|
|
15716
|
+
const body = await readJson(ctx.request);
|
|
15717
|
+
return {
|
|
15718
|
+
status: 201,
|
|
15719
|
+
body: await integrations.choose(id2, pid, String(body.account ?? ""), user)
|
|
15720
|
+
};
|
|
15721
|
+
}
|
|
15722
|
+
throw methodNotAllowed(ctx, "GET, POST");
|
|
15723
|
+
}
|
|
15724
|
+
if (part === "connections" && extra === void 0) {
|
|
15725
|
+
if (cid === void 0) {
|
|
15726
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15727
|
+
return {
|
|
15728
|
+
status: 201,
|
|
15729
|
+
body: await integrations.connect(id2, await readJson(ctx.request), user)
|
|
15730
|
+
};
|
|
15731
|
+
}
|
|
15732
|
+
if (action === "test") {
|
|
15733
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15734
|
+
const target = cid === "env" ? "env" : parseId(cid);
|
|
15735
|
+
if (target === void 0) throw new HttpError("Not found", 404);
|
|
15736
|
+
return { body: await integrations.test(id2, target) };
|
|
15737
|
+
}
|
|
15738
|
+
const connection = parseId(cid);
|
|
15739
|
+
if (connection === void 0 || action !== void 0) throw new HttpError("Not found", 404);
|
|
15740
|
+
if (method === "PATCH")
|
|
15741
|
+
return {
|
|
15742
|
+
body: await integrations.update(id2, connection, await readJson(ctx.request), user)
|
|
15743
|
+
};
|
|
15744
|
+
if (method === "DELETE") return { body: await integrations.disconnect(id2, connection, user) };
|
|
15745
|
+
throw methodNotAllowed(ctx, "PATCH, DELETE");
|
|
15746
|
+
}
|
|
15747
|
+
throw new HttpError("Not found", 404);
|
|
15748
|
+
}
|
|
14297
15749
|
if (first === "admin" && second === "deliveries") {
|
|
14298
15750
|
if (!ctx.user) throw new UnauthorizedError();
|
|
14299
15751
|
if (!await cms.roles.canView(ctx.user, "deliveries")) throw new ForbiddenError();
|
|
@@ -15313,10 +16765,11 @@ export {
|
|
|
15313
16765
|
isStaff,
|
|
15314
16766
|
isSignedIn,
|
|
15315
16767
|
isAdmin,
|
|
15316
|
-
secretsOf,
|
|
15317
16768
|
MEDIA,
|
|
15318
16769
|
MEDIA_FOLDERS,
|
|
15319
16770
|
INTERNAL_COLLECTIONS,
|
|
16771
|
+
AiProviderError,
|
|
16772
|
+
secretsOf,
|
|
15320
16773
|
AuditLog,
|
|
15321
16774
|
API_KEYS,
|
|
15322
16775
|
API_KEY_OPERATIONS,
|