@aws-blocks/bb-file-bucket 0.1.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.
Files changed (87) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +197 -0
  3. package/dist/bucket-name.d.ts +9 -0
  4. package/dist/bucket-name.d.ts.map +1 -0
  5. package/dist/bucket-name.js +62 -0
  6. package/dist/bucket-name.test.d.ts +2 -0
  7. package/dist/bucket-name.test.d.ts.map +1 -0
  8. package/dist/bucket-name.test.js +61 -0
  9. package/dist/errors.d.ts +20 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +21 -0
  12. package/dist/file-server.d.ts +14 -0
  13. package/dist/file-server.d.ts.map +1 -0
  14. package/dist/file-server.js +169 -0
  15. package/dist/file-server.test.d.ts +2 -0
  16. package/dist/file-server.test.d.ts.map +1 -0
  17. package/dist/file-server.test.js +307 -0
  18. package/dist/index.aws.d.ts +47 -0
  19. package/dist/index.aws.d.ts.map +1 -0
  20. package/dist/index.aws.js +183 -0
  21. package/dist/index.browser.d.ts +4 -0
  22. package/dist/index.browser.d.ts.map +1 -0
  23. package/dist/index.browser.js +6 -0
  24. package/dist/index.cdk.d.ts +16 -0
  25. package/dist/index.cdk.d.ts.map +1 -0
  26. package/dist/index.cdk.js +75 -0
  27. package/dist/index.cdk.test.d.ts +2 -0
  28. package/dist/index.cdk.test.d.ts.map +1 -0
  29. package/dist/index.cdk.test.js +69 -0
  30. package/dist/index.mock.d.ts +246 -0
  31. package/dist/index.mock.d.ts.map +1 -0
  32. package/dist/index.mock.js +502 -0
  33. package/dist/index.test.d.ts +2 -0
  34. package/dist/index.test.d.ts.map +1 -0
  35. package/dist/index.test.js +318 -0
  36. package/dist/middleware.d.ts +3 -0
  37. package/dist/middleware.d.ts.map +1 -0
  38. package/dist/middleware.js +62 -0
  39. package/dist/mock-middleware.d.ts +3 -0
  40. package/dist/mock-middleware.d.ts.map +1 -0
  41. package/dist/mock-middleware.js +62 -0
  42. package/dist/mock-utils.d.ts +11 -0
  43. package/dist/mock-utils.d.ts.map +1 -0
  44. package/dist/mock-utils.js +28 -0
  45. package/dist/path-containment.test.d.ts +2 -0
  46. package/dist/path-containment.test.d.ts.map +1 -0
  47. package/dist/path-containment.test.js +91 -0
  48. package/dist/paths.d.ts +25 -0
  49. package/dist/paths.d.ts.map +1 -0
  50. package/dist/paths.js +67 -0
  51. package/dist/scan.test.d.ts +2 -0
  52. package/dist/scan.test.d.ts.map +1 -0
  53. package/dist/scan.test.js +107 -0
  54. package/dist/tokens.d.ts +12 -0
  55. package/dist/tokens.d.ts.map +1 -0
  56. package/dist/tokens.js +42 -0
  57. package/dist/types.d.ts +170 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +3 -0
  60. package/dist/url-encoding.test.d.ts +2 -0
  61. package/dist/url-encoding.test.d.ts.map +1 -0
  62. package/dist/url-encoding.test.js +88 -0
  63. package/dist/version.d.ts +3 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +3 -0
  66. package/package.json +57 -0
  67. package/src/bucket-name.test.ts +103 -0
  68. package/src/bucket-name.ts +83 -0
  69. package/src/errors.ts +22 -0
  70. package/src/file-server.test.ts +381 -0
  71. package/src/file-server.ts +203 -0
  72. package/src/index.aws.ts +219 -0
  73. package/src/index.browser.ts +7 -0
  74. package/src/index.cdk.test.ts +84 -0
  75. package/src/index.cdk.ts +89 -0
  76. package/src/index.mock.ts +531 -0
  77. package/src/index.test.ts +366 -0
  78. package/src/middleware.ts +66 -0
  79. package/src/mock-middleware.ts +66 -0
  80. package/src/mock-utils.ts +31 -0
  81. package/src/path-containment.test.ts +122 -0
  82. package/src/paths.ts +78 -0
  83. package/src/scan.test.ts +137 -0
  84. package/src/tokens.ts +61 -0
  85. package/src/types.ts +206 -0
  86. package/src/url-encoding.test.ts +120 -0
  87. package/src/version.ts +3 -0
