voltjs-framework 1.0.0
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/LICENSE +21 -0
- package/README.md +1265 -0
- package/bin/volt.js +139 -0
- package/package.json +56 -0
- package/src/api/graphql.js +399 -0
- package/src/api/rest.js +204 -0
- package/src/api/websocket.js +285 -0
- package/src/cli/build.js +111 -0
- package/src/cli/create.js +371 -0
- package/src/cli/db.js +106 -0
- package/src/cli/dev.js +114 -0
- package/src/cli/generate.js +278 -0
- package/src/cli/lint.js +172 -0
- package/src/cli/routes.js +118 -0
- package/src/cli/start.js +42 -0
- package/src/cli/test.js +138 -0
- package/src/core/app.js +701 -0
- package/src/core/config.js +232 -0
- package/src/core/middleware.js +133 -0
- package/src/core/plugins.js +88 -0
- package/src/core/react-renderer.js +244 -0
- package/src/core/renderer.js +337 -0
- package/src/core/router.js +183 -0
- package/src/database/index.js +461 -0
- package/src/database/migration.js +192 -0
- package/src/database/model.js +285 -0
- package/src/database/query.js +394 -0
- package/src/database/seeder.js +89 -0
- package/src/index.js +156 -0
- package/src/security/auth.js +425 -0
- package/src/security/cors.js +80 -0
- package/src/security/csrf.js +125 -0
- package/src/security/encryption.js +110 -0
- package/src/security/helmet.js +103 -0
- package/src/security/index.js +75 -0
- package/src/security/rateLimit.js +119 -0
- package/src/security/sanitizer.js +113 -0
- package/src/security/xss.js +110 -0
- package/src/ui/component.js +224 -0
- package/src/ui/reactive.js +503 -0
- package/src/ui/template.js +448 -0
- package/src/utils/cache.js +216 -0
- package/src/utils/collection.js +772 -0
- package/src/utils/cron.js +213 -0
- package/src/utils/date.js +223 -0
- package/src/utils/events.js +181 -0
- package/src/utils/excel.js +482 -0
- package/src/utils/form.js +547 -0
- package/src/utils/hash.js +121 -0
- package/src/utils/http.js +461 -0
- package/src/utils/logger.js +186 -0
- package/src/utils/mail.js +347 -0
- package/src/utils/paginator.js +179 -0
- package/src/utils/pdf.js +417 -0
- package/src/utils/queue.js +199 -0
- package/src/utils/schema.js +985 -0
- package/src/utils/sms.js +243 -0
- package/src/utils/storage.js +348 -0
- package/src/utils/string.js +236 -0
- package/src/utils/validation.js +318 -0
package/src/utils/sms.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS SMS Module
|
|
3
|
+
*
|
|
4
|
+
* Send SMS messages via various providers (Twilio, Vonage, custom API).
|
|
5
|
+
* Uses native HTTPS for API calls — zero dependencies.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { SMS } = require('voltjs');
|
|
9
|
+
*
|
|
10
|
+
* const sms = new SMS({
|
|
11
|
+
* provider: 'twilio',
|
|
12
|
+
* accountSid: 'your_sid',
|
|
13
|
+
* authToken: 'your_token',
|
|
14
|
+
* from: '+1234567890',
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* await sms.send('+0987654321', 'Hello from VoltJS!');
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const https = require('https');
|
|
23
|
+
const http = require('http');
|
|
24
|
+
|
|
25
|
+
class SMS {
|
|
26
|
+
constructor(config = {}) {
|
|
27
|
+
this._config = {
|
|
28
|
+
provider: config.provider || 'custom',
|
|
29
|
+
from: config.from || process.env.SMS_FROM || '',
|
|
30
|
+
// Twilio
|
|
31
|
+
accountSid: config.accountSid || process.env.TWILIO_ACCOUNT_SID || '',
|
|
32
|
+
authToken: config.authToken || process.env.TWILIO_AUTH_TOKEN || '',
|
|
33
|
+
// Vonage/Nexmo
|
|
34
|
+
apiKey: config.apiKey || process.env.VONAGE_API_KEY || '',
|
|
35
|
+
apiSecret: config.apiSecret || process.env.VONAGE_API_SECRET || '',
|
|
36
|
+
// Custom API
|
|
37
|
+
apiUrl: config.apiUrl || process.env.SMS_API_URL || '',
|
|
38
|
+
apiHeaders: config.apiHeaders || {},
|
|
39
|
+
apiBodyTemplate: config.apiBodyTemplate || null,
|
|
40
|
+
...config,
|
|
41
|
+
};
|
|
42
|
+
this._templates = new Map();
|
|
43
|
+
this._log = [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Send an SMS */
|
|
47
|
+
async send(to, message, options = {}) {
|
|
48
|
+
const formattedTo = this._formatPhone(to);
|
|
49
|
+
const formattedMessage = options.template
|
|
50
|
+
? this._renderTemplate(options.template, options.data || {})
|
|
51
|
+
: message;
|
|
52
|
+
|
|
53
|
+
let result;
|
|
54
|
+
switch (this._config.provider.toLowerCase()) {
|
|
55
|
+
case 'twilio':
|
|
56
|
+
result = await this._sendTwilio(formattedTo, formattedMessage);
|
|
57
|
+
break;
|
|
58
|
+
case 'vonage':
|
|
59
|
+
case 'nexmo':
|
|
60
|
+
result = await this._sendVonage(formattedTo, formattedMessage);
|
|
61
|
+
break;
|
|
62
|
+
case 'custom':
|
|
63
|
+
result = await this._sendCustom(formattedTo, formattedMessage);
|
|
64
|
+
break;
|
|
65
|
+
case 'console':
|
|
66
|
+
case 'log':
|
|
67
|
+
result = this._sendConsole(formattedTo, formattedMessage);
|
|
68
|
+
break;
|
|
69
|
+
default:
|
|
70
|
+
throw new Error(`Unknown SMS provider: ${this._config.provider}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this._log.push({
|
|
74
|
+
to: formattedTo,
|
|
75
|
+
message: formattedMessage.substring(0, 50),
|
|
76
|
+
provider: this._config.provider,
|
|
77
|
+
timestamp: new Date(),
|
|
78
|
+
...result,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Send SMS to multiple recipients */
|
|
85
|
+
async sendBulk(recipients, message, options = {}) {
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const to of recipients) {
|
|
88
|
+
try {
|
|
89
|
+
const result = await this.send(to, message, options);
|
|
90
|
+
results.push({ to, status: 'sent', ...result });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
results.push({ to, status: 'failed', error: err.message });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Register an SMS template */
|
|
99
|
+
template(name, template) {
|
|
100
|
+
this._templates.set(name, template);
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Send OTP SMS */
|
|
105
|
+
async sendOTP(to, code, options = {}) {
|
|
106
|
+
const message = options.message || `Your verification code is: ${code}. Valid for ${options.validFor || '10'} minutes.`;
|
|
107
|
+
return this.send(to, message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Get send log */
|
|
111
|
+
getLog() {
|
|
112
|
+
return [...this._log];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ===== PROVIDERS =====
|
|
116
|
+
|
|
117
|
+
async _sendTwilio(to, body) {
|
|
118
|
+
const { accountSid, authToken, from } = this._config;
|
|
119
|
+
|
|
120
|
+
const postData = new URLSearchParams({
|
|
121
|
+
To: to,
|
|
122
|
+
From: from,
|
|
123
|
+
Body: body,
|
|
124
|
+
}).toString();
|
|
125
|
+
|
|
126
|
+
const response = await this._httpRequest({
|
|
127
|
+
hostname: 'api.twilio.com',
|
|
128
|
+
path: `/2010-04-01/Accounts/${accountSid}/Messages.json`,
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
132
|
+
'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
|
|
133
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
134
|
+
},
|
|
135
|
+
}, postData);
|
|
136
|
+
|
|
137
|
+
return { success: true, provider: 'twilio', sid: response.sid };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async _sendVonage(to, text) {
|
|
141
|
+
const { apiKey, apiSecret, from } = this._config;
|
|
142
|
+
|
|
143
|
+
const postData = JSON.stringify({
|
|
144
|
+
api_key: apiKey,
|
|
145
|
+
api_secret: apiSecret,
|
|
146
|
+
to: to.replace(/\+/g, ''),
|
|
147
|
+
from,
|
|
148
|
+
text,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const response = await this._httpRequest({
|
|
152
|
+
hostname: 'rest.nexmo.com',
|
|
153
|
+
path: '/sms/json',
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: {
|
|
156
|
+
'Content-Type': 'application/json',
|
|
157
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
158
|
+
},
|
|
159
|
+
}, postData);
|
|
160
|
+
|
|
161
|
+
return { success: true, provider: 'vonage', response };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async _sendCustom(to, message) {
|
|
165
|
+
const { apiUrl, apiHeaders, apiBodyTemplate } = this._config;
|
|
166
|
+
if (!apiUrl) throw new Error('Custom SMS provider requires apiUrl');
|
|
167
|
+
|
|
168
|
+
const urlObj = new URL(apiUrl);
|
|
169
|
+
let body;
|
|
170
|
+
|
|
171
|
+
if (apiBodyTemplate) {
|
|
172
|
+
body = JSON.stringify(apiBodyTemplate)
|
|
173
|
+
.replace(/\{\{to\}\}/g, to)
|
|
174
|
+
.replace(/\{\{message\}\}/g, message)
|
|
175
|
+
.replace(/\{\{from\}\}/g, this._config.from);
|
|
176
|
+
} else {
|
|
177
|
+
body = JSON.stringify({ to, message, from: this._config.from });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const response = await this._httpRequest({
|
|
181
|
+
hostname: urlObj.hostname,
|
|
182
|
+
port: urlObj.port,
|
|
183
|
+
path: urlObj.pathname + urlObj.search,
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
'Content-Type': 'application/json',
|
|
187
|
+
'Content-Length': Buffer.byteLength(body),
|
|
188
|
+
...apiHeaders,
|
|
189
|
+
},
|
|
190
|
+
}, body);
|
|
191
|
+
|
|
192
|
+
return { success: true, provider: 'custom', response };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_sendConsole(to, message) {
|
|
196
|
+
console.log(`\x1b[36m 📱 SMS [${this._config.from} → ${to}]: ${message}\x1b[0m`);
|
|
197
|
+
return { success: true, provider: 'console' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ===== HELPERS =====
|
|
201
|
+
|
|
202
|
+
_formatPhone(phone) {
|
|
203
|
+
let cleaned = String(phone).replace(/[^0-9+]/g, '');
|
|
204
|
+
if (!cleaned.startsWith('+')) {
|
|
205
|
+
cleaned = '+' + cleaned;
|
|
206
|
+
}
|
|
207
|
+
return cleaned;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_renderTemplate(name, data) {
|
|
211
|
+
const template = this._templates.get(name);
|
|
212
|
+
if (!template) throw new Error(`SMS template "${name}" not found`);
|
|
213
|
+
return template.replace(/\{\{\s*(.+?)\s*\}\}/g, (_, key) => {
|
|
214
|
+
return data[key] !== undefined ? String(data[key]) : '';
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_httpRequest(options, postData) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const protocol = options.port === 80 ? http : https;
|
|
221
|
+
const req = protocol.request(options, (res) => {
|
|
222
|
+
let data = '';
|
|
223
|
+
res.on('data', chunk => data += chunk);
|
|
224
|
+
res.on('end', () => {
|
|
225
|
+
try {
|
|
226
|
+
resolve(JSON.parse(data));
|
|
227
|
+
} catch {
|
|
228
|
+
resolve(data);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
req.on('error', reject);
|
|
233
|
+
req.setTimeout(10000, () => {
|
|
234
|
+
req.destroy();
|
|
235
|
+
reject(new Error('SMS API request timed out'));
|
|
236
|
+
});
|
|
237
|
+
if (postData) req.write(postData);
|
|
238
|
+
req.end();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { SMS };
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Storage
|
|
3
|
+
*
|
|
4
|
+
* File storage abstraction with local filesystem and S3-compatible support.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { Storage } = require('voltjs');
|
|
8
|
+
*
|
|
9
|
+
* const storage = new Storage({ root: './uploads' });
|
|
10
|
+
* await storage.put('avatars/user1.png', buffer);
|
|
11
|
+
* const file = await storage.get('avatars/user1.png');
|
|
12
|
+
* const url = storage.url('avatars/user1.png');
|
|
13
|
+
*
|
|
14
|
+
* // Upload middleware
|
|
15
|
+
* app.post('/upload', Storage.upload({ dest: 'uploads', maxSize: 5 * 1024 * 1024 }), handler);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const https = require('https');
|
|
24
|
+
const http = require('http');
|
|
25
|
+
|
|
26
|
+
class Storage {
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.driver = options.driver || 'local';
|
|
29
|
+
this.root = options.root || './storage';
|
|
30
|
+
this.baseURL = options.baseURL || '/storage';
|
|
31
|
+
|
|
32
|
+
// S3 config
|
|
33
|
+
this.s3 = {
|
|
34
|
+
bucket: options.bucket || process.env.S3_BUCKET,
|
|
35
|
+
region: options.region || process.env.S3_REGION || 'us-east-1',
|
|
36
|
+
accessKey: options.accessKey || process.env.S3_ACCESS_KEY,
|
|
37
|
+
secretKey: options.secretKey || process.env.S3_SECRET_KEY,
|
|
38
|
+
endpoint: options.endpoint || process.env.S3_ENDPOINT,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (this.driver === 'local') {
|
|
42
|
+
if (!fs.existsSync(this.root)) fs.mkdirSync(this.root, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Store a file */
|
|
47
|
+
async put(filePath, content, options = {}) {
|
|
48
|
+
if (this.driver === 's3') return this._s3Put(filePath, content, options);
|
|
49
|
+
|
|
50
|
+
const fullPath = path.join(this.root, filePath);
|
|
51
|
+
const dir = path.dirname(fullPath);
|
|
52
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const data = typeof content === 'string' ? Buffer.from(content) : content;
|
|
55
|
+
fs.writeFileSync(fullPath, data);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
path: filePath,
|
|
59
|
+
size: data.length,
|
|
60
|
+
url: this.url(filePath),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Get file contents */
|
|
65
|
+
async get(filePath) {
|
|
66
|
+
if (this.driver === 's3') return this._s3Get(filePath);
|
|
67
|
+
|
|
68
|
+
const fullPath = path.join(this.root, filePath);
|
|
69
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
70
|
+
return fs.readFileSync(fullPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Get file as string */
|
|
74
|
+
async getText(filePath) {
|
|
75
|
+
const buf = await this.get(filePath);
|
|
76
|
+
return buf ? buf.toString('utf-8') : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Check if file exists */
|
|
80
|
+
async exists(filePath) {
|
|
81
|
+
if (this.driver === 's3') return this._s3Exists(filePath);
|
|
82
|
+
return fs.existsSync(path.join(this.root, filePath));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Delete a file */
|
|
86
|
+
async delete(filePath) {
|
|
87
|
+
if (this.driver === 's3') return this._s3Delete(filePath);
|
|
88
|
+
|
|
89
|
+
const fullPath = path.join(this.root, filePath);
|
|
90
|
+
if (fs.existsSync(fullPath)) {
|
|
91
|
+
fs.unlinkSync(fullPath);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Copy a file */
|
|
98
|
+
async copy(from, to) {
|
|
99
|
+
const content = await this.get(from);
|
|
100
|
+
if (!content) throw new Error(`File not found: ${from}`);
|
|
101
|
+
return this.put(to, content);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Move a file */
|
|
105
|
+
async move(from, to) {
|
|
106
|
+
await this.copy(from, to);
|
|
107
|
+
await this.delete(from);
|
|
108
|
+
return { path: to, url: this.url(to) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Get file metadata */
|
|
112
|
+
async stat(filePath) {
|
|
113
|
+
if (this.driver === 's3') return this._s3Head(filePath);
|
|
114
|
+
|
|
115
|
+
const fullPath = path.join(this.root, filePath);
|
|
116
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
117
|
+
const stat = fs.statSync(fullPath);
|
|
118
|
+
return {
|
|
119
|
+
size: stat.size,
|
|
120
|
+
created: stat.birthtime,
|
|
121
|
+
modified: stat.mtime,
|
|
122
|
+
isFile: stat.isFile(),
|
|
123
|
+
isDirectory: stat.isDirectory(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** List files in directory */
|
|
128
|
+
async list(directory = '', options = {}) {
|
|
129
|
+
if (this.driver === 's3') return this._s3List(directory);
|
|
130
|
+
|
|
131
|
+
const fullPath = path.join(this.root, directory);
|
|
132
|
+
if (!fs.existsSync(fullPath)) return [];
|
|
133
|
+
|
|
134
|
+
const items = fs.readdirSync(fullPath, { withFileTypes: true });
|
|
135
|
+
return items.map(item => ({
|
|
136
|
+
name: item.name,
|
|
137
|
+
path: path.join(directory, item.name).replace(/\\/g, '/'),
|
|
138
|
+
isFile: item.isFile(),
|
|
139
|
+
isDirectory: item.isDirectory(),
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Get public URL for file */
|
|
144
|
+
url(filePath) {
|
|
145
|
+
if (this.driver === 's3') {
|
|
146
|
+
const endpoint = this.s3.endpoint || `https://${this.s3.bucket}.s3.${this.s3.region}.amazonaws.com`;
|
|
147
|
+
return `${endpoint}/${filePath}`;
|
|
148
|
+
}
|
|
149
|
+
return `${this.baseURL}/${filePath}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Create a hash of file contents */
|
|
153
|
+
async hash(filePath, algorithm = 'md5') {
|
|
154
|
+
const content = await this.get(filePath);
|
|
155
|
+
if (!content) return null;
|
|
156
|
+
return crypto.createHash(algorithm).update(content).digest('hex');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Generate a unique filename */
|
|
160
|
+
static uniqueName(originalName) {
|
|
161
|
+
const ext = path.extname(originalName);
|
|
162
|
+
const timestamp = Date.now().toString(36);
|
|
163
|
+
const random = crypto.randomBytes(6).toString('hex');
|
|
164
|
+
return `${timestamp}-${random}${ext}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Upload middleware for Volt/Express apps */
|
|
168
|
+
static upload(options = {}) {
|
|
169
|
+
const dest = options.dest || 'uploads';
|
|
170
|
+
const maxSize = options.maxSize || 10 * 1024 * 1024; // 10MB
|
|
171
|
+
const allowedTypes = options.allowedTypes || null; // ['image/png', 'image/jpeg']
|
|
172
|
+
const storage = new Storage({ root: dest });
|
|
173
|
+
|
|
174
|
+
return async (req, res) => {
|
|
175
|
+
if (!req.files && !req.body?._files) return;
|
|
176
|
+
|
|
177
|
+
const files = req.files || req.body?._files || [];
|
|
178
|
+
const uploaded = [];
|
|
179
|
+
|
|
180
|
+
for (const file of (Array.isArray(files) ? files : [files])) {
|
|
181
|
+
// Size check
|
|
182
|
+
if (file.data && file.data.length > maxSize) {
|
|
183
|
+
res.json({ error: `File too large. Maximum: ${(maxSize / 1024 / 1024).toFixed(0)}MB` }, 413);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Type check
|
|
188
|
+
if (allowedTypes && !allowedTypes.includes(file.type)) {
|
|
189
|
+
res.json({ error: `File type not allowed: ${file.type}` }, 415);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const filename = Storage.uniqueName(file.name || 'upload');
|
|
194
|
+
const result = await storage.put(filename, file.data);
|
|
195
|
+
uploaded.push({ ...result, originalName: file.name, type: file.type });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
req.uploadedFiles = uploaded;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Serve static files middleware */
|
|
203
|
+
static serve(root, options = {}) {
|
|
204
|
+
const MIME_TYPES = {
|
|
205
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
206
|
+
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
207
|
+
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
|
208
|
+
'.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2',
|
|
209
|
+
'.ttf': 'font/ttf', '.pdf': 'application/pdf', '.mp4': 'video/mp4',
|
|
210
|
+
'.webp': 'image/webp', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
|
|
211
|
+
'.wav': 'audio/wav', '.txt': 'text/plain', '.xml': 'application/xml',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const prefix = options.prefix || '';
|
|
215
|
+
|
|
216
|
+
return (req, res) => {
|
|
217
|
+
if (req.method !== 'GET') return;
|
|
218
|
+
|
|
219
|
+
let urlPath = req.url.split('?')[0];
|
|
220
|
+
if (prefix && !urlPath.startsWith(prefix)) return;
|
|
221
|
+
if (prefix) urlPath = urlPath.slice(prefix.length);
|
|
222
|
+
|
|
223
|
+
const filePath = path.join(root, urlPath);
|
|
224
|
+
|
|
225
|
+
// Security: prevent directory traversal
|
|
226
|
+
if (!filePath.startsWith(path.resolve(root))) return;
|
|
227
|
+
|
|
228
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return;
|
|
229
|
+
|
|
230
|
+
const ext = path.extname(filePath);
|
|
231
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
232
|
+
|
|
233
|
+
// Cache headers
|
|
234
|
+
if (options.maxAge) {
|
|
235
|
+
res.setHeader('Cache-Control', `public, max-age=${options.maxAge}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
res.setHeader('Content-Type', contentType);
|
|
239
|
+
const stream = fs.createReadStream(filePath);
|
|
240
|
+
stream.pipe(res);
|
|
241
|
+
return false; // Stop middleware chain
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ===== S3 METHODS =====
|
|
246
|
+
|
|
247
|
+
async _s3Put(key, content, options = {}) {
|
|
248
|
+
const data = typeof content === 'string' ? Buffer.from(content) : content;
|
|
249
|
+
const contentType = options.contentType || 'application/octet-stream';
|
|
250
|
+
|
|
251
|
+
await this._s3Request('PUT', `/${key}`, data, {
|
|
252
|
+
'Content-Type': contentType,
|
|
253
|
+
'Content-Length': data.length,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return { path: key, size: data.length, url: this.url(key) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async _s3Get(key) {
|
|
260
|
+
try {
|
|
261
|
+
return await this._s3Request('GET', `/${key}`);
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async _s3Delete(key) {
|
|
268
|
+
try {
|
|
269
|
+
await this._s3Request('DELETE', `/${key}`);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async _s3Exists(key) {
|
|
277
|
+
try {
|
|
278
|
+
await this._s3Request('HEAD', `/${key}`);
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async _s3Head(key) {
|
|
286
|
+
try {
|
|
287
|
+
const res = await this._s3Request('HEAD', `/${key}`);
|
|
288
|
+
return { size: parseInt(res.headers?.['content-length'] || 0) };
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async _s3List(prefix = '') {
|
|
295
|
+
const params = prefix ? `?prefix=${encodeURIComponent(prefix)}` : '';
|
|
296
|
+
const body = await this._s3Request('GET', `/${params}`);
|
|
297
|
+
return body; // Raw XML — consumer should parse
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_s3Request(method, path, body, extraHeaders = {}) {
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
const date = new Date().toUTCString();
|
|
303
|
+
const host = this.s3.endpoint
|
|
304
|
+
? new URL(this.s3.endpoint).host
|
|
305
|
+
: `${this.s3.bucket}.s3.${this.s3.region}.amazonaws.com`;
|
|
306
|
+
|
|
307
|
+
const headers = {
|
|
308
|
+
Host: host,
|
|
309
|
+
Date: date,
|
|
310
|
+
...extraHeaders,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Simple AWS v2 signature
|
|
314
|
+
if (this.s3.accessKey && this.s3.secretKey) {
|
|
315
|
+
const stringToSign = `${method}\n\n${headers['Content-Type'] || ''}\n${date}\n/${this.s3.bucket}${path}`;
|
|
316
|
+
const signature = crypto.createHmac('sha1', this.s3.secretKey).update(stringToSign).digest('base64');
|
|
317
|
+
headers.Authorization = `AWS ${this.s3.accessKey}:${signature}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const options = {
|
|
321
|
+
hostname: host,
|
|
322
|
+
port: 443,
|
|
323
|
+
path,
|
|
324
|
+
method,
|
|
325
|
+
headers,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const req = https.request(options, (res) => {
|
|
329
|
+
const chunks = [];
|
|
330
|
+
res.on('data', c => chunks.push(c));
|
|
331
|
+
res.on('end', () => {
|
|
332
|
+
const buf = Buffer.concat(chunks);
|
|
333
|
+
if (res.statusCode >= 400) {
|
|
334
|
+
reject(new Error(`S3 ${method} ${path} failed: ${res.statusCode}`));
|
|
335
|
+
} else {
|
|
336
|
+
resolve(method === 'HEAD' ? { headers: res.headers } : buf);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
req.on('error', reject);
|
|
342
|
+
if (body) req.write(body);
|
|
343
|
+
req.end();
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = { Storage };
|