sotre profile avatars in R2..

This commit is contained in:
2026-07-08 11:41:53 +09:00
parent d6f1272afc
commit a195c7334b
8 changed files with 574 additions and 8 deletions
+2
View File
@@ -30,6 +30,8 @@ export class CurriculumController {
this.getBearerToken(request),
referenceId,
);
if (download.url) return response.redirect(download.url);
response.setHeader('content-type', download.contentType);
return response.download(download.path, download.fileName);
}
+2 -1
View File
@@ -3,12 +3,13 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PlatformControlModule } from '../platform-control/platform-control.module';
import { PrismaModule } from '../prisma/prisma.module';
import { R2PublicFileService } from '../storage/r2-public-file.service';
import { CurriculumController } from './curriculum.controller';
import { CurriculumService } from './curriculum.service';
@Module({
imports: [AuthModule, PrismaModule, PlatformControlModule],
controllers: [CurriculumController],
providers: [CurriculumService],
providers: [CurriculumService, R2PublicFileService],
})
export class CurriculumModule {}
+14 -1
View File
@@ -5,6 +5,7 @@ import { join } from 'node:path';
import { AuthService } from '../auth/auth.service';
import { PlatformControlService } from '../platform-control/platform-control.service';
import { PrismaService } from '../prisma/prisma.service';
import { R2PublicFileService } from '../storage/r2-public-file.service';
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
@@ -15,6 +16,7 @@ export class CurriculumService {
private readonly authService: AuthService,
private readonly platformControlService: PlatformControlService,
private readonly prisma: PrismaService,
private readonly r2PublicFileService: R2PublicFileService,
) {}
async listCourses(accessToken?: string) {
@@ -511,6 +513,16 @@ export class CurriculumService {
throw new NotFoundException('Reference not found');
}
const publicUrl = this.r2PublicFileService.publicUrl(reference.storedFileName);
if (publicUrl) {
return {
url: publicUrl,
path: '',
fileName: reference.originalFileName,
contentType: reference.contentType ?? 'application/octet-stream',
};
}
return {
path: join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName),
fileName: reference.originalFileName,
@@ -851,6 +863,7 @@ export class CurriculumService {
kind: string;
title: string;
originalFileName: string;
storedFileName: string;
contentType: string | null;
fileSizeBytes: number;
createdAt: Date;
@@ -867,7 +880,7 @@ export class CurriculumService {
fileSizeBytes: reference.fileSizeBytes,
week,
weekNumber: week,
downloadUrl: `/curriculum/references/${reference.id}/download`,
downloadUrl: this.r2PublicFileService.publicUrl(reference.storedFileName) ?? `/curriculum/references/${reference.id}/download`,
createdAt: reference.createdAt.toISOString(),
};
}
+2 -1
View File
@@ -4,12 +4,13 @@ import { AuthModule } from '../auth/auth.module';
import { ChatModule } from '../chat/chat.module';
import { PrismaModule } from '../prisma/prisma.module';
import { PlatformGatewayClient } from '../platform-control/platform-gateway.client';
import { R2PublicFileService } from '../storage/r2-public-file.service';
import { InstructorController } from './instructor.controller';
import { InstructorService } from './instructor.service';
@Module({
imports: [AuthModule, ChatModule, PrismaModule],
controllers: [InstructorController],
providers: [InstructorService, PlatformGatewayClient],
providers: [InstructorService, PlatformGatewayClient, R2PublicFileService],
})
export class InstructorModule {}
+30 -5
View File
@@ -17,6 +17,7 @@ import { AuthService, type AuthResponseUser } from '../auth/auth.service';
import { ChatGateway } from '../chat/chat.gateway';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformGatewayClient, type GatewayAccessAssignment, type GatewayAgent } from '../platform-control/platform-gateway.client';
import { R2PublicFileService } from '../storage/r2-public-file.service';
import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto';
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
@@ -64,6 +65,7 @@ export class InstructorService {
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly platformGatewayClient: PlatformGatewayClient,
private readonly r2PublicFileService: R2PublicFileService,
) {}
async listCourses(accessToken: string | undefined) {
@@ -1534,9 +1536,27 @@ export class InstructorService {
const kind = input.kind ?? 'REFERENCE';
const originalFileName = this.normalizeUploadedFileName(file.originalname);
await mkdir(COURSE_REFERENCE_UPLOAD_ROOT, { recursive: true });
const storedFileName = `${randomUUID()}${extname(originalFileName || '')}`;
await writeFile(join(COURSE_REFERENCE_UPLOAD_ROOT, storedFileName), file.buffer);
const fileId = randomUUID();
const extension = extname(originalFileName || '');
let storedFileName = `${fileId}${extension}`;
if (this.r2PublicFileService.isConfigured()) {
storedFileName = this.r2PublicFileService.createLmsCourseFileKey({
courseId: course.id,
week: targetWeek.week,
kind,
fileId,
extension,
});
await this.r2PublicFileService.uploadPublicFile({
key: storedFileName,
body: file.buffer,
contentType: file.mimetype,
fileName: originalFileName,
});
} else {
await mkdir(COURSE_REFERENCE_UPLOAD_ROOT, { recursive: true });
await writeFile(join(COURSE_REFERENCE_UPLOAD_ROOT, storedFileName), file.buffer);
}
const maxSortOrder = await this.prisma.courseWeekReference.aggregate({
where: { weekId: targetWeek.id, kind },
@@ -1581,7 +1601,11 @@ export class InstructorService {
}
await this.prisma.courseWeekReference.delete({ where: { id: reference.id } });
await unlink(join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName)).catch(() => undefined);
if (this.r2PublicFileService.isPublicKey(reference.storedFileName)) {
await this.r2PublicFileService.deletePublicFile(reference.storedFileName).catch(() => undefined);
} else {
await unlink(join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName)).catch(() => undefined);
}
return this.getCurriculum(accessToken, null, reference.week.courseId);
}
@@ -2837,6 +2861,7 @@ export class InstructorService {
kind: string;
title: string;
originalFileName: string;
storedFileName: string;
contentType: string | null;
fileSizeBytes: number;
createdAt: Date;
@@ -2853,7 +2878,7 @@ export class InstructorService {
fileSizeBytes: reference.fileSizeBytes,
week,
weekNumber: week,
downloadUrl: `/curriculum/references/${reference.id}/download`,
downloadUrl: this.r2PublicFileService.publicUrl(reference.storedFileName) ?? `/curriculum/references/${reference.id}/download`,
createdAt: reference.createdAt.toISOString(),
};
}
+127
View File
@@ -0,0 +1,127 @@
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)}`;
}