@bobbykim/manguito-cms-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,3189 @@
1
+ // src/app.ts
2
+ import { Hono } from "hono";
3
+ import { sql as sql10 } from "drizzle-orm";
4
+
5
+ // src/middleware/cors.ts
6
+ function createCorsMiddleware(corsConfig) {
7
+ const allowList = Array.isArray(corsConfig.origin) ? corsConfig.origin : [corsConfig.origin];
8
+ const wildcard = allowList.includes("*");
9
+ const methods = corsConfig.methods?.join(",") ?? "GET,POST,PATCH,DELETE,OPTIONS";
10
+ return async function corsMiddleware(c, next) {
11
+ if (corsConfig.enabled === false) {
12
+ return next();
13
+ }
14
+ const requestOrigin = c.req.header("origin");
15
+ if (wildcard) {
16
+ c.res.headers.set("Access-Control-Allow-Origin", "*");
17
+ } else if (requestOrigin && allowList.includes(requestOrigin)) {
18
+ c.res.headers.set("Access-Control-Allow-Origin", requestOrigin);
19
+ c.res.headers.set("Vary", "Origin");
20
+ if (corsConfig.credentials === true) {
21
+ c.res.headers.set("Access-Control-Allow-Credentials", "true");
22
+ }
23
+ }
24
+ c.res.headers.set("Access-Control-Allow-Methods", methods);
25
+ c.res.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
26
+ if (c.req.method === "OPTIONS") {
27
+ return c.newResponse(null, 204);
28
+ }
29
+ return next();
30
+ };
31
+ }
32
+
33
+ // src/middleware/security-headers.ts
34
+ function createSecurityHeadersMiddleware(options = {}) {
35
+ const connectSrc = ["'self'", ...options.connectSrc ?? []].join(" ");
36
+ const csp = [
37
+ "default-src 'self'",
38
+ "img-src 'self' data: https:",
39
+ "media-src 'self' https:",
40
+ "style-src 'self' 'unsafe-inline'",
41
+ "script-src 'self'",
42
+ "font-src 'self' data:",
43
+ `connect-src ${connectSrc}`,
44
+ "frame-ancestors 'none'",
45
+ "base-uri 'self'",
46
+ "form-action 'self'"
47
+ ].join("; ");
48
+ return async function securityHeaders(c, next) {
49
+ await next();
50
+ c.res.headers.set("X-Content-Type-Options", "nosniff");
51
+ c.res.headers.set("X-Frame-Options", "DENY");
52
+ c.res.headers.set("Referrer-Policy", "no-referrer");
53
+ c.res.headers.set("Content-Security-Policy", csp);
54
+ };
55
+ }
56
+
57
+ // src/middleware/error.ts
58
+ var ERROR_STATUS_MAP = {
59
+ NOT_FOUND: 404,
60
+ SLUG_NOT_FOUND: 404,
61
+ METHOD_NOT_ALLOWED: 405,
62
+ VALIDATION_ERROR: 422,
63
+ INVALID_SLUG_FORMAT: 422,
64
+ SLUG_CONFLICT: 409,
65
+ PUBLISH_VALIDATION_ERROR: 422,
66
+ SINGLETON_ALREADY_EXISTS: 409,
67
+ UNAUTHORIZED: 401,
68
+ TOKEN_EXPIRED: 401,
69
+ TOKEN_INVALID: 401,
70
+ INSUFFICIENT_PERMISSION: 403,
71
+ INSUFFICIENT_PRIVILEGE: 403,
72
+ INVALID_FILTER_FIELD: 400,
73
+ INVALID_FILTER_OPERATOR: 400,
74
+ INVALID_SORT_FIELD: 400,
75
+ INVALID_PAGINATION: 400,
76
+ INVALID_INCLUDE_FIELD: 400,
77
+ UNSUPPORTED_MIME_TYPE: 415,
78
+ STORAGE_ERROR: 502,
79
+ MEDIA_IN_USE: 409,
80
+ PRESIGNED_URL_EXPIRED: 410,
81
+ RATE_LIMITED: 429,
82
+ INTERNAL_ERROR: 500
83
+ };
84
+ var errorHandler = (err, c) => {
85
+ console.error(err.stack ?? err.message);
86
+ const apiErr = err;
87
+ const code = apiErr.code ?? "INTERNAL_ERROR";
88
+ const status = ERROR_STATUS_MAP[code] ?? 500;
89
+ return c.json({ ok: false, error: { code, message: err.message } }, status);
90
+ };
91
+
92
+ // src/middleware/auth.ts
93
+ import { getCookie } from "hono/cookie";
94
+ import { sql } from "drizzle-orm";
95
+
96
+ // src/auth/jwt.ts
97
+ import { sign, verify as jwtVerify } from "hono/jwt";
98
+ import { setCookie, deleteCookie } from "hono/cookie";
99
+ var AUTH_TOKEN_TTL = 2 * 60 * 60;
100
+ var REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60;
101
+ var PROACTIVE_REFRESH_THRESHOLD = 30 * 60;
102
+ async function signToken(payload, expiresInSeconds) {
103
+ const secret = process.env["AUTH_SECRET"];
104
+ if (!secret) throw new Error("AUTH_SECRET environment variable is not set");
105
+ const fullPayload = {
106
+ ...payload,
107
+ expires_at: Math.floor(Date.now() / 1e3) + expiresInSeconds
108
+ };
109
+ return sign(fullPayload, secret);
110
+ }
111
+ async function verifyToken(token) {
112
+ const secret = process.env["AUTH_SECRET"];
113
+ if (!secret) throw new Error("AUTH_SECRET environment variable is not set");
114
+ let raw;
115
+ try {
116
+ raw = await jwtVerify(token, secret, "HS256");
117
+ } catch {
118
+ throw Object.assign(new Error("Token signature is invalid"), {
119
+ code: "TOKEN_INVALID"
120
+ });
121
+ }
122
+ const payload = raw;
123
+ if (payload.expires_at < Math.floor(Date.now() / 1e3)) {
124
+ throw Object.assign(new Error("Token has expired"), {
125
+ code: "TOKEN_EXPIRED"
126
+ });
127
+ }
128
+ return payload;
129
+ }
130
+ function setAuthCookie(c, name, token, options) {
131
+ setCookie(c, name, token, {
132
+ ...options,
133
+ httpOnly: true,
134
+ secure: true,
135
+ sameSite: "Strict"
136
+ });
137
+ }
138
+ function clearAuthCookie(c, name, path) {
139
+ deleteCookie(c, name, path !== void 0 ? { path } : void 0);
140
+ }
141
+
142
+ // src/middleware/auth.ts
143
+ function createAuthMiddleware(db) {
144
+ return async (c, next) => {
145
+ const token = getCookie(c, "auth_token");
146
+ if (!token) {
147
+ return c.json(
148
+ { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } },
149
+ 401
150
+ );
151
+ }
152
+ let payload;
153
+ try {
154
+ payload = await verifyToken(token);
155
+ } catch (err) {
156
+ const code = err.code;
157
+ if (code === "TOKEN_EXPIRED") {
158
+ return c.json(
159
+ { ok: false, error: { code: "TOKEN_EXPIRED", message: "Token has expired" } },
160
+ 401
161
+ );
162
+ }
163
+ return c.json(
164
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token signature is invalid" } },
165
+ 401
166
+ );
167
+ }
168
+ const result = await db.execute(
169
+ sql`SELECT token_version, must_change_password FROM users WHERE id = ${payload.user_id} LIMIT 1`
170
+ );
171
+ const row = result.rows[0];
172
+ if (!row) {
173
+ return c.json(
174
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token signature is invalid" } },
175
+ 401
176
+ );
177
+ }
178
+ if (payload.token_version !== row.token_version) {
179
+ return c.json(
180
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token has been invalidated" } },
181
+ 401
182
+ );
183
+ }
184
+ c.set("user", {
185
+ id: payload.user_id,
186
+ role: payload.role,
187
+ must_change_password: row.must_change_password
188
+ });
189
+ const now = Math.floor(Date.now() / 1e3);
190
+ if (payload.expires_at < now + PROACTIVE_REFRESH_THRESHOLD) {
191
+ const newToken = await signToken(
192
+ {
193
+ user_id: payload.user_id,
194
+ role: payload.role,
195
+ token_version: payload.token_version
196
+ },
197
+ AUTH_TOKEN_TTL
198
+ );
199
+ setAuthCookie(c, "auth_token", newToken, {});
200
+ }
201
+ await next();
202
+ };
203
+ }
204
+
205
+ // src/middleware/must-change-password.ts
206
+ var mustChangePasswordCheck = async (c, next) => {
207
+ const user = c.get("user");
208
+ if (user?.must_change_password === true && c.req.path !== "/admin/api/users/change-password") {
209
+ return c.json(
210
+ {
211
+ ok: false,
212
+ error: {
213
+ code: "PASSWORD_CHANGE_REQUIRED",
214
+ message: "You must change your password before continuing."
215
+ }
216
+ },
217
+ 403
218
+ );
219
+ }
220
+ await next();
221
+ };
222
+
223
+ // src/middleware/permission.ts
224
+ function createPermissionMiddleware(registry) {
225
+ return function requirePermission(permission) {
226
+ return async (c, next) => {
227
+ const user = c.get("user");
228
+ const role = user ? registry[user.role] : void 0;
229
+ if (!role || !role.permissions.includes(permission)) {
230
+ return c.json(
231
+ {
232
+ ok: false,
233
+ error: { code: "INSUFFICIENT_PERMISSION", message: "Insufficient permission" }
234
+ },
235
+ 403
236
+ );
237
+ }
238
+ await next();
239
+ };
240
+ };
241
+ }
242
+
243
+ // src/middleware/hierarchy.ts
244
+ function createHierarchyMiddleware(registry, getUserRole) {
245
+ return function requireHierarchy() {
246
+ return async (c, next) => {
247
+ const user = c.get("user");
248
+ const actingRole = user ? registry[user.role] : void 0;
249
+ if (!actingRole) {
250
+ return c.json(
251
+ {
252
+ ok: false,
253
+ error: { code: "INSUFFICIENT_PRIVILEGE", message: "Insufficient privilege" }
254
+ },
255
+ 403
256
+ );
257
+ }
258
+ const method = c.req.method;
259
+ const targetId = c.req.param("id");
260
+ let targetRoleName = null;
261
+ if (method === "POST" && !targetId) {
262
+ let body = {};
263
+ try {
264
+ body = await c.req.json();
265
+ } catch {
266
+ }
267
+ targetRoleName = typeof body.role === "string" ? body.role : null;
268
+ if (!targetRoleName) {
269
+ return c.json(
270
+ {
271
+ ok: false,
272
+ error: { code: "INVALID_ROLE", message: "Request body must include a valid role" }
273
+ },
274
+ 400
275
+ );
276
+ }
277
+ } else if (method === "PATCH" && targetId) {
278
+ let bodyRole;
279
+ try {
280
+ const body = await c.req.json();
281
+ if (typeof body.role === "string") bodyRole = body.role;
282
+ } catch {
283
+ }
284
+ targetRoleName = bodyRole ?? await getUserRole(targetId);
285
+ } else if (targetId) {
286
+ targetRoleName = await getUserRole(targetId);
287
+ }
288
+ if (targetRoleName === null) {
289
+ return next();
290
+ }
291
+ const targetRole = registry[targetRoleName];
292
+ if (!targetRole) {
293
+ return c.json(
294
+ {
295
+ ok: false,
296
+ error: { code: "INVALID_ROLE", message: `Role "${targetRoleName}" is not valid` }
297
+ },
298
+ 400
299
+ );
300
+ }
301
+ if (actingRole.hierarchy_level >= targetRole.hierarchy_level) {
302
+ return c.json(
303
+ {
304
+ ok: false,
305
+ error: { code: "INSUFFICIENT_PRIVILEGE", message: "Insufficient privilege" }
306
+ },
307
+ 403
308
+ );
309
+ }
310
+ await next();
311
+ };
312
+ };
313
+ }
314
+
315
+ // src/middleware/rate-limit.ts
316
+ var DEFAULT_WINDOW_MS = 6e4;
317
+ var DEFAULT_MAX_PER_IP = 30;
318
+ var DEFAULT_MAX_GLOBAL = 500;
319
+ function createRateLimitMiddleware(options) {
320
+ const { windowMs, maxPerIp, maxGlobal } = options;
321
+ const ipRequests = /* @__PURE__ */ new Map();
322
+ const globalRequests = [];
323
+ return async function rateLimitMiddleware(c, next) {
324
+ const authToken = c.req.raw.headers.get("cookie")?.split(";").map((s) => s.trim()).find((s) => s.startsWith("auth_token="));
325
+ if (authToken) {
326
+ return next();
327
+ }
328
+ const now = Date.now();
329
+ const cutoff = now - windowMs;
330
+ let gi = 0;
331
+ while (gi < globalRequests.length && globalRequests[gi] < cutoff) gi++;
332
+ globalRequests.splice(0, gi);
333
+ const forwarded = c.req.header("x-forwarded-for");
334
+ const ip = forwarded ? forwarded.split(",")[0].trim() : c.env?.incoming?.socket?.remoteAddress ?? "unknown";
335
+ const ipTimes = ipRequests.get(ip) ?? [];
336
+ let ii = 0;
337
+ while (ii < ipTimes.length && ipTimes[ii] < cutoff) ii++;
338
+ ipTimes.splice(0, ii);
339
+ ipRequests.set(ip, ipTimes);
340
+ const retryAfterFromGlobal = globalRequests.length > 0 ? Math.ceil((globalRequests[0] + windowMs - now) / 1e3) : 0;
341
+ const retryAfterFromIp = ipTimes.length > 0 ? Math.ceil((ipTimes[0] + windowMs - now) / 1e3) : 0;
342
+ if (globalRequests.length >= maxGlobal) {
343
+ const retryAfter = Math.max(retryAfterFromGlobal, 1);
344
+ const reset = Math.floor((now + retryAfter * 1e3) / 1e3);
345
+ return c.json(
346
+ { ok: false, error: { code: "RATE_LIMITED", message: `Too many requests. Please retry after ${retryAfter} seconds.` } },
347
+ 429,
348
+ {
349
+ "Retry-After": String(retryAfter),
350
+ "X-RateLimit-Limit": String(maxGlobal),
351
+ "X-RateLimit-Remaining": "0",
352
+ "X-RateLimit-Reset": String(reset)
353
+ }
354
+ );
355
+ }
356
+ if (ipTimes.length >= maxPerIp) {
357
+ const retryAfter = Math.max(retryAfterFromIp, 1);
358
+ const reset = Math.floor((now + retryAfter * 1e3) / 1e3);
359
+ return c.json(
360
+ { ok: false, error: { code: "RATE_LIMITED", message: `Too many requests. Please retry after ${retryAfter} seconds.` } },
361
+ 429,
362
+ {
363
+ "Retry-After": String(retryAfter),
364
+ "X-RateLimit-Limit": String(maxPerIp),
365
+ "X-RateLimit-Remaining": "0",
366
+ "X-RateLimit-Reset": String(reset)
367
+ }
368
+ );
369
+ }
370
+ globalRequests.push(now);
371
+ ipTimes.push(now);
372
+ return next();
373
+ };
374
+ }
375
+ function resolveListRateLimit(rateLimit) {
376
+ const findAll = rateLimit?.findAll;
377
+ if (findAll === "*") {
378
+ return void 0;
379
+ }
380
+ return createRateLimitMiddleware({
381
+ windowMs: findAll?.windowMs ?? DEFAULT_WINDOW_MS,
382
+ maxPerIp: findAll?.maxPerIp ?? DEFAULT_MAX_PER_IP,
383
+ maxGlobal: findAll?.maxGlobal ?? DEFAULT_MAX_GLOBAL
384
+ });
385
+ }
386
+
387
+ // src/auth/registry.ts
388
+ var SYSTEM_ROLES = ["admin", "manager", "editor", "writer", "viewer"];
389
+ function validateRoles(roles) {
390
+ if (roles.length === 0) {
391
+ throw new Error(
392
+ "Fatal: roles registry failed to build \u2014 roles array is empty. Run `manguito validate` to check your roles schema."
393
+ );
394
+ }
395
+ const nameSet = new Set(roles.map((r) => r.name));
396
+ for (const systemRole of SYSTEM_ROLES) {
397
+ if (!nameSet.has(systemRole)) {
398
+ throw new Error(
399
+ `Fatal: roles registry failed to build \u2014 missing system role "${systemRole}". Run \`manguito validate\` to check your roles schema.`
400
+ );
401
+ }
402
+ }
403
+ const levelsSeen = /* @__PURE__ */ new Map();
404
+ for (const role of roles) {
405
+ const existing = levelsSeen.get(role.hierarchy_level);
406
+ if (existing !== void 0) {
407
+ throw new Error(
408
+ `Fatal: roles registry failed to build \u2014 duplicate hierarchy_level ${role.hierarchy_level} on roles "${existing}" and "${role.name}". Run \`manguito validate\` to check your roles schema.`
409
+ );
410
+ }
411
+ levelsSeen.set(role.hierarchy_level, role.name);
412
+ }
413
+ const namesSeen = /* @__PURE__ */ new Set();
414
+ for (const role of roles) {
415
+ if (namesSeen.has(role.name)) {
416
+ throw new Error(
417
+ `Fatal: roles registry failed to build \u2014 duplicate role name "${role.name}". Run \`manguito validate\` to check your roles schema.`
418
+ );
419
+ }
420
+ namesSeen.add(role.name);
421
+ }
422
+ }
423
+ function buildRolesRegistry(roles) {
424
+ validateRoles(roles);
425
+ return Object.fromEntries(roles.map((r) => [r.name, r]));
426
+ }
427
+
428
+ // src/routes/query-params.ts
429
+ var SORTABLE_FIELDS = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
430
+ var RELATION_FIELD_TYPES = /* @__PURE__ */ new Set([
431
+ "paragraph",
432
+ "reference",
433
+ "image",
434
+ "video",
435
+ "file"
436
+ ]);
437
+ function parsePagination(pageStr, perPageStr) {
438
+ const page = pageStr !== void 0 ? Number(pageStr) : 1;
439
+ const per_page = perPageStr !== void 0 ? Number(perPageStr) : 10;
440
+ if (!Number.isInteger(page) || page < 1) return { ok: false };
441
+ if (!Number.isInteger(per_page) || per_page < 1 || per_page > 100) return { ok: false };
442
+ return { ok: true, page, per_page };
443
+ }
444
+ function parseInclude(includeParam) {
445
+ if (!includeParam) return [];
446
+ return includeParam.split(",").map((s) => s.trim()).filter(Boolean);
447
+ }
448
+ function parseFilters(url, validFields) {
449
+ const { searchParams } = new URL(url);
450
+ const filters = {};
451
+ for (const [key, value] of searchParams.entries()) {
452
+ const simpleMatch = /^filter\[([^\]]+)\]$/.exec(key);
453
+ const opMatch = /^filter\[([^\]]+)\]\[([^\]]+)\]$/.exec(key);
454
+ if (simpleMatch) {
455
+ const field = simpleMatch[1];
456
+ if (!validFields.has(field)) return { ok: false, invalidField: field };
457
+ const existing = filters[field];
458
+ if (existing !== void 0) {
459
+ filters[field] = Array.isArray(existing) ? [...existing, value] : [existing, value];
460
+ } else {
461
+ filters[field] = value;
462
+ }
463
+ } else if (opMatch) {
464
+ const field = opMatch[1];
465
+ const operator = opMatch[2];
466
+ if (!validFields.has(field)) return { ok: false, invalidField: field };
467
+ if (!["gt", "gte", "lt", "lte"].includes(operator)) continue;
468
+ const existing = filters[field] ?? {};
469
+ filters[field] = { ...existing, [operator]: value };
470
+ }
471
+ }
472
+ return { ok: true, filters };
473
+ }
474
+
475
+ // src/routes/content.ts
476
+ function isPublished(item) {
477
+ return item["published"] === true;
478
+ }
479
+ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
480
+ function registerListRoute(path, handler) {
481
+ if (listRateLimit) {
482
+ app.get(path, listRateLimit, handler);
483
+ } else {
484
+ app.get(path, handler);
485
+ }
486
+ }
487
+ registerListRoute("/api/content", (c) => {
488
+ const data = Object.values(registry.content_types).map((ct) => ({
489
+ name: ct.name,
490
+ label: ct.label,
491
+ only_one: ct.only_one
492
+ }));
493
+ return c.json({ ok: true, data });
494
+ });
495
+ registerListRoute("/api/taxonomy", (c) => {
496
+ const data = Object.values(registry.taxonomy_types).map((tt) => ({
497
+ name: tt.name,
498
+ label: tt.label
499
+ }));
500
+ return c.json({ ok: true, data });
501
+ });
502
+ for (const [typeName, contentType] of Object.entries(registry.content_types)) {
503
+ const basePath = contentType.default_base_path;
504
+ const repo = repos[typeName];
505
+ if (!repo) continue;
506
+ const schemaFieldNames = /* @__PURE__ */ new Set([
507
+ ...contentType.fields.map((f) => f.name),
508
+ ...contentType.system_fields.map((f) => f.name)
509
+ ]);
510
+ const relationFieldNames = new Set(
511
+ contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
512
+ );
513
+ if (contentType.only_one) {
514
+ app.get(`/api/${basePath}`, async (c) => {
515
+ const result = await repo.findMany({ published_only: true, page: 1, per_page: 1 });
516
+ if (result.data.length === 0) {
517
+ return c.json(
518
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
519
+ 404
520
+ );
521
+ }
522
+ return c.json({ ok: true, data: result.data[0] });
523
+ });
524
+ } else {
525
+ registerListRoute(`/api/${basePath}`, async (c) => {
526
+ const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
527
+ if (!pagination.ok) {
528
+ return c.json(
529
+ {
530
+ ok: false,
531
+ error: {
532
+ code: "INVALID_PAGINATION",
533
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
534
+ }
535
+ },
536
+ 400
537
+ );
538
+ }
539
+ const sortBy = c.req.query("sort_by") ?? "created_at";
540
+ if (!SORTABLE_FIELDS.has(sortBy)) {
541
+ return c.json(
542
+ {
543
+ ok: false,
544
+ error: {
545
+ code: "INVALID_SORT_FIELD",
546
+ message: `'${sortBy}' is not sortable. Allowed: title, created_at, updated_at`
547
+ }
548
+ },
549
+ 400
550
+ );
551
+ }
552
+ const sortOrder = c.req.query("sort_order") ?? "asc";
553
+ if (sortOrder !== "asc" && sortOrder !== "desc") {
554
+ return c.json(
555
+ {
556
+ ok: false,
557
+ error: {
558
+ code: "INVALID_SORT_FIELD",
559
+ message: `sort_order must be 'asc' or 'desc'`
560
+ }
561
+ },
562
+ 400
563
+ );
564
+ }
565
+ const filtersResult = parseFilters(c.req.url, schemaFieldNames);
566
+ if (!filtersResult.ok) {
567
+ return c.json(
568
+ {
569
+ ok: false,
570
+ error: {
571
+ code: "INVALID_FILTER_FIELD",
572
+ message: `Filter field '${filtersResult.invalidField}' does not exist on this content type`
573
+ }
574
+ },
575
+ 400
576
+ );
577
+ }
578
+ const include = parseInclude(c.req.query("include"));
579
+ for (const field of include) {
580
+ if (!relationFieldNames.has(field)) {
581
+ return c.json(
582
+ {
583
+ ok: false,
584
+ error: {
585
+ code: "INVALID_INCLUDE_FIELD",
586
+ message: `'${field}' is not a valid relation field`
587
+ }
588
+ },
589
+ 400
590
+ );
591
+ }
592
+ }
593
+ const result = await repo.findMany({
594
+ published_only: true,
595
+ page: pagination.page,
596
+ per_page: pagination.per_page,
597
+ sort_by: sortBy,
598
+ sort_order: sortOrder,
599
+ filters: filtersResult.filters,
600
+ include
601
+ });
602
+ return c.json(result);
603
+ });
604
+ app.get(`/api/${basePath}/:slug`, async (c) => {
605
+ const slug = c.req.param("slug");
606
+ const include = parseInclude(c.req.query("include"));
607
+ for (const field of include) {
608
+ if (!relationFieldNames.has(field)) {
609
+ return c.json(
610
+ {
611
+ ok: false,
612
+ error: {
613
+ code: "INVALID_INCLUDE_FIELD",
614
+ message: `'${field}' is not a valid relation field`
615
+ }
616
+ },
617
+ 400
618
+ );
619
+ }
620
+ }
621
+ const item = await repo.findBySlug(slug, include);
622
+ if (!item || !isPublished(item)) {
623
+ return c.json(
624
+ {
625
+ ok: false,
626
+ error: { code: "SLUG_NOT_FOUND", message: `No item found with slug '${slug}'` }
627
+ },
628
+ 404
629
+ );
630
+ }
631
+ return c.json({ ok: true, data: item });
632
+ });
633
+ }
634
+ }
635
+ for (const [typeName] of Object.entries(registry.taxonomy_types)) {
636
+ const repo = repos[typeName];
637
+ if (!repo) continue;
638
+ registerListRoute(`/api/taxonomy/${typeName}`, async (c) => {
639
+ const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
640
+ if (!pagination.ok) {
641
+ return c.json(
642
+ {
643
+ ok: false,
644
+ error: {
645
+ code: "INVALID_PAGINATION",
646
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
647
+ }
648
+ },
649
+ 400
650
+ );
651
+ }
652
+ const result = await repo.findMany({
653
+ published_only: true,
654
+ page: pagination.page,
655
+ per_page: pagination.per_page
656
+ });
657
+ return c.json(result);
658
+ });
659
+ app.get(`/api/taxonomy/${typeName}/:id`, async (c) => {
660
+ const id = c.req.param("id");
661
+ const item = await repo.findOne(id);
662
+ if (!item || !isPublished(item)) {
663
+ return c.json(
664
+ {
665
+ ok: false,
666
+ error: { code: "NOT_FOUND", message: "Taxonomy term not found" }
667
+ },
668
+ 404
669
+ );
670
+ }
671
+ return c.json({ ok: true, data: item });
672
+ });
673
+ }
674
+ }
675
+
676
+ // src/routes/media.ts
677
+ var VALID_MEDIA_TYPES = /* @__PURE__ */ new Set(["image", "video", "file"]);
678
+ function parsePagination2(pageStr, perPageStr) {
679
+ const page = pageStr !== void 0 ? Number(pageStr) : 1;
680
+ const per_page = perPageStr !== void 0 ? Number(perPageStr) : 10;
681
+ if (!Number.isInteger(page) || page < 1) return { ok: false };
682
+ if (!Number.isInteger(per_page) || per_page < 1 || per_page > 100) return { ok: false };
683
+ return { ok: true, page, per_page };
684
+ }
685
+ function registerPublicMediaRoutes(app, mediaRepo, listRateLimit) {
686
+ const mediaListHandler = async (c) => {
687
+ const pagination = parsePagination2(c.req.query("page"), c.req.query("per_page"));
688
+ if (!pagination.ok) {
689
+ return c.json(
690
+ {
691
+ ok: false,
692
+ error: {
693
+ code: "INVALID_PAGINATION",
694
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
695
+ }
696
+ },
697
+ 400
698
+ );
699
+ }
700
+ const typeParam = c.req.query("type");
701
+ if (typeParam !== void 0 && !VALID_MEDIA_TYPES.has(typeParam)) {
702
+ return c.json(
703
+ {
704
+ ok: false,
705
+ error: {
706
+ code: "VALIDATION_ERROR",
707
+ message: `type must be one of: image, video, file`
708
+ }
709
+ },
710
+ 422
711
+ );
712
+ }
713
+ const opts = {
714
+ page: pagination.page,
715
+ per_page: pagination.per_page
716
+ };
717
+ if (typeParam !== void 0) {
718
+ opts.type = typeParam;
719
+ }
720
+ const result = await mediaRepo.findMany(opts);
721
+ return c.json(result);
722
+ };
723
+ if (listRateLimit) {
724
+ app.get("/api/media", listRateLimit, mediaListHandler);
725
+ } else {
726
+ app.get("/api/media", mediaListHandler);
727
+ }
728
+ app.get("/api/media/:id", async (c) => {
729
+ const id = c.req.param("id");
730
+ const item = await mediaRepo.findOne(id);
731
+ if (!item) {
732
+ return c.json(
733
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
734
+ 404
735
+ );
736
+ }
737
+ return c.json({ ok: true, data: item });
738
+ });
739
+ }
740
+
741
+ // src/routes/admin/content.ts
742
+ import { sql as sql3 } from "drizzle-orm";
743
+
744
+ // src/media-references.ts
745
+ function extractMediaId(value) {
746
+ return typeof value === "string" && value !== "" ? value : null;
747
+ }
748
+ function topLevelMediaDelta(mediaFields, before, after) {
749
+ const added = [];
750
+ const removed = [];
751
+ for (const f of mediaFields) {
752
+ if (after !== null && !(f.name in after)) continue;
753
+ const oldId = before ? extractMediaId(before[f.name]) : null;
754
+ const newId = after ? extractMediaId(after[f.name]) : null;
755
+ if (newId === oldId) continue;
756
+ if (oldId) removed.push(oldId);
757
+ if (newId) added.push(newId);
758
+ }
759
+ return { added, removed };
760
+ }
761
+ function mergeMediaDeltas(...deltas) {
762
+ return {
763
+ added: deltas.flatMap((d) => d.added),
764
+ removed: deltas.flatMap((d) => d.removed)
765
+ };
766
+ }
767
+ async function applyMediaReferenceDelta(delta, mediaRepo) {
768
+ const added = new Set(delta.added);
769
+ const removed = new Set(delta.removed);
770
+ const netAdd = [...added].filter((id) => !removed.has(id));
771
+ const netRemove = [...removed].filter((id) => !added.has(id));
772
+ if (netAdd.length > 0) await mediaRepo.incrementReferenceCount(netAdd);
773
+ if (netRemove.length > 0) await mediaRepo.decrementReferenceCount(netRemove);
774
+ }
775
+
776
+ // src/relations.ts
777
+ import crypto from "crypto";
778
+ import { sql as sql2 } from "drizzle-orm";
779
+ function quoteIdent(name) {
780
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
781
+ return `"${name}"`;
782
+ }
783
+ function paragraphMediaFieldNames(pType) {
784
+ return new Set(
785
+ pType.fields.filter((f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file").map((f) => f.name)
786
+ );
787
+ }
788
+ async function fetchParagraphMediaIds(db, pType, itemId, fieldName) {
789
+ const mediaColumns = pType.fields.filter((f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file").map((f) => f.db_column.column_name);
790
+ if (mediaColumns.length === 0) return [];
791
+ const tbl = sql2.raw(quoteIdent(pType.db.table_name));
792
+ const cols = sql2.join(mediaColumns.map((c) => sql2.raw(quoteIdent(c))), sql2`, `);
793
+ const result = await db.execute(
794
+ sql2`SELECT ${cols} FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`
795
+ );
796
+ const ids = [];
797
+ for (const row of result.rows) {
798
+ for (const col of mediaColumns) {
799
+ const v = row[col];
800
+ if (typeof v === "string" && v !== "") ids.push(v);
801
+ }
802
+ }
803
+ return ids;
804
+ }
805
+ async function persistParagraphField(db, itemId, parentType, fieldName, pType, items) {
806
+ const removed = await fetchParagraphMediaIds(db, pType, itemId, fieldName);
807
+ const tbl = sql2.raw(quoteIdent(pType.db.table_name));
808
+ await db.execute(sql2`DELETE FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`);
809
+ const mediaFieldNames = paragraphMediaFieldNames(pType);
810
+ const added = [];
811
+ for (let i = 0; i < items.length; i++) {
812
+ const pItem = items[i];
813
+ const data = {
814
+ id: crypto.randomUUID(),
815
+ parent_id: itemId,
816
+ parent_type: parentType,
817
+ parent_field: fieldName,
818
+ order: i,
819
+ created_at: /* @__PURE__ */ new Date(),
820
+ updated_at: /* @__PURE__ */ new Date()
821
+ };
822
+ for (const pf of pType.fields) {
823
+ if (!pf.db_column || pf.db_column.junction) continue;
824
+ data[pf.db_column.column_name] = pItem[pf.name] ?? null;
825
+ }
826
+ const cols = sql2.join(Object.keys(data).map((k) => sql2.raw(quoteIdent(k))), sql2`, `);
827
+ const vals = sql2.join(Object.values(data).map((v) => sql2`${v}`), sql2`, `);
828
+ await db.execute(sql2`INSERT INTO ${tbl} (${cols}) VALUES (${vals})`);
829
+ for (const name of mediaFieldNames) {
830
+ const val = pItem[name];
831
+ if (typeof val === "string" && val !== "") added.push(val);
832
+ }
833
+ }
834
+ return { added, removed };
835
+ }
836
+ async function deleteParagraphField(db, itemId, fieldName, pType) {
837
+ const removed = await fetchParagraphMediaIds(db, pType, itemId, fieldName);
838
+ const tbl = sql2.raw(quoteIdent(pType.db.table_name));
839
+ await db.execute(sql2`DELETE FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`);
840
+ return { added: [], removed };
841
+ }
842
+ async function persistJunctionField(db, itemId, junction, relatedIds) {
843
+ const tbl = sql2.raw(quoteIdent(junction.table_name));
844
+ const left = sql2.raw(quoteIdent(junction.left_column));
845
+ const right = sql2.raw(quoteIdent(junction.right_column));
846
+ await db.execute(sql2`DELETE FROM ${tbl} WHERE ${left} = ${itemId}`);
847
+ for (const rid of relatedIds) {
848
+ await db.execute(sql2`INSERT INTO ${tbl} (${left}, ${right}) VALUES (${itemId}, ${rid})`);
849
+ }
850
+ }
851
+ function buildRelationsMap(fields, registry) {
852
+ const relations = {};
853
+ for (const field of fields) {
854
+ if (field.field_type === "image" || field.field_type === "video" || field.field_type === "file") {
855
+ if (!field.db_column) continue;
856
+ relations[field.name] = { type: "media", fk_column: field.db_column.column_name };
857
+ } else if (field.field_type === "paragraph") {
858
+ const ref = field.ui_component.ref;
859
+ const pType = ref ? registry.paragraph_types[ref] : void 0;
860
+ if (!pType) continue;
861
+ relations[field.name] = { type: "paragraph", table: pType.db.table_name };
862
+ } else if (field.field_type === "reference") {
863
+ if (!field.db_column) continue;
864
+ if (field.db_column.junction) {
865
+ const j = field.db_column.junction;
866
+ relations[field.name] = {
867
+ type: "junction",
868
+ table: j.right_table,
869
+ junction_table: j.table_name,
870
+ left_column: j.left_column,
871
+ right_column: j.right_column,
872
+ order_column: j.order_column
873
+ };
874
+ } else if (field.db_column.foreign_key) {
875
+ relations[field.name] = {
876
+ type: "reference",
877
+ table: field.db_column.foreign_key.table,
878
+ fk_column: field.db_column.column_name
879
+ };
880
+ }
881
+ }
882
+ }
883
+ return relations;
884
+ }
885
+ function groupBy(items, key) {
886
+ const result = {};
887
+ for (const item of items) {
888
+ const k = String(item[key]);
889
+ if (!result[k]) result[k] = [];
890
+ result[k].push(item);
891
+ }
892
+ return result;
893
+ }
894
+ async function resolveRelationField(db, rows, fieldName, rel, cache) {
895
+ if (rows.length === 0) return;
896
+ if (rel.type === "paragraph") {
897
+ const parentIds = rows.map((r) => r["id"]);
898
+ const inList = sql2.join(parentIds.map((id) => sql2`${id}`), sql2`, `);
899
+ const result = await db.execute(
900
+ sql2`SELECT * FROM ${sql2.raw(quoteIdent(rel.table))} WHERE parent_id IN (${inList}) ORDER BY "order" ASC`
901
+ );
902
+ const byParent = groupBy(result.rows, "parent_id");
903
+ for (const row of rows) {
904
+ row[fieldName] = byParent[row["id"]] ?? [];
905
+ }
906
+ } else if (rel.type === "reference") {
907
+ const fkValues = rows.map((r) => r[rel.fk_column]).filter(Boolean);
908
+ if (fkValues.length === 0) {
909
+ for (const row of rows) row[fieldName] = null;
910
+ return;
911
+ }
912
+ const unique = [...new Set(fkValues)];
913
+ const uncached = unique.filter((id) => !cache.has(`${rel.table}:${id}`));
914
+ if (uncached.length > 0) {
915
+ const inList = sql2.join(uncached.map((id) => sql2`${id}`), sql2`, `);
916
+ const result = await db.execute(
917
+ sql2`SELECT * FROM ${sql2.raw(quoteIdent(rel.table))} WHERE id IN (${inList})`
918
+ );
919
+ for (const item of result.rows) {
920
+ cache.set(`${rel.table}:${item["id"]}`, item);
921
+ }
922
+ }
923
+ for (const row of rows) {
924
+ const fkVal = row[rel.fk_column];
925
+ row[fieldName] = fkVal ? cache.get(`${rel.table}:${fkVal}`) ?? null : null;
926
+ }
927
+ } else if (rel.type === "junction") {
928
+ const parentIds = rows.map((r) => r["id"]);
929
+ const inList = sql2.join(parentIds.map((id) => sql2`${id}`), sql2`, `);
930
+ const orderBy = rel.order_column ? sql2` ORDER BY "order" ASC` : sql2``;
931
+ const junctionResult = await db.execute(
932
+ sql2`SELECT * FROM ${sql2.raw(quoteIdent(rel.junction_table))} WHERE ${sql2.raw(quoteIdent(rel.left_column))} IN (${inList})${orderBy}`
933
+ );
934
+ const jRows = junctionResult.rows;
935
+ const rightIds = [...new Set(jRows.map((r) => r[rel.right_column]))];
936
+ const uncached = rightIds.filter((id) => !cache.has(`${rel.table}:${id}`));
937
+ if (uncached.length > 0) {
938
+ const rightInList = sql2.join(uncached.map((id) => sql2`${id}`), sql2`, `);
939
+ const entitiesResult = await db.execute(
940
+ sql2`SELECT * FROM ${sql2.raw(quoteIdent(rel.table))} WHERE id IN (${rightInList})`
941
+ );
942
+ for (const item of entitiesResult.rows) {
943
+ cache.set(`${rel.table}:${item["id"]}`, item);
944
+ }
945
+ }
946
+ const byLeft = groupBy(jRows, rel.left_column);
947
+ for (const row of rows) {
948
+ const jEntries = byLeft[row["id"]] ?? [];
949
+ row[fieldName] = jEntries.map((jr) => cache.get(`${rel.table}:${jr[rel.right_column]}`)).filter(Boolean);
950
+ }
951
+ } else if (rel.type === "media") {
952
+ const fkValues = rows.map((r) => r[rel.fk_column]).filter(Boolean);
953
+ if (fkValues.length === 0) {
954
+ for (const row of rows) row[fieldName] = null;
955
+ return;
956
+ }
957
+ const unique = [...new Set(fkValues)];
958
+ const uncached = unique.filter((id) => !cache.has(`media:${id}`));
959
+ if (uncached.length > 0) {
960
+ const inList = sql2.join(uncached.map((id) => sql2`${id}`), sql2`, `);
961
+ const result = await db.execute(sql2`SELECT * FROM "media" WHERE id IN (${inList})`);
962
+ for (const item of result.rows) {
963
+ cache.set(`media:${item["id"]}`, item);
964
+ }
965
+ }
966
+ for (const row of rows) {
967
+ const fkVal = row[rel.fk_column];
968
+ row[fieldName] = fkVal ? cache.get(`media:${fkVal}`) ?? null : null;
969
+ }
970
+ }
971
+ }
972
+ async function resolveRelationBareIds(db, rows, fieldName, rel) {
973
+ if (rows.length === 0) return;
974
+ if (rel.type === "paragraph") {
975
+ const parentIds = rows.map((r) => r["id"]);
976
+ const inList = sql2.join(parentIds.map((id) => sql2`${id}`), sql2`, `);
977
+ const result = await db.execute(
978
+ sql2`SELECT id, parent_id FROM ${sql2.raw(quoteIdent(rel.table))} WHERE parent_id IN (${inList}) ORDER BY "order" ASC`
979
+ );
980
+ const byParent = groupBy(result.rows, "parent_id");
981
+ for (const row of rows) {
982
+ row[fieldName] = (byParent[row["id"]] ?? []).map((r) => r["id"]);
983
+ }
984
+ } else if (rel.type === "junction") {
985
+ const parentIds = rows.map((r) => r["id"]);
986
+ const inList = sql2.join(parentIds.map((id) => sql2`${id}`), sql2`, `);
987
+ const result = await db.execute(
988
+ sql2`SELECT ${sql2.raw(quoteIdent(rel.left_column))} AS left_id, ${sql2.raw(quoteIdent(rel.right_column))} AS right_id FROM ${sql2.raw(quoteIdent(rel.junction_table))} WHERE ${sql2.raw(quoteIdent(rel.left_column))} IN (${inList})`
989
+ );
990
+ const byLeft = groupBy(result.rows, "left_id");
991
+ for (const row of rows) {
992
+ row[fieldName] = (byLeft[row["id"]] ?? []).map((r) => r["right_id"]);
993
+ }
994
+ }
995
+ }
996
+
997
+ // src/routes/admin/content.ts
998
+ function quoteIdent2(name) {
999
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
1000
+ return `"${name}"`;
1001
+ }
1002
+ async function lookupBasePathId(db, pathOrName) {
1003
+ const r = await db.execute(
1004
+ sql3`SELECT id FROM base_paths WHERE path = ${pathOrName} OR name = ${pathOrName} LIMIT 1`
1005
+ );
1006
+ return r.rows[0]?.id ?? null;
1007
+ }
1008
+ var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1009
+ function isEmpty(value) {
1010
+ if (value === null || value === void 0) return true;
1011
+ if (typeof value === "string" && value.trim() === "") return true;
1012
+ if (Array.isArray(value) && value.length === 0) return true;
1013
+ return false;
1014
+ }
1015
+ function checkRequiredFields(fields, data) {
1016
+ return fields.filter((f) => f.required && isEmpty(data[f.name])).map((f) => ({ field: f.name, message: `${f.label} is required` }));
1017
+ }
1018
+ function parseListQuery(url, schemaFieldNames, relationFieldNames) {
1019
+ const searchParams = new URL(url).searchParams;
1020
+ const pagination = parsePagination(
1021
+ searchParams.get("page") ?? void 0,
1022
+ searchParams.get("per_page") ?? void 0
1023
+ );
1024
+ if (!pagination.ok) {
1025
+ return {
1026
+ ok: false,
1027
+ response: {
1028
+ code: "INVALID_PAGINATION",
1029
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
1030
+ },
1031
+ status: 400
1032
+ };
1033
+ }
1034
+ const sortBy = searchParams.get("sort_by") ?? "created_at";
1035
+ if (!SORTABLE_FIELDS.has(sortBy)) {
1036
+ return {
1037
+ ok: false,
1038
+ response: {
1039
+ code: "INVALID_SORT_FIELD",
1040
+ message: `'${sortBy}' is not sortable. Allowed: title, created_at, updated_at`
1041
+ },
1042
+ status: 400
1043
+ };
1044
+ }
1045
+ const sortOrder = searchParams.get("sort_order") ?? "asc";
1046
+ if (sortOrder !== "asc" && sortOrder !== "desc") {
1047
+ return {
1048
+ ok: false,
1049
+ response: { code: "INVALID_SORT_FIELD", message: `sort_order must be 'asc' or 'desc'` },
1050
+ status: 400
1051
+ };
1052
+ }
1053
+ const filtersResult = parseFilters(url, schemaFieldNames);
1054
+ if (!filtersResult.ok) {
1055
+ return {
1056
+ ok: false,
1057
+ response: {
1058
+ code: "INVALID_FILTER_FIELD",
1059
+ message: `Filter field '${filtersResult.invalidField}' does not exist on this schema`
1060
+ },
1061
+ status: 400
1062
+ };
1063
+ }
1064
+ const include = parseInclude(searchParams.get("include") ?? void 0);
1065
+ for (const field of include) {
1066
+ if (!relationFieldNames.has(field)) {
1067
+ return {
1068
+ ok: false,
1069
+ response: { code: "INVALID_INCLUDE_FIELD", message: `'${field}' is not a valid relation field` },
1070
+ status: 400
1071
+ };
1072
+ }
1073
+ }
1074
+ const search = (searchParams.get("search") ?? "").trim();
1075
+ return {
1076
+ ok: true,
1077
+ pagination,
1078
+ sortBy,
1079
+ sortOrder,
1080
+ filters: filtersResult.filters,
1081
+ include,
1082
+ search
1083
+ };
1084
+ }
1085
+ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db) {
1086
+ for (const [typeName, contentType] of Object.entries(registry.content_types)) {
1087
+ const basePath = `content/${typeName}`;
1088
+ const repo = repos[typeName];
1089
+ if (!repo) continue;
1090
+ const schemaFieldNames = /* @__PURE__ */ new Set([
1091
+ ...contentType.fields.map((f) => f.name),
1092
+ ...contentType.system_fields.map((f) => f.name)
1093
+ ]);
1094
+ const relationFieldNames = new Set(
1095
+ contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
1096
+ );
1097
+ const requiredFields = contentType.fields.filter((f) => f.required);
1098
+ const mediaFields = contentType.fields.filter(
1099
+ (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
1100
+ );
1101
+ const paragraphFieldDefs = contentType.fields.filter((f) => f.db_column === null);
1102
+ const searchableColumns = [
1103
+ ...contentType.fields.filter((f) => f.field_type === "text/plain" && f.db_column !== null).map((f) => f.db_column.column_name),
1104
+ ...contentType.system_fields.some((f) => f.name === "slug") ? ["slug"] : []
1105
+ ];
1106
+ app.get(
1107
+ `/admin/api/${basePath}`,
1108
+ requirePermission("content:read"),
1109
+ async (c) => {
1110
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
1111
+ if (!parsed.ok) {
1112
+ return c.json({ ok: false, error: parsed.response }, parsed.status);
1113
+ }
1114
+ const publishedParam = c.req.query("published");
1115
+ const extraFilters = {};
1116
+ if (publishedParam === "false") extraFilters["published"] = false;
1117
+ const findOpts = {
1118
+ page: parsed.pagination.page,
1119
+ per_page: parsed.pagination.per_page,
1120
+ sort_by: parsed.sortBy,
1121
+ sort_order: parsed.sortOrder,
1122
+ filters: { ...parsed.filters, ...extraFilters },
1123
+ include: parsed.include
1124
+ };
1125
+ if (publishedParam === "true") findOpts.published_only = true;
1126
+ if (parsed.search !== "" && searchableColumns.length > 0) {
1127
+ findOpts.search = { term: parsed.search, columns: searchableColumns };
1128
+ }
1129
+ const result = await repo.findMany(findOpts);
1130
+ return c.json(result);
1131
+ }
1132
+ );
1133
+ app.get(
1134
+ `/admin/api/${basePath}/:id`,
1135
+ requirePermission("content:read"),
1136
+ async (c) => {
1137
+ const id = c.req.param("id");
1138
+ const item = await repo.findOne(id);
1139
+ if (!item) {
1140
+ return c.json(
1141
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1142
+ 404
1143
+ );
1144
+ }
1145
+ if (db) {
1146
+ const row = item;
1147
+ for (const f of contentType.fields) {
1148
+ if (f.db_column === null) {
1149
+ const comp = f.ui_component;
1150
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1151
+ const pType = registry.paragraph_types[comp.ref];
1152
+ if (!pType) continue;
1153
+ const r = await db.execute(
1154
+ sql3`SELECT * FROM ${sql3.raw(quoteIdent2(pType.db.table_name))} WHERE parent_id = ${id} AND parent_field = ${f.name} ORDER BY "order" ASC`
1155
+ );
1156
+ row[f.name] = r.rows;
1157
+ } else if (f.db_column.junction) {
1158
+ const j = f.db_column.junction;
1159
+ const r = await db.execute(
1160
+ sql3`SELECT ${sql3.raw(quoteIdent2(j.right_column))} FROM ${sql3.raw(quoteIdent2(j.table_name))} WHERE ${sql3.raw(quoteIdent2(j.left_column))} = ${id}`
1161
+ );
1162
+ row[f.name] = r.rows.map(
1163
+ (row2) => row2[j.right_column]
1164
+ );
1165
+ }
1166
+ }
1167
+ }
1168
+ return c.json({ ok: true, data: item });
1169
+ }
1170
+ );
1171
+ app.post(
1172
+ `/admin/api/${basePath}`,
1173
+ requirePermission("content:create"),
1174
+ async (c) => {
1175
+ const body = await c.req.json();
1176
+ if (contentType.only_one) {
1177
+ const existing = await repo.findMany({ page: 1, per_page: 1 });
1178
+ if (existing.meta.total > 0) {
1179
+ return c.json(
1180
+ {
1181
+ ok: false,
1182
+ error: {
1183
+ code: "SINGLETON_ALREADY_EXISTS",
1184
+ message: `Only one instance of '${contentType.label}' is allowed`
1185
+ }
1186
+ },
1187
+ 409
1188
+ );
1189
+ }
1190
+ } else {
1191
+ const slug = body["slug"];
1192
+ if (typeof slug !== "string" || slug.trim() === "") {
1193
+ return c.json(
1194
+ {
1195
+ ok: false,
1196
+ error: {
1197
+ code: "VALIDATION_ERROR",
1198
+ message: "Required fields are missing",
1199
+ details: [{ field: "slug", message: "Slug is required" }]
1200
+ }
1201
+ },
1202
+ 422
1203
+ );
1204
+ }
1205
+ if (!SLUG_PATTERN.test(slug)) {
1206
+ return c.json(
1207
+ {
1208
+ ok: false,
1209
+ error: {
1210
+ code: "INVALID_SLUG_FORMAT",
1211
+ message: "Slug must be lowercase alphanumeric with hyphens only \u2014 no leading or trailing hyphens"
1212
+ }
1213
+ },
1214
+ 422
1215
+ );
1216
+ }
1217
+ const conflict = await repo.findBySlug(slug);
1218
+ if (conflict) {
1219
+ return c.json(
1220
+ {
1221
+ ok: false,
1222
+ error: {
1223
+ code: "SLUG_CONFLICT",
1224
+ message: `Slug '${slug}' already exists for this content type`
1225
+ }
1226
+ },
1227
+ 409
1228
+ );
1229
+ }
1230
+ }
1231
+ const fieldErrors = checkRequiredFields(requiredFields, body);
1232
+ if (fieldErrors.length > 0) {
1233
+ return c.json(
1234
+ {
1235
+ ok: false,
1236
+ error: {
1237
+ code: "VALIDATION_ERROR",
1238
+ message: "Required fields are missing",
1239
+ details: fieldErrors
1240
+ }
1241
+ },
1242
+ 422
1243
+ );
1244
+ }
1245
+ if (body["published"] === true) {
1246
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1247
+ });
1248
+ if (publishDeny) return publishDeny;
1249
+ }
1250
+ const paragraphFields = contentType.fields.filter((f) => f.db_column === null);
1251
+ const junctionFields = contentType.fields.filter(
1252
+ (f) => f.db_column !== null && f.db_column.junction !== void 0
1253
+ );
1254
+ const columnFields = contentType.fields.filter(
1255
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1256
+ );
1257
+ const columnData = {
1258
+ published: body["published"] ?? false
1259
+ };
1260
+ if (contentType.only_one) {
1261
+ columnData["slug"] = typeName;
1262
+ } else {
1263
+ columnData["slug"] = body["slug"];
1264
+ }
1265
+ for (const f of columnFields) {
1266
+ const val = body[f.name];
1267
+ columnData[f.db_column.column_name] = val !== void 0 ? val : null;
1268
+ }
1269
+ if (db) {
1270
+ const basePathId = await lookupBasePathId(db, contentType.default_base_path);
1271
+ if (!basePathId) {
1272
+ return c.json(
1273
+ {
1274
+ ok: false,
1275
+ error: {
1276
+ code: "BASE_PATH_NOT_FOUND",
1277
+ message: `Base path '${contentType.default_base_path}' not found \u2014 run manguito migrate`
1278
+ }
1279
+ },
1280
+ 500
1281
+ );
1282
+ }
1283
+ columnData["base_path_id"] = basePathId;
1284
+ }
1285
+ const item = await repo.create(columnData);
1286
+ const itemId = item["id"];
1287
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
1288
+ if (db) {
1289
+ for (const f of paragraphFields) {
1290
+ const comp = f.ui_component;
1291
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1292
+ const pType = registry.paragraph_types[comp.ref];
1293
+ if (!pType) continue;
1294
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1295
+ mediaDeltas.push(await persistParagraphField(db, itemId, contentType.db.table_name, f.name, pType, items));
1296
+ }
1297
+ for (const f of junctionFields) {
1298
+ const junction = f.db_column.junction;
1299
+ const relatedIds = Array.isArray(body[f.name]) ? body[f.name].filter((v) => typeof v === "string") : [];
1300
+ await persistJunctionField(db, itemId, junction, relatedIds);
1301
+ }
1302
+ }
1303
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1304
+ return c.json({ ok: true, data: item }, 201);
1305
+ }
1306
+ );
1307
+ app.patch(
1308
+ `/admin/api/${basePath}/:id`,
1309
+ requirePermission("content:edit"),
1310
+ async (c) => {
1311
+ const id = c.req.param("id");
1312
+ const body = await c.req.json();
1313
+ const existing = await repo.findOne(id);
1314
+ if (!existing) {
1315
+ return c.json(
1316
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1317
+ 404
1318
+ );
1319
+ }
1320
+ if (!contentType.only_one && "slug" in body) {
1321
+ const slug = body["slug"];
1322
+ if (typeof slug !== "string" || slug.trim() === "") {
1323
+ return c.json(
1324
+ {
1325
+ ok: false,
1326
+ error: {
1327
+ code: "VALIDATION_ERROR",
1328
+ message: "Required fields are missing",
1329
+ details: [{ field: "slug", message: "Slug cannot be empty" }]
1330
+ }
1331
+ },
1332
+ 422
1333
+ );
1334
+ }
1335
+ if (!SLUG_PATTERN.test(slug)) {
1336
+ return c.json(
1337
+ {
1338
+ ok: false,
1339
+ error: {
1340
+ code: "INVALID_SLUG_FORMAT",
1341
+ message: "Slug must be lowercase alphanumeric with hyphens only \u2014 no leading or trailing hyphens"
1342
+ }
1343
+ },
1344
+ 422
1345
+ );
1346
+ }
1347
+ const conflict = await repo.findBySlug(slug);
1348
+ if (conflict && conflict["id"] !== id) {
1349
+ return c.json(
1350
+ {
1351
+ ok: false,
1352
+ error: {
1353
+ code: "SLUG_CONFLICT",
1354
+ message: `Slug '${slug}' already exists for this content type`
1355
+ }
1356
+ },
1357
+ 409
1358
+ );
1359
+ }
1360
+ }
1361
+ if (body["published"] === true) {
1362
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1363
+ });
1364
+ if (publishDeny) return publishDeny;
1365
+ const merged = { ...existing, ...body };
1366
+ const fieldErrors = checkRequiredFields(requiredFields, merged);
1367
+ if (fieldErrors.length > 0) {
1368
+ return c.json(
1369
+ {
1370
+ ok: false,
1371
+ error: {
1372
+ code: "PUBLISH_VALIDATION_ERROR",
1373
+ message: "Cannot publish \u2014 required fields are missing",
1374
+ details: fieldErrors
1375
+ }
1376
+ },
1377
+ 422
1378
+ );
1379
+ }
1380
+ }
1381
+ const patchParagraphFields = contentType.fields.filter((f) => f.db_column === null);
1382
+ const patchJunctionFields = contentType.fields.filter(
1383
+ (f) => f.db_column !== null && f.db_column.junction !== void 0
1384
+ );
1385
+ const patchColumnFields = contentType.fields.filter(
1386
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1387
+ );
1388
+ const patchData = {};
1389
+ if (!contentType.only_one && "slug" in body) patchData["slug"] = body["slug"];
1390
+ if ("published" in body) patchData["published"] = body["published"];
1391
+ for (const f of patchColumnFields) {
1392
+ if (f.name in body) {
1393
+ const val = body[f.name];
1394
+ patchData[f.db_column.column_name] = val !== void 0 ? val : null;
1395
+ }
1396
+ }
1397
+ const updated = await repo.update(id, patchData);
1398
+ if (!updated) {
1399
+ return c.json(
1400
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1401
+ 404
1402
+ );
1403
+ }
1404
+ const mediaDeltas = [
1405
+ topLevelMediaDelta(mediaFields, existing, body)
1406
+ ];
1407
+ if (db) {
1408
+ for (const f of patchParagraphFields) {
1409
+ const comp = f.ui_component;
1410
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1411
+ const pType = registry.paragraph_types[comp.ref];
1412
+ if (!pType) continue;
1413
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1414
+ mediaDeltas.push(await persistParagraphField(db, id, contentType.db.table_name, f.name, pType, items));
1415
+ }
1416
+ for (const f of patchJunctionFields) {
1417
+ const junction = f.db_column.junction;
1418
+ const relatedIds = Array.isArray(body[f.name]) ? body[f.name].filter((v) => typeof v === "string") : [];
1419
+ await persistJunctionField(db, id, junction, relatedIds);
1420
+ }
1421
+ }
1422
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1423
+ return c.json({ ok: true, data: updated });
1424
+ }
1425
+ );
1426
+ app.delete(
1427
+ `/admin/api/${basePath}/:id`,
1428
+ requirePermission("content:delete"),
1429
+ async (c) => {
1430
+ const id = c.req.param("id");
1431
+ const item = await repo.findOne(id);
1432
+ if (!item) {
1433
+ return c.json(
1434
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1435
+ 404
1436
+ );
1437
+ }
1438
+ const mediaDeltas = [
1439
+ topLevelMediaDelta(mediaFields, item, null)
1440
+ ];
1441
+ if (db) {
1442
+ for (const f of paragraphFieldDefs) {
1443
+ const comp = f.ui_component;
1444
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1445
+ const pType = registry.paragraph_types[comp.ref];
1446
+ if (!pType) continue;
1447
+ mediaDeltas.push(await deleteParagraphField(db, id, f.name, pType));
1448
+ }
1449
+ }
1450
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1451
+ await repo.delete(id);
1452
+ return c.json({ ok: true });
1453
+ }
1454
+ );
1455
+ }
1456
+ for (const [typeName, taxonomyType] of Object.entries(registry.taxonomy_types)) {
1457
+ const repo = repos[typeName];
1458
+ if (!repo) continue;
1459
+ const schemaFieldNames = /* @__PURE__ */ new Set([
1460
+ ...taxonomyType.fields.map((f) => f.name),
1461
+ ...taxonomyType.system_fields.map((f) => f.name)
1462
+ ]);
1463
+ const relationFieldNames = new Set(
1464
+ taxonomyType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
1465
+ );
1466
+ const requiredFields = taxonomyType.fields.filter((f) => f.required);
1467
+ const mediaFields = taxonomyType.fields.filter(
1468
+ (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
1469
+ );
1470
+ const paragraphFieldDefs = taxonomyType.fields.filter((f) => f.db_column === null);
1471
+ const searchableColumns = [
1472
+ ...taxonomyType.fields.filter((f) => f.field_type === "text/plain" && f.db_column !== null).map((f) => f.db_column.column_name),
1473
+ ...taxonomyType.system_fields.some((f) => f.name === "slug") ? ["slug"] : []
1474
+ ];
1475
+ app.get(
1476
+ `/admin/api/taxonomy/${typeName}`,
1477
+ requirePermission("content:read"),
1478
+ async (c) => {
1479
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
1480
+ if (!parsed.ok) {
1481
+ return c.json({ ok: false, error: parsed.response }, parsed.status);
1482
+ }
1483
+ const publishedParam = c.req.query("published");
1484
+ const extraFilters = {};
1485
+ if (publishedParam === "false") extraFilters["published"] = false;
1486
+ const findOpts = {
1487
+ page: parsed.pagination.page,
1488
+ per_page: parsed.pagination.per_page,
1489
+ sort_by: parsed.sortBy,
1490
+ sort_order: parsed.sortOrder,
1491
+ filters: { ...parsed.filters, ...extraFilters },
1492
+ include: parsed.include
1493
+ };
1494
+ if (publishedParam === "true") findOpts.published_only = true;
1495
+ if (parsed.search !== "" && searchableColumns.length > 0) {
1496
+ findOpts.search = { term: parsed.search, columns: searchableColumns };
1497
+ }
1498
+ const result = await repo.findMany(findOpts);
1499
+ return c.json(result);
1500
+ }
1501
+ );
1502
+ app.get(
1503
+ `/admin/api/taxonomy/${typeName}/:id`,
1504
+ requirePermission("content:read"),
1505
+ async (c) => {
1506
+ const id = c.req.param("id");
1507
+ const item = await repo.findOne(id);
1508
+ if (!item) {
1509
+ return c.json(
1510
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1511
+ 404
1512
+ );
1513
+ }
1514
+ return c.json({ ok: true, data: item });
1515
+ }
1516
+ );
1517
+ app.post(
1518
+ `/admin/api/taxonomy/${typeName}`,
1519
+ requirePermission("content:create"),
1520
+ async (c) => {
1521
+ const body = await c.req.json();
1522
+ const fieldErrors = checkRequiredFields(requiredFields, body);
1523
+ if (fieldErrors.length > 0) {
1524
+ return c.json(
1525
+ {
1526
+ ok: false,
1527
+ error: {
1528
+ code: "VALIDATION_ERROR",
1529
+ message: "Required fields are missing",
1530
+ details: fieldErrors
1531
+ }
1532
+ },
1533
+ 422
1534
+ );
1535
+ }
1536
+ if (body["published"] === true) {
1537
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1538
+ });
1539
+ if (publishDeny) return publishDeny;
1540
+ }
1541
+ const taxColumnFields = taxonomyType.fields.filter(
1542
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1543
+ );
1544
+ const taxParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
1545
+ const taxColumnData = {
1546
+ published: body["published"] ?? false
1547
+ };
1548
+ for (const f of taxColumnFields) {
1549
+ const val = body[f.name];
1550
+ taxColumnData[f.db_column.column_name] = val !== void 0 ? val : null;
1551
+ }
1552
+ const item = await repo.create(taxColumnData);
1553
+ const taxItemId = item["id"];
1554
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
1555
+ if (db) {
1556
+ for (const f of taxParagraphFields) {
1557
+ const comp = f.ui_component;
1558
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1559
+ const pType = registry.paragraph_types[comp.ref];
1560
+ if (!pType) continue;
1561
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1562
+ mediaDeltas.push(await persistParagraphField(db, taxItemId, taxonomyType.db.table_name, f.name, pType, items));
1563
+ }
1564
+ }
1565
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1566
+ return c.json({ ok: true, data: item }, 201);
1567
+ }
1568
+ );
1569
+ app.patch(
1570
+ `/admin/api/taxonomy/${typeName}/:id`,
1571
+ requirePermission("content:edit"),
1572
+ async (c) => {
1573
+ const id = c.req.param("id");
1574
+ const body = await c.req.json();
1575
+ const existing = await repo.findOne(id);
1576
+ if (!existing) {
1577
+ return c.json(
1578
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1579
+ 404
1580
+ );
1581
+ }
1582
+ if (body["published"] === true) {
1583
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1584
+ });
1585
+ if (publishDeny) return publishDeny;
1586
+ const merged = { ...existing, ...body };
1587
+ const fieldErrors = checkRequiredFields(requiredFields, merged);
1588
+ if (fieldErrors.length > 0) {
1589
+ return c.json(
1590
+ {
1591
+ ok: false,
1592
+ error: {
1593
+ code: "PUBLISH_VALIDATION_ERROR",
1594
+ message: "Cannot publish \u2014 required fields are missing",
1595
+ details: fieldErrors
1596
+ }
1597
+ },
1598
+ 422
1599
+ );
1600
+ }
1601
+ }
1602
+ const taxPatchColumnFields = taxonomyType.fields.filter(
1603
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1604
+ );
1605
+ const taxPatchParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
1606
+ const taxPatchData = {};
1607
+ if ("published" in body) taxPatchData["published"] = body["published"];
1608
+ for (const f of taxPatchColumnFields) {
1609
+ if (f.name in body) {
1610
+ const val = body[f.name];
1611
+ taxPatchData[f.db_column.column_name] = val !== void 0 ? val : null;
1612
+ }
1613
+ }
1614
+ const updated = await repo.update(id, taxPatchData);
1615
+ if (!updated) {
1616
+ return c.json(
1617
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1618
+ 404
1619
+ );
1620
+ }
1621
+ const mediaDeltas = [
1622
+ topLevelMediaDelta(mediaFields, existing, body)
1623
+ ];
1624
+ if (db) {
1625
+ for (const f of taxPatchParagraphFields) {
1626
+ const comp = f.ui_component;
1627
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1628
+ const pType = registry.paragraph_types[comp.ref];
1629
+ if (!pType) continue;
1630
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1631
+ mediaDeltas.push(await persistParagraphField(db, id, taxonomyType.db.table_name, f.name, pType, items));
1632
+ }
1633
+ }
1634
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1635
+ return c.json({ ok: true, data: updated });
1636
+ }
1637
+ );
1638
+ app.delete(
1639
+ `/admin/api/taxonomy/${typeName}/:id`,
1640
+ requirePermission("content:delete"),
1641
+ async (c) => {
1642
+ const id = c.req.param("id");
1643
+ const item = await repo.findOne(id);
1644
+ if (!item) {
1645
+ return c.json(
1646
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1647
+ 404
1648
+ );
1649
+ }
1650
+ const mediaDeltas = [
1651
+ topLevelMediaDelta(mediaFields, item, null)
1652
+ ];
1653
+ if (db) {
1654
+ for (const f of paragraphFieldDefs) {
1655
+ const comp = f.ui_component;
1656
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1657
+ const pType = registry.paragraph_types[comp.ref];
1658
+ if (!pType) continue;
1659
+ mediaDeltas.push(await deleteParagraphField(db, id, f.name, pType));
1660
+ }
1661
+ }
1662
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1663
+ await repo.delete(id);
1664
+ return c.json({ ok: true });
1665
+ }
1666
+ );
1667
+ }
1668
+ app.get("/admin/api/config", (c) => {
1669
+ return c.json({
1670
+ ok: true,
1671
+ data: {
1672
+ content_types: Object.values(registry.content_types).map((ct) => ({
1673
+ name: ct.name,
1674
+ label: ct.label,
1675
+ only_one: ct.only_one,
1676
+ default_base_path: ct.default_base_path,
1677
+ fields: ct.fields,
1678
+ system_fields: ct.system_fields
1679
+ })),
1680
+ taxonomy_types: Object.values(registry.taxonomy_types).map((tt) => ({
1681
+ name: tt.name,
1682
+ label: tt.label,
1683
+ fields: tt.fields,
1684
+ system_fields: tt.system_fields
1685
+ })),
1686
+ enum_types: Object.values(registry.enum_types).map((et) => ({
1687
+ name: et.name,
1688
+ label: et.label,
1689
+ values: et.values
1690
+ }))
1691
+ }
1692
+ });
1693
+ });
1694
+ }
1695
+
1696
+ // src/routes/admin/media.ts
1697
+ import { sign as sign2, verify } from "hono/jwt";
1698
+ var IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
1699
+ "image/jpeg",
1700
+ "image/png",
1701
+ "image/webp",
1702
+ "image/gif"
1703
+ ]);
1704
+ var VIDEO_MIME_TYPES = /* @__PURE__ */ new Set(["video/mp4", "video/webm", "video/quicktime"]);
1705
+ var FILE_MIME_TYPES = /* @__PURE__ */ new Set(["application/pdf"]);
1706
+ function authSecret() {
1707
+ const secret = process.env["AUTH_SECRET"];
1708
+ if (!secret) throw new Error("AUTH_SECRET environment variable is not set");
1709
+ return secret;
1710
+ }
1711
+ async function signPendingUpload(data, expiresAt) {
1712
+ return sign2({ ...data, exp: expiresAt }, authSecret());
1713
+ }
1714
+ async function verifyPendingUpload(token) {
1715
+ try {
1716
+ const p = await verify(token, authSecret(), "HS256");
1717
+ return { key: p.key, folder: p.folder, mime_type: p.mime_type };
1718
+ } catch {
1719
+ return null;
1720
+ }
1721
+ }
1722
+ async function handleDirectUpload(folder, acceptedTypes, mediaRepo, storage, c, altRequired, maxFileSize) {
1723
+ if (maxFileSize !== void 0) {
1724
+ const contentLength = Number(c.req.header("content-length"));
1725
+ if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
1726
+ return c.json(
1727
+ {
1728
+ ok: false,
1729
+ error: {
1730
+ code: "FILE_TOO_LARGE",
1731
+ message: `Upload exceeds the maximum size of ${maxFileSize} bytes`
1732
+ }
1733
+ },
1734
+ 413
1735
+ );
1736
+ }
1737
+ }
1738
+ let formData;
1739
+ try {
1740
+ formData = await c.req.formData();
1741
+ } catch {
1742
+ return c.json(
1743
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "Expected multipart/form-data" } },
1744
+ 422
1745
+ );
1746
+ }
1747
+ const fileField = formData.get("file");
1748
+ if (!(fileField instanceof File)) {
1749
+ return c.json(
1750
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "file field is required" } },
1751
+ 422
1752
+ );
1753
+ }
1754
+ if (maxFileSize !== void 0 && fileField.size > maxFileSize) {
1755
+ return c.json(
1756
+ {
1757
+ ok: false,
1758
+ error: {
1759
+ code: "FILE_TOO_LARGE",
1760
+ message: `File exceeds the maximum size of ${maxFileSize} bytes`
1761
+ }
1762
+ },
1763
+ 413
1764
+ );
1765
+ }
1766
+ const mimeType = fileField.type;
1767
+ if (!acceptedTypes.has(mimeType)) {
1768
+ return c.json(
1769
+ {
1770
+ ok: false,
1771
+ error: {
1772
+ code: "UNSUPPORTED_MIME_TYPE",
1773
+ message: `MIME type '${mimeType}' is not accepted by this endpoint`
1774
+ }
1775
+ },
1776
+ 415
1777
+ );
1778
+ }
1779
+ const altValue = formData.get("alt");
1780
+ const alt = typeof altValue === "string" && altValue.trim() !== "" ? altValue.trim() : void 0;
1781
+ if (altRequired && !alt) {
1782
+ return c.json(
1783
+ {
1784
+ ok: false,
1785
+ error: {
1786
+ code: "VALIDATION_ERROR",
1787
+ message: "alt is required for this media type",
1788
+ details: [{ field: "alt", message: "alt is required" }]
1789
+ }
1790
+ },
1791
+ 422
1792
+ );
1793
+ }
1794
+ let presigned;
1795
+ try {
1796
+ presigned = await storage.getPresignedUploadUrl({
1797
+ folder,
1798
+ filename: fileField.name,
1799
+ mime_type: mimeType
1800
+ });
1801
+ } catch {
1802
+ return c.json(
1803
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Failed to generate upload URL" } },
1804
+ 502
1805
+ );
1806
+ }
1807
+ if (storage.upload) {
1808
+ try {
1809
+ const bytes = new Uint8Array(await fileField.arrayBuffer());
1810
+ await storage.upload(presigned.key, bytes, mimeType);
1811
+ } catch (err) {
1812
+ console.error("[media] storage.upload error:", err);
1813
+ return c.json(
1814
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Storage upload failed" } },
1815
+ 502
1816
+ );
1817
+ }
1818
+ }
1819
+ const url = storage.getUrl(presigned.key);
1820
+ const fileSize = fileField.size;
1821
+ const item = await mediaRepo.create({
1822
+ type: folder,
1823
+ url,
1824
+ mime_type: mimeType,
1825
+ ...alt !== void 0 && { alt },
1826
+ file_size: fileSize
1827
+ });
1828
+ return c.json({ ok: true, data: item }, 201);
1829
+ }
1830
+ function registerAdminMediaRoutes(app, mediaRepo, storage, requirePermission, maxFileSize) {
1831
+ app.post(
1832
+ "/admin/api/media/image",
1833
+ requirePermission("media:create"),
1834
+ async (c) => {
1835
+ return handleDirectUpload("image", IMAGE_MIME_TYPES, mediaRepo, storage, c, false, maxFileSize);
1836
+ }
1837
+ );
1838
+ app.post(
1839
+ "/admin/api/media/video",
1840
+ requirePermission("media:create"),
1841
+ async (c) => {
1842
+ return handleDirectUpload("video", VIDEO_MIME_TYPES, mediaRepo, storage, c, true, maxFileSize);
1843
+ }
1844
+ );
1845
+ app.post(
1846
+ "/admin/api/media/file",
1847
+ requirePermission("media:create"),
1848
+ async (c) => {
1849
+ return handleDirectUpload("file", FILE_MIME_TYPES, mediaRepo, storage, c, true, maxFileSize);
1850
+ }
1851
+ );
1852
+ app.get("/admin/api/media/presigned-url", requirePermission("media:create"), async (c) => {
1853
+ const type = c.req.query("type");
1854
+ const filename = c.req.query("filename");
1855
+ const mimeType = c.req.query("mime_type");
1856
+ if (!type || !["image", "video", "file"].includes(type)) {
1857
+ return c.json(
1858
+ {
1859
+ ok: false,
1860
+ error: {
1861
+ code: "VALIDATION_ERROR",
1862
+ message: "type must be one of: image, video, file",
1863
+ details: [{ field: "type", message: "type is required" }]
1864
+ }
1865
+ },
1866
+ 422
1867
+ );
1868
+ }
1869
+ if (!filename || filename.trim() === "") {
1870
+ return c.json(
1871
+ {
1872
+ ok: false,
1873
+ error: {
1874
+ code: "VALIDATION_ERROR",
1875
+ message: "filename is required",
1876
+ details: [{ field: "filename", message: "filename is required" }]
1877
+ }
1878
+ },
1879
+ 422
1880
+ );
1881
+ }
1882
+ if (!mimeType || mimeType.trim() === "") {
1883
+ return c.json(
1884
+ {
1885
+ ok: false,
1886
+ error: {
1887
+ code: "VALIDATION_ERROR",
1888
+ message: "mime_type is required",
1889
+ details: [{ field: "mime_type", message: "mime_type is required" }]
1890
+ }
1891
+ },
1892
+ 422
1893
+ );
1894
+ }
1895
+ const accepted = type === "image" ? IMAGE_MIME_TYPES : type === "video" ? VIDEO_MIME_TYPES : FILE_MIME_TYPES;
1896
+ if (!accepted.has(mimeType)) {
1897
+ return c.json(
1898
+ {
1899
+ ok: false,
1900
+ error: {
1901
+ code: "UNSUPPORTED_MIME_TYPE",
1902
+ message: `MIME type '${mimeType}' is not accepted for ${type} uploads`
1903
+ }
1904
+ },
1905
+ 415
1906
+ );
1907
+ }
1908
+ let presigned;
1909
+ try {
1910
+ presigned = await storage.getPresignedUploadUrl({
1911
+ folder: type,
1912
+ filename,
1913
+ mime_type: mimeType
1914
+ });
1915
+ } catch {
1916
+ return c.json(
1917
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Failed to generate upload URL" } },
1918
+ 502
1919
+ );
1920
+ }
1921
+ const media_id = await signPendingUpload(
1922
+ { key: presigned.key, folder: type, mime_type: mimeType },
1923
+ presigned.expires_at
1924
+ );
1925
+ return c.json({
1926
+ ok: true,
1927
+ data: {
1928
+ upload_url: presigned.upload_url,
1929
+ // Signed, self-contained token the client posts back to /confirm/:id.
1930
+ media_id,
1931
+ expires_at: presigned.expires_at,
1932
+ // Present for storages that need a multipart POST with signed fields
1933
+ // (Cloudinary); absent for a raw PUT (S3).
1934
+ ...presigned.method && { method: presigned.method },
1935
+ ...presigned.fields && { fields: presigned.fields }
1936
+ }
1937
+ });
1938
+ });
1939
+ app.post("/admin/api/media/confirm/:id", requirePermission("media:create"), async (c) => {
1940
+ const id = c.req.param("id");
1941
+ const pending = await verifyPendingUpload(id);
1942
+ if (!pending) {
1943
+ return c.json(
1944
+ {
1945
+ ok: false,
1946
+ error: {
1947
+ code: "PRESIGNED_URL_EXPIRED",
1948
+ message: "Presigned upload not found or has expired"
1949
+ }
1950
+ },
1951
+ 410
1952
+ );
1953
+ }
1954
+ const body = await c.req.json().catch(() => ({}));
1955
+ const altValue = typeof body["alt"] === "string" ? body["alt"].trim() : void 0;
1956
+ const altRequired = pending.folder === "video" || pending.folder === "file";
1957
+ if (altRequired && !altValue) {
1958
+ return c.json(
1959
+ {
1960
+ ok: false,
1961
+ error: {
1962
+ code: "VALIDATION_ERROR",
1963
+ message: "alt is required for video and file uploads",
1964
+ details: [{ field: "alt", message: "alt is required" }]
1965
+ }
1966
+ },
1967
+ 422
1968
+ );
1969
+ }
1970
+ let fileSize = 0;
1971
+ if (storage.stat) {
1972
+ const meta = await storage.stat(pending.key);
1973
+ if (!meta) {
1974
+ return c.json(
1975
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Uploaded object not found" } },
1976
+ 502
1977
+ );
1978
+ }
1979
+ const accepted = pending.folder === "image" ? IMAGE_MIME_TYPES : pending.folder === "video" ? VIDEO_MIME_TYPES : FILE_MIME_TYPES;
1980
+ if (meta.content_type && !accepted.has(meta.content_type)) {
1981
+ await storage.delete(pending.key);
1982
+ return c.json(
1983
+ { ok: false, error: { code: "UNSUPPORTED_MIME_TYPE", message: `Stored object type '${meta.content_type}' is not accepted` } },
1984
+ 415
1985
+ );
1986
+ }
1987
+ if (maxFileSize !== void 0 && meta.size > maxFileSize) {
1988
+ await storage.delete(pending.key);
1989
+ return c.json(
1990
+ { ok: false, error: { code: "FILE_TOO_LARGE", message: `Uploaded file exceeds the ${maxFileSize} byte limit` } },
1991
+ 413
1992
+ );
1993
+ }
1994
+ fileSize = meta.size;
1995
+ }
1996
+ const url = storage.getUrl(pending.key);
1997
+ const item = await mediaRepo.create({
1998
+ type: pending.folder,
1999
+ url,
2000
+ mime_type: pending.mime_type,
2001
+ ...altValue !== void 0 && { alt: altValue },
2002
+ file_size: fileSize
2003
+ });
2004
+ return c.json({ ok: true, data: item }, 201);
2005
+ });
2006
+ app.patch(
2007
+ "/admin/api/media/:id",
2008
+ requirePermission("media:edit"),
2009
+ async (c) => {
2010
+ const id = c.req.param("id");
2011
+ const existing = await mediaRepo.findOne(id);
2012
+ if (!existing) {
2013
+ return c.json(
2014
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2015
+ 404
2016
+ );
2017
+ }
2018
+ const body = await c.req.json();
2019
+ const alt = typeof body["alt"] === "string" ? body["alt"].trim() : void 0;
2020
+ const updateData = {};
2021
+ if (alt !== void 0) updateData.alt = alt;
2022
+ const updated = await mediaRepo.update(id, updateData);
2023
+ if (!updated) {
2024
+ return c.json(
2025
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2026
+ 404
2027
+ );
2028
+ }
2029
+ return c.json({ ok: true, data: updated });
2030
+ }
2031
+ );
2032
+ app.delete(
2033
+ "/admin/api/media/:id",
2034
+ requirePermission("media:delete"),
2035
+ async (c) => {
2036
+ const id = c.req.param("id");
2037
+ const item = await mediaRepo.findOne(id);
2038
+ if (!item) {
2039
+ return c.json(
2040
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2041
+ 404
2042
+ );
2043
+ }
2044
+ if (item.reference_count > 0) {
2045
+ return c.json(
2046
+ {
2047
+ ok: false,
2048
+ error: {
2049
+ code: "MEDIA_IN_USE",
2050
+ message: "Cannot delete media that is referenced by content items"
2051
+ }
2052
+ },
2053
+ 409
2054
+ );
2055
+ }
2056
+ const pathname = new URL(item.url).pathname;
2057
+ const uploadIdx = pathname.indexOf("/upload/");
2058
+ const key = uploadIdx >= 0 ? pathname.slice(uploadIdx + "/upload/".length) : pathname.replace(/^\//, "");
2059
+ try {
2060
+ await storage.delete(key);
2061
+ } catch (err) {
2062
+ console.error("[media] storage.delete error:", err);
2063
+ return c.json(
2064
+ {
2065
+ ok: false,
2066
+ error: { code: "STORAGE_ERROR", message: "Failed to delete file from storage" }
2067
+ },
2068
+ 502
2069
+ );
2070
+ }
2071
+ await mediaRepo.delete(id);
2072
+ return c.json({ ok: true });
2073
+ }
2074
+ );
2075
+ app.get("/admin/api/media", requirePermission("media:read"), async (c) => {
2076
+ const pageStr = c.req.query("page");
2077
+ const perPageStr = c.req.query("per_page");
2078
+ const page = pageStr !== void 0 ? Number(pageStr) : 1;
2079
+ const per_page = perPageStr !== void 0 ? Number(perPageStr) : 10;
2080
+ if (!Number.isInteger(page) || page < 1 || !Number.isInteger(per_page) || per_page < 1 || per_page > 100) {
2081
+ return c.json(
2082
+ {
2083
+ ok: false,
2084
+ error: {
2085
+ code: "INVALID_PAGINATION",
2086
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
2087
+ }
2088
+ },
2089
+ 400
2090
+ );
2091
+ }
2092
+ const typeParam = c.req.query("type");
2093
+ if (typeParam !== void 0 && !["image", "video", "file"].includes(typeParam)) {
2094
+ return c.json(
2095
+ {
2096
+ ok: false,
2097
+ error: { code: "VALIDATION_ERROR", message: "type must be one of: image, video, file" }
2098
+ },
2099
+ 422
2100
+ );
2101
+ }
2102
+ const orphanedParam = c.req.query("orphaned");
2103
+ const orphaned = orphanedParam === "true" ? true : orphanedParam === "false" ? false : void 0;
2104
+ const opts = { page, per_page };
2105
+ if (typeParam !== void 0) opts.type = typeParam;
2106
+ if (orphaned !== void 0) opts.orphaned = orphaned;
2107
+ const result = await mediaRepo.findMany(opts);
2108
+ return c.json(result);
2109
+ });
2110
+ app.get("/admin/api/media/:id", requirePermission("media:read"), async (c) => {
2111
+ const id = c.req.param("id");
2112
+ const item = await mediaRepo.findOne(id);
2113
+ if (!item) {
2114
+ return c.json(
2115
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2116
+ 404
2117
+ );
2118
+ }
2119
+ return c.json({ ok: true, data: item });
2120
+ });
2121
+ }
2122
+
2123
+ // src/routes/admin/auth.ts
2124
+ import { getCookie as getCookie2 } from "hono/cookie";
2125
+ import { sql as sql4 } from "drizzle-orm";
2126
+
2127
+ // src/auth/password.ts
2128
+ import { hashPassword, verifyPassword } from "@bobbykim/manguito-cms-core";
2129
+
2130
+ // src/routes/admin/auth.ts
2131
+ var loginAttempts = /* @__PURE__ */ new Map();
2132
+ var globalLoginAttempts = [];
2133
+ var RATE_LIMIT_MAX = 10;
2134
+ var RATE_LIMIT_WINDOW_MS = 15 * 60 * 1e3;
2135
+ var GLOBAL_LOGIN_MAX = 100;
2136
+ var DUMMY_PASSWORD_HASH = "$2a$12$y.MZw/Q4Ceg3x1y3xrRaeuTaM09zSDx1nn78guO3bqc9vgOqGda42";
2137
+ function checkRateLimit(key) {
2138
+ const now = Date.now();
2139
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
2140
+ while (globalLoginAttempts.length > 0 && globalLoginAttempts[0] <= windowStart) {
2141
+ globalLoginAttempts.shift();
2142
+ }
2143
+ if (globalLoginAttempts.length >= GLOBAL_LOGIN_MAX) {
2144
+ const retryAfterSeconds = Math.ceil((globalLoginAttempts[0] + RATE_LIMIT_WINDOW_MS - now) / 1e3);
2145
+ return { allowed: false, retryAfterSeconds };
2146
+ }
2147
+ const attempts = (loginAttempts.get(key) ?? []).filter((t) => t > windowStart);
2148
+ attempts.push(now);
2149
+ loginAttempts.set(key, attempts);
2150
+ globalLoginAttempts.push(now);
2151
+ if (attempts.length > RATE_LIMIT_MAX) {
2152
+ const oldestInWindow = attempts[0];
2153
+ const retryAfterSeconds = Math.ceil((oldestInWindow + RATE_LIMIT_WINDOW_MS - now) / 1e3);
2154
+ return { allowed: false, retryAfterSeconds };
2155
+ }
2156
+ return { allowed: true, retryAfterSeconds: 0 };
2157
+ }
2158
+ function registerAuthRoutes(app, db) {
2159
+ const invalidCredentials = {
2160
+ ok: false,
2161
+ error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password." }
2162
+ };
2163
+ app.post("/admin/api/auth/login", async (c) => {
2164
+ let body;
2165
+ try {
2166
+ body = await c.req.json();
2167
+ } catch {
2168
+ return c.json(invalidCredentials, 401);
2169
+ }
2170
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : null;
2171
+ const password = typeof body.password === "string" ? body.password : null;
2172
+ if (!email || !password) {
2173
+ return c.json(invalidCredentials, 401);
2174
+ }
2175
+ const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
2176
+ const rateCheck = checkRateLimit(`${ip}:${email}`);
2177
+ if (!rateCheck.allowed) {
2178
+ c.header("Retry-After", String(rateCheck.retryAfterSeconds));
2179
+ return c.json(
2180
+ {
2181
+ ok: false,
2182
+ error: {
2183
+ code: "RATE_LIMITED",
2184
+ message: "Too many login attempts. Please try again later."
2185
+ }
2186
+ },
2187
+ 429
2188
+ );
2189
+ }
2190
+ const userResult = await db.execute(
2191
+ sql4`SELECT id, email, password_hash, role_id, token_version
2192
+ FROM users WHERE email = ${email} LIMIT 1`
2193
+ );
2194
+ const user = userResult.rows[0];
2195
+ if (!user) {
2196
+ await verifyPassword(password, DUMMY_PASSWORD_HASH);
2197
+ return c.json(invalidCredentials, 401);
2198
+ }
2199
+ const passwordValid = await verifyPassword(password, user.password_hash);
2200
+ if (!passwordValid) {
2201
+ return c.json(invalidCredentials, 401);
2202
+ }
2203
+ const roleResult = await db.execute(
2204
+ sql4`SELECT r.name AS name, u.must_change_password
2205
+ FROM roles r
2206
+ JOIN users u ON u.role_id = r.id
2207
+ WHERE r.id = ${user.role_id} AND u.id = ${user.id}
2208
+ LIMIT 1`
2209
+ );
2210
+ const roleRow = roleResult.rows[0];
2211
+ if (!roleRow) {
2212
+ return c.json(
2213
+ { ok: false, error: { code: "INVALID_ROLE", message: "User role not found." } },
2214
+ 500
2215
+ );
2216
+ }
2217
+ const [authToken, refreshToken] = await Promise.all([
2218
+ signToken(
2219
+ { user_id: user.id, role: roleRow.name, token_version: user.token_version },
2220
+ AUTH_TOKEN_TTL
2221
+ ),
2222
+ signToken(
2223
+ { user_id: user.id, role: roleRow.name, token_version: user.token_version },
2224
+ REFRESH_TOKEN_TTL
2225
+ )
2226
+ ]);
2227
+ setAuthCookie(c, "auth_token", authToken, { path: "/" });
2228
+ setAuthCookie(c, "refresh_token", refreshToken, { path: "/admin/api/auth" });
2229
+ return c.json({
2230
+ ok: true,
2231
+ data: {
2232
+ id: user.id,
2233
+ email: user.email,
2234
+ role: roleRow.name,
2235
+ must_change_password: roleRow.must_change_password
2236
+ }
2237
+ });
2238
+ });
2239
+ app.post("/admin/api/auth/refresh", async (c) => {
2240
+ const token = getCookie2(c, "refresh_token");
2241
+ if (!token) {
2242
+ return c.json(
2243
+ { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication required." } },
2244
+ 401
2245
+ );
2246
+ }
2247
+ let payload;
2248
+ try {
2249
+ payload = await verifyToken(token);
2250
+ } catch (err) {
2251
+ const code = err.code;
2252
+ if (code === "TOKEN_EXPIRED") {
2253
+ return c.json(
2254
+ { ok: false, error: { code: "TOKEN_EXPIRED", message: "Token has expired." } },
2255
+ 401
2256
+ );
2257
+ }
2258
+ return c.json(
2259
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token signature is invalid." } },
2260
+ 401
2261
+ );
2262
+ }
2263
+ const rowResult = await db.execute(
2264
+ sql4`SELECT u.token_version, r.name AS role
2265
+ FROM users u
2266
+ JOIN roles r ON r.id = u.role_id
2267
+ WHERE u.id = ${payload.user_id}
2268
+ LIMIT 1`
2269
+ );
2270
+ const row = rowResult.rows[0];
2271
+ if (!row || payload.token_version !== row.token_version) {
2272
+ return c.json(
2273
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token has been invalidated." } },
2274
+ 401
2275
+ );
2276
+ }
2277
+ const newAuthToken = await signToken(
2278
+ { user_id: payload.user_id, role: row.role, token_version: row.token_version },
2279
+ AUTH_TOKEN_TTL
2280
+ );
2281
+ setAuthCookie(c, "auth_token", newAuthToken, { path: "/" });
2282
+ return c.json({ ok: true });
2283
+ });
2284
+ const authMiddleware = createAuthMiddleware(db);
2285
+ app.post("/admin/api/auth/logout", authMiddleware, async (c) => {
2286
+ const user = c.get("user");
2287
+ await db.execute(
2288
+ sql4`UPDATE users SET token_version = token_version + 1 WHERE id = ${user.id}`
2289
+ );
2290
+ clearAuthCookie(c, "auth_token");
2291
+ clearAuthCookie(c, "refresh_token", "/admin/api/auth");
2292
+ return c.json({ ok: true });
2293
+ });
2294
+ }
2295
+
2296
+ // src/routes/admin/users.ts
2297
+ import { sql as sql5 } from "drizzle-orm";
2298
+ import { randomBytes } from "crypto";
2299
+ function getActingUser(c) {
2300
+ return c.get("user");
2301
+ }
2302
+ function generateTempPassword() {
2303
+ return randomBytes(8).toString("base64url");
2304
+ }
2305
+ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2306
+ app.post("/admin/api/users/change-password", async (c) => {
2307
+ const actingUser = getActingUser(c);
2308
+ let body;
2309
+ try {
2310
+ body = await c.req.json();
2311
+ } catch {
2312
+ return c.json(
2313
+ { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid credentials." } },
2314
+ 401
2315
+ );
2316
+ }
2317
+ const currentPassword = typeof body.current_password === "string" ? body.current_password : null;
2318
+ const newPassword = typeof body.new_password === "string" ? body.new_password : null;
2319
+ if (!currentPassword || !newPassword) {
2320
+ return c.json(
2321
+ {
2322
+ ok: false,
2323
+ error: {
2324
+ code: "VALIDATION_ERROR",
2325
+ message: "current_password and new_password are required."
2326
+ }
2327
+ },
2328
+ 422
2329
+ );
2330
+ }
2331
+ const hashResult = await db.execute(
2332
+ sql5`SELECT password_hash FROM users WHERE id = ${actingUser.id} LIMIT 1`
2333
+ );
2334
+ const hashRow = hashResult.rows[0];
2335
+ if (!hashRow || !await verifyPassword(currentPassword, hashRow.password_hash)) {
2336
+ return c.json(
2337
+ { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid credentials." } },
2338
+ 401
2339
+ );
2340
+ }
2341
+ const newHash = await hashPassword(newPassword);
2342
+ await db.execute(
2343
+ sql5`UPDATE users
2344
+ SET password_hash = ${newHash},
2345
+ must_change_password = false,
2346
+ token_version = token_version + 1,
2347
+ updated_at = NOW()
2348
+ WHERE id = ${actingUser.id}`
2349
+ );
2350
+ return c.json({ ok: true });
2351
+ });
2352
+ app.get("/admin/api/users", requirePermission("users:read"), async (c) => {
2353
+ const result = await db.execute(
2354
+ sql5`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2355
+ u.created_at, u.updated_at
2356
+ FROM users u
2357
+ JOIN roles r ON r.id = u.role_id
2358
+ ORDER BY u.created_at ASC`
2359
+ );
2360
+ return c.json({ ok: true, data: result.rows });
2361
+ });
2362
+ app.get("/admin/api/users/:id", requirePermission("users:read"), async (c) => {
2363
+ const id = c.req.param("id");
2364
+ const result = await db.execute(
2365
+ sql5`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2366
+ u.created_at, u.updated_at
2367
+ FROM users u
2368
+ JOIN roles r ON r.id = u.role_id
2369
+ WHERE u.id = ${id}
2370
+ LIMIT 1`
2371
+ );
2372
+ const row = result.rows[0];
2373
+ if (!row) {
2374
+ return c.json(
2375
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2376
+ 404
2377
+ );
2378
+ }
2379
+ return c.json({ ok: true, data: row });
2380
+ });
2381
+ app.post(
2382
+ "/admin/api/users",
2383
+ requirePermission("users:create"),
2384
+ requireHierarchy(),
2385
+ async (c) => {
2386
+ let body;
2387
+ try {
2388
+ body = await c.req.json();
2389
+ } catch {
2390
+ return c.json(
2391
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "Request body is required." } },
2392
+ 422
2393
+ );
2394
+ }
2395
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : null;
2396
+ const roleName = typeof body.role === "string" ? body.role : null;
2397
+ if (!email) {
2398
+ return c.json(
2399
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "email is required." } },
2400
+ 422
2401
+ );
2402
+ }
2403
+ if (!roleName) {
2404
+ return c.json(
2405
+ { ok: false, error: { code: "INVALID_ROLE", message: "role is required." } },
2406
+ 400
2407
+ );
2408
+ }
2409
+ const roleResult = await db.execute(
2410
+ sql5`SELECT id FROM roles WHERE name = ${roleName} LIMIT 1`
2411
+ );
2412
+ const roleRow = roleResult.rows[0];
2413
+ if (!roleRow) {
2414
+ return c.json(
2415
+ {
2416
+ ok: false,
2417
+ error: { code: "INVALID_ROLE", message: `Role "${roleName}" does not exist.` }
2418
+ },
2419
+ 400
2420
+ );
2421
+ }
2422
+ const tempPassword = generateTempPassword();
2423
+ const passwordHash = await hashPassword(tempPassword);
2424
+ const insertResult = await db.execute(
2425
+ sql5`INSERT INTO users
2426
+ (id, email, password_hash, role_id, must_change_password, token_version,
2427
+ created_at, updated_at)
2428
+ VALUES
2429
+ (gen_random_uuid(), ${email}, ${passwordHash}, ${roleRow.id},
2430
+ true, 0, NOW(), NOW())
2431
+ RETURNING id, email, must_change_password, created_at, updated_at`
2432
+ );
2433
+ const newUser = insertResult.rows[0];
2434
+ if (!newUser) {
2435
+ return c.json(
2436
+ { ok: false, error: { code: "INTERNAL_ERROR", message: "Failed to create user." } },
2437
+ 500
2438
+ );
2439
+ }
2440
+ return c.json(
2441
+ {
2442
+ ok: true,
2443
+ data: {
2444
+ id: newUser.id,
2445
+ email: newUser.email,
2446
+ role: roleName,
2447
+ must_change_password: newUser.must_change_password,
2448
+ temporary_password: tempPassword,
2449
+ created_at: newUser.created_at,
2450
+ updated_at: newUser.updated_at
2451
+ }
2452
+ },
2453
+ 201
2454
+ );
2455
+ }
2456
+ );
2457
+ app.patch(
2458
+ "/admin/api/users/:id",
2459
+ requirePermission("users:edit"),
2460
+ requireHierarchy(),
2461
+ async (c) => {
2462
+ const id = c.req.param("id");
2463
+ const actingUser = getActingUser(c);
2464
+ let body;
2465
+ try {
2466
+ body = await c.req.json();
2467
+ } catch {
2468
+ body = {};
2469
+ }
2470
+ if (actingUser.id === id && body.role !== void 0) {
2471
+ return c.json(
2472
+ {
2473
+ ok: false,
2474
+ error: { code: "INSUFFICIENT_PRIVILEGE", message: "You cannot change your own role." }
2475
+ },
2476
+ 403
2477
+ );
2478
+ }
2479
+ const existingResult = await db.execute(
2480
+ sql5`SELECT id, email, role_id FROM users WHERE id = ${id} LIMIT 1`
2481
+ );
2482
+ const existing = existingResult.rows[0];
2483
+ if (!existing) {
2484
+ return c.json(
2485
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2486
+ 404
2487
+ );
2488
+ }
2489
+ const newEmail = typeof body.email === "string" ? body.email.trim().toLowerCase() : existing.email;
2490
+ let newRoleId = existing.role_id;
2491
+ let changeRole = false;
2492
+ if (typeof body.role === "string" && body.role) {
2493
+ const roleResult = await db.execute(
2494
+ sql5`SELECT id FROM roles WHERE name = ${body.role} LIMIT 1`
2495
+ );
2496
+ const roleRow = roleResult.rows[0];
2497
+ if (!roleRow) {
2498
+ return c.json(
2499
+ {
2500
+ ok: false,
2501
+ error: {
2502
+ code: "INVALID_ROLE",
2503
+ message: `Role "${body.role}" does not exist.`
2504
+ }
2505
+ },
2506
+ 400
2507
+ );
2508
+ }
2509
+ newRoleId = roleRow.id;
2510
+ changeRole = true;
2511
+ }
2512
+ if (changeRole) {
2513
+ await db.execute(
2514
+ sql5`UPDATE users
2515
+ SET email = ${newEmail},
2516
+ role_id = ${newRoleId},
2517
+ token_version = token_version + 1,
2518
+ updated_at = NOW()
2519
+ WHERE id = ${id}`
2520
+ );
2521
+ } else {
2522
+ await db.execute(
2523
+ sql5`UPDATE users
2524
+ SET email = ${newEmail},
2525
+ role_id = ${newRoleId},
2526
+ updated_at = NOW()
2527
+ WHERE id = ${id}`
2528
+ );
2529
+ }
2530
+ const updatedResult = await db.execute(
2531
+ sql5`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2532
+ u.created_at, u.updated_at
2533
+ FROM users u
2534
+ JOIN roles r ON r.id = u.role_id
2535
+ WHERE u.id = ${id}
2536
+ LIMIT 1`
2537
+ );
2538
+ const updated = updatedResult.rows[0];
2539
+ if (!updated) {
2540
+ return c.json(
2541
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2542
+ 404
2543
+ );
2544
+ }
2545
+ return c.json({ ok: true, data: updated });
2546
+ }
2547
+ );
2548
+ app.delete(
2549
+ "/admin/api/users/:id",
2550
+ requirePermission("users:delete"),
2551
+ requireHierarchy(),
2552
+ async (c) => {
2553
+ const id = c.req.param("id");
2554
+ const actingUser = getActingUser(c);
2555
+ if (actingUser.id === id) {
2556
+ return c.json(
2557
+ {
2558
+ ok: false,
2559
+ error: {
2560
+ code: "INSUFFICIENT_PRIVILEGE",
2561
+ message: "You cannot delete your own account."
2562
+ }
2563
+ },
2564
+ 403
2565
+ );
2566
+ }
2567
+ const result = await db.execute(
2568
+ sql5`DELETE FROM users WHERE id = ${id} RETURNING id`
2569
+ );
2570
+ if (result.rows.length === 0) {
2571
+ return c.json(
2572
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2573
+ 404
2574
+ );
2575
+ }
2576
+ return c.json({ ok: true });
2577
+ }
2578
+ );
2579
+ app.post(
2580
+ "/admin/api/users/:id/reset-password",
2581
+ requirePermission("users:edit"),
2582
+ requireHierarchy(),
2583
+ async (c) => {
2584
+ const id = c.req.param("id");
2585
+ const actingUser = getActingUser(c);
2586
+ if (actingUser.id === id) {
2587
+ return c.json(
2588
+ {
2589
+ ok: false,
2590
+ error: {
2591
+ code: "INSUFFICIENT_PRIVILEGE",
2592
+ message: "You cannot reset your own password via this endpoint."
2593
+ }
2594
+ },
2595
+ 403
2596
+ );
2597
+ }
2598
+ const existingResult = await db.execute(
2599
+ sql5`SELECT id FROM users WHERE id = ${id} LIMIT 1`
2600
+ );
2601
+ const existing = existingResult.rows[0];
2602
+ if (!existing) {
2603
+ return c.json(
2604
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2605
+ 404
2606
+ );
2607
+ }
2608
+ const tempPassword = generateTempPassword();
2609
+ const passwordHash = await hashPassword(tempPassword);
2610
+ await db.execute(
2611
+ sql5`UPDATE users
2612
+ SET password_hash = ${passwordHash},
2613
+ must_change_password = true,
2614
+ token_version = token_version + 1,
2615
+ updated_at = NOW()
2616
+ WHERE id = ${id}`
2617
+ );
2618
+ return c.json({ ok: true, data: { temporary_password: tempPassword } });
2619
+ }
2620
+ );
2621
+ }
2622
+
2623
+ // src/routes/admin/config.ts
2624
+ import { sql as sql6 } from "drizzle-orm";
2625
+ var API_VERSION = (() => {
2626
+ try {
2627
+ return "0.1.0";
2628
+ } catch {
2629
+ return "0.0.0";
2630
+ }
2631
+ })();
2632
+ function registerConfigRoute(app, config, registry, db) {
2633
+ app.get("/admin/api/config", async (c) => {
2634
+ const actingUser = c.get("user");
2635
+ const actingRole = registry[actingUser.role];
2636
+ const actingLevel = actingRole?.hierarchy_level ?? Infinity;
2637
+ const roles = Object.values(registry).filter((r) => r.name !== "admin" && r.hierarchy_level > actingLevel).sort((a, b) => a.hierarchy_level - b.hierarchy_level).map((r) => ({ name: r.name, label: r.label, hierarchy_level: r.hierarchy_level }));
2638
+ const all_roles = Object.values(registry).sort((a, b) => a.hierarchy_level - b.hierarchy_level).map((r) => ({
2639
+ name: r.name,
2640
+ label: r.label,
2641
+ hierarchy_level: r.hierarchy_level,
2642
+ is_system: r.is_system,
2643
+ permissions: r.permissions
2644
+ }));
2645
+ const userResult = await db.execute(
2646
+ sql6`SELECT email, must_change_password FROM users WHERE id = ${actingUser.id} LIMIT 1`
2647
+ );
2648
+ const userRow = userResult.rows[0];
2649
+ return c.json({
2650
+ ok: true,
2651
+ data: {
2652
+ cms_name: config.name ?? "Manguito CMS",
2653
+ version: API_VERSION,
2654
+ roles,
2655
+ all_roles,
2656
+ user: {
2657
+ id: actingUser.id,
2658
+ email: userRow?.email ?? "",
2659
+ role: actingUser.role,
2660
+ must_change_password: userRow?.must_change_password ?? false
2661
+ },
2662
+ media: {
2663
+ max_file_size: config.maxFileSize ?? null,
2664
+ presigned_uploads: config.presignedUploads ?? false
2665
+ }
2666
+ }
2667
+ });
2668
+ });
2669
+ }
2670
+
2671
+ // src/routes/admin/schema.ts
2672
+ import { sql as sql7 } from "drizzle-orm";
2673
+ function registerSchemaRoute(app, registry, db) {
2674
+ app.get("/admin/api/schema", (c) => {
2675
+ const content_types = Object.values(registry.content_types).map((ct) => ({
2676
+ name: ct.name,
2677
+ label: ct.label,
2678
+ only_one: ct.only_one,
2679
+ ui: ct.ui,
2680
+ system_fields: ct.system_fields,
2681
+ fields: ct.fields
2682
+ }));
2683
+ const taxonomy_types = Object.values(registry.taxonomy_types).map((tt) => ({
2684
+ name: tt.name,
2685
+ label: tt.label,
2686
+ system_fields: tt.system_fields,
2687
+ fields: tt.fields
2688
+ }));
2689
+ const paragraph_types = Object.values(registry.paragraph_types).map((pt) => ({
2690
+ name: pt.name,
2691
+ label: pt.label,
2692
+ system_fields: pt.system_fields,
2693
+ fields: pt.fields
2694
+ }));
2695
+ const enum_types = Object.values(registry.enum_types).map((et) => ({
2696
+ name: et.name,
2697
+ label: et.label,
2698
+ values: et.values
2699
+ }));
2700
+ return c.json({
2701
+ ok: true,
2702
+ data: { content_types, taxonomy_types, paragraph_types, enum_types }
2703
+ });
2704
+ });
2705
+ app.get("/admin/api/content", async (c) => {
2706
+ const data = await Promise.all(
2707
+ Object.values(registry.content_types).map(async (ct) => {
2708
+ const result = await db.execute(
2709
+ sql7`SELECT COUNT(*)::int AS count FROM ${sql7.raw(`"${ct.db.table_name}"`)}`
2710
+ );
2711
+ const count = Number(result.rows[0].count);
2712
+ return { name: ct.name, label: ct.label, only_one: ct.only_one, count };
2713
+ })
2714
+ );
2715
+ return c.json({ ok: true, data });
2716
+ });
2717
+ app.get("/admin/api/taxonomy", async (c) => {
2718
+ const data = await Promise.all(
2719
+ Object.values(registry.taxonomy_types).map(async (tt) => {
2720
+ const result = await db.execute(
2721
+ sql7`SELECT COUNT(*)::int AS count FROM ${sql7.raw(`"${tt.db.table_name}"`)}`
2722
+ );
2723
+ const count = Number(result.rows[0].count);
2724
+ return { name: tt.name, label: tt.label, count };
2725
+ })
2726
+ );
2727
+ return c.json({ ok: true, data });
2728
+ });
2729
+ }
2730
+
2731
+ // src/repositories/content.ts
2732
+ import crypto2 from "crypto";
2733
+ import { sql as sql8 } from "drizzle-orm";
2734
+ var SORTABLE_FIELDS2 = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
2735
+ function codeError(code, message) {
2736
+ const err = new Error(message);
2737
+ err.code = code;
2738
+ return err;
2739
+ }
2740
+ function quoteIdent3(name) {
2741
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) {
2742
+ throw new Error(`Unsafe SQL identifier: ${name}`);
2743
+ }
2744
+ return `"${name.replace(/"/g, '""')}"`;
2745
+ }
2746
+ function quoteField(name) {
2747
+ if (!/^[a-z][a-z0-9_]*$/.test(name)) {
2748
+ throw codeError("INVALID_FILTER_FIELD", `Invalid filter field name: ${name}`);
2749
+ }
2750
+ return `"${name}"`;
2751
+ }
2752
+ function buildConditions(published_only, filters, search) {
2753
+ const conds = [];
2754
+ if (published_only) {
2755
+ conds.push(sql8`published = true`);
2756
+ }
2757
+ if (search && search.term.trim() !== "" && search.columns.length > 0) {
2758
+ const pattern = `%${search.term.trim()}%`;
2759
+ const orConds = search.columns.map((col) => sql8`${sql8.raw(quoteField(col))} ILIKE ${pattern}`);
2760
+ conds.push(sql8`(${sql8.join(orConds, sql8` OR `)})`);
2761
+ }
2762
+ if (filters) {
2763
+ for (const [field, value] of Object.entries(filters)) {
2764
+ const col = sql8.raw(quoteField(field));
2765
+ if (Array.isArray(value)) {
2766
+ if (value.length === 0) continue;
2767
+ const inList = sql8.join(
2768
+ value.map((v) => sql8`${v}`),
2769
+ sql8`, `
2770
+ );
2771
+ conds.push(sql8`${col} IN (${inList})`);
2772
+ } else if (typeof value === "object" && value !== null) {
2773
+ const op = value;
2774
+ if (op.gt !== void 0) conds.push(sql8`${col} > ${op.gt}`);
2775
+ if (op.gte !== void 0) conds.push(sql8`${col} >= ${op.gte}`);
2776
+ if (op.lt !== void 0) conds.push(sql8`${col} < ${op.lt}`);
2777
+ if (op.lte !== void 0) conds.push(sql8`${col} <= ${op.lte}`);
2778
+ } else {
2779
+ conds.push(sql8`${col} = ${value}`);
2780
+ }
2781
+ }
2782
+ }
2783
+ return conds;
2784
+ }
2785
+ function whereFragment(conds) {
2786
+ return conds.length > 0 ? sql8` WHERE ${sql8.join(conds, sql8` AND `)}` : sql8``;
2787
+ }
2788
+ function createDrizzleContentRepository(db, tableName, options = {}) {
2789
+ const { relations = {} } = options;
2790
+ function tableRaw() {
2791
+ return sql8.raw(quoteIdent3(tableName));
2792
+ }
2793
+ async function resolveRows(rows, include, cache) {
2794
+ if (rows.length === 0) return rows;
2795
+ for (const fieldName of include) {
2796
+ if (!relations[fieldName]) {
2797
+ throw codeError("INVALID_INCLUDE_FIELD", `'${fieldName}' is not a valid relation field`);
2798
+ }
2799
+ }
2800
+ for (const [fieldName, rel] of Object.entries(relations)) {
2801
+ if (rel.type === "media") {
2802
+ await resolveRelationField(db, rows, fieldName, rel, cache);
2803
+ } else if (!include.includes(fieldName)) {
2804
+ await resolveRelationBareIds(db, rows, fieldName, rel);
2805
+ }
2806
+ }
2807
+ for (const fieldName of include) {
2808
+ const rel = relations[fieldName];
2809
+ if (rel.type !== "media") {
2810
+ await resolveRelationField(db, rows, fieldName, rel, cache);
2811
+ }
2812
+ }
2813
+ return rows;
2814
+ }
2815
+ return {
2816
+ async findMany(opts) {
2817
+ const {
2818
+ page = 1,
2819
+ per_page = 10,
2820
+ include = [],
2821
+ published_only,
2822
+ filters,
2823
+ sort_by = "created_at",
2824
+ sort_order = "asc",
2825
+ search
2826
+ } = opts;
2827
+ if (!SORTABLE_FIELDS2.has(sort_by)) {
2828
+ throw codeError("INVALID_SORT_FIELD", `'${sort_by}' is not sortable. Allowed: title, created_at, updated_at`);
2829
+ }
2830
+ const offset = (page - 1) * per_page;
2831
+ const conds = buildConditions(published_only, filters, search);
2832
+ const where = whereFragment(conds);
2833
+ const sortCol = sql8.raw(quoteIdent3(sort_by));
2834
+ const sortDir = sql8.raw(sort_order === "desc" ? "DESC" : "ASC");
2835
+ const countResult = await db.execute(
2836
+ sql8`SELECT COUNT(*) AS total FROM ${tableRaw()}${where}`
2837
+ );
2838
+ const total = Number(countResult.rows[0]?.["total"] ?? 0);
2839
+ const dataResult = await db.execute(
2840
+ sql8`SELECT * FROM ${tableRaw()}${where} ORDER BY ${sortCol} ${sortDir} LIMIT ${per_page} OFFSET ${offset}`
2841
+ );
2842
+ const rows = dataResult.rows;
2843
+ const cache = /* @__PURE__ */ new Map();
2844
+ await resolveRows(rows, include, cache);
2845
+ const total_pages = Math.ceil(total / per_page);
2846
+ return {
2847
+ ok: true,
2848
+ data: rows,
2849
+ meta: {
2850
+ total,
2851
+ page,
2852
+ per_page,
2853
+ total_pages,
2854
+ has_next: page < total_pages,
2855
+ has_prev: page > 1
2856
+ }
2857
+ };
2858
+ },
2859
+ async findOne(id, include = []) {
2860
+ const result = await db.execute(
2861
+ sql8`SELECT * FROM ${tableRaw()} WHERE id = ${id} LIMIT 1`
2862
+ );
2863
+ if (result.rows.length === 0) return null;
2864
+ const rows = result.rows;
2865
+ const cache = /* @__PURE__ */ new Map();
2866
+ await resolveRows(rows, include, cache);
2867
+ return rows[0];
2868
+ },
2869
+ async findBySlug(slug, include = []) {
2870
+ const result = await db.execute(
2871
+ sql8`SELECT * FROM ${tableRaw()} WHERE slug = ${slug} LIMIT 1`
2872
+ );
2873
+ if (result.rows.length === 0) return null;
2874
+ const rows = result.rows;
2875
+ const cache = /* @__PURE__ */ new Map();
2876
+ await resolveRows(rows, include, cache);
2877
+ return rows[0];
2878
+ },
2879
+ async create(data) {
2880
+ const record = {
2881
+ ...data,
2882
+ id: crypto2.randomUUID(),
2883
+ created_at: /* @__PURE__ */ new Date(),
2884
+ updated_at: /* @__PURE__ */ new Date()
2885
+ };
2886
+ const entries = Object.entries(record).filter(([, v]) => v !== void 0);
2887
+ const colsSql = sql8.join(entries.map(([k]) => sql8.raw(quoteIdent3(k))), sql8`, `);
2888
+ const valsSql = sql8.join(entries.map(([, v]) => sql8`${v}`), sql8`, `);
2889
+ const result = await db.execute(
2890
+ sql8`INSERT INTO ${tableRaw()} (${colsSql}) VALUES (${valsSql}) RETURNING *`
2891
+ );
2892
+ return result.rows[0];
2893
+ },
2894
+ async update(id, data) {
2895
+ const record = {
2896
+ ...data,
2897
+ updated_at: /* @__PURE__ */ new Date()
2898
+ };
2899
+ const entries = Object.entries(record).filter(([k, v]) => k !== "id" && v !== void 0);
2900
+ if (entries.length === 0) {
2901
+ return this.findOne(id);
2902
+ }
2903
+ const setClauses = sql8.join(
2904
+ entries.map(([k, v]) => sql8`${sql8.raw(quoteIdent3(k))} = ${v}`),
2905
+ sql8`, `
2906
+ );
2907
+ const result = await db.execute(
2908
+ sql8`UPDATE ${tableRaw()} SET ${setClauses} WHERE id = ${id} RETURNING *`
2909
+ );
2910
+ if (result.rows.length === 0) return null;
2911
+ return result.rows[0];
2912
+ },
2913
+ async delete(id) {
2914
+ await db.execute(sql8`DELETE FROM ${tableRaw()} WHERE id = ${id}`);
2915
+ },
2916
+ async findAll(opts = {}) {
2917
+ const { include = [], published_only } = opts;
2918
+ const conds = buildConditions(published_only, void 0, void 0);
2919
+ const where = whereFragment(conds);
2920
+ const result = await db.execute(
2921
+ sql8`SELECT * FROM ${tableRaw()}${where} ORDER BY created_at ASC`
2922
+ );
2923
+ const rows = result.rows;
2924
+ const cache = /* @__PURE__ */ new Map();
2925
+ await resolveRows(rows, include, cache);
2926
+ return rows;
2927
+ }
2928
+ };
2929
+ }
2930
+
2931
+ // src/repositories/media.ts
2932
+ import crypto3 from "crypto";
2933
+ import { sql as sql9 } from "drizzle-orm";
2934
+ function createMediaRepository(db) {
2935
+ const TABLE = sql9.raw('"media"');
2936
+ return {
2937
+ async findMany(opts = {}) {
2938
+ const { page = 1, per_page = 10, type, orphaned } = opts;
2939
+ const offset = (page - 1) * per_page;
2940
+ const conds = [];
2941
+ if (type) conds.push(sql9`type = ${type}`);
2942
+ if (orphaned) conds.push(sql9`reference_count = 0`);
2943
+ const where = conds.length > 0 ? sql9` WHERE ${sql9.join(conds, sql9` AND `)}` : sql9``;
2944
+ const countResult = await db.execute(sql9`SELECT COUNT(*) AS total FROM ${TABLE}${where}`);
2945
+ const total = Number(
2946
+ countResult.rows[0]?.["total"] ?? 0
2947
+ );
2948
+ const dataResult = await db.execute(
2949
+ sql9`SELECT * FROM ${TABLE}${where} ORDER BY created_at DESC LIMIT ${per_page} OFFSET ${offset}`
2950
+ );
2951
+ const total_pages = Math.ceil(total / per_page);
2952
+ return {
2953
+ ok: true,
2954
+ data: dataResult.rows,
2955
+ meta: {
2956
+ total,
2957
+ page,
2958
+ per_page,
2959
+ total_pages,
2960
+ has_next: page < total_pages,
2961
+ has_prev: page > 1
2962
+ }
2963
+ };
2964
+ },
2965
+ async findOne(id) {
2966
+ const result = await db.execute(
2967
+ sql9`SELECT * FROM ${TABLE} WHERE id = ${id} LIMIT 1`
2968
+ );
2969
+ if (result.rows.length === 0) return null;
2970
+ return result.rows[0];
2971
+ },
2972
+ async create(data) {
2973
+ const record = {
2974
+ ...data,
2975
+ id: crypto3.randomUUID(),
2976
+ reference_count: 0,
2977
+ created_at: /* @__PURE__ */ new Date(),
2978
+ updated_at: /* @__PURE__ */ new Date()
2979
+ };
2980
+ const entries = Object.entries(record).filter(([, v]) => v !== void 0);
2981
+ const colsSql = sql9.join(entries.map(([k]) => sql9.raw(`"${k}"`)), sql9`, `);
2982
+ const valsSql = sql9.join(entries.map(([, v]) => sql9`${v}`), sql9`, `);
2983
+ const result = await db.execute(
2984
+ sql9`INSERT INTO ${TABLE} (${colsSql}) VALUES (${valsSql}) RETURNING *`
2985
+ );
2986
+ return result.rows[0];
2987
+ },
2988
+ async update(id, data) {
2989
+ const record = { ...data, updated_at: /* @__PURE__ */ new Date() };
2990
+ const entries = Object.entries(record).filter(
2991
+ ([k, v]) => k !== "id" && v !== void 0
2992
+ );
2993
+ if (entries.length === 0) return this.findOne(id);
2994
+ const setClauses = sql9.join(
2995
+ entries.map(([k, v]) => sql9`${sql9.raw(`"${k}"`)} = ${v}`),
2996
+ sql9`, `
2997
+ );
2998
+ const result = await db.execute(
2999
+ sql9`UPDATE ${TABLE} SET ${setClauses} WHERE id = ${id} RETURNING *`
3000
+ );
3001
+ if (result.rows.length === 0) return null;
3002
+ return result.rows[0];
3003
+ },
3004
+ async delete(id) {
3005
+ await db.execute(sql9`DELETE FROM ${TABLE} WHERE id = ${id}`);
3006
+ },
3007
+ async incrementReferenceCount(ids) {
3008
+ if (ids.length === 0) return;
3009
+ const inList = sql9.join(ids.map((id) => sql9`${id}`), sql9`, `);
3010
+ await db.execute(
3011
+ sql9`UPDATE ${TABLE} SET reference_count = reference_count + 1, updated_at = NOW() WHERE id IN (${inList})`
3012
+ );
3013
+ },
3014
+ async decrementReferenceCount(ids) {
3015
+ if (ids.length === 0) return;
3016
+ const inList = sql9.join(ids.map((id) => sql9`${id}`), sql9`, `);
3017
+ await db.execute(
3018
+ sql9`UPDATE ${TABLE} SET reference_count = GREATEST(reference_count - 1, 0), updated_at = NOW() WHERE id IN (${inList})`
3019
+ );
3020
+ }
3021
+ };
3022
+ }
3023
+
3024
+ // src/app.ts
3025
+ var MISSING_STORAGE_ERROR = `\u2717 api.storage is required but not configured.
3026
+ Add a storage adapter to your manguito.config.ts:
3027
+
3028
+ api: createAPIAdapter({
3029
+ storage: createLocalAdapter(), // dev
3030
+ // storage: createS3Adapter({ bucket: '...', region: '...' }) // production
3031
+ })
3032
+
3033
+ Exiting.`;
3034
+ function createCmsApp(options) {
3035
+ if (!options.storage) {
3036
+ throw new Error(MISSING_STORAGE_ERROR);
3037
+ }
3038
+ const prefix = options.prefix ?? "/api";
3039
+ const { storage, registry, db, rateLimit, media, cors } = options;
3040
+ const cmsName = options.name ?? "Manguito CMS";
3041
+ const maxFileSize = media?.max_file_size;
3042
+ const rolesRegistry = buildRolesRegistry(registry.roles.roles);
3043
+ const app = new Hono();
3044
+ const uploadOrigins = storage.getUploadOrigins?.() ?? [];
3045
+ app.use("*", createSecurityHeadersMiddleware({ connectSrc: uploadOrigins }));
3046
+ app.use("*", createCorsMiddleware(cors ?? { origin: "*", enabled: true }));
3047
+ app.onError(errorHandler);
3048
+ const listRateLimit = resolveListRateLimit(rateLimit);
3049
+ const authMiddleware = createAuthMiddleware(db);
3050
+ const requirePermission = createPermissionMiddleware(rolesRegistry);
3051
+ const getUserRole = async (userId) => {
3052
+ const result = await db.execute(
3053
+ sql10`SELECT r.name AS role
3054
+ FROM users u
3055
+ JOIN roles r ON r.id = u.role_id
3056
+ WHERE u.id = ${userId}
3057
+ LIMIT 1`
3058
+ );
3059
+ return result.rows[0]?.role ?? null;
3060
+ };
3061
+ const requireHierarchy = createHierarchyMiddleware(rolesRegistry, getUserRole);
3062
+ const contentRepos = Object.fromEntries(
3063
+ Object.entries(registry.content_types).map(([typeName, ct]) => [
3064
+ typeName,
3065
+ createDrizzleContentRepository(db, ct.db.table_name)
3066
+ ])
3067
+ );
3068
+ const taxonomyRepos = Object.fromEntries(
3069
+ Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3070
+ typeName,
3071
+ createDrizzleContentRepository(db, tt.db.table_name)
3072
+ ])
3073
+ );
3074
+ const repos = { ...contentRepos, ...taxonomyRepos };
3075
+ const mediaRepo = createMediaRepository(db);
3076
+ const publicContentRepos = Object.fromEntries(
3077
+ Object.entries(registry.content_types).map(([typeName, ct]) => [
3078
+ typeName,
3079
+ createDrizzleContentRepository(db, ct.db.table_name, {
3080
+ relations: buildRelationsMap(ct.fields, registry)
3081
+ })
3082
+ ])
3083
+ );
3084
+ const publicTaxonomyRepos = Object.fromEntries(
3085
+ Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3086
+ typeName,
3087
+ createDrizzleContentRepository(db, tt.db.table_name, {
3088
+ relations: buildRelationsMap(tt.fields, registry)
3089
+ })
3090
+ ])
3091
+ );
3092
+ const publicRepos = { ...publicContentRepos, ...publicTaxonomyRepos };
3093
+ registerAuthRoutes(app, db);
3094
+ app.use("/admin/api/*", authMiddleware);
3095
+ app.use("/admin/api/*", mustChangePasswordCheck);
3096
+ app.get("/api/openapi.json", (c) => {
3097
+ const paths = {};
3098
+ for (const ct of Object.values(registry.content_types)) {
3099
+ const base = ct.default_base_path;
3100
+ if (ct.only_one) {
3101
+ paths[`/api/${base}`] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
3102
+ } else {
3103
+ paths[`/api/${base}`] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
3104
+ paths[`/api/${base}/{slug}`] = { get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] } };
3105
+ }
3106
+ }
3107
+ for (const tt of Object.values(registry.taxonomy_types)) {
3108
+ paths[`/api/taxonomy/${tt.name}`] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
3109
+ paths[`/api/taxonomy/${tt.name}/{id}`] = { get: { summary: `Get ${tt.label} by id`, tags: [tt.label] } };
3110
+ }
3111
+ paths["/api/media"] = { get: { summary: "List media items", tags: ["Media"] } };
3112
+ paths["/api/media/{id}"] = { get: { summary: "Get media item by id", tags: ["Media"] } };
3113
+ return c.json({
3114
+ openapi: "3.0.3",
3115
+ info: { title: "Manguito CMS Public API", version: "1.0.0" },
3116
+ paths
3117
+ });
3118
+ });
3119
+ app.get(
3120
+ "/admin/api/openapi.json",
3121
+ (c) => c.json({
3122
+ openapi: "3.0.3",
3123
+ info: { title: "Manguito CMS Admin API", version: "1.0.0" },
3124
+ paths: {}
3125
+ })
3126
+ );
3127
+ registerPublicContentRoutes(app, registry, publicRepos, listRateLimit);
3128
+ registerPublicMediaRoutes(app, mediaRepo, listRateLimit);
3129
+ registerConfigRoute(
3130
+ app,
3131
+ {
3132
+ name: cmsName,
3133
+ ...maxFileSize !== void 0 && { maxFileSize },
3134
+ // Cloud storage (s3/cloudinary) issues real presigned URLs, so the admin
3135
+ // uploads straight to it — never routing the file through the server
3136
+ // (which breaks on serverless). Local storage has no presigned receiver,
3137
+ // so it uses the direct upload endpoint.
3138
+ presignedUploads: storage.type !== "local"
3139
+ },
3140
+ rolesRegistry,
3141
+ db
3142
+ );
3143
+ registerSchemaRoute(app, registry, db);
3144
+ registerUserRoutes(app, db, requirePermission, requireHierarchy);
3145
+ registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db);
3146
+ registerAdminMediaRoutes(app, mediaRepo, storage, requirePermission, maxFileSize);
3147
+ return { prefix, app };
3148
+ }
3149
+
3150
+ // src/server/node.ts
3151
+ function createServer(options = {}) {
3152
+ const port = options.port ?? Number(process.env["PORT"] ?? 3e3);
3153
+ const _base_url = options.base_url ?? `http://localhost:${port}`;
3154
+ const cors = {
3155
+ origin: options.cors?.origin ?? (process.env["ALLOWED_ORIGIN"] ?? "*"),
3156
+ methods: options.cors?.methods ?? [
3157
+ "GET",
3158
+ "POST",
3159
+ "PUT",
3160
+ "PATCH",
3161
+ "DELETE"
3162
+ ],
3163
+ credentials: options.cors?.credentials ?? true
3164
+ };
3165
+ return {
3166
+ type: "node",
3167
+ cors,
3168
+ getEntryPoint() {
3169
+ return "node";
3170
+ }
3171
+ };
3172
+ }
3173
+
3174
+ // src/index.ts
3175
+ function createAPIAdapter(options = {}) {
3176
+ const prefix = options.prefix ?? "/api";
3177
+ const media = options.media?.max_file_size !== void 0 ? { max_file_size: options.media.max_file_size } : void 0;
3178
+ return {
3179
+ prefix,
3180
+ ...media !== void 0 && { media },
3181
+ ...options.rateLimit !== void 0 && { rateLimit: options.rateLimit }
3182
+ };
3183
+ }
3184
+ export {
3185
+ createAPIAdapter,
3186
+ createCmsApp,
3187
+ createServer
3188
+ };
3189
+ //# sourceMappingURL=index.mjs.map