n8n-nodes-binary-to-url 0.0.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.
@@ -0,0 +1,438 @@
1
+ import {
2
+ INodeType,
3
+ INodeTypeDescription,
4
+ IExecuteFunctions,
5
+ IWebhookFunctions,
6
+ IWebhookResponseData,
7
+ INodeExecutionData,
8
+ NodeOperationError,
9
+ } from 'n8n-workflow';
10
+ import { Readable } from 'stream';
11
+ import { createStorageDriver, StorageDriver } from '../../drivers';
12
+ import { fileTypeFromBuffer } from 'file-type';
13
+
14
+ const MAX_FILE_SIZE = 100 * 1024 * 1024;
15
+ const ALLOWED_MIME_TYPES = [
16
+ 'image/jpeg',
17
+ 'image/png',
18
+ 'image/gif',
19
+ 'image/webp',
20
+ 'image/svg+xml',
21
+ 'image/bmp',
22
+ 'image/tiff',
23
+ 'image/avif',
24
+ 'video/mp4',
25
+ 'video/webm',
26
+ 'video/quicktime',
27
+ 'video/x-msvideo',
28
+ 'video/x-matroska',
29
+ 'application/pdf',
30
+ 'application/zip',
31
+ 'application/x-rar-compressed',
32
+ 'application/x-7z-compressed',
33
+ 'audio/mpeg',
34
+ 'audio/wav',
35
+ 'audio/ogg',
36
+ 'audio/flac',
37
+ 'text/plain',
38
+ 'text/csv',
39
+ 'application/json',
40
+ 'application/xml',
41
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
42
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
43
+ ];
44
+
45
+ export class BinaryBridge implements INodeType {
46
+ description: INodeTypeDescription = {
47
+ displayName: 'Binary Bridge',
48
+ name: 'binaryBridge',
49
+ icon: 'file:BinaryBridge.svg',
50
+ group: ['transform'],
51
+ version: 1,
52
+ subtitle: '={{$parameter["operation"]}}',
53
+ description: 'Upload binary files to storage and proxy them via public URL',
54
+ defaults: {
55
+ name: 'Binary Bridge',
56
+ },
57
+ inputs: ['main'],
58
+ outputs: ['main'],
59
+ credentials: [
60
+ {
61
+ name: 'awsS3Api',
62
+ displayName: 'AWS S3 Credentials',
63
+ required: true,
64
+ },
65
+ {
66
+ name: 'supabaseApi',
67
+ displayName: 'Supabase Credentials',
68
+ required: true,
69
+ },
70
+ ],
71
+ webhooks: [
72
+ {
73
+ name: 'default',
74
+ httpMethod: 'GET',
75
+ responseMode: 'onReceived',
76
+ path: 'file/:fileKey',
77
+ isFullPath: true,
78
+ },
79
+ ],
80
+ properties: [
81
+ {
82
+ displayName: 'Storage Driver',
83
+ name: 'storageDriver',
84
+ type: 'options',
85
+ noDataExpression: true,
86
+ options: [
87
+ {
88
+ name: 'AWS S3',
89
+ value: 's3',
90
+ description:
91
+ 'Use AWS S3 or S3-compatible storage (Alibaba OSS, Tencent COS, MinIO, etc.)',
92
+ },
93
+ {
94
+ name: 'Supabase',
95
+ value: 'supabase',
96
+ description: 'Use Supabase Storage',
97
+ },
98
+ ],
99
+ default: 's3',
100
+ },
101
+ {
102
+ displayName: 'Operation',
103
+ name: 'operation',
104
+ type: 'options',
105
+ noDataExpression: true,
106
+ options: [
107
+ {
108
+ name: 'Upload',
109
+ value: 'upload',
110
+ description: 'Upload binary file to storage',
111
+ action: 'Upload file',
112
+ },
113
+ {
114
+ name: 'Delete',
115
+ value: 'delete',
116
+ description: 'Delete file from storage',
117
+ action: 'Delete file',
118
+ },
119
+ ],
120
+ default: 'upload',
121
+ },
122
+ {
123
+ displayName: 'Binary Property',
124
+ name: 'binaryPropertyName',
125
+ type: 'string',
126
+ displayOptions: {
127
+ show: {
128
+ operation: ['upload'],
129
+ },
130
+ },
131
+ default: 'data',
132
+ description: 'Name of binary property containing the file to upload',
133
+ },
134
+ {
135
+ displayName: 'File Key',
136
+ name: 'fileKey',
137
+ type: 'string',
138
+ displayOptions: {
139
+ show: {
140
+ operation: ['delete'],
141
+ },
142
+ },
143
+ default: '',
144
+ description: 'Key of the file to delete from storage',
145
+ },
146
+ {
147
+ displayName: 'Bucket',
148
+ name: 'bucket',
149
+ type: 'string',
150
+ default: '',
151
+ required: true,
152
+ description: 'Storage bucket name',
153
+ },
154
+ {
155
+ displayName: 'Region',
156
+ name: 'region',
157
+ type: 'string',
158
+ default: 'us-east-1',
159
+ required: true,
160
+ displayOptions: {
161
+ show: {
162
+ storageDriver: ['s3'],
163
+ },
164
+ },
165
+ description: 'AWS region',
166
+ },
167
+ {
168
+ displayName: 'Custom Endpoint',
169
+ name: 'endpoint',
170
+ type: 'string',
171
+ default: '',
172
+ displayOptions: {
173
+ show: {
174
+ storageDriver: ['s3'],
175
+ },
176
+ },
177
+ description:
178
+ 'Custom S3 endpoint URL (for S3-compatible services like Alibaba OSS, Tencent COS, MinIO, etc.)',
179
+ },
180
+ {
181
+ displayName: 'Force Path Style',
182
+ name: 'forcePathStyle',
183
+ type: 'boolean',
184
+ default: false,
185
+ displayOptions: {
186
+ show: {
187
+ storageDriver: ['s3'],
188
+ },
189
+ },
190
+ description: 'Use path-style addressing (for MinIO, DigitalOcean Spaces, etc.)',
191
+ },
192
+ {
193
+ displayName: 'Project URL',
194
+ name: 'projectUrl',
195
+ type: 'string',
196
+ default: '',
197
+ required: true,
198
+ displayOptions: {
199
+ show: {
200
+ storageDriver: ['supabase'],
201
+ },
202
+ },
203
+ placeholder: 'https://your-project.supabase.co',
204
+ description: 'Supabase project URL',
205
+ },
206
+ ],
207
+ };
208
+
209
+ async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
210
+ const items = this.getInputData();
211
+ const operation = this.getNodeParameter('operation', 0) as string;
212
+ const storageDriver = this.getNodeParameter('storageDriver', 0) as string;
213
+ const bucket = this.getNodeParameter('bucket', 0) as string;
214
+
215
+ if (!bucket) {
216
+ throw new NodeOperationError(this.getNode(), 'Bucket name is required');
217
+ }
218
+
219
+ try {
220
+ const storage = await createStorageDriver(this, storageDriver, bucket);
221
+
222
+ if (operation === 'upload') {
223
+ return handleUpload(this, items, storage);
224
+ } else if (operation === 'delete') {
225
+ return handleDelete(this, items, storage);
226
+ }
227
+
228
+ throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`);
229
+ } catch (error) {
230
+ // Enhance error messages with context
231
+ if (error instanceof Error) {
232
+ throw new NodeOperationError(this.getNode(), `Operation failed: ${error.message}`);
233
+ }
234
+ throw new NodeOperationError(this.getNode(), `Operation failed: ${String(error)}`);
235
+ }
236
+ }
237
+
238
+ async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
239
+ const req = this.getRequestObject();
240
+ const fileKey = req.params.fileKey as string;
241
+
242
+ if (!fileKey) {
243
+ return {
244
+ webhookResponse: {
245
+ status: 400,
246
+ body: JSON.stringify({ error: 'Missing fileKey' }),
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ },
250
+ },
251
+ };
252
+ }
253
+
254
+ if (!isValidFileKey(fileKey)) {
255
+ return {
256
+ webhookResponse: {
257
+ status: 400,
258
+ body: JSON.stringify({ error: 'Invalid fileKey' }),
259
+ headers: {
260
+ 'Content-Type': 'application/json',
261
+ },
262
+ },
263
+ };
264
+ }
265
+
266
+ const bucket = this.getNodeParameter('bucket', 0) as string;
267
+
268
+ if (!bucket) {
269
+ return {
270
+ webhookResponse: {
271
+ status: 500,
272
+ body: JSON.stringify({ error: 'Node configuration is incomplete' }),
273
+ headers: {
274
+ 'Content-Type': 'application/json',
275
+ },
276
+ },
277
+ };
278
+ }
279
+
280
+ const storageDriver = this.getNodeParameter('storageDriver', 0) as string;
281
+ let storage;
282
+ try {
283
+ storage = await createStorageDriver(this, storageDriver, bucket);
284
+ } catch (error) {
285
+ return {
286
+ webhookResponse: {
287
+ status: 500,
288
+ body: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
289
+ headers: {
290
+ 'Content-Type': 'application/json',
291
+ },
292
+ },
293
+ };
294
+ }
295
+
296
+ try {
297
+ const { stream, contentType } = await storage.downloadStream(fileKey);
298
+
299
+ return {
300
+ webhookResponse: {
301
+ status: 200,
302
+ body: stream,
303
+ headers: {
304
+ 'Content-Type': contentType,
305
+ 'Cache-Control': 'public, max-age=86400',
306
+ 'Content-Disposition': 'inline',
307
+ },
308
+ },
309
+ };
310
+ } catch (error) {
311
+ return {
312
+ webhookResponse: {
313
+ status: 404,
314
+ body: JSON.stringify({ error: 'File not found' }),
315
+ headers: {
316
+ 'Content-Type': 'application/json',
317
+ },
318
+ },
319
+ };
320
+ }
321
+ }
322
+ }
323
+
324
+ async function handleUpload(
325
+ context: IExecuteFunctions,
326
+ items: INodeExecutionData[],
327
+ storage: StorageDriver
328
+ ): Promise<INodeExecutionData[][]> {
329
+ const binaryPropertyName = context.getNodeParameter('binaryPropertyName', 0) as string;
330
+ const webhookBaseUrl = buildWebhookUrl(context, 'default', 'file');
331
+
332
+ const returnData: INodeExecutionData[] = [];
333
+
334
+ for (const item of items) {
335
+ const binaryData = item.binary?.[binaryPropertyName];
336
+
337
+ if (!binaryData) {
338
+ throw new NodeOperationError(
339
+ context.getNode(),
340
+ `No binary data found in property "${binaryPropertyName}"`
341
+ );
342
+ }
343
+
344
+ const buffer = Buffer.from(binaryData.data, 'base64');
345
+
346
+ // Try to detect MIME type from file signature using file-type
347
+ let contentType = binaryData.mimeType || 'application/octet-stream';
348
+ try {
349
+ const detection = await fileTypeFromBuffer(buffer);
350
+ if (detection) {
351
+ contentType = detection.mime;
352
+ }
353
+ } catch (error) {
354
+ // Fall back to provided MIME type or default
355
+ console.warn(
356
+ `Failed to detect MIME type: ${error instanceof Error ? error.message : String(error)}`
357
+ );
358
+ }
359
+
360
+ if (!ALLOWED_MIME_TYPES.includes(contentType)) {
361
+ throw new NodeOperationError(
362
+ context.getNode(),
363
+ `MIME type "${contentType}" is not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}`
364
+ );
365
+ }
366
+
367
+ const fileSize = buffer.length;
368
+ if (fileSize > MAX_FILE_SIZE) {
369
+ throw new NodeOperationError(
370
+ context.getNode(),
371
+ `File size exceeds maximum limit of ${MAX_FILE_SIZE / 1024 / 1024}MB`
372
+ );
373
+ }
374
+
375
+ const uploadStream = Readable.from(buffer);
376
+
377
+ const result = await storage.uploadStream(uploadStream, contentType);
378
+
379
+ const proxyUrl = `${webhookBaseUrl}/${result.fileKey}`;
380
+
381
+ returnData.push({
382
+ json: {
383
+ fileKey: result.fileKey,
384
+ proxyUrl,
385
+ contentType,
386
+ fileSize,
387
+ },
388
+ binary: item.binary,
389
+ });
390
+ }
391
+
392
+ return [returnData];
393
+ }
394
+
395
+ async function handleDelete(
396
+ context: IExecuteFunctions,
397
+ items: INodeExecutionData[],
398
+ storage: StorageDriver
399
+ ): Promise<INodeExecutionData[][]> {
400
+ const returnData: INodeExecutionData[] = [];
401
+
402
+ for (const item of items) {
403
+ const fileKey = (item.json.fileKey || context.getNodeParameter('fileKey', 0)) as string;
404
+
405
+ if (!fileKey) {
406
+ throw new NodeOperationError(context.getNode(), 'File key is required for delete operation');
407
+ }
408
+
409
+ await storage.deleteFile(fileKey);
410
+
411
+ returnData.push({
412
+ json: {
413
+ success: true,
414
+ deleted: fileKey,
415
+ },
416
+ });
417
+ }
418
+
419
+ return [returnData];
420
+ }
421
+
422
+ function buildWebhookUrl(context: IExecuteFunctions, webhookName: string, path: string): string {
423
+ const baseUrl = context.getInstanceBaseUrl();
424
+ const node = context.getNode();
425
+ const workflow = context.getWorkflow();
426
+ const workflowId = workflow.id;
427
+ const nodeName = encodeURIComponent(node.name.toLowerCase());
428
+ return `${baseUrl}/webhook/${workflowId}/${nodeName}/${path}`;
429
+ }
430
+
431
+ function isValidFileKey(fileKey: string): boolean {
432
+ if (!fileKey || typeof fileKey !== 'string') {
433
+ return false;
434
+ }
435
+
436
+ const fileKeyPattern = /^[0-9]+-[a-z0-9]+\.[a-z0-9]+$/i;
437
+ return fileKeyPattern.test(fileKey);
438
+ }
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
3
+ <polyline points="17 8 12 3 7 8" />
4
+ <line x1="12" y1="3" x2="12" y2="15" />
5
+ </svg>
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "n8n-nodes-binary-to-url",
3
+ "version": "0.0.1",
4
+ "description": "n8n community node for binary file to public URL bridge with S3 and Supabase storage",
5
+ "keywords": [
6
+ "n8n-community-node-package",
7
+ "n8n",
8
+ "binary",
9
+ "file",
10
+ "storage",
11
+ "s3",
12
+ "supabase",
13
+ "proxy",
14
+ "webhook",
15
+ "to-url"
16
+ ],
17
+ "license": "MIT",
18
+ "homepage": "",
19
+ "author": {
20
+ "name": "Binary Bridge Team"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://cnb.cool/ksxh-wwrs/n8n-nodes-binary-to-url"
25
+ },
26
+ "main": "dist/index.js",
27
+ "scripts": {
28
+ "build": "tsc && cp -r nodes dist/",
29
+ "dev": "tsc --watch",
30
+ "format": "prettier --write \"**/*.{js,ts,json,md}\"",
31
+ "lint": "eslint --ext .js,.ts .",
32
+ "lintfix": "eslint --fix --ext .js,.ts .",
33
+ "prepublishOnly": "npm run build",
34
+ "test": "jest"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "nodes"
39
+ ],
40
+ "n8n": {
41
+ "n8nNodesApiVersion": 1,
42
+ "credentials": [],
43
+ "nodes": [
44
+ "dist/nodes/BinaryBridge/BinaryBridge.node.js"
45
+ ]
46
+ },
47
+ "dependencies": {
48
+ "@aws-sdk/client-s3": "^3.600.0",
49
+ "@supabase/supabase-js": "^2.39.0",
50
+ "file-type": "^21.3.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/jest": "^30.0.0",
54
+ "@types/node": "^20.0.0",
55
+ "@typescript-eslint/eslint-plugin": "^6.0.0",
56
+ "@typescript-eslint/parser": "^6.0.0",
57
+ "eslint": "^8.50.0",
58
+ "eslint-plugin-n8n-nodes-base": "^1.11.0",
59
+ "jest": "^30.2.0",
60
+ "prettier": "^3.0.0",
61
+ "ts-jest": "^29.4.6",
62
+ "typescript": "^5.2.0"
63
+ },
64
+ "peerDependencies": {
65
+ "n8n-workflow": "*"
66
+ }
67
+ }