From 7f32e3db303f664592be21a8bdcc2cf1071c0e4b Mon Sep 17 00:00:00 2001 From: louis rollet Date: Sun, 15 Mar 2026 21:08:35 +0800 Subject: [PATCH 1/3] [PROJECT] [UPDATE] Implement image upload functionality in project settings and public routes --- src/app.module.ts | 4 +- src/routes/project/project.controller.ts | 35 ++-- .../public/dto/image-url-response.dto.ts | 9 ++ src/routes/public/public.controller.ts | 114 +++++++++++++ src/routes/public/public.module.ts | 10 ++ src/routes/user/user.controller.ts | 2 + src/swagger.app.module.ts | 3 +- swagger.json | 151 ++++++++++++++---- 8 files changed, 283 insertions(+), 45 deletions(-) create mode 100644 src/routes/public/dto/image-url-response.dto.ts create mode 100644 src/routes/public/public.controller.ts create mode 100644 src/routes/public/public.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index c07b3fb..9ed8c7b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { ScheduleModule } from "@nestjs/schedule"; import { TasksModule } from "src/tasks/tasks.module"; import { WebRTCModule } from "@webrtc/webrtc.module"; import { MultiplayerModule } from "@multiplayer/multiplayer.module"; +import { PublicModule } from "src/routes/public/public.module"; import { AppConfig } from "src/app.config"; @Module({ @@ -24,7 +25,8 @@ import { AppConfig } from "src/app.config"; WorkSessionModule, TasksModule, WebRTCModule, - MultiplayerModule + MultiplayerModule, + PublicModule ], providers: [ AppConfig diff --git a/src/routes/project/project.controller.ts b/src/routes/project/project.controller.ts index 5cb81fd..f17e0da 100644 --- a/src/routes/project/project.controller.ts +++ b/src/routes/project/project.controller.ts @@ -53,7 +53,6 @@ import { import { S3DownloadException } from "@s3/s3.error"; import { S3Service } from "@s3/s3.service"; import { CloudfrontService } from "src/routes/s3/edge.service"; -import { SignedCdnResourceDto } from "@common/dto/signed-cdn-resource.dto"; interface RequestWithUser extends Request { user: UserDto; @@ -411,6 +410,7 @@ export class ProjectController { } @Post(":id/image") + @UseGuards(ProjectCollaboratorGuard) @UseInterceptors(FileInterceptor("file")) @ApiOperation({ summary: "Upload project image" }) @ApiConsumes("multipart/form-data") @@ -421,7 +421,7 @@ export class ProjectController { file: { type: "string", format: "binary", - description: "Project image file" + description: "Project image file (JPEG, PNG, GIF, WebP)" } } } @@ -429,12 +429,14 @@ export class ProjectController { @ApiParam({ name: "id", type: "number" }) @ApiResponse({ status: 201, description: "Image uploaded successfully" }) @ApiResponse({ status: 403, description: "Forbidden" }) + @ApiResponse({ status: 422, description: "Invalid file type or size" }) @HttpCode(HttpStatus.CREATED) async uploadProjectImage( @Param("id", ParseIntPipe) id: number, @UploadedFile( new ParseFilePipeBuilder() .addMaxSizeValidator({ maxSize: 5 * 1024 * 1024 }) + .addFileTypeValidator({ fileType: /^image\/(jpeg|png|gif|webp)$/ }) .build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY }) ) file: Express.Multer.File, @@ -452,34 +454,37 @@ export class ProjectController { originalName: file.originalname } }); + await this.s3Service.setObjectPublicRead(key); return { message: "Project image uploaded successfully", id }; } @Get(":id/image") - @ApiOperation({ summary: "Get signed CDN access to project image" }) + @UseGuards(ProjectCollaboratorGuard) + @ApiOperation({ summary: "Get CDN URL for project image (authenticated, any project status)" }) @ApiParam({ name: "id", type: "number" }) @ApiResponse({ status: 200, - description: "Signed cookies and CDN resource URL", - type: SignedCdnResourceDto + description: "CDN URL for the project image", + schema: { + type: "object", + properties: { + url: { type: "string", example: "https://cdn.example.com/projects/42/image" } + } + } }) + @ApiResponse({ status: 403, description: "Forbidden" }) @ApiResponse({ status: 404, description: "Image not found" }) async getProjectImage( - @Param("id", ParseIntPipe) id: number, - @Res() res: Response - ): Promise { + @Param("id", ParseIntPipe) id: number + ): Promise<{ url: string }> { const key = `projects/${id}/image`; const exists = await this.s3Service.fileExists(key); if (!exists) { - res.status(HttpStatus.NOT_FOUND).json({ message: "Project image not found" }); - return; + throw new HttpException("Project image not found", HttpStatus.NOT_FOUND); } - const resourceUrl = this.cloudfrontService.generateSignedUrl(key); - - res.status(HttpStatus.OK).json({ - url: resourceUrl - }); + const url = this.cloudfrontService.getCDNUrl(key); + return { url }; } @Get(":id/fetchContent") diff --git a/src/routes/public/dto/image-url-response.dto.ts b/src/routes/public/dto/image-url-response.dto.ts new file mode 100644 index 0000000..5a5e617 --- /dev/null +++ b/src/routes/public/dto/image-url-response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class ImageUrlResponseDto { + @ApiProperty({ + example: "https://cdn.example.com/projects/42/image", + description: "The public CDN URL for the image" + }) + url!: string; +} diff --git a/src/routes/public/public.controller.ts b/src/routes/public/public.controller.ts new file mode 100644 index 0000000..f7015c8 --- /dev/null +++ b/src/routes/public/public.controller.ts @@ -0,0 +1,114 @@ +import { + Controller, + Get, + HttpStatus, + NotFoundException, + Param, + ParseIntPipe +} from "@nestjs/common"; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags +} from "@nestjs/swagger"; +import { PrismaService } from "@ourPrisma/prisma.service"; +import { S3Service } from "@s3/s3.service"; +import { CloudfrontService } from "@s3/edge.service"; +import { ImageUrlResponseDto } from "./dto/image-url-response.dto"; + +@ApiTags("public") +@Controller("public") +export class PublicController { + constructor( + private readonly prismaService: PrismaService, + private readonly s3Service: S3Service, + private readonly cloudfrontService: CloudfrontService + ) {} + + @Get("projects/:id/image") + @ApiOperation({ + summary: "Get public CDN URL for a published project's image" + }) + @ApiParam({ + name: "id", + type: "number", + description: "Project ID" + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Returns the CDN URL for the project image", + type: ImageUrlResponseDto + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Project not found, not published, or has no image" + }) + async getPublishedProjectImage( + @Param("id", ParseIntPipe) id: number + ): Promise { + const project = await this.prismaService.project.findUnique({ + where: { id }, + select: { id: true, status: true } + }); + + if (!project) { + throw new NotFoundException(`Project with ID ${id} not found`); + } + + if (project.status !== "COMPLETED") { + throw new NotFoundException( + `Project with ID ${id} is not published` + ); + } + + const key = `projects/${id}/image`; + const exists = await this.s3Service.fileExists(key); + if (!exists) { + throw new NotFoundException("Project image not found"); + } + + const url = this.cloudfrontService.getCDNUrl(key); + return { url }; + } + + @Get("users/:id/profile-picture") + @ApiOperation({ + summary: "Get public CDN URL for a user's profile picture" + }) + @ApiParam({ + name: "id", + type: "number", + description: "User ID" + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Returns the CDN URL for the profile picture", + type: ImageUrlResponseDto + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "User not found or has no profile picture" + }) + async getProfilePicture( + @Param("id", ParseIntPipe) id: number + ): Promise { + const user = await this.prismaService.user.findUnique({ + where: { id }, + select: { id: true } + }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + const key = `users/${id}/profile`; + const exists = await this.s3Service.fileExists(key); + if (!exists) { + throw new NotFoundException("Profile picture not found"); + } + + const url = this.cloudfrontService.getCDNUrl(key); + return { url }; + } +} diff --git a/src/routes/public/public.module.ts b/src/routes/public/public.module.ts new file mode 100644 index 0000000..3599587 --- /dev/null +++ b/src/routes/public/public.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { PublicController } from "./public.controller"; +import { PrismaModule } from "@ourPrisma/prisma.module"; +import { S3Module } from "@s3/s3.module"; + +@Module({ + imports: [PrismaModule, S3Module], + controllers: [PublicController] +}) +export class PublicModule {} diff --git a/src/routes/user/user.controller.ts b/src/routes/user/user.controller.ts index 016ca63..b026326 100644 --- a/src/routes/user/user.controller.ts +++ b/src/routes/user/user.controller.ts @@ -109,6 +109,7 @@ export class UserController { @UploadedFile( new ParseFilePipeBuilder() .addMaxSizeValidator({ maxSize: 5 * 1024 * 1024 }) + .addFileTypeValidator({ fileType: /^image\/(jpeg|png|gif|webp)$/ }) .build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY }) ) file: Express.Multer.File, @@ -129,6 +130,7 @@ export class UserController { originalName: file.originalname } }); + await this.s3Service.setObjectPublicRead(key); return { message: "Profile picture uploaded successfully", id }; } diff --git a/src/swagger.app.module.ts b/src/swagger.app.module.ts index d0ecdee..04c8399 100644 --- a/src/swagger.app.module.ts +++ b/src/swagger.app.module.ts @@ -13,6 +13,7 @@ import { AuthModule } from "@auth/auth.module"; import { ProjectController } from "@project/project.controller"; import { MultiplayerController } from "src/routes/multiplayer/multiplayer.controller"; +import { PublicController } from "src/routes/public/public.controller"; import { ProjectService } from "@project/project.service"; import { S3Service } from "@s3/s3.service"; @@ -39,7 +40,7 @@ const nullProvider = (token: InjectionToken): Provider => ({ WorkSessionModule, WebRTCModule ], - controllers: [ProjectController, MultiplayerController], + controllers: [ProjectController, MultiplayerController, PublicController], providers: [ nullProvider(PrismaService), nullProvider(ProjectService), diff --git a/swagger.json b/swagger.json index 0f2dda5..28d1e07 100644 --- a/swagger.json +++ b/swagger.json @@ -567,7 +567,7 @@ "file": { "type": "string", "format": "binary", - "description": "Project image file" + "description": "Project image file (JPEG, PNG, GIF, WebP)" } } } @@ -580,6 +580,9 @@ }, "403": { "description": "Forbidden" + }, + "422": { + "description": "Invalid file type or size" } }, "security": [ @@ -606,15 +609,24 @@ ], "responses": { "200": { - "description": "Signed cookies and CDN resource URL", + "description": "CDN URL for the project image", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignedCdnResourceDto" + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "https://cdn.example.com/projects/42/image" + } + } } } } }, + "403": { + "description": "Forbidden" + }, "404": { "description": "Image not found" } @@ -624,7 +636,7 @@ "JWT-auth": [] } ], - "summary": "Get signed CDN access to project image", + "summary": "Get CDN URL for project image (authenticated, any project status)", "tags": [ "projects" ] @@ -1156,6 +1168,76 @@ ] } }, + "/public/projects/{id}/image": { + "get": { + "operationId": "PublicController_getPublishedProjectImage", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "Project ID", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Returns the CDN URL for the project image", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUrlResponseDto" + } + } + } + }, + "404": { + "description": "Project not found, not published, or has no image" + } + }, + "summary": "Get public CDN URL for a published project's image", + "tags": [ + "public" + ] + } + }, + "/public/users/{id}/profile-picture": { + "get": { + "operationId": "PublicController_getProfilePicture", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "User ID", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Returns the CDN URL for the profile picture", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUrlResponseDto" + } + } + } + }, + "404": { + "description": "User not found or has no profile picture" + } + }, + "summary": "Get public CDN URL for a user's profile picture", + "tags": [ + "public" + ] + } + }, "/auth/login": { "post": { "operationId": "AuthController_login", @@ -2241,30 +2323,6 @@ } } }, - "SignedCdnResourceDto": { - "type": "object", - "properties": { - "resourceUrl": { - "type": "string", - "example": "https://cdn.example.com/projects/42/image", - "description": "The CDN URL for the resource (requires signed cookies)" - }, - "cookies": { - "type": "object", - "description": "Signed Edge cookies (also set as HTTP-only cookies)", - "example": { - "CloudFront-Expires": "1735660800", - "CloudFront-Signature": "base64-signature", - "CloudFront-Key-Pair-Id": "K1234567890", - "CloudFront-Policy": "base64-policy" - } - } - }, - "required": [ - "resourceUrl", - "cookies" - ] - }, "LookupHostsResponseDtoHost": { "type": "object", "properties": { @@ -2444,6 +2502,19 @@ "sessionUuid" ] }, + "ImageUrlResponseDto": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "https://cdn.example.com/projects/42/image", + "description": "The public CDN URL for the image" + } + }, + "required": [ + "url" + ] + }, "LoginDto": { "type": "object", "properties": { @@ -2732,6 +2803,30 @@ "message" ] }, + "SignedCdnResourceDto": { + "type": "object", + "properties": { + "resourceUrl": { + "type": "string", + "example": "https://cdn.example.com/projects/42/image", + "description": "The CDN URL for the resource (requires signed cookies)" + }, + "cookies": { + "type": "object", + "description": "Signed Edge cookies (also set as HTTP-only cookies)", + "example": { + "CloudFront-Expires": "1735660800", + "CloudFront-Signature": "base64-signature", + "CloudFront-Key-Pair-Id": "K1234567890", + "CloudFront-Policy": "base64-policy" + } + } + }, + "required": [ + "resourceUrl", + "cookies" + ] + }, "UpdateUserDto": { "type": "object", "properties": { From ba02b223b08b809ed90ab4bf0714825e05595175 Mon Sep 17 00:00:00 2001 From: louis rollet Date: Sun, 22 Mar 2026 16:13:17 +0800 Subject: [PATCH 2/3] [PROJECT] [UPDATE] image upload functionality and public access for project and user images --- src/app.module.ts | 4 +- src/auth/decorators/public.decorator.ts | 5 + src/auth/guards/jwt-auth.guard.ts | 21 +- .../dto/image-url-response.dto.ts | 0 .../project/dto/project-response.dto.ts | 3 + src/routes/project/project.controller.ts | 60 +++++- src/routes/public/public.controller.ts | 114 ---------- src/routes/public/public.module.ts | 10 - src/routes/user/user.controller.ts | 34 +++ src/swagger.app.module.ts | 3 +- swagger.json | 200 +++++++++--------- 11 files changed, 215 insertions(+), 239 deletions(-) create mode 100644 src/auth/decorators/public.decorator.ts rename src/routes/{public => common}/dto/image-url-response.dto.ts (100%) delete mode 100644 src/routes/public/public.controller.ts delete mode 100644 src/routes/public/public.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 9ed8c7b..c07b3fb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,7 +10,6 @@ import { ScheduleModule } from "@nestjs/schedule"; import { TasksModule } from "src/tasks/tasks.module"; import { WebRTCModule } from "@webrtc/webrtc.module"; import { MultiplayerModule } from "@multiplayer/multiplayer.module"; -import { PublicModule } from "src/routes/public/public.module"; import { AppConfig } from "src/app.config"; @Module({ @@ -25,8 +24,7 @@ import { AppConfig } from "src/app.config"; WorkSessionModule, TasksModule, WebRTCModule, - MultiplayerModule, - PublicModule + MultiplayerModule ], providers: [ AppConfig diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..e958b59 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; +export const Public = (): ReturnType => + SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts index 2e81dba..64e5340 100644 --- a/src/auth/guards/jwt-auth.guard.ts +++ b/src/auth/guards/jwt-auth.guard.ts @@ -1,5 +1,22 @@ -import { Injectable } from "@nestjs/common"; +import { ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; import { AuthGuard } from "@nestjs/passport"; +import { IS_PUBLIC_KEY } from "@auth/decorators/public.decorator"; @Injectable() -export class JwtAuthGuard extends AuthGuard("jwt") {} +export class JwtAuthGuard extends AuthGuard("jwt") { + constructor(private reflector: Reflector) { + super(); + } + + override canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass() + ]); + if (isPublic) { + return true; + } + return super.canActivate(context); + } +} diff --git a/src/routes/public/dto/image-url-response.dto.ts b/src/routes/common/dto/image-url-response.dto.ts similarity index 100% rename from src/routes/public/dto/image-url-response.dto.ts rename to src/routes/common/dto/image-url-response.dto.ts diff --git a/src/routes/project/dto/project-response.dto.ts b/src/routes/project/dto/project-response.dto.ts index be35f01..2d0848e 100644 --- a/src/routes/project/dto/project-response.dto.ts +++ b/src/routes/project/dto/project-response.dto.ts @@ -43,6 +43,7 @@ export class ProjectResponseDto { @ApiProperty({ description: "A detailed description of the project", example: "This game features multiple levels, power-ups, and boss fights.", + type: String, nullable: true }) longDesc?: string | null; @@ -50,6 +51,7 @@ export class ProjectResponseDto { @ApiProperty({ description: "URL to the project icon", example: "https://example.com/icons/MySuperVideoGame.png", + type: String, nullable: true }) iconUrl?: string | null; @@ -73,6 +75,7 @@ export class ProjectResponseDto { @ApiProperty({ example: 99.99, description: "The price of the project, if applicable", + type: Number, nullable: true }) price?: number | null; diff --git a/src/routes/project/project.controller.ts b/src/routes/project/project.controller.ts index f17e0da..3654740 100644 --- a/src/routes/project/project.controller.ts +++ b/src/routes/project/project.controller.ts @@ -53,6 +53,9 @@ import { import { S3DownloadException } from "@s3/s3.error"; import { S3Service } from "@s3/s3.service"; import { CloudfrontService } from "src/routes/s3/edge.service"; +import { PrismaService } from "@ourPrisma/prisma.service"; +import { Public } from "@auth/decorators/public.decorator"; +import { ImageUrlResponseDto } from "src/routes/common/dto/image-url-response.dto"; interface RequestWithUser extends Request { user: UserDto; @@ -66,7 +69,8 @@ export class ProjectController { constructor( private readonly projectService: ProjectService, private readonly s3Service: S3Service, - private readonly cloudfrontService: CloudfrontService + private readonly cloudfrontService: CloudfrontService, + private readonly prismaService: PrismaService ) {} private readonly logger = new Logger(ProjectController.name); @@ -466,27 +470,63 @@ export class ProjectController { @ApiResponse({ status: 200, description: "CDN URL for the project image", - schema: { - type: "object", - properties: { - url: { type: "string", example: "https://cdn.example.com/projects/42/image" } - } - } + type: ImageUrlResponseDto }) + @ApiResponse({ status: 204, description: "Project has no image" }) @ApiResponse({ status: 403, description: "Forbidden" }) - @ApiResponse({ status: 404, description: "Image not found" }) async getProjectImage( @Param("id", ParseIntPipe) id: number - ): Promise<{ url: string }> { + ): Promise { const key = `projects/${id}/image`; const exists = await this.s3Service.fileExists(key); if (!exists) { - throw new HttpException("Project image not found", HttpStatus.NOT_FOUND); + throw new HttpException("No content", HttpStatus.NO_CONTENT); } const url = this.cloudfrontService.getCDNUrl(key); return { url }; } + @Public() + @Get("public/:id/image") + @ApiOperation({ + summary: "Get public CDN URL for a published project's image" + }) + @ApiParam({ + name: "id", + type: "number", + description: "Project ID" + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Returns the CDN URL for the project image", + type: ImageUrlResponseDto + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Project not found, not published, or has no image" + }) + async getPublishedProjectImage( + @Param("id", ParseIntPipe) id: number + ): Promise { + const project = await this.prismaService.project.findFirst({ + where: { id, status: "COMPLETED" }, + select: { id: true } + }); + + if (!project) { + throw new HttpException("Not found", HttpStatus.NOT_FOUND); + } + + const key = `projects/${id}/image`; + const exists = await this.s3Service.fileExists(key); + if (!exists) { + throw new HttpException("Not found", HttpStatus.NOT_FOUND); + } + + const url = this.cloudfrontService.getCDNUrl(key); + return { url }; + } + @Get(":id/fetchContent") @UseGuards(ProjectCollaboratorGuard) @ApiOperation({ summary: "Fetch project's content" }) diff --git a/src/routes/public/public.controller.ts b/src/routes/public/public.controller.ts deleted file mode 100644 index f7015c8..0000000 --- a/src/routes/public/public.controller.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { - Controller, - Get, - HttpStatus, - NotFoundException, - Param, - ParseIntPipe -} from "@nestjs/common"; -import { - ApiOperation, - ApiParam, - ApiResponse, - ApiTags -} from "@nestjs/swagger"; -import { PrismaService } from "@ourPrisma/prisma.service"; -import { S3Service } from "@s3/s3.service"; -import { CloudfrontService } from "@s3/edge.service"; -import { ImageUrlResponseDto } from "./dto/image-url-response.dto"; - -@ApiTags("public") -@Controller("public") -export class PublicController { - constructor( - private readonly prismaService: PrismaService, - private readonly s3Service: S3Service, - private readonly cloudfrontService: CloudfrontService - ) {} - - @Get("projects/:id/image") - @ApiOperation({ - summary: "Get public CDN URL for a published project's image" - }) - @ApiParam({ - name: "id", - type: "number", - description: "Project ID" - }) - @ApiResponse({ - status: HttpStatus.OK, - description: "Returns the CDN URL for the project image", - type: ImageUrlResponseDto - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: "Project not found, not published, or has no image" - }) - async getPublishedProjectImage( - @Param("id", ParseIntPipe) id: number - ): Promise { - const project = await this.prismaService.project.findUnique({ - where: { id }, - select: { id: true, status: true } - }); - - if (!project) { - throw new NotFoundException(`Project with ID ${id} not found`); - } - - if (project.status !== "COMPLETED") { - throw new NotFoundException( - `Project with ID ${id} is not published` - ); - } - - const key = `projects/${id}/image`; - const exists = await this.s3Service.fileExists(key); - if (!exists) { - throw new NotFoundException("Project image not found"); - } - - const url = this.cloudfrontService.getCDNUrl(key); - return { url }; - } - - @Get("users/:id/profile-picture") - @ApiOperation({ - summary: "Get public CDN URL for a user's profile picture" - }) - @ApiParam({ - name: "id", - type: "number", - description: "User ID" - }) - @ApiResponse({ - status: HttpStatus.OK, - description: "Returns the CDN URL for the profile picture", - type: ImageUrlResponseDto - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: "User not found or has no profile picture" - }) - async getProfilePicture( - @Param("id", ParseIntPipe) id: number - ): Promise { - const user = await this.prismaService.user.findUnique({ - where: { id }, - select: { id: true } - }); - - if (!user) { - throw new NotFoundException(`User with ID ${id} not found`); - } - - const key = `users/${id}/profile`; - const exists = await this.s3Service.fileExists(key); - if (!exists) { - throw new NotFoundException("Profile picture not found"); - } - - const url = this.cloudfrontService.getCDNUrl(key); - return { url }; - } -} diff --git a/src/routes/public/public.module.ts b/src/routes/public/public.module.ts deleted file mode 100644 index 3599587..0000000 --- a/src/routes/public/public.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from "@nestjs/common"; -import { PublicController } from "./public.controller"; -import { PrismaModule } from "@ourPrisma/prisma.module"; -import { S3Module } from "@s3/s3.module"; - -@Module({ - imports: [PrismaModule, S3Module], - controllers: [PublicController] -}) -export class PublicModule {} diff --git a/src/routes/user/user.controller.ts b/src/routes/user/user.controller.ts index b026326..3991aee 100644 --- a/src/routes/user/user.controller.ts +++ b/src/routes/user/user.controller.ts @@ -50,6 +50,8 @@ import { CloudfrontService } from "src/routes/s3/edge.service"; import { ConfigService } from "@nestjs/config"; import { SignedCdnResourceDto } from "@common/dto/signed-cdn-resource.dto"; import { MissingEnvVarError } from "@auth/auth.error"; +import { Public } from "@auth/decorators/public.decorator"; +import { ImageUrlResponseDto } from "src/routes/common/dto/image-url-response.dto"; @ApiTags("users") @ApiExtraModels( @@ -326,4 +328,36 @@ export class UserController { message: "User deleted successfully" }; } + + @Public() + @Get("public/:id/profile-picture") + @ApiOperation({ + summary: "Get public CDN URL for a user's profile picture" + }) + @ApiParam({ + name: "id", + type: "number", + description: "User ID" + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Returns the CDN URL for the profile picture", + type: ImageUrlResponseDto + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "User not found or has no profile picture" + }) + async getPublicProfilePicture( + @Param("id", ParseIntPipe) id: number + ): Promise { + const key = `users/${id}/profile`; + const exists = await this.s3Service.fileExists(key); + if (!exists) { + throw new HttpException("Not found", HttpStatus.NOT_FOUND); + } + + const url = this.cloudfrontService.getCDNUrl(key); + return { url }; + } } diff --git a/src/swagger.app.module.ts b/src/swagger.app.module.ts index 04c8399..d0ecdee 100644 --- a/src/swagger.app.module.ts +++ b/src/swagger.app.module.ts @@ -13,7 +13,6 @@ import { AuthModule } from "@auth/auth.module"; import { ProjectController } from "@project/project.controller"; import { MultiplayerController } from "src/routes/multiplayer/multiplayer.controller"; -import { PublicController } from "src/routes/public/public.controller"; import { ProjectService } from "@project/project.service"; import { S3Service } from "@s3/s3.service"; @@ -40,7 +39,7 @@ const nullProvider = (token: InjectionToken): Provider => ({ WorkSessionModule, WebRTCModule ], - controllers: [ProjectController, MultiplayerController, PublicController], + controllers: [ProjectController, MultiplayerController], providers: [ nullProvider(PrismaService), nullProvider(ProjectService), diff --git a/swagger.json b/swagger.json index 28d1e07..570f175 100644 --- a/swagger.json +++ b/swagger.json @@ -613,22 +613,56 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "example": "https://cdn.example.com/projects/42/image" - } - } + "$ref": "#/components/schemas/ImageUrlResponseDto" } } } }, + "204": { + "description": "Project has no image" + }, "403": { "description": "Forbidden" + } + }, + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Get CDN URL for project image (authenticated, any project status)", + "tags": [ + "projects" + ] + } + }, + "/projects/public/{id}/image": { + "get": { + "operationId": "ProjectController_getPublishedProjectImage", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "Project ID", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Returns the CDN URL for the project image", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUrlResponseDto" + } + } + } }, "404": { - "description": "Image not found" + "description": "Project not found, not published, or has no image" } }, "security": [ @@ -636,7 +670,7 @@ "JWT-auth": [] } ], - "summary": "Get CDN URL for project image (authenticated, any project status)", + "summary": "Get public CDN URL for a published project's image", "tags": [ "projects" ] @@ -1168,76 +1202,6 @@ ] } }, - "/public/projects/{id}/image": { - "get": { - "operationId": "PublicController_getPublishedProjectImage", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "description": "Project ID", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { - "description": "Returns the CDN URL for the project image", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImageUrlResponseDto" - } - } - } - }, - "404": { - "description": "Project not found, not published, or has no image" - } - }, - "summary": "Get public CDN URL for a published project's image", - "tags": [ - "public" - ] - } - }, - "/public/users/{id}/profile-picture": { - "get": { - "operationId": "PublicController_getProfilePicture", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "description": "User ID", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { - "description": "Returns the CDN URL for the profile picture", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImageUrlResponseDto" - } - } - } - }, - "404": { - "description": "User not found or has no profile picture" - } - }, - "summary": "Get public CDN URL for a user's profile picture", - "tags": [ - "public" - ] - } - }, "/auth/login": { "post": { "operationId": "AuthController_login", @@ -1780,6 +1744,46 @@ ] } }, + "/users/public/{id}/profile-picture": { + "get": { + "operationId": "UserController_getPublicProfilePicture", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "User ID", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Returns the CDN URL for the profile picture", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUrlResponseDto" + } + } + } + }, + "404": { + "description": "User not found or has no profile picture" + } + }, + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Get public CDN URL for a user's profile picture", + "tags": [ + "users" + ] + } + }, "/work-sessions/join/{id}": { "post": { "operationId": "WorkSessionController_join", @@ -1967,13 +1971,13 @@ "example": "A 2D platformer game with pixel art graphics" }, "longDesc": { - "type": "object", + "type": "string", "description": "A detailed description of the project", "example": "This game features multiple levels, power-ups, and boss fights.", "nullable": true }, "iconUrl": { - "type": "object", + "type": "string", "description": "URL to the project icon", "example": "https://example.com/icons/MySuperVideoGame.png", "nullable": true @@ -2001,7 +2005,7 @@ "nullable": true }, "price": { - "type": "object", + "type": "number", "example": 99.99, "description": "The price of the project, if applicable", "nullable": true @@ -2106,13 +2110,13 @@ "example": "A 2D platformer game with pixel art graphics" }, "longDesc": { - "type": "object", + "type": "string", "description": "A detailed description of the project", "example": "This game features multiple levels, power-ups, and boss fights.", "nullable": true }, "iconUrl": { - "type": "object", + "type": "string", "description": "URL to the project icon", "example": "https://example.com/icons/MySuperVideoGame.png", "nullable": true @@ -2140,7 +2144,7 @@ "nullable": true }, "price": { - "type": "object", + "type": "number", "example": 99.99, "description": "The price of the project, if applicable", "nullable": true @@ -2323,6 +2327,19 @@ } } }, + "ImageUrlResponseDto": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "https://cdn.example.com/projects/42/image", + "description": "The public CDN URL for the image" + } + }, + "required": [ + "url" + ] + }, "LookupHostsResponseDtoHost": { "type": "object", "properties": { @@ -2502,19 +2519,6 @@ "sessionUuid" ] }, - "ImageUrlResponseDto": { - "type": "object", - "properties": { - "url": { - "type": "string", - "example": "https://cdn.example.com/projects/42/image", - "description": "The public CDN URL for the image" - } - }, - "required": [ - "url" - ] - }, "LoginDto": { "type": "object", "properties": { From 6e0344cbe65fee89cc56fd547935161e5cbc5be6 Mon Sep 17 00:00:00 2001 From: louis rollet Date: Sun, 22 Mar 2026 17:22:14 +0800 Subject: [PATCH 3/3] [PROJECT] [UPDATE] Enhance image upload and retrieval with cache control and metadata handling --- src/routes/project/project.controller.ts | 17 +++++++------ src/routes/s3/s3.service.ts | 31 +++++++++++++++++++++--- src/routes/user/user.controller.ts | 27 +++++++++------------ 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/routes/project/project.controller.ts b/src/routes/project/project.controller.ts index 3654740..2aae8e7 100644 --- a/src/routes/project/project.controller.ts +++ b/src/routes/project/project.controller.ts @@ -456,7 +456,8 @@ export class ProjectController { uploadedBy: req.user.id.toString(), projectId: id.toString(), originalName: file.originalname - } + }, + cacheControl: "no-cache" }); await this.s3Service.setObjectPublicRead(key); @@ -478,11 +479,12 @@ export class ProjectController { @Param("id", ParseIntPipe) id: number ): Promise { const key = `projects/${id}/image`; - const exists = await this.s3Service.fileExists(key); - if (!exists) { + const head = await this.s3Service.getFileMetadataOrNull(key); + if (!head) { throw new HttpException("No content", HttpStatus.NO_CONTENT); } - const url = this.cloudfrontService.getCDNUrl(key); + const version = head.ETag?.replace(/"/g, "") ?? Date.now().toString(); + const url = `${this.cloudfrontService.getCDNUrl(key)}?v=${version}`; return { url }; } @@ -518,12 +520,13 @@ export class ProjectController { } const key = `projects/${id}/image`; - const exists = await this.s3Service.fileExists(key); - if (!exists) { + const head = await this.s3Service.getFileMetadataOrNull(key); + if (!head) { throw new HttpException("Not found", HttpStatus.NOT_FOUND); } - const url = this.cloudfrontService.getCDNUrl(key); + const version = head.ETag?.replace(/"/g, "") ?? Date.now().toString(); + const url = `${this.cloudfrontService.getCDNUrl(key)}?v=${version}`; return { url }; } diff --git a/src/routes/s3/s3.service.ts b/src/routes/s3/s3.service.ts index 749cffa..0c3e173 100644 --- a/src/routes/s3/s3.service.ts +++ b/src/routes/s3/s3.service.ts @@ -229,16 +229,39 @@ export class S3Service { } } + async getFileMetadataOrNull( + key: string, + bucketName?: string + ): Promise { + try { + return await this.headFile(key, bucketName); + } catch (error: unknown) { + const s3Error = error as { + name?: string; + $metadata?: { httpStatusCode?: number }; + }; + if ( + s3Error.name === "NotFound" || + s3Error.$metadata?.httpStatusCode === 404 + ) { + return null; + } + throw error; + } + } + async uploadFile({ file, metadata, bucketName, - keyName + keyName, + cacheControl }: { file: Express.Multer.File | DownloadedFile; metadata?: Record; bucketName?: string; keyName?: string; + cacheControl?: string; }): Promise { const resolvedBucketName = this.resolveBucket(bucketName); @@ -252,7 +275,8 @@ export class S3Service { Key: keyName ?? file.originalname, Body: file.buffer, ContentType: file.mimetype, - Metadata: metadata + Metadata: metadata, + CacheControl: cacheControl }; const command = new PutObjectCommand(input); @@ -275,7 +299,8 @@ export class S3Service { Key: keyName, Body: file.body, ContentType: file.contentType, - Metadata: metadata + Metadata: metadata, + CacheControl: cacheControl } }); diff --git a/src/routes/user/user.controller.ts b/src/routes/user/user.controller.ts index 3991aee..60f05cf 100644 --- a/src/routes/user/user.controller.ts +++ b/src/routes/user/user.controller.ts @@ -47,9 +47,7 @@ import { FileInterceptor } from "@nestjs/platform-express"; import { Response } from "express"; import { S3Service } from "@s3/s3.service"; import { CloudfrontService } from "src/routes/s3/edge.service"; -import { ConfigService } from "@nestjs/config"; import { SignedCdnResourceDto } from "@common/dto/signed-cdn-resource.dto"; -import { MissingEnvVarError } from "@auth/auth.error"; import { Public } from "@auth/decorators/public.decorator"; import { ImageUrlResponseDto } from "src/routes/common/dto/image-url-response.dto"; @@ -68,8 +66,7 @@ export class UserController { constructor( private readonly userService: UserService, private readonly s3Service: S3Service, - private readonly cloudfrontService: CloudfrontService, - private readonly configService: ConfigService + private readonly cloudfrontService: CloudfrontService ) {} @Get("profile") @@ -130,7 +127,8 @@ export class UserController { uploadedBy: req.user.id.toString(), userId: id.toString(), originalName: file.originalname - } + }, + cacheControl: "no-cache" }); await this.s3Service.setObjectPublicRead(key); @@ -152,18 +150,14 @@ export class UserController { @Res() res: Response ): Promise { const key = `users/${id}/profile`; - const exists = await this.s3Service.fileExists(key); - if (!exists) { + const head = await this.s3Service.getFileMetadataOrNull(key); + if (!head) { res.status(HttpStatus.NOT_FOUND).json({ message: "Profile picture not found" }); return; } - const cdnUrl = this.configService.get("CDN_URL"); - if (!cdnUrl) { - throw new MissingEnvVarError("CDN_URL"); - } - - const resourceUrl = this.cloudfrontService.generateSignedUrl(key); + const version = head.ETag?.replace(/"/g, "") ?? Date.now().toString(); + const resourceUrl = `${this.cloudfrontService.getCDNUrl(key)}?v=${version}`; res.status(HttpStatus.OK).json({ resourceUrl, @@ -352,12 +346,13 @@ export class UserController { @Param("id", ParseIntPipe) id: number ): Promise { const key = `users/${id}/profile`; - const exists = await this.s3Service.fileExists(key); - if (!exists) { + const head = await this.s3Service.getFileMetadataOrNull(key); + if (!head) { throw new HttpException("Not found", HttpStatus.NOT_FOUND); } - const url = this.cloudfrontService.getCDNUrl(key); + const version = head.ETag?.replace(/"/g, "") ?? Date.now().toString(); + const url = `${this.cloudfrontService.getCDNUrl(key)}?v=${version}`; return { url }; } }