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}