@reventlessdev/reventless-aws 3.0.0-alpha.229 → 3.0.0-alpha.230
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/CHANGELOG.md +8 -0
- package/package.json +5 -5
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res +60 -141
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +52 -118
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res +137 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res.mjs +124 -0
- package/src/adapter/Runtime/HeartbeatEntryPoint.res +44 -0
- package/src/adapter/Runtime/HeartbeatEntryPoint.res.mjs +47 -0
- package/src/adapter/Runtime/PluginExtensionPointEntryPoint.res +234 -0
- package/src/adapter/Runtime/PluginExtensionPointEntryPoint.res.mjs +226 -0
- package/src/adapter/Upload/Upload_Presign_S3.res +65 -164
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +56 -119
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res +157 -0
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +120 -0
- package/src/plugin/runtime/PluginExtensionPointRuntime_Builder.res +1 -1
- package/src/plugin/runtime/PluginExtensionPointRuntime_Builder.res.mjs +1 -1
- package/src/plugin/runtime/PluginRuntime_Builder.res +1 -1
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +1 -1
- package/src/adapter/Runtime/HeartbeatEntryPoint.mjs +0 -38
- package/src/adapter/Runtime/PluginExtensionPointEntryPoint.mjs +0 -127
|
@@ -1,156 +1,25 @@
|
|
|
1
1
|
// Direct-to-S3 upload presign service behind a public Lambda Function URL.
|
|
2
2
|
//
|
|
3
|
-
// Deploy-time: `make` provisions a
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Deploy-time only: `make` provisions a compiled-EntryPoint Lambda (a plain
|
|
4
|
+
// `Lambda.Function` whose code archive re-exports `handler` from the compiled,
|
|
5
|
+
// type-checked runtime module `Upload_Presign_S3_Ops` and ships the shared ESM
|
|
6
|
+
// resolve-hook loader), an IAM execution role scoped to CloudWatch Logs +
|
|
7
|
+
// `s3:PutObject` on the target bucket, and a Function URL (no auth) so a browser
|
|
8
|
+
// can request a presigned PUT.
|
|
6
9
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Why an EntryPoint and not a Pulumi `CallbackFunction`: the handler needs the
|
|
11
|
+
// AWS SDK v3 S3 presigner. A serialized closure bakes the deploy machine's
|
|
12
|
+
// version-specific SDK internals into the archive but then resolves `@smithy/*`
|
|
13
|
+
// and `@aws-sdk/*` transitives from independently-versioned layer/runtime sources
|
|
14
|
+
// that disagree at cold start (an inlined `client-s3` against a newer
|
|
15
|
+
// `@smithy/smithy-client` base whose constructor no longer sets `middlewareStack`
|
|
16
|
+
// → `new S3Client()` throws). Shipping the compiled `_Ops` module with bare
|
|
17
|
+
// `@aws-sdk/*` imports, resolved through the resolve-hook (`@aws-sdk/*` from the
|
|
18
|
+
// runtime, `@smithy/*`/`@reventlessdev/*` from the layer), loads one internally
|
|
19
|
+
// consistent SDK instead. The runtime logic lives in [Upload_Presign_S3_Ops.res].
|
|
13
20
|
|
|
14
21
|
open PulumiAws
|
|
15
22
|
|
|
16
|
-
// ── AWS SDK v3 bindings ─────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
type s3Client
|
|
19
|
-
|
|
20
|
-
type putObjectInput = {
|
|
21
|
-
@as("Bucket") bucket: string,
|
|
22
|
-
@as("Key") key: string,
|
|
23
|
-
@as("ContentType") contentType?: string,
|
|
24
|
-
}
|
|
25
|
-
type putObjectCommand
|
|
26
|
-
|
|
27
|
-
@module("@aws-sdk/client-s3") @new external makeS3Client: unit => s3Client = "S3Client"
|
|
28
|
-
|
|
29
|
-
@module("@aws-sdk/client-s3") @new
|
|
30
|
-
external makePutObjectCommand: putObjectInput => putObjectCommand = "PutObjectCommand"
|
|
31
|
-
|
|
32
|
-
type presignOptions = {expiresIn: int}
|
|
33
|
-
|
|
34
|
-
@module("@aws-sdk/s3-request-presigner")
|
|
35
|
-
external getSignedUrl: (s3Client, putObjectCommand, presignOptions) => promise<string> =
|
|
36
|
-
"getSignedUrl"
|
|
37
|
-
|
|
38
|
-
// ── Function URL event / response shapes (payload format 2.0) ────────────────
|
|
39
|
-
|
|
40
|
-
type functionUrlEvent = {
|
|
41
|
-
body?: string,
|
|
42
|
-
headers?: dict<string>,
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
type response = {
|
|
46
|
-
statusCode: int,
|
|
47
|
-
headers?: dict<string>,
|
|
48
|
-
body: string,
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
let getEnv: string => option<string> = %raw(`
|
|
52
|
-
function(k) { var v = process.env[k]; return (v === undefined || v === null || v === "") ? undefined : v; }
|
|
53
|
-
`)
|
|
54
|
-
|
|
55
|
-
// Node 22 exposes globalThis.crypto.randomUUID; fall back to node:crypto.
|
|
56
|
-
let genUuid: unit => string = %raw(`
|
|
57
|
-
function() {
|
|
58
|
-
return (globalThis.crypto && globalThis.crypto.randomUUID)
|
|
59
|
-
? globalThis.crypto.randomUUID()
|
|
60
|
-
: require("crypto").randomUUID();
|
|
61
|
-
}
|
|
62
|
-
`)
|
|
63
|
-
|
|
64
|
-
// Decode a JWT payload's `sub` claim without verifying the signature (the auth
|
|
65
|
-
// layer verifies; here we only namespace the storage key).
|
|
66
|
-
let decodeJwtSub: string => option<string> = %raw(`
|
|
67
|
-
function(header) {
|
|
68
|
-
try {
|
|
69
|
-
var token = header.indexOf("Bearer ") === 0 ? header.slice(7) : header;
|
|
70
|
-
var parts = token.split(".");
|
|
71
|
-
if (parts.length < 2) return undefined;
|
|
72
|
-
var base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
73
|
-
var json = Buffer.from(base64, "base64").toString("utf8");
|
|
74
|
-
var claims = JSON.parse(json);
|
|
75
|
-
return (claims && claims.sub) ? String(claims.sub) : undefined;
|
|
76
|
-
} catch (_) { return undefined; }
|
|
77
|
-
}
|
|
78
|
-
`)
|
|
79
|
-
|
|
80
|
-
let corsHeaders = () =>
|
|
81
|
-
Dict.fromArray([
|
|
82
|
-
("content-type", "application/json"),
|
|
83
|
-
("access-control-allow-origin", "*"),
|
|
84
|
-
("access-control-allow-methods", "POST,OPTIONS"),
|
|
85
|
-
("access-control-allow-headers", "*"),
|
|
86
|
-
])
|
|
87
|
-
|
|
88
|
-
// Key prefix from the caller identity when a Bearer token is present.
|
|
89
|
-
let identityPrefix = (event: functionUrlEvent): string => {
|
|
90
|
-
let authHeader =
|
|
91
|
-
event.headers->Option.flatMap(h =>
|
|
92
|
-
h->Dict.get("authorization")->Option.orElse(h->Dict.get("Authorization"))
|
|
93
|
-
)
|
|
94
|
-
switch authHeader->Option.flatMap(decodeJwtSub) {
|
|
95
|
-
| Some(sub) => `${sub}/`
|
|
96
|
-
| None => ""
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ── Runtime handler ─────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
let handlePresign = async (event: functionUrlEvent, _context: Lambda.context): response => {
|
|
103
|
-
try {
|
|
104
|
-
let bucket = getEnv("UPLOAD_BUCKET")->Option.getOr("")
|
|
105
|
-
let parsed =
|
|
106
|
-
event.body
|
|
107
|
-
->Option.getOr("{}")
|
|
108
|
-
->JSON.parseOrThrow
|
|
109
|
-
->JSON.Decode.object
|
|
110
|
-
->Option.getOr(Dict.make())
|
|
111
|
-
let fileName =
|
|
112
|
-
parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
|
|
113
|
-
let contentType = parsed->Dict.get("contentType")->Option.flatMap(JSON.Decode.string)
|
|
114
|
-
|
|
115
|
-
// The object key doubles as the served path segment: it is rooted at the
|
|
116
|
-
// served prefix (`SERVED_PREFIX`, e.g. `uploads`) so the CloudFront
|
|
117
|
-
// `{prefix}/*` behavior fronts it. Callers PUT to this exact key.
|
|
118
|
-
let servedPrefix = getEnv("SERVED_PREFIX")->Option.getOr("uploads")
|
|
119
|
-
let key = `${servedPrefix}/${identityPrefix(event)}${genUuid()}/${fileName}`
|
|
120
|
-
let client = makeS3Client()
|
|
121
|
-
let command = makePutObjectCommand({bucket, key, contentType: ?contentType})
|
|
122
|
-
let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
|
|
123
|
-
|
|
124
|
-
// The stored ref is a same-origin relative URL `/{key}`: the served bucket is
|
|
125
|
-
// fronted read-only by the UI's own CloudFront distribution under
|
|
126
|
-
// `{prefix}/*`, so a command stores this directly-renderable value and an
|
|
127
|
-
// `image`-semantic field thumbnails it with no post-processing and no public
|
|
128
|
-
// bucket. See [docs/plans/done/ui-served-buckets.md].
|
|
129
|
-
let storageRef = `/${key}`
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
statusCode: 200,
|
|
133
|
-
headers: corsHeaders(),
|
|
134
|
-
body: Dict.fromArray([
|
|
135
|
-
("uploadUrl", JSON.Encode.string(uploadUrl)),
|
|
136
|
-
("storageRef", JSON.Encode.string(storageRef)),
|
|
137
|
-
])
|
|
138
|
-
->JSON.Encode.object
|
|
139
|
-
->JSON.stringify,
|
|
140
|
-
}
|
|
141
|
-
} catch {
|
|
142
|
-
| _ => {
|
|
143
|
-
statusCode: 400,
|
|
144
|
-
headers: corsHeaders(),
|
|
145
|
-
body: Dict.fromArray([("error", JSON.Encode.string("presign_failed"))])
|
|
146
|
-
->JSON.Encode.object
|
|
147
|
-
->JSON.stringify,
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── Deploy-time factory ─────────────────────────────────────────────────────
|
|
153
|
-
|
|
154
23
|
type serviceOutputs = {
|
|
155
24
|
url: Pulumi.Output.t<string>,
|
|
156
25
|
resources: array<Pulumi.Output.t<string>>,
|
|
@@ -181,7 +50,8 @@ let make = (
|
|
|
181
50
|
~opts?,
|
|
182
51
|
)
|
|
183
52
|
|
|
184
|
-
//
|
|
53
|
+
// CloudWatch Logs (so failures are observable) plus least-privilege
|
|
54
|
+
// `s3:PutObject` on any key under the target bucket.
|
|
185
55
|
let _policy =
|
|
186
56
|
bucketName
|
|
187
57
|
->Pulumi.Output.fromInput
|
|
@@ -193,6 +63,12 @@ let make = (
|
|
|
193
63
|
policy: PolicyDocument.make(
|
|
194
64
|
~id=`${serviceName}Policy`,
|
|
195
65
|
~statements=[
|
|
66
|
+
{
|
|
67
|
+
sid: "AllowLambdaLogging",
|
|
68
|
+
effect: Allow,
|
|
69
|
+
actions: Actions(["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]),
|
|
70
|
+
resources: Resource("arn:aws:logs:*:*:*"),
|
|
71
|
+
},
|
|
196
72
|
{
|
|
197
73
|
sid: "AllowUploadPut",
|
|
198
74
|
effect: Allow,
|
|
@@ -209,29 +85,54 @@ let make = (
|
|
|
209
85
|
)
|
|
210
86
|
})
|
|
211
87
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
("
|
|
218
|
-
|
|
219
|
-
|
|
88
|
+
// Bundle reventless-aws (the compiled `_Ops` handler lives inside it) and
|
|
89
|
+
// re-export its `handler`; buildCodeArchive also ships the ESM resolve-hook.
|
|
90
|
+
let packageDirs = Dict.fromArray([
|
|
91
|
+
(
|
|
92
|
+
"@reventlessdev/reventless-aws",
|
|
93
|
+
Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-aws"),
|
|
94
|
+
),
|
|
95
|
+
])
|
|
96
|
+
let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
|
|
97
|
+
~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs",
|
|
98
|
+
~packageDirs,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
let layers =
|
|
102
|
+
Lambda.reventlessLayerArn
|
|
103
|
+
->Option.map(arn => [arn->Pulumi.Input.make])
|
|
104
|
+
->Option.getOr([])
|
|
105
|
+
->Pulumi.Input.make
|
|
220
106
|
|
|
221
|
-
let lambda = Lambda.
|
|
107
|
+
let lambda = Lambda.Function.make(
|
|
222
108
|
~name=serviceName,
|
|
223
|
-
~args=
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
109
|
+
~args={
|
|
110
|
+
handler: "index.handler"->Pulumi.Input.make,
|
|
111
|
+
runtime: "nodejs22.x"->Pulumi.Input.make,
|
|
112
|
+
code: code->Pulumi.Input.make,
|
|
113
|
+
sourceCodeHash: sourceCodeHash->Pulumi.Input.make,
|
|
114
|
+
role: lambdaRole.arn->Pulumi.Output.asInput,
|
|
115
|
+
memorySize: 256->Pulumi.Input.make,
|
|
116
|
+
timeout: 30->Pulumi.Input.make,
|
|
117
|
+
layers,
|
|
118
|
+
tags: AWS.Tags.make(
|
|
229
119
|
~name=serviceName,
|
|
230
120
|
~kind=ReventlessCore.ComponentType.Platform,
|
|
231
121
|
~role=Runtime,
|
|
232
122
|
~scope=Platform,
|
|
233
123
|
),
|
|
234
|
-
|
|
124
|
+
environment: (
|
|
125
|
+
{
|
|
126
|
+
Lambda.Function.variables: Dict.fromArray([
|
|
127
|
+
("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
|
|
128
|
+
("UPLOAD_BUCKET", bucketName),
|
|
129
|
+
("SERVED_PREFIX", Pulumi.Input.make(servedPrefix)),
|
|
130
|
+
("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
|
|
131
|
+
("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
|
|
132
|
+
]),
|
|
133
|
+
}: Lambda.Function.functionEnvironment
|
|
134
|
+
)->Pulumi.Input.make,
|
|
135
|
+
},
|
|
235
136
|
~opts?,
|
|
236
137
|
)
|
|
237
138
|
|
|
@@ -1,113 +1,16 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Aws from "@pulumi/aws";
|
|
4
|
-
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
4
|
import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
|
|
6
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
6
|
import * as Pulumi from "@pulumi/pulumi";
|
|
8
7
|
import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
|
|
9
8
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
10
9
|
import * as AWS$ReventlessAws from "../AWS.res.mjs";
|
|
11
|
-
import * as ClientS3 from "@aws-sdk/client-s3";
|
|
12
10
|
import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
|
|
13
11
|
import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
|
|
12
|
+
import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
|
|
14
13
|
import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
|
|
15
|
-
import * as S3RequestPresigner from "@aws-sdk/s3-request-presigner";
|
|
16
|
-
|
|
17
|
-
let getEnv = (function(k) { var v = process.env[k]; return (v === undefined || v === null || v === "") ? undefined : v; });
|
|
18
|
-
|
|
19
|
-
let genUuid = (function() {
|
|
20
|
-
return (globalThis.crypto && globalThis.crypto.randomUUID)
|
|
21
|
-
? globalThis.crypto.randomUUID()
|
|
22
|
-
: require("crypto").randomUUID();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
let decodeJwtSub = (function(header) {
|
|
26
|
-
try {
|
|
27
|
-
var token = header.indexOf("Bearer ") === 0 ? header.slice(7) : header;
|
|
28
|
-
var parts = token.split(".");
|
|
29
|
-
if (parts.length < 2) return undefined;
|
|
30
|
-
var base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
31
|
-
var json = Buffer.from(base64, "base64").toString("utf8");
|
|
32
|
-
var claims = JSON.parse(json);
|
|
33
|
-
return (claims && claims.sub) ? String(claims.sub) : undefined;
|
|
34
|
-
} catch (_) { return undefined; }
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
function corsHeaders() {
|
|
38
|
-
return Object.fromEntries([
|
|
39
|
-
[
|
|
40
|
-
"content-type",
|
|
41
|
-
"application/json"
|
|
42
|
-
],
|
|
43
|
-
[
|
|
44
|
-
"access-control-allow-origin",
|
|
45
|
-
"*"
|
|
46
|
-
],
|
|
47
|
-
[
|
|
48
|
-
"access-control-allow-methods",
|
|
49
|
-
"POST,OPTIONS"
|
|
50
|
-
],
|
|
51
|
-
[
|
|
52
|
-
"access-control-allow-headers",
|
|
53
|
-
"*"
|
|
54
|
-
]
|
|
55
|
-
]);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function identityPrefix(event) {
|
|
59
|
-
let authHeader = Stdlib_Option.flatMap(event.headers, h => Stdlib_Option.orElse(h["authorization"], h["Authorization"]));
|
|
60
|
-
let sub = Stdlib_Option.flatMap(authHeader, decodeJwtSub);
|
|
61
|
-
if (sub !== undefined) {
|
|
62
|
-
return sub + `/`;
|
|
63
|
-
} else {
|
|
64
|
-
return "";
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function handlePresign(event, _context) {
|
|
69
|
-
try {
|
|
70
|
-
let bucket = Stdlib_Option.getOr(getEnv("UPLOAD_BUCKET"), "");
|
|
71
|
-
let parsed = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(Stdlib_Option.getOr(event.body, "{}"))), {});
|
|
72
|
-
let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(parsed["fileName"], Stdlib_JSON.Decode.string), "upload");
|
|
73
|
-
let contentType = Stdlib_Option.flatMap(parsed["contentType"], Stdlib_JSON.Decode.string);
|
|
74
|
-
let servedPrefix = Stdlib_Option.getOr(getEnv("SERVED_PREFIX"), "uploads");
|
|
75
|
-
let key = servedPrefix + `/` + identityPrefix(event) + genUuid() + `/` + fileName;
|
|
76
|
-
let client = new ClientS3.S3Client();
|
|
77
|
-
let command = new ClientS3.PutObjectCommand({
|
|
78
|
-
Bucket: bucket,
|
|
79
|
-
Key: key,
|
|
80
|
-
ContentType: contentType
|
|
81
|
-
});
|
|
82
|
-
let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
|
|
83
|
-
expiresIn: 300
|
|
84
|
-
});
|
|
85
|
-
let storageRef = `/` + key;
|
|
86
|
-
return {
|
|
87
|
-
statusCode: 200,
|
|
88
|
-
headers: corsHeaders(),
|
|
89
|
-
body: JSON.stringify(Object.fromEntries([
|
|
90
|
-
[
|
|
91
|
-
"uploadUrl",
|
|
92
|
-
uploadUrl
|
|
93
|
-
],
|
|
94
|
-
[
|
|
95
|
-
"storageRef",
|
|
96
|
-
storageRef
|
|
97
|
-
]
|
|
98
|
-
]))
|
|
99
|
-
};
|
|
100
|
-
} catch (exn) {
|
|
101
|
-
return {
|
|
102
|
-
statusCode: 400,
|
|
103
|
-
headers: corsHeaders(),
|
|
104
|
-
body: JSON.stringify(Object.fromEntries([[
|
|
105
|
-
"error",
|
|
106
|
-
"presign_failed"
|
|
107
|
-
]]))
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
14
|
|
|
112
15
|
function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
|
|
113
16
|
let corsOrigins = corsOriginsOpt !== undefined ? corsOriginsOpt : ["*"];
|
|
@@ -118,28 +21,68 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
|
|
|
118
21
|
bucketName.apply(b => {
|
|
119
22
|
let arn = `arn:aws:s3:::` + b + `/*`;
|
|
120
23
|
new (Aws.iam.RolePolicy)(serviceName + `Policy`, {
|
|
121
|
-
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, serviceName + `Policy`, [
|
|
24
|
+
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, serviceName + `Policy`, [
|
|
25
|
+
{
|
|
26
|
+
Sid: "AllowLambdaLogging",
|
|
27
|
+
Effect: "Allow",
|
|
28
|
+
Action: [
|
|
29
|
+
"logs:CreateLogGroup",
|
|
30
|
+
"logs:CreateLogStream",
|
|
31
|
+
"logs:PutLogEvents"
|
|
32
|
+
],
|
|
33
|
+
Resource: "arn:aws:logs:*:*:*"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
122
36
|
Sid: "AllowUploadPut",
|
|
123
37
|
Effect: "Allow",
|
|
124
38
|
Action: "s3:PutObject",
|
|
125
39
|
Resource: arn
|
|
126
|
-
}
|
|
40
|
+
}
|
|
41
|
+
])),
|
|
127
42
|
role: lambdaRole.id
|
|
128
43
|
}, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
|
|
129
44
|
});
|
|
130
|
-
let
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
45
|
+
let packageDirs = Object.fromEntries([[
|
|
46
|
+
"@reventlessdev/reventless-aws",
|
|
47
|
+
Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-aws")
|
|
48
|
+
]]);
|
|
49
|
+
let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs", packageDirs, undefined);
|
|
50
|
+
let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
|
|
51
|
+
let lambda = new (Aws.lambda.Function)(serviceName, {
|
|
52
|
+
handler: "index.handler",
|
|
53
|
+
runtime: "nodejs22.x",
|
|
54
|
+
code: match.code,
|
|
55
|
+
role: lambdaRole.arn,
|
|
56
|
+
memorySize: 256,
|
|
57
|
+
timeout: 30,
|
|
58
|
+
layers: layers,
|
|
59
|
+
tags: AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined),
|
|
60
|
+
environment: {
|
|
61
|
+
variables: Object.fromEntries([
|
|
62
|
+
[
|
|
63
|
+
"Environment",
|
|
64
|
+
Pulumi.getStack()
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
"UPLOAD_BUCKET",
|
|
68
|
+
bucketName
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
"SERVED_PREFIX",
|
|
72
|
+
servedPrefix
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
"NODE_OPTIONS",
|
|
76
|
+
Util_Bundle$ReventlessAws.esmLoaderNodeOptions
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
"ESM_FALLBACK_DIRS",
|
|
80
|
+
Util_Bundle$ReventlessAws.esmFallbackDirs
|
|
81
|
+
]
|
|
82
|
+
])
|
|
83
|
+
},
|
|
84
|
+
sourceCodeHash: match.sourceCodeHash
|
|
85
|
+
}, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
|
|
143
86
|
let functionUrl = new (Aws.lambda.FunctionUrl)(serviceName + `Url`, {
|
|
144
87
|
authorizationType: "NONE",
|
|
145
88
|
functionName: lambda.name,
|
|
@@ -159,12 +102,6 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
|
|
|
159
102
|
}
|
|
160
103
|
|
|
161
104
|
export {
|
|
162
|
-
getEnv,
|
|
163
|
-
genUuid,
|
|
164
|
-
decodeJwtSub,
|
|
165
|
-
corsHeaders,
|
|
166
|
-
identityPrefix,
|
|
167
|
-
handlePresign,
|
|
168
105
|
make,
|
|
169
106
|
}
|
|
170
107
|
/* @pulumi/aws Not a pure module */
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Runtime handler for the upload presign service — compiled, type-checked, and
|
|
2
|
+
// Pulumi-free so it can be shipped as an EntryPoint module (`Upload_Presign_S3`
|
|
3
|
+
// bundles it and re-exports `handler` from the code archive). Keeping it out of
|
|
4
|
+
// the deploy-time module is what avoids both the serialized-closure SDK skew and
|
|
5
|
+
// the deploy-time Pulumi import leaking into the Lambda's cold-start graph.
|
|
6
|
+
//
|
|
7
|
+
// Reads a JSON body `{fileName, contentType}`, derives a target key
|
|
8
|
+
// `uploads/<identity?>/<uuid>/<fileName>`, presigns a PUT to `UPLOAD_BUCKET`
|
|
9
|
+
// (expires in 300s), and returns `{uploadUrl, storageRef}`. A Bearer token, when
|
|
10
|
+
// present, is decoded (no signature verification here — the auth layer verifies;
|
|
11
|
+
// here we only namespace the storage key) and its `sub` namespaces the key.
|
|
12
|
+
|
|
13
|
+
// ── AWS SDK v3 bindings ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
type s3Client
|
|
16
|
+
|
|
17
|
+
type putObjectInput = {
|
|
18
|
+
@as("Bucket") bucket: string,
|
|
19
|
+
@as("Key") key: string,
|
|
20
|
+
@as("ContentType") contentType?: string,
|
|
21
|
+
}
|
|
22
|
+
type putObjectCommand
|
|
23
|
+
|
|
24
|
+
@module("@aws-sdk/client-s3") @new external makeS3Client: unit => s3Client = "S3Client"
|
|
25
|
+
|
|
26
|
+
@module("@aws-sdk/client-s3") @new
|
|
27
|
+
external makePutObjectCommand: putObjectInput => putObjectCommand = "PutObjectCommand"
|
|
28
|
+
|
|
29
|
+
type presignOptions = {expiresIn: int}
|
|
30
|
+
|
|
31
|
+
@module("@aws-sdk/s3-request-presigner")
|
|
32
|
+
external getSignedUrl: (s3Client, putObjectCommand, presignOptions) => promise<string> =
|
|
33
|
+
"getSignedUrl"
|
|
34
|
+
|
|
35
|
+
// ── Node bindings (replacing the former `%raw` helpers with typed externals) ──
|
|
36
|
+
|
|
37
|
+
@val @scope("process") external processEnv: dict<string> = "env"
|
|
38
|
+
|
|
39
|
+
@module("node:crypto") external randomUUID: unit => string = "randomUUID"
|
|
40
|
+
|
|
41
|
+
type buffer
|
|
42
|
+
@val @scope("Buffer") external bufferFromBase64: (string, string) => buffer = "from"
|
|
43
|
+
@send external bufferToString: (buffer, string) => string = "toString"
|
|
44
|
+
|
|
45
|
+
// Read an env var, mapping "" / unset to None.
|
|
46
|
+
let getEnv = (k: string): option<string> =>
|
|
47
|
+
switch processEnv->Dict.get(k) {
|
|
48
|
+
| Some("") | None => None
|
|
49
|
+
| Some(v) => Some(v)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Function URL event / response shapes (payload format 2.0) ────────────────
|
|
53
|
+
|
|
54
|
+
type functionUrlEvent = {
|
|
55
|
+
body?: string,
|
|
56
|
+
headers?: dict<string>,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type response = {
|
|
60
|
+
statusCode: int,
|
|
61
|
+
headers?: dict<string>,
|
|
62
|
+
body: string,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let corsHeaders = () =>
|
|
66
|
+
Dict.fromArray([
|
|
67
|
+
("content-type", "application/json"),
|
|
68
|
+
("access-control-allow-origin", "*"),
|
|
69
|
+
("access-control-allow-methods", "POST,OPTIONS"),
|
|
70
|
+
("access-control-allow-headers", "*"),
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
// Decode a JWT payload's `sub` claim without verifying the signature.
|
|
74
|
+
let decodeJwtSub = (header: string): option<string> =>
|
|
75
|
+
try {
|
|
76
|
+
let token =
|
|
77
|
+
header->String.startsWith("Bearer ")
|
|
78
|
+
? header->String.slice(~start=7, ~end=header->String.length)
|
|
79
|
+
: header
|
|
80
|
+
switch token->String.split(".")->Array.get(1) {
|
|
81
|
+
| Some(payload) =>
|
|
82
|
+
let base64 = payload->String.replaceAll("-", "+")->String.replaceAll("_", "/")
|
|
83
|
+
switch JSON.parseOrThrow(bufferFromBase64(base64, "base64")->bufferToString("utf8")) {
|
|
84
|
+
| Object(obj) => obj->Dict.get("sub")->Option.flatMap(JSON.Decode.string)
|
|
85
|
+
| _ => None
|
|
86
|
+
}
|
|
87
|
+
| None => None
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
| _ => None
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Key prefix from the caller identity when a Bearer token is present.
|
|
94
|
+
let identityPrefix = (event: functionUrlEvent): string => {
|
|
95
|
+
let authHeader =
|
|
96
|
+
event.headers->Option.flatMap(h =>
|
|
97
|
+
h->Dict.get("authorization")->Option.orElse(h->Dict.get("Authorization"))
|
|
98
|
+
)
|
|
99
|
+
switch authHeader->Option.flatMap(decodeJwtSub) {
|
|
100
|
+
| Some(sub) => `${sub}/`
|
|
101
|
+
| None => ""
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Runtime handler ─────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
let handler = async (event: functionUrlEvent): response => {
|
|
108
|
+
try {
|
|
109
|
+
let bucket = getEnv("UPLOAD_BUCKET")->Option.getOr("")
|
|
110
|
+
let parsed =
|
|
111
|
+
event.body
|
|
112
|
+
->Option.getOr("{}")
|
|
113
|
+
->JSON.parseOrThrow
|
|
114
|
+
->JSON.Decode.object
|
|
115
|
+
->Option.getOr(Dict.make())
|
|
116
|
+
let fileName =
|
|
117
|
+
parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
|
|
118
|
+
let contentType = parsed->Dict.get("contentType")->Option.flatMap(JSON.Decode.string)
|
|
119
|
+
|
|
120
|
+
// The object key doubles as the served path segment: it is rooted at the
|
|
121
|
+
// served prefix (`SERVED_PREFIX`, e.g. `uploads`) so the CloudFront
|
|
122
|
+
// `{prefix}/*` behavior fronts it. Callers PUT to this exact key.
|
|
123
|
+
let servedPrefix = getEnv("SERVED_PREFIX")->Option.getOr("uploads")
|
|
124
|
+
let key = `${servedPrefix}/${identityPrefix(event)}${randomUUID()}/${fileName}`
|
|
125
|
+
let client = makeS3Client()
|
|
126
|
+
let command = makePutObjectCommand({bucket, key, contentType: ?contentType})
|
|
127
|
+
let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
|
|
128
|
+
|
|
129
|
+
// The stored ref is a same-origin relative URL `/{key}`: the served bucket is
|
|
130
|
+
// fronted read-only by the UI's own CloudFront distribution under
|
|
131
|
+
// `{prefix}/*`, so a command stores this directly-renderable value and an
|
|
132
|
+
// `image`-semantic field thumbnails it with no post-processing and no public
|
|
133
|
+
// bucket. See [docs/plans/done/ui-served-buckets.md].
|
|
134
|
+
let storageRef = `/${key}`
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
statusCode: 200,
|
|
138
|
+
headers: corsHeaders(),
|
|
139
|
+
body: Dict.fromArray([
|
|
140
|
+
("uploadUrl", JSON.Encode.string(uploadUrl)),
|
|
141
|
+
("storageRef", JSON.Encode.string(storageRef)),
|
|
142
|
+
])
|
|
143
|
+
->JSON.Encode.object
|
|
144
|
+
->JSON.stringify,
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
| exn =>
|
|
148
|
+
Console.error2("UploadPresign: presign failed", exn)
|
|
149
|
+
{
|
|
150
|
+
statusCode: 400,
|
|
151
|
+
headers: corsHeaders(),
|
|
152
|
+
body: Dict.fromArray([("error", JSON.Encode.string("presign_failed"))])
|
|
153
|
+
->JSON.Encode.object
|
|
154
|
+
->JSON.stringify,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|