opacacms 0.1.7 → 0.1.8

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.
Files changed (46) hide show
  1. package/dist/admin/index.js +255 -20
  2. package/dist/admin/webcomponent.js +291 -46
  3. package/dist/cli/index.js +3638 -30
  4. package/dist/client.js +126 -5
  5. package/dist/db/bun-sqlite.js +790 -21
  6. package/dist/db/d1.js +788 -19
  7. package/dist/db/index.js +53 -4
  8. package/dist/db/postgres.js +792 -23
  9. package/dist/db/sqlite.js +788 -19
  10. package/dist/index.js +456 -8
  11. package/dist/runtimes/bun.js +1909 -8
  12. package/dist/runtimes/cloudflare-workers.js +1910 -9
  13. package/dist/runtimes/next.js +1908 -7
  14. package/dist/runtimes/node.js +1909 -8
  15. package/dist/schema/collection.d.ts +3 -7
  16. package/dist/schema/fields/index.d.ts +24 -25
  17. package/dist/schema/global.d.ts +9 -9
  18. package/dist/schema/index.d.ts +30 -4
  19. package/dist/schema/index.js +546 -1
  20. package/dist/server.js +2246 -17
  21. package/dist/storage/index.js +40 -1
  22. package/package.json +1 -1
  23. package/dist/chunk-16vgcf3k.js +0 -88
  24. package/dist/chunk-2yz1nsxs.js +0 -126
  25. package/dist/chunk-5gvbp2qa.js +0 -167
  26. package/dist/chunk-62ev8gnc.js +0 -41
  27. package/dist/chunk-6ew02s0c.js +0 -472
  28. package/dist/chunk-7a9kn0np.js +0 -116
  29. package/dist/chunk-8sqjbsgt.js +0 -42
  30. package/dist/chunk-9kxpbcb1.js +0 -85
  31. package/dist/chunk-cvdd4eqh.js +0 -110
  32. package/dist/chunk-d3ffeqp9.js +0 -87
  33. package/dist/chunk-fa5mg0hr.js +0 -96
  34. package/dist/chunk-j4d50hrx.js +0 -20
  35. package/dist/chunk-jwjk85ze.js +0 -15
  36. package/dist/chunk-m09hahe2.js +0 -250
  37. package/dist/chunk-s8mqwnm1.js +0 -14
  38. package/dist/chunk-srsac177.js +0 -85
  39. package/dist/chunk-v521d72w.js +0 -10
  40. package/dist/chunk-vtvqfhgy.js +0 -2442
  41. package/dist/chunk-xa7rjsn2.js +0 -20
  42. package/dist/chunk-xg35h5a3.js +0 -15
  43. package/dist/chunk-y8hc6nm4.js +0 -17
  44. package/dist/chunk-ybbbqj63.js +0 -130
  45. package/dist/chunk-yr32cp7h.js +0 -1603
  46. package/dist/chunk-zvwb67nd.js +0 -332
