import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; type UploadPublicFileInput = { key: string; body: Buffer; contentType?: string | null; fileName?: string | null; }; @Injectable() export class R2PublicFileService { private client?: S3Client; constructor(private readonly configService: ConfigService) {} isConfigured() { return Boolean( this.bucket && this.publicBaseUrl && this.accessKeyId && this.secretAccessKey, ); } createLmsCourseFileKey(input: { courseId: string; week: number; kind: 'REFERENCE' | 'EXAMPLE_CODE'; fileId: string; extension?: string; }) { const section = input.kind === 'EXAMPLE_CODE' ? 'example-codes' : 'references'; return [ this.publicPrefix, 'lms', 'courses', input.courseId, 'weeks', String(input.week), section, `${input.fileId}${input.extension ?? ''}`, ].filter(Boolean).join('/'); } async uploadPublicFile(input: UploadPublicFileInput) { if (!this.isConfigured()) return null; await this.getClient().send(new PutObjectCommand({ Bucket: this.bucket, Key: input.key, Body: input.body, ContentLength: input.body.length, ContentType: input.contentType ?? 'application/octet-stream', ContentDisposition: input.fileName ? contentDispositionForFile(input.fileName) : undefined, })); return this.publicUrl(input.key); } async deletePublicFile(key: string) { if (!this.isConfigured() || !this.isPublicKey(key)) return; await this.getClient().send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key, })); } publicUrl(key: string) { if (!this.publicBaseUrl || !this.isPublicKey(key)) return null; return `${this.publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`; } isPublicKey(value: string) { return value.startsWith(`${this.publicPrefix}/`); } private getClient() { if (this.client) return this.client; const endpoint = this.configService.get('R2_ENDPOINT') || `https://${this.configService.get('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`; this.client = new S3Client({ region: this.configService.get('R2_REGION', 'auto'), endpoint, credentials: { accessKeyId: this.accessKeyId, secretAccessKey: this.secretAccessKey, }, forcePathStyle: this.configService.get('R2_FORCE_PATH_STYLE', 'true') !== 'false', }); return this.client; } private get bucket() { return this.configService.get('R2_BUCKET')?.trim() ?? ''; } private get publicBaseUrl() { return this.configService.get('R2_PUBLIC_BASE_URL')?.trim() ?? ''; } private get accessKeyId() { return this.configService.get('R2_ACCESS_KEY_ID')?.trim() ?? ''; } private get secretAccessKey() { return this.configService.get('R2_SECRET_ACCESS_KEY')?.trim() ?? ''; } private get publicPrefix() { return this.configService.get('R2_PUBLIC_FILE_PREFIX', 'studio-files/public').replace(/^\/+|\/+$/g, ''); } } function encodeObjectKey(key: string) { return key.split('/').map(encodeURIComponent).join('/'); } function contentDispositionForFile(fileName: string) { const fallbackName = fileName.replace(/[\\\"\r\n]/g, '_'); return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`; }