@kadi.build/file-sharing 1.0.0 → 1.1.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.
- package/README.md +116 -3
- package/package.json +3 -2
- package/src/FileSharingServer.js +32 -3
- package/src/HttpServerProvider.js +270 -5
- package/src/S3Server.js +241 -19
package/README.md
CHANGED
|
@@ -7,8 +7,10 @@ Integrates [`@kadi.build/file-manager`](https://www.npmjs.com/package/@kadi.buil
|
|
|
7
7
|
|
|
8
8
|
- **HTTP File Server** — Static file serving with directory listing, range requests, CORS, and multi-scheme authentication
|
|
9
9
|
- **Local S3-Compatible API** — Emulates AWS S3 endpoints locally so you can use `@aws-sdk/client-s3` against your local filesystem
|
|
10
|
+
- **Extensible HTTP Pipeline** — Register custom middleware and routes (e.g. Docker Registry v2 endpoints) alongside built-in file serving
|
|
10
11
|
- **Tunnel Integration** — Expose your local server publicly via KĀDI (default), ngrok, serveo, localtunnel, or pinggy
|
|
11
|
-
- **Authentication** — Basic Auth, Bearer Token, and API Key (header / query param) for HTTP; AWS SigV4 / SigV2 / Bearer for S3
|
|
12
|
+
- **Authentication** — Basic Auth, Bearer Token, and API Key (header / query param) for HTTP; AWS SigV4 / SigV2 / Bearer / Basic for S3
|
|
13
|
+
- **Temporary Credentials** — Generate time-limited S3 credentials for secure sharing
|
|
12
14
|
- **Secrets Management** — Automatic `.env` loading (walks up parent directories for monorepo support) + environment variables + explicit config
|
|
13
15
|
- **Download Monitoring** — Track active downloads, progress, speed, and completion statistics
|
|
14
16
|
- **Graceful Shutdown** — Priority-ordered shutdown with timeout and force-kill
|
|
@@ -195,6 +197,7 @@ When custom S3 credentials are set (anything other than the default `minioadmin`
|
|
|
195
197
|
| **AWS SigV4** | Standard `Authorization: AWS4-HMAC-SHA256 Credential=<accessKeyId>/...` |
|
|
196
198
|
| **AWS SigV2** | Legacy `Authorization: AWS <accessKeyId>:signature` |
|
|
197
199
|
| **Bearer** | Convenience `Authorization: Bearer <accessKeyId>` |
|
|
200
|
+
| **Basic** | Docker-style `Authorization: Basic base64(accessKeyId:secretAccessKey)` |
|
|
198
201
|
| **Pre-signed URL** | `?X-Amz-Credential=<accessKeyId>/...` query param |
|
|
199
202
|
|
|
200
203
|
> With default credentials (`minioadmin`/`minioadmin`), auth is skipped for backward compatibility unless you set `enforceAuth: true` in the S3 config.
|
|
@@ -309,6 +312,12 @@ const server = new FileSharingServer({
|
|
|
309
312
|
| `s3:get` | `{bucket, key}` | S3 GetObject |
|
|
310
313
|
| `s3:put` | `{bucket, key}` | S3 PutObject |
|
|
311
314
|
| `s3:delete` | `{bucket, key}` | S3 DeleteObject |
|
|
315
|
+
| `bucket:removed` | `{bucket}` | Bucket programmatically removed |
|
|
316
|
+
| `credentials:generated` | `{accessKey, expiresAt}` | Temporary credentials created |
|
|
317
|
+
| `middleware:added` | `{name, priority}` | HTTP middleware registered |
|
|
318
|
+
| `middleware:removed` | `{name}` | HTTP middleware removed |
|
|
319
|
+
| `route:added` | `{method, path, priority}` | Custom HTTP route registered |
|
|
320
|
+
| `route:removed` | `{method, path}` | Custom HTTP route removed |
|
|
312
321
|
| `tunnel:created` | `TunnelInfo` | Tunnel established |
|
|
313
322
|
| `tunnel:closed` | — | Tunnel shut down |
|
|
314
323
|
| `tunnel:error` | `Error` | Tunnel failed (non-fatal) |
|
|
@@ -317,7 +326,7 @@ const server = new FileSharingServer({
|
|
|
317
326
|
|
|
318
327
|
### HttpServerProvider
|
|
319
328
|
|
|
320
|
-
Low-level HTTP file server with authentication.
|
|
329
|
+
Low-level HTTP file server with authentication and extensibility.
|
|
321
330
|
|
|
322
331
|
```javascript
|
|
323
332
|
import { HttpServerProvider } from '@kadi.build/file-sharing/http';
|
|
@@ -333,6 +342,67 @@ const httpServer = new HttpServerProvider({
|
|
|
333
342
|
await httpServer.start();
|
|
334
343
|
```
|
|
335
344
|
|
|
345
|
+
#### Middleware (Extensibility)
|
|
346
|
+
|
|
347
|
+
Register middleware that runs **before** built-in auth and static file handling. Higher priority runs first.
|
|
348
|
+
|
|
349
|
+
```javascript
|
|
350
|
+
// Add authentication middleware (priority 10 = runs before default handlers)
|
|
351
|
+
httpServer.addMiddleware('dockerAuth', (req, res, next) => {
|
|
352
|
+
if (req.url.startsWith('/v2')) {
|
|
353
|
+
const auth = req.headers.authorization;
|
|
354
|
+
if (!auth || !isValidToken(auth)) {
|
|
355
|
+
res.writeHead(401);
|
|
356
|
+
res.end(JSON.stringify({ errors: [{ code: 'UNAUTHORIZED' }] }));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
req.user = { token: auth };
|
|
360
|
+
}
|
|
361
|
+
next();
|
|
362
|
+
}, { priority: 10 });
|
|
363
|
+
|
|
364
|
+
// List registered middleware
|
|
365
|
+
httpServer.getMiddleware(); // [{ name: 'dockerAuth', priority: 10 }]
|
|
366
|
+
|
|
367
|
+
// Remove middleware
|
|
368
|
+
httpServer.removeMiddleware('dockerAuth');
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
#### Custom Routes (Extensibility)
|
|
372
|
+
|
|
373
|
+
Register custom route handlers with Express-style `:param` path patterns. Custom routes match **before** built-in static file serving.
|
|
374
|
+
|
|
375
|
+
```javascript
|
|
376
|
+
// Docker Registry v2 ping
|
|
377
|
+
httpServer.addCustomRoute('GET', '/v2/', (req, res) => {
|
|
378
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
379
|
+
res.end('{}');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Route with path parameters — req.params is populated automatically
|
|
383
|
+
httpServer.addCustomRoute('GET', '/v2/:name/manifests/:reference', (req, res) => {
|
|
384
|
+
console.log(req.params); // { name: 'myapp', reference: 'latest' }
|
|
385
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
386
|
+
res.end(JSON.stringify(getManifest(req.params.name, req.params.reference)));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// HEAD method support
|
|
390
|
+
httpServer.addCustomRoute('HEAD', '/v2/:name/blobs/:digest', (req, res) => {
|
|
391
|
+
res.writeHead(200, { 'Docker-Content-Digest': req.params.digest });
|
|
392
|
+
res.end();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// List / remove routes
|
|
396
|
+
httpServer.getCustomRoutes(); // [{ method, path, priority }]
|
|
397
|
+
httpServer.removeCustomRoute('GET', '/v2/'); // true if found
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
#### Request Pipeline Order
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
Request → CORS → Middleware Chain → Custom Routes → Built-in Auth → Static Files
|
|
404
|
+
```
|
|
405
|
+
|
|
336
406
|
### S3Server
|
|
337
407
|
|
|
338
408
|
Local S3-compatible API server. Files are stored **on disk**, not on AWS.
|
|
@@ -350,7 +420,8 @@ const s3 = new S3Server({
|
|
|
350
420
|
enforceAuth: true // validate even with default creds
|
|
351
421
|
});
|
|
352
422
|
|
|
353
|
-
await s3.start();
|
|
423
|
+
const info = await s3.start();
|
|
424
|
+
console.log(info.serverId); // 'kadi-s3-9000'
|
|
354
425
|
```
|
|
355
426
|
|
|
356
427
|
**Supported S3 Operations:**
|
|
@@ -363,6 +434,48 @@ await s3.start();
|
|
|
363
434
|
- `HeadObject`
|
|
364
435
|
- `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`
|
|
365
436
|
|
|
437
|
+
#### Programmatic Authentication Validation
|
|
438
|
+
|
|
439
|
+
Validate incoming requests against configured credentials. Returns a structured result — useful for layering custom auth (e.g. Docker Registry) on top of S3.
|
|
440
|
+
|
|
441
|
+
```javascript
|
|
442
|
+
const result = s3.validateAuthentication(req);
|
|
443
|
+
// Success: { success: true, user: { accessKey: 'AKIA...' } }
|
|
444
|
+
// Failure: { success: false, error: 'Invalid credentials' }
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Supports AWS SigV4, SigV2, Bearer, Basic Auth (Docker clients), and pre-signed URL query params.
|
|
448
|
+
|
|
449
|
+
#### Temporary Credentials
|
|
450
|
+
|
|
451
|
+
Generate time-limited credentials for secure sharing:
|
|
452
|
+
|
|
453
|
+
```javascript
|
|
454
|
+
const creds = s3.generateTemporaryCredentials({ ttl: 3600 }); // 1 hour
|
|
455
|
+
console.log(creds.accessKey); // 'AKIA...' (random)
|
|
456
|
+
console.log(creds.secretKey); // random hex string
|
|
457
|
+
console.log(creds.expiresAt); // '2026-02-16T...' (ISO string)
|
|
458
|
+
console.log(creds.expiry); // Date object (backward compat)
|
|
459
|
+
|
|
460
|
+
// Temp credentials are automatically validated by _checkS3Auth and validateAuthentication
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
#### Programmatic Bucket Removal
|
|
464
|
+
|
|
465
|
+
Force-delete a bucket directory and all its contents (for cleanup):
|
|
466
|
+
|
|
467
|
+
```javascript
|
|
468
|
+
await s3.removeBucket('container-id-123');
|
|
469
|
+
// Emits 'bucket:removed' event. Idempotent (no error if bucket doesn't exist).
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### Properties
|
|
473
|
+
|
|
474
|
+
| Property | Type | Description |
|
|
475
|
+
|----------|------|-------------|
|
|
476
|
+
| `serverId` | `string` | Stable server identifier (e.g. `kadi-s3-9000`) |
|
|
477
|
+
| `isRunning` | `boolean` | Whether the server is running |
|
|
478
|
+
|
|
366
479
|
### createQuickShare
|
|
367
480
|
|
|
368
481
|
One-liner to share a directory with optional tunnel and auth.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kadi.build/file-sharing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "File sharing service with tunneling and local S3-compatible interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"test:integration": "node tests/test-file-sharing.js",
|
|
30
30
|
"test:monitor": "node tests/test-download-monitor.js",
|
|
31
31
|
"test:shutdown": "node tests/test-shutdown-manager.js",
|
|
32
|
-
"test:streaming": "node tests/test-streaming.js"
|
|
32
|
+
"test:streaming": "node tests/test-streaming.js",
|
|
33
|
+
"test:extensibility": "node tests/test-extensibility.js"
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"file-sharing",
|
package/src/FileSharingServer.js
CHANGED
|
@@ -123,6 +123,16 @@ function _filterDefined(obj) {
|
|
|
123
123
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Convert a bind address (like `0.0.0.0` or `::`) into a client-usable address.
|
|
128
|
+
* Bind-all addresses are not connectable by clients; replace them with `127.0.0.1`.
|
|
129
|
+
* @param {string} host
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function _clientHost(host) {
|
|
133
|
+
return (host === '0.0.0.0' || host === '::' || host === '[::]') ? '127.0.0.1' : host;
|
|
134
|
+
}
|
|
135
|
+
|
|
126
136
|
export class FileSharingServer extends EventEmitter {
|
|
127
137
|
constructor(config = {}) {
|
|
128
138
|
super();
|
|
@@ -169,6 +179,24 @@ export class FileSharingServer extends EventEmitter {
|
|
|
169
179
|
};
|
|
170
180
|
}
|
|
171
181
|
|
|
182
|
+
// ----------------------------------------------------------------
|
|
183
|
+
// Port collision detection: when S3 is enabled and both ports match,
|
|
184
|
+
// auto-assign s3Port to 0 (OS picks a free port) to avoid EADDRINUSE.
|
|
185
|
+
// ----------------------------------------------------------------
|
|
186
|
+
if (this.config.enableS3 && this.config.port === this.config.s3Port && this.config.port !== 0) {
|
|
187
|
+
const userExplicitlySetS3Port = config.s3Port !== undefined;
|
|
188
|
+
if (userExplicitlySetS3Port) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Port collision: port and s3Port are both set to ${this.config.port}. ` +
|
|
191
|
+
`The HTTP server and S3 server cannot bind the same port. ` +
|
|
192
|
+
`Please specify a different s3Port (or use s3Port: 0 for auto-assign).`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
// User only set `port` (not s3Port) and it happens to match the s3Port default.
|
|
196
|
+
// Auto-resolve by letting the OS pick a free port for S3.
|
|
197
|
+
this.config.s3Port = 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
172
200
|
// ----------------------------------------------------------------
|
|
173
201
|
// Load secrets from env vars / .env (constructor config takes priority)
|
|
174
202
|
// ----------------------------------------------------------------
|
|
@@ -412,12 +440,13 @@ export class FileSharingServer extends EventEmitter {
|
|
|
412
440
|
* @returns {object} Server information
|
|
413
441
|
*/
|
|
414
442
|
getInfo() {
|
|
443
|
+
const host = _clientHost(this.config.host);
|
|
415
444
|
return {
|
|
416
445
|
isRunning: this.isRunning,
|
|
417
|
-
localUrl: `http://${
|
|
446
|
+
localUrl: `http://${host}:${this.config.port}`,
|
|
418
447
|
publicUrl: this.tunnel?.publicUrl || null,
|
|
419
448
|
s3Endpoint: this.s3Server?.isRunning
|
|
420
|
-
? `http://${
|
|
449
|
+
? `http://${host}:${this.s3Server.config.port}`
|
|
421
450
|
: null,
|
|
422
451
|
staticDir: this.config.staticDir,
|
|
423
452
|
stats: this.downloadMonitor.getStats(),
|
|
@@ -488,7 +517,7 @@ export class FileSharingServer extends EventEmitter {
|
|
|
488
517
|
/** @type {string|null} */
|
|
489
518
|
get s3Endpoint() {
|
|
490
519
|
return this.s3Server?.isRunning
|
|
491
|
-
? `http://${this.config.host}:${this.config.
|
|
520
|
+
? `http://${_clientHost(this.config.host)}:${this.s3Server.config.port}`
|
|
492
521
|
: null;
|
|
493
522
|
}
|
|
494
523
|
|
|
@@ -45,6 +45,10 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
45
45
|
this.startTime = null;
|
|
46
46
|
this.requestCount = 0;
|
|
47
47
|
this.errorCount = 0;
|
|
48
|
+
|
|
49
|
+
// Extensibility: middleware chain and custom routes (sorted by priority desc)
|
|
50
|
+
this._middlewares = [];
|
|
51
|
+
this._customRoutes = [];
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/**
|
|
@@ -84,10 +88,14 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
84
88
|
this.startTime = new Date();
|
|
85
89
|
|
|
86
90
|
const protocol = this.config.ssl ? 'https' : 'http';
|
|
91
|
+
// Use a client-connectable host in the URL (0.0.0.0 is a bind address, not routable)
|
|
92
|
+
const clientHost = (this.config.host === '0.0.0.0' || this.config.host === '::' || this.config.host === '[::]')
|
|
93
|
+
? '127.0.0.1'
|
|
94
|
+
: this.config.host;
|
|
87
95
|
const info = {
|
|
88
96
|
port: addr.port,
|
|
89
97
|
host: this.config.host,
|
|
90
|
-
url: `${protocol}://${
|
|
98
|
+
url: `${protocol}://${clientHost}:${addr.port}`
|
|
91
99
|
};
|
|
92
100
|
|
|
93
101
|
this.emit('started', info);
|
|
@@ -143,6 +151,243 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// EXTENSIBILITY — MIDDLEWARE & CUSTOM ROUTES
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Register a named middleware function into the request pipeline.
|
|
160
|
+
* Middleware runs BEFORE built-in auth and static-file handling.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} name - Unique middleware identifier (e.g. 'dockerAuth')
|
|
163
|
+
* @param {Function} handler - (req, res, next) => void
|
|
164
|
+
* @param {Object} [opts]
|
|
165
|
+
* @param {number} [opts.priority=0] - Higher runs first
|
|
166
|
+
*/
|
|
167
|
+
addMiddleware(name, handler, opts = {}) {
|
|
168
|
+
if (typeof name !== 'string' || !name) {
|
|
169
|
+
throw new Error('Middleware name must be a non-empty string');
|
|
170
|
+
}
|
|
171
|
+
if (typeof handler !== 'function') {
|
|
172
|
+
throw new Error('Middleware handler must be a function');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Remove existing middleware with the same name (upsert semantics)
|
|
176
|
+
this._middlewares = this._middlewares.filter(m => m.name !== name);
|
|
177
|
+
|
|
178
|
+
const priority = opts.priority ?? 0;
|
|
179
|
+
this._middlewares.push({ name, handler, priority });
|
|
180
|
+
|
|
181
|
+
// Sort descending by priority (higher priority runs first)
|
|
182
|
+
this._middlewares.sort((a, b) => b.priority - a.priority);
|
|
183
|
+
|
|
184
|
+
this.emit('middleware:added', { name, priority });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Remove a named middleware.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} name - Middleware identifier to remove
|
|
191
|
+
* @returns {boolean} true if middleware was found and removed
|
|
192
|
+
*/
|
|
193
|
+
removeMiddleware(name) {
|
|
194
|
+
const before = this._middlewares.length;
|
|
195
|
+
this._middlewares = this._middlewares.filter(m => m.name !== name);
|
|
196
|
+
const removed = this._middlewares.length < before;
|
|
197
|
+
if (removed) {
|
|
198
|
+
this.emit('middleware:removed', { name });
|
|
199
|
+
}
|
|
200
|
+
return removed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the list of registered middleware names (in execution order).
|
|
205
|
+
* @returns {string[]}
|
|
206
|
+
*/
|
|
207
|
+
getMiddleware() {
|
|
208
|
+
return this._middlewares.map(m => ({ name: m.name, priority: m.priority }));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Register a custom route handler.
|
|
213
|
+
* Custom routes are matched BEFORE built-in static-file/upload handling.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} method - HTTP method ('GET', 'HEAD', 'POST', etc.)
|
|
216
|
+
* @param {string} urlPath - URL path or pattern. Supports Express-style :param segments.
|
|
217
|
+
* @param {Function} handler - (req, res) => void. req.params populated from path pattern.
|
|
218
|
+
* @param {Object} [opts]
|
|
219
|
+
* @param {number} [opts.priority=0] - Higher = matched first (before S3/static routes)
|
|
220
|
+
*/
|
|
221
|
+
addCustomRoute(method, urlPath, handler, opts = {}) {
|
|
222
|
+
if (typeof method !== 'string' || !method) {
|
|
223
|
+
throw new Error('Route method must be a non-empty string');
|
|
224
|
+
}
|
|
225
|
+
if (typeof urlPath !== 'string') {
|
|
226
|
+
throw new Error('Route path must be a string');
|
|
227
|
+
}
|
|
228
|
+
if (typeof handler !== 'function') {
|
|
229
|
+
throw new Error('Route handler must be a function');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const normalizedMethod = method.toUpperCase();
|
|
233
|
+
const priority = opts.priority ?? 0;
|
|
234
|
+
const compiled = this._compilePath(urlPath);
|
|
235
|
+
|
|
236
|
+
// Remove existing route with same method + path (upsert)
|
|
237
|
+
this._customRoutes = this._customRoutes.filter(
|
|
238
|
+
r => !(r.method === normalizedMethod && r.path === urlPath)
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
this._customRoutes.push({
|
|
242
|
+
method: normalizedMethod,
|
|
243
|
+
path: urlPath,
|
|
244
|
+
handler,
|
|
245
|
+
priority,
|
|
246
|
+
...compiled
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Sort descending by priority
|
|
250
|
+
this._customRoutes.sort((a, b) => b.priority - a.priority);
|
|
251
|
+
|
|
252
|
+
this.emit('route:added', { method: normalizedMethod, path: urlPath, priority });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove a custom route.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} method - HTTP method
|
|
259
|
+
* @param {string} urlPath - URL path pattern
|
|
260
|
+
* @returns {boolean} true if route was found and removed
|
|
261
|
+
*/
|
|
262
|
+
removeCustomRoute(method, urlPath) {
|
|
263
|
+
const normalizedMethod = method.toUpperCase();
|
|
264
|
+
const before = this._customRoutes.length;
|
|
265
|
+
this._customRoutes = this._customRoutes.filter(
|
|
266
|
+
r => !(r.method === normalizedMethod && r.path === urlPath)
|
|
267
|
+
);
|
|
268
|
+
const removed = this._customRoutes.length < before;
|
|
269
|
+
if (removed) {
|
|
270
|
+
this.emit('route:removed', { method: normalizedMethod, path: urlPath });
|
|
271
|
+
}
|
|
272
|
+
return removed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get the list of registered custom routes.
|
|
277
|
+
* @returns {Array<{ method: string, path: string, priority: number }>}
|
|
278
|
+
*/
|
|
279
|
+
getCustomRoutes() {
|
|
280
|
+
return this._customRoutes.map(r => ({ method: r.method, path: r.path, priority: r.priority }));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Compile an Express-style path pattern into a regex and param name list.
|
|
285
|
+
*
|
|
286
|
+
* Supports:
|
|
287
|
+
* - Literal paths: `/v2/`
|
|
288
|
+
* - Named params: `/v2/:name/manifests/:reference`
|
|
289
|
+
*
|
|
290
|
+
* @private
|
|
291
|
+
* @param {string} pattern - URL path pattern
|
|
292
|
+
* @returns {{ regex: RegExp, paramNames: string[] }}
|
|
293
|
+
*/
|
|
294
|
+
_compilePath(pattern) {
|
|
295
|
+
const paramNames = [];
|
|
296
|
+
// Escape special regex chars except : (used for params) and /
|
|
297
|
+
const regexStr = pattern
|
|
298
|
+
.replace(/([.+?^${}()|[\]\\])/g, '\\$1')
|
|
299
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
|
300
|
+
paramNames.push(paramName);
|
|
301
|
+
return '([^/]+)';
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Exact match — anchor both ends
|
|
305
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
306
|
+
return { regex, paramNames };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Run the middleware chain. Returns true if a middleware ended the response.
|
|
311
|
+
* @private
|
|
312
|
+
* @param {import('http').IncomingMessage} req
|
|
313
|
+
* @param {import('http').ServerResponse} res
|
|
314
|
+
* @returns {Promise<boolean>} true if response was ended by middleware
|
|
315
|
+
*/
|
|
316
|
+
async _runMiddlewareChain(req, res) {
|
|
317
|
+
const middlewares = this._middlewares;
|
|
318
|
+
if (middlewares.length === 0) return false;
|
|
319
|
+
|
|
320
|
+
let index = 0;
|
|
321
|
+
let responseEnded = false;
|
|
322
|
+
|
|
323
|
+
const runNext = () => {
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
if (responseEnded || res.writableEnded) {
|
|
326
|
+
responseEnded = true;
|
|
327
|
+
return resolve();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (index >= middlewares.length) {
|
|
331
|
+
return resolve(); // All middleware passed through
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const mw = middlewares[index++];
|
|
335
|
+
try {
|
|
336
|
+
const result = mw.handler(req, res, (err) => {
|
|
337
|
+
if (err) {
|
|
338
|
+
return reject(err);
|
|
339
|
+
}
|
|
340
|
+
if (res.writableEnded) {
|
|
341
|
+
responseEnded = true;
|
|
342
|
+
return resolve();
|
|
343
|
+
}
|
|
344
|
+
runNext().then(resolve).catch(reject);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Support async middleware that returns a promise
|
|
348
|
+
if (result && typeof result.then === 'function') {
|
|
349
|
+
result.catch(reject);
|
|
350
|
+
}
|
|
351
|
+
} catch (err) {
|
|
352
|
+
reject(err);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await runNext();
|
|
358
|
+
return responseEnded || res.writableEnded;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Try to match an incoming request against registered custom routes.
|
|
363
|
+
* @private
|
|
364
|
+
* @param {string} method - HTTP method
|
|
365
|
+
* @param {string} pathname - Decoded URL pathname
|
|
366
|
+
* @returns {{ route: object, params: object } | null}
|
|
367
|
+
*/
|
|
368
|
+
_matchCustomRoute(method, pathname) {
|
|
369
|
+
const upperMethod = method.toUpperCase();
|
|
370
|
+
|
|
371
|
+
for (const route of this._customRoutes) {
|
|
372
|
+
if (route.method !== upperMethod) continue;
|
|
373
|
+
|
|
374
|
+
const match = route.regex.exec(pathname);
|
|
375
|
+
if (match) {
|
|
376
|
+
const params = {};
|
|
377
|
+
route.paramNames.forEach((name, i) => {
|
|
378
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
379
|
+
});
|
|
380
|
+
return { route, params };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ============================================================================
|
|
388
|
+
// REQUEST HANDLING
|
|
389
|
+
// ============================================================================
|
|
390
|
+
|
|
146
391
|
/**
|
|
147
392
|
* Create the main request handler
|
|
148
393
|
* @private
|
|
@@ -163,7 +408,29 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
163
408
|
}
|
|
164
409
|
}
|
|
165
410
|
|
|
166
|
-
//
|
|
411
|
+
// ── Middleware chain (Gap 1) ──────────────────────────────────
|
|
412
|
+
// Run registered middleware before auth and routing.
|
|
413
|
+
// If any middleware ends the response, stop processing.
|
|
414
|
+
if (this._middlewares.length > 0) {
|
|
415
|
+
const handled = await this._runMiddlewareChain(req, res);
|
|
416
|
+
if (handled) return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Custom routes (Gap 2) ────────────────────────────────────
|
|
420
|
+
// Check registered custom routes before built-in handling.
|
|
421
|
+
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
422
|
+
const urlPath = decodeURIComponent(urlObj.pathname);
|
|
423
|
+
|
|
424
|
+
if (this._customRoutes.length > 0) {
|
|
425
|
+
const routeMatch = this._matchCustomRoute(req.method, urlPath);
|
|
426
|
+
if (routeMatch) {
|
|
427
|
+
req.params = routeMatch.params;
|
|
428
|
+
await routeMatch.route.handler(req, res);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Built-in authentication ──────────────────────────────────
|
|
167
434
|
if (this.config.auth) {
|
|
168
435
|
if (!this._checkAuth(req)) {
|
|
169
436
|
// Set appropriate WWW-Authenticate based on configured scheme
|
|
@@ -179,9 +446,7 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
179
446
|
}
|
|
180
447
|
}
|
|
181
448
|
|
|
182
|
-
//
|
|
183
|
-
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
184
|
-
const urlPath = decodeURIComponent(urlObj.pathname);
|
|
449
|
+
// ── Static file handling ─────────────────────────────────────
|
|
185
450
|
const filePath = path.join(this.config.staticDir, urlPath);
|
|
186
451
|
|
|
187
452
|
// Security: prevent directory traversal
|
package/src/S3Server.js
CHANGED
|
@@ -48,6 +48,20 @@ export class S3Server extends EventEmitter {
|
|
|
48
48
|
this.isRunning = false;
|
|
49
49
|
this.multipartUploads = new Map();
|
|
50
50
|
this.requestCount = 0;
|
|
51
|
+
|
|
52
|
+
// Temporary credentials store (Gap 7) — Map<accessKey, { secretKey, expiresAt }>
|
|
53
|
+
this._temporaryCredentials = new Map();
|
|
54
|
+
|
|
55
|
+
// Stable server identifier (Gap 5)
|
|
56
|
+
this._serverId = `kadi-s3-${this.config.port}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stable server identifier.
|
|
61
|
+
* @type {string}
|
|
62
|
+
*/
|
|
63
|
+
get serverId() {
|
|
64
|
+
return this._serverId;
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/**
|
|
@@ -56,9 +70,12 @@ export class S3Server extends EventEmitter {
|
|
|
56
70
|
*/
|
|
57
71
|
async start() {
|
|
58
72
|
if (this.isRunning) {
|
|
73
|
+
const clientHost = (this.config.host === '0.0.0.0' || this.config.host === '::' || this.config.host === '[::]')
|
|
74
|
+
? '127.0.0.1'
|
|
75
|
+
: this.config.host;
|
|
59
76
|
return {
|
|
60
77
|
port: this.config.port,
|
|
61
|
-
endpoint: `http://${
|
|
78
|
+
endpoint: `http://${clientHost}:${this.config.port}`
|
|
62
79
|
};
|
|
63
80
|
}
|
|
64
81
|
|
|
@@ -102,10 +119,17 @@ export class S3Server extends EventEmitter {
|
|
|
102
119
|
const addr = this.server.address();
|
|
103
120
|
this.config.port = addr.port;
|
|
104
121
|
this.isRunning = true;
|
|
122
|
+
this._serverId = `kadi-s3-${addr.port}`;
|
|
105
123
|
|
|
124
|
+
// Use a client-connectable host in the endpoint (0.0.0.0 is a bind address, not routable)
|
|
125
|
+
const clientHost = (this.config.host === '0.0.0.0' || this.config.host === '::' || this.config.host === '[::]')
|
|
126
|
+
? '127.0.0.1'
|
|
127
|
+
: this.config.host;
|
|
106
128
|
const info = {
|
|
107
129
|
port: addr.port,
|
|
108
|
-
|
|
130
|
+
host: this.config.host,
|
|
131
|
+
endpoint: `http://${clientHost}:${addr.port}`,
|
|
132
|
+
serverId: this._serverId
|
|
109
133
|
};
|
|
110
134
|
|
|
111
135
|
this.emit('started', info);
|
|
@@ -893,10 +917,170 @@ export class S3Server extends EventEmitter {
|
|
|
893
917
|
res.end(xml);
|
|
894
918
|
}
|
|
895
919
|
|
|
920
|
+
// ============================================================================
|
|
921
|
+
// PUBLIC EXTENSIBILITY APIs
|
|
922
|
+
// ============================================================================
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Validate an incoming request against the server's configured credentials.
|
|
926
|
+
* Returns a structured result object (unlike the private _checkS3Auth which
|
|
927
|
+
* returns a plain boolean).
|
|
928
|
+
*
|
|
929
|
+
* This is the public API that consumers like container-registry-ability use
|
|
930
|
+
* to validate Docker Registry API requests carry valid S3 credentials.
|
|
931
|
+
*
|
|
932
|
+
* @param {import('http').IncomingMessage} req
|
|
933
|
+
* @returns {{ success: boolean, user?: { accessKey: string }, error?: string }}
|
|
934
|
+
*/
|
|
935
|
+
validateAuthentication(req) {
|
|
936
|
+
// Attempt to extract the access key from the request for the user object
|
|
937
|
+
const accessKey = this._extractAccessKey(req);
|
|
938
|
+
const isValid = this._checkS3Auth(req);
|
|
939
|
+
|
|
940
|
+
if (isValid) {
|
|
941
|
+
return {
|
|
942
|
+
success: true,
|
|
943
|
+
user: { accessKey: accessKey || this.config.accessKeyId }
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return {
|
|
948
|
+
success: false,
|
|
949
|
+
error: 'Invalid credentials'
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Programmatically remove a bucket (delete its directory from the S3 root).
|
|
955
|
+
* Unlike the S3 API DeleteBucket handler, this force-deletes even non-empty
|
|
956
|
+
* directories — intended for cleanup when containers are removed.
|
|
957
|
+
*
|
|
958
|
+
* @param {string} bucketName - The bucket/directory name to remove
|
|
959
|
+
* @returns {Promise<void>}
|
|
960
|
+
*/
|
|
961
|
+
async removeBucket(bucketName) {
|
|
962
|
+
if (!bucketName || typeof bucketName !== 'string') {
|
|
963
|
+
throw new Error('bucketName must be a non-empty string');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Security: prevent directory traversal
|
|
967
|
+
if (bucketName.includes('..') || bucketName.includes('/') || bucketName.includes('\\')) {
|
|
968
|
+
throw new Error('Invalid bucket name — must not contain path separators or ".."');
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const bucketDir = path.join(this.config.rootDir, bucketName);
|
|
972
|
+
|
|
973
|
+
// Ensure the resolved path is within rootDir
|
|
974
|
+
const resolvedRoot = path.resolve(this.config.rootDir);
|
|
975
|
+
const resolvedBucket = path.resolve(bucketDir);
|
|
976
|
+
if (!resolvedBucket.startsWith(resolvedRoot)) {
|
|
977
|
+
throw new Error('Invalid bucket name — path traversal detected');
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
try {
|
|
981
|
+
await fs.rm(bucketDir, { recursive: true, force: true });
|
|
982
|
+
this.emit('bucket:removed', { bucket: bucketName });
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (error.code !== 'ENOENT') {
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
// Bucket doesn't exist — treat as success (idempotent)
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Generate temporary S3 credentials with an expiry.
|
|
993
|
+
*
|
|
994
|
+
* Returns both `expiresAt` (ISO string) and `expiry` (Date object) for
|
|
995
|
+
* backward compatibility with consumers expecting the old property name.
|
|
996
|
+
*
|
|
997
|
+
* @param {Object} [opts]
|
|
998
|
+
* @param {number} [opts.ttl=3600] - Time-to-live in seconds (default: 1 hour)
|
|
999
|
+
* @returns {{ accessKey: string, secretKey: string, expiresAt: string, expiry: Date }}
|
|
1000
|
+
*/
|
|
1001
|
+
generateTemporaryCredentials(opts = {}) {
|
|
1002
|
+
const ttl = opts.ttl ?? 3600;
|
|
1003
|
+
const accessKey = `AKIA${crypto.randomBytes(8).toString('hex').toUpperCase()}`;
|
|
1004
|
+
const secretKey = crypto.randomBytes(20).toString('hex');
|
|
1005
|
+
const expiryDate = new Date(Date.now() + ttl * 1000);
|
|
1006
|
+
|
|
1007
|
+
// Store for validation
|
|
1008
|
+
this._temporaryCredentials.set(accessKey, {
|
|
1009
|
+
secretKey,
|
|
1010
|
+
expiresAt: expiryDate.getTime()
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// Schedule cleanup
|
|
1014
|
+
setTimeout(() => {
|
|
1015
|
+
this._temporaryCredentials.delete(accessKey);
|
|
1016
|
+
}, ttl * 1000).unref();
|
|
1017
|
+
|
|
1018
|
+
this.emit('credentials:generated', { accessKey, expiresAt: expiryDate.toISOString() });
|
|
1019
|
+
|
|
1020
|
+
return {
|
|
1021
|
+
accessKey,
|
|
1022
|
+
secretKey,
|
|
1023
|
+
expiresAt: expiryDate.toISOString(),
|
|
1024
|
+
expiry: expiryDate // backward compat alias
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
896
1028
|
// ============================================================================
|
|
897
1029
|
// AUTHENTICATION
|
|
898
1030
|
// ============================================================================
|
|
899
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Extract the access key from an incoming request, if present.
|
|
1034
|
+
* Used by validateAuthentication() to populate the user object.
|
|
1035
|
+
*
|
|
1036
|
+
* @private
|
|
1037
|
+
* @param {import('http').IncomingMessage} req
|
|
1038
|
+
* @returns {string|null}
|
|
1039
|
+
*/
|
|
1040
|
+
_extractAccessKey(req) {
|
|
1041
|
+
const authHeader = req.headers.authorization || '';
|
|
1042
|
+
|
|
1043
|
+
// AWS Signature V4
|
|
1044
|
+
if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
|
|
1045
|
+
const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
|
|
1046
|
+
if (credMatch) return credMatch[1];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// AWS Signature V2
|
|
1050
|
+
if (authHeader.startsWith('AWS ')) {
|
|
1051
|
+
return authHeader.slice(4).split(':')[0] || null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Bearer
|
|
1055
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
1056
|
+
return authHeader.slice(7) || null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Basic auth (Docker clients)
|
|
1060
|
+
if (authHeader.startsWith('Basic ')) {
|
|
1061
|
+
try {
|
|
1062
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
1063
|
+
const [user] = decoded.split(':');
|
|
1064
|
+
return user || null;
|
|
1065
|
+
} catch {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Query params
|
|
1071
|
+
try {
|
|
1072
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
1073
|
+
const amzCred = url.searchParams.get('X-Amz-Credential');
|
|
1074
|
+
if (amzCred) return amzCred.split('/')[0];
|
|
1075
|
+
const awsKey = url.searchParams.get('AWSAccessKeyId');
|
|
1076
|
+
if (awsKey) return awsKey;
|
|
1077
|
+
} catch {
|
|
1078
|
+
// ignore
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
900
1084
|
/**
|
|
901
1085
|
* Validate S3 request authentication.
|
|
902
1086
|
*
|
|
@@ -907,17 +1091,13 @@ export class S3Server extends EventEmitter {
|
|
|
907
1091
|
* - AWS Signature V4: `AWS4-HMAC-SHA256 Credential=<accessKeyId>/…`
|
|
908
1092
|
* - AWS Signature V2: `AWS <accessKeyId>:…`
|
|
909
1093
|
* - Simple Bearer: `Bearer <accessKeyId>` (non-standard convenience)
|
|
1094
|
+
* - Basic auth: `Basic base64(accessKey:secretKey)` (Docker clients)
|
|
910
1095
|
*
|
|
911
1096
|
* 2. Query-string pre-signed URL parameters
|
|
912
1097
|
* - V4: `?X-Amz-Credential=<accessKeyId>/…`
|
|
913
1098
|
* - V2: `?AWSAccessKeyId=<accessKeyId>`
|
|
914
1099
|
*
|
|
915
|
-
*
|
|
916
|
-
* than a full HMAC signature check. A full SigV4 implementation would
|
|
917
|
-
* require reconstructing the canonical request exactly as the SDK built it,
|
|
918
|
-
* which is fragile and unnecessary for a local dev server. The goal is to
|
|
919
|
-
* prevent accidental unauthenticated access when credentials are configured,
|
|
920
|
-
* not to replicate AWS IAM.
|
|
1100
|
+
* Also checks temporary credentials generated via generateTemporaryCredentials().
|
|
921
1101
|
*
|
|
922
1102
|
* @private
|
|
923
1103
|
* @param {import('http').IncomingMessage} req
|
|
@@ -939,23 +1119,41 @@ export class S3Server extends EventEmitter {
|
|
|
939
1119
|
// AWS Signature V4: "AWS4-HMAC-SHA256 Credential=<accessKeyId>/date/region/s3/aws4_request, …"
|
|
940
1120
|
if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
|
|
941
1121
|
const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
|
|
942
|
-
if (credMatch
|
|
943
|
-
return true;
|
|
1122
|
+
if (credMatch) {
|
|
1123
|
+
if (credMatch[1] === accessKeyId) return true;
|
|
1124
|
+
// Check temporary credentials
|
|
1125
|
+
if (this._checkTempCredentials(credMatch[1])) return true;
|
|
944
1126
|
}
|
|
945
1127
|
}
|
|
946
1128
|
|
|
947
1129
|
// AWS Signature V2: "AWS <accessKeyId>:<signature>"
|
|
948
1130
|
if (authHeader.startsWith('AWS ')) {
|
|
949
1131
|
const parts = authHeader.slice(4).split(':');
|
|
950
|
-
if (parts[0] === accessKeyId)
|
|
951
|
-
|
|
952
|
-
}
|
|
1132
|
+
if (parts[0] === accessKeyId) return true;
|
|
1133
|
+
if (this._checkTempCredentials(parts[0])) return true;
|
|
953
1134
|
}
|
|
954
1135
|
|
|
955
1136
|
// Simple Bearer (non-standard convenience for agents/scripts)
|
|
956
1137
|
if (authHeader.startsWith('Bearer ')) {
|
|
957
|
-
|
|
958
|
-
|
|
1138
|
+
const token = authHeader.slice(7);
|
|
1139
|
+
if (token === accessKeyId) return true;
|
|
1140
|
+
if (this._checkTempCredentials(token)) return true;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Basic auth (Docker clients send Basic base64(accessKey:secretKey))
|
|
1144
|
+
if (authHeader.startsWith('Basic ')) {
|
|
1145
|
+
try {
|
|
1146
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
1147
|
+
const [user, pass] = decoded.split(':');
|
|
1148
|
+
// Check against primary credentials
|
|
1149
|
+
if (user === accessKeyId && pass === secretAccessKey) return true;
|
|
1150
|
+
// Check against temporary credentials
|
|
1151
|
+
const tempCred = this._temporaryCredentials.get(user);
|
|
1152
|
+
if (tempCred && tempCred.expiresAt > Date.now() && pass === tempCred.secretKey) {
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
} catch {
|
|
1156
|
+
// Malformed Basic auth — fall through
|
|
959
1157
|
}
|
|
960
1158
|
}
|
|
961
1159
|
|
|
@@ -965,13 +1163,17 @@ export class S3Server extends EventEmitter {
|
|
|
965
1163
|
|
|
966
1164
|
// V4 pre-signed: ?X-Amz-Credential=<accessKeyId>/…
|
|
967
1165
|
const amzCred = url.searchParams.get('X-Amz-Credential');
|
|
968
|
-
if (amzCred
|
|
969
|
-
|
|
1166
|
+
if (amzCred) {
|
|
1167
|
+
const credKey = amzCred.split('/')[0];
|
|
1168
|
+
if (credKey === accessKeyId) return true;
|
|
1169
|
+
if (this._checkTempCredentials(credKey)) return true;
|
|
970
1170
|
}
|
|
971
1171
|
|
|
972
1172
|
// V2 pre-signed: ?AWSAccessKeyId=<accessKeyId>
|
|
973
|
-
|
|
974
|
-
|
|
1173
|
+
const awsKey = url.searchParams.get('AWSAccessKeyId');
|
|
1174
|
+
if (awsKey) {
|
|
1175
|
+
if (awsKey === accessKeyId) return true;
|
|
1176
|
+
if (this._checkTempCredentials(awsKey)) return true;
|
|
975
1177
|
}
|
|
976
1178
|
} catch {
|
|
977
1179
|
// Malformed URL — deny
|
|
@@ -979,6 +1181,26 @@ export class S3Server extends EventEmitter {
|
|
|
979
1181
|
|
|
980
1182
|
return false;
|
|
981
1183
|
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Check if an access key matches a valid, non-expired temporary credential.
|
|
1187
|
+
*
|
|
1188
|
+
* @private
|
|
1189
|
+
* @param {string} accessKey
|
|
1190
|
+
* @returns {boolean}
|
|
1191
|
+
*/
|
|
1192
|
+
_checkTempCredentials(accessKey) {
|
|
1193
|
+
const cred = this._temporaryCredentials.get(accessKey);
|
|
1194
|
+
if (!cred) return false;
|
|
1195
|
+
|
|
1196
|
+
// Expired?
|
|
1197
|
+
if (cred.expiresAt <= Date.now()) {
|
|
1198
|
+
this._temporaryCredentials.delete(accessKey);
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
982
1204
|
}
|
|
983
1205
|
|
|
984
1206
|
export default S3Server;
|