s3-adaptor 0.2.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 +29 -0
- package/README.md +101 -0
- package/dist/S3Adaptor.d.ts +13 -0
- package/dist/S3Adaptor.js +49 -0
- package/dist/adapters/BaseAdapter.d.ts +11 -0
- package/dist/adapters/BaseAdapter.js +10 -0
- package/dist/adapters/LocalAdapter.d.ts +21 -0
- package/dist/adapters/LocalAdapter.js +292 -0
- package/dist/adapters/S3Adapter.d.ts +23 -0
- package/dist/adapters/S3Adapter.js +140 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +46 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -0
- package/dist/middleware/localFileMiddleware.d.ts +3 -0
- package/dist/middleware/localFileMiddleware.js +142 -0
- package/dist/signing/TokenSigner.d.ts +12 -0
- package/dist/signing/TokenSigner.js +39 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +2 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Kevin Foster
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
* Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# s3-adaptor
|
|
2
|
+
|
|
3
|
+
Unified adapter-pattern interface for AWS S3 and local filesystem storage. Switch backends via config — same API in dev and production. Includes signed URL generation and Express middleware for local dev serving.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add s3-adaptor
|
|
9
|
+
# peer dep for middleware:
|
|
10
|
+
pnpm add express
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
**Local (dev)**
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { S3Adaptor } from 's3-adaptor'
|
|
19
|
+
|
|
20
|
+
const storage = new S3Adaptor({
|
|
21
|
+
defaultType: 'local',
|
|
22
|
+
local: {
|
|
23
|
+
storagePath: '/tmp/storage',
|
|
24
|
+
baseUrl: 'http://localhost:3000/files',
|
|
25
|
+
secretKey: 'supersecretkey-atleast-32-chars!',
|
|
26
|
+
buckets: {
|
|
27
|
+
avatars: { public: true }, // no token required to read
|
|
28
|
+
docs: { public: false }, // token required (default)
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**S3 (production)**
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
const storage = new S3Adaptor({
|
|
38
|
+
defaultType: 's3',
|
|
39
|
+
s3: { region: 'us-east-1' }, // uses AWS SDK default credential chain
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## API
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
// Upload
|
|
47
|
+
await storage.putObject({ Bucket, Key, Body, ContentType?, Metadata? })
|
|
48
|
+
|
|
49
|
+
// Download — returns { Body: ReadableStream, ContentType?, ContentLength?, ETag?, Metadata? }
|
|
50
|
+
const result = await storage.getObject({ Bucket, Key })
|
|
51
|
+
|
|
52
|
+
// Delete
|
|
53
|
+
await storage.deleteObject({ Bucket, Key })
|
|
54
|
+
|
|
55
|
+
// List — returns { Contents: [{ Key, Size, LastModified, ETag? }], IsTruncated }
|
|
56
|
+
const list = await storage.listObjects({ Bucket, Prefix?, MaxKeys? })
|
|
57
|
+
|
|
58
|
+
// Signed URL — public bucket → plain URL; protected → ?token=<signed> (local); S3 → pre-signed
|
|
59
|
+
const url = await storage.getSignedUrl({ Bucket, Key, Expires? })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Express Middleware (local only)
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { createLocalFileMiddleware } from 's3-adaptor'
|
|
66
|
+
|
|
67
|
+
// Mount as: GET /files/:bucket/*key (Express 5) or /files/:bucket/* (Express 4)
|
|
68
|
+
app.get('/files/:bucket/*key', createLocalFileMiddleware(config.local))
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Access control is based on bucket policy:
|
|
72
|
+
- Public bucket: serves file without token
|
|
73
|
+
- Protected bucket: requires `?token=<signed>` obtained from `getSignedUrl`
|
|
74
|
+
|
|
75
|
+
## Bucket Access Modes
|
|
76
|
+
|
|
77
|
+
| Mode | `BucketPolicy.public` | `getSignedUrl` result | Middleware |
|
|
78
|
+
|-----------|-----------------------|-------------------------|-----------------------|
|
|
79
|
+
| Public | `true` | plain URL, no token | serves without token |
|
|
80
|
+
| Protected | `false` (default) | URL with `?token=<...>` | requires valid token |
|
|
81
|
+
|
|
82
|
+
## Error Classes
|
|
83
|
+
|
|
84
|
+
All errors extend `S3AdaptorError` with a `code` property.
|
|
85
|
+
|
|
86
|
+
| Class | `code` | When |
|
|
87
|
+
|----------------------|------------------|-------------------------------|
|
|
88
|
+
| `NotFoundError` | `NOT_FOUND` | Object missing |
|
|
89
|
+
| `AccessDeniedError` | `ACCESS_DENIED` | No token on protected bucket |
|
|
90
|
+
| `InvalidTokenError` | `INVALID_TOKEN` | Tampered or wrong-scope token |
|
|
91
|
+
| `TokenExpiredError` | `TOKEN_EXPIRED` | Token past expiry |
|
|
92
|
+
| `InvalidConfigError` | `INVALID_CONFIG` | Bad constructor config |
|
|
93
|
+
|
|
94
|
+
## Scripts
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pnpm build # tsc → dist/
|
|
98
|
+
pnpm test # jest
|
|
99
|
+
pnpm format # prettier --write ./src
|
|
100
|
+
pnpm lint # eslint src
|
|
101
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CopyObjectParams, DeleteObjectParams, GetObjectParams, GetObjectResult, GetSignedUrlParams, ListObjectsParams, ListObjectsResult, PutObjectParams, S3AdaptorConfig, UploadObjectParams } from './types';
|
|
2
|
+
export declare class S3Adaptor {
|
|
3
|
+
private readonly adapter;
|
|
4
|
+
constructor(config: S3AdaptorConfig);
|
|
5
|
+
putObject(params: PutObjectParams): Promise<void>;
|
|
6
|
+
uploadObject(params: UploadObjectParams): Promise<void>;
|
|
7
|
+
getObject(params: GetObjectParams): Promise<GetObjectResult>;
|
|
8
|
+
deleteObject(params: DeleteObjectParams): Promise<void>;
|
|
9
|
+
copyObject(params: CopyObjectParams): Promise<void>;
|
|
10
|
+
listObjects(params: ListObjectsParams): Promise<ListObjectsResult>;
|
|
11
|
+
getSignedUrl(params: GetSignedUrlParams): Promise<string>;
|
|
12
|
+
pruneEmptyDirs(bucket: string, prefix?: string): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.S3Adaptor = void 0;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
const LocalAdapter_1 = require("./adapters/LocalAdapter");
|
|
6
|
+
const S3Adapter_1 = require("./adapters/S3Adapter");
|
|
7
|
+
function validateConfig(config) {
|
|
8
|
+
if (config.defaultType === 's3' && !config.s3) {
|
|
9
|
+
throw new errors_1.InvalidConfigError("s3 config required when defaultType is 's3'");
|
|
10
|
+
}
|
|
11
|
+
if (config.defaultType === 'local' && !config.local) {
|
|
12
|
+
throw new errors_1.InvalidConfigError("local config required when defaultType is 'local'");
|
|
13
|
+
}
|
|
14
|
+
if (config.local && config.local.secretKey.length < 32) {
|
|
15
|
+
throw new errors_1.InvalidConfigError('secretKey must be at least 32 characters');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
class S3Adaptor {
|
|
19
|
+
constructor(config) {
|
|
20
|
+
validateConfig(config);
|
|
21
|
+
this.adapter =
|
|
22
|
+
config.defaultType === 's3' ? new S3Adapter_1.S3Adapter(config.s3) : new LocalAdapter_1.LocalAdapter(config.local);
|
|
23
|
+
}
|
|
24
|
+
putObject(params) {
|
|
25
|
+
return this.adapter.putObject(params);
|
|
26
|
+
}
|
|
27
|
+
uploadObject(params) {
|
|
28
|
+
return this.adapter.uploadObject(params);
|
|
29
|
+
}
|
|
30
|
+
getObject(params) {
|
|
31
|
+
return this.adapter.getObject(params);
|
|
32
|
+
}
|
|
33
|
+
deleteObject(params) {
|
|
34
|
+
return this.adapter.deleteObject(params);
|
|
35
|
+
}
|
|
36
|
+
copyObject(params) {
|
|
37
|
+
return this.adapter.copyObject(params);
|
|
38
|
+
}
|
|
39
|
+
listObjects(params) {
|
|
40
|
+
return this.adapter.listObjects(params);
|
|
41
|
+
}
|
|
42
|
+
getSignedUrl(params) {
|
|
43
|
+
return this.adapter.getSignedUrl(params);
|
|
44
|
+
}
|
|
45
|
+
pruneEmptyDirs(bucket, prefix) {
|
|
46
|
+
return this.adapter.pruneEmptyDirs(bucket, prefix);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.S3Adaptor = S3Adaptor;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CopyObjectParams, DeleteObjectParams, GetObjectParams, GetObjectResult, GetSignedUrlParams, ListObjectsParams, ListObjectsResult, PutObjectParams, UploadObjectParams } from '../types';
|
|
2
|
+
export declare abstract class BaseAdapter {
|
|
3
|
+
abstract putObject(params: PutObjectParams): Promise<void>;
|
|
4
|
+
abstract uploadObject(params: UploadObjectParams): Promise<void>;
|
|
5
|
+
abstract getObject(params: GetObjectParams): Promise<GetObjectResult>;
|
|
6
|
+
abstract deleteObject(params: DeleteObjectParams): Promise<void>;
|
|
7
|
+
abstract copyObject(params: CopyObjectParams): Promise<void>;
|
|
8
|
+
abstract listObjects(params: ListObjectsParams): Promise<ListObjectsResult>;
|
|
9
|
+
abstract getSignedUrl(params: GetSignedUrlParams): Promise<string>;
|
|
10
|
+
pruneEmptyDirs(_bucket: string, _prefix?: string): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BaseAdapter = void 0;
|
|
4
|
+
class BaseAdapter {
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
6
|
+
pruneEmptyDirs(_bucket, _prefix) {
|
|
7
|
+
return Promise.resolve();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
exports.BaseAdapter = BaseAdapter;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as realFs from 'fs';
|
|
2
|
+
import type { CopyObjectParams, DeleteObjectParams, GetObjectParams, GetObjectResult, GetSignedUrlParams, ListObjectsParams, ListObjectsResult, LocalConfig, PutObjectParams, UploadObjectParams } from '../types';
|
|
3
|
+
import { BaseAdapter } from './BaseAdapter';
|
|
4
|
+
type FsModule = typeof realFs;
|
|
5
|
+
export declare class LocalAdapter extends BaseAdapter {
|
|
6
|
+
private readonly config;
|
|
7
|
+
private readonly fs;
|
|
8
|
+
private readonly signer;
|
|
9
|
+
constructor(config: LocalConfig, fs?: FsModule);
|
|
10
|
+
private isPublicBucket;
|
|
11
|
+
private resolveFilePath;
|
|
12
|
+
uploadObject(params: UploadObjectParams): Promise<void>;
|
|
13
|
+
putObject(params: PutObjectParams): Promise<void>;
|
|
14
|
+
getObject(params: GetObjectParams): Promise<GetObjectResult>;
|
|
15
|
+
copyObject(params: CopyObjectParams): Promise<void>;
|
|
16
|
+
deleteObject(params: DeleteObjectParams): Promise<void>;
|
|
17
|
+
listObjects(params: ListObjectsParams): Promise<ListObjectsResult>;
|
|
18
|
+
pruneEmptyDirs(bucket: string, prefix?: string): Promise<void>;
|
|
19
|
+
getSignedUrl(params: GetSignedUrlParams): Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.LocalAdapter = void 0;
|
|
37
|
+
const crypto_1 = require("crypto");
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const realFs = __importStar(require("fs"));
|
|
40
|
+
const stream_1 = require("stream");
|
|
41
|
+
const errors_1 = require("../errors");
|
|
42
|
+
const TokenSigner_1 = require("../signing/TokenSigner");
|
|
43
|
+
const BaseAdapter_1 = require("./BaseAdapter");
|
|
44
|
+
function validateKey(key) {
|
|
45
|
+
if (key.startsWith('/') || key.includes('..')) {
|
|
46
|
+
throw new errors_1.S3AdaptorError('Forbidden key path', 'FORBIDDEN');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function validateBucket(bucket) {
|
|
50
|
+
if (bucket.startsWith('/') || bucket.includes('..') || bucket.includes('/')) {
|
|
51
|
+
throw new errors_1.S3AdaptorError('Forbidden bucket name', 'FORBIDDEN');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function metaPath(filePath) {
|
|
55
|
+
const dir = path.dirname(filePath);
|
|
56
|
+
const base = path.basename(filePath);
|
|
57
|
+
return path.join(dir, `.${base}.meta.json`);
|
|
58
|
+
}
|
|
59
|
+
class LocalAdapter extends BaseAdapter_1.BaseAdapter {
|
|
60
|
+
constructor(config, fs = realFs) {
|
|
61
|
+
super();
|
|
62
|
+
this.config = config;
|
|
63
|
+
this.fs = fs;
|
|
64
|
+
this.signer = new TokenSigner_1.TokenSigner(config.secretKey);
|
|
65
|
+
}
|
|
66
|
+
isPublicBucket(bucket) {
|
|
67
|
+
return this.config.buckets?.[bucket]?.public ?? false;
|
|
68
|
+
}
|
|
69
|
+
resolveFilePath(bucket, key) {
|
|
70
|
+
validateBucket(bucket);
|
|
71
|
+
validateKey(key);
|
|
72
|
+
return path.join(this.config.storagePath, bucket, key);
|
|
73
|
+
}
|
|
74
|
+
uploadObject(params) {
|
|
75
|
+
return this.putObject(params);
|
|
76
|
+
}
|
|
77
|
+
async putObject(params) {
|
|
78
|
+
const { Bucket, Key, Body, ContentType, Metadata } = params;
|
|
79
|
+
const filePath = this.resolveFilePath(Bucket, Key);
|
|
80
|
+
const dir = path.dirname(filePath);
|
|
81
|
+
await this.fs.promises.mkdir(dir, { recursive: true });
|
|
82
|
+
const bodyBuffer = typeof Body === 'string'
|
|
83
|
+
? Buffer.from(Body)
|
|
84
|
+
: Body instanceof Buffer
|
|
85
|
+
? Body
|
|
86
|
+
: await streamToBuffer(Body);
|
|
87
|
+
await this.fs.promises.writeFile(filePath, bodyBuffer);
|
|
88
|
+
const etag = (0, crypto_1.createHash)('md5').update(bodyBuffer).digest('hex');
|
|
89
|
+
const meta = {
|
|
90
|
+
ContentType,
|
|
91
|
+
Metadata,
|
|
92
|
+
ETag: etag,
|
|
93
|
+
Size: bodyBuffer.length,
|
|
94
|
+
LastModified: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
await this.fs.promises.writeFile(metaPath(filePath), JSON.stringify(meta));
|
|
97
|
+
}
|
|
98
|
+
async getObject(params) {
|
|
99
|
+
const { Bucket, Key } = params;
|
|
100
|
+
const filePath = this.resolveFilePath(Bucket, Key);
|
|
101
|
+
try {
|
|
102
|
+
await this.fs.promises.access(filePath);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new errors_1.NotFoundError(Bucket, Key);
|
|
106
|
+
}
|
|
107
|
+
let meta;
|
|
108
|
+
try {
|
|
109
|
+
const raw = await this.fs.promises.readFile(metaPath(filePath), 'utf-8');
|
|
110
|
+
meta = JSON.parse(raw);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// metadata optional
|
|
114
|
+
}
|
|
115
|
+
const fileBuffer = await this.fs.promises.readFile(filePath);
|
|
116
|
+
const body = stream_1.Readable.from(fileBuffer);
|
|
117
|
+
return {
|
|
118
|
+
Body: body,
|
|
119
|
+
ContentType: meta?.ContentType,
|
|
120
|
+
ContentLength: meta?.Size,
|
|
121
|
+
Metadata: meta?.Metadata,
|
|
122
|
+
ETag: meta?.ETag,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
async copyObject(params) {
|
|
126
|
+
const { Bucket, Key, CopySource, ContentType, Metadata } = params;
|
|
127
|
+
// CopySource format: '{sourceBucket}/{sourceKey}'
|
|
128
|
+
const slashIdx = CopySource.indexOf('/');
|
|
129
|
+
const srcBucket = CopySource.slice(0, slashIdx);
|
|
130
|
+
const srcKey = CopySource.slice(slashIdx + 1);
|
|
131
|
+
const srcPath = this.resolveFilePath(srcBucket, srcKey);
|
|
132
|
+
const dstPath = this.resolveFilePath(Bucket, Key);
|
|
133
|
+
await this.fs.promises.mkdir(path.dirname(dstPath), { recursive: true });
|
|
134
|
+
await this.fs.promises.copyFile(srcPath, dstPath);
|
|
135
|
+
let srcMeta;
|
|
136
|
+
try {
|
|
137
|
+
const raw = await this.fs.promises.readFile(metaPath(srcPath), 'utf-8');
|
|
138
|
+
srcMeta = JSON.parse(raw);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// sidecar optional
|
|
142
|
+
}
|
|
143
|
+
const newMeta = {
|
|
144
|
+
ContentType: ContentType ?? srcMeta?.ContentType,
|
|
145
|
+
Metadata: Metadata ?? srcMeta?.Metadata,
|
|
146
|
+
ETag: srcMeta?.ETag ?? '',
|
|
147
|
+
Size: srcMeta?.Size ?? 0,
|
|
148
|
+
LastModified: new Date().toISOString(),
|
|
149
|
+
};
|
|
150
|
+
await this.fs.promises.writeFile(metaPath(dstPath), JSON.stringify(newMeta));
|
|
151
|
+
}
|
|
152
|
+
async deleteObject(params) {
|
|
153
|
+
const { Bucket, Key } = params;
|
|
154
|
+
const filePath = this.resolveFilePath(Bucket, Key);
|
|
155
|
+
try {
|
|
156
|
+
await this.fs.promises.unlink(filePath);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new errors_1.NotFoundError(Bucket, Key);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await this.fs.promises.unlink(metaPath(filePath));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// sidecar may not exist
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async listObjects(params) {
|
|
169
|
+
const { Bucket, Prefix = '', MaxKeys } = params;
|
|
170
|
+
validateBucket(Bucket);
|
|
171
|
+
const bucketDir = path.join(this.config.storagePath, Bucket);
|
|
172
|
+
let allFiles;
|
|
173
|
+
try {
|
|
174
|
+
allFiles = await readdirRecursive(bucketDir, this.fs);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { Contents: [], IsTruncated: false };
|
|
178
|
+
}
|
|
179
|
+
const metaSuffix = '.meta.json';
|
|
180
|
+
const filtered = allFiles.filter((f) => {
|
|
181
|
+
const rel = path.relative(bucketDir, f).replace(/\\/g, '/');
|
|
182
|
+
const base = path.basename(f);
|
|
183
|
+
return !base.startsWith('.') && !base.endsWith(metaSuffix) && rel.startsWith(Prefix);
|
|
184
|
+
});
|
|
185
|
+
const limited = MaxKeys !== undefined ? filtered.slice(0, MaxKeys) : filtered;
|
|
186
|
+
const isTruncated = MaxKeys !== undefined && filtered.length > MaxKeys;
|
|
187
|
+
const contents = await Promise.all(limited.map(async (f) => {
|
|
188
|
+
const rel = path.relative(bucketDir, f).replace(/\\/g, '/');
|
|
189
|
+
const stat = await this.fs.promises.stat(f);
|
|
190
|
+
let etag;
|
|
191
|
+
try {
|
|
192
|
+
const raw = await this.fs.promises.readFile(metaPath(f), 'utf-8');
|
|
193
|
+
const meta = JSON.parse(raw);
|
|
194
|
+
etag = meta.ETag;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// no sidecar
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
Key: rel,
|
|
201
|
+
Size: stat.size,
|
|
202
|
+
LastModified: stat.mtime,
|
|
203
|
+
ETag: etag,
|
|
204
|
+
};
|
|
205
|
+
}));
|
|
206
|
+
return { Contents: contents, IsTruncated: isTruncated };
|
|
207
|
+
}
|
|
208
|
+
async pruneEmptyDirs(bucket, prefix) {
|
|
209
|
+
validateBucket(bucket);
|
|
210
|
+
const bucketDir = path.join(this.config.storagePath, bucket);
|
|
211
|
+
const targetDir = prefix ? path.join(bucketDir, prefix) : bucketDir;
|
|
212
|
+
try {
|
|
213
|
+
await this.fs.promises.access(targetDir);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await pruneEmptyDirsRecursive(targetDir, bucketDir, this.fs);
|
|
219
|
+
}
|
|
220
|
+
async getSignedUrl(params) {
|
|
221
|
+
const { Bucket, Key, Expires, ResponseContentDisposition } = params;
|
|
222
|
+
const base = `${this.config.baseUrl}/${Bucket}/${Key}`;
|
|
223
|
+
if (this.isPublicBucket(Bucket)) {
|
|
224
|
+
// Public buckets have no token to bind a disposition into - anyone can
|
|
225
|
+
// already request any key in a public bucket under any disposition,
|
|
226
|
+
// signed or not, so this remains a bare query param.
|
|
227
|
+
const dispositionParam = ResponseContentDisposition
|
|
228
|
+
? `disposition=${encodeURIComponent(ResponseContentDisposition)}`
|
|
229
|
+
: null;
|
|
230
|
+
return dispositionParam ? `${base}?${dispositionParam}` : base;
|
|
231
|
+
}
|
|
232
|
+
const exp = Math.floor(Date.now() / 1000) + (Expires ?? this.config.defaultExpiry ?? 3600);
|
|
233
|
+
// Bind the disposition into the signature itself, so it can't be changed
|
|
234
|
+
// by editing the query string after the URL is issued.
|
|
235
|
+
const token = this.signer.sign({
|
|
236
|
+
bucket: Bucket,
|
|
237
|
+
key: Key,
|
|
238
|
+
exp,
|
|
239
|
+
disposition: ResponseContentDisposition,
|
|
240
|
+
});
|
|
241
|
+
return `${base}?token=${token}`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
exports.LocalAdapter = LocalAdapter;
|
|
245
|
+
async function streamToBuffer(stream) {
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
const chunks = [];
|
|
248
|
+
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
249
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
250
|
+
stream.on('error', reject);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function pruneEmptyDirsRecursive(dir, stopAt, fs) {
|
|
254
|
+
let entries;
|
|
255
|
+
try {
|
|
256
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (entry.isDirectory()) {
|
|
263
|
+
await pruneEmptyDirsRecursive(path.join(dir, entry.name), stopAt, fs);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (dir === stopAt)
|
|
267
|
+
return;
|
|
268
|
+
const remaining = await fs.promises.readdir(dir);
|
|
269
|
+
if (remaining.length === 0) {
|
|
270
|
+
try {
|
|
271
|
+
await fs.promises.rmdir(dir);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// non-empty or concurrent write — leave it
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function readdirRecursive(dir, fs) {
|
|
279
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
280
|
+
const results = [];
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
const full = path.join(dir, entry.name);
|
|
283
|
+
if (entry.isDirectory()) {
|
|
284
|
+
const sub = await readdirRecursive(full, fs);
|
|
285
|
+
results.push(...sub);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
results.push(full);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return results;
|
|
292
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CopyObjectParams, DeleteObjectParams, GetObjectParams, GetObjectResult, GetSignedUrlParams, ListObjectsParams, ListObjectsResult, PutObjectParams, UploadObjectParams } from '../types';
|
|
2
|
+
import { BaseAdapter } from './BaseAdapter';
|
|
3
|
+
interface S3Config {
|
|
4
|
+
region: string;
|
|
5
|
+
credentials?: {
|
|
6
|
+
accessKeyId: string;
|
|
7
|
+
secretAccessKey: string;
|
|
8
|
+
};
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
forcePathStyle?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare class S3Adapter extends BaseAdapter {
|
|
13
|
+
private readonly client;
|
|
14
|
+
constructor(config: S3Config);
|
|
15
|
+
putObject(params: PutObjectParams): Promise<void>;
|
|
16
|
+
uploadObject(params: UploadObjectParams): Promise<void>;
|
|
17
|
+
getObject(params: GetObjectParams): Promise<GetObjectResult>;
|
|
18
|
+
deleteObject(params: DeleteObjectParams): Promise<void>;
|
|
19
|
+
copyObject(params: CopyObjectParams): Promise<void>;
|
|
20
|
+
listObjects(params: ListObjectsParams): Promise<ListObjectsResult>;
|
|
21
|
+
getSignedUrl(params: GetSignedUrlParams): Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.S3Adapter = void 0;
|
|
4
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
5
|
+
const lib_storage_1 = require("@aws-sdk/lib-storage");
|
|
6
|
+
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
const BaseAdapter_1 = require("./BaseAdapter");
|
|
9
|
+
class S3Adapter extends BaseAdapter_1.BaseAdapter {
|
|
10
|
+
constructor(config) {
|
|
11
|
+
super();
|
|
12
|
+
this.client = new client_s3_1.S3Client({
|
|
13
|
+
region: config.region,
|
|
14
|
+
credentials: config.credentials,
|
|
15
|
+
endpoint: config.endpoint,
|
|
16
|
+
forcePathStyle: config.forcePathStyle,
|
|
17
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
async putObject(params) {
|
|
21
|
+
try {
|
|
22
|
+
await this.client.send(new client_s3_1.PutObjectCommand({
|
|
23
|
+
Bucket: params.Bucket,
|
|
24
|
+
Key: params.Key,
|
|
25
|
+
Body: params.Body,
|
|
26
|
+
ContentType: params.ContentType,
|
|
27
|
+
Metadata: params.Metadata,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw wrapError(err);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async uploadObject(params) {
|
|
35
|
+
try {
|
|
36
|
+
const upload = new lib_storage_1.Upload({
|
|
37
|
+
client: this.client,
|
|
38
|
+
params: {
|
|
39
|
+
Bucket: params.Bucket,
|
|
40
|
+
Key: params.Key,
|
|
41
|
+
Body: params.Body,
|
|
42
|
+
ContentType: params.ContentType,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
await upload.done();
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw wrapError(err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async getObject(params) {
|
|
52
|
+
try {
|
|
53
|
+
const result = await this.client.send(new client_s3_1.GetObjectCommand(params));
|
|
54
|
+
const body = result.Body;
|
|
55
|
+
if (!body)
|
|
56
|
+
throw new errors_1.S3AdaptorError('Empty response body', 'EMPTY_BODY');
|
|
57
|
+
return {
|
|
58
|
+
Body: body,
|
|
59
|
+
ContentType: result.ContentType,
|
|
60
|
+
ContentLength: result.ContentLength,
|
|
61
|
+
Metadata: result.Metadata,
|
|
62
|
+
ETag: result.ETag,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof client_s3_1.NoSuchKey)
|
|
67
|
+
throw new errors_1.NotFoundError(params.Bucket, params.Key);
|
|
68
|
+
throw wrapError(err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async deleteObject(params) {
|
|
72
|
+
try {
|
|
73
|
+
await this.client.send(new client_s3_1.DeleteObjectCommand(params));
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
throw wrapError(err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async copyObject(params) {
|
|
80
|
+
try {
|
|
81
|
+
await this.client.send(new client_s3_1.CopyObjectCommand({
|
|
82
|
+
Bucket: params.Bucket,
|
|
83
|
+
Key: params.Key,
|
|
84
|
+
CopySource: params.CopySource,
|
|
85
|
+
ContentType: params.ContentType,
|
|
86
|
+
Metadata: params.Metadata,
|
|
87
|
+
MetadataDirective: params.Metadata ? 'REPLACE' : 'COPY',
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
throw wrapError(err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async listObjects(params) {
|
|
95
|
+
try {
|
|
96
|
+
const result = await this.client.send(new client_s3_1.ListObjectsV2Command({
|
|
97
|
+
Bucket: params.Bucket,
|
|
98
|
+
Prefix: params.Prefix,
|
|
99
|
+
MaxKeys: params.MaxKeys,
|
|
100
|
+
}));
|
|
101
|
+
return {
|
|
102
|
+
Contents: (result.Contents ?? []).map((item) => ({
|
|
103
|
+
Key: item.Key ?? '',
|
|
104
|
+
Size: item.Size ?? 0,
|
|
105
|
+
LastModified: item.LastModified ?? new Date(),
|
|
106
|
+
ETag: item.ETag,
|
|
107
|
+
})),
|
|
108
|
+
IsTruncated: result.IsTruncated ?? false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
throw wrapError(err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async getSignedUrl(params) {
|
|
116
|
+
const expiresIn = params.Expires ?? 3600;
|
|
117
|
+
if (params.operation === 'put') {
|
|
118
|
+
const cmd = new client_s3_1.PutObjectCommand({
|
|
119
|
+
Bucket: params.Bucket,
|
|
120
|
+
Key: params.Key,
|
|
121
|
+
ContentType: params.ContentType,
|
|
122
|
+
});
|
|
123
|
+
return (0, s3_request_presigner_1.getSignedUrl)(this.client, cmd, { expiresIn });
|
|
124
|
+
}
|
|
125
|
+
const cmd = new client_s3_1.GetObjectCommand({
|
|
126
|
+
Bucket: params.Bucket,
|
|
127
|
+
Key: params.Key,
|
|
128
|
+
ResponseContentDisposition: params.ResponseContentDisposition,
|
|
129
|
+
});
|
|
130
|
+
return (0, s3_request_presigner_1.getSignedUrl)(this.client, cmd, { expiresIn });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
exports.S3Adapter = S3Adapter;
|
|
134
|
+
function wrapError(err) {
|
|
135
|
+
if (err instanceof errors_1.S3AdaptorError)
|
|
136
|
+
return err;
|
|
137
|
+
if (err instanceof Error)
|
|
138
|
+
return new errors_1.S3AdaptorError(err.message, 'S3_ERROR');
|
|
139
|
+
return new errors_1.S3AdaptorError(String(err), 'S3_ERROR');
|
|
140
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class S3AdaptorError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(message: string, code: string);
|
|
4
|
+
}
|
|
5
|
+
export declare class NotFoundError extends S3AdaptorError {
|
|
6
|
+
constructor(bucket: string, key: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class InvalidConfigError extends S3AdaptorError {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class TokenExpiredError extends S3AdaptorError {
|
|
12
|
+
constructor();
|
|
13
|
+
}
|
|
14
|
+
export declare class InvalidTokenError extends S3AdaptorError {
|
|
15
|
+
constructor();
|
|
16
|
+
}
|
|
17
|
+
export declare class AccessDeniedError extends S3AdaptorError {
|
|
18
|
+
constructor();
|
|
19
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AccessDeniedError = exports.InvalidTokenError = exports.TokenExpiredError = exports.InvalidConfigError = exports.NotFoundError = exports.S3AdaptorError = void 0;
|
|
4
|
+
class S3AdaptorError extends Error {
|
|
5
|
+
constructor(message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = 'S3AdaptorError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.S3AdaptorError = S3AdaptorError;
|
|
12
|
+
class NotFoundError extends S3AdaptorError {
|
|
13
|
+
constructor(bucket, key) {
|
|
14
|
+
super(`Object not found: ${bucket}/${key}`, 'NOT_FOUND');
|
|
15
|
+
this.name = 'NotFoundError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.NotFoundError = NotFoundError;
|
|
19
|
+
class InvalidConfigError extends S3AdaptorError {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message, 'INVALID_CONFIG');
|
|
22
|
+
this.name = 'InvalidConfigError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.InvalidConfigError = InvalidConfigError;
|
|
26
|
+
class TokenExpiredError extends S3AdaptorError {
|
|
27
|
+
constructor() {
|
|
28
|
+
super('Token has expired', 'TOKEN_EXPIRED');
|
|
29
|
+
this.name = 'TokenExpiredError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.TokenExpiredError = TokenExpiredError;
|
|
33
|
+
class InvalidTokenError extends S3AdaptorError {
|
|
34
|
+
constructor() {
|
|
35
|
+
super('Invalid token', 'INVALID_TOKEN');
|
|
36
|
+
this.name = 'InvalidTokenError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.InvalidTokenError = InvalidTokenError;
|
|
40
|
+
class AccessDeniedError extends S3AdaptorError {
|
|
41
|
+
constructor() {
|
|
42
|
+
super('Access denied', 'ACCESS_DENIED');
|
|
43
|
+
this.name = 'AccessDeniedError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.AccessDeniedError = AccessDeniedError;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { S3Adaptor } from './S3Adaptor';
|
|
2
|
+
export { createLocalFileMiddleware } from './middleware/localFileMiddleware';
|
|
3
|
+
export type { S3AdaptorConfig, LocalConfig, BucketPolicy, PutObjectParams, UploadObjectParams, GetObjectParams, DeleteObjectParams, CopyObjectParams, ListObjectsParams, GetSignedUrlParams, GetObjectResult, ListObjectsResult, } from './types';
|
|
4
|
+
export { S3AdaptorError, NotFoundError, TokenExpiredError, InvalidTokenError, InvalidConfigError, AccessDeniedError, } from './errors';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AccessDeniedError = exports.InvalidConfigError = exports.InvalidTokenError = exports.TokenExpiredError = exports.NotFoundError = exports.S3AdaptorError = exports.createLocalFileMiddleware = exports.S3Adaptor = void 0;
|
|
4
|
+
var S3Adaptor_1 = require("./S3Adaptor");
|
|
5
|
+
Object.defineProperty(exports, "S3Adaptor", { enumerable: true, get: function () { return S3Adaptor_1.S3Adaptor; } });
|
|
6
|
+
var localFileMiddleware_1 = require("./middleware/localFileMiddleware");
|
|
7
|
+
Object.defineProperty(exports, "createLocalFileMiddleware", { enumerable: true, get: function () { return localFileMiddleware_1.createLocalFileMiddleware; } });
|
|
8
|
+
var errors_1 = require("./errors");
|
|
9
|
+
Object.defineProperty(exports, "S3AdaptorError", { enumerable: true, get: function () { return errors_1.S3AdaptorError; } });
|
|
10
|
+
Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return errors_1.NotFoundError; } });
|
|
11
|
+
Object.defineProperty(exports, "TokenExpiredError", { enumerable: true, get: function () { return errors_1.TokenExpiredError; } });
|
|
12
|
+
Object.defineProperty(exports, "InvalidTokenError", { enumerable: true, get: function () { return errors_1.InvalidTokenError; } });
|
|
13
|
+
Object.defineProperty(exports, "InvalidConfigError", { enumerable: true, get: function () { return errors_1.InvalidConfigError; } });
|
|
14
|
+
Object.defineProperty(exports, "AccessDeniedError", { enumerable: true, get: function () { return errors_1.AccessDeniedError; } });
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.createLocalFileMiddleware = createLocalFileMiddleware;
|
|
37
|
+
const fs_1 = require("fs");
|
|
38
|
+
const fs_2 = require("fs");
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const errors_1 = require("../errors");
|
|
41
|
+
const TokenSigner_1 = require("../signing/TokenSigner");
|
|
42
|
+
function validateKey(key) {
|
|
43
|
+
if (key.startsWith('/') || key.includes('..')) {
|
|
44
|
+
throw new errors_1.S3AdaptorError('Forbidden key path', 'FORBIDDEN');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function validateBucket(bucket) {
|
|
48
|
+
if (bucket.startsWith('/') || bucket.includes('..') || bucket.includes('/')) {
|
|
49
|
+
throw new errors_1.S3AdaptorError('Forbidden bucket name', 'FORBIDDEN');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function resolveFilePath(storagePath, bucket, key) {
|
|
53
|
+
validateBucket(bucket);
|
|
54
|
+
validateKey(key);
|
|
55
|
+
return path.join(storagePath, bucket, key);
|
|
56
|
+
}
|
|
57
|
+
function metaPath(filePath) {
|
|
58
|
+
const dir = path.dirname(filePath);
|
|
59
|
+
const base = path.basename(filePath);
|
|
60
|
+
return path.join(dir, `.${base}.meta.json`);
|
|
61
|
+
}
|
|
62
|
+
async function readMetadata(filePath) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await fs_2.promises.readFile(metaPath(filePath), 'utf-8');
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Object key from the route wildcard. Express 5 mounts as `/:bucket/*key` and
|
|
73
|
+
* yields the path segments as an array; Express 4 mounts as `/:bucket/*` and
|
|
74
|
+
* yields the remainder as `params[0]`.
|
|
75
|
+
*/
|
|
76
|
+
function extractKey(params) {
|
|
77
|
+
const wildcard = params['key'] ?? params['0'] ?? '';
|
|
78
|
+
return Array.isArray(wildcard) ? wildcard.join('/') : wildcard;
|
|
79
|
+
}
|
|
80
|
+
function createLocalFileMiddleware(config) {
|
|
81
|
+
const signer = new TokenSigner_1.TokenSigner(config.secretKey);
|
|
82
|
+
return async (req, res, next) => {
|
|
83
|
+
try {
|
|
84
|
+
const bucket = req.params['bucket'];
|
|
85
|
+
const key = extractKey(req.params);
|
|
86
|
+
if (typeof bucket !== 'string' || !bucket) {
|
|
87
|
+
res.status(400).json({ error: 'Missing bucket' });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const isPublic = config.buckets?.[bucket]?.public ?? false;
|
|
91
|
+
// Signed (private-bucket) disposition comes from the verified token,
|
|
92
|
+
// never the raw query string - otherwise a caller could edit the
|
|
93
|
+
// query string to override the disposition the URL was signed with
|
|
94
|
+
// (e.g. turning a deliberately-forced "attachment" into "inline"),
|
|
95
|
+
// since the query string carries no signature of its own. Public
|
|
96
|
+
// buckets have no token to bind it to, so they keep the bare param.
|
|
97
|
+
let disposition;
|
|
98
|
+
if (!isPublic) {
|
|
99
|
+
const token = req.query['token'];
|
|
100
|
+
if (!token)
|
|
101
|
+
throw new errors_1.AccessDeniedError();
|
|
102
|
+
const claims = signer.verify(token);
|
|
103
|
+
if (claims.bucket !== bucket || claims.key !== key)
|
|
104
|
+
throw new errors_1.InvalidTokenError();
|
|
105
|
+
disposition = claims.disposition;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const queryDisposition = req.query['disposition'];
|
|
109
|
+
disposition = typeof queryDisposition === 'string' ? queryDisposition : undefined;
|
|
110
|
+
}
|
|
111
|
+
const filePath = resolveFilePath(config.storagePath, bucket, key);
|
|
112
|
+
try {
|
|
113
|
+
await fs_2.promises.access(filePath);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new errors_1.NotFoundError(bucket, key);
|
|
117
|
+
}
|
|
118
|
+
const meta = await readMetadata(filePath);
|
|
119
|
+
res.setHeader('Content-Type', meta?.ContentType ?? 'application/octet-stream');
|
|
120
|
+
if (meta?.Size !== undefined)
|
|
121
|
+
res.setHeader('Content-Length', meta.Size);
|
|
122
|
+
if (meta?.ETag)
|
|
123
|
+
res.setHeader('ETag', meta.ETag);
|
|
124
|
+
if (disposition)
|
|
125
|
+
res.setHeader('Content-Disposition', disposition);
|
|
126
|
+
(0, fs_1.createReadStream)(filePath).pipe(res);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof errors_1.AccessDeniedError ||
|
|
130
|
+
err instanceof errors_1.TokenExpiredError ||
|
|
131
|
+
err instanceof errors_1.InvalidTokenError) {
|
|
132
|
+
res.status(403).json({ error: err.message });
|
|
133
|
+
}
|
|
134
|
+
else if (err instanceof errors_1.NotFoundError) {
|
|
135
|
+
res.status(404).json({ error: err.message });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
next(err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface TokenPayload {
|
|
2
|
+
bucket: string;
|
|
3
|
+
key: string;
|
|
4
|
+
exp: number;
|
|
5
|
+
disposition?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class TokenSigner {
|
|
8
|
+
private readonly secretKey;
|
|
9
|
+
constructor(secretKey: string);
|
|
10
|
+
sign(payload: TokenPayload): string;
|
|
11
|
+
verify(token: string): TokenPayload;
|
|
12
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TokenSigner = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const errors_1 = require("../errors");
|
|
6
|
+
class TokenSigner {
|
|
7
|
+
constructor(secretKey) {
|
|
8
|
+
this.secretKey = secretKey;
|
|
9
|
+
}
|
|
10
|
+
sign(payload) {
|
|
11
|
+
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
12
|
+
const sig = (0, crypto_1.createHmac)('sha256', this.secretKey).update(data).digest('base64url');
|
|
13
|
+
return `${data}.${sig}`;
|
|
14
|
+
}
|
|
15
|
+
verify(token) {
|
|
16
|
+
const dotIndex = token.lastIndexOf('.');
|
|
17
|
+
if (dotIndex === -1)
|
|
18
|
+
throw new errors_1.InvalidTokenError();
|
|
19
|
+
const data = token.slice(0, dotIndex);
|
|
20
|
+
const sig = token.slice(dotIndex + 1);
|
|
21
|
+
const expected = (0, crypto_1.createHmac)('sha256', this.secretKey).update(data).digest('base64url');
|
|
22
|
+
const sigBuf = Buffer.from(sig);
|
|
23
|
+
const expectedBuf = Buffer.from(expected);
|
|
24
|
+
if (sigBuf.length !== expectedBuf.length || !(0, crypto_1.timingSafeEqual)(sigBuf, expectedBuf)) {
|
|
25
|
+
throw new errors_1.InvalidTokenError();
|
|
26
|
+
}
|
|
27
|
+
let payload;
|
|
28
|
+
try {
|
|
29
|
+
payload = JSON.parse(Buffer.from(data, 'base64url').toString());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new errors_1.InvalidTokenError();
|
|
33
|
+
}
|
|
34
|
+
if (Date.now() > payload.exp * 1000)
|
|
35
|
+
throw new errors_1.TokenExpiredError();
|
|
36
|
+
return payload;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.TokenSigner = TokenSigner;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export interface BucketPolicy {
|
|
2
|
+
public: boolean;
|
|
3
|
+
}
|
|
4
|
+
export interface LocalConfig {
|
|
5
|
+
storagePath: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
secretKey: string;
|
|
8
|
+
defaultExpiry?: number;
|
|
9
|
+
buckets?: Record<string, BucketPolicy>;
|
|
10
|
+
}
|
|
11
|
+
export interface S3AdaptorConfig {
|
|
12
|
+
defaultType: 's3' | 'local';
|
|
13
|
+
s3?: {
|
|
14
|
+
region: string;
|
|
15
|
+
credentials?: {
|
|
16
|
+
accessKeyId: string;
|
|
17
|
+
secretAccessKey: string;
|
|
18
|
+
};
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
forcePathStyle?: boolean;
|
|
21
|
+
};
|
|
22
|
+
local?: LocalConfig;
|
|
23
|
+
}
|
|
24
|
+
export interface PutObjectParams {
|
|
25
|
+
Bucket: string;
|
|
26
|
+
Key: string;
|
|
27
|
+
Body: Buffer | NodeJS.ReadableStream | string;
|
|
28
|
+
ContentType?: string;
|
|
29
|
+
Metadata?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
export interface UploadObjectParams {
|
|
32
|
+
Bucket: string;
|
|
33
|
+
Key: string;
|
|
34
|
+
Body: Buffer | NodeJS.ReadableStream | string;
|
|
35
|
+
ContentType?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface GetObjectParams {
|
|
38
|
+
Bucket: string;
|
|
39
|
+
Key: string;
|
|
40
|
+
}
|
|
41
|
+
export interface DeleteObjectParams {
|
|
42
|
+
Bucket: string;
|
|
43
|
+
Key: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ListObjectsParams {
|
|
46
|
+
Bucket: string;
|
|
47
|
+
Prefix?: string;
|
|
48
|
+
MaxKeys?: number;
|
|
49
|
+
}
|
|
50
|
+
export interface CopyObjectParams {
|
|
51
|
+
Bucket: string;
|
|
52
|
+
Key: string;
|
|
53
|
+
CopySource: string;
|
|
54
|
+
ContentType?: string;
|
|
55
|
+
Metadata?: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
export interface GetSignedUrlParams {
|
|
58
|
+
Bucket: string;
|
|
59
|
+
Key: string;
|
|
60
|
+
Expires?: number;
|
|
61
|
+
ResponseContentDisposition?: string;
|
|
62
|
+
operation?: 'get' | 'put';
|
|
63
|
+
ContentType?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface GetObjectResult {
|
|
66
|
+
Body: NodeJS.ReadableStream;
|
|
67
|
+
ContentType?: string;
|
|
68
|
+
ContentLength?: number;
|
|
69
|
+
Metadata?: Record<string, string>;
|
|
70
|
+
ETag?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface ListObjectsResult {
|
|
73
|
+
Contents: Array<{
|
|
74
|
+
Key: string;
|
|
75
|
+
Size: number;
|
|
76
|
+
LastModified: Date;
|
|
77
|
+
ETag?: string;
|
|
78
|
+
}>;
|
|
79
|
+
IsTruncated: boolean;
|
|
80
|
+
}
|
|
81
|
+
export interface StoredMetadata {
|
|
82
|
+
ContentType?: string;
|
|
83
|
+
Metadata?: Record<string, string>;
|
|
84
|
+
ETag: string;
|
|
85
|
+
Size: number;
|
|
86
|
+
LastModified: string;
|
|
87
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "s3-adaptor",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Adapter-pattern interface for AWS S3 and local filesystem storage, with signed URLs and Express middleware",
|
|
5
|
+
"license": "BSD-3-Clause",
|
|
6
|
+
"author": "Kevin Foster <kevin.foster.uk@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/kevin-foster-uk/s3-adaptor#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/kevin-foster-uk/s3-adaptor/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/kevin-foster-uk/s3-adaptor.git"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"/dist"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"config": {
|
|
24
|
+
"commitizen": {
|
|
25
|
+
"path": "cz-customizable"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@aws-sdk/client-s3": "^3.1142.0",
|
|
30
|
+
"@aws-sdk/lib-storage": "^3.1142.0",
|
|
31
|
+
"@aws-sdk/s3-request-presigner": "^3.1142.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"express": "^4.0.0 || ^5.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@changesets/cli": "^3.0.3",
|
|
38
|
+
"@cspell/dict-node": "^5.0.9",
|
|
39
|
+
"@cspell/dict-npm": "^5.2.50",
|
|
40
|
+
"@cspell/dict-typescript": "^3.2.3",
|
|
41
|
+
"@types/express": "^5.0.6",
|
|
42
|
+
"@types/jest": "^29.5.14",
|
|
43
|
+
"@types/node": "^20.19.43",
|
|
44
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
45
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
46
|
+
"commitizen": "^4.3.2",
|
|
47
|
+
"cspell": "^9.8.0",
|
|
48
|
+
"cz-customizable": "^7.5.4",
|
|
49
|
+
"eslint": "^8.57.1",
|
|
50
|
+
"jest": "^29.7.0",
|
|
51
|
+
"memfs": "^4.79.0",
|
|
52
|
+
"prettier": "^3.9.9",
|
|
53
|
+
"ts-jest": "^29.4.14",
|
|
54
|
+
"typescript": "^5.9.3"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsc",
|
|
58
|
+
"typecheck": "pnpm -r typecheck",
|
|
59
|
+
"test": "jest",
|
|
60
|
+
"compile": "pnpm format && pnpm build && pnpm typecheck && pnpm test",
|
|
61
|
+
"test:watch": "jest --watch",
|
|
62
|
+
"format": "prettier --write ./src",
|
|
63
|
+
"lint": "eslint src --ext .ts",
|
|
64
|
+
"commit": "cz",
|
|
65
|
+
"changeset": "changeset"
|
|
66
|
+
}
|
|
67
|
+
}
|