opacacms 0.3.18 → 0.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-1bd7fz7n.js → chunk-8mqs2q7j.js} +1 -1
- package/dist/{chunk-2abqb0h6.js → chunk-9dsw6x4x.js} +23 -16
- package/dist/{chunk-b1g8jmth.js → chunk-mvz5hmdb.js} +263 -8
- package/dist/cli/index.js +3 -2
- package/dist/db/better-sqlite.js +1632 -42
- package/dist/db/bun-sqlite.js +1627 -37
- package/dist/db/d1.js +2326 -31
- package/dist/db/index.js +29 -4
- package/dist/db/postgres.js +1623 -32
- package/dist/db/sqlite.js +1631 -41
- package/dist/index.js +7 -9
- package/dist/runtimes/bun.js +3 -7
- package/dist/runtimes/cloudflare-workers.js +3068 -13
- package/dist/runtimes/next.js +3 -7
- package/dist/runtimes/node.js +3 -7
- package/dist/server.js +18 -13
- package/dist/storage/index.js +6 -3
- package/package.json +1 -1
- package/dist/chunk-40tky6qh.js +0 -10
- package/dist/chunk-5xpf5jxd.js +0 -114
- package/dist/chunk-gzbz5jwy.js +0 -700
- package/dist/chunk-h8v093av.js +0 -185
- package/dist/chunk-jq1drsen.js +0 -82
- package/dist/chunk-q5sb5dcr.js +0 -15
- package/dist/chunk-re459gm9.js +0 -429
- package/dist/chunk-s8mqwnm1.js +0 -14
- package/dist/chunk-z9ek88xr.js +0 -15
|
@@ -1,26 +1,3081 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
function __accessProp(key) {
|
|
6
|
+
return this[key];
|
|
7
|
+
}
|
|
8
|
+
var __toCommonJS = (from) => {
|
|
9
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
10
|
+
if (entry)
|
|
11
|
+
return entry;
|
|
12
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (var key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(entry, key))
|
|
16
|
+
__defProp(entry, key, {
|
|
17
|
+
get: __accessProp.bind(from, key),
|
|
18
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
__moduleCache.set(from, entry);
|
|
22
|
+
return entry;
|
|
23
|
+
};
|
|
24
|
+
var __moduleCache;
|
|
25
|
+
var __returnValue = (v) => v;
|
|
26
|
+
function __exportSetter(name, newValue) {
|
|
27
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
28
|
+
}
|
|
29
|
+
var __export = (target, all) => {
|
|
30
|
+
for (var name in all)
|
|
31
|
+
__defProp(target, name, {
|
|
32
|
+
get: all[name],
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
set: __exportSetter.bind(all, name)
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
39
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
40
|
+
}) : x)(function(x) {
|
|
41
|
+
if (typeof require !== "undefined")
|
|
42
|
+
return require.apply(this, arguments);
|
|
43
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// src/db/system-schema.ts
|
|
47
|
+
var exports_system_schema = {};
|
|
48
|
+
__export(exports_system_schema, {
|
|
49
|
+
getSystemCollections: () => getSystemCollections
|
|
50
|
+
});
|
|
51
|
+
var getSystemCollections = () => [
|
|
52
|
+
{
|
|
53
|
+
slug: "_assets",
|
|
54
|
+
label: "Assets",
|
|
55
|
+
apiPath: "assets",
|
|
56
|
+
fields: [
|
|
57
|
+
{ name: "id", type: "text", required: true },
|
|
58
|
+
{ name: "key", type: "text", required: true },
|
|
59
|
+
{ name: "filename", type: "text", required: true },
|
|
60
|
+
{ name: "originalFilename", type: "text", required: true },
|
|
61
|
+
{ name: "mimeType", type: "text", required: true },
|
|
62
|
+
{ name: "filesize", type: "number", required: true },
|
|
63
|
+
{ name: "bucket", type: "text", required: true },
|
|
64
|
+
{ name: "folder", type: "text" },
|
|
65
|
+
{ name: "altText", type: "text" },
|
|
66
|
+
{ name: "caption", type: "text" },
|
|
67
|
+
{ name: "uploadedBy", type: "text" }
|
|
68
|
+
],
|
|
69
|
+
timestamps: true
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
slug: "_users",
|
|
73
|
+
apiPath: "users",
|
|
74
|
+
fields: [
|
|
75
|
+
{ name: "id", type: "text", required: true },
|
|
76
|
+
{ name: "name", type: "text", required: true },
|
|
77
|
+
{ name: "email", type: "text", required: true, unique: true },
|
|
78
|
+
{ name: "emailVerified", type: "boolean", required: true, defaultValue: false },
|
|
79
|
+
{ name: "image", type: "text" },
|
|
80
|
+
{ name: "role", type: "text" },
|
|
81
|
+
{ name: "banned", type: "boolean" },
|
|
82
|
+
{ name: "banReason", type: "text" },
|
|
83
|
+
{ name: "banExpires", type: "date" }
|
|
84
|
+
],
|
|
85
|
+
timestamps: true
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
slug: "_sessions",
|
|
89
|
+
fields: [
|
|
90
|
+
{ name: "id", type: "text", required: true },
|
|
91
|
+
{ name: "expiresAt", type: "date", required: true },
|
|
92
|
+
{ name: "token", type: "text", required: true, unique: true },
|
|
93
|
+
{ name: "ipAddress", type: "text" },
|
|
94
|
+
{ name: "userAgent", type: "text" },
|
|
95
|
+
{
|
|
96
|
+
name: "userId",
|
|
97
|
+
type: "text",
|
|
98
|
+
required: true,
|
|
99
|
+
references: { table: "_users", column: "id", onDelete: "cascade" }
|
|
100
|
+
},
|
|
101
|
+
{ name: "impersonatedBy", type: "text" }
|
|
102
|
+
],
|
|
103
|
+
timestamps: true,
|
|
104
|
+
hidden: true
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
slug: "_accounts",
|
|
108
|
+
fields: [
|
|
109
|
+
{ name: "id", type: "text", required: true },
|
|
110
|
+
{ name: "accountId", type: "text", required: true },
|
|
111
|
+
{ name: "providerId", type: "text", required: true },
|
|
112
|
+
{
|
|
113
|
+
name: "userId",
|
|
114
|
+
type: "text",
|
|
115
|
+
required: true,
|
|
116
|
+
references: { table: "_users", column: "id", onDelete: "cascade" }
|
|
117
|
+
},
|
|
118
|
+
{ name: "accessToken", type: "text" },
|
|
119
|
+
{ name: "refreshToken", type: "text" },
|
|
120
|
+
{ name: "idToken", type: "text" },
|
|
121
|
+
{ name: "accessTokenExpiresAt", type: "date" },
|
|
122
|
+
{ name: "refreshTokenExpiresAt", type: "date" },
|
|
123
|
+
{ name: "scope", type: "text" },
|
|
124
|
+
{ name: "password", type: "text" }
|
|
125
|
+
],
|
|
126
|
+
timestamps: true,
|
|
127
|
+
hidden: true
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
slug: "_verifications",
|
|
131
|
+
fields: [
|
|
132
|
+
{ name: "id", type: "text", required: true },
|
|
133
|
+
{ name: "identifier", type: "text", required: true },
|
|
134
|
+
{ name: "value", type: "text", required: true },
|
|
135
|
+
{ name: "expiresAt", type: "date", required: true }
|
|
136
|
+
],
|
|
137
|
+
timestamps: true,
|
|
138
|
+
hidden: true
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
slug: "_api_keys",
|
|
142
|
+
fields: [
|
|
143
|
+
{ name: "id", type: "text", required: true },
|
|
144
|
+
{ name: "configId", type: "text", required: true },
|
|
145
|
+
{ name: "name", type: "text" },
|
|
146
|
+
{ name: "start", type: "text" },
|
|
147
|
+
{ name: "prefix", type: "text" },
|
|
148
|
+
{ name: "key", type: "text", required: true },
|
|
149
|
+
{ name: "referenceId", type: "text", required: true },
|
|
150
|
+
{ name: "refillInterval", type: "number" },
|
|
151
|
+
{ name: "refillAmount", type: "number" },
|
|
152
|
+
{ name: "lastRefillAt", type: "date" },
|
|
153
|
+
{ name: "enabled", type: "boolean", required: true },
|
|
154
|
+
{ name: "rateLimitEnabled", type: "boolean", required: true },
|
|
155
|
+
{ name: "rateLimitTimeWindow", type: "number" },
|
|
156
|
+
{ name: "rateLimitMax", type: "number" },
|
|
157
|
+
{ name: "requestCount", type: "number", required: true },
|
|
158
|
+
{ name: "remaining", type: "number" },
|
|
159
|
+
{ name: "lastRequest", type: "date" },
|
|
160
|
+
{ name: "expiresAt", type: "date" },
|
|
161
|
+
{ name: "permissions", type: "text" },
|
|
162
|
+
{ name: "metadata", type: "text" }
|
|
163
|
+
],
|
|
164
|
+
timestamps: { createdAt: "createdAt", updatedAt: "updatedAt" },
|
|
165
|
+
hidden: true
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
slug: "_plugin_settings",
|
|
169
|
+
label: "Plugin Settings",
|
|
170
|
+
apiPath: "plugin-settings",
|
|
171
|
+
fields: [
|
|
172
|
+
{ name: "pluginName", type: "text", required: true, unique: true },
|
|
173
|
+
{ name: "config", type: "json", required: true }
|
|
174
|
+
],
|
|
175
|
+
timestamps: true,
|
|
176
|
+
hidden: false,
|
|
177
|
+
admin: {
|
|
178
|
+
hidden: true,
|
|
179
|
+
disableAdmin: true
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
slug: "_audit_logs",
|
|
184
|
+
label: "Audit Logs",
|
|
185
|
+
apiPath: "audit-logs",
|
|
186
|
+
fields: [
|
|
187
|
+
{ name: "id", type: "text", required: true },
|
|
188
|
+
{ name: "operation", type: "text", required: true },
|
|
189
|
+
{ name: "collection", type: "text", required: true },
|
|
190
|
+
{ name: "entity_id", type: "text", required: true },
|
|
191
|
+
{ name: "user_id", type: "text" },
|
|
192
|
+
{ name: "previous_data", type: "json" },
|
|
193
|
+
{ name: "new_data", type: "json" },
|
|
194
|
+
{ name: "timestamp", type: "date" }
|
|
195
|
+
],
|
|
196
|
+
timestamps: true,
|
|
197
|
+
admin: {
|
|
198
|
+
hidden: true,
|
|
199
|
+
disableAdmin: true
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
slug: "_doc_versions",
|
|
204
|
+
label: "Document Versions",
|
|
205
|
+
apiPath: "doc-versions",
|
|
206
|
+
fields: [
|
|
207
|
+
{ name: "id", type: "text", required: true },
|
|
208
|
+
{ name: "collection", type: "text", required: true },
|
|
209
|
+
{ name: "entity_id", type: "text", required: true },
|
|
210
|
+
{ name: "data", type: "json", required: true },
|
|
211
|
+
{ name: "status", type: "text" },
|
|
212
|
+
{ name: "autosave", type: "boolean", defaultValue: false },
|
|
213
|
+
{ name: "version_name", type: "text" },
|
|
214
|
+
{ name: "created_by", type: "text" }
|
|
215
|
+
],
|
|
216
|
+
timestamps: true,
|
|
217
|
+
admin: {
|
|
218
|
+
hidden: true,
|
|
219
|
+
disableAdmin: true
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
// src/schema/compiler.ts
|
|
225
|
+
var exports_compiler = {};
|
|
226
|
+
__export(exports_compiler, {
|
|
227
|
+
zodToOpacaFields: () => zodToOpacaFields
|
|
228
|
+
});
|
|
229
|
+
function zodToOpacaFields(schema, currentPath = "") {
|
|
230
|
+
if (!schema || !schema._def) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
let innerSchema = schema;
|
|
234
|
+
let meta = {};
|
|
235
|
+
while (innerSchema && innerSchema._def && ["optional", "nullable", "default"].includes(innerSchema._def.type)) {
|
|
236
|
+
const currentMeta = typeof innerSchema.meta === "function" ? innerSchema.meta() : innerSchema._def.meta;
|
|
237
|
+
if (currentMeta?.opaca) {
|
|
238
|
+
meta = { ...currentMeta.opaca, ...meta };
|
|
239
|
+
}
|
|
240
|
+
innerSchema = innerSchema._def.innerType;
|
|
241
|
+
}
|
|
242
|
+
if (innerSchema && innerSchema._def) {
|
|
243
|
+
const finalMeta = typeof innerSchema.meta === "function" ? innerSchema.meta() : innerSchema._def.meta;
|
|
244
|
+
if (finalMeta?.opaca) {
|
|
245
|
+
meta = { ...finalMeta.opaca, ...meta };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const def = schema._def;
|
|
249
|
+
const defaultValue = def.type === "default" && typeof def.defaultValue === "function" ? def.defaultValue() : def.defaultValue;
|
|
250
|
+
const baseConfig = {
|
|
251
|
+
name: currentPath,
|
|
252
|
+
label: meta.admin?.label || currentPath,
|
|
253
|
+
required: !schema.isOptional(),
|
|
254
|
+
unique: meta.unique || false,
|
|
255
|
+
localized: meta.localized || false,
|
|
256
|
+
virtual: meta.virtual || meta.isVirtual || false,
|
|
257
|
+
requiredCondition: meta.requiredCondition || meta.required,
|
|
258
|
+
condition: meta.condition || meta.admin?.condition,
|
|
259
|
+
defaultValue,
|
|
260
|
+
admin: {
|
|
261
|
+
...meta.admin,
|
|
262
|
+
condition: meta.condition || meta.admin?.condition,
|
|
263
|
+
requiredCondition: meta.requiredCondition || meta.required
|
|
264
|
+
},
|
|
265
|
+
...meta.admin,
|
|
266
|
+
...meta
|
|
267
|
+
};
|
|
268
|
+
const OPACA_TYPES = [
|
|
269
|
+
"text",
|
|
270
|
+
"slug",
|
|
271
|
+
"textarea",
|
|
272
|
+
"number",
|
|
273
|
+
"richtext",
|
|
274
|
+
"relationship",
|
|
275
|
+
"select",
|
|
276
|
+
"radio",
|
|
277
|
+
"date",
|
|
278
|
+
"boolean",
|
|
279
|
+
"json",
|
|
280
|
+
"file",
|
|
281
|
+
"blocks",
|
|
282
|
+
"group",
|
|
283
|
+
"row",
|
|
284
|
+
"collapsible",
|
|
285
|
+
"tabs",
|
|
286
|
+
"join",
|
|
287
|
+
"array",
|
|
288
|
+
"virtual",
|
|
289
|
+
"ui"
|
|
290
|
+
];
|
|
291
|
+
const type = innerSchema._def.type;
|
|
292
|
+
if (type === "string") {
|
|
293
|
+
const component = meta.admin?.component;
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
...baseConfig,
|
|
297
|
+
type: component && OPACA_TYPES.includes(component) ? component : "text"
|
|
298
|
+
}
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
if (type === "number") {
|
|
302
|
+
return [{ ...baseConfig, type: "number" }];
|
|
303
|
+
}
|
|
304
|
+
if (type === "boolean") {
|
|
305
|
+
return [{ ...baseConfig, type: "boolean" }];
|
|
306
|
+
}
|
|
307
|
+
if (type === "date") {
|
|
308
|
+
return [{ ...baseConfig, type: "date" }];
|
|
309
|
+
}
|
|
310
|
+
if (type === "enum") {
|
|
311
|
+
const component = meta.admin?.component;
|
|
312
|
+
const choices = innerSchema._def.values || Object.values(innerSchema.enum || {});
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
...baseConfig,
|
|
316
|
+
type: component && OPACA_TYPES.includes(component) ? component : "select",
|
|
317
|
+
options: {
|
|
318
|
+
choices
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
if (type === "object") {
|
|
324
|
+
const shape = innerSchema.shape;
|
|
325
|
+
const nestedFields = Object.entries(shape).flatMap(([key, val]) => zodToOpacaFields(val, key));
|
|
326
|
+
if (currentPath === "") {
|
|
327
|
+
return nestedFields;
|
|
328
|
+
}
|
|
329
|
+
const component = meta.admin?.component || "group";
|
|
330
|
+
if (component === "tabs" && meta.admin?.tabsConfig) {
|
|
331
|
+
return [
|
|
332
|
+
{
|
|
333
|
+
...baseConfig,
|
|
334
|
+
type: "tabs",
|
|
335
|
+
tabs: meta.admin.tabsConfig.map((t) => ({
|
|
336
|
+
label: t.label,
|
|
337
|
+
fields: nestedFields.filter((f) => t.fields.includes(f.name))
|
|
338
|
+
}))
|
|
339
|
+
}
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
return [
|
|
343
|
+
{
|
|
344
|
+
...baseConfig,
|
|
345
|
+
type: component,
|
|
346
|
+
fields: nestedFields
|
|
347
|
+
}
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
if (type === "array") {
|
|
351
|
+
const elementSchema = innerSchema._def.element;
|
|
352
|
+
const component = meta.admin?.component || "array";
|
|
353
|
+
const isDiscUnion = elementSchema && elementSchema._def.type === "union" && elementSchema._def.discriminator;
|
|
354
|
+
if (component === "blocks" || isDiscUnion) {
|
|
355
|
+
let blocks = [];
|
|
356
|
+
if (isDiscUnion) {
|
|
357
|
+
const options = elementSchema._def.options;
|
|
358
|
+
blocks = options.map((obj) => {
|
|
359
|
+
const discriminator = elementSchema._def.discriminator;
|
|
360
|
+
const shape = obj.shape;
|
|
361
|
+
const discriminatorField = shape?.[discriminator];
|
|
362
|
+
const slug = discriminatorField?._def.values?.[0] || "block";
|
|
363
|
+
return {
|
|
364
|
+
slug,
|
|
365
|
+
fields: zodToOpacaFields(obj)
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return [
|
|
370
|
+
{
|
|
371
|
+
...baseConfig,
|
|
372
|
+
type: "blocks",
|
|
373
|
+
blocks
|
|
374
|
+
}
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
return [
|
|
378
|
+
{
|
|
379
|
+
...baseConfig,
|
|
380
|
+
type: "array",
|
|
381
|
+
fields: elementSchema ? zodToOpacaFields(elementSchema, "") : []
|
|
382
|
+
}
|
|
383
|
+
];
|
|
384
|
+
}
|
|
385
|
+
if (meta.admin?.component) {
|
|
386
|
+
return [
|
|
387
|
+
{
|
|
388
|
+
...baseConfig,
|
|
389
|
+
type: meta.admin.component
|
|
390
|
+
}
|
|
391
|
+
];
|
|
392
|
+
}
|
|
393
|
+
return [
|
|
394
|
+
{
|
|
395
|
+
...baseConfig,
|
|
396
|
+
type: "json"
|
|
397
|
+
}
|
|
398
|
+
];
|
|
399
|
+
}
|
|
12
400
|
|
|
13
401
|
// src/runtimes/cloudflare-workers.ts
|
|
402
|
+
import { Hono as Hono5 } from "hono";
|
|
403
|
+
|
|
404
|
+
// src/server/router.ts
|
|
405
|
+
import { Hono as Hono4 } from "hono";
|
|
406
|
+
|
|
407
|
+
// src/server/openapi.ts
|
|
408
|
+
import { z as z2 } from "zod";
|
|
409
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
410
|
+
|
|
411
|
+
// src/validator.ts
|
|
412
|
+
import { z } from "zod";
|
|
413
|
+
function generateSchemaForCollection(collection, isUpdate = false, forDocs = false) {
|
|
414
|
+
let shape;
|
|
415
|
+
if (collection.schema && collection.schema._def.type === "object") {
|
|
416
|
+
const originalShape = collection.schema.shape;
|
|
417
|
+
shape = { ...originalShape };
|
|
418
|
+
if (isUpdate) {
|
|
419
|
+
for (const key in shape) {
|
|
420
|
+
const field = shape[key];
|
|
421
|
+
if (field) {
|
|
422
|
+
shape[key] = field.optional().nullable();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
shape = mapFieldsToShape(collection.fields, isUpdate, forDocs);
|
|
428
|
+
}
|
|
429
|
+
if (collection.versions) {
|
|
430
|
+
shape._status = z.enum(["draft", "published"]).optional().nullable();
|
|
431
|
+
}
|
|
432
|
+
const ts = collection.timestamps;
|
|
433
|
+
if (ts !== false && ts !== undefined) {
|
|
434
|
+
const config = typeof ts === "object" ? ts : {};
|
|
435
|
+
const createdField = config.createdAt || "createdAt";
|
|
436
|
+
const updatedField = config.updatedAt || "updatedAt";
|
|
437
|
+
const dateSchema = forDocs ? z.string() : z.union([z.string(), z.date()]);
|
|
438
|
+
shape[createdField] = dateSchema.optional().nullable();
|
|
439
|
+
shape[updatedField] = dateSchema.optional().nullable();
|
|
440
|
+
}
|
|
441
|
+
let baseSchema = z.object(shape);
|
|
442
|
+
if (!isUpdate) {
|
|
443
|
+
baseSchema = baseSchema.superRefine((data, ctx) => {
|
|
444
|
+
const checkFields = (fields, currentData, path = []) => {
|
|
445
|
+
if (!fields)
|
|
446
|
+
return;
|
|
447
|
+
fields.forEach((field) => {
|
|
448
|
+
if (field.requiredCondition && typeof field.requiredCondition === "function") {
|
|
449
|
+
const isRequired = field.requiredCondition(data);
|
|
450
|
+
const value = currentData?.[field.name];
|
|
451
|
+
if (isRequired && (value === undefined || value === null || value === "")) {
|
|
452
|
+
ctx.addIssue({
|
|
453
|
+
code: z.ZodIssueCode.custom,
|
|
454
|
+
message: `${field.label || field.name} is required`,
|
|
455
|
+
path: [...path, field.name]
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
460
|
+
checkFields(field.fields, currentData?.[field.name], [...path, field.name]);
|
|
461
|
+
}
|
|
462
|
+
if (field.tabs && Array.isArray(field.tabs)) {
|
|
463
|
+
field.tabs.forEach((tab) => {
|
|
464
|
+
checkFields(tab.fields, currentData, path);
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
checkFields(collection.fields, data);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return baseSchema;
|
|
473
|
+
}
|
|
474
|
+
function mapFieldsToShape(fields, isUpdate = false, forDocs = false) {
|
|
475
|
+
const shape = {};
|
|
476
|
+
for (const field of fields) {
|
|
477
|
+
if (field.type === "virtual" || field.type === "ui")
|
|
478
|
+
continue;
|
|
479
|
+
if (!field.name) {
|
|
480
|
+
if (field.type === "tabs" && field.tabs) {
|
|
481
|
+
for (const tab of field.tabs) {
|
|
482
|
+
Object.assign(shape, mapFieldsToShape(tab.fields, isUpdate, forDocs));
|
|
483
|
+
}
|
|
484
|
+
} else if (field.type === "group" && field.fields) {
|
|
485
|
+
Object.assign(shape, mapFieldsToShape(field.fields, isUpdate, forDocs));
|
|
486
|
+
} else if (field.type === "row" && field.fields) {
|
|
487
|
+
Object.assign(shape, mapFieldsToShape(field.fields, isUpdate, forDocs));
|
|
488
|
+
} else if (field.type === "collapsible" && field.fields) {
|
|
489
|
+
Object.assign(shape, mapFieldsToShape(field.fields, isUpdate, forDocs));
|
|
490
|
+
}
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const fieldName = field.name;
|
|
494
|
+
let schema;
|
|
495
|
+
if (field.type === "group" && field.fields) {
|
|
496
|
+
schema = z.object(mapFieldsToShape(field.fields, isUpdate, forDocs));
|
|
497
|
+
} else if (field.type === "blocks" && field.blocks) {
|
|
498
|
+
const blockSchemas = field.blocks.map((block) => z.object({
|
|
499
|
+
blockType: z.literal(block.slug),
|
|
500
|
+
...mapFieldsToShape(block.fields, isUpdate, forDocs)
|
|
501
|
+
}));
|
|
502
|
+
schema = z.array(z.union(blockSchemas));
|
|
503
|
+
} else if (field.type === "row" || field.type === "collapsible") {
|
|
504
|
+
if (field.fields) {
|
|
505
|
+
schema = z.object(mapFieldsToShape(field.fields, isUpdate, forDocs));
|
|
506
|
+
} else {
|
|
507
|
+
schema = z.any();
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
schema = mapFieldToZod(field, forDocs);
|
|
511
|
+
}
|
|
512
|
+
if (field.localized) {
|
|
513
|
+
schema = z.union([z.record(z.string(), schema), schema]);
|
|
514
|
+
}
|
|
515
|
+
if (field.required && !isUpdate) {
|
|
516
|
+
if (field.type === "slug" && field.from) {
|
|
517
|
+
schema = schema.optional().nullable();
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
schema = schema.optional().nullable();
|
|
521
|
+
}
|
|
522
|
+
if (field.defaultValue !== undefined && !isUpdate) {
|
|
523
|
+
schema = schema.default(field.defaultValue);
|
|
524
|
+
}
|
|
525
|
+
if (field.validate) {
|
|
526
|
+
if (typeof field.validate === "function") {
|
|
527
|
+
schema = schema.superRefine((val, ctx) => {
|
|
528
|
+
if (val === undefined || val === null)
|
|
529
|
+
return;
|
|
530
|
+
const result = field.validate(val);
|
|
531
|
+
if (result !== true) {
|
|
532
|
+
ctx.addIssue({
|
|
533
|
+
code: z.ZodIssueCode.custom,
|
|
534
|
+
message: typeof result === "string" ? result : "Invalid field"
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
} else if (field.validate instanceof z.ZodType) {
|
|
539
|
+
schema = z.intersection(schema, field.validate);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
shape[fieldName] = schema;
|
|
543
|
+
}
|
|
544
|
+
return shape;
|
|
545
|
+
}
|
|
546
|
+
function mapFieldToZod(field, forDocs = false) {
|
|
547
|
+
switch (field.type) {
|
|
548
|
+
case "text":
|
|
549
|
+
case "slug":
|
|
550
|
+
case "textarea":
|
|
551
|
+
return z.string();
|
|
552
|
+
case "richtext":
|
|
553
|
+
return z.any();
|
|
554
|
+
case "relationship":
|
|
555
|
+
return field.hasMany ? z.array(z.string()) : z.string();
|
|
556
|
+
case "select":
|
|
557
|
+
case "radio": {
|
|
558
|
+
const choices = field.options?.choices || [];
|
|
559
|
+
const values = choices.map((c) => typeof c === "string" ? c : c.value);
|
|
560
|
+
if (values.length > 0) {
|
|
561
|
+
return z.enum(values);
|
|
562
|
+
}
|
|
563
|
+
return z.string();
|
|
564
|
+
}
|
|
565
|
+
case "date":
|
|
566
|
+
return forDocs ? z.string() : z.union([z.string(), z.date()]);
|
|
567
|
+
case "boolean":
|
|
568
|
+
return z.preprocess((val) => {
|
|
569
|
+
if (val === "true" || val === 1)
|
|
570
|
+
return true;
|
|
571
|
+
if (val === "false" || val === 0)
|
|
572
|
+
return false;
|
|
573
|
+
return val;
|
|
574
|
+
}, z.boolean());
|
|
575
|
+
case "number":
|
|
576
|
+
return z.preprocess((val) => {
|
|
577
|
+
if (val === "" || val === undefined || val === null)
|
|
578
|
+
return;
|
|
579
|
+
if (typeof val === "string") {
|
|
580
|
+
const num = Number(val);
|
|
581
|
+
return Number.isNaN(num) ? undefined : num;
|
|
582
|
+
}
|
|
583
|
+
return val;
|
|
584
|
+
}, z.number());
|
|
585
|
+
case "json":
|
|
586
|
+
return z.any();
|
|
587
|
+
case "file":
|
|
588
|
+
return z.object({
|
|
589
|
+
id: z.string(),
|
|
590
|
+
url: z.string(),
|
|
591
|
+
filename: z.string(),
|
|
592
|
+
mime_type: z.string(),
|
|
593
|
+
filesize: z.number(),
|
|
594
|
+
width: z.number().optional().nullable(),
|
|
595
|
+
height: z.number().optional().nullable(),
|
|
596
|
+
focal_x: z.number().optional().nullable(),
|
|
597
|
+
focal_y: z.number().optional().nullable()
|
|
598
|
+
});
|
|
599
|
+
case "array":
|
|
600
|
+
return z.array(z.any());
|
|
601
|
+
default:
|
|
602
|
+
return z.any();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/server/openapi.ts
|
|
607
|
+
function generateOpenAPISchema(config) {
|
|
608
|
+
const openapi = {
|
|
609
|
+
openapi: "3.1.0",
|
|
610
|
+
info: {
|
|
611
|
+
title: config.appName || "OpacaCMS API",
|
|
612
|
+
version: "1.0.0",
|
|
613
|
+
description: "Automatically generated OpenAPI schema for OpacaCMS Collections and Globals."
|
|
614
|
+
},
|
|
615
|
+
paths: {},
|
|
616
|
+
components: {
|
|
617
|
+
schemas: {},
|
|
618
|
+
securitySchemes: {
|
|
619
|
+
bearerAuth: {
|
|
620
|
+
type: "http",
|
|
621
|
+
scheme: "bearer"
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
for (const collection of config.collections || []) {
|
|
627
|
+
const isHidden = collection.hidden === true;
|
|
628
|
+
if (isHidden)
|
|
629
|
+
continue;
|
|
630
|
+
const pathBase = `/api/${collection.apiPath || collection.slug}`;
|
|
631
|
+
const tag = collection.label || collection.slug;
|
|
632
|
+
const zodSchema = generateSchemaForCollection(collection, false, true);
|
|
633
|
+
const schemaName = collection.slug.charAt(0).toUpperCase() + collection.slug.slice(1);
|
|
634
|
+
const jsonSchema = typeof z2.toJSONSchema === "function" ? z2.toJSONSchema(zodSchema, { target: "openapi-3.0" }) : zodToJsonSchema(zodSchema, { target: "openApi3" });
|
|
635
|
+
openapi.components.schemas[schemaName] = jsonSchema;
|
|
636
|
+
const ref = `#/components/schemas/${schemaName}`;
|
|
637
|
+
openapi.paths[pathBase] = {
|
|
638
|
+
get: {
|
|
639
|
+
tags: [tag],
|
|
640
|
+
summary: `Find ${tag}`,
|
|
641
|
+
parameters: [
|
|
642
|
+
{ name: "limit", in: "query", schema: { type: "integer" } },
|
|
643
|
+
{ name: "page", in: "query", schema: { type: "integer" } }
|
|
644
|
+
],
|
|
645
|
+
responses: {
|
|
646
|
+
"200": {
|
|
647
|
+
description: "Successful response",
|
|
648
|
+
content: {
|
|
649
|
+
"application/json": {
|
|
650
|
+
schema: {
|
|
651
|
+
type: "object",
|
|
652
|
+
properties: {
|
|
653
|
+
docs: { type: "array", items: { $ref: ref } },
|
|
654
|
+
totalDocs: { type: "integer" }
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
post: {
|
|
663
|
+
tags: [tag],
|
|
664
|
+
summary: `Create ${tag}`,
|
|
665
|
+
requestBody: {
|
|
666
|
+
required: true,
|
|
667
|
+
content: {
|
|
668
|
+
"application/json": { schema: { $ref: ref } }
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
responses: {
|
|
672
|
+
"201": {
|
|
673
|
+
description: "Created successfully",
|
|
674
|
+
content: { "application/json": { schema: { $ref: ref } } }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
openapi.paths[`${pathBase}/{id}`] = {
|
|
680
|
+
get: {
|
|
681
|
+
tags: [tag],
|
|
682
|
+
summary: `Find ${tag} by ID`,
|
|
683
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
684
|
+
responses: {
|
|
685
|
+
"200": {
|
|
686
|
+
description: "Successful response",
|
|
687
|
+
content: { "application/json": { schema: { $ref: ref } } }
|
|
688
|
+
},
|
|
689
|
+
"404": { description: "Not found" }
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
patch: {
|
|
693
|
+
tags: [tag],
|
|
694
|
+
summary: `Update ${tag}`,
|
|
695
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
696
|
+
requestBody: {
|
|
697
|
+
content: { "application/json": { schema: { $ref: ref } } }
|
|
698
|
+
},
|
|
699
|
+
responses: {
|
|
700
|
+
"200": {
|
|
701
|
+
description: "Updated successfully",
|
|
702
|
+
content: { "application/json": { schema: { $ref: ref } } }
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
delete: {
|
|
707
|
+
tags: [tag],
|
|
708
|
+
summary: `Delete ${tag}`,
|
|
709
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
710
|
+
responses: {
|
|
711
|
+
"200": { description: "Deleted successfully" }
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
for (const global of config.globals || []) {
|
|
717
|
+
const pathBase = `/api/globals/${global.slug}`;
|
|
718
|
+
const tag = global.label || global.slug;
|
|
719
|
+
const zodSchema = generateSchemaForCollection(global, false, true);
|
|
720
|
+
const schemaName = global.slug.charAt(0).toUpperCase() + global.slug.slice(1);
|
|
721
|
+
const jsonSchema = typeof z2.toJSONSchema === "function" ? z2.toJSONSchema(zodSchema, { target: "openapi-3.0" }) : zodToJsonSchema(zodSchema, { target: "openApi3" });
|
|
722
|
+
openapi.components.schemas[schemaName] = jsonSchema;
|
|
723
|
+
const ref = `#/components/schemas/${schemaName}`;
|
|
724
|
+
openapi.paths[pathBase] = {
|
|
725
|
+
get: {
|
|
726
|
+
tags: [tag],
|
|
727
|
+
summary: `Find ${tag}`,
|
|
728
|
+
responses: {
|
|
729
|
+
"200": {
|
|
730
|
+
description: "Successful response",
|
|
731
|
+
content: { "application/json": { schema: { $ref: ref } } }
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
post: {
|
|
736
|
+
tags: [tag],
|
|
737
|
+
summary: `Update ${tag}`,
|
|
738
|
+
requestBody: {
|
|
739
|
+
content: { "application/json": { schema: { $ref: ref } } }
|
|
740
|
+
},
|
|
741
|
+
responses: {
|
|
742
|
+
"200": {
|
|
743
|
+
description: "Updated successfully",
|
|
744
|
+
content: { "application/json": { schema: { $ref: ref } } }
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
return openapi;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/server/routers/admin.ts
|
|
14
754
|
import { Hono } from "hono";
|
|
755
|
+
|
|
756
|
+
// src/config-utils.ts
|
|
757
|
+
function sanitizeConfig(config, settings = {}) {
|
|
758
|
+
const collections = [...config.collections];
|
|
759
|
+
const supportsAuth = ["sqlite", "postgres", "d1", "bun-sqlite", "better-sqlite3"].includes(config.db.name);
|
|
760
|
+
const systemCollections = getSystemCollections();
|
|
761
|
+
for (const systemCol of systemCollections) {
|
|
762
|
+
const isAsset = systemCol.slug === "_assets";
|
|
763
|
+
const isAuth = ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(systemCol.slug);
|
|
764
|
+
if (isAsset && config.storages || isAuth && supportsAuth) {
|
|
765
|
+
if (!collections.find((col) => col.slug === systemCol.slug)) {
|
|
766
|
+
collections.push({
|
|
767
|
+
...systemCol,
|
|
768
|
+
admin: true
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const serializeFn = (fn) => {
|
|
774
|
+
if (typeof fn !== "function")
|
|
775
|
+
return fn;
|
|
776
|
+
return `__opaca_fn__:${fn.toString()}`;
|
|
777
|
+
};
|
|
778
|
+
const sanitizeField = (f) => ({
|
|
779
|
+
name: f.name,
|
|
780
|
+
type: f.type,
|
|
781
|
+
label: f.label,
|
|
782
|
+
required: f.required,
|
|
783
|
+
unique: f.unique,
|
|
784
|
+
defaultValue: f.defaultValue,
|
|
785
|
+
localized: f.localized,
|
|
786
|
+
condition: serializeFn(f.condition),
|
|
787
|
+
requiredCondition: serializeFn(f.requiredCondition),
|
|
788
|
+
admin: f.admin ? {
|
|
789
|
+
description: f.admin.description,
|
|
790
|
+
hidden: f.admin.hidden,
|
|
791
|
+
readOnly: f.admin.readOnly,
|
|
792
|
+
components: f.admin.components,
|
|
793
|
+
condition: serializeFn(f.admin.condition),
|
|
794
|
+
requiredCondition: serializeFn(f.admin.requiredCondition)
|
|
795
|
+
} : undefined,
|
|
796
|
+
options: f.options,
|
|
797
|
+
fields: f.fields ? f.fields.map(sanitizeField) : undefined,
|
|
798
|
+
relationTo: f.relationTo,
|
|
799
|
+
displayField: f.displayField,
|
|
800
|
+
collection: f.collection,
|
|
801
|
+
on: f.on,
|
|
802
|
+
blocks: f.blocks ? f.blocks.map((b) => ({
|
|
803
|
+
slug: b.slug,
|
|
804
|
+
label: b.label,
|
|
805
|
+
fields: b.fields.map(sanitizeField)
|
|
806
|
+
})) : undefined,
|
|
807
|
+
tabs: f.tabs ? f.tabs.map((t) => ({
|
|
808
|
+
label: t.label,
|
|
809
|
+
fields: t.fields.map(sanitizeField)
|
|
810
|
+
})) : undefined
|
|
811
|
+
});
|
|
812
|
+
return {
|
|
813
|
+
appName: config.appName,
|
|
814
|
+
serverURL: config.serverURL,
|
|
815
|
+
collections: collections.map((col) => ({
|
|
816
|
+
slug: col.slug,
|
|
817
|
+
apiPath: col.apiPath,
|
|
818
|
+
label: col.label,
|
|
819
|
+
icon: col.icon,
|
|
820
|
+
admin: col.admin,
|
|
821
|
+
hidden: col.hidden,
|
|
822
|
+
fields: col.fields.map(sanitizeField),
|
|
823
|
+
timestamps: col.timestamps,
|
|
824
|
+
auth: col.auth,
|
|
825
|
+
versions: col.versions
|
|
826
|
+
})),
|
|
827
|
+
globals: config.globals?.map((g) => ({
|
|
828
|
+
slug: g.slug,
|
|
829
|
+
label: g.label,
|
|
830
|
+
icon: g.icon,
|
|
831
|
+
fields: g.fields.map(sanitizeField)
|
|
832
|
+
})),
|
|
833
|
+
storages: config.storages ? Object.keys(config.storages).reduce((acc, key) => {
|
|
834
|
+
acc[key] = {};
|
|
835
|
+
return acc;
|
|
836
|
+
}, {}) : {},
|
|
837
|
+
i18n: config.i18n,
|
|
838
|
+
plugins: config.plugins?.map((p) => ({
|
|
839
|
+
name: p.name,
|
|
840
|
+
label: p.label,
|
|
841
|
+
description: p.description,
|
|
842
|
+
version: p.version,
|
|
843
|
+
author: p.author,
|
|
844
|
+
homepage: p.homepage,
|
|
845
|
+
icon: p.icon,
|
|
846
|
+
adminAssets: p.adminAssets ? p.adminAssets() : undefined,
|
|
847
|
+
adminUI: p.adminUI,
|
|
848
|
+
settings: settings[p.name] || {},
|
|
849
|
+
configSchema: p.configSchema ? (Array.isArray(p.configSchema) ? p.configSchema : __toCommonJS(exports_compiler).zodToOpacaFields(p.configSchema)).map(sanitizeField) : undefined
|
|
850
|
+
})),
|
|
851
|
+
api: config.api ? {
|
|
852
|
+
rest: config.api.rest,
|
|
853
|
+
graphql: config.api.graphql
|
|
854
|
+
} : undefined
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// src/server/admin.ts
|
|
859
|
+
function createAdminHandlers(config, settings, getAuth) {
|
|
860
|
+
const getMetadata = (c) => {
|
|
861
|
+
return c.json(sanitizeConfig(config, settings));
|
|
862
|
+
};
|
|
863
|
+
const getCollections = (c) => {
|
|
864
|
+
const collections = [...config.collections];
|
|
865
|
+
const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
|
|
866
|
+
const { getSystemCollections: getSystemCollections2 } = __toCommonJS(exports_system_schema);
|
|
867
|
+
const systemCollections = getSystemCollections2();
|
|
868
|
+
for (const systemCol of systemCollections) {
|
|
869
|
+
const isAsset = systemCol.slug === "_assets";
|
|
870
|
+
const isAuth = ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(systemCol.slug);
|
|
871
|
+
if (isAsset && config.storages || isAuth && supportsAuth) {
|
|
872
|
+
if (!collections.find((col) => col.slug === systemCol.slug)) {
|
|
873
|
+
collections.push({
|
|
874
|
+
...systemCol,
|
|
875
|
+
admin: true
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const filteredCollections = collections.filter((c2) => !c2.hidden);
|
|
881
|
+
return c.json({
|
|
882
|
+
collections: filteredCollections,
|
|
883
|
+
globals: config.globals
|
|
884
|
+
});
|
|
885
|
+
};
|
|
886
|
+
const getConfig = async (c) => {
|
|
887
|
+
return c.json({
|
|
888
|
+
serverURL: config.serverURL,
|
|
889
|
+
admin: config.admin
|
|
890
|
+
});
|
|
891
|
+
};
|
|
892
|
+
const getSetupStatus = async (c) => {
|
|
893
|
+
try {
|
|
894
|
+
let userCount = 0;
|
|
895
|
+
try {
|
|
896
|
+
userCount = await config.db.count("_users");
|
|
897
|
+
} catch (_e) {
|
|
898
|
+
const result = await config.db.unsafe("SELECT COUNT(*) as count FROM _users");
|
|
899
|
+
const rows = result?.results || result || [];
|
|
900
|
+
userCount = Number(rows[0]?.count || rows[0]?.["count(*)"] || 0);
|
|
901
|
+
}
|
|
902
|
+
return c.json({
|
|
903
|
+
initialized: userCount > 0,
|
|
904
|
+
api: sanitizeConfig(config, settings).api
|
|
905
|
+
});
|
|
906
|
+
} catch (e) {
|
|
907
|
+
console.error("[OpacaCMS] Failed to check setup status:", e);
|
|
908
|
+
return c.json({
|
|
909
|
+
initialized: false
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
const createApiKey = async (c) => {
|
|
914
|
+
const auth = getAuth();
|
|
915
|
+
if (!auth) {
|
|
916
|
+
return c.json({ message: "Auth not initialized" }, 503);
|
|
917
|
+
}
|
|
918
|
+
const user = c.get("user");
|
|
919
|
+
if (!user) {
|
|
920
|
+
return c.json({ message: "Unauthorized" }, 401);
|
|
921
|
+
}
|
|
922
|
+
try {
|
|
923
|
+
const { name, expiresIn, permissions } = await c.req.json();
|
|
924
|
+
if (!name || typeof name !== "string") {
|
|
925
|
+
return c.json({ message: "Invalid or missing 'name'" }, 400);
|
|
926
|
+
}
|
|
927
|
+
const res = await auth.api.createApiKey({
|
|
928
|
+
body: {
|
|
929
|
+
name,
|
|
930
|
+
expiresIn: expiresIn ? Number(expiresIn) : undefined,
|
|
931
|
+
permissions,
|
|
932
|
+
userId: user.id
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
return c.json(res);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
console.error("[OpacaCMS] Failed to create API key:", err);
|
|
938
|
+
const message = err?.message || (typeof err === "string" ? err : JSON.stringify(err));
|
|
939
|
+
return c.json({ message, detail: err }, 400);
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
const getPluginSettings = async (c) => {
|
|
943
|
+
const name = c.req.param("name");
|
|
944
|
+
if (!name)
|
|
945
|
+
return c.json({ error: "Plugin name is required" }, 400);
|
|
946
|
+
const pluginSettings = settings[name] || {};
|
|
947
|
+
return c.json(pluginSettings);
|
|
948
|
+
};
|
|
949
|
+
return {
|
|
950
|
+
getMetadata,
|
|
951
|
+
getCollections,
|
|
952
|
+
getConfig,
|
|
953
|
+
getSetupStatus,
|
|
954
|
+
createApiKey,
|
|
955
|
+
getPluginSettings
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/server/middlewares/admin.ts
|
|
960
|
+
var adminMiddleware = async (c, next) => {
|
|
961
|
+
const user = c.get("user");
|
|
962
|
+
const config = c.get("config");
|
|
963
|
+
const isDevelopment = true;
|
|
964
|
+
const path = c.req.path;
|
|
965
|
+
const isMetadata = path.endsWith("/__admin/metadata");
|
|
966
|
+
const isSetup = path.endsWith("/__admin/setup");
|
|
967
|
+
const secretHeader = c.req.header("X-Opaca-Secret");
|
|
968
|
+
const isSecretValid = config?.secret && secretHeader && secretHeader === config.secret;
|
|
969
|
+
if (isSetup) {
|
|
970
|
+
await next();
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (isMetadata) {
|
|
974
|
+
if (isDevelopment || isSecretValid) {
|
|
975
|
+
await next();
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (user) {
|
|
980
|
+
const isAdmin = user.role === "admin" || user.role?.includes("admin");
|
|
981
|
+
if (isAdmin) {
|
|
982
|
+
await next();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
986
|
+
}
|
|
987
|
+
return c.json({ message: "Unauthorized" }, 401);
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// src/server/routers/admin.ts
|
|
991
|
+
function createAdminRouter(config, settings, state) {
|
|
992
|
+
const adminRouter = new Hono;
|
|
993
|
+
const adminHandlers = createAdminHandlers(config, settings, () => state.auth);
|
|
994
|
+
adminRouter.get("/collections", adminMiddleware, adminHandlers.getCollections);
|
|
995
|
+
adminRouter.get("/metadata", adminMiddleware, adminHandlers.getMetadata);
|
|
996
|
+
adminRouter.get("/config", adminMiddleware, adminHandlers.getConfig);
|
|
997
|
+
adminRouter.get("/setup", adminHandlers.getSetupStatus);
|
|
998
|
+
adminRouter.get("/plugin-settings/:name", adminMiddleware, adminHandlers.getPluginSettings);
|
|
999
|
+
adminRouter.post("/api-key/create", adminMiddleware, adminHandlers.createApiKey);
|
|
1000
|
+
return adminRouter;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// src/server/routers/auth.ts
|
|
1004
|
+
import { Hono as Hono2 } from "hono";
|
|
1005
|
+
function createAuthRouter(config, state) {
|
|
1006
|
+
const authRouter = new Hono2;
|
|
1007
|
+
authRouter.all("/*", async (c) => {
|
|
1008
|
+
if (!state.auth) {
|
|
1009
|
+
return c.json({ message: "Auth not initialized" }, 503);
|
|
1010
|
+
}
|
|
1011
|
+
return await state.auth.handler(c.req.raw);
|
|
1012
|
+
});
|
|
1013
|
+
return authRouter;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/server/routers/collections.ts
|
|
1017
|
+
import { Hono as Hono3 } from "hono";
|
|
1018
|
+
|
|
1019
|
+
// src/server/assets.ts
|
|
1020
|
+
function createAssetsHandlers(config) {
|
|
1021
|
+
return {
|
|
1022
|
+
async upload(c) {
|
|
1023
|
+
const user = c.get("user");
|
|
1024
|
+
if (!user)
|
|
1025
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
1026
|
+
const bucket = c.req.query("bucket") || "default";
|
|
1027
|
+
if (!config.storages)
|
|
1028
|
+
return c.json({ error: "Storage not configured" }, 500);
|
|
1029
|
+
const storageAdapter = config.storages[bucket];
|
|
1030
|
+
if (!storageAdapter) {
|
|
1031
|
+
return c.json({ error: `Bucket '${bucket}' not found` }, 404);
|
|
1032
|
+
}
|
|
1033
|
+
try {
|
|
1034
|
+
try {
|
|
1035
|
+
if (config.db.name === "sqlite" || config.db.name === "d1") {
|
|
1036
|
+
const tableInfo = await config.db.unsafe(`PRAGMA table_info(_assets)`);
|
|
1037
|
+
const columns = tableInfo.map((c2) => c2.name);
|
|
1038
|
+
if (!columns.includes("folder"))
|
|
1039
|
+
await config.db.unsafe(`ALTER TABLE _assets ADD COLUMN folder TEXT`);
|
|
1040
|
+
if (!columns.includes("alt_text"))
|
|
1041
|
+
await config.db.unsafe(`ALTER TABLE _assets ADD COLUMN alt_text TEXT`);
|
|
1042
|
+
if (!columns.includes("caption"))
|
|
1043
|
+
await config.db.unsafe(`ALTER TABLE _assets ADD COLUMN caption TEXT`);
|
|
1044
|
+
} else if (config.db.name === "postgres") {
|
|
1045
|
+
const checkCols = await config.db.unsafe(`
|
|
1046
|
+
SELECT column_name FROM information_schema.columns
|
|
1047
|
+
WHERE table_name = '_assets' AND column_name IN ('folder', 'alt_text', 'caption')
|
|
1048
|
+
`);
|
|
1049
|
+
const existing = checkCols.map((c2) => c2.column_name);
|
|
1050
|
+
if (!existing.includes("folder"))
|
|
1051
|
+
await config.db.unsafe(`ALTER TABLE _assets ADD COLUMN folder TEXT`);
|
|
1052
|
+
if (!existing.includes("alt_text"))
|
|
1053
|
+
await config.db.unsafe(`ALTER TABLE _assets ADD COLUMN "alt_text" TEXT`);
|
|
1054
|
+
if (!existing.includes("caption"))
|
|
1055
|
+
await config.db.unsafe(`ALTER TABLE _assets ADD COLUMN caption TEXT`);
|
|
1056
|
+
}
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
console.error("Auto-patch columns failed", e);
|
|
1059
|
+
}
|
|
1060
|
+
const folder = c.req.query("folder") || null;
|
|
1061
|
+
const keyPrefix = folder ? `${folder}/` : "";
|
|
1062
|
+
const now = new Date().toISOString();
|
|
1063
|
+
const formData = await c.req.parseBody({ all: true });
|
|
1064
|
+
const fileRaw = formData["file"];
|
|
1065
|
+
const file = Array.isArray(fileRaw) ? fileRaw[0] : fileRaw;
|
|
1066
|
+
if (!file || typeof file !== "object" && typeof file !== "string") {
|
|
1067
|
+
return c.json({ error: "No file provided" }, 400);
|
|
1068
|
+
}
|
|
1069
|
+
const fileName = file.name || "unnamed";
|
|
1070
|
+
const fileType = file.type || "application/octet-stream";
|
|
1071
|
+
const fileSize = file.size || 0;
|
|
1072
|
+
const fileRecord = {
|
|
1073
|
+
filename: fileName,
|
|
1074
|
+
original_filename: fileName,
|
|
1075
|
+
mime_type: fileType,
|
|
1076
|
+
filesize: fileSize,
|
|
1077
|
+
stream: typeof file.stream === "function" ? file.stream() : new Response(file).body
|
|
1078
|
+
};
|
|
1079
|
+
const uploadedFileData = await storageAdapter.upload(fileRecord, {
|
|
1080
|
+
generateUniqueName: true,
|
|
1081
|
+
keyPrefix
|
|
1082
|
+
});
|
|
1083
|
+
const storedKey = keyPrefix + uploadedFileData.filename;
|
|
1084
|
+
try {
|
|
1085
|
+
const assetId = (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "");
|
|
1086
|
+
await config.db.create("_assets", {
|
|
1087
|
+
id: assetId,
|
|
1088
|
+
key: storedKey,
|
|
1089
|
+
filename: fileName,
|
|
1090
|
+
originalFilename: fileName,
|
|
1091
|
+
mimeType: uploadedFileData.mime_type,
|
|
1092
|
+
filesize: uploadedFileData.filesize,
|
|
1093
|
+
bucket,
|
|
1094
|
+
folder,
|
|
1095
|
+
altText: null,
|
|
1096
|
+
caption: null,
|
|
1097
|
+
uploadedBy: user.id || null
|
|
1098
|
+
});
|
|
1099
|
+
return c.json({
|
|
1100
|
+
assetId,
|
|
1101
|
+
...uploadedFileData,
|
|
1102
|
+
key: storedKey
|
|
1103
|
+
}, 201);
|
|
1104
|
+
} catch (dbError) {
|
|
1105
|
+
console.error(`[OpacaCMS] Registry insert failed, rolling back physical file upload: ${storedKey}`);
|
|
1106
|
+
storageAdapter.delete(storedKey).catch((cleanupError) => {
|
|
1107
|
+
console.error(`[OpacaCMS] CRITICAL: Failed to clean up orphaned file ${storedKey}!`, cleanupError);
|
|
1108
|
+
});
|
|
1109
|
+
throw dbError;
|
|
1110
|
+
}
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
return c.json({ error: error.message }, 400);
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
async list(c) {
|
|
1116
|
+
const user = c.get("user");
|
|
1117
|
+
if (!user || user.role !== "admin" && !user.role?.includes("admin")) {
|
|
1118
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
1119
|
+
}
|
|
1120
|
+
const bucket = c.req.query("bucket") || "all";
|
|
1121
|
+
const page = parseInt(c.req.query("page") || "1", 10);
|
|
1122
|
+
const limit = parseInt(c.req.query("limit") || "20", 10);
|
|
1123
|
+
const offset = (page - 1) * limit;
|
|
1124
|
+
const folder = c.req.query("folder") || null;
|
|
1125
|
+
try {
|
|
1126
|
+
let query = {};
|
|
1127
|
+
if (bucket !== "all")
|
|
1128
|
+
query.bucket = bucket;
|
|
1129
|
+
if (folder !== null && folder !== "") {
|
|
1130
|
+
query.folder = folder;
|
|
1131
|
+
} else {
|
|
1132
|
+
if (bucket !== "all") {
|
|
1133
|
+
query = {
|
|
1134
|
+
and: [{ bucket }, { or: [{ folder: null }, { folder: "" }] }]
|
|
1135
|
+
};
|
|
1136
|
+
} else {
|
|
1137
|
+
query = { or: [{ folder: null }, { folder: "" }] };
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const result = await config.db.find("_assets", query, {
|
|
1141
|
+
page,
|
|
1142
|
+
limit,
|
|
1143
|
+
sort: "created_at:desc"
|
|
1144
|
+
});
|
|
1145
|
+
const rows = result.docs;
|
|
1146
|
+
const total = result.totalDocs;
|
|
1147
|
+
let folderRows = [];
|
|
1148
|
+
if (config.db.name === "postgres") {
|
|
1149
|
+
if (folder === null || folder === "") {
|
|
1150
|
+
folderRows = await config.db.unsafe("SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _assets WHERE folder IS NOT NULL AND folder != '' AND (bucket = $1 OR $1 = 'all')", [bucket]);
|
|
1151
|
+
} else {
|
|
1152
|
+
folderRows = await config.db.unsafe("SELECT DISTINCT split_part(substring(folder from length($1) + 2), '/', 1) as subfolder, bucket FROM _assets WHERE folder LIKE $2 AND (bucket = $3 OR $3 = 'all')", [folder, `${folder}/%`, bucket]);
|
|
1153
|
+
}
|
|
1154
|
+
} else {
|
|
1155
|
+
if (folder === null || folder === "") {
|
|
1156
|
+
folderRows = await config.db.unsafe(`
|
|
1157
|
+
SELECT DISTINCT
|
|
1158
|
+
CASE
|
|
1159
|
+
WHEN INSTR(folder, '/') > 0 THEN SUBSTR(folder, 1, INSTR(folder, '/') - 1)
|
|
1160
|
+
ELSE folder
|
|
1161
|
+
END as subfolder,
|
|
1162
|
+
bucket
|
|
1163
|
+
FROM _assets WHERE folder IS NOT NULL AND folder != '' AND (bucket = ? OR ? = 'all')
|
|
1164
|
+
`, [bucket, bucket]);
|
|
1165
|
+
} else {
|
|
1166
|
+
const skipLen = folder.length + 2;
|
|
1167
|
+
folderRows = await config.db.unsafe(`
|
|
1168
|
+
SELECT DISTINCT
|
|
1169
|
+
CASE
|
|
1170
|
+
WHEN INSTR(SUBSTR(folder, ?), '/') > 0 THEN SUBSTR(SUBSTR(folder, ?), 1, INSTR(SUBSTR(folder, ?), '/') - 1)
|
|
1171
|
+
ELSE SUBSTR(folder, ?)
|
|
1172
|
+
END as subfolder,
|
|
1173
|
+
bucket
|
|
1174
|
+
FROM _assets WHERE folder LIKE ? AND (bucket = ? OR ? = 'all')
|
|
1175
|
+
`, [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, bucket, bucket]);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const folderMap = {};
|
|
1179
|
+
for (const row of folderRows) {
|
|
1180
|
+
if (!row.subfolder)
|
|
1181
|
+
continue;
|
|
1182
|
+
if (!folderMap[row.subfolder])
|
|
1183
|
+
folderMap[row.subfolder] = [];
|
|
1184
|
+
if (!folderMap[row.subfolder]?.includes(row.bucket)) {
|
|
1185
|
+
folderMap[row.subfolder]?.push(row.bucket);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const folders = Object.entries(folderMap).map(([name, buckets]) => ({
|
|
1189
|
+
name,
|
|
1190
|
+
buckets
|
|
1191
|
+
}));
|
|
1192
|
+
return c.json({
|
|
1193
|
+
docs: rows,
|
|
1194
|
+
folders,
|
|
1195
|
+
totalDocs: total,
|
|
1196
|
+
limit,
|
|
1197
|
+
page,
|
|
1198
|
+
totalPages: Math.ceil(total / limit)
|
|
1199
|
+
});
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
return c.json({ error: e.message }, 500);
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
async presign(c) {
|
|
1205
|
+
const user = c.get("user");
|
|
1206
|
+
if (!user)
|
|
1207
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
1208
|
+
const { filename, bucket = "default", operation = "write" } = await c.req.json();
|
|
1209
|
+
if (!config.storages || !config.storages[bucket]) {
|
|
1210
|
+
return c.json({ error: "Bucket not found" }, 404);
|
|
1211
|
+
}
|
|
1212
|
+
const adapter = config.storages[bucket];
|
|
1213
|
+
if (!adapter.generatePresignedUrl) {
|
|
1214
|
+
return c.json({ error: "Adapter does not support presigned URLs" }, 400);
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
const url = await adapter.generatePresignedUrl(filename, operation, 3600);
|
|
1218
|
+
return c.json({ uploadUrl: url, filename });
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
return c.json({ error: e.message }, 500);
|
|
1221
|
+
}
|
|
1222
|
+
},
|
|
1223
|
+
async serve(c) {
|
|
1224
|
+
const id = c.req.param("id");
|
|
1225
|
+
try {
|
|
1226
|
+
const asset = await config.db.findOne("_assets", { id });
|
|
1227
|
+
if (!asset) {
|
|
1228
|
+
return c.json({ error: "Asset not found" }, 404);
|
|
1229
|
+
}
|
|
1230
|
+
const bucket = asset.bucket || "default";
|
|
1231
|
+
if (!config.storages || !config.storages[bucket]) {
|
|
1232
|
+
return c.json({ error: "Storage bucket not configured" }, 500);
|
|
1233
|
+
}
|
|
1234
|
+
const adapter = config.storages[bucket];
|
|
1235
|
+
if (!adapter.download) {
|
|
1236
|
+
return c.json({ error: "Storage adapter does not support direct downloads" }, 400);
|
|
1237
|
+
}
|
|
1238
|
+
const stream = await adapter.download(asset.key || asset.filename);
|
|
1239
|
+
c.header("Content-Type", asset.mimeType || "application/octet-stream");
|
|
1240
|
+
c.header("Content-Length", asset.filesize.toString());
|
|
1241
|
+
c.header("Cache-Control", "public, max-age=86400");
|
|
1242
|
+
return c.body(stream);
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
console.error(`[OpacaCMS] Failed to serve asset ${id}:`, e);
|
|
1245
|
+
return c.json({ error: e.message }, 500);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
// src/utils/logger.ts
|
|
1251
|
+
var RESET = "\x1B[0m";
|
|
1252
|
+
var BOLD = "\x1B[1m";
|
|
1253
|
+
var BLUE = "\x1B[34m";
|
|
1254
|
+
var GREEN = "\x1B[32m";
|
|
1255
|
+
var YELLOW = "\x1B[33m";
|
|
1256
|
+
var RED = "\x1B[31m";
|
|
1257
|
+
var GRAY = "\x1B[90m";
|
|
1258
|
+
var PREFIX = `${BLUE}${BOLD}[OpacaCMS]${RESET}`;
|
|
1259
|
+
var LOG_LEVELS = {
|
|
1260
|
+
debug: 0,
|
|
1261
|
+
info: 1,
|
|
1262
|
+
warn: 2,
|
|
1263
|
+
error: 3
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
class OpacaLogger {
|
|
1267
|
+
config;
|
|
1268
|
+
constructor(config = {}) {
|
|
1269
|
+
this.config = config;
|
|
1270
|
+
}
|
|
1271
|
+
shouldLog(level) {
|
|
1272
|
+
if (this.config.disabled)
|
|
1273
|
+
return false;
|
|
1274
|
+
const configLevel = this.config.level || "info";
|
|
1275
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[configLevel];
|
|
1276
|
+
}
|
|
1277
|
+
info(message, ...args) {
|
|
1278
|
+
if (!this.shouldLog("info"))
|
|
1279
|
+
return;
|
|
1280
|
+
console.log(`${PREFIX} ${message}`, ...args);
|
|
1281
|
+
}
|
|
1282
|
+
success(message, ...args) {
|
|
1283
|
+
if (!this.shouldLog("info"))
|
|
1284
|
+
return;
|
|
1285
|
+
console.log(`${PREFIX} ${GREEN}${message}${RESET}`, ...args);
|
|
1286
|
+
}
|
|
1287
|
+
debug(message, ...args) {
|
|
1288
|
+
if (!this.shouldLog("debug"))
|
|
1289
|
+
return;
|
|
1290
|
+
console.log(`${PREFIX} ${GRAY}${message}${RESET}`, ...args);
|
|
1291
|
+
}
|
|
1292
|
+
warn(message, ...args) {
|
|
1293
|
+
if (!this.shouldLog("warn"))
|
|
1294
|
+
return;
|
|
1295
|
+
console.warn(`${PREFIX} ${YELLOW}Warning: ${message}${RESET}`, ...args);
|
|
1296
|
+
}
|
|
1297
|
+
error(message, ...args) {
|
|
1298
|
+
if (!this.shouldLog("error"))
|
|
1299
|
+
return;
|
|
1300
|
+
console.error(`${PREFIX} ${RED}Error: ${message}${RESET}`, ...args);
|
|
1301
|
+
}
|
|
1302
|
+
log(message, ...args) {
|
|
1303
|
+
if (this.config.disabled)
|
|
1304
|
+
return;
|
|
1305
|
+
console.log(message, ...args);
|
|
1306
|
+
}
|
|
1307
|
+
bold(msg) {
|
|
1308
|
+
if (this.config.disableColors)
|
|
1309
|
+
return msg;
|
|
1310
|
+
return `${BOLD}${msg}${RESET}`;
|
|
1311
|
+
}
|
|
1312
|
+
format(color, msg) {
|
|
1313
|
+
if (this.config.disableColors)
|
|
1314
|
+
return msg;
|
|
1315
|
+
switch (color) {
|
|
1316
|
+
case "green":
|
|
1317
|
+
return `${GREEN}${msg}${RESET}`;
|
|
1318
|
+
case "red":
|
|
1319
|
+
return `${RED}${msg}${RESET}`;
|
|
1320
|
+
case "yellow":
|
|
1321
|
+
return `${YELLOW}${msg}${RESET}`;
|
|
1322
|
+
case "gray":
|
|
1323
|
+
return `${GRAY}${msg}${RESET}`;
|
|
1324
|
+
default:
|
|
1325
|
+
return msg;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
var logger = new OpacaLogger;
|
|
1330
|
+
|
|
1331
|
+
// src/utils/webhooks-engine.ts
|
|
1332
|
+
async function verifyWebhookSignature(req, secretOrFn, headerName = "x-opaca-signature") {
|
|
1333
|
+
if (typeof secretOrFn === "function") {
|
|
1334
|
+
return secretOrFn(req);
|
|
1335
|
+
}
|
|
1336
|
+
const signature = req.headers.get(headerName);
|
|
1337
|
+
if (!signature || !secretOrFn)
|
|
1338
|
+
return false;
|
|
1339
|
+
try {
|
|
1340
|
+
const body = await req.clone().text();
|
|
1341
|
+
const encoder = new TextEncoder;
|
|
1342
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secretOrFn).buffer, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
1343
|
+
const sigArray = hexToUint8Array(signature);
|
|
1344
|
+
return await crypto.subtle.verify("HMAC", key, sigArray.buffer, encoder.encode(body).buffer);
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
logger.error("[Webhooks] Signature verification failed:", err);
|
|
1347
|
+
return false;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async function dispatchWebhook(args) {
|
|
1351
|
+
const { url, data, event, collection, headers, retries = 3, timeoutMs = 30000, transform } = args;
|
|
1352
|
+
let payload = data;
|
|
1353
|
+
if (transform) {
|
|
1354
|
+
try {
|
|
1355
|
+
payload = await transform(data);
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
logger.error(`[Webhooks] Transformation failed for ${collection} [${event}]`, err);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
const fullPayload = {
|
|
1361
|
+
event,
|
|
1362
|
+
collection,
|
|
1363
|
+
data: payload,
|
|
1364
|
+
timestamp: new Date().toISOString()
|
|
1365
|
+
};
|
|
1366
|
+
let lastError = null;
|
|
1367
|
+
const controller = new AbortController;
|
|
1368
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1369
|
+
const attempt = async (retryCount) => {
|
|
1370
|
+
try {
|
|
1371
|
+
const response = await fetch(url, {
|
|
1372
|
+
method: "POST",
|
|
1373
|
+
headers: {
|
|
1374
|
+
"Content-Type": "application/json",
|
|
1375
|
+
"X-Opaca-Event": event,
|
|
1376
|
+
"X-Opaca-Collection": collection,
|
|
1377
|
+
...headers
|
|
1378
|
+
},
|
|
1379
|
+
body: JSON.stringify(fullPayload),
|
|
1380
|
+
signal: controller.signal
|
|
1381
|
+
});
|
|
1382
|
+
if (!response.ok) {
|
|
1383
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1384
|
+
}
|
|
1385
|
+
logger.info(`[Webhooks] Sent to ${url} [${collection}:${event}]`);
|
|
1386
|
+
return;
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
lastError = err;
|
|
1389
|
+
logger.warn(`[Webhooks] Attempt ${retryCount + 1}/${retries} failed for ${url} [${collection}:${event}]: ${err.message}`);
|
|
1390
|
+
if (retryCount < retries) {
|
|
1391
|
+
const delay = 2 ** retryCount * 1000;
|
|
1392
|
+
logger.warn(`[Webhooks] Delivery failed to ${url}. Retrying in ${delay}ms... (Attempt ${retryCount + 1}/${retries})`);
|
|
1393
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1394
|
+
return attempt(retryCount + 1);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
try {
|
|
1399
|
+
await attempt(0);
|
|
1400
|
+
} catch {} finally {
|
|
1401
|
+
clearTimeout(timeout);
|
|
1402
|
+
if (lastError) {
|
|
1403
|
+
const msg = lastError.message || String(lastError);
|
|
1404
|
+
logger.error(`[Webhooks] Final delivery failure to ${url} after ${retries} attempts. Last error: ${msg}`);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
function hexToUint8Array(hex) {
|
|
1409
|
+
const matches = hex.match(/.{1,2}/g);
|
|
1410
|
+
if (!matches)
|
|
1411
|
+
return new Uint8Array;
|
|
1412
|
+
return new Uint8Array(matches.map((byte) => parseInt(byte, 16)));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/server/handlers.ts
|
|
1416
|
+
var hydrateDoc = async (doc, fields, c, config) => {
|
|
1417
|
+
if (!doc)
|
|
1418
|
+
return doc;
|
|
1419
|
+
const user = c.get("user");
|
|
1420
|
+
const session = c.get("session");
|
|
1421
|
+
const apiKey = c.get("apiKey");
|
|
1422
|
+
const hydratePromises = fields.map(async (field) => {
|
|
1423
|
+
if (field.type === "virtual" && typeof field.resolve === "function") {
|
|
1424
|
+
try {
|
|
1425
|
+
doc[field.name] = await field.resolve({
|
|
1426
|
+
data: doc,
|
|
1427
|
+
req: c,
|
|
1428
|
+
user,
|
|
1429
|
+
session,
|
|
1430
|
+
apiKey
|
|
1431
|
+
});
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
console.error(`[OpacaCMS] Failed to resolve virtual field ${field.name} `, err);
|
|
1434
|
+
doc[field.name] = null;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
1438
|
+
await hydrateDoc(doc, field.fields, c, config);
|
|
1439
|
+
}
|
|
1440
|
+
if (field.tabs && Array.isArray(field.tabs)) {
|
|
1441
|
+
for (const tab of field.tabs) {
|
|
1442
|
+
if (tab.fields && Array.isArray(tab.fields)) {
|
|
1443
|
+
await hydrateDoc(doc, tab.fields, c, config);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
if (field.type === "group" && field.fields && doc[field.name]) {
|
|
1448
|
+
await hydrateDoc(doc[field.name], field.fields, c, config);
|
|
1449
|
+
}
|
|
1450
|
+
if (field.type === "array" && field.fields && Array.isArray(doc[field.name])) {
|
|
1451
|
+
await Promise.all(doc[field.name].map((item) => hydrateDoc(item, field.fields, c, config)));
|
|
1452
|
+
}
|
|
1453
|
+
if (field.type === "blocks" && field.blocks && Array.isArray(doc[field.name])) {
|
|
1454
|
+
await Promise.all(doc[field.name].map((item) => {
|
|
1455
|
+
const block = field.blocks.find((b) => b.slug === item.block_type);
|
|
1456
|
+
if (block && block.fields) {
|
|
1457
|
+
return hydrateDoc(item, block.fields, c, config);
|
|
1458
|
+
}
|
|
1459
|
+
return Promise.resolve();
|
|
1460
|
+
}));
|
|
1461
|
+
}
|
|
1462
|
+
if (field.localized && doc[field.name] && typeof doc[field.name] === "object") {
|
|
1463
|
+
const i18nConfig = config.i18n;
|
|
1464
|
+
const requestedLocale = c.req.header("x-opaca-locale") || c.req.query("locale");
|
|
1465
|
+
const targetLocale = requestedLocale || i18nConfig?.defaultLocale || "en";
|
|
1466
|
+
if (targetLocale !== "all") {
|
|
1467
|
+
const localizedValue = doc[field.name][targetLocale] ?? doc[field.name][i18nConfig?.defaultLocale || "en"] ?? "";
|
|
1468
|
+
doc[field.name] = localizedValue;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
await Promise.all(hydratePromises);
|
|
1473
|
+
return doc;
|
|
1474
|
+
};
|
|
1475
|
+
function parsePopulate(populate) {
|
|
1476
|
+
if (!populate)
|
|
1477
|
+
return {};
|
|
1478
|
+
if (typeof populate === "object" && !Array.isArray(populate)) {
|
|
1479
|
+
return populate;
|
|
1480
|
+
}
|
|
1481
|
+
if (typeof populate === "string" && (populate.startsWith("{") || populate.startsWith("["))) {
|
|
1482
|
+
try {
|
|
1483
|
+
const parsed = JSON.parse(populate);
|
|
1484
|
+
if (Array.isArray(parsed)) {
|
|
1485
|
+
const result2 = {};
|
|
1486
|
+
for (const item of parsed) {
|
|
1487
|
+
if (typeof item === "string") {
|
|
1488
|
+
Object.assign(result2, parsePopulate(item));
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return result2;
|
|
1492
|
+
}
|
|
1493
|
+
return parsed;
|
|
1494
|
+
} catch {}
|
|
1495
|
+
}
|
|
1496
|
+
const result = {};
|
|
1497
|
+
const keys = typeof populate === "string" ? populate.split(",") : [];
|
|
1498
|
+
for (const key of keys) {
|
|
1499
|
+
const parts = key.trim().split(".");
|
|
1500
|
+
let current = result;
|
|
1501
|
+
for (let i = 0;i < parts.length; i++) {
|
|
1502
|
+
const part = parts[i];
|
|
1503
|
+
if (!part)
|
|
1504
|
+
continue;
|
|
1505
|
+
if (i === parts.length - 1) {
|
|
1506
|
+
current[part] = current[part] || true;
|
|
1507
|
+
} else {
|
|
1508
|
+
if (typeof current[part] !== "object") {
|
|
1509
|
+
current[part] = { populate: {} };
|
|
1510
|
+
} else if (!current[part].populate) {
|
|
1511
|
+
current[part].populate = { ...current[part].populate };
|
|
1512
|
+
}
|
|
1513
|
+
current = current[part].populate;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
return result;
|
|
1518
|
+
}
|
|
1519
|
+
var populateDoc = async (db, fields, doc, populate, config) => {
|
|
1520
|
+
if (!doc || !populate || Object.keys(populate).length === 0)
|
|
1521
|
+
return doc;
|
|
1522
|
+
const populatePromises = fields.map(async (field) => {
|
|
1523
|
+
const pValue = populate[field.name];
|
|
1524
|
+
if (field.type === "relationship" && field.relationTo && pValue) {
|
|
1525
|
+
const relationTo = field.relationTo;
|
|
1526
|
+
const nestedPopulate = typeof pValue === "object" ? pValue.populate : undefined;
|
|
1527
|
+
const fetchAndPopulate = async (id) => {
|
|
1528
|
+
if (!id)
|
|
1529
|
+
return id;
|
|
1530
|
+
try {
|
|
1531
|
+
const systemCollections = getSystemCollections();
|
|
1532
|
+
const allCollections = [...config.collections, ...systemCollections];
|
|
1533
|
+
const relatedCollection = allCollections.find((c) => c.slug === relationTo || c.apiPath === relationTo);
|
|
1534
|
+
const targetSlug = relatedCollection ? relatedCollection.slug : relationTo;
|
|
1535
|
+
let relatedDoc = await db.findOne(targetSlug, { id });
|
|
1536
|
+
if (relatedDoc && nestedPopulate && relatedCollection) {
|
|
1537
|
+
relatedDoc = await populateDoc(db, relatedCollection.fields, relatedDoc, nestedPopulate, config);
|
|
1538
|
+
}
|
|
1539
|
+
return relatedDoc || id;
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
console.error(`[OpacaCMS] Failed to populate relationship ${field.name}`, err);
|
|
1542
|
+
return id;
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
if (field.hasMany && Array.isArray(doc[field.name])) {
|
|
1546
|
+
doc[field.name] = await Promise.all(doc[field.name].map(fetchAndPopulate));
|
|
1547
|
+
} else if (doc[field.name]) {
|
|
1548
|
+
doc[field.name] = await fetchAndPopulate(doc[field.name]);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (field.type === "group" && field.fields && doc[field.name]) {
|
|
1552
|
+
if (typeof pValue === "object" && pValue.populate) {
|
|
1553
|
+
await populateDoc(db, field.fields, doc[field.name], pValue.populate, config);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
if (field.type === "array" && field.fields && Array.isArray(doc[field.name])) {
|
|
1557
|
+
if (typeof pValue === "object" && pValue.populate) {
|
|
1558
|
+
await Promise.all(doc[field.name].map((item) => populateDoc(db, field.fields, item, pValue.populate, config)));
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
if (field.type === "blocks" && field.blocks && Array.isArray(doc[field.name])) {
|
|
1562
|
+
if (typeof pValue === "object" && pValue.populate) {
|
|
1563
|
+
await Promise.all(doc[field.name].map((item) => {
|
|
1564
|
+
const block = field.blocks.find((b) => b.slug === item.block_type);
|
|
1565
|
+
if (block && block.fields) {
|
|
1566
|
+
return populateDoc(db, block.fields, item, pValue.populate, config);
|
|
1567
|
+
}
|
|
1568
|
+
return Promise.resolve();
|
|
1569
|
+
}));
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
await Promise.all(populatePromises);
|
|
1574
|
+
const pKeys = Object.keys(populate);
|
|
1575
|
+
const hasSelection = pKeys.some((k) => {
|
|
1576
|
+
const field = fields.find((f) => f.name === k);
|
|
1577
|
+
return field && !["relationship", "group", "array", "blocks"].includes(field.type);
|
|
1578
|
+
});
|
|
1579
|
+
if (hasSelection) {
|
|
1580
|
+
const filtered = {};
|
|
1581
|
+
for (const key of pKeys) {
|
|
1582
|
+
if (populate[key]) {
|
|
1583
|
+
filtered[key] = doc[key];
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return filtered;
|
|
1587
|
+
}
|
|
1588
|
+
return doc;
|
|
1589
|
+
};
|
|
1590
|
+
function createHandlers(collection, config) {
|
|
1591
|
+
const { db } = config;
|
|
1592
|
+
const checkAccess = async (c, action, data) => {
|
|
1593
|
+
const access = collection.access?.[action];
|
|
1594
|
+
const apiKey = c.get("apiKey");
|
|
1595
|
+
if (collection.access?.requireApiKey && !apiKey) {
|
|
1596
|
+
const user2 = c.get("user");
|
|
1597
|
+
if (user2?.role === "admin" || Array.isArray(user2?.role) && user2.role.includes("admin")) {} else {
|
|
1598
|
+
return false;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (apiKey) {
|
|
1602
|
+
if (apiKey.permissions) {
|
|
1603
|
+
const collectionPermissions = apiKey.permissions[collection.slug];
|
|
1604
|
+
if (collectionPermissions) {
|
|
1605
|
+
if (!collectionPermissions.includes(action)) {
|
|
1606
|
+
return false;
|
|
1607
|
+
}
|
|
1608
|
+
return true;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (access === undefined)
|
|
1613
|
+
return true;
|
|
1614
|
+
if (typeof access === "boolean")
|
|
1615
|
+
return access;
|
|
1616
|
+
const user = c.get("user");
|
|
1617
|
+
const session = c.get("session");
|
|
1618
|
+
return await access({
|
|
1619
|
+
req: c,
|
|
1620
|
+
user,
|
|
1621
|
+
session,
|
|
1622
|
+
apiKey,
|
|
1623
|
+
data,
|
|
1624
|
+
operation: action
|
|
1625
|
+
});
|
|
1626
|
+
};
|
|
1627
|
+
const getFieldAccessPermissions = async (c, operation, fields, data) => {
|
|
1628
|
+
const permissions = {};
|
|
1629
|
+
const user = c.get("user");
|
|
1630
|
+
const session = c.get("session");
|
|
1631
|
+
const apiKey = c.get("apiKey");
|
|
1632
|
+
const accessArgs = {
|
|
1633
|
+
req: c,
|
|
1634
|
+
user,
|
|
1635
|
+
session,
|
|
1636
|
+
apiKey,
|
|
1637
|
+
data,
|
|
1638
|
+
operation
|
|
1639
|
+
};
|
|
1640
|
+
for (const field of fields) {
|
|
1641
|
+
if (field.name) {
|
|
1642
|
+
if (!field.access) {
|
|
1643
|
+
permissions[field.name] = { hidden: false, readOnly: false, disabled: false };
|
|
1644
|
+
} else {
|
|
1645
|
+
const hidden = typeof field.access.hidden === "function" ? await field.access.hidden(accessArgs) : !!field.access.hidden;
|
|
1646
|
+
const readOnly = typeof field.access.readOnly === "function" ? await field.access.readOnly(accessArgs) : !!field.access.readOnly;
|
|
1647
|
+
const disabled = typeof field.access.disabled === "function" ? await field.access.disabled(accessArgs) : !!field.access.disabled;
|
|
1648
|
+
permissions[field.name] = { hidden, readOnly, disabled };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
1652
|
+
const nestedPermissions = await getFieldAccessPermissions(c, operation, field.fields, data);
|
|
1653
|
+
Object.assign(permissions, nestedPermissions);
|
|
1654
|
+
}
|
|
1655
|
+
if (field.tabs && Array.isArray(field.tabs)) {
|
|
1656
|
+
for (const tab of field.tabs) {
|
|
1657
|
+
if (tab.fields && Array.isArray(tab.fields)) {
|
|
1658
|
+
const nestedPermissions = await getFieldAccessPermissions(c, operation, tab.fields, data);
|
|
1659
|
+
Object.assign(permissions, nestedPermissions);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return permissions;
|
|
1665
|
+
};
|
|
1666
|
+
const saveVersion = async (doc, status) => {
|
|
1667
|
+
if (!collection.versions)
|
|
1668
|
+
return;
|
|
1669
|
+
try {
|
|
1670
|
+
await db.db.insertInto("_doc_versions").values({
|
|
1671
|
+
id: crypto.randomUUID(),
|
|
1672
|
+
collection: collection.slug,
|
|
1673
|
+
entity_id: doc.id,
|
|
1674
|
+
data: JSON.stringify(doc),
|
|
1675
|
+
status: status || doc._status || "published",
|
|
1676
|
+
created_at: new Date().toISOString(),
|
|
1677
|
+
updated_at: new Date().toISOString()
|
|
1678
|
+
}).execute();
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
logger.error(`[OpacaCMS] Failed to save version for ${collection.slug}: `, err);
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
return {
|
|
1684
|
+
async find(c) {
|
|
1685
|
+
if (!await checkAccess(c, "read")) {
|
|
1686
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
1687
|
+
}
|
|
1688
|
+
const queries = c.req.query();
|
|
1689
|
+
const maxLimit = config.api?.maxLimit ?? 100;
|
|
1690
|
+
const page = queries.page ? parseInt(queries.page, 10) : 1;
|
|
1691
|
+
let limit = queries.limit ? parseInt(queries.limit, 10) : 10;
|
|
1692
|
+
if (limit > maxLimit)
|
|
1693
|
+
limit = maxLimit;
|
|
1694
|
+
const sort = queries.sort;
|
|
1695
|
+
const populate = parsePopulate(queries.populate);
|
|
1696
|
+
const filter = {};
|
|
1697
|
+
for (const [key, value] of Object.entries(queries)) {
|
|
1698
|
+
if ([
|
|
1699
|
+
"page",
|
|
1700
|
+
"limit",
|
|
1701
|
+
"sort",
|
|
1702
|
+
"populate",
|
|
1703
|
+
"locale",
|
|
1704
|
+
"draft",
|
|
1705
|
+
"after",
|
|
1706
|
+
"before",
|
|
1707
|
+
"cursorColumn"
|
|
1708
|
+
].includes(key)) {
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
const match = key.match(/^([^[]+)\[([^\]]+)\]$/);
|
|
1712
|
+
if (match) {
|
|
1713
|
+
const field = match[1];
|
|
1714
|
+
const op = match[2];
|
|
1715
|
+
if (!filter[field])
|
|
1716
|
+
filter[field] = {};
|
|
1717
|
+
filter[field][op] = value;
|
|
1718
|
+
} else {
|
|
1719
|
+
filter[key] = value;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const after = queries.after;
|
|
1723
|
+
const before = queries.before;
|
|
1724
|
+
const cursorColumn = queries.cursorColumn;
|
|
1725
|
+
const results = await db.find(collection.slug, filter, {
|
|
1726
|
+
page,
|
|
1727
|
+
limit,
|
|
1728
|
+
sort,
|
|
1729
|
+
after,
|
|
1730
|
+
before,
|
|
1731
|
+
cursorColumn
|
|
1732
|
+
});
|
|
1733
|
+
if (Object.keys(populate).length > 0) {
|
|
1734
|
+
results.docs = await Promise.all(results.docs.map((doc) => populateDoc(db, collection.fields, doc, populate, config)));
|
|
1735
|
+
}
|
|
1736
|
+
results.docs = await Promise.all(results.docs.map((doc) => hydrateDoc(doc, collection.fields, c, config)));
|
|
1737
|
+
return c.json(results);
|
|
1738
|
+
},
|
|
1739
|
+
async findOne(c) {
|
|
1740
|
+
if (!await checkAccess(c, "read")) {
|
|
1741
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
1742
|
+
}
|
|
1743
|
+
const queries = c.req.query();
|
|
1744
|
+
const populate = parsePopulate(queries.populate);
|
|
1745
|
+
const id = c.req.param("id");
|
|
1746
|
+
let doc = await db.findOne(collection.slug, { id });
|
|
1747
|
+
if (!doc)
|
|
1748
|
+
return c.json({ message: "Not found" }, 404);
|
|
1749
|
+
if (Object.keys(populate).length > 0) {
|
|
1750
|
+
doc = await populateDoc(db, collection.fields, doc, populate, config);
|
|
1751
|
+
}
|
|
1752
|
+
doc = await hydrateDoc(doc, collection.fields, c, config);
|
|
1753
|
+
const permissions = await getFieldAccessPermissions(c, "read", collection.fields, doc);
|
|
1754
|
+
const cleanDoc = { ...doc };
|
|
1755
|
+
for (const [field, perm] of Object.entries(permissions)) {
|
|
1756
|
+
if (perm.hidden) {
|
|
1757
|
+
delete cleanDoc[field];
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return c.json(cleanDoc);
|
|
1761
|
+
},
|
|
1762
|
+
async create(c) {
|
|
1763
|
+
const body = await c.req.json();
|
|
1764
|
+
if (!await checkAccess(c, "create", body)) {
|
|
1765
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
1766
|
+
}
|
|
1767
|
+
for (const field of collection.fields) {
|
|
1768
|
+
if (field.type === "slug" && !body[field.name]) {
|
|
1769
|
+
const fromValue = body[field.from];
|
|
1770
|
+
if (fromValue && typeof fromValue === "string") {
|
|
1771
|
+
const formatted = field.format ? field.format(fromValue) : fromValue.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1772
|
+
body[field.name] = formatted;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
const schema = generateSchemaForCollection(collection);
|
|
1777
|
+
const validation = schema.safeParse(body);
|
|
1778
|
+
if (!validation.success) {
|
|
1779
|
+
return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
|
|
1780
|
+
}
|
|
1781
|
+
let data = validation.data;
|
|
1782
|
+
const locale = c.req.header("x-opaca-locale");
|
|
1783
|
+
if (locale && locale !== "all") {
|
|
1784
|
+
for (const field of collection.fields) {
|
|
1785
|
+
if (field.name && field.localized && data[field.name] !== undefined) {
|
|
1786
|
+
data[field.name] = { [locale]: data[field.name] };
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (collection.hooks?.beforeCreate) {
|
|
1791
|
+
data = await collection.hooks.beforeCreate(data);
|
|
1792
|
+
}
|
|
1793
|
+
const doc = await db.create(collection.slug, data);
|
|
1794
|
+
if (collection.hooks?.afterCreate) {
|
|
1795
|
+
await collection.hooks.afterCreate(doc);
|
|
1796
|
+
}
|
|
1797
|
+
if (collection.webhooks) {
|
|
1798
|
+
const afterCreateWebhooks = collection.webhooks.filter((w) => w.type === "outgoing" && w.events.includes("after:create"));
|
|
1799
|
+
for (const webhook of afterCreateWebhooks) {
|
|
1800
|
+
if (webhook.type !== "outgoing")
|
|
1801
|
+
continue;
|
|
1802
|
+
const hookPromise = dispatchWebhook({
|
|
1803
|
+
url: webhook.url,
|
|
1804
|
+
data: doc,
|
|
1805
|
+
event: "after:create",
|
|
1806
|
+
collection: collection.slug,
|
|
1807
|
+
headers: webhook.headers,
|
|
1808
|
+
retries: webhook.retries,
|
|
1809
|
+
timeoutMs: webhook.timeoutMs,
|
|
1810
|
+
transform: webhook.transform
|
|
1811
|
+
});
|
|
1812
|
+
if (c.executionCtx?.waitUntil) {
|
|
1813
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
if (collection.versions) {
|
|
1818
|
+
await saveVersion(doc, body._status);
|
|
1819
|
+
}
|
|
1820
|
+
const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
|
|
1821
|
+
return c.json(hydratedDoc, 201);
|
|
1822
|
+
},
|
|
1823
|
+
async update(c) {
|
|
1824
|
+
const id = c.req.param("id");
|
|
1825
|
+
const body = await c.req.json();
|
|
1826
|
+
if (!await checkAccess(c, "update", body)) {
|
|
1827
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
1828
|
+
}
|
|
1829
|
+
for (const field of collection.fields) {
|
|
1830
|
+
if (field.type === "slug" && !body[field.name]) {
|
|
1831
|
+
const fromValue = body[field.from];
|
|
1832
|
+
if (fromValue && typeof fromValue === "string") {
|
|
1833
|
+
const formatted = field.format ? field.format(fromValue) : fromValue.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1834
|
+
body[field.name] = formatted;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
const schema = generateSchemaForCollection(collection, true);
|
|
1839
|
+
const validation = schema.safeParse(body);
|
|
1840
|
+
if (!validation.success) {
|
|
1841
|
+
return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
|
|
1842
|
+
}
|
|
1843
|
+
let data = validation.data;
|
|
1844
|
+
const locale = c.req.header("x-opaca-locale");
|
|
1845
|
+
if (locale && locale !== "all") {
|
|
1846
|
+
let existing = null;
|
|
1847
|
+
for (const field of collection.fields) {
|
|
1848
|
+
if (field.name && field.localized && data[field.name] !== undefined) {
|
|
1849
|
+
if (!existing) {
|
|
1850
|
+
existing = await db.findOne(collection.slug, { id });
|
|
1851
|
+
}
|
|
1852
|
+
const currentObj = existing?.[field.name] || {};
|
|
1853
|
+
data[field.name] = { ...currentObj, [locale]: data[field.name] };
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
if (collection.hooks?.beforeUpdate) {
|
|
1858
|
+
data = await collection.hooks.beforeUpdate(data);
|
|
1859
|
+
}
|
|
1860
|
+
const doc = await db.update(collection.slug, { id }, data);
|
|
1861
|
+
if (collection.hooks?.afterUpdate) {
|
|
1862
|
+
await collection.hooks.afterUpdate(doc);
|
|
1863
|
+
}
|
|
1864
|
+
if (collection.webhooks) {
|
|
1865
|
+
const afterUpdateWebhooks = collection.webhooks.filter((w) => w.type === "outgoing" && w.events.includes("after:update"));
|
|
1866
|
+
for (const webhook of afterUpdateWebhooks) {
|
|
1867
|
+
if (webhook.type !== "outgoing")
|
|
1868
|
+
continue;
|
|
1869
|
+
const hookPromise = dispatchWebhook({
|
|
1870
|
+
url: webhook.url,
|
|
1871
|
+
data: doc,
|
|
1872
|
+
event: "after:update",
|
|
1873
|
+
collection: collection.slug,
|
|
1874
|
+
headers: webhook.headers,
|
|
1875
|
+
retries: webhook.retries,
|
|
1876
|
+
timeoutMs: webhook.timeoutMs,
|
|
1877
|
+
transform: webhook.transform
|
|
1878
|
+
});
|
|
1879
|
+
if (c.executionCtx?.waitUntil) {
|
|
1880
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (collection.versions) {
|
|
1885
|
+
await saveVersion(doc, body._status);
|
|
1886
|
+
}
|
|
1887
|
+
const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
|
|
1888
|
+
return c.json(hydratedDoc);
|
|
1889
|
+
},
|
|
1890
|
+
async delete(c) {
|
|
1891
|
+
if (!await checkAccess(c, "delete")) {
|
|
1892
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
1893
|
+
}
|
|
1894
|
+
const id = c.req.param("id");
|
|
1895
|
+
let docToPass = { id };
|
|
1896
|
+
try {
|
|
1897
|
+
if (collection.webhooks && collection.webhooks.some((w) => w.type === "outgoing" && w.events.includes("after:delete"))) {
|
|
1898
|
+
docToPass = await db.findOne(collection.slug, { id });
|
|
1899
|
+
}
|
|
1900
|
+
} catch (e) {}
|
|
1901
|
+
if (collection.hooks?.beforeDelete) {
|
|
1902
|
+
await collection.hooks.beforeDelete(id);
|
|
1903
|
+
}
|
|
1904
|
+
await db.delete(collection.slug, { id });
|
|
1905
|
+
if (collection.hooks?.afterDelete) {
|
|
1906
|
+
await collection.hooks.afterDelete(id);
|
|
1907
|
+
}
|
|
1908
|
+
if (collection.webhooks) {
|
|
1909
|
+
const afterDeleteWebhooks = collection.webhooks.filter((w) => w.type === "outgoing" && w.events.includes("after:delete"));
|
|
1910
|
+
for (const webhook of afterDeleteWebhooks) {
|
|
1911
|
+
if (webhook.type !== "outgoing")
|
|
1912
|
+
continue;
|
|
1913
|
+
const hookPromise = dispatchWebhook({
|
|
1914
|
+
url: webhook.url,
|
|
1915
|
+
data: docToPass || { id },
|
|
1916
|
+
event: "after:delete",
|
|
1917
|
+
collection: collection.slug,
|
|
1918
|
+
headers: webhook.headers,
|
|
1919
|
+
retries: webhook.retries,
|
|
1920
|
+
timeoutMs: webhook.timeoutMs,
|
|
1921
|
+
transform: webhook.transform
|
|
1922
|
+
});
|
|
1923
|
+
if (c.executionCtx?.waitUntil) {
|
|
1924
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
return c.json({ message: "Deleted" });
|
|
1929
|
+
},
|
|
1930
|
+
async findVersions(c) {
|
|
1931
|
+
if (!await checkAccess(c, "read")) {
|
|
1932
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
1933
|
+
}
|
|
1934
|
+
const parentId = c.req.query("parentId");
|
|
1935
|
+
try {
|
|
1936
|
+
let qb = db.db.selectFrom("_doc_versions").selectAll().where("collection", "=", collection.slug);
|
|
1937
|
+
if (parentId) {
|
|
1938
|
+
qb = qb.where("entity_id", "=", parentId);
|
|
1939
|
+
}
|
|
1940
|
+
const rows = await qb.orderBy("created_at", "desc").execute();
|
|
1941
|
+
return c.json({ docs: rows });
|
|
1942
|
+
} catch (err) {
|
|
1943
|
+
return c.json({ message: "Failed to fetch versions", error: err.message }, 500);
|
|
1944
|
+
}
|
|
1945
|
+
},
|
|
1946
|
+
async restoreVersion(c) {
|
|
1947
|
+
if (!await checkAccess(c, "update")) {
|
|
1948
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
1949
|
+
}
|
|
1950
|
+
const versionId = c.req.param("versionId");
|
|
1951
|
+
try {
|
|
1952
|
+
const version = await db.db.selectFrom("_doc_versions").selectAll().where("id", "=", versionId).executeTakeFirst();
|
|
1953
|
+
if (!version)
|
|
1954
|
+
return c.json({ message: "Version not found" }, 404);
|
|
1955
|
+
const data = JSON.parse(version.data);
|
|
1956
|
+
const id = version.entity_id;
|
|
1957
|
+
delete data.id;
|
|
1958
|
+
delete data.createdAt;
|
|
1959
|
+
delete data.updatedAt;
|
|
1960
|
+
const doc = await db.update(collection.slug, { id }, data);
|
|
1961
|
+
await saveVersion(doc, "published");
|
|
1962
|
+
return c.json(doc);
|
|
1963
|
+
} catch (err) {
|
|
1964
|
+
return c.json({ message: "Failed to restore version", error: err.message }, 500);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
function createIncomingWebhookHandler(webhookConfig, collection, config) {
|
|
1970
|
+
const { db } = config;
|
|
1971
|
+
if (!webhookConfig) {
|
|
1972
|
+
throw new Error(`Collection ${collection.slug} does not have webhooksIncoming configured.`);
|
|
1973
|
+
}
|
|
1974
|
+
return async (c) => {
|
|
1975
|
+
if (webhookConfig.verifySignature) {
|
|
1976
|
+
const isValid = await verifyWebhookSignature(c.req.raw, webhookConfig.verifySignature);
|
|
1977
|
+
if (!isValid) {
|
|
1978
|
+
logger.warn(`[Webhooks] Invalid signature for incoming webhook on ${collection.slug}`);
|
|
1979
|
+
return c.json({ message: "Invalid signature" }, 401);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
try {
|
|
1983
|
+
const payload = await c.req.json();
|
|
1984
|
+
let data = payload;
|
|
1985
|
+
if (webhookConfig.mapPayload) {
|
|
1986
|
+
data = await webhookConfig.mapPayload(payload);
|
|
1987
|
+
}
|
|
1988
|
+
let doc = null;
|
|
1989
|
+
let operation = "create";
|
|
1990
|
+
const uniqueFields = collection.fields.filter((f) => f.name && f.unique && data[f.name] !== undefined);
|
|
1991
|
+
if (data.id) {
|
|
1992
|
+
doc = await db.findOne(collection.slug, { id: data.id });
|
|
1993
|
+
} else if (uniqueFields.length > 0) {
|
|
1994
|
+
const query = {};
|
|
1995
|
+
for (const f of uniqueFields) {
|
|
1996
|
+
const fieldName = f.name;
|
|
1997
|
+
query[fieldName] = data[fieldName];
|
|
1998
|
+
}
|
|
1999
|
+
doc = await db.findOne(collection.slug, query);
|
|
2000
|
+
}
|
|
2001
|
+
if (doc) {
|
|
2002
|
+
operation = "update";
|
|
2003
|
+
doc = await db.update(collection.slug, { id: doc.id }, data);
|
|
2004
|
+
} else {
|
|
2005
|
+
doc = await db.create(collection.slug, data);
|
|
2006
|
+
}
|
|
2007
|
+
if (webhookConfig.onSuccess) {
|
|
2008
|
+
await webhookConfig.onSuccess({ data: doc, payload, operation });
|
|
2009
|
+
}
|
|
2010
|
+
logger.info(`[Webhooks] Incoming webhook processed for ${collection.slug} (${operation})`);
|
|
2011
|
+
return c.json({ success: true, operation, id: doc.id });
|
|
2012
|
+
} catch (err) {
|
|
2013
|
+
logger.error(`[Webhooks] Failed to process incoming webhook for ${collection.slug}:`, err.message);
|
|
2014
|
+
return c.json({ message: "Internal Server Error", error: err.message }, 500);
|
|
2015
|
+
}
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
function createGlobalHandlers(config, globalConfig, getAuth) {
|
|
2019
|
+
const { db } = config;
|
|
2020
|
+
const checkAccess = async (c, action, data) => {
|
|
2021
|
+
const access = globalConfig.access?.[action];
|
|
2022
|
+
if (access === undefined)
|
|
2023
|
+
return true;
|
|
2024
|
+
if (typeof access === "boolean")
|
|
2025
|
+
return access;
|
|
2026
|
+
const user = c.get("user");
|
|
2027
|
+
const session = c.get("session");
|
|
2028
|
+
const apiKey = c.get("apiKey");
|
|
2029
|
+
return await access({
|
|
2030
|
+
req: c,
|
|
2031
|
+
user,
|
|
2032
|
+
session,
|
|
2033
|
+
apiKey,
|
|
2034
|
+
data,
|
|
2035
|
+
operation: action
|
|
2036
|
+
});
|
|
2037
|
+
};
|
|
2038
|
+
return {
|
|
2039
|
+
async find(c) {
|
|
2040
|
+
if (!await checkAccess(c, "read")) {
|
|
2041
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
2042
|
+
}
|
|
2043
|
+
if (!db.findGlobal) {
|
|
2044
|
+
return c.json({ message: "Globals are not supported by this database adapter" }, 501);
|
|
2045
|
+
}
|
|
2046
|
+
const doc = await db.findGlobal(globalConfig.slug);
|
|
2047
|
+
const hydratedDoc = await hydrateDoc(doc || {}, globalConfig.fields, c, config);
|
|
2048
|
+
return c.json(hydratedDoc);
|
|
2049
|
+
},
|
|
2050
|
+
async update(c) {
|
|
2051
|
+
const body = await c.req.json();
|
|
2052
|
+
if (!await checkAccess(c, "update", body)) {
|
|
2053
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
2054
|
+
}
|
|
2055
|
+
if (!db.updateGlobal) {
|
|
2056
|
+
return c.json({ message: "Globals are not supported by this database adapter" }, 501);
|
|
2057
|
+
}
|
|
2058
|
+
const schema = generateSchemaForCollection(globalConfig, true);
|
|
2059
|
+
const validation = schema.safeParse(body);
|
|
2060
|
+
if (!validation.success) {
|
|
2061
|
+
logger.error(`Validation Error on Global ${globalConfig.slug}: `, validation.error.format());
|
|
2062
|
+
return c.json({
|
|
2063
|
+
message: "Validation Error",
|
|
2064
|
+
errors: validation.error.format()
|
|
2065
|
+
}, 400);
|
|
2066
|
+
}
|
|
2067
|
+
const doc = await db.updateGlobal(globalConfig.slug, validation.data);
|
|
2068
|
+
const hydratedDoc = await hydrateDoc(doc, globalConfig.fields, c, config);
|
|
2069
|
+
return c.json(hydratedDoc);
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// src/server/routers/collections.ts
|
|
2075
|
+
function mountCollectionRoutes(router, config, state) {
|
|
2076
|
+
const combinedCollections = [...config.collections];
|
|
2077
|
+
for (const systemCol of getSystemCollections()) {
|
|
2078
|
+
if (!combinedCollections.find((c) => c.slug === systemCol.slug)) {
|
|
2079
|
+
combinedCollections.push(systemCol);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const exposedCollections = combinedCollections.filter((c) => !c.hidden);
|
|
2083
|
+
for (const collection of exposedCollections) {
|
|
2084
|
+
const handlers = createHandlers(collection, config);
|
|
2085
|
+
const path = `/${collection.apiPath || collection.slug}`;
|
|
2086
|
+
router.get(path, handlers.find);
|
|
2087
|
+
router.get(`${path}/versions`, handlers.findVersions);
|
|
2088
|
+
router.get(`${path}/:id`, handlers.findOne);
|
|
2089
|
+
router.post(path, handlers.create);
|
|
2090
|
+
router.patch(`${path}/:id`, handlers.update);
|
|
2091
|
+
router.post(`${path}/versions/:versionId/restore`, handlers.restoreVersion);
|
|
2092
|
+
router.delete(`${path}/:id`, handlers.delete);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
function mountIncomingWebhookRoutes(router, config) {
|
|
2096
|
+
for (const collection of config.collections) {
|
|
2097
|
+
if (collection.webhooks) {
|
|
2098
|
+
for (const webhook of collection.webhooks) {
|
|
2099
|
+
if (webhook.type === "incoming") {
|
|
2100
|
+
const handler = createIncomingWebhookHandler(webhook, collection, config);
|
|
2101
|
+
router.post(webhook.path, handler);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
function mountGlobalRoutes(router, config, state) {
|
|
2108
|
+
if (config.globals) {
|
|
2109
|
+
for (const globalConfig of config.globals) {
|
|
2110
|
+
const handlers = createGlobalHandlers(config, globalConfig, () => state.auth);
|
|
2111
|
+
const path = `/globals/${globalConfig.slug}`;
|
|
2112
|
+
router.get(path, handlers.find);
|
|
2113
|
+
router.post(path, handlers.update);
|
|
2114
|
+
router.patch(path, handlers.update);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
function createSystemRouter(config) {
|
|
2119
|
+
const systemRouter = new Hono3;
|
|
2120
|
+
systemRouter.get("/plugins/assets", adminMiddleware, (c) => {
|
|
2121
|
+
const response = {
|
|
2122
|
+
scripts: [],
|
|
2123
|
+
styles: []
|
|
2124
|
+
};
|
|
2125
|
+
if (config.plugins && Array.isArray(config.plugins)) {
|
|
2126
|
+
for (const plugin of config.plugins) {
|
|
2127
|
+
if (plugin.adminAssets) {
|
|
2128
|
+
try {
|
|
2129
|
+
const assets = plugin.adminAssets();
|
|
2130
|
+
if (assets.scripts)
|
|
2131
|
+
response.scripts.push(...assets.scripts);
|
|
2132
|
+
if (assets.styles)
|
|
2133
|
+
response.styles.push(...assets.styles);
|
|
2134
|
+
} catch (e) {
|
|
2135
|
+
console.error(`[Plugin] ${plugin.name} failed to expose adminAssets: `, e);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
return c.json(response);
|
|
2141
|
+
});
|
|
2142
|
+
if (config.storages) {
|
|
2143
|
+
const assetsHandlers = createAssetsHandlers(config);
|
|
2144
|
+
systemRouter.post("/assets/upload", adminMiddleware, assetsHandlers.upload);
|
|
2145
|
+
systemRouter.get("/assets", adminMiddleware, assetsHandlers.list);
|
|
2146
|
+
systemRouter.post("/assets/presign-upload", adminMiddleware, assetsHandlers.presign);
|
|
2147
|
+
}
|
|
2148
|
+
return systemRouter;
|
|
2149
|
+
}
|
|
2150
|
+
function createAssetsServingRouter(config) {
|
|
2151
|
+
const assetsServingRouter = new Hono3;
|
|
2152
|
+
if (config.storages) {
|
|
2153
|
+
const assetsHandlers = createAssetsHandlers(config);
|
|
2154
|
+
const assetCol = getSystemCollections().find((c) => c.slug === "_assets");
|
|
2155
|
+
const assetPath = `/${assetCol?.apiPath || assetCol?.slug || "_assets"}`;
|
|
2156
|
+
assetsServingRouter.get(`${assetPath}/:id/view`, assetsHandlers.serve);
|
|
2157
|
+
}
|
|
2158
|
+
return assetsServingRouter;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// src/server/middlewares/auth.ts
|
|
2162
|
+
function createAuthMiddleware(getAuth) {
|
|
2163
|
+
return async (c, next) => {
|
|
2164
|
+
const auth = getAuth();
|
|
2165
|
+
if (!auth) {
|
|
2166
|
+
c.set("user", null);
|
|
2167
|
+
c.set("session", null);
|
|
2168
|
+
c.set("apiKey", null);
|
|
2169
|
+
await next();
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
try {
|
|
2173
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
2174
|
+
if (session) {
|
|
2175
|
+
c.set("user", session.user);
|
|
2176
|
+
c.set("session", session.session);
|
|
2177
|
+
c.set("apiKey", null);
|
|
2178
|
+
await next();
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
} catch (err) {
|
|
2182
|
+
logger.debug("Session verification failed or threw:", err);
|
|
2183
|
+
}
|
|
2184
|
+
const authHeader = c.req.header("Authorization");
|
|
2185
|
+
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
2186
|
+
const token = authHeader.split(" ")[1];
|
|
2187
|
+
if (token) {
|
|
2188
|
+
try {
|
|
2189
|
+
const result = await auth.api.verifyApiKey({
|
|
2190
|
+
headers: c.req.raw.headers,
|
|
2191
|
+
body: { key: token }
|
|
2192
|
+
});
|
|
2193
|
+
if (result && result.valid && result.key) {
|
|
2194
|
+
c.set("apiKey", {
|
|
2195
|
+
id: result.key.id,
|
|
2196
|
+
name: result.key.name,
|
|
2197
|
+
permissions: result.key.permissions,
|
|
2198
|
+
referenceId: result.key.referenceId
|
|
2199
|
+
});
|
|
2200
|
+
try {
|
|
2201
|
+
const ownerResult = await auth.options.database?.findOne?.("_users", {
|
|
2202
|
+
id: result.key.referenceId
|
|
2203
|
+
});
|
|
2204
|
+
c.set("user", ownerResult);
|
|
2205
|
+
} catch (e) {
|
|
2206
|
+
logger.warn("Failed to fetch API key owner from database:", e);
|
|
2207
|
+
c.set("user", null);
|
|
2208
|
+
}
|
|
2209
|
+
c.set("session", null);
|
|
2210
|
+
await next();
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
} catch (err) {
|
|
2214
|
+
logger.warn("API Key verification failed:", err);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
c.set("user", null);
|
|
2219
|
+
c.set("session", null);
|
|
2220
|
+
c.set("apiKey", null);
|
|
2221
|
+
await next();
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// src/server/middlewares/context.ts
|
|
2226
|
+
function createContextMiddleware(config) {
|
|
2227
|
+
const logger2 = new OpacaLogger(config.logger);
|
|
2228
|
+
return async (c, next) => {
|
|
2229
|
+
c.set("config", config);
|
|
2230
|
+
c.set("db", config.db);
|
|
2231
|
+
c.set("logger", logger2);
|
|
2232
|
+
await next();
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// src/server/middlewares/cors.ts
|
|
2237
|
+
import { cors } from "hono/cors";
|
|
2238
|
+
function createCorsMiddleware(config) {
|
|
2239
|
+
const trustedOrigins = config.trustedOrigins || [];
|
|
2240
|
+
return cors({
|
|
2241
|
+
origin: async (origin, _c) => {
|
|
2242
|
+
const allowed = typeof trustedOrigins === "function" ? await trustedOrigins(_c.req.raw) : trustedOrigins;
|
|
2243
|
+
if (Array.isArray(allowed) && allowed.includes(origin)) {
|
|
2244
|
+
return origin;
|
|
2245
|
+
}
|
|
2246
|
+
return;
|
|
2247
|
+
},
|
|
2248
|
+
allowMethods: ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
2249
|
+
exposeHeaders: ["Content-Length"],
|
|
2250
|
+
credentials: true
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// src/auth/index.ts
|
|
2255
|
+
import { apiKey } from "@better-auth/api-key";
|
|
2256
|
+
import { betterAuth } from "better-auth";
|
|
2257
|
+
import { admin, openAPI } from "better-auth/plugins";
|
|
2258
|
+
|
|
2259
|
+
// src/auth/premissions.ts
|
|
2260
|
+
import { createAccessControl } from "better-auth/plugins/access";
|
|
2261
|
+
function createPermissions(config) {
|
|
2262
|
+
const resources = {
|
|
2263
|
+
user: ["create", "read", "update", "delete", "ban", "impersonate"],
|
|
2264
|
+
session: ["read", "revoke", "delete"]
|
|
2265
|
+
};
|
|
2266
|
+
for (const collection of config.collections) {
|
|
2267
|
+
resources[collection.slug] = ["create", "read", "update", "delete"];
|
|
2268
|
+
}
|
|
2269
|
+
const ac = createAccessControl(resources);
|
|
2270
|
+
const adminRole = ac.newRole({
|
|
2271
|
+
user: ["create", "read", "update", "delete", "ban", "impersonate"],
|
|
2272
|
+
session: ["read", "revoke", "delete"]
|
|
2273
|
+
});
|
|
2274
|
+
for (const collection of config.collections) {
|
|
2275
|
+
adminRole[collection.slug] = ["create", "read", "update", "delete"];
|
|
2276
|
+
}
|
|
2277
|
+
const userRole = ac.newRole({});
|
|
2278
|
+
const roles = {
|
|
2279
|
+
admin: adminRole,
|
|
2280
|
+
user: userRole
|
|
2281
|
+
};
|
|
2282
|
+
if (config.access?.roles) {
|
|
2283
|
+
for (const [roleName, permissions] of Object.entries(config.access.roles)) {
|
|
2284
|
+
roles[roleName] = ac.newRole(permissions);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
return {
|
|
2288
|
+
ac,
|
|
2289
|
+
roles
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// src/auth/index.ts
|
|
2294
|
+
async function createAuth(config) {
|
|
2295
|
+
const env = typeof process !== "undefined" ? process.env : {};
|
|
2296
|
+
const baseURL = String(config.serverURL || env.BETTER_AUTH_URL || "").replace(/\/$/, "");
|
|
2297
|
+
if (!baseURL) {
|
|
2298
|
+
throw new Error("[OpacaAuth] baseURL could not be determined. Please provide 'serverURL' in your config or 'BETTER_AUTH_URL' in your environment.");
|
|
2299
|
+
}
|
|
2300
|
+
const authURL = `${baseURL}/api/auth`;
|
|
2301
|
+
const secret = env.OPACA_SECRET || env.BETTER_AUTH_SECRET || config.secret;
|
|
2302
|
+
if (!secret) {
|
|
2303
|
+
throw new Error("[OpacaAuth] No secret found for authentication. Please provide 'OPACA_SECRET' or 'BETTER_AUTH_SECRET'.");
|
|
2304
|
+
}
|
|
2305
|
+
if (typeof process !== "undefined" && !env.BETTER_AUTH_URL) {
|
|
2306
|
+
process.env.BETTER_AUTH_URL = authURL;
|
|
2307
|
+
}
|
|
2308
|
+
const databaseConfig = {
|
|
2309
|
+
db: config.db.db,
|
|
2310
|
+
type: config.db.name === "postgres" ? "pg" : "sqlite"
|
|
2311
|
+
};
|
|
2312
|
+
const isSecure = baseURL.startsWith("https");
|
|
2313
|
+
const userAuth = config.auth || {};
|
|
2314
|
+
const { ac, roles } = createPermissions(config);
|
|
2315
|
+
const trustedOrigins = config.trustedOrigins;
|
|
2316
|
+
const plugins = [
|
|
2317
|
+
admin({
|
|
2318
|
+
ac,
|
|
2319
|
+
roles
|
|
2320
|
+
}),
|
|
2321
|
+
openAPI({
|
|
2322
|
+
disableDefaultReference: true,
|
|
2323
|
+
path: "/open-api"
|
|
2324
|
+
}),
|
|
2325
|
+
...userAuth.features?.apiKeys?.enabled ? [
|
|
2326
|
+
apiKey({
|
|
2327
|
+
defaultPrefix: "opaca_",
|
|
2328
|
+
schema: {
|
|
2329
|
+
apikey: {
|
|
2330
|
+
modelName: "_api_keys"
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
})
|
|
2334
|
+
] : []
|
|
2335
|
+
];
|
|
2336
|
+
const socialProviders = {};
|
|
2337
|
+
if (userAuth.socialProviders?.github) {
|
|
2338
|
+
socialProviders.github = userAuth.socialProviders.github;
|
|
2339
|
+
}
|
|
2340
|
+
if (userAuth.socialProviders?.google) {
|
|
2341
|
+
socialProviders.google = userAuth.socialProviders.google;
|
|
2342
|
+
}
|
|
2343
|
+
return betterAuth({
|
|
2344
|
+
database: databaseConfig,
|
|
2345
|
+
baseURL,
|
|
2346
|
+
basePath: "/api/auth",
|
|
2347
|
+
secret,
|
|
2348
|
+
trustedOrigins,
|
|
2349
|
+
emailAndPassword: {
|
|
2350
|
+
enabled: userAuth.strategies?.emailPassword !== false
|
|
2351
|
+
},
|
|
2352
|
+
socialProviders,
|
|
2353
|
+
user: {
|
|
2354
|
+
modelName: "_users"
|
|
2355
|
+
},
|
|
2356
|
+
session: {
|
|
2357
|
+
modelName: "_sessions",
|
|
2358
|
+
expiresIn: (userAuth.session?.expiresInDays || 30) * 86400,
|
|
2359
|
+
updateAge: userAuth.session?.updateAgeSeconds || 86400
|
|
2360
|
+
},
|
|
2361
|
+
account: {
|
|
2362
|
+
modelName: "_accounts"
|
|
2363
|
+
},
|
|
2364
|
+
verification: {
|
|
2365
|
+
modelName: "_verifications"
|
|
2366
|
+
},
|
|
2367
|
+
advanced: {
|
|
2368
|
+
useSecureCookies: isSecure,
|
|
2369
|
+
defaultCookieAttributes: {
|
|
2370
|
+
sameSite: isSecure ? "none" : "lax",
|
|
2371
|
+
secure: isSecure
|
|
2372
|
+
}
|
|
2373
|
+
},
|
|
2374
|
+
databaseHooks: {
|
|
2375
|
+
user: {
|
|
2376
|
+
create: {
|
|
2377
|
+
before: async (user) => {
|
|
2378
|
+
try {
|
|
2379
|
+
let userCount = 0;
|
|
2380
|
+
try {
|
|
2381
|
+
userCount = await config.db.count("_users");
|
|
2382
|
+
} catch (e) {
|
|
2383
|
+
const result = await config.db.unsafe("SELECT COUNT(*) as count FROM _users");
|
|
2384
|
+
const rows = result?.results || result || [];
|
|
2385
|
+
userCount = Number(rows[0]?.count || rows[0]?.["count(*)"] || 0);
|
|
2386
|
+
}
|
|
2387
|
+
if (userCount === 0) {
|
|
2388
|
+
return {
|
|
2389
|
+
data: {
|
|
2390
|
+
...user,
|
|
2391
|
+
role: "admin"
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
} catch (e) {
|
|
2396
|
+
console.error("[OpacaAuth] Failed to check user count in hook:", e);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
},
|
|
2402
|
+
plugins,
|
|
2403
|
+
logger: {
|
|
2404
|
+
disabled: config.logger?.disabled,
|
|
2405
|
+
disableColors: config.logger?.disableColors,
|
|
2406
|
+
level: config.logger?.level,
|
|
2407
|
+
log: (level, message, ...args) => {
|
|
2408
|
+
const rebrand = (msg) => {
|
|
2409
|
+
if (typeof msg !== "string")
|
|
2410
|
+
return String(msg);
|
|
2411
|
+
return msg.replace(/\[better-auth\]\s*/g, "").replace(/BETTER_AUTH_SECRET/g, "OPACA_SECRET").replace(/`npx auth secret`/g, "`openssl rand -base64 32`").replace(/npx auth secret/g, "openssl rand -base64 32").replace(/^Warning:\s*/i, "");
|
|
2412
|
+
};
|
|
2413
|
+
const branded = rebrand(message);
|
|
2414
|
+
if (level === "error" && (branded.toLowerCase().includes("invalid api key") || branded.toLowerCase().includes("failed to validate api key"))) {
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
const brandedArgs = args.map((a) => typeof a === "string" ? rebrand(a) : a);
|
|
2418
|
+
const authLogger = new OpacaLogger(config.logger);
|
|
2419
|
+
switch (level) {
|
|
2420
|
+
case "debug":
|
|
2421
|
+
authLogger.debug(branded, ...brandedArgs);
|
|
2422
|
+
break;
|
|
2423
|
+
case "info":
|
|
2424
|
+
authLogger.info(branded, ...brandedArgs);
|
|
2425
|
+
break;
|
|
2426
|
+
case "warn":
|
|
2427
|
+
authLogger.warn(branded, ...brandedArgs);
|
|
2428
|
+
break;
|
|
2429
|
+
case "error":
|
|
2430
|
+
authLogger.error(branded, ...brandedArgs);
|
|
2431
|
+
break;
|
|
2432
|
+
default:
|
|
2433
|
+
authLogger.info(branded, ...brandedArgs);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// src/db/kysely/field-mapper.ts
|
|
2441
|
+
function toSnakeCase(str) {
|
|
2442
|
+
const res = str.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
2443
|
+
if (res.startsWith("_") && !str.startsWith("_")) {
|
|
2444
|
+
return res.slice(1);
|
|
2445
|
+
}
|
|
2446
|
+
return res;
|
|
2447
|
+
}
|
|
2448
|
+
// src/auth/migrations.ts
|
|
2449
|
+
async function runAuthMigrations(db) {
|
|
2450
|
+
const rawDb = db.raw;
|
|
2451
|
+
if (!rawDb) {
|
|
2452
|
+
logger.error("Database not connected yet. Skipping auth migrations.");
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
const isPostgres = db.name === "postgres";
|
|
2456
|
+
const authCollections = getSystemCollections().filter((c) => ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(c.slug));
|
|
2457
|
+
try {
|
|
2458
|
+
for (const collection of authCollections) {
|
|
2459
|
+
const columnDefs = [];
|
|
2460
|
+
columnDefs.push(`"id" TEXT PRIMARY KEY`);
|
|
2461
|
+
for (const field of collection.fields) {
|
|
2462
|
+
if (field.name === "id")
|
|
2463
|
+
continue;
|
|
2464
|
+
let type = "TEXT";
|
|
2465
|
+
if (field.type === "number")
|
|
2466
|
+
type = "INTEGER";
|
|
2467
|
+
if (field.type === "boolean")
|
|
2468
|
+
type = isPostgres ? "BOOLEAN" : "INTEGER";
|
|
2469
|
+
if (field.type === "date")
|
|
2470
|
+
type = isPostgres ? "TIMESTAMPTZ" : "TEXT";
|
|
2471
|
+
let definition = `"${toSnakeCase(field.name)}" ${type}`;
|
|
2472
|
+
if (field.required)
|
|
2473
|
+
definition += " NOT NULL";
|
|
2474
|
+
if (field.unique)
|
|
2475
|
+
definition += " UNIQUE";
|
|
2476
|
+
if (field.defaultValue !== undefined) {
|
|
2477
|
+
const val = typeof field.defaultValue === "string" ? `'${field.defaultValue}'` : field.defaultValue;
|
|
2478
|
+
definition += ` DEFAULT ${val}`;
|
|
2479
|
+
}
|
|
2480
|
+
if (field.references) {
|
|
2481
|
+
definition += ` REFERENCES "${field.references.table}"("${toSnakeCase(field.references.column)}")`;
|
|
2482
|
+
if (field.references.onDelete) {
|
|
2483
|
+
definition += ` ON DELETE ${field.references.onDelete.toUpperCase()}`;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
columnDefs.push(definition);
|
|
2487
|
+
}
|
|
2488
|
+
const ts = collection.timestamps;
|
|
2489
|
+
if (ts !== false && ts !== undefined) {
|
|
2490
|
+
const config = typeof ts === "object" ? ts : {};
|
|
2491
|
+
const createdField = toSnakeCase(config.createdAt || "createdAt");
|
|
2492
|
+
const updatedField = toSnakeCase(config.updatedAt || "updatedAt");
|
|
2493
|
+
const fieldNames = new Set(collection.fields.map((f) => toSnakeCase(f.name)));
|
|
2494
|
+
if (!fieldNames.has(createdField)) {
|
|
2495
|
+
columnDefs.push(`"${createdField}" ${isPostgres ? "TIMESTAMPTZ" : "TEXT"} DEFAULT CURRENT_TIMESTAMP`);
|
|
2496
|
+
}
|
|
2497
|
+
if (!fieldNames.has(updatedField)) {
|
|
2498
|
+
columnDefs.push(`"${updatedField}" ${isPostgres ? "TIMESTAMPTZ" : "TEXT"} DEFAULT CURRENT_TIMESTAMP`);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
logger.info(` -> Verifying table: ${logger.format("green", `"${collection.slug}"`)}`);
|
|
2502
|
+
await db.unsafe(`CREATE TABLE IF NOT EXISTS "${collection.slug}" (${columnDefs.join(", ")})`);
|
|
2503
|
+
}
|
|
2504
|
+
logger.success("Auth tables verified/created successfully.");
|
|
2505
|
+
} catch (error) {
|
|
2506
|
+
logger.error("Failed to create auth tables:", error);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
// src/server/middlewares/database-init.ts
|
|
2510
|
+
function createDatabaseInitMiddleware(config, state) {
|
|
2511
|
+
return async (_c, next) => {
|
|
2512
|
+
if (!state.migrated) {
|
|
2513
|
+
const isDev = typeof process !== "undefined" && true;
|
|
2514
|
+
if (isDev) {
|
|
2515
|
+
logger.info(`Connecting to database: ${logger.format("yellow", config.db.name)}...`);
|
|
2516
|
+
} else {
|
|
2517
|
+
logger.debug(`Connecting to database: ${config.db.name}...`);
|
|
2518
|
+
}
|
|
2519
|
+
await config.db.connect();
|
|
2520
|
+
if (isDev) {
|
|
2521
|
+
logger.debug("Synchronizing database schema...");
|
|
2522
|
+
}
|
|
2523
|
+
const allCollections = [...config.collections];
|
|
2524
|
+
for (const systemCol of getSystemCollections()) {
|
|
2525
|
+
if (!allCollections.find((c) => c.slug === systemCol.slug)) {
|
|
2526
|
+
allCollections.push(systemCol);
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
await config.db.migrate(allCollections, config.globals);
|
|
2530
|
+
if (isDev) {
|
|
2531
|
+
logger.success("Database schema synchronized.");
|
|
2532
|
+
}
|
|
2533
|
+
const shouldMigrate = config.runMigrationsOnStartup || isDev;
|
|
2534
|
+
if (shouldMigrate) {
|
|
2535
|
+
if (config.runMigrationsOnStartup && config.db.runMigrations) {
|
|
2536
|
+
try {
|
|
2537
|
+
logger.info("Running file-based migrations on startup...");
|
|
2538
|
+
await config.db.runMigrations();
|
|
2539
|
+
} catch (e) {
|
|
2540
|
+
if (e.code === "ENOENT" && e.path && e.path.includes("migrations")) {
|
|
2541
|
+
logger.debug("No 'migrations' folder found. Skipping file-based migrations.");
|
|
2542
|
+
} else {
|
|
2543
|
+
logger.error("Error running migrations on startup:");
|
|
2544
|
+
console.error(e);
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
await runAuthMigrations(config.db);
|
|
2549
|
+
} else {
|
|
2550
|
+
logger.debug("Automatic schema migrations skipped (Production).");
|
|
2551
|
+
}
|
|
2552
|
+
if (!state.auth) {
|
|
2553
|
+
state.auth = await createAuth(config);
|
|
2554
|
+
}
|
|
2555
|
+
state.migrated = true;
|
|
2556
|
+
}
|
|
2557
|
+
await next();
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// src/server/middlewares/rate-limit.ts
|
|
2562
|
+
function createRateLimitMiddleware(config) {
|
|
2563
|
+
const rateLimitConfig = config.api?.rateLimit;
|
|
2564
|
+
if (rateLimitConfig?.enabled === false) {
|
|
2565
|
+
return async (_c, next) => await next();
|
|
2566
|
+
}
|
|
2567
|
+
const windowMs = rateLimitConfig?.windowMs || 60000;
|
|
2568
|
+
const limit = rateLimitConfig?.limit || 100;
|
|
2569
|
+
return async (c, next) => {
|
|
2570
|
+
let provider = rateLimitConfig?.provider?.(c);
|
|
2571
|
+
if (!provider && !rateLimitConfig?.store && c.env) {
|
|
2572
|
+
const rateLimitKey = Object.keys(c.env).find((key) => c.env[key]?.limit && typeof c.env[key].limit === "function");
|
|
2573
|
+
if (rateLimitKey) {
|
|
2574
|
+
provider = c.env[rateLimitKey];
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
if (provider) {
|
|
2578
|
+
try {
|
|
2579
|
+
const { rateLimiter } = await import("hono-rate-limiter");
|
|
2580
|
+
const limiter = rateLimiter({
|
|
2581
|
+
binding: () => provider,
|
|
2582
|
+
keyGenerator: rateLimitConfig?.keyGenerator || ((c2) => c2.req.header("cf-connecting-ip") || c2.req.header("x-forwarded-for") || "anonymous")
|
|
2583
|
+
});
|
|
2584
|
+
return limiter(c, next);
|
|
2585
|
+
} catch (e) {
|
|
2586
|
+
logger.error("Failed to load 'hono-rate-limiter'. Please install it to use rate limiting features.", e);
|
|
2587
|
+
return await next();
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
let resolvedStore = rateLimitConfig?.store;
|
|
2591
|
+
if (!resolvedStore && c.env) {
|
|
2592
|
+
const kvBindingKey = Object.keys(c.env).find((key) => key.startsWith("OPACA_") && c.env[key]?.put && c.env[key]?.get);
|
|
2593
|
+
if (kvBindingKey) {
|
|
2594
|
+
try {
|
|
2595
|
+
const { WorkersKVStore } = await import("@hono-rate-limiter/cloudflare");
|
|
2596
|
+
resolvedStore = new WorkersKVStore({ namespace: c.env[kvBindingKey] });
|
|
2597
|
+
} catch (e) {
|
|
2598
|
+
logger.error("Failed to load @hono-rate-limiter/cloudflare dynamic import. Make sure you are in a Cloudflare environment.", e);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
try {
|
|
2603
|
+
const { rateLimiter } = await import("hono-rate-limiter");
|
|
2604
|
+
const limiter = rateLimiter({
|
|
2605
|
+
windowMs,
|
|
2606
|
+
limit,
|
|
2607
|
+
standardHeaders: "draft-6",
|
|
2608
|
+
store: resolvedStore,
|
|
2609
|
+
keyGenerator: rateLimitConfig?.keyGenerator || ((c2) => c2.req.header("cf-connecting-ip") || c2.req.header("x-forwarded-for") || "anonymous"),
|
|
2610
|
+
message: "Too many requests from this IP, please try again after a minute."
|
|
2611
|
+
});
|
|
2612
|
+
return limiter(c, next);
|
|
2613
|
+
} catch (e) {
|
|
2614
|
+
logger.error("Failed to load 'hono-rate-limiter'. Please install it to use rate limiting features.", e);
|
|
2615
|
+
return await next();
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// src/utils/context.ts
|
|
2621
|
+
var {AsyncLocalStorage} = (() => ({}));
|
|
2622
|
+
var GLOBAL_CONTEXT_KEY = Symbol.for("opacacms.requestContext");
|
|
2623
|
+
if (!globalThis[GLOBAL_CONTEXT_KEY]) {
|
|
2624
|
+
globalThis[GLOBAL_CONTEXT_KEY] = new AsyncLocalStorage;
|
|
2625
|
+
}
|
|
2626
|
+
var requestContext = globalThis[GLOBAL_CONTEXT_KEY];
|
|
2627
|
+
|
|
2628
|
+
// src/server/setup-middlewares.ts
|
|
2629
|
+
function setupMiddlewares(router, config, state) {
|
|
2630
|
+
router.use("*", async (c, next) => {
|
|
2631
|
+
await next();
|
|
2632
|
+
c.res.headers.set("X-Powered-By", "OpacaCMS");
|
|
2633
|
+
});
|
|
2634
|
+
router.use("*", createContextMiddleware(config));
|
|
2635
|
+
router.use("*", createRateLimitMiddleware(config));
|
|
2636
|
+
router.use("*", createCorsMiddleware(config));
|
|
2637
|
+
router.use("*", createDatabaseInitMiddleware(config, state));
|
|
2638
|
+
router.onError((err, c) => {
|
|
2639
|
+
const ctxLogger = c.get("logger");
|
|
2640
|
+
ctxLogger.error(`API Error: ${err.message}`, err);
|
|
2641
|
+
return c.json({ message: "Internal Server Error", error: err.message }, 500);
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
function setupAuthMiddlewares(router, config, state) {
|
|
2645
|
+
router.use("*", createAuthMiddleware(() => state.auth));
|
|
2646
|
+
router.use("*", async (c, next) => {
|
|
2647
|
+
const user = c.get("user");
|
|
2648
|
+
await requestContext.run({ userId: user?.id }, async () => {
|
|
2649
|
+
await next();
|
|
2650
|
+
});
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// src/server/graphql.ts
|
|
2655
|
+
import {
|
|
2656
|
+
GraphQLBoolean,
|
|
2657
|
+
GraphQLFloat,
|
|
2658
|
+
GraphQLInt,
|
|
2659
|
+
GraphQLList,
|
|
2660
|
+
GraphQLNonNull,
|
|
2661
|
+
GraphQLObjectType,
|
|
2662
|
+
GraphQLScalarType,
|
|
2663
|
+
GraphQLSchema,
|
|
2664
|
+
GraphQLString
|
|
2665
|
+
} from "graphql";
|
|
2666
|
+
|
|
2667
|
+
// src/utils/string.ts
|
|
2668
|
+
function sanitizeGraphQLName(name) {
|
|
2669
|
+
return name.replace(/[^_a-zA-Z0-9]/g, "_");
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// src/server/graphql.ts
|
|
2673
|
+
var GraphQLJSON = new GraphQLScalarType({
|
|
2674
|
+
name: "JSON",
|
|
2675
|
+
description: "The `JSON` scalar type represents JSON values as specified by ECMA-404",
|
|
2676
|
+
serialize: (value) => value,
|
|
2677
|
+
parseValue: (value) => value,
|
|
2678
|
+
parseLiteral: (ast) => {
|
|
2679
|
+
switch (ast.kind) {
|
|
2680
|
+
case "StringValue":
|
|
2681
|
+
return JSON.parse(ast.value);
|
|
2682
|
+
case "ObjectValue":
|
|
2683
|
+
throw new Error("Not implemented");
|
|
2684
|
+
default:
|
|
2685
|
+
return null;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
});
|
|
2689
|
+
function getGraphQLType(field) {
|
|
2690
|
+
switch (field.type) {
|
|
2691
|
+
case "text":
|
|
2692
|
+
case "slug":
|
|
2693
|
+
case "textarea":
|
|
2694
|
+
case "richtext":
|
|
2695
|
+
case "select":
|
|
2696
|
+
case "radio":
|
|
2697
|
+
case "date":
|
|
2698
|
+
case "file":
|
|
2699
|
+
case "relationship":
|
|
2700
|
+
return GraphQLString;
|
|
2701
|
+
case "number":
|
|
2702
|
+
return GraphQLFloat;
|
|
2703
|
+
case "boolean":
|
|
2704
|
+
return GraphQLBoolean;
|
|
2705
|
+
case "json":
|
|
2706
|
+
case "blocks":
|
|
2707
|
+
case "array":
|
|
2708
|
+
case "group":
|
|
2709
|
+
case "row":
|
|
2710
|
+
case "collapsible":
|
|
2711
|
+
case "tabs":
|
|
2712
|
+
return GraphQLJSON;
|
|
2713
|
+
default:
|
|
2714
|
+
return GraphQLString;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
function buildCollectionType(collection, nameOverride) {
|
|
2718
|
+
const fields = {
|
|
2719
|
+
id: { type: GraphQLString }
|
|
2720
|
+
};
|
|
2721
|
+
if (collection.timestamps !== false) {
|
|
2722
|
+
fields.createdAt = { type: GraphQLString };
|
|
2723
|
+
fields.updatedAt = { type: GraphQLString };
|
|
2724
|
+
}
|
|
2725
|
+
for (const field of collection.fields) {
|
|
2726
|
+
if (field.name) {
|
|
2727
|
+
fields[sanitizeGraphQLName(field.name)] = { type: getGraphQLType(field) };
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return new GraphQLObjectType({
|
|
2731
|
+
name: nameOverride || sanitizeGraphQLName(collection.slug),
|
|
2732
|
+
fields
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
function buildCollectionPaginatedType(collectionType, collectionSlug) {
|
|
2736
|
+
return new GraphQLObjectType({
|
|
2737
|
+
name: `Paginated${sanitizeGraphQLName(collectionSlug)}`,
|
|
2738
|
+
fields: {
|
|
2739
|
+
docs: { type: new GraphQLList(collectionType) },
|
|
2740
|
+
totalDocs: { type: GraphQLInt },
|
|
2741
|
+
limit: { type: GraphQLInt },
|
|
2742
|
+
totalPages: { type: GraphQLInt },
|
|
2743
|
+
page: { type: GraphQLInt },
|
|
2744
|
+
pagingCounter: { type: GraphQLInt },
|
|
2745
|
+
hasPrevPage: { type: GraphQLBoolean },
|
|
2746
|
+
hasNextPage: { type: GraphQLBoolean },
|
|
2747
|
+
prevPage: { type: GraphQLInt },
|
|
2748
|
+
nextPage: { type: GraphQLInt },
|
|
2749
|
+
nextCursor: { type: GraphQLString },
|
|
2750
|
+
prevCursor: { type: GraphQLString }
|
|
2751
|
+
}
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
function generateGraphQLSchema(config, state) {
|
|
2755
|
+
const queryFields = {};
|
|
2756
|
+
const mutationFields = {};
|
|
2757
|
+
for (const collection of config.collections) {
|
|
2758
|
+
if (collection.hidden)
|
|
2759
|
+
continue;
|
|
2760
|
+
const handlers = createHandlers(collection, config);
|
|
2761
|
+
const collectionType = buildCollectionType(collection);
|
|
2762
|
+
const paginatedType = buildCollectionPaginatedType(collectionType, collection.slug);
|
|
2763
|
+
const sanitizedSlug = sanitizeGraphQLName(collection.slug);
|
|
2764
|
+
const capitalizedSlug = sanitizedSlug.charAt(0).toUpperCase() + sanitizedSlug.slice(1);
|
|
2765
|
+
queryFields[`find${capitalizedSlug}`] = {
|
|
2766
|
+
type: paginatedType,
|
|
2767
|
+
args: {
|
|
2768
|
+
limit: { type: GraphQLInt },
|
|
2769
|
+
page: { type: GraphQLInt },
|
|
2770
|
+
sort: { type: GraphQLString }
|
|
2771
|
+
},
|
|
2772
|
+
resolve: async (_, args, c) => {
|
|
2773
|
+
const req = {
|
|
2774
|
+
query: (key) => {
|
|
2775
|
+
if (!key)
|
|
2776
|
+
return args;
|
|
2777
|
+
return args[key] !== undefined ? String(args[key]) : undefined;
|
|
2778
|
+
},
|
|
2779
|
+
queries: () => ({})
|
|
2780
|
+
};
|
|
2781
|
+
const ctx = { ...c, req: { ...c.req, ...req } };
|
|
2782
|
+
ctx.req.query = req.query;
|
|
2783
|
+
let result;
|
|
2784
|
+
ctx.json = (data, status) => {
|
|
2785
|
+
if (status && status >= 400)
|
|
2786
|
+
throw new Error(data?.message || "Error");
|
|
2787
|
+
result = data;
|
|
2788
|
+
return data;
|
|
2789
|
+
};
|
|
2790
|
+
await handlers.find(ctx);
|
|
2791
|
+
return result;
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
queryFields[`get${capitalizedSlug}`] = {
|
|
2795
|
+
type: collectionType,
|
|
2796
|
+
args: {
|
|
2797
|
+
id: { type: new GraphQLNonNull(GraphQLString) }
|
|
2798
|
+
},
|
|
2799
|
+
resolve: async (_, args, c) => {
|
|
2800
|
+
const req = {
|
|
2801
|
+
param: (key) => {
|
|
2802
|
+
if (key === "id")
|
|
2803
|
+
return args.id;
|
|
2804
|
+
if (!key)
|
|
2805
|
+
return { id: args.id };
|
|
2806
|
+
return args.id;
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
const ctx = { ...c, req: { ...c.req, ...req } };
|
|
2810
|
+
ctx.req.param = req.param;
|
|
2811
|
+
let result;
|
|
2812
|
+
ctx.json = (data, status) => {
|
|
2813
|
+
if (status && status >= 400)
|
|
2814
|
+
throw new Error(data?.message || "Error");
|
|
2815
|
+
result = data;
|
|
2816
|
+
return data;
|
|
2817
|
+
};
|
|
2818
|
+
await handlers.findOne(ctx);
|
|
2819
|
+
return result?.doc || result;
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
mutationFields[`create${capitalizedSlug}`] = {
|
|
2823
|
+
type: collectionType,
|
|
2824
|
+
args: {
|
|
2825
|
+
data: { type: new GraphQLNonNull(GraphQLJSON) }
|
|
2826
|
+
},
|
|
2827
|
+
resolve: async (_, args, c) => {
|
|
2828
|
+
const req = {
|
|
2829
|
+
json: async () => args.data
|
|
2830
|
+
};
|
|
2831
|
+
const ctx = { ...c, req: { ...c.req, ...req } };
|
|
2832
|
+
let result;
|
|
2833
|
+
ctx.json = (data, status) => {
|
|
2834
|
+
if (status && status >= 400)
|
|
2835
|
+
throw new Error(data?.message || "Error");
|
|
2836
|
+
result = data;
|
|
2837
|
+
return data;
|
|
2838
|
+
};
|
|
2839
|
+
await handlers.create(ctx);
|
|
2840
|
+
return result?.doc || result;
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
mutationFields[`update${capitalizedSlug}`] = {
|
|
2844
|
+
type: collectionType,
|
|
2845
|
+
args: {
|
|
2846
|
+
id: { type: new GraphQLNonNull(GraphQLString) },
|
|
2847
|
+
data: { type: new GraphQLNonNull(GraphQLJSON) }
|
|
2848
|
+
},
|
|
2849
|
+
resolve: async (_, args, c) => {
|
|
2850
|
+
const req = {
|
|
2851
|
+
param: (key) => {
|
|
2852
|
+
if (key === "id")
|
|
2853
|
+
return args.id;
|
|
2854
|
+
if (!key)
|
|
2855
|
+
return { id: args.id };
|
|
2856
|
+
return args.id;
|
|
2857
|
+
},
|
|
2858
|
+
json: async () => args.data
|
|
2859
|
+
};
|
|
2860
|
+
const ctx = { ...c, req: { ...c.req, ...req } };
|
|
2861
|
+
ctx.req.param = req.param;
|
|
2862
|
+
let result;
|
|
2863
|
+
ctx.json = (data, status) => {
|
|
2864
|
+
if (status && status >= 400)
|
|
2865
|
+
throw new Error(data?.message || "Error");
|
|
2866
|
+
result = data;
|
|
2867
|
+
return data;
|
|
2868
|
+
};
|
|
2869
|
+
await handlers.update(ctx);
|
|
2870
|
+
return result?.doc || result;
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
mutationFields[`delete${capitalizedSlug}`] = {
|
|
2874
|
+
type: GraphQLBoolean,
|
|
2875
|
+
args: {
|
|
2876
|
+
id: { type: new GraphQLNonNull(GraphQLString) }
|
|
2877
|
+
},
|
|
2878
|
+
resolve: async (_, args, c) => {
|
|
2879
|
+
const req = {
|
|
2880
|
+
param: (key) => {
|
|
2881
|
+
if (key === "id")
|
|
2882
|
+
return args.id;
|
|
2883
|
+
if (!key)
|
|
2884
|
+
return { id: args.id };
|
|
2885
|
+
return args.id;
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
const ctx = { ...c, req: { ...c.req, ...req } };
|
|
2889
|
+
ctx.req.param = req.param;
|
|
2890
|
+
let result;
|
|
2891
|
+
ctx.json = (data, status) => {
|
|
2892
|
+
if (status && status >= 400)
|
|
2893
|
+
throw new Error(data?.message || "Error");
|
|
2894
|
+
result = data;
|
|
2895
|
+
return data;
|
|
2896
|
+
};
|
|
2897
|
+
await handlers.delete(ctx);
|
|
2898
|
+
return result;
|
|
2899
|
+
}
|
|
2900
|
+
};
|
|
2901
|
+
}
|
|
2902
|
+
if (config.globals) {
|
|
2903
|
+
for (const globalConfig of config.globals) {
|
|
2904
|
+
const handlers = createGlobalHandlers(config, globalConfig, () => state.auth);
|
|
2905
|
+
const sanitizedGlobalSlug = sanitizeGraphQLName(globalConfig.slug);
|
|
2906
|
+
const capitalizedGlobalSlug = sanitizedGlobalSlug.charAt(0).toUpperCase() + sanitizedGlobalSlug.slice(1);
|
|
2907
|
+
const globalType = buildCollectionType(globalConfig, `Global_${sanitizedGlobalSlug}`);
|
|
2908
|
+
queryFields[`get${capitalizedGlobalSlug}`] = {
|
|
2909
|
+
type: globalType,
|
|
2910
|
+
resolve: async (_, __, c) => {
|
|
2911
|
+
const req = {};
|
|
2912
|
+
const ctx = { ...c, req: { ...c.req, ...req } };
|
|
2913
|
+
let result;
|
|
2914
|
+
ctx.json = (data) => {
|
|
2915
|
+
result = data;
|
|
2916
|
+
return data;
|
|
2917
|
+
};
|
|
2918
|
+
await handlers.find(ctx);
|
|
2919
|
+
return result;
|
|
2920
|
+
}
|
|
2921
|
+
};
|
|
2922
|
+
mutationFields[`update${capitalizedGlobalSlug}`] = {
|
|
2923
|
+
type: globalType,
|
|
2924
|
+
args: {
|
|
2925
|
+
data: { type: new GraphQLNonNull(GraphQLJSON) }
|
|
2926
|
+
},
|
|
2927
|
+
resolve: async (_, args, c) => {
|
|
2928
|
+
const req = {
|
|
2929
|
+
json: async () => args.data
|
|
2930
|
+
};
|
|
2931
|
+
const ctx = { ...c, req: { ...c.req, ...req } };
|
|
2932
|
+
let result;
|
|
2933
|
+
ctx.json = (data) => {
|
|
2934
|
+
result = data;
|
|
2935
|
+
return data;
|
|
2936
|
+
};
|
|
2937
|
+
await handlers.update(ctx);
|
|
2938
|
+
return result;
|
|
2939
|
+
}
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
if (Object.keys(queryFields).length === 0) {
|
|
2944
|
+
queryFields._empty = { type: GraphQLString, resolve: () => "Empty" };
|
|
2945
|
+
}
|
|
2946
|
+
const queryType = new GraphQLObjectType({
|
|
2947
|
+
name: "Query",
|
|
2948
|
+
fields: queryFields
|
|
2949
|
+
});
|
|
2950
|
+
const schemaConfig = { query: queryType };
|
|
2951
|
+
if (Object.keys(mutationFields).length > 0) {
|
|
2952
|
+
schemaConfig.mutation = new GraphQLObjectType({
|
|
2953
|
+
name: "Mutation",
|
|
2954
|
+
fields: mutationFields
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
return new GraphQLSchema(schemaConfig);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
// src/server/routers/plugins.ts
|
|
2961
|
+
function mountPluginRoutes(config, settings, logger2, router, env = {}) {
|
|
2962
|
+
if (config.plugins && Array.isArray(config.plugins)) {
|
|
2963
|
+
for (const plugin of config.plugins) {
|
|
2964
|
+
const pluginSettings = settings[plugin.name] || {};
|
|
2965
|
+
const pluginContext = { config, logger: logger2, settings: pluginSettings, env };
|
|
2966
|
+
if (plugin.onRequest) {
|
|
2967
|
+
router.use("*", async (c, next) => {
|
|
2968
|
+
const result = await plugin.onRequest(c);
|
|
2969
|
+
if (result === false) {
|
|
2970
|
+
return c.json({ error: "Blocked by plugin: " + plugin.name }, 403);
|
|
2971
|
+
}
|
|
2972
|
+
await next();
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
if (plugin.onRouterInit) {
|
|
2976
|
+
try {
|
|
2977
|
+
plugin.onRouterInit(router, pluginContext);
|
|
2978
|
+
} catch (e) {
|
|
2979
|
+
logger2.error(`[Plugin] ${plugin.name} failed during onRouterInit: `, e);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
function firePluginInitComplete(config, settings, logger2, env = {}) {
|
|
2986
|
+
if (config.plugins && Array.isArray(config.plugins)) {
|
|
2987
|
+
for (const plugin of config.plugins) {
|
|
2988
|
+
if (plugin.onInitComplete) {
|
|
2989
|
+
const pluginSettings = settings[plugin.name] || {};
|
|
2990
|
+
plugin.onInitComplete({ config, logger: logger2, settings: pluginSettings, env });
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// src/server/router.ts
|
|
2997
|
+
function createAPIRouter(config, settings = {}, env = {}) {
|
|
2998
|
+
const state = { auth: undefined, migrated: false };
|
|
2999
|
+
const app = new Hono4;
|
|
3000
|
+
const router = new Hono4;
|
|
3001
|
+
setupMiddlewares(router, config, state);
|
|
3002
|
+
setupAuthMiddlewares(router, config, state);
|
|
3003
|
+
router.get("/", (c) => {
|
|
3004
|
+
return c.json({ status: "ok", version: "1.0.0", appName: config.appName });
|
|
3005
|
+
});
|
|
3006
|
+
router.route("/auth", createAuthRouter(config, state));
|
|
3007
|
+
router.route("/__admin", createAdminRouter(config, settings, state));
|
|
3008
|
+
router.route("/__system", createSystemRouter(config));
|
|
3009
|
+
router.route("/", createAssetsServingRouter(config));
|
|
3010
|
+
mountPluginRoutes(config, settings, logger, router, env);
|
|
3011
|
+
const isRestEnabled = config.api?.rest?.enabled !== false;
|
|
3012
|
+
const isGraphQLEnabled = config.api?.graphql?.enabled === true;
|
|
3013
|
+
if (isRestEnabled) {
|
|
3014
|
+
mountCollectionRoutes(router, config, state);
|
|
3015
|
+
mountGlobalRoutes(router, config, state);
|
|
3016
|
+
}
|
|
3017
|
+
mountIncomingWebhookRoutes(router, config);
|
|
3018
|
+
if (isGraphQLEnabled) {
|
|
3019
|
+
const graphqlPath = config.api?.graphql?.path || "/graphql";
|
|
3020
|
+
const schema = generateGraphQLSchema(config, state);
|
|
3021
|
+
router.all(graphqlPath, async (c, next) => {
|
|
3022
|
+
try {
|
|
3023
|
+
const { graphqlServer } = await import("@hono/graphql-server");
|
|
3024
|
+
return graphqlServer({
|
|
3025
|
+
schema,
|
|
3026
|
+
graphiql: config.api?.graphql?.graphiql ?? false
|
|
3027
|
+
})(c, next);
|
|
3028
|
+
} catch (e) {
|
|
3029
|
+
logger.error("Failed to load @hono/graphql-server. Please install 'graphql' and '@hono/graphql-server' to use GraphQL features.", e);
|
|
3030
|
+
return c.json({ error: "GraphQL is enabled but dependencies are missing." }, 500);
|
|
3031
|
+
}
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
if (config.api?.openAPI?.enabled) {
|
|
3035
|
+
router.get("/open-api.json", (c) => {
|
|
3036
|
+
const schema = generateOpenAPISchema(config);
|
|
3037
|
+
return c.json(schema);
|
|
3038
|
+
});
|
|
3039
|
+
const referencePath = config.api.openAPI.path || "/reference";
|
|
3040
|
+
const safeRefPath = referencePath.startsWith("/") ? referencePath : `/${referencePath}`;
|
|
3041
|
+
router.get(safeRefPath, async (c, next) => {
|
|
3042
|
+
const { Scalar } = await import("@scalar/hono-api-reference");
|
|
3043
|
+
return Scalar({
|
|
3044
|
+
pageTitle: `${config.appName || "OpacaCMS"} API Documentation`,
|
|
3045
|
+
theme: config.api?.openAPI?.theme || "default",
|
|
3046
|
+
layout: config.api?.openAPI?.layout === "classic" ? "classic" : "modern",
|
|
3047
|
+
hideModels: config.api?.openAPI?.hideModels,
|
|
3048
|
+
hideDownloadButton: config.api?.openAPI?.hideDownloadButton,
|
|
3049
|
+
customCss: config.api?.openAPI?.customCss,
|
|
3050
|
+
...{
|
|
3051
|
+
sources: [
|
|
3052
|
+
{ url: "/api/open-api.json", title: "CMS Collections" },
|
|
3053
|
+
{ url: "/api/auth/open-api/generate-schema", title: "Auth APIs" }
|
|
3054
|
+
]
|
|
3055
|
+
}
|
|
3056
|
+
})(c, next);
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
firePluginInitComplete(config, settings, logger, env);
|
|
3060
|
+
app.route("/api", router);
|
|
3061
|
+
app.get("/", (c) => c.redirect("/api"));
|
|
3062
|
+
app.notFound((c) => {
|
|
3063
|
+
console.error(`[OpacaRouter] 404 Not Found: ${c.req.method} ${c.req.url}`);
|
|
3064
|
+
return c.json({ message: "Not Found", path: c.req.path }, 404);
|
|
3065
|
+
});
|
|
3066
|
+
return app;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
// src/runtimes/cloudflare-workers.ts
|
|
15
3070
|
var cachedApp;
|
|
16
3071
|
function createCloudflareWorkersHandler(configOrFactory, options) {
|
|
17
|
-
const app = new
|
|
3072
|
+
const app = new Hono5;
|
|
18
3073
|
app.use("/api/*", async (c, next) => {
|
|
19
3074
|
if (cachedApp)
|
|
20
3075
|
return cachedApp.fetch(c.req.raw, c.env, c.executionCtx);
|
|
21
3076
|
const config = typeof configOrFactory === "function" ? await configOrFactory(c.env, c.req.raw) : configOrFactory;
|
|
22
3077
|
const apiRouter = createAPIRouter(config, {}, c.env);
|
|
23
|
-
const innerApp = new
|
|
3078
|
+
const innerApp = new Hono5;
|
|
24
3079
|
innerApp.route("/", apiRouter);
|
|
25
3080
|
cachedApp = innerApp;
|
|
26
3081
|
return cachedApp.fetch(c.req.raw, c.env, c.executionCtx);
|