@@ -1,22 +1,1923 @@
1
- import {
2
- createAPIRouter
3
- } from "../chunk-yr32cp7h.js";
4
- import"../chunk-62ev8gnc.js";
5
- import"../chunk-cvdd4eqh.js";
6
- import"../chunk-ybbbqj63.js";
7
- import"../chunk-8sqjbsgt.js";
1
+ import { createRequire } from "node:module";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ function __accessProp(key) {
7
+ return this[key];
8
+ }
9
+ var __toCommonJS = (from) => {
10
+ var entry = (__moduleCache ??= new WeakMap).get(from), desc;
11
+ if (entry)
12
+ return entry;
13
+ entry = __defProp({}, "__esModule", { value: true });
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (var key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(entry, key))
17
+ __defProp(entry, key, {
18
+ get: __accessProp.bind(from, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ __moduleCache.set(from, entry);
23
+ return entry;
24
+ };
25
+ var __moduleCache;
26
+ var __returnValue = (v) => v;
27
+ function __exportSetter(name, newValue) {
28
+ this[name] = __returnValue.bind(null, newValue);
29
+ }
30
+ var __export = (target, all) => {
31
+ for (var name in all)
32
+ __defProp(target, name, {
33
+ get: all[name],
34
+ enumerable: true,
35
+ configurable: true,
36
+ set: __exportSetter.bind(all, name)
37
+ });
38
+ };
39
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
40
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
41
+
42
+ // src/db/system-schema.ts
43
+ var exports_system_schema = {};
44
+ __export(exports_system_schema, {
45
+ getSystemCollections: () => getSystemCollections
46
+ });
47
+ var getSystemCollections = () => [
48
+ {
49
+ slug: "_opaca_assets",
50
+ label: "Assets",
51
+ apiPath: "assets",
52
+ fields: [
53
+ { name: "id", type: "text", required: true },
54
+ { name: "key", type: "text", required: true },
55
+ { name: "filename", type: "text", required: true },
56
+ { name: "originalFilename", type: "text", required: true },
57
+ { name: "mimeType", type: "text", required: true },
58
+ { name: "filesize", type: "number", required: true },
59
+ { name: "bucket", type: "text", required: true },
60
+ { name: "folder", type: "text" },
61
+ { name: "altText", type: "text" },
62
+ { name: "caption", type: "text" },
63
+ { name: "uploadedBy", type: "text" }
64
+ ],
65
+ timestamps: true
66
+ },
67
+ {
68
+ slug: "_users",
69
+ apiPath: "users",
70
+ fields: [
71
+ { name: "id", type: "text", required: true },
72
+ { name: "name", type: "text", required: true },
73
+ { name: "email", type: "text", required: true, unique: true },
74
+ { name: "emailVerified", type: "boolean", required: true, defaultValue: false },
75
+ { name: "image", type: "text" },
76
+ { name: "role", type: "text" },
77
+ { name: "banned", type: "boolean" },
78
+ { name: "banReason", type: "text" },
79
+ { name: "banExpires", type: "date" }
80
+ ],
81
+ timestamps: true
82
+ },
83
+ {
84
+ slug: "_sessions",
85
+ fields: [
86
+ { name: "id", type: "text", required: true },
87
+ { name: "expiresAt", type: "date", required: true },
88
+ { name: "token", type: "text", required: true, unique: true },
89
+ { name: "ipAddress", type: "text" },
90
+ { name: "userAgent", type: "text" },
91
+ {
92
+ name: "userId",
93
+ type: "text",
94
+ required: true,
95
+ references: { table: "_users", column: "id", onDelete: "cascade" }
96
+ },
97
+ { name: "impersonatedBy", type: "text" }
98
+ ],
99
+ timestamps: true,
100
+ hidden: true
101
+ },
102
+ {
103
+ slug: "_accounts",
104
+ fields: [
105
+ { name: "id", type: "text", required: true },
106
+ { name: "accountId", type: "text", required: true },
107
+ { name: "providerId", type: "text", required: true },
108
+ {
109
+ name: "userId",
110
+ type: "text",
111
+ required: true,
112
+ references: { table: "_users", column: "id", onDelete: "cascade" }
113
+ },
114
+ { name: "accessToken", type: "text" },
115
+ { name: "refreshToken", type: "text" },
116
+ { name: "idToken", type: "text" },
117
+ { name: "accessTokenExpiresAt", type: "date" },
118
+ { name: "refreshTokenExpiresAt", type: "date" },
119
+ { name: "scope", type: "text" },
120
+ { name: "password", type: "text" }
121
+ ],
122
+ timestamps: true,
123
+ hidden: true
124
+ },
125
+ {
126
+ slug: "_verifications",
127
+ fields: [
128
+ { name: "id", type: "text", required: true },
129
+ { name: "identifier", type: "text", required: true },
130
+ { name: "value", type: "text", required: true },
131
+ { name: "expiresAt", type: "date", required: true }
132
+ ],
133
+ timestamps: true,
134
+ hidden: true
135
+ },
136
+ {
137
+ slug: "_api_keys",
138
+ fields: [
139
+ { name: "id", type: "text", required: true },
140
+ { name: "configId", type: "text", required: true },
141
+ { name: "name", type: "text" },
142
+ { name: "start", type: "text" },
143
+ { name: "prefix", type: "text" },
144
+ { name: "key", type: "text", required: true },
145
+ { name: "referenceId", type: "text", required: true },
146
+ { name: "refillInterval", type: "number" },
147
+ { name: "refillAmount", type: "number" },
148
+ { name: "lastRefillAt", type: "date" },
149
+ { name: "enabled", type: "boolean", required: true },
150
+ { name: "rateLimitEnabled", type: "boolean", required: true },
151
+ { name: "rateLimitTimeWindow", type: "number" },
152
+ { name: "rateLimitMax", type: "number" },
153
+ { name: "requestCount", type: "number", required: true },
154
+ { name: "remaining", type: "number" },
155
+ { name: "lastRequest", type: "date" },
156
+ { name: "expiresAt", type: "date" },
157
+ { name: "permissions", type: "text" },
158
+ { name: "metadata", type: "text" }
159
+ ],
160
+ timestamps: { createdAt: "createdAt", updatedAt: "updatedAt" },
161
+ hidden: true
162
+ }
163
+ ];
164
+
165
+ // src/utils/logger.ts
166
+ var RESET = "\x1B[0m", BLUE = "\x1B[34m", GREEN = "\x1B[32m", YELLOW = "\x1B[33m", RED = "\x1B[31m", GRAY = "\x1B[90m", PREFIX, logger;
167
+ var init_logger = __esm(() => {
168
+ PREFIX = `${BLUE}[OpacaCMS]${RESET}`;
169
+ logger = {
170
+ info: (message, ...args) => {
171
+ console.log(`${PREFIX} ${message}`, ...args);
172
+ },
173
+ success: (message, ...args) => {
174
+ console.log(`${PREFIX} ${GREEN}${message}${RESET}`, ...args);
175
+ },
176
+ debug: (message, ...args) => {
177
+ console.log(`${PREFIX} ${GRAY}${message}${RESET}`, ...args);
178
+ },
179
+ warn: (message, ...args) => {
180
+ console.warn(`${PREFIX} ${YELLOW}Warning: ${message}${RESET}`, ...args);
181
+ },
182
+ error: (message, ...args) => {
183
+ console.error(`${PREFIX} ${RED}Error: ${message}${RESET}`, ...args);
184
+ },
185
+ format: (color, msg) => {
186
+ switch (color) {
187
+ case "green":
188
+ return `${GREEN}${msg}${RESET}`;
189
+ case "red":
190
+ return `${RED}${msg}${RESET}`;
191
+ case "yellow":
192
+ return `${YELLOW}${msg}${RESET}`;
193
+ case "gray":
194
+ return `${GRAY}${msg}${RESET}`;
195
+ default:
196
+ return msg;
197
+ }
198
+ }
199
+ };
200
+ });
201
+
202
+ // src/db/kysely/field-mapper.ts
203
+ var exports_field_mapper = {};
204
+ __export(exports_field_mapper, {
205
+ toSnakeCase: () => toSnakeCase,
206
+ mapFieldToSQLiteType: () => mapFieldToSQLiteType,
207
+ mapFieldToPostgresType: () => mapFieldToPostgresType,
208
+ getRelationalFields: () => getRelationalFields,
209
+ flattenFields: () => flattenFields
210
+ });
211
+ function toSnakeCase(str) {
212
+ return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
213
+ }
214
+ function mapFieldToPostgresType(field) {
215
+ switch (field.type) {
216
+ case "text":
217
+ case "richtext":
218
+ case "select":
219
+ case "radio":
220
+ case "relationship":
221
+ return "text";
222
+ case "number":
223
+ return "double precision";
224
+ case "boolean":
225
+ return "boolean";
226
+ case "date":
227
+ return "timestamp with time zone";
228
+ case "json":
229
+ case "file":
230
+ return "jsonb";
231
+ default:
232
+ return "text";
233
+ }
234
+ }
235
+ function mapFieldToSQLiteType(field) {
236
+ switch (field.type) {
237
+ case "text":
238
+ case "richtext":
239
+ case "select":
240
+ case "radio":
241
+ case "relationship":
242
+ case "date":
243
+ case "json":
244
+ case "file":
245
+ return "text";
246
+ case "number":
247
+ return "numeric";
248
+ case "boolean":
249
+ return "integer";
250
+ default:
251
+ return "text";
252
+ }
253
+ }
254
+ function flattenFields(fields, prefix = "") {
255
+ const result = [];
256
+ for (const field of fields) {
257
+ if (field.type === "join" || field.type === "virtual")
258
+ continue;
259
+ const currentName = field.name ? `${prefix}${field.name}` : undefined;
260
+ if (field.type === "group") {
261
+ if (field.fields && Array.isArray(field.fields)) {
262
+ const nextPrefix = currentName ? `${currentName}__` : "";
263
+ result.push(...flattenFields(field.fields, nextPrefix));
264
+ }
265
+ continue;
266
+ }
267
+ if (field.type === "blocks") {
268
+ continue;
269
+ }
270
+ if (field.type === "relationship" && "hasMany" in field && field.hasMany) {
271
+ continue;
272
+ }
273
+ if (currentName) {
274
+ result.push({ ...field, name: currentName });
275
+ }
276
+ if (field.type === "row" || field.type === "collapsible") {
277
+ if (field.fields && Array.isArray(field.fields)) {
278
+ result.push(...flattenFields(field.fields, prefix));
279
+ }
280
+ }
281
+ if (field.type === "tabs" && field.tabs && Array.isArray(field.tabs)) {
282
+ for (const tab of field.tabs) {
283
+ if (tab.fields && Array.isArray(tab.fields)) {
284
+ result.push(...flattenFields(tab.fields, prefix));
285
+ }
286
+ }
287
+ }
288
+ }
289
+ return result;
290
+ }
291
+ function getRelationalFields(fields, prefix = "") {
292
+ const result = [];
293
+ for (const field of fields) {
294
+ const currentName = field.name ? `${prefix}${field.name}` : undefined;
295
+ if (field.type === "relationship" && "hasMany" in field && field.hasMany || field.type === "blocks") {
296
+ if (currentName) {
297
+ result.push({ ...field, name: currentName });
298
+ }
299
+ continue;
300
+ }
301
+ if (field.type === "group" || field.type === "row" || field.type === "collapsible") {
302
+ if (field.fields && Array.isArray(field.fields)) {
303
+ const nextPrefix = field.type === "group" && field.name ? `${currentName}__` : prefix;
304
+ result.push(...getRelationalFields(field.fields, nextPrefix));
305
+ }
306
+ continue;
307
+ }
308
+ if (field.type === "tabs" && field.tabs && Array.isArray(field.tabs)) {
309
+ for (const tab of field.tabs) {
310
+ if (tab.fields && Array.isArray(tab.fields)) {
311
+ result.push(...getRelationalFields(tab.fields, prefix));
312
+ }
313
+ }
314
+ }
315
+ }
316
+ return result;
317
+ }
8
318
 
9
319
  // src/runtimes/cloudflare-workers.ts
320
+ import { Hono as Hono4 } from "hono";
321
+
322
+ // src/server/router.ts
323
+ import { Hono as Hono3 } from "hono";
324
+
325
+ // src/server/admin-router.ts
10
326
  import { Hono } from "hono";
327
+
328
+ // src/config-utils.ts
329
+ function sanitizeConfig(config) {
330
+ const collections = [...config.collections];
331
+ const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
332
+ const systemCollections = getSystemCollections();
333
+ for (const systemCol of systemCollections) {
334
+ const isAsset = systemCol.slug === "_opaca_assets";
335
+ const isAuth = ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(systemCol.slug);
336
+ if (isAsset && config.storages || isAuth && supportsAuth) {
337
+ if (!collections.find((col) => col.slug === systemCol.slug)) {
338
+ collections.push({
339
+ ...systemCol,
340
+ admin: true
341
+ });
342
+ }
343
+ }
344
+ }
345
+ const sanitizeField = (f) => ({
346
+ name: f.name,
347
+ type: f.type,
348
+ label: f.label,
349
+ required: f.required,
350
+ unique: f.unique,
351
+ defaultValue: f.defaultValue,
352
+ localized: f.localized,
353
+ admin: f.admin ? {
354
+ description: f.admin.description,
355
+ hidden: f.admin.hidden,
356
+ readOnly: f.admin.readOnly,
357
+ components: f.admin.components
358
+ } : undefined,
359
+ options: f.options,
360
+ fields: f.fields ? f.fields.map(sanitizeField) : undefined,
361
+ relationTo: f.relationTo,
362
+ displayField: f.displayField,
363
+ collection: f.collection,
364
+ on: f.on,
365
+ blocks: f.blocks ? f.blocks.map((b) => ({
366
+ slug: b.slug,
367
+ label: b.label,
368
+ fields: b.fields.map(sanitizeField)
369
+ })) : undefined,
370
+ tabs: f.tabs ? f.tabs.map((t) => ({
371
+ label: t.label,
372
+ fields: t.fields.map(sanitizeField)
373
+ })) : undefined
374
+ });
375
+ return {
376
+ appName: config.appName,
377
+ serverURL: config.serverURL,
378
+ collections: collections.map((col) => ({
379
+ slug: col.slug,
380
+ apiPath: col.apiPath,
381
+ label: col.label,
382
+ icon: col.icon,
383
+ admin: col.admin,
384
+ hidden: col.hidden,
385
+ fields: col.fields.map(sanitizeField),
386
+ timestamps: col.timestamps,
387
+ auth: col.auth,
388
+ versions: col.versions
389
+ })),
390
+ globals: config.globals?.map((g) => ({
391
+ slug: g.slug,
392
+ label: g.label,
393
+ icon: g.icon,
394
+ fields: g.fields.map(sanitizeField)
395
+ })),
396
+ storages: config.storages ? Object.keys(config.storages).reduce((acc, key) => {
397
+ acc[key] = {};
398
+ return acc;
399
+ }, {}) : {},
400
+ i18n: config.i18n
401
+ };
402
+ }
403
+
404
+ // src/server/admin.ts
405
+ function createAdminHandlers(config, getAuth) {
406
+ const getMetadata = (c) => {
407
+ return c.json(sanitizeConfig(config));
408
+ };
409
+ const getCollections = (c) => {
410
+ const collections = [...config.collections];
411
+ const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
412
+ const { getSystemCollections: getSystemCollections2 } = __toCommonJS(exports_system_schema);
413
+ const systemCollections = getSystemCollections2();
414
+ for (const systemCol of systemCollections) {
415
+ const isAsset = systemCol.slug === "_opaca_assets";
416
+ const isAuth = ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(systemCol.slug);
417
+ if (isAsset && config.storages || isAuth && supportsAuth) {
418
+ if (!collections.find((col) => col.slug === systemCol.slug)) {
419
+ collections.push({
420
+ ...systemCol,
421
+ admin: true
422
+ });
423
+ }
424
+ }
425
+ }
426
+ const filteredCollections = collections.filter((c2) => !c2.hidden);
427
+ return c.json({
428
+ collections: filteredCollections,
429
+ globals: config.globals
430
+ });
431
+ };
432
+ const getConfig = async (c) => {
433
+ return c.json({
434
+ serverURL: config.serverURL,
435
+ admin: config.admin
436
+ });
437
+ };
438
+ const getSetupStatus = async (c) => {
439
+ try {
440
+ let userCount = 0;
441
+ try {
442
+ userCount = await config.db.count("_users");
443
+ } catch (_e) {
444
+ const result = await config.db.unsafe("SELECT COUNT(*) as count FROM _users");
445
+ const rows = result?.results || result || [];
446
+ userCount = Number(rows[0]?.count || rows[0]?.["count(*)"] || 0);
447
+ }
448
+ return c.json({
449
+ initialized: userCount > 0
450
+ });
451
+ } catch (e) {
452
+ console.error("[OpacaCMS] Failed to check setup status:", e);
453
+ return c.json({
454
+ initialized: false
455
+ });
456
+ }
457
+ };
458
+ const createApiKey = async (c) => {
459
+ const auth = getAuth();
460
+ if (!auth) {
461
+ return c.json({ message: "Auth not initialized" }, 503);
462
+ }
463
+ const user = c.get("user");
464
+ if (!user) {
465
+ return c.json({ message: "Unauthorized" }, 401);
466
+ }
467
+ try {
468
+ const { name, expiresIn, permissions } = await c.req.json();
469
+ if (!name || typeof name !== "string") {
470
+ return c.json({ message: "Invalid or missing 'name'" }, 400);
471
+ }
472
+ const res = await auth.api.createApiKey({
473
+ body: {
474
+ name,
475
+ expiresIn: expiresIn ? Number(expiresIn) : undefined,
476
+ permissions,
477
+ userId: user.id
478
+ }
479
+ });
480
+ return c.json(res);
481
+ } catch (err) {
482
+ console.error("[OpacaCMS] Failed to create API key:", err);
483
+ const message = err?.message || (typeof err === "string" ? err : JSON.stringify(err));
484
+ return c.json({ message, detail: err }, 400);
485
+ }
486
+ };
487
+ return {
488
+ getMetadata,
489
+ getCollections,
490
+ getConfig,
491
+ getSetupStatus,
492
+ createApiKey
493
+ };
494
+ }
495
+
496
+ // src/server/middlewares/admin.ts
497
+ var adminMiddleware = async (c, next) => {
498
+ const user = c.get("user");
499
+ const isPublicAdmin = c.req.path.endsWith("/__admin/metadata") || c.req.path.endsWith("/__admin/setup");
500
+ if (!user && !isPublicAdmin) {
501
+ return c.json({ message: "Unauthorized" }, 401);
502
+ }
503
+ if (isPublicAdmin) {
504
+ await next();
505
+ return;
506
+ }
507
+ if (user.role === "admin" || user.role?.includes("admin")) {
508
+ await next();
509
+ return;
510
+ }
511
+ return c.json({ message: "Forbidden" }, 403);
512
+ };
513
+
514
+ // src/server/admin-router.ts
515
+ function createAdminRouter(config, state) {
516
+ const adminRouter = new Hono;
517
+ const adminHandlers = createAdminHandlers(config, () => state.auth);
518
+ adminRouter.get("/collections", adminMiddleware, adminHandlers.getCollections);
519
+ adminRouter.get("/metadata", adminHandlers.getMetadata);
520
+ adminRouter.get("/config", adminMiddleware, adminHandlers.getConfig);
521
+ adminRouter.get("/setup", adminHandlers.getSetupStatus);
522
+ adminRouter.post("/api-key/create", adminMiddleware, adminHandlers.createApiKey);
523
+ return adminRouter;
524
+ }
525
+ // src/server/handlers.ts
526
+ init_logger();
527
+
528
+ // src/validator.ts
529
+ import { z } from "zod";
530
+ function generateSchemaForCollection(collection, isUpdate = false) {
531
+ const shape = mapFieldsToShape(collection.fields, isUpdate);
532
+ if (collection.versions) {
533
+ shape._status = z.enum(["draft", "published"]).optional().nullable();
534
+ }
535
+ const ts = collection.timestamps;
536
+ if (ts !== false && ts !== undefined) {
537
+ const config = typeof ts === "object" ? ts : {};
538
+ const createdField = config.createdAt || "createdAt";
539
+ const updatedField = config.updatedAt || "updatedAt";
540
+ shape[createdField] = z.union([z.string(), z.date()]).optional().nullable();
541
+ shape[updatedField] = z.union([z.string(), z.date()]).optional().nullable();
542
+ }
543
+ return z.object(shape);
544
+ }
545
+ function mapFieldsToShape(fields, isUpdate = false) {
546
+ const shape = {};
547
+ for (const field of fields) {
548
+ if (!field.name) {
549
+ if (field.type === "tabs" && field.tabs) {
550
+ for (const tab of field.tabs) {
551
+ Object.assign(shape, mapFieldsToShape(tab.fields, isUpdate));
552
+ }
553
+ } else if (field.type === "group" && field.fields) {
554
+ Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
555
+ } else if (field.type === "row" && field.fields) {
556
+ Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
557
+ } else if (field.type === "collapsible" && field.fields) {
558
+ Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
559
+ }
560
+ continue;
561
+ }
562
+ const fieldName = field.name;
563
+ let schema;
564
+ if (field.type === "group" && field.fields) {
565
+ schema = z.object(mapFieldsToShape(field.fields, isUpdate));
566
+ } else if (field.type === "blocks" && field.blocks) {
567
+ const blockSchemas = field.blocks.map((block) => z.object({
568
+ blockType: z.literal(block.slug),
569
+ ...mapFieldsToShape(block.fields, isUpdate)
570
+ }));
571
+ schema = z.array(z.union(blockSchemas));
572
+ } else if (field.type === "row" || field.type === "collapsible") {
573
+ if (field.fields) {
574
+ schema = z.object(mapFieldsToShape(field.fields, isUpdate));
575
+ } else {
576
+ schema = z.any();
577
+ }
578
+ } else {
579
+ schema = mapFieldToZod(field);
580
+ }
581
+ if (field.localized) {
582
+ schema = z.union([z.record(z.string(), schema), schema]);
583
+ }
584
+ if (field.required && !isUpdate) {
585
+ if (field.type === "slug" && field.from) {
586
+ schema = schema.optional().nullable();
587
+ }
588
+ } else {
589
+ schema = schema.optional().nullable();
590
+ }
591
+ if (field.defaultValue !== undefined && !isUpdate) {
592
+ schema = schema.default(field.defaultValue);
593
+ }
594
+ if (field.validate) {
595
+ schema = schema.superRefine((val, ctx) => {
596
+ if (val === undefined || val === null)
597
+ return;
598
+ const result = field.validate(val);
599
+ if (result !== true) {
600
+ ctx.addIssue({
601
+ code: z.ZodIssueCode.custom,
602
+ message: typeof result === "string" ? result : "Invalid field"
603
+ });
604
+ }
605
+ });
606
+ }
607
+ shape[fieldName] = schema;
608
+ }
609
+ return shape;
610
+ }
611
+ function mapFieldToZod(field) {
612
+ switch (field.type) {
613
+ case "text":
614
+ case "slug":
615
+ case "richtext":
616
+ case "textarea":
617
+ return z.string();
618
+ case "relationship": {
619
+ const isHasMany = "hasMany" in field && field.hasMany;
620
+ const base = z.union([z.string(), z.number(), z.undefined(), z.null()]);
621
+ const schema = isHasMany ? z.array(base.optional().nullable()) : base;
622
+ if (isHasMany) {
623
+ return z.preprocess((val) => {
624
+ if (val === undefined || val === null || val === "")
625
+ return [];
626
+ if (Array.isArray(val))
627
+ return val;
628
+ return [val];
629
+ }, schema);
630
+ }
631
+ return schema;
632
+ }
633
+ case "number":
634
+ return z.preprocess((val) => {
635
+ if (val === "" || val === undefined || val === null)
636
+ return;
637
+ if (typeof val === "string") {
638
+ const num = Number(val);
639
+ return Number.isNaN(num) ? undefined : num;
640
+ }
641
+ return val;
642
+ }, z.union([z.number(), z.undefined(), z.null()]));
643
+ case "boolean":
644
+ return z.preprocess((val) => {
645
+ if (typeof val === "string")
646
+ return val === "true";
647
+ return val;
648
+ }, z.boolean());
649
+ case "date":
650
+ return z.preprocess((val) => {
651
+ if (val === "" || val === undefined || val === null)
652
+ return;
653
+ return val;
654
+ }, z.union([
655
+ z.string().regex(/^\d{4}-\d{2}-\d{2}/, "Invalid date format"),
656
+ z.date(),
657
+ z.undefined(),
658
+ z.null()
659
+ ]));
660
+ case "select":
661
+ return z.string();
662
+ case "array":
663
+ return z.preprocess((val) => {
664
+ if (val === undefined || val === null || val === "")
665
+ return [];
666
+ if (Array.isArray(val))
667
+ return val;
668
+ return [val];
669
+ }, z.array(z.any()));
670
+ default:
671
+ return z.any();
672
+ }
673
+ }
674
+
675
+ // src/server/handlers.ts
676
+ var hydrateDoc = async (doc, fields, c, config) => {
677
+ if (!doc)
678
+ return doc;
679
+ const user = c.get("user");
680
+ const session = c.get("session");
681
+ const apiKey = c.get("apiKey");
682
+ const hydratePromises = fields.map(async (field) => {
683
+ if (field.type === "virtual" && typeof field.resolve === "function") {
684
+ try {
685
+ doc[field.name] = await field.resolve({
686
+ data: doc,
687
+ req: c,
688
+ user,
689
+ session,
690
+ apiKey
691
+ });
692
+ } catch (err) {
693
+ console.error(`[OpacaCMS] Failed to resolve virtual field ${field.name} `, err);
694
+ doc[field.name] = null;
695
+ }
696
+ }
697
+ if (field.fields && Array.isArray(field.fields)) {
698
+ await hydrateDoc(doc, field.fields, c, config);
699
+ }
700
+ if (field.tabs && Array.isArray(field.tabs)) {
701
+ for (const tab of field.tabs) {
702
+ if (tab.fields && Array.isArray(tab.fields)) {
703
+ await hydrateDoc(doc, tab.fields, c, config);
704
+ }
705
+ }
706
+ }
707
+ if (field.type === "group" && field.fields && doc[field.name]) {
708
+ await hydrateDoc(doc[field.name], field.fields, c, config);
709
+ }
710
+ if (field.type === "array" && field.fields && Array.isArray(doc[field.name])) {
711
+ await Promise.all(doc[field.name].map((item) => hydrateDoc(item, field.fields, c, config)));
712
+ }
713
+ if (field.type === "blocks" && field.blocks && Array.isArray(doc[field.name])) {
714
+ await Promise.all(doc[field.name].map((item) => {
715
+ const block = field.blocks.find((b) => b.slug === item.block_type);
716
+ if (block && block.fields) {
717
+ return hydrateDoc(item, block.fields, c, config);
718
+ }
719
+ return Promise.resolve();
720
+ }));
721
+ }
722
+ if (field.localized && doc[field.name] && typeof doc[field.name] === "object") {
723
+ const i18nConfig = config.i18n;
724
+ const requestedLocale = c.req.header("x-opaca-locale") || c.req.query("locale");
725
+ const targetLocale = requestedLocale || i18nConfig?.defaultLocale || "en";
726
+ if (targetLocale !== "all") {
727
+ const localizedValue = doc[field.name][targetLocale] ?? doc[field.name][i18nConfig?.defaultLocale || "en"] ?? "";
728
+ doc[field.name] = localizedValue;
729
+ }
730
+ }
731
+ });
732
+ await Promise.all(hydratePromises);
733
+ return doc;
734
+ };
735
+ var populateDoc = async (db, fields, doc, populateKeys) => {
736
+ if (!doc)
737
+ return doc;
738
+ const populatePromises = fields.filter((f) => {
739
+ const field = f;
740
+ return field.type === "relationship" && field.relationTo && populateKeys.includes(field.name) && doc[field.name];
741
+ }).map(async (f) => {
742
+ const field = f;
743
+ try {
744
+ const relatedDoc = await db.findOne(field.relationTo, { id: doc[field.name] });
745
+ if (relatedDoc) {
746
+ doc[field.name] = relatedDoc;
747
+ }
748
+ } catch (err) {
749
+ console.error(`[OpacaCMS] Failed to populate relationship ${field.name} `, err);
750
+ }
751
+ });
752
+ await Promise.all(populatePromises);
753
+ return doc;
754
+ };
755
+ function createHandlers(config, collection, getAuth) {
756
+ const { db } = config;
757
+ const checkAccess = async (c, action, data) => {
758
+ const access = collection.access?.[action];
759
+ const apiKey = c.get("apiKey");
760
+ if (collection.access?.requireApiKey && !apiKey) {
761
+ const user2 = c.get("user");
762
+ if (user2?.role === "admin" || Array.isArray(user2?.role) && user2.role.includes("admin")) {} else {
763
+ return false;
764
+ }
765
+ }
766
+ if (apiKey) {
767
+ if (apiKey.permissions) {
768
+ const collectionPermissions = apiKey.permissions[collection.slug];
769
+ if (collectionPermissions) {
770
+ if (!collectionPermissions.includes(action)) {
771
+ return false;
772
+ }
773
+ return true;
774
+ }
775
+ }
776
+ }
777
+ if (access === undefined)
778
+ return true;
779
+ if (typeof access === "boolean")
780
+ return access;
781
+ const user = c.get("user");
782
+ const session = c.get("session");
783
+ return await access({
784
+ req: c,
785
+ user,
786
+ session,
787
+ apiKey,
788
+ data
789
+ });
790
+ };
791
+ const getFieldAccessPermissions = async (c, operation, fields, data) => {
792
+ const permissions = {};
793
+ const user = c.get("user");
794
+ const session = c.get("session");
795
+ const apiKey = c.get("apiKey");
796
+ const accessArgs = {
797
+ req: c,
798
+ user,
799
+ session,
800
+ apiKey,
801
+ data,
802
+ operation
803
+ };
804
+ for (const field of fields) {
805
+ if (field.name) {
806
+ if (!field.access) {
807
+ permissions[field.name] = { hidden: false, readOnly: false, disabled: false };
808
+ } else {
809
+ const hidden = typeof field.access.hidden === "function" ? await field.access.hidden(accessArgs) : !!field.access.hidden;
810
+ const readOnly = typeof field.access.readOnly === "function" ? await field.access.readOnly(accessArgs) : !!field.access.readOnly;
811
+ const disabled = typeof field.access.disabled === "function" ? await field.access.disabled(accessArgs) : !!field.access.disabled;
812
+ permissions[field.name] = { hidden, readOnly, disabled };
813
+ }
814
+ }
815
+ if (field.fields && Array.isArray(field.fields)) {
816
+ const nestedPermissions = await getFieldAccessPermissions(c, operation, field.fields, data);
817
+ Object.assign(permissions, nestedPermissions);
818
+ }
819
+ if (field.tabs && Array.isArray(field.tabs)) {
820
+ for (const tab of field.tabs) {
821
+ if (tab.fields && Array.isArray(tab.fields)) {
822
+ const nestedPermissions = await getFieldAccessPermissions(c, operation, tab.fields, data);
823
+ Object.assign(permissions, nestedPermissions);
824
+ }
825
+ }
826
+ }
827
+ }
828
+ return permissions;
829
+ };
830
+ const saveVersion = async (doc, status) => {
831
+ if (!collection.versions)
832
+ return;
833
+ const versionsTable = `${collection.slug}_versions`.toLowerCase();
834
+ const versionDoc = {
835
+ id: crypto.randomUUID(),
836
+ _parent_id: doc.id,
837
+ _version_data: JSON.stringify(doc),
838
+ _status: status || doc._status || "published",
839
+ created_at: new Date().toISOString()
840
+ };
841
+ try {
842
+ await db.unsafe(`INSERT INTO ${versionsTable} (id, _parent_id, _version_data, _status, created_at) VALUES(?, ?, ?, ?, ?)`, [
843
+ versionDoc.id,
844
+ versionDoc._parent_id,
845
+ versionDoc._version_data,
846
+ versionDoc._status,
847
+ versionDoc.created_at
848
+ ]);
849
+ } catch (err) {
850
+ console.error(`[OpacaCMS] Failed to save version for ${collection.slug}: `, err);
851
+ }
852
+ };
853
+ return {
854
+ async find(c) {
855
+ if (!await checkAccess(c, "read")) {
856
+ return c.json({ message: "Forbidden" }, 403);
857
+ }
858
+ const queries = c.req.query();
859
+ const maxLimit = config.api?.maxLimit ?? 100;
860
+ const page = queries.page ? parseInt(queries.page, 10) : 1;
861
+ let limit = queries.limit ? parseInt(queries.limit, 10) : 10;
862
+ if (limit > maxLimit)
863
+ limit = maxLimit;
864
+ const sort = queries.sort;
865
+ const populate = queries.populate ? queries.populate.split(",") : [];
866
+ const filter = {};
867
+ for (const [key, value] of Object.entries(queries)) {
868
+ if (["page", "limit", "sort", "populate", "locale", "draft"].includes(key))
869
+ continue;
870
+ const match = key.match(/^([^[]+)\[([^\]]+)\]$/);
871
+ if (match) {
872
+ const field = match[1];
873
+ const op = match[2];
874
+ if (!filter[field])
875
+ filter[field] = {};
876
+ filter[field][op] = value;
877
+ } else {
878
+ filter[key] = value;
879
+ }
880
+ }
881
+ const results = await db.find(collection.slug, filter, { page, limit, sort });
882
+ if (populate.length > 0) {
883
+ results.docs = await Promise.all(results.docs.map((doc) => populateDoc(db, collection.fields, doc, populate)));
884
+ }
885
+ results.docs = await Promise.all(results.docs.map((doc) => hydrateDoc(doc, collection.fields, c, config)));
886
+ return c.json(results);
887
+ },
888
+ async findOne(c) {
889
+ if (!await checkAccess(c, "read")) {
890
+ return c.json({ message: "Forbidden" }, 403);
891
+ }
892
+ const queries = c.req.query();
893
+ const populate = queries.populate ? queries.populate.split(",") : [];
894
+ const id = c.req.param("id");
895
+ let doc = await db.findOne(collection.slug, { id });
896
+ if (!doc)
897
+ return c.json({ message: "Not found" }, 404);
898
+ if (populate.length > 0) {
899
+ doc = await populateDoc(db, collection.fields, doc, populate);
900
+ }
901
+ doc = await hydrateDoc(doc, collection.fields, c, config);
902
+ const permissions = await getFieldAccessPermissions(c, "read", collection.fields, doc);
903
+ const cleanDoc = { ...doc };
904
+ for (const [field, perm] of Object.entries(permissions)) {
905
+ if (perm.hidden) {
906
+ delete cleanDoc[field];
907
+ }
908
+ }
909
+ return c.json(cleanDoc);
910
+ },
911
+ async create(c) {
912
+ const body = await c.req.json();
913
+ if (!await checkAccess(c, "create", body)) {
914
+ return c.json({ message: "Forbidden" }, 403);
915
+ }
916
+ for (const field of collection.fields) {
917
+ if (field.type === "slug" && !body[field.name]) {
918
+ const fromValue = body[field.from];
919
+ if (fromValue && typeof fromValue === "string") {
920
+ const formatted = field.format ? field.format(fromValue) : fromValue.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
921
+ body[field.name] = formatted;
922
+ }
923
+ }
924
+ }
925
+ const schema = generateSchemaForCollection(collection);
926
+ const validation = schema.safeParse(body);
927
+ if (!validation.success) {
928
+ return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
929
+ }
930
+ let data = validation.data;
931
+ const locale = c.req.header("x-opaca-locale");
932
+ if (locale && locale !== "all") {
933
+ for (const field of collection.fields) {
934
+ if (field.name && field.localized && data[field.name] !== undefined) {
935
+ data[field.name] = { [locale]: data[field.name] };
936
+ }
937
+ }
938
+ }
939
+ if (collection.hooks?.beforeCreate) {
940
+ data = await collection.hooks.beforeCreate(data);
941
+ }
942
+ const doc = await db.create(collection.slug, data);
943
+ if (collection.hooks?.afterCreate) {
944
+ await collection.hooks.afterCreate(doc);
945
+ }
946
+ if (collection.webhooks) {
947
+ const afterCreateWebhooks = collection.webhooks.filter((w) => w.events.includes("afterCreate"));
948
+ for (const webhook of afterCreateWebhooks) {
949
+ const hookPromise = fetch(webhook.url, {
950
+ method: "POST",
951
+ headers: { "Content-Type": "application/json", ...webhook.headers },
952
+ body: JSON.stringify(doc)
953
+ }).catch((e) => logger.error(`Webhook afterCreate failed for ${collection.slug}: `, e));
954
+ if (c.executionCtx?.waitUntil) {
955
+ c.executionCtx.waitUntil(hookPromise);
956
+ }
957
+ }
958
+ }
959
+ if (collection.versions) {
960
+ await saveVersion(doc, body._status);
961
+ }
962
+ const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
963
+ return c.json(hydratedDoc, 201);
964
+ },
965
+ async update(c) {
966
+ const id = c.req.param("id");
967
+ const body = await c.req.json();
968
+ if (!await checkAccess(c, "update", body)) {
969
+ return c.json({ message: "Forbidden" }, 403);
970
+ }
971
+ for (const field of collection.fields) {
972
+ if (field.type === "slug" && !body[field.name]) {
973
+ const fromValue = body[field.from];
974
+ if (fromValue && typeof fromValue === "string") {
975
+ const formatted = field.format ? field.format(fromValue) : fromValue.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
976
+ body[field.name] = formatted;
977
+ }
978
+ }
979
+ }
980
+ const schema = generateSchemaForCollection(collection, true);
981
+ const validation = schema.safeParse(body);
982
+ if (!validation.success) {
983
+ return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
984
+ }
985
+ let data = validation.data;
986
+ const locale = c.req.header("x-opaca-locale");
987
+ if (locale && locale !== "all") {
988
+ let existing = null;
989
+ for (const field of collection.fields) {
990
+ if (field.name && field.localized && data[field.name] !== undefined) {
991
+ if (!existing) {
992
+ existing = await db.findOne(collection.slug, { id });
993
+ }
994
+ const currentObj = existing?.[field.name] || {};
995
+ data[field.name] = { ...currentObj, [locale]: data[field.name] };
996
+ }
997
+ }
998
+ }
999
+ if (collection.hooks?.beforeUpdate) {
1000
+ data = await collection.hooks.beforeUpdate(data);
1001
+ }
1002
+ const doc = await db.update(collection.slug, { id }, data);
1003
+ if (collection.hooks?.afterUpdate) {
1004
+ await collection.hooks.afterUpdate(doc);
1005
+ }
1006
+ if (collection.webhooks) {
1007
+ const afterUpdateWebhooks = collection.webhooks.filter((w) => w.events.includes("afterUpdate"));
1008
+ for (const webhook of afterUpdateWebhooks) {
1009
+ const hookPromise = fetch(webhook.url, {
1010
+ method: "POST",
1011
+ headers: { "Content-Type": "application/json", ...webhook.headers },
1012
+ body: JSON.stringify(doc)
1013
+ }).catch((e) => logger.error(`Webhook afterUpdate failed for ${collection.slug}: `, e));
1014
+ if (c.executionCtx?.waitUntil) {
1015
+ c.executionCtx.waitUntil(hookPromise);
1016
+ }
1017
+ }
1018
+ }
1019
+ if (collection.versions) {
1020
+ await saveVersion(doc, body._status);
1021
+ }
1022
+ const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
1023
+ return c.json(hydratedDoc);
1024
+ },
1025
+ async delete(c) {
1026
+ if (!await checkAccess(c, "delete")) {
1027
+ return c.json({ message: "Forbidden" }, 403);
1028
+ }
1029
+ const id = c.req.param("id");
1030
+ let docToPass = { id };
1031
+ try {
1032
+ if (collection.webhooks && collection.webhooks.some((w) => w.events.includes("afterDelete"))) {
1033
+ docToPass = await db.findOne(collection.slug, { id });
1034
+ }
1035
+ } catch (e) {}
1036
+ if (collection.hooks?.beforeDelete) {
1037
+ await collection.hooks.beforeDelete(id);
1038
+ }
1039
+ await db.delete(collection.slug, { id });
1040
+ if (collection.hooks?.afterDelete) {
1041
+ await collection.hooks.afterDelete(id);
1042
+ }
1043
+ if (collection.webhooks) {
1044
+ const afterDeleteWebhooks = collection.webhooks.filter((w) => w.events.includes("afterDelete"));
1045
+ for (const webhook of afterDeleteWebhooks) {
1046
+ const hookPromise = fetch(webhook.url, {
1047
+ method: "POST",
1048
+ headers: { "Content-Type": "application/json", ...webhook.headers },
1049
+ body: JSON.stringify(docToPass || { id })
1050
+ }).catch((e) => logger.error(`Webhook afterDelete failed for ${collection.slug}: `, e));
1051
+ if (c.executionCtx?.waitUntil) {
1052
+ c.executionCtx.waitUntil(hookPromise);
1053
+ }
1054
+ }
1055
+ }
1056
+ return c.json({ message: "Deleted" });
1057
+ },
1058
+ async findVersions(c) {
1059
+ if (!await checkAccess(c, "read")) {
1060
+ return c.json({ message: "Forbidden" }, 403);
1061
+ }
1062
+ const parentId = c.req.query("parentId");
1063
+ const versionsTable = `${collection.slug} _versions`.toLowerCase();
1064
+ const query = parentId ? `SELECT * FROM ${versionsTable} WHERE _parent_id = ? ORDER BY created_at DESC` : `SELECT * FROM ${versionsTable} ORDER BY created_at DESC`;
1065
+ const params = parentId ? [parentId] : [];
1066
+ try {
1067
+ const rows = await db.unsafe(query, params);
1068
+ return c.json({ docs: rows });
1069
+ } catch (err) {
1070
+ return c.json({ message: "Failed to fetch versions", error: err.message }, 500);
1071
+ }
1072
+ },
1073
+ async restoreVersion(c) {
1074
+ if (!await checkAccess(c, "update")) {
1075
+ return c.json({ message: "Forbidden" }, 403);
1076
+ }
1077
+ const versionId = c.req.param("versionId");
1078
+ const versionsTable = `${collection.slug} _versions`.toLowerCase();
1079
+ try {
1080
+ const versionRows = await db.unsafe(`SELECT * FROM ${versionsTable} WHERE id = ? `, [versionId]);
1081
+ if (versionRows.length === 0)
1082
+ return c.json({ message: "Version not found" }, 404);
1083
+ const version = versionRows[0];
1084
+ const data = JSON.parse(version._version_data);
1085
+ const id = version._parent_id;
1086
+ delete data.id;
1087
+ delete data.created_at;
1088
+ delete data.updated_at;
1089
+ const doc = await db.update(collection.slug, { id }, data);
1090
+ await saveVersion(doc, "published");
1091
+ return c.json(doc);
1092
+ } catch (err) {
1093
+ return c.json({ message: "Failed to restore version", error: err.message }, 500);
1094
+ }
1095
+ }
1096
+ };
1097
+ }
1098
+ function createGlobalHandlers(config, globalConfig, getAuth) {
1099
+ const { db } = config;
1100
+ const checkAccess = async (c, action, data) => {
1101
+ const access = globalConfig.access?.[action];
1102
+ if (access === undefined)
1103
+ return true;
1104
+ if (typeof access === "boolean")
1105
+ return access;
1106
+ const user = c.get("user");
1107
+ const session = c.get("session");
1108
+ const apiKey = c.get("apiKey");
1109
+ return await access({
1110
+ req: c,
1111
+ user,
1112
+ session,
1113
+ apiKey,
1114
+ data
1115
+ });
1116
+ };
1117
+ return {
1118
+ async find(c) {
1119
+ if (!await checkAccess(c, "read")) {
1120
+ return c.json({ message: "Forbidden" }, 403);
1121
+ }
1122
+ if (!db.findGlobal) {
1123
+ return c.json({ message: "Globals are not supported by this database adapter" }, 501);
1124
+ }
1125
+ const doc = await db.findGlobal(globalConfig.slug);
1126
+ const hydratedDoc = await hydrateDoc(doc || {}, globalConfig.fields, c, config);
1127
+ return c.json(hydratedDoc);
1128
+ },
1129
+ async update(c) {
1130
+ const body = await c.req.json();
1131
+ if (!await checkAccess(c, "update", body)) {
1132
+ return c.json({ message: "Forbidden" }, 403);
1133
+ }
1134
+ if (!db.updateGlobal) {
1135
+ return c.json({ message: "Globals are not supported by this database adapter" }, 501);
1136
+ }
1137
+ const schema = generateSchemaForCollection(globalConfig, true);
1138
+ const validation = schema.safeParse(body);
1139
+ if (!validation.success) {
1140
+ logger.error(`Validation Error on Global ${globalConfig.slug}: `, validation.error.format());
1141
+ return c.json({
1142
+ message: "Validation Error",
1143
+ errors: validation.error.format()
1144
+ }, 400);
1145
+ }
1146
+ const doc = await db.updateGlobal(globalConfig.slug, validation.data);
1147
+ const hydratedDoc = await hydrateDoc(doc, globalConfig.fields, c, config);
1148
+ return c.json(hydratedDoc);
1149
+ }
1150
+ };
1151
+ }
1152
+
1153
+ // src/server/collection-router.ts
1154
+ function mountCollectionRoutes(router, config, state) {
1155
+ const combinedCollections = [...config.collections];
1156
+ for (const systemCol of getSystemCollections()) {
1157
+ if (!combinedCollections.find((c) => c.slug === systemCol.slug)) {
1158
+ combinedCollections.push(systemCol);
1159
+ }
1160
+ }
1161
+ const exposedCollections = combinedCollections.filter((c) => !c.hidden);
1162
+ for (const collection of exposedCollections) {
1163
+ const handlers = createHandlers(config, collection, () => state.auth);
1164
+ const path = `/${collection.apiPath || collection.slug}`;
1165
+ router.get(path, handlers.find);
1166
+ router.get(`${path}/versions`, handlers.findVersions);
1167
+ router.get(`${path}/:id`, handlers.findOne);
1168
+ router.post(path, handlers.create);
1169
+ router.patch(`${path}/:id`, handlers.update);
1170
+ router.post(`${path}/versions/:versionId/restore`, handlers.restoreVersion);
1171
+ router.delete(`${path}/:id`, handlers.delete);
1172
+ }
1173
+ }
1174
+ function mountGlobalRoutes(router, config, state) {
1175
+ if (config.globals) {
1176
+ for (const globalConfig of config.globals) {
1177
+ const handlers = createGlobalHandlers(config, globalConfig, () => state.auth);
1178
+ const path = `/globals/${globalConfig.slug}`;
1179
+ router.get(path, handlers.find);
1180
+ router.post(path, handlers.update);
1181
+ router.patch(path, handlers.update);
1182
+ }
1183
+ }
1184
+ }
1185
+
1186
+ // src/server/setup-middlewares.ts
1187
+ init_logger();
1188
+
1189
+ // src/server/middlewares/auth.ts
1190
+ init_logger();
1191
+ function createAuthMiddleware(getAuth) {
1192
+ return async (c, next) => {
1193
+ const auth = getAuth();
1194
+ if (!auth) {
1195
+ c.set("user", null);
1196
+ c.set("session", null);
1197
+ c.set("apiKey", null);
1198
+ await next();
1199
+ return;
1200
+ }
1201
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
1202
+ if (session) {
1203
+ c.set("user", session.user);
1204
+ c.set("session", session.session);
1205
+ c.set("apiKey", null);
1206
+ await next();
1207
+ return;
1208
+ }
1209
+ const authHeader = c.req.header("Authorization");
1210
+ if (authHeader && authHeader.startsWith("Bearer ")) {
1211
+ const token = authHeader.split(" ")[1];
1212
+ if (token) {
1213
+ try {
1214
+ const result = await auth.api.verifyApiKey({
1215
+ headers: c.req.raw.headers,
1216
+ body: { key: token }
1217
+ });
1218
+ if (result && result.valid && result.key) {
1219
+ c.set("apiKey", {
1220
+ id: result.key.id,
1221
+ name: result.key.name,
1222
+ permissions: result.key.permissions,
1223
+ referenceId: result.key.referenceId
1224
+ });
1225
+ try {
1226
+ const ownerResult = await auth.options.database?.findOne?.("_users", {
1227
+ id: result.key.referenceId
1228
+ });
1229
+ c.set("user", ownerResult || null);
1230
+ } catch (e) {
1231
+ logger.warn("Failed to fetch API key owner from database:", e);
1232
+ c.set("user", null);
1233
+ }
1234
+ c.set("session", null);
1235
+ await next();
1236
+ return;
1237
+ }
1238
+ } catch (err) {
1239
+ logger.warn("API Key verification failed:", err);
1240
+ }
1241
+ }
1242
+ }
1243
+ c.set("user", null);
1244
+ c.set("session", null);
1245
+ c.set("apiKey", null);
1246
+ await next();
1247
+ };
1248
+ }
1249
+
1250
+ // src/server/middlewares/context.ts
1251
+ function createContextMiddleware(config) {
1252
+ return async (c, next) => {
1253
+ c.set("config", config);
1254
+ c.set("db", config.db);
1255
+ await next();
1256
+ };
1257
+ }
1258
+
1259
+ // src/server/middlewares/cors.ts
1260
+ import { cors } from "hono/cors";
1261
+ function createCorsMiddleware(config) {
1262
+ const trustedOrigins = config.trustedOrigins || [];
1263
+ return cors({
1264
+ origin: async (origin, _c) => {
1265
+ const allowed = typeof trustedOrigins === "function" ? await trustedOrigins(_c.req.raw) : trustedOrigins;
1266
+ if (Array.isArray(allowed) && allowed.includes(origin)) {
1267
+ return origin;
1268
+ }
1269
+ return;
1270
+ },
1271
+ allowMethods: ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"],
1272
+ exposeHeaders: ["Content-Length"],
1273
+ credentials: true
1274
+ });
1275
+ }
1276
+
1277
+ // src/auth/index.ts
1278
+ import { apiKey } from "@better-auth/api-key";
1279
+ import { betterAuth } from "better-auth";
1280
+ import { admin } from "better-auth/plugins";
1281
+ import { CamelCasePlugin } from "kysely";
1282
+
1283
+ // src/auth/premissions.ts
1284
+ import { createAccessControl } from "better-auth/plugins/access";
1285
+ function createPermissions(config) {
1286
+ const resources = {
1287
+ user: ["create", "read", "update", "delete", "ban", "impersonate"],
1288
+ session: ["read", "revoke", "delete"]
1289
+ };
1290
+ for (const collection of config.collections) {
1291
+ resources[collection.slug] = ["create", "read", "update", "delete"];
1292
+ }
1293
+ const ac = createAccessControl(resources);
1294
+ const adminRole = ac.newRole({
1295
+ user: ["create", "read", "update", "delete", "ban", "impersonate"],
1296
+ session: ["read", "revoke", "delete"]
1297
+ });
1298
+ for (const collection of config.collections) {
1299
+ adminRole[collection.slug] = ["create", "read", "update", "delete"];
1300
+ }
1301
+ const userRole = ac.newRole({});
1302
+ const roles = {
1303
+ admin: adminRole,
1304
+ user: userRole
1305
+ };
1306
+ if (config.access?.roles) {
1307
+ for (const [roleName, permissions] of Object.entries(config.access.roles)) {
1308
+ roles[roleName] = ac.newRole(permissions);
1309
+ }
1310
+ }
1311
+ return {
1312
+ ac,
1313
+ roles
1314
+ };
1315
+ }
1316
+
1317
+ // src/auth/index.ts
1318
+ async function createAuth(config) {
1319
+ const userAuth = config.auth || {};
1320
+ const trustedOrigins = config.trustedOrigins;
1321
+ const { ac, roles } = createPermissions(config);
1322
+ const rawDb = config.db.raw;
1323
+ const env = typeof process !== "undefined" ? process.env : {};
1324
+ const baseURL = String(config.serverURL || env.BETTER_AUTH_URL || "").replace(/\/$/, "");
1325
+ if (!baseURL) {
1326
+ throw new Error("[OpacaAuth] baseURL could not be determined. Please provide 'serverURL' in your config or 'BETTER_AUTH_URL' in your environment.");
1327
+ }
1328
+ const authURL = `${baseURL}/api/auth`;
1329
+ const secret = env.OPACA_SECRET || env.BETTER_AUTH_SECRET || config.secret;
1330
+ if (!secret) {
1331
+ throw new Error("[OpacaAuth] No secret found for authentication. Please provide 'OPACA_SECRET' or 'BETTER_AUTH_SECRET'.");
1332
+ }
1333
+ if (typeof process !== "undefined" && !env.BETTER_AUTH_URL) {
1334
+ process.env.BETTER_AUTH_URL = authURL;
1335
+ }
1336
+ let databaseConfig;
1337
+ if (config.db.name === "postgres" && rawDb) {
1338
+ const { PostgresJSDialect } = await import("kysely-postgres-js");
1339
+ const { Kysely } = await import("kysely");
1340
+ const kysely = new Kysely({
1341
+ dialect: new PostgresJSDialect({ postgres: rawDb }),
1342
+ plugins: [new CamelCasePlugin]
1343
+ });
1344
+ databaseConfig = {
1345
+ db: kysely,
1346
+ type: "pg"
1347
+ };
1348
+ } else if (config.db.name === "d1" && rawDb) {
1349
+ const { D1Dialect } = await import("kysely-d1");
1350
+ const { Kysely } = await import("kysely");
1351
+ const kysely = new Kysely({
1352
+ dialect: new D1Dialect({ database: rawDb }),
1353
+ plugins: [new CamelCasePlugin]
1354
+ });
1355
+ databaseConfig = {
1356
+ db: kysely,
1357
+ type: "sqlite"
1358
+ };
1359
+ } else {
1360
+ databaseConfig = {
1361
+ db: rawDb,
1362
+ type: "sqlite"
1363
+ };
1364
+ }
1365
+ const isSecure = baseURL.startsWith("https");
1366
+ const plugins = [
1367
+ admin({
1368
+ ac,
1369
+ roles
1370
+ }),
1371
+ ...userAuth.features?.apiKeys?.enabled ? [
1372
+ apiKey({
1373
+ defaultPrefix: "opaca_",
1374
+ schema: {
1375
+ apikey: {
1376
+ modelName: "_api_keys"
1377
+ }
1378
+ }
1379
+ })
1380
+ ] : []
1381
+ ];
1382
+ const socialProviders = {};
1383
+ if (userAuth.socialProviders?.github) {
1384
+ socialProviders.github = userAuth.socialProviders.github;
1385
+ }
1386
+ if (userAuth.socialProviders?.google) {
1387
+ socialProviders.google = userAuth.socialProviders.google;
1388
+ }
1389
+ return betterAuth({
1390
+ database: databaseConfig,
1391
+ baseURL,
1392
+ basePath: "/api/auth",
1393
+ secret,
1394
+ trustedOrigins,
1395
+ emailAndPassword: {
1396
+ enabled: userAuth.strategies?.emailPassword !== false
1397
+ },
1398
+ socialProviders,
1399
+ user: {
1400
+ modelName: "_users"
1401
+ },
1402
+ session: {
1403
+ modelName: "_sessions",
1404
+ expiresIn: (userAuth.session?.expiresInDays || 30) * 86400,
1405
+ updateAge: userAuth.session?.updateAgeSeconds || 86400
1406
+ },
1407
+ account: {
1408
+ modelName: "_accounts"
1409
+ },
1410
+ verification: {
1411
+ modelName: "_verifications"
1412
+ },
1413
+ advanced: {
1414
+ useSecureCookies: isSecure,
1415
+ defaultCookieAttributes: {
1416
+ sameSite: isSecure ? "none" : "lax",
1417
+ secure: isSecure
1418
+ }
1419
+ },
1420
+ databaseHooks: {
1421
+ user: {
1422
+ create: {
1423
+ before: async (user) => {
1424
+ try {
1425
+ let userCount = 0;
1426
+ try {
1427
+ userCount = await config.db.count("_users");
1428
+ } catch (e) {
1429
+ const result = await config.db.unsafe("SELECT COUNT(*) as count FROM _users");
1430
+ const rows = result?.results || result || [];
1431
+ userCount = Number(rows[0]?.count || rows[0]?.["count(*)"] || 0);
1432
+ }
1433
+ if (userCount === 0) {
1434
+ return {
1435
+ data: {
1436
+ ...user,
1437
+ role: "admin"
1438
+ }
1439
+ };
1440
+ }
1441
+ } catch (e) {
1442
+ console.error("[OpacaAuth] Failed to check user count in hook:", e);
1443
+ }
1444
+ }
1445
+ }
1446
+ }
1447
+ },
1448
+ plugins
1449
+ });
1450
+ }
1451
+
1452
+ // src/auth/migrations.ts
1453
+ init_logger();
1454
+ async function runAuthMigrations(db) {
1455
+ const rawDb = db.raw;
1456
+ if (!rawDb) {
1457
+ logger.error("Database not connected yet. Skipping auth migrations.");
1458
+ return;
1459
+ }
1460
+ const isPostgres = db.name === "postgres";
1461
+ const authCollections = getSystemCollections().filter((c) => ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(c.slug));
1462
+ try {
1463
+ for (const collection of authCollections) {
1464
+ const columnDefs = [];
1465
+ columnDefs.push(`"id" TEXT PRIMARY KEY`);
1466
+ for (const field of collection.fields) {
1467
+ if (field.name === "id")
1468
+ continue;
1469
+ let type = "TEXT";
1470
+ if (field.type === "number")
1471
+ type = "INTEGER";
1472
+ if (field.type === "boolean")
1473
+ type = isPostgres ? "BOOLEAN" : "INTEGER";
1474
+ if (field.type === "date")
1475
+ type = isPostgres ? "TIMESTAMPTZ" : "TEXT";
1476
+ let definition = `"${toSnakeCase(field.name)}" ${type}`;
1477
+ if (field.required)
1478
+ definition += " NOT NULL";
1479
+ if (field.unique)
1480
+ definition += " UNIQUE";
1481
+ if (field.defaultValue !== undefined) {
1482
+ const val = typeof field.defaultValue === "string" ? `'${field.defaultValue}'` : field.defaultValue;
1483
+ definition += ` DEFAULT ${val}`;
1484
+ }
1485
+ if (field.references) {
1486
+ definition += ` REFERENCES "${field.references.table}"("${toSnakeCase(field.references.column)}")`;
1487
+ if (field.references.onDelete) {
1488
+ definition += ` ON DELETE ${field.references.onDelete.toUpperCase()}`;
1489
+ }
1490
+ }
1491
+ columnDefs.push(definition);
1492
+ }
1493
+ const ts = collection.timestamps;
1494
+ if (ts !== false && ts !== undefined) {
1495
+ const config = typeof ts === "object" ? ts : {};
1496
+ const createdField = toSnakeCase(config.createdAt || "createdAt");
1497
+ const updatedField = toSnakeCase(config.updatedAt || "updatedAt");
1498
+ const fieldNames = new Set(collection.fields.map((f) => toSnakeCase(f.name)));
1499
+ if (!fieldNames.has(createdField)) {
1500
+ columnDefs.push(`"${createdField}" ${isPostgres ? "TIMESTAMPTZ" : "TEXT"} DEFAULT CURRENT_TIMESTAMP`);
1501
+ }
1502
+ if (!fieldNames.has(updatedField)) {
1503
+ columnDefs.push(`"${updatedField}" ${isPostgres ? "TIMESTAMPTZ" : "TEXT"} DEFAULT CURRENT_TIMESTAMP`);
1504
+ }
1505
+ }
1506
+ logger.info(` -> Verifying table: ${logger.format("green", `"${collection.slug}"`)}`);
1507
+ await db.unsafe(`CREATE TABLE IF NOT EXISTS "${collection.slug}" (${columnDefs.join(", ")})`);
1508
+ }
1509
+ logger.success("Auth tables verified/created successfully.");
1510
+ } catch (error) {
1511
+ logger.error("Failed to create auth tables:", error);
1512
+ }
1513
+ }
1514
+
1515
+ // src/server/middlewares/database-init.ts
1516
+ init_logger();
1517
+ function createDatabaseInitMiddleware(config, state) {
1518
+ const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
1519
+ return async (_c, next) => {
1520
+ if (!state.migrated) {
1521
+ const isDev = typeof process !== "undefined" && true;
1522
+ if (isDev) {
1523
+ logger.info(`Connecting to database: ${logger.format("yellow", config.db.name)}...`);
1524
+ } else {
1525
+ logger.debug(`Connecting to database: ${config.db.name}...`);
1526
+ }
1527
+ await config.db.connect();
1528
+ if (isDev) {
1529
+ logger.debug("Synchronizing database schema...");
1530
+ }
1531
+ const allCollections = [...config.collections];
1532
+ for (const systemCol of getSystemCollections()) {
1533
+ if (!allCollections.find((c) => c.slug === systemCol.slug)) {
1534
+ allCollections.push(systemCol);
1535
+ }
1536
+ }
1537
+ await config.db.migrate(allCollections, config.globals);
1538
+ if (isDev) {
1539
+ logger.success("Database schema synchronized.");
1540
+ }
1541
+ const shouldMigrate = config.runMigrationsOnStartup || isDev;
1542
+ if (shouldMigrate) {
1543
+ if (config.runMigrationsOnStartup && config.db.runMigrations) {
1544
+ logger.info("Running file-based migrations on startup...");
1545
+ await config.db.runMigrations();
1546
+ }
1547
+ await runAuthMigrations(config.db);
1548
+ } else {
1549
+ logger.debug("Automatic schema migrations skipped (Production).");
1550
+ }
1551
+ if (supportsAuth && !state.auth) {
1552
+ state.auth = await createAuth(config);
1553
+ }
1554
+ state.migrated = true;
1555
+ }
1556
+ await next();
1557
+ };
1558
+ }
1559
+
1560
+ // src/server/middlewares/rate-limit.ts
1561
+ import { rateLimiter } from "hono-rate-limiter";
1562
+ function createRateLimitMiddleware(config) {
1563
+ const rateLimitConfig = config.api?.rateLimit;
1564
+ if (rateLimitConfig?.enabled === false) {
1565
+ return async (_c, next) => await next();
1566
+ }
1567
+ const windowMs = rateLimitConfig?.windowMs || 60000;
1568
+ const limit = rateLimitConfig?.limit || 100;
1569
+ return async (c, next) => {
1570
+ let provider = rateLimitConfig?.provider?.(c);
1571
+ if (!provider && !rateLimitConfig?.store && c.env) {
1572
+ const rateLimitKey = Object.keys(c.env).find((key) => c.env[key]?.limit && typeof c.env[key].limit === "function");
1573
+ if (rateLimitKey) {
1574
+ provider = c.env[rateLimitKey];
1575
+ }
1576
+ }
1577
+ if (provider) {
1578
+ const limiter2 = rateLimiter({
1579
+ binding: () => provider,
1580
+ keyGenerator: rateLimitConfig?.keyGenerator || ((c2) => c2.req.header("cf-connecting-ip") || c2.req.header("x-forwarded-for") || "anonymous")
1581
+ });
1582
+ return limiter2(c, next);
1583
+ }
1584
+ let resolvedStore = rateLimitConfig?.store;
1585
+ if (!resolvedStore && c.env) {
1586
+ const kvBindingKey = Object.keys(c.env).find((key) => key.startsWith("OPACA_") && c.env[key]?.put && c.env[key]?.get);
1587
+ if (kvBindingKey) {
1588
+ try {
1589
+ const { WorkersKVStore } = await import("@hono-rate-limiter/cloudflare");
1590
+ resolvedStore = new WorkersKVStore({ namespace: c.env[kvBindingKey] });
1591
+ } catch (_) {}
1592
+ }
1593
+ }
1594
+ const limiter = rateLimiter({
1595
+ windowMs,
1596
+ limit,
1597
+ standardHeaders: "draft-6",
1598
+ store: resolvedStore,
1599
+ keyGenerator: rateLimitConfig?.keyGenerator || ((c2) => c2.req.header("cf-connecting-ip") || c2.req.header("x-forwarded-for") || "anonymous"),
1600
+ message: "Too many requests from this IP, please try again after a minute."
1601
+ });
1602
+ return limiter(c, next);
1603
+ };
1604
+ }
1605
+
1606
+ // src/server/setup-middlewares.ts
1607
+ function setupMiddlewares(router, config, state) {
1608
+ router.use("*", async (c, next) => {
1609
+ await next();
1610
+ c.res.headers.set("X-Powered-By", "OpacaCMS");
1611
+ });
1612
+ router.use("*", createContextMiddleware(config));
1613
+ router.use("*", createRateLimitMiddleware(config));
1614
+ router.use("*", createCorsMiddleware(config));
1615
+ router.use("*", createDatabaseInitMiddleware(config, state));
1616
+ router.onError((err, c) => {
1617
+ logger.error(`API Error: ${err.message}`, err);
1618
+ return c.json({ message: "Internal Server Error", error: err.message }, 500);
1619
+ });
1620
+ }
1621
+ function setupAuthMiddlewares(router, config, state) {
1622
+ const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
1623
+ if (supportsAuth) {
1624
+ router.use("*", createAuthMiddleware(() => state.auth));
1625
+ router.on(["POST", "GET"], ["/auth/*"], async (c) => {
1626
+ if (!state.auth) {
1627
+ return c.json({ message: "Auth not initialized" }, 503);
1628
+ }
1629
+ return await state.auth.handler(c.req.raw);
1630
+ });
1631
+ }
1632
+ }
1633
+
1634
+ // src/server/system-router.ts
1635
+ import { Hono as Hono2 } from "hono";
1636
+
1637
+ // src/server/assets.ts
1638
+ function createAssetsHandlers(config) {
1639
+ return {
1640
+ async upload(c) {
1641
+ const user = c.get("user");
1642
+ if (!user)
1643
+ return c.json({ error: "Unauthorized" }, 401);
1644
+ const bucket = c.req.query("bucket") || "default";
1645
+ if (!config.storages)
1646
+ return c.json({ error: "Storage not configured" }, 500);
1647
+ const storageAdapter = config.storages[bucket];
1648
+ if (!storageAdapter) {
1649
+ return c.json({ error: `Bucket '${bucket}' not found` }, 404);
1650
+ }
1651
+ try {
1652
+ try {
1653
+ if (config.db.name === "sqlite" || config.db.name === "d1") {
1654
+ const tableInfo = await config.db.unsafe(`PRAGMA table_info(_opaca_assets)`);
1655
+ const columns = tableInfo.map((c2) => c2.name);
1656
+ if (!columns.includes("folder"))
1657
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
1658
+ if (!columns.includes("alt_text"))
1659
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN alt_text TEXT`);
1660
+ if (!columns.includes("caption"))
1661
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
1662
+ } else if (config.db.name === "postgres") {
1663
+ const checkCols = await config.db.unsafe(`
1664
+ SELECT column_name FROM information_schema.columns
1665
+ WHERE table_name = '_opaca_assets' AND column_name IN ('folder', 'alt_text', 'caption')
1666
+ `);
1667
+ const existing = checkCols.map((c2) => c2.column_name);
1668
+ if (!existing.includes("folder"))
1669
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
1670
+ if (!existing.includes("alt_text"))
1671
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN "alt_text" TEXT`);
1672
+ if (!existing.includes("caption"))
1673
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
1674
+ }
1675
+ } catch (e) {
1676
+ console.error("Auto-patch columns failed", e);
1677
+ }
1678
+ const folder = c.req.query("folder") || null;
1679
+ const keyPrefix = folder ? `${folder}/` : "";
1680
+ const now = new Date().toISOString();
1681
+ const formData = await c.req.parseBody({ all: true });
1682
+ const fileRaw = formData["file"];
1683
+ const file = Array.isArray(fileRaw) ? fileRaw[0] : fileRaw;
1684
+ if (!file || typeof file !== "object" && typeof file !== "string") {
1685
+ return c.json({ error: "No file provided" }, 400);
1686
+ }
1687
+ const fileName = file.name || "unnamed";
1688
+ const fileType = file.type || "application/octet-stream";
1689
+ const fileSize = file.size || 0;
1690
+ const fileRecord = {
1691
+ filename: fileName,
1692
+ original_filename: fileName,
1693
+ mime_type: fileType,
1694
+ filesize: fileSize,
1695
+ stream: typeof file.stream === "function" ? file.stream() : new Response(file).body
1696
+ };
1697
+ const uploadedFileData = await storageAdapter.upload(fileRecord, {
1698
+ generateUniqueName: true,
1699
+ keyPrefix
1700
+ });
1701
+ const storedKey = keyPrefix + uploadedFileData.filename;
1702
+ try {
1703
+ const assetId = (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "");
1704
+ await config.db.create("_opaca_assets", {
1705
+ id: assetId,
1706
+ key: storedKey,
1707
+ filename: fileName,
1708
+ originalFilename: fileName,
1709
+ mimeType: uploadedFileData.mime_type,
1710
+ filesize: uploadedFileData.filesize,
1711
+ bucket,
1712
+ folder,
1713
+ altText: null,
1714
+ caption: null,
1715
+ uploadedBy: user.id || null
1716
+ });
1717
+ return c.json({
1718
+ assetId,
1719
+ ...uploadedFileData,
1720
+ key: storedKey
1721
+ }, 201);
1722
+ } catch (dbError) {
1723
+ console.error(`[OpacaCMS] Registry insert failed, rolling back physical file upload: ${storedKey}`);
1724
+ storageAdapter.delete(storedKey).catch((cleanupError) => {
1725
+ console.error(`[OpacaCMS] CRITICAL: Failed to clean up orphaned file ${storedKey}!`, cleanupError);
1726
+ });
1727
+ throw dbError;
1728
+ }
1729
+ } catch (error) {
1730
+ return c.json({ error: error.message }, 400);
1731
+ }
1732
+ },
1733
+ async list(c) {
1734
+ const user = c.get("user");
1735
+ if (!user || user.role !== "admin" && !user.role?.includes("admin")) {
1736
+ return c.json({ error: "Unauthorized" }, 401);
1737
+ }
1738
+ const bucket = c.req.query("bucket") || "all";
1739
+ const page = parseInt(c.req.query("page") || "1", 10);
1740
+ const limit = parseInt(c.req.query("limit") || "20", 10);
1741
+ const offset = (page - 1) * limit;
1742
+ const folder = c.req.query("folder") || null;
1743
+ try {
1744
+ let query = {};
1745
+ if (bucket !== "all")
1746
+ query.bucket = bucket;
1747
+ if (folder !== null && folder !== "") {
1748
+ query.folder = folder;
1749
+ } else {
1750
+ if (bucket !== "all") {
1751
+ query = {
1752
+ and: [{ bucket }, { or: [{ folder: null }, { folder: "" }] }]
1753
+ };
1754
+ } else {
1755
+ query = { or: [{ folder: null }, { folder: "" }] };
1756
+ }
1757
+ }
1758
+ const result = await config.db.find("_opaca_assets", query, {
1759
+ page,
1760
+ limit,
1761
+ sort: "created_at:desc"
1762
+ });
1763
+ const rows = result.docs;
1764
+ const total = result.totalDocs;
1765
+ let folderRows = [];
1766
+ const bucketFilter = bucket !== "all" ? `AND bucket = ?` : "";
1767
+ const bucketParam = bucket !== "all" ? [bucket] : [];
1768
+ if (config.db.name === "postgres") {
1769
+ const pgBucketFilter = bucketFilter.replace("?", "$1");
1770
+ if (folder === null || folder === "") {
1771
+ folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${pgBucketFilter}`, bucketParam);
1772
+ } else {
1773
+ folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(substring(folder from length($1) + 2), '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder LIKE $2 ${bucket !== "all" ? "AND bucket = $3" : ""}`, [folder, `${folder}/%`, ...bucketParam]);
1774
+ }
1775
+ } else {
1776
+ if (folder === null || folder === "") {
1777
+ folderRows = await config.db.unsafe(`
1778
+ SELECT DISTINCT
1779
+ CASE
1780
+ WHEN INSTR(folder, '/') > 0 THEN SUBSTR(folder, 1, INSTR(folder, '/') - 1)
1781
+ ELSE folder
1782
+ END as subfolder,
1783
+ bucket
1784
+ FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${bucketFilter}
1785
+ `, bucketParam);
1786
+ } else {
1787
+ const skipLen = folder.length + 2;
1788
+ folderRows = await config.db.unsafe(`
1789
+ SELECT DISTINCT
1790
+ CASE
1791
+ WHEN INSTR(SUBSTR(folder, ?), '/') > 0 THEN SUBSTR(SUBSTR(folder, ?), 1, INSTR(SUBSTR(folder, ?), '/') - 1)
1792
+ ELSE SUBSTR(folder, ?)
1793
+ END as subfolder,
1794
+ bucket
1795
+ FROM _opaca_assets WHERE folder LIKE ? ${bucketFilter}
1796
+ `, [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, ...bucketParam]);
1797
+ }
1798
+ }
1799
+ const folderMap = {};
1800
+ for (const row of folderRows) {
1801
+ if (!row.subfolder)
1802
+ continue;
1803
+ if (!folderMap[row.subfolder])
1804
+ folderMap[row.subfolder] = [];
1805
+ if (!folderMap[row.subfolder]?.includes(row.bucket)) {
1806
+ folderMap[row.subfolder]?.push(row.bucket);
1807
+ }
1808
+ }
1809
+ const folders = Object.entries(folderMap).map(([name, buckets]) => ({
1810
+ name,
1811
+ buckets
1812
+ }));
1813
+ return c.json({
1814
+ docs: rows,
1815
+ folders,
1816
+ totalDocs: total,
1817
+ limit,
1818
+ page,
1819
+ totalPages: Math.ceil(total / limit)
1820
+ });
1821
+ } catch (e) {
1822
+ return c.json({ error: e.message }, 500);
1823
+ }
1824
+ },
1825
+ async presign(c) {
1826
+ const user = c.get("user");
1827
+ if (!user)
1828
+ return c.json({ error: "Unauthorized" }, 401);
1829
+ const { filename, bucket = "default", operation = "write" } = await c.req.json();
1830
+ if (!config.storages || !config.storages[bucket]) {
1831
+ return c.json({ error: "Bucket not found" }, 404);
1832
+ }
1833
+ const adapter = config.storages[bucket];
1834
+ if (!adapter.generatePresignedUrl) {
1835
+ return c.json({ error: "Adapter does not support presigned URLs" }, 400);
1836
+ }
1837
+ try {
1838
+ const url = await adapter.generatePresignedUrl(filename, operation, 3600);
1839
+ return c.json({ uploadUrl: url, filename });
1840
+ } catch (e) {
1841
+ return c.json({ error: e.message }, 500);
1842
+ }
1843
+ },
1844
+ async serve(c) {
1845
+ const id = c.req.param("id");
1846
+ try {
1847
+ const asset = await config.db.findOne("_opaca_assets", { id });
1848
+ if (!asset) {
1849
+ return c.json({ error: "Asset not found" }, 404);
1850
+ }
1851
+ const bucket = asset.bucket || "default";
1852
+ if (!config.storages || !config.storages[bucket]) {
1853
+ return c.json({ error: "Storage bucket not configured" }, 500);
1854
+ }
1855
+ const adapter = config.storages[bucket];
1856
+ if (!adapter.download) {
1857
+ return c.json({ error: "Storage adapter does not support direct downloads" }, 400);
1858
+ }
1859
+ const stream = await adapter.download(asset.key || asset.filename);
1860
+ c.header("Content-Type", asset.mimeType || "application/octet-stream");
1861
+ c.header("Content-Length", asset.filesize.toString());
1862
+ c.header("Cache-Control", "public, max-age=86400");
1863
+ return c.body(stream);
1864
+ } catch (e) {
1865
+ console.error(`[OpacaCMS] Failed to serve asset ${id}:`, e);
1866
+ return c.json({ error: e.message }, 500);
1867
+ }
1868
+ }
1869
+ };
1870
+ }
1871
+
1872
+ // src/server/system-router.ts
1873
+ function createSystemRouter(config) {
1874
+ const systemRouter = new Hono2;
1875
+ if (config.storages) {
1876
+ const assetsHandlers = createAssetsHandlers(config);
1877
+ systemRouter.post("/assets/upload", adminMiddleware, assetsHandlers.upload);
1878
+ systemRouter.get("/assets", adminMiddleware, assetsHandlers.list);
1879
+ systemRouter.post("/assets/presign-upload", adminMiddleware, assetsHandlers.presign);
1880
+ }
1881
+ return systemRouter;
1882
+ }
1883
+ function createAssetsServingRouter(config) {
1884
+ const assetsServingRouter = new Hono2;
1885
+ if (config.storages) {
1886
+ const assetsHandlers = createAssetsHandlers(config);
1887
+ const assetCol = getSystemCollections().find((c) => c.slug === "_opaca_assets");
1888
+ const assetPath = `/${assetCol?.apiPath || assetCol?.slug || "_opaca_assets"}`;
1889
+ assetsServingRouter.get(`${assetPath}/:id/view`, assetsHandlers.serve);
1890
+ }
1891
+ return assetsServingRouter;
1892
+ }
1893
+
1894
+ // src/server/router.ts
1895
+ function createAPIRouter(config) {
1896
+ const state = { auth: undefined, migrated: false };
1897
+ const router = new Hono3().basePath("/api");
1898
+ setupMiddlewares(router, config, state);
1899
+ setupAuthMiddlewares(router, config, state);
1900
+ router.get("/", (c) => {
1901
+ return c.json({ status: "ok", version: "1.0.0", appName: config.appName });
1902
+ });
1903
+ router.route("/__admin", createAdminRouter(config, state));
1904
+ router.route("/__system", createSystemRouter(config));
1905
+ router.route("/", createAssetsServingRouter(config));
1906
+ mountCollectionRoutes(router, config, state);
1907
+ mountGlobalRoutes(router, config, state);
1908
+ return router;
1909
+ }
1910
+
1911
+ // src/runtimes/cloudflare-workers.ts
11
1912
  var cachedApp;
12
1913
  function createCloudflareWorkersHandler(configOrFactory) {
13
- const app = new Hono;
1914
+ const app = new Hono4;
14
1915
  app.use("/api/*", async (c, next) => {
15
1916
  if (cachedApp)
16
1917
  return cachedApp.fetch(c.req.raw, c.env, c.executionCtx);
17
1918
  const config = typeof configOrFactory === "function" ? await configOrFactory(c.env, c.req.raw) : configOrFactory;
18
1919
  const apiRouter = createAPIRouter(config);
19
- const innerApp = new Hono;
1920
+ const innerApp = new Hono4;
20
1921
  innerApp.route("/", apiRouter);
21
1922
  cachedApp = innerApp;
22
1923
  return cachedApp.fetch(c.req.raw, c.env, c.executionCtx);