@xcelsior/storage-api 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cdk.context.json +6 -0
- package/package.json +35 -0
- package/packages/functions/config.ts +35 -0
- package/packages/functions/sst-env.d.ts +1 -0
- package/packages/functions/storage/getPublicUploadUrl.ts +88 -0
- package/packages/functions/storage/getSecureUploadUrl.ts +110 -0
- package/sst.config.ts +15 -0
- package/stacks/StorageStack.ts +429 -0
- package/tsconfig.json +14 -0
package/cdk.context.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xcelsior/storage-api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"packages/**",
|
|
7
|
+
"stacks/**",
|
|
8
|
+
"*.json",
|
|
9
|
+
"sst.config.ts"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@aws-sdk/client-s3": "^3.888.0",
|
|
13
|
+
"@aws-sdk/s3-request-presigner": "^3.888.0",
|
|
14
|
+
"sst": "^2.40.3",
|
|
15
|
+
"zod": "^3.22.4",
|
|
16
|
+
"@types/aws-lambda": "^8.10.152",
|
|
17
|
+
"aws-cdk-lib": "2.201.0",
|
|
18
|
+
"constructs": "10.3.0",
|
|
19
|
+
"dotenv": "^17.2.1",
|
|
20
|
+
"typescript": "^5.3.3",
|
|
21
|
+
"@tsconfig/node18": "^18.2.2",
|
|
22
|
+
"@types/node": "^20.11.16",
|
|
23
|
+
"@xcelsior/aws": "1.0.6",
|
|
24
|
+
"@xcelsior/monitoring": "1.0.4",
|
|
25
|
+
"@xcelsior/lambda-http": "1.0.5"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "sst dev --stage dev --mode basic",
|
|
29
|
+
"build": "sst build",
|
|
30
|
+
"deploy": "sst deploy",
|
|
31
|
+
"remove": "sst remove",
|
|
32
|
+
"console": "sst console",
|
|
33
|
+
"typecheck": "tsc --noEmit"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createLogger, createTracer } from '@xcelsior/lambda-http';
|
|
2
|
+
|
|
3
|
+
// ─── Bucket names ─────────────────────────────────────────────────────────────
|
|
4
|
+
// Injected as CloudFormation-resolved env vars by StorageStack
|
|
5
|
+
export const PUBLIC_BUCKET_NAME = process.env.PUBLIC_BUCKET_NAME!;
|
|
6
|
+
export const SECURE_BUCKET_NAME = process.env.SECURE_BUCKET_NAME!;
|
|
7
|
+
|
|
8
|
+
// ─── CloudFront domains ───────────────────────────────────────────────────────
|
|
9
|
+
export const PUBLIC_CLOUDFRONT_DOMAIN = process.env.PUBLIC_CLOUDFRONT_DOMAIN!;
|
|
10
|
+
export const SECURE_CLOUDFRONT_DOMAIN = process.env.SECURE_CLOUDFRONT_DOMAIN!;
|
|
11
|
+
|
|
12
|
+
// ─── Upload config ────────────────────────────────────────────────────────────
|
|
13
|
+
/** Maximum file size the API will accept (MB). Validated in the request schema. */
|
|
14
|
+
export const MAX_FILE_SIZE_BYTES = Number(process.env.MAX_FILE_SIZE_MB || '100') * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** How long presigned upload URLs and HMAC-signed access URLs are valid (seconds). Default: 5 min. */
|
|
17
|
+
export const PRESIGNED_URL_EXPIRY_SECONDS = Number(
|
|
18
|
+
process.env.PRESIGNED_URL_EXPIRY_SECONDS || '300'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* HMAC-SHA256 signing key shared between this Lambda and the CloudFront Function.
|
|
23
|
+
* Used to produce per-file tokens — never placed directly in any URL.
|
|
24
|
+
*/
|
|
25
|
+
export const SECURE_STORAGE_ACCESS_SECRET = process.env.SECURE_STORAGE_ACCESS_SECRET!;
|
|
26
|
+
|
|
27
|
+
// ─── Observability ────────────────────────────────────────────────────────────
|
|
28
|
+
export const logger = createLogger('@xcelsior/storage');
|
|
29
|
+
const tracer = createTracer('@xcelsior/storage');
|
|
30
|
+
|
|
31
|
+
export const middlewareConfig = {
|
|
32
|
+
logger,
|
|
33
|
+
tracer,
|
|
34
|
+
cors: { origin: '*' },
|
|
35
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="sst/node/api" />
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { S3Service } from '@xcelsior/aws/src/s3';
|
|
5
|
+
import { middyfy, ValidationError } from '@xcelsior/lambda-http';
|
|
6
|
+
import {
|
|
7
|
+
PUBLIC_BUCKET_NAME,
|
|
8
|
+
PUBLIC_CLOUDFRONT_DOMAIN,
|
|
9
|
+
MAX_FILE_SIZE_BYTES,
|
|
10
|
+
PRESIGNED_URL_EXPIRY_SECONDS,
|
|
11
|
+
logger,
|
|
12
|
+
middlewareConfig,
|
|
13
|
+
} from '../config';
|
|
14
|
+
|
|
15
|
+
const schema = z.object({
|
|
16
|
+
/** Original file name — used to preserve the extension */
|
|
17
|
+
fileName: z.string().min(1).max(255),
|
|
18
|
+
/** MIME type (e.g. "image/png", "application/pdf") */
|
|
19
|
+
contentType: z.string().min(1),
|
|
20
|
+
/** File size in bytes — validated against MAX_FILE_SIZE_MB */
|
|
21
|
+
fileSize: z
|
|
22
|
+
.number()
|
|
23
|
+
.int()
|
|
24
|
+
.positive()
|
|
25
|
+
.max(MAX_FILE_SIZE_BYTES, {
|
|
26
|
+
message: `File size must not exceed ${MAX_FILE_SIZE_BYTES / 1024 / 1024} MB`,
|
|
27
|
+
}),
|
|
28
|
+
/**
|
|
29
|
+
* Optional sub-folder (e.g. "avatars", "documents").
|
|
30
|
+
* Sanitised to prevent path traversal.
|
|
31
|
+
*/
|
|
32
|
+
folder: z
|
|
33
|
+
.string()
|
|
34
|
+
.regex(
|
|
35
|
+
/^[a-zA-Z0-9_\-/]+$/,
|
|
36
|
+
'folder must contain only alphanumeric, dash, underscore or slash characters'
|
|
37
|
+
)
|
|
38
|
+
.optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const getPublicUploadUrlHandler = async (event: APIGatewayProxyEventV2) => {
|
|
42
|
+
let body: z.infer<typeof schema>;
|
|
43
|
+
try {
|
|
44
|
+
body = schema.parse(event.body);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof z.ZodError) {
|
|
47
|
+
throw new ValidationError('Invalid request body', error.errors);
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { fileName, contentType, fileSize, folder } = body;
|
|
53
|
+
|
|
54
|
+
// Build a collision-free object key
|
|
55
|
+
const ext =
|
|
56
|
+
fileName
|
|
57
|
+
.split('.')
|
|
58
|
+
.pop()
|
|
59
|
+
?.toLowerCase()
|
|
60
|
+
.replace(/[^a-z0-9]/g, '') || 'bin';
|
|
61
|
+
const prefix = folder ? `${folder}/` : 'uploads/';
|
|
62
|
+
const key = `${prefix}${randomUUID()}.${ext}`;
|
|
63
|
+
|
|
64
|
+
logger.info('Generating presigned public upload URL', { fileName, contentType, fileSize, key });
|
|
65
|
+
|
|
66
|
+
const uploadUrl = await S3Service.generatePresignedUploadUrl(
|
|
67
|
+
PUBLIC_BUCKET_NAME,
|
|
68
|
+
key,
|
|
69
|
+
contentType,
|
|
70
|
+
PRESIGNED_URL_EXPIRY_SECONDS
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Permanent CloudFront URL — no auth required, never expires
|
|
74
|
+
const fileUrl = `https://${PUBLIC_CLOUDFRONT_DOMAIN}/${key}`;
|
|
75
|
+
|
|
76
|
+
logger.info('Presigned public upload URL generated', { key, fileUrl });
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
/** PUT to this URL to upload the file. Expires in `expiresIn` seconds. */
|
|
80
|
+
uploadUrl,
|
|
81
|
+
/** Permanent CloudFront URL — use this to read/serve the file after upload. */
|
|
82
|
+
fileUrl,
|
|
83
|
+
key,
|
|
84
|
+
expiresIn: PRESIGNED_URL_EXPIRY_SECONDS,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const handler = middyfy(getPublicUploadUrlHandler, middlewareConfig);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { S3Service } from '@xcelsior/aws/src/s3';
|
|
6
|
+
import { middyfy, ValidationError } from '@xcelsior/lambda-http';
|
|
7
|
+
import {
|
|
8
|
+
SECURE_BUCKET_NAME,
|
|
9
|
+
SECURE_CLOUDFRONT_DOMAIN,
|
|
10
|
+
SECURE_STORAGE_ACCESS_SECRET,
|
|
11
|
+
MAX_FILE_SIZE_BYTES,
|
|
12
|
+
PRESIGNED_URL_EXPIRY_SECONDS,
|
|
13
|
+
logger,
|
|
14
|
+
middlewareConfig,
|
|
15
|
+
} from '../config';
|
|
16
|
+
|
|
17
|
+
const schema = z.object({
|
|
18
|
+
/** Original file name — used to preserve the extension */
|
|
19
|
+
fileName: z.string().min(1).max(255),
|
|
20
|
+
/** MIME type (e.g. "application/pdf", "image/jpeg") */
|
|
21
|
+
contentType: z.string().min(1),
|
|
22
|
+
/** File size in bytes — validated against MAX_FILE_SIZE_MB */
|
|
23
|
+
fileSize: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.positive()
|
|
27
|
+
.max(MAX_FILE_SIZE_BYTES, {
|
|
28
|
+
message: `File size must not exceed ${MAX_FILE_SIZE_BYTES / 1024 / 1024} MB`,
|
|
29
|
+
}),
|
|
30
|
+
/**
|
|
31
|
+
* Optional sub-folder (e.g. "contracts", "invoices").
|
|
32
|
+
* Sanitised to prevent path traversal.
|
|
33
|
+
*/
|
|
34
|
+
folder: z
|
|
35
|
+
.string()
|
|
36
|
+
.regex(
|
|
37
|
+
/^[a-zA-Z0-9_\-/]+$/,
|
|
38
|
+
'folder must contain only alphanumeric, dash, underscore or slash characters'
|
|
39
|
+
)
|
|
40
|
+
.optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates a permanent per-file HMAC-SHA256 token for CloudFront access.
|
|
45
|
+
*
|
|
46
|
+
* The signed message is the CloudFront request URI (e.g. `/secure-uploads/uuid.pdf`).
|
|
47
|
+
* The token is unique per file because the UUID key is part of the URI.
|
|
48
|
+
* A token for one file cannot be used to access a different file.
|
|
49
|
+
* The URL never expires — revocation requires rotating SECURE_STORAGE_ACCESS_SECRET.
|
|
50
|
+
*/
|
|
51
|
+
function generateFileToken(uri: string): string {
|
|
52
|
+
return createHmac('sha256', SECURE_STORAGE_ACCESS_SECRET).update(uri).digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const getSecureUploadUrlHandler = async (event: APIGatewayProxyEventV2) => {
|
|
56
|
+
let body: z.infer<typeof schema>;
|
|
57
|
+
try {
|
|
58
|
+
body = schema.parse(event.body);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof z.ZodError) {
|
|
61
|
+
throw new ValidationError('Invalid request body', error.errors);
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { fileName, contentType, fileSize, folder } = body;
|
|
67
|
+
|
|
68
|
+
// Build a collision-free object key
|
|
69
|
+
const ext =
|
|
70
|
+
fileName
|
|
71
|
+
.split('.')
|
|
72
|
+
.pop()
|
|
73
|
+
?.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9]/g, '') || 'bin';
|
|
75
|
+
const prefix = folder ? `${folder}/` : 'secure-uploads/';
|
|
76
|
+
const key = `${prefix}${randomUUID()}.${ext}`;
|
|
77
|
+
|
|
78
|
+
// CloudFront uses a leading-slash URI (e.g. /secure-uploads/uuid.pdf)
|
|
79
|
+
const uri = `/${key}`;
|
|
80
|
+
const token = generateFileToken(uri);
|
|
81
|
+
|
|
82
|
+
logger.info('Generating presigned secure upload URL', { fileName, contentType, fileSize, key });
|
|
83
|
+
|
|
84
|
+
const uploadUrl = await S3Service.generatePresignedUploadUrl(
|
|
85
|
+
SECURE_BUCKET_NAME,
|
|
86
|
+
key,
|
|
87
|
+
contentType,
|
|
88
|
+
PRESIGNED_URL_EXPIRY_SECONDS
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Permanent per-file CloudFront URL.
|
|
93
|
+
* ?token=<hex> — HMAC-SHA256(signingSecret, uri) — unique per file, never expires.
|
|
94
|
+
* The shared signing secret is never in this URL.
|
|
95
|
+
*/
|
|
96
|
+
const fileUrl = `https://${SECURE_CLOUDFRONT_DOMAIN}${uri}?token=${token}`;
|
|
97
|
+
|
|
98
|
+
logger.info('Presigned secure upload URL generated', { key });
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
/** PUT to this URL to upload the file. Expires in `uploadExpiresIn` seconds. */
|
|
102
|
+
uploadUrl,
|
|
103
|
+
/** Permanent HMAC-signed CloudFront URL — unique per file, never expires. */
|
|
104
|
+
fileUrl,
|
|
105
|
+
key,
|
|
106
|
+
uploadExpiresIn: PRESIGNED_URL_EXPIRY_SECONDS,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const handler = middyfy(getSecureUploadUrlHandler, middlewareConfig);
|
package/sst.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import type { SSTConfig } from 'sst';
|
|
3
|
+
import { StorageStack } from './stacks/StorageStack';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
config(_input) {
|
|
7
|
+
return {
|
|
8
|
+
name: 'storage',
|
|
9
|
+
region: 'ap-southeast-2',
|
|
10
|
+
};
|
|
11
|
+
},
|
|
12
|
+
stacks(app) {
|
|
13
|
+
app.stack(StorageStack);
|
|
14
|
+
},
|
|
15
|
+
} satisfies SSTConfig;
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { ApiGatewayV1Api, type StackContext } from 'sst/constructs';
|
|
3
|
+
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
4
|
+
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
|
|
5
|
+
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
|
|
6
|
+
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
|
|
7
|
+
import { CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager';
|
|
8
|
+
import * as route53 from 'aws-cdk-lib/aws-route53';
|
|
9
|
+
import { Duration, RemovalPolicy } from 'aws-cdk-lib';
|
|
10
|
+
|
|
11
|
+
// ─── Config helpers ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function requireEnv(name: string): string {
|
|
14
|
+
const value = process.env[name];
|
|
15
|
+
if (!value) throw new Error(`Environment variable "${name}" is required`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds a CloudFront Function (runtime 2.0, async) that validates a
|
|
21
|
+
* permanent per-file HMAC-SHA256 token delivered via `?token=<hex>`.
|
|
22
|
+
*
|
|
23
|
+
* How it works
|
|
24
|
+
* ─────────────
|
|
25
|
+
* 1. The Lambda generates a token: HMAC-SHA256(signingSecret, uri)
|
|
26
|
+
* 2. CloudFront Function re-computes the same HMAC and compares it with the
|
|
27
|
+
* token in the query string.
|
|
28
|
+
* 3. If the HMAC matches, the request is forwarded to S3 (token stripped so
|
|
29
|
+
* it never appears in S3 access logs).
|
|
30
|
+
*
|
|
31
|
+
* Security properties
|
|
32
|
+
* ────────────────────
|
|
33
|
+
* • The signing secret is never in any URL — only its HMAC derivative is.
|
|
34
|
+
* • Each file gets a different token because the UUID URI is the signed message.
|
|
35
|
+
* • A token for /a/b.pdf cannot be used for /a/c.pdf.
|
|
36
|
+
* • URLs never expire; rotate SECURE_STORAGE_ACCESS_SECRET to revoke all tokens.
|
|
37
|
+
*
|
|
38
|
+
* NOTE: The signing secret is embedded in the function source and is therefore
|
|
39
|
+
* visible in the CloudFront console. For higher-sensitivity workloads, migrate
|
|
40
|
+
* to CloudFront Functions runtime 2.0 + KeyValueStore.
|
|
41
|
+
*/
|
|
42
|
+
function buildSecureAccessFunctionCode(signingSecret: string): string {
|
|
43
|
+
const escaped = signingSecret.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
44
|
+
return `
|
|
45
|
+
function hexToBytes(hex) {
|
|
46
|
+
var len = hex.length / 2;
|
|
47
|
+
var out = new Uint8Array(len);
|
|
48
|
+
for (var i = 0; i < len; i++) {
|
|
49
|
+
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function deny(msg) {
|
|
55
|
+
return {
|
|
56
|
+
statusCode: 403,
|
|
57
|
+
statusDescription: 'Forbidden',
|
|
58
|
+
headers: { 'content-type': { value: 'application/json' } },
|
|
59
|
+
body: '{"error":"' + msg + '"}'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handler(event) {
|
|
64
|
+
var request = event.request;
|
|
65
|
+
var tokenP = request.querystring['token'];
|
|
66
|
+
|
|
67
|
+
if (!tokenP) {
|
|
68
|
+
return deny('Missing token.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
var key = await crypto.subtle.importKey(
|
|
73
|
+
'raw',
|
|
74
|
+
new TextEncoder().encode('${escaped}'),
|
|
75
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
76
|
+
false,
|
|
77
|
+
['verify']
|
|
78
|
+
);
|
|
79
|
+
var ok = await crypto.subtle.verify(
|
|
80
|
+
'HMAC',
|
|
81
|
+
key,
|
|
82
|
+
hexToBytes(tokenP.value),
|
|
83
|
+
new TextEncoder().encode(request.uri)
|
|
84
|
+
);
|
|
85
|
+
if (!ok) return deny('Invalid token.');
|
|
86
|
+
} catch (_) {
|
|
87
|
+
return deny('Token validation failed.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
delete request.querystring['token'];
|
|
91
|
+
return request;
|
|
92
|
+
}`.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Stack ───────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export function StorageStack({ stack }: StackContext) {
|
|
98
|
+
const domainName = process.env.API_DOMAIN_NAME || 'xcelsior.co';
|
|
99
|
+
const isProd = ['production', 'prod'].includes(stack.stage);
|
|
100
|
+
|
|
101
|
+
// ── Configurable feature flags ──────────────────────────────────────────
|
|
102
|
+
// Versioning is configured independently for each bucket
|
|
103
|
+
const enableVersioningPublic = process.env.ENABLE_VERSIONING_PUBLIC === 'true';
|
|
104
|
+
const enableVersioningSecure = process.env.ENABLE_VERSIONING_SECURE === 'true';
|
|
105
|
+
const maxFileSizeMb = Number(process.env.MAX_FILE_SIZE_MB || '100');
|
|
106
|
+
const presignedUrlExpiry = Number(process.env.PRESIGNED_URL_EXPIRY_SECONDS || '300');
|
|
107
|
+
|
|
108
|
+
// ── Secure-bucket access secret ─────────────────────────────────────────
|
|
109
|
+
const accessSecret = requireEnv('SECURE_STORAGE_ACCESS_SECRET');
|
|
110
|
+
|
|
111
|
+
// ── Optional custom-domain config ───────────────────────────────────────
|
|
112
|
+
// Certificates are created automatically via DNS validation — no ARN needed.
|
|
113
|
+
const publicStorageDomain = process.env.PUBLIC_STORAGE_DOMAIN || '';
|
|
114
|
+
const secureStorageDomain = process.env.SECURE_STORAGE_DOMAIN || '';
|
|
115
|
+
const hostedZoneId = process.env.HOSTED_ZONE_ID || '';
|
|
116
|
+
|
|
117
|
+
// ── API subdomain ────────────────────────────────────────────────────────
|
|
118
|
+
const apiSubdomain = isProd
|
|
119
|
+
? `storage.api.${domainName}`
|
|
120
|
+
: `${stack.stage}-storage.api.${domainName}`;
|
|
121
|
+
|
|
122
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
123
|
+
// 1. S3 Buckets
|
|
124
|
+
// Both buckets are fully private; CloudFront is the only public entry point.
|
|
125
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const sharedCorsRules: s3.CorsRule[] = [
|
|
128
|
+
{
|
|
129
|
+
allowedOrigins: ['*'],
|
|
130
|
+
allowedHeaders: ['*'],
|
|
131
|
+
allowedMethods: [
|
|
132
|
+
s3.HttpMethods.GET,
|
|
133
|
+
s3.HttpMethods.PUT,
|
|
134
|
+
s3.HttpMethods.POST,
|
|
135
|
+
s3.HttpMethods.HEAD,
|
|
136
|
+
],
|
|
137
|
+
maxAge: 86400, // 1 day
|
|
138
|
+
exposedHeaders: ['ETag'],
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
function lifecycleRules(versioned: boolean): s3.LifecycleRule[] {
|
|
143
|
+
return versioned
|
|
144
|
+
? [
|
|
145
|
+
{
|
|
146
|
+
enabled: true,
|
|
147
|
+
// Retain the 5 most recent non-current versions
|
|
148
|
+
noncurrentVersionsToRetain: 5,
|
|
149
|
+
// Hard-delete older non-current versions after 90 days
|
|
150
|
+
noncurrentVersionExpiration: Duration.days(90),
|
|
151
|
+
// Clean up stalled multipart uploads after 7 days
|
|
152
|
+
abortIncompleteMultipartUploadAfter: Duration.days(7),
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
: [
|
|
156
|
+
{
|
|
157
|
+
enabled: true,
|
|
158
|
+
abortIncompleteMultipartUploadAfter: Duration.days(7),
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Public bucket — files are served to anyone via CloudFront */
|
|
164
|
+
const publicBucket = new s3.Bucket(stack, 'StoragePublicBucket', {
|
|
165
|
+
versioned: enableVersioningPublic,
|
|
166
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
167
|
+
enforceSSL: true,
|
|
168
|
+
removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
|
|
169
|
+
...(isProd ? {} : { autoDeleteObjects: true }),
|
|
170
|
+
cors: sharedCorsRules,
|
|
171
|
+
lifecycleRules: lifecycleRules(enableVersioningPublic),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Secure bucket — files are private; access is via permanent HMAC-signed
|
|
176
|
+
* CloudFront URLs (?token=<hex>) issued by the upload Lambda. Each file
|
|
177
|
+
* receives a unique token; the signing secret is never placed in any URL.
|
|
178
|
+
*/
|
|
179
|
+
const secureBucket = new s3.Bucket(stack, 'StorageSecureBucket', {
|
|
180
|
+
versioned: enableVersioningSecure,
|
|
181
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
182
|
+
enforceSSL: true,
|
|
183
|
+
// Encrypt at rest with SSE-S3 (default since Jan 2023, explicit here for clarity)
|
|
184
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
185
|
+
removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
|
|
186
|
+
...(isProd ? {} : { autoDeleteObjects: true }),
|
|
187
|
+
cors: sharedCorsRules,
|
|
188
|
+
lifecycleRules: lifecycleRules(enableVersioningSecure),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
192
|
+
// 2. CloudFront Function — validates the access secret on the secure dist
|
|
193
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
const secureAccessFunction = new cloudfront.Function(stack, 'SecureAccessFunction', {
|
|
196
|
+
functionName: `${stack.stage}-storage-secure-access`,
|
|
197
|
+
// Runtime 2.0 is required for async handlers and the Web Crypto API (crypto.subtle)
|
|
198
|
+
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
199
|
+
code: cloudfront.FunctionCode.fromInline(buildSecureAccessFunctionCode(accessSecret)),
|
|
200
|
+
comment: `HMAC-SHA256 per-file token validation for secure storage [${stack.stage}]`,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
204
|
+
// 3. CloudFront Origins (OAC — recommended over legacy OAI)
|
|
205
|
+
// S3BucketOrigin.withOriginAccessControl() automatically:
|
|
206
|
+
// • Creates an S3 Origin Access Control
|
|
207
|
+
// • Adds a bucket-policy statement granting cloudfront.amazonaws.com
|
|
208
|
+
// s3:GetObject scoped to the specific distribution ARN
|
|
209
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const publicOrigin = origins.S3BucketOrigin.withOriginAccessControl(publicBucket);
|
|
212
|
+
const secureOrigin = origins.S3BucketOrigin.withOriginAccessControl(secureBucket);
|
|
213
|
+
|
|
214
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
215
|
+
// 4. CloudFront Distributions
|
|
216
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
219
|
+
// 4+5. CloudFront Distributions + optional custom domains
|
|
220
|
+
//
|
|
221
|
+
// When PUBLIC_STORAGE_DOMAIN / SECURE_STORAGE_DOMAIN are set (and
|
|
222
|
+
// HOSTED_ZONE_ID is provided), we automatically:
|
|
223
|
+
// a) Create an ACM certificate in us-east-1 via DNS validation
|
|
224
|
+
// (DnsValidatedCertificate deploys a custom-resource Lambda that
|
|
225
|
+
// creates & validates the cert in the correct region for CloudFront)
|
|
226
|
+
// b) Attach it to the distribution
|
|
227
|
+
// c) Create a Route53 alias A-record pointing to the distribution
|
|
228
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const hostedZone = route53.HostedZone.fromLookup(stack, 'hosted-zone', {
|
|
231
|
+
domainName: domainName,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
function buildDistributionDomain(
|
|
235
|
+
certId: string,
|
|
236
|
+
domain: string
|
|
237
|
+
): { domainNames: string[]; certificate: acm.ICertificate } | Record<string, never> {
|
|
238
|
+
if (!domain || !hostedZone) return {};
|
|
239
|
+
// DnsValidatedCertificate creates the cert in us-east-1 (required by CloudFront)
|
|
240
|
+
// and adds the DNS validation CNAME to the hosted zone automatically.
|
|
241
|
+
const certificate = new acm.DnsValidatedCertificate(stack, certId, {
|
|
242
|
+
domainName: domain,
|
|
243
|
+
hostedZone: hostedZone,
|
|
244
|
+
region: 'us-east-1',
|
|
245
|
+
});
|
|
246
|
+
return { domainNames: [domain], certificate: certificate };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Public distribution — standard CDN, response is cached and compressed */
|
|
250
|
+
const publicDistribution = new cloudfront.Distribution(stack, 'PublicStorageDistribution', {
|
|
251
|
+
comment: `Public storage CDN [${stack.stage}]`,
|
|
252
|
+
defaultBehavior: {
|
|
253
|
+
origin: publicOrigin,
|
|
254
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
255
|
+
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
|
256
|
+
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
|
257
|
+
cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
|
|
258
|
+
compress: true,
|
|
259
|
+
},
|
|
260
|
+
...buildDistributionDomain('PublicStorageCert', publicStorageDomain),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Secure distribution — caching disabled so every request passes through
|
|
265
|
+
* the CloudFront Function that validates the per-file HMAC token.
|
|
266
|
+
*/
|
|
267
|
+
const secureDistribution = new cloudfront.Distribution(stack, 'SecureStorageDistribution', {
|
|
268
|
+
comment: `Secure storage CDN [${stack.stage}]`,
|
|
269
|
+
defaultBehavior: {
|
|
270
|
+
origin: secureOrigin,
|
|
271
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
272
|
+
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
|
|
273
|
+
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
|
274
|
+
cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
|
|
275
|
+
compress: true,
|
|
276
|
+
functionAssociations: [
|
|
277
|
+
{
|
|
278
|
+
function: secureAccessFunction,
|
|
279
|
+
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
...buildDistributionDomain('SecureStorageCert', secureStorageDomain),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
287
|
+
// 6. Lambda API
|
|
288
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
const publicCdnDomain = publicStorageDomain || publicDistribution.distributionDomainName;
|
|
291
|
+
const secureCdnDomain = secureStorageDomain || secureDistribution.distributionDomainName;
|
|
292
|
+
|
|
293
|
+
const commonEnv: Record<string, string> = {
|
|
294
|
+
MONITORING_PROVIDER: process.env.MONITORING_PROVIDER ?? '',
|
|
295
|
+
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
|
296
|
+
ROLLBAR_ACCESS_TOKEN: process.env.ROLLBAR_ACCESS_TOKEN ?? '',
|
|
297
|
+
POWERTOOLS_SERVICE_NAME: '@xcelsior/storage',
|
|
298
|
+
POWERTOOLS_METRICS_NAMESPACE: 'ExcelsiorStorage',
|
|
299
|
+
POWERTOOLS_LOGGER_LOG_LEVEL: 'INFO',
|
|
300
|
+
POWERTOOLS_LOGGER_LOG_EVENT: 'true',
|
|
301
|
+
POWERTOOLS_DEV: stack.stage === 'dev' ? 'true' : '',
|
|
302
|
+
LOGTAIL_TOKEN: process.env.LOGTAIL_TOKEN ?? '',
|
|
303
|
+
LOGTAIL_HTTP_API_URL: process.env.LOGTAIL_HTTP_API_URL ?? '',
|
|
304
|
+
APP_ENV: stack.stage,
|
|
305
|
+
NODE_ENV: stack.stage === 'dev' ? 'development' : 'production',
|
|
306
|
+
AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
|
|
307
|
+
// Runtime config
|
|
308
|
+
MAX_FILE_SIZE_MB: String(maxFileSizeMb),
|
|
309
|
+
PRESIGNED_URL_EXPIRY_SECONDS: String(presignedUrlExpiry),
|
|
310
|
+
PUBLIC_VERSIONING_ENABLED: String(enableVersioningPublic),
|
|
311
|
+
SECURE_VERSIONING_ENABLED: String(enableVersioningSecure),
|
|
312
|
+
// HMAC signing key — used by the secure-upload Lambda to produce per-file tokens
|
|
313
|
+
// that the CloudFront Function validates. Never appears in any generated URL.
|
|
314
|
+
SECURE_STORAGE_ACCESS_SECRET: accessSecret,
|
|
315
|
+
// CDN domains (resolved by CloudFormation at deploy time)
|
|
316
|
+
PUBLIC_CLOUDFRONT_DOMAIN: publicCdnDomain,
|
|
317
|
+
SECURE_CLOUDFRONT_DOMAIN: secureCdnDomain,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const api = new ApiGatewayV1Api(stack, 'StorageApi', {
|
|
321
|
+
customDomain: {
|
|
322
|
+
domainName: apiSubdomain,
|
|
323
|
+
hostedZone: domainName,
|
|
324
|
+
},
|
|
325
|
+
defaults: {
|
|
326
|
+
function: {
|
|
327
|
+
layers: [
|
|
328
|
+
'arn:aws:lambda:ap-southeast-2:192933325589:layer:logtail-lambda-extension:1',
|
|
329
|
+
],
|
|
330
|
+
environment: commonEnv,
|
|
331
|
+
nodejs: { esbuild: { minify: true } },
|
|
332
|
+
runtime: 'nodejs22.x',
|
|
333
|
+
tracing: 'active',
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
cors: true,
|
|
337
|
+
routes: {
|
|
338
|
+
/**
|
|
339
|
+
* POST /api/storage/public/upload-url
|
|
340
|
+
* Returns a presigned S3 PutObject URL for the public bucket.
|
|
341
|
+
* The uploaded file will be publicly accessible via CloudFront.
|
|
342
|
+
*/
|
|
343
|
+
'POST /api/storage/public/upload-url': {
|
|
344
|
+
function: {
|
|
345
|
+
handler: 'packages/functions/storage/getPublicUploadUrl.handler',
|
|
346
|
+
functionName: `${stack.stage}-storage-get-public-upload-url-function`,
|
|
347
|
+
environment: {
|
|
348
|
+
// Resolved by CloudFormation; not available as SST binding
|
|
349
|
+
// because we use a native CDK bucket for full versioning control
|
|
350
|
+
PUBLIC_BUCKET_NAME: publicBucket.bucketName,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
cdk: { method: { apiKeyRequired: true } },
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* POST /api/storage/secure/upload-url
|
|
358
|
+
* Returns a presigned S3 PutObject URL (for uploading) and a CloudFront URL
|
|
359
|
+
* with a permanent per-file HMAC-SHA256 token (?token=…) for reading.
|
|
360
|
+
* Each file receives a unique, time-limited token — the shared signing secret
|
|
361
|
+
* is never present in any URL.
|
|
362
|
+
*/
|
|
363
|
+
'POST /api/storage/secure/upload-url': {
|
|
364
|
+
function: {
|
|
365
|
+
handler: 'packages/functions/storage/getSecureUploadUrl.handler',
|
|
366
|
+
functionName: `${stack.stage}-storage-get-secure-upload-url-function`,
|
|
367
|
+
environment: {
|
|
368
|
+
SECURE_BUCKET_NAME: secureBucket.bucketName,
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
cdk: { method: { apiKeyRequired: true } },
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const publicUploadFn = api.getFunction('POST /api/storage/public/upload-url');
|
|
377
|
+
const secureUploadFn = api.getFunction('POST /api/storage/secure/upload-url');
|
|
378
|
+
|
|
379
|
+
// Public upload: only needs s3:PutObject to sign the upload URL
|
|
380
|
+
if (publicUploadFn) publicBucket.grantPut(publicUploadFn);
|
|
381
|
+
|
|
382
|
+
// Secure upload: only needs s3:PutObject — the access URL is an HMAC-signed
|
|
383
|
+
// CloudFront URL, so no s3:GetObject is required on the Lambda side
|
|
384
|
+
if (secureUploadFn) secureBucket.grantPut(secureUploadFn);
|
|
385
|
+
|
|
386
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
387
|
+
// 7. API key + usage plan (all routes require the key)
|
|
388
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
const apiKey = api.cdk.restApi.addApiKey('StorageApiKey', {
|
|
391
|
+
apiKeyName: `storage-api-key-${stack.stage}`,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const usagePlan = api.cdk.restApi.addUsagePlan('StorageApiUsagePlan', {
|
|
395
|
+
name: `storage-usage-plan-${stack.stage}`,
|
|
396
|
+
apiStages: [
|
|
397
|
+
{
|
|
398
|
+
api: api.cdk.restApi,
|
|
399
|
+
stage: api.cdk.restApi.deploymentStage,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
usagePlan.addApiKey(apiKey);
|
|
405
|
+
|
|
406
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
407
|
+
// 8. Outputs
|
|
408
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
stack.addOutputs({
|
|
411
|
+
ApiEndpoint: api.url,
|
|
412
|
+
PublicBucketName: publicBucket.bucketName,
|
|
413
|
+
SecureBucketName: secureBucket.bucketName,
|
|
414
|
+
PublicCloudFrontDomain: publicDistribution.distributionDomainName,
|
|
415
|
+
SecureCloudFrontDomain: secureDistribution.distributionDomainName,
|
|
416
|
+
...(publicStorageDomain ? { PublicCustomDomain: `https://${publicStorageDomain}` } : {}),
|
|
417
|
+
...(secureStorageDomain ? { SecureCustomDomain: `https://${secureStorageDomain}` } : {}),
|
|
418
|
+
PublicVersioningEnabled: String(enableVersioningPublic),
|
|
419
|
+
SecureVersioningEnabled: String(enableVersioningSecure),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
api,
|
|
424
|
+
publicBucket,
|
|
425
|
+
secureBucket,
|
|
426
|
+
publicDistribution,
|
|
427
|
+
secureDistribution,
|
|
428
|
+
};
|
|
429
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@tsconfig/node18/tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "esnext",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"moduleResolution": "node",
|
|
7
|
+
"baseUrl": ".",
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"paths": {
|
|
10
|
+
"@xcelsior/storage-api/*": ["*"]
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"include": ["packages/**/*.ts", "stacks/**/*.ts", "sst.config.ts"]
|
|
14
|
+
}
|