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.
This commit is contained in:
Mathis HERRIOT
2026-01-14 23:13:28 +01:00
parent 4ca15b578d
commit 38adbb6e77
12 changed files with 128 additions and 38 deletions

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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 `<!DOCTYPE html>
<html>
<head>
@@ -221,19 +221,6 @@ export class ContentsService {
</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 {
return text
.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 { 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],
})

View File

@@ -155,4 +155,20 @@ export class S3Service implements OnModuleInit, IStorageService {
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 = {
uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
};
const mockConfigService = {

View File

@@ -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}`;
}
}

View File

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

View File

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