@@ -0,0 +1,169 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { isBlocksError } from '@aws-blocks/core';
6
+ import { validateFileToken, LOCAL_FILE_SECRET } from './tokens.js';
7
+ import { assertContainedPath } from './mock-utils.js';
8
+ import { contentRoot, contentPath, metaPath, versionContentPath, versionMetaPath } from './paths.js';
9
+ const PREFIX = '/.bb-file-bucket/';
10
+ function parseUrl(url) {
11
+ if (!url.startsWith(PREFIX))
12
+ return null;
13
+ const [pathPart, query] = url.slice(PREFIX.length).split('?');
14
+ if (!pathPart || !query)
15
+ return null;
16
+ const params = new URLSearchParams(query);
17
+ const token = params.get('token');
18
+ if (!token)
19
+ return null;
20
+ const slashIdx = pathPart.indexOf('/');
21
+ if (slashIdx === -1)
22
+ return null;
23
+ return {
24
+ fullId: pathPart.slice(0, slashIdx),
25
+ path: decodeURIComponent(pathPart.slice(slashIdx + 1)),
26
+ token,
27
+ versionId: params.get('versionId') ?? undefined,
28
+ };
29
+ }
30
+ function collectBody(req) {
31
+ return new Promise((resolve, reject) => {
32
+ const chunks = [];
33
+ req.on('data', (chunk) => chunks.push(chunk));
34
+ req.on('end', () => resolve(Buffer.concat(chunks)));
35
+ req.on('error', reject);
36
+ });
37
+ }
38
+ function sendError(res, status, error) {
39
+ res.writeHead(status, { 'Content-Type': 'application/json' });
40
+ res.end(JSON.stringify({ error }));
41
+ }
42
+ export function attach(httpServer) {
43
+ const originalListeners = httpServer.listeners('request').slice();
44
+ httpServer.removeAllListeners('request');
45
+ httpServer.on('request', (req, res) => {
46
+ const url = req.url || '';
47
+ if (!url.startsWith(PREFIX)) {
48
+ // Pass through to original handlers
49
+ for (const listener of originalListeners) {
50
+ listener(req, res);
51
+ }
52
+ return;
53
+ }
54
+ // CORS for browser uploads/downloads
55
+ const origin = req.headers.origin || '*';
56
+ res.setHeader('Access-Control-Allow-Origin', origin);
57
+ res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
58
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
59
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
60
+ if (req.method === 'OPTIONS') {
61
+ res.writeHead(200);
62
+ res.end();
63
+ return;
64
+ }
65
+ const parsed = parseUrl(url);
66
+ if (!parsed) {
67
+ sendError(res, 400, 'Invalid file URL');
68
+ return;
69
+ }
70
+ const { fullId, path, token, versionId } = parsed;
71
+ const dataDir = join(process.cwd(), '.bb-data', fullId);
72
+ try {
73
+ // User keys live under the content root; validate against it so a
74
+ // traversal attempt can't escape into internal meta/version storage.
75
+ assertContainedPath(contentRoot(dataDir), path);
76
+ }
77
+ catch (err) {
78
+ const message = isBlocksError(err, 'ValidationFailed') ? err.message : 'Invalid path: traversal detected';
79
+ sendError(res, 400, message);
80
+ return;
81
+ }
82
+ // versionId is attacker-controlled and is joined into a filesystem path
83
+ // (versionContentPath). It must match the generated format (`v<n>`);
84
+ // anything else (e.g. `../../../etc/passwd`) is rejected so it can't be
85
+ // used for path traversal / arbitrary file read.
86
+ if (versionId !== undefined && !/^v\d{1,10}$/.test(versionId)) {
87
+ sendError(res, 400, 'Invalid versionId');
88
+ return;
89
+ }
90
+ if (req.method === 'GET') {
91
+ const valid = validateFileToken(token, LOCAL_FILE_SECRET, fullId, path, 'GET');
92
+ if (!valid) {
93
+ sendError(res, 403, 'Invalid or expired token');
94
+ return;
95
+ }
96
+ // Resolve the file to read — specific version or current
97
+ let readPath = contentPath(dataDir, path);
98
+ let metaFilePath = metaPath(dataDir, path);
99
+ if (versionId) {
100
+ const vPath = versionContentPath(dataDir, path, versionId);
101
+ if (existsSync(vPath)) {
102
+ readPath = vPath;
103
+ metaFilePath = versionMetaPath(dataDir, path, versionId);
104
+ }
105
+ }
106
+ if (!existsSync(readPath)) {
107
+ sendError(res, 404, 'NoSuchKey');
108
+ return;
109
+ }
110
+ let contentType = 'application/octet-stream';
111
+ if (existsSync(metaFilePath)) {
112
+ try {
113
+ const meta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
114
+ contentType = meta.contentType ?? contentType;
115
+ }
116
+ catch { }
117
+ }
118
+ const body = readFileSync(readPath);
119
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': body.length.toString() });
120
+ res.end(body);
121
+ }
122
+ else if (req.method === 'PUT') {
123
+ const valid = validateFileToken(token, LOCAL_FILE_SECRET, fullId, path, 'PUT');
124
+ if (!valid) {
125
+ sendError(res, 403, 'Invalid or expired token');
126
+ return;
127
+ }
128
+ // PUT delegates to the registered FileBucket instance, which owns the
129
+ // storage layout (content/meta/versions) plus versioning and key
130
+ // validation. The dev server and the buckets share a process, so the
131
+ // instance is always registered in practice; if it's missing, fail
132
+ // loud rather than silently writing an unversioned object.
133
+ const registry = globalThis.__BLOCKS_FILE_BUCKET_REGISTRY__;
134
+ const bucket = registry?.get(fullId);
135
+ if (!bucket || typeof bucket.put !== 'function') {
136
+ sendError(res, 500, `No FileBucket registered for "${fullId}" — cannot handle upload`);
137
+ return;
138
+ }
139
+ // Content-Type parity with real S3: when a presigned PUT URL is
140
+ // minted with a contentType, the AWS SDK adds `content-type` to the
141
+ // signed headers, so S3 returns 403 SignatureDoesNotMatch if the
142
+ // uploaded request's Content-Type differs from (or omits) the signed
143
+ // value. The mock used to ignore the request header entirely and
144
+ // accept any upload, masking a failure that only surfaced in prod.
145
+ // Enforce the same check here so a mismatch fails loudly in local dev.
146
+ const requestContentType = req.headers['content-type'];
147
+ if (valid.contentType !== undefined && requestContentType !== valid.contentType) {
148
+ sendError(res, 403, `SignatureDoesNotMatch: request Content-Type ${requestContentType === undefined ? '(missing)' : `"${requestContentType}"`} does not match the signed Content-Type "${valid.contentType}". ` +
149
+ `Send the same Content-Type header that was used to create the upload URL.`);
150
+ return;
151
+ }
152
+ collectBody(req).then(async (body) => {
153
+ // When a contentType was signed, it equals the (now validated)
154
+ // request header. Otherwise fall back to whatever the request
155
+ // sent, then octet-stream — matching S3's stored content type.
156
+ const contentType = valid.contentType || requestContentType || 'application/octet-stream';
157
+ await bucket.put(path, body, { contentType });
158
+ res.writeHead(200);
159
+ res.end();
160
+ }).catch((err) => {
161
+ sendError(res, 500, err instanceof Error ? err.message : String(err));
162
+ });
163
+ }
164
+ else {
165
+ res.writeHead(405);
166
+ res.end();
167
+ }
168
+ });
169
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=file-server.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-server.test.d.ts","sourceRoot":"","sources":["../src/file-server.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,307 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Tests for the dev file-server attachment — presigned URL serving,
5
+ * path encoding/decoding, and versioning integration.
6
+ */
7
+ import { test, describe, beforeEach, afterEach } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import { rmSync, existsSync, readdirSync } from 'node:fs';
10
+ import { createServer } from 'node:http';
11
+ import { Scope } from '@aws-blocks/core';
12
+ import { FileBucket } from './index.mock.js';
13
+ import { attach } from './file-server.js';
14
+ import { mintFileToken, LOCAL_FILE_SECRET } from './tokens.js';
15
+ const scope = new Scope('fsrv');
16
+ let server;
17
+ let port;
18
+ beforeEach(async () => {
19
+ const dir = '.bb-data';
20
+ try {
21
+ if (existsSync(dir)) {
22
+ for (const entry of readdirSync(dir)) {
23
+ if (entry.startsWith('fsrv-')) {
24
+ rmSync(`${dir}/${entry}`, { recursive: true, force: true });
25
+ }
26
+ }
27
+ }
28
+ }
29
+ catch { }
30
+ server = createServer((_req, res) => {
31
+ res.writeHead(404);
32
+ res.end('not found');
33
+ });
34
+ attach(server);
35
+ port = await new Promise((resolve) => {
36
+ server.listen(0, () => {
37
+ const addr = server.address();
38
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
39
+ });
40
+ });
41
+ });
42
+ afterEach(() => {
43
+ server.close();
44
+ });
45
+ // ── Basic presigned URL round-trip ──────────────────────────────────────────
46
+ describe('file-server: basic GET/PUT', () => {
47
+ test('PUT then GET via presigned URLs', async () => {
48
+ const bucket = new FileBucket(scope, 'fs-basic');
49
+ const putUrl = await bucket.putUrl('hello.txt', { contentType: 'text/plain' });
50
+ const adjustedPut = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
51
+ const putRes = await fetch(adjustedPut, {
52
+ method: 'PUT',
53
+ body: 'hello world',
54
+ headers: { 'Content-Type': 'text/plain' },
55
+ });
56
+ assert.strictEqual(putRes.status, 200, `PUT failed: ${putRes.status}`);
57
+ const getUrl = await bucket.getUrl('hello.txt');
58
+ const adjustedGet = getUrl.replace(/localhost:\d+/, `localhost:${port}`);
59
+ const getRes = await fetch(adjustedGet);
60
+ assert.strictEqual(getRes.status, 200, `GET failed: ${getRes.status}`);
61
+ assert.strictEqual(await getRes.text(), 'hello world');
62
+ });
63
+ test('GET non-existent file returns 404', async () => {
64
+ const bucket = new FileBucket(scope, 'fs-404');
65
+ const url = await bucket.getUrl('missing.txt');
66
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
67
+ const res = await fetch(adjusted);
68
+ assert.strictEqual(res.status, 404);
69
+ });
70
+ test('invalid token returns 403', async () => {
71
+ const url = `http://localhost:${port}/.bb-file-bucket/root-test/file.txt?token=invalid.token`;
72
+ const res = await fetch(url);
73
+ assert.strictEqual(res.status, 403);
74
+ });
75
+ test('PUT for an unregistered bucket fails loud (500), no silent write', async () => {
76
+ // Mint a structurally valid token for a fullId that has no FileBucket
77
+ // instance registered. The server must refuse rather than fall back to
78
+ // an unversioned direct write.
79
+ const unknownId = 'fsrv-unregistered';
80
+ const token = mintFileToken(unknownId, 'orphan.txt', 'PUT', 3600, LOCAL_FILE_SECRET, 'text/plain');
81
+ const url = `http://localhost:${port}/.bb-file-bucket/${unknownId}/orphan.txt?token=${token}`;
82
+ const res = await fetch(url, {
83
+ method: 'PUT',
84
+ body: 'should not be written',
85
+ headers: { 'Content-Type': 'text/plain' },
86
+ });
87
+ assert.strictEqual(res.status, 500, `Expected 500 for unregistered bucket, got ${res.status}`);
88
+ });
89
+ });
90
+ // ── Content-Type parity with S3 presigned PUT ───────────────────────────────
91
+ describe('file-server: Content-Type signing parity', () => {
92
+ test('PUT with a Content-Type that differs from the signed value returns 403', async () => {
93
+ const bucket = new FileBucket(scope, 'fs-ct1');
94
+ // URL is signed for image/png …
95
+ const putUrl = await bucket.putUrl('avatar', { contentType: 'image/png' });
96
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
97
+ // … but the client uploads with image/jpeg. Real S3 rejects this with
98
+ // SignatureDoesNotMatch; the mock must do the same so the failure shows
99
+ // up locally instead of only in prod.
100
+ const res = await fetch(adjusted, {
101
+ method: 'PUT',
102
+ body: 'fake png bytes',
103
+ headers: { 'Content-Type': 'image/jpeg' },
104
+ });
105
+ assert.strictEqual(res.status, 403, `Expected 403 for mismatched Content-Type, got ${res.status}`);
106
+ // And nothing was written.
107
+ assert.strictEqual(await bucket.get('avatar'), null, 'mismatched upload must not be stored');
108
+ });
109
+ test('PUT omitting Content-Type when one was signed returns 403', async () => {
110
+ const bucket = new FileBucket(scope, 'fs-ct2');
111
+ const putUrl = await bucket.putUrl('doc.pdf', { contentType: 'application/pdf' });
112
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
113
+ // node-fetch/undici defaults a string body to text/plain; send an empty
114
+ // body with no explicit Content-Type isn't reliable across runtimes, so
115
+ // assert the realistic case: a wrong/absent signed header is rejected.
116
+ const res = await fetch(adjusted, {
117
+ method: 'PUT',
118
+ body: new Blob(['data']), // Blob with no type → Content-Type omitted by undici
119
+ });
120
+ assert.strictEqual(res.status, 403, `Expected 403 when signed Content-Type is missing, got ${res.status}`);
121
+ });
122
+ test('PUT with a matching Content-Type succeeds and stores it', async () => {
123
+ const bucket = new FileBucket(scope, 'fs-ct3');
124
+ const putUrl = await bucket.putUrl('report.csv', { contentType: 'text/csv' });
125
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
126
+ const res = await fetch(adjusted, {
127
+ method: 'PUT',
128
+ body: 'a,b,c',
129
+ headers: { 'Content-Type': 'text/csv' },
130
+ });
131
+ assert.strictEqual(res.status, 200, `Expected 200 for matching Content-Type, got ${res.status}`);
132
+ const file = await bucket.get('report.csv');
133
+ assert.ok(file, 'matching upload should be stored');
134
+ assert.strictEqual(file.contentType, 'text/csv');
135
+ assert.strictEqual(file.body.toString(), 'a,b,c');
136
+ });
137
+ test('createUploadHandle round-trips because it sends the signed Content-Type', async () => {
138
+ // The typed handle path sets the request header from the same value it
139
+ // signs, so it stays consistent across mock and prod. This pins that the
140
+ // new enforcement does not regress the happy path.
141
+ const bucket = new FileBucket(scope, 'fs-ct4');
142
+ const handle = await bucket.createUploadHandle('photo.jpg', { contentType: 'image/jpeg' });
143
+ const adjusted = handle.getUrl().replace(/localhost:\d+/, `localhost:${port}`);
144
+ const res = await fetch(adjusted, {
145
+ method: 'PUT',
146
+ body: 'jpeg bytes',
147
+ headers: { 'Content-Type': 'image/jpeg' },
148
+ });
149
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}`);
150
+ const file = await bucket.get('photo.jpg');
151
+ assert.ok(file);
152
+ assert.strictEqual(file.contentType, 'image/jpeg');
153
+ });
154
+ test('PUT without a signed Content-Type accepts any request header', async () => {
155
+ // putUrl without contentType signs no content-type, mirroring an S3
156
+ // presigned URL that did not include it — any upload header is allowed.
157
+ const bucket = new FileBucket(scope, 'fs-ct5');
158
+ const putUrl = await bucket.putUrl('blob.bin');
159
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
160
+ const res = await fetch(adjusted, {
161
+ method: 'PUT',
162
+ body: 'anything',
163
+ headers: { 'Content-Type': 'application/x-custom' },
164
+ });
165
+ assert.strictEqual(res.status, 200, `Expected 200 when no Content-Type was signed, got ${res.status}`);
166
+ const file = await bucket.get('blob.bin');
167
+ assert.ok(file);
168
+ assert.strictEqual(file.contentType, 'application/x-custom');
169
+ });
170
+ });
171
+ // ── Path encoding/decoding ──────────────────────────────────────────────────
172
+ describe('file-server: URL-encoded paths', () => {
173
+ test('GET with spaces in path (encoded as %20)', async () => {
174
+ const bucket = new FileBucket(scope, 'fs-enc1');
175
+ await bucket.put('my folder/my file.txt', 'spaced content', { contentType: 'text/plain' });
176
+ const token = mintFileToken('fsrv-fs-enc1', 'my folder/my file.txt', 'GET', 3600, LOCAL_FILE_SECRET);
177
+ const encodedPath = 'my%20folder/my%20file.txt';
178
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc1/${encodedPath}?token=${token}`;
179
+ const res = await fetch(url);
180
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
181
+ assert.strictEqual(await res.text(), 'spaced content');
182
+ });
183
+ test('GET with # in filename (encoded as %23)', async () => {
184
+ const bucket = new FileBucket(scope, 'fs-enc2');
185
+ await bucket.put('file#1.txt', 'hash content', { contentType: 'text/plain' });
186
+ const token = mintFileToken('fsrv-fs-enc2', 'file#1.txt', 'GET', 3600, LOCAL_FILE_SECRET);
187
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc2/file%231.txt?token=${token}`;
188
+ const res = await fetch(url);
189
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
190
+ assert.strictEqual(await res.text(), 'hash content');
191
+ });
192
+ test('GET with + in filename (should not decode as space)', async () => {
193
+ const bucket = new FileBucket(scope, 'fs-enc3');
194
+ await bucket.put('a+b.txt', 'plus content', { contentType: 'text/plain' });
195
+ const token = mintFileToken('fsrv-fs-enc3', 'a+b.txt', 'GET', 3600, LOCAL_FILE_SECRET);
196
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc3/a%2Bb.txt?token=${token}`;
197
+ const res = await fetch(url);
198
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
199
+ assert.strictEqual(await res.text(), 'plus content');
200
+ });
201
+ test('GET with unicode characters (encoded)', async () => {
202
+ const bucket = new FileBucket(scope, 'fs-enc4');
203
+ await bucket.put('文件/数据.txt', 'unicode content', { contentType: 'text/plain' });
204
+ const token = mintFileToken('fsrv-fs-enc4', '文件/数据.txt', 'GET', 3600, LOCAL_FILE_SECRET);
205
+ const encodedPath = encodeURIComponent('文件') + '/' + encodeURIComponent('数据.txt');
206
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc4/${encodedPath}?token=${token}`;
207
+ const res = await fetch(url);
208
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
209
+ assert.strictEqual(await res.text(), 'unicode content');
210
+ });
211
+ test('PUT with spaces in path (encoded)', async () => {
212
+ const bucket = new FileBucket(scope, 'fs-enc5');
213
+ const token = mintFileToken('fsrv-fs-enc5', 'dir name/file name.txt', 'PUT', 3600, LOCAL_FILE_SECRET, 'text/plain');
214
+ const encodedPath = 'dir%20name/file%20name.txt';
215
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc5/${encodedPath}?token=${token}`;
216
+ const putRes = await fetch(url, {
217
+ method: 'PUT',
218
+ body: 'uploaded with spaces',
219
+ headers: { 'Content-Type': 'text/plain' },
220
+ });
221
+ assert.strictEqual(putRes.status, 200, `PUT failed: ${putRes.status}`);
222
+ // Verify via bucket API
223
+ const file = await bucket.get('dir name/file name.txt');
224
+ assert.ok(file, 'File should exist after presigned PUT');
225
+ assert.strictEqual(file.body.toString(), 'uploaded with spaces');
226
+ });
227
+ });
228
+ // ── Versioning integration ──────────────────────────────────────────────────
229
+ describe('file-server: versioning', () => {
230
+ test('PUT via presigned URL creates a new version', async () => {
231
+ const bucket = new FileBucket(scope, 'fs-ver1', { versioned: true });
232
+ await bucket.put('doc.txt', 'v1-api', { contentType: 'text/plain' });
233
+ const vBefore = await bucket.listVersions('doc.txt');
234
+ assert.strictEqual(vBefore.length, 1);
235
+ // Upload v2 via presigned URL
236
+ const putUrl = await bucket.putUrl('doc.txt', { contentType: 'text/plain' });
237
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
238
+ const res = await fetch(adjusted, {
239
+ method: 'PUT',
240
+ body: 'v2-presigned',
241
+ headers: { 'Content-Type': 'text/plain' },
242
+ });
243
+ assert.strictEqual(res.status, 200);
244
+ const vAfter = await bucket.listVersions('doc.txt');
245
+ assert.strictEqual(vAfter.length, 2, `Expected 2 versions after presigned PUT, got ${vAfter.length}`);
246
+ const current = await bucket.get('doc.txt');
247
+ assert.ok(current);
248
+ assert.strictEqual(current.body.toString(), 'v2-presigned');
249
+ });
250
+ test('GET with versionId via presigned URL returns specific version', async () => {
251
+ const bucket = new FileBucket(scope, 'fs-ver2', { versioned: true });
252
+ await bucket.put('doc.txt', 'v1', { contentType: 'text/plain' });
253
+ await bucket.put('doc.txt', 'v2', { contentType: 'text/plain' });
254
+ const versions = await bucket.listVersions('doc.txt');
255
+ const oldVersion = versions[versions.length - 1];
256
+ const url = await bucket.getUrl('doc.txt', { versionId: oldVersion.versionId });
257
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
258
+ const res = await fetch(adjusted);
259
+ assert.strictEqual(res.status, 200);
260
+ assert.strictEqual(await res.text(), 'v1');
261
+ });
262
+ test('rejects a traversal versionId (path-traversal guard)', async () => {
263
+ const bucket = new FileBucket(scope, 'fs-ver-traversal', { versioned: true });
264
+ await bucket.put('doc.txt', 'secret-contents', { contentType: 'text/plain' });
265
+ // Start from a legitimate presigned GET URL, then tamper with versionId.
266
+ const url = await bucket.getUrl('doc.txt');
267
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
268
+ const malicious = `${adjusted}${adjusted.includes('?') ? '&' : '?'}versionId=${encodeURIComponent('../../../../../../etc/passwd')}`;
269
+ const res = await fetch(malicious);
270
+ assert.strictEqual(res.status, 400, 'traversal versionId must be rejected with 400');
271
+ const body = await res.text();
272
+ assert.ok(!body.includes('root:'), 'must not leak /etc/passwd contents');
273
+ });
274
+ test('rejects a prefix-bypass versionId (anchored regex guard)', async () => {
275
+ const bucket = new FileBucket(scope, 'fs-ver-prefix-bypass', { versioned: true });
276
+ await bucket.put('doc.txt', 'secret-contents', { contentType: 'text/plain' });
277
+ // Attempt to bypass with a versionId that starts valid but appends traversal.
278
+ const url = await bucket.getUrl('doc.txt');
279
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
280
+ const malicious = `${adjusted}${adjusted.includes('?') ? '&' : '?'}versionId=${encodeURIComponent('v1/../../../etc/passwd')}`;
281
+ const res = await fetch(malicious);
282
+ assert.strictEqual(res.status, 400, 'prefix-bypass versionId must be rejected with 400');
283
+ });
284
+ test('multiple presigned PUTs each create a version', async () => {
285
+ const bucket = new FileBucket(scope, 'fs-ver3', { versioned: true });
286
+ for (let i = 1; i <= 3; i++) {
287
+ const putUrl = await bucket.putUrl('counter.txt', { contentType: 'text/plain' });
288
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
289
+ await fetch(adjusted, {
290
+ method: 'PUT',
291
+ body: `version-${i}`,
292
+ headers: { 'Content-Type': 'text/plain' },
293
+ });
294
+ }
295
+ const versions = await bucket.listVersions('counter.txt');
296
+ assert.strictEqual(versions.length, 3, `Expected 3 versions, got ${versions.length}`);
297
+ });
298
+ });
299
+ // ── CORS headers ────────────────────────────────────────────────────────────
300
+ describe('file-server: CORS', () => {
301
+ test('OPTIONS request returns CORS headers', async () => {
302
+ const url = `http://localhost:${port}/.bb-file-bucket/any/path?token=x`;
303
+ const res = await fetch(url, { method: 'OPTIONS' });
304
+ assert.strictEqual(res.status, 200);
305
+ assert.ok(res.headers.get('access-control-allow-methods')?.includes('PUT'));
306
+ });
307
+ });
@@ -0,0 +1,47 @@
1
+ import { Scope } from '@aws-blocks/core';
2
+ import type { ScopeParent } from '@aws-blocks/core';
3
+ import type { FileBucketOptions, PutOptions, PutUrlOptions, ScanOptions, FileContent, FileInfo, ExternalBucketRef, FileDownloadClient, FileUploadClient, FileVersionInfo, GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor } from './types.js';
4
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
5
+ export { FileBucketErrors } from './errors.js';
6
+ export type { FileBucketOptions, PutOptions, GetUrlOptions, PutUrlOptions, ScanOptions, FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef, FileDownloadClient, FileUploadClient, FileVersionInfo, FileDownloadDescriptor, FileUploadDescriptor, VersionedGetOptions, VersionedDeleteOptions, VersionedGetUrlOptions, GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor, } from './types.js';
7
+ /**
8
+ * File storage backed by Amazon S3.
9
+ *
10
+ * **When to use:** You need to store, retrieve, or serve binary files —
11
+ * user uploads, generated reports, images, videos, or static assets.
12
+ *
13
+ * **When NOT to use:** If you need structured key-value data with conditional
14
+ * writes, use `KVStore`. If you need queryable records with indexes, use
15
+ * `DistributedTable`.
16
+ *
17
+ * **Best practices:**
18
+ * - Use path prefixes to organize files (e.g., `uploads/{userId}/`, `reports/`)
19
+ * - Set `contentType` on `put()` to ensure correct MIME handling on download
20
+ * - Use `getFileHandle` / `createUploadHandle` for ergonomic browser file transfers
21
+ * - Use presigned URLs (`getUrl` / `putUrl`) when you need direct URL control
22
+ * - Prefer `scan({ prefix })` over unscoped `scan()` to limit enumeration cost
23
+ *
24
+ * **Scaling:** S3 scales automatically. No provisioned throughput. Costs are
25
+ * per-request plus storage. Individual objects up to 5 TB. For objects larger
26
+ * than ~100 MB, consider multipart upload.
27
+ */
28
+ export declare class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends Scope {
29
+ readonly bbName = "FileBucket";
30
+ private s3;
31
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
32
+ protected log: ChildLogger;
33
+ constructor(scope: ScopeParent, id: string, options?: O);
34
+ put(path: string, body: Buffer | string, options?: PutOptions): Promise<void>;
35
+ get(path: string, options?: GetOptionsFor<O>): Promise<FileContent | null>;
36
+ delete(path: string, options?: DeleteOptionsFor<O>): Promise<void>;
37
+ deleteBatch(paths: string[]): Promise<void>;
38
+ getUrl(path: string, options?: GetUrlOptionsFor<O>): Promise<string>;
39
+ putUrl(path: string, options?: PutUrlOptions): Promise<string>;
40
+ getFileHandle(path: string, options?: GetUrlOptionsFor<O>): Promise<FileDownloadClient>;
41
+ createUploadHandle(path: string, options?: PutUrlOptions): Promise<FileUploadClient>;
42
+ scan(options?: ScanOptions): AsyncIterable<FileInfo>;
43
+ listVersions(path: string): Promise<FileVersionInfo[]>;
44
+ restoreVersion(path: string, versionId: string): Promise<void>;
45
+ static fromExisting(bucketName: string): ExternalBucketRef;
46
+ }
47
+ //# sourceMappingURL=index.aws.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,KAAK,EAA6C,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,KAAK,EACX,iBAAiB,EAAE,UAAU,EAAE,aAAa,EAAE,WAAW,EACzD,WAAW,EAAE,QAAQ,EAAE,iBAAiB,EACxC,kBAAkB,EAAE,gBAAgB,EAAE,eAAe,EACrD,aAAa,EAAE,gBAAgB,EAAE,gBAAgB,EACjD,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAGzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EACX,iBAAiB,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,EACxE,WAAW,EAAE,QAAQ,EAAE,QAAQ,EAAE,aAAa,EAAE,iBAAiB,EACjE,kBAAkB,EAAE,gBAAgB,EAAE,eAAe,EACrD,sBAAsB,EAAE,oBAAoB,EAC5C,mBAAmB,EAAE,sBAAsB,EAAE,sBAAsB,EACnE,aAAa,EAAE,gBAAgB,EAAE,gBAAgB,GACjD,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,UAAU,CAAC,CAAC,SAAS,iBAAiB,GAAG,iBAAiB,CAAE,SAAQ,KAAK;IACrF,QAAQ,CAAC,MAAM,gBAAW;IAC1B,OAAO,CAAC,EAAE,CAAW;IAErB,2FAA2F;IAC3F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAWjD,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7E,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAmB1E,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAOlE,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAW3C,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;IAUpE,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC;IAM9D,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAavF,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAenF,IAAI,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IAarD,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAyBtD,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASpE,MAAM,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,iBAAiB;CAG1D"}