Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions src/auth/decorators/public.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from "@nestjs/common";

export const IS_PUBLIC_KEY = "isPublic";
export const Public = (): ReturnType<typeof SetMetadata> =>
SetMetadata(IS_PUBLIC_KEY, true);
21 changes: 19 additions & 2 deletions src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass()
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
9 changes: 9 additions & 0 deletions src/routes/common/dto/image-url-response.dto.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.

Put this in routes/common/dto/...

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,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;
}
3 changes: 3 additions & 0 deletions src/routes/project/dto/project-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ 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;

@ApiProperty({
description: "URL to the project icon",
example: "https://example.com/icons/MySuperVideoGame.png",
type: String,
nullable: true
})
iconUrl?: string | null;
Expand All @@ -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;
Expand Down
84 changes: 66 additions & 18 deletions src/routes/project/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -411,6 +414,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 +425,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 @@ -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<void> {
@Param("id", ParseIntPipe) id: number
): Promise<ImageUrlResponseDto> {
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<ImageUrlResponseDto> {
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")
Expand Down
31 changes: 28 additions & 3 deletions src/routes/s3/s3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,39 @@ export class S3Service {
}
}

async getFileMetadataOrNull(
key: string,
bucketName?: string
): Promise<HeadObjectCommandOutput | null> {
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<string, string>;
bucketName?: string;
keyName?: string;
cacheControl?: string;
}): Promise<void> {
const resolvedBucketName = this.resolveBucket(bucketName);

Expand All @@ -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);

Expand All @@ -275,7 +299,8 @@ export class S3Service {
Key: keyName,
Body: file.body,
ContentType: file.contentType,
Metadata: metadata
Metadata: metadata,
CacheControl: cacheControl
}
});

Expand Down
57 changes: 44 additions & 13 deletions src/routes/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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);
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 All @@ -148,18 +150,14 @@ export class UserController {
@Res() res: Response
): Promise<void> {
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<string>("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,
Expand Down Expand Up @@ -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<ImageUrlResponseDto> {
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 };
}
}
Loading
Loading