feat: add modular services and repositories for improved code organization

Introduce repository pattern across multiple services, including `favorites`, `tags`, `sessions`, `reports`, `auth`, and more. Decouple crypto functionalities into modular services like `HashingService`, `JwtService`, and `EncryptionService`. Improve testability and maintainability by simplifying dependencies and consolidating utility logic.
This commit is contained in:
Mathis HERRIOT
2026-01-14 12:11:39 +01:00
parent 9c45bf11e4
commit 514bd354bf
64 changed files with 1801 additions and 1295 deletions

View File

@@ -4,11 +4,12 @@ import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module"; import { DatabaseModule } from "../database/database.module";
import { ApiKeysController } from "./api-keys.controller"; import { ApiKeysController } from "./api-keys.controller";
import { ApiKeysService } from "./api-keys.service"; import { ApiKeysService } from "./api-keys.service";
import { ApiKeysRepository } from "./repositories/api-keys.repository";
@Module({ @Module({
imports: [DatabaseModule, AuthModule, CryptoModule], imports: [DatabaseModule, AuthModule, CryptoModule],
controllers: [ApiKeysController], controllers: [ApiKeysController],
providers: [ApiKeysService], providers: [ApiKeysService, ApiKeysRepository],
exports: [ApiKeysService], exports: [ApiKeysService],
}) })
export class ApiKeysModule {} export class ApiKeysModule {}

View File

