affiliate-launch-kit / backend /src /modules /export /storage.service.ts
mohmmed5787's picture
Deploying Affiliate Launch Kit - Production Ready
d44ff09 verified
Raw
History Blame Contribute Delete
3.83 kB
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.
*/
@Injectable()
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;
}
}