Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -24,7 +25,8 @@ import { AppConfig } from "src/app.config";
WorkSessionModule,
TasksModule,
WebRTCModule,
MultiplayerModule
MultiplayerModule,
PublicModule
],
providers: [
AppConfig
Expand Down
35 changes: 20 additions & 15 deletions src/routes/project/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -411,6 +410,7 @@ export class ProjectController {
}

@Post(":id/image")
@UseGuards(ProjectCollaboratorGuard)
@UseInterceptors(FileInterceptor("file"))
@ApiOperation({ summary: "Upload project image" })
@ApiConsumes("multipart/form-data")
Expand All @@ -421,20 +421,22 @@ export class ProjectController {
file: {
type: "string",
format: "binary",
description: "Project image file"
description: "Project image file (JPEG, PNG, GIF, WebP)"
}
}
}
})
@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,
Expand All @@ -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<void> {
@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")
Expand Down
9 changes: 9 additions & 0 deletions src/routes/public/dto/image-url-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
114 changes: 114 additions & 0 deletions src/routes/public/public.controller.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the routes should be moved to their respective controllers over creating a new controller.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -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<ImageUrlResponseDto> {
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`
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion from Copilot

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


const key = `projects/${id}/image`;
const exists = await this.s3Service.fileExists(key);
if (!exists) {
throw new NotFoundException("Project image not found");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be here a NoContentException (if it exists?)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

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<ImageUrlResponseDto> {
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 };
}
}
10 changes: 10 additions & 0 deletions src/routes/public/public.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
2 changes: 2 additions & 0 deletions src/routes/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -129,6 +130,7 @@ export class UserController {
originalName: file.originalname
}
});
await this.s3Service.setObjectPublicRead(key);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ehh we can ignore this

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really otherwise we cannot have access to the ressource with the cdn


return { message: "Profile picture uploaded successfully", id };
}
Expand Down
3 changes: 2 additions & 1 deletion src/swagger.app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,7 +40,7 @@ const nullProvider = (token: InjectionToken): Provider => ({
WorkSessionModule,
WebRTCModule
],
controllers: [ProjectController, MultiplayerController],
controllers: [ProjectController, MultiplayerController, PublicController],
providers: [
nullProvider(PrismaService),
nullProvider(ProjectService),
Expand Down
Loading
Loading