@anteros/core 0.0.2-alpha.3 → 0.0.2-alpha.6
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/package.json +1 -1
- package/server/api.ts +328 -514
package/package.json
CHANGED
package/server/api.ts
CHANGED
|
@@ -40,9 +40,86 @@ const ActionsValues = [
|
|
|
40
40
|
'aggregate',
|
|
41
41
|
] as const;
|
|
42
42
|
|
|
43
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
43
44
|
|
|
45
|
+
function getAccessToken(c: any) {
|
|
46
|
+
const t = c.get('token');
|
|
47
|
+
return {
|
|
48
|
+
value: (t?.value ?? null) as string | null,
|
|
49
|
+
decoded: (t?.decoded ?? null) as Record<string, unknown> | null,
|
|
50
|
+
provided: (t?.provided ?? false) as boolean,
|
|
51
|
+
expired: (t?.expired ?? false) as boolean,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function errorResponse(c: any, err: any) {
|
|
56
|
+
const isAppError = err instanceof AppError;
|
|
57
|
+
const status = isAppError ? Number(err.status) : 500;
|
|
58
|
+
return c.json({
|
|
59
|
+
message: isAppError ? err.message : 'Internal server error',
|
|
60
|
+
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
61
|
+
meta: isAppError ? err.meta : undefined,
|
|
62
|
+
}, status);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stripReadOnlyData(col: any, body: any) {
|
|
66
|
+
if (col?.api?.readOnlyFields?.length && body?.data) {
|
|
67
|
+
body.data = func.omit(body.data, col.api.readOnlyFields);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stripReadOnlyUpdate(col: any, body: any) {
|
|
72
|
+
if (col?.api?.readOnlyFields?.length && body?.update) {
|
|
73
|
+
body.update = func.omit(body.update, col.api.readOnlyFields);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Access control helpers ──────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function evaluateAccess(
|
|
80
|
+
access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
|
|
81
|
+
operation: string,
|
|
82
|
+
rest: InstanceType<typeof useRest>,
|
|
83
|
+
label: string,
|
|
84
|
+
c: any,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
if (!access) {
|
|
87
|
+
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasWildcard = access['*'] !== undefined;
|
|
91
|
+
const hasSpecific = access[operation] !== undefined;
|
|
92
|
+
|
|
93
|
+
if (!hasWildcard && !hasSpecific) {
|
|
94
|
+
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rule = hasSpecific ? access[operation] : access['*'];
|
|
98
|
+
if (rule === undefined) return;
|
|
99
|
+
|
|
100
|
+
// Boolean `true` → allow without requiring a token
|
|
101
|
+
if (typeof rule === 'boolean') {
|
|
102
|
+
if (!rule) {
|
|
103
|
+
throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
44
107
|
|
|
45
|
-
//
|
|
108
|
+
// Function → requires a valid token
|
|
109
|
+
const accessToken = getAccessToken(c);
|
|
110
|
+
|
|
111
|
+
if (accessToken.expired) {
|
|
112
|
+
throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
|
|
113
|
+
}
|
|
114
|
+
if (!accessToken.value) {
|
|
115
|
+
throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const allowed = await (rule as Function)({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
|
|
119
|
+
if (!allowed) {
|
|
120
|
+
throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
46
123
|
|
|
47
124
|
async function checkFileAccess(
|
|
48
125
|
access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
|
|
@@ -58,12 +135,10 @@ async function checkFileAccess(
|
|
|
58
135
|
const hasWildcard = access['*'] !== undefined;
|
|
59
136
|
const hasSpecific = access[operation] !== undefined;
|
|
60
137
|
|
|
61
|
-
// No matching rule at all → deny
|
|
62
138
|
if (!hasWildcard && !hasSpecific) {
|
|
63
139
|
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
64
140
|
}
|
|
65
141
|
|
|
66
|
-
// Specific rule takes precedence over wildcard
|
|
67
142
|
const rule = hasSpecific ? access[operation] : access['*'];
|
|
68
143
|
if (rule === undefined) {
|
|
69
144
|
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
@@ -78,8 +153,7 @@ async function checkFileAccess(
|
|
|
78
153
|
}
|
|
79
154
|
|
|
80
155
|
// Function → requires a valid token
|
|
81
|
-
const
|
|
82
|
-
const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
|
|
156
|
+
const accessToken = getAccessToken(c);
|
|
83
157
|
|
|
84
158
|
if (accessToken.expired) {
|
|
85
159
|
throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
|
|
@@ -95,9 +169,24 @@ async function checkFileAccess(
|
|
|
95
169
|
}
|
|
96
170
|
}
|
|
97
171
|
|
|
98
|
-
|
|
172
|
+
// ─── CRUD dispatch table ───────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
type CrudHandler = (rest: InstanceType<typeof useRest>, collection: string, body: any) => Promise<any>;
|
|
99
175
|
|
|
100
|
-
|
|
176
|
+
const crudHandlers: Record<string, CrudHandler> = {
|
|
177
|
+
aggregate: (rest, collection, body) => rest.aggregate(collection, body?.pipeline || []),
|
|
178
|
+
find: (rest, collection, body) => rest.find(collection, body?.params || {}),
|
|
179
|
+
findOne: (rest, collection, body) => rest.findOne(collection, body?.id, body?.params || {}),
|
|
180
|
+
insertOne: (rest, collection, body) => rest.insertOne(collection, body?.data),
|
|
181
|
+
insertMany: (rest, collection, body) => rest.insertMany(collection, body?.data),
|
|
182
|
+
updateOne: (rest, collection, body) => rest.updateOne(collection, body?.id || body?._id, body?.update || {}),
|
|
183
|
+
updateMany: (rest, collection, body) => rest.updateMany(collection, body?.ids || body?._ids || [], body?.update || {}),
|
|
184
|
+
findOneAndUpdate:(rest, collection, body) => rest.findOneAndUpdate(collection, body?.filter || {}, body?.update || {}, body?.options || {}),
|
|
185
|
+
deleteOne: (rest, collection, body) => rest.deleteOne(collection, body?.id || body?._id),
|
|
186
|
+
deleteMany: (rest, collection, body) => rest.deleteMany(collection, body?.ids || body?._ids || []),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
101
190
|
|
|
102
191
|
// tenant middlewares (scoped to their tenant only)
|
|
103
192
|
const tenantMiddlewares = getTenantMiddlewares();
|
|
@@ -105,8 +194,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
105
194
|
app.use(async (c, next) => {
|
|
106
195
|
const url = new URL(c.req.url);
|
|
107
196
|
const segments = url.pathname.split('/').filter(Boolean);
|
|
108
|
-
const tenantInUrl = segments[1];
|
|
109
|
-
|
|
197
|
+
const tenantInUrl = segments[1];
|
|
110
198
|
if (mw._tenant_ === tenantInUrl) {
|
|
111
199
|
return mw.handler(c, next);
|
|
112
200
|
}
|
|
@@ -122,8 +210,8 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
122
210
|
tenant_id: string;
|
|
123
211
|
action: string;
|
|
124
212
|
collection: string;
|
|
125
|
-
collectionType?:
|
|
126
|
-
status:
|
|
213
|
+
collectionType?: 'document' | 'file';
|
|
214
|
+
status: 'success' | 'error';
|
|
127
215
|
input?: any;
|
|
128
216
|
result?: any;
|
|
129
217
|
error?: { message: string; code: string } | null;
|
|
@@ -157,615 +245,341 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
157
245
|
}]);
|
|
158
246
|
}
|
|
159
247
|
|
|
248
|
+
// ─── Collection API ─────────────────────────────────────────────────
|
|
160
249
|
app.post(API_PREFIX, async (c) => {
|
|
161
|
-
let isMultipartOrFormData = false;
|
|
162
250
|
let response: any;
|
|
163
251
|
let body: any;
|
|
164
|
-
let parseBody: any;
|
|
165
252
|
let rest: InstanceType<typeof useRest>;
|
|
166
253
|
let canAccessToAction: boolean | undefined;
|
|
167
254
|
|
|
168
255
|
try {
|
|
169
256
|
const ContentType = c.req.header('Content-Type');
|
|
170
257
|
const { action, collection, tenant_id } = c.req.param() as { action: typeof ActionsValues[number], collection: string, tenant_id: string };
|
|
171
|
-
const { cleanDeep, useCache } = c.req.query() as ApiOptions;
|
|
172
|
-
|
|
173
|
-
|
|
174
258
|
|
|
175
|
-
//
|
|
176
|
-
if (!tenant_id) {
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (!collection) {
|
|
181
|
-
throw new AppError('Collection is required', { status: 400, code: 'COLLECTION_REQUIRED' });
|
|
182
|
-
}
|
|
259
|
+
// Validate params
|
|
260
|
+
if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
|
|
261
|
+
if (!collection) throw new AppError('Collection is required', { status: 400, code: 'COLLECTION_REQUIRED' });
|
|
262
|
+
if (!action) throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
|
|
183
263
|
|
|
184
|
-
|
|
185
|
-
throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// Content Type
|
|
191
|
-
if (ContentType?.includes('application/x-www-form-urlencoded') || ContentType?.includes('multipart/form-data')) {
|
|
192
|
-
isMultipartOrFormData = true;
|
|
193
|
-
try {
|
|
194
|
-
parseBody = await c.req.parseBody({ all: true });
|
|
195
|
-
} catch {
|
|
196
|
-
throw new AppError('Invalid form data', { status: 400, code: 'INVALID_FORM_DATA' });
|
|
197
|
-
}
|
|
198
|
-
}
|
|
264
|
+
// Parse body
|
|
199
265
|
if (ContentType?.includes('application/json')) {
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
body = await c.req.json();
|
|
203
|
-
} catch {
|
|
266
|
+
try { body = await c.req.json(); } catch {
|
|
204
267
|
throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
|
|
205
268
|
}
|
|
269
|
+
} else if (ContentType?.includes('multipart/form-data') || ContentType?.includes('application/x-www-form-urlencoded')) {
|
|
270
|
+
try { body = await c.req.parseBody({ all: true }); } catch {
|
|
271
|
+
throw new AppError('Invalid form data', { status: 400, code: 'INVALID_FORM_DATA' });
|
|
272
|
+
}
|
|
206
273
|
}
|
|
207
274
|
|
|
208
|
-
|
|
209
|
-
|
|
210
275
|
if (!cfg.tenants.find(t => t.id === tenant_id)) {
|
|
211
276
|
throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
212
277
|
}
|
|
213
278
|
|
|
214
|
-
|
|
215
|
-
// Collection
|
|
216
|
-
let col = getCollection(collection, tenant_id)
|
|
279
|
+
const col = getCollection(collection, tenant_id)
|
|
217
280
|
if (!col) {
|
|
218
281
|
throw new AppError('Collection `' + collection + '` not found', { status: 400, code: 'COLLECTION_NOT_FOUND' });
|
|
219
282
|
}
|
|
220
283
|
|
|
221
|
-
//console.log('Has action:', col?.actions?.hasOwnProperty(action))
|
|
222
|
-
|
|
223
284
|
if (!ActionsValues.includes(action as (typeof ActionsValues)[number]) && !Object.hasOwn(col?.actions ?? {}, action)) {
|
|
224
285
|
throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
225
286
|
}
|
|
226
287
|
|
|
227
288
|
const fieldOrder = ['_id', ...col.fields.map(f => f.name)];
|
|
228
289
|
const privateFields = col.api?.privateFields || [];
|
|
290
|
+
const accessToken = getAccessToken(c);
|
|
229
291
|
|
|
292
|
+
rest = new useRest({ internal: false, tenant_id, useHook: true });
|
|
230
293
|
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
tenant_id: tenant_id,
|
|
235
|
-
useHook: true,
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
// Logout Action
|
|
239
|
-
if (action == 'logout') {
|
|
240
|
-
const logStart = Date.now()
|
|
294
|
+
// ── Logout ──
|
|
295
|
+
if (action === 'logout') {
|
|
296
|
+
const logStart = Date.now();
|
|
241
297
|
try {
|
|
242
|
-
if (!col?.api?.auth?.enabled) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
jwt: func.jwt,
|
|
253
|
-
cookies: {
|
|
254
|
-
delete: (name: string) => {
|
|
255
|
-
cookie.deleteCookie(c, name)
|
|
256
|
-
},
|
|
257
|
-
}
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
await logActivity({
|
|
261
|
-
rest, tenant_id, action: 'logout', collection,
|
|
262
|
-
status: 'success',
|
|
263
|
-
input: { payload: body?.payload },
|
|
264
|
-
duration: Date.now() - logStart,
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
return c.json({ message: 'Logout successful', ok: true })
|
|
298
|
+
if (!col?.api?.auth?.enabled) throw new AppError('Auth is not enabled', { status: 400, code: 'AUTH_NOT_ENABLED' });
|
|
299
|
+
if (!col?.api?.auth?.onLogout) throw new AppError('Auth onLogout is not defined', { status: 400, code: 'AUTH_HANDLER_NOT_DEFINED' });
|
|
300
|
+
|
|
301
|
+
await col.api.auth.onLogout({
|
|
302
|
+
rest, payload: body?.payload, error: fn.error, jwt: func.jwt,
|
|
303
|
+
cookies: { delete: (name: string) => cookie.deleteCookie(c, name) },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await logActivity({ rest, tenant_id, action: 'logout', collection, status: 'success', input: { payload: body?.payload }, duration: Date.now() - logStart });
|
|
307
|
+
return c.json({ message: 'Logout successful', ok: true });
|
|
268
308
|
} catch (err: any) {
|
|
269
|
-
await logActivity({
|
|
270
|
-
|
|
271
|
-
status: 'error',
|
|
272
|
-
input: { payload: body?.payload },
|
|
273
|
-
error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' },
|
|
274
|
-
duration: Date.now() - logStart,
|
|
275
|
-
}).catch(() => {})
|
|
276
|
-
throw err
|
|
309
|
+
await logActivity({ rest, tenant_id, action: 'logout', collection, status: 'error', input: { payload: body?.payload }, error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' }, duration: Date.now() - logStart }).catch(() => {});
|
|
310
|
+
throw err;
|
|
277
311
|
}
|
|
278
312
|
}
|
|
279
313
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (action == 'login') {
|
|
284
|
-
const logStart = Date.now()
|
|
314
|
+
// ── Login ──
|
|
315
|
+
if (action === 'login') {
|
|
316
|
+
const logStart = Date.now();
|
|
285
317
|
try {
|
|
286
|
-
if (!col?.api?.auth?.enabled) {
|
|
287
|
-
|
|
318
|
+
if (!col?.api?.auth?.enabled) throw new AppError('Auth is not enabled', { status: 400, code: 'AUTH_NOT_ENABLED' });
|
|
319
|
+
if (!col?.api?.auth?.onLogin) throw new AppError('Auth onLogin is not defined', { status: 400, code: 'AUTH_HANDLER_NOT_DEFINED' });
|
|
320
|
+
|
|
321
|
+
const authResult = await col.api.auth.onLogin({
|
|
322
|
+
rest, payload: body?.payload, error: fn.error, jwt: func.jwt,
|
|
323
|
+
cookies: {
|
|
324
|
+
set: (name: string, value: string, opts?: any) => cookie.setCookie(c, name, value, opts),
|
|
325
|
+
get: (name: string) => cookie.getCookie(c, name),
|
|
326
|
+
delete: (name: string) => cookie.deleteCookie(c, name),
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (!authResult || typeof authResult === 'function' || !('token' in authResult)) {
|
|
331
|
+
throw new AppError('Invalid login response', { status: 401, code: 'INVALID_LOGIN' });
|
|
288
332
|
}
|
|
289
|
-
if (!
|
|
290
|
-
throw new AppError('
|
|
333
|
+
if (!authResult.token) {
|
|
334
|
+
throw new AppError('Invalid token assigned', { status: 401, code: 'INVALID_TOKEN_ASSIGNED' });
|
|
291
335
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
set: (name: string, value: string, options?: {
|
|
299
|
-
httpOnly?: boolean;
|
|
300
|
-
secure?: boolean;
|
|
301
|
-
maxAge?: number;
|
|
302
|
-
path?: string;
|
|
303
|
-
domain?: string;
|
|
304
|
-
sameSite?: 'lax' | 'strict' | 'none';
|
|
305
|
-
}) => {
|
|
306
|
-
cookie.setCookie(c, name, value, options)
|
|
307
|
-
},
|
|
308
|
-
get: (name: string) => {
|
|
309
|
-
return cookie.getCookie(c, name)
|
|
310
|
-
},
|
|
311
|
-
delete: (name: string) => {
|
|
312
|
-
cookie.deleteCookie(c, name)
|
|
313
|
-
},
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (!authResult || typeof authResult === 'function' || !('token' in authResult)) {
|
|
322
|
-
throw new AppError('Invalid login response', { status: 401, code: 'INVALID_LOGIN' });
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if (!authResult.token) {
|
|
326
|
-
throw new AppError('Invalid token assigned', { status: 401, code: 'INVALID_TOKEN_ASSIGNED' });
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const { value, error } = await func.jwt.verify(authResult.token)
|
|
330
|
-
if (error) {
|
|
331
|
-
throw new AppError('Invalid token', { status: 401, code: 'INVALID_TOKEN' });
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
await logActivity({
|
|
335
|
-
rest, tenant_id, action: 'login', collection,
|
|
336
|
-
status: 'success',
|
|
337
|
-
input: { payload: body?.payload },
|
|
338
|
-
result: { message: 'Login successful' },
|
|
339
|
-
duration: Date.now() - logStart,
|
|
340
|
-
token: { decoded: value, value: null, provided: true, expired: false },
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
return c.json({ token: authResult.token, data: authResult.data })
|
|
336
|
+
|
|
337
|
+
const { value, error } = await func.jwt.verify(authResult.token);
|
|
338
|
+
if (error) throw new AppError('Invalid token', { status: 401, code: 'INVALID_TOKEN' });
|
|
339
|
+
|
|
340
|
+
await logActivity({ rest, tenant_id, action: 'login', collection, status: 'success', input: { payload: body?.payload }, result: { message: 'Login successful' }, duration: Date.now() - logStart, token: { decoded: value, value: null, provided: true, expired: false } });
|
|
341
|
+
return c.json({ token: authResult.token, data: authResult.data });
|
|
344
342
|
} catch (err: any) {
|
|
345
|
-
await logActivity({
|
|
346
|
-
|
|
347
|
-
status: 'error',
|
|
348
|
-
input: { payload: body?.payload },
|
|
349
|
-
error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' },
|
|
350
|
-
duration: Date.now() - logStart,
|
|
351
|
-
}).catch(() => {})
|
|
352
|
-
throw err
|
|
343
|
+
await logActivity({ rest, tenant_id, action: 'login', collection, status: 'error', input: { payload: body?.payload }, error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' }, duration: Date.now() - logStart }).catch(() => {});
|
|
344
|
+
throw err;
|
|
353
345
|
}
|
|
354
346
|
}
|
|
355
347
|
|
|
356
|
-
//
|
|
357
|
-
const
|
|
358
|
-
const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
|
|
359
|
-
|
|
360
|
-
// For all actions
|
|
361
|
-
if (Object.hasOwn(col?.api?.access ?? {}, '*') && !col?.api?.access?.[action]) {
|
|
362
|
-
|
|
363
|
-
if (typeof col?.api?.access?.['*'] === 'function') {
|
|
364
|
-
canAccessToAction = await col?.api?.access?.['*']({ rest: rest, error: fn.error, jwt: func.jwt, token: accessToken })
|
|
365
|
-
}
|
|
366
|
-
if (typeof col?.api?.access?.['*'] === 'boolean') {
|
|
348
|
+
// ── Access control ──
|
|
349
|
+
const access = col?.api?.access as Record<string, boolean | Function> | undefined;
|
|
367
350
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
351
|
+
if (access?.['*'] !== undefined && access?.[action] === undefined) {
|
|
352
|
+
if (typeof access['*'] === 'function') {
|
|
353
|
+
canAccessToAction = await access['*']({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
|
|
354
|
+
} else {
|
|
355
|
+
canAccessToAction = access['*'] as boolean;
|
|
372
356
|
}
|
|
357
|
+
if (canAccessToAction !== true) throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
373
358
|
}
|
|
374
359
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
canAccessToAction = col?.api?.access?.[action]
|
|
381
|
-
}
|
|
382
|
-
if (canAccessToAction !== true) {
|
|
383
|
-
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
384
|
-
}
|
|
360
|
+
if (access?.[action] !== undefined) {
|
|
361
|
+
canAccessToAction = typeof access[action] === 'function'
|
|
362
|
+
? await (access[action] as Function)({ rest, error: fn.error, jwt: func.jwt, token: accessToken })
|
|
363
|
+
: access[action] as boolean;
|
|
364
|
+
if (canAccessToAction !== true) throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
385
365
|
}
|
|
386
366
|
|
|
387
367
|
if (canAccessToAction !== true) {
|
|
388
368
|
throw new AppError('Unauthorized', { status: 401, code: 'ACCESS_DENIED_TO_PERFORM_THIS_ACTION' });
|
|
389
369
|
}
|
|
390
370
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
response = await rest.findOne(collection, body?.id, body?.params || {})
|
|
404
|
-
} else if (action == 'insertOne') {
|
|
405
|
-
/* Insert One */
|
|
406
|
-
if (col.api?.readOnlyFields?.length) {
|
|
407
|
-
body.data = func.omit(body.data, col.api?.readOnlyFields)
|
|
371
|
+
// ── Execute action ──
|
|
372
|
+
if (col.actions?.[action]) {
|
|
373
|
+
response = await col.actions[action]({ rest, data: body?.data, error: fn.error });
|
|
374
|
+
} else {
|
|
375
|
+
const handler = crudHandlers[action];
|
|
376
|
+
if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
377
|
+
|
|
378
|
+
// Strip readOnlyFields for mutations
|
|
379
|
+
if (action === 'insertOne' || action === 'insertMany') {
|
|
380
|
+
stripReadOnlyData(col, body);
|
|
381
|
+
} else if (action === 'updateOne' || action === 'updateMany' || action === 'findOneAndUpdate') {
|
|
382
|
+
stripReadOnlyUpdate(col, body);
|
|
408
383
|
}
|
|
409
|
-
response = await rest.insertOne(collection, body?.data)
|
|
410
|
-
} else if (action == 'insertMany') {
|
|
411
|
-
/* Insert Many */
|
|
412
|
-
if (col.api?.readOnlyFields?.length) {
|
|
413
|
-
body.data = func.omit(body.data, col.api?.readOnlyFields)
|
|
414
|
-
}
|
|
415
|
-
response = await rest.insertMany(collection, body?.data)
|
|
416
|
-
} else if (action == 'updateOne') {
|
|
417
|
-
/* Update One */
|
|
418
|
-
if (col.api?.readOnlyFields?.length) {
|
|
419
|
-
body.update = func.omit(body.update, col.api?.readOnlyFields)
|
|
420
|
-
}
|
|
421
|
-
response = await rest.updateOne(collection, body?.id || body?._id, body?.update || {})
|
|
422
|
-
} else if (action == 'updateMany') {
|
|
423
|
-
/* Update Many */
|
|
424
|
-
if (col.api?.readOnlyFields?.length) {
|
|
425
|
-
body.update = func.omit(body.update, col.api?.readOnlyFields)
|
|
426
|
-
}
|
|
427
|
-
response = await rest.updateMany(collection, body?.ids || body?._ids || [], body?.update || {})
|
|
428
|
-
} else if (action == 'findOneAndUpdate') {
|
|
429
|
-
/* Find One And Update */
|
|
430
|
-
if (col.api?.readOnlyFields?.length) {
|
|
431
|
-
body.update = func.omit(body.update, col.api?.readOnlyFields)
|
|
432
|
-
}
|
|
433
|
-
response = await rest.findOneAndUpdate(collection, body?.filter || {}, body?.update || {}, body?.options || {})
|
|
434
|
-
} else if (action == 'deleteOne') {
|
|
435
|
-
/* Delete One */
|
|
436
|
-
response = await rest.deleteOne(collection, body?.id || body?._id)
|
|
437
|
-
} else if (action == 'deleteMany') {
|
|
438
|
-
/* Delete Many */
|
|
439
|
-
response = await rest.deleteMany(collection, body?.ids || body?._ids || [])
|
|
440
|
-
}
|
|
441
384
|
|
|
385
|
+
response = await handler(rest, collection, body);
|
|
386
|
+
}
|
|
442
387
|
|
|
443
|
-
|
|
388
|
+
// Strip private fields from response
|
|
444
389
|
if (privateFields.length > 0) {
|
|
445
|
-
response = func.omit(response!, privateFields, fieldOrder)
|
|
390
|
+
response = func.omit(response!, privateFields, fieldOrder);
|
|
446
391
|
}
|
|
447
392
|
|
|
448
|
-
return c.json(response)
|
|
449
|
-
|
|
393
|
+
return c.json(response);
|
|
450
394
|
|
|
451
395
|
} catch (err: any) {
|
|
452
|
-
|
|
453
|
-
const isAppError = err instanceof AppError;
|
|
454
|
-
const status = isAppError ? Number(err.status) : 500;
|
|
455
|
-
return c.json({
|
|
456
|
-
message: isAppError ? err.message : 'Internal server error',
|
|
457
|
-
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
458
|
-
meta: isAppError ? err.meta : undefined,
|
|
459
|
-
}, status as any);
|
|
396
|
+
return errorResponse(c, err);
|
|
460
397
|
}
|
|
398
|
+
});
|
|
461
399
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
// Service API
|
|
400
|
+
// ─── Service API ────────────────────────────────────────────────────
|
|
466
401
|
app.post(SERVICE_PREFIX, async (c) => {
|
|
467
402
|
try {
|
|
468
403
|
const ContentType = c.req.header('Content-Type');
|
|
469
|
-
let isMultipartOrFormData = false;
|
|
470
|
-
let response: any;
|
|
471
404
|
let body: any;
|
|
472
|
-
let parseBody: any;
|
|
473
|
-
let rest: InstanceType<typeof useRest>;
|
|
474
405
|
|
|
475
|
-
// Content Type
|
|
476
|
-
if (ContentType?.includes('application/x-www-form-urlencoded') || ContentType?.includes('multipart/form-data')) {
|
|
477
|
-
isMultipartOrFormData = true;
|
|
478
|
-
try {
|
|
479
|
-
parseBody = await c.req.parseBody({ all: true });
|
|
480
|
-
} catch {
|
|
481
|
-
throw new AppError('Invalid form data', { status: 400, code: 'INVALID_FORM_DATA' });
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
406
|
if (ContentType?.includes('application/json')) {
|
|
485
|
-
|
|
486
|
-
try {
|
|
487
|
-
body = await c.req.json();
|
|
488
|
-
} catch {
|
|
407
|
+
try { body = await c.req.json(); } catch {
|
|
489
408
|
throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
|
|
490
409
|
}
|
|
410
|
+
} else if (ContentType?.includes('multipart/form-data') || ContentType?.includes('application/x-www-form-urlencoded')) {
|
|
411
|
+
try { body = await c.req.parseBody({ all: true }); } catch {
|
|
412
|
+
throw new AppError('Invalid form data', { status: 400, code: 'INVALID_FORM_DATA' });
|
|
413
|
+
}
|
|
491
414
|
}
|
|
492
415
|
|
|
493
416
|
const { service, tenant_id } = c.req.param() as { service: string, tenant_id: string };
|
|
494
|
-
if (!tenant_id) {
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
if (!service) {
|
|
498
|
-
throw new AppError('Service is required', { status: 400, code: 'SERVICE_REQUIRED' });
|
|
499
|
-
}
|
|
417
|
+
if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
|
|
418
|
+
if (!service) throw new AppError('Service is required', { status: 400, code: 'SERVICE_REQUIRED' });
|
|
500
419
|
|
|
501
|
-
|
|
420
|
+
const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === tenant_id) as Service;
|
|
502
421
|
const { action } = c.req.param() as { action: string };
|
|
503
|
-
if (!serviceInstance) {
|
|
504
|
-
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
422
|
+
if (!serviceInstance) throw new AppError('Service `' + service + '` not found', { status: 400, code: 'SERVICE_NOT_FOUND' });
|
|
423
|
+
if (!serviceInstance.enabled) throw new AppError('Service `' + service + '` is not enabled', { status: 400, code: 'SERVICE_NOT_ENABLED' });
|
|
424
|
+
if (!serviceInstance.actions?.[action]) throw new AppError('Service `' + service + '` is not defined', { status: 400, code: 'SERVICE_NOT_DEFINED' });
|
|
425
|
+
|
|
426
|
+
const rest = new useRest({ internal: false, tenant_id });
|
|
427
|
+
|
|
428
|
+
// Access control (same logic as checkFileAccess)
|
|
429
|
+
const serviceAccess = serviceInstance.api?.access as Record<string, boolean | Function> | undefined;
|
|
430
|
+
await evaluateAccess(serviceAccess as any, action, rest, action, c);
|
|
431
|
+
|
|
432
|
+
// Re-read accessToken for the action handler
|
|
433
|
+
const accessToken = getAccessToken(c);
|
|
434
|
+
|
|
435
|
+
const logStart = Date.now();
|
|
436
|
+
try {
|
|
437
|
+
const response = await serviceInstance.actions[action]({
|
|
438
|
+
data: body?.data, error: fn.error, io, jwt: func.jwt, token: accessToken, rest,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await logActivity({ rest, tenant_id, action, collection: service, status: 'success', input: body?.data, result: response, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } });
|
|
442
|
+
return c.json(response);
|
|
443
|
+
} catch (err: any) {
|
|
444
|
+
await logActivity({ rest, tenant_id, action, collection: service, status: 'error', input: body?.data, error: { message: err?.message, code: err?.code || 'INTERNAL_SERVICE_ERROR' }, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } }).catch(() => {});
|
|
445
|
+
throw err;
|
|
511
446
|
}
|
|
447
|
+
} catch (err: any) {
|
|
448
|
+
return errorResponse(c, err);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
512
451
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
452
|
+
// ─── File Upload ────────────────────────────────────────────────────
|
|
453
|
+
app.post(UPLOAD_PREFIX, async (c) => {
|
|
454
|
+
try {
|
|
455
|
+
const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
|
|
516
456
|
|
|
517
|
-
if (
|
|
518
|
-
throw new AppError('
|
|
457
|
+
if (!cfg.tenants.find(t => t.id === tenant_id)) {
|
|
458
|
+
throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
519
459
|
}
|
|
520
|
-
|
|
521
|
-
|
|
460
|
+
|
|
461
|
+
const colUpload = getFileCollection(collection, tenant_id);
|
|
462
|
+
if (!colUpload) {
|
|
463
|
+
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
522
464
|
}
|
|
465
|
+
await checkFileAccess(colUpload?.api?.access as any, 'upload', tenant_id, c);
|
|
523
466
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
467
|
+
const contentType = c.req.header('Content-Type') || '';
|
|
468
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
469
|
+
throw new AppError('Content-Type must be multipart/form-data', { code: 'INVALID_CONTENT_TYPE', status: 400 });
|
|
470
|
+
}
|
|
528
471
|
|
|
529
|
-
|
|
530
|
-
|
|
472
|
+
const formData = await c.req.parseBody();
|
|
473
|
+
const fileField = formData['file'] || formData['upload'];
|
|
474
|
+
if (!fileField || !(fileField instanceof File)) {
|
|
475
|
+
throw new AppError('No file provided. Use field name "file" or "upload".', { code: 'FILE_REQUIRED', status: 400 });
|
|
476
|
+
}
|
|
531
477
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (typeof serviceInstance?.api?.access?.['*'] === 'boolean') {
|
|
537
|
-
canAccessToService = serviceInstance.api.access['*'];
|
|
538
|
-
}
|
|
539
|
-
if (!canAccessToService) {
|
|
540
|
-
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
478
|
+
const data: Record<string, any> = {};
|
|
479
|
+
for (const [key, value] of Object.entries(formData)) {
|
|
480
|
+
if (key !== 'file' && key !== 'upload' && !(value instanceof File)) {
|
|
481
|
+
data[key] = value;
|
|
541
482
|
}
|
|
542
483
|
}
|
|
543
484
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
485
|
+
const result = await handleUpload({ collection, tenant_id, file: fileField, data });
|
|
486
|
+
return c.json(result);
|
|
487
|
+
} catch (err: any) {
|
|
488
|
+
return errorResponse(c, err);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ─── File Serve ─────────────────────────────────────────────────────
|
|
493
|
+
app.get(FILES_PREFIX, async (c) => {
|
|
494
|
+
try {
|
|
495
|
+
const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
|
|
496
|
+
|
|
497
|
+
if (!cfg.tenants.find(t => t.id === tenant_id)) {
|
|
498
|
+
throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
553
499
|
}
|
|
554
500
|
|
|
555
|
-
|
|
556
|
-
|
|
501
|
+
const colServe = getFileCollection(collection, tenant_id);
|
|
502
|
+
if (!colServe) {
|
|
503
|
+
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
557
504
|
}
|
|
505
|
+
await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
|
|
558
506
|
|
|
559
|
-
const
|
|
507
|
+
const filename = basename(c.req.param('file') as string);
|
|
508
|
+
if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
|
|
509
|
+
throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
|
|
510
|
+
}
|
|
511
|
+
const fileId = filename.replace(/\.[^.]+$/, '');
|
|
512
|
+
|
|
513
|
+
const query = c.req.query();
|
|
514
|
+
const transform = query.w || query.width || query.h || query.height || query.format
|
|
515
|
+
? {
|
|
516
|
+
width: query.w ? Number(query.w) : query.width ? Number(query.width) : undefined,
|
|
517
|
+
height: query.h ? Number(query.h) : query.height ? Number(query.height) : undefined,
|
|
518
|
+
format: (query.format as 'webp' | 'jpeg' | 'png' | 'avif') || undefined,
|
|
519
|
+
quality: query.q ? Number(query.q) : query.quality ? Number(query.quality) : undefined,
|
|
520
|
+
}
|
|
521
|
+
: undefined;
|
|
560
522
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
error: fn.error,
|
|
565
|
-
io: io,
|
|
566
|
-
jwt: func.jwt,
|
|
567
|
-
token: accessToken,
|
|
568
|
-
rest,
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
await logActivity({
|
|
572
|
-
rest, tenant_id, action, collection: service,
|
|
573
|
-
status: 'success',
|
|
574
|
-
input: body?.data,
|
|
575
|
-
result: response,
|
|
576
|
-
duration: Date.now() - logStart,
|
|
577
|
-
token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
|
|
578
|
-
})
|
|
579
|
-
|
|
580
|
-
return c.json(response)
|
|
581
|
-
} catch (err: any) {
|
|
582
|
-
await logActivity({
|
|
583
|
-
rest, tenant_id, action, collection: service,
|
|
584
|
-
status: 'error',
|
|
585
|
-
input: body?.data,
|
|
586
|
-
error: { message: err?.message, code: err?.code || 'INTERNAL_SERVICE_ERROR' },
|
|
587
|
-
duration: Date.now() - logStart,
|
|
588
|
-
token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
|
|
589
|
-
}).catch(() => {})
|
|
590
|
-
throw err
|
|
523
|
+
const result = await handleServe(tenant_id, collection, fileId, filename, transform);
|
|
524
|
+
if (!result) {
|
|
525
|
+
throw new AppError('File not found', { code: 'FILE_NOT_FOUND', status: 404 });
|
|
591
526
|
}
|
|
527
|
+
|
|
528
|
+
c.header('Content-Type', result.mimetype);
|
|
529
|
+
if (result.size) c.header('Content-Length', String(result.size));
|
|
530
|
+
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
531
|
+
return c.newResponse(result.stream);
|
|
592
532
|
} catch (err: any) {
|
|
593
|
-
|
|
594
|
-
const isAppError = err instanceof AppError;
|
|
595
|
-
const status = isAppError ? Number(err.status) : 500;
|
|
596
|
-
return c.json({
|
|
597
|
-
message: isAppError ? err.message : 'Internal server error',
|
|
598
|
-
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
599
|
-
meta: isAppError ? err.meta : undefined,
|
|
600
|
-
}, status as any);
|
|
533
|
+
return errorResponse(c, err);
|
|
601
534
|
}
|
|
602
|
-
})
|
|
603
|
-
|
|
535
|
+
});
|
|
604
536
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
537
|
+
// ─── File Delete ────────────────────────────────────────────────────
|
|
538
|
+
app.delete(FILES_PREFIX, async (c) => {
|
|
539
|
+
try {
|
|
540
|
+
const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
|
|
609
541
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
542
|
+
if (!cfg.tenants.find(t => t.id === tenant_id)) {
|
|
543
|
+
throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
544
|
+
}
|
|
613
545
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
await checkFileAccess(colUpload?.api?.access as any, 'upload', tenant_id, c);
|
|
546
|
+
const colDelete = getFileCollection(collection, tenant_id);
|
|
547
|
+
if (!colDelete) {
|
|
548
|
+
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
549
|
+
}
|
|
550
|
+
await checkFileAccess(colDelete?.api?.access as any, 'delete', tenant_id, c);
|
|
620
551
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
});
|
|
626
|
-
}
|
|
552
|
+
const fileId = c.req.param('file') as string;
|
|
553
|
+
if (!fileId || fileId.startsWith('.') || fileId.includes('..') || fileId.includes('/')) {
|
|
554
|
+
throw new AppError('Invalid file id', { status: 400, code: 'INVALID_FILE_ID' });
|
|
555
|
+
}
|
|
627
556
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
}
|
|
557
|
+
await handleDelete(tenant_id, collection, fileId);
|
|
558
|
+
return c.json({ message: 'File deleted', ok: true });
|
|
559
|
+
} catch (err: any) {
|
|
560
|
+
return errorResponse(c, err);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
635
563
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (
|
|
640
|
-
|
|
564
|
+
// ─── Public config ──────────────────────────────────────────────────
|
|
565
|
+
app.get('/_dnax/config/:tenant_id', (c) => {
|
|
566
|
+
const t = c.get('token');
|
|
567
|
+
if (!t?.value) {
|
|
568
|
+
return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
|
|
641
569
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
} catch (err: any) {
|
|
653
|
-
const isAppError = err instanceof AppError;
|
|
654
|
-
const status = isAppError ? Number(err.status) : 500;
|
|
655
|
-
return c.json({
|
|
656
|
-
message: isAppError ? err.message : 'Internal server error',
|
|
657
|
-
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
658
|
-
}, status as any);
|
|
659
|
-
}
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
// Serve files
|
|
663
|
-
app.get(FILES_PREFIX, async (c) => {
|
|
664
|
-
try {
|
|
665
|
-
const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
|
|
666
|
-
|
|
667
|
-
if (!cfg.tenants.find(t => t.id === tenant_id)) {
|
|
668
|
-
throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Check access
|
|
672
|
-
const colServe = getFileCollection(collection, tenant_id);
|
|
673
|
-
if (!colServe) {
|
|
674
|
-
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
675
|
-
}
|
|
676
|
-
await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
|
|
677
|
-
|
|
678
|
-
const filename = basename(c.req.param('file') as string);
|
|
679
|
-
if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
|
|
680
|
-
throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
|
|
681
|
-
}
|
|
682
|
-
const fileId = filename.replace(/\.[^.]+$/, ''); // remove extension to get the ID
|
|
683
|
-
|
|
684
|
-
// Parse optional transformation query params
|
|
685
|
-
const query = c.req.query();
|
|
686
|
-
const transform = query.w || query.width || query.h || query.height || query.format
|
|
687
|
-
? {
|
|
688
|
-
width: query.w ? Number(query.w) : query.width ? Number(query.width) : undefined,
|
|
689
|
-
height: query.h ? Number(query.h) : query.height ? Number(query.height) : undefined,
|
|
690
|
-
format: (query.format as 'webp' | 'jpeg' | 'png' | 'avif') || undefined,
|
|
691
|
-
quality: query.q ? Number(query.q) : query.quality ? Number(query.quality) : undefined,
|
|
692
|
-
}
|
|
693
|
-
: undefined;
|
|
694
|
-
|
|
695
|
-
const result = await handleServe(tenant_id, collection, fileId, filename, transform);
|
|
696
|
-
if (!result) {
|
|
697
|
-
throw new AppError('File not found', { code: 'FILE_NOT_FOUND', status: 404 });
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
c.header('Content-Type', result.mimetype);
|
|
701
|
-
if (result.size) c.header('Content-Length', String(result.size));
|
|
702
|
-
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
703
|
-
|
|
704
|
-
return c.newResponse(result.stream);
|
|
705
|
-
} catch (err: any) {
|
|
706
|
-
const isAppError = err instanceof AppError;
|
|
707
|
-
const status = isAppError ? Number(err.status) : 500;
|
|
708
|
-
return c.json({
|
|
709
|
-
message: isAppError ? err.message : 'Internal server error',
|
|
710
|
-
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
711
|
-
}, status as any);
|
|
712
|
-
}
|
|
713
|
-
})
|
|
714
|
-
|
|
715
|
-
// Delete file
|
|
716
|
-
app.delete(FILES_PREFIX, async (c) => {
|
|
717
|
-
try {
|
|
718
|
-
const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
|
|
719
|
-
|
|
720
|
-
if (!cfg.tenants.find(t => t.id === tenant_id)) {
|
|
721
|
-
throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Check access
|
|
725
|
-
const colDelete = getFileCollection(collection, tenant_id);
|
|
726
|
-
if (!colDelete) {
|
|
727
|
-
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
728
|
-
}
|
|
729
|
-
await checkFileAccess(colDelete?.api?.access as any, 'delete', tenant_id, c);
|
|
730
|
-
|
|
731
|
-
const fileId = c.req.param('file') as string;
|
|
732
|
-
if (!fileId || fileId.startsWith('.') || fileId.includes('..') || fileId.includes('/')) {
|
|
733
|
-
throw new AppError('Invalid file id', { status: 400, code: 'INVALID_FILE_ID' });
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
await handleDelete(tenant_id, collection, fileId);
|
|
737
|
-
|
|
738
|
-
return c.json({ message: 'File deleted', ok: true });
|
|
739
|
-
} catch (err: any) {
|
|
740
|
-
const isAppError = err instanceof AppError;
|
|
741
|
-
const status = isAppError ? Number(err.status) : 500;
|
|
742
|
-
return c.json({
|
|
743
|
-
message: isAppError ? err.message : 'Internal server error',
|
|
744
|
-
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
745
|
-
}, status as any);
|
|
746
|
-
}
|
|
747
|
-
})
|
|
748
|
-
|
|
749
|
-
// Public config endpoint — filtered by tenant (auth required)
|
|
750
|
-
app.get('/_dnax/config/:tenant_id', (c) => {
|
|
751
|
-
const t = c.get('token');
|
|
752
|
-
if (!t?.value) {
|
|
753
|
-
return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
|
|
754
|
-
}
|
|
755
|
-
const { tenant_id } = c.req.param() as { tenant_id: string };
|
|
756
|
-
const config = safePublicConfig();
|
|
757
|
-
|
|
758
|
-
return c.json({
|
|
759
|
-
tenants: config?.tenants?.filter(t => t.id === tenant_id),
|
|
760
|
-
collections: config?.collections?.filter(c => c._tenant_ === tenant_id),
|
|
761
|
-
services: config?.services?.filter(s => s._tenant_ === tenant_id),
|
|
762
|
-
fileCollections: config?.fileCollections?.filter(fc => fc._tenant_ === tenant_id),
|
|
763
|
-
});
|
|
764
|
-
})
|
|
570
|
+
const { tenant_id } = c.req.param() as { tenant_id: string };
|
|
571
|
+
const config = safePublicConfig();
|
|
572
|
+
|
|
573
|
+
return c.json({
|
|
574
|
+
tenants: config?.tenants?.filter(t => t.id === tenant_id),
|
|
575
|
+
collections: config?.collections?.filter(c => c._tenant_ === tenant_id),
|
|
576
|
+
services: config?.services?.filter(s => s._tenant_ === tenant_id),
|
|
577
|
+
fileCollections: config?.fileCollections?.filter(fc => fc._tenant_ === tenant_id),
|
|
578
|
+
});
|
|
579
|
+
});
|
|
765
580
|
|
|
766
581
|
}
|
|
767
582
|
|
|
768
|
-
|
|
769
583
|
export {
|
|
770
584
|
initializeApi
|
|
771
585
|
}
|