@objectql/server 1.8.0 → 1.8.2
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/CHANGELOG.md +18 -0
- package/dist/adapters/node.d.ts +9 -1
- package/dist/adapters/node.js +30 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/file-handler.d.ts +50 -0
- package/dist/file-handler.js +354 -0
- package/dist/file-handler.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/storage.d.ts +35 -0
- package/dist/storage.js +181 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +72 -0
- package/package.json +16 -4
- package/src/adapters/node.ts +44 -2
- package/src/file-handler.ts +414 -0
- package/src/index.ts +2 -0
- package/src/storage.ts +171 -0
- package/src/types.ts +79 -0
- package/test/file-upload-integration.example.ts +201 -0
- package/test/file-validation.test.ts +131 -0
- package/test/storage.test.ts +104 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'http';
|
|
2
|
+
import { IFileStorage, AttachmentData, ErrorCode } from './types';
|
|
3
|
+
import { IObjectQL, FieldConfig } from '@objectql/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse multipart/form-data request
|
|
7
|
+
*/
|
|
8
|
+
export function parseMultipart(
|
|
9
|
+
req: IncomingMessage,
|
|
10
|
+
boundary: string
|
|
11
|
+
): Promise<{ fields: Record<string, string>; files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> }> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const chunks: Buffer[] = [];
|
|
14
|
+
|
|
15
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
16
|
+
req.on('error', reject);
|
|
17
|
+
req.on('end', () => {
|
|
18
|
+
try {
|
|
19
|
+
const buffer = Buffer.concat(chunks);
|
|
20
|
+
const result = parseMultipartBuffer(buffer, boundary);
|
|
21
|
+
resolve(result);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
reject(error);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseMultipartBuffer(
|
|
30
|
+
buffer: Buffer,
|
|
31
|
+
boundary: string
|
|
32
|
+
): { fields: Record<string, string>; files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> } {
|
|
33
|
+
const fields: Record<string, string> = {};
|
|
34
|
+
const files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> = [];
|
|
35
|
+
|
|
36
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
37
|
+
const parts = splitBuffer(buffer, delimiter);
|
|
38
|
+
|
|
39
|
+
for (const part of parts) {
|
|
40
|
+
if (part.length === 0 || part.toString().trim() === '--') {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Find header/body separator (double CRLF)
|
|
45
|
+
const headerEnd = findSequence(part, Buffer.from('\r\n\r\n'));
|
|
46
|
+
if (headerEnd === -1) continue;
|
|
47
|
+
|
|
48
|
+
const headerSection = part.slice(0, headerEnd).toString();
|
|
49
|
+
const bodySection = part.slice(headerEnd + 4);
|
|
50
|
+
|
|
51
|
+
// Parse Content-Disposition header
|
|
52
|
+
const dispositionMatch = headerSection.match(/Content-Disposition: form-data; name="([^"]+)"(?:; filename="([^"]+)")?/i);
|
|
53
|
+
if (!dispositionMatch) continue;
|
|
54
|
+
|
|
55
|
+
const fieldname = dispositionMatch[1];
|
|
56
|
+
const filename = dispositionMatch[2];
|
|
57
|
+
|
|
58
|
+
if (filename) {
|
|
59
|
+
// This is a file upload
|
|
60
|
+
const contentTypeMatch = headerSection.match(/Content-Type: (.+)/i);
|
|
61
|
+
const mimeType = contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream';
|
|
62
|
+
|
|
63
|
+
// Remove trailing CRLF from body
|
|
64
|
+
let fileBuffer = bodySection;
|
|
65
|
+
if (fileBuffer.length >= 2 && fileBuffer[fileBuffer.length - 2] === 0x0d && fileBuffer[fileBuffer.length - 1] === 0x0a) {
|
|
66
|
+
fileBuffer = fileBuffer.slice(0, -2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
files.push({ fieldname, filename, mimeType, buffer: fileBuffer });
|
|
70
|
+
} else {
|
|
71
|
+
// This is a regular form field
|
|
72
|
+
let value = bodySection.toString('utf-8');
|
|
73
|
+
if (value.endsWith('\r\n')) {
|
|
74
|
+
value = value.slice(0, -2);
|
|
75
|
+
}
|
|
76
|
+
fields[fieldname] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { fields, files };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function splitBuffer(buffer: Buffer, delimiter: Buffer): Buffer[] {
|
|
84
|
+
const parts: Buffer[] = [];
|
|
85
|
+
let start = 0;
|
|
86
|
+
let pos = 0;
|
|
87
|
+
|
|
88
|
+
while (pos <= buffer.length - delimiter.length) {
|
|
89
|
+
let match = true;
|
|
90
|
+
for (let i = 0; i < delimiter.length; i++) {
|
|
91
|
+
if (buffer[pos + i] !== delimiter[i]) {
|
|
92
|
+
match = false;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (match) {
|
|
98
|
+
if (pos > start) {
|
|
99
|
+
parts.push(buffer.slice(start, pos));
|
|
100
|
+
}
|
|
101
|
+
pos += delimiter.length;
|
|
102
|
+
start = pos;
|
|
103
|
+
} else {
|
|
104
|
+
pos++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (start < buffer.length) {
|
|
109
|
+
parts.push(buffer.slice(start));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findSequence(buffer: Buffer, sequence: Buffer): number {
|
|
116
|
+
for (let i = 0; i <= buffer.length - sequence.length; i++) {
|
|
117
|
+
let match = true;
|
|
118
|
+
for (let j = 0; j < sequence.length; j++) {
|
|
119
|
+
if (buffer[i + j] !== sequence[j]) {
|
|
120
|
+
match = false;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (match) return i;
|
|
125
|
+
}
|
|
126
|
+
return -1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate uploaded file against field configuration
|
|
131
|
+
*/
|
|
132
|
+
export function validateFile(
|
|
133
|
+
file: { filename: string; mimeType: string; buffer: Buffer },
|
|
134
|
+
fieldConfig?: FieldConfig,
|
|
135
|
+
objectName?: string,
|
|
136
|
+
fieldName?: string
|
|
137
|
+
): { valid: boolean; error?: { code: string; message: string; details?: any } } {
|
|
138
|
+
// If no field config provided, allow the upload
|
|
139
|
+
if (!fieldConfig) {
|
|
140
|
+
return { valid: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fileSize = file.buffer.length;
|
|
144
|
+
const fileName = file.filename;
|
|
145
|
+
const mimeType = file.mimeType;
|
|
146
|
+
|
|
147
|
+
// Validate file size
|
|
148
|
+
if (fieldConfig.max_size && fileSize > fieldConfig.max_size) {
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
error: {
|
|
152
|
+
code: 'FILE_TOO_LARGE',
|
|
153
|
+
message: `File size (${fileSize} bytes) exceeds maximum allowed size (${fieldConfig.max_size} bytes)`,
|
|
154
|
+
details: {
|
|
155
|
+
file: fileName,
|
|
156
|
+
size: fileSize,
|
|
157
|
+
max_size: fieldConfig.max_size
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (fieldConfig.min_size && fileSize < fieldConfig.min_size) {
|
|
164
|
+
return {
|
|
165
|
+
valid: false,
|
|
166
|
+
error: {
|
|
167
|
+
code: 'FILE_TOO_SMALL',
|
|
168
|
+
message: `File size (${fileSize} bytes) is below minimum required size (${fieldConfig.min_size} bytes)`,
|
|
169
|
+
details: {
|
|
170
|
+
file: fileName,
|
|
171
|
+
size: fileSize,
|
|
172
|
+
min_size: fieldConfig.min_size
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Validate file type/extension
|
|
179
|
+
if (fieldConfig.accept && Array.isArray(fieldConfig.accept) && fieldConfig.accept.length > 0) {
|
|
180
|
+
const fileExt = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
|
|
181
|
+
const acceptedExtensions = fieldConfig.accept.map(ext => ext.toLowerCase());
|
|
182
|
+
|
|
183
|
+
if (!acceptedExtensions.includes(fileExt)) {
|
|
184
|
+
return {
|
|
185
|
+
valid: false,
|
|
186
|
+
error: {
|
|
187
|
+
code: 'FILE_TYPE_NOT_ALLOWED',
|
|
188
|
+
message: `File type '${fileExt}' is not allowed. Allowed types: ${acceptedExtensions.join(', ')}`,
|
|
189
|
+
details: {
|
|
190
|
+
file: fileName,
|
|
191
|
+
extension: fileExt,
|
|
192
|
+
allowed: acceptedExtensions
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { valid: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Send error response
|
|
204
|
+
*/
|
|
205
|
+
export function sendError(res: ServerResponse, statusCode: number, code: string, message: string, details?: any) {
|
|
206
|
+
res.setHeader('Content-Type', 'application/json');
|
|
207
|
+
res.statusCode = statusCode;
|
|
208
|
+
res.end(JSON.stringify({
|
|
209
|
+
error: {
|
|
210
|
+
code,
|
|
211
|
+
message,
|
|
212
|
+
details
|
|
213
|
+
}
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Send success response
|
|
219
|
+
*/
|
|
220
|
+
export function sendSuccess(res: ServerResponse, data: any) {
|
|
221
|
+
res.setHeader('Content-Type', 'application/json');
|
|
222
|
+
res.statusCode = 200;
|
|
223
|
+
res.end(JSON.stringify({ data }));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract user ID from authorization header
|
|
228
|
+
* @internal This is a placeholder implementation. In production, integrate with actual auth middleware.
|
|
229
|
+
*/
|
|
230
|
+
function extractUserId(authHeader: string | undefined): string | undefined {
|
|
231
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// TODO: In production, decode JWT or validate token properly
|
|
236
|
+
// This is a placeholder implementation
|
|
237
|
+
console.warn('[Security] File upload authentication is using placeholder implementation. Integrate with actual auth system.');
|
|
238
|
+
return 'user_from_token';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Create file upload handler
|
|
243
|
+
*/
|
|
244
|
+
export function createFileUploadHandler(storage: IFileStorage, app: IObjectQL) {
|
|
245
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
246
|
+
try {
|
|
247
|
+
// Check content type
|
|
248
|
+
const contentType = req.headers['content-type'];
|
|
249
|
+
if (!contentType || !contentType.startsWith('multipart/form-data')) {
|
|
250
|
+
sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Content-Type must be multipart/form-data');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Extract boundary
|
|
255
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
256
|
+
if (!boundaryMatch) {
|
|
257
|
+
sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Missing boundary in Content-Type');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const boundary = boundaryMatch[1];
|
|
262
|
+
|
|
263
|
+
// Parse multipart data
|
|
264
|
+
const { fields, files } = await parseMultipart(req, boundary);
|
|
265
|
+
|
|
266
|
+
if (files.length === 0) {
|
|
267
|
+
sendError(res, 400, ErrorCode.INVALID_REQUEST, 'No file provided');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Get field configuration if object and field are specified
|
|
272
|
+
let fieldConfig: FieldConfig | undefined;
|
|
273
|
+
if (fields.object && fields.field) {
|
|
274
|
+
const objectConfig = (app as any).getObject(fields.object);
|
|
275
|
+
if (objectConfig && objectConfig.fields) {
|
|
276
|
+
fieldConfig = objectConfig.fields[fields.field];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Single file upload
|
|
281
|
+
const file = files[0];
|
|
282
|
+
|
|
283
|
+
// Validate file
|
|
284
|
+
const validation = validateFile(file, fieldConfig, fields.object, fields.field);
|
|
285
|
+
if (!validation.valid) {
|
|
286
|
+
sendError(res, 400, validation.error!.code, validation.error!.message, validation.error!.details);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Extract user ID from authorization header
|
|
291
|
+
const userId = extractUserId(req.headers.authorization);
|
|
292
|
+
|
|
293
|
+
// Save file
|
|
294
|
+
const attachmentData = await storage.save(
|
|
295
|
+
file.buffer,
|
|
296
|
+
file.filename,
|
|
297
|
+
file.mimeType,
|
|
298
|
+
{
|
|
299
|
+
folder: fields.folder,
|
|
300
|
+
object: fields.object,
|
|
301
|
+
field: fields.field,
|
|
302
|
+
userId
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
sendSuccess(res, attachmentData);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error('File upload error:', error);
|
|
309
|
+
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'File upload failed');
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Create batch file upload handler
|
|
316
|
+
*/
|
|
317
|
+
export function createBatchFileUploadHandler(storage: IFileStorage, app: IObjectQL) {
|
|
318
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
319
|
+
try {
|
|
320
|
+
// Check content type
|
|
321
|
+
const contentType = req.headers['content-type'];
|
|
322
|
+
if (!contentType || !contentType.startsWith('multipart/form-data')) {
|
|
323
|
+
sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Content-Type must be multipart/form-data');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Extract boundary
|
|
328
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
329
|
+
if (!boundaryMatch) {
|
|
330
|
+
sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Missing boundary in Content-Type');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const boundary = boundaryMatch[1];
|
|
335
|
+
|
|
336
|
+
// Parse multipart data
|
|
337
|
+
const { fields, files } = await parseMultipart(req, boundary);
|
|
338
|
+
|
|
339
|
+
if (files.length === 0) {
|
|
340
|
+
sendError(res, 400, ErrorCode.INVALID_REQUEST, 'No files provided');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Get field configuration if object and field are specified
|
|
345
|
+
let fieldConfig: FieldConfig | undefined;
|
|
346
|
+
if (fields.object && fields.field) {
|
|
347
|
+
const objectConfig = (app as any).getObject(fields.object);
|
|
348
|
+
if (objectConfig && objectConfig.fields) {
|
|
349
|
+
fieldConfig = objectConfig.fields[fields.field];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Extract user ID from authorization header
|
|
354
|
+
const userId = extractUserId(req.headers.authorization);
|
|
355
|
+
|
|
356
|
+
// Upload all files
|
|
357
|
+
const uploadedFiles: AttachmentData[] = [];
|
|
358
|
+
|
|
359
|
+
for (const file of files) {
|
|
360
|
+
// Validate each file
|
|
361
|
+
const validation = validateFile(file, fieldConfig, fields.object, fields.field);
|
|
362
|
+
if (!validation.valid) {
|
|
363
|
+
sendError(res, 400, validation.error!.code, validation.error!.message, validation.error!.details);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Save file
|
|
368
|
+
const attachmentData = await storage.save(
|
|
369
|
+
file.buffer,
|
|
370
|
+
file.filename,
|
|
371
|
+
file.mimeType,
|
|
372
|
+
{
|
|
373
|
+
folder: fields.folder,
|
|
374
|
+
object: fields.object,
|
|
375
|
+
field: fields.field,
|
|
376
|
+
userId
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
uploadedFiles.push(attachmentData);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
sendSuccess(res, uploadedFiles);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
console.error('Batch file upload error:', error);
|
|
386
|
+
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Batch file upload failed');
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Create file download handler
|
|
393
|
+
*/
|
|
394
|
+
export function createFileDownloadHandler(storage: IFileStorage) {
|
|
395
|
+
return async (req: IncomingMessage, res: ServerResponse, fileId: string) => {
|
|
396
|
+
try {
|
|
397
|
+
const file = await storage.get(fileId);
|
|
398
|
+
|
|
399
|
+
if (!file) {
|
|
400
|
+
sendError(res, 404, ErrorCode.NOT_FOUND, 'File not found');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Set appropriate headers
|
|
405
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
406
|
+
res.setHeader('Content-Length', file.length);
|
|
407
|
+
res.statusCode = 200;
|
|
408
|
+
res.end(file);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.error('File download error:', error);
|
|
411
|
+
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'File download failed');
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ export * from './openapi';
|
|
|
3
3
|
export * from './server';
|
|
4
4
|
export * from './metadata';
|
|
5
5
|
export * from './studio';
|
|
6
|
+
export * from './storage';
|
|
7
|
+
export * from './file-handler';
|
|
6
8
|
// We export createNodeHandler from root for convenience,
|
|
7
9
|
// but in the future we might encourage 'import ... from @objectql/server/node'
|
|
8
10
|
export * from './adapters/node';
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { IFileStorage, AttachmentData, FileStorageOptions } from './types';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Local filesystem storage implementation for file attachments
|
|
8
|
+
*/
|
|
9
|
+
export class LocalFileStorage implements IFileStorage {
|
|
10
|
+
private baseDir: string;
|
|
11
|
+
private baseUrl: string;
|
|
12
|
+
|
|
13
|
+
constructor(options: { baseDir: string; baseUrl: string }) {
|
|
14
|
+
this.baseDir = options.baseDir;
|
|
15
|
+
this.baseUrl = options.baseUrl;
|
|
16
|
+
|
|
17
|
+
// Ensure base directory exists
|
|
18
|
+
if (!fs.existsSync(this.baseDir)) {
|
|
19
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise<AttachmentData> {
|
|
24
|
+
// Generate unique ID for the file
|
|
25
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
26
|
+
const ext = path.extname(filename);
|
|
27
|
+
const basename = path.basename(filename, ext);
|
|
28
|
+
const storedFilename = `${id}${ext}`;
|
|
29
|
+
|
|
30
|
+
// Determine storage path
|
|
31
|
+
let folder = options?.folder || 'uploads';
|
|
32
|
+
if (options?.object) {
|
|
33
|
+
folder = path.join(folder, options.object);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const folderPath = path.join(this.baseDir, folder);
|
|
37
|
+
if (!fs.existsSync(folderPath)) {
|
|
38
|
+
fs.mkdirSync(folderPath, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const filePath = path.join(folderPath, storedFilename);
|
|
42
|
+
|
|
43
|
+
// Write file to disk (async for better performance)
|
|
44
|
+
await fs.promises.writeFile(filePath, file);
|
|
45
|
+
|
|
46
|
+
// Generate public URL
|
|
47
|
+
const url = this.getPublicUrl(path.join(folder, storedFilename));
|
|
48
|
+
|
|
49
|
+
const attachmentData: AttachmentData = {
|
|
50
|
+
id,
|
|
51
|
+
name: storedFilename,
|
|
52
|
+
url,
|
|
53
|
+
size: file.length,
|
|
54
|
+
type: mimeType,
|
|
55
|
+
original_name: filename,
|
|
56
|
+
uploaded_at: new Date().toISOString(),
|
|
57
|
+
uploaded_by: options?.userId
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return attachmentData;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async get(fileId: string): Promise<Buffer | null> {
|
|
64
|
+
try {
|
|
65
|
+
// Search for file in the upload directory
|
|
66
|
+
const found = this.findFile(this.baseDir, fileId);
|
|
67
|
+
if (!found) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// Use async read for better performance
|
|
71
|
+
return await fs.promises.readFile(found);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('Error reading file:', error);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async delete(fileId: string): Promise<boolean> {
|
|
79
|
+
try {
|
|
80
|
+
const found = this.findFile(this.baseDir, fileId);
|
|
81
|
+
if (!found) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
// Use async unlink for better performance
|
|
85
|
+
await fs.promises.unlink(found);
|
|
86
|
+
return true;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Error deleting file:', error);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getPublicUrl(filePath: string): string {
|
|
94
|
+
// Normalize path separators for URLs
|
|
95
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
96
|
+
return `${this.baseUrl}/${normalizedPath}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Recursively search for a file by ID
|
|
101
|
+
*/
|
|
102
|
+
private findFile(dir: string, fileId: string): string | null {
|
|
103
|
+
const files = fs.readdirSync(dir);
|
|
104
|
+
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
const filePath = path.join(dir, file);
|
|
107
|
+
const stat = fs.statSync(filePath);
|
|
108
|
+
|
|
109
|
+
if (stat.isDirectory()) {
|
|
110
|
+
const found = this.findFile(filePath, fileId);
|
|
111
|
+
if (found) {
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
} else if (file.startsWith(fileId)) {
|
|
115
|
+
return filePath;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Memory storage implementation for testing
|
|
125
|
+
*/
|
|
126
|
+
export class MemoryFileStorage implements IFileStorage {
|
|
127
|
+
private files = new Map<string, { buffer: Buffer; metadata: AttachmentData }>();
|
|
128
|
+
private baseUrl: string;
|
|
129
|
+
|
|
130
|
+
constructor(options: { baseUrl: string }) {
|
|
131
|
+
this.baseUrl = options.baseUrl;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise<AttachmentData> {
|
|
135
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
136
|
+
const ext = path.extname(filename);
|
|
137
|
+
const storedFilename = `${id}${ext}`;
|
|
138
|
+
|
|
139
|
+
const attachmentData: AttachmentData = {
|
|
140
|
+
id,
|
|
141
|
+
name: storedFilename,
|
|
142
|
+
url: this.getPublicUrl(storedFilename),
|
|
143
|
+
size: file.length,
|
|
144
|
+
type: mimeType,
|
|
145
|
+
original_name: filename,
|
|
146
|
+
uploaded_at: new Date().toISOString(),
|
|
147
|
+
uploaded_by: options?.userId
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.files.set(id, { buffer: file, metadata: attachmentData });
|
|
151
|
+
|
|
152
|
+
return attachmentData;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async get(fileId: string): Promise<Buffer | null> {
|
|
156
|
+
const entry = this.files.get(fileId);
|
|
157
|
+
return entry ? entry.buffer : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async delete(fileId: string): Promise<boolean> {
|
|
161
|
+
return this.files.delete(fileId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
getPublicUrl(filePath: string): string {
|
|
165
|
+
return `${this.baseUrl}/${filePath}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
clear(): void {
|
|
169
|
+
this.files.clear();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -92,3 +92,82 @@ export interface ObjectQLResponse {
|
|
|
92
92
|
// This allows any additional fields from the actual data object
|
|
93
93
|
[key: string]: any;
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Attachment/File metadata structure
|
|
98
|
+
*/
|
|
99
|
+
export interface AttachmentData {
|
|
100
|
+
/** Unique identifier for this file */
|
|
101
|
+
id: string;
|
|
102
|
+
/** File name (e.g., "invoice.pdf") */
|
|
103
|
+
name: string;
|
|
104
|
+
/** Publicly accessible URL to the file */
|
|
105
|
+
url: string;
|
|
106
|
+
/** File size in bytes */
|
|
107
|
+
size: number;
|
|
108
|
+
/** MIME type (e.g., "application/pdf", "image/jpeg") */
|
|
109
|
+
type: string;
|
|
110
|
+
/** Original filename as uploaded by user */
|
|
111
|
+
original_name?: string;
|
|
112
|
+
/** Upload timestamp (ISO 8601) */
|
|
113
|
+
uploaded_at?: string;
|
|
114
|
+
/** User ID who uploaded the file */
|
|
115
|
+
uploaded_by?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Image-specific attachment data with metadata
|
|
120
|
+
*/
|
|
121
|
+
export interface ImageAttachmentData extends AttachmentData {
|
|
122
|
+
/** Image width in pixels */
|
|
123
|
+
width?: number;
|
|
124
|
+
/** Image height in pixels */
|
|
125
|
+
height?: number;
|
|
126
|
+
/** Thumbnail URL (if generated) */
|
|
127
|
+
thumbnail_url?: string;
|
|
128
|
+
/** Alternative sizes/versions */
|
|
129
|
+
variants?: {
|
|
130
|
+
small?: string;
|
|
131
|
+
medium?: string;
|
|
132
|
+
large?: string;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* File storage provider interface
|
|
138
|
+
*/
|
|
139
|
+
export interface IFileStorage {
|
|
140
|
+
/**
|
|
141
|
+
* Save a file and return its metadata
|
|
142
|
+
*/
|
|
143
|
+
save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise<AttachmentData>;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Retrieve a file by its ID or path
|
|
147
|
+
*/
|
|
148
|
+
get(fileId: string): Promise<Buffer | null>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Delete a file
|
|
152
|
+
*/
|
|
153
|
+
delete(fileId: string): Promise<boolean>;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Generate a public URL for a file
|
|
157
|
+
*/
|
|
158
|
+
getPublicUrl(fileId: string): string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Options for file storage operations
|
|
163
|
+
*/
|
|
164
|
+
export interface FileStorageOptions {
|
|
165
|
+
/** Logical folder/path for organization */
|
|
166
|
+
folder?: string;
|
|
167
|
+
/** Object name (for context/validation) */
|
|
168
|
+
object?: string;
|
|
169
|
+
/** Field name (for validation against field config) */
|
|
170
|
+
field?: string;
|
|
171
|
+
/** User ID who uploaded the file */
|
|
172
|
+
userId?: string;
|
|
173
|
+
}
|