Merge pull request 'dev' (#14) from dev into prod
Some checks failed
Deploy to Production / Validate Build & Lint (backend) (push) Failing after 1m6s
Backend Tests / test (push) Successful in 1m24s
Deploy to Production / Validate Build & Lint (documentation) (push) Successful in 1m38s
Lint / lint (backend) (push) Successful in 1m15s
Deploy to Production / Validate Build & Lint (frontend) (push) Successful in 1m33s
Deploy to Production / Deploy to Production (push) Has been skipped
Lint / lint (documentation) (push) Successful in 1m13s
Lint / lint (frontend) (push) Successful in 1m8s

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-01-20 10:12:18 +01:00
9 changed files with 72 additions and 22 deletions

View File

@@ -40,17 +40,6 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Cache Next.js build
if: matrix.component != 'backend'
uses: actions/cache@v4
with:
path: ${{ matrix.component }}/.next/cache
# Clé basée sur le lockfile et les fichiers source du composant
key: ${{ runner.os }}-nextjs-${{ matrix.component }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles(concat(matrix.component, '/**/*.[jt]s'), concat(matrix.component, '/**/*.[jt]sx')) }}
restore-keys: |
${{ runner.os }}-nextjs-${{ matrix.component }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
${{ runner.os }}-nextjs-${{ matrix.component }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline run: pnpm install --frozen-lockfile --prefer-offline

View File

@@ -12,6 +12,7 @@ import { AuthModule } from "./auth/auth.module";
import { CategoriesModule } from "./categories/categories.module"; import { CategoriesModule } from "./categories/categories.module";
import { CommonModule } from "./common/common.module"; import { CommonModule } from "./common/common.module";
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware"; import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
import { validateEnv } from "./config/env.schema"; import { validateEnv } from "./config/env.schema";
import { ContentsModule } from "./contents/contents.module"; import { ContentsModule } from "./contents/contents.module";
import { CryptoModule } from "./crypto/crypto.module"; import { CryptoModule } from "./crypto/crypto.module";
@@ -76,6 +77,8 @@ import { UsersModule } from "./users/users.module";
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
consumer.apply(CrawlerDetectionMiddleware).forRoutes("*"); consumer
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
.forRoutes("*");
} }
} }

View File

