@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.
@@ -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
+ }