@easy-cms/core 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +251 -3
- package/dist/index.js +822 -73
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -253,14 +253,19 @@ function andWhere(where, constraint) {
|
|
|
253
253
|
return where === void 0 ? constraint : { and: [where, constraint] };
|
|
254
254
|
}
|
|
255
255
|
var FieldAccessChecker = class {
|
|
256
|
-
|
|
256
|
+
/** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
|
|
257
|
+
constructor(kind, args, rules) {
|
|
257
258
|
this.kind = kind;
|
|
258
259
|
this.args = args;
|
|
260
|
+
this.rules = rules;
|
|
259
261
|
}
|
|
260
262
|
kind;
|
|
261
263
|
args;
|
|
264
|
+
rules;
|
|
262
265
|
cache = /* @__PURE__ */ new Map();
|
|
263
266
|
async allows(field) {
|
|
267
|
+
const rule = this.rules?.get(field);
|
|
268
|
+
if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
|
|
264
269
|
const fn = field.access?.[this.kind];
|
|
265
270
|
if (!fn) return true;
|
|
266
271
|
let allowed2 = this.cache.get(field);
|
|
@@ -327,6 +332,7 @@ var WEBHOOK_DELIVERIES = "webhook-deliveries";
|
|
|
327
332
|
var EMAIL_DELIVERIES = "email-deliveries";
|
|
328
333
|
var DATABASE_BACKUPS = "database-backups";
|
|
329
334
|
var ROLES = "user-roles";
|
|
335
|
+
var USER_IDENTITIES = "user-identities";
|
|
330
336
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
331
337
|
SESSIONS,
|
|
332
338
|
LOGIN_ATTEMPTS,
|
|
@@ -335,9 +341,27 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
335
341
|
WEBHOOK_DELIVERIES,
|
|
336
342
|
EMAIL_DELIVERIES,
|
|
337
343
|
DATABASE_BACKUPS,
|
|
338
|
-
ROLES
|
|
344
|
+
ROLES,
|
|
345
|
+
USER_IDENTITIES
|
|
339
346
|
]);
|
|
340
347
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
348
|
+
var CREATED_BY_FIELD = {
|
|
349
|
+
name: "createdBy",
|
|
350
|
+
type: "relationship",
|
|
351
|
+
to: USERS,
|
|
352
|
+
position: "sidebar",
|
|
353
|
+
access: { update: () => false },
|
|
354
|
+
label: { en: "Created by", th: "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E42\u0E14\u0E22" }
|
|
355
|
+
};
|
|
356
|
+
function withCreatedBy(config) {
|
|
357
|
+
if (config.auth?.rbac !== true) return config;
|
|
358
|
+
return {
|
|
359
|
+
...config,
|
|
360
|
+
collections: (config.collections ?? []).map(
|
|
361
|
+
(c) => c.slug === USERS || c.fields?.some((f) => f.name === CREATED_BY_FIELD.name) ? c : { ...c, fields: [...c.fields, CREATED_BY_FIELD] }
|
|
362
|
+
)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
341
365
|
var adminOrSelf = ({ user }) => {
|
|
342
366
|
if (!user) return false;
|
|
343
367
|
if (user.role === "admin") return true;
|
|
@@ -578,6 +602,18 @@ var databaseBackupsCollection = {
|
|
|
578
602
|
{ name: "downloadedAt", type: "text" }
|
|
579
603
|
]
|
|
580
604
|
};
|
|
605
|
+
var userIdentitiesCollection = {
|
|
606
|
+
slug: USER_IDENTITIES,
|
|
607
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
608
|
+
fields: [
|
|
609
|
+
{ name: "user", type: "relationship", to: USERS, required: true, index: true },
|
|
610
|
+
// The provider's id (`google`) and its id for the account (`sub`).
|
|
611
|
+
{ name: "provider", type: "text", required: true, index: true },
|
|
612
|
+
{ name: "subject", type: "text", required: true, index: true },
|
|
613
|
+
{ name: "email", type: "text" },
|
|
614
|
+
{ name: "lastUsedAt", type: "text" }
|
|
615
|
+
]
|
|
616
|
+
};
|
|
581
617
|
var emailDeliveriesCollection = {
|
|
582
618
|
slug: EMAIL_DELIVERIES,
|
|
583
619
|
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
@@ -620,7 +656,17 @@ var DEFAULT_PASSWORD_EMAILS = {
|
|
|
620
656
|
],
|
|
621
657
|
{ label: "Set a new password", url }
|
|
622
658
|
),
|
|
623
|
-
invite: ({ url = "", locale, expiresAt, user }) => locale === "th" ? email(
|
|
659
|
+
invite: ({ url = "", locale, expiresAt, user, sso }) => sso ? locale === "th" ? email(
|
|
660
|
+
"\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
|
|
661
|
+
[`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A\u0E14\u0E49\u0E27\u0E22 ${sso.join(" \u0E2B\u0E23\u0E37\u0E2D ")} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`],
|
|
662
|
+
{ label: "\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A", url }
|
|
663
|
+
) : email(
|
|
664
|
+
"You're invited to the content admin",
|
|
665
|
+
[
|
|
666
|
+
`An account for ${user.email} is waiting for you. Sign in with ${sso.join(" or ")} to start.`
|
|
667
|
+
],
|
|
668
|
+
{ label: "Sign in", url }
|
|
669
|
+
) : locale === "th" ? email(
|
|
624
670
|
"\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
|
|
625
671
|
[
|
|
626
672
|
`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`,
|
|
@@ -689,6 +735,9 @@ async function fakeVerify(password) {
|
|
|
689
735
|
return false;
|
|
690
736
|
}
|
|
691
737
|
|
|
738
|
+
// src/auth/sso.ts
|
|
739
|
+
import { createHmac as createHmac2, randomBytes as randomBytes4 } from "crypto";
|
|
740
|
+
|
|
692
741
|
// src/auth/tokens.ts
|
|
693
742
|
import { createHash as createHash2, createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
694
743
|
function newToken() {
|
|
@@ -770,14 +819,279 @@ function passwordTokenMatches(secret, token, fingerprint) {
|
|
|
770
819
|
return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
|
|
771
820
|
}
|
|
772
821
|
|
|
822
|
+
// src/auth/sso.ts
|
|
823
|
+
var SSO_COOKIE = "ecms-sso";
|
|
824
|
+
var PENDING_TTL = 10 * 6e4;
|
|
825
|
+
var random = () => randomBytes4(32).toString("base64url");
|
|
826
|
+
function sign(secret, payload) {
|
|
827
|
+
return createHmac2("sha256", secret).update(`sso:${payload}`).digest("base64url");
|
|
828
|
+
}
|
|
829
|
+
function seal(secret, pending) {
|
|
830
|
+
const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
|
|
831
|
+
return `${payload}.${sign(secret, payload)}`;
|
|
832
|
+
}
|
|
833
|
+
function unseal(secret, value) {
|
|
834
|
+
if (!value) return null;
|
|
835
|
+
const dot = value.lastIndexOf(".");
|
|
836
|
+
if (dot <= 0) return null;
|
|
837
|
+
const payload = value.slice(0, dot);
|
|
838
|
+
if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
|
|
839
|
+
try {
|
|
840
|
+
const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
841
|
+
return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
|
|
842
|
+
} catch {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
var SingleSignOn = class {
|
|
847
|
+
constructor(cms) {
|
|
848
|
+
this.cms = cms;
|
|
849
|
+
}
|
|
850
|
+
cms;
|
|
851
|
+
get config() {
|
|
852
|
+
return this.cms.config;
|
|
853
|
+
}
|
|
854
|
+
get enabled() {
|
|
855
|
+
return this.config.auth.providers.length > 0;
|
|
856
|
+
}
|
|
857
|
+
providers() {
|
|
858
|
+
return this.config.auth.providers.map((p) => ({
|
|
859
|
+
id: p.id,
|
|
860
|
+
name: p.name,
|
|
861
|
+
icon: p.icon ?? "key"
|
|
862
|
+
}));
|
|
863
|
+
}
|
|
864
|
+
/** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
|
|
865
|
+
passwordAllowed(user) {
|
|
866
|
+
return this.config.auth.password || user.role === "admin";
|
|
867
|
+
}
|
|
868
|
+
/** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
|
|
869
|
+
callbackURL(provider, origin) {
|
|
870
|
+
const base = this.cms.auth.passwordLinkBase(origin);
|
|
871
|
+
return base ? `${base}${this.config.routes.api}/auth/${provider}/callback` : null;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Starts signing in (or, with `user`, linking an account): the provider's URL to send the
|
|
875
|
+
* browser to, and the sealed value of the cookie that must come back with it.
|
|
876
|
+
*/
|
|
877
|
+
async start(providerId, options) {
|
|
878
|
+
const provider = this.provider(providerId);
|
|
879
|
+
const redirectUri = this.callbackURL(provider.id, options.origin);
|
|
880
|
+
if (!redirectUri)
|
|
881
|
+
throw new QueryError("Signing in with providers needs `serverURL` in production");
|
|
882
|
+
const pending = {
|
|
883
|
+
p: provider.id,
|
|
884
|
+
s: random(),
|
|
885
|
+
v: random(),
|
|
886
|
+
n: random(),
|
|
887
|
+
r: this.adminPage(options.redirect),
|
|
888
|
+
...options.user ? { u: String(options.user.id) } : {},
|
|
889
|
+
e: Date.now() + PENDING_TTL
|
|
890
|
+
};
|
|
891
|
+
const url = await provider.authorizationURL({
|
|
892
|
+
redirectUri,
|
|
893
|
+
state: pending.s,
|
|
894
|
+
codeVerifier: pending.v,
|
|
895
|
+
nonce: pending.n
|
|
896
|
+
});
|
|
897
|
+
return { url: url.toString(), cookie: seal(this.config.secret, pending) };
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Finishes at `<api>/auth/<id>/callback`: checks the response, finds (or makes) the user and
|
|
901
|
+
* starts a session. Returns where to send the browser; failures end on the login page (or the
|
|
902
|
+
* account page, when linking) with `?sso=<outcome>`.
|
|
903
|
+
*/
|
|
904
|
+
async finish(providerId, options) {
|
|
905
|
+
const admin = this.config.admin.path.replace(/\/+$/, "");
|
|
906
|
+
const pending = unseal(this.config.secret, options.cookie);
|
|
907
|
+
const back = (page2, outcome) => ({ redirect: `${admin}/${page2}?sso=${outcome}` });
|
|
908
|
+
const page = pending?.u ? "account" : "login";
|
|
909
|
+
if (!pending || pending.p !== providerId) return back(page, "expired");
|
|
910
|
+
const state = options.url.searchParams.get("state") ?? "";
|
|
911
|
+
if (!safeEqual(state, pending.s)) return back(page, "expired");
|
|
912
|
+
if (options.url.searchParams.has("error")) return back(page, "cancelled");
|
|
913
|
+
const provider = this.provider(providerId);
|
|
914
|
+
const redirectUri = this.callbackURL(provider.id, options.origin);
|
|
915
|
+
let profile;
|
|
916
|
+
try {
|
|
917
|
+
if (!redirectUri) throw new Error("no callback URL");
|
|
918
|
+
profile = await provider.callback({
|
|
919
|
+
redirectUri,
|
|
920
|
+
state: pending.s,
|
|
921
|
+
codeVerifier: pending.v,
|
|
922
|
+
nonce: pending.n,
|
|
923
|
+
url: options.url
|
|
924
|
+
});
|
|
925
|
+
} catch (error) {
|
|
926
|
+
this.cms.logger.warn(
|
|
927
|
+
`Signing in with ${provider.name} failed: ${error.message || String(error)}`
|
|
928
|
+
);
|
|
929
|
+
return back(page, "failed");
|
|
930
|
+
}
|
|
931
|
+
const subject = String(profile.subject);
|
|
932
|
+
const email2 = profile.email?.trim().toLowerCase() || null;
|
|
933
|
+
if (pending.u) {
|
|
934
|
+
if (!options.user || String(options.user.id) !== pending.u) return back("account", "failed");
|
|
935
|
+
const existing = await this.identity(provider.id, subject);
|
|
936
|
+
if (existing && String(existing.user) !== pending.u) return back("account", "taken");
|
|
937
|
+
if (!existing) await this.addIdentity(options.user.id, provider.id, subject, email2);
|
|
938
|
+
return { redirect: `${admin}/account?sso=linked` };
|
|
939
|
+
}
|
|
940
|
+
let user = null;
|
|
941
|
+
const identity = await this.identity(provider.id, subject);
|
|
942
|
+
if (identity) {
|
|
943
|
+
user = await this.cms.db.findById({ collection: USERS, id: identity.user });
|
|
944
|
+
} else {
|
|
945
|
+
if (!email2 || !profile.emailVerified)
|
|
946
|
+
return back("login", email2 ? "unverified" : "no-account");
|
|
947
|
+
user = await this.userByEmail(email2);
|
|
948
|
+
if (!user) user = await this.signUp(email2, profile.name);
|
|
949
|
+
if (!user) return back("login", "no-account");
|
|
950
|
+
await this.addIdentity(user.id, provider.id, subject, email2);
|
|
951
|
+
}
|
|
952
|
+
if (!user) return back("login", "no-account");
|
|
953
|
+
if (user.active === false) return back("login", "inactive");
|
|
954
|
+
const found = identity ?? await this.identity(provider.id, subject);
|
|
955
|
+
if (found) {
|
|
956
|
+
const { id, ...rest } = found;
|
|
957
|
+
await this.cms.db.update({
|
|
958
|
+
collection: USER_IDENTITIES,
|
|
959
|
+
id,
|
|
960
|
+
data: { ...rest, email: email2, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
return { redirect: pending.r, session: await this.cms.auth.createSession(user.id) };
|
|
964
|
+
}
|
|
965
|
+
/** A user's outside accounts. */
|
|
966
|
+
async identities(userId) {
|
|
967
|
+
if (!this.enabled) return [];
|
|
968
|
+
const { docs } = await this.cms.db.find({
|
|
969
|
+
collection: USER_IDENTITIES,
|
|
970
|
+
where: { user: { equals: userId } },
|
|
971
|
+
sort: ["id"],
|
|
972
|
+
limit: 0,
|
|
973
|
+
page: 1
|
|
974
|
+
});
|
|
975
|
+
return docs.map((row) => ({
|
|
976
|
+
id: row.id,
|
|
977
|
+
provider: String(row.provider),
|
|
978
|
+
name: this.config.auth.providers.find((p) => p.id === row.provider)?.name ?? String(row.provider),
|
|
979
|
+
email: typeof row.email === "string" ? row.email : null,
|
|
980
|
+
createdAt: String(row.createdAt),
|
|
981
|
+
lastUsedAt: typeof row.lastUsedAt === "string" ? row.lastUsedAt : null
|
|
982
|
+
}));
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Unlinks an outside account: the user's own, or anyone's for admins. Refuses to unlink the
|
|
986
|
+
* last way a user has to sign in.
|
|
987
|
+
*/
|
|
988
|
+
async unlink(actor, identityId) {
|
|
989
|
+
if (!this.enabled) throw new NotFoundError(USER_IDENTITIES, identityId);
|
|
990
|
+
const row = await this.cms.db.findById({ collection: USER_IDENTITIES, id: identityId });
|
|
991
|
+
if (!row) throw new NotFoundError(USER_IDENTITIES, identityId);
|
|
992
|
+
const own = String(row.user) === String(actor.id);
|
|
993
|
+
if (!own && (actor.role !== "admin" || actor.apiKey)) throw new ForbiddenError();
|
|
994
|
+
const user = await this.cms.db.findById({ collection: USERS, id: row.user });
|
|
995
|
+
const others = (await this.identities(row.user)).length - 1;
|
|
996
|
+
const password = !!user?.passwordHash && this.passwordAllowed(user);
|
|
997
|
+
if (others === 0 && !password)
|
|
998
|
+
throw new ValidationError(USER_IDENTITIES, [
|
|
999
|
+
{ field: "provider", message: "is the only way left to sign in to this account" }
|
|
1000
|
+
]);
|
|
1001
|
+
await this.cms.db.delete({ collection: USER_IDENTITIES, id: row.id });
|
|
1002
|
+
}
|
|
1003
|
+
/** Forgets a deleted user's outside accounts. */
|
|
1004
|
+
async forget(userId) {
|
|
1005
|
+
if (!this.enabled) return;
|
|
1006
|
+
for (const identity of await this.identities(userId))
|
|
1007
|
+
await this.cms.db.delete({ collection: USER_IDENTITIES, id: identity.id });
|
|
1008
|
+
}
|
|
1009
|
+
settings(origin) {
|
|
1010
|
+
const { allowSignUp } = this.config.auth;
|
|
1011
|
+
return {
|
|
1012
|
+
providers: this.config.auth.providers.map((p) => ({
|
|
1013
|
+
id: p.id,
|
|
1014
|
+
name: p.name,
|
|
1015
|
+
icon: p.icon ?? "key",
|
|
1016
|
+
callbackURL: this.callbackURL(p.id, origin),
|
|
1017
|
+
details: { ...p.describe?.() ?? {} }
|
|
1018
|
+
})),
|
|
1019
|
+
password: this.config.auth.password ? "everyone" : "admins",
|
|
1020
|
+
signUp: allowSignUp.domains.length > 0 ? { domains: [...allowSignUp.domains], role: allowSignUp.role ?? this.defaultRole() } : null,
|
|
1021
|
+
serverURL: !!this.config.serverURL
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
// -------------------------------------------------------------------------
|
|
1025
|
+
provider(id) {
|
|
1026
|
+
const provider = this.config.auth.providers.find((p) => p.id === id);
|
|
1027
|
+
if (!provider) throw new NotFoundError("auth providers", id);
|
|
1028
|
+
return provider;
|
|
1029
|
+
}
|
|
1030
|
+
/** Only pages of the admin: anything else (another site) goes to the admin's home. */
|
|
1031
|
+
adminPage(redirect) {
|
|
1032
|
+
const admin = this.config.admin.path.replace(/\/+$/, "");
|
|
1033
|
+
if (typeof redirect === "string" && (redirect === admin || redirect.startsWith(`${admin}/`)) && !redirect.startsWith("//") && !redirect.includes("\\"))
|
|
1034
|
+
return redirect;
|
|
1035
|
+
return `${admin}/`;
|
|
1036
|
+
}
|
|
1037
|
+
async identity(provider, subject) {
|
|
1038
|
+
const { docs } = await this.cms.db.find({
|
|
1039
|
+
collection: USER_IDENTITIES,
|
|
1040
|
+
where: { and: [{ provider: { equals: provider } }, { subject: { equals: subject } }] },
|
|
1041
|
+
sort: [],
|
|
1042
|
+
limit: 1,
|
|
1043
|
+
page: 1
|
|
1044
|
+
});
|
|
1045
|
+
return docs[0];
|
|
1046
|
+
}
|
|
1047
|
+
async addIdentity(user, provider, subject, email2) {
|
|
1048
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1049
|
+
await this.cms.db.create({
|
|
1050
|
+
collection: USER_IDENTITIES,
|
|
1051
|
+
data: { user, provider, subject, email: email2, lastUsedAt: now, createdAt: now, updatedAt: now }
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
async userByEmail(email2) {
|
|
1055
|
+
const { docs } = await this.cms.db.find({
|
|
1056
|
+
collection: USERS,
|
|
1057
|
+
where: { email: { equals: email2 } },
|
|
1058
|
+
sort: [],
|
|
1059
|
+
limit: 1,
|
|
1060
|
+
page: 1
|
|
1061
|
+
});
|
|
1062
|
+
return docs[0] ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
/** A new account for people from `allowSignUp.domains`. */
|
|
1065
|
+
async signUp(email2, name) {
|
|
1066
|
+
const { domains, role } = this.config.auth.allowSignUp;
|
|
1067
|
+
const domain = email2.slice(email2.lastIndexOf("@") + 1);
|
|
1068
|
+
if (!domains.some((d) => d.toLowerCase() === domain)) return null;
|
|
1069
|
+
const created = await this.cms.create(USERS, {
|
|
1070
|
+
email: email2,
|
|
1071
|
+
role: role ?? this.defaultRole(),
|
|
1072
|
+
active: true,
|
|
1073
|
+
...name ? { name } : {}
|
|
1074
|
+
});
|
|
1075
|
+
this.cms.logger.info(`${email2} signed up with a provider`);
|
|
1076
|
+
return this.cms.db.findById({ collection: USERS, id: created.id });
|
|
1077
|
+
}
|
|
1078
|
+
defaultRole() {
|
|
1079
|
+
const roles = this.config.auth.roles;
|
|
1080
|
+
return roles.includes("editor") ? "editor" : roles[roles.length - 1];
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
773
1084
|
// src/auth/auth.ts
|
|
774
1085
|
var INVALID = "Invalid email or password";
|
|
775
1086
|
var RESET_REQUESTS = 3;
|
|
776
1087
|
var Auth = class {
|
|
777
1088
|
constructor(cms) {
|
|
778
1089
|
this.cms = cms;
|
|
1090
|
+
this.sso = new SingleSignOn(cms);
|
|
779
1091
|
}
|
|
780
1092
|
cms;
|
|
1093
|
+
/** Signing in with outside accounts (`auth.providers`). */
|
|
1094
|
+
sso;
|
|
781
1095
|
get db() {
|
|
782
1096
|
return this.cms.db;
|
|
783
1097
|
}
|
|
@@ -804,6 +1118,10 @@ var Auth = class {
|
|
|
804
1118
|
throw new UnauthorizedError(INVALID);
|
|
805
1119
|
}
|
|
806
1120
|
await this.clearFailures(key2);
|
|
1121
|
+
if (!this.sso.passwordAllowed(user))
|
|
1122
|
+
throw new UnauthorizedError(
|
|
1123
|
+
`Sign in with ${this.config.auth.providers.map((p) => p.name).join(" or ")}`
|
|
1124
|
+
);
|
|
807
1125
|
await this.deleteExpiredSessions(user.id);
|
|
808
1126
|
return this.startSession(user);
|
|
809
1127
|
}
|
|
@@ -903,7 +1221,7 @@ var Auth = class {
|
|
|
903
1221
|
if (recent >= RESET_REQUESTS) return;
|
|
904
1222
|
await this.recordFailure(key2);
|
|
905
1223
|
const user = await this.findByEmail(email2);
|
|
906
|
-
if (!user || user.active === false) return;
|
|
1224
|
+
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return;
|
|
907
1225
|
await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
|
|
908
1226
|
}
|
|
909
1227
|
/**
|
|
@@ -959,7 +1277,7 @@ var Auth = class {
|
|
|
959
1277
|
if (!claims || claims.expiresAt <= Date.now()) return null;
|
|
960
1278
|
const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
|
|
961
1279
|
const user = await this.db.findById({ collection: USERS, id });
|
|
962
|
-
if (!user || user.active === false) return null;
|
|
1280
|
+
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
|
|
963
1281
|
const fingerprint = passwordFingerprint(user.passwordHash);
|
|
964
1282
|
if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
|
|
965
1283
|
return { user, purpose: claims.purpose };
|
|
@@ -974,13 +1292,14 @@ var Auth = class {
|
|
|
974
1292
|
expiresAt,
|
|
975
1293
|
fingerprint: passwordFingerprint(user.passwordHash)
|
|
976
1294
|
});
|
|
977
|
-
const
|
|
1295
|
+
const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
|
|
1296
|
+
const url = sso ? `${base}${this.config.admin.path}/login` : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
|
|
978
1297
|
const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
|
|
979
1298
|
const content = await write({
|
|
980
1299
|
user: await this.toAuthUser(user),
|
|
981
1300
|
url,
|
|
982
1301
|
locale: this.localeOf(options.locale),
|
|
983
|
-
expiresAt: new Date(expiresAt)
|
|
1302
|
+
...sso ? { sso } : { expiresAt: new Date(expiresAt) }
|
|
984
1303
|
});
|
|
985
1304
|
await this.cms.sendEmail({ to: String(user.email), ...content });
|
|
986
1305
|
}
|
|
@@ -1361,6 +1680,7 @@ async function runBackup(cms, record) {
|
|
|
1361
1680
|
const filename = filenameFor(cms, started);
|
|
1362
1681
|
const key2 = await storage.get(filename) ? filename.replace(/\.db\.gz$/, `-${row.id}.db.gz`) : filename;
|
|
1363
1682
|
await storage.put(key2, data, { contentType: "application/gzip" });
|
|
1683
|
+
await pruneBackups(cms, 1);
|
|
1364
1684
|
row = await update(cms, row, {
|
|
1365
1685
|
state: "done",
|
|
1366
1686
|
filename: key2,
|
|
@@ -1368,7 +1688,6 @@ async function runBackup(cms, record) {
|
|
|
1368
1688
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1369
1689
|
});
|
|
1370
1690
|
cms.logger.info(`Backup ${key2} written (${data.byteLength} bytes)`);
|
|
1371
|
-
await pruneBackups(cms);
|
|
1372
1691
|
} catch (error) {
|
|
1373
1692
|
await update(cms, row, {
|
|
1374
1693
|
state: "failed",
|
|
@@ -1380,8 +1699,8 @@ async function runBackup(cms, record) {
|
|
|
1380
1699
|
await rm2(dir, { recursive: true, force: true });
|
|
1381
1700
|
}
|
|
1382
1701
|
}
|
|
1383
|
-
async function pruneBackups(cms) {
|
|
1384
|
-
const keep = cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP;
|
|
1702
|
+
async function pruneBackups(cms, room = 0) {
|
|
1703
|
+
const keep = Math.max(0, (cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP) - room);
|
|
1385
1704
|
const storage = await backupStorage(cms);
|
|
1386
1705
|
const { docs } = await cms.db.find({
|
|
1387
1706
|
collection: DATABASE_BACKUPS,
|
|
@@ -2313,6 +2632,7 @@ var VersionStore = class {
|
|
|
2313
2632
|
// src/roles.ts
|
|
2314
2633
|
var ROLE_OPERATIONS = ["read", "create", "update", "delete", "publish"];
|
|
2315
2634
|
var GLOBAL_OPERATIONS = ["read", "update", "publish"];
|
|
2635
|
+
var OWN_OPERATIONS = ["read", "update", "delete", "publish"];
|
|
2316
2636
|
var KEY = /^[a-z][a-z0-9-]{0,31}$/;
|
|
2317
2637
|
var HISTORY = 50;
|
|
2318
2638
|
var TTL = 3e4;
|
|
@@ -2328,6 +2648,9 @@ var rolesCollection = {
|
|
|
2328
2648
|
function governed(slug2) {
|
|
2329
2649
|
return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
|
|
2330
2650
|
}
|
|
2651
|
+
function ruleFields(fields) {
|
|
2652
|
+
return fields.filter((f) => !f.hidden && f.name !== "createdBy");
|
|
2653
|
+
}
|
|
2331
2654
|
function references(fields, out = /* @__PURE__ */ new Set()) {
|
|
2332
2655
|
for (const field of fields) {
|
|
2333
2656
|
if (field.type === "relationship") out.add(field.to);
|
|
@@ -2343,14 +2666,16 @@ var viewId = {
|
|
|
2343
2666
|
};
|
|
2344
2667
|
var tagOf = (component) => typeof component === "string" ? component : component.tag;
|
|
2345
2668
|
var Roles = class {
|
|
2346
|
-
constructor(config, db) {
|
|
2669
|
+
constructor(config, db, log) {
|
|
2347
2670
|
this.config = config;
|
|
2348
2671
|
this.db = db;
|
|
2672
|
+
this.log = log;
|
|
2349
2673
|
this.enabled = config.auth.rbac;
|
|
2350
2674
|
this.versions = new VersionStore(db);
|
|
2351
2675
|
}
|
|
2352
2676
|
config;
|
|
2353
2677
|
db;
|
|
2678
|
+
log;
|
|
2354
2679
|
enabled;
|
|
2355
2680
|
versions;
|
|
2356
2681
|
cache;
|
|
@@ -2364,7 +2689,13 @@ var Roles = class {
|
|
|
2364
2689
|
if ("collection" in target && !governed(target.collection)) return true;
|
|
2365
2690
|
const permissions = await this.permissionsOf(user.role);
|
|
2366
2691
|
const ops = "collection" in target ? permissions.collections?.[target.collection] : permissions.globals?.[target.global];
|
|
2367
|
-
if (ops?.includes(operation))
|
|
2692
|
+
if (ops?.includes(operation)) {
|
|
2693
|
+
if ("collection" in target && permissions.own?.[target.collection]?.includes(operation)) {
|
|
2694
|
+
const owner = this.ownerOf(target.collection);
|
|
2695
|
+
if (owner && operation !== "create") return { [owner]: { equals: user.id } };
|
|
2696
|
+
}
|
|
2697
|
+
return true;
|
|
2698
|
+
}
|
|
2368
2699
|
if ("collection" in target && target.collection === USERS) {
|
|
2369
2700
|
if (operation === "read" || operation === "update") return { id: { equals: user.id } };
|
|
2370
2701
|
}
|
|
@@ -2380,6 +2711,76 @@ var Roles = class {
|
|
|
2380
2711
|
if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
|
|
2381
2712
|
return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
|
|
2382
2713
|
}
|
|
2714
|
+
/**
|
|
2715
|
+
* The field that names a collection's owner, for "own documents only": `admin.ownerField`, or
|
|
2716
|
+
* `createdBy`. `undefined` without roles, and for users and collections roles don't cover.
|
|
2717
|
+
*/
|
|
2718
|
+
ownerOf(slug2) {
|
|
2719
|
+
if (!this.enabled || slug2 === USERS || !governed(slug2)) return void 0;
|
|
2720
|
+
const config = this.config.collections.find((c) => c.slug === slug2);
|
|
2721
|
+
if (!config) return void 0;
|
|
2722
|
+
return config.admin?.ownerField ?? "createdBy";
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* The user's field rules (top-level fields their role may only read, or not see), for field
|
|
2726
|
+
* access checks. Roles that may update only their own documents can't change the owner field.
|
|
2727
|
+
*/
|
|
2728
|
+
async fieldRules(user) {
|
|
2729
|
+
if (!this.enabled || !user || user.role === "admin") return void 0;
|
|
2730
|
+
await this.load();
|
|
2731
|
+
const cache = this.cache;
|
|
2732
|
+
const cached = cache?.rules.get(user.role);
|
|
2733
|
+
if (cached) return cached;
|
|
2734
|
+
const permissions = await this.permissionsOf(user.role);
|
|
2735
|
+
const rules = /* @__PURE__ */ new Map();
|
|
2736
|
+
const add = (fields, set) => {
|
|
2737
|
+
for (const field of fields) {
|
|
2738
|
+
const rule = set?.[field.name];
|
|
2739
|
+
if (rule === "read" || rule === "hidden") rules.set(field, rule);
|
|
2740
|
+
}
|
|
2741
|
+
};
|
|
2742
|
+
for (const c of this.config.collections) {
|
|
2743
|
+
if (!governed(c.slug)) continue;
|
|
2744
|
+
add(c.fields, permissions.fields?.collections?.[c.slug]);
|
|
2745
|
+
const ownerField = c.admin?.ownerField;
|
|
2746
|
+
if (ownerField && permissions.own?.[c.slug]?.includes("update")) {
|
|
2747
|
+
const field = c.fields.find((f) => f.name === ownerField);
|
|
2748
|
+
if (field && !rules.has(field)) rules.set(field, "read");
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
for (const g of this.config.globals) add(g.fields, permissions.fields?.globals?.[g.slug]);
|
|
2752
|
+
cache?.rules.set(user.role, rules);
|
|
2753
|
+
return rules;
|
|
2754
|
+
}
|
|
2755
|
+
/** A new document is its creator's: sets `createdBy`, and an empty owner field. */
|
|
2756
|
+
fillOwner(slug2, data, user) {
|
|
2757
|
+
if (!user || !this.ownerOf(slug2)) return;
|
|
2758
|
+
data.createdBy ??= user.id;
|
|
2759
|
+
const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
|
|
2760
|
+
if (ownerField && (data[ownerField] === void 0 || data[ownerField] === null))
|
|
2761
|
+
data[ownerField] = user.id;
|
|
2762
|
+
}
|
|
2763
|
+
/** Documents a user owns, by collection (`createdBy` and owner fields), for deleting them. */
|
|
2764
|
+
async owned(userId) {
|
|
2765
|
+
const out = [];
|
|
2766
|
+
for (const c of this.config.collections) {
|
|
2767
|
+
const columns = this.ownerColumns(c.slug);
|
|
2768
|
+
if (columns.length === 0) continue;
|
|
2769
|
+
const count = await this.db.count({
|
|
2770
|
+
collection: c.slug,
|
|
2771
|
+
where: { or: columns.map((column) => ({ [column]: { equals: userId } })) }
|
|
2772
|
+
});
|
|
2773
|
+
if (count > 0) out.push({ collection: c.slug, count });
|
|
2774
|
+
}
|
|
2775
|
+
return out;
|
|
2776
|
+
}
|
|
2777
|
+
/** Gives a deleted user's documents to another user, or to nobody (`null`). */
|
|
2778
|
+
async transfer(from, to) {
|
|
2779
|
+
for (const c of this.config.collections) {
|
|
2780
|
+
for (const column of this.ownerColumns(c.slug))
|
|
2781
|
+
await this.setWhere(c.slug, { [column]: { equals: from } }, column, to);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2383
2784
|
/** Forgets cached permissions, after a role changed. */
|
|
2384
2785
|
invalidate() {
|
|
2385
2786
|
this.cache = void 0;
|
|
@@ -2409,6 +2810,39 @@ var Roles = class {
|
|
|
2409
2810
|
}
|
|
2410
2811
|
}
|
|
2411
2812
|
this.invalidate();
|
|
2813
|
+
await this.backfillCreators();
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Documents from before roles have no `createdBy`: it is filled from who saved their first
|
|
2817
|
+
* version, for collections with versions. Only while no document of the collection has one,
|
|
2818
|
+
* so it runs once (and finds nothing to do afterwards).
|
|
2819
|
+
*/
|
|
2820
|
+
async backfillCreators() {
|
|
2821
|
+
for (const c of this.config.collections) {
|
|
2822
|
+
if (!c.versions || !this.ownerOf(c.slug)) continue;
|
|
2823
|
+
if (await this.db.count({ collection: c.slug, where: { createdBy: { exists: true } } }) > 0)
|
|
2824
|
+
continue;
|
|
2825
|
+
const versions = await this.db.find({
|
|
2826
|
+
collection: VERSIONS,
|
|
2827
|
+
where: { and: [{ parent: { equals: c.slug } }, { author: { exists: true } }] },
|
|
2828
|
+
sort: ["id"],
|
|
2829
|
+
limit: 0,
|
|
2830
|
+
page: 1
|
|
2831
|
+
});
|
|
2832
|
+
const first = /* @__PURE__ */ new Map();
|
|
2833
|
+
for (const v of versions.docs) {
|
|
2834
|
+
if (!first.has(v.doc) && v.author !== null) first.set(v.doc, v.author);
|
|
2835
|
+
}
|
|
2836
|
+
let filled = 0;
|
|
2837
|
+
for (const [doc, author] of first) {
|
|
2838
|
+
const row = await this.db.findById({ collection: c.slug, id: doc });
|
|
2839
|
+
if (!row || row.createdBy !== null && row.createdBy !== void 0) continue;
|
|
2840
|
+
const { id, ...rest } = row;
|
|
2841
|
+
await this.db.update({ collection: c.slug, id, data: { ...rest, createdBy: author } });
|
|
2842
|
+
filled++;
|
|
2843
|
+
}
|
|
2844
|
+
if (filled > 0) this.log?.(`Set who created ${filled} ${c.slug} from their history`);
|
|
2845
|
+
}
|
|
2412
2846
|
}
|
|
2413
2847
|
/** Whether a role exists (users can only be given existing roles). */
|
|
2414
2848
|
async exists(key2) {
|
|
@@ -2437,16 +2871,24 @@ var Roles = class {
|
|
|
2437
2871
|
const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
|
|
2438
2872
|
return {
|
|
2439
2873
|
roles,
|
|
2440
|
-
collections: this.config.collections.filter((c) => governed(c.slug)).map((c) =>
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2874
|
+
collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => {
|
|
2875
|
+
const owner = this.ownerOf(c.slug);
|
|
2876
|
+
return {
|
|
2877
|
+
slug: c.slug,
|
|
2878
|
+
ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
|
|
2879
|
+
new: !seen("collections", c.slug),
|
|
2880
|
+
references: [...references(ruleFields(c.fields))].filter(
|
|
2881
|
+
(s) => s !== c.slug && governed(s)
|
|
2882
|
+
),
|
|
2883
|
+
fields: adminFields(c.fields, c.admin?.ownerField),
|
|
2884
|
+
...owner ? { owner } : {}
|
|
2885
|
+
};
|
|
2886
|
+
}),
|
|
2446
2887
|
globals: this.config.globals.map((g) => ({
|
|
2447
2888
|
slug: g.slug,
|
|
2448
2889
|
ops: GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true),
|
|
2449
|
-
new: !seen("globals", g.slug)
|
|
2890
|
+
new: !seen("globals", g.slug),
|
|
2891
|
+
fields: adminFields(g.fields, void 0)
|
|
2450
2892
|
})),
|
|
2451
2893
|
views: this.views()
|
|
2452
2894
|
};
|
|
@@ -2560,9 +3002,9 @@ var Roles = class {
|
|
|
2560
3002
|
for (const [slug2, ops] of Object.entries(group)) {
|
|
2561
3003
|
const valid = allowed2.get(slug2);
|
|
2562
3004
|
if (!valid || !Array.isArray(ops)) continue;
|
|
2563
|
-
const
|
|
2564
|
-
if (
|
|
2565
|
-
out[slug2] =
|
|
3005
|
+
const picked2 = valid.filter((op) => ops.includes(op));
|
|
3006
|
+
if (picked2.length > 0 && !picked2.includes("read")) picked2.unshift("read");
|
|
3007
|
+
out[slug2] = picked2;
|
|
2566
3008
|
}
|
|
2567
3009
|
return out;
|
|
2568
3010
|
};
|
|
@@ -2584,11 +3026,71 @@ var Roles = class {
|
|
|
2584
3026
|
input.admin.filter((id) => typeof id === "string" && views.has(id))
|
|
2585
3027
|
)
|
|
2586
3028
|
] : [];
|
|
2587
|
-
|
|
3029
|
+
const picked = {
|
|
2588
3030
|
collections: pick(input.collections, collections),
|
|
2589
|
-
globals: pick(input.globals, globals)
|
|
2590
|
-
|
|
3031
|
+
globals: pick(input.globals, globals)
|
|
3032
|
+
};
|
|
3033
|
+
const own = {};
|
|
3034
|
+
if (typeof input.own === "object" && input.own !== null) {
|
|
3035
|
+
for (const [slug2, ops] of Object.entries(input.own)) {
|
|
3036
|
+
const granted = picked.collections[slug2];
|
|
3037
|
+
if (!granted || !this.ownerOf(slug2) || !Array.isArray(ops)) continue;
|
|
3038
|
+
const valid = OWN_OPERATIONS.filter((op) => granted.includes(op) && ops.includes(op));
|
|
3039
|
+
if (valid.length > 0) own[slug2] = valid;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
const fields = {
|
|
3043
|
+
collections: this.cleanRules(
|
|
3044
|
+
input.fields?.collections,
|
|
3045
|
+
this.config.collections.filter((c) => governed(c.slug)),
|
|
3046
|
+
picked.collections
|
|
3047
|
+
),
|
|
3048
|
+
globals: this.cleanRules(
|
|
3049
|
+
input.fields?.globals,
|
|
3050
|
+
this.config.globals,
|
|
3051
|
+
void 0
|
|
3052
|
+
)
|
|
2591
3053
|
};
|
|
3054
|
+
return { ...picked, admin, own, fields };
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* Keeps rules for known top-level fields. A field that must be filled in (required, without a
|
|
3058
|
+
* default) can't be read-only or hidden for a role that creates documents.
|
|
3059
|
+
*/
|
|
3060
|
+
cleanRules(value, containers, granted) {
|
|
3061
|
+
const out = {};
|
|
3062
|
+
if (typeof value !== "object" || value === null) return out;
|
|
3063
|
+
for (const [slug2, rules] of Object.entries(value)) {
|
|
3064
|
+
const container = containers.find((c) => c.slug === slug2);
|
|
3065
|
+
if (!container || typeof rules !== "object" || rules === null) continue;
|
|
3066
|
+
const set = {};
|
|
3067
|
+
for (const field of ruleFields(container.fields)) {
|
|
3068
|
+
const rule = rules[field.name];
|
|
3069
|
+
if (rule !== "read" && rule !== "hidden") continue;
|
|
3070
|
+
if (granted?.[slug2]?.includes("create") && mustFill(field))
|
|
3071
|
+
throw new ValidationError(ROLES, [
|
|
3072
|
+
{
|
|
3073
|
+
field: `fields.${slug2}.${field.name}`,
|
|
3074
|
+
message: "is required: roles that create documents must be able to fill it"
|
|
3075
|
+
}
|
|
3076
|
+
]);
|
|
3077
|
+
set[field.name] = rule;
|
|
3078
|
+
}
|
|
3079
|
+
if (Object.keys(set).length > 0) out[slug2] = set;
|
|
3080
|
+
}
|
|
3081
|
+
return out;
|
|
3082
|
+
}
|
|
3083
|
+
/** Fields that name a collection's owner (`createdBy`, and `admin.ownerField`). */
|
|
3084
|
+
ownerColumns(slug2) {
|
|
3085
|
+
if (!this.ownerOf(slug2)) return [];
|
|
3086
|
+
const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
|
|
3087
|
+
return ownerField ? ["createdBy", ownerField] : ["createdBy"];
|
|
3088
|
+
}
|
|
3089
|
+
/** Sets one field of every matching document, keeping the rest as stored. */
|
|
3090
|
+
async setWhere(collection2, where, field, value) {
|
|
3091
|
+
const { docs } = await this.db.find({ collection: collection2, where, sort: ["id"], limit: 0, page: 1 });
|
|
3092
|
+
for (const { id, ...rest } of docs)
|
|
3093
|
+
await this.db.update({ collection: collection2, id, data: { ...rest, [field]: value } });
|
|
2592
3094
|
}
|
|
2593
3095
|
cleanName(value) {
|
|
2594
3096
|
if (value === void 0 || value === null) return "";
|
|
@@ -2607,7 +3109,7 @@ var Roles = class {
|
|
|
2607
3109
|
const byKey = /* @__PURE__ */ new Map();
|
|
2608
3110
|
for (const row of await this.rows())
|
|
2609
3111
|
byKey.set(String(row.key), row.permissions ?? {});
|
|
2610
|
-
this.cache = { at: Date.now(), byKey };
|
|
3112
|
+
this.cache = { at: Date.now(), byKey, rules: /* @__PURE__ */ new Map() };
|
|
2611
3113
|
return byKey;
|
|
2612
3114
|
}
|
|
2613
3115
|
async rows() {
|
|
@@ -2655,13 +3157,24 @@ var Roles = class {
|
|
|
2655
3157
|
};
|
|
2656
3158
|
}
|
|
2657
3159
|
};
|
|
3160
|
+
function mustFill(field) {
|
|
3161
|
+
return field.required === true && field.defaultValue === void 0;
|
|
3162
|
+
}
|
|
3163
|
+
function adminFields(fields, ownerField) {
|
|
3164
|
+
return ruleFields(fields).map((f) => ({
|
|
3165
|
+
name: f.name,
|
|
3166
|
+
...f.label !== void 0 ? { label: f.label } : {},
|
|
3167
|
+
required: mustFill(f),
|
|
3168
|
+
...f.name === ownerField ? { owner: true } : {}
|
|
3169
|
+
}));
|
|
3170
|
+
}
|
|
2658
3171
|
function rank(role, config) {
|
|
2659
3172
|
const index = config.auth.roles.indexOf(role.key);
|
|
2660
3173
|
return index === -1 ? config.auth.roles.length : index;
|
|
2661
3174
|
}
|
|
2662
3175
|
|
|
2663
3176
|
// src/webhooks.ts
|
|
2664
|
-
import { createHmac as
|
|
3177
|
+
import { createHmac as createHmac3, randomUUID as randomUUID2 } from "crypto";
|
|
2665
3178
|
var WEBHOOK_EVENTS = [
|
|
2666
3179
|
"create",
|
|
2667
3180
|
"update",
|
|
@@ -2805,7 +3318,7 @@ var Webhooks = class {
|
|
|
2805
3318
|
...hook.headers
|
|
2806
3319
|
};
|
|
2807
3320
|
if (hook.secret) {
|
|
2808
|
-
headers["x-easy-cms-signature"] = `sha256=${
|
|
3321
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac3("sha256", hook.secret).update(body).digest("hex")}`;
|
|
2809
3322
|
}
|
|
2810
3323
|
try {
|
|
2811
3324
|
const response = await this.fetcher(hook.url, {
|
|
@@ -2852,8 +3365,10 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
2852
3365
|
"email-deliveries",
|
|
2853
3366
|
"database-backups",
|
|
2854
3367
|
"user-roles",
|
|
3368
|
+
"user-identities",
|
|
2855
3369
|
"api-keys",
|
|
2856
3370
|
"jobs",
|
|
3371
|
+
"auth",
|
|
2857
3372
|
"migrations",
|
|
2858
3373
|
"access"
|
|
2859
3374
|
]);
|
|
@@ -2908,7 +3423,10 @@ function validateConfig(config) {
|
|
|
2908
3423
|
if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
|
|
2909
3424
|
collections.forEach((collection2, i) => {
|
|
2910
3425
|
const path = `collections.${collection2.slug ?? `[${i}]`}`;
|
|
2911
|
-
validateContainer(collection2, path, collectionSlugs, add
|
|
3426
|
+
validateContainer(collection2, path, collectionSlugs, add, {
|
|
3427
|
+
collection: true,
|
|
3428
|
+
rbac: config.auth?.rbac === true
|
|
3429
|
+
});
|
|
2912
3430
|
validateUseAsTitle(collection2, path, add);
|
|
2913
3431
|
validateIcon(collection2, path, add);
|
|
2914
3432
|
const editIn = collection2.editIn;
|
|
@@ -2917,7 +3435,10 @@ function validateConfig(config) {
|
|
|
2917
3435
|
});
|
|
2918
3436
|
globals.forEach((global2, i) => {
|
|
2919
3437
|
const path = `globals.${global2.slug ?? `[${i}]`}`;
|
|
2920
|
-
validateContainer(global2, path, collectionSlugs, add
|
|
3438
|
+
validateContainer(global2, path, collectionSlugs, add, {
|
|
3439
|
+
collection: false,
|
|
3440
|
+
rbac: config.auth?.rbac === true
|
|
3441
|
+
});
|
|
2921
3442
|
validateIcon(global2, path, add);
|
|
2922
3443
|
});
|
|
2923
3444
|
const plugins = config.plugins;
|
|
@@ -3095,6 +3616,52 @@ function validateAuth(config, add) {
|
|
|
3095
3616
|
if (auth === void 0) return;
|
|
3096
3617
|
if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
|
|
3097
3618
|
add("auth.rbac", "must be true or false");
|
|
3619
|
+
if (auth.password !== void 0 && typeof auth.password !== "boolean")
|
|
3620
|
+
add("auth.password", "must be true or false");
|
|
3621
|
+
const providers = auth.providers;
|
|
3622
|
+
if (providers !== void 0) {
|
|
3623
|
+
if (!Array.isArray(providers)) {
|
|
3624
|
+
add(
|
|
3625
|
+
"auth.providers",
|
|
3626
|
+
"must be an array",
|
|
3627
|
+
"e.g. providers: [google({ clientId, clientSecret })]"
|
|
3628
|
+
);
|
|
3629
|
+
} else {
|
|
3630
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3631
|
+
providers.forEach((provider, i) => {
|
|
3632
|
+
const p = provider;
|
|
3633
|
+
const path = `auth.providers[${i}]`;
|
|
3634
|
+
if (typeof p !== "object" || p === null || typeof p.authorizationURL !== "function" || typeof p.callback !== "function") {
|
|
3635
|
+
add(
|
|
3636
|
+
path,
|
|
3637
|
+
"must be a provider",
|
|
3638
|
+
"e.g. google({ clientId, clientSecret }) from @easy-cms/auth-oauth"
|
|
3639
|
+
);
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
if (typeof p.id !== "string" || !PAGE_PATH.test(p.id))
|
|
3643
|
+
add(`${path}.id`, 'must be lowercase letters, digits and "-"');
|
|
3644
|
+
else if (ids.has(p.id))
|
|
3645
|
+
add(`${path}.id`, `"${p.id}" is used by another provider`, "give one of them its own id");
|
|
3646
|
+
else ids.add(p.id);
|
|
3647
|
+
if (typeof p.name !== "string" || p.name.trim() === "")
|
|
3648
|
+
add(`${path}.name`, "must be a name");
|
|
3649
|
+
});
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
const signUp = auth.allowSignUp;
|
|
3653
|
+
if (signUp !== void 0) {
|
|
3654
|
+
if (typeof signUp !== "object" || signUp === null || !Array.isArray(signUp.domains) || signUp.domains.some((d) => typeof d !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d)))
|
|
3655
|
+
add("auth.allowSignUp.domains", "must be email domains", "e.g. domains: ['example.com']");
|
|
3656
|
+
if (signUp?.role === "admin")
|
|
3657
|
+
add("auth.allowSignUp.role", `can't be "admin"`, "make admins by hand, in Settings \u2192 Users");
|
|
3658
|
+
else if (signUp?.role !== void 0 && (typeof signUp.role !== "string" || !auth.rbac && !(auth.roles ?? ["admin", "editor"]).includes(signUp.role)))
|
|
3659
|
+
add("auth.allowSignUp.role", "must be one of auth.roles");
|
|
3660
|
+
if (!Array.isArray(providers) || providers.length === 0)
|
|
3661
|
+
add("auth.allowSignUp", "needs auth.providers");
|
|
3662
|
+
}
|
|
3663
|
+
if (auth.password === false && (!Array.isArray(providers) || providers.length === 0))
|
|
3664
|
+
add("auth.password", "false needs auth.providers", "otherwise only admins could sign in");
|
|
3098
3665
|
if (auth.roles !== void 0) {
|
|
3099
3666
|
const roles = auth.roles;
|
|
3100
3667
|
if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
|
|
@@ -3361,7 +3928,7 @@ function checkSlug(container, path, seen, add) {
|
|
|
3361
3928
|
seen.add(slug2);
|
|
3362
3929
|
return slug2;
|
|
3363
3930
|
}
|
|
3364
|
-
function validateContainer(container, path, collectionSlugs, add) {
|
|
3931
|
+
function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac }) {
|
|
3365
3932
|
if (!Array.isArray(container.fields)) {
|
|
3366
3933
|
add(`${path}.fields`, "must be an array");
|
|
3367
3934
|
return;
|
|
@@ -3398,8 +3965,25 @@ function validateContainer(container, path, collectionSlugs, add) {
|
|
|
3398
3965
|
add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
|
|
3399
3966
|
const list = admin.list;
|
|
3400
3967
|
if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
|
|
3968
|
+
const owner = admin.ownerField;
|
|
3969
|
+
if (owner !== void 0) {
|
|
3970
|
+
const field = container.fields.find((f) => f.name === owner);
|
|
3971
|
+
if (!collection2 || container.slug === "users" || field?.type !== "relationship" || field.to !== "users" || field.hasMany)
|
|
3972
|
+
add(
|
|
3973
|
+
`${path}.admin.ownerField`,
|
|
3974
|
+
'must name a relationship field to "users" (not hasMany) of a collection',
|
|
3975
|
+
"e.g. { name: 'author', type: 'relationship', to: 'users' }"
|
|
3976
|
+
);
|
|
3977
|
+
}
|
|
3401
3978
|
}
|
|
3402
3979
|
}
|
|
3980
|
+
const createdBy = container.fields.find((f) => f.name === CREATED_BY_FIELD.name);
|
|
3981
|
+
if (rbac && createdBy && createdBy !== CREATED_BY_FIELD)
|
|
3982
|
+
add(
|
|
3983
|
+
`${path}.fields.createdBy`,
|
|
3984
|
+
'"createdBy" is added by Easy CMS with auth.rbac',
|
|
3985
|
+
"rename the field; to use it as the owner, set admin.ownerField"
|
|
3986
|
+
);
|
|
3403
3987
|
const reserved = new Set(SYSTEM_FIELD_NAMES);
|
|
3404
3988
|
if (container.drafts) reserved.add("status");
|
|
3405
3989
|
validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
|
|
@@ -3604,7 +4188,15 @@ function validateComponents(components, path, add) {
|
|
|
3604
4188
|
}
|
|
3605
4189
|
var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
3606
4190
|
var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
|
|
3607
|
-
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
|
|
4191
|
+
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
|
|
4192
|
+
"users",
|
|
4193
|
+
"globals",
|
|
4194
|
+
"admin",
|
|
4195
|
+
"jobs",
|
|
4196
|
+
"media",
|
|
4197
|
+
"api-keys",
|
|
4198
|
+
"auth"
|
|
4199
|
+
]);
|
|
3608
4200
|
function validateCommands(config, add) {
|
|
3609
4201
|
const commands = config.commands;
|
|
3610
4202
|
if (commands === void 0) return;
|
|
@@ -3753,7 +4345,7 @@ async function resolveConfig(input) {
|
|
|
3753
4345
|
if (resolved.has(input)) return input;
|
|
3754
4346
|
let config = await applyPlugins(input);
|
|
3755
4347
|
if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
|
|
3756
|
-
config = withMedia(withUsers(config));
|
|
4348
|
+
config = withCreatedBy(withMedia(withUsers(config)));
|
|
3757
4349
|
}
|
|
3758
4350
|
const applied = applyFieldTypes(config);
|
|
3759
4351
|
config = applied.config;
|
|
@@ -3790,6 +4382,9 @@ async function resolveConfig(input) {
|
|
|
3790
4382
|
auth: {
|
|
3791
4383
|
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
3792
4384
|
rbac: config.auth?.rbac === true,
|
|
4385
|
+
providers: config.auth?.providers ?? [],
|
|
4386
|
+
allowSignUp: config.auth?.allowSignUp ?? { domains: [] },
|
|
4387
|
+
password: config.auth?.password !== false,
|
|
3793
4388
|
tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
|
|
3794
4389
|
maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
|
|
3795
4390
|
lockWindow: config.auth?.lockWindow ?? 15 * 60,
|
|
@@ -3808,7 +4403,8 @@ async function resolveConfig(input) {
|
|
|
3808
4403
|
...config.apiKeys ? [apiKeysCollection] : [],
|
|
3809
4404
|
...config.email ? [emailDeliveriesCollection] : [],
|
|
3810
4405
|
databaseBackupsCollection,
|
|
3811
|
-
...config.auth?.rbac ? [rolesCollection] : []
|
|
4406
|
+
...config.auth?.rbac ? [rolesCollection] : [],
|
|
4407
|
+
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : []
|
|
3812
4408
|
],
|
|
3813
4409
|
globals: config.globals ?? [],
|
|
3814
4410
|
endpoints: config.endpoints ?? [],
|
|
@@ -3874,7 +4470,7 @@ async function loadConfig(options = {}) {
|
|
|
3874
4470
|
}
|
|
3875
4471
|
|
|
3876
4472
|
// src/local-api.ts
|
|
3877
|
-
import { randomBytes as
|
|
4473
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
3878
4474
|
|
|
3879
4475
|
// src/localization.ts
|
|
3880
4476
|
var ALL_LOCALES = "all";
|
|
@@ -4071,10 +4667,10 @@ function imageDimensions(data, type) {
|
|
|
4071
4667
|
}
|
|
4072
4668
|
return void 0;
|
|
4073
4669
|
}
|
|
4074
|
-
function storageKey(originalName, type,
|
|
4670
|
+
function storageKey(originalName, type, random2) {
|
|
4075
4671
|
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
4076
4672
|
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
4077
|
-
return `${base}-${
|
|
4673
|
+
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
4078
4674
|
}
|
|
4079
4675
|
|
|
4080
4676
|
// src/populate.ts
|
|
@@ -4402,7 +4998,7 @@ var EasyCMS = class {
|
|
|
4402
4998
|
this.storage = storage;
|
|
4403
4999
|
this.cwd = cwd;
|
|
4404
5000
|
this.versions = new VersionStore(db);
|
|
4405
|
-
this.roles = new Roles(config, db);
|
|
5001
|
+
this.roles = new Roles(config, db, (message2) => logger.info(message2));
|
|
4406
5002
|
this.webhooks = new Webhooks(
|
|
4407
5003
|
config.webhooks ?? [],
|
|
4408
5004
|
logger,
|
|
@@ -4429,6 +5025,7 @@ var EasyCMS = class {
|
|
|
4429
5025
|
options
|
|
4430
5026
|
);
|
|
4431
5027
|
const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
5028
|
+
await this.checkQueryFields(config, guard, void 0, requested);
|
|
4432
5029
|
const locale = this.localeOf(options);
|
|
4433
5030
|
const sort = locale === void 0 ? requested : localizeSort(
|
|
4434
5031
|
config.fields,
|
|
@@ -4512,8 +5109,8 @@ var EasyCMS = class {
|
|
|
4512
5109
|
}
|
|
4513
5110
|
]);
|
|
4514
5111
|
}
|
|
4515
|
-
const
|
|
4516
|
-
const filename = storageKey(file.name, mimeType,
|
|
5112
|
+
const random2 = randomBytes5(4).toString("hex");
|
|
5113
|
+
const filename = storageKey(file.name, mimeType, random2);
|
|
4517
5114
|
const stored = [];
|
|
4518
5115
|
try {
|
|
4519
5116
|
await this.storage.put(filename, file.data, { contentType: mimeType });
|
|
@@ -4723,7 +5320,7 @@ var EasyCMS = class {
|
|
|
4723
5320
|
const filtered = await filterInput(
|
|
4724
5321
|
config.fields,
|
|
4725
5322
|
input,
|
|
4726
|
-
this.fieldChecker("update", guard, parsed, input)
|
|
5323
|
+
await this.fieldChecker("update", guard, parsed, input)
|
|
4727
5324
|
);
|
|
4728
5325
|
const localized = this.toMaps(config, filtered, base ?? {}, options);
|
|
4729
5326
|
const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
|
|
@@ -4763,7 +5360,7 @@ var EasyCMS = class {
|
|
|
4763
5360
|
const input = await filterInput(
|
|
4764
5361
|
config.fields,
|
|
4765
5362
|
raw,
|
|
4766
|
-
this.fieldChecker("update", guard, void 0, raw)
|
|
5363
|
+
await this.fieldChecker("update", guard, void 0, raw)
|
|
4767
5364
|
);
|
|
4768
5365
|
const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
|
|
4769
5366
|
const current = await this.globalDraft(config, saved);
|
|
@@ -4828,7 +5425,7 @@ var EasyCMS = class {
|
|
|
4828
5425
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
4829
5426
|
await this.checkDocumentAccess(config, "update", guard, parsed, raw);
|
|
4830
5427
|
if (config.drafts && (mode === "unpublish" || raw.status === "published"))
|
|
4831
|
-
await this.
|
|
5428
|
+
await this.checkDocumentGrant(guard, collection2, "publish", parsed);
|
|
4832
5429
|
const [drafted] = await this.withDrafts(config, [existing]);
|
|
4833
5430
|
const live = options.live === true && mode === "save";
|
|
4834
5431
|
const current = live ? existing : drafted;
|
|
@@ -4840,7 +5437,7 @@ var EasyCMS = class {
|
|
|
4840
5437
|
const filtered = await filterInput(
|
|
4841
5438
|
config.fields,
|
|
4842
5439
|
input,
|
|
4843
|
-
this.fieldChecker("update", guard, parsed, input)
|
|
5440
|
+
await this.fieldChecker("update", guard, parsed, input)
|
|
4844
5441
|
);
|
|
4845
5442
|
const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
|
|
4846
5443
|
let merged = mergeForUpdate(config.fields, current, localized);
|
|
@@ -4908,6 +5505,10 @@ var EasyCMS = class {
|
|
|
4908
5505
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
4909
5506
|
return out;
|
|
4910
5507
|
}
|
|
5508
|
+
/**
|
|
5509
|
+
* Deletes a document. Deleting a user with roles from the admin (`auth.rbac`): `transferTo`
|
|
5510
|
+
* gives the documents they own to another user; without it they have no owner.
|
|
5511
|
+
*/
|
|
4911
5512
|
async delete(collection2, id, options = {}) {
|
|
4912
5513
|
const config = this.collection(collection2);
|
|
4913
5514
|
const guard = guardOf(options);
|
|
@@ -4919,7 +5520,20 @@ var EasyCMS = class {
|
|
|
4919
5520
|
await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
|
|
4920
5521
|
const base = this.hookArgs(config, guard);
|
|
4921
5522
|
for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
|
|
4922
|
-
if (config.slug === USERS)
|
|
5523
|
+
if (config.slug === USERS) {
|
|
5524
|
+
const to = options.transferTo;
|
|
5525
|
+
if (to !== void 0 && to !== null) {
|
|
5526
|
+
const target = parseId(to);
|
|
5527
|
+
const heir = target === void 0 ? null : await this.db.findById({ collection: USERS, id: target });
|
|
5528
|
+
if (!heir || String(heir.id) === String(parsed))
|
|
5529
|
+
throw new ValidationError(USERS, [
|
|
5530
|
+
{ field: "transferTo", message: "must be another existing user" }
|
|
5531
|
+
]);
|
|
5532
|
+
await this.roles.transfer(parsed, heir.id);
|
|
5533
|
+
} else await this.roles.transfer(parsed, null);
|
|
5534
|
+
await this.auth.revokeSessions(parsed);
|
|
5535
|
+
await this.auth.sso.forget(parsed);
|
|
5536
|
+
}
|
|
4923
5537
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
4924
5538
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
4925
5539
|
if (config.schedule) {
|
|
@@ -5027,7 +5641,7 @@ var EasyCMS = class {
|
|
|
5027
5641
|
const input = await filterInput(
|
|
5028
5642
|
config.fields,
|
|
5029
5643
|
raw,
|
|
5030
|
-
this.fieldChecker("update", guard, void 0, raw)
|
|
5644
|
+
await this.fieldChecker("update", guard, void 0, raw)
|
|
5031
5645
|
);
|
|
5032
5646
|
const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
|
|
5033
5647
|
const current = await this.globalDraft(config, existing);
|
|
@@ -5312,7 +5926,7 @@ var EasyCMS = class {
|
|
|
5312
5926
|
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
5313
5927
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
5314
5928
|
await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
|
|
5315
|
-
await this.
|
|
5929
|
+
await this.checkDocumentGrant(guardOf(options), collection2, "publish", parsed);
|
|
5316
5930
|
return { config, parsed };
|
|
5317
5931
|
}
|
|
5318
5932
|
async globalScheduleTarget(slug2, options) {
|
|
@@ -5483,6 +6097,7 @@ var EasyCMS = class {
|
|
|
5483
6097
|
const own = await this.checkGrant(guard, { collection: config.slug }, "read");
|
|
5484
6098
|
const access = await evaluateAccess(config.access?.read, { user: guard.user });
|
|
5485
6099
|
if (access === false) throw deny(guard.user);
|
|
6100
|
+
await this.checkQueryFields(config, guard, where);
|
|
5486
6101
|
return andWhere(andWhere(where, access), own);
|
|
5487
6102
|
}
|
|
5488
6103
|
async checkDocumentAccess(config, operation, guard, id, data) {
|
|
@@ -5512,17 +6127,52 @@ var EasyCMS = class {
|
|
|
5512
6127
|
throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
|
|
5513
6128
|
if (!access) throw deny(guard.user);
|
|
5514
6129
|
}
|
|
5515
|
-
fieldChecker(kind, guard, id, data) {
|
|
6130
|
+
async fieldChecker(kind, guard, id, data) {
|
|
5516
6131
|
if (!guard.enforce) return void 0;
|
|
5517
|
-
return new FieldAccessChecker(
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
6132
|
+
return new FieldAccessChecker(
|
|
6133
|
+
kind,
|
|
6134
|
+
{
|
|
6135
|
+
user: guard.user,
|
|
6136
|
+
...id !== void 0 ? { id } : {},
|
|
6137
|
+
...data ? { data } : {}
|
|
6138
|
+
},
|
|
6139
|
+
await this.roles.fieldRules(guard.user)
|
|
6140
|
+
);
|
|
6141
|
+
}
|
|
6142
|
+
/**
|
|
6143
|
+
* Refuses filtering or sorting by fields the caller may not read: the results would tell
|
|
6144
|
+
* their values. Checks each field along a dotted path, through groups, arrays and blocks.
|
|
6145
|
+
*/
|
|
6146
|
+
async checkQueryFields(config, guard, where, sort = []) {
|
|
6147
|
+
if (!guard.enforce) return;
|
|
6148
|
+
const paths = [...wherePaths(where), ...sort.map((s) => s.replace(/^-/, ""))];
|
|
6149
|
+
if (paths.length === 0) return;
|
|
6150
|
+
const read2 = await this.fieldChecker("read", guard, void 0, void 0);
|
|
6151
|
+
for (const path of paths) {
|
|
6152
|
+
let fields = config.fields;
|
|
6153
|
+
for (const name of path.split(".")) {
|
|
6154
|
+
if (!fields) break;
|
|
6155
|
+
const field = fields.find((f) => f.name === name);
|
|
6156
|
+
if (!field) break;
|
|
6157
|
+
if (field.hidden || read2 && !await read2.allows(field))
|
|
6158
|
+
throw new ForbiddenError(`You may not filter or sort by "${path}"`);
|
|
6159
|
+
fields = field.type === "group" || field.type === "array" ? field.fields : field.type === "blocks" ? field.blocks.flatMap((b) => b.fields) : void 0;
|
|
6160
|
+
}
|
|
6161
|
+
}
|
|
6162
|
+
}
|
|
6163
|
+
/** `checkGrant` for one document: a role limited to its own documents must own it. */
|
|
6164
|
+
async checkDocumentGrant(guard, collection2, operation, id) {
|
|
6165
|
+
const own = await this.checkGrant(guard, { collection: collection2 }, operation);
|
|
6166
|
+
if (own === true) return;
|
|
6167
|
+
const matches2 = await this.db.count({
|
|
6168
|
+
collection: collection2,
|
|
6169
|
+
where: andWhere(own, { id: { equals: id } })
|
|
5521
6170
|
});
|
|
6171
|
+
if (matches2 === 0) throw deny(guard.user);
|
|
5522
6172
|
}
|
|
5523
6173
|
/** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
|
|
5524
6174
|
async output(config, docs, guard, options) {
|
|
5525
|
-
const read2 = this.fieldChecker("read", guard, void 0, void 0);
|
|
6175
|
+
const read2 = await this.fieldChecker("read", guard, void 0, void 0);
|
|
5526
6176
|
const finish = async (target, doc) => {
|
|
5527
6177
|
const hooked = await this.transform(
|
|
5528
6178
|
target.hooks?.afterRead,
|
|
@@ -5572,8 +6222,9 @@ var EasyCMS = class {
|
|
|
5572
6222
|
const filtered = await filterInput(
|
|
5573
6223
|
config.fields,
|
|
5574
6224
|
input,
|
|
5575
|
-
this.fieldChecker("update", guard, void 0, input)
|
|
6225
|
+
await this.fieldChecker("update", guard, void 0, input)
|
|
5576
6226
|
);
|
|
6227
|
+
this.roles.fillOwner(collection2, filtered, guard.user ?? hookGuard?.user ?? null);
|
|
5577
6228
|
const base = this.hookArgs(config, hookGuard ?? guard);
|
|
5578
6229
|
let data = applyDefaults(
|
|
5579
6230
|
config.fields,
|
|
@@ -5944,6 +6595,16 @@ function loadSharp() {
|
|
|
5944
6595
|
);
|
|
5945
6596
|
return sharpModule;
|
|
5946
6597
|
}
|
|
6598
|
+
function wherePaths(where) {
|
|
6599
|
+
if (!where) return [];
|
|
6600
|
+
const out = [];
|
|
6601
|
+
for (const [key2, value] of Object.entries(where)) {
|
|
6602
|
+
if (key2 === "and" || key2 === "or") {
|
|
6603
|
+
for (const sub of value ?? []) out.push(...wherePaths(sub));
|
|
6604
|
+
} else out.push(key2);
|
|
6605
|
+
}
|
|
6606
|
+
return out;
|
|
6607
|
+
}
|
|
5947
6608
|
function guardOf(options) {
|
|
5948
6609
|
return { enforce: options.overrideAccess === false, user: options.user ?? null };
|
|
5949
6610
|
}
|
|
@@ -6215,10 +6876,11 @@ function componentRef(component) {
|
|
|
6215
6876
|
async function allowed(access, user) {
|
|
6216
6877
|
return await evaluateAccess(access, { user }) !== false;
|
|
6217
6878
|
}
|
|
6218
|
-
async function serializeFields(fields,
|
|
6879
|
+
async function serializeFields(fields, checks, localized) {
|
|
6880
|
+
const { update: update2 } = checks;
|
|
6219
6881
|
const out = [];
|
|
6220
6882
|
for (const field of fields) {
|
|
6221
|
-
if (field.hidden) continue;
|
|
6883
|
+
if (field.hidden || !await checks.read.allows(field)) continue;
|
|
6222
6884
|
const f = { name: field.name, type: field.type };
|
|
6223
6885
|
if (field.label !== void 0) f.label = field.label;
|
|
6224
6886
|
if (field.required) f.required = true;
|
|
@@ -6271,7 +6933,7 @@ async function serializeFields(fields, update2, localized) {
|
|
|
6271
6933
|
case "array":
|
|
6272
6934
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
6273
6935
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
6274
|
-
f.fields = await serializeFields(field.fields,
|
|
6936
|
+
f.fields = await serializeFields(field.fields, checks, localized);
|
|
6275
6937
|
break;
|
|
6276
6938
|
case "blocks":
|
|
6277
6939
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -6280,28 +6942,31 @@ async function serializeFields(fields, update2, localized) {
|
|
|
6280
6942
|
field.blocks.map(async (block) => ({
|
|
6281
6943
|
slug: block.slug,
|
|
6282
6944
|
...block.labels ? { labels: block.labels } : {},
|
|
6283
|
-
fields: await serializeFields(block.fields,
|
|
6945
|
+
fields: await serializeFields(block.fields, checks, localized)
|
|
6284
6946
|
}))
|
|
6285
6947
|
);
|
|
6286
6948
|
break;
|
|
6287
6949
|
case "group":
|
|
6288
|
-
f.fields = await serializeFields(field.fields,
|
|
6950
|
+
f.fields = await serializeFields(field.fields, checks, localized);
|
|
6289
6951
|
break;
|
|
6290
6952
|
}
|
|
6291
6953
|
out.push(f);
|
|
6292
6954
|
}
|
|
6293
6955
|
return out;
|
|
6294
6956
|
}
|
|
6957
|
+
async function checkers(cms, user) {
|
|
6958
|
+
const rules = await cms.roles.fieldRules(user);
|
|
6959
|
+
return {
|
|
6960
|
+
update: new FieldAccessChecker("update", { user }, rules),
|
|
6961
|
+
read: new FieldAccessChecker("read", { user }, rules)
|
|
6962
|
+
};
|
|
6963
|
+
}
|
|
6295
6964
|
async function may(cms, access, user, target, operation) {
|
|
6296
6965
|
return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
|
|
6297
6966
|
}
|
|
6298
6967
|
async function collection(cms, config, user, localized, roles) {
|
|
6299
6968
|
const target = { collection: config.slug };
|
|
6300
|
-
const fields = await serializeFields(
|
|
6301
|
-
config.fields,
|
|
6302
|
-
new FieldAccessChecker("update", { user }),
|
|
6303
|
-
localized
|
|
6304
|
-
);
|
|
6969
|
+
const fields = await serializeFields(config.fields, await checkers(cms, user), localized);
|
|
6305
6970
|
if (config.slug === USERS && cms.roles.enabled) {
|
|
6306
6971
|
const role = fields.find((f) => f.name === "role");
|
|
6307
6972
|
if (role) {
|
|
@@ -6331,23 +6996,22 @@ async function collection(cms, config, user, localized, roles) {
|
|
|
6331
6996
|
if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
|
|
6332
6997
|
if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
|
|
6333
6998
|
if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
|
|
6999
|
+
const owner = cms.roles.ownerOf(config.slug);
|
|
7000
|
+
if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
|
|
6334
7001
|
if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
|
|
6335
7002
|
result.editIn = "drawer";
|
|
6336
7003
|
return result;
|
|
6337
7004
|
}
|
|
6338
7005
|
async function global(cms, config, user, localized) {
|
|
6339
7006
|
const target = { global: config.slug };
|
|
7007
|
+
const checks = await checkers(cms, user);
|
|
6340
7008
|
const result = {
|
|
6341
7009
|
slug: config.slug,
|
|
6342
7010
|
drafts: config.drafts === true,
|
|
6343
7011
|
versions: Boolean(config.versions),
|
|
6344
7012
|
preview: typeof config.preview === "function",
|
|
6345
7013
|
schedule: config.schedule === true,
|
|
6346
|
-
fields: await serializeFields(
|
|
6347
|
-
config.fields,
|
|
6348
|
-
new FieldAccessChecker("update", { user }),
|
|
6349
|
-
localized
|
|
6350
|
-
),
|
|
7014
|
+
fields: await serializeFields(config.fields, checks, localized),
|
|
6351
7015
|
permissions: {
|
|
6352
7016
|
read: await may(cms, config.access?.read, user, target, "read"),
|
|
6353
7017
|
update: await may(cms, config.access?.update, user, target, "update"),
|
|
@@ -6372,7 +7036,8 @@ async function adminSchema(cms, user, origin) {
|
|
|
6372
7036
|
deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
|
|
6373
7037
|
backups: admin,
|
|
6374
7038
|
email: admin,
|
|
6375
|
-
roles: admin && cms.roles.enabled
|
|
7039
|
+
roles: admin && cms.roles.enabled,
|
|
7040
|
+
sso: admin && cms.auth.sso.enabled
|
|
6376
7041
|
};
|
|
6377
7042
|
const roles = await cms.roles.options();
|
|
6378
7043
|
return {
|
|
@@ -6387,6 +7052,8 @@ async function adminSchema(cms, user, origin) {
|
|
|
6387
7052
|
modules: adminModuleUrls(cms),
|
|
6388
7053
|
uploadFromURL: cms.config.upload.fromURL !== void 0,
|
|
6389
7054
|
views,
|
|
7055
|
+
rbac: cms.roles.enabled,
|
|
7056
|
+
providers: cms.auth.sso.providers(),
|
|
6390
7057
|
...views.deliveries ? {
|
|
6391
7058
|
deliveries: {
|
|
6392
7059
|
webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
|
|
@@ -6432,7 +7099,7 @@ async function widgets(cms, user) {
|
|
|
6432
7099
|
}
|
|
6433
7100
|
|
|
6434
7101
|
// src/version.ts
|
|
6435
|
-
var VERSION = true ? "0.
|
|
7102
|
+
var VERSION = true ? "0.35.0" : "0.0.0-dev";
|
|
6436
7103
|
|
|
6437
7104
|
// src/rest/admin-status.ts
|
|
6438
7105
|
var DAY = 864e5;
|
|
@@ -6782,6 +7449,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
6782
7449
|
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
6783
7450
|
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
6784
7451
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
7452
|
+
if (first === "auth") return ssoRoute(cms, ctx, method, segments);
|
|
6785
7453
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
6786
7454
|
switch (`${method} ${second}`) {
|
|
6787
7455
|
case "POST login": {
|
|
@@ -6812,7 +7480,10 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
6812
7480
|
body: {
|
|
6813
7481
|
hasUsers: await cms.auth.hasUsers(),
|
|
6814
7482
|
// The login page offers "Forgot password?" only when the email can be sent.
|
|
6815
|
-
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
|
|
7483
|
+
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
|
|
7484
|
+
// Buttons to sign in with outside accounts; `password: false`: only admins use one.
|
|
7485
|
+
providers: cms.auth.sso.providers(),
|
|
7486
|
+
password: cms.config.auth.password
|
|
6816
7487
|
}
|
|
6817
7488
|
};
|
|
6818
7489
|
// Forgot password: the same answer whether or not the email has an account.
|
|
@@ -6923,6 +7594,20 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
6923
7594
|
}
|
|
6924
7595
|
throw new HttpError("Not found", 404);
|
|
6925
7596
|
}
|
|
7597
|
+
if (first === "admin" && second === "sso" && third === void 0) {
|
|
7598
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
7599
|
+
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
7600
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7601
|
+
return { body: cms.auth.sso.settings(ctx.url.origin) };
|
|
7602
|
+
}
|
|
7603
|
+
if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
|
|
7604
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
7605
|
+
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
7606
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7607
|
+
const parsed = parseId(third);
|
|
7608
|
+
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
7609
|
+
return { body: await cms.roles.owned(parsed) };
|
|
7610
|
+
}
|
|
6926
7611
|
if (first === "admin" && second === "roles") {
|
|
6927
7612
|
if (!ctx.user) throw new UnauthorizedError();
|
|
6928
7613
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
@@ -7127,7 +7812,9 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7127
7812
|
return { body: doc };
|
|
7128
7813
|
}
|
|
7129
7814
|
if (method === "DELETE") {
|
|
7130
|
-
|
|
7815
|
+
const to = collection2 === USERS ? ctx.url.searchParams.get("transferTo") : null;
|
|
7816
|
+
const transferTo = to === null ? {} : { transferTo: to === "none" ? null : parseId(to) ?? to };
|
|
7817
|
+
return { body: await cms.delete(collection2, id, { ...access, ...transferTo }) };
|
|
7131
7818
|
}
|
|
7132
7819
|
throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
|
|
7133
7820
|
}
|
|
@@ -7386,6 +8073,67 @@ function cookie(ctx, name, value, opts) {
|
|
|
7386
8073
|
if (opts.maxAge !== void 0) parts.push(`Max-Age=${opts.maxAge}`);
|
|
7387
8074
|
return parts.join("; ");
|
|
7388
8075
|
}
|
|
8076
|
+
async function ssoRoute(cms, ctx, method, segments) {
|
|
8077
|
+
const sso = cms.auth.sso;
|
|
8078
|
+
if (!sso.enabled) throw new HttpError("Not found", 404);
|
|
8079
|
+
const [, second, third, extra] = segments;
|
|
8080
|
+
if (extra !== void 0 || second === void 0) throw new HttpError("Not found", 404);
|
|
8081
|
+
if (second === "identities") {
|
|
8082
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
8083
|
+
if (third === void 0) {
|
|
8084
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8085
|
+
const asked = ctx.url.searchParams.get("user");
|
|
8086
|
+
const target = asked === null ? ctx.user.id : parseId(asked) ?? asked;
|
|
8087
|
+
if (String(target) !== String(ctx.user.id) && (ctx.user.role !== "admin" || ctx.user.apiKey))
|
|
8088
|
+
throw new ForbiddenError();
|
|
8089
|
+
return { body: await sso.identities(target) };
|
|
8090
|
+
}
|
|
8091
|
+
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
8092
|
+
const id = parseId(third);
|
|
8093
|
+
if (id === void 0) throw new HttpError("Not found", 404);
|
|
8094
|
+
await sso.unlink(ctx.user, id);
|
|
8095
|
+
return { body: { deleted: 1 } };
|
|
8096
|
+
}
|
|
8097
|
+
const pendingCookie = (value, maxAge) => cookie(ctx, SSO_COOKIE, value, { httpOnly: true, maxAge });
|
|
8098
|
+
if (third === "login") {
|
|
8099
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8100
|
+
const started = await sso.start(second, {
|
|
8101
|
+
origin: ctx.url.origin,
|
|
8102
|
+
redirect: ctx.url.searchParams.get("redirect")
|
|
8103
|
+
});
|
|
8104
|
+
ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
|
|
8105
|
+
return { body: redirectResponse(ctx, started.url) };
|
|
8106
|
+
}
|
|
8107
|
+
if (third === "link") {
|
|
8108
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
8109
|
+
if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
|
|
8110
|
+
const started = await sso.start(second, {
|
|
8111
|
+
origin: ctx.url.origin,
|
|
8112
|
+
redirect: null,
|
|
8113
|
+
user: ctx.user
|
|
8114
|
+
});
|
|
8115
|
+
ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
|
|
8116
|
+
return { body: { url: started.url } };
|
|
8117
|
+
}
|
|
8118
|
+
if (third === "callback") {
|
|
8119
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8120
|
+
const finished = await sso.finish(second, {
|
|
8121
|
+
url: ctx.url,
|
|
8122
|
+
cookie: readCookie(ctx.request, SSO_COOKIE),
|
|
8123
|
+
origin: ctx.url.origin,
|
|
8124
|
+
user: ctx.user
|
|
8125
|
+
});
|
|
8126
|
+
ctx.headers.append("set-cookie", pendingCookie("", 0));
|
|
8127
|
+
if (finished.session) setSessionCookies(cms, ctx, finished.session);
|
|
8128
|
+
return { body: redirectResponse(ctx, finished.redirect) };
|
|
8129
|
+
}
|
|
8130
|
+
throw new HttpError("Not found", 404);
|
|
8131
|
+
}
|
|
8132
|
+
function redirectResponse(ctx, location) {
|
|
8133
|
+
const headers = new Headers({ location, "cache-control": "no-store" });
|
|
8134
|
+
for (const value of ctx.headers.getSetCookie()) headers.append("set-cookie", value);
|
|
8135
|
+
return new Response(null, { status: 302, headers });
|
|
8136
|
+
}
|
|
7389
8137
|
function setSessionCookies(cms, ctx, session) {
|
|
7390
8138
|
const maxAge = cms.config.auth.tokenExpiration;
|
|
7391
8139
|
ctx.headers.append(
|
|
@@ -7615,6 +8363,7 @@ export {
|
|
|
7615
8363
|
ROLE_OPERATIONS,
|
|
7616
8364
|
Roles,
|
|
7617
8365
|
SESSION_COOKIE,
|
|
8366
|
+
SSO_COOKIE,
|
|
7618
8367
|
SchemaError,
|
|
7619
8368
|
TooManyRequestsError,
|
|
7620
8369
|
UnauthorizedError,
|