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