@5minds/node-red-contrib-processcube-tools 1.1.0-feature-6eab97-mg0ov11s → 1.1.0-feature-975857-mg4vqpai
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/.env.template +1 -7
- package/package.json +4 -9
- package/swagger/locales/en-US/swagger.json +72 -0
- package/swagger/swagger-ui/swagger-ui.html +396 -0
- package/swagger/swagger.html +959 -0
- package/swagger/swagger.js +277 -0
- package/file-storage/file-storage.html +0 -97
- package/file-storage/file-storage.js +0 -70
- package/storage/providers/fs.js +0 -117
- package/storage/providers/postgres.js +0 -159
- package/storage/storage-core.js +0 -73
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2015, 2016 IBM Corp.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
**/
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TEMPLATE = {
|
|
18
|
+
openapi: '3.0.0',
|
|
19
|
+
info: {
|
|
20
|
+
title: 'My Node-RED API',
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
description: 'A sample API',
|
|
23
|
+
},
|
|
24
|
+
servers: [
|
|
25
|
+
{
|
|
26
|
+
url: 'http://localhost:1880/',
|
|
27
|
+
description: 'Local server',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
paths: {},
|
|
31
|
+
components: {
|
|
32
|
+
schemas: {},
|
|
33
|
+
responses: {},
|
|
34
|
+
parameters: {},
|
|
35
|
+
securitySchemes: {},
|
|
36
|
+
},
|
|
37
|
+
tags: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
module.exports = function (RED) {
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
const path = require('path');
|
|
44
|
+
|
|
45
|
+
const convToSwaggerPath = (x) => `/{${x.substring(2)}}`;
|
|
46
|
+
const trimAll = (ary) => ary.map((x) => x.trim());
|
|
47
|
+
const csvStrToArray = (csvStr) => (csvStr ? trimAll(csvStr.split(',')) : []);
|
|
48
|
+
const ensureLeadingSlash = (url) => (url.startsWith('/') ? url : '/' + url);
|
|
49
|
+
const stripTerminalSlash = (url) => (url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url);
|
|
50
|
+
const regexColons = /\/:\w*/g;
|
|
51
|
+
|
|
52
|
+
// Helper function to convert collection format to OpenAPI 3.0 style
|
|
53
|
+
const getStyleFromCollectionFormat = (collectionFormat) => {
|
|
54
|
+
const formatMap = {
|
|
55
|
+
csv: 'simple',
|
|
56
|
+
ssv: 'spaceDelimited',
|
|
57
|
+
tsv: 'pipeDelimited',
|
|
58
|
+
pipes: 'pipeDelimited',
|
|
59
|
+
multi: 'form',
|
|
60
|
+
};
|
|
61
|
+
return formatMap[collectionFormat] || 'simple';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
RED.httpNode.get('/http-api/swagger.json', (req, res) => {
|
|
65
|
+
try {
|
|
66
|
+
const { httpNodeRoot, openapi: { template = {}, parameters: additionalParams = [] } = {} } = RED.settings;
|
|
67
|
+
|
|
68
|
+
const resp = { ...DEFAULT_TEMPLATE, ...template };
|
|
69
|
+
resp.paths = {};
|
|
70
|
+
|
|
71
|
+
// Update server URL to include the httpNodeRoot
|
|
72
|
+
if (httpNodeRoot && httpNodeRoot !== '/') {
|
|
73
|
+
resp.servers = resp.servers.map((server) => ({
|
|
74
|
+
...server,
|
|
75
|
+
url: server.url.replace(/\/$/, '') + httpNodeRoot,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
RED.nodes.eachNode((node) => {
|
|
80
|
+
const { name, type, method, swaggerDoc, url } = node;
|
|
81
|
+
|
|
82
|
+
if (type === 'http in' && swaggerDoc) {
|
|
83
|
+
const swaggerDocNode = RED.nodes.getNode(swaggerDoc);
|
|
84
|
+
|
|
85
|
+
if (swaggerDocNode) {
|
|
86
|
+
// Convert Node-RED path parameters to OpenAPI format
|
|
87
|
+
const endPoint = stripTerminalSlash(
|
|
88
|
+
ensureLeadingSlash(url.replace(regexColons, convToSwaggerPath)),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!resp.paths[endPoint]) resp.paths[endPoint] = {};
|
|
92
|
+
|
|
93
|
+
const {
|
|
94
|
+
summary = name || `${method.toUpperCase()} ${endPoint}`,
|
|
95
|
+
description = '',
|
|
96
|
+
tags = '',
|
|
97
|
+
deprecated = false,
|
|
98
|
+
parameters = [],
|
|
99
|
+
requestBody = null,
|
|
100
|
+
responses = {},
|
|
101
|
+
} = swaggerDocNode;
|
|
102
|
+
|
|
103
|
+
const aryTags = csvStrToArray(tags);
|
|
104
|
+
|
|
105
|
+
const operation = {
|
|
106
|
+
summary,
|
|
107
|
+
description,
|
|
108
|
+
tags: aryTags,
|
|
109
|
+
deprecated,
|
|
110
|
+
parameters: [...parameters, ...additionalParams].map((param) => {
|
|
111
|
+
const paramDef = {
|
|
112
|
+
name: param.name,
|
|
113
|
+
in: param.in,
|
|
114
|
+
required: param.required || false,
|
|
115
|
+
description: param.description || '',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Handle parameter schema - preserve the original schema structure
|
|
119
|
+
if (param.schema) {
|
|
120
|
+
// If there's already a schema object, use it
|
|
121
|
+
paramDef.schema = param.schema;
|
|
122
|
+
} else if (param.type) {
|
|
123
|
+
// Build schema from individual type properties
|
|
124
|
+
paramDef.schema = { type: param.type };
|
|
125
|
+
if (param.format) {
|
|
126
|
+
paramDef.schema.format = param.format;
|
|
127
|
+
}
|
|
128
|
+
if (param.type === 'array' && param.items) {
|
|
129
|
+
paramDef.schema.items = param.items;
|
|
130
|
+
}
|
|
131
|
+
if (param.collectionFormat && param.type === 'array') {
|
|
132
|
+
paramDef.style = getStyleFromCollectionFormat(param.collectionFormat);
|
|
133
|
+
paramDef.explode = param.collectionFormat === 'multi';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return paramDef;
|
|
138
|
+
}),
|
|
139
|
+
responses: {},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Add request body if it exists
|
|
143
|
+
if (requestBody && Object.keys(requestBody.content || {}).length > 0) {
|
|
144
|
+
const content = requestBody.content;
|
|
145
|
+
Object.keys(content).forEach(contentType => {
|
|
146
|
+
if (contentType.includes('xml') && content[contentType].example) {
|
|
147
|
+
content['text/plain'] = {
|
|
148
|
+
schema: { type: 'string' },
|
|
149
|
+
example: content[contentType].example.replace(/\\n/g, '\n')
|
|
150
|
+
};
|
|
151
|
+
delete content[contentType];
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
operation.requestBody = requestBody;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Process responses
|
|
158
|
+
if (responses && typeof responses === 'object') {
|
|
159
|
+
Object.keys(responses).forEach((status) => {
|
|
160
|
+
const responseDetails = responses[status];
|
|
161
|
+
operation.responses[status] = {
|
|
162
|
+
description: responseDetails.description || 'No description',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Add content if schema exists
|
|
166
|
+
if (responseDetails.schema) {
|
|
167
|
+
operation.responses[status].content = {
|
|
168
|
+
'application/json': {
|
|
169
|
+
schema: responseDetails.schema,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Ensure at least one response exists
|
|
177
|
+
if (Object.keys(operation.responses).length === 0) {
|
|
178
|
+
operation.responses['200'] = {
|
|
179
|
+
description: 'Successful response',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
resp.paths[endPoint][method.toLowerCase()] = operation;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Clean up empty sections
|
|
189
|
+
cleanupOpenAPISpec(resp);
|
|
190
|
+
res.json(resp);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('Error generating Swagger JSON:', error);
|
|
193
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
function cleanupOpenAPISpec(spec) {
|
|
198
|
+
// Clean up components
|
|
199
|
+
if (spec.components) {
|
|
200
|
+
['schemas', 'responses', 'parameters', 'securitySchemes'].forEach((key) => {
|
|
201
|
+
if (spec.components[key] && Object.keys(spec.components[key]).length === 0) {
|
|
202
|
+
delete spec.components[key];
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// If all components are empty, remove the components object itself
|
|
207
|
+
if (Object.keys(spec.components).length === 0) {
|
|
208
|
+
delete spec.components;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Clean up empty tags array
|
|
213
|
+
if (Array.isArray(spec.tags) && spec.tags.length === 0) {
|
|
214
|
+
delete spec.tags;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function SwaggerDoc(n) {
|
|
219
|
+
RED.nodes.createNode(this, n);
|
|
220
|
+
this.summary = n.summary;
|
|
221
|
+
this.description = n.description;
|
|
222
|
+
this.tags = n.tags;
|
|
223
|
+
this.parameters = n.parameters || [];
|
|
224
|
+
this.responses = n.responses || {};
|
|
225
|
+
this.requestBody = n.requestBody || null;
|
|
226
|
+
this.deprecated = n.deprecated || false;
|
|
227
|
+
}
|
|
228
|
+
RED.nodes.registerType('swagger-doc', SwaggerDoc);
|
|
229
|
+
|
|
230
|
+
// Serve the main Swagger UI HTML file
|
|
231
|
+
RED.httpAdmin.get('/swagger-ui/swagger-ui.html', (req, res) => {
|
|
232
|
+
const filename = path.join(__dirname, 'swagger-ui', 'swagger-ui.html');
|
|
233
|
+
sendFile(res, filename);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Serve Swagger UI assets
|
|
237
|
+
RED.httpAdmin.get('/swagger-ui/*', (req, res, next) => {
|
|
238
|
+
let filename = req.params[0];
|
|
239
|
+
|
|
240
|
+
// Skip if it's the HTML file (handled by specific route above)
|
|
241
|
+
if (filename === 'swagger-ui.html') {
|
|
242
|
+
return next();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const swaggerUiPath = require('swagger-ui-dist').getAbsoluteFSPath();
|
|
247
|
+
const filePath = path.join(swaggerUiPath, filename);
|
|
248
|
+
sendFile(res, filePath);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error('Error serving Swagger UI asset:', err);
|
|
251
|
+
res.status(404).send('File not found');
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Serve localization files
|
|
256
|
+
RED.httpAdmin.get('/swagger-ui/nls/*', (req, res) => {
|
|
257
|
+
const filename = path.join(__dirname, 'locales', req.params[0]);
|
|
258
|
+
sendFile(res, filename);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Generic function to send files
|
|
262
|
+
function sendFile(res, filePath) {
|
|
263
|
+
const fs = require('fs');
|
|
264
|
+
|
|
265
|
+
// Check if file exists
|
|
266
|
+
if (!fs.existsSync(filePath)) {
|
|
267
|
+
return res.status(404).send('File not found');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
res.sendFile(path.resolve(filePath), (err) => {
|
|
271
|
+
if (err) {
|
|
272
|
+
console.error('Error sending file:', err);
|
|
273
|
+
res.status(err.status || 500).send('Error sending file');
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
};
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
<script type="text/javascript">
|
|
2
|
-
RED.nodes.registerType('file-storage', {
|
|
3
|
-
category: 'ProcessCube Tools',
|
|
4
|
-
color: '#02AFD6',
|
|
5
|
-
defaults: {
|
|
6
|
-
name: { value: '' },
|
|
7
|
-
provider: { value: 'fs' },
|
|
8
|
-
baseDir: { value: '' },
|
|
9
|
-
pgConnectionString: { value: '' },
|
|
10
|
-
pgSchema: { value: 'public' },
|
|
11
|
-
pgTable: { value: 'files' },
|
|
12
|
-
outputAs: { value: 'stream' },
|
|
13
|
-
defaultAction: { value: 'store' },
|
|
14
|
-
},
|
|
15
|
-
inputs: 1,
|
|
16
|
-
outputs: 1,
|
|
17
|
-
icon: 'file.png',
|
|
18
|
-
label: function () {
|
|
19
|
-
return this.name || 'file-storage';
|
|
20
|
-
},
|
|
21
|
-
});
|
|
22
|
-
</script>
|
|
23
|
-
|
|
24
|
-
<script type="text/html" data-template-name="file-storage">
|
|
25
|
-
<div class="form-row">
|
|
26
|
-
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
27
|
-
<input type="text" id="node-input-name" placeholder="file-storage" />
|
|
28
|
-
</div>
|
|
29
|
-
<div class="form-row">
|
|
30
|
-
<label for="node-input-provider"><i class="fa fa-database"></i> Provider</label>
|
|
31
|
-
<select id="node-input-provider">
|
|
32
|
-
<option value="fs">Filesystem</option>
|
|
33
|
-
<option value="pg">PostgreSQL</option>
|
|
34
|
-
</select>
|
|
35
|
-
</div>
|
|
36
|
-
<div class="form-row">
|
|
37
|
-
<label for="node-input-outputAs"><i class="fa fa-share-square-o"></i> Output</label>
|
|
38
|
-
<select id="node-input-outputAs">
|
|
39
|
-
<option value="stream">Stream</option>
|
|
40
|
-
<option value="buffer">Buffer</option>
|
|
41
|
-
<option value="path">Path (nur FS)</option>
|
|
42
|
-
</select>
|
|
43
|
-
</div>
|
|
44
|
-
<hr />
|
|
45
|
-
<div class="form-tips">Filesystem</div>
|
|
46
|
-
<div class="form-row">
|
|
47
|
-
<label for="node-input-baseDir"><i class="fa fa-folder-open"></i> Base Dir</label>
|
|
48
|
-
<input type="text" id="node-input-baseDir" placeholder="/data/files" />
|
|
49
|
-
</div>
|
|
50
|
-
<hr />
|
|
51
|
-
<div class="form-tips">PostgreSQL</div>
|
|
52
|
-
<div class="form-row">
|
|
53
|
-
<label for="node-input-pgConnectionString"><i class="fa fa-plug"></i> Connection</label>
|
|
54
|
-
<input type="text" id="node-input-pgConnectionString" placeholder="postgres://user:pass@host:5432/db" />
|
|
55
|
-
</div>
|
|
56
|
-
<div class="form-row">
|
|
57
|
-
<label for="node-input-pgSchema"><i class="fa fa-sitemap"></i> Schema</label>
|
|
58
|
-
<input type="text" id="node-input-pgSchema" placeholder="public" />
|
|
59
|
-
</div>
|
|
60
|
-
<div class="form-row">
|
|
61
|
-
<label for="node-input-pgTable"><i class="fa fa-table"></i> Tabelle</label>
|
|
62
|
-
<input type="text" id="node-input-pgTable" placeholder="files" />
|
|
63
|
-
</div>
|
|
64
|
-
<hr />
|
|
65
|
-
<div class="form-row">
|
|
66
|
-
<label for="node-input-defaultAction"><i class="fa fa-cog"></i> Default Action</label>
|
|
67
|
-
<select id="node-input-defaultAction">
|
|
68
|
-
<option value="store">store</option>
|
|
69
|
-
<option value="get">get</option>
|
|
70
|
-
<option value="delete">delete</option>
|
|
71
|
-
</select>
|
|
72
|
-
</div>
|
|
73
|
-
</script>
|
|
74
|
-
|
|
75
|
-
<script type="text/html" data-help-name="file-storage">
|
|
76
|
-
<p>
|
|
77
|
-
File-Storage-Node zum Speichern/Abrufen/Löschen von Dateien inkl. Metadaten. Provider: Filesystem (Datei + JSON)
|
|
78
|
-
oder PostgreSQL (Large Objects + Metadaten-Tabelle).
|
|
79
|
-
</p>
|
|
80
|
-
<h3>Input</h3>
|
|
81
|
-
<pre>
|
|
82
|
-
msg.action = "store" | "get" | "delete"
|
|
83
|
-
msg.payload = Buffer | Readable | String (bei store)
|
|
84
|
-
msg.file = {
|
|
85
|
-
id?: string (bei get/delete),
|
|
86
|
-
filename?: string,
|
|
87
|
-
contentType?: string,
|
|
88
|
-
metadata?: object
|
|
89
|
-
}
|
|
90
|
-
</pre
|
|
91
|
-
>
|
|
92
|
-
<h3>Output</h3>
|
|
93
|
-
<p>
|
|
94
|
-
Bei <code>store</code>: <code>msg.payload</code> enthält Metadaten inkl. <code>id</code>. Bei <code>get</code>:
|
|
95
|
-
<code>msg.payload</code> ist Stream/Buffer/Pfad (je nach Option), Metadaten in <code>msg.file</code>.
|
|
96
|
-
</p>
|
|
97
|
-
</script>
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
module.exports = function (RED) {
|
|
2
|
-
const StorageCore = require('../storage/storage-core');
|
|
3
|
-
|
|
4
|
-
function FileStorageNode(config) {
|
|
5
|
-
RED.nodes.createNode(this, config);
|
|
6
|
-
const node = this;
|
|
7
|
-
|
|
8
|
-
// Node-Konfiguration
|
|
9
|
-
node.provider = config.provider || 'fs';
|
|
10
|
-
node.baseDir = config.baseDir;
|
|
11
|
-
node.pg = {
|
|
12
|
-
connectionString: config.pgConnectionString,
|
|
13
|
-
schema: config.pgSchema || 'public',
|
|
14
|
-
table: config.pgTable || 'files',
|
|
15
|
-
};
|
|
16
|
-
node.outputAs = config.outputAs || 'stream'; // 'stream' | 'buffer' | 'path' (path nur fs)
|
|
17
|
-
|
|
18
|
-
// Storage-Kern
|
|
19
|
-
const storage = new StorageCore({
|
|
20
|
-
provider: node.provider,
|
|
21
|
-
fs: { baseDir: node.baseDir },
|
|
22
|
-
pg: node.pg,
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
storage.init().catch((err) => node.error(err));
|
|
26
|
-
|
|
27
|
-
node.on('input', async function (msg, send, done) {
|
|
28
|
-
try {
|
|
29
|
-
const action = msg.action || config.defaultAction || 'store';
|
|
30
|
-
if (action === 'store') {
|
|
31
|
-
const file = msg.file || {};
|
|
32
|
-
const result = await storage.store(msg.payload, file);
|
|
33
|
-
msg.payload = result;
|
|
34
|
-
msg.file = { ...file, ...result };
|
|
35
|
-
send(msg);
|
|
36
|
-
done();
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (action === 'get') {
|
|
41
|
-
const id = msg.file && msg.file.id;
|
|
42
|
-
if (!id) throw new Error('file.id is required for get');
|
|
43
|
-
const { meta, payload } = await storage.get(id, { as: node.outputAs });
|
|
44
|
-
msg.file = { ...meta, id: meta.id };
|
|
45
|
-
msg.payload = payload;
|
|
46
|
-
send(msg);
|
|
47
|
-
done();
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (action === 'delete') {
|
|
52
|
-
const id = msg.file && msg.file.id;
|
|
53
|
-
if (!id) throw new Error('file.id is required for delete');
|
|
54
|
-
const result = await storage.delete(id);
|
|
55
|
-
msg.payload = result;
|
|
56
|
-
send(msg);
|
|
57
|
-
done();
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
throw new Error(`Unknown action: ${action}`);
|
|
62
|
-
} catch (err) {
|
|
63
|
-
node.error(err, msg);
|
|
64
|
-
if (done) done(err);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
RED.nodes.registerType('file-storage', FileStorageNode);
|
|
70
|
-
};
|
package/storage/providers/fs.js
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const fsp = require('fs/promises');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const { pipeline } = require('stream');
|
|
5
|
-
const { createHash } = require('crypto');
|
|
6
|
-
const { promisify } = require('util');
|
|
7
|
-
const pump = promisify(pipeline);
|
|
8
|
-
|
|
9
|
-
class FsProvider {
|
|
10
|
-
constructor(opts = {}) {
|
|
11
|
-
this.baseDir = opts.baseDir || path.resolve(process.cwd(), 'data');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async init() {
|
|
15
|
-
await fsp.mkdir(this.baseDir, { recursive: true });
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
_buildPaths(id) {
|
|
19
|
-
const d = new Date();
|
|
20
|
-
const parts = [
|
|
21
|
-
String(d.getUTCFullYear()),
|
|
22
|
-
String(d.getUTCMonth() + 1).padStart(2, '0'),
|
|
23
|
-
String(d.getUTCDate()).padStart(2, '0'),
|
|
24
|
-
];
|
|
25
|
-
const dir = path.join(this.baseDir, ...parts);
|
|
26
|
-
const filePath = path.join(dir, id);
|
|
27
|
-
const metaPath = path.join(dir, `${id}.json`);
|
|
28
|
-
return { dir, filePath, metaPath };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async store(readable, info) {
|
|
32
|
-
const { id, filename, contentType, metadata, createdAt } = info;
|
|
33
|
-
const { dir, filePath, metaPath } = this._buildPaths(id);
|
|
34
|
-
await fsp.mkdir(dir, { recursive: true });
|
|
35
|
-
|
|
36
|
-
const hash = createHash('sha256');
|
|
37
|
-
let size = 0;
|
|
38
|
-
|
|
39
|
-
const out = fs.createWriteStream(filePath);
|
|
40
|
-
readable.on('data', (chunk) => {
|
|
41
|
-
hash.update(chunk);
|
|
42
|
-
size += chunk.length;
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
await pump(readable, out);
|
|
46
|
-
|
|
47
|
-
const sha256 = hash.digest('hex');
|
|
48
|
-
const meta = { id, filename, contentType, size, sha256, metadata, createdAt };
|
|
49
|
-
await fsp.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
|
50
|
-
|
|
51
|
-
return { size, sha256, path: filePath };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async get(id, options = { as: 'stream' }) {
|
|
55
|
-
// Find meta file by searching dated folders
|
|
56
|
-
const meta = await this._findMeta(id);
|
|
57
|
-
if (!meta) throw new Error(`File not found: ${id}`);
|
|
58
|
-
const filePath = meta.__filePath;
|
|
59
|
-
|
|
60
|
-
if (options.as === 'path') {
|
|
61
|
-
return { meta, payload: filePath };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (options.as === 'buffer') {
|
|
65
|
-
const buf = await fsp.readFile(filePath);
|
|
66
|
-
return { meta, payload: buf };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// default: stream
|
|
70
|
-
const stream = fs.createReadStream(filePath);
|
|
71
|
-
return { meta, payload: stream };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async delete(id) {
|
|
75
|
-
const meta = await this._findMeta(id);
|
|
76
|
-
if (!meta) return; // idempotent
|
|
77
|
-
await fsp.unlink(meta.__filePath).catch(() => {});
|
|
78
|
-
await fsp.unlink(meta.__metaPath).catch(() => {});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async _findMeta(id) {
|
|
82
|
-
// Walk date folders (YYYY/MM/DD). For Performance: keep index/cache in prod.
|
|
83
|
-
const years = await this._ls(this.baseDir);
|
|
84
|
-
for (const y of years) {
|
|
85
|
-
const yearDir = path.join(this.baseDir, y);
|
|
86
|
-
const months = await this._ls(yearDir);
|
|
87
|
-
for (const m of months) {
|
|
88
|
-
const monthDir = path.join(yearDir, m);
|
|
89
|
-
const days = await this._ls(monthDir);
|
|
90
|
-
for (const d of days) {
|
|
91
|
-
const dir = path.join(monthDir, d);
|
|
92
|
-
const metaPath = path.join(dir, `${id}.json`);
|
|
93
|
-
try {
|
|
94
|
-
const raw = await fsp.readFile(metaPath, 'utf-8');
|
|
95
|
-
const meta = JSON.parse(raw);
|
|
96
|
-
meta.__metaPath = metaPath;
|
|
97
|
-
meta.__filePath = path.join(dir, id);
|
|
98
|
-
return meta;
|
|
99
|
-
} catch (_) {
|
|
100
|
-
/* continue */
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async _ls(dir) {
|
|
109
|
-
try {
|
|
110
|
-
return (await fsp.readdir(dir)).filter((n) => !n.startsWith('.'));
|
|
111
|
-
} catch {
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
module.exports = FsProvider;
|