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/common/dto/image-url-response.dto.ts b/src/routes/common/dto/image-url-response.dto.ts new file mode 100644 index 0000000..5a5e617 --- /dev/null +++ b/src/routes/common/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/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 5cb81fd..2aae8e7 100644 --- a/src/routes/project/project.controller.ts +++ b/src/routes/project/project.controller.ts @@ -53,7 +53,9 @@ 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"; +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; @@ -67,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); @@ -411,6 +414,7 @@ export class ProjectController { } @Post(":id/image") + @UseGuards(ProjectCollaboratorGuard) @UseInterceptors(FileInterceptor("file")) @ApiOperation({ summary: "Upload project image" }) @ApiConsumes("multipart/form-data") @@ -421,7 +425,7 @@ export class ProjectController { file: { type: "string", format: "binary", - description: "Project image file" + description: "Project image file (JPEG, PNG, GIF, WebP)" } } } @@ -429,12 +433,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, @@ -450,36 +456,78 @@ export class ProjectController { uploadedBy: req.user.id.toString(), projectId: id.toString(), originalName: file.originalname - } + }, + cacheControl: "no-cache" }); + 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", + type: ImageUrlResponseDto }) - @ApiResponse({ status: 404, description: "Image not found" }) + @ApiResponse({ status: 204, description: "Project has no image" }) + @ApiResponse({ status: 403, description: "Forbidden" }) async getProjectImage( - @Param("id", ParseIntPipe) id: number, - @Res() res: Response - ): Promise { + @Param("id", ParseIntPipe) id: number + ): Promise { 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; + const head = await this.s3Service.getFileMetadataOrNull(key); + if (!head) { + throw new HttpException("No content", HttpStatus.NO_CONTENT); } - const resourceUrl = this.cloudfrontService.generateSignedUrl(key); + const version = head.ETag?.replace(/"/g, "") ?? Date.now().toString(); + const url = `${this.cloudfrontService.getCDNUrl(key)}?v=${version}`; + return { url }; + } - res.status(HttpStatus.OK).json({ - url: resourceUrl + @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 head = await this.s3Service.getFileMetadataOrNull(key); + if (!head) { + throw new HttpException("Not found", HttpStatus.NOT_FOUND); + } + + const version = head.ETag?.replace(/"/g, "") ?? Date.now().toString(); + const url = `${this.cloudfrontService.getCDNUrl(key)}?v=${version}`; + return { url }; } @Get(":id/fetchContent") 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 016ca63..60f05cf 100644 --- a/src/routes/user/user.controller.ts +++ b/src/routes/user/user.controller.ts @@ -47,9 +47,9 @@ 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"; @ApiTags("users") @ApiExtraModels( @@ -66,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") @@ -109,6 +108,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, @@ -127,8 +127,10 @@ export class UserController { uploadedBy: req.user.id.toString(), userId: id.toString(), originalName: file.originalname - } + }, + cacheControl: "no-cache" }); + await this.s3Service.setObjectPublicRead(key); return { message: "Profile picture uploaded successfully", id }; } @@ -148,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, @@ -324,4 +322,37 @@ 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 head = await this.s3Service.getFileMetadataOrNull(key); + if (!head) { + throw new HttpException("Not found", HttpStatus.NOT_FOUND); + } + + const version = head.ETag?.replace(/"/g, "") ?? Date.now().toString(); + const url = `${this.cloudfrontService.getCDNUrl(key)}?v=${version}`; + return { url }; + } } diff --git a/swagger.json b/swagger.json index 0f2dda5..570f175 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,17 +609,60 @@ ], "responses": { "200": { - "description": "Signed cookies and CDN resource URL", + "description": "CDN URL for the project image", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignedCdnResourceDto" + "$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": [ @@ -624,7 +670,7 @@ "JWT-auth": [] } ], - "summary": "Get signed CDN access to project image", + "summary": "Get public CDN URL for a published project's image", "tags": [ "projects" ] @@ -1698,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", @@ -1885,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 @@ -1919,7 +2005,7 @@ "nullable": true }, "price": { - "type": "object", + "type": "number", "example": 99.99, "description": "The price of the project, if applicable", "nullable": true @@ -2024,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 @@ -2058,7 +2144,7 @@ "nullable": true }, "price": { - "type": "object", + "type": "number", "example": 99.99, "description": "The price of the project, if applicable", "nullable": true @@ -2241,28 +2327,17 @@ } } }, - "SignedCdnResourceDto": { + "ImageUrlResponseDto": { "type": "object", "properties": { - "resourceUrl": { + "url": { "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" - } + "description": "The public CDN URL for the image" } }, "required": [ - "resourceUrl", - "cookies" + "url" ] }, "LookupHostsResponseDtoHost": { @@ -2732,6 +2807,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": {