219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
import { AwsClient } from "aws4fetch";
|
|
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
|
|
/**
|
|
* Normalize a logical object-store key the same way Aws4FetchClient assigns URL pathnames.
|
|
* Decodes percent-escapes and resolves `.` / `..` segments before the request is signed.
|
|
*/
|
|
export function normalizeObjectStoreLogicalKeyPathname(logicalKey: string): string {
|
|
const url = new URL("https://trigger.invalid");
|
|
url.pathname = `/${logicalKey.replace(/^\/+/, "")}`;
|
|
return url.pathname;
|
|
}
|
|
|
|
interface IObjectStoreClient {
|
|
putObject(key: string, body: ReadableStream | string, contentType: string): Promise<string>;
|
|
getObject(key: string): Promise<string>;
|
|
presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string>;
|
|
}
|
|
|
|
type Aws4FetchConfig = {
|
|
baseUrl: string;
|
|
accessKeyId: string;
|
|
secretAccessKey: string;
|
|
region?: string;
|
|
service?: string;
|
|
};
|
|
|
|
class Aws4FetchClient implements IObjectStoreClient {
|
|
private readonly awsClient: AwsClient;
|
|
|
|
constructor(private readonly config: Aws4FetchConfig) {
|
|
this.awsClient = new AwsClient({
|
|
accessKeyId: config.accessKeyId,
|
|
secretAccessKey: config.secretAccessKey,
|
|
region: config.region,
|
|
// We set the default value to "s3" in the schema to enhance interoperability with various
|
|
// S3-compatible services. Setting this env var to an empty string restores the old behaviour.
|
|
service: config.service || undefined,
|
|
});
|
|
}
|
|
|
|
private buildUrl(key: string): string {
|
|
const url = new URL(this.config.baseUrl);
|
|
url.pathname = normalizeObjectStoreLogicalKeyPathname(key);
|
|
return url.toString();
|
|
}
|
|
|
|
async putObject(
|
|
key: string,
|
|
body: ReadableStream | string,
|
|
contentType: string
|
|
): Promise<string> {
|
|
const objectUrl = this.buildUrl(key);
|
|
const response = await this.awsClient.fetch(objectUrl, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": contentType },
|
|
body,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to upload to object store: ${response.statusText}`);
|
|
}
|
|
return objectUrl;
|
|
}
|
|
|
|
async getObject(key: string): Promise<string> {
|
|
const response = await this.awsClient.fetch(this.buildUrl(key));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download from object store: ${response.statusText}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
|
|
const url = new URL(this.config.baseUrl);
|
|
url.pathname = normalizeObjectStoreLogicalKeyPathname(key);
|
|
url.searchParams.set("X-Amz-Expires", String(expiresIn));
|
|
|
|
const signed = await this.awsClient.sign(new Request(url, { method }), {
|
|
aws: { signQuery: true },
|
|
});
|
|
return signed.url;
|
|
}
|
|
}
|
|
|
|
type AwsSdkConfig = {
|
|
bucket: string;
|
|
baseUrl: string;
|
|
region?: string;
|
|
};
|
|
|
|
class AwsSdkClient implements IObjectStoreClient {
|
|
private readonly s3Client: S3Client;
|
|
|
|
constructor(private readonly config: AwsSdkConfig) {
|
|
this.s3Client = new S3Client({
|
|
endpoint: config.baseUrl,
|
|
forcePathStyle: true,
|
|
...(config.region ? { region: config.region } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Callers use a single logical key (same as aws4fetch path: `bucket/object/...`).
|
|
* S3 APIs take Bucket + Key where Key must not repeat the bucket name.
|
|
*/
|
|
private toS3ObjectKey(logicalKey: string): string {
|
|
const prefix = `${this.config.bucket}/`;
|
|
if (logicalKey.startsWith(prefix)) {
|
|
return logicalKey.slice(prefix.length);
|
|
}
|
|
return logicalKey;
|
|
}
|
|
|
|
private logicalObjectUrl(logicalKey: string): string {
|
|
const url = new URL(this.config.baseUrl);
|
|
url.pathname = normalizeObjectStoreLogicalKeyPathname(logicalKey);
|
|
return url.href;
|
|
}
|
|
|
|
async putObject(
|
|
key: string,
|
|
body: ReadableStream | string,
|
|
contentType: string
|
|
): Promise<string> {
|
|
const s3Key = this.toS3ObjectKey(key);
|
|
await this.s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: this.config.bucket,
|
|
Key: s3Key,
|
|
Body: body,
|
|
ContentType: contentType,
|
|
})
|
|
);
|
|
return this.logicalObjectUrl(key);
|
|
}
|
|
|
|
async getObject(key: string): Promise<string> {
|
|
const s3Key = this.toS3ObjectKey(key);
|
|
const response = await this.s3Client.send(
|
|
new GetObjectCommand({ Bucket: this.config.bucket, Key: s3Key })
|
|
);
|
|
if (!response.Body) {
|
|
throw new Error(`Empty response body from object store for key: ${key}`);
|
|
}
|
|
return response.Body.transformToString();
|
|
}
|
|
|
|
async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
|
|
const s3Key = this.toS3ObjectKey(key);
|
|
const command =
|
|
method === "PUT"
|
|
? new PutObjectCommand({ Bucket: this.config.bucket, Key: s3Key })
|
|
: new GetObjectCommand({ Bucket: this.config.bucket, Key: s3Key });
|
|
|
|
return getSignedUrl(this.s3Client, command, { expiresIn });
|
|
}
|
|
}
|
|
|
|
export type ObjectStoreClientConfig = {
|
|
baseUrl: string;
|
|
bucket?: string;
|
|
accessKeyId?: string;
|
|
secretAccessKey?: string;
|
|
region?: string;
|
|
service?: string;
|
|
};
|
|
|
|
export class ObjectStoreClient implements IObjectStoreClient {
|
|
private constructor(
|
|
private readonly impl: IObjectStoreClient,
|
|
/** When set, logical keys may start with `${bucket}/…`; AwsSdkClient strips that prefix for S3 APIs. */
|
|
readonly bucket: string | undefined
|
|
) {}
|
|
|
|
static create(config: ObjectStoreClientConfig): ObjectStoreClient {
|
|
if (config.accessKeyId && config.secretAccessKey) {
|
|
return new ObjectStoreClient(
|
|
new Aws4FetchClient({
|
|
baseUrl: config.baseUrl,
|
|
accessKeyId: config.accessKeyId,
|
|
secretAccessKey: config.secretAccessKey,
|
|
region: config.region,
|
|
service: config.service,
|
|
}),
|
|
config.bucket
|
|
);
|
|
}
|
|
|
|
// IAM credential chain — AWS SDK S3Client handles credential refresh automatically
|
|
if (!config.bucket) {
|
|
throw new Error(
|
|
"OBJECT_STORE_BUCKET is required when not using access key credentials (IAM mode)"
|
|
);
|
|
}
|
|
|
|
return new ObjectStoreClient(
|
|
new AwsSdkClient({
|
|
bucket: config.bucket,
|
|
baseUrl: config.baseUrl,
|
|
region: config.region,
|
|
}),
|
|
config.bucket
|
|
);
|
|
}
|
|
|
|
putObject(key: string, body: ReadableStream | string, contentType: string): Promise<string> {
|
|
return this.impl.putObject(key, body, contentType);
|
|
}
|
|
|
|
getObject(key: string): Promise<string> {
|
|
return this.impl.getObject(key);
|
|
}
|
|
|
|
presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
|
|
return this.impl.presign(key, method, expiresIn);
|
|
}
|
|
}
|