Commit 1bf2ac6c authored by ThinhNC's avatar ThinhNC

fix: resolve P0/P1 audit findings for security, architecture, and race conditions

parent c168a47c
This diff is collapsed.
...@@ -25,6 +25,29 @@ export default [ ...@@ -25,6 +25,29 @@ export default [
], ],
"no-console": "off", "no-console": "off",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@prisma/client",
message:
"Importing directly from @prisma/client is forbidden outside repositories and database.types.ts (Rule: AGENTS.md). Use src/common/constants or src/common/types/database.types.",
},
],
},
],
},
},
{
files: [
"src/**/*.repository.ts",
"src/database/prisma.client.ts",
"src/common/types/database.types.ts",
"src/**/__tests__/**/*.ts",
],
rules: {
"no-restricted-imports": "off",
}, },
}, },
]; ];
...@@ -7,3 +7,4 @@ process.env.JWT_REFRESH_SECRET = ...@@ -7,3 +7,4 @@ process.env.JWT_REFRESH_SECRET =
process.env.WEBHOOK_ENCRYPTION_KEY = process.env.WEBHOOK_ENCRYPTION_KEY =
process.env.WEBHOOK_ENCRYPTION_KEY || process.env.WEBHOOK_ENCRYPTION_KEY ||
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
process.env.REDIS_ENABLED = "false";
...@@ -40,7 +40,7 @@ app.use( ...@@ -40,7 +40,7 @@ app.use(
maxAge: 86400, maxAge: 86400,
}), }),
); );
app.use(morgan("dev")); app.use(morgan(envConfig.nodeEnv === "production" ? "combined" : "dev"));
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
......
export const CRAWL_PAGE_STATUS = {
PENDING: "PENDING",
SUCCESS: "SUCCESS",
FAILED: "FAILED",
BLOCKED: "BLOCKED",
REQUIRES_LOGIN: "REQUIRES_LOGIN",
CAPTCHA_DETECTED: "CAPTCHA_DETECTED",
PAYWALL_DETECTED: "PAYWALL_DETECTED",
TIMEOUT: "TIMEOUT",
SKIPPED: "SKIPPED",
} as const;
export type CrawlPageStatus = keyof typeof CRAWL_PAGE_STATUS;
...@@ -16,3 +16,11 @@ export const EXPORT_MIME_TYPES: Record<ExportType, string> = { ...@@ -16,3 +16,11 @@ export const EXPORT_MIME_TYPES: Record<ExportType, string> = {
MARKDOWN: "application/zip", MARKDOWN: "application/zip",
ZIP: "application/zip", ZIP: "application/zip",
}; };
export const EXPORT_STATUS = {
PENDING: "PENDING",
PROCESSING: "PROCESSING",
COMPLETED: "COMPLETED",
FAILED: "FAILED",
} as const;
export type ExportStatus = keyof typeof EXPORT_STATUS;
...@@ -8,3 +8,5 @@ export * from "./timezone.constant"; ...@@ -8,3 +8,5 @@ export * from "./timezone.constant";
export * from "./crawl-mode.constant"; export * from "./crawl-mode.constant";
export * from "./schedule-frequency.constant"; export * from "./schedule-frequency.constant";
export * from "./asset-type.constant"; export * from "./asset-type.constant";
export * from "./crawl-page-status.constant";
export * from "./webhook.constant";
...@@ -5,3 +5,4 @@ export const ROLES = { ...@@ -5,3 +5,4 @@ export const ROLES = {
} as const; } as const;
export type Role = keyof typeof ROLES; export type Role = keyof typeof ROLES;
export type UserRole = Role;
export const WEBHOOK_DELIVERY_STATUS = {
PENDING: "PENDING",
SUCCESS: "SUCCESS",
FAILED: "FAILED",
} as const;
export type WebhookDeliveryStatus = keyof typeof WEBHOOK_DELIVERY_STATUS;
export const WEBHOOK_EVENT = {
JOB_COMPLETED: "job.completed",
JOB_FAILED: "job.failed",
} as const;
export type WebhookEvent = (typeof WEBHOOK_EVENT)[keyof typeof WEBHOOK_EVENT];
import crypto from "crypto"; import crypto from "crypto";
import { CrawlPage, CrawlAsset } from "@prisma/client"; import { CrawlPage, CrawlAsset } from "../types/database.types";
import { import {
CrawlPageRecord, CrawlPageRecord,
DataQualityWarning, DataQualityWarning,
...@@ -15,10 +15,6 @@ import { ...@@ -15,10 +15,6 @@ import {
DATA_CONTRACT_HASH_ALGORITHM, DATA_CONTRACT_HASH_ALGORITHM,
} from "../constants/data-contract.constant"; } from "../constants/data-contract.constant";
// ─────────────────────────────────────────────
// URL Normalization
// ─────────────────────────────────────────────
/** /**
* Normalize một URL để phục vụ deduplicate và so sánh. * Normalize một URL để phục vụ deduplicate và so sánh.
* Các bước: * Các bước:
...@@ -66,10 +62,6 @@ export function normalizeUrl(rawUrl: string): string { ...@@ -66,10 +62,6 @@ export function normalizeUrl(rawUrl: string): string {
} }
} }
// ─────────────────────────────────────────────
// Text Utilities
// ─────────────────────────────────────────────
/** /**
* Strip toàn bộ Markdown syntax, trả về plain text thuần. * Strip toàn bộ Markdown syntax, trả về plain text thuần.
* Phạm vi: heading, bold/italic, inline code, code block, link, image, blockquote, HR. * Phạm vi: heading, bold/italic, inline code, code block, link, image, blockquote, HR.
...@@ -234,10 +226,6 @@ export function hashContent(text: string): string | null { ...@@ -234,10 +226,6 @@ export function hashContent(text: string): string | null {
.digest("hex"); .digest("hex");
} }
// ─────────────────────────────────────────────
// Data Quality
// ─────────────────────────────────────────────
/** /**
* Tính điểm chất lượng dữ liệu (0–100) của một page dựa trên các tiêu chí: * Tính điểm chất lượng dữ liệu (0–100) của một page dựa trên các tiêu chí:
* - Có mainContent : +40 điểm * - Có mainContent : +40 điểm
...@@ -295,10 +283,6 @@ export function detectWarnings(params: { ...@@ -295,10 +283,6 @@ export function detectWarnings(params: {
return warnings; return warnings;
} }
// ─────────────────────────────────────────────
// Asset Transformation
// ─────────────────────────────────────────────
/** /**
* Chuyển đổi danh sách CrawlAsset sang LinkRecord[]. * Chuyển đổi danh sách CrawlAsset sang LinkRecord[].
* Phân loại internal/external dựa vào domain của startUrl (job's domain). * Phân loại internal/external dựa vào domain của startUrl (job's domain).
...@@ -361,10 +345,6 @@ function inferImageType(imageUrl: string): string { ...@@ -361,10 +345,6 @@ function inferImageType(imageUrl: string): string {
} }
} }
// ─────────────────────────────────────────────
// Main Transformer
// ─────────────────────────────────────────────
export interface TransformPageOptions { export interface TransformPageOptions {
page: CrawlPage & { normalizedUrl?: string | null }; page: CrawlPage & { normalizedUrl?: string | null };
assets: CrawlAsset[]; assets: CrawlAsset[];
...@@ -452,10 +432,6 @@ export function transformPageToRecord( ...@@ -452,10 +432,6 @@ export function transformPageToRecord(
}; };
} }
// ─────────────────────────────────────────────
// Envelope Builder
// ─────────────────────────────────────────────
/** /**
* Đóng gói danh sách CrawlPageRecord vào PagesJsonEnvelope để ghi ra pages.json. * Đóng gói danh sách CrawlPageRecord vào PagesJsonEnvelope để ghi ra pages.json.
*/ */
......
export type {
User,
CrawlJob,
CrawlPage,
CrawlAsset,
CrawlExport,
CrawlJobLog,
ApiKey,
WebhookConfig,
WebhookDelivery,
AuditLog,
RefreshToken,
Prisma,
} from "@prisma/client";
import { UserRole } from "@prisma/client"; import { UserRole } from "../constants/role.constant";
declare global { declare global {
namespace Express { namespace Express {
......
This diff is collapsed.
import type { ApiKey } from "@prisma/client"; import type { ApiKey } from "../../common/types/database.types";
export type PublicApiKey = Omit<ApiKey, "keyHash">; export type PublicApiKey = Omit<ApiKey, "keyHash">;
......
...@@ -2,7 +2,7 @@ import crypto from "crypto"; ...@@ -2,7 +2,7 @@ import crypto from "crypto";
import { ApiKeyRepository } from "./api-key.repository"; import { ApiKeyRepository } from "./api-key.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";
import type { ApiKey } from "@prisma/client"; import type { ApiKey } from "../../common/types/database.types";
import { import {
toPublicApiKey, toPublicApiKey,
type CreatedApiKey, type CreatedApiKey,
......
...@@ -170,4 +170,89 @@ describe("AuthService registration mail failures", () => { ...@@ -170,4 +170,89 @@ describe("AuthService registration mail failures", () => {
expect(mailService.sendVerificationEmail).toHaveBeenCalledTimes(1); expect(mailService.sendVerificationEmail).toHaveBeenCalledTimes(1);
expect(repository.createUser).not.toHaveBeenCalled(); expect(repository.createUser).not.toHaveBeenCalled();
}); });
describe("AuthService forgotPassword security", () => {
const activeUser = {
id: "user-active",
email: "active@example.com",
fullName: "Active User",
role: "CRAWLER_USER",
isActive: true,
passwordHash: "hash",
createdAt: new Date(),
};
it("sends password reset email internally and returns only { success: true } without leaking token or userId", async () => {
const service = new AuthService();
const repository = {
findByEmail: jest.fn().mockResolvedValue(activeUser),
};
const mailService = {
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
};
const mutableService = service as unknown as {
repository: typeof repository;
mailService: typeof mailService;
};
mutableService.repository = repository;
mutableService.mailService = mailService;
const result = await service.forgotPassword({ email: activeUser.email });
expect(result).toEqual({ success: true });
expect((result as Record<string, unknown>).resetToken).toBeUndefined();
expect((result as Record<string, unknown>).userId).toBeUndefined();
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledTimes(1);
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
activeUser.email,
expect.any(String),
);
});
it("returns { success: true } and does not call mail service if user is not found", async () => {
const service = new AuthService();
const repository = {
findByEmail: jest.fn().mockResolvedValue(null),
};
const mailService = {
sendPasswordResetEmail: jest.fn(),
};
const mutableService = service as unknown as {
repository: typeof repository;
mailService: typeof mailService;
};
mutableService.repository = repository;
mutableService.mailService = mailService;
const result = await service.forgotPassword({
email: "nonexistent@example.com",
});
expect(result).toEqual({ success: true });
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
});
it("returns { success: true } and does not call mail service if user is inactive", async () => {
const service = new AuthService();
const repository = {
findByEmail: jest
.fn()
.mockResolvedValue({ ...activeUser, isActive: false }),
};
const mailService = {
sendPasswordResetEmail: jest.fn(),
};
const mutableService = service as unknown as {
repository: typeof repository;
mailService: typeof mailService;
};
mutableService.repository = repository;
mutableService.mailService = mailService;
const result = await service.forgotPassword({ email: activeUser.email });
expect(result).toEqual({ success: true });
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
});
});
}); });
...@@ -283,23 +283,15 @@ export class AuthController { ...@@ -283,23 +283,15 @@ export class AuthController {
forgotPassword = async (req: Request, res: Response, next: NextFunction) => { forgotPassword = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const forgotPasswordDto: ForgotPasswordDto = req.body; const forgotPasswordDto: ForgotPasswordDto = req.body;
const result = await this.service.forgotPassword(forgotPasswordDto); await this.service.forgotPassword(forgotPasswordDto);
if (result.userId && result.resetToken) {
await this.auditLogService.log({ await this.auditLogService.log({
userId: result.userId,
action: AUDIT_ACTIONS.FORGOT_PASSWORD, action: AUDIT_ACTIONS.FORGOT_PASSWORD,
ipAddress: req.ip, ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string, userAgent: req.headers["user-agent"] as string,
details: { email: forgotPasswordDto.email }, details: { email: forgotPasswordDto.email },
}); });
await this.mailService.sendPasswordResetEmail(
forgotPasswordDto.email,
result.resetToken,
);
}
res.json({ res.json({
success: true, success: true,
message: message:
......
...@@ -502,9 +502,7 @@ export class AuthService { ...@@ -502,9 +502,7 @@ export class AuthService {
}; };
} }
async forgotPassword( async forgotPassword(data: ForgotPasswordDto): Promise<{ success: boolean }> {
data: ForgotPasswordDto,
): Promise<{ success: boolean; resetToken?: string; userId?: string }> {
const { email } = data; const { email } = data;
const user = await this.repository.findByEmail(email); const user = await this.repository.findByEmail(email);
...@@ -517,10 +515,14 @@ export class AuthService { ...@@ -517,10 +515,14 @@ export class AuthService {
expiresIn: "15m", expiresIn: "15m",
}); });
try {
await this.mailService.sendPasswordResetEmail(user.email, resetToken);
} catch (error: unknown) {
console.error("[Mail] Password reset delivery failed:", error);
}
return { return {
success: true, success: true,
resetToken,
userId: user.id,
}; };
} }
......
import fs from "fs"; import fs from "fs";
import { CrawlJob, Prisma } from "@prisma/client"; import { CrawlJob, Prisma } from "../../common/types/database.types";
import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository"; import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository";
import { import {
DiffReportEnvelope, DiffReportEnvelope,
......
...@@ -57,6 +57,15 @@ export class CrawlAssetRepository { ...@@ -57,6 +57,15 @@ export class CrawlAssetRepository {
}); });
} }
countByJobId(jobId: string, assetType?: AssetType) {
return prisma.crawlAsset.count({
where: {
crawlJobId: jobId,
...(assetType ? { assetType } : {}),
},
});
}
findAssetsForJsonExport(jobId: string) { findAssetsForJsonExport(jobId: string) {
return prisma.crawlAsset.findMany({ return prisma.crawlAsset.findMany({
where: { crawlJobId: jobId }, where: { crawlJobId: jobId },
......
...@@ -10,7 +10,19 @@ export class CrawlAssetService { ...@@ -10,7 +10,19 @@ export class CrawlAssetService {
page = 1, page = 1,
limit = 50, limit = 50,
) { ) {
return this.repository.findByJobId(jobId, assetType, page, limit); const safeLimit = Math.min(Math.max(1, limit), 500);
const safePage = Math.max(1, page);
const [items, total] = await Promise.all([
this.repository.findByJobId(jobId, assetType, safePage, safeLimit),
this.repository.countByJobId(jobId, assetType),
]);
return {
items,
total,
page: safePage,
limit: safeLimit,
totalPages: Math.ceil(total / safeLimit),
};
} }
async create(data: { async create(data: {
......
import { ExportType, ExportStatus } from "@prisma/client"; import {
ExportType,
ExportStatus,
} from "../../common/constants/export-type.constant";
export interface CreateCrawlExportDto { export interface CreateCrawlExportDto {
jobId: string; jobId: string;
......
...@@ -5,6 +5,7 @@ jest.mock("../../../database/prisma.client", () => ({ ...@@ -5,6 +5,7 @@ jest.mock("../../../database/prisma.client", () => ({
jest.mock("../crawl-job.repository"); jest.mock("../crawl-job.repository");
jest.mock("../../users/user.repository"); jest.mock("../../users/user.repository");
jest.mock("../../crawl-exports/crawl-export.repository"); jest.mock("../../crawl-exports/crawl-export.repository");
jest.mock("../../crawl-schedules/crawl-schedule.repository");
jest.mock("../../../common/helpers/url.helper"); jest.mock("../../../common/helpers/url.helper");
jest.mock("../../../queues/crawl.queue", () => ({ jest.mock("../../../queues/crawl.queue", () => ({
crawlQueue: { crawlQueue: {
...@@ -15,12 +16,14 @@ jest.mock("../../../queues/crawl.queue", () => ({ ...@@ -15,12 +16,14 @@ jest.mock("../../../queues/crawl.queue", () => ({
import { CrawlJobService } from "../crawl-job.service"; import { CrawlJobService } from "../crawl-job.service";
import { CrawlJobRepository } from "../crawl-job.repository"; import { CrawlJobRepository } from "../crawl-job.repository";
import { UserRepository } from "../../users/user.repository"; import { UserRepository } from "../../users/user.repository";
import { CrawlScheduleRepository } from "../../crawl-schedules/crawl-schedule.repository";
import * as urlHelper from "../../../common/helpers/url.helper"; import * as urlHelper from "../../../common/helpers/url.helper";
describe("CrawlJobService", () => { describe("CrawlJobService", () => {
let service: CrawlJobService; let service: CrawlJobService;
let mockJobRepo: jest.Mocked<CrawlJobRepository>; let mockJobRepo: jest.Mocked<CrawlJobRepository>;
let mockUserRepo: jest.Mocked<UserRepository>; let mockUserRepo: jest.Mocked<UserRepository>;
let mockScheduleRepo: jest.Mocked<CrawlScheduleRepository>;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
...@@ -32,6 +35,13 @@ describe("CrawlJobService", () => { ...@@ -32,6 +35,13 @@ describe("CrawlJobService", () => {
findById: jest.fn(), findById: jest.fn(),
} as any; } as any;
mockScheduleRepo = {
findById: jest.fn().mockResolvedValue(null),
} as any;
(CrawlJobRepository as jest.Mock).mockReturnValue(mockJobRepo);
(CrawlScheduleRepository as jest.Mock).mockReturnValue(mockScheduleRepo);
mockUserRepo = { mockUserRepo = {
findById: jest.fn().mockResolvedValue({ findById: jest.fn().mockResolvedValue({
id: "user-1", id: "user-1",
...@@ -109,24 +119,34 @@ describe("CrawlJobService", () => { ...@@ -109,24 +119,34 @@ describe("CrawlJobService", () => {
).rejects.toThrow("Concurrent jobs quota of 3 exceeded"); ).rejects.toThrow("Concurrent jobs quota of 3 exceeded");
}); });
it("deduplicates URLs in URL_LIST mode and validates them", async () => { it("throws error when scheduleId does not belong to user", async () => {
const urls = [ mockScheduleRepo.findById.mockResolvedValue({
"https://example.com/1", id: "sched-1",
"https://example.com/2", userId: "other-user",
"https://example.com/1", } as any);
];
await expect(
service.create("user-1", {
startUrl: "https://example.com",
scheduleId: "sched-1",
}),
).rejects.toThrow("Crawl schedule not found");
});
it("attaches scheduleId when schedule belongs to user", async () => {
mockScheduleRepo.findById.mockResolvedValue({
id: "sched-1",
userId: "user-1",
} as any);
await service.create("user-1", { await service.create("user-1", {
mode: "URL_LIST", startUrl: "https://example.com",
urls, scheduleId: "sched-1",
}); });
expect(urlHelper.validateUrlAsync).toHaveBeenCalledTimes(2);
expect(mockJobRepo.create).toHaveBeenCalledWith( expect(mockJobRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
mode: "URL_LIST", scheduleId: "sched-1",
startUrl: "https://example.com/1",
urls: ["https://example.com/1", "https://example.com/2"],
}), }),
); );
}); });
......
...@@ -8,10 +8,7 @@ import { AuditLogService } from "../audit-logs/audit-log.service"; ...@@ -8,10 +8,7 @@ import { AuditLogService } from "../audit-logs/audit-log.service";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant"; import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant"; import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { CrawlAssetService } from "../crawl-assets/crawl-asset.service"; import { CrawlAssetService } from "../crawl-assets/crawl-asset.service";
import { import { AssetType } from "../../common/constants/asset-type.constant";
AssetType,
ASSET_TYPES,
} from "../../common/constants/asset-type.constant";
import { streamStorageDownload } from "../../common/storage/storage-download.helper"; import { streamStorageDownload } from "../../common/storage/storage-download.helper";
export class CrawlJobController { export class CrawlJobController {
private readonly service = new CrawlJobService(); private readonly service = new CrawlJobService();
...@@ -137,22 +134,11 @@ export class CrawlJobController { ...@@ -137,22 +134,11 @@ export class CrawlJobController {
try { try {
await this.service.findById(req.user.id, req.user.role, req.params.id); await this.service.findById(req.user.id, req.user.role, req.params.id);
const VALID_ASSET_TYPES: readonly string[] = Object.values(ASSET_TYPES); const assetType = req.query.assetType as AssetType | undefined;
const rawType = req.query.assetType as string | undefined; const page = Number(req.query.page) || 1;
if (rawType && !VALID_ASSET_TYPES.includes(rawType)) { const limit = Number(req.query.limit) || 50;
res
.status(400)
.json({ success: false, message: `Invalid assetType: ${rawType}` });
return;
}
const assetType = rawType as AssetType | undefined;
const page = Math.max(1, parseInt((req.query.page as string) || "1", 10));
const limit = Math.min(
Math.max(1, parseInt((req.query.limit as string) || "50", 10)),
500,
);
const items = await this.assetService.findByJobId( const result = await this.assetService.findByJobId(
req.params.id, req.params.id,
assetType, assetType,
page, page,
...@@ -162,8 +148,13 @@ export class CrawlJobController { ...@@ -162,8 +148,13 @@ export class CrawlJobController {
res.json({ res.json({
success: true, success: true,
data: { data: {
items, items: result.items,
meta: { page, limit }, meta: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
}, },
}); });
} catch (error) { } catch (error) {
...@@ -413,11 +404,14 @@ export class CrawlJobController { ...@@ -413,11 +404,14 @@ export class CrawlJobController {
); );
res.json({ res.json({
success: true, success: true,
data: result.items, data: {
pagination: { items: result.items,
meta: {
total: result.total, total: result.total,
page: result.page, page: result.page,
limit: result.limit, limit: result.limit,
totalPages: Math.ceil(result.total / (result.limit || 1)),
},
}, },
}); });
} catch (error) { } catch (error) {
......
...@@ -166,15 +166,16 @@ export class CrawlJobRepository { ...@@ -166,15 +166,16 @@ export class CrawlJobRepository {
failedPages?: number; failedPages?: number;
}, },
) { ) {
const currentJob = await prisma.crawlJob.findUnique({ await prisma.crawlJob.updateMany({
where: { id }, where: {
select: { status: true }, id,
...(status !== JOB_STATUS.CANCELED
? { status: { not: JOB_STATUS.CANCELED } }
: {}),
},
data: { status, ...extra },
}); });
if (
currentJob?.status === JOB_STATUS.CANCELED &&
status !== JOB_STATUS.CANCELED
) {
return prisma.crawlJob.findUnique({ return prisma.crawlJob.findUnique({
where: { id }, where: { id },
include: { include: {
...@@ -183,12 +184,6 @@ export class CrawlJobRepository { ...@@ -183,12 +184,6 @@ export class CrawlJobRepository {
}); });
} }
return prisma.crawlJob.update({
where: { id },
data: { status, ...extra },
});
}
updateProgress( updateProgress(
id: string, id: string,
data: { totalPages?: number; successPages?: number; failedPages?: number }, data: { totalPages?: number; successPages?: number; failedPages?: number },
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
createCrawlJobSchema, createCrawlJobSchema,
createExportSchema, createExportSchema,
listCrawlJobsQuerySchema, listCrawlJobsQuerySchema,
getAssetsQuerySchema,
} from "./crawl-job.validation"; } from "./crawl-job.validation";
import { crawlPageQuerySchema } from "../crawl-pages/crawl-page.validation"; import { crawlPageQuerySchema } from "../crawl-pages/crawl-page.validation";
import { requireRole } from "../../middlewares/role.middleware"; import { requireRole } from "../../middlewares/role.middleware";
...@@ -105,6 +106,7 @@ router.get( ...@@ -105,6 +106,7 @@ router.get(
"/:id/assets", "/:id/assets",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER),
validateQuery(getAssetsQuerySchema),
controller.getAssets, controller.getAssets,
); );
router.get( router.get(
......
import { CrawlJobRepository } from "./crawl-job.repository"; import { CrawlJobRepository } from "./crawl-job.repository";
import { CrawlExportRepository } from "../crawl-exports/crawl-export.repository"; import { CrawlExportRepository } from "../crawl-exports/crawl-export.repository";
import { CrawlScheduleRepository } from "../crawl-schedules/crawl-schedule.repository";
import { UserRepository } from "../users/user.repository"; import { UserRepository } from "../users/user.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";
...@@ -21,6 +22,8 @@ import { getErrorMessage } from "../../common/helpers/error-mapping.helper"; ...@@ -21,6 +22,8 @@ import { getErrorMessage } from "../../common/helpers/error-mapping.helper";
export class CrawlJobService { export class CrawlJobService {
private readonly repository = new CrawlJobRepository(); private readonly repository = new CrawlJobRepository();
private readonly userRepository = new UserRepository(); private readonly userRepository = new UserRepository();
private readonly scheduleRepository = new CrawlScheduleRepository();
private readonly exportRepository = new CrawlExportRepository();
async create(userId: string, payload: CreateCrawlJobDto) { async create(userId: string, payload: CreateCrawlJobDto) {
const isUrlList = payload.mode === CRAWL_MODE.URL_LIST; const isUrlList = payload.mode === CRAWL_MODE.URL_LIST;
...@@ -41,6 +44,22 @@ export class CrawlJobService { ...@@ -41,6 +44,22 @@ export class CrawlJobService {
throw new AppError("User not found", 404, ERROR_CODE.NOT_FOUND); throw new AppError("User not found", 404, ERROR_CODE.NOT_FOUND);
} }
if (payload.scheduleId) {
const schedule = await this.scheduleRepository.findById(
payload.scheduleId,
);
if (
!schedule ||
(user.role !== ROLES.ADMIN && schedule.userId !== userId)
) {
throw new AppError(
"Crawl schedule not found",
404,
ERROR_CODE.CRAWL_SCHEDULE_NOT_FOUND,
);
}
}
// SSRF validation with bounded concurrency for URL_LIST // SSRF validation with bounded concurrency for URL_LIST
if (isUrlList) { if (isUrlList) {
const { validateUrlAsync } = const { validateUrlAsync } =
...@@ -221,8 +240,7 @@ export class CrawlJobService { ...@@ -221,8 +240,7 @@ export class CrawlJobService {
); );
} }
const exportRepository = new CrawlExportRepository(); const exports = await this.exportRepository.findByJobId(jobId);
const exports = await exportRepository.findByJobId(jobId);
const storage = StorageFactory.getStorageService(); const storage = StorageFactory.getStorageService();
for (const exportRecord of exports) { for (const exportRecord of exports) {
...@@ -254,8 +272,7 @@ export class CrawlJobService { ...@@ -254,8 +272,7 @@ export class CrawlJobService {
); );
} }
const exportRepository = new CrawlExportRepository(); const exports = await this.exportRepository.findByJobId(jobId);
const exports = await exportRepository.findByJobId(jobId);
const storage = StorageFactory.getStorageService(); const storage = StorageFactory.getStorageService();
for (const exp of exports) { for (const exp of exports) {
......
...@@ -2,6 +2,7 @@ import { z } from "zod"; ...@@ -2,6 +2,7 @@ import { z } from "zod";
import { EXPORT_TYPE } from "../../common/constants/export-type.constant"; import { EXPORT_TYPE } from "../../common/constants/export-type.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant"; import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { CRAWL_MODE } from "../../common/constants/crawl-mode.constant"; import { CRAWL_MODE } from "../../common/constants/crawl-mode.constant";
import { ASSET_TYPES } from "../../common/constants";
export const createCrawlJobSchema = z export const createCrawlJobSchema = z
.object({ .object({
...@@ -67,3 +68,9 @@ export const listCrawlJobsQuerySchema = z.object({ ...@@ -67,3 +68,9 @@ export const listCrawlJobsQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional(), page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(), limit: z.coerce.number().int().min(1).max(100).optional(),
}); });
export const getAssetsQuerySchema = z.object({
assetType: z.nativeEnum(ASSET_TYPES).optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(500).default(50),
});
import { CrawlPageStatus } from "@prisma/client"; import { CrawlPageStatus } from "../../common/constants/crawl-page-status.constant";
import { import {
FirecrawlPageResult, FirecrawlPageResult,
CrawlErrorItem, CrawlErrorItem,
......
import { CrawlPageStatus } from "@prisma/client"; import { CrawlPageStatus } from "../../common/constants/crawl-page-status.constant";
export interface CreateCrawlPageDto { export interface CreateCrawlPageDto {
jobId: string; jobId: string;
......
...@@ -272,6 +272,7 @@ export class CrawlPageRepository { ...@@ -272,6 +272,7 @@ export class CrawlPageRepository {
contentHash?: string | null; contentHash?: string | null;
dataQualityScore?: number | null; dataQualityScore?: number | null;
warnings?: string[]; warnings?: string[];
hasSensitiveData?: boolean;
}) { }) {
return prisma.crawlPage.upsert({ return prisma.crawlPage.upsert({
where: { jobId_url: { jobId: data.jobId, url: data.url } }, where: { jobId_url: { jobId: data.jobId, url: data.url } },
...@@ -291,6 +292,7 @@ export class CrawlPageRepository { ...@@ -291,6 +292,7 @@ export class CrawlPageRepository {
contentHash: data.contentHash, contentHash: data.contentHash,
dataQualityScore: data.dataQualityScore, dataQualityScore: data.dataQualityScore,
warnings: data.warnings, warnings: data.warnings,
hasSensitiveData: data.hasSensitiveData,
}, },
}); });
} }
......
import { CrawlPageRepository } from "./crawl-page.repository"; import { CrawlPageRepository } from "./crawl-page.repository";
import { CrawlPageStatus } from "@prisma/client"; import { CrawlPageStatus } from "../../common/constants/crawl-page-status.constant";
import { CrawlPageQueryDto } from "./crawl-page.dto"; import { CrawlPageQueryDto } from "./crawl-page.dto";
import { import {
extractMainContent, extractMainContent,
......
import { z } from "zod"; import { z } from "zod";
import { CrawlPageStatus } from "@prisma/client"; import { CRAWL_PAGE_STATUS } from "../../common/constants/crawl-page-status.constant";
const parseBooleanQuery = (val: unknown) => { const parseBooleanQuery = (val: unknown) => {
if (val === undefined || val === null || val === "") return undefined; if (val === undefined || val === null || val === "") return undefined;
...@@ -15,7 +15,7 @@ const parseNumberQuery = (val: unknown) => { ...@@ -15,7 +15,7 @@ const parseNumberQuery = (val: unknown) => {
}; };
export const crawlPageQuerySchema = z.object({ export const crawlPageQuerySchema = z.object({
status: z.nativeEnum(CrawlPageStatus).optional(), status: z.nativeEnum(CRAWL_PAGE_STATUS).optional(),
statusCode: z.preprocess(parseNumberQuery, z.number().int().optional()), statusCode: z.preprocess(parseNumberQuery, z.number().int().optional()),
search: z.string().trim().optional(), search: z.string().trim().optional(),
......
...@@ -46,20 +46,6 @@ export const createCrawlScheduleSchema = z ...@@ -46,20 +46,6 @@ export const createCrawlScheduleSchema = z
}); });
} }
} }
if (
data.frequency === SCHEDULE_FREQUENCY.WEEKLY &&
data.dayOfWeek === undefined
) {
data.dayOfWeek = 0; // Default to Sunday
}
if (
data.frequency === SCHEDULE_FREQUENCY.MONTHLY &&
data.dayOfMonth === undefined
) {
data.dayOfMonth = 1; // Default to 1st of month
}
}); });
export const updateCrawlScheduleSchema = z export const updateCrawlScheduleSchema = z
...@@ -99,20 +85,24 @@ export const updateCrawlScheduleSchema = z ...@@ -99,20 +85,24 @@ export const updateCrawlScheduleSchema = z
export const crawlScheduleQuerySchema = z.object({ export const crawlScheduleQuerySchema = z.object({
search: z.string().trim().optional(), search: z.string().trim().optional(),
frequency: z.nativeEnum(SCHEDULE_FREQUENCY).optional(), frequency: z.nativeEnum(SCHEDULE_FREQUENCY).optional(),
isActive: z isActive: z.preprocess((val) => {
.string() if (val === "true" || val === true || val === "1") return true;
.optional() if (val === "false" || val === false || val === "0") return false;
.transform((val) => return undefined;
val === "true" ? true : val === "false" ? false : undefined, }, z.boolean().optional()),
), page: z.coerce.number().int().min(1).default(1),
page: z limit: z.coerce.number().int().min(1).max(100).default(20),
.string() sortBy: z
.optional() .enum([
.transform((val) => (val ? parseInt(val, 10) : 1)), "createdAt",
limit: z "updatedAt",
.string() "name",
"frequency",
"nextRunAt",
"lastRunAt",
"isActive",
])
.optional() .optional()
.transform((val) => (val ? parseInt(val, 10) : 20)), .default("createdAt"),
sortBy: z.string().optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"), order: z.enum(["asc", "desc"]).optional().default("desc"),
}); });
import { CrawlJob, CrawlPage } from "@prisma/client"; import { CrawlJob, CrawlPage } from "../../common/types/database.types";
import path from "path"; import path from "path";
import { import {
ensureJobExportStructure, ensureJobExportStructure,
......
import fs from "fs"; import fs from "fs";
import { CrawlAsset, CrawlJob, CrawlPage } from "@prisma/client"; import {
CrawlAsset,
CrawlJob,
CrawlPage,
} from "../../common/types/database.types";
import { CrawlAssetRepository } from "../crawl-assets/crawl-asset.repository"; import { CrawlAssetRepository } from "../crawl-assets/crawl-asset.repository";
import { JOB_EXPORT_FILES } from "../../common/constants/storage-path.constant"; import { JOB_EXPORT_FILES } from "../../common/constants/storage-path.constant";
import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant"; import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant";
......
import { CrawlJob, CrawlPage } from "@prisma/client"; import { CrawlJob, CrawlPage } from "../../common/types/database.types";
import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository"; import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository";
import { CrawlExportRepository } from "../crawl-exports/crawl-export.repository"; import { CrawlExportRepository } from "../crawl-exports/crawl-export.repository";
import { JsonExportService } from "./json-export.service"; import { JsonExportService } from "./json-export.service";
......
import fs from "fs"; import fs from "fs";
import { parse as parseHtml } from "node-html-parser"; import { parse as parseHtml } from "node-html-parser";
import { CrawlJob, CrawlPage, CrawlAsset } from "@prisma/client"; import {
CrawlJob,
CrawlPage,
CrawlAsset,
} from "../../common/types/database.types";
import { CrawlAssetRepository } from "../crawl-assets/crawl-asset.repository"; import { CrawlAssetRepository } from "../crawl-assets/crawl-asset.repository";
import { JOB_EXPORT_FILES } from "../../common/constants/storage-path.constant"; import { JOB_EXPORT_FILES } from "../../common/constants/storage-path.constant";
import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant"; import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant";
......
import fs from "fs"; import fs from "fs";
import archiver from "archiver"; import archiver from "archiver";
import { CrawlJob, CrawlPage } from "@prisma/client"; import { CrawlJob, CrawlPage } from "../../common/types/database.types";
import { JOB_EXPORT_SUBDIRS } from "../../common/constants/storage-path.constant"; import { JOB_EXPORT_SUBDIRS } from "../../common/constants/storage-path.constant";
import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant"; import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant";
import { import {
......
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { parse as parseHtml } from "node-html-parser"; import { parse as parseHtml } from "node-html-parser";
import { CrawlJob, CrawlPage } from "@prisma/client"; import { CrawlJob, CrawlPage } from "../../common/types/database.types";
import { JOB_EXPORT_FILES } from "../../common/constants/storage-path.constant"; import { JOB_EXPORT_FILES } from "../../common/constants/storage-path.constant";
import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant"; import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant";
import { buildJobDataFilePath } from "../../common/helpers/file.helper"; import { buildJobDataFilePath } from "../../common/helpers/file.helper";
......
...@@ -2,7 +2,7 @@ import fs from "fs"; ...@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import archiver from "archiver"; import archiver from "archiver";
import { PassThrough } from "stream"; import { PassThrough } from "stream";
import { CrawlJob, CrawlPage } from "@prisma/client"; import { CrawlJob, CrawlPage } from "../../common/types/database.types";
import { CrawlAssetRepository } from "../crawl-assets/crawl-asset.repository"; import { CrawlAssetRepository } from "../crawl-assets/crawl-asset.repository";
import { import {
JOB_EXPORT_FILES, JOB_EXPORT_FILES,
......
...@@ -2,6 +2,7 @@ import { prisma } from "../../database/prisma.client"; ...@@ -2,6 +2,7 @@ import { prisma } from "../../database/prisma.client";
import { UserRole, Prisma, User } from "@prisma/client"; import { UserRole, Prisma, User } from "@prisma/client";
import { UserQueryDto } from "./user.dto"; import { UserQueryDto } from "./user.dto";
import { envConfig } from "../../config/env.config"; import { envConfig } from "../../config/env.config";
import { ROLES } from "../../common/constants/role.constant";
export class UserRepository { export class UserRepository {
async findAll(query: UserQueryDto = {}) { async findAll(query: UserQueryDto = {}) {
...@@ -88,7 +89,7 @@ export class UserRepository { ...@@ -88,7 +89,7 @@ export class UserRepository {
passwordHash: data.passwordHash, passwordHash: data.passwordHash,
fullName: data.fullName, fullName: data.fullName,
avatarUrl: data.avatarUrl, avatarUrl: data.avatarUrl,
role: data.role ?? "CRAWLER_USER", role: data.role ?? ROLES.CRAWLER_USER,
maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages, maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages,
maxJobsPerDayLimit: maxJobsPerDayLimit:
data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay, data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay,
......
...@@ -2,7 +2,7 @@ import bcrypt from "bcryptjs"; ...@@ -2,7 +2,7 @@ import bcrypt from "bcryptjs";
import { UserRepository } from "./user.repository"; import { UserRepository } from "./user.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";
import { User } from "@prisma/client"; import { User } from "../../common/types/database.types";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { import {
CreateUserDto, CreateUserDto,
......
...@@ -11,9 +11,9 @@ export const createUserSchema = z.object({ ...@@ -11,9 +11,9 @@ export const createUserSchema = z.object({
.or(z.literal("")) .or(z.literal(""))
.optional(), .optional(),
role: z.nativeEnum(ROLES).optional(), role: z.nativeEnum(ROLES).optional(),
maxPagesLimit: z.number().int().min(1).optional(), maxPagesLimit: z.number().int().min(1).max(100000).optional(),
maxJobsPerDayLimit: z.number().int().min(1).optional(), maxJobsPerDayLimit: z.number().int().min(1).max(10000).optional(),
maxConcurrentJobsLimit: z.number().int().min(1).optional(), maxConcurrentJobsLimit: z.number().int().min(1).max(100).optional(),
}); });
export const updateUserSchema = z.object({ export const updateUserSchema = z.object({
...@@ -26,9 +26,9 @@ export const updateUserSchema = z.object({ ...@@ -26,9 +26,9 @@ export const updateUserSchema = z.object({
.optional(), .optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
role: z.nativeEnum(ROLES).optional(), role: z.nativeEnum(ROLES).optional(),
maxPagesLimit: z.number().int().min(1).optional(), maxPagesLimit: z.number().int().min(1).max(100000).optional(),
maxJobsPerDayLimit: z.number().int().min(1).optional(), maxJobsPerDayLimit: z.number().int().min(1).max(10000).optional(),
maxConcurrentJobsLimit: z.number().int().min(1).optional(), maxConcurrentJobsLimit: z.number().int().min(1).max(100).optional(),
}); });
export const listUsersQuerySchema = z.object({ export const listUsersQuerySchema = z.object({
...@@ -38,6 +38,6 @@ export const listUsersQuerySchema = z.object({ ...@@ -38,6 +38,6 @@ export const listUsersQuerySchema = z.object({
.enum(["true", "false"]) .enum(["true", "false"])
.transform((v) => v === "true") .transform((v) => v === "true")
.optional(), .optional(),
page: z.coerce.number().int().min(1).optional(), page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).optional(), limit: z.coerce.number().int().min(1).max(100).default(20),
}); });
import { WebhookConfig } from "@prisma/client"; import { WebhookConfig } from "../../common/types/database.types";
import { WebhookRepository } from "./webhook.repository"; import { WebhookRepository } from "./webhook.repository";
import { encrypt } from "./webhook-crypto.helper"; import { encrypt } from "./webhook-crypto.helper";
import { AppError } from "../../common/errors/app-error"; import { AppError } from "../../common/errors/app-error";
......
import { WebhookDelivery, Prisma } from "@prisma/client"; import { WebhookDelivery, Prisma } from "../../common/types/database.types";
import { WebhookRepository } from "./webhook.repository"; import { WebhookRepository } from "./webhook.repository";
import { decrypt, signPayload } from "./webhook-crypto.helper"; import { decrypt, signPayload } from "./webhook-crypto.helper";
import { webhookQueue } from "../../queues/webhook.queue"; import { webhookQueue } from "../../queues/webhook.queue";
......
import { z } from "zod"; import { z } from "zod";
import {
WEBHOOK_DELIVERY_STATUS,
WEBHOOK_EVENT,
} from "../../common/constants/webhook.constant";
export const createWebhookConfigSchema = z.object({ export const createWebhookConfigSchema = z.object({
url: z url: z
...@@ -13,7 +17,7 @@ export const createWebhookConfigSchema = z.object({ ...@@ -13,7 +17,7 @@ export const createWebhookConfigSchema = z.object({
.min(16, "Signing secret must be at least 16 characters long for security") .min(16, "Signing secret must be at least 16 characters long for security")
.max(128, "Signing secret is too long"), .max(128, "Signing secret is too long"),
events: z events: z
.array(z.enum(["job.completed", "job.failed"])) .array(z.nativeEnum(WEBHOOK_EVENT))
.min(1, "At least one event must be selected for notifications"), .min(1, "At least one event must be selected for notifications"),
}); });
...@@ -25,7 +29,7 @@ export const updateWebhookConfigSchema = z.object({ ...@@ -25,7 +29,7 @@ export const updateWebhookConfigSchema = z.object({
.max(128, "Signing secret is too long") .max(128, "Signing secret is too long")
.optional(), .optional(),
events: z events: z
.array(z.enum(["job.completed", "job.failed"])) .array(z.nativeEnum(WEBHOOK_EVENT))
.min(1, "At least one event must be selected for notifications") .min(1, "At least one event must be selected for notifications")
.optional(), .optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
...@@ -33,7 +37,7 @@ export const updateWebhookConfigSchema = z.object({ ...@@ -33,7 +37,7 @@ export const updateWebhookConfigSchema = z.object({
export const listWebhookDeliveriesQuerySchema = z.object({ export const listWebhookDeliveriesQuerySchema = z.object({
jobId: z.string().uuid().optional(), jobId: z.string().uuid().optional(),
status: z.enum(["PENDING", "SUCCESS", "FAILED"]).optional(), status: z.nativeEnum(WEBHOOK_DELIVERY_STATUS).optional(),
page: z.coerce.number().int().min(1).optional(), page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).optional(), limit: z.coerce.number().int().min(1).max(100).default(20),
}); });
...@@ -269,10 +269,6 @@ export async function processCrawlJob(job: Job): Promise<void> { ...@@ -269,10 +269,6 @@ export async function processCrawlJob(job: Job): Promise<void> {
} }
} }
await getJobRepository().updateStatus(jobId, JOB_STATUS.RUNNING, {
startedAt: new Date(),
});
try { try {
if (crawlJob.mode === CRAWL_MODE.SCRAPE) { if (crawlJob.mode === CRAWL_MODE.SCRAPE) {
console.log( console.log(
......
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