@kadi.build/file-sharing 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/README.md +477 -0
- package/package.json +60 -0
- package/src/DownloadMonitor.js +199 -0
- package/src/EventNotifier.js +178 -0
- package/src/FileSharingServer.js +598 -0
- package/src/HttpServerProvider.js +538 -0
- package/src/MonitoringDashboard.js +181 -0
- package/src/S3Server.js +984 -0
- package/src/ShutdownManager.js +135 -0
- package/src/index.js +69 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HttpServerProvider - HTTP file server for @kadi.build/file-sharing
|
|
3
|
+
*
|
|
4
|
+
* Provides HTTP file serving with:
|
|
5
|
+
* - Static file serving with correct MIME types
|
|
6
|
+
* - Directory listing (HTML and JSON)
|
|
7
|
+
* - Range requests (video streaming)
|
|
8
|
+
* - CORS support
|
|
9
|
+
* - Optional basic authentication
|
|
10
|
+
* - SSL/TLS support
|
|
11
|
+
* - Download event tracking
|
|
12
|
+
* - Connection tracking for graceful shutdown
|
|
13
|
+
*
|
|
14
|
+
* Migrated from src/providers/httpServerProvider.js
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
18
|
+
import http from 'http';
|
|
19
|
+
import https from 'https';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import fs from 'fs/promises';
|
|
22
|
+
import { createReadStream, statSync } from 'fs';
|
|
23
|
+
import mime from 'mime-types';
|
|
24
|
+
|
|
25
|
+
export class HttpServerProvider extends EventEmitter {
|
|
26
|
+
constructor(config = {}) {
|
|
27
|
+
super();
|
|
28
|
+
|
|
29
|
+
this.config = {
|
|
30
|
+
port: 3000,
|
|
31
|
+
host: '0.0.0.0',
|
|
32
|
+
staticDir: process.cwd(),
|
|
33
|
+
enableDirectoryListing: true,
|
|
34
|
+
cors: true,
|
|
35
|
+
auth: null,
|
|
36
|
+
ssl: null,
|
|
37
|
+
maxFileSize: null,
|
|
38
|
+
uploadDir: null,
|
|
39
|
+
...config
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
this.server = null;
|
|
43
|
+
this.isRunning = false;
|
|
44
|
+
this.activeConnections = new Map();
|
|
45
|
+
this.startTime = null;
|
|
46
|
+
this.requestCount = 0;
|
|
47
|
+
this.errorCount = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Start the HTTP server
|
|
52
|
+
* @returns {{ port: number, host: string, url: string }}
|
|
53
|
+
*/
|
|
54
|
+
async start() {
|
|
55
|
+
if (this.isRunning) {
|
|
56
|
+
return { port: this.config.port, host: this.config.host, already: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const requestHandler = this._createRequestHandler();
|
|
61
|
+
|
|
62
|
+
// Create HTTP or HTTPS server
|
|
63
|
+
if (this.config.ssl) {
|
|
64
|
+
this.server = https.createServer({
|
|
65
|
+
key: this.config.ssl.key,
|
|
66
|
+
cert: this.config.ssl.cert,
|
|
67
|
+
...(this.config.ssl.ca ? { ca: this.config.ssl.ca } : {})
|
|
68
|
+
}, requestHandler);
|
|
69
|
+
} else {
|
|
70
|
+
this.server = http.createServer(requestHandler);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Track connections for graceful shutdown
|
|
74
|
+
this.server.on('connection', (socket) => {
|
|
75
|
+
const id = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
76
|
+
this.activeConnections.set(id, socket);
|
|
77
|
+
socket.on('close', () => this.activeConnections.delete(id));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.server.listen(this.config.port, this.config.host, () => {
|
|
81
|
+
const addr = this.server.address();
|
|
82
|
+
this.config.port = addr.port;
|
|
83
|
+
this.isRunning = true;
|
|
84
|
+
this.startTime = new Date();
|
|
85
|
+
|
|
86
|
+
const protocol = this.config.ssl ? 'https' : 'http';
|
|
87
|
+
const info = {
|
|
88
|
+
port: addr.port,
|
|
89
|
+
host: this.config.host,
|
|
90
|
+
url: `${protocol}://${this.config.host}:${addr.port}`
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
this.emit('started', info);
|
|
94
|
+
resolve(info);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this.server.on('error', (error) => {
|
|
98
|
+
this.emit('error', error);
|
|
99
|
+
reject(error);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop the HTTP server
|
|
106
|
+
*/
|
|
107
|
+
async stop() {
|
|
108
|
+
if (!this.isRunning) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
// Close active connections
|
|
114
|
+
for (const [id, socket] of this.activeConnections) {
|
|
115
|
+
socket.destroy();
|
|
116
|
+
this.activeConnections.delete(id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.server.close(() => {
|
|
120
|
+
this.isRunning = false;
|
|
121
|
+
this.server = null;
|
|
122
|
+
this.emit('stopped');
|
|
123
|
+
resolve();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get active connection count
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
getActiveConnections() {
|
|
133
|
+
return this.activeConnections.size;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Force close all active connections
|
|
138
|
+
*/
|
|
139
|
+
closeAllConnections() {
|
|
140
|
+
for (const [id, socket] of this.activeConnections) {
|
|
141
|
+
socket.destroy();
|
|
142
|
+
this.activeConnections.delete(id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create the main request handler
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
_createRequestHandler() {
|
|
151
|
+
return async (req, res) => {
|
|
152
|
+
this.requestCount++;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// CORS headers
|
|
156
|
+
if (this.config.cors) {
|
|
157
|
+
this._setCorsHeaders(res);
|
|
158
|
+
|
|
159
|
+
if (req.method === 'OPTIONS') {
|
|
160
|
+
res.writeHead(204);
|
|
161
|
+
res.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Authentication
|
|
167
|
+
if (this.config.auth) {
|
|
168
|
+
if (!this._checkAuth(req)) {
|
|
169
|
+
// Set appropriate WWW-Authenticate based on configured scheme
|
|
170
|
+
const auth = this.config.auth;
|
|
171
|
+
if (auth.apiKey || auth.bearerToken) {
|
|
172
|
+
res.setHeader('WWW-Authenticate', 'Bearer');
|
|
173
|
+
} else {
|
|
174
|
+
res.setHeader('WWW-Authenticate', 'Basic realm="File Server"');
|
|
175
|
+
}
|
|
176
|
+
res.writeHead(401);
|
|
177
|
+
res.end('Unauthorized');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Route request
|
|
183
|
+
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
184
|
+
const urlPath = decodeURIComponent(urlObj.pathname);
|
|
185
|
+
const filePath = path.join(this.config.staticDir, urlPath);
|
|
186
|
+
|
|
187
|
+
// Security: prevent directory traversal
|
|
188
|
+
const resolvedStatic = path.resolve(this.config.staticDir);
|
|
189
|
+
const resolvedFile = path.resolve(filePath);
|
|
190
|
+
if (!resolvedFile.startsWith(resolvedStatic)) {
|
|
191
|
+
res.writeHead(403);
|
|
192
|
+
res.end('Forbidden');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Handle file upload
|
|
197
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
198
|
+
await this._handleUpload(req, res, resolvedFile, urlPath);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await this._handleFileRequest(req, res, resolvedFile, urlPath);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
this.errorCount++;
|
|
205
|
+
this.emit('error', error);
|
|
206
|
+
if (!res.headersSent) {
|
|
207
|
+
res.writeHead(500);
|
|
208
|
+
res.end('Internal Server Error');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Set CORS headers on response
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
_setCorsHeaders(res) {
|
|
219
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
220
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, HEAD');
|
|
221
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Range, Accept');
|
|
222
|
+
res.setHeader('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Handle file request (GET/HEAD)
|
|
227
|
+
* @private
|
|
228
|
+
*/
|
|
229
|
+
async _handleFileRequest(req, res, filePath, urlPath) {
|
|
230
|
+
try {
|
|
231
|
+
const stats = await fs.stat(filePath);
|
|
232
|
+
|
|
233
|
+
if (stats.isDirectory()) {
|
|
234
|
+
// Check for index.html
|
|
235
|
+
const indexPath = path.join(filePath, 'index.html');
|
|
236
|
+
try {
|
|
237
|
+
await fs.access(indexPath);
|
|
238
|
+
return this._serveFile(req, res, indexPath);
|
|
239
|
+
} catch {
|
|
240
|
+
// No index.html, show directory listing
|
|
241
|
+
if (this.config.enableDirectoryListing) {
|
|
242
|
+
return this._serveDirectoryListing(req, res, filePath, urlPath);
|
|
243
|
+
} else {
|
|
244
|
+
res.writeHead(403);
|
|
245
|
+
res.end('Directory listing disabled');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
return this._serveFile(req, res, filePath);
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error.code === 'ENOENT') {
|
|
253
|
+
res.writeHead(404);
|
|
254
|
+
res.end('Not Found');
|
|
255
|
+
} else {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Serve a file with proper headers, range support, and event tracking
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
async _serveFile(req, res, filePath) {
|
|
266
|
+
const stats = await fs.stat(filePath);
|
|
267
|
+
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
|
|
268
|
+
|
|
269
|
+
// Emit download start
|
|
270
|
+
this.emit('download:start', {
|
|
271
|
+
file: filePath,
|
|
272
|
+
size: stats.size,
|
|
273
|
+
clientIp: req.socket.remoteAddress
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const startTime = Date.now();
|
|
277
|
+
|
|
278
|
+
// ETag and Last-Modified
|
|
279
|
+
const etag = `"${stats.size}-${stats.mtime.getTime()}"`;
|
|
280
|
+
res.setHeader('ETag', etag);
|
|
281
|
+
res.setHeader('Last-Modified', stats.mtime.toUTCString());
|
|
282
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
283
|
+
|
|
284
|
+
// Conditional request handling
|
|
285
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
286
|
+
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
287
|
+
res.writeHead(304);
|
|
288
|
+
res.end();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const ifModifiedSince = req.headers['if-modified-since'];
|
|
293
|
+
if (ifModifiedSince && new Date(ifModifiedSince) >= stats.mtime) {
|
|
294
|
+
res.writeHead(304);
|
|
295
|
+
res.end();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle range requests (for video streaming)
|
|
300
|
+
const range = req.headers.range;
|
|
301
|
+
if (range) {
|
|
302
|
+
const parts = range.replace(/bytes=/, '').split('-');
|
|
303
|
+
const start = parseInt(parts[0], 10);
|
|
304
|
+
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
|
|
305
|
+
|
|
306
|
+
if (start >= stats.size || start < 0 || end >= stats.size) {
|
|
307
|
+
res.writeHead(416, {
|
|
308
|
+
'Content-Range': `bytes */${stats.size}`
|
|
309
|
+
});
|
|
310
|
+
res.end();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const chunkSize = end - start + 1;
|
|
315
|
+
|
|
316
|
+
res.writeHead(206, {
|
|
317
|
+
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
|
|
318
|
+
'Accept-Ranges': 'bytes',
|
|
319
|
+
'Content-Length': chunkSize,
|
|
320
|
+
'Content-Type': mimeType
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const stream = createReadStream(filePath, { start, end });
|
|
324
|
+
stream.pipe(res);
|
|
325
|
+
} else {
|
|
326
|
+
res.writeHead(200, {
|
|
327
|
+
'Content-Length': stats.size,
|
|
328
|
+
'Content-Type': mimeType,
|
|
329
|
+
'Accept-Ranges': 'bytes'
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const stream = createReadStream(filePath);
|
|
333
|
+
stream.pipe(res);
|
|
334
|
+
|
|
335
|
+
stream.on('end', () => {
|
|
336
|
+
this.emit('download:complete', {
|
|
337
|
+
file: filePath,
|
|
338
|
+
size: stats.size,
|
|
339
|
+
duration: Date.now() - startTime,
|
|
340
|
+
clientIp: req.socket.remoteAddress
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
stream.on('error', (err) => {
|
|
345
|
+
this.emit('download:error', {
|
|
346
|
+
file: filePath,
|
|
347
|
+
error: err
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Serve directory listing in HTML or JSON format
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
async _serveDirectoryListing(req, res, dirPath, urlPath) {
|
|
358
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
359
|
+
|
|
360
|
+
// Check Accept header for JSON response
|
|
361
|
+
const acceptsJson = req.headers.accept?.includes('application/json');
|
|
362
|
+
|
|
363
|
+
if (acceptsJson) {
|
|
364
|
+
const items = await Promise.all(entries.map(async (entry) => {
|
|
365
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
366
|
+
try {
|
|
367
|
+
const stats = await fs.stat(fullPath);
|
|
368
|
+
return {
|
|
369
|
+
name: entry.name,
|
|
370
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
371
|
+
size: stats.size,
|
|
372
|
+
modified: stats.mtime.toISOString()
|
|
373
|
+
};
|
|
374
|
+
} catch {
|
|
375
|
+
return {
|
|
376
|
+
name: entry.name,
|
|
377
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
378
|
+
size: 0,
|
|
379
|
+
modified: null
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
385
|
+
res.end(JSON.stringify({ path: urlPath, entries: items }));
|
|
386
|
+
} else {
|
|
387
|
+
// HTML response
|
|
388
|
+
const html = this._generateDirectoryHtml(urlPath, entries);
|
|
389
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
390
|
+
res.end(html);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Handle file upload (PUT/POST)
|
|
396
|
+
* @private
|
|
397
|
+
*/
|
|
398
|
+
async _handleUpload(req, res, filePath, urlPath) {
|
|
399
|
+
try {
|
|
400
|
+
// Ensure parent directory exists
|
|
401
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
402
|
+
|
|
403
|
+
const chunks = [];
|
|
404
|
+
for await (const chunk of req) {
|
|
405
|
+
chunks.push(chunk);
|
|
406
|
+
}
|
|
407
|
+
const body = Buffer.concat(chunks);
|
|
408
|
+
|
|
409
|
+
await fs.writeFile(filePath, body);
|
|
410
|
+
|
|
411
|
+
this.emit('upload:complete', {
|
|
412
|
+
file: filePath,
|
|
413
|
+
size: body.length,
|
|
414
|
+
clientIp: req.socket.remoteAddress
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
res.writeHead(201, { 'Content-Length': 0 });
|
|
418
|
+
res.end();
|
|
419
|
+
} catch (error) {
|
|
420
|
+
this.emit('upload:error', { file: filePath, error });
|
|
421
|
+
res.writeHead(500);
|
|
422
|
+
res.end('Upload failed');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Generate HTML directory listing
|
|
428
|
+
* @private
|
|
429
|
+
*/
|
|
430
|
+
_generateDirectoryHtml(urlPath, entries) {
|
|
431
|
+
// Sort: directories first, then files
|
|
432
|
+
const sorted = [...entries].sort((a, b) => {
|
|
433
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
434
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
435
|
+
return a.name.localeCompare(b.name);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const items = sorted.map(entry => {
|
|
439
|
+
const icon = entry.isDirectory() ? '📁' : '📄';
|
|
440
|
+
const href = path.posix.join(urlPath, entry.name);
|
|
441
|
+
const name = entry.isDirectory() ? `${entry.name}/` : entry.name;
|
|
442
|
+
return `<li><a href="${href}">${icon} ${name}</a></li>`;
|
|
443
|
+
}).join('\n ');
|
|
444
|
+
|
|
445
|
+
return `<!DOCTYPE html>
|
|
446
|
+
<html>
|
|
447
|
+
<head>
|
|
448
|
+
<title>Index of ${urlPath}</title>
|
|
449
|
+
<style>
|
|
450
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; max-width: 900px; margin: 0 auto; }
|
|
451
|
+
h1 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; }
|
|
452
|
+
ul { list-style: none; padding: 0; }
|
|
453
|
+
li { padding: 8px 4px; border-bottom: 1px solid #f0f0f0; }
|
|
454
|
+
a { text-decoration: none; color: #0066cc; font-size: 15px; }
|
|
455
|
+
a:hover { text-decoration: underline; }
|
|
456
|
+
.footer { margin-top: 20px; color: #999; font-size: 12px; }
|
|
457
|
+
</style>
|
|
458
|
+
</head>
|
|
459
|
+
<body>
|
|
460
|
+
<h1>Index of ${urlPath}</h1>
|
|
461
|
+
<ul>
|
|
462
|
+
${urlPath !== '/' ? '<li><a href="..">⬆️ Parent Directory</a></li>' : ''}
|
|
463
|
+
${items}
|
|
464
|
+
</ul>
|
|
465
|
+
<div class="footer">Powered by @kadi.build/file-sharing</div>
|
|
466
|
+
</body>
|
|
467
|
+
</html>`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Check authentication on incoming request.
|
|
472
|
+
*
|
|
473
|
+
* Supports three auth schemes (configured via `config.auth`):
|
|
474
|
+
*
|
|
475
|
+
* 1. **API Key** — `config.auth.apiKey`
|
|
476
|
+
* Checked against: `Authorization: Bearer <key>` header,
|
|
477
|
+
* `X-API-Key: <key>` header, or `?apiKey=<key>` query param.
|
|
478
|
+
*
|
|
479
|
+
* 2. **Bearer Token** — `config.auth.bearerToken`
|
|
480
|
+
* Checked against: `Authorization: Bearer <token>` header.
|
|
481
|
+
*
|
|
482
|
+
* 3. **Basic Auth** — `config.auth.username` + `config.auth.password`
|
|
483
|
+
* Checked against: `Authorization: Basic <base64>` header.
|
|
484
|
+
*
|
|
485
|
+
* If multiple schemes are configured, ANY valid match succeeds (OR logic).
|
|
486
|
+
*
|
|
487
|
+
* @private
|
|
488
|
+
* @param {import('http').IncomingMessage} req
|
|
489
|
+
* @returns {boolean}
|
|
490
|
+
*/
|
|
491
|
+
_checkAuth(req) {
|
|
492
|
+
const authHeader = req.headers.authorization || '';
|
|
493
|
+
const auth = this.config.auth;
|
|
494
|
+
|
|
495
|
+
// --- API Key auth ---
|
|
496
|
+
if (auth.apiKey) {
|
|
497
|
+
// Check Authorization: Bearer <apiKey>
|
|
498
|
+
if (authHeader.startsWith('Bearer ') && authHeader.slice(7) === auth.apiKey) {
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
// Check X-API-Key header
|
|
502
|
+
if (req.headers['x-api-key'] === auth.apiKey) {
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
// Check ?apiKey= query param
|
|
506
|
+
try {
|
|
507
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
508
|
+
if (url.searchParams.get('apiKey') === auth.apiKey) {
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
// malformed URL — ignore
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// --- Bearer token auth ---
|
|
517
|
+
if (auth.bearerToken) {
|
|
518
|
+
if (authHeader.startsWith('Bearer ') && authHeader.slice(7) === auth.bearerToken) {
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// --- Basic auth ---
|
|
524
|
+
if (auth.username && auth.password) {
|
|
525
|
+
if (authHeader.startsWith('Basic ')) {
|
|
526
|
+
const credentials = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
527
|
+
const [username, password] = credentials.split(':');
|
|
528
|
+
if (username === auth.username && password === auth.password) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export default HttpServerProvider;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MonitoringDashboard - Console monitoring dashboard for @kadi.build/file-sharing
|
|
3
|
+
*
|
|
4
|
+
* Provides a console-based real-time dashboard showing server status,
|
|
5
|
+
* active downloads, transfer statistics, and progress information.
|
|
6
|
+
*
|
|
7
|
+
* Migrated from src/monitoringDashboard.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
|
|
12
|
+
export class MonitoringDashboard extends EventEmitter {
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
super();
|
|
15
|
+
|
|
16
|
+
this.config = {
|
|
17
|
+
refreshInterval: 1000,
|
|
18
|
+
showProgressBars: true,
|
|
19
|
+
maxDisplayItems: 10,
|
|
20
|
+
...config
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
this.isRunning = false;
|
|
24
|
+
this._intervalId = null;
|
|
25
|
+
this._stats = {
|
|
26
|
+
totalBytes: 0,
|
|
27
|
+
totalDownloads: 0,
|
|
28
|
+
activeCount: 0,
|
|
29
|
+
activeDownloads: [],
|
|
30
|
+
averageSpeed: 0,
|
|
31
|
+
peakConcurrent: 0,
|
|
32
|
+
uptime: 0
|
|
33
|
+
};
|
|
34
|
+
this._serverInfo = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Start the monitoring dashboard
|
|
39
|
+
* @returns {{ running: boolean }}
|
|
40
|
+
*/
|
|
41
|
+
async start() {
|
|
42
|
+
if (this.isRunning) {
|
|
43
|
+
return { running: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.isRunning = true;
|
|
47
|
+
|
|
48
|
+
this._intervalId = setInterval(() => {
|
|
49
|
+
this._render();
|
|
50
|
+
}, this.config.refreshInterval);
|
|
51
|
+
|
|
52
|
+
this.emit('started');
|
|
53
|
+
return { running: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop the monitoring dashboard
|
|
58
|
+
*/
|
|
59
|
+
async stop() {
|
|
60
|
+
if (!this.isRunning) return;
|
|
61
|
+
|
|
62
|
+
if (this._intervalId) {
|
|
63
|
+
clearInterval(this._intervalId);
|
|
64
|
+
this._intervalId = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.isRunning = false;
|
|
68
|
+
this.emit('stopped');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Update dashboard statistics
|
|
73
|
+
* @param {object} stats - New stats to display
|
|
74
|
+
*/
|
|
75
|
+
updateStats(stats) {
|
|
76
|
+
this._stats = { ...this._stats, ...stats };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set server information
|
|
81
|
+
* @param {object} info - Server info (localUrl, publicUrl, etc.)
|
|
82
|
+
*/
|
|
83
|
+
setServerInfo(info) {
|
|
84
|
+
this._serverInfo = info;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render the dashboard to console
|
|
89
|
+
* @private
|
|
90
|
+
*/
|
|
91
|
+
_render() {
|
|
92
|
+
if (!this.isRunning) return;
|
|
93
|
+
|
|
94
|
+
const lines = [];
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('╔══════════════════════════════════════════════════════╗');
|
|
97
|
+
lines.push('║ KĀDI File Sharing Monitor ║');
|
|
98
|
+
lines.push('╠══════════════════════════════════════════════════════╣');
|
|
99
|
+
|
|
100
|
+
// Server info
|
|
101
|
+
if (this._serverInfo) {
|
|
102
|
+
lines.push(`║ Local: ${(this._serverInfo.localUrl || 'N/A').padEnd(44)}║`);
|
|
103
|
+
if (this._serverInfo.publicUrl) {
|
|
104
|
+
lines.push(`║ Public: ${this._serverInfo.publicUrl.padEnd(44)}║`);
|
|
105
|
+
}
|
|
106
|
+
if (this._serverInfo.s3Endpoint) {
|
|
107
|
+
lines.push(`║ S3: ${this._serverInfo.s3Endpoint.padEnd(44)}║`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
lines.push('╠══════════════════════════════════════════════════════╣');
|
|
112
|
+
|
|
113
|
+
// Stats
|
|
114
|
+
const stats = this._stats;
|
|
115
|
+
lines.push(`║ Downloads: ${String(stats.totalDownloads).padEnd(8)} Active: ${String(stats.activeCount).padEnd(8)} ║`);
|
|
116
|
+
lines.push(`║ Total: ${this._formatBytes(stats.totalBytes).padEnd(8)} Speed: ${this._formatSpeed(stats.averageSpeed).padEnd(8)} ║`);
|
|
117
|
+
|
|
118
|
+
// Active downloads
|
|
119
|
+
if (stats.activeDownloads && stats.activeDownloads.length > 0) {
|
|
120
|
+
lines.push('╠══════════════════════════════════════════════════════╣');
|
|
121
|
+
lines.push('║ Active Downloads: ║');
|
|
122
|
+
|
|
123
|
+
const displayed = stats.activeDownloads.slice(0, this.config.maxDisplayItems);
|
|
124
|
+
for (const dl of displayed) {
|
|
125
|
+
const name = (dl.file || dl.id || 'unknown').slice(-30).padEnd(30);
|
|
126
|
+
const progress = dl.progress ? `${dl.progress.toFixed(0)}%` : '?%';
|
|
127
|
+
|
|
128
|
+
if (this.config.showProgressBars) {
|
|
129
|
+
const bar = this._progressBar(dl.progress || 0, 15);
|
|
130
|
+
lines.push(`║ ${name} ${bar} ${progress.padStart(4)} ║`);
|
|
131
|
+
} else {
|
|
132
|
+
lines.push(`║ ${name} ${progress.padStart(4)} ║`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lines.push('╚══════════════════════════════════════════════════════╝');
|
|
138
|
+
|
|
139
|
+
// Output (in a real console dashboard, this would use cursor movement)
|
|
140
|
+
this.emit('render', lines.join('\n'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Generate a progress bar string
|
|
145
|
+
* @param {number} percent - Progress percentage (0-100)
|
|
146
|
+
* @param {number} width - Bar width in characters
|
|
147
|
+
* @returns {string}
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
_progressBar(percent, width = 20) {
|
|
151
|
+
const filled = Math.round((percent / 100) * width);
|
|
152
|
+
const empty = width - filled;
|
|
153
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Format bytes to human-readable string
|
|
158
|
+
* @param {number} bytes
|
|
159
|
+
* @returns {string}
|
|
160
|
+
* @private
|
|
161
|
+
*/
|
|
162
|
+
_formatBytes(bytes) {
|
|
163
|
+
if (bytes === 0) return '0 B';
|
|
164
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
165
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
166
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Format speed to human-readable string
|
|
171
|
+
* @param {number} bytesPerSec
|
|
172
|
+
* @returns {string}
|
|
173
|
+
* @private
|
|
174
|
+
*/
|
|
175
|
+
_formatSpeed(bytesPerSec) {
|
|
176
|
+
if (!bytesPerSec || bytesPerSec === 0) return '0 B/s';
|
|
177
|
+
return `${this._formatBytes(bytesPerSec)}/s`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default MonitoringDashboard;
|