diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c20ffb8..719c1d4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -40,17 +40,6 @@ jobs: restore-keys: | ${{ 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 run: pnpm install --frozen-lockfile --prefer-offline diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5b46aab..859bbec 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { AuthModule } from "./auth/auth.module"; import { CategoriesModule } from "./categories/categories.module"; import { CommonModule } from "./common/common.module"; import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware"; +import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware"; import { validateEnv } from "./config/env.schema"; import { ContentsModule } from "./contents/contents.module"; import { CryptoModule } from "./crypto/crypto.module"; @@ -76,6 +77,8 @@ import { UsersModule } from "./users/users.module"; }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(CrawlerDetectionMiddleware).forRoutes("*"); + consumer + .apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware) + .forRoutes("*"); } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 2133afb..f564272 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -110,6 +110,7 @@ export class AuthService { const user = await this.usersService.findByEmailHash(emailHash); if (!user) { + this.logger.warn(`Login failed: user not found for email hash`); throw new UnauthorizedException("Invalid credentials"); } @@ -119,10 +120,12 @@ export class AuthService { ); if (!isPasswordValid) { + this.logger.warn(`Login failed: invalid password for user ${user.uuid}`); throw new UnauthorizedException("Invalid credentials"); } if (user.isTwoFactorEnabled) { + this.logger.log(`2FA required for user ${user.uuid}`); return { message: "2FA required", requires2FA: true, @@ -141,6 +144,7 @@ export class AuthService { ip, ); + this.logger.log(`User ${user.uuid} logged in successfully`); return { message: "User logged in successfully", access_token: accessToken, @@ -165,6 +169,9 @@ export class AuthService { const isValid = authenticator.verify({ token, secret }); if (!isValid) { + this.logger.warn( + `2FA verification failed for user ${userId}: invalid token`, + ); throw new UnauthorizedException("Invalid 2FA token"); } @@ -179,6 +186,7 @@ export class AuthService { ip, ); + this.logger.log(`User ${userId} logged in successfully via 2FA`); return { message: "User logged in successfully (2FA)", access_token: accessToken, diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 6ef6e4f..a93e28c 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -9,6 +9,14 @@ import { import * as Sentry from "@sentry/nestjs"; import { Request, Response } from "express"; +interface RequestWithUser extends Request { + user?: { + sub?: string; + username?: string; + id?: string; + }; +} + @Catch() export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger("ExceptionFilter"); @@ -16,7 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - const request = ctx.getRequest(); + const request = ctx.getRequest(); const status = exception instanceof HttpException @@ -28,6 +36,9 @@ export class AllExceptionsFilter implements ExceptionFilter { ? exception.getResponse() : "Internal server error"; + const userId = request.user?.sub || request.user?.id; + const userPart = userId ? `[User: ${userId}] ` : ""; + const errorResponse = { statusCode: status, timestamp: new Date().toISOString(), @@ -42,12 +53,12 @@ export class AllExceptionsFilter implements ExceptionFilter { if (status === HttpStatus.INTERNAL_SERVER_ERROR) { Sentry.captureException(exception); 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 : "", ); } else { this.logger.warn( - `${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`, + `${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`, ); } diff --git a/backend/src/common/middlewares/http-logger.middleware.ts b/backend/src/common/middlewares/http-logger.middleware.ts new file mode 100644 index 0000000..f42538a --- /dev/null +++ b/backend/src/common/middlewares/http-logger.middleware.ts @@ -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(); + } +} diff --git a/backend/src/media/media.controller.spec.ts b/backend/src/media/media.controller.spec.ts index 608bbcb..359a512 100644 --- a/backend/src/media/media.controller.spec.ts +++ b/backend/src/media/media.controller.spec.ts @@ -1,12 +1,12 @@ import { Readable } from "node:stream"; import { NotFoundException } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; +import type { Response } from "express"; import { S3Service } from "../s3/s3.service"; import { MediaController } from "./media.controller"; describe("MediaController", () => { let controller: MediaController; - let s3Service: S3Service; const mockS3Service = { getFileInfo: jest.fn(), @@ -20,7 +20,6 @@ describe("MediaController", () => { }).compile(); controller = module.get(MediaController); - s3Service = module.get(S3Service); }); it("should be defined", () => { @@ -31,7 +30,7 @@ describe("MediaController", () => { it("should stream the file and set headers with path containing slashes", async () => { const res = { setHeader: jest.fn(), - } as any; + } as unknown as Response; const stream = new Readable(); stream.pipe = jest.fn(); const key = "contents/user-id/test.webp"; @@ -52,7 +51,7 @@ describe("MediaController", () => { it("should throw NotFoundException if file is not found", async () => { 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( NotFoundException, diff --git a/backend/src/media/media.controller.ts b/backend/src/media/media.controller.ts index f38edec..9a538ba 100644 --- a/backend/src/media/media.controller.ts +++ b/backend/src/media/media.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common"; import type { Response } from "express"; +import type { BucketItemStat } from "minio"; import { S3Service } from "../s3/s3.service"; @Controller("media") @@ -9,7 +10,7 @@ export class MediaController { @Get("*key") async getFile(@Param("key") key: string, @Res() res: Response) { 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 contentType = diff --git a/backend/src/s3/s3.service.spec.ts b/backend/src/s3/s3.service.spec.ts index 5e9cdcc..be3281f 100644 --- a/backend/src/s3/s3.service.spec.ts +++ b/backend/src/s3/s3.service.spec.ts @@ -197,7 +197,7 @@ describe("S3Service", () => { it("should use DOMAIN_NAME and PORT for localhost", () => { (configService.get as jest.Mock).mockImplementation( - (key: string, def: any) => { + (key: string, def: unknown) => { if (key === "API_URL") return null; if (key === "DOMAIN_NAME") return "localhost"; if (key === "PORT") return 3000; @@ -210,7 +210,7 @@ describe("S3Service", () => { it("should use api.DOMAIN_NAME for production", () => { (configService.get as jest.Mock).mockImplementation( - (key: string, def: any) => { + (key: string, def: unknown) => { if (key === "API_URL") return null; if (key === "DOMAIN_NAME") return "memegoat.fr"; return def; diff --git a/backend/src/s3/s3.service.ts b/backend/src/s3/s3.service.ts index 21d0fe0..0efa784 100644 --- a/backend/src/s3/s3.service.ts +++ b/backend/src/s3/s3.service.ts @@ -54,6 +54,7 @@ export class S3Service implements OnModuleInit, IStorageService { ...metaData, "Content-Type": mimeType, }); + this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`); return fileName; } catch (error) { 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) { try { await this.minioClient.removeObject(bucketName, fileName); + this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`); } catch (error) { this.logger.error( `Error deleting file from ${bucketName}: ${error.message}`,