@easy-cms/core 0.1.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/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/index.d.ts +1049 -0
- package/dist/index.js +2710 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2710 @@
|
|
|
1
|
+
// src/access.ts
|
|
2
|
+
var anyone = () => true;
|
|
3
|
+
var isLoggedIn = ({ user }) => user !== null;
|
|
4
|
+
var isAdmin = ({ user }) => user?.role === "admin";
|
|
5
|
+
|
|
6
|
+
// src/errors.ts
|
|
7
|
+
var ConfigError = class _ConfigError extends Error {
|
|
8
|
+
issues;
|
|
9
|
+
constructor(issues) {
|
|
10
|
+
super(_ConfigError.format(issues));
|
|
11
|
+
this.name = "ConfigError";
|
|
12
|
+
this.issues = issues;
|
|
13
|
+
}
|
|
14
|
+
static format(issues) {
|
|
15
|
+
const lines = issues.map((issue) => {
|
|
16
|
+
const hint = issue.hint ? `
|
|
17
|
+
\u2192 ${issue.hint}` : "";
|
|
18
|
+
return ` \u2022 ${issue.path}: ${issue.message}${hint}`;
|
|
19
|
+
});
|
|
20
|
+
const noun = issues.length === 1 ? "problem" : "problems";
|
|
21
|
+
return `Invalid Easy CMS config (${issues.length} ${noun}):
|
|
22
|
+
${lines.join("\n")}`;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var EasyCMSError = class extends Error {
|
|
26
|
+
status;
|
|
27
|
+
constructor(message, status) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "EasyCMSError";
|
|
30
|
+
this.status = status;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var ValidationError = class extends EasyCMSError {
|
|
34
|
+
errors;
|
|
35
|
+
constructor(collection2, errors) {
|
|
36
|
+
const list = errors.map((e) => `${e.field}: ${e.message}`).join("; ");
|
|
37
|
+
super(`Invalid data for "${collection2}": ${list}`, 400);
|
|
38
|
+
this.name = "ValidationError";
|
|
39
|
+
this.errors = errors;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var NotFoundError = class extends EasyCMSError {
|
|
43
|
+
constructor(collection2, id) {
|
|
44
|
+
super(`No document with id ${JSON.stringify(id)} in "${collection2}"`, 404);
|
|
45
|
+
this.name = "NotFoundError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var QueryError = class extends EasyCMSError {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message, 400);
|
|
51
|
+
this.name = "QueryError";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var SchemaError = class extends EasyCMSError {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message, 500);
|
|
57
|
+
this.name = "SchemaError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var UnauthorizedError = class extends EasyCMSError {
|
|
61
|
+
constructor(message = "You must be logged in") {
|
|
62
|
+
super(message, 401);
|
|
63
|
+
this.name = "UnauthorizedError";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var ForbiddenError = class extends EasyCMSError {
|
|
67
|
+
constructor(message = "You are not allowed to do this") {
|
|
68
|
+
super(message, 403);
|
|
69
|
+
this.name = "ForbiddenError";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var TooManyRequestsError = class extends EasyCMSError {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message, 429);
|
|
75
|
+
this.name = "TooManyRequestsError";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var PayloadTooLargeError = class extends EasyCMSError {
|
|
79
|
+
constructor(message) {
|
|
80
|
+
super(message, 413);
|
|
81
|
+
this.name = "PayloadTooLargeError";
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/access-control.ts
|
|
86
|
+
async function evaluateAccess(access, args) {
|
|
87
|
+
const result = await (access ?? isLoggedIn)(args);
|
|
88
|
+
if (typeof result === "boolean") return result;
|
|
89
|
+
if (typeof result === "object" && result !== null) return result;
|
|
90
|
+
throw new QueryError("Access functions must return a boolean or a where query");
|
|
91
|
+
}
|
|
92
|
+
function andWhere(where, constraint) {
|
|
93
|
+
if (constraint === true) return where;
|
|
94
|
+
return where === void 0 ? constraint : { and: [where, constraint] };
|
|
95
|
+
}
|
|
96
|
+
var FieldAccessChecker = class {
|
|
97
|
+
constructor(kind, args) {
|
|
98
|
+
this.kind = kind;
|
|
99
|
+
this.args = args;
|
|
100
|
+
}
|
|
101
|
+
kind;
|
|
102
|
+
args;
|
|
103
|
+
cache = /* @__PURE__ */ new Map();
|
|
104
|
+
async allows(field) {
|
|
105
|
+
const fn = field.access?.[this.kind];
|
|
106
|
+
if (!fn) return true;
|
|
107
|
+
let allowed2 = this.cache.get(field);
|
|
108
|
+
if (allowed2 === void 0) {
|
|
109
|
+
allowed2 = await fn(this.args) === true;
|
|
110
|
+
this.cache.set(field, allowed2);
|
|
111
|
+
}
|
|
112
|
+
return allowed2;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
async function stripFields(fields, doc, read) {
|
|
116
|
+
const result = { ...doc };
|
|
117
|
+
for (const field of fields) {
|
|
118
|
+
if (!(field.name in result)) continue;
|
|
119
|
+
if (field.hidden || read && !await read.allows(field)) {
|
|
120
|
+
delete result[field.name];
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const value = result[field.name];
|
|
124
|
+
if (field.type === "group" && value && typeof value === "object") {
|
|
125
|
+
result[field.name] = await stripFields(field.fields, value, read);
|
|
126
|
+
} else if (field.type === "array" && Array.isArray(value)) {
|
|
127
|
+
result[field.name] = await Promise.all(
|
|
128
|
+
value.map(
|
|
129
|
+
(row) => row && typeof row === "object" ? stripFields(field.fields, row, read) : row
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
async function filterInput(fields, data, update) {
|
|
137
|
+
const result = { ...data };
|
|
138
|
+
for (const field of fields) {
|
|
139
|
+
if (!(field.name in result)) continue;
|
|
140
|
+
if (field.hidden || update && !await update.allows(field)) {
|
|
141
|
+
delete result[field.name];
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const value = result[field.name];
|
|
145
|
+
if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
146
|
+
result[field.name] = await filterInput(field.fields, value, update);
|
|
147
|
+
} else if (field.type === "array" && Array.isArray(value)) {
|
|
148
|
+
result[field.name] = await Promise.all(
|
|
149
|
+
value.map(
|
|
150
|
+
(row) => row && typeof row === "object" ? filterInput(field.fields, row, update) : row
|
|
151
|
+
)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/builtins.ts
|
|
159
|
+
var USERS = "users";
|
|
160
|
+
var MEDIA = "media";
|
|
161
|
+
var SESSIONS = "sessions";
|
|
162
|
+
var LOGIN_ATTEMPTS = "login-attempts";
|
|
163
|
+
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([SESSIONS, LOGIN_ATTEMPTS]);
|
|
164
|
+
var DEFAULT_ROLES = ["admin", "editor"];
|
|
165
|
+
var adminOrSelf = ({ user }) => {
|
|
166
|
+
if (!user) return false;
|
|
167
|
+
if (user.role === "admin") return true;
|
|
168
|
+
return { id: { equals: user.id } };
|
|
169
|
+
};
|
|
170
|
+
var adminOnly = {
|
|
171
|
+
update: ({ user }) => user?.role === "admin"
|
|
172
|
+
};
|
|
173
|
+
function userFields(roles) {
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
name: "email",
|
|
177
|
+
type: "email",
|
|
178
|
+
required: true,
|
|
179
|
+
unique: true,
|
|
180
|
+
label: { en: "Email", th: "\u0E2D\u0E35\u0E40\u0E21\u0E25" }
|
|
181
|
+
},
|
|
182
|
+
{ name: "name", type: "text", label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
|
|
183
|
+
{
|
|
184
|
+
name: "role",
|
|
185
|
+
type: "select",
|
|
186
|
+
options: roles,
|
|
187
|
+
required: true,
|
|
188
|
+
defaultValue: roles.includes("editor") ? "editor" : roles[roles.length - 1],
|
|
189
|
+
access: adminOnly,
|
|
190
|
+
label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "active",
|
|
194
|
+
type: "boolean",
|
|
195
|
+
defaultValue: true,
|
|
196
|
+
access: adminOnly,
|
|
197
|
+
label: { en: "Active", th: "\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19" }
|
|
198
|
+
},
|
|
199
|
+
{ name: "passwordHash", type: "text", hidden: true }
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
function withUsers(config) {
|
|
203
|
+
const roles = config.auth?.roles ?? DEFAULT_ROLES;
|
|
204
|
+
const custom = config.collections?.find((c) => c.slug === USERS);
|
|
205
|
+
const users = {
|
|
206
|
+
slug: USERS,
|
|
207
|
+
labels: { singular: { en: "User", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49" }, plural: { en: "Users", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49" } },
|
|
208
|
+
useAsTitle: "email",
|
|
209
|
+
...custom,
|
|
210
|
+
fields: [...userFields(roles), ...custom?.fields ?? []],
|
|
211
|
+
access: {
|
|
212
|
+
read: isLoggedIn,
|
|
213
|
+
create: isAdmin,
|
|
214
|
+
update: adminOrSelf,
|
|
215
|
+
delete: isAdmin,
|
|
216
|
+
...custom?.access
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const others = (config.collections ?? []).filter((c) => c.slug !== USERS);
|
|
220
|
+
return { ...config, collections: [users, ...others] };
|
|
221
|
+
}
|
|
222
|
+
var systemField = { update: () => false };
|
|
223
|
+
function mediaFields() {
|
|
224
|
+
return [
|
|
225
|
+
{
|
|
226
|
+
name: "filename",
|
|
227
|
+
type: "text",
|
|
228
|
+
required: true,
|
|
229
|
+
unique: true,
|
|
230
|
+
access: systemField,
|
|
231
|
+
label: { en: "File name", th: "\u0E0A\u0E37\u0E48\u0E2D\u0E44\u0E1F\u0E25\u0E4C" }
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "originalName",
|
|
235
|
+
type: "text",
|
|
236
|
+
access: systemField,
|
|
237
|
+
label: { en: "Original name", th: "\u0E0A\u0E37\u0E48\u0E2D\u0E40\u0E14\u0E34\u0E21" }
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "mimeType",
|
|
241
|
+
type: "text",
|
|
242
|
+
required: true,
|
|
243
|
+
access: systemField,
|
|
244
|
+
label: { en: "Type", th: "\u0E0A\u0E19\u0E34\u0E14" }
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "filesize",
|
|
248
|
+
type: "number",
|
|
249
|
+
required: true,
|
|
250
|
+
access: systemField,
|
|
251
|
+
label: { en: "Size (bytes)", th: "\u0E02\u0E19\u0E32\u0E14 (\u0E44\u0E1A\u0E15\u0E4C)" }
|
|
252
|
+
},
|
|
253
|
+
{ name: "width", type: "number", access: systemField, label: { en: "Width", th: "\u0E01\u0E27\u0E49\u0E32\u0E07" } },
|
|
254
|
+
{ name: "height", type: "number", access: systemField, label: { en: "Height", th: "\u0E2A\u0E39\u0E07" } },
|
|
255
|
+
{
|
|
256
|
+
name: "sizes",
|
|
257
|
+
type: "json",
|
|
258
|
+
access: systemField,
|
|
259
|
+
label: { en: "Resized copies", th: "\u0E02\u0E19\u0E32\u0E14\u0E22\u0E48\u0E2D" }
|
|
260
|
+
},
|
|
261
|
+
{ name: "alt", type: "text", label: { en: "Alternative text", th: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E41\u0E17\u0E19\u0E23\u0E39\u0E1B" } }
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
function withMedia(config) {
|
|
265
|
+
const custom = config.collections?.find((c) => c.slug === MEDIA);
|
|
266
|
+
const media = {
|
|
267
|
+
slug: MEDIA,
|
|
268
|
+
labels: { singular: { en: "Media", th: "\u0E2A\u0E37\u0E48\u0E2D" }, plural: { en: "Media", th: "\u0E04\u0E25\u0E31\u0E07\u0E2A\u0E37\u0E48\u0E2D" } },
|
|
269
|
+
useAsTitle: "filename",
|
|
270
|
+
...custom,
|
|
271
|
+
fields: [...mediaFields(), ...custom?.fields ?? []],
|
|
272
|
+
access: {
|
|
273
|
+
read: anyone,
|
|
274
|
+
create: isLoggedIn,
|
|
275
|
+
update: isLoggedIn,
|
|
276
|
+
delete: isLoggedIn,
|
|
277
|
+
...custom?.access
|
|
278
|
+
},
|
|
279
|
+
hooks: {
|
|
280
|
+
...custom?.hooks,
|
|
281
|
+
afterRead: [
|
|
282
|
+
({ doc, cms }) => {
|
|
283
|
+
const sizes = doc.sizes ?? {};
|
|
284
|
+
return {
|
|
285
|
+
...doc,
|
|
286
|
+
url: typeof doc.filename === "string" ? cms.mediaURL(doc.filename) : null,
|
|
287
|
+
sizes: Object.fromEntries(
|
|
288
|
+
Object.entries(sizes).map(([name, size]) => [
|
|
289
|
+
name,
|
|
290
|
+
{ ...size, url: cms.mediaURL(size.filename) }
|
|
291
|
+
])
|
|
292
|
+
)
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
...custom?.hooks?.afterRead ?? []
|
|
296
|
+
],
|
|
297
|
+
afterDelete: [
|
|
298
|
+
async ({ doc, cms }) => {
|
|
299
|
+
const sizes = Object.values(doc.sizes ?? {});
|
|
300
|
+
for (const key2 of [doc.filename, ...sizes.map((s) => s.filename)]) {
|
|
301
|
+
if (typeof key2 === "string") await cms.storage.delete(key2);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
...custom?.hooks?.afterDelete ?? []
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const others = (config.collections ?? []).filter((c) => c.slug !== MEDIA);
|
|
309
|
+
return { ...config, collections: [...others.slice(0, 1), media, ...others.slice(1)] };
|
|
310
|
+
}
|
|
311
|
+
var nobody = () => false;
|
|
312
|
+
var internalCollections = [
|
|
313
|
+
{
|
|
314
|
+
slug: SESSIONS,
|
|
315
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
316
|
+
fields: [
|
|
317
|
+
{ name: "tokenHash", type: "text", required: true, unique: true },
|
|
318
|
+
{ name: "user", type: "relationship", to: USERS, required: true },
|
|
319
|
+
{ name: "expiresAt", type: "date", required: true, index: true }
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
slug: LOGIN_ATTEMPTS,
|
|
324
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
325
|
+
fields: [{ name: "key", type: "text", required: true, index: true }]
|
|
326
|
+
}
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
// src/auth/password.ts
|
|
330
|
+
import { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
331
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
332
|
+
var N = 2 ** 17;
|
|
333
|
+
var R = 8;
|
|
334
|
+
var P = 1;
|
|
335
|
+
var KEY_LENGTH = 64;
|
|
336
|
+
var MAX_MEM = 256 * 1024 * 1024;
|
|
337
|
+
function derive(password, salt, n, r, p, length) {
|
|
338
|
+
return new Promise((resolve3, reject) => {
|
|
339
|
+
scrypt(
|
|
340
|
+
password.normalize("NFKC"),
|
|
341
|
+
salt,
|
|
342
|
+
length,
|
|
343
|
+
{ N: n, r, p, maxmem: MAX_MEM },
|
|
344
|
+
(error, key2) => error ? reject(error) : resolve3(key2)
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
async function hashPassword(password) {
|
|
349
|
+
const salt = randomBytes(16);
|
|
350
|
+
const key2 = await derive(password, salt, N, R, P, KEY_LENGTH);
|
|
351
|
+
return ["scrypt", N, R, P, salt.toString("base64url"), key2.toString("base64url")].join("$");
|
|
352
|
+
}
|
|
353
|
+
async function verifyPassword(password, stored) {
|
|
354
|
+
const [scheme, n, r, p, saltText, keyText] = stored.split("$");
|
|
355
|
+
if (scheme !== "scrypt" || !saltText || !keyText) return false;
|
|
356
|
+
const expected = Buffer.from(keyText, "base64url");
|
|
357
|
+
const key2 = await derive(
|
|
358
|
+
password,
|
|
359
|
+
Buffer.from(saltText, "base64url"),
|
|
360
|
+
Number(n),
|
|
361
|
+
Number(r),
|
|
362
|
+
Number(p),
|
|
363
|
+
expected.length
|
|
364
|
+
);
|
|
365
|
+
return key2.length === expected.length && timingSafeEqual(key2, expected);
|
|
366
|
+
}
|
|
367
|
+
var dummyHash;
|
|
368
|
+
async function fakeVerify(password) {
|
|
369
|
+
dummyHash ??= hashPassword("easy-cms-dummy-password");
|
|
370
|
+
await verifyPassword(password, await dummyHash);
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/auth/tokens.ts
|
|
375
|
+
import { createHash, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
376
|
+
function newToken() {
|
|
377
|
+
return randomBytes2(32).toString("base64url");
|
|
378
|
+
}
|
|
379
|
+
function hashToken(token) {
|
|
380
|
+
return createHash("sha256").update(token).digest("hex");
|
|
381
|
+
}
|
|
382
|
+
function hmac(secret, value) {
|
|
383
|
+
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
384
|
+
}
|
|
385
|
+
function signToken(secret, token) {
|
|
386
|
+
return `${token}.${hmac(secret, `session:${token}`)}`;
|
|
387
|
+
}
|
|
388
|
+
function unsignToken(secret, value) {
|
|
389
|
+
const dot = value.lastIndexOf(".");
|
|
390
|
+
if (dot <= 0) return void 0;
|
|
391
|
+
const token = value.slice(0, dot);
|
|
392
|
+
return safeEqual(value.slice(dot + 1), hmac(secret, `session:${token}`)) ? token : void 0;
|
|
393
|
+
}
|
|
394
|
+
function csrfToken(secret, token) {
|
|
395
|
+
return hmac(secret, `csrf:${token}`);
|
|
396
|
+
}
|
|
397
|
+
function safeEqual(a, b) {
|
|
398
|
+
const left = Buffer.from(a);
|
|
399
|
+
const right = Buffer.from(b);
|
|
400
|
+
return left.length === right.length && timingSafeEqual2(left, right);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/auth/auth.ts
|
|
404
|
+
var INVALID = "Invalid email or password";
|
|
405
|
+
var Auth = class {
|
|
406
|
+
constructor(cms) {
|
|
407
|
+
this.cms = cms;
|
|
408
|
+
}
|
|
409
|
+
cms;
|
|
410
|
+
get db() {
|
|
411
|
+
return this.cms.db;
|
|
412
|
+
}
|
|
413
|
+
get config() {
|
|
414
|
+
return this.cms.config;
|
|
415
|
+
}
|
|
416
|
+
/** Checks credentials and starts a session. Throws `UnauthorizedError` or `TooManyRequestsError`. */
|
|
417
|
+
async login(args) {
|
|
418
|
+
const email = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
|
|
419
|
+
const password = typeof args.password === "string" ? args.password : "";
|
|
420
|
+
const key2 = `${email}|${args.ip ?? ""}`;
|
|
421
|
+
await this.checkRateLimit(key2);
|
|
422
|
+
const [user] = email ? (await this.db.find({
|
|
423
|
+
collection: USERS,
|
|
424
|
+
where: { email: { equals: email } },
|
|
425
|
+
sort: [],
|
|
426
|
+
limit: 1,
|
|
427
|
+
page: 1
|
|
428
|
+
})).docs : [];
|
|
429
|
+
const hash = typeof user?.passwordHash === "string" ? user.passwordHash : void 0;
|
|
430
|
+
const valid = user && hash && password ? await verifyPassword(password, hash) : await fakeVerify(password);
|
|
431
|
+
if (!user || !valid || user.active === false) {
|
|
432
|
+
await this.recordFailure(key2);
|
|
433
|
+
throw new UnauthorizedError(INVALID);
|
|
434
|
+
}
|
|
435
|
+
await this.clearFailures(key2);
|
|
436
|
+
await this.deleteExpiredSessions(user.id);
|
|
437
|
+
return this.startSession(user);
|
|
438
|
+
}
|
|
439
|
+
/** Creates the first admin. Only works while there are no users. */
|
|
440
|
+
async registerFirstUser(args) {
|
|
441
|
+
if (await this.hasUsers()) throw new ForbiddenError("An admin already exists");
|
|
442
|
+
const user = await this.cms.create(USERS, {
|
|
443
|
+
email: args.email,
|
|
444
|
+
password: args.password,
|
|
445
|
+
role: "admin",
|
|
446
|
+
active: true,
|
|
447
|
+
...args.name ? { name: args.name } : {}
|
|
448
|
+
});
|
|
449
|
+
const raw = await this.db.findById({ collection: USERS, id: user.id });
|
|
450
|
+
return this.startSession(raw);
|
|
451
|
+
}
|
|
452
|
+
async hasUsers() {
|
|
453
|
+
return await this.db.count({ collection: USERS }) > 0;
|
|
454
|
+
}
|
|
455
|
+
/** Ends the session for a signed token. Unknown tokens are ignored. */
|
|
456
|
+
async logout(signedToken) {
|
|
457
|
+
const token = unsignToken(this.config.secret, signedToken);
|
|
458
|
+
if (!token) return;
|
|
459
|
+
const session = await this.findSession(token);
|
|
460
|
+
if (session) await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
461
|
+
}
|
|
462
|
+
/** Returns the user for a signed session token, or `null` if it is invalid or expired. */
|
|
463
|
+
async verify(signedToken) {
|
|
464
|
+
if (!signedToken) return null;
|
|
465
|
+
const token = unsignToken(this.config.secret, signedToken);
|
|
466
|
+
if (!token) return null;
|
|
467
|
+
const session = await this.findSession(token);
|
|
468
|
+
if (!session) return null;
|
|
469
|
+
if (String(session.expiresAt) <= (/* @__PURE__ */ new Date()).toISOString()) {
|
|
470
|
+
await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const user = await this.db.findById({ collection: USERS, id: session.user });
|
|
474
|
+
if (!user || user.active === false) return null;
|
|
475
|
+
return this.toAuthUser(user);
|
|
476
|
+
}
|
|
477
|
+
/** The CSRF token clients must echo for a signed session token. */
|
|
478
|
+
csrfFor(signedToken) {
|
|
479
|
+
const token = unsignToken(this.config.secret, signedToken);
|
|
480
|
+
return token ? csrfToken(this.config.secret, token) : void 0;
|
|
481
|
+
}
|
|
482
|
+
/** Starts a new session for a user, e.g. after they changed their own password. */
|
|
483
|
+
async createSession(userId) {
|
|
484
|
+
const user = await this.db.findById({ collection: USERS, id: userId });
|
|
485
|
+
if (!user || user.active === false) throw new UnauthorizedError();
|
|
486
|
+
return this.startSession(user);
|
|
487
|
+
}
|
|
488
|
+
/** Signs a user out everywhere. */
|
|
489
|
+
async revokeSessions(userId) {
|
|
490
|
+
const sessions = await this.db.find({
|
|
491
|
+
collection: SESSIONS,
|
|
492
|
+
where: { user: { equals: userId } },
|
|
493
|
+
sort: [],
|
|
494
|
+
limit: 0,
|
|
495
|
+
page: 1
|
|
496
|
+
});
|
|
497
|
+
for (const session of sessions.docs)
|
|
498
|
+
await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
499
|
+
}
|
|
500
|
+
// -------------------------------------------------------------------------
|
|
501
|
+
async startSession(user) {
|
|
502
|
+
const token = newToken();
|
|
503
|
+
const now = /* @__PURE__ */ new Date();
|
|
504
|
+
const expiresAt = new Date(
|
|
505
|
+
now.getTime() + this.config.auth.tokenExpiration * 1e3
|
|
506
|
+
).toISOString();
|
|
507
|
+
await this.db.create({
|
|
508
|
+
collection: SESSIONS,
|
|
509
|
+
data: {
|
|
510
|
+
tokenHash: hashToken(token),
|
|
511
|
+
user: user.id,
|
|
512
|
+
expiresAt,
|
|
513
|
+
createdAt: now.toISOString(),
|
|
514
|
+
updatedAt: now.toISOString()
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
return {
|
|
518
|
+
user: await this.toAuthUser(user),
|
|
519
|
+
token: signToken(this.config.secret, token),
|
|
520
|
+
csrfToken: csrfToken(this.config.secret, token),
|
|
521
|
+
expiresAt
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
async findSession(token) {
|
|
525
|
+
const result = await this.db.find({
|
|
526
|
+
collection: SESSIONS,
|
|
527
|
+
where: { tokenHash: { equals: hashToken(token) } },
|
|
528
|
+
sort: [],
|
|
529
|
+
limit: 1,
|
|
530
|
+
page: 1
|
|
531
|
+
});
|
|
532
|
+
return result.docs[0];
|
|
533
|
+
}
|
|
534
|
+
async toAuthUser(user) {
|
|
535
|
+
const fields = this.cms.collection(USERS).fields;
|
|
536
|
+
return await stripFields(fields, user);
|
|
537
|
+
}
|
|
538
|
+
async deleteExpiredSessions(userId) {
|
|
539
|
+
const expired = await this.db.find({
|
|
540
|
+
collection: SESSIONS,
|
|
541
|
+
where: { user: { equals: userId }, expiresAt: { lte: (/* @__PURE__ */ new Date()).toISOString() } },
|
|
542
|
+
sort: [],
|
|
543
|
+
limit: 0,
|
|
544
|
+
page: 1
|
|
545
|
+
});
|
|
546
|
+
for (const session of expired.docs)
|
|
547
|
+
await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
548
|
+
}
|
|
549
|
+
windowStart() {
|
|
550
|
+
return new Date(Date.now() - this.config.auth.lockWindow * 1e3).toISOString();
|
|
551
|
+
}
|
|
552
|
+
async checkRateLimit(key2) {
|
|
553
|
+
const recent = await this.db.count({
|
|
554
|
+
collection: LOGIN_ATTEMPTS,
|
|
555
|
+
where: { key: { equals: key2 }, createdAt: { gt: this.windowStart() } }
|
|
556
|
+
});
|
|
557
|
+
if (recent >= this.config.auth.maxLoginAttempts) {
|
|
558
|
+
throw new TooManyRequestsError("Too many failed login attempts. Try again later.");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async recordFailure(key2) {
|
|
562
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
563
|
+
await this.db.create({
|
|
564
|
+
collection: LOGIN_ATTEMPTS,
|
|
565
|
+
data: { key: key2, createdAt: now, updatedAt: now }
|
|
566
|
+
});
|
|
567
|
+
const old = await this.db.find({
|
|
568
|
+
collection: LOGIN_ATTEMPTS,
|
|
569
|
+
where: { key: { equals: key2 }, createdAt: { lte: this.windowStart() } },
|
|
570
|
+
sort: [],
|
|
571
|
+
limit: 0,
|
|
572
|
+
page: 1
|
|
573
|
+
});
|
|
574
|
+
for (const attempt of old.docs)
|
|
575
|
+
await this.db.delete({ collection: LOGIN_ATTEMPTS, id: attempt.id });
|
|
576
|
+
}
|
|
577
|
+
async clearFailures(key2) {
|
|
578
|
+
const attempts = await this.db.find({
|
|
579
|
+
collection: LOGIN_ATTEMPTS,
|
|
580
|
+
where: { key: { equals: key2 } },
|
|
581
|
+
sort: [],
|
|
582
|
+
limit: 0,
|
|
583
|
+
page: 1
|
|
584
|
+
});
|
|
585
|
+
for (const attempt of attempts.docs)
|
|
586
|
+
await this.db.delete({ collection: LOGIN_ATTEMPTS, id: attempt.id });
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
// src/config.ts
|
|
591
|
+
function defineConfig(config) {
|
|
592
|
+
return config;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/document.ts
|
|
596
|
+
import { randomUUID } from "crypto";
|
|
597
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
598
|
+
var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
599
|
+
var isEmpty = (value) => value === void 0 || value === null || value === "";
|
|
600
|
+
function slugify(text) {
|
|
601
|
+
return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
602
|
+
}
|
|
603
|
+
function applyDefaults(fields, data) {
|
|
604
|
+
const result = { ...data };
|
|
605
|
+
for (const field of fields) {
|
|
606
|
+
const value = result[field.name];
|
|
607
|
+
if (field.type === "group") {
|
|
608
|
+
result[field.name] = applyDefaults(field.fields, isPlainObject(value) ? value : {});
|
|
609
|
+
} else if (field.type === "array") {
|
|
610
|
+
if (Array.isArray(value)) {
|
|
611
|
+
result[field.name] = value.map(
|
|
612
|
+
(row) => isPlainObject(row) ? applyDefaults(field.fields, row) : row
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
} else if (value === void 0 && field.defaultValue !== void 0) {
|
|
616
|
+
result[field.name] = structuredClone(field.defaultValue);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return result;
|
|
620
|
+
}
|
|
621
|
+
function fillMissing(fields, data) {
|
|
622
|
+
const result = { ...data };
|
|
623
|
+
for (const field of fields) {
|
|
624
|
+
const value = result[field.name];
|
|
625
|
+
if (field.type === "group") {
|
|
626
|
+
result[field.name] = fillMissing(field.fields, isPlainObject(value) ? value : {});
|
|
627
|
+
} else if (value === void 0) {
|
|
628
|
+
result[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
function generateSlugs(fields, data) {
|
|
634
|
+
const result = { ...data };
|
|
635
|
+
for (const field of fields) {
|
|
636
|
+
if (field.type === "slug") {
|
|
637
|
+
const current = result[field.name];
|
|
638
|
+
if (typeof current === "string" && current !== "") {
|
|
639
|
+
result[field.name] = slugify(current);
|
|
640
|
+
} else if (field.from) {
|
|
641
|
+
const source = result[field.from];
|
|
642
|
+
if (typeof source === "string" && source.trim() !== "") result[field.name] = slugify(source);
|
|
643
|
+
}
|
|
644
|
+
} else if (field.type === "group" && isPlainObject(result[field.name])) {
|
|
645
|
+
result[field.name] = generateSlugs(field.fields, result[field.name]);
|
|
646
|
+
} else if (field.type === "array" && Array.isArray(result[field.name])) {
|
|
647
|
+
result[field.name] = result[field.name].map(
|
|
648
|
+
(row) => isPlainObject(row) ? generateSlugs(field.fields, row) : row
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
function mergeForUpdate(fields, existing, patch) {
|
|
655
|
+
const result = {};
|
|
656
|
+
for (const field of fields) {
|
|
657
|
+
const has = Object.hasOwn(patch, field.name);
|
|
658
|
+
const next = patch[field.name];
|
|
659
|
+
const prev = existing[field.name];
|
|
660
|
+
if (field.type === "group" && has && isPlainObject(next)) {
|
|
661
|
+
result[field.name] = mergeForUpdate(field.fields, isPlainObject(prev) ? prev : {}, next);
|
|
662
|
+
} else {
|
|
663
|
+
result[field.name] = has ? next : prev;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
function parseId(value) {
|
|
669
|
+
if (typeof value === "number") return Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
670
|
+
if (typeof value === "string" && /^[1-9]\d*$/.test(value)) {
|
|
671
|
+
const n = Number(value);
|
|
672
|
+
return Number.isSafeInteger(n) ? n : void 0;
|
|
673
|
+
}
|
|
674
|
+
return void 0;
|
|
675
|
+
}
|
|
676
|
+
async function validateFields(fields, input, options, prefix = "") {
|
|
677
|
+
const data = {};
|
|
678
|
+
const errors = [];
|
|
679
|
+
const references = [];
|
|
680
|
+
for (const field of fields) {
|
|
681
|
+
const path = prefix + field.name;
|
|
682
|
+
const raw = input[field.name];
|
|
683
|
+
const fail = (message) => errors.push({ field: path, message });
|
|
684
|
+
if (field.type === "group") {
|
|
685
|
+
if (!isEmpty(raw) && !isPlainObject(raw)) {
|
|
686
|
+
fail("must be an object");
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const sub = await validateFields(
|
|
690
|
+
field.fields,
|
|
691
|
+
isPlainObject(raw) ? raw : {},
|
|
692
|
+
options,
|
|
693
|
+
`${path}.`
|
|
694
|
+
);
|
|
695
|
+
data[field.name] = sub.data;
|
|
696
|
+
errors.push(...sub.errors);
|
|
697
|
+
references.push(...sub.references);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
|
|
701
|
+
if (field.required && !options.skipRequired) fail("is required");
|
|
702
|
+
data[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
|
|
703
|
+
if (!field.required) await runCustom(field, null, options, fail);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const value = await normalizeValue(field, raw, path, options, fail, errors, references);
|
|
707
|
+
if (value === void 0) continue;
|
|
708
|
+
data[field.name] = value;
|
|
709
|
+
await runCustom(field, value, options, fail);
|
|
710
|
+
}
|
|
711
|
+
return { data, errors, references };
|
|
712
|
+
}
|
|
713
|
+
function isHasMany(field) {
|
|
714
|
+
return (field.type === "select" || field.type === "relationship") && field.hasMany === true;
|
|
715
|
+
}
|
|
716
|
+
async function runCustom(field, value, options, fail) {
|
|
717
|
+
if (!field.validate) return;
|
|
718
|
+
const validate = field.validate;
|
|
719
|
+
const result = await validate(value, { data: options.root, operation: options.operation });
|
|
720
|
+
if (result !== true) fail(typeof result === "string" ? result : "is invalid");
|
|
721
|
+
}
|
|
722
|
+
async function normalizeValue(field, raw, path, options, fail, errors, references) {
|
|
723
|
+
switch (field.type) {
|
|
724
|
+
case "text":
|
|
725
|
+
case "textarea":
|
|
726
|
+
case "slug": {
|
|
727
|
+
if (typeof raw !== "string") return fail("must be a string");
|
|
728
|
+
if (field.type !== "slug") {
|
|
729
|
+
if (field.minLength !== void 0 && raw.length < field.minLength)
|
|
730
|
+
return fail(`must be at least ${field.minLength} characters`);
|
|
731
|
+
if (field.maxLength !== void 0 && raw.length > field.maxLength)
|
|
732
|
+
return fail(`must be at most ${field.maxLength} characters`);
|
|
733
|
+
}
|
|
734
|
+
return raw;
|
|
735
|
+
}
|
|
736
|
+
case "email": {
|
|
737
|
+
if (typeof raw !== "string" || !EMAIL_PATTERN.test(raw))
|
|
738
|
+
return fail("must be a valid email address");
|
|
739
|
+
return raw.trim().toLowerCase();
|
|
740
|
+
}
|
|
741
|
+
case "number": {
|
|
742
|
+
const n = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : raw;
|
|
743
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return fail("must be a number");
|
|
744
|
+
if (field.min !== void 0 && n < field.min) return fail(`must be at least ${field.min}`);
|
|
745
|
+
if (field.max !== void 0 && n > field.max) return fail(`must be at most ${field.max}`);
|
|
746
|
+
return n;
|
|
747
|
+
}
|
|
748
|
+
case "boolean":
|
|
749
|
+
if (typeof raw !== "boolean") return fail("must be true or false");
|
|
750
|
+
return raw;
|
|
751
|
+
case "date": {
|
|
752
|
+
const date = raw instanceof Date ? raw : typeof raw === "string" ? new Date(raw) : void 0;
|
|
753
|
+
if (!date || Number.isNaN(date.getTime())) return fail("must be a valid date");
|
|
754
|
+
return date.toISOString();
|
|
755
|
+
}
|
|
756
|
+
case "select":
|
|
757
|
+
return normalizeSelect(field, raw, fail);
|
|
758
|
+
case "json":
|
|
759
|
+
try {
|
|
760
|
+
return JSON.parse(JSON.stringify(raw));
|
|
761
|
+
} catch {
|
|
762
|
+
return fail("must be JSON-serializable");
|
|
763
|
+
}
|
|
764
|
+
case "richText":
|
|
765
|
+
if (!isPlainObject(raw) || raw.type !== "doc")
|
|
766
|
+
return fail('must be a rich text document ({ type: "doc" })');
|
|
767
|
+
return raw;
|
|
768
|
+
case "upload":
|
|
769
|
+
case "relationship": {
|
|
770
|
+
const target = field.type === "upload" ? "media" : field.to;
|
|
771
|
+
const many = field.type === "relationship" && field.hasMany === true;
|
|
772
|
+
const values = many ? raw : [raw];
|
|
773
|
+
if (!Array.isArray(values)) return fail("must be an array of ids");
|
|
774
|
+
const ids = [];
|
|
775
|
+
for (const item of values) {
|
|
776
|
+
const id = parseId(isPlainObject(item) ? item.id : item);
|
|
777
|
+
if (id === void 0)
|
|
778
|
+
return fail(`must be ${many ? "ids" : "an id"} of "${target}" documents`);
|
|
779
|
+
ids.push(id);
|
|
780
|
+
references.push({ field: path, collection: target, id });
|
|
781
|
+
}
|
|
782
|
+
return many ? ids : ids[0];
|
|
783
|
+
}
|
|
784
|
+
case "array": {
|
|
785
|
+
if (!Array.isArray(raw)) return fail("must be an array");
|
|
786
|
+
if (field.minRows !== void 0 && raw.length < field.minRows)
|
|
787
|
+
return fail(`must have at least ${field.minRows} rows`);
|
|
788
|
+
if (field.maxRows !== void 0 && raw.length > field.maxRows)
|
|
789
|
+
return fail(`must have at most ${field.maxRows} rows`);
|
|
790
|
+
const rows = [];
|
|
791
|
+
for (const [i, row] of raw.entries()) {
|
|
792
|
+
if (!isPlainObject(row)) {
|
|
793
|
+
errors.push({ field: `${path}.${i}`, message: "must be an object" });
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const sub = await validateFields(field.fields, row, options, `${path}.${i}.`);
|
|
797
|
+
errors.push(...sub.errors);
|
|
798
|
+
references.push(...sub.references);
|
|
799
|
+
const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
|
|
800
|
+
rows.push({ id, ...sub.data });
|
|
801
|
+
}
|
|
802
|
+
return rows;
|
|
803
|
+
}
|
|
804
|
+
case "group":
|
|
805
|
+
return raw;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function normalizeSelect(field, raw, fail) {
|
|
809
|
+
const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
|
|
810
|
+
const values = field.hasMany ? raw : [raw];
|
|
811
|
+
if (!Array.isArray(values)) return fail("must be an array");
|
|
812
|
+
for (const v of values) {
|
|
813
|
+
if (typeof v !== "string" || !allowed2.includes(v))
|
|
814
|
+
return fail(`must be one of: ${allowed2.join(", ")}`);
|
|
815
|
+
}
|
|
816
|
+
return field.hasMany ? [...new Set(values)] : values[0];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/fields.ts
|
|
820
|
+
var FIELD_TYPES = [
|
|
821
|
+
"text",
|
|
822
|
+
"textarea",
|
|
823
|
+
"number",
|
|
824
|
+
"boolean",
|
|
825
|
+
"date",
|
|
826
|
+
"email",
|
|
827
|
+
"json",
|
|
828
|
+
"select",
|
|
829
|
+
"slug",
|
|
830
|
+
"richText",
|
|
831
|
+
"upload",
|
|
832
|
+
"relationship",
|
|
833
|
+
"array",
|
|
834
|
+
"group"
|
|
835
|
+
];
|
|
836
|
+
|
|
837
|
+
// src/load-config.ts
|
|
838
|
+
import { existsSync } from "fs";
|
|
839
|
+
import { resolve } from "path";
|
|
840
|
+
import { createJiti } from "jiti";
|
|
841
|
+
|
|
842
|
+
// src/validate-config.ts
|
|
843
|
+
var MIN_SECRET_LENGTH = 32;
|
|
844
|
+
var BUILTIN_COLLECTIONS = ["users", "media"];
|
|
845
|
+
var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
846
|
+
"admin",
|
|
847
|
+
"globals",
|
|
848
|
+
"sessions",
|
|
849
|
+
"login-attempts",
|
|
850
|
+
"migrations",
|
|
851
|
+
"access"
|
|
852
|
+
]);
|
|
853
|
+
var SYSTEM_FIELD_NAMES = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt"]);
|
|
854
|
+
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
855
|
+
var FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
856
|
+
function validateConfig(config) {
|
|
857
|
+
const issues = [];
|
|
858
|
+
const add = (path, message, hint) => issues.push(hint ? { path, message, hint } : { path, message });
|
|
859
|
+
if (typeof config !== "object" || config === null) {
|
|
860
|
+
add("config", "must be an object", "export default defineConfig({ ... })");
|
|
861
|
+
return issues;
|
|
862
|
+
}
|
|
863
|
+
validateSecret(config.secret, add);
|
|
864
|
+
if (!config.db || typeof config.db.name !== "string" || typeof config.db.init !== "function") {
|
|
865
|
+
add("db", "is required", "pass a database adapter, e.g. db: sqlite({ url: 'file:./cms.db' })");
|
|
866
|
+
}
|
|
867
|
+
validateAdmin(config, add);
|
|
868
|
+
validateUpload(config, add);
|
|
869
|
+
validateAuth(config, add);
|
|
870
|
+
const collections = asArray(config.collections, "collections", add);
|
|
871
|
+
const globals = asArray(config.globals, "globals", add);
|
|
872
|
+
const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
|
|
873
|
+
const seenCollections = /* @__PURE__ */ new Set();
|
|
874
|
+
collections.forEach((collection2, i) => {
|
|
875
|
+
const slug = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
|
|
876
|
+
if (slug) collectionSlugs.add(slug);
|
|
877
|
+
});
|
|
878
|
+
const seenGlobals = /* @__PURE__ */ new Set();
|
|
879
|
+
globals.forEach((global2, i) => {
|
|
880
|
+
checkSlug(global2, `globals[${i}]`, seenGlobals, add);
|
|
881
|
+
});
|
|
882
|
+
collections.forEach((collection2, i) => {
|
|
883
|
+
const path = `collections.${collection2.slug ?? `[${i}]`}`;
|
|
884
|
+
validateContainer(collection2, path, collectionSlugs, add);
|
|
885
|
+
validateUseAsTitle(collection2, path, add);
|
|
886
|
+
});
|
|
887
|
+
globals.forEach((global2, i) => {
|
|
888
|
+
validateContainer(global2, `globals.${global2.slug ?? `[${i}]`}`, collectionSlugs, add);
|
|
889
|
+
});
|
|
890
|
+
const plugins = config.plugins;
|
|
891
|
+
if (plugins !== void 0 && (!Array.isArray(plugins) || plugins.some((p) => typeof p !== "function"))) {
|
|
892
|
+
add("plugins", "must be an array of functions", "a plugin is (config) => config");
|
|
893
|
+
}
|
|
894
|
+
return issues;
|
|
895
|
+
}
|
|
896
|
+
function validateSecret(secret, add) {
|
|
897
|
+
const hint = `set EASY_CMS_SECRET to a random string of at least ${MIN_SECRET_LENGTH} characters, e.g. \`openssl rand -hex 32\``;
|
|
898
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
899
|
+
add("secret", "is required", hint);
|
|
900
|
+
} else if (secret.length < MIN_SECRET_LENGTH) {
|
|
901
|
+
add("secret", `must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length})`, hint);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function validateAdmin(config, add) {
|
|
905
|
+
const admin = config.admin;
|
|
906
|
+
if (admin === void 0) return;
|
|
907
|
+
if (admin.path !== void 0 && (typeof admin.path !== "string" || !admin.path.startsWith("/"))) {
|
|
908
|
+
add("admin.path", 'must start with "/"', "e.g. path: '/admin'");
|
|
909
|
+
}
|
|
910
|
+
if (admin.locale !== void 0 && admin.locale !== "en" && admin.locale !== "th") {
|
|
911
|
+
add("admin.locale", `must be "en" or "th" (got ${JSON.stringify(admin.locale)})`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
function validateAuth(config, add) {
|
|
915
|
+
const auth = config.auth;
|
|
916
|
+
if (auth === void 0) return;
|
|
917
|
+
if (auth.roles !== void 0) {
|
|
918
|
+
const roles = auth.roles;
|
|
919
|
+
if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
|
|
920
|
+
add("auth.roles", "must be an array of non-empty strings");
|
|
921
|
+
} else if (!roles.includes("admin")) {
|
|
922
|
+
add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const key2 of ["tokenExpiration", "maxLoginAttempts", "lockWindow"]) {
|
|
926
|
+
const value = auth[key2];
|
|
927
|
+
if (value !== void 0 && !(Number.isInteger(value) && value > 0)) {
|
|
928
|
+
add(`auth.${key2}`, "must be a positive integer");
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (auth.trustedOrigins !== void 0) {
|
|
932
|
+
for (const [i, origin] of auth.trustedOrigins.entries()) {
|
|
933
|
+
if (typeof origin !== "string" || !/^https?:\/\/[^/]+$/.test(origin)) {
|
|
934
|
+
add(
|
|
935
|
+
`auth.trustedOrigins[${i}]`,
|
|
936
|
+
`must be an origin like "https://example.com" (got ${JSON.stringify(origin)})`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
function validateUpload(config, add) {
|
|
943
|
+
const upload = config.upload;
|
|
944
|
+
if (upload === void 0) return;
|
|
945
|
+
if (upload.maxFileSize !== void 0 && !(Number.isInteger(upload.maxFileSize) && upload.maxFileSize > 0)) {
|
|
946
|
+
add("upload.maxFileSize", "must be a positive integer (bytes)");
|
|
947
|
+
}
|
|
948
|
+
if (upload.dir !== void 0 && (typeof upload.dir !== "string" || upload.dir.length === 0)) {
|
|
949
|
+
add("upload.dir", "must be a non-empty string");
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
function asArray(value, path, add) {
|
|
953
|
+
if (value === void 0) return [];
|
|
954
|
+
if (!Array.isArray(value)) {
|
|
955
|
+
add(path, "must be an array");
|
|
956
|
+
return [];
|
|
957
|
+
}
|
|
958
|
+
return value;
|
|
959
|
+
}
|
|
960
|
+
function checkSlug(container, path, seen, add) {
|
|
961
|
+
const slug = container.slug;
|
|
962
|
+
if (typeof slug !== "string" || !SLUG_PATTERN.test(slug)) {
|
|
963
|
+
add(
|
|
964
|
+
`${path}.slug`,
|
|
965
|
+
`must be lowercase letters, digits, "-" or "_", starting with a letter (got ${JSON.stringify(slug)})`
|
|
966
|
+
);
|
|
967
|
+
return void 0;
|
|
968
|
+
}
|
|
969
|
+
if (RESERVED_SLUGS.has(slug)) {
|
|
970
|
+
add(`${path}.slug`, `"${slug}" is reserved by Easy CMS`, "choose another slug");
|
|
971
|
+
return void 0;
|
|
972
|
+
}
|
|
973
|
+
if (seen.has(slug)) {
|
|
974
|
+
add(`${path}.slug`, `duplicate slug "${slug}"`);
|
|
975
|
+
return void 0;
|
|
976
|
+
}
|
|
977
|
+
seen.add(slug);
|
|
978
|
+
return slug;
|
|
979
|
+
}
|
|
980
|
+
function validateContainer(container, path, collectionSlugs, add) {
|
|
981
|
+
if (!Array.isArray(container.fields)) {
|
|
982
|
+
add(`${path}.fields`, "must be an array");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const reserved = new Set(SYSTEM_FIELD_NAMES);
|
|
986
|
+
if (container.drafts) reserved.add("status");
|
|
987
|
+
validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
|
|
988
|
+
}
|
|
989
|
+
function validateFields2(fields, path, reserved, collectionSlugs, add) {
|
|
990
|
+
const names2 = /* @__PURE__ */ new Set();
|
|
991
|
+
const byName = /* @__PURE__ */ new Map();
|
|
992
|
+
fields.forEach((field, i) => {
|
|
993
|
+
const name = field?.name;
|
|
994
|
+
if (typeof name !== "string" || !FIELD_NAME_PATTERN.test(name)) {
|
|
995
|
+
add(`${path}[${i}].name`, `must be a valid identifier (got ${JSON.stringify(name)})`);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (reserved.has(name)) {
|
|
999
|
+
add(`${path}.${name}`, `"${name}" is reserved`, "Easy CMS adds this field automatically");
|
|
1000
|
+
}
|
|
1001
|
+
if (names2.has(name)) add(`${path}.${name}`, `duplicate field name "${name}"`);
|
|
1002
|
+
names2.add(name);
|
|
1003
|
+
byName.set(name, field);
|
|
1004
|
+
});
|
|
1005
|
+
for (const field of fields) {
|
|
1006
|
+
if (typeof field?.name !== "string") continue;
|
|
1007
|
+
const fieldPath = `${path}.${field.name}`;
|
|
1008
|
+
const type = field.type;
|
|
1009
|
+
if (!FIELD_TYPES.includes(type)) {
|
|
1010
|
+
add(
|
|
1011
|
+
`${fieldPath}.type`,
|
|
1012
|
+
`unknown field type ${JSON.stringify(type)}`,
|
|
1013
|
+
`use one of: ${FIELD_TYPES.join(", ")}`
|
|
1014
|
+
);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
validateField(field, fieldPath, byName, collectionSlugs, add);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
function validateField(field, path, siblings, collectionSlugs, add) {
|
|
1021
|
+
switch (field.type) {
|
|
1022
|
+
case "text":
|
|
1023
|
+
case "textarea":
|
|
1024
|
+
checkRange(field.minLength, field.maxLength, "minLength", "maxLength", path, add);
|
|
1025
|
+
break;
|
|
1026
|
+
case "number":
|
|
1027
|
+
checkRange(field.min, field.max, "min", "max", path, add);
|
|
1028
|
+
break;
|
|
1029
|
+
case "select":
|
|
1030
|
+
validateSelect(field.options, field.defaultValue, field.hasMany, path, add);
|
|
1031
|
+
break;
|
|
1032
|
+
case "slug":
|
|
1033
|
+
if (field.from !== void 0) {
|
|
1034
|
+
const source = siblings.get(field.from);
|
|
1035
|
+
if (!source) {
|
|
1036
|
+
add(`${path}.from`, `no sibling field named "${field.from}"`);
|
|
1037
|
+
} else if (source.type !== "text") {
|
|
1038
|
+
add(`${path}.from`, `"${field.from}" must be a text field (got ${source.type})`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
break;
|
|
1042
|
+
case "relationship":
|
|
1043
|
+
if (typeof field.to !== "string" || !collectionSlugs.has(field.to)) {
|
|
1044
|
+
add(
|
|
1045
|
+
`${path}.to`,
|
|
1046
|
+
`unknown collection ${JSON.stringify(field.to)}`,
|
|
1047
|
+
`use one of: ${[...collectionSlugs].join(", ")}`
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
break;
|
|
1051
|
+
case "array":
|
|
1052
|
+
case "group":
|
|
1053
|
+
if (!Array.isArray(field.fields) || field.fields.length === 0) {
|
|
1054
|
+
add(`${path}.fields`, "must contain at least one field");
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
if (field.type === "array")
|
|
1058
|
+
checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
|
|
1059
|
+
validateFields2(field.fields, `${path}.fields`, /* @__PURE__ */ new Set(["id"]), collectionSlugs, add);
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
function checkRange(min, max, minName, maxName, path, add) {
|
|
1064
|
+
if (min !== void 0 && max !== void 0 && min > max) {
|
|
1065
|
+
add(path, `${minName} (${min}) is greater than ${maxName} (${max})`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
function validateSelect(options, defaultValue, hasMany, path, add) {
|
|
1069
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
1070
|
+
add(`${path}.options`, "must contain at least one option");
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const values = options.map((o) => typeof o === "string" ? o : o.value);
|
|
1074
|
+
const duplicates = values.filter((v, i) => values.indexOf(v) !== i);
|
|
1075
|
+
if (duplicates.length > 0) {
|
|
1076
|
+
add(`${path}.options`, `duplicate option values: ${[...new Set(duplicates)].join(", ")}`);
|
|
1077
|
+
}
|
|
1078
|
+
if (defaultValue === void 0) return;
|
|
1079
|
+
const defaults = typeof defaultValue === "string" ? [defaultValue] : defaultValue;
|
|
1080
|
+
if (typeof defaultValue !== "string" && !hasMany) {
|
|
1081
|
+
add(`${path}.defaultValue`, "must be a single value unless hasMany is true");
|
|
1082
|
+
}
|
|
1083
|
+
for (const value of defaults) {
|
|
1084
|
+
if (!values.includes(value)) {
|
|
1085
|
+
add(`${path}.defaultValue`, `"${value}" is not one of the options`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
function validateUseAsTitle(collection2, path, add) {
|
|
1090
|
+
if (collection2.useAsTitle === void 0 || !Array.isArray(collection2.fields)) return;
|
|
1091
|
+
const field = collection2.fields.find((f) => f.name === collection2.useAsTitle);
|
|
1092
|
+
if (!field) {
|
|
1093
|
+
add(`${path}.useAsTitle`, `no top-level field named "${collection2.useAsTitle}"`);
|
|
1094
|
+
} else if (!["text", "textarea", "email", "slug", "number", "date"].includes(field.type)) {
|
|
1095
|
+
add(
|
|
1096
|
+
`${path}.useAsTitle`,
|
|
1097
|
+
`"${field.name}" is a ${field.type} field and cannot be shown as a title`
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// src/resolve-config.ts
|
|
1103
|
+
var DEFAULT_ADMIN_PATH = "/admin";
|
|
1104
|
+
var DEFAULT_API_PATH = "/api/cms";
|
|
1105
|
+
var DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
1106
|
+
var DEFAULT_TOKEN_EXPIRATION = 7 * 24 * 60 * 60;
|
|
1107
|
+
var resolved = /* @__PURE__ */ new WeakSet();
|
|
1108
|
+
async function resolveConfig(input) {
|
|
1109
|
+
if (resolved.has(input)) return input;
|
|
1110
|
+
let config = input;
|
|
1111
|
+
const plugins = Array.isArray(config?.plugins) ? config.plugins : [];
|
|
1112
|
+
for (const plugin of plugins) {
|
|
1113
|
+
if (typeof plugin !== "function") break;
|
|
1114
|
+
config = await plugin(config);
|
|
1115
|
+
}
|
|
1116
|
+
if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
|
|
1117
|
+
config = withMedia(withUsers(config));
|
|
1118
|
+
}
|
|
1119
|
+
const issues = validateConfig(config);
|
|
1120
|
+
if (issues.length > 0) throw new ConfigError(issues);
|
|
1121
|
+
const { plugins: _plugins, ...rest } = config;
|
|
1122
|
+
const result = {
|
|
1123
|
+
...rest,
|
|
1124
|
+
routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
|
|
1125
|
+
admin: {
|
|
1126
|
+
path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
|
|
1127
|
+
locale: config.admin?.locale ?? "en"
|
|
1128
|
+
},
|
|
1129
|
+
upload: {
|
|
1130
|
+
dir: config.upload?.dir ?? "uploads",
|
|
1131
|
+
maxFileSize: config.upload?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
1132
|
+
mimeTypes: config.upload?.mimeTypes ?? ["image/*", "application/pdf"],
|
|
1133
|
+
imageSizes: config.upload?.imageSizes ?? [],
|
|
1134
|
+
...config.upload?.storage ? { storage: config.upload.storage } : {}
|
|
1135
|
+
},
|
|
1136
|
+
auth: {
|
|
1137
|
+
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
1138
|
+
tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
|
|
1139
|
+
maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
|
|
1140
|
+
lockWindow: config.auth?.lockWindow ?? 15 * 60,
|
|
1141
|
+
trustedOrigins: config.auth?.trustedOrigins ?? []
|
|
1142
|
+
},
|
|
1143
|
+
collections: [...config.collections ?? [], ...internalCollections],
|
|
1144
|
+
globals: config.globals ?? []
|
|
1145
|
+
};
|
|
1146
|
+
resolved.add(result);
|
|
1147
|
+
return result;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/load-config.ts
|
|
1151
|
+
var CONFIG_FILE_NAMES = [
|
|
1152
|
+
"easy-cms.config.ts",
|
|
1153
|
+
"easy-cms.config.mts",
|
|
1154
|
+
"easy-cms.config.js",
|
|
1155
|
+
"easy-cms.config.mjs"
|
|
1156
|
+
];
|
|
1157
|
+
function findConfigFile(cwd = process.cwd()) {
|
|
1158
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
1159
|
+
const path = resolve(cwd, name);
|
|
1160
|
+
if (existsSync(path)) return path;
|
|
1161
|
+
}
|
|
1162
|
+
return void 0;
|
|
1163
|
+
}
|
|
1164
|
+
async function importConfig(file) {
|
|
1165
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false, interopDefault: false });
|
|
1166
|
+
const mod = await jiti.import(file);
|
|
1167
|
+
return mod.default;
|
|
1168
|
+
}
|
|
1169
|
+
async function loadConfig(options = {}) {
|
|
1170
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1171
|
+
const file = options.configFile ? resolve(cwd, options.configFile) : findConfigFile(cwd);
|
|
1172
|
+
if (!file || !existsSync(file)) {
|
|
1173
|
+
const looked = options.configFile ?? CONFIG_FILE_NAMES.join(", ");
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
`Easy CMS config not found in ${cwd} (looked for ${looked}).
|
|
1176
|
+
\u2192 run \`npx create-easy-cms\` to create one`
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false, interopDefault: false });
|
|
1180
|
+
const mod = await jiti.import(file);
|
|
1181
|
+
if (!mod.default) {
|
|
1182
|
+
throw new Error(`${file} has no default export.
|
|
1183
|
+
\u2192 export default defineConfig({ ... })`);
|
|
1184
|
+
}
|
|
1185
|
+
return resolveConfig(mod.default);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/local-api.ts
|
|
1189
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
1190
|
+
|
|
1191
|
+
// src/logger.ts
|
|
1192
|
+
var consoleLogger = {
|
|
1193
|
+
info: (message) => console.info(`[easy-cms] ${message}`),
|
|
1194
|
+
warn: (message) => console.warn(`[easy-cms] ${message}`),
|
|
1195
|
+
error: (message) => console.error(`[easy-cms] ${message}`)
|
|
1196
|
+
};
|
|
1197
|
+
var silentLogger = { info() {
|
|
1198
|
+
}, warn() {
|
|
1199
|
+
}, error() {
|
|
1200
|
+
} };
|
|
1201
|
+
|
|
1202
|
+
// src/media.ts
|
|
1203
|
+
var EXTENSIONS = {
|
|
1204
|
+
"image/png": "png",
|
|
1205
|
+
"image/jpeg": "jpg",
|
|
1206
|
+
"image/gif": "gif",
|
|
1207
|
+
"image/webp": "webp",
|
|
1208
|
+
"image/avif": "avif",
|
|
1209
|
+
"image/svg+xml": "svg",
|
|
1210
|
+
"application/pdf": "pdf",
|
|
1211
|
+
"video/mp4": "mp4",
|
|
1212
|
+
"text/plain": "txt"
|
|
1213
|
+
};
|
|
1214
|
+
var startsWith = (data, bytes, offset = 0) => bytes.every((b, i) => data[offset + i] === b);
|
|
1215
|
+
var ascii = (data, start, end) => String.fromCharCode(...data.subarray(start, end));
|
|
1216
|
+
function sniffMimeType(data) {
|
|
1217
|
+
if (startsWith(data, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
|
|
1218
|
+
if (startsWith(data, [255, 216, 255])) return "image/jpeg";
|
|
1219
|
+
if (ascii(data, 0, 6) === "GIF87a" || ascii(data, 0, 6) === "GIF89a") return "image/gif";
|
|
1220
|
+
if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 12) === "WEBP") return "image/webp";
|
|
1221
|
+
if (ascii(data, 4, 8) === "ftyp") {
|
|
1222
|
+
const brand = ascii(data, 8, 12);
|
|
1223
|
+
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
1224
|
+
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V "].includes(brand)) return "video/mp4";
|
|
1225
|
+
}
|
|
1226
|
+
if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
|
|
1227
|
+
const head = data.subarray(0, 4096);
|
|
1228
|
+
if (head.includes(0)) return void 0;
|
|
1229
|
+
let text;
|
|
1230
|
+
try {
|
|
1231
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(head);
|
|
1232
|
+
} catch {
|
|
1233
|
+
return void 0;
|
|
1234
|
+
}
|
|
1235
|
+
const trimmed = text.replace(/^/, "").trimStart();
|
|
1236
|
+
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
|
|
1237
|
+
return "image/svg+xml";
|
|
1238
|
+
return "text/plain";
|
|
1239
|
+
}
|
|
1240
|
+
function mimeAllowed(type, allowed2) {
|
|
1241
|
+
return allowed2.some(
|
|
1242
|
+
(pattern) => pattern.endsWith("/*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
function imageDimensions(data, type) {
|
|
1246
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1247
|
+
try {
|
|
1248
|
+
switch (type) {
|
|
1249
|
+
case "image/png":
|
|
1250
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
1251
|
+
case "image/gif":
|
|
1252
|
+
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
1253
|
+
case "image/webp": {
|
|
1254
|
+
const chunk = ascii(data, 12, 16);
|
|
1255
|
+
if (chunk === "VP8 ")
|
|
1256
|
+
return {
|
|
1257
|
+
width: view.getUint16(26, true) & 16383,
|
|
1258
|
+
height: view.getUint16(28, true) & 16383
|
|
1259
|
+
};
|
|
1260
|
+
if (chunk === "VP8L") {
|
|
1261
|
+
const bits = view.getUint32(21, true);
|
|
1262
|
+
return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
|
|
1263
|
+
}
|
|
1264
|
+
if (chunk === "VP8X") {
|
|
1265
|
+
const w = view.getUint16(24, true) | view.getUint8(26) << 16;
|
|
1266
|
+
const h = view.getUint16(27, true) | view.getUint8(29) << 16;
|
|
1267
|
+
return { width: w + 1, height: h + 1 };
|
|
1268
|
+
}
|
|
1269
|
+
return void 0;
|
|
1270
|
+
}
|
|
1271
|
+
case "image/jpeg": {
|
|
1272
|
+
let offset = 2;
|
|
1273
|
+
while (offset + 9 < data.length) {
|
|
1274
|
+
if (data[offset] !== 255) return void 0;
|
|
1275
|
+
const marker = data[offset + 1];
|
|
1276
|
+
const length = view.getUint16(offset + 2);
|
|
1277
|
+
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
|
|
1278
|
+
return { width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
|
|
1279
|
+
}
|
|
1280
|
+
offset += 2 + length;
|
|
1281
|
+
}
|
|
1282
|
+
return void 0;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
} catch {
|
|
1286
|
+
return void 0;
|
|
1287
|
+
}
|
|
1288
|
+
return void 0;
|
|
1289
|
+
}
|
|
1290
|
+
function storageKey(originalName, type, random) {
|
|
1291
|
+
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
1292
|
+
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
1293
|
+
return `${base}-${random}.${EXTENSIONS[type] ?? "bin"}`;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/populate.ts
|
|
1297
|
+
var DEFAULT_DEPTH = 1;
|
|
1298
|
+
var MAX_DEPTH = 3;
|
|
1299
|
+
async function populate(load, collections, fields, docs, depth) {
|
|
1300
|
+
if (depth <= 0 || docs.length === 0) return docs;
|
|
1301
|
+
const bySlug = new Map(collections.map((c) => [c.slug, c]));
|
|
1302
|
+
const copies = docs.map((doc) => structuredClone(doc));
|
|
1303
|
+
const slots = [];
|
|
1304
|
+
for (const doc of copies) collectSlots(fields, doc, bySlug, slots);
|
|
1305
|
+
if (slots.length === 0) return copies;
|
|
1306
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
1307
|
+
for (const slot of slots) {
|
|
1308
|
+
const set = wanted.get(slot.collection) ?? /* @__PURE__ */ new Set();
|
|
1309
|
+
for (const id of slot.ids) set.add(id);
|
|
1310
|
+
wanted.set(slot.collection, set);
|
|
1311
|
+
}
|
|
1312
|
+
const found = /* @__PURE__ */ new Map();
|
|
1313
|
+
await Promise.all(
|
|
1314
|
+
[...wanted].map(async ([slug, ids]) => {
|
|
1315
|
+
const collection2 = bySlug.get(slug);
|
|
1316
|
+
if (!collection2) return;
|
|
1317
|
+
const raw = await load(collection2, [...ids]);
|
|
1318
|
+
const populated = await populate(load, collections, collection2.fields, raw, depth - 1);
|
|
1319
|
+
found.set(slug, new Map(populated.map((d) => [d.id, d])));
|
|
1320
|
+
})
|
|
1321
|
+
);
|
|
1322
|
+
for (const slot of slots) slot.assign(found.get(slot.collection) ?? /* @__PURE__ */ new Map());
|
|
1323
|
+
return copies;
|
|
1324
|
+
}
|
|
1325
|
+
function collectSlots(fields, data, bySlug, slots) {
|
|
1326
|
+
for (const field of fields) {
|
|
1327
|
+
const value = data[field.name];
|
|
1328
|
+
if (value === null || value === void 0) continue;
|
|
1329
|
+
if (field.type === "group") {
|
|
1330
|
+
if (typeof value === "object") collectSlots(field.fields, value, bySlug, slots);
|
|
1331
|
+
} else if (field.type === "array") {
|
|
1332
|
+
if (Array.isArray(value))
|
|
1333
|
+
for (const row of value) collectSlots(field.fields, row, bySlug, slots);
|
|
1334
|
+
} else if (field.type === "relationship" || field.type === "upload") {
|
|
1335
|
+
const collection2 = field.type === "upload" ? "media" : field.to;
|
|
1336
|
+
if (!bySlug.has(collection2)) continue;
|
|
1337
|
+
if (Array.isArray(value)) {
|
|
1338
|
+
const ids = value;
|
|
1339
|
+
slots.push({
|
|
1340
|
+
collection: collection2,
|
|
1341
|
+
ids,
|
|
1342
|
+
assign: (byId) => {
|
|
1343
|
+
data[field.name] = ids.map((id) => byId.get(id)).filter((d) => d !== void 0);
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
} else {
|
|
1347
|
+
const id = value;
|
|
1348
|
+
slots.push({
|
|
1349
|
+
collection: collection2,
|
|
1350
|
+
ids: [id],
|
|
1351
|
+
assign: (byId) => {
|
|
1352
|
+
data[field.name] = byId.get(id) ?? null;
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// src/storage.ts
|
|
1361
|
+
import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
|
|
1362
|
+
import { dirname, isAbsolute, join, normalize, resolve as resolve2, sep } from "path";
|
|
1363
|
+
function localStorage(options = {}) {
|
|
1364
|
+
let root = resolve2(options.dir ?? "uploads");
|
|
1365
|
+
const pathOf = (key2) => {
|
|
1366
|
+
const path = normalize(join(root, key2));
|
|
1367
|
+
if (!path.startsWith(root + sep)) throw new Error(`Invalid storage key "${key2}"`);
|
|
1368
|
+
return path;
|
|
1369
|
+
};
|
|
1370
|
+
return {
|
|
1371
|
+
name: "local",
|
|
1372
|
+
init({ cwd }) {
|
|
1373
|
+
const dir = options.dir ?? "uploads";
|
|
1374
|
+
root = isAbsolute(dir) ? dir : resolve2(cwd, dir);
|
|
1375
|
+
},
|
|
1376
|
+
async put(key2, data) {
|
|
1377
|
+
const path = pathOf(key2);
|
|
1378
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1379
|
+
await writeFile(path, data, { flag: "wx" });
|
|
1380
|
+
},
|
|
1381
|
+
async get(key2) {
|
|
1382
|
+
const path = pathOf(key2);
|
|
1383
|
+
const info = await stat(path).catch(() => void 0);
|
|
1384
|
+
if (!info?.isFile()) return null;
|
|
1385
|
+
return { body: new Uint8Array(await readFile(path)), size: info.size };
|
|
1386
|
+
},
|
|
1387
|
+
async delete(key2) {
|
|
1388
|
+
await rm(pathOf(key2), { force: true });
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/local-api.ts
|
|
1394
|
+
async function createEasyCMS(config, options = {}) {
|
|
1395
|
+
const resolved2 = await resolveConfig(config);
|
|
1396
|
+
const logger = options.logger ?? consoleLogger;
|
|
1397
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1398
|
+
const storage = resolved2.upload.storage ?? localStorage({ dir: resolved2.upload.dir });
|
|
1399
|
+
await storage.init?.({ cwd });
|
|
1400
|
+
const db = await resolved2.db.init({
|
|
1401
|
+
config: resolved2,
|
|
1402
|
+
cwd,
|
|
1403
|
+
schema: options.schema ?? (process.env.NODE_ENV === "production" ? "verify" : "push"),
|
|
1404
|
+
logger,
|
|
1405
|
+
interactive: options.interactive ?? false
|
|
1406
|
+
});
|
|
1407
|
+
return new EasyCMS(resolved2, db, logger, storage);
|
|
1408
|
+
}
|
|
1409
|
+
var EasyCMS = class {
|
|
1410
|
+
config;
|
|
1411
|
+
db;
|
|
1412
|
+
logger;
|
|
1413
|
+
/** Login, logout and session checks. */
|
|
1414
|
+
auth;
|
|
1415
|
+
/** Where uploaded files are stored. */
|
|
1416
|
+
storage;
|
|
1417
|
+
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
|
|
1418
|
+
this.config = config;
|
|
1419
|
+
this.db = db;
|
|
1420
|
+
this.logger = logger;
|
|
1421
|
+
this.storage = storage;
|
|
1422
|
+
this.auth = new Auth(this);
|
|
1423
|
+
}
|
|
1424
|
+
async find(collection2, options = {}) {
|
|
1425
|
+
const config = this.collection(collection2);
|
|
1426
|
+
const guard = guardOf(options);
|
|
1427
|
+
const limit = options.limit ?? 10;
|
|
1428
|
+
const page = options.page ?? 1;
|
|
1429
|
+
if (!Number.isInteger(limit) || limit < 0)
|
|
1430
|
+
throw new QueryError("limit must be a non-negative integer");
|
|
1431
|
+
if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
|
|
1432
|
+
const where = draftWhere(
|
|
1433
|
+
config,
|
|
1434
|
+
options.draft,
|
|
1435
|
+
await this.readWhere(config, guard, options.where)
|
|
1436
|
+
);
|
|
1437
|
+
const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
1438
|
+
const result = await this.db.find({ collection: collection2, where, sort, limit, page });
|
|
1439
|
+
const docs = await this.output(config, result.docs, guard, options);
|
|
1440
|
+
return { ...result, docs };
|
|
1441
|
+
}
|
|
1442
|
+
async findById(collection2, id, options = {}) {
|
|
1443
|
+
const config = this.collection(collection2);
|
|
1444
|
+
const guard = guardOf(options);
|
|
1445
|
+
const parsed = parseId(id);
|
|
1446
|
+
if (parsed === void 0) return null;
|
|
1447
|
+
const where = draftWhere(config, options.draft, await this.readWhere(config, guard, void 0));
|
|
1448
|
+
const doc = where ? (await this.db.find({
|
|
1449
|
+
collection: collection2,
|
|
1450
|
+
where: andWhere(where, { id: { equals: parsed } }),
|
|
1451
|
+
sort: [],
|
|
1452
|
+
limit: 1,
|
|
1453
|
+
page: 1
|
|
1454
|
+
})).docs[0] : await this.db.findById({ collection: collection2, id: parsed });
|
|
1455
|
+
if (!doc) return null;
|
|
1456
|
+
const [out] = await this.output(config, [doc], guard, options);
|
|
1457
|
+
return out ?? null;
|
|
1458
|
+
}
|
|
1459
|
+
async count(collection2, options = {}) {
|
|
1460
|
+
const config = this.collection(collection2);
|
|
1461
|
+
const where = draftWhere(
|
|
1462
|
+
config,
|
|
1463
|
+
options.draft,
|
|
1464
|
+
await this.readWhere(config, guardOf(options), options.where)
|
|
1465
|
+
);
|
|
1466
|
+
return this.db.count({ collection: collection2, where });
|
|
1467
|
+
}
|
|
1468
|
+
async create(collection2, data, options = {}) {
|
|
1469
|
+
const config = this.collection(collection2);
|
|
1470
|
+
if (config.slug === MEDIA) {
|
|
1471
|
+
throw new ValidationError(MEDIA, [
|
|
1472
|
+
{ field: "file", message: "upload files with cms.upload() or POST multipart" }
|
|
1473
|
+
]);
|
|
1474
|
+
}
|
|
1475
|
+
return await this.createDocument(config, asObject(data, collection2), options);
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Stores a file and creates its `media` document (FR-UPL). The type is detected from
|
|
1479
|
+
* the contents; images get width/height, and resized copies when `sharp` is installed.
|
|
1480
|
+
*/
|
|
1481
|
+
async upload(file, data = {}, options = {}) {
|
|
1482
|
+
const config = this.collection(MEDIA);
|
|
1483
|
+
const guard = guardOf(options);
|
|
1484
|
+
if (guard.enforce) {
|
|
1485
|
+
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
|
|
1486
|
+
if (allowed2 !== true) throw deny(guard.user);
|
|
1487
|
+
}
|
|
1488
|
+
const { maxFileSize, mimeTypes, imageSizes } = this.config.upload;
|
|
1489
|
+
if (file.data.byteLength > maxFileSize) {
|
|
1490
|
+
throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
|
|
1491
|
+
}
|
|
1492
|
+
if (file.data.byteLength === 0)
|
|
1493
|
+
throw new ValidationError(MEDIA, [{ field: "file", message: "is empty" }]);
|
|
1494
|
+
const mimeType = sniffMimeType(file.data);
|
|
1495
|
+
if (!mimeType || !mimeAllowed(mimeType, mimeTypes)) {
|
|
1496
|
+
throw new ValidationError(MEDIA, [
|
|
1497
|
+
{
|
|
1498
|
+
field: "file",
|
|
1499
|
+
message: `file type ${mimeType ?? "unknown"} is not allowed (allowed: ${mimeTypes.join(", ")})`
|
|
1500
|
+
}
|
|
1501
|
+
]);
|
|
1502
|
+
}
|
|
1503
|
+
const random = randomBytes3(4).toString("hex");
|
|
1504
|
+
const filename = storageKey(file.name, mimeType, random);
|
|
1505
|
+
const stored = [];
|
|
1506
|
+
try {
|
|
1507
|
+
await this.storage.put(filename, file.data, { contentType: mimeType });
|
|
1508
|
+
stored.push(filename);
|
|
1509
|
+
const dimensions = imageDimensions(file.data, mimeType);
|
|
1510
|
+
const sizes = await this.resizeImage(file.data, mimeType, filename, imageSizes, stored);
|
|
1511
|
+
const doc = await this.createDocument(
|
|
1512
|
+
config,
|
|
1513
|
+
{
|
|
1514
|
+
...data,
|
|
1515
|
+
filename,
|
|
1516
|
+
originalName: file.name.slice(0, 255),
|
|
1517
|
+
mimeType,
|
|
1518
|
+
filesize: file.data.byteLength,
|
|
1519
|
+
width: dimensions?.width ?? null,
|
|
1520
|
+
height: dimensions?.height ?? null,
|
|
1521
|
+
sizes
|
|
1522
|
+
},
|
|
1523
|
+
// System fields are set here, not by the caller: skip field-level update access for them.
|
|
1524
|
+
{ ...options, overrideAccess: true },
|
|
1525
|
+
guard
|
|
1526
|
+
);
|
|
1527
|
+
return doc;
|
|
1528
|
+
} catch (error) {
|
|
1529
|
+
for (const key2 of stored) await this.storage.delete(key2).catch(() => {
|
|
1530
|
+
});
|
|
1531
|
+
throw error;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
/** Public URL of a stored file. */
|
|
1535
|
+
mediaURL(key2) {
|
|
1536
|
+
const custom = this.storage.url?.(key2);
|
|
1537
|
+
if (custom) return custom;
|
|
1538
|
+
return `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}/media/file/${encodeURIComponent(key2)}`;
|
|
1539
|
+
}
|
|
1540
|
+
async update(collection2, id, data, options = {}) {
|
|
1541
|
+
const config = this.collection(collection2);
|
|
1542
|
+
const guard = guardOf(options);
|
|
1543
|
+
const raw = asObject(data, collection2);
|
|
1544
|
+
const parsed = parseId(id);
|
|
1545
|
+
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
1546
|
+
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
1547
|
+
await this.checkDocumentAccess(config, "update", guard, parsed, raw);
|
|
1548
|
+
const { input, password } = splitPassword(config, raw);
|
|
1549
|
+
const filtered = await filterInput(
|
|
1550
|
+
config.fields,
|
|
1551
|
+
input,
|
|
1552
|
+
this.fieldChecker("update", guard, parsed, input)
|
|
1553
|
+
);
|
|
1554
|
+
let merged = mergeForUpdate(config.fields, existing, filtered);
|
|
1555
|
+
if (config.drafts)
|
|
1556
|
+
merged.status = Object.hasOwn(filtered, "status") ? filtered.status : existing.status;
|
|
1557
|
+
const base = this.hookArgs(config, guard);
|
|
1558
|
+
merged = await this.transform(
|
|
1559
|
+
config.hooks?.beforeValidate,
|
|
1560
|
+
"data",
|
|
1561
|
+
{ ...base, operation: "update", originalDoc: existing },
|
|
1562
|
+
merged
|
|
1563
|
+
);
|
|
1564
|
+
let prepared = await this.prepare(
|
|
1565
|
+
config,
|
|
1566
|
+
generateSlugs(config.fields, merged),
|
|
1567
|
+
"update",
|
|
1568
|
+
parsed
|
|
1569
|
+
);
|
|
1570
|
+
prepared = await this.transform(
|
|
1571
|
+
config.hooks?.beforeChange,
|
|
1572
|
+
"data",
|
|
1573
|
+
{ ...base, operation: "update", originalDoc: existing },
|
|
1574
|
+
prepared
|
|
1575
|
+
);
|
|
1576
|
+
if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
|
|
1577
|
+
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
1578
|
+
const doc = await this.db.update({
|
|
1579
|
+
collection: collection2,
|
|
1580
|
+
id: parsed,
|
|
1581
|
+
data: { ...prepared, createdAt: existing.createdAt, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1582
|
+
});
|
|
1583
|
+
if (password !== void 0) await this.auth.revokeSessions(parsed);
|
|
1584
|
+
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
1585
|
+
...base,
|
|
1586
|
+
doc,
|
|
1587
|
+
previousDoc: existing,
|
|
1588
|
+
operation: "update"
|
|
1589
|
+
});
|
|
1590
|
+
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
1591
|
+
return out;
|
|
1592
|
+
}
|
|
1593
|
+
async delete(collection2, id, options = {}) {
|
|
1594
|
+
const config = this.collection(collection2);
|
|
1595
|
+
const guard = guardOf(options);
|
|
1596
|
+
const parsed = parseId(id);
|
|
1597
|
+
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
1598
|
+
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
1599
|
+
await this.checkDocumentAccess(config, "delete", guard, parsed, void 0);
|
|
1600
|
+
if (config.slug === USERS)
|
|
1601
|
+
await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
|
|
1602
|
+
const base = this.hookArgs(config, guard);
|
|
1603
|
+
for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
|
|
1604
|
+
if (config.slug === USERS) await this.auth.revokeSessions(parsed);
|
|
1605
|
+
await this.db.delete({ collection: collection2, id: parsed });
|
|
1606
|
+
await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
|
|
1607
|
+
...base,
|
|
1608
|
+
id: parsed,
|
|
1609
|
+
doc: existing
|
|
1610
|
+
});
|
|
1611
|
+
const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
|
|
1612
|
+
return out;
|
|
1613
|
+
}
|
|
1614
|
+
async findGlobal(slug, options = {}) {
|
|
1615
|
+
const config = this.global(slug);
|
|
1616
|
+
const guard = guardOf(options);
|
|
1617
|
+
await this.checkGlobalAccess(config, "read", guard);
|
|
1618
|
+
const stored = await this.db.findGlobal({ slug });
|
|
1619
|
+
const data = fillMissing(config.fields, applyDefaults(config.fields, stored ?? {}));
|
|
1620
|
+
if (!stored) {
|
|
1621
|
+
data.updatedAt = null;
|
|
1622
|
+
if (config.drafts) data.status = "draft";
|
|
1623
|
+
}
|
|
1624
|
+
const [out] = await this.output(config, [{ ...data, id: 0 }], guard, options);
|
|
1625
|
+
const { id: _id, ...doc } = out;
|
|
1626
|
+
return doc;
|
|
1627
|
+
}
|
|
1628
|
+
async updateGlobal(slug, data, options = {}) {
|
|
1629
|
+
const config = this.global(slug);
|
|
1630
|
+
const guard = guardOf(options);
|
|
1631
|
+
await this.checkGlobalAccess(config, "update", guard);
|
|
1632
|
+
const raw = asObject(data, slug);
|
|
1633
|
+
const input = await filterInput(
|
|
1634
|
+
config.fields,
|
|
1635
|
+
raw,
|
|
1636
|
+
this.fieldChecker("update", guard, void 0, raw)
|
|
1637
|
+
);
|
|
1638
|
+
const existing = await this.db.findGlobal({ slug }) ?? {};
|
|
1639
|
+
const merged = generateSlugs(
|
|
1640
|
+
config.fields,
|
|
1641
|
+
applyDefaults(config.fields, mergeForUpdate(config.fields, existing, input))
|
|
1642
|
+
);
|
|
1643
|
+
if (config.drafts) merged.status = input.status ?? existing.status ?? "draft";
|
|
1644
|
+
const base = this.hookArgs(config, guard);
|
|
1645
|
+
let prepared = await this.prepare(config, merged, "update", void 0);
|
|
1646
|
+
prepared = await this.transform(
|
|
1647
|
+
config.hooks?.beforeChange,
|
|
1648
|
+
"data",
|
|
1649
|
+
{ ...base, operation: "update", originalDoc: existing },
|
|
1650
|
+
prepared
|
|
1651
|
+
);
|
|
1652
|
+
const doc = await this.db.updateGlobal({
|
|
1653
|
+
slug,
|
|
1654
|
+
data: { ...prepared, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1655
|
+
});
|
|
1656
|
+
await this.notify(config.hooks?.afterChange, "afterChange", slug, {
|
|
1657
|
+
...base,
|
|
1658
|
+
doc,
|
|
1659
|
+
previousDoc: existing,
|
|
1660
|
+
operation: "update"
|
|
1661
|
+
});
|
|
1662
|
+
return this.findGlobal(slug, { ...options, draft: true });
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* What `user` may do with one document. Resolves `where`-style access
|
|
1666
|
+
* against the document, so the admin UI can hide actions precisely.
|
|
1667
|
+
*/
|
|
1668
|
+
async documentPermissions(collection2, id, user) {
|
|
1669
|
+
const config = this.collection(collection2);
|
|
1670
|
+
const parsed = parseId(id);
|
|
1671
|
+
if (parsed === void 0) return { update: false, delete: false };
|
|
1672
|
+
const guard = { enforce: true, user };
|
|
1673
|
+
const check = (operation) => this.checkDocumentAccess(config, operation, guard, parsed, void 0).then(
|
|
1674
|
+
() => true,
|
|
1675
|
+
(error) => {
|
|
1676
|
+
if (error instanceof ForbiddenError || error instanceof UnauthorizedError) return false;
|
|
1677
|
+
throw error;
|
|
1678
|
+
}
|
|
1679
|
+
);
|
|
1680
|
+
return { update: await check("update"), delete: await check("delete") };
|
|
1681
|
+
}
|
|
1682
|
+
/** Closes the database connection. */
|
|
1683
|
+
async destroy() {
|
|
1684
|
+
await this.db.destroy();
|
|
1685
|
+
}
|
|
1686
|
+
// -------------------------------------------------------------------------
|
|
1687
|
+
/** @internal */
|
|
1688
|
+
collection(slug) {
|
|
1689
|
+
const config = this.config.collections.find((c) => c.slug === slug);
|
|
1690
|
+
if (!config) throw new QueryError(`Unknown collection "${slug}"`);
|
|
1691
|
+
return config;
|
|
1692
|
+
}
|
|
1693
|
+
global(slug) {
|
|
1694
|
+
const config = this.config.globals.find((g) => g.slug === slug);
|
|
1695
|
+
if (!config) throw new QueryError(`Unknown global "${slug}"`);
|
|
1696
|
+
return config;
|
|
1697
|
+
}
|
|
1698
|
+
/** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
|
|
1699
|
+
async readWhere(config, guard, where) {
|
|
1700
|
+
if (!guard.enforce) return where;
|
|
1701
|
+
const access = await evaluateAccess(config.access?.read, { user: guard.user });
|
|
1702
|
+
if (access === false) throw deny(guard.user);
|
|
1703
|
+
return andWhere(where, access);
|
|
1704
|
+
}
|
|
1705
|
+
async checkDocumentAccess(config, operation, guard, id, data) {
|
|
1706
|
+
if (!guard.enforce) return;
|
|
1707
|
+
const access = await evaluateAccess(config.access?.[operation], {
|
|
1708
|
+
user: guard.user,
|
|
1709
|
+
id,
|
|
1710
|
+
...data ? { data } : {}
|
|
1711
|
+
});
|
|
1712
|
+
if (access === true) return;
|
|
1713
|
+
if (access !== false) {
|
|
1714
|
+
const matches = await this.db.count({
|
|
1715
|
+
collection: config.slug,
|
|
1716
|
+
where: andWhere(access, { id: { equals: id } })
|
|
1717
|
+
});
|
|
1718
|
+
if (matches > 0) return;
|
|
1719
|
+
}
|
|
1720
|
+
throw deny(guard.user);
|
|
1721
|
+
}
|
|
1722
|
+
async checkGlobalAccess(config, operation, guard) {
|
|
1723
|
+
if (!guard.enforce) return;
|
|
1724
|
+
const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
|
|
1725
|
+
if (typeof access === "object")
|
|
1726
|
+
throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
|
|
1727
|
+
if (!access) throw deny(guard.user);
|
|
1728
|
+
}
|
|
1729
|
+
fieldChecker(kind, guard, id, data) {
|
|
1730
|
+
if (!guard.enforce) return void 0;
|
|
1731
|
+
return new FieldAccessChecker(kind, {
|
|
1732
|
+
user: guard.user,
|
|
1733
|
+
...id !== void 0 ? { id } : {},
|
|
1734
|
+
...data ? { data } : {}
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
/** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
|
|
1738
|
+
async output(config, docs, guard, options) {
|
|
1739
|
+
const read = this.fieldChecker("read", guard, void 0, void 0);
|
|
1740
|
+
const finish = async (target, doc) => {
|
|
1741
|
+
const hooked = await this.transform(
|
|
1742
|
+
target.hooks?.afterRead,
|
|
1743
|
+
"doc",
|
|
1744
|
+
this.hookArgs(target, guard),
|
|
1745
|
+
doc
|
|
1746
|
+
);
|
|
1747
|
+
return await stripFields(target.fields, hooked, read);
|
|
1748
|
+
};
|
|
1749
|
+
const load = async (target, ids) => {
|
|
1750
|
+
const where = await this.readWhere(target, guard, { id: { in: [...ids] } }).catch((error) => {
|
|
1751
|
+
if (error instanceof ForbiddenError || error instanceof UnauthorizedError) return null;
|
|
1752
|
+
throw error;
|
|
1753
|
+
});
|
|
1754
|
+
if (where === null) return [];
|
|
1755
|
+
const found = await this.db.find({
|
|
1756
|
+
collection: target.slug,
|
|
1757
|
+
where: draftWhere(target, options.draft, where),
|
|
1758
|
+
sort: [],
|
|
1759
|
+
limit: 0,
|
|
1760
|
+
page: 1
|
|
1761
|
+
});
|
|
1762
|
+
return Promise.all(found.docs.map((d) => finish(target, d)));
|
|
1763
|
+
};
|
|
1764
|
+
const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
|
|
1765
|
+
const populated = await populate(load, this.config.collections, config.fields, docs, depth);
|
|
1766
|
+
return Promise.all(populated.map((d) => finish(config, d)));
|
|
1767
|
+
}
|
|
1768
|
+
/** Creates a document: access, input filtering, hooks, validation, then afterChange. */
|
|
1769
|
+
async createDocument(config, raw, options, hookGuard) {
|
|
1770
|
+
const guard = guardOf(options);
|
|
1771
|
+
const collection2 = config.slug;
|
|
1772
|
+
if (guard.enforce) {
|
|
1773
|
+
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
|
|
1774
|
+
if (typeof allowed2 === "object")
|
|
1775
|
+
throw new QueryError(`create access of "${collection2}" must return a boolean`);
|
|
1776
|
+
if (!allowed2) throw deny(guard.user);
|
|
1777
|
+
}
|
|
1778
|
+
const { input, password } = splitPassword(config, raw);
|
|
1779
|
+
if (config.slug === USERS && password === void 0) {
|
|
1780
|
+
throw new ValidationError(collection2, [{ field: "password", message: "is required" }]);
|
|
1781
|
+
}
|
|
1782
|
+
const filtered = await filterInput(
|
|
1783
|
+
config.fields,
|
|
1784
|
+
input,
|
|
1785
|
+
this.fieldChecker("update", guard, void 0, input)
|
|
1786
|
+
);
|
|
1787
|
+
const base = this.hookArgs(config, hookGuard ?? guard);
|
|
1788
|
+
let data = applyDefaults(config.fields, filtered);
|
|
1789
|
+
data = await this.transform(
|
|
1790
|
+
config.hooks?.beforeValidate,
|
|
1791
|
+
"data",
|
|
1792
|
+
{ ...base, operation: "create" },
|
|
1793
|
+
data
|
|
1794
|
+
);
|
|
1795
|
+
let prepared = await this.prepare(
|
|
1796
|
+
config,
|
|
1797
|
+
generateSlugs(config.fields, data),
|
|
1798
|
+
"create",
|
|
1799
|
+
void 0
|
|
1800
|
+
);
|
|
1801
|
+
prepared = await this.transform(
|
|
1802
|
+
config.hooks?.beforeChange,
|
|
1803
|
+
"data",
|
|
1804
|
+
{ ...base, operation: "create" },
|
|
1805
|
+
prepared
|
|
1806
|
+
);
|
|
1807
|
+
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
1808
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1809
|
+
const doc = await this.db.create({
|
|
1810
|
+
collection: collection2,
|
|
1811
|
+
data: { ...prepared, createdAt: now, updatedAt: now }
|
|
1812
|
+
});
|
|
1813
|
+
await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
|
|
1814
|
+
...base,
|
|
1815
|
+
doc,
|
|
1816
|
+
operation: "create"
|
|
1817
|
+
});
|
|
1818
|
+
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
1819
|
+
return out;
|
|
1820
|
+
}
|
|
1821
|
+
hookArgs(config, guard) {
|
|
1822
|
+
return { user: guard.user, cms: this, slug: config.slug };
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Runs hooks that may replace a value, passed as `data` (before hooks) or `doc` (afterRead).
|
|
1826
|
+
* Returning `undefined` keeps the value; a throw cancels the operation.
|
|
1827
|
+
*/
|
|
1828
|
+
async transform(hooks, key2, args, value) {
|
|
1829
|
+
let current = value;
|
|
1830
|
+
for (const hook of hooks ?? []) {
|
|
1831
|
+
const result = await hook({
|
|
1832
|
+
...args,
|
|
1833
|
+
[key2]: current
|
|
1834
|
+
});
|
|
1835
|
+
if (result !== void 0 && result !== null && typeof result === "object")
|
|
1836
|
+
current = result;
|
|
1837
|
+
}
|
|
1838
|
+
return current;
|
|
1839
|
+
}
|
|
1840
|
+
/** Runs hooks after the change is saved: failures are logged, never undo the save (NFR-REL-03). */
|
|
1841
|
+
async notify(hooks, name, slug, args) {
|
|
1842
|
+
for (const hook of hooks ?? []) {
|
|
1843
|
+
try {
|
|
1844
|
+
await hook(args);
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
this.logger.error(
|
|
1847
|
+
`${name} hook of "${slug}" failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
/** Writes resized copies with sharp, when installed and configured. */
|
|
1853
|
+
async resizeImage(data, mimeType, filename, sizes, stored) {
|
|
1854
|
+
if (sizes.length === 0 || !["image/png", "image/jpeg", "image/webp", "image/avif"].includes(mimeType))
|
|
1855
|
+
return {};
|
|
1856
|
+
const sharp = await loadSharp();
|
|
1857
|
+
if (!sharp) {
|
|
1858
|
+
this.logger.warn(
|
|
1859
|
+
"upload.imageSizes is set but sharp is not installed; skipping resized copies"
|
|
1860
|
+
);
|
|
1861
|
+
return {};
|
|
1862
|
+
}
|
|
1863
|
+
const result = {};
|
|
1864
|
+
for (const size of sizes) {
|
|
1865
|
+
const { data: out, info } = await sharp(data).rotate().resize({
|
|
1866
|
+
width: size.width,
|
|
1867
|
+
...size.height ? { height: size.height } : {},
|
|
1868
|
+
fit: size.fit ?? "cover",
|
|
1869
|
+
withoutEnlargement: true
|
|
1870
|
+
}).toBuffer({ resolveWithObject: true });
|
|
1871
|
+
const key2 = filename.replace(/\.([^.]+)$/, `-${size.name}.$1`);
|
|
1872
|
+
await this.storage.put(key2, new Uint8Array(out), { contentType: mimeType });
|
|
1873
|
+
stored.push(key2);
|
|
1874
|
+
result[size.name] = {
|
|
1875
|
+
filename: key2,
|
|
1876
|
+
width: info.width,
|
|
1877
|
+
height: info.height,
|
|
1878
|
+
filesize: out.byteLength
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
return result;
|
|
1882
|
+
}
|
|
1883
|
+
/** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
|
|
1884
|
+
async prepare(config, data, operation, selfId) {
|
|
1885
|
+
const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
|
|
1886
|
+
const result = await validateFields(config.fields, data, {
|
|
1887
|
+
operation,
|
|
1888
|
+
root: data,
|
|
1889
|
+
skipRequired: isDraft
|
|
1890
|
+
});
|
|
1891
|
+
const errors = [...result.errors];
|
|
1892
|
+
const clean = { ...result.data };
|
|
1893
|
+
if (config.drafts) {
|
|
1894
|
+
const status = data.status ?? "draft";
|
|
1895
|
+
if (status !== "draft" && status !== "published") {
|
|
1896
|
+
errors.push({ field: "status", message: 'must be "draft" or "published"' });
|
|
1897
|
+
}
|
|
1898
|
+
clean.status = status;
|
|
1899
|
+
}
|
|
1900
|
+
const isCollection = this.config.collections.includes(config);
|
|
1901
|
+
if (isCollection && errors.length === 0) {
|
|
1902
|
+
await this.makeSlugsUnique(config, clean, selfId);
|
|
1903
|
+
errors.push(...await this.checkUnique(config, clean, selfId));
|
|
1904
|
+
}
|
|
1905
|
+
errors.push(...await this.checkReferences(result.references));
|
|
1906
|
+
if (errors.length > 0) throw new ValidationError(config.slug, errors);
|
|
1907
|
+
return clean;
|
|
1908
|
+
}
|
|
1909
|
+
/** Refuses changes that would leave no active admin (and lock everyone out). */
|
|
1910
|
+
async guardLastAdmin(id, before, after) {
|
|
1911
|
+
const wasAdmin = before.role === "admin" && before.active !== false;
|
|
1912
|
+
const staysAdmin = after.role === "admin" && after.active !== false;
|
|
1913
|
+
if (!wasAdmin || staysAdmin) return;
|
|
1914
|
+
const others = await this.db.count({
|
|
1915
|
+
collection: USERS,
|
|
1916
|
+
where: {
|
|
1917
|
+
and: [
|
|
1918
|
+
{ role: { equals: "admin" } },
|
|
1919
|
+
{ active: { not_equals: false } },
|
|
1920
|
+
{ id: { not_equals: id } }
|
|
1921
|
+
]
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
if (others === 0) {
|
|
1925
|
+
throw new ValidationError(USERS, [
|
|
1926
|
+
{ field: "role", message: "cannot remove the last active admin" }
|
|
1927
|
+
]);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
async makeSlugsUnique(config, data, selfId) {
|
|
1931
|
+
for (const field of config.fields) {
|
|
1932
|
+
const base = data[field.name];
|
|
1933
|
+
if (field.type !== "slug" || typeof base !== "string" || base === "") continue;
|
|
1934
|
+
let candidate = base;
|
|
1935
|
+
for (let n = 2; await this.isTaken(config.slug, field.name, candidate, selfId); n++) {
|
|
1936
|
+
candidate = `${base}-${n}`;
|
|
1937
|
+
}
|
|
1938
|
+
data[field.name] = candidate;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
async checkUnique(config, data, selfId) {
|
|
1942
|
+
const errors = [];
|
|
1943
|
+
for (const field of config.fields) {
|
|
1944
|
+
const value = data[field.name];
|
|
1945
|
+
if (!field.unique || field.type === "slug" || value === null || value === void 0) continue;
|
|
1946
|
+
if (await this.isTaken(config.slug, field.name, value, selfId)) {
|
|
1947
|
+
errors.push({ field: field.name, message: "must be unique" });
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return errors;
|
|
1951
|
+
}
|
|
1952
|
+
async isTaken(collection2, field, value, selfId) {
|
|
1953
|
+
const where = selfId === void 0 ? { [field]: { equals: value } } : { and: [{ [field]: { equals: value } }, { id: { not_equals: selfId } }] };
|
|
1954
|
+
return await this.db.count({ collection: collection2, where }) > 0;
|
|
1955
|
+
}
|
|
1956
|
+
async checkReferences(references) {
|
|
1957
|
+
const byCollection = /* @__PURE__ */ new Map();
|
|
1958
|
+
for (const ref of references) {
|
|
1959
|
+
if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
|
|
1960
|
+
byCollection.set(ref.collection, [...byCollection.get(ref.collection) ?? [], ref]);
|
|
1961
|
+
}
|
|
1962
|
+
const errors = [];
|
|
1963
|
+
for (const [collection2, refs] of byCollection) {
|
|
1964
|
+
const found = await this.db.findByIds({
|
|
1965
|
+
collection: collection2,
|
|
1966
|
+
ids: [...new Set(refs.map((r) => r.id))]
|
|
1967
|
+
});
|
|
1968
|
+
const ids = new Set(found.map((d) => d.id));
|
|
1969
|
+
for (const ref of refs) {
|
|
1970
|
+
if (!ids.has(ref.id)) {
|
|
1971
|
+
errors.push({ field: ref.field, message: `${collection2} ${ref.id} does not exist` });
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
return errors;
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
function draftWhere(config, draft, where) {
|
|
1979
|
+
if (!config.drafts || draft) return where;
|
|
1980
|
+
return andWhere(where, { status: { equals: "published" } });
|
|
1981
|
+
}
|
|
1982
|
+
var sharpModule;
|
|
1983
|
+
function loadSharp() {
|
|
1984
|
+
sharpModule ??= import("sharp").then(
|
|
1985
|
+
(mod) => mod.default,
|
|
1986
|
+
() => void 0
|
|
1987
|
+
);
|
|
1988
|
+
return sharpModule;
|
|
1989
|
+
}
|
|
1990
|
+
function guardOf(options) {
|
|
1991
|
+
return { enforce: options.overrideAccess === false, user: options.user ?? null };
|
|
1992
|
+
}
|
|
1993
|
+
function deny(user) {
|
|
1994
|
+
return user ? new ForbiddenError() : new UnauthorizedError();
|
|
1995
|
+
}
|
|
1996
|
+
function splitPassword(config, raw) {
|
|
1997
|
+
if (config.slug !== USERS || !Object.hasOwn(raw, "password")) return { input: raw };
|
|
1998
|
+
const { password, ...input } = raw;
|
|
1999
|
+
if (password === void 0 || password === null || password === "") return { input };
|
|
2000
|
+
if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
|
|
2001
|
+
throw new ValidationError(USERS, [
|
|
2002
|
+
{ field: "password", message: `must be at least ${MIN_PASSWORD_LENGTH} characters` }
|
|
2003
|
+
]);
|
|
2004
|
+
}
|
|
2005
|
+
return { input, password };
|
|
2006
|
+
}
|
|
2007
|
+
function asObject(data, name) {
|
|
2008
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
2009
|
+
throw new ValidationError(name, [{ field: "", message: "data must be an object" }]);
|
|
2010
|
+
}
|
|
2011
|
+
return data;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// src/rest/admin-schema.ts
|
|
2015
|
+
async function allowed(access, user) {
|
|
2016
|
+
return await evaluateAccess(access, { user }) !== false;
|
|
2017
|
+
}
|
|
2018
|
+
async function serializeFields(fields, update) {
|
|
2019
|
+
const out = [];
|
|
2020
|
+
for (const field of fields) {
|
|
2021
|
+
if (field.hidden) continue;
|
|
2022
|
+
const f = { name: field.name, type: field.type };
|
|
2023
|
+
if (field.label !== void 0) f.label = field.label;
|
|
2024
|
+
if (field.required) f.required = true;
|
|
2025
|
+
if (field.unique) f.unique = true;
|
|
2026
|
+
if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
|
|
2027
|
+
if (!await update.allows(field)) f.readOnly = true;
|
|
2028
|
+
switch (field.type) {
|
|
2029
|
+
case "text":
|
|
2030
|
+
case "textarea":
|
|
2031
|
+
if (field.minLength !== void 0) f.minLength = field.minLength;
|
|
2032
|
+
if (field.maxLength !== void 0) f.maxLength = field.maxLength;
|
|
2033
|
+
break;
|
|
2034
|
+
case "number":
|
|
2035
|
+
if (field.min !== void 0) f.min = field.min;
|
|
2036
|
+
if (field.max !== void 0) f.max = field.max;
|
|
2037
|
+
break;
|
|
2038
|
+
case "select":
|
|
2039
|
+
f.options = field.options.map(
|
|
2040
|
+
(o) => typeof o === "string" ? { label: o, value: o } : { label: o.label, value: o.value }
|
|
2041
|
+
);
|
|
2042
|
+
if (field.hasMany) f.hasMany = true;
|
|
2043
|
+
break;
|
|
2044
|
+
case "slug":
|
|
2045
|
+
if (field.from) f.from = field.from;
|
|
2046
|
+
break;
|
|
2047
|
+
case "relationship":
|
|
2048
|
+
f.to = field.to;
|
|
2049
|
+
if (field.hasMany) f.hasMany = true;
|
|
2050
|
+
break;
|
|
2051
|
+
case "upload":
|
|
2052
|
+
f.to = "media";
|
|
2053
|
+
break;
|
|
2054
|
+
case "array":
|
|
2055
|
+
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
2056
|
+
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
2057
|
+
f.fields = await serializeFields(field.fields, update);
|
|
2058
|
+
break;
|
|
2059
|
+
case "group":
|
|
2060
|
+
f.fields = await serializeFields(field.fields, update);
|
|
2061
|
+
break;
|
|
2062
|
+
}
|
|
2063
|
+
out.push(f);
|
|
2064
|
+
}
|
|
2065
|
+
return out;
|
|
2066
|
+
}
|
|
2067
|
+
async function collection(config, user) {
|
|
2068
|
+
const result = {
|
|
2069
|
+
slug: config.slug,
|
|
2070
|
+
drafts: config.drafts === true,
|
|
2071
|
+
fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
|
|
2072
|
+
permissions: {
|
|
2073
|
+
read: await allowed(config.access?.read, user),
|
|
2074
|
+
create: await allowed(config.access?.create, user),
|
|
2075
|
+
update: await allowed(config.access?.update, user),
|
|
2076
|
+
delete: await allowed(config.access?.delete, user)
|
|
2077
|
+
}
|
|
2078
|
+
};
|
|
2079
|
+
if (config.labels) result.labels = config.labels;
|
|
2080
|
+
if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
|
|
2081
|
+
return result;
|
|
2082
|
+
}
|
|
2083
|
+
async function global(config, user) {
|
|
2084
|
+
const result = {
|
|
2085
|
+
slug: config.slug,
|
|
2086
|
+
drafts: config.drafts === true,
|
|
2087
|
+
fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
|
|
2088
|
+
permissions: {
|
|
2089
|
+
read: await allowed(config.access?.read, user),
|
|
2090
|
+
update: await allowed(config.access?.update, user)
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
if (config.label !== void 0) result.label = config.label;
|
|
2094
|
+
return result;
|
|
2095
|
+
}
|
|
2096
|
+
async function adminSchema(cms, user) {
|
|
2097
|
+
const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
|
|
2098
|
+
return {
|
|
2099
|
+
locale: cms.config.admin.locale,
|
|
2100
|
+
collections: await Promise.all(collections.map((c) => collection(c, user))),
|
|
2101
|
+
globals: await Promise.all(cms.config.globals.map((g) => global(g, user)))
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// src/rest/query.ts
|
|
2106
|
+
var MAX_DEPTH2 = 10;
|
|
2107
|
+
var MAX_INDEX = 100;
|
|
2108
|
+
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
2109
|
+
function tokens(key2) {
|
|
2110
|
+
const match = /^([^[\]]+)((?:\[[^[\]]*\])*)$/.exec(key2);
|
|
2111
|
+
if (!match) throw new QueryError(`Malformed query parameter "${key2}"`);
|
|
2112
|
+
const rest = [...(match[2] ?? "").matchAll(/\[([^[\]]*)\]/g)].map((m) => m[1]);
|
|
2113
|
+
return [match[1], ...rest];
|
|
2114
|
+
}
|
|
2115
|
+
function arrays(node) {
|
|
2116
|
+
if (typeof node === "string") return node;
|
|
2117
|
+
const keys = Object.keys(node);
|
|
2118
|
+
const out = {};
|
|
2119
|
+
for (const key2 of keys) out[key2] = arrays(node[key2]);
|
|
2120
|
+
if (keys.length > 0 && keys.every((k, i) => k === String(i))) return keys.map((k) => out[k]);
|
|
2121
|
+
return out;
|
|
2122
|
+
}
|
|
2123
|
+
function parseNested(params) {
|
|
2124
|
+
const root = {};
|
|
2125
|
+
for (const [key2, value] of params) {
|
|
2126
|
+
const path = tokens(key2);
|
|
2127
|
+
if (path.length > MAX_DEPTH2)
|
|
2128
|
+
throw new QueryError(`Query parameter "${key2}" is nested too deeply`);
|
|
2129
|
+
let node = root;
|
|
2130
|
+
path.forEach((token, i) => {
|
|
2131
|
+
if (FORBIDDEN_KEYS.has(token)) throw new QueryError(`Query parameter "${key2}" is not allowed`);
|
|
2132
|
+
if (/^\d+$/.test(token) && Number(token) > MAX_INDEX)
|
|
2133
|
+
throw new QueryError(`Index too large in "${key2}"`);
|
|
2134
|
+
if (i === path.length - 1) {
|
|
2135
|
+
node[token] = value;
|
|
2136
|
+
} else {
|
|
2137
|
+
const next = node[token];
|
|
2138
|
+
if (typeof next === "string") throw new QueryError(`Conflicting query parameter "${key2}"`);
|
|
2139
|
+
node[token] = next ?? {};
|
|
2140
|
+
node = node[token];
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
return arrays(root);
|
|
2145
|
+
}
|
|
2146
|
+
function normalizeWhere(value) {
|
|
2147
|
+
if (Array.isArray(value)) return value.map(normalizeWhere);
|
|
2148
|
+
if (typeof value !== "object" || value === null) return value;
|
|
2149
|
+
const out = {};
|
|
2150
|
+
for (const [key2, inner] of Object.entries(value)) {
|
|
2151
|
+
if (key2 === "exists" && typeof inner === "string") out[key2] = inner !== "false";
|
|
2152
|
+
else if ((key2 === "in" || key2 === "not_in") && typeof inner === "string")
|
|
2153
|
+
out[key2] = inner === "" ? [] : inner.split(",");
|
|
2154
|
+
else if ((key2 === "equals" || key2 === "not_equals") && inner === "null") out[key2] = null;
|
|
2155
|
+
else out[key2] = normalizeWhere(inner);
|
|
2156
|
+
}
|
|
2157
|
+
return out;
|
|
2158
|
+
}
|
|
2159
|
+
var MAX_REST_LIMIT = 100;
|
|
2160
|
+
function integer(name, raw, min, max) {
|
|
2161
|
+
if (raw === void 0) return void 0;
|
|
2162
|
+
const n = Number(raw);
|
|
2163
|
+
if (typeof raw !== "string" || !Number.isInteger(n) || n < min || n > max) {
|
|
2164
|
+
throw new QueryError(`${name} must be an integer from ${min} to ${max}`);
|
|
2165
|
+
}
|
|
2166
|
+
return n;
|
|
2167
|
+
}
|
|
2168
|
+
function parseListQuery(url) {
|
|
2169
|
+
const params = parseNested(url.searchParams);
|
|
2170
|
+
let where = params.where;
|
|
2171
|
+
if (typeof where === "string") {
|
|
2172
|
+
try {
|
|
2173
|
+
where = JSON.parse(where);
|
|
2174
|
+
} catch {
|
|
2175
|
+
throw new QueryError("where must be JSON or bracket parameters");
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (where !== void 0 && (typeof where !== "object" || where === null || Array.isArray(where))) {
|
|
2179
|
+
throw new QueryError("where must be an object");
|
|
2180
|
+
}
|
|
2181
|
+
const sort = typeof params.sort === "string" ? params.sort.split(",").filter(Boolean) : void 0;
|
|
2182
|
+
const query = {
|
|
2183
|
+
limit: integer("limit", params.limit, 1, MAX_REST_LIMIT) ?? 10,
|
|
2184
|
+
page: integer("page", params.page, 1, Number.MAX_SAFE_INTEGER) ?? 1
|
|
2185
|
+
};
|
|
2186
|
+
if (where !== void 0) query.where = normalizeWhere(where);
|
|
2187
|
+
if (sort) query.sort = sort;
|
|
2188
|
+
const depth = integer("depth", params.depth, 0, 3);
|
|
2189
|
+
if (depth !== void 0) query.depth = depth;
|
|
2190
|
+
return query;
|
|
2191
|
+
}
|
|
2192
|
+
function parseDepth(url) {
|
|
2193
|
+
const depth = integer("depth", url.searchParams.get("depth") ?? void 0, 0, 3);
|
|
2194
|
+
return depth === void 0 ? {} : { depth };
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// src/rest/handler.ts
|
|
2198
|
+
var SESSION_COOKIE = "ecms-session";
|
|
2199
|
+
var CSRF_COOKIE = "ecms-csrf";
|
|
2200
|
+
var CSRF_HEADER = "x-csrf-token";
|
|
2201
|
+
var MAX_BODY_BYTES = 1024 * 1024;
|
|
2202
|
+
var HttpError = class extends EasyCMSError {
|
|
2203
|
+
};
|
|
2204
|
+
function createRestHandler(instance, options = {}) {
|
|
2205
|
+
const cms = instance;
|
|
2206
|
+
const basePath = (options.basePath ?? cms.config.routes.api).replace(/\/+$/, "");
|
|
2207
|
+
const production = process.env.NODE_ENV === "production";
|
|
2208
|
+
return async (request) => {
|
|
2209
|
+
const headers = new Headers({
|
|
2210
|
+
"content-type": "application/json; charset=utf-8",
|
|
2211
|
+
"cache-control": "no-store",
|
|
2212
|
+
"x-content-type-options": "nosniff"
|
|
2213
|
+
});
|
|
2214
|
+
try {
|
|
2215
|
+
const url = new URL(request.url);
|
|
2216
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
|
2217
|
+
throw new HttpError("Not found", 404);
|
|
2218
|
+
}
|
|
2219
|
+
const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map((s) => decodeURIComponent(s));
|
|
2220
|
+
const { token, via } = readToken(request);
|
|
2221
|
+
const user = token ? await cms.auth.verify(token) : null;
|
|
2222
|
+
const ctx = { request, url, user, via: user ? via : null, token, headers };
|
|
2223
|
+
const method = request.method.toUpperCase();
|
|
2224
|
+
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") checkCsrf(cms, ctx);
|
|
2225
|
+
const result = await route(cms, ctx, method, segments, options);
|
|
2226
|
+
if (result.body instanceof Response) return result.body;
|
|
2227
|
+
return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
|
|
2228
|
+
} catch (error) {
|
|
2229
|
+
return errorResponse(cms, error, headers, production);
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
async function route(cms, ctx, method, segments, options) {
|
|
2234
|
+
const [first, second, third] = segments;
|
|
2235
|
+
const access = { overrideAccess: false, user: ctx.user };
|
|
2236
|
+
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
2237
|
+
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
2238
|
+
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
2239
|
+
switch (`${method} ${second}`) {
|
|
2240
|
+
case "POST login": {
|
|
2241
|
+
const body = await readJson(ctx.request);
|
|
2242
|
+
const session = await cms.auth.login({
|
|
2243
|
+
email: String(body.email ?? ""),
|
|
2244
|
+
password: String(body.password ?? ""),
|
|
2245
|
+
ip: options.getClientIp?.(ctx.request)
|
|
2246
|
+
});
|
|
2247
|
+
setSessionCookies(cms, ctx, session);
|
|
2248
|
+
return {
|
|
2249
|
+
body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
case "POST logout": {
|
|
2253
|
+
if (ctx.token) await cms.auth.logout(ctx.token);
|
|
2254
|
+
clearSessionCookies(ctx);
|
|
2255
|
+
return { body: { message: "Logged out" } };
|
|
2256
|
+
}
|
|
2257
|
+
case "GET me": {
|
|
2258
|
+
const csrfToken2 = ctx.via === "cookie" && ctx.token ? cms.auth.csrfFor(ctx.token) : void 0;
|
|
2259
|
+
if (csrfToken2)
|
|
2260
|
+
ctx.headers.append("set-cookie", cookie(ctx, CSRF_COOKIE, csrfToken2, { httpOnly: false }));
|
|
2261
|
+
return { body: { user: ctx.user, ...csrfToken2 ? { csrfToken: csrfToken2 } : {} } };
|
|
2262
|
+
}
|
|
2263
|
+
case "GET init":
|
|
2264
|
+
return { body: { hasUsers: await cms.auth.hasUsers() } };
|
|
2265
|
+
case "POST first-register": {
|
|
2266
|
+
const body = await readJson(ctx.request);
|
|
2267
|
+
const session = await cms.auth.registerFirstUser({
|
|
2268
|
+
email: String(body.email ?? ""),
|
|
2269
|
+
password: String(body.password ?? ""),
|
|
2270
|
+
...typeof body.name === "string" ? { name: body.name } : {}
|
|
2271
|
+
});
|
|
2272
|
+
setSessionCookies(cms, ctx, session);
|
|
2273
|
+
return {
|
|
2274
|
+
status: 201,
|
|
2275
|
+
body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
throw new HttpError("Not found", 404);
|
|
2280
|
+
}
|
|
2281
|
+
if (first === "admin") {
|
|
2282
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
2283
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
2284
|
+
if (second === "schema" && third === void 0)
|
|
2285
|
+
return { body: await adminSchema(cms, ctx.user) };
|
|
2286
|
+
const [, , collection3, id2, extra] = segments;
|
|
2287
|
+
if (second === "access" && collection3 && id2 && extra === void 0) {
|
|
2288
|
+
if (INTERNAL_COLLECTIONS.has(collection3) || !cms.config.collections.some((c) => c.slug === collection3)) {
|
|
2289
|
+
throw new HttpError(`Unknown collection "${collection3}"`, 404);
|
|
2290
|
+
}
|
|
2291
|
+
return { body: await cms.documentPermissions(collection3, id2, ctx.user) };
|
|
2292
|
+
}
|
|
2293
|
+
throw new HttpError("Not found", 404);
|
|
2294
|
+
}
|
|
2295
|
+
if (first === "globals") {
|
|
2296
|
+
if (!second || third !== void 0) throw new HttpError("Not found", 404);
|
|
2297
|
+
if (!cms.config.globals.some((g) => g.slug === second))
|
|
2298
|
+
throw new HttpError(`Unknown global "${second}"`, 404);
|
|
2299
|
+
if (method === "GET")
|
|
2300
|
+
return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
|
|
2301
|
+
if (method === "POST") {
|
|
2302
|
+
const body = await readJson(ctx.request);
|
|
2303
|
+
return { body: await cms.updateGlobal(second, body, { ...access, ...parseDepth(ctx.url) }) };
|
|
2304
|
+
}
|
|
2305
|
+
throw methodNotAllowed(ctx, "GET, POST");
|
|
2306
|
+
}
|
|
2307
|
+
if (first === MEDIA && second === "file" && third !== void 0 && segments.length === 3) {
|
|
2308
|
+
if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
|
|
2309
|
+
return { body: await serveFile(cms, third, method === "HEAD") };
|
|
2310
|
+
}
|
|
2311
|
+
if (first === MEDIA && second === void 0 && method === "POST") {
|
|
2312
|
+
const { file, data } = await readUpload(ctx.request, cms.config.upload.maxFileSize);
|
|
2313
|
+
return {
|
|
2314
|
+
status: 201,
|
|
2315
|
+
body: await cms.upload(file, data, { ...access, ...parseDepth(ctx.url) })
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
const collection2 = first;
|
|
2319
|
+
if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
|
|
2320
|
+
throw new HttpError(`Unknown collection "${collection2}"`, 404);
|
|
2321
|
+
}
|
|
2322
|
+
if (third !== void 0) throw new HttpError("Not found", 404);
|
|
2323
|
+
if (second === void 0) {
|
|
2324
|
+
if (method === "GET") {
|
|
2325
|
+
const query = parseListQuery(ctx.url);
|
|
2326
|
+
return { body: await cms.find(collection2, { ...access, ...query, draft }) };
|
|
2327
|
+
}
|
|
2328
|
+
if (method === "POST") {
|
|
2329
|
+
const body = await readJson(ctx.request);
|
|
2330
|
+
return {
|
|
2331
|
+
status: 201,
|
|
2332
|
+
body: await cms.create(collection2, body, { ...access, ...parseDepth(ctx.url) })
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
throw methodNotAllowed(ctx, "GET, POST");
|
|
2336
|
+
}
|
|
2337
|
+
const id = second;
|
|
2338
|
+
if (method === "GET") {
|
|
2339
|
+
const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
|
|
2340
|
+
if (!doc) throw new NotFoundError(collection2, id);
|
|
2341
|
+
return { body: doc };
|
|
2342
|
+
}
|
|
2343
|
+
if (method === "PATCH") {
|
|
2344
|
+
const body = await readJson(ctx.request);
|
|
2345
|
+
const doc = await cms.update(collection2, id, body, { ...access, ...parseDepth(ctx.url) });
|
|
2346
|
+
if (collection2 === USERS && body.password && ctx.user && String(ctx.user.id) === String(doc.id) && ctx.via === "cookie") {
|
|
2347
|
+
setSessionCookies(cms, ctx, await cms.auth.createSession(doc.id));
|
|
2348
|
+
}
|
|
2349
|
+
return { body: doc };
|
|
2350
|
+
}
|
|
2351
|
+
if (method === "DELETE") {
|
|
2352
|
+
return { body: await cms.delete(collection2, id, access) };
|
|
2353
|
+
}
|
|
2354
|
+
throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
|
|
2355
|
+
}
|
|
2356
|
+
function methodNotAllowed(ctx, allow) {
|
|
2357
|
+
ctx.headers.set("allow", allow);
|
|
2358
|
+
return new HttpError("Method not allowed", 405);
|
|
2359
|
+
}
|
|
2360
|
+
function readToken(request) {
|
|
2361
|
+
const authorization = request.headers.get("authorization");
|
|
2362
|
+
if (authorization?.startsWith("Bearer "))
|
|
2363
|
+
return { token: authorization.slice(7).trim(), via: "bearer" };
|
|
2364
|
+
const token = readCookie(request, SESSION_COOKIE);
|
|
2365
|
+
return { token, via: token ? "cookie" : null };
|
|
2366
|
+
}
|
|
2367
|
+
function readCookie(request, name) {
|
|
2368
|
+
const header = request.headers.get("cookie");
|
|
2369
|
+
if (!header) return void 0;
|
|
2370
|
+
for (const part of header.split(";")) {
|
|
2371
|
+
const eq = part.indexOf("=");
|
|
2372
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
2373
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
2374
|
+
}
|
|
2375
|
+
return void 0;
|
|
2376
|
+
}
|
|
2377
|
+
function checkCsrf(cms, ctx) {
|
|
2378
|
+
const origin = ctx.request.headers.get("origin");
|
|
2379
|
+
const fetchSite = ctx.request.headers.get("sec-fetch-site");
|
|
2380
|
+
const trusted = origin !== null && (origin === ctx.url.origin || cms.config.auth.trustedOrigins.includes(origin));
|
|
2381
|
+
if (origin !== null && !trusted) throw new ForbiddenError("CSRF check failed: untrusted origin");
|
|
2382
|
+
if (origin === null && fetchSite === "cross-site")
|
|
2383
|
+
throw new ForbiddenError("CSRF check failed: cross-site request");
|
|
2384
|
+
if (ctx.via === "cookie" && ctx.token) {
|
|
2385
|
+
const expected = cms.auth.csrfFor(ctx.token);
|
|
2386
|
+
const given = ctx.request.headers.get(CSRF_HEADER);
|
|
2387
|
+
if (!expected || !given || !safeEqual(given, expected)) {
|
|
2388
|
+
throw new ForbiddenError(
|
|
2389
|
+
`CSRF check failed: send the ${CSRF_HEADER} header from GET /users/me`
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
var TYPE_BY_EXTENSION = Object.fromEntries(
|
|
2395
|
+
Object.entries(EXTENSIONS).map(([type, ext]) => [ext, type])
|
|
2396
|
+
);
|
|
2397
|
+
async function serveFile(cms, key2, head) {
|
|
2398
|
+
if (!/^[\p{L}\p{M}\p{N}-]+\.[a-z0-9]+$/u.test(key2)) throw new HttpError("Not found", 404);
|
|
2399
|
+
const file = await cms.storage.get(key2);
|
|
2400
|
+
if (!file) throw new HttpError("Not found", 404);
|
|
2401
|
+
const extension = key2.slice(key2.lastIndexOf(".") + 1);
|
|
2402
|
+
return new Response(
|
|
2403
|
+
head ? null : file.body,
|
|
2404
|
+
{
|
|
2405
|
+
status: 200,
|
|
2406
|
+
headers: {
|
|
2407
|
+
"content-type": TYPE_BY_EXTENSION[extension] ?? "application/octet-stream",
|
|
2408
|
+
"content-length": String(file.size),
|
|
2409
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
2410
|
+
"x-content-type-options": "nosniff",
|
|
2411
|
+
"content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
|
2412
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
async function readUpload(request, maxFileSize) {
|
|
2418
|
+
const type = request.headers.get("content-type") ?? "";
|
|
2419
|
+
if (!type.toLowerCase().startsWith("multipart/form-data")) {
|
|
2420
|
+
throw new HttpError('Uploads must be multipart/form-data with a "file" field', 415);
|
|
2421
|
+
}
|
|
2422
|
+
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
2423
|
+
if (declared > maxFileSize + 64 * 1024)
|
|
2424
|
+
throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
|
|
2425
|
+
let form;
|
|
2426
|
+
try {
|
|
2427
|
+
form = await request.formData();
|
|
2428
|
+
} catch {
|
|
2429
|
+
throw new HttpError("Malformed multipart body", 400);
|
|
2430
|
+
}
|
|
2431
|
+
const file = form.get("file");
|
|
2432
|
+
if (!file || typeof file === "string")
|
|
2433
|
+
throw new ValidationError(MEDIA, [{ field: "file", message: "is required" }]);
|
|
2434
|
+
const data = {};
|
|
2435
|
+
for (const [key2, value] of form) {
|
|
2436
|
+
if (key2 !== "file" && typeof value === "string") data[key2] = value;
|
|
2437
|
+
}
|
|
2438
|
+
return {
|
|
2439
|
+
file: { data: new Uint8Array(await file.arrayBuffer()), name: file.name || "file" },
|
|
2440
|
+
data
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
async function readJson(request) {
|
|
2444
|
+
const type = request.headers.get("content-type") ?? "";
|
|
2445
|
+
if (!type.toLowerCase().startsWith("application/json")) {
|
|
2446
|
+
throw new HttpError("Content-Type must be application/json", 415);
|
|
2447
|
+
}
|
|
2448
|
+
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
2449
|
+
if (declared > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
|
|
2450
|
+
const text = await request.text();
|
|
2451
|
+
if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
|
|
2452
|
+
let body;
|
|
2453
|
+
try {
|
|
2454
|
+
body = JSON.parse(text);
|
|
2455
|
+
} catch {
|
|
2456
|
+
throw new HttpError("Request body is not valid JSON", 400);
|
|
2457
|
+
}
|
|
2458
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
2459
|
+
throw new HttpError("Request body must be a JSON object", 400);
|
|
2460
|
+
}
|
|
2461
|
+
return body;
|
|
2462
|
+
}
|
|
2463
|
+
function isSecure(ctx) {
|
|
2464
|
+
return ctx.url.protocol === "https:" || process.env.NODE_ENV === "production";
|
|
2465
|
+
}
|
|
2466
|
+
function cookie(ctx, name, value, opts) {
|
|
2467
|
+
const parts = [`${name}=${encodeURIComponent(value)}`, "Path=/", "SameSite=Lax"];
|
|
2468
|
+
if (opts.httpOnly) parts.push("HttpOnly");
|
|
2469
|
+
if (isSecure(ctx)) parts.push("Secure");
|
|
2470
|
+
if (opts.maxAge !== void 0) parts.push(`Max-Age=${opts.maxAge}`);
|
|
2471
|
+
return parts.join("; ");
|
|
2472
|
+
}
|
|
2473
|
+
function setSessionCookies(cms, ctx, session) {
|
|
2474
|
+
const maxAge = cms.config.auth.tokenExpiration;
|
|
2475
|
+
ctx.headers.append(
|
|
2476
|
+
"set-cookie",
|
|
2477
|
+
cookie(ctx, SESSION_COOKIE, session.token, { httpOnly: true, maxAge })
|
|
2478
|
+
);
|
|
2479
|
+
ctx.headers.append(
|
|
2480
|
+
"set-cookie",
|
|
2481
|
+
cookie(ctx, CSRF_COOKIE, session.csrfToken, { httpOnly: false, maxAge })
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
function clearSessionCookies(ctx) {
|
|
2485
|
+
ctx.headers.append("set-cookie", cookie(ctx, SESSION_COOKIE, "", { httpOnly: true, maxAge: 0 }));
|
|
2486
|
+
ctx.headers.append("set-cookie", cookie(ctx, CSRF_COOKIE, "", { httpOnly: false, maxAge: 0 }));
|
|
2487
|
+
}
|
|
2488
|
+
function errorResponse(cms, error, headers, production) {
|
|
2489
|
+
if (error instanceof EasyCMSError) {
|
|
2490
|
+
const errors = error instanceof ValidationError ? error.errors.map((e) => ({ message: e.message, field: e.field })) : [{ message: error.message }];
|
|
2491
|
+
if (error instanceof UnauthorizedError) headers.set("www-authenticate", "Bearer");
|
|
2492
|
+
return new Response(JSON.stringify({ errors }), { status: error.status, headers });
|
|
2493
|
+
}
|
|
2494
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2495
|
+
cms.logger.error(
|
|
2496
|
+
`REST request failed: ${error instanceof Error ? error.stack ?? message : message}`
|
|
2497
|
+
);
|
|
2498
|
+
const body = { errors: [{ message: production ? "Internal Server Error" : message }] };
|
|
2499
|
+
return new Response(JSON.stringify(body), { status: 500, headers });
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// src/typegen.ts
|
|
2503
|
+
var pascal = (name) => name.split(/[^A-Za-z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
2504
|
+
function singularize(word) {
|
|
2505
|
+
if (/ies$/i.test(word)) return word.replace(/ies$/i, "y");
|
|
2506
|
+
if (/(ss|us|ia)$/i.test(word)) return word;
|
|
2507
|
+
if (/(x|ch|sh)es$/i.test(word)) return word.replace(/es$/i, "");
|
|
2508
|
+
return word.replace(/s$/i, "");
|
|
2509
|
+
}
|
|
2510
|
+
var quote = (value) => JSON.stringify(value);
|
|
2511
|
+
var key = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : quote(name);
|
|
2512
|
+
function names(config) {
|
|
2513
|
+
const taken = /* @__PURE__ */ new Set(["ID", "RichTextDocument", "MediaSize", "Collections", "Globals"]);
|
|
2514
|
+
const claim = (base, suffix) => {
|
|
2515
|
+
let name = base || suffix;
|
|
2516
|
+
if (taken.has(name)) name = `${base}${suffix}`;
|
|
2517
|
+
for (let n = 2; taken.has(name); n++) name = `${base}${suffix}${n}`;
|
|
2518
|
+
taken.add(name);
|
|
2519
|
+
return name;
|
|
2520
|
+
};
|
|
2521
|
+
const collections = /* @__PURE__ */ new Map();
|
|
2522
|
+
for (const c of config.collections) {
|
|
2523
|
+
if (!INTERNAL_COLLECTIONS.has(c.slug))
|
|
2524
|
+
collections.set(c.slug, claim(pascal(singularize(c.slug)), "Document"));
|
|
2525
|
+
}
|
|
2526
|
+
const globals = /* @__PURE__ */ new Map();
|
|
2527
|
+
for (const g of config.globals) globals.set(g.slug, claim(pascal(g.slug), "Global"));
|
|
2528
|
+
return { collections, globals };
|
|
2529
|
+
}
|
|
2530
|
+
function fieldType(field, n, indent) {
|
|
2531
|
+
switch (field.type) {
|
|
2532
|
+
case "text":
|
|
2533
|
+
case "textarea":
|
|
2534
|
+
case "email":
|
|
2535
|
+
case "slug":
|
|
2536
|
+
case "date":
|
|
2537
|
+
return "string";
|
|
2538
|
+
case "number":
|
|
2539
|
+
return "number";
|
|
2540
|
+
case "boolean":
|
|
2541
|
+
return "boolean";
|
|
2542
|
+
case "json":
|
|
2543
|
+
return "unknown";
|
|
2544
|
+
case "richText":
|
|
2545
|
+
return "RichTextDocument";
|
|
2546
|
+
case "select": {
|
|
2547
|
+
const union = field.options.map((o) => quote(typeof o === "string" ? o : o.value)).join(" | ") || "string";
|
|
2548
|
+
return field.hasMany ? `(${union})[]` : union;
|
|
2549
|
+
}
|
|
2550
|
+
case "upload":
|
|
2551
|
+
return `ID | ${n.collections.get(MEDIA) ?? "Record<string, unknown>"}`;
|
|
2552
|
+
case "relationship": {
|
|
2553
|
+
const target = n.collections.get(field.to);
|
|
2554
|
+
const one = target ? `ID | ${target}` : "ID";
|
|
2555
|
+
return field.hasMany ? `(${one})[]` : one;
|
|
2556
|
+
}
|
|
2557
|
+
case "group":
|
|
2558
|
+
return `{
|
|
2559
|
+
${fieldLines(field.fields, n, `${indent} `).join("\n")}
|
|
2560
|
+
${indent}}`;
|
|
2561
|
+
case "array":
|
|
2562
|
+
return `{
|
|
2563
|
+
${indent} id: string
|
|
2564
|
+
${fieldLines(field.fields, n, `${indent} `).join("\n")}
|
|
2565
|
+
${indent}}[]`;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
function fieldLines(fields, n, indent) {
|
|
2569
|
+
const lines = [];
|
|
2570
|
+
for (const field of fields) {
|
|
2571
|
+
if (field.hidden) continue;
|
|
2572
|
+
const type = fieldType(field, n, indent);
|
|
2573
|
+
const alwaysPresent = field.type === "array" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
|
|
2574
|
+
if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
|
|
2575
|
+
else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
|
|
2576
|
+
}
|
|
2577
|
+
return lines;
|
|
2578
|
+
}
|
|
2579
|
+
function collectionInterface(collection2, name, n) {
|
|
2580
|
+
const lines = [" id: ID"];
|
|
2581
|
+
if (collection2.slug === MEDIA) {
|
|
2582
|
+
lines.push(" url: string", " sizes: Record<string, MediaSize>");
|
|
2583
|
+
}
|
|
2584
|
+
const fields = collection2.slug === MEDIA ? collection2.fields.filter((f) => f.name !== "sizes") : collection2.fields;
|
|
2585
|
+
lines.push(...fieldLines(fields, n, " "));
|
|
2586
|
+
if (collection2.drafts) lines.push(` status: 'draft' | 'published'`);
|
|
2587
|
+
lines.push(" createdAt: string", " updatedAt: string");
|
|
2588
|
+
return `export interface ${name} {
|
|
2589
|
+
${lines.join("\n")}
|
|
2590
|
+
}`;
|
|
2591
|
+
}
|
|
2592
|
+
function globalInterface(global2, name, n) {
|
|
2593
|
+
const lines = [...fieldLines(global2.fields, n, " ")];
|
|
2594
|
+
if (global2.drafts) lines.push(` status: 'draft' | 'published'`);
|
|
2595
|
+
lines.push(
|
|
2596
|
+
" /** `null` until the global is saved for the first time. */",
|
|
2597
|
+
" updatedAt: string | null"
|
|
2598
|
+
);
|
|
2599
|
+
return `export interface ${name} {
|
|
2600
|
+
${lines.join("\n")}
|
|
2601
|
+
}`;
|
|
2602
|
+
}
|
|
2603
|
+
function generateTypes(config) {
|
|
2604
|
+
const n = names(config);
|
|
2605
|
+
const parts = [
|
|
2606
|
+
"// Generated by `easy-cms generate:types`. Do not edit; run the command again after changing the config.",
|
|
2607
|
+
"",
|
|
2608
|
+
"/** Document ids are integers. */",
|
|
2609
|
+
"export type ID = number",
|
|
2610
|
+
"",
|
|
2611
|
+
"/** Rich text as stored: a Tiptap / ProseMirror JSON document. */",
|
|
2612
|
+
"export interface RichTextDocument {",
|
|
2613
|
+
` type: 'doc'`,
|
|
2614
|
+
" content?: unknown[]",
|
|
2615
|
+
"}",
|
|
2616
|
+
"",
|
|
2617
|
+
"export interface MediaSize {",
|
|
2618
|
+
" filename: string",
|
|
2619
|
+
" width: number",
|
|
2620
|
+
" height: number",
|
|
2621
|
+
" filesize: number",
|
|
2622
|
+
" url: string",
|
|
2623
|
+
"}"
|
|
2624
|
+
];
|
|
2625
|
+
for (const collection2 of config.collections) {
|
|
2626
|
+
const name = n.collections.get(collection2.slug);
|
|
2627
|
+
if (name) parts.push("", collectionInterface(collection2, name, n));
|
|
2628
|
+
}
|
|
2629
|
+
for (const global2 of config.globals) {
|
|
2630
|
+
parts.push("", globalInterface(global2, n.globals.get(global2.slug), n));
|
|
2631
|
+
}
|
|
2632
|
+
const map = (entries) => [...entries].map(([slug, name]) => ` ${key(slug)}: ${name}`).join("\n");
|
|
2633
|
+
parts.push(
|
|
2634
|
+
"",
|
|
2635
|
+
"/** Document type by collection slug. */",
|
|
2636
|
+
`export interface Collections {
|
|
2637
|
+
${map(n.collections)}
|
|
2638
|
+
}`
|
|
2639
|
+
);
|
|
2640
|
+
parts.push(
|
|
2641
|
+
"",
|
|
2642
|
+
"/** Data type by global slug. */",
|
|
2643
|
+
`export interface Globals {
|
|
2644
|
+
${map(n.globals)}
|
|
2645
|
+
}`,
|
|
2646
|
+
""
|
|
2647
|
+
);
|
|
2648
|
+
return parts.join("\n");
|
|
2649
|
+
}
|
|
2650
|
+
export {
|
|
2651
|
+
Auth,
|
|
2652
|
+
BUILTIN_COLLECTIONS,
|
|
2653
|
+
CONFIG_FILE_NAMES,
|
|
2654
|
+
CSRF_COOKIE,
|
|
2655
|
+
CSRF_HEADER,
|
|
2656
|
+
ConfigError,
|
|
2657
|
+
DEFAULT_ADMIN_PATH,
|
|
2658
|
+
DEFAULT_API_PATH,
|
|
2659
|
+
DEFAULT_DEPTH,
|
|
2660
|
+
DEFAULT_MAX_FILE_SIZE,
|
|
2661
|
+
DEFAULT_TOKEN_EXPIRATION,
|
|
2662
|
+
EXTENSIONS,
|
|
2663
|
+
EasyCMS,
|
|
2664
|
+
EasyCMSError,
|
|
2665
|
+
FIELD_TYPES,
|
|
2666
|
+
ForbiddenError,
|
|
2667
|
+
INTERNAL_COLLECTIONS,
|
|
2668
|
+
MAX_DEPTH,
|
|
2669
|
+
MEDIA,
|
|
2670
|
+
MIN_PASSWORD_LENGTH,
|
|
2671
|
+
MIN_SECRET_LENGTH,
|
|
2672
|
+
NotFoundError,
|
|
2673
|
+
PayloadTooLargeError,
|
|
2674
|
+
QueryError,
|
|
2675
|
+
SESSION_COOKIE,
|
|
2676
|
+
SchemaError,
|
|
2677
|
+
TooManyRequestsError,
|
|
2678
|
+
UnauthorizedError,
|
|
2679
|
+
ValidationError,
|
|
2680
|
+
anyone,
|
|
2681
|
+
applyDefaults,
|
|
2682
|
+
consoleLogger,
|
|
2683
|
+
createEasyCMS,
|
|
2684
|
+
createRestHandler,
|
|
2685
|
+
defineConfig,
|
|
2686
|
+
fillMissing,
|
|
2687
|
+
findConfigFile,
|
|
2688
|
+
generateSlugs,
|
|
2689
|
+
generateTypes,
|
|
2690
|
+
hashPassword,
|
|
2691
|
+
imageDimensions,
|
|
2692
|
+
importConfig,
|
|
2693
|
+
isAdmin,
|
|
2694
|
+
isLoggedIn,
|
|
2695
|
+
loadConfig,
|
|
2696
|
+
localStorage,
|
|
2697
|
+
mergeForUpdate,
|
|
2698
|
+
mimeAllowed,
|
|
2699
|
+
parseId,
|
|
2700
|
+
populate,
|
|
2701
|
+
readCookie,
|
|
2702
|
+
resolveConfig,
|
|
2703
|
+
silentLogger,
|
|
2704
|
+
singularize,
|
|
2705
|
+
slugify,
|
|
2706
|
+
sniffMimeType,
|
|
2707
|
+
validateConfig,
|
|
2708
|
+
validateFields,
|
|
2709
|
+
verifyPassword
|
|
2710
|
+
};
|