Commit b454ffff authored by ThinhNC's avatar ThinhNC

Merge branch 'fix/audit-p0-p1-hardening' into 'develop'

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

See merge request !5
parents e3ba1bf4 1bf2ac6c
This diff is collapsed.
......@@ -25,6 +25,29 @@ export default [
],
"no-console": "off",
"@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 =
process.env.WEBHOOK_ENCRYPTION_KEY =
process.env.WEBHOOK_ENCRYPTION_KEY ||
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
process.env.REDIS_ENABLED = "false";
......@@ -40,7 +40,7 @@ app.use(
maxAge: 86400,
}),
);
app.use(morgan("dev"));
app.use(morgan(envConfig.nodeEnv === "production" ? "combined" : "dev"));
app.use(cookieParser());
app.use(express.json());
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> = {
MARKDOWN: "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";
export * from "./crawl-mode.constant";
export * from "./schedule-frequency.constant";
export * from "./asset-type.constant";
export * from "./crawl-page-status.constant";
export * from "./webhook.constant";
......@@ -5,3 +5,4 @@ export const ROLES = {
} as const;
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 { CrawlPage, CrawlAsset } from "@prisma/client";
import { CrawlPage, CrawlAsset } from "../types/database.types";
import {
CrawlPageRecord,
DataQualityWarning,
......@@ -15,10 +15,6 @@ import {
DATA_CONTRACT_HASH_ALGORITHM,
} from "../constants/data-contract.constant";
// ─────────────────────────────────────────────
// URL Normalization
// ─────────────────────────────────────────────
/**
* Normalize một URL để phục vụ deduplicate và so sánh.
* Các bước:
......@@ -66,10 +62,6 @@ export function normalizeUrl(rawUrl: string): string {
}
}
// ─────────────────────────────────────────────
// Text Utilities
// ─────────────────────────────────────────────
/**
* 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.
......@@ -234,10 +226,6 @@ export function hashContent(text: string): string | null {
.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í:
* - Có mainContent : +40 điểm
......@@ -295,10 +283,6 @@ export function detectWarnings(params: {
return warnings;
}
// ─────────────────────────────────────────────
// Asset Transformation
// ─────────────────────────────────────────────
/**
* 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).
......@@ -361,10 +345,6 @@ function inferImageType(imageUrl: string): string {
}
}
// ─────────────────────────────────────────────
// Main Transformer
// ─────────────────────────────────────────────
export interface TransformPageOptions {
page: CrawlPage & { normalizedUrl?: string | null };
assets: CrawlAsset[];
......@@ -452,10 +432,6 @@ export function transformPageToRecord(
};
}
// ─────────────────────────────────────────────
// Envelope Builder
// ─────────────────────────────────────────────
/**
* Đó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 {
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">;
......
......@@ -2,7 +2,7 @@ import crypto from "crypto";
import { ApiKeyRepository } from "./api-key.repository";
import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code";
import type { ApiKey } from "@prisma/client";
import type { ApiKey } from "../../common/types/database.types";
import {
toPublicApiKey,
type CreatedApiKey,
......
......@@ -170,4 +170,89 @@ describe("AuthService registration mail failures", () => {
expect(mailService.sendVerificationEmail).toHaveBeenCalledTimes(1);
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,22 +283,14 @@ export class AuthController {
forgotPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
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({
userId: result.userId,
action: AUDIT_ACTIONS.FORGOT_PASSWORD,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
details: { email: forgotPasswordDto.email },
});
await this.mailService.sendPasswordResetEmail(
forgotPasswordDto.email,
result.resetToken,
);
}
await this.auditLogService.log({
action: AUDIT_ACTIONS.FORGOT_PASSWORD,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
details: { email: forgotPasswordDto.email },
});
res.json({
success: true,
......
......@@ -502,9 +502,7 @@ export class AuthService {
};
}
async forgotPassword(
data: ForgotPasswordDto,
): Promise<{ success: boolean; resetToken?: string; userId?: string }> {
async forgotPassword(data: ForgotPasswordDto): Promise<{ success: boolean }> {
const { email } = data;
const user = await this.repository.findByEmail(email);
......@@ -517,10 +515,14 @@ export class AuthService {
expiresIn: "15m",
});
try {
await this.mailService.sendPasswordResetEmail(user.email, resetToken);
} catch (error: unknown) {
console.error("[Mail] Password reset delivery failed:", error);
}
return {
success: true,
resetToken,
userId: user.id,
};
}
......
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 {
DiffReportEnvelope,
......
......@@ -57,6 +57,15 @@ export class CrawlAssetRepository {
});
}
countByJobId(jobId: string, assetType?: AssetType) {
return prisma.crawlAsset.count({
where: {
crawlJobId: jobId,
...(assetType ? { assetType } : {}),
},
});
}
findAssetsForJsonExport(jobId: string) {
return prisma.crawlAsset.findMany({
where: { crawlJobId: jobId },
......
......@@ -10,7 +10,19 @@ export class CrawlAssetService {
page = 1,
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: {
......
import { ExportType, ExportStatus } from "@prisma/client";
import {
ExportType,
ExportStatus,
} from "../../common/constants/export-type.constant";
export interface CreateCrawlExportDto {
jobId: string;
......
......@@ -5,6 +5,7 @@ jest.mock("../../../database/prisma.client", () => ({
jest.mock("../crawl-job.repository");
jest.mock("../../users/user.repository");
jest.mock("../../crawl-exports/crawl-export.repository");
jest.mock("../../crawl-schedules/crawl-schedule.repository");
jest.mock("../../../common/helpers/url.helper");
jest.mock("../../../queues/crawl.queue", () => ({
crawlQueue: {
......@@ -15,12 +16,14 @@ jest.mock("../../../queues/crawl.queue", () => ({
import { CrawlJobService } from "../crawl-job.service";
import { CrawlJobRepository } from "../crawl-job.repository";
import { UserRepository } from "../../users/user.repository";
import { CrawlScheduleRepository } from "../../crawl-schedules/crawl-schedule.repository";
import * as urlHelper from "../../../common/helpers/url.helper";
describe("CrawlJobService", () => {
let service: CrawlJobService;
let mockJobRepo: jest.Mocked<CrawlJobRepository>;
let mockUserRepo: jest.Mocked<UserRepository>;
let mockScheduleRepo: jest.Mocked<CrawlScheduleRepository>;
beforeEach(() => {
jest.clearAllMocks();
......@@ -32,6 +35,13 @@ describe("CrawlJobService", () => {
findById: jest.fn(),
} as any;
mockScheduleRepo = {
findById: jest.fn().mockResolvedValue(null),
} as any;
(CrawlJobRepository as jest.Mock).mockReturnValue(mockJobRepo);
(CrawlScheduleRepository as jest.Mock).mockReturnValue(mockScheduleRepo);
mockUserRepo = {
findById: jest.fn().mockResolvedValue({
id: "user-1",
......@@ -109,24 +119,34 @@ describe("CrawlJobService", () => {
).rejects.toThrow("Concurrent jobs quota of 3 exceeded");
});
it("deduplicates URLs in URL_LIST mode and validates them", async () => {
const urls = [
"https://example.com/1",
"https://example.com/2",
"https://example.com/1",
];
it("throws error when scheduleId does not belong to user", async () => {
mockScheduleRepo.findById.mockResolvedValue({
id: "sched-1",
userId: "other-user",
} 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", {
mode: "URL_LIST",
urls,
startUrl: "https://example.com",
scheduleId: "sched-1",
});
expect(urlHelper.validateUrlAsync).toHaveBeenCalledTimes(2);
expect(mockJobRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
mode: "URL_LIST",
startUrl: "https://example.com/1",
urls: ["https://example.com/1", "https://example.com/2"],
scheduleId: "sched-1",
}),
);
});
......
......@@ -8,10 +8,7 @@ import { AuditLogService } from "../audit-logs/audit-log.service";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { CrawlAssetService } from "../crawl-assets/crawl-asset.service";
import {
AssetType,
ASSET_TYPES,
} from "../../common/constants/asset-type.constant";
import { AssetType } from "../../common/constants/asset-type.constant";
import { streamStorageDownload } from "../../common/storage/storage-download.helper";
export class CrawlJobController {
private readonly service = new CrawlJobService();
......@@ -137,22 +134,11 @@ export class CrawlJobController {
try {
await this.service.findById(req.user.id, req.user.role, req.params.id);
const VALID_ASSET_TYPES: readonly string[] = Object.values(ASSET_TYPES);
const rawType = req.query.assetType as string | undefined;
if (rawType && !VALID_ASSET_TYPES.includes(rawType)) {
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 assetType = req.query.assetType as AssetType | undefined;
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 50;
const items = await this.assetService.findByJobId(
const result = await this.assetService.findByJobId(
req.params.id,
assetType,
page,
......@@ -162,8 +148,13 @@ export class CrawlJobController {
res.json({
success: true,
data: {
items,
meta: { page, limit },
items: result.items,
meta: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
},
});
} catch (error) {
......@@ -413,11 +404,14 @@ export class CrawlJobController {
);
res.json({
success: true,
data: result.items,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
data: {
items: result.items,
meta: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: Math.ceil(result.total / (result.limit || 1)),
},
},
});
} catch (error) {
......
......@@ -166,26 +166,21 @@ export class CrawlJobRepository {
failedPages?: number;
},
) {
const currentJob = await prisma.crawlJob.findUnique({
where: { id },
select: { status: true },
await prisma.crawlJob.updateMany({
where: {
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({
where: { id },
include: {
exports: true,
},
});
}
return prisma.crawlJob.update({
return prisma.crawlJob.findUnique({
where: { id },
data: { status, ...extra },
include: {
exports: true,
},
});
}
......
......@@ -6,6 +6,7 @@ import {
createCrawlJobSchema,
createExportSchema,
listCrawlJobsQuerySchema,
getAssetsQuerySchema,
} from "./crawl-job.validation";
import { crawlPageQuerySchema } from "../crawl-pages/crawl-page.validation";
import { requireRole } from "../../middlewares/role.middleware";
......@@ -105,6 +106,7 @@ router.get(
"/:id/assets",
apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER),
validateQuery(getAssetsQuerySchema),
controller.getAssets,
);
router.get(
......
import { CrawlJobRepository } from "./crawl-job.repository";
import { CrawlExportRepository } from "../crawl-exports/crawl-export.repository";
import { CrawlScheduleRepository } from "../crawl-schedules/crawl-schedule.repository";
import { UserRepository } from "../users/user.repository";
import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code";
......@@ -21,6 +22,8 @@ import { getErrorMessage } from "../../common/helpers/error-mapping.helper";
export class CrawlJobService {
private readonly repository = new CrawlJobRepository();
private readonly userRepository = new UserRepository();
private readonly scheduleRepository = new CrawlScheduleRepository();
private readonly exportRepository = new CrawlExportRepository();
async create(userId: string, payload: CreateCrawlJobDto) {
const isUrlList = payload.mode === CRAWL_MODE.URL_LIST;
......@@ -41,6 +44,22 @@ export class CrawlJobService {
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
if (isUrlList) {
const { validateUrlAsync } =
......@@ -221,8 +240,7 @@ export class CrawlJobService {
);
}
const exportRepository = new CrawlExportRepository();
const exports = await exportRepository.findByJobId(jobId);
const exports = await this.exportRepository.findByJobId(jobId);
const storage = StorageFactory.getStorageService();
for (const exportRecord of exports) {
......@@ -254,8 +272,7 @@ export class CrawlJobService {
);
}
const exportRepository = new CrawlExportRepository();
const exports = await exportRepository.findByJobId(jobId);
const exports = await this.exportRepository.findByJobId(jobId);
const storage = StorageFactory.getStorageService();
for (const exp of exports) {
......
......@@ -2,6 +2,7 @@ import { z } from "zod";
import { EXPORT_TYPE } from "../../common/constants/export-type.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { CRAWL_MODE } from "../../common/constants/crawl-mode.constant";
import { ASSET_TYPES } from "../../common/constants";
export const createCrawlJobSchema = z
.object({
......@@ -67,3 +68,9 @@ export const listCrawlJobsQuerySchema = z.object({
page: z.coerce.number().int().min(1).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 {
FirecrawlPageResult,
CrawlErrorItem,
......
import { CrawlPageStatus } from "@prisma/client";
import { CrawlPageStatus } from "../../common/constants/crawl-page-status.constant";
export interface CreateCrawlPageDto {
jobId: string;
......
......@@ -272,6 +272,7 @@ export class CrawlPageRepository {
contentHash?: string | null;
dataQualityScore?: number | null;
warnings?: string[];
hasSensitiveData?: boolean;
}) {
return prisma.crawlPage.upsert({
where: { jobId_url: { jobId: data.jobId, url: data.url } },
......@@ -291,6 +292,7 @@ export class CrawlPageRepository {
contentHash: data.contentHash,
dataQualityScore: data.dataQualityScore,
warnings: data.warnings,
hasSensitiveData: data.hasSensitiveData,
},
});
}
......
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 {
extractMainContent,
......
import { z } from "zod";
import { CrawlPageStatus } from "@prisma/client";
import { CRAWL_PAGE_STATUS } from "../../common/constants/crawl-page-status.constant";
const parseBooleanQuery = (val: unknown) => {
if (val === undefined || val === null || val === "") return undefined;
......@@ -15,7 +15,7 @@ const parseNumberQuery = (val: unknown) => {
};
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()),
search: z.string().trim().optional(),
......
......@@ -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
......@@ -99,20 +85,24 @@ export const updateCrawlScheduleSchema = z
export const crawlScheduleQuerySchema = z.object({
search: z.string().trim().optional(),
frequency: z.nativeEnum(SCHEDULE_FREQUENCY).optional(),
isActive: z
.string()
.optional()
.transform((val) =>
val === "true" ? true : val === "false" ? false : undefined,
),
page: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 1)),
limit: z
.string()
isActive: z.preprocess((val) => {
if (val === "true" || val === true || val === "1") return true;
if (val === "false" || val === false || val === "0") return false;
return undefined;
}, z.boolean().optional()),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z
.enum([
"createdAt",
"updatedAt",
"name",
"frequency",
"nextRunAt",
"lastRunAt",
"isActive",
])
.optional()
.transform((val) => (val ? parseInt(val, 10) : 20)),
sortBy: z.string().optional().default("createdAt"),
.default("createdAt"),
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 {
ensureJobExportStructure,
......
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 { JOB_EXPORT_FILES } from "../../common/constants/storage-path.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 { CrawlExportRepository } from "../crawl-exports/crawl-export.repository";
import { JsonExportService } from "./json-export.service";
......
import fs from "fs";
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 { JOB_EXPORT_FILES } from "../../common/constants/storage-path.constant";
import { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant";
......
import fs from "fs";
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 { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant";
import {
......
import ExcelJS from "exceljs";
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 { EXPORT_MIME_TYPES } from "../../common/constants/export-type.constant";
import { buildJobDataFilePath } from "../../common/helpers/file.helper";
......
......@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
import archiver from "archiver";
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 {
JOB_EXPORT_FILES,
......
......@@ -2,6 +2,7 @@ import { prisma } from "../../database/prisma.client";
import { UserRole, Prisma, User } from "@prisma/client";
import { UserQueryDto } from "./user.dto";
import { envConfig } from "../../config/env.config";
import { ROLES } from "../../common/constants/role.constant";
export class UserRepository {
async findAll(query: UserQueryDto = {}) {
......@@ -88,7 +89,7 @@ export class UserRepository {
passwordHash: data.passwordHash,
fullName: data.fullName,
avatarUrl: data.avatarUrl,
role: data.role ?? "CRAWLER_USER",
role: data.role ?? ROLES.CRAWLER_USER,
maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages,
maxJobsPerDayLimit:
data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay,
......
......@@ -2,7 +2,7 @@ import bcrypt from "bcryptjs";
import { UserRepository } from "./user.repository";
import { AppError } from "../../common/errors/app-error";
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 {
CreateUserDto,
......
......@@ -11,9 +11,9 @@ export const createUserSchema = z.object({
.or(z.literal(""))
.optional(),
role: z.nativeEnum(ROLES).optional(),
maxPagesLimit: z.number().int().min(1).optional(),
maxJobsPerDayLimit: z.number().int().min(1).optional(),
maxConcurrentJobsLimit: z.number().int().min(1).optional(),
maxPagesLimit: z.number().int().min(1).max(100000).optional(),
maxJobsPerDayLimit: z.number().int().min(1).max(10000).optional(),
maxConcurrentJobsLimit: z.number().int().min(1).max(100).optional(),
});
export const updateUserSchema = z.object({
......@@ -26,9 +26,9 @@ export const updateUserSchema = z.object({
.optional(),
isActive: z.boolean().optional(),
role: z.nativeEnum(ROLES).optional(),
maxPagesLimit: z.number().int().min(1).optional(),
maxJobsPerDayLimit: z.number().int().min(1).optional(),
maxConcurrentJobsLimit: z.number().int().min(1).optional(),
maxPagesLimit: z.number().int().min(1).max(100000).optional(),
maxJobsPerDayLimit: z.number().int().min(1).max(10000).optional(),
maxConcurrentJobsLimit: z.number().int().min(1).max(100).optional(),
});
export const listUsersQuerySchema = z.object({
......@@ -38,6 +38,6 @@ export const listUsersQuerySchema = z.object({
.enum(["true", "false"])
.transform((v) => v === "true")
.optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
page: z.coerce.number().int().min(1).default(1),
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 { encrypt } from "./webhook-crypto.helper";
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 { decrypt, signPayload } from "./webhook-crypto.helper";
import { webhookQueue } from "../../queues/webhook.queue";
......
import { z } from "zod";
import {
WEBHOOK_DELIVERY_STATUS,
WEBHOOK_EVENT,
} from "../../common/constants/webhook.constant";
export const createWebhookConfigSchema = z.object({
url: z
......@@ -13,7 +17,7 @@ export const createWebhookConfigSchema = z.object({
.min(16, "Signing secret must be at least 16 characters long for security")
.max(128, "Signing secret is too long"),
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"),
});
......@@ -25,7 +29,7 @@ export const updateWebhookConfigSchema = z.object({
.max(128, "Signing secret is too long")
.optional(),
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")
.optional(),
isActive: z.boolean().optional(),
......@@ -33,7 +37,7 @@ export const updateWebhookConfigSchema = z.object({
export const listWebhookDeliveriesQuerySchema = z.object({
jobId: z.string().uuid().optional(),
status: z.enum(["PENDING", "SUCCESS", "FAILED"]).optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
status: z.nativeEnum(WEBHOOK_DELIVERY_STATUS).optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
......@@ -269,10 +269,6 @@ export async function processCrawlJob(job: Job): Promise<void> {
}
}
await getJobRepository().updateStatus(jobId, JOB_STATUS.RUNNING, {
startedAt: new Date(),
});
try {
if (crawlJob.mode === CRAWL_MODE.SCRAPE) {
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