@stacksjs/ts-cloud 0.1.7 → 0.1.9

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.
Files changed (77) hide show
  1. package/dist/aws/s3.d.ts +1 -1
  2. package/dist/bin/cli.js +223 -222
  3. package/dist/index.js +132 -132
  4. package/package.json +18 -16
  5. package/src/aws/acm.ts +768 -0
  6. package/src/aws/application-autoscaling.ts +845 -0
  7. package/src/aws/bedrock.ts +4074 -0
  8. package/src/aws/client.ts +891 -0
  9. package/src/aws/cloudformation.ts +896 -0
  10. package/src/aws/cloudfront.ts +1531 -0
  11. package/src/aws/cloudwatch-logs.ts +154 -0
  12. package/src/aws/comprehend.ts +839 -0
  13. package/src/aws/connect.ts +1056 -0
  14. package/src/aws/deploy-imap.ts +384 -0
  15. package/src/aws/dynamodb.ts +340 -0
  16. package/src/aws/ec2.ts +1385 -0
  17. package/src/aws/ecr.ts +621 -0
  18. package/src/aws/ecs.ts +615 -0
  19. package/src/aws/elasticache.ts +301 -0
  20. package/src/aws/elbv2.ts +942 -0
  21. package/src/aws/email.ts +928 -0
  22. package/src/aws/eventbridge.ts +248 -0
  23. package/src/aws/iam.ts +1689 -0
  24. package/src/aws/imap-server.ts +2100 -0
  25. package/src/aws/index.ts +213 -0
  26. package/src/aws/kendra.ts +1097 -0
  27. package/src/aws/lambda.ts +786 -0
  28. package/src/aws/opensearch.ts +158 -0
  29. package/src/aws/personalize.ts +977 -0
  30. package/src/aws/polly.ts +559 -0
  31. package/src/aws/rds.ts +888 -0
  32. package/src/aws/rekognition.ts +846 -0
  33. package/src/aws/route53-domains.ts +359 -0
  34. package/src/aws/route53.ts +1046 -0
  35. package/src/aws/s3.ts +2334 -0
  36. package/src/aws/scheduler.ts +571 -0
  37. package/src/aws/secrets-manager.ts +769 -0
  38. package/src/aws/ses.ts +1081 -0
  39. package/src/aws/setup-phone.ts +104 -0
  40. package/src/aws/setup-sms.ts +580 -0
  41. package/src/aws/sms.ts +1735 -0
  42. package/src/aws/smtp-server.ts +531 -0
  43. package/src/aws/sns.ts +758 -0
  44. package/src/aws/sqs.ts +382 -0
  45. package/src/aws/ssm.ts +807 -0
  46. package/src/aws/sts.ts +92 -0
  47. package/src/aws/support.ts +391 -0
  48. package/src/aws/test-imap.ts +86 -0
  49. package/src/aws/textract.ts +780 -0
  50. package/src/aws/transcribe.ts +108 -0
  51. package/src/aws/translate.ts +641 -0
  52. package/src/aws/voice.ts +1379 -0
  53. package/src/config.ts +35 -0
  54. package/src/deploy/index.ts +7 -0
  55. package/src/deploy/static-site-external-dns.ts +945 -0
  56. package/src/deploy/static-site.ts +1175 -0
  57. package/src/dns/cloudflare.ts +548 -0
  58. package/src/dns/godaddy.ts +412 -0
  59. package/src/dns/index.ts +205 -0
  60. package/src/dns/porkbun.ts +362 -0
  61. package/src/dns/route53-adapter.ts +414 -0
  62. package/src/dns/types.ts +119 -0
  63. package/src/dns/validator.ts +369 -0
  64. package/src/generators/index.ts +5 -0
  65. package/src/generators/infrastructure.ts +1660 -0
  66. package/src/index.ts +163 -0
  67. package/src/push/apns.ts +452 -0
  68. package/src/push/fcm.ts +506 -0
  69. package/src/push/index.ts +58 -0
  70. package/src/security/pre-deploy-scanner.ts +655 -0
  71. package/src/ssl/acme-client.ts +478 -0
  72. package/src/ssl/index.ts +7 -0
  73. package/src/ssl/letsencrypt.ts +747 -0
  74. package/src/types.ts +2 -0
  75. package/src/utils/cli.ts +398 -0
  76. package/src/validation/index.ts +5 -0
  77. package/src/validation/template.ts +405 -0
package/src/aws/s3.ts ADDED
@@ -0,0 +1,2334 @@
1
+ /**
2
+ * AWS S3 Operations
3
+ * Direct API calls without AWS CLI dependency
4
+ */
5
+
6
+ import * as crypto from 'node:crypto'
7
+ import { AWSClient } from './client'
8
+ import { readdir, stat } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import { readFileSync } from 'node:fs'
11
+
12
+ /**
13
+ * Convert binary data to a fetch-compatible body.
14
+ * Workaround for TypeScript 5.7+ generic ArrayBuffer types
15
+ * where Buffer/Uint8Array are not directly assignable to BodyInit.
16
+ */
17
+ function toFetchBody(data: Uint8Array | Buffer): ArrayBuffer {
18
+ const { buffer, byteOffset, byteLength } = data
19
+ if (buffer instanceof ArrayBuffer) {
20
+ return buffer.slice(byteOffset, byteOffset + byteLength)
21
+ }
22
+ // SharedArrayBuffer fallback: copy into a new ArrayBuffer
23
+ const copy = new ArrayBuffer(byteLength)
24
+ new Uint8Array(copy).set(new Uint8Array(data))
25
+ return copy
26
+ }
27
+
28
+ export interface S3SyncOptions {
29
+ source: string
30
+ bucket: string
31
+ prefix?: string
32
+ delete?: boolean
33
+ acl?: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'
34
+ cacheControl?: string
35
+ contentType?: string
36
+ metadata?: Record<string, string>
37
+ exclude?: string[]
38
+ include?: string[]
39
+ dryRun?: boolean
40
+ }
41
+
42
+ export interface S3CopyOptions {
43
+ source: string
44
+ bucket: string
45
+ key: string
46
+ acl?: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'
47
+ cacheControl?: string
48
+ contentType?: string
49
+ metadata?: Record<string, string>
50
+ }
51
+
52
+ export interface S3ListOptions {
53
+ bucket: string
54
+ prefix?: string
55
+ maxKeys?: number
56
+ }
57
+
58
+ export interface S3Object {
59
+ Key: string
60
+ LastModified: string
61
+ Size: number
62
+ ETag?: string
63
+ }
64
+
65
+ /**
66
+ * S3 client using direct API calls
67
+ */
68
+ export class S3Client {
69
+ private client: AWSClient
70
+ private region: string
71
+
72
+ constructor(region: string = 'us-east-1', profile?: string) {
73
+ this.region = region
74
+ this.client = new AWSClient()
75
+ }
76
+
77
+ /**
78
+ * Get AWS credentials from environment or credentials file
79
+ */
80
+ private getCredentials(): { accessKeyId: string, secretAccessKey: string, sessionToken?: string } {
81
+ // 1. Check environment variables first
82
+ const envAccessKey = process.env.AWS_ACCESS_KEY_ID
83
+ const envSecretKey = process.env.AWS_SECRET_ACCESS_KEY
84
+ const envSessionToken = process.env.AWS_SESSION_TOKEN
85
+
86
+ if (envAccessKey && envSecretKey) {
87
+ return { accessKeyId: envAccessKey, secretAccessKey: envSecretKey, sessionToken: envSessionToken }
88
+ }
89
+
90
+ // 2. Try to load from ~/.aws/credentials file
91
+ const fileCreds = this.loadCredentialsFromFile()
92
+ if (fileCreds) {
93
+ return fileCreds
94
+ }
95
+
96
+ throw new Error('AWS credentials not found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or configure ~/.aws/credentials.')
97
+ }
98
+
99
+ /**
100
+ * Load credentials from ~/.aws/credentials file
101
+ */
102
+ private loadCredentialsFromFile(): { accessKeyId: string, secretAccessKey: string, sessionToken?: string } | null {
103
+ try {
104
+ const { existsSync, readFileSync } = require('node:fs')
105
+ const { homedir } = require('node:os')
106
+ const { join: pathJoin } = require('node:path')
107
+
108
+ const profile = process.env.AWS_PROFILE || 'default'
109
+ const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || pathJoin(homedir(), '.aws', 'credentials')
110
+
111
+ if (!existsSync(credentialsPath)) {
112
+ return null
113
+ }
114
+
115
+ const content = readFileSync(credentialsPath, 'utf-8')
116
+ const lines = content.split('\n')
117
+ let currentProfile = ''
118
+ let accessKeyId = ''
119
+ let secretAccessKey = ''
120
+ let sessionToken: string | undefined
121
+
122
+ for (const line of lines) {
123
+ const trimmed = line.trim()
124
+ if (!trimmed || trimmed.startsWith('#')) continue
125
+
126
+ const profileMatch = trimmed.match(/^\[([^\]]+)\]$/)
127
+ if (profileMatch) {
128
+ currentProfile = profileMatch[1]
129
+ continue
130
+ }
131
+
132
+ if (currentProfile === profile) {
133
+ const [key, ...valueParts] = trimmed.split('=')
134
+ const value = valueParts.join('=').trim()
135
+
136
+ if (key.trim() === 'aws_access_key_id') {
137
+ accessKeyId = value
138
+ } else if (key.trim() === 'aws_secret_access_key') {
139
+ secretAccessKey = value
140
+ } else if (key.trim() === 'aws_session_token') {
141
+ sessionToken = value
142
+ }
143
+ }
144
+ }
145
+
146
+ if (accessKeyId && secretAccessKey) {
147
+ return { accessKeyId, secretAccessKey, sessionToken }
148
+ }
149
+ } catch {
150
+ // Failed to read credentials file
151
+ }
152
+
153
+ return null
154
+ }
155
+
156
+ /**
157
+ * List all S3 buckets in the account
158
+ */
159
+ async listBuckets(): Promise<{ Buckets: Array<{ Name: string, CreationDate?: string }> }> {
160
+ const result = await this.client.request({
161
+ service: 's3',
162
+ region: this.region,
163
+ method: 'GET',
164
+ path: '/',
165
+ })
166
+
167
+ const buckets: Array<{ Name: string, CreationDate?: string }> = []
168
+ const bucketList = result?.ListAllMyBucketsResult?.Buckets?.Bucket
169
+
170
+ if (bucketList) {
171
+ const list = Array.isArray(bucketList) ? bucketList : [bucketList]
172
+ for (const b of list) {
173
+ buckets.push({
174
+ Name: b.Name,
175
+ CreationDate: b.CreationDate,
176
+ })
177
+ }
178
+ }
179
+
180
+ return { Buckets: buckets }
181
+ }
182
+
183
+ /**
184
+ * Create an S3 bucket
185
+ */
186
+ async createBucket(bucket: string, options?: { acl?: string }): Promise<void> {
187
+ const headers: Record<string, string> = {}
188
+ if (options?.acl) {
189
+ headers['x-amz-acl'] = options.acl
190
+ }
191
+
192
+ // For us-east-1, don't include LocationConstraint
193
+ // For other regions, include it in the body
194
+ let body: string | undefined
195
+ if (this.region !== 'us-east-1') {
196
+ body = `<?xml version="1.0" encoding="UTF-8"?>
197
+ <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
198
+ <LocationConstraint>${this.region}</LocationConstraint>
199
+ </CreateBucketConfiguration>`
200
+ headers['Content-Type'] = 'application/xml'
201
+ }
202
+
203
+ await this.client.request({
204
+ service: 's3',
205
+ region: this.region,
206
+ method: 'PUT',
207
+ path: `/${bucket}`,
208
+ headers,
209
+ body,
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Delete an S3 bucket (must be empty)
215
+ */
216
+ async deleteBucket(bucket: string): Promise<void> {
217
+ await this.client.request({
218
+ service: 's3',
219
+ region: this.region,
220
+ method: 'DELETE',
221
+ path: `/${bucket}`,
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Empty and delete an S3 bucket
227
+ */
228
+ async emptyAndDeleteBucket(bucket: string): Promise<void> {
229
+ // First list and delete all objects
230
+ let hasMore = true
231
+ while (hasMore) {
232
+ const objects = await this.listAllObjects({ bucket })
233
+ if (objects.length === 0) {
234
+ hasMore = false
235
+ break
236
+ }
237
+
238
+ // Delete in batches of 1000 (S3 limit)
239
+ const keys = objects.map(obj => obj.Key)
240
+ for (let i = 0; i < keys.length; i += 1000) {
241
+ const batch = keys.slice(i, i + 1000)
242
+ await this.deleteObjects(bucket, batch)
243
+ }
244
+ }
245
+
246
+ // Now delete the bucket
247
+ await this.deleteBucket(bucket)
248
+ }
249
+
250
+ /**
251
+ * List all objects in a bucket (handles pagination)
252
+ */
253
+ async listAllObjects(options: S3ListOptions): Promise<S3Object[]> {
254
+ const allObjects: S3Object[] = []
255
+ let continuationToken: string | undefined
256
+
257
+ do {
258
+ const params: Record<string, any> = {
259
+ 'list-type': '2',
260
+ 'max-keys': '1000',
261
+ }
262
+
263
+ if (options.prefix) {
264
+ params.prefix = options.prefix
265
+ }
266
+
267
+ if (continuationToken) {
268
+ params['continuation-token'] = continuationToken
269
+ }
270
+
271
+ const result = await this.client.request({
272
+ service: 's3',
273
+ region: this.region,
274
+ method: 'GET',
275
+ path: `/${options.bucket}`,
276
+ queryParams: params,
277
+ })
278
+
279
+ const contents = result?.ListBucketResult?.Contents
280
+ if (contents) {
281
+ const list = Array.isArray(contents) ? contents : [contents]
282
+ for (const obj of list) {
283
+ allObjects.push({
284
+ Key: obj.Key,
285
+ LastModified: obj.LastModified || '',
286
+ Size: Number.parseInt(obj.Size || '0'),
287
+ ETag: obj.ETag,
288
+ })
289
+ }
290
+ }
291
+
292
+ // Check for more results
293
+ const isTruncated = result?.ListBucketResult?.IsTruncated
294
+ continuationToken = isTruncated === 'true' || isTruncated === true
295
+ ? result?.ListBucketResult?.NextContinuationToken
296
+ : undefined
297
+
298
+ } while (continuationToken)
299
+
300
+ return allObjects
301
+ }
302
+
303
+ /**
304
+ * List objects in S3 bucket
305
+ */
306
+ async list(options: S3ListOptions): Promise<S3Object[]> {
307
+ // Use path-style URL without query params for simpler signing
308
+ const result = await this.client.request({
309
+ service: 's3',
310
+ region: this.region,
311
+ method: 'GET',
312
+ path: `/${options.bucket}`,
313
+ })
314
+
315
+ // Parse S3 XML response
316
+ const objects: S3Object[] = []
317
+
318
+ // Handle ListBucketResult structure from XML parsing
319
+ const contents = result?.ListBucketResult?.Contents
320
+ if (contents) {
321
+ const items = Array.isArray(contents) ? contents : [contents]
322
+ for (const item of items) {
323
+ // Filter by prefix if specified
324
+ if (options.prefix && !item.Key?.startsWith(options.prefix)) {
325
+ continue
326
+ }
327
+ objects.push({
328
+ Key: item.Key || '',
329
+ LastModified: item.LastModified || '',
330
+ Size: Number.parseInt(item.Size || '0'),
331
+ ETag: item.ETag,
332
+ })
333
+ // Respect maxKeys
334
+ if (options.maxKeys && objects.length >= options.maxKeys) {
335
+ break
336
+ }
337
+ }
338
+ }
339
+
340
+ return objects
341
+ }
342
+
343
+ /**
344
+ * Put object to S3 bucket
345
+ */
346
+ async putObject(options: {
347
+ bucket: string
348
+ key: string
349
+ body: string | Buffer | Uint8Array
350
+ acl?: string
351
+ cacheControl?: string
352
+ contentType?: string
353
+ metadata?: Record<string, string>
354
+ }): Promise<void> {
355
+ const headers: Record<string, string> = {}
356
+
357
+ if (options.acl) {
358
+ headers['x-amz-acl'] = options.acl
359
+ }
360
+
361
+ if (options.cacheControl) {
362
+ headers['Cache-Control'] = options.cacheControl
363
+ }
364
+
365
+ if (options.contentType) {
366
+ headers['Content-Type'] = options.contentType
367
+ }
368
+
369
+ if (options.metadata) {
370
+ for (const [key, value] of Object.entries(options.metadata)) {
371
+ headers[`x-amz-meta-${key}`] = value
372
+ }
373
+ }
374
+
375
+ // Normalize body to Buffer for binary data
376
+ // Uint8Array needs to be converted to Buffer for proper handling
377
+ const normalizedBody = options.body instanceof Uint8Array && !Buffer.isBuffer(options.body)
378
+ ? Buffer.from(options.body)
379
+ : options.body
380
+
381
+ // For binary data (Buffer/Uint8Array), use direct binary upload
382
+ if (Buffer.isBuffer(normalizedBody) || (normalizedBody as any) instanceof Uint8Array) {
383
+ const binaryBody = Buffer.isBuffer(normalizedBody) ? normalizedBody : Buffer.from(normalizedBody)
384
+ // Actually, for S3 we need to send raw binary, not base64
385
+ // Let's use Bun's fetch which handles Buffer natively
386
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
387
+ const host = `${options.bucket}.s3.${this.region}.amazonaws.com`
388
+ const url = `https://${host}/${options.key}`
389
+
390
+ const now = new Date()
391
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
392
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
393
+
394
+ const payloadHash = crypto.createHash('sha256').update(binaryBody).digest('hex')
395
+
396
+ const requestHeaders: Record<string, string> = {
397
+ 'host': host,
398
+ 'x-amz-date': amzDate,
399
+ 'x-amz-content-sha256': payloadHash,
400
+ ...headers,
401
+ }
402
+
403
+ if (sessionToken) {
404
+ requestHeaders['x-amz-security-token'] = sessionToken
405
+ }
406
+
407
+ // Create canonical request
408
+ const canonicalHeaders = Object.keys(requestHeaders)
409
+ .sort()
410
+ .map(key => `${key.toLowerCase()}:${requestHeaders[key].trim()}\n`)
411
+ .join('')
412
+
413
+ const signedHeaders = Object.keys(requestHeaders)
414
+ .sort()
415
+ .map(key => key.toLowerCase())
416
+ .join(';')
417
+
418
+ const canonicalRequest = [
419
+ 'PUT',
420
+ `/${options.key}`,
421
+ '',
422
+ canonicalHeaders,
423
+ signedHeaders,
424
+ payloadHash,
425
+ ].join('\n')
426
+
427
+ // Create string to sign
428
+ const algorithm = 'AWS4-HMAC-SHA256'
429
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
430
+ const stringToSign = [
431
+ algorithm,
432
+ amzDate,
433
+ credentialScope,
434
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
435
+ ].join('\n')
436
+
437
+ // Calculate signature
438
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
439
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
440
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
441
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
442
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
443
+
444
+ const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
445
+
446
+ const response = await fetch(url, {
447
+ method: 'PUT',
448
+ headers: {
449
+ ...requestHeaders,
450
+ 'Authorization': authorizationHeader,
451
+ },
452
+ body: toFetchBody(binaryBody),
453
+ })
454
+
455
+ if (!response.ok) {
456
+ const errorText = await response.text()
457
+ throw new Error(`S3 PUT failed: ${response.status} ${errorText}`)
458
+ }
459
+
460
+ return
461
+ }
462
+
463
+ await this.client.request({
464
+ service: 's3',
465
+ region: this.region,
466
+ method: 'PUT',
467
+ path: `/${options.key}`,
468
+ bucket: options.bucket, // Use virtual-hosted style
469
+ headers,
470
+ body: options.body as string,
471
+ })
472
+ }
473
+
474
+ /**
475
+ * Get object from S3 bucket
476
+ * Returns raw content as string (not parsed as XML)
477
+ */
478
+ async getObject(bucket: string, key: string): Promise<string> {
479
+ const result = await this.client.request({
480
+ service: 's3',
481
+ region: this.region,
482
+ method: 'GET',
483
+ path: `/${bucket}/${key}`,
484
+ rawResponse: true,
485
+ })
486
+
487
+ return result
488
+ }
489
+
490
+ /**
491
+ * Copy object within S3 (server-side copy)
492
+ */
493
+ async copyObject(options: {
494
+ sourceBucket: string
495
+ sourceKey: string
496
+ destinationBucket: string
497
+ destinationKey: string
498
+ contentType?: string
499
+ metadata?: Record<string, string>
500
+ metadataDirective?: 'COPY' | 'REPLACE'
501
+ }): Promise<void> {
502
+ const headers: Record<string, string> = {
503
+ 'x-amz-copy-source': `/${options.sourceBucket}/${options.sourceKey}`,
504
+ }
505
+
506
+ if (options.metadataDirective) {
507
+ headers['x-amz-metadata-directive'] = options.metadataDirective
508
+ }
509
+
510
+ if (options.contentType) {
511
+ headers['Content-Type'] = options.contentType
512
+ }
513
+
514
+ if (options.metadata) {
515
+ for (const [key, value] of Object.entries(options.metadata)) {
516
+ headers[`x-amz-meta-${key}`] = value
517
+ }
518
+ }
519
+
520
+ await this.client.request({
521
+ service: 's3',
522
+ region: this.region,
523
+ method: 'PUT',
524
+ path: `/${options.destinationBucket}/${options.destinationKey}`,
525
+ headers,
526
+ })
527
+ }
528
+
529
+ /**
530
+ * Delete object from S3
531
+ */
532
+ async deleteObject(bucket: string, key: string): Promise<void> {
533
+ await this.client.request({
534
+ service: 's3',
535
+ region: this.region,
536
+ method: 'DELETE',
537
+ path: `/${bucket}/${key}`,
538
+ })
539
+ }
540
+
541
+ /**
542
+ * Delete multiple objects from S3
543
+ */
544
+ async deleteObjects(bucket: string, keys: string[]): Promise<void> {
545
+ const deleteXml = `<?xml version="1.0" encoding="UTF-8"?>
546
+ <Delete>
547
+ ${keys.map(key => `<Object><Key>${key}</Key></Object>`).join('\n ')}
548
+ </Delete>`
549
+
550
+ // S3 DeleteObjects requires Content-MD5 header
551
+ const contentMd5 = crypto.createHash('md5').update(deleteXml).digest('base64')
552
+
553
+ await this.client.request({
554
+ service: 's3',
555
+ region: this.region,
556
+ method: 'POST',
557
+ path: `/${bucket}`,
558
+ queryParams: { delete: '' },
559
+ body: deleteXml,
560
+ headers: {
561
+ 'Content-Type': 'application/xml',
562
+ 'Content-MD5': contentMd5,
563
+ },
564
+ })
565
+ }
566
+
567
+ /**
568
+ * Check if bucket exists
569
+ */
570
+ async bucketExists(bucket: string): Promise<boolean> {
571
+ try {
572
+ await this.client.request({
573
+ service: 's3',
574
+ region: this.region,
575
+ method: 'HEAD',
576
+ path: `/${bucket}`,
577
+ })
578
+ return true
579
+ }
580
+ catch {
581
+ return false
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Copy file to S3
587
+ */
588
+ async copy(options: S3CopyOptions): Promise<void> {
589
+ // Read file and upload
590
+ const fileContent = readFileSync(options.source)
591
+
592
+ await this.putObject({
593
+ bucket: options.bucket,
594
+ key: options.key,
595
+ body: fileContent,
596
+ acl: options.acl,
597
+ cacheControl: options.cacheControl,
598
+ contentType: options.contentType,
599
+ metadata: options.metadata,
600
+ })
601
+ }
602
+
603
+ /**
604
+ * Sync local directory to S3 bucket
605
+ * Note: This is a simplified version. For production use, implement proper sync logic
606
+ */
607
+ async sync(options: S3SyncOptions): Promise<void> {
608
+ const files = await this.listFilesRecursive(options.source)
609
+
610
+ for (const file of files) {
611
+ // Skip excluded files
612
+ if (options.exclude && options.exclude.some(pattern => file.includes(pattern))) {
613
+ continue
614
+ }
615
+
616
+ // Check included files
617
+ if (options.include && !options.include.some(pattern => file.includes(pattern))) {
618
+ continue
619
+ }
620
+
621
+ const relativePath = file.substring(options.source.length + 1)
622
+ const s3Key = options.prefix ? `${options.prefix}/${relativePath}` : relativePath
623
+
624
+ if (!options.dryRun) {
625
+ const fileContent = readFileSync(file)
626
+
627
+ await this.putObject({
628
+ bucket: options.bucket,
629
+ key: s3Key,
630
+ body: fileContent,
631
+ acl: options.acl,
632
+ cacheControl: options.cacheControl,
633
+ contentType: options.contentType,
634
+ metadata: options.metadata,
635
+ })
636
+ }
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Delete object from S3 (alias for deleteObject)
642
+ */
643
+ async delete(bucket: string, key: string): Promise<void> {
644
+ await this.deleteObject(bucket, key)
645
+ }
646
+
647
+ /**
648
+ * Delete all objects in a prefix
649
+ */
650
+ async deletePrefix(bucket: string, prefix: string): Promise<void> {
651
+ const objects = await this.list({ bucket, prefix })
652
+ const keys = objects.map(obj => obj.Key)
653
+
654
+ if (keys.length > 0) {
655
+ await this.deleteObjects(bucket, keys)
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Get bucket size
661
+ */
662
+ async getBucketSize(bucket: string, prefix?: string): Promise<number> {
663
+ const objects = await this.list({ bucket, prefix })
664
+ return objects.reduce((total, obj) => total + obj.Size, 0)
665
+ }
666
+
667
+ /**
668
+ * List files recursively in a directory
669
+ */
670
+ private async listFilesRecursive(dir: string): Promise<string[]> {
671
+ const files: string[] = []
672
+ const entries = await readdir(dir, { withFileTypes: true })
673
+
674
+ for (const entry of entries) {
675
+ const fullPath = join(dir, entry.name)
676
+
677
+ if (entry.isDirectory()) {
678
+ const subFiles = await this.listFilesRecursive(fullPath)
679
+ files.push(...subFiles)
680
+ }
681
+ else {
682
+ files.push(fullPath)
683
+ }
684
+ }
685
+
686
+ return files
687
+ }
688
+
689
+ /**
690
+ * Put bucket policy for an S3 bucket
691
+ * Uses path-style URLs to avoid redirect issues
692
+ */
693
+ async putBucketPolicy(bucket: string, policy: object | string): Promise<void> {
694
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
695
+ const host = `s3.${this.region}.amazonaws.com`
696
+ const policyString = typeof policy === 'string' ? policy : JSON.stringify(policy)
697
+
698
+ const now = new Date()
699
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
700
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
701
+
702
+ const payloadHash = crypto.createHash('sha256').update(policyString).digest('hex')
703
+
704
+ // Use path-style URL: s3.region.amazonaws.com/bucket?policy
705
+ const canonicalUri = '/' + bucket
706
+ const canonicalQuerystring = 'policy='
707
+
708
+ const requestHeaders: Record<string, string> = {
709
+ 'host': host,
710
+ 'x-amz-date': amzDate,
711
+ 'x-amz-content-sha256': payloadHash,
712
+ 'content-type': 'application/json',
713
+ }
714
+
715
+ if (sessionToken) {
716
+ requestHeaders['x-amz-security-token'] = sessionToken
717
+ }
718
+
719
+ const canonicalHeaders = Object.keys(requestHeaders)
720
+ .sort()
721
+ .map(key => `${key.toLowerCase()}:${requestHeaders[key].trim()}\n`)
722
+ .join('')
723
+
724
+ const signedHeaders = Object.keys(requestHeaders)
725
+ .sort()
726
+ .map(key => key.toLowerCase())
727
+ .join(';')
728
+
729
+ const canonicalRequest = [
730
+ 'PUT',
731
+ canonicalUri,
732
+ canonicalQuerystring,
733
+ canonicalHeaders,
734
+ signedHeaders,
735
+ payloadHash,
736
+ ].join('\n')
737
+
738
+ const algorithm = 'AWS4-HMAC-SHA256'
739
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
740
+ const stringToSign = [
741
+ algorithm,
742
+ amzDate,
743
+ credentialScope,
744
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
745
+ ].join('\n')
746
+
747
+ const kDate = crypto.createHmac('sha256', 'AWS4' + secretAccessKey).update(dateStamp).digest()
748
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
749
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
750
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
751
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
752
+
753
+ const authHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
754
+
755
+ const url = `https://${host}${canonicalUri}?${canonicalQuerystring}`
756
+
757
+ const response = await fetch(url, {
758
+ method: 'PUT',
759
+ headers: {
760
+ ...requestHeaders,
761
+ 'Authorization': authHeader,
762
+ },
763
+ body: policyString,
764
+ })
765
+
766
+ if (!response.ok) {
767
+ const text = await response.text()
768
+ throw new Error(`Failed to put bucket policy: ${response.status} ${text}`)
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Get bucket policy for an S3 bucket
774
+ * Uses path-style URLs to avoid redirect issues
775
+ */
776
+ async getBucketPolicy(bucket: string): Promise<object | null> {
777
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
778
+ const host = `s3.${this.region}.amazonaws.com`
779
+
780
+ const now = new Date()
781
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
782
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
783
+
784
+ const payloadHash = crypto.createHash('sha256').update('').digest('hex')
785
+
786
+ // Use path-style URL: s3.region.amazonaws.com/bucket?policy
787
+ const canonicalUri = '/' + bucket
788
+ const canonicalQuerystring = 'policy='
789
+
790
+ const requestHeaders: Record<string, string> = {
791
+ 'host': host,
792
+ 'x-amz-date': amzDate,
793
+ 'x-amz-content-sha256': payloadHash,
794
+ }
795
+
796
+ if (sessionToken) {
797
+ requestHeaders['x-amz-security-token'] = sessionToken
798
+ }
799
+
800
+ const canonicalHeaders = Object.keys(requestHeaders)
801
+ .sort()
802
+ .map(key => `${key.toLowerCase()}:${requestHeaders[key].trim()}\n`)
803
+ .join('')
804
+
805
+ const signedHeaders = Object.keys(requestHeaders)
806
+ .sort()
807
+ .map(key => key.toLowerCase())
808
+ .join(';')
809
+
810
+ const canonicalRequest = [
811
+ 'GET',
812
+ canonicalUri,
813
+ canonicalQuerystring,
814
+ canonicalHeaders,
815
+ signedHeaders,
816
+ payloadHash,
817
+ ].join('\n')
818
+
819
+ const algorithm = 'AWS4-HMAC-SHA256'
820
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
821
+ const stringToSign = [
822
+ algorithm,
823
+ amzDate,
824
+ credentialScope,
825
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
826
+ ].join('\n')
827
+
828
+ const kDate = crypto.createHmac('sha256', 'AWS4' + secretAccessKey).update(dateStamp).digest()
829
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
830
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
831
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
832
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
833
+
834
+ const authHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
835
+
836
+ const url = `https://${host}${canonicalUri}?${canonicalQuerystring}`
837
+
838
+ const response = await fetch(url, {
839
+ method: 'GET',
840
+ headers: {
841
+ ...requestHeaders,
842
+ 'Authorization': authHeader,
843
+ },
844
+ })
845
+
846
+ if (response.status === 404) {
847
+ return null
848
+ }
849
+
850
+ if (!response.ok) {
851
+ const text = await response.text()
852
+ throw new Error(`Failed to get bucket policy: ${response.status} ${text}`)
853
+ }
854
+
855
+ const text = await response.text()
856
+ return JSON.parse(text)
857
+ }
858
+
859
+ /**
860
+ * Delete bucket policy
861
+ */
862
+ async deleteBucketPolicy(bucket: string): Promise<void> {
863
+ await this.client.request({
864
+ service: 's3',
865
+ region: this.region,
866
+ method: 'DELETE',
867
+ path: `/${bucket}`,
868
+ queryParams: { policy: '' },
869
+ })
870
+ }
871
+
872
+ /**
873
+ * Head bucket - check if bucket exists and you have access
874
+ */
875
+ async headBucket(bucket: string): Promise<{ exists: boolean; region?: string }> {
876
+ try {
877
+ const result = await this.client.request({
878
+ service: 's3',
879
+ region: this.region,
880
+ method: 'HEAD',
881
+ path: `/${bucket}`,
882
+ returnHeaders: true,
883
+ })
884
+ return { exists: true, region: result?.headers?.['x-amz-bucket-region'] }
885
+ } catch (e: any) {
886
+ if (e.statusCode === 404) {
887
+ return { exists: false }
888
+ }
889
+ throw e
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Head object - get object metadata without downloading
895
+ */
896
+ async headObject(bucket: string, key: string): Promise<{
897
+ ContentLength?: number
898
+ ContentType?: string
899
+ ETag?: string
900
+ LastModified?: string
901
+ Metadata?: Record<string, string>
902
+ } | null> {
903
+ try {
904
+ const result = await this.client.request({
905
+ service: 's3',
906
+ region: this.region,
907
+ method: 'HEAD',
908
+ path: `/${bucket}/${key}`,
909
+ returnHeaders: true,
910
+ })
911
+ return {
912
+ ContentLength: result?.headers?.['content-length'] ? parseInt(result.headers['content-length']) : undefined,
913
+ ContentType: result?.headers?.['content-type'],
914
+ ETag: result?.headers?.['etag'],
915
+ LastModified: result?.headers?.['last-modified'],
916
+ }
917
+ } catch (e: any) {
918
+ if (e.statusCode === 404) {
919
+ return null
920
+ }
921
+ throw e
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Get object as Buffer
927
+ */
928
+ async getObjectBuffer(bucket: string, key: string): Promise<Buffer> {
929
+ const content = await this.getObject(bucket, key)
930
+ return Buffer.from(content)
931
+ }
932
+
933
+ /**
934
+ * Get object as JSON
935
+ */
936
+ async getObjectJson<T = any>(bucket: string, key: string): Promise<T> {
937
+ const content = await this.getObject(bucket, key)
938
+ return JSON.parse(content)
939
+ }
940
+
941
+ /**
942
+ * Put JSON object
943
+ */
944
+ async putObjectJson(bucket: string, key: string, data: any, options?: {
945
+ acl?: string
946
+ cacheControl?: string
947
+ metadata?: Record<string, string>
948
+ }): Promise<void> {
949
+ await this.putObject({
950
+ bucket,
951
+ key,
952
+ body: JSON.stringify(data),
953
+ contentType: 'application/json',
954
+ ...options,
955
+ })
956
+ }
957
+
958
+ /**
959
+ * Get bucket versioning configuration
960
+ */
961
+ async getBucketVersioning(bucket: string): Promise<{ Status?: 'Enabled' | 'Suspended' }> {
962
+ const result = await this.client.request({
963
+ service: 's3',
964
+ region: this.region,
965
+ method: 'GET',
966
+ path: `/${bucket}`,
967
+ queryParams: { versioning: '' },
968
+ })
969
+ return {
970
+ Status: result?.VersioningConfiguration?.Status,
971
+ }
972
+ }
973
+
974
+ /**
975
+ * Put bucket versioning configuration
976
+ */
977
+ async putBucketVersioning(bucket: string, status: 'Enabled' | 'Suspended'): Promise<void> {
978
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
979
+ <VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
980
+ <Status>${status}</Status>
981
+ </VersioningConfiguration>`
982
+
983
+ await this.client.request({
984
+ service: 's3',
985
+ region: this.region,
986
+ method: 'PUT',
987
+ path: `/${bucket}`,
988
+ queryParams: { versioning: '' },
989
+ headers: { 'Content-Type': 'application/xml' },
990
+ body,
991
+ })
992
+ }
993
+
994
+ /**
995
+ * Get bucket lifecycle configuration
996
+ */
997
+ async getBucketLifecycleConfiguration(bucket: string): Promise<any> {
998
+ try {
999
+ const result = await this.client.request({
1000
+ service: 's3',
1001
+ region: this.region,
1002
+ method: 'GET',
1003
+ path: `/${bucket}`,
1004
+ queryParams: { lifecycle: '' },
1005
+ })
1006
+ return result?.LifecycleConfiguration
1007
+ } catch (e: any) {
1008
+ if (e.statusCode === 404) {
1009
+ return null
1010
+ }
1011
+ throw e
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Put bucket lifecycle configuration
1017
+ */
1018
+ async putBucketLifecycleConfiguration(bucket: string, rules: Array<{
1019
+ ID: string
1020
+ Status: 'Enabled' | 'Disabled'
1021
+ Filter?: { Prefix?: string }
1022
+ Expiration?: { Days?: number; Date?: string }
1023
+ Transitions?: Array<{ Days?: number; StorageClass: string }>
1024
+ NoncurrentVersionExpiration?: { NoncurrentDays: number }
1025
+ }>): Promise<void> {
1026
+ const rulesXml = rules.map(rule => {
1027
+ let ruleXml = `<Rule><ID>${rule.ID}</ID><Status>${rule.Status}</Status>`
1028
+
1029
+ if (rule.Filter) {
1030
+ ruleXml += `<Filter><Prefix>${rule.Filter.Prefix || ''}</Prefix></Filter>`
1031
+ } else {
1032
+ ruleXml += '<Filter><Prefix></Prefix></Filter>'
1033
+ }
1034
+
1035
+ if (rule.Expiration) {
1036
+ if (rule.Expiration.Days) {
1037
+ ruleXml += `<Expiration><Days>${rule.Expiration.Days}</Days></Expiration>`
1038
+ } else if (rule.Expiration.Date) {
1039
+ ruleXml += `<Expiration><Date>${rule.Expiration.Date}</Date></Expiration>`
1040
+ }
1041
+ }
1042
+
1043
+ if (rule.Transitions) {
1044
+ for (const t of rule.Transitions) {
1045
+ ruleXml += `<Transition><Days>${t.Days}</Days><StorageClass>${t.StorageClass}</StorageClass></Transition>`
1046
+ }
1047
+ }
1048
+
1049
+ if (rule.NoncurrentVersionExpiration) {
1050
+ ruleXml += `<NoncurrentVersionExpiration><NoncurrentDays>${rule.NoncurrentVersionExpiration.NoncurrentDays}</NoncurrentDays></NoncurrentVersionExpiration>`
1051
+ }
1052
+
1053
+ ruleXml += '</Rule>'
1054
+ return ruleXml
1055
+ }).join('')
1056
+
1057
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1058
+ <LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1059
+ ${rulesXml}
1060
+ </LifecycleConfiguration>`
1061
+
1062
+ await this.client.request({
1063
+ service: 's3',
1064
+ region: this.region,
1065
+ method: 'PUT',
1066
+ path: `/${bucket}`,
1067
+ queryParams: { lifecycle: '' },
1068
+ headers: { 'Content-Type': 'application/xml' },
1069
+ body,
1070
+ })
1071
+ }
1072
+
1073
+ /**
1074
+ * Delete bucket lifecycle configuration
1075
+ */
1076
+ async deleteBucketLifecycleConfiguration(bucket: string): Promise<void> {
1077
+ await this.client.request({
1078
+ service: 's3',
1079
+ region: this.region,
1080
+ method: 'DELETE',
1081
+ path: `/${bucket}`,
1082
+ queryParams: { lifecycle: '' },
1083
+ })
1084
+ }
1085
+
1086
+ /**
1087
+ * Get bucket CORS configuration
1088
+ */
1089
+ async getBucketCors(bucket: string): Promise<any> {
1090
+ try {
1091
+ const result = await this.client.request({
1092
+ service: 's3',
1093
+ region: this.region,
1094
+ method: 'GET',
1095
+ path: `/${bucket}`,
1096
+ queryParams: { cors: '' },
1097
+ })
1098
+ return result?.CORSConfiguration
1099
+ } catch (e: any) {
1100
+ if (e.statusCode === 404) {
1101
+ return null
1102
+ }
1103
+ throw e
1104
+ }
1105
+ }
1106
+
1107
+ /**
1108
+ * Put bucket CORS configuration
1109
+ */
1110
+ async putBucketCors(bucket: string, rules: Array<{
1111
+ AllowedOrigins: string[]
1112
+ AllowedMethods: string[]
1113
+ AllowedHeaders?: string[]
1114
+ ExposeHeaders?: string[]
1115
+ MaxAgeSeconds?: number
1116
+ }>): Promise<void> {
1117
+ const rulesXml = rules.map(rule => {
1118
+ let ruleXml = '<CORSRule>'
1119
+ for (const origin of rule.AllowedOrigins) {
1120
+ ruleXml += `<AllowedOrigin>${origin}</AllowedOrigin>`
1121
+ }
1122
+ for (const method of rule.AllowedMethods) {
1123
+ ruleXml += `<AllowedMethod>${method}</AllowedMethod>`
1124
+ }
1125
+ if (rule.AllowedHeaders) {
1126
+ for (const header of rule.AllowedHeaders) {
1127
+ ruleXml += `<AllowedHeader>${header}</AllowedHeader>`
1128
+ }
1129
+ }
1130
+ if (rule.ExposeHeaders) {
1131
+ for (const header of rule.ExposeHeaders) {
1132
+ ruleXml += `<ExposeHeader>${header}</ExposeHeader>`
1133
+ }
1134
+ }
1135
+ if (rule.MaxAgeSeconds) {
1136
+ ruleXml += `<MaxAgeSeconds>${rule.MaxAgeSeconds}</MaxAgeSeconds>`
1137
+ }
1138
+ ruleXml += '</CORSRule>'
1139
+ return ruleXml
1140
+ }).join('')
1141
+
1142
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1143
+ <CORSConfiguration>
1144
+ ${rulesXml}
1145
+ </CORSConfiguration>`
1146
+
1147
+ await this.client.request({
1148
+ service: 's3',
1149
+ region: this.region,
1150
+ method: 'PUT',
1151
+ path: `/${bucket}`,
1152
+ queryParams: { cors: '' },
1153
+ headers: { 'Content-Type': 'application/xml' },
1154
+ body,
1155
+ })
1156
+ }
1157
+
1158
+ /**
1159
+ * Delete bucket CORS configuration
1160
+ */
1161
+ async deleteBucketCors(bucket: string): Promise<void> {
1162
+ await this.client.request({
1163
+ service: 's3',
1164
+ region: this.region,
1165
+ method: 'DELETE',
1166
+ path: `/${bucket}`,
1167
+ queryParams: { cors: '' },
1168
+ })
1169
+ }
1170
+
1171
+ /**
1172
+ * Get bucket encryption configuration
1173
+ */
1174
+ async getBucketEncryption(bucket: string): Promise<any> {
1175
+ try {
1176
+ const result = await this.client.request({
1177
+ service: 's3',
1178
+ region: this.region,
1179
+ method: 'GET',
1180
+ path: `/${bucket}`,
1181
+ queryParams: { encryption: '' },
1182
+ })
1183
+ return result?.ServerSideEncryptionConfiguration
1184
+ } catch (e: any) {
1185
+ if (e.statusCode === 404) {
1186
+ return null
1187
+ }
1188
+ throw e
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Put bucket encryption configuration
1194
+ */
1195
+ async putBucketEncryption(bucket: string, sseAlgorithm: 'AES256' | 'aws:kms', kmsKeyId?: string): Promise<void> {
1196
+ let ruleXml = `<ApplyServerSideEncryptionByDefault><SSEAlgorithm>${sseAlgorithm}</SSEAlgorithm>`
1197
+ if (kmsKeyId) {
1198
+ ruleXml += `<KMSMasterKeyID>${kmsKeyId}</KMSMasterKeyID>`
1199
+ }
1200
+ ruleXml += '</ApplyServerSideEncryptionByDefault>'
1201
+
1202
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1203
+ <ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1204
+ <Rule>${ruleXml}</Rule>
1205
+ </ServerSideEncryptionConfiguration>`
1206
+
1207
+ await this.client.request({
1208
+ service: 's3',
1209
+ region: this.region,
1210
+ method: 'PUT',
1211
+ path: `/${bucket}`,
1212
+ queryParams: { encryption: '' },
1213
+ headers: { 'Content-Type': 'application/xml' },
1214
+ body,
1215
+ })
1216
+ }
1217
+
1218
+ /**
1219
+ * Delete bucket encryption configuration
1220
+ */
1221
+ async deleteBucketEncryption(bucket: string): Promise<void> {
1222
+ await this.client.request({
1223
+ service: 's3',
1224
+ region: this.region,
1225
+ method: 'DELETE',
1226
+ path: `/${bucket}`,
1227
+ queryParams: { encryption: '' },
1228
+ })
1229
+ }
1230
+
1231
+ /**
1232
+ * Get bucket tagging
1233
+ */
1234
+ async getBucketTagging(bucket: string): Promise<Array<{ Key: string; Value: string }>> {
1235
+ try {
1236
+ const result = await this.client.request({
1237
+ service: 's3',
1238
+ region: this.region,
1239
+ method: 'GET',
1240
+ path: `/${bucket}`,
1241
+ queryParams: { tagging: '' },
1242
+ })
1243
+ const tagSet = result?.Tagging?.TagSet?.Tag
1244
+ if (!tagSet) return []
1245
+ return Array.isArray(tagSet) ? tagSet : [tagSet]
1246
+ } catch (e: any) {
1247
+ if (e.statusCode === 404) {
1248
+ return []
1249
+ }
1250
+ throw e
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * Put bucket tagging
1256
+ */
1257
+ async putBucketTagging(bucket: string, tags: Array<{ Key: string; Value: string }>): Promise<void> {
1258
+ const tagsXml = tags.map(t => `<Tag><Key>${t.Key}</Key><Value>${t.Value}</Value></Tag>`).join('')
1259
+
1260
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1261
+ <Tagging>
1262
+ <TagSet>${tagsXml}</TagSet>
1263
+ </Tagging>`
1264
+
1265
+ await this.client.request({
1266
+ service: 's3',
1267
+ region: this.region,
1268
+ method: 'PUT',
1269
+ path: `/${bucket}`,
1270
+ queryParams: { tagging: '' },
1271
+ headers: { 'Content-Type': 'application/xml' },
1272
+ body,
1273
+ })
1274
+ }
1275
+
1276
+ /**
1277
+ * Delete bucket tagging
1278
+ */
1279
+ async deleteBucketTagging(bucket: string): Promise<void> {
1280
+ await this.client.request({
1281
+ service: 's3',
1282
+ region: this.region,
1283
+ method: 'DELETE',
1284
+ path: `/${bucket}`,
1285
+ queryParams: { tagging: '' },
1286
+ })
1287
+ }
1288
+
1289
+ /**
1290
+ * Get object tagging
1291
+ */
1292
+ async getObjectTagging(bucket: string, key: string): Promise<Array<{ Key: string; Value: string }>> {
1293
+ try {
1294
+ const result = await this.client.request({
1295
+ service: 's3',
1296
+ region: this.region,
1297
+ method: 'GET',
1298
+ path: `/${bucket}/${key}`,
1299
+ queryParams: { tagging: '' },
1300
+ })
1301
+ const tagSet = result?.Tagging?.TagSet?.Tag
1302
+ if (!tagSet) return []
1303
+ return Array.isArray(tagSet) ? tagSet : [tagSet]
1304
+ } catch (e: any) {
1305
+ if (e.statusCode === 404) {
1306
+ return []
1307
+ }
1308
+ throw e
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * Put object tagging
1314
+ */
1315
+ async putObjectTagging(bucket: string, key: string, tags: Array<{ Key: string; Value: string }>): Promise<void> {
1316
+ const tagsXml = tags.map(t => `<Tag><Key>${t.Key}</Key><Value>${t.Value}</Value></Tag>`).join('')
1317
+
1318
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1319
+ <Tagging>
1320
+ <TagSet>${tagsXml}</TagSet>
1321
+ </Tagging>`
1322
+
1323
+ await this.client.request({
1324
+ service: 's3',
1325
+ region: this.region,
1326
+ method: 'PUT',
1327
+ path: `/${bucket}/${key}`,
1328
+ queryParams: { tagging: '' },
1329
+ headers: { 'Content-Type': 'application/xml' },
1330
+ body,
1331
+ })
1332
+ }
1333
+
1334
+ /**
1335
+ * Delete object tagging
1336
+ */
1337
+ async deleteObjectTagging(bucket: string, key: string): Promise<void> {
1338
+ await this.client.request({
1339
+ service: 's3',
1340
+ region: this.region,
1341
+ method: 'DELETE',
1342
+ path: `/${bucket}/${key}`,
1343
+ queryParams: { tagging: '' },
1344
+ })
1345
+ }
1346
+
1347
+ /**
1348
+ * Get bucket ACL
1349
+ */
1350
+ async getBucketAcl(bucket: string): Promise<any> {
1351
+ const result = await this.client.request({
1352
+ service: 's3',
1353
+ region: this.region,
1354
+ method: 'GET',
1355
+ path: `/${bucket}`,
1356
+ queryParams: { acl: '' },
1357
+ })
1358
+ return result?.AccessControlPolicy
1359
+ }
1360
+
1361
+ /**
1362
+ * Put bucket ACL (canned ACL)
1363
+ */
1364
+ async putBucketAcl(bucket: string, acl: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'): Promise<void> {
1365
+ await this.client.request({
1366
+ service: 's3',
1367
+ region: this.region,
1368
+ method: 'PUT',
1369
+ path: `/${bucket}`,
1370
+ queryParams: { acl: '' },
1371
+ headers: { 'x-amz-acl': acl },
1372
+ })
1373
+ }
1374
+
1375
+ /**
1376
+ * Get object ACL
1377
+ */
1378
+ async getObjectAcl(bucket: string, key: string): Promise<any> {
1379
+ const result = await this.client.request({
1380
+ service: 's3',
1381
+ region: this.region,
1382
+ method: 'GET',
1383
+ path: `/${bucket}/${key}`,
1384
+ queryParams: { acl: '' },
1385
+ })
1386
+ return result?.AccessControlPolicy
1387
+ }
1388
+
1389
+ /**
1390
+ * Put object ACL (canned ACL)
1391
+ */
1392
+ async putObjectAcl(bucket: string, key: string, acl: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'): Promise<void> {
1393
+ await this.client.request({
1394
+ service: 's3',
1395
+ region: this.region,
1396
+ method: 'PUT',
1397
+ path: `/${bucket}/${key}`,
1398
+ queryParams: { acl: '' },
1399
+ headers: { 'x-amz-acl': acl },
1400
+ })
1401
+ }
1402
+
1403
+ /**
1404
+ * Get bucket location
1405
+ */
1406
+ async getBucketLocation(bucket: string): Promise<string> {
1407
+ const result = await this.client.request({
1408
+ service: 's3',
1409
+ region: this.region,
1410
+ method: 'GET',
1411
+ path: `/${bucket}`,
1412
+ queryParams: { location: '' },
1413
+ })
1414
+ // Empty string means us-east-1
1415
+ return result?.LocationConstraint || 'us-east-1'
1416
+ }
1417
+
1418
+ /**
1419
+ * Get bucket logging configuration
1420
+ */
1421
+ async getBucketLogging(bucket: string): Promise<any> {
1422
+ const result = await this.client.request({
1423
+ service: 's3',
1424
+ region: this.region,
1425
+ method: 'GET',
1426
+ path: `/${bucket}`,
1427
+ queryParams: { logging: '' },
1428
+ })
1429
+ return result?.BucketLoggingStatus
1430
+ }
1431
+
1432
+ /**
1433
+ * Put bucket logging configuration
1434
+ */
1435
+ async putBucketLogging(bucket: string, targetBucket: string, targetPrefix: string): Promise<void> {
1436
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1437
+ <BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1438
+ <LoggingEnabled>
1439
+ <TargetBucket>${targetBucket}</TargetBucket>
1440
+ <TargetPrefix>${targetPrefix}</TargetPrefix>
1441
+ </LoggingEnabled>
1442
+ </BucketLoggingStatus>`
1443
+
1444
+ await this.client.request({
1445
+ service: 's3',
1446
+ region: this.region,
1447
+ method: 'PUT',
1448
+ path: `/${bucket}`,
1449
+ queryParams: { logging: '' },
1450
+ headers: { 'Content-Type': 'application/xml' },
1451
+ body,
1452
+ })
1453
+ }
1454
+
1455
+ /**
1456
+ * Get bucket notification configuration
1457
+ */
1458
+ async getBucketNotificationConfiguration(bucket: string): Promise<any> {
1459
+ const result = await this.client.request({
1460
+ service: 's3',
1461
+ region: this.region,
1462
+ method: 'GET',
1463
+ path: `/${bucket}`,
1464
+ queryParams: { notification: '' },
1465
+ })
1466
+ return result?.NotificationConfiguration
1467
+ }
1468
+
1469
+ /**
1470
+ * Put bucket notification configuration
1471
+ */
1472
+ async putBucketNotificationConfiguration(bucket: string, config: {
1473
+ LambdaFunctionConfigurations?: Array<{
1474
+ Id?: string
1475
+ LambdaFunctionArn: string
1476
+ Events: string[]
1477
+ Filter?: { Key?: { FilterRules: Array<{ Name: string; Value: string }> } }
1478
+ }>
1479
+ TopicConfigurations?: Array<{
1480
+ Id?: string
1481
+ TopicArn: string
1482
+ Events: string[]
1483
+ Filter?: { Key?: { FilterRules: Array<{ Name: string; Value: string }> } }
1484
+ }>
1485
+ QueueConfigurations?: Array<{
1486
+ Id?: string
1487
+ QueueArn: string
1488
+ Events: string[]
1489
+ Filter?: { Key?: { FilterRules: Array<{ Name: string; Value: string }> } }
1490
+ }>
1491
+ }): Promise<void> {
1492
+ let configXml = ''
1493
+
1494
+ if (config.LambdaFunctionConfigurations) {
1495
+ for (const c of config.LambdaFunctionConfigurations) {
1496
+ configXml += '<CloudFunctionConfiguration>'
1497
+ if (c.Id) configXml += `<Id>${c.Id}</Id>`
1498
+ configXml += `<CloudFunction>${c.LambdaFunctionArn}</CloudFunction>`
1499
+ for (const event of c.Events) {
1500
+ configXml += `<Event>${event}</Event>`
1501
+ }
1502
+ if (c.Filter?.Key?.FilterRules) {
1503
+ configXml += '<Filter><S3Key>'
1504
+ for (const rule of c.Filter.Key.FilterRules) {
1505
+ configXml += `<FilterRule><Name>${rule.Name}</Name><Value>${rule.Value}</Value></FilterRule>`
1506
+ }
1507
+ configXml += '</S3Key></Filter>'
1508
+ }
1509
+ configXml += '</CloudFunctionConfiguration>'
1510
+ }
1511
+ }
1512
+
1513
+ if (config.TopicConfigurations) {
1514
+ for (const c of config.TopicConfigurations) {
1515
+ configXml += '<TopicConfiguration>'
1516
+ if (c.Id) configXml += `<Id>${c.Id}</Id>`
1517
+ configXml += `<Topic>${c.TopicArn}</Topic>`
1518
+ for (const event of c.Events) {
1519
+ configXml += `<Event>${event}</Event>`
1520
+ }
1521
+ configXml += '</TopicConfiguration>'
1522
+ }
1523
+ }
1524
+
1525
+ if (config.QueueConfigurations) {
1526
+ for (const c of config.QueueConfigurations) {
1527
+ configXml += '<QueueConfiguration>'
1528
+ if (c.Id) configXml += `<Id>${c.Id}</Id>`
1529
+ configXml += `<Queue>${c.QueueArn}</Queue>`
1530
+ for (const event of c.Events) {
1531
+ configXml += `<Event>${event}</Event>`
1532
+ }
1533
+ configXml += '</QueueConfiguration>'
1534
+ }
1535
+ }
1536
+
1537
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1538
+ <NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1539
+ ${configXml}
1540
+ </NotificationConfiguration>`
1541
+
1542
+ await this.client.request({
1543
+ service: 's3',
1544
+ region: this.region,
1545
+ method: 'PUT',
1546
+ path: `/${bucket}`,
1547
+ queryParams: { notification: '' },
1548
+ headers: { 'Content-Type': 'application/xml' },
1549
+ body,
1550
+ })
1551
+ }
1552
+
1553
+ /**
1554
+ * Get bucket website configuration
1555
+ */
1556
+ async getBucketWebsite(bucket: string): Promise<any> {
1557
+ try {
1558
+ const result = await this.client.request({
1559
+ service: 's3',
1560
+ region: this.region,
1561
+ method: 'GET',
1562
+ path: `/${bucket}`,
1563
+ queryParams: { website: '' },
1564
+ })
1565
+ return result?.WebsiteConfiguration
1566
+ } catch (e: any) {
1567
+ if (e.statusCode === 404) {
1568
+ return null
1569
+ }
1570
+ throw e
1571
+ }
1572
+ }
1573
+
1574
+ /**
1575
+ * Put bucket website configuration
1576
+ */
1577
+ async putBucketWebsite(bucket: string, config: {
1578
+ IndexDocument: string
1579
+ ErrorDocument?: string
1580
+ RedirectAllRequestsTo?: { HostName: string; Protocol?: string }
1581
+ }): Promise<void> {
1582
+ let configXml = ''
1583
+
1584
+ if (config.RedirectAllRequestsTo) {
1585
+ configXml = `<RedirectAllRequestsTo>
1586
+ <HostName>${config.RedirectAllRequestsTo.HostName}</HostName>
1587
+ ${config.RedirectAllRequestsTo.Protocol ? `<Protocol>${config.RedirectAllRequestsTo.Protocol}</Protocol>` : ''}
1588
+ </RedirectAllRequestsTo>`
1589
+ } else {
1590
+ configXml = `<IndexDocument><Suffix>${config.IndexDocument}</Suffix></IndexDocument>`
1591
+ if (config.ErrorDocument) {
1592
+ configXml += `<ErrorDocument><Key>${config.ErrorDocument}</Key></ErrorDocument>`
1593
+ }
1594
+ }
1595
+
1596
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1597
+ <WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1598
+ ${configXml}
1599
+ </WebsiteConfiguration>`
1600
+
1601
+ await this.client.request({
1602
+ service: 's3',
1603
+ region: this.region,
1604
+ method: 'PUT',
1605
+ path: `/${bucket}`,
1606
+ queryParams: { website: '' },
1607
+ headers: { 'Content-Type': 'application/xml' },
1608
+ body,
1609
+ })
1610
+ }
1611
+
1612
+ /**
1613
+ * Delete bucket website configuration
1614
+ */
1615
+ async deleteBucketWebsite(bucket: string): Promise<void> {
1616
+ await this.client.request({
1617
+ service: 's3',
1618
+ region: this.region,
1619
+ method: 'DELETE',
1620
+ path: `/${bucket}`,
1621
+ queryParams: { website: '' },
1622
+ })
1623
+ }
1624
+
1625
+ /**
1626
+ * Get bucket replication configuration
1627
+ */
1628
+ async getBucketReplication(bucket: string): Promise<any> {
1629
+ try {
1630
+ const result = await this.client.request({
1631
+ service: 's3',
1632
+ region: this.region,
1633
+ method: 'GET',
1634
+ path: `/${bucket}`,
1635
+ queryParams: { replication: '' },
1636
+ })
1637
+ return result?.ReplicationConfiguration
1638
+ } catch (e: any) {
1639
+ if (e.statusCode === 404) {
1640
+ return null
1641
+ }
1642
+ throw e
1643
+ }
1644
+ }
1645
+
1646
+ /**
1647
+ * Delete bucket replication configuration
1648
+ */
1649
+ async deleteBucketReplication(bucket: string): Promise<void> {
1650
+ await this.client.request({
1651
+ service: 's3',
1652
+ region: this.region,
1653
+ method: 'DELETE',
1654
+ path: `/${bucket}`,
1655
+ queryParams: { replication: '' },
1656
+ })
1657
+ }
1658
+
1659
+ /**
1660
+ * Get public access block configuration
1661
+ */
1662
+ async getPublicAccessBlock(bucket: string): Promise<any> {
1663
+ try {
1664
+ const result = await this.client.request({
1665
+ service: 's3',
1666
+ region: this.region,
1667
+ method: 'GET',
1668
+ path: `/${bucket}`,
1669
+ queryParams: { publicAccessBlock: '' },
1670
+ })
1671
+ return result?.PublicAccessBlockConfiguration
1672
+ } catch (e: any) {
1673
+ if (e.statusCode === 404) {
1674
+ return null
1675
+ }
1676
+ throw e
1677
+ }
1678
+ }
1679
+
1680
+ /**
1681
+ * Put public access block configuration
1682
+ */
1683
+ async putPublicAccessBlock(bucket: string, config: {
1684
+ BlockPublicAcls?: boolean
1685
+ IgnorePublicAcls?: boolean
1686
+ BlockPublicPolicy?: boolean
1687
+ RestrictPublicBuckets?: boolean
1688
+ }): Promise<void> {
1689
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1690
+ <PublicAccessBlockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1691
+ <BlockPublicAcls>${config.BlockPublicAcls ?? true}</BlockPublicAcls>
1692
+ <IgnorePublicAcls>${config.IgnorePublicAcls ?? true}</IgnorePublicAcls>
1693
+ <BlockPublicPolicy>${config.BlockPublicPolicy ?? true}</BlockPublicPolicy>
1694
+ <RestrictPublicBuckets>${config.RestrictPublicBuckets ?? true}</RestrictPublicBuckets>
1695
+ </PublicAccessBlockConfiguration>`
1696
+
1697
+ await this.client.request({
1698
+ service: 's3',
1699
+ region: this.region,
1700
+ method: 'PUT',
1701
+ path: `/${bucket}`,
1702
+ queryParams: { publicAccessBlock: '' },
1703
+ headers: { 'Content-Type': 'application/xml' },
1704
+ body,
1705
+ })
1706
+ }
1707
+
1708
+ /**
1709
+ * Delete public access block configuration
1710
+ */
1711
+ async deletePublicAccessBlock(bucket: string): Promise<void> {
1712
+ await this.client.request({
1713
+ service: 's3',
1714
+ region: this.region,
1715
+ method: 'DELETE',
1716
+ path: `/${bucket}`,
1717
+ queryParams: { publicAccessBlock: '' },
1718
+ })
1719
+ }
1720
+
1721
+ /**
1722
+ * Generate a presigned URL for GET
1723
+ */
1724
+ generatePresignedGetUrl(bucket: string, key: string, expiresInSeconds: number = 3600): string {
1725
+ const { accessKeyId, secretAccessKey } = this.getCredentials()
1726
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
1727
+ const now = new Date()
1728
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
1729
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
1730
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
1731
+ const credential = `${accessKeyId}/${credentialScope}`
1732
+
1733
+ const queryParams = new URLSearchParams({
1734
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
1735
+ 'X-Amz-Credential': credential,
1736
+ 'X-Amz-Date': amzDate,
1737
+ 'X-Amz-Expires': expiresInSeconds.toString(),
1738
+ 'X-Amz-SignedHeaders': 'host',
1739
+ })
1740
+
1741
+ const canonicalRequest = [
1742
+ 'GET',
1743
+ `/${key}`,
1744
+ queryParams.toString(),
1745
+ `host:${host}\n`,
1746
+ 'host',
1747
+ 'UNSIGNED-PAYLOAD',
1748
+ ].join('\n')
1749
+
1750
+ const stringToSign = [
1751
+ 'AWS4-HMAC-SHA256',
1752
+ amzDate,
1753
+ credentialScope,
1754
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
1755
+ ].join('\n')
1756
+
1757
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
1758
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
1759
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
1760
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
1761
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
1762
+
1763
+ queryParams.append('X-Amz-Signature', signature)
1764
+
1765
+ return `https://${host}/${key}?${queryParams.toString()}`
1766
+ }
1767
+
1768
+ /**
1769
+ * Generate a presigned URL for PUT
1770
+ */
1771
+ generatePresignedPutUrl(bucket: string, key: string, contentType: string, expiresInSeconds: number = 3600): string {
1772
+ const { accessKeyId, secretAccessKey } = this.getCredentials()
1773
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
1774
+ const now = new Date()
1775
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
1776
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
1777
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
1778
+ const credential = `${accessKeyId}/${credentialScope}`
1779
+
1780
+ const queryParams = new URLSearchParams({
1781
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
1782
+ 'X-Amz-Credential': credential,
1783
+ 'X-Amz-Date': amzDate,
1784
+ 'X-Amz-Expires': expiresInSeconds.toString(),
1785
+ 'X-Amz-SignedHeaders': 'content-type;host',
1786
+ })
1787
+
1788
+ const canonicalRequest = [
1789
+ 'PUT',
1790
+ `/${key}`,
1791
+ queryParams.toString(),
1792
+ `content-type:${contentType}\nhost:${host}\n`,
1793
+ 'content-type;host',
1794
+ 'UNSIGNED-PAYLOAD',
1795
+ ].join('\n')
1796
+
1797
+ const stringToSign = [
1798
+ 'AWS4-HMAC-SHA256',
1799
+ amzDate,
1800
+ credentialScope,
1801
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
1802
+ ].join('\n')
1803
+
1804
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
1805
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
1806
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
1807
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
1808
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
1809
+
1810
+ queryParams.append('X-Amz-Signature', signature)
1811
+
1812
+ return `https://${host}/${key}?${queryParams.toString()}`
1813
+ }
1814
+
1815
+ /**
1816
+ * Initiate multipart upload
1817
+ */
1818
+ async createMultipartUpload(bucket: string, key: string, options?: {
1819
+ contentType?: string
1820
+ metadata?: Record<string, string>
1821
+ }): Promise<{ UploadId: string }> {
1822
+ const headers: Record<string, string> = {}
1823
+ if (options?.contentType) {
1824
+ headers['Content-Type'] = options.contentType
1825
+ }
1826
+ if (options?.metadata) {
1827
+ for (const [k, v] of Object.entries(options.metadata)) {
1828
+ headers[`x-amz-meta-${k}`] = v
1829
+ }
1830
+ }
1831
+
1832
+ const result = await this.client.request({
1833
+ service: 's3',
1834
+ region: this.region,
1835
+ method: 'POST',
1836
+ path: `/${bucket}/${key}`,
1837
+ queryParams: { uploads: '' },
1838
+ headers,
1839
+ })
1840
+
1841
+ return { UploadId: result?.InitiateMultipartUploadResult?.UploadId }
1842
+ }
1843
+
1844
+ /**
1845
+ * Upload a part in multipart upload
1846
+ */
1847
+ async uploadPart(bucket: string, key: string, uploadId: string, partNumber: number, body: Uint8Array | Buffer): Promise<{ ETag: string }> {
1848
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
1849
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
1850
+ const url = `https://${host}/${key}?partNumber=${partNumber}&uploadId=${encodeURIComponent(uploadId)}`
1851
+
1852
+ const now = new Date()
1853
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
1854
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
1855
+
1856
+ const payloadHash = crypto.createHash('sha256').update(body).digest('hex')
1857
+
1858
+ const requestHeaders: Record<string, string> = {
1859
+ 'host': host,
1860
+ 'x-amz-date': amzDate,
1861
+ 'x-amz-content-sha256': payloadHash,
1862
+ }
1863
+
1864
+ if (sessionToken) {
1865
+ requestHeaders['x-amz-security-token'] = sessionToken
1866
+ }
1867
+
1868
+ const canonicalHeaders = Object.keys(requestHeaders)
1869
+ .sort()
1870
+ .map(k => `${k.toLowerCase()}:${requestHeaders[k].trim()}\n`)
1871
+ .join('')
1872
+
1873
+ const signedHeaders = Object.keys(requestHeaders)
1874
+ .sort()
1875
+ .map(k => k.toLowerCase())
1876
+ .join(';')
1877
+
1878
+ const canonicalRequest = [
1879
+ 'PUT',
1880
+ `/${key}`,
1881
+ `partNumber=${partNumber}&uploadId=${encodeURIComponent(uploadId)}`,
1882
+ canonicalHeaders,
1883
+ signedHeaders,
1884
+ payloadHash,
1885
+ ].join('\n')
1886
+
1887
+ const algorithm = 'AWS4-HMAC-SHA256'
1888
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
1889
+ const stringToSign = [
1890
+ algorithm,
1891
+ amzDate,
1892
+ credentialScope,
1893
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
1894
+ ].join('\n')
1895
+
1896
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
1897
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
1898
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
1899
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
1900
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
1901
+
1902
+ const authHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
1903
+
1904
+ const response = await fetch(url, {
1905
+ method: 'PUT',
1906
+ headers: {
1907
+ ...requestHeaders,
1908
+ 'Authorization': authHeader,
1909
+ },
1910
+ body: toFetchBody(body),
1911
+ })
1912
+
1913
+ if (!response.ok) {
1914
+ const text = await response.text()
1915
+ throw new Error(`Upload part failed: ${response.status} ${text}`)
1916
+ }
1917
+
1918
+ return { ETag: response.headers.get('etag') || '' }
1919
+ }
1920
+
1921
+ /**
1922
+ * Complete multipart upload
1923
+ */
1924
+ async completeMultipartUpload(bucket: string, key: string, uploadId: string, parts: Array<{ PartNumber: number; ETag: string }>): Promise<void> {
1925
+ const partsXml = parts
1926
+ .sort((a, b) => a.PartNumber - b.PartNumber)
1927
+ .map(p => `<Part><PartNumber>${p.PartNumber}</PartNumber><ETag>${p.ETag}</ETag></Part>`)
1928
+ .join('')
1929
+
1930
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1931
+ <CompleteMultipartUpload>${partsXml}</CompleteMultipartUpload>`
1932
+
1933
+ await this.client.request({
1934
+ service: 's3',
1935
+ region: this.region,
1936
+ method: 'POST',
1937
+ path: `/${bucket}/${key}`,
1938
+ queryParams: { uploadId },
1939
+ headers: { 'Content-Type': 'application/xml' },
1940
+ body,
1941
+ })
1942
+ }
1943
+
1944
+ /**
1945
+ * Abort multipart upload
1946
+ */
1947
+ async abortMultipartUpload(bucket: string, key: string, uploadId: string): Promise<void> {
1948
+ await this.client.request({
1949
+ service: 's3',
1950
+ region: this.region,
1951
+ method: 'DELETE',
1952
+ path: `/${bucket}/${key}`,
1953
+ queryParams: { uploadId },
1954
+ })
1955
+ }
1956
+
1957
+ /**
1958
+ * List multipart uploads
1959
+ */
1960
+ async listMultipartUploads(bucket: string): Promise<Array<{ Key: string; UploadId: string; Initiated: string }>> {
1961
+ const result = await this.client.request({
1962
+ service: 's3',
1963
+ region: this.region,
1964
+ method: 'GET',
1965
+ path: `/${bucket}`,
1966
+ queryParams: { uploads: '' },
1967
+ })
1968
+
1969
+ const uploads = result?.ListMultipartUploadsResult?.Upload
1970
+ if (!uploads) return []
1971
+ const list = Array.isArray(uploads) ? uploads : [uploads]
1972
+ return list.map((u: any) => ({
1973
+ Key: u.Key,
1974
+ UploadId: u.UploadId,
1975
+ Initiated: u.Initiated,
1976
+ }))
1977
+ }
1978
+
1979
+ /**
1980
+ * Restore object from Glacier
1981
+ */
1982
+ async restoreObject(bucket: string, key: string, days: number, tier: 'Standard' | 'Bulk' | 'Expedited' = 'Standard'): Promise<void> {
1983
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1984
+ <RestoreRequest>
1985
+ <Days>${days}</Days>
1986
+ <GlacierJobParameters>
1987
+ <Tier>${tier}</Tier>
1988
+ </GlacierJobParameters>
1989
+ </RestoreRequest>`
1990
+
1991
+ await this.client.request({
1992
+ service: 's3',
1993
+ region: this.region,
1994
+ method: 'POST',
1995
+ path: `/${bucket}/${key}`,
1996
+ queryParams: { restore: '' },
1997
+ headers: { 'Content-Type': 'application/xml' },
1998
+ body,
1999
+ })
2000
+ }
2001
+
2002
+ /**
2003
+ * Select object content (S3 Select)
2004
+ */
2005
+ async selectObjectContent(bucket: string, key: string, expression: string, inputFormat: 'CSV' | 'JSON' | 'Parquet', outputFormat: 'CSV' | 'JSON' = 'JSON'): Promise<string> {
2006
+ let inputSerialization = ''
2007
+ if (inputFormat === 'CSV') {
2008
+ inputSerialization = '<CSV><FileHeaderInfo>USE</FileHeaderInfo></CSV>'
2009
+ } else if (inputFormat === 'JSON') {
2010
+ inputSerialization = '<JSON><Type>DOCUMENT</Type></JSON>'
2011
+ } else {
2012
+ inputSerialization = '<Parquet/>'
2013
+ }
2014
+
2015
+ let outputSerialization = ''
2016
+ if (outputFormat === 'CSV') {
2017
+ outputSerialization = '<CSV/>'
2018
+ } else {
2019
+ outputSerialization = '<JSON/>'
2020
+ }
2021
+
2022
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
2023
+ <SelectObjectContentRequest>
2024
+ <Expression>${expression}</Expression>
2025
+ <ExpressionType>SQL</ExpressionType>
2026
+ <InputSerialization>${inputSerialization}</InputSerialization>
2027
+ <OutputSerialization>${outputSerialization}</OutputSerialization>
2028
+ </SelectObjectContentRequest>`
2029
+
2030
+ const result = await this.client.request({
2031
+ service: 's3',
2032
+ region: this.region,
2033
+ method: 'POST',
2034
+ path: `/${bucket}/${key}`,
2035
+ queryParams: { select: '', 'select-type': '2' },
2036
+ headers: { 'Content-Type': 'application/xml' },
2037
+ body,
2038
+ rawResponse: true,
2039
+ })
2040
+
2041
+ return result
2042
+ }
2043
+
2044
+ /**
2045
+ * Generate a presigned URL for S3 object access
2046
+ * Allows temporary access to private objects without authentication
2047
+ */
2048
+ async getSignedUrl(options: {
2049
+ bucket: string
2050
+ key: string
2051
+ expiresIn?: number
2052
+ operation?: 'getObject' | 'putObject'
2053
+ }): Promise<string> {
2054
+ const { bucket, key, expiresIn = 3600, operation = 'getObject' } = options
2055
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
2056
+
2057
+ const now = new Date()
2058
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
2059
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
2060
+
2061
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
2062
+ const method = operation === 'putObject' ? 'PUT' : 'GET'
2063
+ const algorithm = 'AWS4-HMAC-SHA256'
2064
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
2065
+ const credential = `${accessKeyId}/${credentialScope}`
2066
+
2067
+ // Build query parameters
2068
+ const queryParams: Record<string, string> = {
2069
+ 'X-Amz-Algorithm': algorithm,
2070
+ 'X-Amz-Credential': credential,
2071
+ 'X-Amz-Date': amzDate,
2072
+ 'X-Amz-Expires': expiresIn.toString(),
2073
+ 'X-Amz-SignedHeaders': 'host',
2074
+ }
2075
+
2076
+ if (sessionToken) {
2077
+ queryParams['X-Amz-Security-Token'] = sessionToken
2078
+ }
2079
+
2080
+ // Sort and encode query string
2081
+ const sortedParams = Object.keys(queryParams).sort()
2082
+ const canonicalQuerystring = sortedParams
2083
+ .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`)
2084
+ .join('&')
2085
+
2086
+ // Canonical request
2087
+ const canonicalUri = '/' + key
2088
+ const canonicalHeaders = `host:${host}\n`
2089
+ const signedHeaders = 'host'
2090
+ const payloadHash = 'UNSIGNED-PAYLOAD'
2091
+
2092
+ const canonicalRequest = [
2093
+ method,
2094
+ canonicalUri,
2095
+ canonicalQuerystring,
2096
+ canonicalHeaders,
2097
+ signedHeaders,
2098
+ payloadHash,
2099
+ ].join('\n')
2100
+
2101
+ // String to sign
2102
+ const stringToSign = [
2103
+ algorithm,
2104
+ amzDate,
2105
+ credentialScope,
2106
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
2107
+ ].join('\n')
2108
+
2109
+ // Calculate signature
2110
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
2111
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
2112
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
2113
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
2114
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
2115
+
2116
+ // Build presigned URL
2117
+ const presignedUrl = `https://${host}${canonicalUri}?${canonicalQuerystring}&X-Amz-Signature=${signature}`
2118
+
2119
+ return presignedUrl
2120
+ }
2121
+
2122
+ /**
2123
+ * List objects in a bucket with pagination support
2124
+ */
2125
+ async listObjects(options: {
2126
+ bucket: string
2127
+ prefix?: string
2128
+ maxKeys?: number
2129
+ continuationToken?: string
2130
+ }): Promise<{
2131
+ objects: S3Object[]
2132
+ nextContinuationToken?: string
2133
+ }> {
2134
+ const { bucket, prefix, maxKeys = 1000, continuationToken } = options
2135
+
2136
+ // Build query parameters for ListObjectsV2
2137
+ const queryParams: Record<string, string> = {
2138
+ 'list-type': '2',
2139
+ 'max-keys': maxKeys.toString(),
2140
+ }
2141
+
2142
+ if (prefix) queryParams.prefix = prefix
2143
+ if (continuationToken) queryParams['continuation-token'] = continuationToken
2144
+
2145
+ const result = await this.client.request({
2146
+ service: 's3',
2147
+ region: this.region,
2148
+ method: 'GET',
2149
+ path: `/${bucket}`,
2150
+ queryParams,
2151
+ })
2152
+
2153
+ // Parse S3 XML response
2154
+ const objects: S3Object[] = []
2155
+ const listResult = result?.ListBucketResult
2156
+
2157
+ if (listResult?.Contents) {
2158
+ const items = Array.isArray(listResult.Contents) ? listResult.Contents : [listResult.Contents]
2159
+ for (const item of items) {
2160
+ objects.push({
2161
+ Key: item.Key || '',
2162
+ LastModified: item.LastModified || '',
2163
+ Size: Number.parseInt(item.Size || '0'),
2164
+ ETag: item.ETag,
2165
+ })
2166
+ }
2167
+ }
2168
+
2169
+ return {
2170
+ objects,
2171
+ nextContinuationToken: listResult?.NextContinuationToken,
2172
+ }
2173
+ }
2174
+
2175
+ /**
2176
+ * Empty a bucket by deleting all objects (required before bucket deletion)
2177
+ */
2178
+ async emptyBucket(bucket: string): Promise<{ deletedCount: number }> {
2179
+ let deletedCount = 0
2180
+
2181
+ // List all objects in the bucket (handles pagination internally)
2182
+ const objects = await this.listAllObjects({ bucket })
2183
+
2184
+ if (objects.length > 0) {
2185
+ // Delete objects in batches of 1000
2186
+ for (let i = 0; i < objects.length; i += 1000) {
2187
+ const batch = objects.slice(i, i + 1000)
2188
+ const keys = batch.map((obj: S3Object) => obj.Key)
2189
+ await this.deleteObjects(bucket, keys)
2190
+ deletedCount += keys.length
2191
+ }
2192
+ }
2193
+
2194
+ // Also delete any object versions if versioning is enabled
2195
+ try {
2196
+ let keyMarker: string | undefined
2197
+ let versionIdMarker: string | undefined
2198
+
2199
+ do {
2200
+ const versionsResult = await this.listObjectVersions({
2201
+ bucket,
2202
+ keyMarker,
2203
+ versionIdMarker,
2204
+ maxKeys: 1000,
2205
+ })
2206
+
2207
+ const versionsToDelete: Array<{ Key: string; VersionId?: string }> = []
2208
+
2209
+ if (versionsResult.versions) {
2210
+ for (const version of versionsResult.versions) {
2211
+ versionsToDelete.push({ Key: version.Key, VersionId: version.VersionId })
2212
+ }
2213
+ }
2214
+
2215
+ if (versionsResult.deleteMarkers) {
2216
+ for (const marker of versionsResult.deleteMarkers) {
2217
+ versionsToDelete.push({ Key: marker.Key, VersionId: marker.VersionId })
2218
+ }
2219
+ }
2220
+
2221
+ if (versionsToDelete.length > 0) {
2222
+ await this.deleteObjectVersions(bucket, versionsToDelete)
2223
+ deletedCount += versionsToDelete.length
2224
+ }
2225
+
2226
+ keyMarker = versionsResult.nextKeyMarker
2227
+ versionIdMarker = versionsResult.nextVersionIdMarker
2228
+ } while (keyMarker)
2229
+ }
2230
+ catch {
2231
+ // Versioning might not be enabled, ignore errors
2232
+ }
2233
+
2234
+ return { deletedCount }
2235
+ }
2236
+
2237
+ /**
2238
+ * List object versions in a bucket
2239
+ */
2240
+ async listObjectVersions(options: {
2241
+ bucket: string
2242
+ prefix?: string
2243
+ keyMarker?: string
2244
+ versionIdMarker?: string
2245
+ maxKeys?: number
2246
+ }): Promise<{
2247
+ versions: Array<{ Key: string; VersionId: string; IsLatest: boolean }>
2248
+ deleteMarkers: Array<{ Key: string; VersionId: string; IsLatest: boolean }>
2249
+ nextKeyMarker?: string
2250
+ nextVersionIdMarker?: string
2251
+ }> {
2252
+ const { bucket, prefix, keyMarker, versionIdMarker, maxKeys = 1000 } = options
2253
+
2254
+ const queryParams: Record<string, string> = {
2255
+ versions: '',
2256
+ 'max-keys': maxKeys.toString(),
2257
+ }
2258
+
2259
+ if (prefix) queryParams.prefix = prefix
2260
+ if (keyMarker) queryParams['key-marker'] = keyMarker
2261
+ if (versionIdMarker) queryParams['version-id-marker'] = versionIdMarker
2262
+
2263
+ const result = await this.client.request({
2264
+ service: 's3',
2265
+ region: this.region,
2266
+ method: 'GET',
2267
+ path: `/${bucket}`,
2268
+ queryParams,
2269
+ })
2270
+
2271
+ const versions: Array<{ Key: string; VersionId: string; IsLatest: boolean }> = []
2272
+ const deleteMarkers: Array<{ Key: string; VersionId: string; IsLatest: boolean }> = []
2273
+
2274
+ // Parse versions
2275
+ if (result.Version) {
2276
+ const versionList = Array.isArray(result.Version) ? result.Version : [result.Version]
2277
+ for (const v of versionList) {
2278
+ versions.push({
2279
+ Key: v.Key,
2280
+ VersionId: v.VersionId,
2281
+ IsLatest: v.IsLatest === 'true',
2282
+ })
2283
+ }
2284
+ }
2285
+
2286
+ // Parse delete markers
2287
+ if (result.DeleteMarker) {
2288
+ const markerList = Array.isArray(result.DeleteMarker) ? result.DeleteMarker : [result.DeleteMarker]
2289
+ for (const m of markerList) {
2290
+ deleteMarkers.push({
2291
+ Key: m.Key,
2292
+ VersionId: m.VersionId,
2293
+ IsLatest: m.IsLatest === 'true',
2294
+ })
2295
+ }
2296
+ }
2297
+
2298
+ return {
2299
+ versions,
2300
+ deleteMarkers,
2301
+ nextKeyMarker: result.NextKeyMarker,
2302
+ nextVersionIdMarker: result.NextVersionIdMarker,
2303
+ }
2304
+ }
2305
+
2306
+ /**
2307
+ * Delete specific object versions
2308
+ */
2309
+ async deleteObjectVersions(
2310
+ bucket: string,
2311
+ objects: Array<{ Key: string; VersionId?: string }>,
2312
+ ): Promise<void> {
2313
+ const deleteXml = `<?xml version="1.0" encoding="UTF-8"?>
2314
+ <Delete>
2315
+ <Quiet>true</Quiet>
2316
+ ${objects.map(obj => `<Object><Key>${obj.Key}</Key>${obj.VersionId ? `<VersionId>${obj.VersionId}</VersionId>` : ''}</Object>`).join('\n ')}
2317
+ </Delete>`
2318
+
2319
+ const contentMd5 = crypto.createHash('md5').update(deleteXml).digest('base64')
2320
+
2321
+ await this.client.request({
2322
+ service: 's3',
2323
+ region: this.region,
2324
+ method: 'POST',
2325
+ path: `/${bucket}`,
2326
+ queryParams: { delete: '' },
2327
+ body: deleteXml,
2328
+ headers: {
2329
+ 'Content-Type': 'application/xml',
2330
+ 'Content-MD5': contentMd5,
2331
+ },
2332
+ })
2333
+ }
2334
+ }