@@ -110,6 +110,7 @@ export class AuthService {
const user = await this.usersService.findByEmailHash(emailHash); const user = await this.usersService.findByEmailHash(emailHash);
if (!user) { if (!user) {
this.logger.warn(`Login failed: user not found for email hash`);
throw new UnauthorizedException("Invalid credentials"); throw new UnauthorizedException("Invalid credentials");
} }
@@ -119,10 +120,12 @@ export class AuthService {
); );
if (!isPasswordValid) { if (!isPasswordValid) {
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
throw new UnauthorizedException("Invalid credentials"); throw new UnauthorizedException("Invalid credentials");
} }
if (user.isTwoFactorEnabled) { if (user.isTwoFactorEnabled) {
this.logger.log(`2FA required for user ${user.uuid}`);
return { return {
message: "2FA required", message: "2FA required",
requires2FA: true, requires2FA: true,
@@ -141,6 +144,7 @@ export class AuthService {
ip, ip,
); );
this.logger.log(`User ${user.uuid} logged in successfully`);
return { return {
message: "User logged in successfully", message: "User logged in successfully",
access_token: accessToken, access_token: accessToken,
@@ -165,6 +169,9 @@ export class AuthService {
const isValid = authenticator.verify({ token, secret }); const isValid = authenticator.verify({ token, secret });
if (!isValid) { if (!isValid) {
this.logger.warn(
`2FA verification failed for user ${userId}: invalid token`,
);
throw new UnauthorizedException("Invalid 2FA token"); throw new UnauthorizedException("Invalid 2FA token");
} }
@@ -179,6 +186,7 @@ export class AuthService {
ip, ip,
); );
this.logger.log(`User ${userId} logged in successfully via 2FA`);
return { return {
message: "User logged in successfully (2FA)", message: "User logged in successfully (2FA)",
access_token: accessToken, access_token: accessToken,

View File

@@ -9,6 +9,14 @@ import {
import * as Sentry from "@sentry/nestjs"; import * as Sentry from "@sentry/nestjs";
import { Request, Response } from "express"; import { Request, Response } from "express";
interface RequestWithUser extends Request {
user?: {
sub?: string;
username?: string;
id?: string;
};
}
@Catch() @Catch()
export class AllExceptionsFilter implements ExceptionFilter { export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter"); private readonly logger = new Logger("ExceptionFilter");
@@ -16,7 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<RequestWithUser>();
const status = const status =
exception instanceof HttpException exception instanceof HttpException
@@ -28,6 +36,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
? exception.getResponse() ? exception.getResponse()
: "Internal server error"; : "Internal server error";
const userId = request.user?.sub || request.user?.id;
const userPart = userId ? `[User: ${userId}] ` : "";
const errorResponse = { const errorResponse = {
statusCode: status, statusCode: status,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -42,12 +53,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
if (status === HttpStatus.INTERNAL_SERVER_ERROR) { if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
Sentry.captureException(exception); Sentry.captureException(exception);
this.logger.error( this.logger.error(
`${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`, `${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
exception instanceof Error ? exception.stack : "", exception instanceof Error ? exception.stack : "",
); );
} else { } else {
this.logger.warn( this.logger.warn(
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`, `${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
); );
} }

View File

@@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import { NextFunction, Request, Response } from "express";
@Injectable()
export class HTTPLoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void {
const { method, originalUrl, ip } = request;
const userAgent = request.get("user-agent") || "";
const startTime = Date.now();
response.on("finish", () => {
const { statusCode } = response;
const contentLength = response.get("content-length");
const duration = Date.now() - startTime;
const hashedIp = createHash("sha256")
.update(ip as string)
.digest("hex");
const message = `${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${hashedIp} +${duration}ms`;
if (statusCode >= 500) {
return this.logger.error(message);
}
if (statusCode >= 400) {
return this.logger.warn(message);
}
return this.logger.log(message);
});
next();
}
}

View File

@@ -1,12 +1,12 @@
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import type { Response } from "express";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { MediaController } from "./media.controller"; import { MediaController } from "./media.controller";
describe("MediaController", () => { describe("MediaController", () => {
let controller: MediaController; let controller: MediaController;
let s3Service: S3Service;
const mockS3Service = { const mockS3Service = {
getFileInfo: jest.fn(), getFileInfo: jest.fn(),
@@ -20,7 +20,6 @@ describe("MediaController", () => {
}).compile(); }).compile();
controller = module.get<MediaController>(MediaController); controller = module.get<MediaController>(MediaController);
s3Service = module.get<S3Service>(S3Service);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -31,7 +30,7 @@ describe("MediaController", () => {
it("should stream the file and set headers with path containing slashes", async () => { it("should stream the file and set headers with path containing slashes", async () => {
const res = { const res = {
setHeader: jest.fn(), setHeader: jest.fn(),
} as any; } as unknown as Response;
const stream = new Readable(); const stream = new Readable();
stream.pipe = jest.fn(); stream.pipe = jest.fn();
const key = "contents/user-id/test.webp"; const key = "contents/user-id/test.webp";
@@ -52,7 +51,7 @@ describe("MediaController", () => {
it("should throw NotFoundException if file is not found", async () => { it("should throw NotFoundException if file is not found", async () => {
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found")); mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
const res = {} as any; const res = {} as unknown as Response;
await expect(controller.getFile("invalid", res)).rejects.toThrow( await expect(controller.getFile("invalid", res)).rejects.toThrow(
NotFoundException, NotFoundException,

View File

@@ -1,5 +1,6 @@
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common"; import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
import type { Response } from "express"; import type { Response } from "express";
import type { BucketItemStat } from "minio";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
@Controller("media") @Controller("media")
@@ -9,7 +10,7 @@ export class MediaController {
@Get("*key") @Get("*key")
async getFile(@Param("key") key: string, @Res() res: Response) { async getFile(@Param("key") key: string, @Res() res: Response) {
try { try {
const stats = (await this.s3Service.getFileInfo(key)) as any; const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
const stream = await this.s3Service.getFile(key); const stream = await this.s3Service.getFile(key);
const contentType = const contentType =

View File

@@ -197,7 +197,7 @@ describe("S3Service", () => {
it("should use DOMAIN_NAME and PORT for localhost", () => { it("should use DOMAIN_NAME and PORT for localhost", () => {
(configService.get as jest.Mock).mockImplementation( (configService.get as jest.Mock).mockImplementation(
(key: string, def: any) => { (key: string, def: unknown) => {
if (key === "API_URL") return null; if (key === "API_URL") return null;
if (key === "DOMAIN_NAME") return "localhost"; if (key === "DOMAIN_NAME") return "localhost";
if (key === "PORT") return 3000; if (key === "PORT") return 3000;
@@ -210,7 +210,7 @@ describe("S3Service", () => {
it("should use api.DOMAIN_NAME for production", () => { it("should use api.DOMAIN_NAME for production", () => {
(configService.get as jest.Mock).mockImplementation( (configService.get as jest.Mock).mockImplementation(
(key: string, def: any) => { (key: string, def: unknown) => {
if (key === "API_URL") return null; if (key === "API_URL") return null;
if (key === "DOMAIN_NAME") return "memegoat.fr"; if (key === "DOMAIN_NAME") return "memegoat.fr";
return def; return def;

View File

@@ -54,6 +54,7 @@ export class S3Service implements OnModuleInit, IStorageService {
...metaData, ...metaData,
"Content-Type": mimeType, "Content-Type": mimeType,
}); });
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
return fileName; return fileName;
} catch (error) { } catch (error) {
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`); this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
@@ -113,6 +114,7 @@ export class S3Service implements OnModuleInit, IStorageService {
async deleteFile(fileName: string, bucketName: string = this.bucketName) { async deleteFile(fileName: string, bucketName: string = this.bucketName) {
try { try {
await this.minioClient.removeObject(bucketName, fileName); await this.minioClient.removeObject(bucketName, fileName);
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Error deleting file from ${bucketName}: ${error.message}`, `Error deleting file from ${bucketName}: ${error.message}`,