From f79507730eedd0599ced14032521fc0b77b801b1 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:39:56 +0100 Subject: [PATCH 1/4] ci(workflows): improve caching and optimize dependency installation Add Next.js build cache to deployment workflow for improved performance. Update all workflows to use `pnpm install --frozen-lockfile --prefer-offline` for faster and more reliable dependency management. --- .gitea/workflows/backend-tests.yml | 2 +- .gitea/workflows/deploy.yml | 13 ++++++++++++- .gitea/workflows/lint.yml | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/backend-tests.yml b/.gitea/workflows/backend-tests.yml index 930abfb..848d5e7 100644 --- a/.gitea/workflows/backend-tests.yml +++ b/.gitea/workflows/backend-tests.yml @@ -31,6 +31,6 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --prefer-offline - name: Run Backend Tests run: pnpm -F @memegoat/backend test diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fcd4620..c20ffb8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -40,8 +40,19 @@ 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 + run: pnpm install --frozen-lockfile --prefer-offline - name: Lint ${{ matrix.component }} run: pnpm -F @memegoat/${{ matrix.component }} lint diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 5e23b16..adb1c37 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -38,6 +38,6 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --prefer-offline - name: Lint ${{ matrix.component }} run: pnpm -F @memegoat/${{ matrix.component }} lint -- 2.49.1 From 8d27532dc06fbe1f1254169aa350cf618a7f03a1 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:40:36 +0100 Subject: [PATCH 2/4] feat(s3): enhance logging and public URL generation Add detailed logging for S3 uploads in user and content services. Improve public URL generation logic in `S3Service` by providing better handling for `API_URL`, `DOMAIN_NAME`, and `PORT`. Update relevant tests to cover all scenarios. --- backend/src/contents/contents.service.ts | 1 + backend/src/media/media.controller.spec.ts | 6 ++- backend/src/media/media.controller.ts | 12 +++-- backend/src/s3/s3.service.spec.ts | 56 +++++++++++----------- backend/src/s3/s3.service.ts | 16 ++++--- backend/src/users/users.service.ts | 1 + 6 files changed, 50 insertions(+), 42 deletions(-) diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts index 5f992f1..451acb5 100644 --- a/backend/src/contents/contents.service.ts +++ b/backend/src/contents/contents.service.ts @@ -100,6 +100,7 @@ export class ContentsService { // 3. Upload vers S3 const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`; await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); + this.logger.log(`File uploaded successfully to S3: ${key}`); // 4. Création en base de données return await this.create(userId, { diff --git a/backend/src/media/media.controller.spec.ts b/backend/src/media/media.controller.spec.ts index 6c371e0..608bbcb 100644 --- a/backend/src/media/media.controller.spec.ts +++ b/backend/src/media/media.controller.spec.ts @@ -28,12 +28,13 @@ describe("MediaController", () => { }); describe("getFile", () => { - it("should stream the file and set headers", async () => { + it("should stream the file and set headers with path containing slashes", async () => { const res = { setHeader: jest.fn(), } as any; const stream = new Readable(); stream.pipe = jest.fn(); + const key = "contents/user-id/test.webp"; mockS3Service.getFileInfo.mockResolvedValue({ size: 100, @@ -41,8 +42,9 @@ describe("MediaController", () => { }); mockS3Service.getFile.mockResolvedValue(stream); - await controller.getFile("test.webp", res); + await controller.getFile(key, res); + expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp"); expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100); expect(stream.pipe).toHaveBeenCalledWith(res); diff --git a/backend/src/media/media.controller.ts b/backend/src/media/media.controller.ts index b32e53e..f38edec 100644 --- a/backend/src/media/media.controller.ts +++ b/backend/src/media/media.controller.ts @@ -9,13 +9,15 @@ export class MediaController { @Get("*key") async getFile(@Param("key") key: string, @Res() res: Response) { try { - const stats = await this.s3Service.getFileInfo(key); + const stats = (await this.s3Service.getFileInfo(key)) as any; const stream = await this.s3Service.getFile(key); - res.setHeader( - "Content-Type", - stats.metaData["content-type"] || "application/octet-stream", - ); + const contentType = + stats.metaData?.["content-type"] || + stats.metadata?.["content-type"] || + "application/octet-stream"; + + res.setHeader("Content-Type", contentType); res.setHeader("Content-Length", stats.size); res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); diff --git a/backend/src/s3/s3.service.spec.ts b/backend/src/s3/s3.service.spec.ts index 570154b..4403ff1 100644 --- a/backend/src/s3/s3.service.spec.ts +++ b/backend/src/s3/s3.service.spec.ts @@ -7,7 +7,7 @@ jest.mock("minio"); describe("S3Service", () => { let service: S3Service; - let _configService: ConfigService; + let configService: ConfigService; // biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes let minioClient: any; @@ -42,7 +42,7 @@ describe("S3Service", () => { }).compile(); service = module.get(S3Service); - _configService = module.get(ConfigService); + configService = module.get(ConfigService); }); it("should be defined", () => { @@ -185,35 +185,35 @@ describe("S3Service", () => { }); }); - describe("moveFile", () => { - it("should move file within default bucket", async () => { - const source = "source.txt"; - const dest = "dest.txt"; - await service.moveFile(source, dest); - - expect(minioClient.copyObject).toHaveBeenCalledWith( - "memegoat", - dest, - "/memegoat/source.txt", - expect.any(Minio.CopyConditions), - ); - expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source); + describe("getPublicUrl", () => { + it("should use API_URL if provided", () => { + (configService.get as jest.Mock).mockImplementation((key: string) => { + if (key === "API_URL") return "https://api.test.com"; + return null; + }); + const url = service.getPublicUrl("test.webp"); + expect(url).toBe("https://api.test.com/media/test.webp"); }); - it("should move file between different buckets", async () => { - const source = "source.txt"; - const dest = "dest.txt"; - const sBucket = "source-bucket"; - const dBucket = "dest-bucket"; - await service.moveFile(source, dest, sBucket, dBucket); + it("should use DOMAIN_NAME and PORT for localhost", () => { + (configService.get as jest.Mock).mockImplementation((key: string, def: any) => { + if (key === "API_URL") return null; + if (key === "DOMAIN_NAME") return "localhost"; + if (key === "PORT") return 3000; + return def; + }); + const url = service.getPublicUrl("test.webp"); + expect(url).toBe("http://localhost:3000/media/test.webp"); + }); - expect(minioClient.copyObject).toHaveBeenCalledWith( - dBucket, - dest, - `/${sBucket}/${source}`, - expect.any(Minio.CopyConditions), - ); - expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source); + it("should use api.DOMAIN_NAME for production", () => { + (configService.get as jest.Mock).mockImplementation((key: string, def: any) => { + if (key === "API_URL") return null; + if (key === "DOMAIN_NAME") return "memegoat.fr"; + return def; + }); + const url = service.getPublicUrl("test.webp"); + expect(url).toBe("https://api.memegoat.fr/media/test.webp"); }); }); }); diff --git a/backend/src/s3/s3.service.ts b/backend/src/s3/s3.service.ts index 22e7dae..21d0fe0 100644 --- a/backend/src/s3/s3.service.ts +++ b/backend/src/s3/s3.service.ts @@ -158,17 +158,19 @@ export class S3Service implements OnModuleInit, IStorageService { getPublicUrl(storageKey: string): string { const apiUrl = this.configService.get("API_URL"); - if (apiUrl) { - return `${apiUrl.replace(/\/$/, "")}/media/${storageKey}`; - } - const domain = this.configService.get("DOMAIN_NAME", "localhost"); const port = this.configService.get("PORT", 3000); - if (domain === "localhost" || domain === "127.0.0.1") { - return `http://${domain}:${port}/media/${storageKey}`; + let baseUrl: string; + + if (apiUrl) { + baseUrl = apiUrl.replace(/\/$/, ""); + } else if (domain === "localhost" || domain === "127.0.0.1") { + baseUrl = `http://${domain}:${port}`; + } else { + baseUrl = `https://api.${domain}`; } - return `https://api.${domain}/media/${storageKey}`; + return `${baseUrl}/media/${storageKey}`; } } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 5cfb763..b2d9c31 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -143,6 +143,7 @@ export class UsersService { // 3. Upload vers S3 const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`; await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); + this.logger.log(`Avatar uploaded successfully to S3: ${key}`); // 4. Mise à jour de la base de données const user = await this.update(uuid, { avatarUrl: key }); -- 2.49.1 From eae1f84b92dfa1bdd746d91ede832838628ef2e5 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:44:44 +0100 Subject: [PATCH 3/4] ci(docker): optimize Dockerfiles with pnpm and build cache integration Switch to `node:22-alpine` for smaller base images. Introduce pnpm cache mounts and utilize `--frozen-lockfile` for faster and more reliable builds. Add Next.js build cache optimizations for `frontend` and `documentation`. --- backend/Dockerfile | 16 ++++++++++++---- documentation/Dockerfile | 19 ++++++++++++++----- frontend/Dockerfile | 19 ++++++++++++++----- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 359d907..aa66886 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,5 @@ -FROM node:22-slim AS base +# syntax=docker/dockerfile:1 +FROM node:22-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable && corepack prepare pnpm@latest --activate @@ -9,10 +10,17 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY backend/package.json ./backend/ COPY frontend/package.json ./frontend/ COPY documentation/package.json ./documentation/ -RUN pnpm install --no-frozen-lockfile + +# Utilisation du cache pour pnpm et installation figée +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + COPY . . -# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects -RUN pnpm install --no-frozen-lockfile + +# Deuxième passe avec cache pour les scripts/liens +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + RUN pnpm run --filter @memegoat/backend build RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app RUN cp -r backend/dist /app/dist diff --git a/documentation/Dockerfile b/documentation/Dockerfile index d871966..003da53 100644 --- a/documentation/Dockerfile +++ b/documentation/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker.io/docker/dockerfile:1 +# syntax=docker/dockerfile:1 FROM node:22-alpine AS base ENV PNPM_HOME="/pnpm" @@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY backend/package.json ./backend/ COPY frontend/package.json ./frontend/ COPY documentation/package.json ./documentation/ -RUN pnpm install --no-frozen-lockfile + +# Montage du cache pnpm +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + COPY . . -# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects -RUN pnpm install --no-frozen-lockfile -RUN pnpm run --filter @memegoat/documentation build + +# Deuxième passe avec cache pour les scripts/liens +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +# Build avec cache Next.js +RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \ + pnpm run --filter @memegoat/documentation build FROM node:22-alpine AS runner WORKDIR /app diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a26b050..e29df65 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker.io/docker/dockerfile:1 +# syntax=docker/dockerfile:1 FROM node:22-alpine AS base ENV PNPM_HOME="/pnpm" @@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY backend/package.json ./backend/ COPY frontend/package.json ./frontend/ COPY documentation/package.json ./documentation/ -RUN pnpm install --no-frozen-lockfile + +# Montage du cache pnpm +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + COPY . . -# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects -RUN pnpm install --no-frozen-lockfile -RUN pnpm run --filter @memegoat/frontend build + +# Deuxième passe avec cache pour les scripts/liens +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +# Build avec cache Next.js +RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \ + pnpm run --filter @memegoat/frontend build FROM node:22-alpine AS runner WORKDIR /app -- 2.49.1 From c1118e9f2564c826fafb9b97dbddae7c6fb6f709 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:44:55 +0100 Subject: [PATCH 4/4] test(s3): fix formatting of mock implementation in unit tests --- backend/src/s3/s3.service.spec.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/backend/src/s3/s3.service.spec.ts b/backend/src/s3/s3.service.spec.ts index 4403ff1..5e9cdcc 100644 --- a/backend/src/s3/s3.service.spec.ts +++ b/backend/src/s3/s3.service.spec.ts @@ -196,22 +196,26 @@ describe("S3Service", () => { }); it("should use DOMAIN_NAME and PORT for localhost", () => { - (configService.get as jest.Mock).mockImplementation((key: string, def: any) => { - if (key === "API_URL") return null; - if (key === "DOMAIN_NAME") return "localhost"; - if (key === "PORT") return 3000; - return def; - }); + (configService.get as jest.Mock).mockImplementation( + (key: string, def: any) => { + if (key === "API_URL") return null; + if (key === "DOMAIN_NAME") return "localhost"; + if (key === "PORT") return 3000; + return def; + }, + ); const url = service.getPublicUrl("test.webp"); expect(url).toBe("http://localhost:3000/media/test.webp"); }); it("should use api.DOMAIN_NAME for production", () => { - (configService.get as jest.Mock).mockImplementation((key: string, def: any) => { - if (key === "API_URL") return null; - if (key === "DOMAIN_NAME") return "memegoat.fr"; - return def; - }); + (configService.get as jest.Mock).mockImplementation( + (key: string, def: any) => { + if (key === "API_URL") return null; + if (key === "DOMAIN_NAME") return "memegoat.fr"; + return def; + }, + ); const url = service.getPublicUrl("test.webp"); expect(url).toBe("https://api.memegoat.fr/media/test.webp"); }); -- 2.49.1