| 1 | import * as SigV4 from "@distilled.cloud/aws/SigV4"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import { Worker, WorkerEnvironment } from "../Workers/Worker.ts"; |
| 5 | import type { Bucket } from "./Bucket.ts"; |
| 6 | import type { PresignError } from "./BucketTypes.ts"; |
| 7 | import { |
| 8 | makeS3Credentials, |
| 9 | type S3CredentialsAccess, |
| 10 | type S3CredentialsValue, |
| 11 | } from "./S3Credentials.ts"; |
| 12 | |
| 13 | /** |
| 14 | * Shared scaffolding for the R2 presign bindings (`PresignGetObjectToken`, |
| 15 | * `PresignPutObjectToken`): resolve the bucket's `S3Credentials` and |
| 16 | * sign each request locally with SigV4. `makeS3Credentials` handles the |
| 17 | * per-mode wiring (a scoped API token when deployed, the Worker's local S3 |
| 18 | * endpoint under `alchemy dev`). |
| 19 | * |
| 20 | * NOT exported from `index.ts`. |
| 21 | */ |
| 22 | export const makePresignBinding = <Request>(options: { |
| 23 | name: string; |
| 24 | access: S3CredentialsAccess; |
| 25 | presign: ( |
| 26 | credentials: S3CredentialsValue, |
| 27 | request: Request, |
| 28 | ) => Effect.Effect<string, PresignError>; |
| 29 | }) => |
| 30 | Effect.gen(function* () { |
| 31 | // Resolved once at layer construction and provided to each bind, so the |
| 32 | // binding callable carries no Worker requirements. |
| 33 | const host = yield* Worker; |
| 34 | const env = yield* WorkerEnvironment; |
| 35 | |
| 36 | return Effect.fn(function* (bucket: Bucket) { |
| 37 | const credentials = yield* makeS3Credentials( |
| 38 | host, |
| 39 | env, |
| 40 | bucket, |
| 41 | options.access, |
| 42 | ); |
| 43 | return Effect.fn(`${options.name}(${bucket.LogicalId})`)(function* ( |
| 44 | request: Request, |
| 45 | ) { |
| 46 | return yield* options.presign(yield* credentials, request); |
| 47 | }); |
| 48 | }); |
| 49 | }); |
| 50 | |
| 51 | export interface PresignR2UrlOptions { |
| 52 | method: "GET" | "PUT"; |
| 53 | key: string; |
| 54 | /** @default 900 */ |
| 55 | expiresIn?: number; |
| 56 | /** Headers the consumer must send verbatim (signed into the URL). */ |
| 57 | headers?: Record<string, string>; |
| 58 | /** Extra query parameters signed into the URL (e.g. `response-content-type`). */ |
| 59 | query?: Record<string, string>; |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Presign a path-style R2 object URL (`{endpoint}/{bucket}/{key}`) with the |
| 64 | * given {@link S3CredentialsValue}. Pure SigV4 — no request is made. |
| 65 | */ |
| 66 | export const presignR2Url = ( |
| 67 | credentials: S3CredentialsValue, |
| 68 | options: PresignR2UrlOptions, |
| 69 | ): Effect.Effect<string, PresignError> => |
| 70 | Effect.gen(function* () { |
| 71 | const key = options.key.split("/").map(encodeURIComponent).join("/"); |
| 72 | const url = new URL( |
| 73 | `${credentials.endpoint.replace(/\/+$/, "")}/${encodeURIComponent(credentials.bucketName)}/${key}`, |
| 74 | ); |
| 75 | for (const [name, value] of Object.entries(options.query ?? {})) { |
| 76 | url.searchParams.set(name, value); |
| 77 | } |
| 78 | url.searchParams.set("X-Amz-Expires", String(options.expiresIn ?? 900)); |
| 79 | const signed = yield* SigV4.sign({ |
| 80 | method: options.method, |
| 81 | url: url.toString(), |
| 82 | headers: options.headers, |
| 83 | accessKeyId: credentials.accessKeyId, |
| 84 | secretAccessKey: Redacted.make(credentials.secretAccessKey), |
| 85 | service: "s3", |
| 86 | region: credentials.region, |
| 87 | signQuery: true, |
| 88 | // Pin explicitly passed headers (e.g. content-type) into the signature |
| 89 | allHeaders: options.headers !== undefined, |
| 90 | }); |
| 91 | return signed.url; |
| 92 | }); |