From 38adbb6e77bf8f6596f3127e01753cc78e4879ed Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:13:28 +0100 Subject: [PATCH] feat(media): add public URL generation for media files and improve S3 integration Introduce `getPublicUrl` in `S3Service` for generating public URLs. Replace custom file URL generation logic across services with the new method. Add media controller for file streaming and update related tests. Adjust frontend to display user roles instead of email in the sidebar. Update environment schema to include optional `API_URL`. Fix help page contact email. --- .../common/interfaces/storage.interface.ts | 2 + backend/src/config/env.schema.ts | 1 + backend/src/contents/contents.service.spec.ts | 1 + backend/src/contents/contents.service.ts | 23 ++----- backend/src/media/media.controller.spec.ts | 60 +++++++++++++++++++ backend/src/media/media.controller.ts | 27 +++++++++ backend/src/media/media.module.ts | 4 ++ backend/src/s3/s3.service.ts | 16 +++++ backend/src/users/users.service.spec.ts | 1 + backend/src/users/users.service.ts | 27 +++------ frontend/src/app/(dashboard)/help/page.tsx | 2 +- frontend/src/components/app-sidebar.tsx | 2 +- 12 files changed, 128 insertions(+), 38 deletions(-) create mode 100644 backend/src/media/media.controller.spec.ts create mode 100644 backend/src/media/media.controller.ts diff --git a/backend/src/common/interfaces/storage.interface.ts b/backend/src/common/interfaces/storage.interface.ts index f0d7f0a..b32ac96 100644 --- a/backend/src/common/interfaces/storage.interface.ts +++ b/backend/src/common/interfaces/storage.interface.ts @@ -33,4 +33,6 @@ export interface IStorageService { sourceBucketName?: string, destinationBucketName?: string, ): Promise; + + getPublicUrl(storageKey: string): string; } diff --git a/backend/src/config/env.schema.ts b/backend/src/config/env.schema.ts index fcb7544..628d1cf 100644 --- a/backend/src/config/env.schema.ts +++ b/backend/src/config/env.schema.ts @@ -33,6 +33,7 @@ export const envSchema = z.object({ MAIL_FROM: z.string().email(), DOMAIN_NAME: z.string(), + API_URL: z.string().url().optional(), // Sentry SENTRY_DSN: z.string().optional(), diff --git a/backend/src/contents/contents.service.spec.ts b/backend/src/contents/contents.service.spec.ts index cc6059f..75307bb 100644 --- a/backend/src/contents/contents.service.spec.ts +++ b/backend/src/contents/contents.service.spec.ts @@ -30,6 +30,7 @@ describe("ContentsService", () => { const mockS3Service = { getUploadUrl: jest.fn(), uploadFile: jest.fn(), + getPublicUrl: jest.fn(), }; const mockMediaService = { diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts index d933c66..5f992f1 100644 --- a/backend/src/contents/contents.service.ts +++ b/backend/src/contents/contents.service.ts @@ -128,11 +128,11 @@ export class ContentsService { const processedData = data.map((content) => ({ ...content, - url: this.getFileUrl(content.storageKey), + url: this.s3Service.getPublicUrl(content.storageKey), author: { ...content.author, avatarUrl: content.author?.avatarUrl - ? this.getFileUrl(content.author.avatarUrl) + ? this.s3Service.getPublicUrl(content.author.avatarUrl) : null, }, })); @@ -189,18 +189,18 @@ export class ContentsService { return { ...content, - url: this.getFileUrl(content.storageKey), + url: this.s3Service.getPublicUrl(content.storageKey), author: { ...content.author, avatarUrl: content.author?.avatarUrl - ? this.getFileUrl(content.author.avatarUrl) + ? this.s3Service.getPublicUrl(content.author.avatarUrl) : null, }, }; } generateBotHtml(content: { title: string; storageKey: string }): string { - const imageUrl = this.getFileUrl(content.storageKey); + const imageUrl = this.s3Service.getPublicUrl(content.storageKey); return ` @@ -221,19 +221,6 @@ export class ContentsService { `; } - getFileUrl(storageKey: string): string { - const endpoint = this.configService.get("S3_ENDPOINT"); - const port = this.configService.get("S3_PORT"); - const protocol = - this.configService.get("S3_USE_SSL") === true ? "https" : "http"; - const bucket = this.configService.get("S3_BUCKET_NAME"); - - if (endpoint === "localhost" || endpoint === "127.0.0.1") { - return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`; - } - return `${protocol}://${endpoint}/${bucket}/${storageKey}`; - } - private generateSlug(text: string): string { return text .toLowerCase() diff --git a/backend/src/media/media.controller.spec.ts b/backend/src/media/media.controller.spec.ts new file mode 100644 index 0000000..6c371e0 --- /dev/null +++ b/backend/src/media/media.controller.spec.ts @@ -0,0 +1,60 @@ +import { Readable } from "node:stream"; +import { NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { S3Service } from "../s3/s3.service"; +import { MediaController } from "./media.controller"; + +describe("MediaController", () => { + let controller: MediaController; + let s3Service: S3Service; + + const mockS3Service = { + getFileInfo: jest.fn(), + getFile: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MediaController], + providers: [{ provide: S3Service, useValue: mockS3Service }], + }).compile(); + + controller = module.get(MediaController); + s3Service = module.get(S3Service); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("getFile", () => { + it("should stream the file and set headers", async () => { + const res = { + setHeader: jest.fn(), + } as any; + const stream = new Readable(); + stream.pipe = jest.fn(); + + mockS3Service.getFileInfo.mockResolvedValue({ + size: 100, + metaData: { "content-type": "image/webp" }, + }); + mockS3Service.getFile.mockResolvedValue(stream); + + await controller.getFile("test.webp", res); + + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp"); + expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100); + expect(stream.pipe).toHaveBeenCalledWith(res); + }); + + it("should throw NotFoundException if file is not found", async () => { + mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found")); + const res = {} as any; + + 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 new file mode 100644 index 0000000..dddd313 --- /dev/null +++ b/backend/src/media/media.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common"; +import type { Response } from "express"; +import { S3Service } from "../s3/s3.service"; + +@Controller("media") +export class MediaController { + constructor(private readonly s3Service: S3Service) {} + + @Get(":key(*)") + async getFile(@Param("key") key: string, @Res() res: Response) { + try { + const stats = await this.s3Service.getFileInfo(key); + const stream = await this.s3Service.getFile(key); + + res.setHeader( + "Content-Type", + stats.metaData["content-type"] || "application/octet-stream", + ); + res.setHeader("Content-Length", stats.size); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + + stream.pipe(res); + } catch (_error) { + throw new NotFoundException("Fichier non trouvé"); + } + } +} diff --git a/backend/src/media/media.module.ts b/backend/src/media/media.module.ts index 6118f3b..d72c06d 100644 --- a/backend/src/media/media.module.ts +++ b/backend/src/media/media.module.ts @@ -1,9 +1,13 @@ import { Module } from "@nestjs/common"; +import { S3Module } from "../s3/s3.module"; +import { MediaController } from "./media.controller"; import { MediaService } from "./media.service"; import { ImageProcessorStrategy } from "./strategies/image-processor.strategy"; import { VideoProcessorStrategy } from "./strategies/video-processor.strategy"; @Module({ + imports: [S3Module], + controllers: [MediaController], providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy], exports: [MediaService], }) diff --git a/backend/src/s3/s3.service.ts b/backend/src/s3/s3.service.ts index e511426..22e7dae 100644 --- a/backend/src/s3/s3.service.ts +++ b/backend/src/s3/s3.service.ts @@ -155,4 +155,20 @@ export class S3Service implements OnModuleInit, IStorageService { throw error; } } + + 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}`; + } + + return `https://api.${domain}/media/${storageKey}`; + } } diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 4ba94c3..a203946 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -58,6 +58,7 @@ describe("UsersService", () => { const mockS3Service = { uploadFile: jest.fn(), + getPublicUrl: jest.fn(), }; const mockConfigService = { diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index ad2f4d9..5cfb763 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -6,7 +6,6 @@ import { Injectable, Logger, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import type { Cache } from "cache-manager"; import { v4 as uuidv4 } from "uuid"; import { RbacService } from "../auth/rbac.service"; @@ -28,7 +27,6 @@ export class UsersService { private readonly rbacService: RbacService, @Inject(MediaService) private readonly mediaService: IMediaService, @Inject(S3Service) private readonly s3Service: IStorageService, - private readonly configService: ConfigService, ) {} private async clearUserCache(username?: string) { @@ -60,7 +58,9 @@ export class UsersService { return { ...user, - avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + avatarUrl: user.avatarUrl + ? this.s3Service.getPublicUrl(user.avatarUrl) + : null, role: roles.includes("admin") ? "admin" : "user", roles, }; @@ -74,7 +74,9 @@ export class UsersService { const processedData = data.map((user) => ({ ...user, - avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + avatarUrl: user.avatarUrl + ? this.s3Service.getPublicUrl(user.avatarUrl) + : null, })); return { data: processedData, totalCount }; @@ -86,7 +88,9 @@ export class UsersService { return { ...user, - avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + avatarUrl: user.avatarUrl + ? this.s3Service.getPublicUrl(user.avatarUrl) + : null, }; } @@ -193,17 +197,4 @@ export class UsersService { async remove(uuid: string) { return await this.usersRepository.softDeleteUserAndContents(uuid); } - - private getFileUrl(storageKey: string): string { - const endpoint = this.configService.get("S3_ENDPOINT"); - const port = this.configService.get("S3_PORT"); - const protocol = - this.configService.get("S3_USE_SSL") === true ? "https" : "http"; - const bucket = this.configService.get("S3_BUCKET_NAME"); - - if (endpoint === "localhost" || endpoint === "127.0.0.1") { - return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`; - } - return `${protocol}://${endpoint}/${bucket}/${storageKey}`; - } } diff --git a/frontend/src/app/(dashboard)/help/page.tsx b/frontend/src/app/(dashboard)/help/page.tsx index a07d148..0bcfc01 100644 --- a/frontend/src/app/(dashboard)/help/page.tsx +++ b/frontend/src/app/(dashboard)/help/page.tsx @@ -63,7 +63,7 @@ export default function HelpPage() {

N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.

-

contact@memegoat.local

+

contact@memegoat.fr

); diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index ad67847..df1aa50 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -228,7 +228,7 @@ export function AppSidebar() { {user.displayName || user.username} - {user.email} + {user.role} -- 2.49.1