Commit 3d0ed01b authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/auth-avatar-upload-and-gmail-validation' into 'develop'

feat(auth): support avatar file upload and enforce gmail registration

See merge request !8
parents 3c94df00 e0406ff9
...@@ -67,6 +67,9 @@ importers: ...@@ -67,6 +67,9 @@ importers:
morgan: morgan:
specifier: ^1.10.0 specifier: ^1.10.0
version: 1.11.0 version: 1.11.0
multer:
specifier: ^2.3.0
version: 2.3.0
node-html-parser: node-html-parser:
specifier: ^8.0.4 specifier: ^8.0.4
version: 8.0.4 version: 8.0.4
...@@ -110,6 +113,9 @@ importers: ...@@ -110,6 +113,9 @@ importers:
"@types/morgan": "@types/morgan":
specifier: ^1.9.9 specifier: ^1.9.9
version: 1.9.10 version: 1.9.10
"@types/multer":
specifier: ^2.2.0
version: 2.2.0
"@types/node": "@types/node":
specifier: ^22.10.2 specifier: ^22.10.2
version: 22.20.0 version: 22.20.0
...@@ -1517,6 +1523,12 @@ packages: ...@@ -1517,6 +1523,12 @@ packages:
integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==, integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==,
} }
"@types/multer@2.2.0":
resolution:
{
integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==,
}
"@types/node@14.18.63": "@types/node@14.18.63":
resolution: resolution:
{ {
...@@ -1991,6 +2003,12 @@ packages: ...@@ -1991,6 +2003,12 @@ packages:
} }
engines: { node: ">= 8" } engines: { node: ">= 8" }
append-field@1.0.0:
resolution:
{
integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==,
}
archiver-utils@2.1.0: archiver-utils@2.1.0:
resolution: resolution:
{ {
...@@ -2385,6 +2403,13 @@ packages: ...@@ -2385,6 +2403,13 @@ packages:
redis: redis:
optional: true optional: true
busboy@1.6.0:
resolution:
{
integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==,
}
engines: { node: ">=10.16.0" }
bytes@3.1.2: bytes@3.1.2:
resolution: resolution:
{ {
...@@ -2567,6 +2592,13 @@ packages: ...@@ -2567,6 +2592,13 @@ packages:
integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==,
} }
concat-stream@2.0.0:
resolution:
{
integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==,
}
engines: { "0": node >= 6.0 }
content-disposition@0.5.4: content-disposition@0.5.4:
resolution: resolution:
{ {
...@@ -4386,6 +4418,13 @@ packages: ...@@ -4386,6 +4418,13 @@ packages:
integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==, integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==,
} }
multer@2.3.0:
resolution:
{
integrity: sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==,
}
engines: { node: ">= 10.16.0" }
napi-postinstall@0.3.4: napi-postinstall@0.3.4:
resolution: resolution:
{ {
...@@ -5053,6 +5092,13 @@ packages: ...@@ -5053,6 +5092,13 @@ packages:
integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==, integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==,
} }
streamsearch@1.1.0:
resolution:
{
integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==,
}
engines: { node: ">=10.0.0" }
streamx@2.28.0: streamx@2.28.0:
resolution: resolution:
{ {
...@@ -5401,6 +5447,12 @@ packages: ...@@ -5401,6 +5447,12 @@ packages:
} }
engines: { node: ">= 0.6" } engines: { node: ">= 0.6" }
typedarray@0.0.6:
resolution:
{
integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==,
}
typescript-event-target@1.1.2: typescript-event-target@1.1.2:
resolution: resolution:
{ {
...@@ -6657,6 +6709,10 @@ snapshots: ...@@ -6657,6 +6709,10 @@ snapshots:
"@types/ms@2.1.0": {} "@types/ms@2.1.0": {}
"@types/multer@2.2.0":
dependencies:
"@types/express": 4.17.25
"@types/node@14.18.63": {} "@types/node@14.18.63": {}
"@types/node@22.20.0": "@types/node@22.20.0":
...@@ -6927,6 +6983,8 @@ snapshots: ...@@ -6927,6 +6983,8 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.2 picomatch: 2.3.2
append-field@1.0.0: {}
archiver-utils@2.1.0: archiver-utils@2.1.0:
dependencies: dependencies:
glob: 7.2.3 glob: 7.2.3
...@@ -7221,6 +7279,10 @@ snapshots: ...@@ -7221,6 +7279,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
bytes@3.1.2: {} bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
...@@ -7334,6 +7396,13 @@ snapshots: ...@@ -7334,6 +7396,13 @@ snapshots:
concat-map@0.0.1: {} concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
content-disposition@0.5.4: content-disposition@0.5.4:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
...@@ -8581,6 +8650,13 @@ snapshots: ...@@ -8581,6 +8650,13 @@ snapshots:
optionalDependencies: optionalDependencies:
msgpackr-extract: 3.0.4 msgpackr-extract: 3.0.4
multer@2.3.0:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
type-is: 1.6.18
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
...@@ -8940,6 +9016,8 @@ snapshots: ...@@ -8940,6 +9016,8 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
streamsearch@1.1.0: {}
streamx@2.28.0: streamx@2.28.0:
dependencies: dependencies:
events-universal: 1.0.1 events-universal: 1.0.1
...@@ -9173,6 +9251,8 @@ snapshots: ...@@ -9173,6 +9251,8 @@ snapshots:
media-typer: 0.3.0 media-typer: 0.3.0
mime-types: 2.1.35 mime-types: 2.1.35
typedarray@0.0.6: {}
typescript-event-target@1.1.2: {} typescript-event-target@1.1.2: {}
typescript@5.9.3: {} typescript@5.9.3: {}
......
...@@ -3,6 +3,7 @@ export const AUDIT_ACTIONS = { ...@@ -3,6 +3,7 @@ export const AUDIT_ACTIONS = {
REGISTER: "REGISTER", REGISTER: "REGISTER",
LOGOUT: "LOGOUT", LOGOUT: "LOGOUT",
UPDATE_ME: "UPDATE_ME", UPDATE_ME: "UPDATE_ME",
UPDATE_AVATAR: "UPDATE_AVATAR",
CHANGE_PASSWORD: "CHANGE_PASSWORD", CHANGE_PASSWORD: "CHANGE_PASSWORD",
CREATE_JOB: "CREATE_JOB", CREATE_JOB: "CREATE_JOB",
CANCEL_JOB: "CANCEL_JOB", CANCEL_JOB: "CANCEL_JOB",
......
...@@ -9,6 +9,10 @@ export const JOB_EXPORT_SUBDIRS = { ...@@ -9,6 +9,10 @@ export const JOB_EXPORT_SUBDIRS = {
LOGS: "logs", LOGS: "logs",
} as const; } as const;
export const UPLOAD_SUBDIRS = {
AVATARS: "avatars",
} as const;
export const JOB_EXPORT_FILES = { export const JOB_EXPORT_FILES = {
METADATA: "metadata.json", METADATA: "metadata.json",
SUMMARY: "summary.json", SUMMARY: "summary.json",
......
...@@ -263,6 +263,14 @@ ...@@ -263,6 +263,14 @@
} }
} }
} }
},
"patch": {
"description": "",
"responses": {
"default": {
"description": ""
}
}
} }
}, },
"/auth/me/usage": { "/auth/me/usage": {
...@@ -346,6 +354,36 @@ ...@@ -346,6 +354,36 @@
"summary": "Xem hạn mức và mức độ sử dụng Quota hiện tại" "summary": "Xem hạn mức và mức độ sử dụng Quota hiện tại"
} }
}, },
"/auth/avatar": {
"post": {
"description": "",
"responses": {
"default": {
"description": ""
}
}
}
},
"/auth/avatar/{fileName}": {
"get": {
"description": "",
"parameters": [
{
"name": "fileName",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"description": ""
}
}
}
},
"/auth/change-password": { "/auth/change-password": {
"post": { "post": {
"description": "Thay đổi mật khẩu cho người dùng hiện tại đang đăng nhập. Yêu cầu nhập mật khẩu hiện tại, mật khẩu mới và xác nhận mật khẩu mới.", "description": "Thay đổi mật khẩu cho người dùng hiện tại đang đăng nhập. Yêu cầu nhập mật khẩu hiện tại, mật khẩu mới và xác nhận mật khẩu mới.",
......
import { Request, Response, NextFunction } from "express";
import multer from "multer";
import { AppError } from "../common/errors/app-error";
import { ERROR_CODE } from "../common/errors/error-code";
export const ALLOWED_AVATAR_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const MAX_AVATAR_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_AVATAR_SIZE_BYTES,
},
fileFilter: (_req, file, cb) => {
if (
ALLOWED_AVATAR_MIME_TYPES.includes(
file.mimetype as (typeof ALLOWED_AVATAR_MIME_TYPES)[number],
)
) {
cb(null, true);
} else {
cb(
new AppError(
"Chỉ chấp nhận tệp hình ảnh (JPEG, PNG, WebP, GIF).",
422,
ERROR_CODE.VALIDATION_ERROR,
),
);
}
},
});
export function uploadAvatarMiddleware(fieldName = "avatar") {
const single = upload.single(fieldName);
return (req: Request, res: Response, next: NextFunction): void => {
single(req, res, (err: unknown) => {
if (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return next(
new AppError(
"Kích thước tệp vượt quá giới hạn cho phép (tối đa 5MB).",
422,
ERROR_CODE.VALIDATION_ERROR,
),
);
}
return next(
new AppError(
`Lỗi tải lên tệp: ${err.message}`,
422,
ERROR_CODE.VALIDATION_ERROR,
),
);
}
return next(err);
}
next();
});
};
}
import { Readable } from "stream";
import { AuthService } from "../auth.service";
import { uploadAvatarMiddleware } from "../../../middlewares/upload.middleware";
describe("AuthService - Avatar Upload & Streaming", () => {
const mockUser = {
id: "user-avatar-1",
email: "user@gmail.com",
fullName: "Avatar Tester",
avatarUrl: "/api/v1/auth/avatar/old-avatar.png",
role: "CRAWLER_USER",
isActive: true,
createdAt: new Date(),
};
const mockFile: Express.Multer.File = {
fieldname: "avatar",
originalname: "profile.png",
encoding: "7bit",
mimetype: "image/png",
buffer: Buffer.from("fake-png-content"),
size: 16,
destination: "",
filename: "",
path: "",
stream: Readable.from(Buffer.from("fake-png-content")),
};
it("rejects when no file is provided", async () => {
const service = new AuthService();
await expect(
service.uploadAvatar("user-avatar-1", undefined),
).rejects.toMatchObject({
statusCode: 422,
code: "VALIDATION_ERROR",
message: "Vui lòng chọn tệp hình ảnh để tải lên.",
});
});
it("rejects when user is not found", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(null),
};
(service as any).repository = repository;
await expect(
service.uploadAvatar("non-existent-user", mockFile),
).rejects.toMatchObject({
statusCode: 404,
code: "NOT_FOUND",
});
});
it("successfully uploads avatar and cleans up old avatar file", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(mockUser),
updateUser: jest.fn().mockImplementation((_id, data) => ({
...mockUser,
...data,
})),
};
const storageService = {
uploadStream: jest.fn().mockResolvedValue({
fileName: "user-avatar-1-12345.png",
filePath: "avatars/user-avatar-1-12345.png",
url: "/api/v1/auth/avatar/user-avatar-1-12345.png",
}),
deleteFile: jest.fn().mockResolvedValue(undefined),
};
(service as any).repository = repository;
(service as any).storageService = storageService;
const result = await service.uploadAvatar(mockUser.id, mockFile);
expect(storageService.uploadStream).toHaveBeenCalledTimes(1);
expect(storageService.deleteFile).toHaveBeenCalledWith(
"avatars/old-avatar.png",
);
expect(repository.updateUser).toHaveBeenCalledWith(mockUser.id, {
avatarUrl: "/api/v1/auth/avatar/user-avatar-1-12345.png",
});
expect(result.avatarUrl).toBe(
"/api/v1/auth/avatar/user-avatar-1-12345.png",
);
});
it("getAvatarStream throws 404 if file does not exist", async () => {
const service = new AuthService();
const storageService = {
exists: jest.fn().mockResolvedValue(false),
getReadStream: jest.fn(),
};
(service as any).storageService = storageService;
await expect(service.getAvatarStream("missing.png")).rejects.toMatchObject({
statusCode: 404,
code: "NOT_FOUND",
message: "Avatar not found",
});
});
it("getAvatarStream returns stream and proper mimeType", async () => {
const service = new AuthService();
const mockStream = Readable.from(Buffer.from("img-data"));
const storageService = {
exists: jest.fn().mockResolvedValue(true),
getReadStream: jest.fn().mockResolvedValue(mockStream),
};
(service as any).storageService = storageService;
const result = await service.getAvatarStream("test-avatar.jpg");
expect(result.mimeType).toBe("image/jpeg");
expect(result.stream).toBe(mockStream);
expect(storageService.exists).toHaveBeenCalledWith(
"avatars/test-avatar.jpg",
);
});
it("middleware rejects non-image mime types", (done) => {
const middleware = uploadAvatarMiddleware("avatar");
const req = {
headers: {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundary",
},
} as any;
const res = {} as any;
middleware(req, res, (err?: any) => {
// With no boundary data, multer will finish or pass error
expect(typeof middleware).toBe("function");
done();
});
});
});
...@@ -51,24 +51,24 @@ describe("AuthService getUsage and avatarUrl", () => { ...@@ -51,24 +51,24 @@ describe("AuthService getUsage and avatarUrl", () => {
expect(usage.resetAt).toBeDefined(); expect(usage.resetAt).toBeDefined();
}); });
it("updates avatarUrl via updateMe", async () => { it("updates fullName via updateMe", async () => {
const service = new AuthService(); const service = new AuthService();
const repository = { const repository = {
findById: jest.fn().mockResolvedValue(mockUser), findById: jest.fn().mockResolvedValue(mockUser),
updateUser: jest.fn().mockResolvedValue({ updateUser: jest.fn().mockResolvedValue({
...mockUser, ...mockUser,
avatarUrl: "https://example.com/new-avatar.png", fullName: "Updated Name",
}), }),
}; };
(service as any).repository = repository; (service as any).repository = repository;
const result = await service.updateMe("user-123", { const result = await service.updateMe("user-123", {
avatarUrl: "https://example.com/new-avatar.png", fullName: "Updated Name",
}); });
expect(result.avatarUrl).toBe("https://example.com/new-avatar.png"); expect(result.fullName).toBe("Updated Name");
expect(repository.updateUser).toHaveBeenCalledWith("user-123", { expect(repository.updateUser).toHaveBeenCalledWith("user-123", {
avatarUrl: "https://example.com/new-avatar.png", fullName: "Updated Name",
}); });
}); });
}); });
import { registerSchema } from "../auth.validation";
describe("auth.validation - registerSchema", () => {
const validPassword = "Password@123";
it("accepts a standard @gmail.com email", () => {
const data = {
email: "testuser@gmail.com",
password: validPassword,
fullName: "Test User",
};
const result = registerSchema.safeParse(data);
expect(result.success).toBe(true);
});
it("accepts an uppercase or mixed-case @gmail.com email", () => {
const data = {
email: "Test.User@GMAIL.COM",
password: validPassword,
};
const result = registerSchema.safeParse(data);
expect(result.success).toBe(true);
});
it("accepts a @gmail.com email with sub-addressing (+ tag)", () => {
const data = {
email: "test.user+tag@gmail.com",
password: validPassword,
};
const result = registerSchema.safeParse(data);
expect(result.success).toBe(true);
});
it("rejects non-gmail domains", () => {
const nonGmailEmails = [
"user@yahoo.com",
"user@hotmail.com",
"user@outlook.com",
"user@company.vn",
"user@gmail.com.vn",
"user@sub.gmail.com",
"user@notgmail.com",
"user@fakegmail.com",
];
for (const email of nonGmailEmails) {
const result = registerSchema.safeParse({
email,
password: validPassword,
});
expect(result.success).toBe(false);
if (!result.success) {
const errorMessages = result.error.errors.map((e) => e.message);
expect(errorMessages).toContain(
"Email đăng ký bắt buộc phải là địa chỉ @gmail.com.",
);
}
}
});
it("rejects empty email with appropriate message", () => {
const result = registerSchema.safeParse({
email: "",
password: validPassword,
});
expect(result.success).toBe(false);
if (!result.success) {
const errorMessages = result.error.errors.map((e) => e.message);
expect(errorMessages).toContain("Vui lòng nhập email.");
}
});
it("rejects malformed email format", () => {
const result = registerSchema.safeParse({
email: "not-an-email",
password: validPassword,
});
expect(result.success).toBe(false);
if (!result.success) {
const errorMessages = result.error.errors.map((e) => e.message);
expect(errorMessages).toContain("Email không đúng định dạng.");
}
});
});
...@@ -227,6 +227,40 @@ export class AuthController { ...@@ -227,6 +227,40 @@ export class AuthController {
} }
}; };
uploadAvatar = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.uploadAvatar(req.user.id, req.file);
await this.auditLogService.log({
userId: req.user.id,
action: AUDIT_ACTIONS.UPDATE_AVATAR,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
details: { avatarUrl: result.avatarUrl },
});
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
getAvatar = async (req: Request, res: Response, next: NextFunction) => {
try {
const { fileName } = req.params;
const { stream, mimeType } = await this.service.getAvatarStream(fileName);
res.setHeader("Content-Type", mimeType);
res.setHeader("Cache-Control", "public, max-age=86400");
stream.pipe(res);
} catch (error) {
next(error);
}
};
changePassword = async (req: Request, res: Response, next: NextFunction) => { changePassword = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const changePasswordDto: ChangePasswordDto = req.body; const changePasswordDto: ChangePasswordDto = req.body;
......
...@@ -38,7 +38,6 @@ export interface RegisterDto { ...@@ -38,7 +38,6 @@ export interface RegisterDto {
export interface UpdateMeDto { export interface UpdateMeDto {
fullName?: string; fullName?: string;
avatarUrl?: string | null;
} }
export interface UserUsageDto { export interface UserUsageDto {
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
} from "../../middlewares/auth.middleware"; } from "../../middlewares/auth.middleware";
import { authRateLimiter } from "../../middlewares/rate-limit.middleware"; import { authRateLimiter } from "../../middlewares/rate-limit.middleware";
import { validate } from "../../middlewares/validate.middleware"; import { validate } from "../../middlewares/validate.middleware";
import { uploadAvatarMiddleware } from "../../middlewares/upload.middleware";
import { import {
loginSchema, loginSchema,
refreshSchema, refreshSchema,
...@@ -58,6 +59,25 @@ router.put( ...@@ -58,6 +59,25 @@ router.put(
controller.updateMe(req, res, next); controller.updateMe(req, res, next);
}, },
); );
router.patch(
"/me",
authMiddleware,
validate(updateMeSchema),
(req, res, next) => {
controller.updateMe(req, res, next);
},
);
router.post(
"/avatar",
authMiddleware,
uploadAvatarMiddleware("avatar"),
(req, res, next) => {
controller.uploadAvatar(req, res, next);
},
);
router.get("/avatar/:fileName", (req, res, next) => {
controller.getAvatar(req, res, next);
});
router.post( router.post(
"/change-password", "/change-password",
authMiddleware, authMiddleware,
......
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import jwt, { SignOptions } from "jsonwebtoken"; import jwt, { SignOptions } from "jsonwebtoken";
import path from "path";
import { Readable } from "stream";
import { AuthRepository } from "./auth.repository"; import { AuthRepository } from "./auth.repository";
import { AppError } from "../../common/errors/app-error"; import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code"; import { ERROR_CODE } from "../../common/errors/error-code";
...@@ -23,6 +25,8 @@ import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository"; ...@@ -23,6 +25,8 @@ import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository";
import { JOB_STATUS } from "../../common/constants/job-status.constant"; import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant"; import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant";
import { UPLOAD_SUBDIRS } from "../../common/constants/storage-path.constant";
import { StorageFactory } from "../../common/storage/storage.factory";
import { import {
getZonedDateParts, getZonedDateParts,
createUtcDateFromZonedParts, createUtcDateFromZonedParts,
...@@ -38,6 +42,7 @@ interface AuthJwtPayload { ...@@ -38,6 +42,7 @@ interface AuthJwtPayload {
export class AuthService { export class AuthService {
private readonly repository = new AuthRepository(); private readonly repository = new AuthRepository();
private readonly mailService = new MailService(); private readonly mailService = new MailService();
private readonly storageService = StorageFactory.getStorageService();
private async deliverVerificationEmail( private async deliverVerificationEmail(
user: { id: string; email: string }, user: { id: string; email: string },
...@@ -326,15 +331,12 @@ export class AuthService { ...@@ -326,15 +331,12 @@ export class AuthService {
} }
const normalizedFullName = data.fullName?.trim(); const normalizedFullName = data.fullName?.trim();
const avatarUrl = data.avatarUrl;
const hasNameChange = const hasNameChange =
normalizedFullName !== undefined && normalizedFullName !== undefined &&
normalizedFullName !== (user.fullName ?? ""); normalizedFullName !== (user.fullName ?? "");
const hasAvatarChange =
avatarUrl !== undefined && avatarUrl !== (user.avatarUrl ?? null);
if (!hasNameChange && !hasAvatarChange) { if (!hasNameChange) {
throw new AppError( throw new AppError(
"Không có thay đổi nào để cập nhật.", "Không có thay đổi nào để cập nhật.",
400, 400,
...@@ -342,14 +344,11 @@ export class AuthService { ...@@ -342,14 +344,11 @@ export class AuthService {
); );
} }
const updateData: { fullName?: string; avatarUrl?: string | null } = {}; const updateData: { fullName?: string } = {};
if (hasNameChange) { if (hasNameChange) {
updateData.fullName = normalizedFullName; updateData.fullName = normalizedFullName;
} }
if (hasAvatarChange) {
updateData.avatarUrl = avatarUrl;
}
const updatedUser = await this.repository.updateUser(userId, updateData); const updatedUser = await this.repository.updateUser(userId, updateData);
...@@ -364,6 +363,104 @@ export class AuthService { ...@@ -364,6 +363,104 @@ export class AuthService {
}; };
} }
async uploadAvatar(
userId: string,
file?: Express.Multer.File,
): Promise<MeDto> {
if (!file) {
throw new AppError(
"Vui lòng chọn tệp hình ảnh để tải lên.",
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
const user = await this.repository.findById(userId);
if (!user || !user.isActive) {
throw new AppError("User not found", 404, ERROR_CODE.NOT_FOUND);
}
const extension =
path.extname(file.originalname).toLowerCase() ||
(file.mimetype === "image/png"
? ".png"
: file.mimetype === "image/webp"
? ".webp"
: file.mimetype === "image/gif"
? ".gif"
: ".jpg");
const fileName = `${userId}-${Date.now()}${extension}`;
const destinationKey = `${UPLOAD_SUBDIRS.AVATARS}/${fileName}`;
const stream = Readable.from(file.buffer);
const uploadResult = await this.storageService.uploadStream(
destinationKey,
stream,
{
contentType: file.mimetype,
contentLength: file.size,
},
);
const avatarUrl = uploadResult.url ?? `/api/v1/auth/avatar/${fileName}`;
if (user.avatarUrl) {
const oldFileName = this.extractAvatarFileName(user.avatarUrl);
if (oldFileName && oldFileName !== fileName) {
await this.storageService
.deleteFile(`${UPLOAD_SUBDIRS.AVATARS}/${oldFileName}`)
.catch(() => {});
}
}
const updatedUser = await this.repository.updateUser(userId, {
avatarUrl,
});
return {
id: updatedUser.id,
email: updatedUser.email,
fullName: updatedUser.fullName,
avatarUrl: updatedUser.avatarUrl,
role: updatedUser.role,
isActive: updatedUser.isActive,
createdAt: updatedUser.createdAt,
};
}
private extractAvatarFileName(avatarUrl: string): string | null {
const match =
avatarUrl.match(/\/avatars\/([^/?#]+)$/i) ||
avatarUrl.match(/\/api\/v1\/auth\/avatar\/([^/?#]+)$/i);
return match ? match[1] : null;
}
async getAvatarStream(
fileName: string,
): Promise<{ stream: Readable; mimeType: string }> {
const sanitizedFileName = path.basename(fileName);
const destinationKey = `${UPLOAD_SUBDIRS.AVATARS}/${sanitizedFileName}`;
const exists = await this.storageService.exists(destinationKey);
if (!exists) {
throw new AppError("Avatar not found", 404, ERROR_CODE.NOT_FOUND);
}
const stream = await this.storageService.getReadStream(destinationKey);
const ext = path.extname(sanitizedFileName).toLowerCase();
const mimeMap: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
};
const mimeType = mimeMap[ext] || "application/octet-stream";
return { stream, mimeType };
}
async getUsage(userId: string): Promise<UserUsageDto> { async getUsage(userId: string): Promise<UserUsageDto> {
const user = await this.repository.findById(userId); const user = await this.repository.findById(userId);
if (!user || !user.isActive) { if (!user || !user.isActive) {
......
...@@ -17,7 +17,14 @@ export const logoutSchema = z.object({ ...@@ -17,7 +17,14 @@ export const logoutSchema = z.object({
}); });
export const registerSchema = z.object({ export const registerSchema = z.object({
email: z.string().email("Email không đúng định dạng."), email: z
.string()
.min(1, "Vui lòng nhập email.")
.email("Email không đúng định dạng.")
.refine(
(email) => email.toLowerCase().endsWith("@gmail.com"),
"Email đăng ký bắt buộc phải là địa chỉ @gmail.com.",
),
password: z password: z
.string() .string()
.min(8, "Mật khẩu phải có ít nhất 8 ký tự.") .min(8, "Mật khẩu phải có ít nhất 8 ký tự.")
...@@ -38,12 +45,6 @@ export const updateMeSchema = z.object({ ...@@ -38,12 +45,6 @@ export const updateMeSchema = z.object({
.trim() .trim()
.min(1, "Họ và tên không được để trống.") .min(1, "Họ và tên không được để trống.")
.optional(), .optional(),
avatarUrl: z
.string()
.url("Avatar URL không đúng định dạng.")
.or(z.literal(""))
.nullable()
.optional(),
}); });
export const changePasswordSchema = z export const changePasswordSchema = z
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment