import { createHash } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import type { StoragePort } from './storage.port'; export interface S3Config { endpoint?: string; // MinIO/self-hosted URL; omit for AWS region: string; bucket: string; accessKeyId: string; secretAccessKey: string; forcePathStyle: boolean; // true for MinIO } /** The one method this adapter uses — lets tests inject a stub client (no live S3/MinIO). */ export interface S3Like { send(command: unknown): Promise; } /** Build an S3Config from env, or null if the required bits are missing (→ fall back to disk). */ export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null { const bucket = env.IIOS_S3_BUCKET; const accessKeyId = env.IIOS_S3_ACCESS_KEY; const secretAccessKey = env.IIOS_S3_SECRET_KEY; if (!bucket || !accessKeyId || !secretAccessKey) return null; return { ...(env.IIOS_S3_ENDPOINT ? { endpoint: env.IIOS_S3_ENDPOINT } : {}), region: env.IIOS_S3_REGION ?? 'us-east-1', bucket, accessKeyId, secretAccessKey, // MinIO/self-hosted needs path-style; default true unless explicitly disabled for AWS. forcePathStyle: env.IIOS_S3_FORCE_PATH_STYLE !== 'false', }; } /** * S3-compatible object storage (AWS S3, MinIO, R2, Supabase Storage). A drop-in for LocalDiskStorage: * same StoragePort contract. sha256 is computed locally on put (S3 doesn't return it), matching the * disk adapter. A missing object reads back as null (not an error). */ @Injectable() export class S3Storage implements StoragePort { private readonly client: S3Like; private readonly bucket: string; constructor(config: S3Config, client?: S3Like) { this.bucket = config.bucket; this.client = client ?? new S3Client({ region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), forcePathStyle: config.forcePathStyle, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }, }); } async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> { const checksumSha256 = createHash('sha256').update(data).digest('hex'); await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, Body: data, ContentType: mime })); return { sizeBytes: data.length, checksumSha256 }; } async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> { try { const res = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }))) as { Body?: { transformToByteArray(): Promise }; ContentType?: string; }; if (!res.Body) return null; const bytes = await res.Body.transformToByteArray(); const data = Buffer.from(bytes); return { data, mime: res.ContentType ?? 'application/octet-stream', sizeBytes: data.length }; } catch (err) { if (isNotFound(err)) return null; throw err; } } async remove(objectKey: string): Promise { await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey })); } } function isNotFound(err: unknown): boolean { const e = err as { name?: string; $metadata?: { httpStatusCode?: number } }; return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404; }