Merge pull request 'dev' (#11) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m37s

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-01-14 23:23:06 +01:00
12 changed files with 128 additions and 38 deletions

View File

@@ -33,4 +33,6 @@ export interface IStorageService {
sourceBucketName?: string, sourceBucketName?: string,
destinationBucketName?: string, destinationBucketName?: string,
): Promise<string>; ): Promise<string>;
getPublicUrl(storageKey: string): string;
} }

View File

@@ -33,6 +33,7 @@ export const envSchema = z.object({
MAIL_FROM: z.string().email(), MAIL_FROM: z.string().email(),
DOMAIN_NAME: z.string(), DOMAIN_NAME: z.string(),
API_URL: z.string().url().optional(),
// Sentry // Sentry
SENTRY_DSN: z.string().optional(), SENTRY_DSN: z.string().optional(),

View File

@@ -30,6 +30,7 @@ describe("ContentsService", () => {
const mockS3Service = { const mockS3Service = {
getUploadUrl: jest.fn(), getUploadUrl: jest.fn(),
uploadFile: jest.fn(), uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
}; };
const mockMediaService = { const mockMediaService = {

View File

@@ -128,11 +128,11 @@ export class ContentsService {
const processedData = data.map((content) => ({ const processedData = data.map((content) => ({
...content, ...content,
url: this.getFileUrl(content.storageKey), url: this.s3Service.getPublicUrl(content.storageKey),
author: { author: {
...content.author, ...content.author,
avatarUrl: content.author?.avatarUrl avatarUrl: content.author?.avatarUrl
? this.getFileUrl(content.author.avatarUrl) ? this.s3Service.getPublicUrl(content.author.avatarUrl)
: null, : null,
}, },
})); }));
@@ -189,18 +189,18 @@ export class ContentsService {
return { return {
...content, ...content,
url: this.getFileUrl(content.storageKey), url: this.s3Service.getPublicUrl(content.storageKey),
author: { author: {
...content.author, ...content.author,
avatarUrl: content.author?.avatarUrl avatarUrl: content.author?.avatarUrl
? this.getFileUrl(content.author.avatarUrl) ? this.s3Service.getPublicUrl(content.author.avatarUrl)
: null, : null,
}, },
}; };
} }
generateBotHtml(content: { title: string; storageKey: string }): string { generateBotHtml(content: { title: string; storageKey: string }): string {
const imageUrl = this.getFileUrl(content.storageKey); const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -221,19 +221,6 @@ export class ContentsService {
</html>`; </html>`;
} }
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 { private generateSlug(text: string): string {
return text return text
.toLowerCase() .toLowerCase()

View File

@@ -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>(MediaController);
s3Service = module.get<S3Service>(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,
);
});
});
});

View File

@@ -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é");
}
}
}

View File

@@ -1,9 +1,13 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { S3Module } from "../s3/s3.module";
import { MediaController } from "./media.controller";
import { MediaService } from "./media.service"; import { MediaService } from "./media.service";
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy"; import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy"; import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
@Module({ @Module({
imports: [S3Module],
controllers: [MediaController],
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy], providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
exports: [MediaService], exports: [MediaService],
}) })

View File

@@ -155,4 +155,20 @@ export class S3Service implements OnModuleInit, IStorageService {
throw error; throw error;
} }
} }
getPublicUrl(storageKey: string): string {
const apiUrl = this.configService.get<string>("API_URL");
if (apiUrl) {
return `${apiUrl.replace(/\/$/, "")}/media/${storageKey}`;
}
const domain = this.configService.get<string>("DOMAIN_NAME", "localhost");
const port = this.configService.get<number>("PORT", 3000);
if (domain === "localhost" || domain === "127.0.0.1") {
return `http://${domain}:${port}/media/${storageKey}`;
}
return `https://api.${domain}/media/${storageKey}`;
}
} }

View File

@@ -58,6 +58,7 @@ describe("UsersService", () => {
const mockS3Service = { const mockS3Service = {
uploadFile: jest.fn(), uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
}; };
const mockConfigService = { const mockConfigService = {

View File

@@ -6,7 +6,6 @@ import {
Injectable, Injectable,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Cache } from "cache-manager"; import type { Cache } from "cache-manager";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { RbacService } from "../auth/rbac.service"; import { RbacService } from "../auth/rbac.service";
@@ -28,7 +27,6 @@ export class UsersService {
private readonly rbacService: RbacService, private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService, @Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService, @Inject(S3Service) private readonly s3Service: IStorageService,
private readonly configService: ConfigService,
) {} ) {}
private async clearUserCache(username?: string) { private async clearUserCache(username?: string) {
@@ -60,7 +58,9 @@ export class UsersService {
return { return {
...user, ...user,
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
role: roles.includes("admin") ? "admin" : "user", role: roles.includes("admin") ? "admin" : "user",
roles, roles,
}; };
@@ -74,7 +74,9 @@ export class UsersService {
const processedData = data.map((user) => ({ const processedData = data.map((user) => ({
...user, ...user,
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
})); }));
return { data: processedData, totalCount }; return { data: processedData, totalCount };
@@ -86,7 +88,9 @@ export class UsersService {
return { return {
...user, ...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) { async remove(uuid: string) {
return await this.usersRepository.softDeleteUserAndContents(uuid); 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}`;
}
} }

View File

@@ -63,7 +63,7 @@ export default function HelpPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p> </p>
<p className="font-semibold text-primary">contact@memegoat.local</p> <p className="font-semibold text-primary">contact@memegoat.fr</p>
</div> </div>
</div> </div>
); );

View File

@@ -228,7 +228,7 @@ export function AppSidebar() {
<span className="truncate font-semibold"> <span className="truncate font-semibold">
{user.displayName || user.username} {user.displayName || user.username}
</span> </span>
<span className="truncate text-xs">{user.email}</span> <span className="truncate text-xs">{user.role}</span>
</div> </div>
<ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" /> <ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton> </SidebarMenuButton>