Files
lms-be/src/storage/r2-public-file.service.ts
T
2026-07-08 11:41:53 +09:00

128 lines
3.5 KiB
TypeScript

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<string>('R2_ENDPOINT')
|| `https://${this.configService.get<string>('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`;
this.client = new S3Client({
region: this.configService.get<string>('R2_REGION', 'auto'),
endpoint,
credentials: {
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey,
},
forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false',
});
return this.client;
}
private get bucket() {
return this.configService.get<string>('R2_BUCKET')?.trim() ?? '';
}
private get publicBaseUrl() {
return this.configService.get<string>('R2_PUBLIC_BASE_URL')?.trim() ?? '';
}
private get accessKeyId() {
return this.configService.get<string>('R2_ACCESS_KEY_ID')?.trim() ?? '';
}
private get secretAccessKey() {
return this.configService.get<string>('R2_SECRET_ACCESS_KEY')?.trim() ?? '';
}
private get publicPrefix() {
return this.configService.get<string>('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)}`;
}