@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/lib/define.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ServerConfig } from "../types/config";
|
|
2
|
+
import type { Collection } from "../types/collection";
|
|
3
|
+
import type { FileCollection } from "../types/file";
|
|
4
|
+
import type { Script } from "../types/scripts";
|
|
5
|
+
import type { Route } from "../types/route";
|
|
6
|
+
import type { Service } from "../types/service";
|
|
7
|
+
import type { WebSocketHandler } from "../types/websocket";
|
|
8
|
+
import type { TenantMiddlewareConfig, GlobalMiddlewareConfig } from "../types/middleware";
|
|
9
|
+
import { file } from "bun";
|
|
10
|
+
function Server(config: ServerConfig) {
|
|
11
|
+
config.clusterMode = config?.clusterMode ?? true;
|
|
12
|
+
config.server = {
|
|
13
|
+
...config?.server,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return config;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
function Collection(collection: Collection): Collection {
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
collection.type = collection.type ?? 'document';
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
...collection,
|
|
29
|
+
_isTimeSerie_: false,
|
|
30
|
+
_isCollection_: true,
|
|
31
|
+
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function FileCollection(collection: FileCollection): FileCollection {
|
|
36
|
+
return {
|
|
37
|
+
...collection,
|
|
38
|
+
_isTimeSerie_: false,
|
|
39
|
+
_isFileCollection_: true,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Script(config: Script) {
|
|
44
|
+
return {
|
|
45
|
+
...config,
|
|
46
|
+
_isScript_: true,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
function Route(route: Route): Route {
|
|
52
|
+
return {
|
|
53
|
+
...route,
|
|
54
|
+
enabled: route.enabled ?? true,
|
|
55
|
+
_isRoute_: true,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Service(service: Service): Service {
|
|
60
|
+
return {
|
|
61
|
+
...service,
|
|
62
|
+
_isService_: true,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
function TenantMiddleware(config: TenantMiddlewareConfig): TenantMiddlewareConfig {
|
|
68
|
+
return {
|
|
69
|
+
...config,
|
|
70
|
+
enabled: config.enabled ?? true,
|
|
71
|
+
_isTenantMiddleware_: true,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function WebSocket(handler: WebSocketHandler): WebSocketHandler {
|
|
76
|
+
return {
|
|
77
|
+
...handler,
|
|
78
|
+
enabled: handler.enabled ?? true,
|
|
79
|
+
_isWebSocket_: true,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function Middleware(config: GlobalMiddlewareConfig): GlobalMiddlewareConfig {
|
|
84
|
+
return {
|
|
85
|
+
...config,
|
|
86
|
+
enabled: config.enabled ?? true,
|
|
87
|
+
_isGlobalMiddleware_: true,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
import type { WorkflowDefinition } from "../types/workflow";
|
|
92
|
+
|
|
93
|
+
export function Workflow<TData = any>(workflow: WorkflowDefinition<TData>): WorkflowDefinition<TData> {
|
|
94
|
+
return {
|
|
95
|
+
...workflow,
|
|
96
|
+
_isWorkflow_: true,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
export const define = {
|
|
103
|
+
Server: Server,
|
|
104
|
+
App: Server,
|
|
105
|
+
Collection,
|
|
106
|
+
FileCollection,
|
|
107
|
+
Script,
|
|
108
|
+
Route,
|
|
109
|
+
Service,
|
|
110
|
+
Workflow,
|
|
111
|
+
TenantMiddleware,
|
|
112
|
+
Middleware,
|
|
113
|
+
WebSocket
|
|
114
|
+
}
|
package/lib/error.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class AppError extends Error {
|
|
2
|
+
code: number | string | undefined | null;
|
|
3
|
+
status: number | string;
|
|
4
|
+
reason: any;
|
|
5
|
+
meta?: { [key: string]: any };
|
|
6
|
+
constructor(message: string, options: { status?: number | string | undefined, reason?: any, codeName?: any, code?: number | string } = {}, meta?: { [key: string]: any }) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = options?.status ?? 500;
|
|
9
|
+
this.reason = options?.reason;
|
|
10
|
+
this.code = options?.code;
|
|
11
|
+
this.meta = meta || {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const fn = {
|
|
16
|
+
error: (message: string, options: { status?: number | string | undefined, reason?: any, codeName?: any, code?: number | string } = {}, meta?: { [key: string]: any }) => {
|
|
17
|
+
throw new AppError(message, options, meta);
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { AppError, fn }
|
package/lib/files.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { getFileCollection } from "../database/file";
|
|
2
|
+
import { useRest } from "../database/rest";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import { AppError } from "./error";
|
|
5
|
+
import type { FileCollection } from "../types/file";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import fs from "fs/promises";
|
|
8
|
+
import { createReadStream, existsSync, mkdirSync } from "fs";
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
|
|
11
|
+
// ─── Storage Interface ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type FileResult = {
|
|
14
|
+
_id: string;
|
|
15
|
+
_file: {
|
|
16
|
+
filename: string;
|
|
17
|
+
name: string;
|
|
18
|
+
mimetype: string;
|
|
19
|
+
size: number;
|
|
20
|
+
url: string;
|
|
21
|
+
};
|
|
22
|
+
metadata?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type UploadOptions = {
|
|
26
|
+
collection: string;
|
|
27
|
+
tenant_id: string;
|
|
28
|
+
file: File;
|
|
29
|
+
data?: Record<string, any>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type FileStorage = {
|
|
33
|
+
save(tenant_id: string, collection: string, file: File, meta: {
|
|
34
|
+
id: string;
|
|
35
|
+
mimetype: string;
|
|
36
|
+
subpath?: string;
|
|
37
|
+
}): Promise<{ path: string; size: number }>;
|
|
38
|
+
getStream(tenant_id: string, collection: string, fileId: string, filename: string, subpath?: string): Promise<ReadableStream | null>;
|
|
39
|
+
delete(tenant_id: string, collection: string, fileId: string, filename: string, subpath?: string): Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ─── Disk Storage ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export function createDiskStorage(baseDir?: string): FileStorage {
|
|
45
|
+
const root = baseDir ?? path.join(process.cwd(), 'storage');
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async save(tenant_id, collection, file, meta) {
|
|
49
|
+
const { subpath } = meta;
|
|
50
|
+
const dir = subpath ? path.join(root, subpath) : path.join(root, tenant_id, collection);
|
|
51
|
+
await fs.mkdir(dir, { recursive: true });
|
|
52
|
+
const ext = path.extname(file.name) || '';
|
|
53
|
+
const filename = `${meta.id}${ext}`;
|
|
54
|
+
const filepath = path.join(dir, filename);
|
|
55
|
+
const buffer = await file.arrayBuffer();
|
|
56
|
+
await fs.writeFile(filepath, new Uint8Array(buffer));
|
|
57
|
+
const outPath = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
58
|
+
return { path: outPath, size: buffer.byteLength };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async getStream(tenant_id, collection, fileId, filename, subpath) {
|
|
62
|
+
const filepath = subpath ? path.join(root, subpath, filename) : path.join(root, tenant_id, collection, filename);
|
|
63
|
+
if (!existsSync(filepath)) return null;
|
|
64
|
+
const file = Bun.file(filepath);
|
|
65
|
+
return file.stream();
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async delete(tenant_id, collection, fileId, filename, subpath) {
|
|
69
|
+
const filepath = subpath ? path.join(root, subpath, filename) : path.join(root, tenant_id, collection, filename);
|
|
70
|
+
await fs.unlink(filepath).catch(() => {});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── S3 Storage ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export type S3Config = {
|
|
78
|
+
region: string;
|
|
79
|
+
bucket: string;
|
|
80
|
+
accessKeyId: string;
|
|
81
|
+
secretAccessKey: string;
|
|
82
|
+
endpoint?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function createS3Storage(config: S3Config): FileStorage {
|
|
86
|
+
return {
|
|
87
|
+
async save(tenant_id, collection, file, meta) {
|
|
88
|
+
const { subpath } = meta;
|
|
89
|
+
const ext = path.extname(file.name) || '';
|
|
90
|
+
const filename = `${meta.id}${ext}`;
|
|
91
|
+
const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
92
|
+
const buffer = await file.arrayBuffer();
|
|
93
|
+
// S3 upload via fetch (AWS S3 REST API)
|
|
94
|
+
const url = config.endpoint
|
|
95
|
+
? `${config.endpoint}/${config.bucket}/${key}`
|
|
96
|
+
: `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
97
|
+
const response = await fetch(url, {
|
|
98
|
+
method: 'PUT',
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': meta.mimetype,
|
|
101
|
+
'Content-Length': String(buffer.byteLength),
|
|
102
|
+
},
|
|
103
|
+
body: buffer,
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new AppError(`S3 upload failed: ${response.statusText}`, {
|
|
107
|
+
code: 'S3_UPLOAD_ERROR', status: 500,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return { path: key, size: buffer.byteLength };
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async getStream(tenant_id, collection, fileId, filename, subpath) {
|
|
114
|
+
const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
115
|
+
const url = config.endpoint
|
|
116
|
+
? `${config.endpoint}/${config.bucket}/${key}`
|
|
117
|
+
: `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
118
|
+
const response = await fetch(url);
|
|
119
|
+
if (!response.ok) return null;
|
|
120
|
+
return response.body;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async delete(tenant_id, collection, fileId, filename, subpath) {
|
|
124
|
+
const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
125
|
+
const url = config.endpoint
|
|
126
|
+
? `${config.endpoint}/${config.bucket}/${key}`
|
|
127
|
+
: `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
128
|
+
await fetch(url, { method: 'DELETE' });
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Image Transformations ─────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export type TransformOptions = {
|
|
136
|
+
width?: number;
|
|
137
|
+
height?: number;
|
|
138
|
+
format?: 'webp' | 'jpeg' | 'png' | 'avif';
|
|
139
|
+
quality?: number;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
async function transformImage(
|
|
143
|
+
input: Uint8Array | ReadableStream,
|
|
144
|
+
options: TransformOptions,
|
|
145
|
+
): Promise<{ data: Uint8Array; mimetype: string }> {
|
|
146
|
+
const format = options.format || 'webp';
|
|
147
|
+
const quality = options.quality ?? 80;
|
|
148
|
+
|
|
149
|
+
// Convertir ReadableStream en Uint8Array si nécessaire
|
|
150
|
+
let buffer: Uint8Array;
|
|
151
|
+
if (input instanceof Uint8Array) {
|
|
152
|
+
buffer = input;
|
|
153
|
+
} else {
|
|
154
|
+
const chunks: Uint8Array[] = [];
|
|
155
|
+
const reader = input.getReader();
|
|
156
|
+
while (true) {
|
|
157
|
+
const { done, value } = await reader.read();
|
|
158
|
+
if (done) break;
|
|
159
|
+
chunks.push(value!);
|
|
160
|
+
}
|
|
161
|
+
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
|
162
|
+
buffer = new Uint8Array(totalLength);
|
|
163
|
+
let offset = 0;
|
|
164
|
+
for (const chunk of chunks) {
|
|
165
|
+
buffer.set(chunk, offset);
|
|
166
|
+
offset += chunk.length;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Utiliser Bun.Image (natif, zéro dépendance)
|
|
171
|
+
const img = new Bun.Image(buffer);
|
|
172
|
+
|
|
173
|
+
if (options.width || options.height) {
|
|
174
|
+
img.resize(options.width ?? 0, options.height ?? 0, {
|
|
175
|
+
fit: 'inside',
|
|
176
|
+
withoutEnlargement: true,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Encoder dans le format demandé
|
|
181
|
+
let out: Blob;
|
|
182
|
+
switch (format) {
|
|
183
|
+
case 'jpeg':
|
|
184
|
+
out = await img.jpeg({ quality }).blob();
|
|
185
|
+
break;
|
|
186
|
+
case 'png':
|
|
187
|
+
out = await img.png().blob();
|
|
188
|
+
break;
|
|
189
|
+
case 'avif':
|
|
190
|
+
out = await img.avif({ quality }).blob();
|
|
191
|
+
break;
|
|
192
|
+
default: // webp
|
|
193
|
+
out = await img.webp({ quality }).blob();
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const data = new Uint8Array(await out.arrayBuffer());
|
|
198
|
+
return { data, mimetype: `image/${format}` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── File Service ──────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
let _storage: FileStorage | null = null;
|
|
204
|
+
|
|
205
|
+
function resolveStorage(): FileStorage {
|
|
206
|
+
if (_storage) return _storage;
|
|
207
|
+
_storage = createDiskStorage();
|
|
208
|
+
return _storage;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve the storage backend for a given file collection.
|
|
213
|
+
* Falls back to disk storage if the collection or driver is not configured.
|
|
214
|
+
*/
|
|
215
|
+
export function getStorageForCollection(slug: string, tenant_id: string): FileStorage {
|
|
216
|
+
const col = getFileCollection(slug, tenant_id);
|
|
217
|
+
if (!col?.storage) return resolveStorage();
|
|
218
|
+
const storage = col.storage;
|
|
219
|
+
if (storage.driver === 's3') {
|
|
220
|
+
return createS3Storage({
|
|
221
|
+
region: storage.region || process.env.AWS_REGION || 'us-east-1',
|
|
222
|
+
bucket: storage.bucket || process.env.AWS_BUCKET || '',
|
|
223
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
|
224
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
|
225
|
+
endpoint: process.env.S3_ENDPOINT,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return resolveStorage();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Handle a file upload for a given collection.
|
|
233
|
+
*/
|
|
234
|
+
export async function handleUpload(options: UploadOptions): Promise<FileResult> {
|
|
235
|
+
const { collection, tenant_id, file, data } = options;
|
|
236
|
+
|
|
237
|
+
const col = getFileCollection(collection, tenant_id);
|
|
238
|
+
if (!col) {
|
|
239
|
+
throw new AppError(`File collection '${collection}' not found`, {
|
|
240
|
+
code: 'FILE_COLLECTION_NOT_FOUND', status: 404,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Validate MIME type
|
|
245
|
+
const mimetype = file.type || 'application/octet-stream';
|
|
246
|
+
if (col.upload?.allowedMimeTypes?.length && !col.upload.allowedMimeTypes.includes(mimetype)) {
|
|
247
|
+
throw new AppError(`MIME type '${mimetype}' not allowed`, {
|
|
248
|
+
code: 'MIMETYPE_NOT_ALLOWED', status: 400,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Validate file size
|
|
253
|
+
const maxSize = col.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
|
|
254
|
+
if (file.size > maxSize) {
|
|
255
|
+
throw new AppError(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`, {
|
|
256
|
+
code: 'FILE_TOO_LARGE', status: 413,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const id = crypto.randomUUID();
|
|
261
|
+
const storage = getStorageForCollection(collection, tenant_id);
|
|
262
|
+
const subpath = col.storage?.path || undefined;
|
|
263
|
+
const { path: filepath, size } = await storage.save(tenant_id, collection, file, { id, mimetype, subpath });
|
|
264
|
+
|
|
265
|
+
const ext = path.extname(file.name);
|
|
266
|
+
const filename = `${id}${ext}`;
|
|
267
|
+
|
|
268
|
+
// Enregistrer les métadonnées en base si trackMetaData est activé
|
|
269
|
+
if (col.trackMetaData !== false) {
|
|
270
|
+
try {
|
|
271
|
+
const rest = new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false });
|
|
272
|
+
await rest.insertOne(collection, {
|
|
273
|
+
_id: id,
|
|
274
|
+
_file: {
|
|
275
|
+
filename,
|
|
276
|
+
name: file.name,
|
|
277
|
+
mimetype,
|
|
278
|
+
size,
|
|
279
|
+
url: `/files/${tenant_id}/${collection}/${filename}`,
|
|
280
|
+
},
|
|
281
|
+
...(data || {}),
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
await storage.delete(tenant_id, collection, id, filename).catch(() => {});
|
|
285
|
+
throw new AppError('Failed to save file metadata', {
|
|
286
|
+
code: 'FILE_METADATA_ERROR', status: 500,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Réplication vers les destinations configurées
|
|
292
|
+
if (col.replicate?.length) {
|
|
293
|
+
replicateFile(filepath, filename, col.replicate).catch(err => {
|
|
294
|
+
console.error('Replication failed:', err?.message || err);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
_id: id,
|
|
300
|
+
_file: {
|
|
301
|
+
filename,
|
|
302
|
+
name: file.name,
|
|
303
|
+
mimetype,
|
|
304
|
+
size,
|
|
305
|
+
url: `/files/${tenant_id}/${collection}/${filename}`,
|
|
306
|
+
},
|
|
307
|
+
...(data || {}),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Serve a file with optional transformations.
|
|
313
|
+
*/
|
|
314
|
+
export async function handleServe(
|
|
315
|
+
tenant_id: string,
|
|
316
|
+
collection: string,
|
|
317
|
+
fileId: string,
|
|
318
|
+
filename: string,
|
|
319
|
+
transform?: TransformOptions,
|
|
320
|
+
): Promise<{ stream: ReadableStream; mimetype: string; size?: number } | null> {
|
|
321
|
+
const storage = getStorageForCollection(collection, tenant_id);
|
|
322
|
+
const col = getFileCollection(collection, tenant_id);
|
|
323
|
+
const subpath = col?.storage?.path || undefined;
|
|
324
|
+
const stream = await storage.getStream(tenant_id, collection, fileId, filename, subpath);
|
|
325
|
+
if (!stream) return null;
|
|
326
|
+
|
|
327
|
+
let mimetype = getMimeType(filename);
|
|
328
|
+
|
|
329
|
+
if (transform && (transform.width || transform.height || transform.format)) {
|
|
330
|
+
const { data, mimetype: newMime } = await transformImage(stream, transform);
|
|
331
|
+
return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { stream, mimetype };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getMimeType(filename: string): string {
|
|
338
|
+
const ext = path.extname(filename).toLowerCase();
|
|
339
|
+
const map: Record<string, string> = {
|
|
340
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
341
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif',
|
|
342
|
+
'.svg': 'image/svg+xml', '.pdf': 'application/pdf',
|
|
343
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
344
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
345
|
+
'.json': 'application/json', '.csv': 'text/csv',
|
|
346
|
+
'.zip': 'application/zip',
|
|
347
|
+
};
|
|
348
|
+
return map[ext] || 'application/octet-stream';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function handleDelete(
|
|
352
|
+
tenant_id: string,
|
|
353
|
+
collection: string,
|
|
354
|
+
fileId: string,
|
|
355
|
+
filename: string,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
const storage = getStorageForCollection(collection, tenant_id);
|
|
358
|
+
const col = getFileCollection(collection, tenant_id);
|
|
359
|
+
const subpath = col?.storage?.path || undefined;
|
|
360
|
+
await storage.delete(tenant_id, collection, fileId, filename, subpath);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ─── Replication ──────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
type ReplicateTarget = NonNullable<NonNullable<ReturnType<typeof getFileCollection>>['replicate']>[number];
|
|
366
|
+
|
|
367
|
+
async function replicateToS3(target: ReplicateTarget, sourcePath: string, filename: string): Promise<void> {
|
|
368
|
+
const storage = createS3Storage({
|
|
369
|
+
region: target.region || process.env.AWS_REGION || 'us-east-1',
|
|
370
|
+
bucket: target.bucket || '',
|
|
371
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
|
372
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
|
373
|
+
endpoint: target.endpoint || process.env.S3_ENDPOINT,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const localPath = path.join(process.cwd(), sourcePath);
|
|
377
|
+
const file = Bun.file(localPath);
|
|
378
|
+
const exists = await file.exists();
|
|
379
|
+
if (!exists) return;
|
|
380
|
+
|
|
381
|
+
const buffer = await file.arrayBuffer();
|
|
382
|
+
const fakeFile = new File([buffer], filename, { type: 'application/octet-stream' });
|
|
383
|
+
const key = target.path ? `${target.path}/${filename}` : filename;
|
|
384
|
+
await storage.save('replicate', 'replicate', fakeFile, { id: filename.replace(/\.[^.]+$/, ''), mimetype: 'application/octet-stream', subpath: target.path });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function replicateToSSH(target: ReplicateTarget, sourcePath: string, filename: string): Promise<void> {
|
|
388
|
+
const host = target.host || 'localhost';
|
|
389
|
+
const port = target.port || 22;
|
|
390
|
+
const username = target.username || process.env.USER || 'root';
|
|
391
|
+
const remotePath = target.path ? `${target.path}/${filename}` : filename;
|
|
392
|
+
const localPath = path.join(process.cwd(), sourcePath);
|
|
393
|
+
|
|
394
|
+
const args = [
|
|
395
|
+
'scp', '-P', String(port),
|
|
396
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
397
|
+
'-o', 'ConnectTimeout=10',
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
// Add private key if provided
|
|
401
|
+
if (target.privateKey) {
|
|
402
|
+
args.push('-i', target.privateKey);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
args.push(localPath, `${username}@${host}:${remotePath}`);
|
|
406
|
+
|
|
407
|
+
const result = await Bun.spawn(args);
|
|
408
|
+
await result.exited;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function replicateToSFTP(target: ReplicateTarget, sourcePath: string, filename: string): Promise<void> {
|
|
412
|
+
const host = target.host || 'localhost';
|
|
413
|
+
const port = target.port || 22;
|
|
414
|
+
const username = target.username || process.env.USER || 'root';
|
|
415
|
+
const remotePath = target.path || '';
|
|
416
|
+
const localPath = path.join(process.cwd(), sourcePath);
|
|
417
|
+
|
|
418
|
+
const args = [
|
|
419
|
+
'sftp', '-P', String(port),
|
|
420
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
421
|
+
'-o', 'ConnectTimeout=10',
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
if (target.privateKey) {
|
|
425
|
+
args.push('-i', target.privateKey);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
args.push(`${username}@${host}`);
|
|
429
|
+
|
|
430
|
+
const result = await Bun.spawn(args, {
|
|
431
|
+
stdin: Buffer.from(`put "${localPath}" "${remotePath}/${filename}"\nquit\n`),
|
|
432
|
+
});
|
|
433
|
+
await result.exited;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function replicateFile(sourcePath: string, filename: string, targets: ReplicateTarget[]): Promise<void> {
|
|
437
|
+
const results = await Promise.allSettled(
|
|
438
|
+
targets.map(async (target) => {
|
|
439
|
+
switch (target.driver) {
|
|
440
|
+
case 's3':
|
|
441
|
+
await replicateToS3(target, sourcePath, filename);
|
|
442
|
+
break;
|
|
443
|
+
case 'ssh':
|
|
444
|
+
await replicateToSSH(target, sourcePath, filename);
|
|
445
|
+
break;
|
|
446
|
+
case 'sftp':
|
|
447
|
+
await replicateToSFTP(target, sourcePath, filename);
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Log failures without throwing
|
|
454
|
+
for (const result of results) {
|
|
455
|
+
if (result.status === 'rejected') {
|
|
456
|
+
console.error('Replication failed:', result.reason?.message || result.reason);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Glob } from "bun"
|
|
2
|
+
import { cfg } from "../server/config"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import fs from "fs/promises"
|
|
5
|
+
import type { Context, Next } from "hono"
|
|
6
|
+
import type { TenantMiddlewareConfig, GlobalMiddlewareConfig } from "../types/middleware"
|
|
7
|
+
|
|
8
|
+
const globalMiddlewares: GlobalMiddlewareConfig[] = [];
|
|
9
|
+
const tenantMiddlewares: TenantMiddlewareConfig[] = [];
|
|
10
|
+
|
|
11
|
+
async function loadTenantsMiddlewares() {
|
|
12
|
+
try {
|
|
13
|
+
globalMiddlewares.length = 0;
|
|
14
|
+
tenantMiddlewares.length = 0;
|
|
15
|
+
|
|
16
|
+
// Global middlewares (loaded from project root `middlewares/`)
|
|
17
|
+
const GLOBAL_PATH = path.join(process.cwd(), 'middlewares')
|
|
18
|
+
const globalExists = await fs.exists(GLOBAL_PATH)
|
|
19
|
+
if (globalExists && (await (await fs.stat(GLOBAL_PATH)).isDirectory())) {
|
|
20
|
+
const globalGlob = new Glob(path.join(GLOBAL_PATH, '**/*.middleware.ts'))
|
|
21
|
+
for await (let file of globalGlob.scan('.')) {
|
|
22
|
+
let module = await import(file)
|
|
23
|
+
if (module?.default?._isGlobalMiddleware_ && module.default.enabled !== false) {
|
|
24
|
+
globalMiddlewares.push({
|
|
25
|
+
...module.default,
|
|
26
|
+
name: module.default.name || path.basename(file, '.middleware.ts'),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Tenant-scoped middlewares (loaded from each tenant's `middlewares/`)
|
|
33
|
+
for (let tenant of cfg.tenants ?? []) {
|
|
34
|
+
const TENANT_PATH = path.join(process.cwd(), tenant.dir)
|
|
35
|
+
const MIDDLEWARES_PATH = path.join(TENANT_PATH, 'middlewares')
|
|
36
|
+
let exist = await fs.exists(MIDDLEWARES_PATH)
|
|
37
|
+
if (!exist) continue;
|
|
38
|
+
const isDirectory = await (await fs.stat(MIDDLEWARES_PATH)).isDirectory()
|
|
39
|
+
if (!isDirectory) continue;
|
|
40
|
+
|
|
41
|
+
const glob = new Glob(path.join(MIDDLEWARES_PATH, '**/*.middleware.ts'))
|
|
42
|
+
for await (let file of glob.scan('.')) {
|
|
43
|
+
let module = await import(file)
|
|
44
|
+
if (module?.default?._isTenantMiddleware_ && module.default.enabled !== false) {
|
|
45
|
+
tenantMiddlewares.push({
|
|
46
|
+
...module.default,
|
|
47
|
+
name: module.default.name || path.basename(file, '.middleware.ts'),
|
|
48
|
+
_tenant_: tenant.id,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
console.error('Failed to load tenant middlewares:', err?.message)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getGlobalMiddlewares(): GlobalMiddlewareConfig[] {
|
|
59
|
+
return globalMiddlewares;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getTenantMiddlewares(): TenantMiddlewareConfig[] {
|
|
63
|
+
return tenantMiddlewares;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { loadTenantsMiddlewares, getGlobalMiddlewares, getTenantMiddlewares }
|
package/lib/routes.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Glob } from "bun"
|
|
2
|
+
import { cfg } from "../server/config"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import fs from "fs/promises"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async function loadRoutes() {
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
let routes = []
|
|
11
|
+
for (let tenant of cfg.tenants ?? []) {
|
|
12
|
+
const TENANT_PATH = path.join(process.cwd(), tenant.dir)
|
|
13
|
+
const ROUTES_PATH = path.join(TENANT_PATH, 'routes')
|
|
14
|
+
let exist = await fs.exists(ROUTES_PATH)
|
|
15
|
+
if (!exist) continue;
|
|
16
|
+
const isDirectory = await (await fs.stat(ROUTES_PATH)).isDirectory()
|
|
17
|
+
if (isDirectory && tenant.routes?.prefix) {
|
|
18
|
+
const glob = new Glob(path.join(ROUTES_PATH, '**/*.route.ts'))
|
|
19
|
+
for await (let file of glob.scan('.')) {
|
|
20
|
+
let routeModule = await import(file)
|
|
21
|
+
if (routeModule?.default?._isRoute_ && routeModule?.default?.path && routeModule?.default?.method && routeModule?.default?.enabled) {
|
|
22
|
+
routes.push({
|
|
23
|
+
...routeModule?.default,
|
|
24
|
+
prefix: tenant.routes?.prefix,
|
|
25
|
+
_tenant_: tenant.id,
|
|
26
|
+
_prefix_: tenant.routes?.prefix,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
cfg.routes = routes
|
|
34
|
+
|
|
35
|
+
} catch (err: any) {
|
|
36
|
+
console.error(err?.message)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
loadRoutes
|
|
44
|
+
}
|