@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.
- package/LICENSE +174 -0
- package/README.md +197 -0
- package/dist/bucket-name.d.ts +9 -0
- package/dist/bucket-name.d.ts.map +1 -0
- package/dist/bucket-name.js +62 -0
- package/dist/bucket-name.test.d.ts +2 -0
- package/dist/bucket-name.test.d.ts.map +1 -0
- package/dist/bucket-name.test.js +61 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/file-server.d.ts +14 -0
- package/dist/file-server.d.ts.map +1 -0
- package/dist/file-server.js +169 -0
- package/dist/file-server.test.d.ts +2 -0
- package/dist/file-server.test.d.ts.map +1 -0
- package/dist/file-server.test.js +307 -0
- package/dist/index.aws.d.ts +47 -0
- package/dist/index.aws.d.ts.map +1 -0
- package/dist/index.aws.js +183 -0
- package/dist/index.browser.d.ts +4 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +6 -0
- package/dist/index.cdk.d.ts +16 -0
- package/dist/index.cdk.d.ts.map +1 -0
- package/dist/index.cdk.js +75 -0
- package/dist/index.cdk.test.d.ts +2 -0
- package/dist/index.cdk.test.d.ts.map +1 -0
- package/dist/index.cdk.test.js +69 -0
- package/dist/index.mock.d.ts +246 -0
- package/dist/index.mock.d.ts.map +1 -0
- package/dist/index.mock.js +502 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +318 -0
- package/dist/middleware.d.ts +3 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +62 -0
- package/dist/mock-middleware.d.ts +3 -0
- package/dist/mock-middleware.d.ts.map +1 -0
- package/dist/mock-middleware.js +62 -0
- package/dist/mock-utils.d.ts +11 -0
- package/dist/mock-utils.d.ts.map +1 -0
- package/dist/mock-utils.js +28 -0
- package/dist/path-containment.test.d.ts +2 -0
- package/dist/path-containment.test.d.ts.map +1 -0
- package/dist/path-containment.test.js +91 -0
- package/dist/paths.d.ts +25 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +67 -0
- package/dist/scan.test.d.ts +2 -0
- package/dist/scan.test.d.ts.map +1 -0
- package/dist/scan.test.js +107 -0
- package/dist/tokens.d.ts +12 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +42 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/url-encoding.test.d.ts +2 -0
- package/dist/url-encoding.test.d.ts.map +1 -0
- package/dist/url-encoding.test.js +88 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/package.json +57 -0
- package/src/bucket-name.test.ts +103 -0
- package/src/bucket-name.ts +83 -0
- package/src/errors.ts +22 -0
- package/src/file-server.test.ts +381 -0
- package/src/file-server.ts +203 -0
- package/src/index.aws.ts +219 -0
- package/src/index.browser.ts +7 -0
- package/src/index.cdk.test.ts +84 -0
- package/src/index.cdk.ts +89 -0
- package/src/index.mock.ts +531 -0
- package/src/index.test.ts +366 -0
- package/src/middleware.ts +66 -0
- package/src/mock-middleware.ts +66 -0
- package/src/mock-utils.ts +31 -0
- package/src/path-containment.test.ts +122 -0
- package/src/paths.ts +78 -0
- package/src/scan.test.ts +137 -0
- package/src/tokens.ts +61 -0
- package/src/types.ts +206 -0
- package/src/url-encoding.test.ts +120 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dev server attachment for FileBucket.
|
|
6
|
+
* Serves token-gated GET/PUT requests at /.bb-file-bucket/{fullId}/{path}?token=...
|
|
7
|
+
* Mirrors S3 presigned URL behavior: method-scoped, time-limited, path-specific.
|
|
8
|
+
*
|
|
9
|
+
* Storage layout mirrors `index.mock.ts` via the shared `paths.ts` helpers, so
|
|
10
|
+
* user content (under `content/`) is never confused with internal metadata or
|
|
11
|
+
* version bookkeeping. PUT always delegates to the registered FileBucket
|
|
12
|
+
* instance so uploads get versioning, key validation, and metadata handling —
|
|
13
|
+
* there is no direct-write fallback.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Server, IncomingMessage, ServerResponse } from 'node:http';
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { isBlocksError } from '@aws-blocks/core';
|
|
20
|
+
import { validateFileToken, LOCAL_FILE_SECRET } from './tokens.js';
|
|
21
|
+
import { assertContainedPath } from './mock-utils.js';
|
|
22
|
+
import { contentRoot, contentPath, metaPath, versionContentPath, versionMetaPath } from './paths.js';
|
|
23
|
+
|
|
24
|
+
const PREFIX = '/.bb-file-bucket/';
|
|
25
|
+
|
|
26
|
+
function parseUrl(url: string): { fullId: string; path: string; token: string; versionId?: string } | null {
|
|
27
|
+
if (!url.startsWith(PREFIX)) return null;
|
|
28
|
+
const [pathPart, query] = url.slice(PREFIX.length).split('?');
|
|
29
|
+
if (!pathPart || !query) return null;
|
|
30
|
+
const params = new URLSearchParams(query);
|
|
31
|
+
const token = params.get('token');
|
|
32
|
+
if (!token) return null;
|
|
33
|
+
const slashIdx = pathPart.indexOf('/');
|
|
34
|
+
if (slashIdx === -1) return null;
|
|
35
|
+
return {
|
|
36
|
+
fullId: pathPart.slice(0, slashIdx),
|
|
37
|
+
path: decodeURIComponent(pathPart.slice(slashIdx + 1)),
|
|
38
|
+
token,
|
|
39
|
+
versionId: params.get('versionId') ?? undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function collectBody(req: IncomingMessage): Promise<Buffer> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const chunks: Buffer[] = [];
|
|
46
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
47
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
48
|
+
req.on('error', reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sendError(res: ServerResponse, status: number, error: string): void {
|
|
53
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
54
|
+
res.end(JSON.stringify({ error }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function attach(httpServer: Server) {
|
|
58
|
+
const originalListeners = httpServer.listeners('request').slice() as Array<(req: IncomingMessage, res: ServerResponse) => void>;
|
|
59
|
+
httpServer.removeAllListeners('request');
|
|
60
|
+
|
|
61
|
+
httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
|
|
62
|
+
const url = req.url || '';
|
|
63
|
+
if (!url.startsWith(PREFIX)) {
|
|
64
|
+
// Pass through to original handlers
|
|
65
|
+
for (const listener of originalListeners) {
|
|
66
|
+
listener(req, res);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// CORS for browser uploads/downloads
|
|
72
|
+
const origin = req.headers.origin || '*';
|
|
73
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
74
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
|
|
75
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
76
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
77
|
+
|
|
78
|
+
if (req.method === 'OPTIONS') {
|
|
79
|
+
res.writeHead(200);
|
|
80
|
+
res.end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parsed = parseUrl(url);
|
|
85
|
+
if (!parsed) {
|
|
86
|
+
sendError(res, 400, 'Invalid file URL');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { fullId, path, token, versionId } = parsed;
|
|
91
|
+
const dataDir = join(process.cwd(), '.bb-data', fullId);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// User keys live under the content root; validate against it so a
|
|
95
|
+
// traversal attempt can't escape into internal meta/version storage.
|
|
96
|
+
assertContainedPath(contentRoot(dataDir), path);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const message = isBlocksError(err, 'ValidationFailed') ? err.message : 'Invalid path: traversal detected';
|
|
99
|
+
sendError(res, 400, message);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// versionId is attacker-controlled and is joined into a filesystem path
|
|
104
|
+
// (versionContentPath). It must match the generated format (`v<n>`);
|
|
105
|
+
// anything else (e.g. `../../../etc/passwd`) is rejected so it can't be
|
|
106
|
+
// used for path traversal / arbitrary file read.
|
|
107
|
+
if (versionId !== undefined && !/^v\d{1,10}$/.test(versionId)) {
|
|
108
|
+
sendError(res, 400, 'Invalid versionId');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (req.method === 'GET') {
|
|
113
|
+
const valid = validateFileToken(token, LOCAL_FILE_SECRET, fullId, path, 'GET');
|
|
114
|
+
if (!valid) {
|
|
115
|
+
sendError(res, 403, 'Invalid or expired token');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Resolve the file to read — specific version or current
|
|
120
|
+
let readPath = contentPath(dataDir, path);
|
|
121
|
+
let metaFilePath = metaPath(dataDir, path);
|
|
122
|
+
if (versionId) {
|
|
123
|
+
const vPath = versionContentPath(dataDir, path, versionId);
|
|
124
|
+
if (existsSync(vPath)) {
|
|
125
|
+
readPath = vPath;
|
|
126
|
+
metaFilePath = versionMetaPath(dataDir, path, versionId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!existsSync(readPath)) {
|
|
131
|
+
sendError(res, 404, 'NoSuchKey');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let contentType = 'application/octet-stream';
|
|
136
|
+
if (existsSync(metaFilePath)) {
|
|
137
|
+
try {
|
|
138
|
+
const meta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
|
|
139
|
+
contentType = meta.contentType ?? contentType;
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const body = readFileSync(readPath);
|
|
144
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': body.length.toString() });
|
|
145
|
+
res.end(body);
|
|
146
|
+
} else if (req.method === 'PUT') {
|
|
147
|
+
const valid = validateFileToken(token, LOCAL_FILE_SECRET, fullId, path, 'PUT');
|
|
148
|
+
if (!valid) {
|
|
149
|
+
sendError(res, 403, 'Invalid or expired token');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// PUT delegates to the registered FileBucket instance, which owns the
|
|
154
|
+
// storage layout (content/meta/versions) plus versioning and key
|
|
155
|
+
// validation. The dev server and the buckets share a process, so the
|
|
156
|
+
// instance is always registered in practice; if it's missing, fail
|
|
157
|
+
// loud rather than silently writing an unversioned object.
|
|
158
|
+
const registry: Map<string, { put?: unknown }> | undefined = (globalThis as any).__BLOCKS_FILE_BUCKET_REGISTRY__;
|
|
159
|
+
const bucket = registry?.get(fullId);
|
|
160
|
+
if (!bucket || typeof bucket.put !== 'function') {
|
|
161
|
+
sendError(res, 500, `No FileBucket registered for "${fullId}" — cannot handle upload`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Content-Type parity with real S3: when a presigned PUT URL is
|
|
166
|
+
// minted with a contentType, the AWS SDK adds `content-type` to the
|
|
167
|
+
// signed headers, so S3 returns 403 SignatureDoesNotMatch if the
|
|
168
|
+
// uploaded request's Content-Type differs from (or omits) the signed
|
|
169
|
+
// value. The mock used to ignore the request header entirely and
|
|
170
|
+
// accept any upload, masking a failure that only surfaced in prod.
|
|
171
|
+
// Enforce the same check here so a mismatch fails loudly in local dev.
|
|
172
|
+
const requestContentType = req.headers['content-type'];
|
|
173
|
+
if (valid.contentType !== undefined && requestContentType !== valid.contentType) {
|
|
174
|
+
sendError(
|
|
175
|
+
res,
|
|
176
|
+
403,
|
|
177
|
+
`SignatureDoesNotMatch: request Content-Type ${
|
|
178
|
+
requestContentType === undefined ? '(missing)' : `"${requestContentType}"`
|
|
179
|
+
} does not match the signed Content-Type "${valid.contentType}". ` +
|
|
180
|
+
`Send the same Content-Type header that was used to create the upload URL.`,
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
collectBody(req).then(async (body) => {
|
|
186
|
+
// When a contentType was signed, it equals the (now validated)
|
|
187
|
+
// request header. Otherwise fall back to whatever the request
|
|
188
|
+
// sent, then octet-stream — matching S3's stored content type.
|
|
189
|
+
const contentType = valid.contentType || requestContentType || 'application/octet-stream';
|
|
190
|
+
await (bucket.put as (p: string, b: Buffer, o: { contentType: string }) => Promise<void>)(
|
|
191
|
+
path, body, { contentType },
|
|
192
|
+
);
|
|
193
|
+
res.writeHead(200);
|
|
194
|
+
res.end();
|
|
195
|
+
}).catch((err) => {
|
|
196
|
+
sendError(res, 500, err instanceof Error ? err.message : String(err));
|
|
197
|
+
});
|
|
198
|
+
} else {
|
|
199
|
+
res.writeHead(405);
|
|
200
|
+
res.end();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
package/src/index.aws.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
S3Client,
|
|
6
|
+
PutObjectCommand,
|
|
7
|
+
GetObjectCommand,
|
|
8
|
+
DeleteObjectCommand,
|
|
9
|
+
DeleteObjectsCommand,
|
|
10
|
+
ListObjectsV2Command,
|
|
11
|
+
ListObjectVersionsCommand,
|
|
12
|
+
CopyObjectCommand,
|
|
13
|
+
} from '@aws-sdk/client-s3';
|
|
14
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
15
|
+
import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
|
|
16
|
+
import type { ScopeParent } from '@aws-blocks/core';
|
|
17
|
+
import { BB_NAME, BB_VERSION } from './version.js';
|
|
18
|
+
import type {
|
|
19
|
+
FileBucketOptions, PutOptions, PutUrlOptions, ScanOptions,
|
|
20
|
+
FileContent, FileInfo, ExternalBucketRef,
|
|
21
|
+
FileDownloadClient, FileUploadClient, FileVersionInfo,
|
|
22
|
+
GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
import { Logger } from '@aws-blocks/bb-logger';
|
|
25
|
+
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
26
|
+
|
|
27
|
+
// Re-export public types
|
|
28
|
+
export { FileBucketErrors } from './errors.js';
|
|
29
|
+
export type {
|
|
30
|
+
FileBucketOptions, PutOptions, GetUrlOptions, PutUrlOptions, ScanOptions,
|
|
31
|
+
FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef,
|
|
32
|
+
FileDownloadClient, FileUploadClient, FileVersionInfo,
|
|
33
|
+
FileDownloadDescriptor, FileUploadDescriptor,
|
|
34
|
+
VersionedGetOptions, VersionedDeleteOptions, VersionedGetUrlOptions,
|
|
35
|
+
GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor,
|
|
36
|
+
} from './types.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* File storage backed by Amazon S3.
|
|
40
|
+
*
|
|
41
|
+
* **When to use:** You need to store, retrieve, or serve binary files —
|
|
42
|
+
* user uploads, generated reports, images, videos, or static assets.
|
|
43
|
+
*
|
|
44
|
+
* **When NOT to use:** If you need structured key-value data with conditional
|
|
45
|
+
* writes, use `KVStore`. If you need queryable records with indexes, use
|
|
46
|
+
* `DistributedTable`.
|
|
47
|
+
*
|
|
48
|
+
* **Best practices:**
|
|
49
|
+
* - Use path prefixes to organize files (e.g., `uploads/{userId}/`, `reports/`)
|
|
50
|
+
* - Set `contentType` on `put()` to ensure correct MIME handling on download
|
|
51
|
+
* - Use `getFileHandle` / `createUploadHandle` for ergonomic browser file transfers
|
|
52
|
+
* - Use presigned URLs (`getUrl` / `putUrl`) when you need direct URL control
|
|
53
|
+
* - Prefer `scan({ prefix })` over unscoped `scan()` to limit enumeration cost
|
|
54
|
+
*
|
|
55
|
+
* **Scaling:** S3 scales automatically. No provisioned throughput. Costs are
|
|
56
|
+
* per-request plus storage. Individual objects up to 5 TB. For objects larger
|
|
57
|
+
* than ~100 MB, consider multipart upload.
|
|
58
|
+
*/
|
|
59
|
+
export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends Scope {
|
|
60
|
+
readonly bbName = BB_NAME;
|
|
61
|
+
private s3: S3Client;
|
|
62
|
+
|
|
63
|
+
/** @internal Logger for internal operations. Defaults to error-level when not provided. */
|
|
64
|
+
protected log: ChildLogger;
|
|
65
|
+
|
|
66
|
+
constructor(scope: ScopeParent, id: string, options?: O) {
|
|
67
|
+
super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
|
|
68
|
+
this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
|
|
69
|
+
this.registerClientMiddleware('@aws-blocks/bb-file-bucket/middleware');
|
|
70
|
+
const bucketName = options?.bucket ? options.bucket.bucketName : this.fullId;
|
|
71
|
+
registerSdkIdentifiers(this.fullId, { bucketName });
|
|
72
|
+
this.s3 = new S3Client({
|
|
73
|
+
customUserAgent: this.buildUserAgentChain(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async put(path: string, body: Buffer | string, options?: PutOptions): Promise<void> {
|
|
78
|
+
await this.s3.send(new PutObjectCommand({
|
|
79
|
+
Bucket: getSdkIdentifiers(this).bucketName,
|
|
80
|
+
Key: path,
|
|
81
|
+
Body: typeof body === 'string' ? Buffer.from(body) : body,
|
|
82
|
+
ContentType: options?.contentType,
|
|
83
|
+
Metadata: options?.metadata,
|
|
84
|
+
CacheControl: options?.cacheControl,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async get(path: string, options?: GetOptionsFor<O>): Promise<FileContent | null> {
|
|
89
|
+
try {
|
|
90
|
+
const result = await this.s3.send(new GetObjectCommand({
|
|
91
|
+
Bucket: getSdkIdentifiers(this).bucketName, Key: path,
|
|
92
|
+
...(options ? { VersionId: (options as any).versionId } : {}),
|
|
93
|
+
}));
|
|
94
|
+
const bytes = await result.Body!.transformToByteArray();
|
|
95
|
+
return {
|
|
96
|
+
body: Buffer.from(bytes),
|
|
97
|
+
contentType: result.ContentType ?? 'application/octet-stream',
|
|
98
|
+
metadata: result.Metadata ?? {},
|
|
99
|
+
size: result.ContentLength ?? bytes.length,
|
|
100
|
+
};
|
|
101
|
+
} catch (e: any) {
|
|
102
|
+
if (e.name === 'NoSuchKey') return null;
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async delete(path: string, options?: DeleteOptionsFor<O>): Promise<void> {
|
|
108
|
+
await this.s3.send(new DeleteObjectCommand({
|
|
109
|
+
Bucket: getSdkIdentifiers(this).bucketName, Key: path,
|
|
110
|
+
...(options ? { VersionId: (options as any).versionId } : {}),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async deleteBatch(paths: string[]): Promise<void> {
|
|
115
|
+
const CHUNK_SIZE = 1000;
|
|
116
|
+
for (let i = 0; i < paths.length; i += CHUNK_SIZE) {
|
|
117
|
+
const chunk = paths.slice(i, i + CHUNK_SIZE);
|
|
118
|
+
await this.s3.send(new DeleteObjectsCommand({
|
|
119
|
+
Bucket: getSdkIdentifiers(this).bucketName,
|
|
120
|
+
Delete: { Objects: chunk.map(Key => ({ Key })), Quiet: true },
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getUrl(path: string, options?: GetUrlOptionsFor<O>): Promise<string> {
|
|
126
|
+
const opts = options as any;
|
|
127
|
+
return getSignedUrl(this.s3, new GetObjectCommand({
|
|
128
|
+
Bucket: getSdkIdentifiers(this).bucketName, Key: path,
|
|
129
|
+
...(opts?.versionId ? { VersionId: opts.versionId } : {}),
|
|
130
|
+
}), {
|
|
131
|
+
expiresIn: opts?.expiresIn ?? 3600,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async putUrl(path: string, options?: PutUrlOptions): Promise<string> {
|
|
136
|
+
return getSignedUrl(this.s3, new PutObjectCommand({
|
|
137
|
+
Bucket: getSdkIdentifiers(this).bucketName, Key: path, ContentType: options?.contentType,
|
|
138
|
+
}), { expiresIn: options?.expiresIn ?? 3600 });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async getFileHandle(path: string, options?: GetUrlOptionsFor<O>): Promise<FileDownloadClient> {
|
|
142
|
+
const url = await this.getUrl(path, options);
|
|
143
|
+
return {
|
|
144
|
+
download: async () => {
|
|
145
|
+
const res = await fetch(url);
|
|
146
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
147
|
+
return res.blob();
|
|
148
|
+
},
|
|
149
|
+
getUrl: () => url,
|
|
150
|
+
toJSON: () => ({ __blocks: 'file-bucket/download' as const, url }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async createUploadHandle(path: string, options?: PutUrlOptions): Promise<FileUploadClient> {
|
|
155
|
+
const url = await this.putUrl(path, options);
|
|
156
|
+
const contentType = options?.contentType;
|
|
157
|
+
return {
|
|
158
|
+
upload: async (body: Blob | File | ArrayBuffer) => {
|
|
159
|
+
const headers: Record<string, string> = {};
|
|
160
|
+
if (contentType) headers['Content-Type'] = contentType;
|
|
161
|
+
const res = await fetch(url, { method: 'PUT', body, headers });
|
|
162
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
|
163
|
+
},
|
|
164
|
+
getUrl: () => url,
|
|
165
|
+
toJSON: () => ({ __blocks: 'file-bucket/upload' as const, url, contentType }),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async *scan(options?: ScanOptions): AsyncIterable<FileInfo> {
|
|
170
|
+
let continuationToken: string | undefined;
|
|
171
|
+
do {
|
|
172
|
+
const result = await this.s3.send(new ListObjectsV2Command({
|
|
173
|
+
Bucket: getSdkIdentifiers(this).bucketName, Prefix: options?.prefix, ContinuationToken: continuationToken,
|
|
174
|
+
}));
|
|
175
|
+
for (const obj of result.Contents ?? []) {
|
|
176
|
+
yield { path: obj.Key!, size: obj.Size ?? 0, lastModified: obj.LastModified ?? new Date() };
|
|
177
|
+
}
|
|
178
|
+
continuationToken = result.NextContinuationToken;
|
|
179
|
+
} while (continuationToken);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listVersions(path: string): Promise<FileVersionInfo[]> {
|
|
183
|
+
const versions: FileVersionInfo[] = [];
|
|
184
|
+
let keyMarker: string | undefined;
|
|
185
|
+
let versionIdMarker: string | undefined;
|
|
186
|
+
do {
|
|
187
|
+
const result = await this.s3.send(new ListObjectVersionsCommand({
|
|
188
|
+
Bucket: getSdkIdentifiers(this).bucketName, Prefix: path, KeyMarker: keyMarker, VersionIdMarker: versionIdMarker,
|
|
189
|
+
}));
|
|
190
|
+
for (const v of result.Versions ?? []) {
|
|
191
|
+
if (v.Key !== path) continue; // prefix match may include other keys
|
|
192
|
+
versions.push({
|
|
193
|
+
versionId: v.VersionId!,
|
|
194
|
+
lastModified: v.LastModified ?? new Date(),
|
|
195
|
+
size: v.Size ?? 0,
|
|
196
|
+
isCurrent: v.IsLatest ?? false,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
keyMarker = result.NextKeyMarker;
|
|
200
|
+
versionIdMarker = result.NextVersionIdMarker;
|
|
201
|
+
} while (keyMarker);
|
|
202
|
+
// Newest first
|
|
203
|
+
versions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
204
|
+
return versions;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async restoreVersion(path: string, versionId: string): Promise<void> {
|
|
208
|
+
const encodedPath = path.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
209
|
+
await this.s3.send(new CopyObjectCommand({
|
|
210
|
+
Bucket: getSdkIdentifiers(this).bucketName,
|
|
211
|
+
Key: path,
|
|
212
|
+
CopySource: `${getSdkIdentifiers(this).bucketName}/${path}?versionId=${versionId}`,
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
static fromExisting(bucketName: string): ExternalBucketRef {
|
|
217
|
+
return { __brand: 'ExternalBucketRef' as const, bucketName };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CDK-side regression tests for FileBucket.
|
|
6
|
+
*
|
|
7
|
+
* History: FileBucket.fromExisting was advertised in types but the CDK
|
|
8
|
+
* constructor unconditionally provisioned a new S3 bucket. These tests pin
|
|
9
|
+
* the fix.
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
import * as cdk from 'aws-cdk-lib';
|
|
14
|
+
import type { Construct } from 'constructs';
|
|
15
|
+
import { Template } from 'aws-cdk-lib/assertions';
|
|
16
|
+
import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
17
|
+
import { FileBucket } from './index.cdk.js';
|
|
18
|
+
|
|
19
|
+
class StubBlocksStack extends cdk.Stack {
|
|
20
|
+
public readonly handler: cdk.aws_lambda.Function;
|
|
21
|
+
public readonly id: string;
|
|
22
|
+
constructor(scope: Construct, id: string) {
|
|
23
|
+
super(scope, id);
|
|
24
|
+
this.id = id;
|
|
25
|
+
(globalThis as any).CURRENT_BLOCKS_STACK = this;
|
|
26
|
+
this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
|
|
27
|
+
runtime: DEFAULT_NODE_RUNTIME,
|
|
28
|
+
handler: 'index.handler',
|
|
29
|
+
code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setup(): { stack: StubBlocksStack; parent: Scope } {
|
|
35
|
+
const app = new cdk.App();
|
|
36
|
+
// S3 bucket names must be lowercase. The default-mode FileBucket derives
|
|
37
|
+
// its bucket name from the scope chain, so keep ids lowercase.
|
|
38
|
+
const stack = new StubBlocksStack(app, 'teststack');
|
|
39
|
+
const parent = new Scope('app');
|
|
40
|
+
return { stack, parent };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('CDK: default FileBucket provisions an S3 bucket', () => {
|
|
44
|
+
const { stack, parent } = setup();
|
|
45
|
+
new FileBucket(parent, 'uploads');
|
|
46
|
+
const template = Template.fromStack(stack);
|
|
47
|
+
template.resourceCountIs('AWS::S3::Bucket', 1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('CDK: FileBucket.fromExisting does NOT provision a bucket (regression)', () => {
|
|
51
|
+
const { stack, parent } = setup();
|
|
52
|
+
new FileBucket(parent, 'uploads', {
|
|
53
|
+
bucket: FileBucket.fromExisting('preexisting-bucket-123'),
|
|
54
|
+
});
|
|
55
|
+
const template = Template.fromStack(stack);
|
|
56
|
+
template.resourceCountIs('AWS::S3::Bucket', 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('CDK: FileBucket.fromExisting returns a branded ref', () => {
|
|
60
|
+
const ref = FileBucket.fromExisting('foo');
|
|
61
|
+
assert.strictEqual(ref.bucketName, 'foo');
|
|
62
|
+
assert.strictEqual(ref.__brand, 'ExternalBucketRef');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('CDK: default FileBucket with an over-long derived name throws at synth', () => {
|
|
66
|
+
const { parent } = setup();
|
|
67
|
+
// parent id "app" + "-" + a 60-char id => 64 chars, over the S3 limit.
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => new FileBucket(parent, 'u'.repeat(60)),
|
|
70
|
+
(err: unknown) =>
|
|
71
|
+
err instanceof Error &&
|
|
72
|
+
err.name === 'ValidationFailed' &&
|
|
73
|
+
/63-character limit/.test(err.message),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('CDK: fromExisting skips derived-name validation even when the chain is over-long', () => {
|
|
78
|
+
const { parent } = setup();
|
|
79
|
+
assert.doesNotThrow(() =>
|
|
80
|
+
new FileBucket(parent, 'u'.repeat(60), {
|
|
81
|
+
bucket: FileBucket.fromExisting('preexisting-bucket-123'),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
});
|
package/src/index.cdk.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
5
|
+
import * as cdk from 'aws-cdk-lib';
|
|
6
|
+
import { Duration, RemovalPolicy } from 'aws-cdk-lib';
|
|
7
|
+
import { Scope } from '@aws-blocks/core/cdk';
|
|
8
|
+
import type { ScopeParent } from '@aws-blocks/core';
|
|
9
|
+
import type { FileBucketOptions, CorsRule, LifecycleRule, ExternalBucketRef } from './types.js';
|
|
10
|
+
import { validateBucketName } from './bucket-name.js';
|
|
11
|
+
|
|
12
|
+
export { FileBucketErrors } from './errors.js';
|
|
13
|
+
export type { FileBucketOptions, PutOptions, GetUrlOptions, PutUrlOptions, ScanOptions, FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef } from './types.js';
|
|
14
|
+
|
|
15
|
+
const httpMethodMap: Record<string, s3.HttpMethods> = {
|
|
16
|
+
GET: s3.HttpMethods.GET,
|
|
17
|
+
PUT: s3.HttpMethods.PUT,
|
|
18
|
+
POST: s3.HttpMethods.POST,
|
|
19
|
+
DELETE: s3.HttpMethods.DELETE,
|
|
20
|
+
HEAD: s3.HttpMethods.HEAD,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends Scope {
|
|
24
|
+
private bucket: s3.IBucket;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reference an existing S3 bucket instead of provisioning a new one.
|
|
28
|
+
* Mirrors the same factory exposed by the runtime build so the same code
|
|
29
|
+
* works in both contexts.
|
|
30
|
+
*/
|
|
31
|
+
static fromExisting(bucketName: string): ExternalBucketRef {
|
|
32
|
+
return { __brand: 'ExternalBucketRef' as const, bucketName };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
constructor(scope: ScopeParent, id: string, options?: O) {
|
|
36
|
+
super(id, { parent: scope });
|
|
37
|
+
|
|
38
|
+
if (options?.bucket) {
|
|
39
|
+
// `fromExisting`: don't provision; bind to the pre-existing bucket and
|
|
40
|
+
// grant read/write to the Blocks runtime Lambda.
|
|
41
|
+
this.bucket = s3.Bucket.fromBucketName(this, 'bucket', options.bucket.bucketName);
|
|
42
|
+
this.bucket.grantReadWrite(this.handler);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// In sandbox mode, default to DESTROY + autoDeleteObjects so
|
|
47
|
+
// `cdk destroy` can fully clean up without manual bucket emptying.
|
|
48
|
+
// Explicit `removalPolicy` from the customer takes precedence.
|
|
49
|
+
// `autoDeleteObjects: true` is only valid paired with DESTROY (CDK
|
|
50
|
+
// validates this at construct time), so we tie the two together.
|
|
51
|
+
const isSandbox = cdk.Stack.of(this).node.tryGetContext('sandboxMode') === 'true';
|
|
52
|
+
const destroy = options?.removalPolicy === 'destroy' || (isSandbox && options?.removalPolicy === undefined);
|
|
53
|
+
|
|
54
|
+
// Bucket name is derived from the scope chain. Validate against S3's
|
|
55
|
+
// naming rules at synth so an invalid name fails here rather than at
|
|
56
|
+
// `cdk deploy` (where CloudFormation rejects it with a cryptic error).
|
|
57
|
+
validateBucketName(this.fullId);
|
|
58
|
+
|
|
59
|
+
this.bucket = new s3.Bucket(this, 'bucket', {
|
|
60
|
+
bucketName: this.fullId,
|
|
61
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
62
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
63
|
+
versioned: options?.versioned ?? false,
|
|
64
|
+
removalPolicy: destroy
|
|
65
|
+
? RemovalPolicy.DESTROY
|
|
66
|
+
: options?.removalPolicy === 'retain'
|
|
67
|
+
? RemovalPolicy.RETAIN
|
|
68
|
+
: undefined,
|
|
69
|
+
autoDeleteObjects: destroy,
|
|
70
|
+
cors: options?.corsRules?.map((rule: CorsRule) => ({
|
|
71
|
+
allowedOrigins: rule.allowedOrigins,
|
|
72
|
+
allowedMethods: rule.allowedMethods.map(m => httpMethodMap[m]),
|
|
73
|
+
allowedHeaders: rule.allowedHeaders,
|
|
74
|
+
exposedHeaders: rule.exposedHeaders,
|
|
75
|
+
maxAge: rule.maxAge,
|
|
76
|
+
})),
|
|
77
|
+
lifecycleRules: options?.lifecycleRules?.map((rule: LifecycleRule) => ({
|
|
78
|
+
prefix: rule.prefix,
|
|
79
|
+
expiration: rule.expirationDays ? Duration.days(rule.expirationDays) : undefined,
|
|
80
|
+
transitions: rule.transitionToIaDays ? [{
|
|
81
|
+
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
|
|
82
|
+
transitionAfter: Duration.days(rule.transitionToIaDays),
|
|
83
|
+
}] : undefined,
|
|
84
|
+
})),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
this.bucket.grantReadWrite(this.handler);
|
|
88
|
+
}
|
|
89
|
+
}
|