dev #11
@@ -33,4 +33,6 @@ export interface IStorageService {
|
||||
sourceBucketName?: string,
|
||||
destinationBucketName?: string,
|
||||
): Promise<string>;
|
||||
|
||||
getPublicUrl(storageKey: string): string;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("ContentsService", () => {
|
||||
const mockS3Service = {
|
||||
getUploadUrl: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
getPublicUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMediaService = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
60
backend/src/media/media.controller.spec.ts
Normal file
60
backend/src/media/media.controller.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
27
backend/src/media/media.controller.ts
Normal file
27
backend/src/media/media.controller.ts
Normal 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é");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("UsersService", () => {
|
||||
|
||||
const mockS3Service = {
|
||||
uploadFile: jest.fn(),
|
||||
getPublicUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user