From a195c7334b8983245a0d6142d1fdf121b87cc125 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 8 Jul 2026 11:41:53 +0900 Subject: [PATCH] sotre profile avatars in R2.. --- package-lock.json | 396 ++++++++++++++++++++++++ package.json | 1 + src/curriculum/curriculum.controller.ts | 2 + src/curriculum/curriculum.module.ts | 3 +- src/curriculum/curriculum.service.ts | 15 +- src/instructor/instructor.module.ts | 3 +- src/instructor/instructor.service.ts | 35 ++- src/storage/r2-public-file.service.ts | 127 ++++++++ 8 files changed, 574 insertions(+), 8 deletions(-) create mode 100644 src/storage/r2-public-file.service.ts diff --git a/package-lock.json b/package-lock.json index becaade..91c6bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { + "@aws-sdk/client-s3": "^3.1081.0", "@nestjs/common": "^11.1.24", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", @@ -168,6 +169,314 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.14.tgz", + "integrity": "sha512-9j0TSNmYVywV+shhBXXkIhaazZwZ+nDSGfG6V47nFLqXdk6mPYk/OJkV4+uRvIg2Ju5F6HTvgM/sfsuTsbAYwg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1081.0.tgz", + "integrity": "sha512-mHAdLBibsbFwcW2YHrH5sIQlaXjB3zha7Nkooa2gqTlXxRpVJ+ba/v/mg19B6rJeZmOEteRHFz+INOlAbCbxJg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.14", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/middleware-sdk-s3": "^3.972.60", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.29.tgz", + "integrity": "sha512-yqKcltLbtRh1ubzhRSldIs8jFHNZlyMlgoIccCC0aDVbrB99nXaBdmfr89mK7obWX/NVg4rAMpCpZ6dCDiVBtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@aws-sdk/xml-builder": "^3.972.33", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.0", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.55.tgz", + "integrity": "sha512-Ah36tYkqyaVnaHkx7VseoTYrHUmwgBps3V+wnrC1idhIIMGlviH0FtrX9EIPdAlVHvXC7FQZLhmHBRz+pLaiWg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.57.tgz", + "integrity": "sha512-/vp6i5YEliJqRm5k/BDmYjAyRAMTdkjW6UciVRk9oh/0OfDCWeb/ih7hqte4lFvKXkIbsqe9AdK9LQK6NGardw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.62.tgz", + "integrity": "sha512-pQIRiQQs+MUlVnJdWJ7/6KS0WxcLRVfut57OFgwC3cnM1F8mXw3Kh4gAVwj6AtvD6CWx8x6+po4ENRcqe64XrQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-env": "^3.972.55", + "@aws-sdk/credential-provider-http": "^3.972.57", + "@aws-sdk/credential-provider-login": "^3.972.61", + "@aws-sdk/credential-provider-process": "^3.972.55", + "@aws-sdk/credential-provider-sso": "^3.972.61", + "@aws-sdk/credential-provider-web-identity": "^3.972.61", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.61.tgz", + "integrity": "sha512-jtrxWwC7slqxh7DnAWHrwsA3UwCsnlypdYtavGT7EX5p791wxWQys7QzkCZ7JvOMAyylDtPoxyV+ic0zg3rV9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.64.tgz", + "integrity": "sha512-zyKVYDyMR9VQL/kPi03ygN2vtD9uLMuWRLoJ77KxgZZaS1VlJloI+SzleF9Zg4HWUI+AIu+ZRs8zsJFNqbrxsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.55", + "@aws-sdk/credential-provider-http": "^3.972.57", + "@aws-sdk/credential-provider-ini": "^3.972.62", + "@aws-sdk/credential-provider-process": "^3.972.55", + "@aws-sdk/credential-provider-sso": "^3.972.61", + "@aws-sdk/credential-provider-web-identity": "^3.972.61", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.55.tgz", + "integrity": "sha512-x0XjjF0l1WGRtK2vEhTZqCguQuAIZLep9l2+eeEmuxQQjjD3BlGQXY5xADR+l3t576UX+dxRkRtTjEu40l81Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.61.tgz", + "integrity": "sha512-d/V0VRsz73i+PHhbult/tx0Y1+de1SNQVsXkcQCmpfeBq7uODy/RTxNsOLpT9ZVHxcRNzbQFuywLKC33fUMIxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/token-providers": "3.1081.0", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.61.tgz", + "integrity": "sha512-Bv4n3NOI6hPy+rmr6Bw9R6LnBVRkcp3ncj2E2IKSYJG+0UkysSitWMvbgndNvMxDw7gE1pQ/ErwkNceuKwj7zQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.60.tgz", + "integrity": "sha512-f8SREKL6uQUYt8fwreiUgaAJQ31/M+UkWfJMDY/TBJC5FdHgNzxuvRFeZ9eMbnf2ydQ3p4pcKEhaLk35DDIPxw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.29.tgz", + "integrity": "sha512-ot6v8J5W8P0w6ryyuIkXP1bHZHTlvwtn83mVCYaBE0GJ6tJX4vPSBx7M98w9O4wmmDruFsDBUMjhEHA+OosUFQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", + "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1081.0.tgz", + "integrity": "sha512-kduAeI6cL+zqwj3gjPh9LhuX7kBZ83msYxutavaR+UPm5K8J7iThJBvNRAsFNyWTji92CSU8dogUgvi9T0BehA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", + "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", + "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2605,6 +2914,87 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.1.tgz", + "integrity": "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz", + "integrity": "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz", + "integrity": "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.3.tgz", + "integrity": "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", + "integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", + "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -4172,6 +4562,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", diff --git a/package.json b/package.json index 2d68310..e501270 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:cov": "jest --coverage" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1081.0", "@nestjs/common": "^11.1.24", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", diff --git a/src/curriculum/curriculum.controller.ts b/src/curriculum/curriculum.controller.ts index 57c5f4a..2d0168b 100644 --- a/src/curriculum/curriculum.controller.ts +++ b/src/curriculum/curriculum.controller.ts @@ -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); } diff --git a/src/curriculum/curriculum.module.ts b/src/curriculum/curriculum.module.ts index 20ca617..425f121 100644 --- a/src/curriculum/curriculum.module.ts +++ b/src/curriculum/curriculum.module.ts @@ -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 {} diff --git a/src/curriculum/curriculum.service.ts b/src/curriculum/curriculum.service.ts index cedfb17..2c2dd5f 100644 --- a/src/curriculum/curriculum.service.ts +++ b/src/curriculum/curriculum.service.ts @@ -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(), }; } diff --git a/src/instructor/instructor.module.ts b/src/instructor/instructor.module.ts index 25674bc..1cbdf7c 100644 --- a/src/instructor/instructor.module.ts +++ b/src/instructor/instructor.module.ts @@ -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 {} diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index 4f0871d..d056b78 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -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(), }; } diff --git a/src/storage/r2-public-file.service.ts b/src/storage/r2-public-file.service.ts new file mode 100644 index 0000000..233ca74 --- /dev/null +++ b/src/storage/r2-public-file.service.ts @@ -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('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)}`; +}