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