dev #14
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
backend/src/common/middlewares/http-logger.middleware.ts
Normal file
37
backend/src/common/middlewares/http-logger.middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user