Merge pull request 'dev' (#11) from dev into prod
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -33,4 +33,6 @@ export interface IStorageService {
|
|||||||
sourceBucketName?: string,
|
sourceBucketName?: string,
|
||||||
destinationBucketName?: string,
|
destinationBucketName?: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
|
getPublicUrl(storageKey: string): string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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 { 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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe("UsersService", () => {
|
|||||||
|
|
||||||
const mockS3Service = {
|
const mockS3Service = {
|
||||||
uploadFile: jest.fn(),
|
uploadFile: jest.fn(),
|
||||||
|
getPublicUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockConfigService = {
|
const mockConfigService = {
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user