128 lines
3.5 KiB
TypeScript
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)}`;
|
|
}
|