@@ -1,58 +1,43 @@
import { createHash } from "node:crypto";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service"; import { HashingService } from "../crypto/services/hashing.service";
import { apiKeys } from "../database/schemas";
import { ApiKeysService } from "./api-keys.service"; import { ApiKeysService } from "./api-keys.service";
import { ApiKeysRepository } from "./repositories/api-keys.repository";
describe("ApiKeysService", () => { describe("ApiKeysService", () => {
let service: ApiKeysService; let service: ApiKeysService;
let repository: ApiKeysRepository;
const mockDb = { const mockApiKeysRepository = {
insert: jest.fn(), create: jest.fn(),
values: jest.fn(), findAll: jest.fn(),
select: jest.fn(), revoke: jest.fn(),
from: jest.fn(), findActiveByKeyHash: jest.fn(),
where: jest.fn(), updateLastUsed: jest.fn(),
limit: jest.fn(), };
update: jest.fn(),
set: jest.fn(), const mockHashingService = {
returning: jest.fn(), hashSha256: jest.fn().mockResolvedValue("hashed-key"),
}; };
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
mockDb.insert.mockReturnThis();
mockDb.values.mockResolvedValue(undefined);
mockDb.select.mockReturnThis();
mockDb.from.mockReturnThis();
mockDb.where.mockReturnThis();
mockDb.limit.mockReturnThis();
mockDb.update.mockReturnThis();
mockDb.set.mockReturnThis();
mockDb.returning.mockResolvedValue([]);
// Default for findAll which is awaited on where()
mockDb.where.mockImplementation(() => {
const chain = {
returning: jest.fn().mockResolvedValue([]),
};
return Object.assign(Promise.resolve([]), chain);
});
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
ApiKeysService, ApiKeysService,
{ {
provide: DatabaseService, provide: ApiKeysRepository,
useValue: { useValue: mockApiKeysRepository,
db: mockDb, },
}, {
provide: HashingService,
useValue: mockHashingService,
}, },
], ],
}).compile(); }).compile();
service = module.get<ApiKeysService>(ApiKeysService); service = module.get<ApiKeysService>(ApiKeysService);
repository = module.get<ApiKeysRepository>(ApiKeysRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -67,8 +52,7 @@ describe("ApiKeysService", () => {
const result = await service.create(userId, name, expiresAt); const result = await service.create(userId, name, expiresAt);
expect(mockDb.insert).toHaveBeenCalledWith(apiKeys); expect(repository.create).toHaveBeenCalledWith(
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
userId, userId,
name, name,
@@ -87,12 +71,11 @@ describe("ApiKeysService", () => {
it("should find all API keys for a user", async () => { it("should find all API keys for a user", async () => {
const userId = "user-id"; const userId = "user-id";
const expectedKeys = [{ id: "1", name: "Key 1" }]; const expectedKeys = [{ id: "1", name: "Key 1" }];
(mockDb.where as jest.Mock).mockResolvedValue(expectedKeys); mockApiKeysRepository.findAll.mockResolvedValue(expectedKeys);
const result = await service.findAll(userId); const result = await service.findAll(userId);
expect(mockDb.select).toHaveBeenCalled(); expect(repository.findAll).toHaveBeenCalledWith(userId);
expect(mockDb.from).toHaveBeenCalledWith(apiKeys);
expect(result).toEqual(expectedKeys); expect(result).toEqual(expectedKeys);
}); });
}); });
@@ -102,17 +85,11 @@ describe("ApiKeysService", () => {
const userId = "user-id"; const userId = "user-id";
const keyId = "key-id"; const keyId = "key-id";
const expectedResult = [{ id: keyId, isActive: false }]; const expectedResult = [{ id: keyId, isActive: false }];
mockApiKeysRepository.revoke.mockResolvedValue(expectedResult);
mockDb.where.mockReturnValue({
returning: jest.fn().mockResolvedValue(expectedResult),
});
const result = await service.revoke(userId, keyId); const result = await service.revoke(userId, keyId);
expect(mockDb.update).toHaveBeenCalledWith(apiKeys); expect(repository.revoke).toHaveBeenCalledWith(userId, keyId);
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ isActive: false }),
);
expect(result).toEqual(expectedResult); expect(result).toEqual(expectedResult);
}); });
}); });
@@ -120,42 +97,19 @@ describe("ApiKeysService", () => {
describe("validateKey", () => { describe("validateKey", () => {
it("should validate a valid API key", async () => { it("should validate a valid API key", async () => {
const key = "mg_live_testkey"; const key = "mg_live_testkey";
const keyHash = createHash("sha256").update(key).digest("hex"); const apiKey = { id: "1", isActive: true, expiresAt: null };
const apiKey = { id: "1", keyHash, isActive: true, expiresAt: null }; mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(apiKey);
(mockDb.limit as jest.Mock).mockResolvedValue([apiKey]);
(mockDb.where as jest.Mock).mockResolvedValue([apiKey]); // For the update later if needed, but here it's for select
// We need to be careful with chaining mockDb.where is used in both select and update
const mockSelect = {
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockResolvedValue([apiKey]),
};
const mockUpdate = {
set: jest.fn().mockReturnThis(),
where: jest.fn().mockResolvedValue(undefined),
};
(mockDb.select as jest.Mock).mockReturnValue(mockSelect);
(mockDb.update as jest.Mock).mockReturnValue(mockUpdate);
const result = await service.validateKey(key); const result = await service.validateKey(key);
expect(result).toEqual(apiKey); expect(result).toEqual(apiKey);
expect(mockDb.select).toHaveBeenCalled(); expect(repository.findActiveByKeyHash).toHaveBeenCalled();
expect(mockDb.update).toHaveBeenCalledWith(apiKeys); expect(repository.updateLastUsed).toHaveBeenCalledWith(apiKey.id);
}); });
it("should return null for invalid API key", async () => { it("should return null for invalid API key", async () => {
(mockDb.select as jest.Mock).mockReturnValue({ mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(null);
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockResolvedValue([]),
});
const result = await service.validateKey("invalid-key"); const result = await service.validateKey("invalid-key");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -164,12 +118,7 @@ describe("ApiKeysService", () => {
const expiredDate = new Date(); const expiredDate = new Date();
expiredDate.setFullYear(expiredDate.getFullYear() - 1); expiredDate.setFullYear(expiredDate.getFullYear() - 1);
const apiKey = { id: "1", isActive: true, expiresAt: expiredDate }; const apiKey = { id: "1", isActive: true, expiresAt: expiredDate };
mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(apiKey);
(mockDb.select as jest.Mock).mockReturnValue({
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockResolvedValue([apiKey]),
});
const result = await service.validateKey(key); const result = await service.validateKey(key);

View File

@@ -1,14 +1,16 @@
import { createHash, randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { and, eq } from "drizzle-orm"; import { HashingService } from "../crypto/services/hashing.service";
import { DatabaseService } from "../database/database.service"; import { ApiKeysRepository } from "./repositories/api-keys.repository";
import { apiKeys } from "../database/schemas";
@Injectable() @Injectable()
export class ApiKeysService { export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name); private readonly logger = new Logger(ApiKeysService.name);
constructor(private readonly databaseService: DatabaseService) {} constructor(
private readonly apiKeysRepository: ApiKeysRepository,
private readonly hashingService: HashingService,
) {}
async create(userId: string, name: string, expiresAt?: Date) { async create(userId: string, name: string, expiresAt?: Date) {
this.logger.log(`Creating API key for user ${userId}: ${name}`); this.logger.log(`Creating API key for user ${userId}: ${name}`);
@@ -16,9 +18,9 @@ export class ApiKeysService {
const randomPart = randomBytes(24).toString("hex"); const randomPart = randomBytes(24).toString("hex");
const key = `${prefix}${randomPart}`; const key = `${prefix}${randomPart}`;
const keyHash = createHash("sha256").update(key).digest("hex"); const keyHash = await this.hashingService.hashSha256(key);
await this.databaseService.db.insert(apiKeys).values({ await this.apiKeysRepository.create({
userId, userId,
name, name,
prefix: prefix.substring(0, 8), prefix: prefix.substring(0, 8),
@@ -34,37 +36,18 @@ export class ApiKeysService {
} }
async findAll(userId: string) { async findAll(userId: string) {
return await this.databaseService.db return await this.apiKeysRepository.findAll(userId);
.select({
id: apiKeys.id,
name: apiKeys.name,
prefix: apiKeys.prefix,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId));
} }
async revoke(userId: string, keyId: string) { async revoke(userId: string, keyId: string) {
this.logger.log(`Revoking API key ${keyId} for user ${userId}`); this.logger.log(`Revoking API key ${keyId} for user ${userId}`);
return await this.databaseService.db return await this.apiKeysRepository.revoke(userId, keyId);
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)))
.returning();
} }
async validateKey(key: string) { async validateKey(key: string) {
const keyHash = createHash("sha256").update(key).digest("hex"); const keyHash = await this.hashingService.hashSha256(key);
const [apiKey] = await this.databaseService.db const apiKey = await this.apiKeysRepository.findActiveByKeyHash(keyHash);
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
.limit(1);
if (!apiKey) return null; if (!apiKey) return null;
@@ -73,10 +56,7 @@ export class ApiKeysService {
} }
// Update last used at // Update last used at
await this.databaseService.db await this.apiKeysRepository.updateLastUsed(apiKey.id);
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, apiKey.id));
return apiKey; return apiKey;
} }

View File

@@ -0,0 +1,58 @@
import { Injectable } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { apiKeys } from "../../database/schemas";
@Injectable()
export class ApiKeysRepository {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: {
userId: string;
name: string;
prefix: string;
keyHash: string;
expiresAt?: Date;
}) {
return await this.databaseService.db.insert(apiKeys).values(data);
}
async findAll(userId: string) {
return await this.databaseService.db
.select({
id: apiKeys.id,
name: apiKeys.name,
prefix: apiKeys.prefix,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId));
}
async revoke(userId: string, keyId: string) {
return await this.databaseService.db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)))
.returning();
}
async findActiveByKeyHash(keyHash: string) {
const result = await this.databaseService.db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
.limit(1);
return result[0] || null;
}
async updateLastUsed(id: string) {
return await this.databaseService.db
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, id));
}
}

View File

@@ -6,11 +6,12 @@ import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { RbacService } from "./rbac.service"; import { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository";
@Module({ @Module({
imports: [UsersModule, CryptoModule, SessionsModule, DatabaseModule], imports: [UsersModule, CryptoModule, SessionsModule, DatabaseModule],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, RbacService], providers: [AuthService, RbacService, RbacRepository],
exports: [AuthService, RbacService], exports: [AuthService, RbacService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -17,14 +17,14 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { authenticator } from "otplib"; import { authenticator } from "otplib";
import * as qrcode from "qrcode"; import * as qrcode from "qrcode";
import { CryptoService } from "../crypto/crypto.service"; import { HashingService } from "../crypto/services/hashing.service";
import { JwtService } from "../crypto/services/jwt.service";
import { SessionsService } from "../sessions/sessions.service"; import { SessionsService } from "../sessions/sessions.service";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
jest.mock("otplib"); jest.mock("otplib");
jest.mock("qrcode"); jest.mock("qrcode");
jest.mock("../crypto/crypto.service");
jest.mock("../users/users.service"); jest.mock("../users/users.service");
jest.mock("../sessions/sessions.service"); jest.mock("../sessions/sessions.service");
@@ -41,10 +41,13 @@ describe("AuthService", () => {
findOneWithPrivateData: jest.fn(), findOneWithPrivateData: jest.fn(),
}; };
const mockCryptoService = { const mockHashingService = {
hashPassword: jest.fn(), hashPassword: jest.fn(),
hashEmail: jest.fn(), hashEmail: jest.fn(),
verifyPassword: jest.fn(), verifyPassword: jest.fn(),
};
const mockJwtService = {
generateJwt: jest.fn(), generateJwt: jest.fn(),
}; };
@@ -62,7 +65,8 @@ describe("AuthService", () => {
providers: [ providers: [
AuthService, AuthService,
{ provide: UsersService, useValue: mockUsersService }, { provide: UsersService, useValue: mockUsersService },
{ provide: CryptoService, useValue: mockCryptoService }, { provide: HashingService, useValue: mockHashingService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: SessionsService, useValue: mockSessionsService }, { provide: SessionsService, useValue: mockSessionsService },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
], ],
@@ -142,8 +146,8 @@ describe("AuthService", () => {
email: "test@example.com", email: "test@example.com",
password: "password", password: "password",
}; };
mockCryptoService.hashPassword.mockResolvedValue("hashed-password"); mockHashingService.hashPassword.mockResolvedValue("hashed-password");
mockCryptoService.hashEmail.mockResolvedValue("hashed-email"); mockHashingService.hashEmail.mockResolvedValue("hashed-email");
mockUsersService.create.mockResolvedValue({ uuid: "new-user-id" }); mockUsersService.create.mockResolvedValue({ uuid: "new-user-id" });
const result = await service.register(dto); const result = await service.register(dto);
@@ -164,10 +168,10 @@ describe("AuthService", () => {
passwordHash: "hash", passwordHash: "hash",
isTwoFactorEnabled: false, isTwoFactorEnabled: false,
}; };
mockCryptoService.hashEmail.mockResolvedValue("hashed-email"); mockHashingService.hashEmail.mockResolvedValue("hashed-email");
mockUsersService.findByEmailHash.mockResolvedValue(user); mockUsersService.findByEmailHash.mockResolvedValue(user);
mockCryptoService.verifyPassword.mockResolvedValue(true); mockHashingService.verifyPassword.mockResolvedValue(true);
mockCryptoService.generateJwt.mockResolvedValue("access-token"); mockJwtService.generateJwt.mockResolvedValue("access-token");
mockSessionsService.createSession.mockResolvedValue({ mockSessionsService.createSession.mockResolvedValue({
refreshToken: "refresh-token", refreshToken: "refresh-token",
}); });
@@ -189,9 +193,9 @@ describe("AuthService", () => {
passwordHash: "hash", passwordHash: "hash",
isTwoFactorEnabled: true, isTwoFactorEnabled: true,
}; };
mockCryptoService.hashEmail.mockResolvedValue("hashed-email"); mockHashingService.hashEmail.mockResolvedValue("hashed-email");
mockUsersService.findByEmailHash.mockResolvedValue(user); mockUsersService.findByEmailHash.mockResolvedValue(user);
mockCryptoService.verifyPassword.mockResolvedValue(true); mockHashingService.verifyPassword.mockResolvedValue(true);
const result = await service.login(dto); const result = await service.login(dto);
@@ -218,7 +222,7 @@ describe("AuthService", () => {
mockUsersService.findOneWithPrivateData.mockResolvedValue(user); mockUsersService.findOneWithPrivateData.mockResolvedValue(user);
mockUsersService.getTwoFactorSecret.mockResolvedValue("secret"); mockUsersService.getTwoFactorSecret.mockResolvedValue("secret");
(authenticator.verify as jest.Mock).mockReturnValue(true); (authenticator.verify as jest.Mock).mockReturnValue(true);
mockCryptoService.generateJwt.mockResolvedValue("access-token"); mockJwtService.generateJwt.mockResolvedValue("access-token");
mockSessionsService.createSession.mockResolvedValue({ mockSessionsService.createSession.mockResolvedValue({
refreshToken: "refresh-token", refreshToken: "refresh-token",
}); });
@@ -240,7 +244,7 @@ describe("AuthService", () => {
const user = { uuid: "user-id", username: "test" }; const user = { uuid: "user-id", username: "test" };
mockSessionsService.refreshSession.mockResolvedValue(session); mockSessionsService.refreshSession.mockResolvedValue(session);
mockUsersService.findOne.mockResolvedValue(user); mockUsersService.findOne.mockResolvedValue(user);
mockCryptoService.generateJwt.mockResolvedValue("new-access"); mockJwtService.generateJwt.mockResolvedValue("new-access");
const result = await service.refresh(refreshToken); const result = await service.refresh(refreshToken);

View File

@@ -7,7 +7,8 @@ import {
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { authenticator } from "otplib"; import { authenticator } from "otplib";
import { toDataURL } from "qrcode"; import { toDataURL } from "qrcode";
import { CryptoService } from "../crypto/crypto.service"; import { HashingService } from "../crypto/services/hashing.service";
import { JwtService } from "../crypto/services/jwt.service";
import { SessionsService } from "../sessions/sessions.service"; import { SessionsService } from "../sessions/sessions.service";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { LoginDto } from "./dto/login.dto"; import { LoginDto } from "./dto/login.dto";
@@ -19,7 +20,8 @@ export class AuthService {
constructor( constructor(
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly cryptoService: CryptoService, private readonly hashingService: HashingService,
private readonly jwtService: JwtService,
private readonly sessionsService: SessionsService, private readonly sessionsService: SessionsService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
@@ -81,8 +83,8 @@ export class AuthService {
this.logger.log(`Registering new user: ${dto.username}`); this.logger.log(`Registering new user: ${dto.username}`);
const { username, email, password } = dto; const { username, email, password } = dto;
const passwordHash = await this.cryptoService.hashPassword(password); const passwordHash = await this.hashingService.hashPassword(password);
const emailHash = await this.cryptoService.hashEmail(email); const emailHash = await this.hashingService.hashEmail(email);
const user = await this.usersService.create({ const user = await this.usersService.create({
username, username,
@@ -101,14 +103,14 @@ export class AuthService {
this.logger.log(`Login attempt for email: ${dto.email}`); this.logger.log(`Login attempt for email: ${dto.email}`);
const { email, password } = dto; const { email, password } = dto;
const emailHash = await this.cryptoService.hashEmail(email); const emailHash = await this.hashingService.hashEmail(email);
const user = await this.usersService.findByEmailHash(emailHash); const user = await this.usersService.findByEmailHash(emailHash);
if (!user) { if (!user) {
throw new UnauthorizedException("Invalid credentials"); throw new UnauthorizedException("Invalid credentials");
} }
const isPasswordValid = await this.cryptoService.verifyPassword( const isPasswordValid = await this.hashingService.verifyPassword(
password, password,
user.passwordHash, user.passwordHash,
); );
@@ -125,7 +127,7 @@ export class AuthService {
}; };
} }
const accessToken = await this.cryptoService.generateJwt({ const accessToken = await this.jwtService.generateJwt({
sub: user.uuid, sub: user.uuid,
username: user.username, username: user.username,
}); });
@@ -163,7 +165,7 @@ export class AuthService {
throw new UnauthorizedException("Invalid 2FA token"); throw new UnauthorizedException("Invalid 2FA token");
} }
const accessToken = await this.cryptoService.generateJwt({ const accessToken = await this.jwtService.generateJwt({
sub: user.uuid, sub: user.uuid,
username: user.username, username: user.username,
}); });
@@ -189,7 +191,7 @@ export class AuthService {
throw new UnauthorizedException("User not found"); throw new UnauthorizedException("User not found");
} }
const accessToken = await this.cryptoService.generateJwt({ const accessToken = await this.jwtService.generateJwt({
sub: user.uuid, sub: user.uuid,
username: user.username, username: user.username,
}); });

View File

@@ -6,13 +6,13 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session"; import { getIronSession } from "iron-session";
import { CryptoService } from "../../crypto/crypto.service"; import { JwtService } from "../../crypto/services/jwt.service";
import { getSessionOptions, SessionData } from "../session.config"; import { getSessionOptions, SessionData } from "../session.config";
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor( constructor(
private readonly cryptoService: CryptoService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
@@ -33,7 +33,7 @@ export class AuthGuard implements CanActivate {
} }
try { try {
const payload = await this.cryptoService.verifyJwt(token); const payload = await this.jwtService.verifyJwt(token);
request.user = payload; request.user = payload;
} catch { } catch {
throw new UnauthorizedException(); throw new UnauthorizedException();

View File

@@ -1,15 +1,14 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service";
import { RbacService } from "./rbac.service"; import { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository";
describe("RbacService", () => { describe("RbacService", () => {
let service: RbacService; let service: RbacService;
let repository: RbacRepository;
const mockDb = { const mockRbacRepository = {
select: jest.fn().mockReturnThis(), findRolesByUserId: jest.fn(),
from: jest.fn().mockReturnThis(), findPermissionsByUserId: jest.fn(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -18,15 +17,14 @@ describe("RbacService", () => {
providers: [ providers: [
RbacService, RbacService,
{ {
provide: DatabaseService, provide: RbacRepository,
useValue: { useValue: mockRbacRepository,
db: mockDb,
},
}, },
], ],
}).compile(); }).compile();
service = module.get<RbacService>(RbacService); service = module.get<RbacService>(RbacService);
repository = module.get<RbacRepository>(RbacRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -36,34 +34,26 @@ describe("RbacService", () => {
describe("getUserRoles", () => { describe("getUserRoles", () => {
it("should return user roles", async () => { it("should return user roles", async () => {
const userId = "user-id"; const userId = "user-id";
const mockRoles = [{ slug: "admin" }, { slug: "user" }]; const mockRoles = ["admin", "user"];
mockDb.where.mockResolvedValue(mockRoles); mockRbacRepository.findRolesByUserId.mockResolvedValue(mockRoles);
const result = await service.getUserRoles(userId); const result = await service.getUserRoles(userId);
expect(result).toEqual(["admin", "user"]); expect(result).toEqual(mockRoles);
expect(mockDb.select).toHaveBeenCalled(); expect(repository.findRolesByUserId).toHaveBeenCalledWith(userId);
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.innerJoin).toHaveBeenCalled();
}); });
}); });
describe("getUserPermissions", () => { describe("getUserPermissions", () => {
it("should return unique user permissions", async () => { it("should return user permissions", async () => {
const userId = "user-id"; const userId = "user-id";
const mockPermissions = [ const mockPermissions = ["read", "write"];
{ slug: "read" }, mockRbacRepository.findPermissionsByUserId.mockResolvedValue(mockPermissions);
{ slug: "write" },
{ slug: "read" }, // Duplicate
];
mockDb.where.mockResolvedValue(mockPermissions);
const result = await service.getUserPermissions(userId); const result = await service.getUserPermissions(userId);
expect(result).toEqual(["read", "write"]); expect(result).toEqual(mockPermissions);
expect(mockDb.select).toHaveBeenCalled(); expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.innerJoin).toHaveBeenCalledTimes(2);
}); });
}); });
}); });

View File

@@ -1,42 +1,15 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm"; import { RbacRepository } from "./repositories/rbac.repository";
import { DatabaseService } from "../database/database.service";
import {
permissions,
roles,
rolesToPermissions,
usersToRoles,
} from "../database/schemas";
@Injectable() @Injectable()
export class RbacService { export class RbacService {
constructor(private readonly databaseService: DatabaseService) {} constructor(private readonly rbacRepository: RbacRepository) {}
async getUserRoles(userId: string) { async getUserRoles(userId: string) {
const result = await this.databaseService.db return this.rbacRepository.findRolesByUserId(userId);
.select({
slug: roles.slug,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId));
return result.map((r) => r.slug);
} }
async getUserPermissions(userId: string) { async getUserPermissions(userId: string) {
const result = await this.databaseService.db return this.rbacRepository.findPermissionsByUserId(userId);
.select({
slug: permissions.slug,
})
.from(usersToRoles)
.innerJoin(
rolesToPermissions,
eq(usersToRoles.roleId, rolesToPermissions.roleId),
)
.innerJoin(permissions, eq(rolesToPermissions.permissionId, permissions.id))
.where(eq(usersToRoles.userId, userId));
return Array.from(new Set(result.map((p) => p.slug)));
} }
} }

View File

@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
permissions,
roles,
rolesToPermissions,
usersToRoles,
} from "../../database/schemas";
@Injectable()
export class RbacRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findRolesByUserId(userId: string) {
const result = await this.databaseService.db
.select({
slug: roles.slug,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId));
return result.map((r) => r.slug);
}
async findPermissionsByUserId(userId: string) {
const result = await this.databaseService.db
.select({
slug: permissions.slug,
})
.from(usersToRoles)
.innerJoin(
rolesToPermissions,
eq(usersToRoles.roleId, rolesToPermissions.roleId),
)
.innerJoin(permissions, eq(rolesToPermissions.permissionId, permissions.id))
.where(eq(usersToRoles.userId, userId));
return Array.from(new Set(result.map((p) => p.slug)));
}
}

View File

@@ -2,11 +2,12 @@ import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module"; import { DatabaseModule } from "../database/database.module";
import { CategoriesController } from "./categories.controller"; import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service"; import { CategoriesService } from "./categories.service";
import { CategoriesRepository } from "./repositories/categories.repository";
@Module({ @Module({
imports: [DatabaseModule], imports: [DatabaseModule],
controllers: [CategoriesController], controllers: [CategoriesController],
providers: [CategoriesService], providers: [CategoriesService, CategoriesRepository],
exports: [CategoriesService], exports: [CategoriesService],
}) })
export class CategoriesModule {} export class CategoriesModule {}

View File

@@ -1,25 +1,24 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service"; import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { categories } from "../database/schemas";
import { CategoriesService } from "./categories.service"; import { CategoriesService } from "./categories.service";
import { CategoriesRepository } from "./repositories/categories.repository";
import { CreateCategoryDto } from "./dto/create-category.dto"; import { CreateCategoryDto } from "./dto/create-category.dto";
import { UpdateCategoryDto } from "./dto/update-category.dto"; import { UpdateCategoryDto } from "./dto/update-category.dto";
describe("CategoriesService", () => { describe("CategoriesService", () => {
let service: CategoriesService; let service: CategoriesService;
let repository: CategoriesRepository;
const mockDb = { const mockCategoriesRepository = {
select: jest.fn().mockReturnThis(), findAll: jest.fn(),
from: jest.fn().mockReturnThis(), findOne: jest.fn(),
where: jest.fn().mockReturnThis(), create: jest.fn(),
limit: jest.fn().mockResolvedValue([]), update: jest.fn(),
orderBy: jest.fn().mockResolvedValue([]), remove: jest.fn(),
insert: jest.fn().mockReturnThis(), };
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(), const mockCacheManager = {
set: jest.fn().mockReturnThis(), del: jest.fn(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([]),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -28,15 +27,15 @@ describe("CategoriesService", () => {
providers: [ providers: [
CategoriesService, CategoriesService,
{ {
provide: DatabaseService, provide: CategoriesRepository,
useValue: { useValue: mockCategoriesRepository,
db: mockDb,
},
}, },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
], ],
}).compile(); }).compile();
service = module.get<CategoriesService>(CategoriesService); service = module.get<CategoriesService>(CategoriesService);
repository = module.get<CategoriesRepository>(CategoriesRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -46,28 +45,28 @@ describe("CategoriesService", () => {
describe("findAll", () => { describe("findAll", () => {
it("should return all categories ordered by name", async () => { it("should return all categories ordered by name", async () => {
const mockCategories = [{ name: "A" }, { name: "B" }]; const mockCategories = [{ name: "A" }, { name: "B" }];
mockDb.orderBy.mockResolvedValue(mockCategories); mockCategoriesRepository.findAll.mockResolvedValue(mockCategories);
const result = await service.findAll(); const result = await service.findAll();
expect(result).toEqual(mockCategories); expect(result).toEqual(mockCategories);
expect(mockDb.select).toHaveBeenCalled(); expect(repository.findAll).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalledWith(categories);
}); });
}); });
describe("findOne", () => { describe("findOne", () => {
it("should return a category by id", async () => { it("should return a category by id", async () => {
const mockCategory = { id: "1", name: "Cat" }; const mockCategory = { id: "1", name: "Cat" };
mockDb.limit.mockResolvedValue([mockCategory]); mockCategoriesRepository.findOne.mockResolvedValue(mockCategory);
const result = await service.findOne("1"); const result = await service.findOne("1");
expect(result).toEqual(mockCategory); expect(result).toEqual(mockCategory);
expect(repository.findOne).toHaveBeenCalledWith("1");
}); });
it("should return null if category not found", async () => { it("should return null if category not found", async () => {
mockDb.limit.mockResolvedValue([]); mockCategoriesRepository.findOne.mockResolvedValue(null);
const result = await service.findOne("999"); const result = await service.findOne("999");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -76,12 +75,11 @@ describe("CategoriesService", () => {
describe("create", () => { describe("create", () => {
it("should create a category and generate slug", async () => { it("should create a category and generate slug", async () => {
const dto: CreateCategoryDto = { name: "Test Category" }; const dto: CreateCategoryDto = { name: "Test Category" };
mockDb.returning.mockResolvedValue([{ ...dto, slug: "test-category" }]); mockCategoriesRepository.create.mockResolvedValue([{ ...dto, slug: "test-category" }]);
const result = await service.create(dto); const result = await service.create(dto);
expect(mockDb.insert).toHaveBeenCalledWith(categories); expect(repository.create).toHaveBeenCalledWith({
expect(mockDb.values).toHaveBeenCalledWith({
name: "Test Category", name: "Test Category",
slug: "test-category", slug: "test-category",
}); });
@@ -93,12 +91,12 @@ describe("CategoriesService", () => {
it("should update a category and regenerate slug", async () => { it("should update a category and regenerate slug", async () => {
const id = "1"; const id = "1";
const dto: UpdateCategoryDto = { name: "New Name" }; const dto: UpdateCategoryDto = { name: "New Name" };
mockDb.returning.mockResolvedValue([{ id, ...dto, slug: "new-name" }]); mockCategoriesRepository.update.mockResolvedValue([{ id, ...dto, slug: "new-name" }]);
const result = await service.update(id, dto); const result = await service.update(id, dto);
expect(mockDb.update).toHaveBeenCalledWith(categories); expect(repository.update).toHaveBeenCalledWith(
expect(mockDb.set).toHaveBeenCalledWith( id,
expect.objectContaining({ expect.objectContaining({
name: "New Name", name: "New Name",
slug: "new-name", slug: "new-name",
@@ -111,11 +109,11 @@ describe("CategoriesService", () => {
describe("remove", () => { describe("remove", () => {
it("should remove a category", async () => { it("should remove a category", async () => {
const id = "1"; const id = "1";
mockDb.returning.mockResolvedValue([{ id }]); mockCategoriesRepository.remove.mockResolvedValue([{ id }]);
const result = await service.remove(id); const result = await service.remove(id);
expect(mockDb.delete).toHaveBeenCalledWith(categories); expect(repository.remove).toHaveBeenCalledWith(id);
expect(result).toEqual([{ id }]); expect(result).toEqual([{ id }]);
}); });
}); });

View File

@@ -1,9 +1,7 @@
import { Injectable, Logger, Inject } from "@nestjs/common"; import { Injectable, Logger, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager"; import { Cache } from "cache-manager";
import { eq } from "drizzle-orm"; import { CategoriesRepository } from "./repositories/categories.repository";
import { DatabaseService } from "../database/database.service";
import { categories } from "../database/schemas";
import { CreateCategoryDto } from "./dto/create-category.dto"; import { CreateCategoryDto } from "./dto/create-category.dto";
import { UpdateCategoryDto } from "./dto/update-category.dto"; import { UpdateCategoryDto } from "./dto/update-category.dto";
@@ -12,7 +10,7 @@ export class CategoriesService {
private readonly logger = new Logger(CategoriesService.name); private readonly logger = new Logger(CategoriesService.name);
constructor( constructor(
private readonly databaseService: DatabaseService, private readonly categoriesRepository: CategoriesRepository,
@Inject(CACHE_MANAGER) private cacheManager: Cache, @Inject(CACHE_MANAGER) private cacheManager: Cache,
) {} ) {}
@@ -22,20 +20,11 @@ export class CategoriesService {
} }
async findAll() { async findAll() {
return await this.databaseService.db return await this.categoriesRepository.findAll();
.select()
.from(categories)
.orderBy(categories.name);
} }
async findOne(id: string) { async findOne(id: string) {
const result = await this.databaseService.db return await this.categoriesRepository.findOne(id);
.select()
.from(categories)
.where(eq(categories.id, id))
.limit(1);
return result[0] || null;
} }
async create(data: CreateCategoryDto) { async create(data: CreateCategoryDto) {
@@ -44,10 +33,7 @@ export class CategoriesService {
.toLowerCase() .toLowerCase()
.replace(/ /g, "-") .replace(/ /g, "-")
.replace(/[^\w-]/g, ""); .replace(/[^\w-]/g, "");
const result = await this.databaseService.db const result = await this.categoriesRepository.create({ ...data, slug });
.insert(categories)
.values({ ...data, slug })
.returning();
await this.clearCategoriesCache(); await this.clearCategoriesCache();
return result; return result;
@@ -65,11 +51,7 @@ export class CategoriesService {
.replace(/[^\w-]/g, "") .replace(/[^\w-]/g, "")
: undefined, : undefined,
}; };
const result = await this.databaseService.db const result = await this.categoriesRepository.update(id, updateData);
.update(categories)
.set(updateData)
.where(eq(categories.id, id))
.returning();
await this.clearCategoriesCache(); await this.clearCategoriesCache();
return result; return result;
@@ -77,10 +59,7 @@ export class CategoriesService {
async remove(id: string) { async remove(id: string) {
this.logger.log(`Removing category: ${id}`); this.logger.log(`Removing category: ${id}`);
const result = await this.databaseService.db const result = await this.categoriesRepository.remove(id);
.delete(categories)
.where(eq(categories.id, id))
.returning();
await this.clearCategoriesCache(); await this.clearCategoriesCache();
return result; return result;

View File

@@ -0,0 +1,50 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { categories } from "../../database/schemas";
import type { CreateCategoryDto } from "../dto/create-category.dto";
import type { UpdateCategoryDto } from "../dto/update-category.dto";
@Injectable()
export class CategoriesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findAll() {
return await this.databaseService.db
.select()
.from(categories)
.orderBy(categories.name);
}
async findOne(id: string) {
const result = await this.databaseService.db
.select()
.from(categories)
.where(eq(categories.id, id))
.limit(1);
return result[0] || null;
}
async create(data: CreateCategoryDto & { slug: string }) {
return await this.databaseService.db
.insert(categories)
.values(data)
.returning();
}
async update(id: string, data: UpdateCategoryDto & { slug?: string; updatedAt: Date }) {
return await this.databaseService.db
.update(categories)
.set(data)
.where(eq(categories.id, id))
.returning();
}
async remove(id: string) {
return await this.databaseService.db
.delete(categories)
.where(eq(categories.id, id))
.returning();
}
}

View File

@@ -0,0 +1,4 @@
export interface IMailService {
sendEmailValidation(email: string, token: string): Promise<void>;
sendPasswordReset(email: string, token: string): Promise<void>;
}

View File

@@ -0,0 +1,25 @@
export interface MediaProcessingResult {
buffer: Buffer;
mimeType: string;
extension: string;
width?: number;
height?: number;
size: number;
}
export interface ScanResult {
isInfected: boolean;
virusName?: string;
}
export interface IMediaService {
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>;
processImage(
buffer: Buffer,
format?: "webp" | "avif",
): Promise<MediaProcessingResult>;
processVideo(
buffer: Buffer,
format?: "webm" | "av1",
): Promise<MediaProcessingResult>;
}

View File

@@ -0,0 +1,39 @@
import type { Readable } from "node:stream";
export interface IStorageService {
uploadFile(
fileName: string,
file: Buffer,
mimeType: string,
metaData?: Record<string, string>,
bucketName?: string,
): Promise<string>;
getFile(
fileName: string,
bucketName?: string,
): Promise<Readable>;
getFileUrl(
fileName: string,
expiry?: number,
bucketName?: string,
): Promise<string>;
getUploadUrl(
fileName: string,
expiry?: number,
bucketName?: string,
): Promise<string>;
deleteFile(fileName: string, bucketName?: string): Promise<void>;
getFileInfo(fileName: string, bucketName?: string): Promise<any>;
moveFile(
sourceFileName: string,
destinationFileName: string,
sourceBucketName?: string,
destinationBucketName?: string,
): Promise<string>;
}

View File

@@ -1,38 +1,31 @@
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service"; import { ContentsRepository } from "../../contents/repositories/contents.repository";
import { ReportsRepository } from "../../reports/repositories/reports.repository";
import { SessionsRepository } from "../../sessions/repositories/sessions.repository";
import { UsersRepository } from "../../users/repositories/users.repository";
import { PurgeService } from "./purge.service"; import { PurgeService } from "./purge.service";
describe("PurgeService", () => { describe("PurgeService", () => {
let service: PurgeService; let service: PurgeService;
const mockDb = { const mockSessionsRepository = { purgeExpired: jest.fn().mockResolvedValue([]) };
delete: jest.fn(), const mockReportsRepository = { purgeObsolete: jest.fn().mockResolvedValue([]) };
where: jest.fn(), const mockUsersRepository = { purgeDeleted: jest.fn().mockResolvedValue([]) };
returning: jest.fn(), const mockContentsRepository = { purgeSoftDeleted: jest.fn().mockResolvedValue([]) };
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.spyOn(Logger.prototype, "error").mockImplementation(() => {}); jest.spyOn(Logger.prototype, "error").mockImplementation(() => {});
jest.spyOn(Logger.prototype, "log").mockImplementation(() => {}); jest.spyOn(Logger.prototype, "log").mockImplementation(() => {});
const chain = {
delete: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([]),
};
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
for (const mock of Object.values(chain)) {
mock.mockImplementation(mockImplementation);
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
PurgeService, PurgeService,
{ provide: DatabaseService, useValue: { db: mockDb } }, { provide: SessionsRepository, useValue: mockSessionsRepository },
{ provide: ReportsRepository, useValue: mockReportsRepository },
{ provide: UsersRepository, useValue: mockUsersRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
], ],
}).compile(); }).compile();
@@ -44,23 +37,22 @@ describe("PurgeService", () => {
}); });
describe("purgeExpiredData", () => { describe("purgeExpiredData", () => {
it("should purge data", async () => { it("should purge data using repositories", async () => {
mockDb.returning mockSessionsRepository.purgeExpired.mockResolvedValue([{ id: "s1" }]);
.mockResolvedValueOnce([{ id: "s1" }]) // sessions mockReportsRepository.purgeObsolete.mockResolvedValue([{ id: "r1" }]);
.mockResolvedValueOnce([{ id: "r1" }]) // reports mockUsersRepository.purgeDeleted.mockResolvedValue([{ id: "u1" }]);
.mockResolvedValueOnce([{ id: "u1" }]) // users mockContentsRepository.purgeSoftDeleted.mockResolvedValue([{ id: "c1" }]);
.mockResolvedValueOnce([{ id: "c1" }]); // contents
await service.purgeExpiredData(); await service.purgeExpiredData();
expect(mockDb.delete).toHaveBeenCalledTimes(4); expect(mockSessionsRepository.purgeExpired).toHaveBeenCalled();
expect(mockDb.returning).toHaveBeenCalledTimes(4); expect(mockReportsRepository.purgeObsolete).toHaveBeenCalled();
expect(mockUsersRepository.purgeDeleted).toHaveBeenCalled();
expect(mockContentsRepository.purgeSoftDeleted).toHaveBeenCalled();
}); });
it("should handle errors", async () => { it("should handle errors", async () => {
mockDb.delete.mockImplementation(() => { mockSessionsRepository.purgeExpired.mockRejectedValue(new Error("Db error"));
throw new Error("Db error");
});
await expect(service.purgeExpiredData()).resolves.not.toThrow(); await expect(service.purgeExpiredData()).resolves.not.toThrow();
}); });
}); });

View File

@@ -1,14 +1,20 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule"; import { Cron, CronExpression } from "@nestjs/schedule";
import { and, eq, isNotNull, lte } from "drizzle-orm"; import { ContentsRepository } from "../../contents/repositories/contents.repository";
import { DatabaseService } from "../../database/database.service"; import { ReportsRepository } from "../../reports/repositories/reports.repository";
import { contents, reports, sessions, users } from "../../database/schemas"; import { SessionsRepository } from "../../sessions/repositories/sessions.repository";
import { UsersRepository } from "../../users/repositories/users.repository";
@Injectable() @Injectable()
export class PurgeService { export class PurgeService {
private readonly logger = new Logger(PurgeService.name); private readonly logger = new Logger(PurgeService.name);
constructor(private readonly databaseService: DatabaseService) {} constructor(
private readonly sessionsRepository: SessionsRepository,
private readonly reportsRepository: ReportsRepository,
private readonly usersRepository: UsersRepository,
private readonly contentsRepository: ContentsRepository,
) {}
// Toutes les nuits à minuit // Toutes les nuits à minuit
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@@ -19,40 +25,26 @@ export class PurgeService {
const now = new Date(); const now = new Date();
// 1. Purge des sessions expirées // 1. Purge des sessions expirées
const deletedSessions = await this.databaseService.db const deletedSessions = await this.sessionsRepository.purgeExpired(now);
.delete(sessions)
.where(lte(sessions.expiresAt, now))
.returning();
this.logger.log(`Purged ${deletedSessions.length} expired sessions.`); this.logger.log(`Purged ${deletedSessions.length} expired sessions.`);
// 2. Purge des signalements obsolètes // 2. Purge des signalements obsolètes
const deletedReports = await this.databaseService.db const deletedReports = await this.reportsRepository.purgeObsolete(now);
.delete(reports)
.where(lte(reports.expiresAt, now))
.returning();
this.logger.log(`Purged ${deletedReports.length} obsolete reports.`); this.logger.log(`Purged ${deletedReports.length} obsolete reports.`);
// 3. Purge des utilisateurs supprimés (Soft Delete > 30 jours) // 3. Purge des utilisateurs supprimés (Soft Delete > 30 jours)
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const deletedUsers = await this.databaseService.db const deletedUsers = await this.usersRepository.purgeDeleted(thirtyDaysAgo);
.delete(users)
.where(
and(eq(users.status, "deleted"), lte(users.deletedAt, thirtyDaysAgo)),
)
.returning();
this.logger.log( this.logger.log(
`Purged ${deletedUsers.length} users marked for deletion more than 30 days ago.`, `Purged ${deletedUsers.length} users marked for deletion more than 30 days ago.`,
); );
// 4. Purge des contenus supprimés (Soft Delete > 30 jours) // 4. Purge des contenus supprimés (Soft Delete > 30 jours)
const deletedContents = await this.databaseService.db const deletedContents = await this.contentsRepository.purgeSoftDeleted(
.delete(contents) thirtyDaysAgo,
.where( );
and(isNotNull(contents.deletedAt), lte(contents.deletedAt, thirtyDaysAgo)),
)
.returning();
this.logger.log( this.logger.log(
`Purged ${deletedContents.length} contents marked for deletion more than 30 days ago.`, `Purged ${deletedContents.length} contents marked for deletion more than 30 days ago.`,
); );

View File

@@ -136,25 +136,7 @@ export class ContentsController {
); );
if (isBot) { if (isBot) {
const imageUrl = this.contentsService.getFileUrl(content.storageKey); const html = this.contentsService.generateBotHtml(content);
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${content.title}</title>
<meta property="og:title" content="${content.title}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="${imageUrl}" />
<meta property="og:description" content="Découvrez ce meme sur Memegoat" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${content.title}" />
<meta name="twitter:image" content="${imageUrl}" />
</head>
<body>
<h1>${content.title}</h1>
<img src="${imageUrl}" alt="${content.title}" />
</body>
</html>`;
return res.send(html); return res.send(html);
} }

View File

@@ -6,10 +6,11 @@ import { MediaModule } from "../media/media.module";
import { S3Module } from "../s3/s3.module"; import { S3Module } from "../s3/s3.module";
import { ContentsController } from "./contents.controller"; import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service"; import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
@Module({ @Module({
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule], imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
controllers: [ContentsController], controllers: [ContentsController],
providers: [ContentsService], providers: [ContentsService, ContentsRepository],
}) })
export class ContentsModule {} export class ContentsModule {}

View File

@@ -4,33 +4,28 @@ jest.mock("uuid", () => ({
import { BadRequestException } from "@nestjs/common"; import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service"; import { DatabaseService } from "../database/database.service";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { ContentsService } from "./contents.service"; import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
describe("ContentsService", () => { describe("ContentsService", () => {
let service: ContentsService; let service: ContentsService;
let s3Service: S3Service; let s3Service: S3Service;
let mediaService: MediaService; let mediaService: MediaService;
const mockDb = { const mockContentsRepository = {
select: jest.fn().mockReturnThis(), findAll: jest.fn(),
from: jest.fn().mockReturnThis(), count: jest.fn(),
where: jest.fn().mockReturnThis(), create: jest.fn(),
limit: jest.fn().mockReturnThis(), incrementViews: jest.fn(),
offset: jest.fn().mockReturnThis(), incrementUsage: jest.fn(),
orderBy: jest.fn().mockReturnThis(), softDelete: jest.fn(),
innerJoin: jest.fn().mockReturnThis(), findOne: jest.fn(),
insert: jest.fn().mockReturnThis(), findBySlug: jest.fn(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([]),
onConflictDoNothing: jest.fn().mockReturnThis(),
transaction: jest.fn().mockImplementation((cb) => cb(mockDb)),
execute: jest.fn().mockResolvedValue([]),
}; };
const mockS3Service = { const mockS3Service = {
@@ -48,46 +43,24 @@ describe("ContentsService", () => {
get: jest.fn(), get: jest.fn(),
}; };
const mockCacheManager = {
store: {
keys: jest.fn().mockResolvedValue([]),
},
del: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
const chain = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
onConflictDoNothing: jest.fn().mockReturnThis(),
};
const mockImplementation = () => {
return Object.assign(Promise.resolve([]), chain);
};
for (const mock of Object.values(chain)) {
//TODO Fix : TS2774: This condition will always return true since this function is always defined. Did you mean to call it instead?
if (mock.mockReturnValue) {
mock.mockImplementation(mockImplementation);
}
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
ContentsService, ContentsService,
{ provide: DatabaseService, useValue: { db: mockDb } }, { provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: S3Service, useValue: mockS3Service }, { provide: S3Service, useValue: mockS3Service },
{ provide: MediaService, useValue: mockMediaService }, { provide: MediaService, useValue: mockMediaService },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
], ],
}).compile(); }).compile();
@@ -127,7 +100,8 @@ describe("ContentsService", () => {
mimeType: "image/webp", mimeType: "image/webp",
size: 500, size: 500,
}); });
mockDb.returning.mockResolvedValue([{ id: "content-id" }]); mockContentsRepository.findBySlug.mockResolvedValue(null);
mockContentsRepository.create.mockResolvedValue({ id: "content-id" });
const result = await service.uploadAndProcess("user1", file, { const result = await service.uploadAndProcess("user1", file, {
title: "Meme", title: "Meme",
@@ -155,8 +129,8 @@ describe("ContentsService", () => {
describe("findAll", () => { describe("findAll", () => {
it("should return contents and total count", async () => { it("should return contents and total count", async () => {
mockDb.where.mockResolvedValueOnce([{ count: 10 }]); // for count mockContentsRepository.count.mockResolvedValue(10);
mockDb.offset.mockResolvedValueOnce([{ id: "1" }]); // for data mockContentsRepository.findAll.mockResolvedValue([{ id: "1" }]);
const result = await service.findAll({ limit: 10, offset: 0 }); const result = await service.findAll({ limit: 10, offset: 0 });
@@ -167,9 +141,9 @@ describe("ContentsService", () => {
describe("incrementViews", () => { describe("incrementViews", () => {
it("should increment views", async () => { it("should increment views", async () => {
mockDb.returning.mockResolvedValue([{ id: "1", views: 1 }]); mockContentsRepository.incrementViews.mockResolvedValue([{ id: "1", views: 1 }]);
const result = await service.incrementViews("1"); const result = await service.incrementViews("1");
expect(mockDb.update).toHaveBeenCalled(); expect(mockContentsRepository.incrementViews).toHaveBeenCalledWith("1");
expect(result[0].views).toBe(1); expect(result[0].views).toBe(1);
}); });
}); });

View File

@@ -3,39 +3,21 @@ import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager"; import { Cache } from "cache-manager";
import { Inject } from "@nestjs/common"; import { Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import {
and,
desc,
eq,
exists,
ilike,
isNull,
type SQL,
sql,
} from "drizzle-orm";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { DatabaseService } from "../database/database.service"; import type { IMediaService } from "../common/interfaces/media.interface";
import { import type { IStorageService } from "../common/interfaces/storage.interface";
categories,
contents,
contentsToTags,
favorites,
tags,
users,
} from "../database/schemas";
import type { MediaProcessingResult } from "../media/interfaces/media.interface";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { CreateContentDto } from "./dto/create-content.dto"; import { ContentsRepository } from "./repositories/contents.repository";
@Injectable() @Injectable()
export class ContentsService { export class ContentsService {
private readonly logger = new Logger(ContentsService.name); private readonly logger = new Logger(ContentsService.name);
constructor( constructor(
private readonly databaseService: DatabaseService, private readonly contentsRepository: ContentsRepository,
private readonly s3Service: S3Service, @Inject(S3Service) private readonly s3Service: IStorageService,
private readonly mediaService: MediaService, @Inject(MediaService) private readonly mediaService: IMediaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, @Inject(CACHE_MANAGER) private cacheManager: Cache,
) {} ) {}
@@ -104,7 +86,7 @@ export class ContentsService {
} }
// 2. Transcodage // 2. Transcodage
let processed: MediaProcessingResult; let processed;
if (file.mimetype.startsWith("image/")) { if (file.mimetype.startsWith("image/")) {
// Image ou GIF -> WebP (format moderne, bien supporté) // Image ou GIF -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp"); processed = await this.mediaService.processImage(file.buffer, "webp");
@@ -139,195 +121,71 @@ export class ContentsService {
favoritesOnly?: boolean; favoritesOnly?: boolean;
userId?: string; // Nécessaire si favoritesOnly est vrai userId?: string; // Nécessaire si favoritesOnly est vrai
}) { }) {
const { const [data, totalCount] = await Promise.all([
limit, this.contentsRepository.findAll(options),
offset, this.contentsRepository.count(options),
sortBy, ]);
tag,
category,
author,
query,
favoritesOnly,
userId,
} = options;
let whereClause: SQL | undefined = isNull(contents.deletedAt);
if (tag) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
),
),
);
}
if (author) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(users)
.where(and(eq(users.uuid, contents.userId), eq(users.username, author))),
),
);
}
if (category) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(categories)
.where(
and(
eq(categories.id, contents.categoryId),
sql`(${categories.slug} = ${category} OR ${categories.id}::text = ${category})`,
),
),
),
);
}
if (query) {
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
}
if (favoritesOnly && userId) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(favorites)
.where(
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
),
),
);
}
// Pagination Total Count
const totalCountResult = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(contents)
.where(whereClause);
const totalCount = Number(totalCountResult[0].count);
// Sorting
let orderBy: SQL = desc(contents.createdAt);
if (sortBy === "trend") {
orderBy = desc(sql`${contents.views} + ${contents.usageCount}`);
}
const data = await this.databaseService.db
.select()
.from(contents)
.where(whereClause)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
return { data, totalCount }; return { data, totalCount };
} }
async create(userId: string, data: CreateContentDto) { async create(userId: string, data: any) {
this.logger.log(`Creating content for user ${userId}: ${data.title}`); this.logger.log(`Creating content for user ${userId}: ${data.title}`);
const { tags: tagNames, ...contentData } = data; const { tags: tagNames, ...contentData } = data;
const slug = await this.ensureUniqueSlug(contentData.title); const slug = await this.ensureUniqueSlug(contentData.title);
return await this.databaseService.db.transaction(async (tx) => { const newContent = await this.contentsRepository.create(
const [newContent] = await tx { ...contentData, userId, slug },
.insert(contents) tagNames,
.values({ ...contentData, userId, slug }) );
.returning();
if (tagNames && tagNames.length > 0) { await this.clearContentsCache();
for (const tagName of tagNames) { return newContent;
const slug = tagName
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]/g, "");
// Trouver ou créer le tag
let [tag] = await tx
.select()
.from(tags)
.where(eq(tags.slug, slug))
.limit(1);
if (!tag) {
[tag] = await tx
.insert(tags)
.values({ name: tagName, slug, userId })
.returning();
}
// Lier le tag au contenu
await tx
.insert(contentsToTags)
.values({ contentId: newContent.id, tagId: tag.id })
.onConflictDoNothing();
}
}
await this.clearContentsCache();
return newContent;
});
} }
async incrementViews(id: string) { async incrementViews(id: string) {
return await this.databaseService.db return await this.contentsRepository.incrementViews(id);
.update(contents)
.set({ views: sql`${contents.views} + 1` })
.where(eq(contents.id, id))
.returning();
} }
async incrementUsage(id: string) { async incrementUsage(id: string) {
return await this.databaseService.db return await this.contentsRepository.incrementUsage(id);
.update(contents)
.set({ usageCount: sql`${contents.usageCount} + 1` })
.where(eq(contents.id, id))
.returning();
} }
async remove(id: string, userId: string) { async remove(id: string, userId: string) {
this.logger.log(`Removing content ${id} for user ${userId}`); this.logger.log(`Removing content ${id} for user ${userId}`);
const result = await this.databaseService.db const deleted = await this.contentsRepository.softDelete(id, userId);
.update(contents)
.set({ deletedAt: new Date() })
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
.returning();
if (result.length > 0) { if (deleted) {
await this.clearContentsCache(); await this.clearContentsCache();
} }
return result; return deleted;
} }
async findOne(idOrSlug: string) { async findOne(idOrSlug: string) {
const [content] = await this.databaseService.db return this.contentsRepository.findOne(idOrSlug);
.select() }
.from(contents)
.where( generateBotHtml(content: any): string {
and( const imageUrl = this.getFileUrl(content.storageKey);
isNull(contents.deletedAt), return `<!DOCTYPE html>
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`, <html>
), <head>
) <meta charset="UTF-8">
.limit(1); <title>${content.title}</title>
return content; <meta property="og:title" content="${content.title}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="${imageUrl}" />
<meta property="og:description" content="Découvrez ce meme sur Memegoat" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${content.title}" />
<meta name="twitter:image" content="${imageUrl}" />
</head>
<body>
<h1>${content.title}</h1>
<img src="${imageUrl}" alt="${content.title}" />
</body>
</html>`;
} }
getFileUrl(storageKey: string): string { getFileUrl(storageKey: string): string {
@@ -359,11 +217,7 @@ export class ContentsService {
let counter = 1; let counter = 1;
while (true) { while (true) {
const [existing] = await this.databaseService.db const existing = await this.contentsRepository.findBySlug(slug);
.select()
.from(contents)
.where(eq(contents.slug, slug))
.limit(1);
if (!existing) break; if (!existing) break;
slug = `${baseSlug}-${counter++}`; slug = `${baseSlug}-${counter++}`;

View File

@@ -0,0 +1,383 @@
import { Injectable } from "@nestjs/common";
import {
and,
desc,
eq,
exists,
ilike,
isNull,
sql,
lte,
type SQL,
} from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
categories,
contents,
contentsToTags,
favorites,
tags,
users,
} from "../../database/schemas";
import type { NewContentInDb } from "../../database/schemas/content";
export interface FindAllOptions {
limit: number;
offset: number;
sortBy?: "trend" | "recent";
tag?: string;
category?: string;
author?: string;
query?: string;
favoritesOnly?: boolean;
userId?: string;
}
@Injectable()
export class ContentsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findAll(options: FindAllOptions) {
const {
limit,
offset,
sortBy,
tag,
category,
author,
query,
favoritesOnly,
userId,
} = options;
let whereClause: SQL | undefined = isNull(contents.deletedAt);
if (tag) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
),
),
);
}
if (category) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(categories)
.where(
and(
eq(contents.categoryId, categories.id),
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
),
),
),
);
}
if (author) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(users)
.where(
and(
eq(contents.userId, users.uuid),
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
),
),
),
);
}
if (query) {
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
}
if (favoritesOnly && userId) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(favorites)
.where(
and(
eq(favorites.contentId, contents.id),
eq(favorites.userId, userId),
),
),
),
);
}
let orderBy = desc(contents.createdAt);
if (sortBy === "trend") {
orderBy = desc(sql`${contents.views} + ${contents.usageCount} * 2`);
}
const results = await this.databaseService.db
.select({
id: contents.id,
title: contents.title,
slug: contents.slug,
type: contents.type,
storageKey: contents.storageKey,
mimeType: contents.mimeType,
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
author: {
id: users.uuid,
username: users.username,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
name: categories.name,
slug: categories.slug,
},
})
.from(contents)
.leftJoin(users, eq(contents.userId, users.uuid))
.leftJoin(categories, eq(contents.categoryId, categories.id))
.where(whereClause)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const contentIds = results.map((r) => r.id);
const tagsForContents = contentIds.length
? await this.databaseService.db
.select({
contentId: contentsToTags.contentId,
name: tags.name,
})
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(sql`${contentsToTags.contentId} IN ${contentIds}`)
: [];
return results.map((r) => ({
...r,
tags: tagsForContents
.filter((t) => t.contentId === r.id)
.map((t) => t.name),
}));
}
async create(data: NewContentInDb & { userId: string }, tagNames?: string[]) {
return await this.databaseService.db.transaction(async (tx) => {
const [newContent] = await tx
.insert(contents)
.values(data)
.returning();
if (tagNames && tagNames.length > 0) {
for (const tagName of tagNames) {
const slug = tagName
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]/g, "");
let [tag] = await tx
.select()
.from(tags)
.where(eq(tags.slug, slug))
.limit(1);
if (!tag) {
[tag] = await tx
.insert(tags)
.values({
name: tagName,
slug,
userId: data.userId,
})
.returning();
}
await tx
.insert(contentsToTags)
.values({
contentId: newContent.id,
tagId: tag.id,
})
.onConflictDoNothing();
}
}
return newContent;
});
}
async findOne(idOrSlug: string) {
const [result] = await this.databaseService.db
.select({
id: contents.id,
title: contents.title,
slug: contents.slug,
type: contents.type,
storageKey: contents.storageKey,
mimeType: contents.mimeType,
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
userId: contents.userId,
})
.from(contents)
.where(
and(
isNull(contents.deletedAt),
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`,
),
)
.limit(1);
return result;
}
async count(options: {
tag?: string;
category?: string;
author?: string;
query?: string;
favoritesOnly?: boolean;
userId?: string;
}) {
const { tag, category, author, query, favoritesOnly, userId } = options;
let whereClause: SQL | undefined = isNull(contents.deletedAt);
if (tag) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
),
),
);
}
if (category) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(categories)
.where(
and(
eq(contents.categoryId, categories.id),
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
),
),
),
);
}
if (author) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(users)
.where(
and(
eq(contents.userId, users.uuid),
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
),
),
),
);
}
if (query) {
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
}
if (favoritesOnly && userId) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(favorites)
.where(
and(
eq(favorites.contentId, contents.id),
eq(favorites.userId, userId),
),
),
),
);
}
const [result] = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(contents)
.where(whereClause);
return Number(result.count);
}
async incrementViews(id: string) {
await this.databaseService.db
.update(contents)
.set({ views: sql`${contents.views} + 1` })
.where(eq(contents.id, id));
}
async incrementUsage(id: string) {
await this.databaseService.db
.update(contents)
.set({ usageCount: sql`${contents.usageCount} + 1` })
.where(eq(contents.id, id));
}
async softDelete(id: string, userId: string) {
const [deleted] = await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
.returning();
return deleted;
}
async findBySlug(slug: string) {
const [result] = await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.slug, slug))
.limit(1);
return result;
}
async purgeSoftDeleted(before: Date) {
return await this.databaseService.db
.delete(contents)
.where(and(sql`${contents.deletedAt} IS NOT NULL`, lte(contents.deletedAt, before)))
.returning();
}
}

View File

@@ -1,8 +1,24 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { CryptoService } from "./crypto.service"; import { CryptoService } from "./crypto.service";
import { HashingService } from "./services/hashing.service";
import { JwtService } from "./services/jwt.service";
import { EncryptionService } from "./services/encryption.service";
import { PostQuantumService } from "./services/post-quantum.service";
@Module({ @Module({
providers: [CryptoService], providers: [
exports: [CryptoService], CryptoService,
HashingService,
JwtService,
EncryptionService,
PostQuantumService,
],
exports: [
CryptoService,
HashingService,
JwtService,
EncryptionService,
PostQuantumService,
],
}) })
export class CryptoModule {} export class CryptoModule {}

View File

@@ -64,6 +64,10 @@ jest.mock("jose", () => ({
})); }));
import { CryptoService } from "./crypto.service"; import { CryptoService } from "./crypto.service";
import { HashingService } from "./services/hashing.service";
import { JwtService } from "./services/jwt.service";
import { EncryptionService } from "./services/encryption.service";
import { PostQuantumService } from "./services/post-quantum.service";
describe("CryptoService", () => { describe("CryptoService", () => {
let service: CryptoService; let service: CryptoService;
@@ -72,6 +76,10 @@ describe("CryptoService", () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
CryptoService, CryptoService,
HashingService,
JwtService,
EncryptionService,
PostQuantumService,
{ {
provide: ConfigService, provide: ConfigService,
useValue: { useValue: {

View File

@@ -1,151 +1,79 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import type * as jose from "jose";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js"; import { EncryptionService } from "./services/encryption.service";
import { hash, verify } from "@node-rs/argon2"; import { HashingService } from "./services/hashing.service";
import * as jose from "jose"; import { JwtService } from "./services/jwt.service";
import { PostQuantumService } from "./services/post-quantum.service";
/**
* @deprecated Use HashingService, JwtService, EncryptionService or PostQuantumService directly.
* This service acts as a Facade for backward compatibility.
*/
@Injectable() @Injectable()
export class CryptoService { export class CryptoService {
private readonly logger = new Logger(CryptoService.name); constructor(
private readonly jwtSecret: Uint8Array; private readonly hashingService: HashingService,
private readonly encryptionKey: Uint8Array; private readonly jwtService: JwtService,
private readonly encryptionService: EncryptionService,
constructor(private configService: ConfigService) { private readonly postQuantumService: PostQuantumService,
const secret = this.configService.get<string>("JWT_SECRET"); ) {}
if (!secret) {
this.logger.warn(
"JWT_SECRET is not defined, using a default insecure secret for development",
);
}
this.jwtSecret = new TextEncoder().encode(
secret || "default-secret-change-me-in-production",
);
const encKey = this.configService.get<string>("ENCRYPTION_KEY");
if (!encKey) {
this.logger.warn(
"ENCRYPTION_KEY is not defined, using a default insecure key for development",
);
}
// Pour AES-GCM 256, on a besoin de 32 octets (256 bits)
const rawKey = encKey || "default-encryption-key-32-chars-";
this.encryptionKey = new TextEncoder().encode(
rawKey.padEnd(32, "0").substring(0, 32),
);
}
// --- Blind Indexing (for search on encrypted data) ---
async hashEmail(email: string): Promise<string> { async hashEmail(email: string): Promise<string> {
const normalizedEmail = email.toLowerCase().trim(); return this.hashingService.hashEmail(email);
const data = new TextEncoder().encode(normalizedEmail);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} }
async hashIp(ip: string): Promise<string> { async hashIp(ip: string): Promise<string> {
const data = new TextEncoder().encode(ip); return this.hashingService.hashIp(ip);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} }
getPgpEncryptionKey(): string { getPgpEncryptionKey(): string {
return ( return this.encryptionService.getPgpEncryptionKey();
this.configService.get<string>("PGP_ENCRYPTION_KEY") || "default-pgp-key"
);
} }
// --- Argon2 Hashing ---
async hashPassword(password: string): Promise<string> { async hashPassword(password: string): Promise<string> {
return hash(password, { return this.hashingService.hashPassword(password);
algorithm: 2,
});
} }
async verifyPassword(password: string, hash: string): Promise<boolean> { async verifyPassword(password: string, hash: string): Promise<boolean> {
return verify(hash, password); return this.hashingService.verifyPassword(password, hash);
} }
// --- JWT Operations via jose ---
async generateJwt( async generateJwt(
payload: jose.JWTPayload, payload: jose.JWTPayload,
expiresIn = "2h", expiresIn = "2h",
): Promise<string> { ): Promise<string> {
return new jose.SignJWT(payload) return this.jwtService.generateJwt(payload, expiresIn);
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(this.jwtSecret);
} }
async verifyJwt<T extends jose.JWTPayload>(token: string): Promise<T> { async verifyJwt<T extends jose.JWTPayload>(token: string): Promise<T> {
const { payload } = await jose.jwtVerify(token, this.jwtSecret); return this.jwtService.verifyJwt<T>(token);
return payload as T;
} }
// --- Encryption & Decryption (JWE) ---
/**
* Chiffre un contenu textuel en utilisant JWE (Compact Serialization)
* Algorithme: A256GCMKW pour la gestion des clés, A256GCM pour le chiffrement de contenu
*/
async encryptContent(content: string): Promise<string> { async encryptContent(content: string): Promise<string> {
const data = new TextEncoder().encode(content); return this.encryptionService.encryptContent(content);
return new jose.CompactEncrypt(data)
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.encrypt(this.encryptionKey);
} }
/**
* Déchiffre un contenu JWE
*/
async decryptContent(jwe: string): Promise<string> { async decryptContent(jwe: string): Promise<string> {
const { plaintext } = await jose.compactDecrypt(jwe, this.encryptionKey); return this.encryptionService.decryptContent(jwe);
return new TextDecoder().decode(plaintext);
} }
// --- Signature & Verification (JWS) ---
/**
* Signe un contenu textuel en utilisant JWS (Compact Serialization)
* Algorithme: HS256 (HMAC-SHA256)
*/
async signContent(content: string): Promise<string> { async signContent(content: string): Promise<string> {
const data = new TextEncoder().encode(content); return this.encryptionService.signContent(content);
return new jose.CompactSign(data)
.setProtectedHeader({ alg: "HS256" })
.sign(this.jwtSecret);
} }
/**
* Vérifie la signature JWS d'un contenu
*/
async verifyContentSignature(jws: string): Promise<string> { async verifyContentSignature(jws: string): Promise<string> {
const { payload } = await jose.compactVerify(jws, this.jwtSecret); return this.encryptionService.verifyContentSignature(jws);
return new TextDecoder().decode(payload);
} }
// --- Post-Quantum Cryptography via @noble/post-quantum ---
// Example: Kyber (ML-KEM) key encapsulation
generatePostQuantumKeyPair() { generatePostQuantumKeyPair() {
const seed = new Uint8Array(64); return this.postQuantumService.generatePostQuantumKeyPair();
crypto.getRandomValues(seed);
const { publicKey, secretKey } = ml_kem768.keygen(seed);
return { publicKey, secretKey };
} }
encapsulate(publicKey: Uint8Array) { encapsulate(publicKey: Uint8Array) {
return ml_kem768.encapsulate(publicKey); return this.postQuantumService.encapsulate(publicKey);
} }
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) { decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) {
return ml_kem768.decapsulate(cipherText, secretKey); return this.postQuantumService.decapsulate(cipherText, secretKey);
} }
} }

View File

@@ -0,0 +1,58 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as jose from "jose";
@Injectable()
export class EncryptionService {
private readonly logger = new Logger(EncryptionService.name);
private readonly jwtSecret: Uint8Array;
private readonly encryptionKey: Uint8Array;
constructor(private configService: ConfigService) {
const secret = this.configService.get<string>("JWT_SECRET");
this.jwtSecret = new TextEncoder().encode(
secret || "default-secret-change-me-in-production",
);
const encKey = this.configService.get<string>("ENCRYPTION_KEY");
if (!encKey) {
this.logger.warn(
"ENCRYPTION_KEY is not defined, using a default insecure key for development",
);
}
const rawKey = encKey || "default-encryption-key-32-chars-";
this.encryptionKey = new TextEncoder().encode(
rawKey.padEnd(32, "0").substring(0, 32),
);
}
async encryptContent(content: string): Promise<string> {
const data = new TextEncoder().encode(content);
return new jose.CompactEncrypt(data)
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.encrypt(this.encryptionKey);
}
async decryptContent(jwe: string): Promise<string> {
const { plaintext } = await jose.compactDecrypt(jwe, this.encryptionKey);
return new TextDecoder().decode(plaintext);
}
async signContent(content: string): Promise<string> {
const data = new TextEncoder().encode(content);
return new jose.CompactSign(data)
.setProtectedHeader({ alg: "HS256" })
.sign(this.jwtSecret);
}
async verifyContentSignature(jws: string): Promise<string> {
const { payload } = await jose.compactVerify(jws, this.jwtSecret);
return new TextDecoder().decode(payload);
}
getPgpEncryptionKey(): string {
return (
this.configService.get<string>("PGP_ENCRYPTION_KEY") || "default-pgp-key"
);
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from "@nestjs/common";
import { hash, verify } from "@node-rs/argon2";
@Injectable()
export class HashingService {
async hashEmail(email: string): Promise<string> {
const normalizedEmail = email.toLowerCase().trim();
return this.hashSha256(normalizedEmail);
}
async hashIp(ip: string): Promise<string> {
return this.hashSha256(ip);
}
async hashSha256(text: string): Promise<string> {
const data = new TextEncoder().encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async hashPassword(password: string): Promise<string> {
return hash(password, {
algorithm: 2,
});
}
async verifyPassword(password: string, hash: string): Promise<boolean> {
return verify(hash, password);
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as jose from "jose";
@Injectable()
export class JwtService {
private readonly logger = new Logger(JwtService.name);
private readonly jwtSecret: Uint8Array;
constructor(private configService: ConfigService) {
const secret = this.configService.get<string>("JWT_SECRET");
if (!secret) {
this.logger.warn(
"JWT_SECRET is not defined, using a default insecure secret for development",
);
}
this.jwtSecret = new TextEncoder().encode(
secret || "default-secret-change-me-in-production",
);
}
async generateJwt(
payload: jose.JWTPayload,
expiresIn = "2h",
): Promise<string> {
return new jose.SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(this.jwtSecret);
}
async verifyJwt<T extends jose.JWTPayload>(token: string): Promise<T> {
const { payload } = await jose.jwtVerify(token, this.jwtSecret);
return payload as T;
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
@Injectable()
export class PostQuantumService {
generatePostQuantumKeyPair() {
const seed = new Uint8Array(64);
crypto.getRandomValues(seed);
const { publicKey, secretKey } = ml_kem768.keygen(seed);
return { publicKey, secretKey };
}
encapsulate(publicKey: Uint8Array) {
return ml_kem768.encapsulate(publicKey);
}
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) {
return ml_kem768.decapsulate(cipherText, secretKey);
}
}

View File

@@ -2,11 +2,12 @@ import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module"; import { DatabaseModule } from "../database/database.module";
import { FavoritesController } from "./favorites.controller"; import { FavoritesController } from "./favorites.controller";
import { FavoritesService } from "./favorites.service"; import { FavoritesService } from "./favorites.service";
import { FavoritesRepository } from "./repositories/favorites.repository";
@Module({ @Module({
imports: [DatabaseModule], imports: [DatabaseModule],
controllers: [FavoritesController], controllers: [FavoritesController],
providers: [FavoritesService], providers: [FavoritesService, FavoritesRepository],
exports: [FavoritesService], exports: [FavoritesService],
}) })
export class FavoritesModule {} export class FavoritesModule {}

View File

@@ -1,54 +1,31 @@
import { ConflictException, NotFoundException } from "@nestjs/common"; import { ConflictException, NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service";
import { FavoritesService } from "./favorites.service"; import { FavoritesService } from "./favorites.service";
import { FavoritesRepository } from "./repositories/favorites.repository";
describe("FavoritesService", () => { describe("FavoritesService", () => {
let service: FavoritesService; let service: FavoritesService;
let repository: FavoritesRepository;
const mockDb = { const mockFavoritesRepository = {
select: jest.fn(), findContentById: jest.fn(),
from: jest.fn(), add: jest.fn(),
where: jest.fn(), remove: jest.fn(),
limit: jest.fn(), findByUserId: jest.fn(),
offset: jest.fn(),
innerJoin: jest.fn(),
insert: jest.fn(),
values: jest.fn(),
delete: jest.fn(),
returning: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
const chain = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
};
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
for (const mock of Object.values(chain)) {
mock.mockImplementation(mockImplementation);
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
FavoritesService, FavoritesService,
{ provide: DatabaseService, useValue: { db: mockDb } }, { provide: FavoritesRepository, useValue: mockFavoritesRepository },
], ],
}).compile(); }).compile();
service = module.get<FavoritesService>(FavoritesService); service = module.get<FavoritesService>(FavoritesService);
repository = module.get<FavoritesRepository>(FavoritesRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -57,26 +34,27 @@ describe("FavoritesService", () => {
describe("addFavorite", () => { describe("addFavorite", () => {
it("should add a favorite", async () => { it("should add a favorite", async () => {
mockDb.limit.mockResolvedValue([{ id: "content1" }]); mockFavoritesRepository.findContentById.mockResolvedValue({ id: "content1" });
mockDb.returning.mockResolvedValue([ mockFavoritesRepository.add.mockResolvedValue([
{ userId: "u1", contentId: "content1" }, { userId: "u1", contentId: "content1" },
]); ]);
const result = await service.addFavorite("u1", "content1"); const result = await service.addFavorite("u1", "content1");
expect(result).toEqual([{ userId: "u1", contentId: "content1" }]); expect(result).toEqual([{ userId: "u1", contentId: "content1" }]);
expect(repository.add).toHaveBeenCalledWith("u1", "content1");
}); });
it("should throw NotFoundException if content does not exist", async () => { it("should throw NotFoundException if content does not exist", async () => {
mockDb.limit.mockResolvedValue([]); mockFavoritesRepository.findContentById.mockResolvedValue(null);
await expect(service.addFavorite("u1", "invalid")).rejects.toThrow( await expect(service.addFavorite("u1", "invalid")).rejects.toThrow(
NotFoundException, NotFoundException,
); );
}); });
it("should throw ConflictException on duplicate favorite", async () => { it("should throw ConflictException on duplicate favorite", async () => {
mockDb.limit.mockResolvedValue([{ id: "content1" }]); mockFavoritesRepository.findContentById.mockResolvedValue({ id: "content1" });
mockDb.returning.mockRejectedValue(new Error("Duplicate")); mockFavoritesRepository.add.mockRejectedValue(new Error("Duplicate"));
await expect(service.addFavorite("u1", "content1")).rejects.toThrow( await expect(service.addFavorite("u1", "content1")).rejects.toThrow(
ConflictException, ConflictException,
); );
@@ -85,13 +63,14 @@ describe("FavoritesService", () => {
describe("removeFavorite", () => { describe("removeFavorite", () => {
it("should remove a favorite", async () => { it("should remove a favorite", async () => {
mockDb.returning.mockResolvedValue([{ userId: "u1", contentId: "c1" }]); mockFavoritesRepository.remove.mockResolvedValue([{ userId: "u1", contentId: "c1" }]);
const result = await service.removeFavorite("u1", "c1"); const result = await service.removeFavorite("u1", "c1");
expect(result).toEqual({ userId: "u1", contentId: "c1" }); expect(result).toEqual({ userId: "u1", contentId: "c1" });
expect(repository.remove).toHaveBeenCalledWith("u1", "c1");
}); });
it("should throw NotFoundException if favorite not found", async () => { it("should throw NotFoundException if favorite not found", async () => {
mockDb.returning.mockResolvedValue([]); mockFavoritesRepository.remove.mockResolvedValue([]);
await expect(service.removeFavorite("u1", "c1")).rejects.toThrow( await expect(service.removeFavorite("u1", "c1")).rejects.toThrow(
NotFoundException, NotFoundException,
); );

View File

@@ -4,46 +4,32 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { and, eq } from "drizzle-orm"; import { FavoritesRepository } from "./repositories/favorites.repository";
import { DatabaseService } from "../database/database.service";
import { contents, favorites } from "../database/schemas";
@Injectable() @Injectable()
export class FavoritesService { export class FavoritesService {
private readonly logger = new Logger(FavoritesService.name); private readonly logger = new Logger(FavoritesService.name);
constructor(private readonly databaseService: DatabaseService) {} constructor(private readonly favoritesRepository: FavoritesRepository) {}
async addFavorite(userId: string, contentId: string) { async addFavorite(userId: string, contentId: string) {
this.logger.log(`Adding favorite: user ${userId}, content ${contentId}`); this.logger.log(`Adding favorite: user ${userId}, content ${contentId}`);
// Vérifier si le contenu existe
const content = await this.databaseService.db const content = await this.favoritesRepository.findContentById(contentId);
.select() if (!content) {
.from(contents)
.where(eq(contents.id, contentId))
.limit(1);
if (content.length === 0) {
throw new NotFoundException("Content not found"); throw new NotFoundException("Content not found");
} }
try { try {
return await this.databaseService.db return await this.favoritesRepository.add(userId, contentId);
.insert(favorites)
.values({ userId, contentId })
.returning();
} catch (_error) { } catch (_error) {
// Probablement une violation de clé primaire (déjà en favori)
throw new ConflictException("Content already in favorites"); throw new ConflictException("Content already in favorites");
} }
} }
async removeFavorite(userId: string, contentId: string) { async removeFavorite(userId: string, contentId: string) {
this.logger.log(`Removing favorite: user ${userId}, content ${contentId}`); this.logger.log(`Removing favorite: user ${userId}, content ${contentId}`);
const result = await this.databaseService.db const result = await this.favoritesRepository.remove(userId, contentId);
.delete(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.contentId, contentId)))
.returning();
if (result.length === 0) { if (result.length === 0) {
throw new NotFoundException("Favorite not found"); throw new NotFoundException("Favorite not found");
@@ -53,16 +39,6 @@ export class FavoritesService {
} }
async getUserFavorites(userId: string, limit: number, offset: number) { async getUserFavorites(userId: string, limit: number, offset: number) {
const data = await this.databaseService.db return await this.favoritesRepository.findByUserId(userId, limit, offset);
.select({
content: contents,
})
.from(favorites)
.innerJoin(contents, eq(favorites.contentId, contents.id))
.where(eq(favorites.userId, userId))
.limit(limit)
.offset(offset);
return data.map((item) => item.content);
} }
} }

View File

@@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { contents, favorites } from "../../database/schemas";
@Injectable()
export class FavoritesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findContentById(contentId: string) {
const result = await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.id, contentId))
.limit(1);
return result[0] || null;
}
async add(userId: string, contentId: string) {
return await this.databaseService.db
.insert(favorites)
.values({ userId, contentId })
.returning();
}
async remove(userId: string, contentId: string) {
return await this.databaseService.db
.delete(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.contentId, contentId)))
.returning();
}
async findByUserId(userId: string, limit: number, offset: number) {
const data = await this.databaseService.db
.select({
content: contents,
})
.from(favorites)
.innerJoin(contents, eq(favorites.contentId, contents.id))
.where(eq(favorites.userId, userId))
.limit(limit)
.offset(offset);
return data.map((item) => item.content);
}
}

View File

@@ -1,9 +1,10 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { MailerService } from "@nestjs-modules/mailer"; import { MailerService } from "@nestjs-modules/mailer";
import type { IMailService } from "../common/interfaces/mail.interface";
@Injectable() @Injectable()
export class MailService { export class MailService implements IMailService {
private readonly logger = new Logger(MailService.name); private readonly logger = new Logger(MailService.name);
private readonly domain: string; private readonly domain: string;

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MediaService } from "./media.service"; import { MediaService } from "./media.service";
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
@Module({ @Module({
providers: [MediaService], providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
exports: [MediaService], exports: [MediaService],
}) })
export class MediaModule {} export class MediaModule {}

View File

@@ -6,6 +6,9 @@ import ffmpeg from "fluent-ffmpeg";
import sharp from "sharp"; import sharp from "sharp";
import { MediaService } from "./media.service"; import { MediaService } from "./media.service";
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
jest.mock("sharp"); jest.mock("sharp");
jest.mock("fluent-ffmpeg"); jest.mock("fluent-ffmpeg");
jest.mock("node:fs/promises"); jest.mock("node:fs/promises");
@@ -29,6 +32,8 @@ describe("MediaService", () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
MediaService, MediaService,
ImageProcessorStrategy,
VideoProcessorStrategy,
{ {
provide: ConfigService, provide: ConfigService,
useValue: { useValue: {

View File

@@ -1,22 +1,18 @@
import { readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { import {
BadRequestException,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import * as NodeClam from "clamscan"; import * as NodeClam from "clamscan";
import ffmpeg from "fluent-ffmpeg";
import sharp from "sharp";
import { v4 as uuidv4 } from "uuid";
import type { import type {
IMediaService,
MediaProcessingResult, MediaProcessingResult,
ScanResult, ScanResult,
} from "./interfaces/media.interface"; } from "../common/interfaces/media.interface";
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
interface ClamScanner { interface ClamScanner {
scanStream( scanStream(
@@ -25,12 +21,16 @@ interface ClamScanner {
} }
@Injectable() @Injectable()
export class MediaService { export class MediaService implements IMediaService {
private readonly logger = new Logger(MediaService.name); private readonly logger = new Logger(MediaService.name);
private clamscan: ClamScanner | null = null; private clamscan: ClamScanner | null = null;
private isClamAvInitialized = false; private isClamAvInitialized = false;
constructor(private readonly configService: ConfigService) { constructor(
private readonly configService: ConfigService,
private readonly imageProcessor: ImageProcessorStrategy,
private readonly videoProcessor: VideoProcessorStrategy,
) {
this.initClamScan(); this.initClamScan();
} }
@@ -84,82 +84,13 @@ export class MediaService {
buffer: Buffer, buffer: Buffer,
format: "webp" | "avif" = "webp", format: "webp" | "avif" = "webp",
): Promise<MediaProcessingResult> { ): Promise<MediaProcessingResult> {
try { return this.imageProcessor.process(buffer, { format });
let pipeline = sharp(buffer);
const metadata = await pipeline.metadata();
if (format === "webp") {
pipeline = pipeline.webp({ quality: 80, effort: 6 });
} else {
pipeline = pipeline.avif({ quality: 65, effort: 6 });
}
const processedBuffer = await pipeline.toBuffer();
return {
buffer: processedBuffer,
mimeType: `image/${format}`,
extension: format,
width: metadata.width,
height: metadata.height,
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing image: ${error.message}`);
throw new BadRequestException("Failed to process image");
}
} }
async processVideo( async processVideo(
buffer: Buffer, buffer: Buffer,
format: "webm" | "av1" = "webm", format: "webm" | "av1" = "webm",
): Promise<MediaProcessingResult> { ): Promise<MediaProcessingResult> {
const tempInput = join(tmpdir(), `${uuidv4()}.tmp`); return this.videoProcessor.process(buffer, { format });
const tempOutput = join(
tmpdir(),
`${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`,
);
try {
await writeFile(tempInput, buffer);
await new Promise<void>((resolve, reject) => {
let command = ffmpeg(tempInput);
if (format === "webm") {
command = command
.toFormat("webm")
.videoCodec("libvpx-vp9")
.audioCodec("libopus")
.outputOptions("-crf 30", "-b:v 0");
} else {
command = command
.toFormat("mp4")
.videoCodec("libaom-av1")
.audioCodec("libopus")
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
}
command
.on("end", () => resolve())
.on("error", (err) => reject(err))
.save(tempOutput);
});
const processedBuffer = await readFile(tempOutput);
return {
buffer: processedBuffer,
mimeType: format === "av1" ? "video/mp4" : "video/webm",
extension: format === "av1" ? "mp4" : "webm",
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing video: ${error.message}`);
throw new BadRequestException("Failed to process video");
} finally {
await unlink(tempInput).catch(() => {});
await unlink(tempOutput).catch(() => {});
}
} }
} }

View File

@@ -0,0 +1,44 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import sharp from "sharp";
import type { MediaProcessingResult } from "../../common/interfaces/media.interface";
import type { IMediaProcessorStrategy } from "./media-processor.strategy";
@Injectable()
export class ImageProcessorStrategy implements IMediaProcessorStrategy {
private readonly logger = new Logger(ImageProcessorStrategy.name);
canHandle(mimeType: string): boolean {
return mimeType.startsWith("image/");
}
async process(
buffer: Buffer,
options: { format: "webp" | "avif" } = { format: "webp" },
): Promise<MediaProcessingResult> {
try {
const { format } = options;
let pipeline = sharp(buffer);
const metadata = await pipeline.metadata();
if (format === "webp") {
pipeline = pipeline.webp({ quality: 80, effort: 6 });
} else {
pipeline = pipeline.avif({ quality: 65, effort: 6 });
}
const processedBuffer = await pipeline.toBuffer();
return {
buffer: processedBuffer,
mimeType: `image/${format}`,
extension: format,
width: metadata.width,
height: metadata.height,
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing image: ${error.message}`);
throw new BadRequestException("Failed to process image");
}
}
}

View File

@@ -0,0 +1,6 @@
import type { MediaProcessingResult } from "../../common/interfaces/media.interface";
export interface IMediaProcessorStrategy {
canHandle(mimeType: string): boolean;
process(buffer: Buffer, options?: any): Promise<MediaProcessingResult>;
}

View File

@@ -0,0 +1,71 @@
import { readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import ffmpeg from "fluent-ffmpeg";
import { v4 as uuidv4 } from "uuid";
import type { MediaProcessingResult } from "../../common/interfaces/media.interface";
import type { IMediaProcessorStrategy } from "./media-processor.strategy";
@Injectable()
export class VideoProcessorStrategy implements IMediaProcessorStrategy {
private readonly logger = new Logger(VideoProcessorStrategy.name);
canHandle(mimeType: string): boolean {
return mimeType.startsWith("video/");
}
async process(
buffer: Buffer,
options: { format: "webm" | "av1" } = { format: "webm" },
): Promise<MediaProcessingResult> {
const { format } = options;
const tempInput = join(tmpdir(), `${uuidv4()}.tmp`);
const tempOutput = join(
tmpdir(),
`${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`,
);
try {
await writeFile(tempInput, buffer);
await new Promise<void>((resolve, reject) => {
let command = ffmpeg(tempInput);
if (format === "webm") {
command = command
.toFormat("webm")
.videoCodec("libvpx-vp9")
.audioCodec("libopus")
.outputOptions("-crf 30", "-b:v 0");
} else {
command = command
.toFormat("mp4")
.videoCodec("libaom-av1")
.audioCodec("libopus")
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
}
command
.on("end", () => resolve())
.on("error", (err) => reject(err))
.save(tempOutput);
});
const processedBuffer = await readFile(tempOutput);
return {
buffer: processedBuffer,
mimeType: format === "av1" ? "video/mp4" : "video/webm",
extension: format === "av1" ? "mp4" : "webm",
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing video: ${error.message}`);
throw new BadRequestException("Failed to process video");
} finally {
await unlink(tempInput).catch(() => {});
await unlink(tempOutput).catch(() => {});
}
}
}

View File

@@ -4,10 +4,11 @@ import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module"; import { DatabaseModule } from "../database/database.module";
import { ReportsController } from "./reports.controller"; import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service"; import { ReportsService } from "./reports.service";
import { ReportsRepository } from "./repositories/reports.repository";
@Module({ @Module({
imports: [DatabaseModule, AuthModule, CryptoModule], imports: [DatabaseModule, AuthModule, CryptoModule],
controllers: [ReportsController], controllers: [ReportsController],
providers: [ReportsService], providers: [ReportsService, ReportsRepository],
}) })
export class ReportsModule {} export class ReportsModule {}

View File

@@ -1,56 +1,29 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service";
import { CreateReportDto } from "./dto/create-report.dto";
import { ReportsService } from "./reports.service"; import { ReportsService } from "./reports.service";
import { ReportsRepository } from "./repositories/reports.repository";
describe("ReportsService", () => { describe("ReportsService", () => {
let service: ReportsService; let service: ReportsService;
let repository: ReportsRepository;
const mockDb = { const mockReportsRepository = {
insert: jest.fn(), create: jest.fn(),
values: jest.fn(), findAll: jest.fn(),
returning: jest.fn(), updateStatus: jest.fn(),
select: jest.fn(),
from: jest.fn(),
orderBy: jest.fn(),
limit: jest.fn(),
offset: jest.fn(),
update: jest.fn(),
set: jest.fn(),
where: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
const chain = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
};
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
for (const mock of Object.values(chain)) {
mock.mockImplementation(mockImplementation);
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
ReportsService, ReportsService,
{ provide: DatabaseService, useValue: { db: mockDb } }, { provide: ReportsRepository, useValue: mockReportsRepository },
], ],
}).compile(); }).compile();
service = module.get<ReportsService>(ReportsService); service = module.get<ReportsService>(ReportsService);
repository = module.get<ReportsRepository>(ReportsRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -60,29 +33,31 @@ describe("ReportsService", () => {
describe("create", () => { describe("create", () => {
it("should create a report", async () => { it("should create a report", async () => {
const reporterId = "u1"; const reporterId = "u1";
const data: CreateReportDto = { contentId: "c1", reason: "spam" }; const data = { contentId: "c1", reason: "spam" };
mockDb.returning.mockResolvedValue([{ id: "r1", ...data, reporterId }]); mockReportsRepository.create.mockResolvedValue({ id: "r1", ...data, reporterId });
const result = await service.create(reporterId, data); const result = await service.create(reporterId, data);
expect(result.id).toBe("r1"); expect(result.id).toBe("r1");
expect(mockDb.insert).toHaveBeenCalled(); expect(repository.create).toHaveBeenCalled();
}); });
}); });
describe("findAll", () => { describe("findAll", () => {
it("should return reports", async () => { it("should return reports", async () => {
mockDb.offset.mockResolvedValue([{ id: "r1" }]); mockReportsRepository.findAll.mockResolvedValue([{ id: "r1" }]);
const result = await service.findAll(10, 0); const result = await service.findAll(10, 0);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(repository.findAll).toHaveBeenCalledWith(10, 0);
}); });
}); });
describe("updateStatus", () => { describe("updateStatus", () => {
it("should update report status", async () => { it("should update report status", async () => {
mockDb.returning.mockResolvedValue([{ id: "r1", status: "resolved" }]); mockReportsRepository.updateStatus.mockResolvedValue([{ id: "r1", status: "resolved" }]);
const result = await service.updateStatus("r1", "resolved"); const result = await service.updateStatus("r1", "resolved");
expect(result[0].status).toBe("resolved"); expect(result[0].status).toBe("resolved");
expect(repository.updateStatus).toHaveBeenCalledWith("r1", "resolved");
}); });
}); });
}); });

View File

@@ -1,37 +1,26 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { desc, eq } from "drizzle-orm"; import { ReportsRepository } from "./repositories/reports.repository";
import { DatabaseService } from "../database/database.service";
import { reports } from "../database/schemas";
import { CreateReportDto } from "./dto/create-report.dto"; import { CreateReportDto } from "./dto/create-report.dto";
@Injectable() @Injectable()
export class ReportsService { export class ReportsService {
private readonly logger = new Logger(ReportsService.name); private readonly logger = new Logger(ReportsService.name);
constructor(private readonly databaseService: DatabaseService) {} constructor(private readonly reportsRepository: ReportsRepository) {}
async create(reporterId: string, data: CreateReportDto) { async create(reporterId: string, data: CreateReportDto) {
this.logger.log(`Creating report from user ${reporterId}`); this.logger.log(`Creating report from user ${reporterId}`);
const [newReport] = await this.databaseService.db return await this.reportsRepository.create({
.insert(reports) reporterId,
.values({ contentId: data.contentId,
reporterId, tagId: data.tagId,
contentId: data.contentId, reason: data.reason,
tagId: data.tagId, description: data.description,
reason: data.reason, });
description: data.description,
})
.returning();
return newReport;
} }
async findAll(limit: number, offset: number) { async findAll(limit: number, offset: number) {
return await this.databaseService.db return await this.reportsRepository.findAll(limit, offset);
.select()
.from(reports)
.orderBy(desc(reports.createdAt))
.limit(limit)
.offset(offset);
} }
async updateStatus( async updateStatus(
@@ -39,10 +28,6 @@ export class ReportsService {
status: "pending" | "reviewed" | "resolved" | "dismissed", status: "pending" | "reviewed" | "resolved" | "dismissed",
) { ) {
this.logger.log(`Updating report ${id} status to ${status}`); this.logger.log(`Updating report ${id} status to ${status}`);
return await this.databaseService.db return await this.reportsRepository.updateStatus(id, status);
.update(reports)
.set({ status, updatedAt: new Date() })
.where(eq(reports.id, id))
.returning();
} }
} }

View File

@@ -0,0 +1,50 @@
import { Injectable } from "@nestjs/common";
import { desc, eq, lte } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { reports } from "../../database/schemas";
@Injectable()
export class ReportsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: {
reporterId: string;
contentId?: string;
tagId?: string;
reason: string;
description?: string;
}) {
const [newReport] = await this.databaseService.db
.insert(reports)
.values(data)
.returning();
return newReport;
}
async findAll(limit: number, offset: number) {
return await this.databaseService.db
.select()
.from(reports)
.orderBy(desc(reports.createdAt))
.limit(limit)
.offset(offset);
}
async updateStatus(
id: string,
status: "pending" | "reviewed" | "resolved" | "dismissed",
) {
return await this.databaseService.db
.update(reports)
.set({ status, updatedAt: new Date() })
.where(eq(reports.id, id))
.returning();
}
async purgeObsolete(now: Date) {
return await this.databaseService.db
.delete(reports)
.where(lte(reports.expiresAt, now))
.returning();
}
}

View File

@@ -1,9 +1,10 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import * as Minio from "minio"; import * as Minio from "minio";
import type { IStorageService } from "../common/interfaces/storage.interface";
@Injectable() @Injectable()
export class S3Service implements OnModuleInit { export class S3Service implements OnModuleInit, IStorageService {
private readonly logger = new Logger(S3Service.name); private readonly logger = new Logger(S3Service.name);
private minioClient: Minio.Client; private minioClient: Minio.Client;
private readonly bucketName: string; private readonly bucketName: string;

View File

@@ -0,0 +1,64 @@
import { Injectable } from "@nestjs/common";
import { and, eq, lte } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { sessions } from "../../database/schemas";
@Injectable()
export class SessionsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: {
userId: string;
refreshToken: string;
userAgent?: string;
ipHash?: string | null;
expiresAt: Date;
}) {
const [session] = await this.databaseService.db
.insert(sessions)
.values(data)
.returning();
return session;
}
async findValidByRefreshToken(refreshToken: string) {
const result = await this.databaseService.db
.select()
.from(sessions)
.where(
and(eq(sessions.refreshToken, refreshToken), eq(sessions.isValid, true)),
)
.limit(1);
return result[0] || null;
}
async update(sessionId: string, data: any) {
const [updatedSession] = await this.databaseService.db
.update(sessions)
.set({ ...data, updatedAt: new Date() })
.where(eq(sessions.id, sessionId))
.returning();
return updatedSession;
}
async revoke(sessionId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.id, sessionId));
}
async revokeAllByUserId(userId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.userId, userId));
}
async purgeExpired(now: Date) {
return await this.databaseService.db
.delete(sessions)
.where(lte(sessions.expiresAt, now))
.returning();
}
}

View File

@@ -2,10 +2,11 @@ import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module"; import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module"; import { DatabaseModule } from "../database/database.module";
import { SessionsService } from "./sessions.service"; import { SessionsService } from "./sessions.service";
import { SessionsRepository } from "./repositories/sessions.repository";
@Module({ @Module({
imports: [DatabaseModule, CryptoModule], imports: [DatabaseModule, CryptoModule],
providers: [SessionsService], providers: [SessionsService, SessionsRepository],
exports: [SessionsService], exports: [SessionsService],
}) })
export class SessionsModule {} export class SessionsModule {}

View File

@@ -13,60 +13,45 @@ jest.mock("jose", () => ({
import { UnauthorizedException } from "@nestjs/common"; import { UnauthorizedException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { CryptoService } from "../crypto/crypto.service"; import { HashingService } from "../crypto/services/hashing.service";
import { DatabaseService } from "../database/database.service"; import { JwtService } from "../crypto/services/jwt.service";
import { SessionsService } from "./sessions.service"; import { SessionsService } from "./sessions.service";
import { SessionsRepository } from "./repositories/sessions.repository";
describe("SessionsService", () => { describe("SessionsService", () => {
let service: SessionsService; let service: SessionsService;
let repository: SessionsRepository;
const mockDb = { const mockSessionsRepository = {
insert: jest.fn(), create: jest.fn(),
select: jest.fn(), findValidByRefreshToken: jest.fn(),
update: jest.fn(), update: jest.fn(),
revoke: jest.fn(),
revokeAllByUserId: jest.fn(),
}; };
const mockCryptoService = { const mockHashingService = {
generateJwt: jest.fn().mockResolvedValue("mock-jwt"), hashIp: jest.fn().mockResolvedValue("hashed-ip"),
hashIp: jest.fn().mockResolvedValue("mock-ip-hash"), };
const mockJwtService = {
generateJwt: jest.fn().mockResolvedValue("new-token"),
}; };
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
const chain = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
then: jest.fn().mockImplementation(function (cb) {
return Promise.resolve(this).then(cb);
}),
};
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
for (const mock of Object.values(chain)) {
if (mock !== chain.then) {
mock.mockImplementation(mockImplementation);
}
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
SessionsService, SessionsService,
{ provide: DatabaseService, useValue: { db: mockDb } }, { provide: SessionsRepository, useValue: mockSessionsRepository },
{ provide: CryptoService, useValue: mockCryptoService }, { provide: HashingService, useValue: mockHashingService },
{ provide: JwtService, useValue: mockJwtService },
], ],
}).compile(); }).compile();
service = module.get<SessionsService>(SessionsService); service = module.get<SessionsService>(SessionsService);
repository = module.get<SessionsRepository>(SessionsRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -75,13 +60,10 @@ describe("SessionsService", () => {
describe("createSession", () => { describe("createSession", () => {
it("should create a session", async () => { it("should create a session", async () => {
mockDb.returning.mockResolvedValue([{ id: "s1", refreshToken: "mock-jwt" }]); mockSessionsRepository.create.mockResolvedValue({ id: "s1" });
const result = await service.createSession("u1", "agent", "1.2.3.4"); const result = await service.createSession("u1", "agent", "1.2.3.4");
expect(result.id).toBe("s1"); expect(result).toEqual({ id: "s1" });
expect(mockCryptoService.generateJwt).toHaveBeenCalledWith( expect(repository.create).toHaveBeenCalled();
{ sub: "u1", type: "refresh" },
"7d",
);
}); });
}); });
@@ -89,36 +71,46 @@ describe("SessionsService", () => {
it("should refresh a valid session", async () => { it("should refresh a valid session", async () => {
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 1); expiresAt.setDate(expiresAt.getDate() + 1);
const session = { id: "s1", userId: "u1", expiresAt, isValid: true }; mockSessionsRepository.findValidByRefreshToken.mockResolvedValue({
id: "s1",
mockDb.where.mockImplementation(() => { userId: "u1",
const chain = { expiresAt,
limit: jest.fn().mockReturnThis(),
returning: jest
.fn()
.mockResolvedValue([{ ...session, refreshToken: "new-jwt" }]),
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
then: jest.fn().mockResolvedValue(session),
};
return Object.assign(Promise.resolve([session]), chain);
}); });
mockSessionsRepository.update.mockResolvedValue({ id: "s1", refreshToken: "new-token" });
const result = await service.refreshSession("old-jwt"); const result = await service.refreshSession("old-token");
expect(result.refreshToken).toBe("new-jwt");
expect(result.refreshToken).toBe("new-token");
expect(repository.update).toHaveBeenCalled();
}); });
it("should throw UnauthorizedException if session not found", async () => { it("should throw if session not found", async () => {
mockDb.where.mockImplementation(() => { mockSessionsRepository.findValidByRefreshToken.mockResolvedValue(null);
const chain = {
limit: jest.fn().mockReturnThis(),
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
then: jest.fn().mockResolvedValue(null),
};
return Object.assign(Promise.resolve([]), chain);
});
await expect(service.refreshSession("invalid")).rejects.toThrow( await expect(service.refreshSession("invalid")).rejects.toThrow(
UnauthorizedException, UnauthorizedException,
); );
}); });
it("should throw and revoke if session expired", async () => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() - 1);
mockSessionsRepository.findValidByRefreshToken.mockResolvedValue({
id: "s1",
userId: "u1",
expiresAt,
});
await expect(service.refreshSession("expired")).rejects.toThrow(
UnauthorizedException,
);
expect(repository.revoke).toHaveBeenCalledWith("s1");
});
});
describe("revokeSession", () => {
it("should revoke a session", async () => {
await service.revokeSession("s1");
expect(repository.revoke).toHaveBeenCalledWith("s1");
});
}); });
}); });

View File

@@ -1,48 +1,36 @@
import { Injectable, UnauthorizedException } from "@nestjs/common"; import { Injectable, UnauthorizedException } from "@nestjs/common";
import { and, eq } from "drizzle-orm"; import { HashingService } from "../crypto/services/hashing.service";
import { CryptoService } from "../crypto/crypto.service"; import { JwtService } from "../crypto/services/jwt.service";
import { DatabaseService } from "../database/database.service"; import { SessionsRepository } from "./repositories/sessions.repository";
import { sessions } from "../database/schemas";
@Injectable() @Injectable()
export class SessionsService { export class SessionsService {
constructor( constructor(
private readonly databaseService: DatabaseService, private readonly sessionsRepository: SessionsRepository,
private readonly cryptoService: CryptoService, private readonly hashingService: HashingService,
private readonly jwtService: JwtService,
) {} ) {}
async createSession(userId: string, userAgent?: string, ip?: string) { async createSession(userId: string, userAgent?: string, ip?: string) {
const refreshToken = await this.cryptoService.generateJwt( const refreshToken = await this.jwtService.generateJwt(
{ sub: userId, type: "refresh" }, { sub: userId, type: "refresh" },
"7d", "7d",
); );
const ipHash = ip ? await this.cryptoService.hashIp(ip) : null; const ipHash = ip ? await this.hashingService.hashIp(ip) : null;
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); expiresAt.setDate(expiresAt.getDate() + 7);
const [session] = await this.databaseService.db return await this.sessionsRepository.create({
.insert(sessions) userId,
.values({ refreshToken,
userId, userAgent,
refreshToken, ipHash,
userAgent, expiresAt,
ipHash, });
expiresAt,
})
.returning();
return session;
} }
async refreshSession(oldRefreshToken: string) { async refreshSession(oldRefreshToken: string) {
const session = await this.databaseService.db const session = await this.sessionsRepository.findValidByRefreshToken(oldRefreshToken);
.select()
.from(sessions)
.where(
and(eq(sessions.refreshToken, oldRefreshToken), eq(sessions.isValid, true)),
)
.limit(1)
.then((res) => res[0]);
if (!session || session.expiresAt < new Date()) { if (!session || session.expiresAt < new Date()) {
if (session) { if (session) {
@@ -52,37 +40,24 @@ export class SessionsService {
} }
// Rotation du refresh token // Rotation du refresh token
const newRefreshToken = await this.cryptoService.generateJwt( const newRefreshToken = await this.jwtService.generateJwt(
{ sub: session.userId, type: "refresh" }, { sub: session.userId, type: "refresh" },
"7d", "7d",
); );
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); expiresAt.setDate(expiresAt.getDate() + 7);
const [updatedSession] = await this.databaseService.db return await this.sessionsRepository.update(session.id, {
.update(sessions) refreshToken: newRefreshToken,
.set({ expiresAt,
refreshToken: newRefreshToken, });
expiresAt,
updatedAt: new Date(),
})
.where(eq(sessions.id, session.id))
.returning();
return updatedSession;
} }
async revokeSession(sessionId: string) { async revokeSession(sessionId: string) {
await this.databaseService.db await this.sessionsRepository.revoke(sessionId);
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.id, sessionId));
} }
async revokeAllUserSessions(userId: string) { async revokeAllUserSessions(userId: string) {
await this.databaseService.db await this.sessionsRepository.revokeAllByUserId(userId);
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.userId, userId));
} }
} }

View File

@@ -0,0 +1,48 @@
import { Injectable } from "@nestjs/common";
import { desc, eq, ilike, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { contentsToTags, tags } from "../../database/schemas";
@Injectable()
export class TagsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findAll(options: {
limit: number;
offset: number;
query?: string;
sortBy?: "popular" | "recent";
}) {
const { limit, offset, query, sortBy } = options;
let whereClause = sql`1=1`;
if (query) {
whereClause = ilike(tags.name, `%${query}%`);
}
if (sortBy === "popular") {
return await this.databaseService.db
.select({
id: tags.id,
name: tags.name,
slug: tags.slug,
count: sql<number>`count(${contentsToTags.contentId})`.as("usage_count"),
})
.from(tags)
.leftJoin(contentsToTags, eq(tags.id, contentsToTags.tagId))
.where(whereClause)
.groupBy(tags.id)
.orderBy(desc(sql`usage_count`))
.limit(limit)
.offset(offset);
}
return await this.databaseService.db
.select()
.from(tags)
.where(whereClause)
.orderBy(sortBy === "recent" ? desc(tags.createdAt) : desc(tags.name))
.limit(limit)
.offset(offset);
}
}

View File

@@ -2,11 +2,12 @@ import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module"; import { DatabaseModule } from "../database/database.module";
import { TagsController } from "./tags.controller"; import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service"; import { TagsService } from "./tags.service";
import { TagsRepository } from "./repositories/tags.repository";
@Module({ @Module({
imports: [DatabaseModule], imports: [DatabaseModule],
controllers: [TagsController], controllers: [TagsController],
providers: [TagsService], providers: [TagsService, TagsRepository],
exports: [TagsService], exports: [TagsService],
}) })
export class TagsModule {} export class TagsModule {}

View File

@@ -1,49 +1,27 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service";
import { TagsService } from "./tags.service"; import { TagsService } from "./tags.service";
import { TagsRepository } from "./repositories/tags.repository";
describe("TagsService", () => { describe("TagsService", () => {
let service: TagsService; let service: TagsService;
let repository: TagsRepository;
const mockDb = { const mockTagsRepository = {
select: jest.fn(), findAll: jest.fn(),
from: jest.fn(),
leftJoin: jest.fn(),
where: jest.fn(),
groupBy: jest.fn(),
orderBy: jest.fn(),
limit: jest.fn(),
offset: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
const chain = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
};
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
for (const mock of Object.values(chain)) {
mock.mockImplementation(mockImplementation);
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
TagsService, TagsService,
{ provide: DatabaseService, useValue: { db: mockDb } }, { provide: TagsRepository, useValue: mockTagsRepository },
], ],
}).compile(); }).compile();
service = module.get<TagsService>(TagsService); service = module.get<TagsService>(TagsService);
repository = module.get<TagsRepository>(TagsRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -53,24 +31,12 @@ describe("TagsService", () => {
describe("findAll", () => { describe("findAll", () => {
it("should return tags", async () => { it("should return tags", async () => {
const mockTags = [{ id: "1", name: "tag1" }]; const mockTags = [{ id: "1", name: "tag1" }];
mockDb.offset.mockResolvedValue(mockTags); mockTagsRepository.findAll.mockResolvedValue(mockTags);
const result = await service.findAll({ limit: 10, offset: 0 }); const result = await service.findAll({ limit: 10, offset: 0 });
expect(result).toEqual(mockTags); expect(result).toEqual(mockTags);
expect(mockDb.select).toHaveBeenCalled(); expect(repository.findAll).toHaveBeenCalledWith({ limit: 10, offset: 0 });
});
it("should handle popular sort", async () => {
mockDb.offset.mockResolvedValue([{ id: "1", name: "tag1", count: 5 }]);
const result = await service.findAll({
limit: 10,
offset: 0,
sortBy: "popular",
});
expect(result[0].count).toBe(5);
expect(mockDb.leftJoin).toHaveBeenCalled();
expect(mockDb.groupBy).toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -1,13 +1,11 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { desc, eq, ilike, sql } from "drizzle-orm"; import { TagsRepository } from "./repositories/tags.repository";
import { DatabaseService } from "../database/database.service";
import { contentsToTags, tags } from "../database/schemas";
@Injectable() @Injectable()
export class TagsService { export class TagsService {
private readonly logger = new Logger(TagsService.name); private readonly logger = new Logger(TagsService.name);
constructor(private readonly databaseService: DatabaseService) {} constructor(private readonly tagsRepository: TagsRepository) {}
async findAll(options: { async findAll(options: {
limit: number; limit: number;
@@ -16,41 +14,6 @@ export class TagsService {
sortBy?: "popular" | "recent"; sortBy?: "popular" | "recent";
}) { }) {
this.logger.log(`Fetching tags with options: ${JSON.stringify(options)}`); this.logger.log(`Fetching tags with options: ${JSON.stringify(options)}`);
const { limit, offset, query, sortBy } = options; return await this.tagsRepository.findAll(options);
let whereClause = sql`1=1`;
if (query) {
whereClause = ilike(tags.name, `%${query}%`);
}
// Pour la popularité, on compte le nombre d'associations dans contentsToTags
if (sortBy === "popular") {
const data = await this.databaseService.db
.select({
id: tags.id,
name: tags.name,
slug: tags.slug,
count: sql<number>`count(${contentsToTags.contentId})`.as("usage_count"),
})
.from(tags)
.leftJoin(contentsToTags, eq(tags.id, contentsToTags.tagId))
.where(whereClause)
.groupBy(tags.id)
.orderBy(desc(sql`usage_count`))
.limit(limit)
.offset(offset);
return data;
}
const data = await this.databaseService.db
.select()
.from(tags)
.where(whereClause)
.orderBy(sortBy === "recent" ? desc(tags.createdAt) : desc(tags.name))
.limit(limit)
.offset(offset);
return data;
} }
} }

View File

@@ -0,0 +1,158 @@
import { Injectable } from "@nestjs/common";
import { and, eq, lte, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { users, contents, favorites } from "../../database/schemas";
import type { UpdateUserDto } from "../dto/update-user.dto";
@Injectable()
export class UsersRepository {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: {
username: string;
email: string;
passwordHash: string;
emailHash: string;
}) {
const [newUser] = await this.databaseService.db
.insert(users)
.values(data)
.returning();
return newUser;
}
async findByEmailHash(emailHash: string) {
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
email: users.email,
passwordHash: users.passwordHash,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
})
.from(users)
.where(eq(users.emailHash, emailHash))
.limit(1);
return result[0] || null;
}
async findOneWithPrivateData(uuid: string) {
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
email: users.email,
displayName: users.displayName,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0] || null;
}
async countAll() {
const result = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(users);
return Number(result[0].count);
}
async findAll(limit: number, offset: number) {
return await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
status: users.status,
createdAt: users.createdAt,
})
.from(users)
.limit(limit)
.offset(offset);
}
async findByUsername(username: string) {
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.username, username))
.limit(1);
return result[0] || null;
}
async findOne(uuid: string) {
const result = await this.databaseService.db
.select()
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0] || null;
}
async update(uuid: string, data: any) {
return await this.databaseService.db
.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.uuid, uuid))
.returning();
}
async getTwoFactorSecret(uuid: string) {
const result = await this.databaseService.db
.select({
secret: users.twoFactorSecret,
})
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0]?.secret || null;
}
async getUserContents(uuid: string) {
return await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.userId, uuid));
}
async getUserFavorites(uuid: string) {
return await this.databaseService.db
.select()
.from(favorites)
.where(eq(favorites.userId, uuid));
}
async softDeleteUserAndContents(uuid: string) {
return await this.databaseService.db.transaction(async (tx) => {
const userResult = await tx
.update(users)
.set({ status: "deleted", deletedAt: new Date() })
.where(eq(users.uuid, uuid))
.returning();
await tx
.update(contents)
.set({ deletedAt: new Date() })
.where(eq(contents.userId, uuid));
return userResult;
});
}
async purgeDeleted(before: Date) {
return await this.databaseService.db
.delete(users)
.where(and(eq(users.status, "deleted"), lte(users.deletedAt, before)))
.returning();
}
}

View File

@@ -4,11 +4,12 @@ import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module"; import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller"; import { UsersController } from "./users.controller";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
import { UsersRepository } from "./repositories/users.repository";
@Module({ @Module({
imports: [DatabaseModule, CryptoModule, AuthModule], imports: [DatabaseModule, CryptoModule, AuthModule],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService], providers: [UsersService, UsersRepository],
exports: [UsersService], exports: [UsersService],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -12,61 +12,46 @@ jest.mock("jose", () => ({
})); }));
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { CryptoService } from "../crypto/crypto.service"; import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { DatabaseService } from "../database/database.service";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
import { UsersRepository } from "./repositories/users.repository";
describe("UsersService", () => { describe("UsersService", () => {
let service: UsersService; let service: UsersService;
let repository: UsersRepository;
const mockDb = { const mockUsersRepository = {
insert: jest.fn(), create: jest.fn(),
select: jest.fn(), findOne: jest.fn(),
findByEmailHash: jest.fn(),
findOneWithPrivateData: jest.fn(),
countAll: jest.fn(),
findAll: jest.fn(),
findByUsername: jest.fn(),
update: jest.fn(), update: jest.fn(),
transaction: jest.fn().mockImplementation((cb) => cb(mockDb)), getTwoFactorSecret: jest.fn(),
getUserContents: jest.fn(),
getUserFavorites: jest.fn(),
softDeleteUserAndContents: jest.fn(),
}; };
const mockCryptoService = { const mockCacheManager = {
getPgpEncryptionKey: jest.fn().mockReturnValue("mock-pgp-key"), del: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
const chain = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
then: jest.fn().mockImplementation(function (cb) {
return Promise.resolve(this).then(cb);
}),
};
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
for (const mock of Object.values(chain)) {
if (mock !== chain.then) {
mock.mockImplementation(mockImplementation);
}
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
UsersService, UsersService,
{ provide: DatabaseService, useValue: { db: mockDb } }, { provide: UsersRepository, useValue: mockUsersRepository },
{ provide: CryptoService, useValue: mockCryptoService }, { provide: CACHE_MANAGER, useValue: mockCacheManager },
], ],
}).compile(); }).compile();
service = module.get<UsersService>(UsersService); service = module.get<UsersService>(UsersService);
repository = module.get<UsersRepository>(UsersRepository);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -81,24 +66,24 @@ describe("UsersService", () => {
passwordHash: "p1", passwordHash: "p1",
emailHash: "eh1", emailHash: "eh1",
}; };
mockDb.returning.mockResolvedValue([{ uuid: "uuid1", ...data }]); mockUsersRepository.create.mockResolvedValue({ uuid: "uuid1", ...data });
const result = await service.create(data); const result = await service.create(data);
expect(result.uuid).toBe("uuid1"); expect(result.uuid).toBe("uuid1");
expect(mockDb.insert).toHaveBeenCalled(); expect(repository.create).toHaveBeenCalledWith(data);
}); });
}); });
describe("findOne", () => { describe("findOne", () => {
it("should find a user", async () => { it("should find a user", async () => {
mockDb.limit.mockResolvedValue([{ uuid: "uuid1" }]); mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" });
const result = await service.findOne("uuid1"); const result = await service.findOne("uuid1");
expect(result.uuid).toBe("uuid1"); expect(result.uuid).toBe("uuid1");
}); });
it("should return null if not found", async () => { it("should return null if not found", async () => {
mockDb.limit.mockResolvedValue([]); mockUsersRepository.findOne.mockResolvedValue(null);
const result = await service.findOne("uuid1"); const result = await service.findOne("uuid1");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -106,7 +91,7 @@ describe("UsersService", () => {
describe("update", () => { describe("update", () => {
it("should update a user", async () => { it("should update a user", async () => {
mockDb.returning.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]); mockUsersRepository.update.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]);
const result = await service.update("uuid1", { displayName: "New" }); const result = await service.update("uuid1", { displayName: "New" });
expect(result[0].displayName).toBe("New"); expect(result[0].displayName).toBe("New");
}); });

View File

@@ -1,14 +1,7 @@
import { Injectable, Logger, Inject } from "@nestjs/common"; import { Injectable, Logger, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager"; import { Cache } from "cache-manager";
import { eq, sql } from "drizzle-orm"; import { UsersRepository } from "./repositories/users.repository";
import { CryptoService } from "../crypto/crypto.service";
import { DatabaseService } from "../database/database.service";
import {
contents,
favorites,
users,
} from "../database/schemas";
import { UpdateUserDto } from "./dto/update-user.dto"; import { UpdateUserDto } from "./dto/update-user.dto";
@Injectable() @Injectable()
@@ -16,8 +9,7 @@ export class UsersService {
private readonly logger = new Logger(UsersService.name); private readonly logger = new Logger(UsersService.name);
constructor( constructor(
private readonly databaseService: DatabaseService, private readonly usersRepository: UsersRepository,
private readonly cryptoService: CryptoService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, @Inject(CACHE_MANAGER) private cacheManager: Cache,
) {} ) {}
@@ -33,109 +25,37 @@ export class UsersService {
passwordHash: string; passwordHash: string;
emailHash: string; emailHash: string;
}) { }) {
const [newUser] = await this.databaseService.db return await this.usersRepository.create(data);
.insert(users)
.values({
username: data.username,
email: data.email,
emailHash: data.emailHash,
passwordHash: data.passwordHash,
})
.returning();
return newUser;
} }
async findByEmailHash(emailHash: string) { async findByEmailHash(emailHash: string) {
const result = await this.databaseService.db return await this.usersRepository.findByEmailHash(emailHash);
.select({
uuid: users.uuid,
username: users.username,
email: users.email,
passwordHash: users.passwordHash,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
})
.from(users)
.where(eq(users.emailHash, emailHash))
.limit(1);
return result[0] || null;
} }
async findOneWithPrivateData(uuid: string) { async findOneWithPrivateData(uuid: string) {
const result = await this.databaseService.db return await this.usersRepository.findOneWithPrivateData(uuid);
.select({
uuid: users.uuid,
username: users.username,
email: users.email,
displayName: users.displayName,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0] || null;
} }
async findAll(limit: number, offset: number) { async findAll(limit: number, offset: number) {
const totalCountResult = await this.databaseService.db const [data, totalCount] = await Promise.all([
.select({ count: sql<number>`count(*)` }) this.usersRepository.findAll(limit, offset),
.from(users); this.usersRepository.countAll(),
]);
const totalCount = Number(totalCountResult[0].count);
const data = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
status: users.status,
createdAt: users.createdAt,
})
.from(users)
.limit(limit)
.offset(offset);
return { data, totalCount }; return { data, totalCount };
} }
async findPublicProfile(username: string) { async findPublicProfile(username: string) {
const result = await this.databaseService.db return await this.usersRepository.findByUsername(username);
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.username, username))
.limit(1);
return result[0] || null;
} }
async findOne(uuid: string) { async findOne(uuid: string) {
const result = await this.databaseService.db return await this.usersRepository.findOne(uuid);
.select()
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0] || null;
} }
async update(uuid: string, data: UpdateUserDto) { async update(uuid: string, data: UpdateUserDto) {
this.logger.log(`Updating user profile for ${uuid}`); this.logger.log(`Updating user profile for ${uuid}`);
const result = await this.databaseService.db const result = await this.usersRepository.update(uuid, data);
.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.uuid, uuid))
.returning();
if (result[0]) { if (result[0]) {
await this.clearUserCache(result[0].username); await this.clearUserCache(result[0].username);
@@ -148,65 +68,37 @@ export class UsersService {
termsVersion: string, termsVersion: string,
privacyVersion: string, privacyVersion: string,
) { ) {
return await this.databaseService.db return await this.usersRepository.update(uuid, {
.update(users) termsVersion,
.set({ privacyVersion,
termsVersion, gdprAcceptedAt: new Date(),
privacyVersion, });
gdprAcceptedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(users.uuid, uuid))
.returning();
} }
async setTwoFactorSecret(uuid: string, secret: string) { async setTwoFactorSecret(uuid: string, secret: string) {
return await this.databaseService.db return await this.usersRepository.update(uuid, {
.update(users) twoFactorSecret: secret,
.set({ });
twoFactorSecret: secret,
updatedAt: new Date(),
})
.where(eq(users.uuid, uuid))
.returning();
} }
async toggleTwoFactor(uuid: string, enabled: boolean) { async toggleTwoFactor(uuid: string, enabled: boolean) {
return await this.databaseService.db return await this.usersRepository.update(uuid, {
.update(users) isTwoFactorEnabled: enabled,
.set({ });
isTwoFactorEnabled: enabled,
updatedAt: new Date(),
})
.where(eq(users.uuid, uuid))
.returning();
} }
async getTwoFactorSecret(uuid: string): Promise<string | null> { async getTwoFactorSecret(uuid: string): Promise<string | null> {
const result = await this.databaseService.db return await this.usersRepository.getTwoFactorSecret(uuid);
.select({
secret: users.twoFactorSecret,
})
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0]?.secret || null;
} }
async exportUserData(uuid: string) { async exportUserData(uuid: string) {
const user = await this.findOneWithPrivateData(uuid); const user = await this.findOneWithPrivateData(uuid);
if (!user) return null; if (!user) return null;
const userContents = await this.databaseService.db const [userContents, userFavorites] = await Promise.all([
.select() this.usersRepository.getUserContents(uuid),
.from(contents) this.usersRepository.getUserFavorites(uuid),
.where(eq(contents.userId, uuid)); ]);
const userFavorites = await this.databaseService.db
.select()
.from(favorites)
.where(eq(favorites.userId, uuid));
return { return {
profile: user, profile: user,
@@ -217,21 +109,6 @@ export class UsersService {
} }
async remove(uuid: string) { async remove(uuid: string) {
return await this.databaseService.db.transaction(async (tx) => { return await this.usersRepository.softDeleteUserAndContents(uuid);
// Soft delete de l'utilisateur
const userResult = await tx
.update(users)
.set({ status: "deleted", deletedAt: new Date() })
.where(eq(users.uuid, uuid))
.returning();
// Soft delete de tous ses contenus
await tx
.update(contents)
.set({ deletedAt: new Date() })
.where(eq(contents.userId, uuid));
return userResult;
});
} }
} }

View File

@@ -0,0 +1,3 @@
module.exports = {
createCuid: () => () => 'mocked-cuid',
};

View File

@@ -0,0 +1,13 @@
module.exports = {
SignJWT: class {
constructor() { return this; }
setProtectedHeader() { return this; }
setIssuedAt() { return this; }
setExpirationTime() { return this; }
sign() { return Promise.resolve('mocked-token'); }
},
jwtVerify: () => Promise.resolve({ payload: { sub: 'mocked-user' } }),
importJWK: () => Promise.resolve({}),
exportJWK: () => Promise.resolve({}),
generateKeyPair: () => Promise.resolve({ publicKey: {}, privateKey: {} }),
};

View File

@@ -0,0 +1,7 @@
module.exports = {
ml_kem768: {
keygen: () => ({ publicKey: Buffer.alloc(1184), secretKey: Buffer.alloc(2400) }),
encapsulate: () => ({ cipherText: Buffer.alloc(1088), sharedSecret: Buffer.alloc(32) }),
decapsulate: () => Buffer.alloc(32),
}
};

View File

@@ -0,0 +1,5 @@
module.exports = {
sha3_256: () => ({ update: () => ({ digest: () => Buffer.alloc(32) }) }),
sha3_512: () => ({ update: () => ({ digest: () => Buffer.alloc(64) }) }),
shake256: () => ({ update: () => ({ digest: () => Buffer.alloc(32) }) }),
};