import { awsSignedFetch } from './client.ts' import { mutationPreview } from './helpers.ts' import { assertBucket, assertObjectKey, boundedPageSize, inputRecord, optionalString, xmlTag, xmlTags, type AwsAuthOptions, } from './validation.ts' export type S3ObjectMetadata = { key: string size: number | null lastModified: string | null etag: string | null storageClass: string | null } function header(headers: Record, name: string): string | null { const found = Object.entries(headers).find( ([key]) => key.toLowerCase() === name.toLowerCase(), ) return found?.[1] ?? null } /** * List buckets visible to the caller. Names only — no account-specific defaults. * * @example * import { listBuckets } from 'kody:@kody/aws/s3' * const { items } = await listBuckets() */ export async function listBuckets(params: AwsAuthOptions = {}) { const result = await awsSignedFetch({ ...params, service: 's3', method: 'GET', path: '/', }) const names = xmlTags(result.text, 'Name') return { ok: true, region: result.region, items: names.map((name) => ({ name })), } } /** * List object keys and sizes in a bucket. Does not download object bodies. * * @example * import { listObjects } from 'kody:@kody/aws/s3' * const { items } = await listObjects({ bucket: 'example-bucket', prefix: 'logs/' }) */ export async function listObjects( params: AwsAuthOptions & { bucket: string prefix?: string pageSize?: number continuationToken?: string }, ) { const input = inputRecord(params) const bucket = assertBucket(requiredBucket(input)) const result = await awsSignedFetch({ ...params, service: 's3', method: 'GET', path: '/' + encodeURIComponent(bucket), query: { 'list-type': '2', 'max-keys': String(boundedPageSize(input.pageSize, 20, 1000)), prefix: optionalString(input, 'prefix'), 'continuation-token': optionalString(input, 'continuationToken'), }, }) const contents = result.text.match(/[\s\S]*?<\/Contents>/g) ?? [] const items: Array = contents.map((block) => ({ key: xmlTag(block, 'Key') ?? '', size: xmlTag(block, 'Size') ? Number(xmlTag(block, 'Size')) : null, lastModified: xmlTag(block, 'LastModified'), etag: xmlTag(block, 'ETag'), storageClass: xmlTag(block, 'StorageClass'), })) return { ok: true, bucket, region: result.region, prefix: optionalString(input, 'prefix') ?? null, truncated: xmlTag(result.text, 'IsTruncated') === 'true', nextContinuationToken: xmlTag(result.text, 'NextContinuationToken'), items, } } /** * Read S3 object metadata (HEAD). Never returns the object body. * * @example * import { getObjectMetadata } from 'kody:@kody/aws/s3' * const meta = await getObjectMetadata({ bucket: 'example-bucket', key: 'readme.txt' }) */ export async function getObjectMetadata( params: AwsAuthOptions & { bucket: string; key: string }, ) { const input = inputRecord(params) const bucket = assertBucket(requiredBucket(input)) const key = assertObjectKey(requiredStringLocal(input, 'key')) const result = await awsSignedFetch({ ...params, service: 's3', method: 'HEAD', path: '/' + encodeURIComponent(bucket) + '/' + key.split('/').map(encodeURIComponent).join('/'), }) const contentLength = header(result.headers, 'content-length') return { ok: true, bucket, key, region: result.region, contentType: header(result.headers, 'content-type'), contentLength: contentLength ? Number(contentLength) : null, etag: header(result.headers, 'etag'), lastModified: header(result.headers, 'last-modified'), storageClass: header(result.headers, 'x-amz-storage-class'), versionId: header(result.headers, 'x-amz-version-id'), } } /** * Preview or upload an S3 object. Defaults to dry-run. Live put needs * `confirm: true`. Do not put secret files; this helper never echoes the body. */ export async function putObject( params: AwsAuthOptions & { bucket: string key: string body: string contentType?: string dryRun?: boolean confirm?: boolean }, ) { const input = inputRecord(params) const bucket = assertBucket(requiredBucket(input)) const key = assertObjectKey(requiredStringLocal(input, 'key')) const body = requiredStringLocal(input, 'body') const contentType = optionalString(input, 'contentType') ?? 'text/plain' const preview = mutationPreview(input, { method: 'PUT', service: 's3', region: optionalString(input, 'region') ?? 'us-east-1', host: 's3.amazonaws.com', path: '/' + bucket + '/' + key, body: { contentType, bodyLength: body.length }, }) if (preview) { return { ...preview, bucket, key, bodyLength: body.length } } const result = await awsSignedFetch({ ...params, service: 's3', method: 'PUT', path: '/' + encodeURIComponent(bucket) + '/' + key.split('/').map(encodeURIComponent).join('/'), headers: { 'content-type': contentType }, body, unsignedPayload: false, }) return { ok: true, bucket, key, region: result.region, etag: header(result.headers, 'etag'), } } /** * Preview or delete an S3 object. Defaults to dry-run. Live delete needs * `confirm: true`. */ export async function deleteObject( params: AwsAuthOptions & { bucket: string key: string dryRun?: boolean confirm?: boolean }, ) { const input = inputRecord(params) const bucket = assertBucket(requiredBucket(input)) const key = assertObjectKey(requiredStringLocal(input, 'key')) const preview = mutationPreview(input, { method: 'DELETE', service: 's3', region: optionalString(input, 'region') ?? 'us-east-1', host: 's3.amazonaws.com', path: '/' + bucket + '/' + key, }) if (preview) return { ...preview, bucket, key } await awsSignedFetch({ ...params, service: 's3', method: 'DELETE', path: '/' + encodeURIComponent(bucket) + '/' + key.split('/').map(encodeURIComponent).join('/'), }) return { ok: true, deleted: true, bucket, key } } function requiredBucket(input: Record): string { const value = optionalString(input, 'bucket') if (!value) { throw new Error( 'bucket is required. Pass the caller bucket at call time — this package has no default bucket.', ) } return value } function requiredStringLocal( input: Record, key: string, ): string { const value = optionalString(input, key) if (!value) throw new Error(key + ' is required.') return value } export default listBuckets