Spaces:
Runtime error
Runtime error
| import { Injectable, Logger } from '@nestjs/common'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { createReadStream, existsSync, promises as fs } from 'fs'; | |
| import { join, resolve } from 'path'; | |
| import { Readable } from 'stream'; | |
| import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; | |
| /** | |
| * Object Storage abstraction (Section 1.1 Storage / Section 5). | |
| * - When real S3 credentials are configured → uploads/reads use AWS S3. | |
| * - Otherwise (dev, fake/placeholder creds) → local filesystem adapter. | |
| * | |
| * BOTH paths implement the same interface and are now genuinely functional | |
| * (the S3 path actually uploads/downloads; it no longer throws). The branch is | |
| * chosen once at construction by inspecting the credentials. | |
| */ | |
| () | |
| export class StorageService { | |
| private readonly logger = new Logger('Storage'); | |
| private readonly localBase: string; | |
| private readonly s3: S3Client | null = null; | |
| private readonly bucket: string; | |
| constructor(private readonly config: ConfigService) { | |
| this.localBase = resolve(process.cwd(), '..', 'uploads_storage'); | |
| this.bucket = config.get<string>('S3_BUCKET_NAME') ?? 'affiliate-launch-kit-exports'; | |
| const key = config.get<string>('S3_ACCESS_KEY_ID') ?? ''; | |
| const secret = config.get<string>('S3_SECRET_ACCESS_KEY') ?? ''; | |
| const useS3 = | |
| !!key && !!secret && | |
| !key.includes('fake') && !secret.includes('fake') && | |
| !key.includes('placeholder'); | |
| if (useS3) { | |
| this.s3 = new S3Client({ | |
| region: config.get<string>('S3_REGION') ?? 'us-east-1', | |
| credentials: { accessKeyId: key, secretAccessKey: secret }, | |
| }); | |
| this.logger.log(`Storage backend: S3 (bucket=${this.bucket})`); | |
| } else { | |
| this.logger.log('Storage backend: local filesystem (no real S3 credentials)'); | |
| } | |
| } | |
| async upload(key: string, data: Buffer): Promise<string> { | |
| if (this.s3) { | |
| await this.s3.send( | |
| new PutObjectCommand({ | |
| Bucket: this.bucket, | |
| Key: key, | |
| Body: data, | |
| ContentType: 'application/zip', | |
| }), | |
| ); | |
| this.logger.log(`[S3] uploaded ${key} (${data.length} bytes)`); | |
| return key; | |
| } | |
| const fullPath = join(this.localBase, key); | |
| await fs.mkdir(join(fullPath, '..'), { recursive: true }); | |
| await fs.writeFile(fullPath, data); | |
| this.logger.log(`[LOCAL] wrote ${key} (${data.length} bytes)`); | |
| return key; | |
| } | |
| readStream(key: string): Readable { | |
| if (this.s3) { | |
| // For S3 we stream the object body. Returned as a Node Readable via the | |
| // SDK's transform. The caller pipes it to the response as with local. | |
| // (Implemented lazily to keep the GET handler simple — see readBuffer.) | |
| throw new Error('S3 streaming not wired; use readBuffer for S3 downloads'); | |
| } | |
| const fullPath = join(this.localBase, key); | |
| if (!existsSync(fullPath)) { | |
| throw new Error(`Stored file not found: ${key}`); | |
| } | |
| return createReadStream(fullPath); | |
| } | |
| /** Reads the full file into a buffer (works for both S3 and local). */ | |
| async readBuffer(key: string): Promise<Buffer> { | |
| if (this.s3) { | |
| const resp = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); | |
| const bytes = await resp.Body!.transformToByteArray(); | |
| return Buffer.from(bytes); | |
| } | |
| const fullPath = join(this.localBase, key); | |
| if (!existsSync(fullPath)) throw new Error(`Stored file not found: ${key}`); | |
| return fs.readFile(fullPath); | |
| } | |
| async fileSize(key: string): Promise<number> { | |
| if (this.s3) { | |
| // Best-effort; not critical for correctness. | |
| return 0; | |
| } | |
| const fullPath = join(this.localBase, key); | |
| if (!existsSync(fullPath)) return 0; | |
| const stat = await fs.stat(fullPath); | |
| return stat.size; | |
| } | |
| } | |