@easy-cms/core 1.32.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-J4NASOMO.js → chunk-OBVILNIV.js} +877 -225
- package/dist/{config-Dr_NZGm6.d.ts → config-3opRDa7L.d.ts} +787 -523
- package/dist/{handler-B0m4VOAy.d.ts → handler-Ce-WEeAh.d.ts} +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +1 -1
- package/dist/{plugin-DBHFjM-r.d.ts → plugin-D7ra0pjj.d.ts} +1 -1
- package/dist/plugin.d.ts +2 -2
- package/package.json +1 -1
|
@@ -50,100 +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
|
-
var signValue = (secret, value) => hmac(secret, value);
|
|
65
|
-
function secretsOf(config) {
|
|
66
|
-
return [config.secret, ...config.previousSecrets ?? []];
|
|
67
|
-
}
|
|
68
|
-
function signedWith(secrets, value, signature) {
|
|
69
|
-
return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
|
|
70
|
-
}
|
|
71
|
-
function signToken(secret, token) {
|
|
72
|
-
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
73
|
-
}
|
|
74
|
-
function unsignToken(secrets, value) {
|
|
75
|
-
const dot = value.lastIndexOf(".");
|
|
76
|
-
if (dot <= 0) return void 0;
|
|
77
|
-
const token = value.slice(0, dot);
|
|
78
|
-
return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
|
|
79
|
-
}
|
|
80
|
-
function csrfToken(secret, token) {
|
|
81
|
-
return hmac(secret, `csrf:${token}`);
|
|
82
|
-
}
|
|
83
|
-
function csrfMatches(secrets, token, given) {
|
|
84
|
-
return signedWith(secrets, `csrf:${token}`, given);
|
|
85
|
-
}
|
|
86
|
-
function safeEqual(a, b) {
|
|
87
|
-
const left = Buffer.from(a);
|
|
88
|
-
const right = Buffer.from(b);
|
|
89
|
-
return left.length === right.length && timingSafeEqual(left, right);
|
|
90
|
-
}
|
|
91
|
-
function signPreviewToken(secret, target, expiresAt, user) {
|
|
92
|
-
const payload = Buffer.from(
|
|
93
|
-
JSON.stringify({
|
|
94
|
-
..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
|
|
95
|
-
e: expiresAt,
|
|
96
|
-
...user !== void 0 ? { u: user } : {}
|
|
97
|
-
})
|
|
98
|
-
).toString("base64url");
|
|
99
|
-
return `${payload}.${hmac(secret, `preview:${payload}`)}`;
|
|
100
|
-
}
|
|
101
|
-
function verifyPreviewToken(secrets, token, now = Date.now()) {
|
|
102
|
-
const dot = token.lastIndexOf(".");
|
|
103
|
-
if (dot <= 0) return null;
|
|
104
|
-
const payload = token.slice(0, dot);
|
|
105
|
-
if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
|
|
106
|
-
try {
|
|
107
|
-
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
108
|
-
if (typeof data.e !== "number" || data.e <= now) return null;
|
|
109
|
-
const user = typeof data.u === "string" ? { user: data.u } : {};
|
|
110
|
-
if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
|
|
111
|
-
if (typeof data.c === "string" && typeof data.i === "string")
|
|
112
|
-
return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
|
|
113
|
-
return null;
|
|
114
|
-
} catch {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
function passwordFingerprint(passwordHash) {
|
|
119
|
-
return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
|
|
120
|
-
}
|
|
121
|
-
function signPasswordToken(secret, args) {
|
|
122
|
-
const payload = Buffer.from(
|
|
123
|
-
JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
|
|
124
|
-
).toString("base64url");
|
|
125
|
-
return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
|
|
126
|
-
}
|
|
127
|
-
function readPasswordToken(token) {
|
|
128
|
-
const dot = token.lastIndexOf(".");
|
|
129
|
-
if (dot <= 0) return null;
|
|
130
|
-
const payload = token.slice(0, dot);
|
|
131
|
-
try {
|
|
132
|
-
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
133
|
-
if (typeof data.u !== "string" || typeof data.e !== "number") return null;
|
|
134
|
-
if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
|
|
135
|
-
return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
|
|
136
|
-
} catch {
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
function passwordTokenMatches(secrets, token, fingerprint) {
|
|
141
|
-
const dot = token.lastIndexOf(".");
|
|
142
|
-
if (dot <= 0) return false;
|
|
143
|
-
const payload = token.slice(0, dot);
|
|
144
|
-
return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
53
|
// src/builtins.ts
|
|
148
54
|
var USERS = "users";
|
|
149
55
|
var MEDIA = "media";
|
|
@@ -164,6 +70,7 @@ var RATE_LIMITS = "rate-limits";
|
|
|
164
70
|
var BULK_OPERATIONS = "bulk-operations";
|
|
165
71
|
var SAVED_VIEWS = "saved-views";
|
|
166
72
|
var CONNECTIONS = "connections";
|
|
73
|
+
var AI_USAGE = "ai-usage";
|
|
167
74
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
168
75
|
SESSIONS,
|
|
169
76
|
LOGIN_ATTEMPTS,
|
|
@@ -180,7 +87,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
180
87
|
RATE_LIMITS,
|
|
181
88
|
BULK_OPERATIONS,
|
|
182
89
|
SAVED_VIEWS,
|
|
183
|
-
CONNECTIONS
|
|
90
|
+
CONNECTIONS,
|
|
91
|
+
AI_USAGE
|
|
184
92
|
]);
|
|
185
93
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
186
94
|
var CREATED_BY_FIELD = {
|
|
@@ -602,6 +510,22 @@ var connectionsCollection = {
|
|
|
602
510
|
{ name: "enabled", type: "boolean" }
|
|
603
511
|
]
|
|
604
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
|
+
};
|
|
605
529
|
var webhookDeliveriesCollection = {
|
|
606
530
|
slug: WEBHOOK_DELIVERIES,
|
|
607
531
|
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
@@ -741,6 +665,667 @@ var emailDeliveriesCollection = {
|
|
|
741
665
|
]
|
|
742
666
|
};
|
|
743
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 CORE_AI_FEATURES = [
|
|
752
|
+
{
|
|
753
|
+
id: "alt-text",
|
|
754
|
+
label: { en: "Alt text", th: "\u0E40\u0E02\u0E35\u0E22\u0E19 alt text" },
|
|
755
|
+
description: {
|
|
756
|
+
en: "Describes an image in the media library",
|
|
757
|
+
th: "\u0E1A\u0E23\u0E23\u0E22\u0E32\u0E22\u0E23\u0E39\u0E1B\u0E43\u0E19\u0E04\u0E25\u0E31\u0E07\u0E2A\u0E37\u0E48\u0E2D"
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
];
|
|
761
|
+
var SETTINGS = "ai";
|
|
762
|
+
var DEFAULT_LIMIT = 100;
|
|
763
|
+
var TIMEOUT = 6e4;
|
|
764
|
+
var MAX_IMAGE = 4 * 1024 * 1024;
|
|
765
|
+
var KEEP = 90 * 24 * 36e5;
|
|
766
|
+
var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
767
|
+
var AI_TEST_FEATURE = "test";
|
|
768
|
+
var ref = (integration, name) => `${integration}:${name}`;
|
|
769
|
+
var Ai = class {
|
|
770
|
+
constructor(cms, ai) {
|
|
771
|
+
this.cms = cms;
|
|
772
|
+
this.ai = ai;
|
|
773
|
+
}
|
|
774
|
+
cms;
|
|
775
|
+
ai;
|
|
776
|
+
get config() {
|
|
777
|
+
return this.cms.config;
|
|
778
|
+
}
|
|
779
|
+
get defaultLimit() {
|
|
780
|
+
return this.ai.limits?.perUserPerDay ?? DEFAULT_LIMIT;
|
|
781
|
+
}
|
|
782
|
+
/** The features of the config. */
|
|
783
|
+
get features() {
|
|
784
|
+
return this.config.aiFeatures;
|
|
785
|
+
}
|
|
786
|
+
async settingsRow() {
|
|
787
|
+
const found = await this.cms.db.find({
|
|
788
|
+
collection: CONNECTIONS,
|
|
789
|
+
where: { and: [{ kind: { equals: "settings" } }, { integration: { equals: SETTINGS } }] },
|
|
790
|
+
sort: ["id"],
|
|
791
|
+
limit: 1,
|
|
792
|
+
page: 1
|
|
793
|
+
});
|
|
794
|
+
return found.docs[0];
|
|
795
|
+
}
|
|
796
|
+
async settings() {
|
|
797
|
+
return (await this.settingsRow())?.values ?? {};
|
|
798
|
+
}
|
|
799
|
+
/** Every connection of every provider, in the providers' order. */
|
|
800
|
+
async connections() {
|
|
801
|
+
const out = [];
|
|
802
|
+
for (const provider of this.ai.providers)
|
|
803
|
+
for (const conn of await this.cms.integrations.all(provider.integration))
|
|
804
|
+
out.push({ ...conn, integration: provider.integration });
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
/** Whether a feature is on: admins' choice, else its `default`. */
|
|
808
|
+
async enabled(feature) {
|
|
809
|
+
const def = this.features.find((f) => f.id === feature);
|
|
810
|
+
if (!def) return false;
|
|
811
|
+
return (await this.settings()).features?.[feature]?.enabled ?? def.default ?? true;
|
|
812
|
+
}
|
|
813
|
+
/** Requests a user may make today: their role's, else everyone's. */
|
|
814
|
+
async limitFor(user) {
|
|
815
|
+
const role = typeof user.role === "string" ? user.role : void 0;
|
|
816
|
+
if (role) {
|
|
817
|
+
const fromRoles = await this.cms.roles.aiLimit(role);
|
|
818
|
+
if (fromRoles !== void 0) return fromRoles;
|
|
819
|
+
const fromConfig = this.ai.limits?.roles?.[role];
|
|
820
|
+
if (fromConfig !== void 0) return fromConfig;
|
|
821
|
+
}
|
|
822
|
+
return (await this.settings()).perUserPerDay ?? this.defaultLimit;
|
|
823
|
+
}
|
|
824
|
+
/** Requests the user made today (in `timeZone`) and how many are left. */
|
|
825
|
+
async remaining(user, now = /* @__PURE__ */ new Date()) {
|
|
826
|
+
const since = lastSlot(now, "00:00", this.config.timeZone).toISOString();
|
|
827
|
+
const used = await this.cms.db.count({
|
|
828
|
+
collection: AI_USAGE,
|
|
829
|
+
where: {
|
|
830
|
+
and: [
|
|
831
|
+
{ user: { equals: String(user.id) } },
|
|
832
|
+
{ ok: { equals: true } },
|
|
833
|
+
{ createdAt: { gte: since } }
|
|
834
|
+
]
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
return { used, limit: await this.limitFor(user) };
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Asks the feature's connection (or the main one). With `user`, the request counts toward their
|
|
841
|
+
* daily limit; it is refused once they reach it. Usage is recorded, never the prompt.
|
|
842
|
+
*/
|
|
843
|
+
async generate(request, options = {}) {
|
|
844
|
+
const feature = this.features.find((f) => f.id === request.feature);
|
|
845
|
+
if (!feature)
|
|
846
|
+
throw new QueryError(
|
|
847
|
+
`Unknown AI feature "${request.feature}": add it to \`aiFeatures\` in the config`
|
|
848
|
+
);
|
|
849
|
+
if (!await this.enabled(feature.id))
|
|
850
|
+
throw new QueryError(`The AI feature "${feature.id}" is turned off`);
|
|
851
|
+
const user = options.user ?? null;
|
|
852
|
+
if (user) {
|
|
853
|
+
const { used, limit } = await this.remaining(user);
|
|
854
|
+
if (used >= limit)
|
|
855
|
+
throw new TooManyRequestsError(
|
|
856
|
+
limit === 0 ? "Your role may not use AI" : `You used AI ${limit} times today, the most allowed`
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
const conn = await this.connectionFor(feature.id);
|
|
860
|
+
const images = await Promise.all((request.images ?? []).map((i) => this.image(i)));
|
|
861
|
+
return this.ask(conn, {
|
|
862
|
+
feature: feature.id,
|
|
863
|
+
user,
|
|
864
|
+
about: options.about,
|
|
865
|
+
request: {
|
|
866
|
+
...request.system ? { system: request.system } : {},
|
|
867
|
+
prompt: request.prompt,
|
|
868
|
+
images,
|
|
869
|
+
...request.json ? { json: request.json } : {},
|
|
870
|
+
maxTokens: request.maxTokens ?? 1024
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* The Test button of a provider's connection: a short prompt; its tokens count on the card,
|
|
876
|
+
* not toward anyone's limit.
|
|
877
|
+
*/
|
|
878
|
+
async test(integration, values, name) {
|
|
879
|
+
try {
|
|
880
|
+
await this.ask(
|
|
881
|
+
{ integration, name: name ?? "", values, env: false },
|
|
882
|
+
{
|
|
883
|
+
feature: AI_TEST_FEATURE,
|
|
884
|
+
user: null,
|
|
885
|
+
markConnection: false,
|
|
886
|
+
request: { prompt: "Reply with the word OK.", images: [], maxTokens: 16 }
|
|
887
|
+
}
|
|
888
|
+
);
|
|
889
|
+
return { ok: true, ...values.model ? { account: values.model } : {} };
|
|
890
|
+
} catch (error) {
|
|
891
|
+
return { ok: false, message: error.message };
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
async connectionFor(feature) {
|
|
895
|
+
const settings = await this.settings();
|
|
896
|
+
const wanted = settings.features?.[feature]?.connection ?? settings.main;
|
|
897
|
+
const all = await this.connections();
|
|
898
|
+
const conn = wanted && all.find((c) => ref(c.integration, c.name) === wanted) || all[0];
|
|
899
|
+
if (!conn)
|
|
900
|
+
throw new QueryError("No AI provider is connected: connect one in Settings \u2192 Integrations");
|
|
901
|
+
return conn;
|
|
902
|
+
}
|
|
903
|
+
async ask(conn, args) {
|
|
904
|
+
const provider = this.ai.providers.find((p) => p.integration === conn.integration);
|
|
905
|
+
if (!provider) throw new QueryError(`No AI provider answers for "${conn.integration}"`);
|
|
906
|
+
const record = (data) => this.cms.db.create({
|
|
907
|
+
collection: AI_USAGE,
|
|
908
|
+
data: {
|
|
909
|
+
user: args.user ? String(args.user.id) : null,
|
|
910
|
+
feature: args.feature,
|
|
911
|
+
integration: conn.integration,
|
|
912
|
+
connection: conn.name || null,
|
|
913
|
+
input: 0,
|
|
914
|
+
output: 0,
|
|
915
|
+
error: null,
|
|
916
|
+
about: args.about ? `${args.about.collection}:${args.about.id}` : null,
|
|
917
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
918
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
919
|
+
...data
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
let result;
|
|
923
|
+
try {
|
|
924
|
+
result = await this.call(provider, conn.values, args.request);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
const status = error instanceof AiProviderError ? error.status : void 0;
|
|
927
|
+
const message2 = error.message;
|
|
928
|
+
await record({ ok: false, error: message2.slice(0, 500) });
|
|
929
|
+
if ((status === 401 || status === 403) && args.markConnection !== false && conn.name)
|
|
930
|
+
await this.cms.integrations.markError(conn.integration, message2, conn.name);
|
|
931
|
+
throw new QueryError(message2);
|
|
932
|
+
}
|
|
933
|
+
await record({ ok: true, input: result.usage.input, output: result.usage.output });
|
|
934
|
+
if (args.markConnection !== false && conn.name)
|
|
935
|
+
await this.cms.integrations.markUsed(conn.integration, conn.name);
|
|
936
|
+
let json2;
|
|
937
|
+
if (args.request.json) {
|
|
938
|
+
try {
|
|
939
|
+
json2 = JSON.parse(stripFence(result.text));
|
|
940
|
+
} catch {
|
|
941
|
+
throw new QueryError("The AI answered with something other than JSON; try again");
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return {
|
|
945
|
+
text: result.text,
|
|
946
|
+
...args.request.json ? { json: json2 } : {},
|
|
947
|
+
usage: result.usage,
|
|
948
|
+
connection: ref(conn.integration, conn.name)
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
/** One try, and another after a second when the provider is busy or failing (429, 5xx). */
|
|
952
|
+
async call(provider, values, request) {
|
|
953
|
+
for (let attempt = 0; ; attempt++) {
|
|
954
|
+
try {
|
|
955
|
+
return await provider.generate(values, { ...request, signal: AbortSignal.timeout(TIMEOUT) });
|
|
956
|
+
} catch (error) {
|
|
957
|
+
const status = error instanceof AiProviderError ? error.status : void 0;
|
|
958
|
+
const busy = status === 429 || status !== void 0 && status >= 500;
|
|
959
|
+
if (!busy || attempt > 0) {
|
|
960
|
+
if (error.name === "TimeoutError")
|
|
961
|
+
throw new AiProviderError("The AI provider took longer than 60 seconds");
|
|
962
|
+
throw error;
|
|
963
|
+
}
|
|
964
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/** An image as base64: given, or read from a media document. */
|
|
969
|
+
async image(image) {
|
|
970
|
+
if (image.data !== void 0) {
|
|
971
|
+
if (!image.mediaType || !IMAGE_TYPES.has(image.mediaType))
|
|
972
|
+
throw new ValidationError(AI_USAGE, [
|
|
973
|
+
{ field: "images", message: "must be JPEG, PNG, WebP or GIF" }
|
|
974
|
+
]);
|
|
975
|
+
return { data: image.data, mediaType: image.mediaType };
|
|
976
|
+
}
|
|
977
|
+
if (image.media === void 0)
|
|
978
|
+
throw new ValidationError(AI_USAGE, [{ field: "images", message: "needs media or data" }]);
|
|
979
|
+
const doc = await this.cms.db.findById({ collection: MEDIA, id: image.media });
|
|
980
|
+
if (!doc) throw new ValidationError(AI_USAGE, [{ field: "images", message: "media not found" }]);
|
|
981
|
+
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));
|
|
982
|
+
const filename = String(sizes[0]?.filename ?? doc.filename);
|
|
983
|
+
const mediaType = String(doc.mimeType ?? "");
|
|
984
|
+
if (!IMAGE_TYPES.has(mediaType))
|
|
985
|
+
throw new ValidationError(AI_USAGE, [
|
|
986
|
+
{ field: "images", message: `${mediaType || "this file"} is not an image AI can read` }
|
|
987
|
+
]);
|
|
988
|
+
const file = await this.cms.storageFor(doc.private === true).get(filename);
|
|
989
|
+
if (!file) throw new ValidationError(AI_USAGE, [{ field: "images", message: "file not found" }]);
|
|
990
|
+
if (file.body.byteLength > MAX_IMAGE)
|
|
991
|
+
throw new ValidationError(AI_USAGE, [
|
|
992
|
+
{ field: "images", message: "is larger than 4 MB; add an image size of about 1024 pixels" }
|
|
993
|
+
]);
|
|
994
|
+
return { data: Buffer.from(file.body).toString("base64"), mediaType };
|
|
995
|
+
}
|
|
996
|
+
/** For the admin's AI buttons: whether they work, and how many requests are left today. */
|
|
997
|
+
async status(user) {
|
|
998
|
+
const enabled = await Promise.all(
|
|
999
|
+
this.features.map(async (f) => await this.enabled(f.id) ? f.id : null)
|
|
1000
|
+
);
|
|
1001
|
+
return {
|
|
1002
|
+
connected: (await this.connections()).length > 0,
|
|
1003
|
+
features: enabled.filter((id) => id !== null),
|
|
1004
|
+
...await this.remaining(user)
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/** The language to write in: the content locale being edited, else the default one. */
|
|
1008
|
+
language(locale) {
|
|
1009
|
+
const code = locale ?? this.config.localization?.defaultLocale ?? this.config.admin.locale ?? "en";
|
|
1010
|
+
try {
|
|
1011
|
+
return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code;
|
|
1012
|
+
} catch {
|
|
1013
|
+
return code;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Alt text for an image in the media library (`alt-text`), for the user to review before saving:
|
|
1018
|
+
* what it shows, in one sentence of at most 125 characters.
|
|
1019
|
+
*/
|
|
1020
|
+
async altText(id, options) {
|
|
1021
|
+
const access = await this.cms.documentPermissions(
|
|
1022
|
+
MEDIA,
|
|
1023
|
+
String(id),
|
|
1024
|
+
options.user,
|
|
1025
|
+
options.context ?? {}
|
|
1026
|
+
);
|
|
1027
|
+
if (!access.update) throw new ForbiddenError("You may not change this file");
|
|
1028
|
+
const result = await this.generate(
|
|
1029
|
+
{
|
|
1030
|
+
feature: "alt-text",
|
|
1031
|
+
system: [
|
|
1032
|
+
"You write alt text for images on a website: one plain sentence saying what the image",
|
|
1033
|
+
'shows, for people who cannot see it. Do not start with "Image of" or "Picture of".',
|
|
1034
|
+
`At most 125 characters. Write it in ${this.language(options.locale)}.`,
|
|
1035
|
+
"Answer with the alt text only, without quotes."
|
|
1036
|
+
].join(" "),
|
|
1037
|
+
prompt: "Write the alt text for this image.",
|
|
1038
|
+
images: [{ media: id }],
|
|
1039
|
+
maxTokens: 200
|
|
1040
|
+
},
|
|
1041
|
+
{ user: options.user, about: { collection: MEDIA, id } }
|
|
1042
|
+
);
|
|
1043
|
+
return { alt: shorten(result.text.trim().replace(/^["'“]+|["'”]+$/g, ""), 125) };
|
|
1044
|
+
}
|
|
1045
|
+
// --- Admins ----------------------------------------------------------------------------------
|
|
1046
|
+
/** Settings → Integrations, AI: the main connection, the features, the limit and this month. */
|
|
1047
|
+
async overview(now = /* @__PURE__ */ new Date()) {
|
|
1048
|
+
const settings = await this.settings();
|
|
1049
|
+
const conns = await this.connections();
|
|
1050
|
+
const labels = new Map(this.config.integrations.map((i) => [i.id, i.label]));
|
|
1051
|
+
const month = monthStart(now, this.config.timeZone).toISOString();
|
|
1052
|
+
const rows2 = (await this.cms.db.find({
|
|
1053
|
+
collection: AI_USAGE,
|
|
1054
|
+
where: { createdAt: { gte: month } },
|
|
1055
|
+
sort: ["id"],
|
|
1056
|
+
limit: 0,
|
|
1057
|
+
page: 1
|
|
1058
|
+
})).docs;
|
|
1059
|
+
const sum = (key) => {
|
|
1060
|
+
const totals = /* @__PURE__ */ new Map();
|
|
1061
|
+
for (const row of rows2) {
|
|
1062
|
+
if (key === "user" && !row.user) continue;
|
|
1063
|
+
const k = String(row[key]);
|
|
1064
|
+
const t = totals.get(k) ?? { requests: 0, input: 0, output: 0 };
|
|
1065
|
+
t.requests++;
|
|
1066
|
+
t.input += Number(row.input ?? 0);
|
|
1067
|
+
t.output += Number(row.output ?? 0);
|
|
1068
|
+
totals.set(k, t);
|
|
1069
|
+
}
|
|
1070
|
+
return [...totals].sort((a, b) => b[1].requests - a[1].requests);
|
|
1071
|
+
};
|
|
1072
|
+
const users = sum("user");
|
|
1073
|
+
const emails = /* @__PURE__ */ new Map();
|
|
1074
|
+
if (users.length > 0) {
|
|
1075
|
+
const found = await this.cms.db.findByIds({
|
|
1076
|
+
collection: "users",
|
|
1077
|
+
ids: users.map(([id]) => /^\d+$/.test(id) ? Number(id) : id)
|
|
1078
|
+
});
|
|
1079
|
+
for (const u of found) emails.set(String(u.id), String(u.email ?? u.id));
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
main: settings.main ?? null,
|
|
1083
|
+
perUserPerDay: settings.perUserPerDay ?? this.defaultLimit,
|
|
1084
|
+
defaultPerUserPerDay: this.defaultLimit,
|
|
1085
|
+
connections: conns.map((c) => ({
|
|
1086
|
+
id: ref(c.integration, c.name),
|
|
1087
|
+
integration: c.integration,
|
|
1088
|
+
name: c.name,
|
|
1089
|
+
label: labels.get(c.integration) ?? c.integration
|
|
1090
|
+
})),
|
|
1091
|
+
features: await Promise.all(
|
|
1092
|
+
this.features.map(async (f) => ({
|
|
1093
|
+
id: f.id,
|
|
1094
|
+
label: f.label,
|
|
1095
|
+
...f.description ? { description: f.description } : {},
|
|
1096
|
+
enabled: await this.enabled(f.id),
|
|
1097
|
+
connection: settings.features?.[f.id]?.connection ?? null
|
|
1098
|
+
}))
|
|
1099
|
+
),
|
|
1100
|
+
usage: {
|
|
1101
|
+
byUser: users.map(([id, t]) => ({ user: emails.get(id) ?? id, ...t })),
|
|
1102
|
+
byFeature: sum("feature").map(([feature, t]) => ({ feature, ...t }))
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
/** Tokens and requests of each connection this month, for the cards. */
|
|
1107
|
+
async usageByConnection(now = /* @__PURE__ */ new Date()) {
|
|
1108
|
+
const month = monthStart(now, this.config.timeZone).toISOString();
|
|
1109
|
+
const rows2 = (await this.cms.db.find({
|
|
1110
|
+
collection: AI_USAGE,
|
|
1111
|
+
where: { createdAt: { gte: month } },
|
|
1112
|
+
sort: ["id"],
|
|
1113
|
+
limit: 0,
|
|
1114
|
+
page: 1
|
|
1115
|
+
})).docs;
|
|
1116
|
+
const out = /* @__PURE__ */ new Map();
|
|
1117
|
+
for (const row of rows2) {
|
|
1118
|
+
const k = ref(String(row.integration), String(row.connection ?? ""));
|
|
1119
|
+
const t = out.get(k) ?? { requests: 0, input: 0, output: 0 };
|
|
1120
|
+
t.requests++;
|
|
1121
|
+
t.input += Number(row.input ?? 0);
|
|
1122
|
+
t.output += Number(row.output ?? 0);
|
|
1123
|
+
out.set(k, t);
|
|
1124
|
+
}
|
|
1125
|
+
return out;
|
|
1126
|
+
}
|
|
1127
|
+
/** Saves admins' choices; audit-logged. */
|
|
1128
|
+
async update(body, user) {
|
|
1129
|
+
const input = body ?? {};
|
|
1130
|
+
const current = await this.settings();
|
|
1131
|
+
const conns = new Set((await this.connections()).map((c) => ref(c.integration, c.name)));
|
|
1132
|
+
const next = { ...current };
|
|
1133
|
+
const fail = (field, message2) => {
|
|
1134
|
+
throw new ValidationError(AI_USAGE, [{ field, message: message2 }]);
|
|
1135
|
+
};
|
|
1136
|
+
if (input.main !== void 0) {
|
|
1137
|
+
if (input.main === null) delete next.main;
|
|
1138
|
+
else if (typeof input.main === "string" && conns.has(input.main)) next.main = input.main;
|
|
1139
|
+
else fail("main", "is not a connection");
|
|
1140
|
+
}
|
|
1141
|
+
if (input.perUserPerDay !== void 0) {
|
|
1142
|
+
if (input.perUserPerDay === null) delete next.perUserPerDay;
|
|
1143
|
+
else if (Number.isInteger(input.perUserPerDay) && input.perUserPerDay >= 0)
|
|
1144
|
+
next.perUserPerDay = input.perUserPerDay;
|
|
1145
|
+
else fail("perUserPerDay", "must be a whole number, 0 or more");
|
|
1146
|
+
}
|
|
1147
|
+
if (input.features !== void 0) {
|
|
1148
|
+
if (typeof input.features !== "object" || input.features === null)
|
|
1149
|
+
fail("features", "must be an object");
|
|
1150
|
+
const features = { ...current.features ?? {} };
|
|
1151
|
+
for (const [id, raw] of Object.entries(input.features)) {
|
|
1152
|
+
if (!this.features.some((f) => f.id === id)) fail("features", `unknown feature "${id}"`);
|
|
1153
|
+
const change = raw ?? {};
|
|
1154
|
+
const entry = { ...features[id] ?? {} };
|
|
1155
|
+
if (typeof change.enabled === "boolean") entry.enabled = change.enabled;
|
|
1156
|
+
if (change.connection === null) delete entry.connection;
|
|
1157
|
+
else if (change.connection !== void 0) {
|
|
1158
|
+
if (typeof change.connection !== "string" || !conns.has(change.connection))
|
|
1159
|
+
fail("features", "connection is not a connection");
|
|
1160
|
+
entry.connection = change.connection;
|
|
1161
|
+
}
|
|
1162
|
+
features[id] = entry;
|
|
1163
|
+
}
|
|
1164
|
+
next.features = features;
|
|
1165
|
+
}
|
|
1166
|
+
const row = await this.settingsRow();
|
|
1167
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1168
|
+
if (row)
|
|
1169
|
+
await this.cms.db.update({
|
|
1170
|
+
collection: CONNECTIONS,
|
|
1171
|
+
id: row.id,
|
|
1172
|
+
data: { ...row, values: next, updatedAt: now }
|
|
1173
|
+
});
|
|
1174
|
+
else
|
|
1175
|
+
await this.cms.db.create({
|
|
1176
|
+
collection: CONNECTIONS,
|
|
1177
|
+
data: {
|
|
1178
|
+
kind: "settings",
|
|
1179
|
+
integration: SETTINGS,
|
|
1180
|
+
name: null,
|
|
1181
|
+
isDefault: false,
|
|
1182
|
+
values: next,
|
|
1183
|
+
secret: null,
|
|
1184
|
+
account: null,
|
|
1185
|
+
lastUsedAt: null,
|
|
1186
|
+
lastError: null,
|
|
1187
|
+
enabled: null,
|
|
1188
|
+
createdAt: now,
|
|
1189
|
+
updatedAt: now
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
await this.cms.audit.record({
|
|
1193
|
+
action: "integration.ai",
|
|
1194
|
+
target: SETTINGS,
|
|
1195
|
+
user,
|
|
1196
|
+
detail: { ...next }
|
|
1197
|
+
});
|
|
1198
|
+
return this.overview();
|
|
1199
|
+
}
|
|
1200
|
+
/** Forgets usage older than 90 days (with the scheduled jobs). */
|
|
1201
|
+
async upkeep(now = /* @__PURE__ */ new Date()) {
|
|
1202
|
+
const before = new Date(now.getTime() - KEEP).toISOString();
|
|
1203
|
+
for (; ; ) {
|
|
1204
|
+
const old = await this.cms.db.find({
|
|
1205
|
+
collection: AI_USAGE,
|
|
1206
|
+
where: { createdAt: { lt: before } },
|
|
1207
|
+
sort: ["id"],
|
|
1208
|
+
limit: 500,
|
|
1209
|
+
page: 1
|
|
1210
|
+
});
|
|
1211
|
+
for (const row of old.docs) await this.cms.db.delete({ collection: AI_USAGE, id: row.id });
|
|
1212
|
+
if (old.docs.length < 500) return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
function shorten(text4, max) {
|
|
1217
|
+
if (text4.length <= max) return text4;
|
|
1218
|
+
const cut2 = text4.slice(0, max);
|
|
1219
|
+
const space = cut2.lastIndexOf(" ");
|
|
1220
|
+
return (space > max * 0.6 ? cut2.slice(0, space) : cut2).replace(/[\s,;:]+$/, "");
|
|
1221
|
+
}
|
|
1222
|
+
function stripFence(text4) {
|
|
1223
|
+
const trimmed = text4.trim();
|
|
1224
|
+
const match = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/.exec(trimmed);
|
|
1225
|
+
return match ? match[1] : trimmed;
|
|
1226
|
+
}
|
|
1227
|
+
function monthStart(now, zone) {
|
|
1228
|
+
let day = lastSlot(now, "00:00", zone);
|
|
1229
|
+
const dayOf = (d) => Number(new Intl.DateTimeFormat("en-US", { day: "numeric", timeZone: zone }).format(d));
|
|
1230
|
+
for (let i = 0; i < 31 && dayOf(day) > 1; i++)
|
|
1231
|
+
day = lastSlot(new Date(day.getTime() - 1), "00:00", zone);
|
|
1232
|
+
return day;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// src/auth/tokens.ts
|
|
1236
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
1237
|
+
function newToken() {
|
|
1238
|
+
return randomBytes(32).toString("base64url");
|
|
1239
|
+
}
|
|
1240
|
+
function hashToken(token) {
|
|
1241
|
+
return createHash("sha256").update(token).digest("hex");
|
|
1242
|
+
}
|
|
1243
|
+
function hmac(secret, value) {
|
|
1244
|
+
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
1245
|
+
}
|
|
1246
|
+
var signValue = (secret, value) => hmac(secret, value);
|
|
1247
|
+
function secretsOf(config) {
|
|
1248
|
+
return [config.secret, ...config.previousSecrets ?? []];
|
|
1249
|
+
}
|
|
1250
|
+
function signedWith(secrets, value, signature) {
|
|
1251
|
+
return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
|
|
1252
|
+
}
|
|
1253
|
+
function signToken(secret, token) {
|
|
1254
|
+
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
1255
|
+
}
|
|
1256
|
+
function unsignToken(secrets, value) {
|
|
1257
|
+
const dot = value.lastIndexOf(".");
|
|
1258
|
+
if (dot <= 0) return void 0;
|
|
1259
|
+
const token = value.slice(0, dot);
|
|
1260
|
+
return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
|
|
1261
|
+
}
|
|
1262
|
+
function csrfToken(secret, token) {
|
|
1263
|
+
return hmac(secret, `csrf:${token}`);
|
|
1264
|
+
}
|
|
1265
|
+
function csrfMatches(secrets, token, given) {
|
|
1266
|
+
return signedWith(secrets, `csrf:${token}`, given);
|
|
1267
|
+
}
|
|
1268
|
+
function safeEqual(a, b) {
|
|
1269
|
+
const left = Buffer.from(a);
|
|
1270
|
+
const right = Buffer.from(b);
|
|
1271
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
1272
|
+
}
|
|
1273
|
+
function signPreviewToken(secret, target, expiresAt, user) {
|
|
1274
|
+
const payload = Buffer.from(
|
|
1275
|
+
JSON.stringify({
|
|
1276
|
+
..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
|
|
1277
|
+
e: expiresAt,
|
|
1278
|
+
...user !== void 0 ? { u: user } : {}
|
|
1279
|
+
})
|
|
1280
|
+
).toString("base64url");
|
|
1281
|
+
return `${payload}.${hmac(secret, `preview:${payload}`)}`;
|
|
1282
|
+
}
|
|
1283
|
+
function verifyPreviewToken(secrets, token, now = Date.now()) {
|
|
1284
|
+
const dot = token.lastIndexOf(".");
|
|
1285
|
+
if (dot <= 0) return null;
|
|
1286
|
+
const payload = token.slice(0, dot);
|
|
1287
|
+
if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
|
|
1288
|
+
try {
|
|
1289
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1290
|
+
if (typeof data.e !== "number" || data.e <= now) return null;
|
|
1291
|
+
const user = typeof data.u === "string" ? { user: data.u } : {};
|
|
1292
|
+
if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
|
|
1293
|
+
if (typeof data.c === "string" && typeof data.i === "string")
|
|
1294
|
+
return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
|
|
1295
|
+
return null;
|
|
1296
|
+
} catch {
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
function passwordFingerprint(passwordHash) {
|
|
1301
|
+
return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
|
|
1302
|
+
}
|
|
1303
|
+
function signPasswordToken(secret, args) {
|
|
1304
|
+
const payload = Buffer.from(
|
|
1305
|
+
JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
|
|
1306
|
+
).toString("base64url");
|
|
1307
|
+
return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
|
|
1308
|
+
}
|
|
1309
|
+
function readPasswordToken(token) {
|
|
1310
|
+
const dot = token.lastIndexOf(".");
|
|
1311
|
+
if (dot <= 0) return null;
|
|
1312
|
+
const payload = token.slice(0, dot);
|
|
1313
|
+
try {
|
|
1314
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1315
|
+
if (typeof data.u !== "string" || typeof data.e !== "number") return null;
|
|
1316
|
+
if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
|
|
1317
|
+
return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
|
|
1318
|
+
} catch {
|
|
1319
|
+
return null;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
function passwordTokenMatches(secrets, token, fingerprint) {
|
|
1323
|
+
const dot = token.lastIndexOf(".");
|
|
1324
|
+
if (dot <= 0) return false;
|
|
1325
|
+
const payload = token.slice(0, dot);
|
|
1326
|
+
return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
744
1329
|
// src/audit.ts
|
|
745
1330
|
import { AsyncLocalStorage } from "async_hooks";
|
|
746
1331
|
import { createHmac as createHmac2 } from "crypto";
|
|
@@ -758,7 +1343,7 @@ var DEFAULT_FAILED_LOGINS = 20;
|
|
|
758
1343
|
var MAX_VALUE = 500;
|
|
759
1344
|
var PAGE = 50;
|
|
760
1345
|
var CSV_LIMIT = 1e4;
|
|
761
|
-
var
|
|
1346
|
+
var DAY2 = 864e5;
|
|
762
1347
|
var BULKY = /* @__PURE__ */ new Set(["richText", "blocks", "array", "json"]);
|
|
763
1348
|
var auditContext = new AsyncLocalStorage();
|
|
764
1349
|
var SIGN_IN_FAILURES = /* @__PURE__ */ new Set(["login.failed", "login.locked", "sso.failed"]);
|
|
@@ -1033,10 +1618,10 @@ var AuditLog = class {
|
|
|
1033
1618
|
async upkeep(now = /* @__PURE__ */ new Date()) {
|
|
1034
1619
|
if (!this.enabled) return;
|
|
1035
1620
|
const last = await this.lastVerification();
|
|
1036
|
-
if (last && now.getTime() - Date.parse(last.at) <
|
|
1621
|
+
if (last && now.getTime() - Date.parse(last.at) < DAY2) return;
|
|
1037
1622
|
const keep = this.settings.keepDays;
|
|
1038
1623
|
if (keep > 0) {
|
|
1039
|
-
const before = new Date(now.getTime() - keep *
|
|
1624
|
+
const before = new Date(now.getTime() - keep * DAY2).toISOString();
|
|
1040
1625
|
for (; ; ) {
|
|
1041
1626
|
const { docs } = await this.cms.db.find({
|
|
1042
1627
|
collection: AUDIT_LOGS,
|
|
@@ -3311,82 +3896,6 @@ import { tmpdir } from "os";
|
|
|
3311
3896
|
import { join as join2 } from "path";
|
|
3312
3897
|
import { promisify } from "util";
|
|
3313
3898
|
import { gzip } from "zlib";
|
|
3314
|
-
|
|
3315
|
-
// src/time-of-day.ts
|
|
3316
|
-
var DAY2 = 864e5;
|
|
3317
|
-
function isTimeZone(zone) {
|
|
3318
|
-
try {
|
|
3319
|
-
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
3320
|
-
return true;
|
|
3321
|
-
} catch {
|
|
3322
|
-
return false;
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3325
|
-
var formatters = /* @__PURE__ */ new Map();
|
|
3326
|
-
function wallClock(instant, zone) {
|
|
3327
|
-
let format = formatters.get(zone);
|
|
3328
|
-
if (!format) {
|
|
3329
|
-
format = new Intl.DateTimeFormat("en-US", {
|
|
3330
|
-
timeZone: zone,
|
|
3331
|
-
hourCycle: "h23",
|
|
3332
|
-
year: "numeric",
|
|
3333
|
-
month: "numeric",
|
|
3334
|
-
day: "numeric",
|
|
3335
|
-
hour: "numeric",
|
|
3336
|
-
minute: "numeric",
|
|
3337
|
-
second: "numeric"
|
|
3338
|
-
});
|
|
3339
|
-
formatters.set(zone, format);
|
|
3340
|
-
}
|
|
3341
|
-
const parts = {};
|
|
3342
|
-
for (const part of format.formatToParts(instant))
|
|
3343
|
-
if (part.type !== "literal") parts[part.type] = Number(part.value);
|
|
3344
|
-
return Date.UTC(
|
|
3345
|
-
parts.year,
|
|
3346
|
-
parts.month - 1,
|
|
3347
|
-
parts.day,
|
|
3348
|
-
parts.hour,
|
|
3349
|
-
parts.minute,
|
|
3350
|
-
parts.second
|
|
3351
|
-
);
|
|
3352
|
-
}
|
|
3353
|
-
function instantOf(wall, zone) {
|
|
3354
|
-
let instant = wall - (wallClock(wall, zone) - wall);
|
|
3355
|
-
instant = wall - (wallClock(instant, zone) - instant);
|
|
3356
|
-
return instant;
|
|
3357
|
-
}
|
|
3358
|
-
var parse = (at) => at.split(":").map(Number);
|
|
3359
|
-
function lastSlot(now, at, zone) {
|
|
3360
|
-
const [hours2, minutes] = parse(at);
|
|
3361
|
-
if (!zone) {
|
|
3362
|
-
const slot2 = new Date(now);
|
|
3363
|
-
slot2.setHours(hours2, minutes, 0, 0);
|
|
3364
|
-
if (slot2.getTime() > now.getTime()) slot2.setDate(slot2.getDate() - 1);
|
|
3365
|
-
return slot2;
|
|
3366
|
-
}
|
|
3367
|
-
const today = wallClock(now.getTime(), zone);
|
|
3368
|
-
const midnight = today - today % DAY2;
|
|
3369
|
-
let slot = instantOf(midnight + hours2 * 36e5 + minutes * 6e4, zone);
|
|
3370
|
-
if (slot > now.getTime())
|
|
3371
|
-
slot = instantOf(midnight - DAY2 + hours2 * 36e5 + minutes * 6e4, zone);
|
|
3372
|
-
return new Date(slot);
|
|
3373
|
-
}
|
|
3374
|
-
function nextSlot(after, at, zone) {
|
|
3375
|
-
const last = lastSlot(after, at, zone);
|
|
3376
|
-
return lastSlot(new Date(last.getTime() + DAY2 + 2 * 36e5), at, zone);
|
|
3377
|
-
}
|
|
3378
|
-
function dueAt(now, at, every, lastRun, zone) {
|
|
3379
|
-
if (lastRun === void 0) return true;
|
|
3380
|
-
const last = Date.parse(lastRun);
|
|
3381
|
-
if (Number.isNaN(last)) return true;
|
|
3382
|
-
const slot = lastSlot(now, at, zone).getTime();
|
|
3383
|
-
return last < slot - (every * 1e3 - DAY2);
|
|
3384
|
-
}
|
|
3385
|
-
function nextAt(at, every, lastRun, zone) {
|
|
3386
|
-
return nextSlot(new Date(Date.parse(lastRun) + (every * 1e3 - DAY2)), at, zone);
|
|
3387
|
-
}
|
|
3388
|
-
|
|
3389
|
-
// src/backups.ts
|
|
3390
3899
|
var gzipAsync = promisify(gzip);
|
|
3391
3900
|
var MAGIC = Buffer.from("ECMSBAK1");
|
|
3392
3901
|
var IV_BYTES = 12;
|
|
@@ -4927,7 +5436,7 @@ var OFFICIAL_PLUGINS = [
|
|
|
4927
5436
|
en: "Claude, GPT and Gemini for alt text, SEO and translation",
|
|
4928
5437
|
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"
|
|
4929
5438
|
},
|
|
4930
|
-
docs: "https://easycms.dev/docs/
|
|
5439
|
+
docs: "https://easycms.dev/docs/ai"
|
|
4931
5440
|
},
|
|
4932
5441
|
{
|
|
4933
5442
|
name: "@easy-cms/plugin-ecommerce",
|
|
@@ -5066,6 +5575,8 @@ var Integrations = class {
|
|
|
5066
5575
|
// --- The admin's page ------------------------------------------------------------------------
|
|
5067
5576
|
async overview() {
|
|
5068
5577
|
const integrations = [];
|
|
5578
|
+
const usage = await this.cms.ai?.usageByConnection() ?? /* @__PURE__ */ new Map();
|
|
5579
|
+
const aiProviders = new Set(this.config.ai?.providers.map((p) => p.integration) ?? []);
|
|
5069
5580
|
const current = keyId(keyOf2(this.config.secret));
|
|
5070
5581
|
let stale = 0;
|
|
5071
5582
|
for (const def of this.definitions) {
|
|
@@ -5126,7 +5637,11 @@ var Integrations = class {
|
|
|
5126
5637
|
enabled: await this.enabled(def.id),
|
|
5127
5638
|
envFields: Object.keys(env),
|
|
5128
5639
|
testable: typeof def.test === "function",
|
|
5129
|
-
connections
|
|
5640
|
+
connections: aiProviders.has(def.id) ? connections.map((c) => ({
|
|
5641
|
+
...c,
|
|
5642
|
+
usage: usage.get(`${def.id}:${c.name}`) ?? { requests: 0, input: 0, output: 0 }
|
|
5643
|
+
})) : connections,
|
|
5644
|
+
...def.consent ? { consent: def.consent, consented: await this.consented(def.id) } : {}
|
|
5130
5645
|
});
|
|
5131
5646
|
}
|
|
5132
5647
|
const installed = new Set(this.config.installedPlugins.map((p) => p.name));
|
|
@@ -5204,11 +5719,12 @@ var Integrations = class {
|
|
|
5204
5719
|
async connect(id, body, user) {
|
|
5205
5720
|
const def = this.definition(id);
|
|
5206
5721
|
if (def.kind !== "fields") throw new QueryError(`${id} connects with its Connect button`);
|
|
5722
|
+
await this.checkConsent(def);
|
|
5207
5723
|
const input = body ?? {};
|
|
5208
5724
|
const name = await this.uniqueName(id, String(input.name ?? ""));
|
|
5209
5725
|
const values = this.cleanValues(def, input.values);
|
|
5210
5726
|
this.checkFields(def, values, false);
|
|
5211
|
-
const result = def.test ? await def.test(values, { cms: this.cms }) : null;
|
|
5727
|
+
const result = def.test ? await def.test(values, { cms: this.cms, name }) : null;
|
|
5212
5728
|
if (result && !result.ok)
|
|
5213
5729
|
throw new ValidationError(CONNECTIONS, [
|
|
5214
5730
|
{ field: "values", message: result.message ?? "the provider refused these values" }
|
|
@@ -5268,13 +5784,41 @@ var Integrations = class {
|
|
|
5268
5784
|
/** Turns the integration on or off; its connections are kept. */
|
|
5269
5785
|
async setEnabled(id, enabled, user) {
|
|
5270
5786
|
this.definition(id);
|
|
5787
|
+
await this.saveIntegration(id, { enabled });
|
|
5788
|
+
await this.audit(user, enabled ? "enable" : "disable", id);
|
|
5789
|
+
return { enabled };
|
|
5790
|
+
}
|
|
5791
|
+
/** Who accepted the integration's `consent`, and when; `null` before anyone did. */
|
|
5792
|
+
async consented(id) {
|
|
5793
|
+
const [row] = await this.rows(id, "integration");
|
|
5794
|
+
const consent = (row?.values ?? {}).consent;
|
|
5795
|
+
return consent ?? null;
|
|
5796
|
+
}
|
|
5797
|
+
async checkConsent(def) {
|
|
5798
|
+
if (def.consent && !await this.consented(def.id))
|
|
5799
|
+
throw new ValidationError(CONNECTIONS, [
|
|
5800
|
+
{ field: "consent", message: "must be accepted before connecting" }
|
|
5801
|
+
]);
|
|
5802
|
+
}
|
|
5803
|
+
/** An admin accepts the integration's `consent`; audit-logged. */
|
|
5804
|
+
async consent(id, user) {
|
|
5805
|
+
const def = this.definition(id);
|
|
5806
|
+
if (!def.consent) throw new QueryError(`${id} asks for no consent`);
|
|
5807
|
+
const [row] = await this.rows(id, "integration");
|
|
5808
|
+
const consent = { at: (/* @__PURE__ */ new Date()).toISOString(), by: String(user.email ?? user.id) };
|
|
5809
|
+
await this.saveIntegration(id, { values: { ...row?.values ?? {}, consent } });
|
|
5810
|
+
await this.audit(user, "consent", id);
|
|
5811
|
+
return { consented: consent };
|
|
5812
|
+
}
|
|
5813
|
+
/** The integration's own row (on or off, consent), made when first needed. */
|
|
5814
|
+
async saveIntegration(id, patch) {
|
|
5271
5815
|
const [row] = await this.rows(id, "integration");
|
|
5272
5816
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5273
5817
|
if (row)
|
|
5274
5818
|
await this.cms.db.update({
|
|
5275
5819
|
collection: CONNECTIONS,
|
|
5276
5820
|
id: row.id,
|
|
5277
|
-
data: { ...row,
|
|
5821
|
+
data: { ...row, ...patch, updatedAt: now }
|
|
5278
5822
|
});
|
|
5279
5823
|
else
|
|
5280
5824
|
await this.cms.db.create({
|
|
@@ -5289,22 +5833,22 @@ var Integrations = class {
|
|
|
5289
5833
|
account: null,
|
|
5290
5834
|
lastUsedAt: null,
|
|
5291
5835
|
lastError: null,
|
|
5292
|
-
enabled,
|
|
5836
|
+
enabled: null,
|
|
5293
5837
|
createdAt: now,
|
|
5294
|
-
updatedAt: now
|
|
5838
|
+
updatedAt: now,
|
|
5839
|
+
...patch
|
|
5295
5840
|
}
|
|
5296
5841
|
});
|
|
5297
|
-
await this.audit(user, enabled ? "enable" : "disable", id);
|
|
5298
|
-
return { enabled };
|
|
5299
5842
|
}
|
|
5300
5843
|
/** Checks a connection's values with the provider; the card shows the result. */
|
|
5301
5844
|
async test(id, cid) {
|
|
5302
5845
|
const def = this.definition(id);
|
|
5303
5846
|
if (!def.test) throw new QueryError(`${id} can't be tested`);
|
|
5304
5847
|
const values = cid === "env" ? this.envValues(def) : { ...this.decrypt(await this.row(id, cid)), ...this.envValues(def) };
|
|
5848
|
+
const name = cid === "env" ? ENV_NAME : String((await this.row(id, cid)).name);
|
|
5305
5849
|
let result;
|
|
5306
5850
|
try {
|
|
5307
|
-
result = await def.test(values, { cms: this.cms });
|
|
5851
|
+
result = await def.test(values, { cms: this.cms, name });
|
|
5308
5852
|
} catch (error) {
|
|
5309
5853
|
result = { ok: false, message: error.message };
|
|
5310
5854
|
}
|
|
@@ -5349,10 +5893,11 @@ var Integrations = class {
|
|
|
5349
5893
|
}
|
|
5350
5894
|
// --- OAuth -----------------------------------------------------------------------------------
|
|
5351
5895
|
/** The provider's sign-in page to send the admin to. */
|
|
5352
|
-
authorize(id, user, redirectUri, name) {
|
|
5896
|
+
async authorize(id, user, redirectUri, name) {
|
|
5353
5897
|
const def = this.definition(id);
|
|
5354
5898
|
if (def.kind !== "oauth" || !def.oauth)
|
|
5355
5899
|
throw new QueryError(`${id} connects with pasted values`);
|
|
5900
|
+
await this.checkConsent(def);
|
|
5356
5901
|
const state = signState(this.config.secret, {
|
|
5357
5902
|
i: id,
|
|
5358
5903
|
u: String(user.id),
|
|
@@ -6150,7 +6695,7 @@ function read(response, maxBytes, deadline) {
|
|
|
6150
6695
|
|
|
6151
6696
|
// src/config-keys.ts
|
|
6152
6697
|
var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
|
|
6153
|
-
var CONFIG = keys(`secret previousSecrets integrations listeners db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
|
|
6698
|
+
var CONFIG = keys(`secret previousSecrets integrations listeners ai aiFeatures db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
|
|
6154
6699
|
upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
|
|
6155
6700
|
audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
|
|
6156
6701
|
var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
|
|
@@ -6704,7 +7249,7 @@ var WEBHOOK_EVENTS = [
|
|
|
6704
7249
|
var RETRY_DELAYS = [1e3, 5e3];
|
|
6705
7250
|
var QUEUE_DELAYS2 = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
|
|
6706
7251
|
var CLAIM2 = 5 * 6e4;
|
|
6707
|
-
var
|
|
7252
|
+
var TIMEOUT2 = 1e4;
|
|
6708
7253
|
var Webhooks = class {
|
|
6709
7254
|
constructor(hooks, logger, queue, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
|
|
6710
7255
|
this.hooks = hooks;
|
|
@@ -6872,7 +7417,7 @@ var Webhooks = class {
|
|
|
6872
7417
|
method: "POST",
|
|
6873
7418
|
headers,
|
|
6874
7419
|
body,
|
|
6875
|
-
signal: AbortSignal.timeout(
|
|
7420
|
+
signal: AbortSignal.timeout(TIMEOUT2)
|
|
6876
7421
|
});
|
|
6877
7422
|
if (response.ok || response.status < 500 && response.status !== 429) {
|
|
6878
7423
|
if (!response.ok)
|
|
@@ -6981,6 +7526,7 @@ function validateConfig(config) {
|
|
|
6981
7526
|
validateJobs(config, add);
|
|
6982
7527
|
validateIntegrations(config, add);
|
|
6983
7528
|
validateListeners(config, add);
|
|
7529
|
+
validateAi(config, add);
|
|
6984
7530
|
validateTimeZone(config, add);
|
|
6985
7531
|
validateEvents(config, add);
|
|
6986
7532
|
const apiKeys = config.apiKeys;
|
|
@@ -8626,6 +9172,22 @@ function validateIntegrations(config, add) {
|
|
|
8626
9172
|
} else add(`${at}.kind`, "must be 'fields' or 'oauth'");
|
|
8627
9173
|
if (item.test !== void 0 && typeof item.test !== "function")
|
|
8628
9174
|
add(`${at}.test`, "must be a function");
|
|
9175
|
+
if (item.icon !== void 0 && typeof item.icon !== "string") {
|
|
9176
|
+
const logo = item.icon ?? {};
|
|
9177
|
+
const hex = (v) => typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v);
|
|
9178
|
+
const paths = Array.isArray(logo.paths) ? logo.paths : [];
|
|
9179
|
+
if (paths.length === 0 || paths.some((p) => {
|
|
9180
|
+
const path = p ?? {};
|
|
9181
|
+
return typeof path.d !== "string" || !/^[MmLlHhVvCcSsQqTtAaZzEe0-9.,\s+-]+$/.test(path.d) || path.fill !== void 0 && !hex(path.fill);
|
|
9182
|
+
}))
|
|
9183
|
+
add(
|
|
9184
|
+
`${at}.icon`,
|
|
9185
|
+
"must be an admin icon name, or { paths: [{ d, fill? }], color? } with SVG path data"
|
|
9186
|
+
);
|
|
9187
|
+
if (logo.color !== void 0 && !hex(logo.color)) add(`${at}.icon.color`, "must be #rrggbb");
|
|
9188
|
+
if (logo.viewBox !== void 0 && !(typeof logo.viewBox === "string" && /^-?[\d.]+( -?[\d.]+){3}$/.test(logo.viewBox)))
|
|
9189
|
+
add(`${at}.icon.viewBox`, "must be four numbers, e.g. '0 0 24 24'");
|
|
9190
|
+
}
|
|
8629
9191
|
for (const [f, field] of (Array.isArray(item.fields) ? item.fields : []).entries()) {
|
|
8630
9192
|
const fieldItem = field ?? {};
|
|
8631
9193
|
if (fieldItem.lookup !== void 0 && typeof fieldItem.lookup !== "function")
|
|
@@ -8648,6 +9210,45 @@ function validateListeners(config, add) {
|
|
|
8648
9210
|
if (typeof item.handler !== "function") add(`listeners[${i}].handler`, "must be a function");
|
|
8649
9211
|
}
|
|
8650
9212
|
}
|
|
9213
|
+
function validateAi(config, add) {
|
|
9214
|
+
const ai = config.ai;
|
|
9215
|
+
if (ai !== void 0) {
|
|
9216
|
+
const providers = ai?.providers;
|
|
9217
|
+
if (!Array.isArray(providers)) add("ai.providers", "must be an array");
|
|
9218
|
+
else {
|
|
9219
|
+
const ids = new Set((config.integrations ?? []).map((i) => i?.id));
|
|
9220
|
+
for (const [i, raw] of providers.entries()) {
|
|
9221
|
+
const provider = raw ?? {};
|
|
9222
|
+
if (typeof provider.integration !== "string" || !ids.has(provider.integration))
|
|
9223
|
+
add(`ai.providers[${i}].integration`, "must be the id of an integration");
|
|
9224
|
+
if (typeof provider.generate !== "function")
|
|
9225
|
+
add(`ai.providers[${i}].generate`, "must be a function");
|
|
9226
|
+
}
|
|
9227
|
+
}
|
|
9228
|
+
const limits = ai?.limits;
|
|
9229
|
+
const whole = (v) => Number.isInteger(v) && v >= 0;
|
|
9230
|
+
if (limits?.perUserPerDay !== void 0 && !whole(limits.perUserPerDay))
|
|
9231
|
+
add("ai.limits.perUserPerDay", "must be a whole number, 0 or more");
|
|
9232
|
+
for (const [role, value] of Object.entries(limits?.roles ?? {}))
|
|
9233
|
+
if (!whole(value)) add(`ai.limits.roles.${role}`, "must be a whole number, 0 or more");
|
|
9234
|
+
}
|
|
9235
|
+
const features = config.aiFeatures;
|
|
9236
|
+
if (features === void 0) return;
|
|
9237
|
+
if (!Array.isArray(features)) {
|
|
9238
|
+
add("aiFeatures", "must be an array");
|
|
9239
|
+
return;
|
|
9240
|
+
}
|
|
9241
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9242
|
+
for (const [i, raw] of features.entries()) {
|
|
9243
|
+
const feature = raw ?? {};
|
|
9244
|
+
if (typeof feature.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(feature.id))
|
|
9245
|
+
add(`aiFeatures[${i}].id`, 'must be lowercase letters, digits and "-"', "e.g. id: 'alt-text'");
|
|
9246
|
+
else if (seen.has(feature.id) || feature.id === "test" || feature.id === "alt-text")
|
|
9247
|
+
add(`aiFeatures[${i}].id`, `"${feature.id}" is taken`);
|
|
9248
|
+
else seen.add(feature.id);
|
|
9249
|
+
if (!isLabel(feature.label)) add(`aiFeatures[${i}].label`, "must be a string or { en, th }");
|
|
9250
|
+
}
|
|
9251
|
+
}
|
|
8651
9252
|
|
|
8652
9253
|
// src/resolve-config.ts
|
|
8653
9254
|
var DEFAULT_ADMIN_PATH = "/admin";
|
|
@@ -8759,6 +9360,7 @@ async function resolveConfig(input) {
|
|
|
8759
9360
|
bulkOperationsCollection,
|
|
8760
9361
|
savedViewsCollection,
|
|
8761
9362
|
...resolvedIntegrations(config).length > 0 ? [connectionsCollection] : [],
|
|
9363
|
+
...config.ai ? [aiUsageCollection] : [],
|
|
8762
9364
|
...config.auth?.rbac ? [rolesCollection] : [],
|
|
8763
9365
|
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
|
|
8764
9366
|
...config.audit ? [auditLogsCollection] : []
|
|
@@ -8766,6 +9368,7 @@ async function resolveConfig(input) {
|
|
|
8766
9368
|
globals: config.globals ?? [],
|
|
8767
9369
|
endpoints: config.endpoints ?? [],
|
|
8768
9370
|
integrations: resolvedIntegrations(config),
|
|
9371
|
+
aiFeatures: config.ai ? [...CORE_AI_FEATURES, ...config.aiFeatures ?? []] : [],
|
|
8769
9372
|
cliCommands: config.cliCommands ?? [],
|
|
8770
9373
|
// Easy CMS's own events (#356), for webhooks and `cms.on`.
|
|
8771
9374
|
events: [.../* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS])],
|
|
@@ -8884,7 +9487,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
|
|
|
8884
9487
|
}
|
|
8885
9488
|
|
|
8886
9489
|
// src/version.ts
|
|
8887
|
-
var VERSION = true ? "1.
|
|
9490
|
+
var VERSION = true ? "1.34.0" : "0.0.0-dev";
|
|
8888
9491
|
|
|
8889
9492
|
// src/telemetry.ts
|
|
8890
9493
|
var DB_CALLS = /* @__PURE__ */ new Set([
|
|
@@ -9163,6 +9766,8 @@ var EasyCMS = class {
|
|
|
9163
9766
|
folders;
|
|
9164
9767
|
/** Accounts connected to plugins' integrations (`integrations`, Settings → Integrations). */
|
|
9165
9768
|
integrations;
|
|
9769
|
+
/** AI for the admin's features (#357): `null` without an AI plugin (`ai` in the config). */
|
|
9770
|
+
ai;
|
|
9166
9771
|
versions;
|
|
9167
9772
|
webhooks;
|
|
9168
9773
|
mailer;
|
|
@@ -9191,6 +9796,7 @@ var EasyCMS = class {
|
|
|
9191
9796
|
);
|
|
9192
9797
|
this.audit = new AuditLog(this);
|
|
9193
9798
|
this.integrations = new Integrations(this);
|
|
9799
|
+
this.ai = config.ai ? new Ai(this, config.ai) : null;
|
|
9194
9800
|
for (const listener of config.listeners ?? []) {
|
|
9195
9801
|
const run = (message2) => listener.handler(message2, { cms: this });
|
|
9196
9802
|
if (listener.events === "*") this.anyListeners.add(run);
|
|
@@ -10718,6 +11324,7 @@ var EasyCMS = class {
|
|
|
10718
11324
|
(error) => this.logger.error(`Backups: ${error.message}`, { error })
|
|
10719
11325
|
);
|
|
10720
11326
|
await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`, { error }));
|
|
11327
|
+
await this.ai?.upkeep(now).catch((error) => this.logger.error(`AI usage: ${error.message}`, { error }));
|
|
10721
11328
|
await this.rateLimiter?.clear(now).catch((error) => this.logger.error(`Rate limits: ${error.message}`, { error }));
|
|
10722
11329
|
const result = {
|
|
10723
11330
|
scheduled,
|
|
@@ -12049,51 +12656,51 @@ var EasyCMS = class {
|
|
|
12049
12656
|
const cms = this;
|
|
12050
12657
|
const { user, context } = guard;
|
|
12051
12658
|
const allowed2 = /* @__PURE__ */ new Map();
|
|
12052
|
-
for (const
|
|
12053
|
-
const filter =
|
|
12659
|
+
for (const ref2 of references2) {
|
|
12660
|
+
const filter = ref2.filterOptions;
|
|
12054
12661
|
if (!filter) continue;
|
|
12055
12662
|
const byCollection = allowed2.get(filter) ?? /* @__PURE__ */ new Map();
|
|
12056
12663
|
allowed2.set(filter, byCollection);
|
|
12057
|
-
if (!byCollection.has(
|
|
12664
|
+
if (!byCollection.has(ref2.collection))
|
|
12058
12665
|
byCollection.set(
|
|
12059
|
-
|
|
12060
|
-
await filter({ id: selfId, relationTo:
|
|
12666
|
+
ref2.collection,
|
|
12667
|
+
await filter({ id: selfId, relationTo: ref2.collection, user, cms, context })
|
|
12061
12668
|
);
|
|
12062
|
-
const where = byCollection.get(
|
|
12669
|
+
const where = byCollection.get(ref2.collection);
|
|
12063
12670
|
if (where === true) continue;
|
|
12064
|
-
const target = this.config.collections.find((c) => c.slug ===
|
|
12671
|
+
const target = this.config.collections.find((c) => c.slug === ref2.collection);
|
|
12065
12672
|
const localization = this.config.localization;
|
|
12066
12673
|
const scoped = target ? this.whereFor(target, where, localization ? { locale: localization.defaultLocale } : {}) : where;
|
|
12067
12674
|
const count = await this.db.count({
|
|
12068
|
-
collection:
|
|
12069
|
-
where: { and: [{ id: { equals:
|
|
12675
|
+
collection: ref2.collection,
|
|
12676
|
+
where: { and: [{ id: { equals: ref2.id } }, ...scoped ? [scoped] : []] }
|
|
12070
12677
|
});
|
|
12071
12678
|
if (count === 0)
|
|
12072
|
-
errors.push({ field:
|
|
12679
|
+
errors.push({ field: ref2.field, message: "is not one of the allowed choices" });
|
|
12073
12680
|
}
|
|
12074
12681
|
return errors;
|
|
12075
12682
|
}
|
|
12076
12683
|
async checkReferences(references2, guard) {
|
|
12077
12684
|
const byCollection = /* @__PURE__ */ new Map();
|
|
12078
12685
|
const errors = [];
|
|
12079
|
-
for (const
|
|
12080
|
-
const target = this.config.collections.find((c) => c.slug ===
|
|
12081
|
-
if (
|
|
12686
|
+
for (const ref2 of references2) {
|
|
12687
|
+
const target = this.config.collections.find((c) => c.slug === ref2.collection);
|
|
12688
|
+
if (ref2.link && (!target || INTERNAL_COLLECTIONS.has(ref2.collection))) {
|
|
12082
12689
|
errors.push({
|
|
12083
|
-
field:
|
|
12084
|
-
message: `links to "${
|
|
12690
|
+
field: ref2.field,
|
|
12691
|
+
message: `links to "${ref2.collection}", which is not a collection`
|
|
12085
12692
|
});
|
|
12086
12693
|
continue;
|
|
12087
12694
|
}
|
|
12088
|
-
if (
|
|
12695
|
+
if (ref2.link && typeof target?.preview !== "function") {
|
|
12089
12696
|
errors.push({
|
|
12090
|
-
field:
|
|
12091
|
-
message: `links to "${
|
|
12697
|
+
field: ref2.field,
|
|
12698
|
+
message: `links to "${ref2.collection}", whose documents have no URL (set its preview)`
|
|
12092
12699
|
});
|
|
12093
12700
|
continue;
|
|
12094
12701
|
}
|
|
12095
12702
|
if (!target) continue;
|
|
12096
|
-
byCollection.set(
|
|
12703
|
+
byCollection.set(ref2.collection, [...byCollection.get(ref2.collection) ?? [], ref2]);
|
|
12097
12704
|
}
|
|
12098
12705
|
for (const [collection2, refs] of byCollection) {
|
|
12099
12706
|
const found = await this.db.findByIds({
|
|
@@ -12101,28 +12708,28 @@ var EasyCMS = class {
|
|
|
12101
12708
|
ids: [...new Set(refs.map((r) => r.id))]
|
|
12102
12709
|
});
|
|
12103
12710
|
const byId = new Map(found.map((d) => [d.id, d]));
|
|
12104
|
-
for (const
|
|
12105
|
-
const doc = byId.get(
|
|
12711
|
+
for (const ref2 of refs) {
|
|
12712
|
+
const doc = byId.get(ref2.id);
|
|
12106
12713
|
if (!doc) {
|
|
12107
|
-
errors.push({ field:
|
|
12714
|
+
errors.push({ field: ref2.field, message: `${collection2} ${ref2.id} does not exist` });
|
|
12108
12715
|
continue;
|
|
12109
12716
|
}
|
|
12110
12717
|
const type = typeof doc.mimeType === "string" ? doc.mimeType : "";
|
|
12111
|
-
if (
|
|
12112
|
-
const images =
|
|
12718
|
+
if (ref2.mimeTypes && !mimeAllowedBy(type, ref2.mimeTypes)) {
|
|
12719
|
+
const images = ref2.mimeTypes.every((t) => t.toLowerCase() === "image/*");
|
|
12113
12720
|
errors.push({
|
|
12114
|
-
field:
|
|
12115
|
-
message: images ? `must be an image (${doc.filename ??
|
|
12721
|
+
field: ref2.field,
|
|
12722
|
+
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"})`
|
|
12116
12723
|
});
|
|
12117
12724
|
}
|
|
12118
|
-
if (
|
|
12119
|
-
|
|
12725
|
+
if (ref2.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
|
|
12726
|
+
ref2.folderOnly,
|
|
12120
12727
|
doc.folder ?? null,
|
|
12121
12728
|
await this.folderScope(guard)
|
|
12122
12729
|
))
|
|
12123
12730
|
errors.push({
|
|
12124
|
-
field:
|
|
12125
|
-
message: `must be a file from the "${
|
|
12731
|
+
field: ref2.field,
|
|
12732
|
+
message: `must be a file from the "${ref2.folderOnly}" folder (${doc.filename ?? ref2.id} is not)`
|
|
12126
12733
|
});
|
|
12127
12734
|
}
|
|
12128
12735
|
}
|
|
@@ -12744,7 +13351,8 @@ var Roles = class {
|
|
|
12744
13351
|
new: !seen("globals", g.slug),
|
|
12745
13352
|
fields: adminFields(g.fields, void 0)
|
|
12746
13353
|
})),
|
|
12747
|
-
views: this.views()
|
|
13354
|
+
views: this.views(),
|
|
13355
|
+
ai: Boolean(this.config.ai)
|
|
12748
13356
|
};
|
|
12749
13357
|
}
|
|
12750
13358
|
async create(input, author) {
|
|
@@ -12923,7 +13531,8 @@ var Roles = class {
|
|
|
12923
13531
|
void 0
|
|
12924
13532
|
)
|
|
12925
13533
|
};
|
|
12926
|
-
|
|
13534
|
+
const aiPerDay = this.config.ai && Number.isInteger(input.aiPerDay) && input.aiPerDay >= 0 ? { aiPerDay: input.aiPerDay } : {};
|
|
13535
|
+
return { ...picked, admin, own, fields, ...aiPerDay };
|
|
12927
13536
|
}
|
|
12928
13537
|
/**
|
|
12929
13538
|
* Keeps rules for known top-level fields. A field that must be filled in (required, without a
|
|
@@ -12972,6 +13581,11 @@ var Roles = class {
|
|
|
12972
13581
|
]);
|
|
12973
13582
|
return value.trim();
|
|
12974
13583
|
}
|
|
13584
|
+
/** The role's daily AI limit set in Settings → Roles, if any (`auth.rbac`). */
|
|
13585
|
+
async aiLimit(key) {
|
|
13586
|
+
if (!this.enabled) return void 0;
|
|
13587
|
+
return (await this.permissionsOf(key)).aiPerDay;
|
|
13588
|
+
}
|
|
12975
13589
|
async permissionsOf(key) {
|
|
12976
13590
|
return (await this.load()).get(key) ?? {};
|
|
12977
13591
|
}
|
|
@@ -13628,10 +14242,10 @@ function folderIds(cms, user, context) {
|
|
|
13628
14242
|
return cms.folders.enabled ? (key) => cms.keyedFolder(key, { user, context }) : void 0;
|
|
13629
14243
|
}
|
|
13630
14244
|
function panelRef(panel) {
|
|
13631
|
-
const
|
|
14245
|
+
const ref2 = componentRef(panel);
|
|
13632
14246
|
if (typeof panel === "object" && "position" in panel && panel.position === "top")
|
|
13633
|
-
|
|
13634
|
-
return
|
|
14247
|
+
ref2.position = "top";
|
|
14248
|
+
return ref2;
|
|
13635
14249
|
}
|
|
13636
14250
|
function layoutOf(layout, fields) {
|
|
13637
14251
|
const shown2 = new Set(fields.filter((f) => f.position !== "sidebar").map((f) => f.name));
|
|
@@ -15097,6 +15711,37 @@ async function route(cms, ctx, method, path, options) {
|
|
|
15097
15711
|
}
|
|
15098
15712
|
throw methodNotAllowed(ctx, "PATCH, DELETE");
|
|
15099
15713
|
}
|
|
15714
|
+
if (first === "admin" && second === "ai" && segments.length === 3) {
|
|
15715
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
15716
|
+
if (!cms.ai) throw new HttpError("Not found", 404);
|
|
15717
|
+
if (third === "status") {
|
|
15718
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
15719
|
+
return { body: await cms.ai.status(ctx.user) };
|
|
15720
|
+
}
|
|
15721
|
+
if (third === "alt-text") {
|
|
15722
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15723
|
+
const body = await readJson(ctx.request);
|
|
15724
|
+
if (typeof body.media !== "string" && typeof body.media !== "number")
|
|
15725
|
+
throw new ValidationError("media", [{ field: "media", message: "is required" }]);
|
|
15726
|
+
return {
|
|
15727
|
+
body: await cms.ai.altText(body.media, {
|
|
15728
|
+
user: ctx.user,
|
|
15729
|
+
locale: typeof body.locale === "string" ? body.locale : null,
|
|
15730
|
+
context: ctx.context
|
|
15731
|
+
})
|
|
15732
|
+
};
|
|
15733
|
+
}
|
|
15734
|
+
throw new HttpError("Not found", 404);
|
|
15735
|
+
}
|
|
15736
|
+
if (first === "admin" && second === "ai" && segments.length === 2) {
|
|
15737
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
15738
|
+
if (!cms.ai) throw new HttpError("Not found", 404);
|
|
15739
|
+
if (!await cms.roles.canView(ctx.user, "integrations")) throw new ForbiddenError();
|
|
15740
|
+
if (method === "GET") return { body: await cms.ai.overview() };
|
|
15741
|
+
if (method === "PUT")
|
|
15742
|
+
return { body: await cms.ai.update(await readJson(ctx.request), ctx.user) };
|
|
15743
|
+
throw methodNotAllowed(ctx, "GET, PUT");
|
|
15744
|
+
}
|
|
15100
15745
|
if (first === "admin" && second === "integrations") {
|
|
15101
15746
|
if (!ctx.user) throw new UnauthorizedError();
|
|
15102
15747
|
if (cms.config.integrations.length === 0) throw new HttpError("Not found", 404);
|
|
@@ -15117,7 +15762,9 @@ async function route(cms, ctx, method, path, options) {
|
|
|
15117
15762
|
if (part === "authorize" && cid === void 0) {
|
|
15118
15763
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15119
15764
|
const body = await readJson(ctx.request);
|
|
15120
|
-
return {
|
|
15765
|
+
return {
|
|
15766
|
+
body: await integrations.authorize(id2, user, callbackUrl, String(body.name ?? ""))
|
|
15767
|
+
};
|
|
15121
15768
|
}
|
|
15122
15769
|
if (part === "callback" && cid === void 0) {
|
|
15123
15770
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
@@ -15140,6 +15787,10 @@ async function route(cms, ctx, method, path, options) {
|
|
|
15140
15787
|
}
|
|
15141
15788
|
return { body: new Response(null, { status: 302, headers: { location: page.toString() } }) };
|
|
15142
15789
|
}
|
|
15790
|
+
if (part === "consent" && cid === void 0) {
|
|
15791
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15792
|
+
return { body: await integrations.consent(id2, user) };
|
|
15793
|
+
}
|
|
15143
15794
|
if (part === "lookup" && cid !== void 0 && action === void 0) {
|
|
15144
15795
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
15145
15796
|
return { body: await integrations.lookup(id2, cid, await readJson(ctx.request)) };
|
|
@@ -16201,10 +16852,11 @@ export {
|
|
|
16201
16852
|
isStaff,
|
|
16202
16853
|
isSignedIn,
|
|
16203
16854
|
isAdmin,
|
|
16204
|
-
secretsOf,
|
|
16205
16855
|
MEDIA,
|
|
16206
16856
|
MEDIA_FOLDERS,
|
|
16207
16857
|
INTERNAL_COLLECTIONS,
|
|
16858
|
+
AiProviderError,
|
|
16859
|
+
secretsOf,
|
|
16208
16860
|
AuditLog,
|
|
16209
16861
|
API_KEYS,
|
|
16210
16862
|
API_KEY_OPERATIONS,
|