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