fase(15): reporting module with pdf generation
This commit is contained in:
@@ -5,6 +5,7 @@ import { Router } from 'express';
|
||||
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
|
||||
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
|
||||
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
||||
import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController';
|
||||
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
|
||||
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
|
||||
import { ServerDependencies } from './server';
|
||||
@@ -64,6 +65,7 @@ export function createRouter(deps: ServerDependencies): Router {
|
||||
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
|
||||
router.use('/findings', createFindingsRouter(deps.findingsDeps));
|
||||
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
|
||||
router.use('/reports', createReportingRouter(deps.reportingDeps));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createRouter } from './router';
|
||||
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
|
||||
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
|
||||
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
||||
import { ReportingControllerDeps } from '../modules/reporting/infrastructure/http/ReportingController';
|
||||
import { AuthControllerDeps } from './router';
|
||||
|
||||
export interface ServerDependencies {
|
||||
@@ -27,6 +28,7 @@ export interface ServerDependencies {
|
||||
crawlingDeps: CrawlingControllerDeps;
|
||||
findingsDeps: FindingsControllerDeps;
|
||||
fuzzingDeps: FuzzingControllerDeps;
|
||||
reportingDeps: ReportingControllerDeps;
|
||||
authDeps: AuthControllerDeps;
|
||||
}
|
||||
|
||||
|
||||
24
src/db/migrations/005_reports_table.ts
Normal file
24
src/db/migrations/005_reports_table.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('reports')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||
.addColumn('title', 'text', (col) => col.notNull())
|
||||
.addColumn('format', 'text', (col) => col.notNull())
|
||||
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
|
||||
.addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
|
||||
.addColumn('file_path', 'text')
|
||||
.addColumn('error_message', 'text')
|
||||
.addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
|
||||
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||
.addColumn('completed_at', 'integer')
|
||||
.execute();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('reports').ifExists().execute();
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* ReportWorker — handles 'report:generate' jobs.
|
||||
* Generates reports in the background (full implementation in Phase 15).
|
||||
* Generates HTML, JSON, or PDF reports in the background.
|
||||
*/
|
||||
import { JobHandler } from '../JobQueue';
|
||||
import { Logger } from '../../shared/infrastructure/Logger';
|
||||
import { IReportRepository } from '../../modules/reporting/domain/ports/IReportRepository';
|
||||
import { IFindingRepository } from '../../modules/findings/domain/ports/IFindingRepository';
|
||||
import { HTMLReportGenerator } from '../../modules/reporting/infrastructure/generators/HTMLReportGenerator';
|
||||
import { JSONReportGenerator } from '../../modules/reporting/infrastructure/generators/JSONReportGenerator';
|
||||
import { PDFReportGenerator } from '../../modules/reporting/infrastructure/generators/PDFReportGenerator';
|
||||
|
||||
export const REPORT_JOB_TYPE = 'report:generate';
|
||||
|
||||
@@ -25,16 +30,51 @@ export interface ReportJobResult {
|
||||
|
||||
export function createReportJobHandler(deps: {
|
||||
logger: Logger;
|
||||
reportRepository: IReportRepository;
|
||||
findingRepository: IFindingRepository;
|
||||
}): JobHandler<ReportJobPayload, ReportJobResult> {
|
||||
const htmlGen = new HTMLReportGenerator();
|
||||
const jsonGen = new JSONReportGenerator();
|
||||
const pdfGen = new PDFReportGenerator();
|
||||
|
||||
return async (payload: ReportJobPayload): Promise<ReportJobResult> => {
|
||||
const log = deps.logger.child({ jobType: REPORT_JOB_TYPE, reportId: payload.reportId });
|
||||
log.info({ format: payload.format }, 'Report generation job executing');
|
||||
|
||||
// Full implementation in Phase 15 (Reporting Module)
|
||||
// For now, return a placeholder result
|
||||
const filePath = `./reports/${payload.reportId}.${payload.format}`;
|
||||
log.info({ filePath }, 'Report job complete');
|
||||
const report = await deps.reportRepository.findById(payload.reportId);
|
||||
if (!report) {
|
||||
throw new Error(`Report not found: ${payload.reportId}`);
|
||||
}
|
||||
|
||||
report.markGenerating();
|
||||
await deps.reportRepository.update(report);
|
||||
|
||||
// Load findings with filters from report
|
||||
const findings = await deps.findingRepository.findAll({
|
||||
sessionId: report.filters.sessionId,
|
||||
severity: report.filters.severity,
|
||||
});
|
||||
|
||||
let filePath: string;
|
||||
try {
|
||||
if (payload.format === 'pdf') {
|
||||
filePath = await pdfGen.generate(report, findings);
|
||||
} else if (payload.format === 'json') {
|
||||
filePath = await jsonGen.generate(report, findings);
|
||||
} else {
|
||||
filePath = await htmlGen.generate(report, findings);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
report.markFailed(msg);
|
||||
await deps.reportRepository.update(report);
|
||||
throw err;
|
||||
}
|
||||
|
||||
report.markReady(filePath, findings.length);
|
||||
await deps.reportRepository.update(report);
|
||||
|
||||
log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
|
||||
return { reportId: payload.reportId, filePath };
|
||||
};
|
||||
}
|
||||
|
||||
29
src/main.ts
29
src/main.ts
@@ -51,6 +51,10 @@ import { GetUserQuery } from './modules/auth/application/queries/GetUserQuery';
|
||||
import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery';
|
||||
import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth/PasswordService';
|
||||
|
||||
// Reporting module
|
||||
import { KyselyReportRepository } from './modules/reporting/infrastructure/repositories/KyselyReportRepository';
|
||||
import { GenerateReportCommand } from './modules/reporting/application/commands/GenerateReportCommand';
|
||||
|
||||
// Job queue
|
||||
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
|
||||
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
|
||||
@@ -80,6 +84,7 @@ async function bootstrap(): Promise<void> {
|
||||
const sessionRepo = new KyselyCrawlSessionRepository(db);
|
||||
const stateRepo = new KyselyStateRepository(db);
|
||||
const findingRepo = new KyselyFindingRepository(db);
|
||||
const reportRepo = new KyselyReportRepository(db);
|
||||
const fuzzRepo = new InMemoryFuzzSessionRepository();
|
||||
|
||||
// Suppress unused warning for stateRepo — used by crawling infrastructure
|
||||
@@ -125,7 +130,19 @@ async function bootstrap(): Promise<void> {
|
||||
const getUserQuery = new GetUserQuery(userRepo);
|
||||
const listOrgMembersQuery = new ListOrgMembersQuery(orgRepo, userRepo);
|
||||
|
||||
// 11. HTTP server
|
||||
// 11. Reporting use cases
|
||||
const generateReport = new GenerateReportCommand(reportRepo, eventBus);
|
||||
|
||||
// 12. Job queue (created before HTTP server so it can be injected)
|
||||
const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
|
||||
jobQueue.registerHandler(
|
||||
EXPLORATION_JOB_TYPE,
|
||||
createExplorationJobHandler({ sessionRepo, eventBus, logger }),
|
||||
);
|
||||
jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
|
||||
jobQueue.start();
|
||||
|
||||
// 13. HTTP server
|
||||
const app = createServer({
|
||||
config,
|
||||
logger,
|
||||
@@ -133,6 +150,7 @@ async function bootstrap(): Promise<void> {
|
||||
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
|
||||
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
||||
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
||||
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
|
||||
authDeps: {
|
||||
registerCommand,
|
||||
loginCommand,
|
||||
@@ -149,15 +167,6 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
// 11. Job queue
|
||||
const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
|
||||
jobQueue.registerHandler(
|
||||
EXPLORATION_JOB_TYPE,
|
||||
createExplorationJobHandler({ sessionRepo, eventBus, logger }),
|
||||
);
|
||||
jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger }));
|
||||
jobQueue.start();
|
||||
|
||||
// 12. Socket.io + gateway
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: { origin: config.cors.origin, credentials: true },
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
|
||||
res.json(result.value);
|
||||
});
|
||||
|
||||
// GET /api/findings/:id — finding detail
|
||||
// GET /api/findings/:id — finding detail (includes actionTrace)
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const findingId = req.params['id'] as string;
|
||||
const result = await deps.getFinding.execute({ findingId });
|
||||
@@ -58,7 +58,8 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(toDTO(result.value));
|
||||
const f = result.value;
|
||||
res.json({ ...toDTO(f), actionTrace: f.actionTrace });
|
||||
});
|
||||
|
||||
// PATCH /api/findings/:id/status — update status
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { IReportRepository } from '../../domain/ports/IReportRepository';
|
||||
import { Report, ReportFilters } from '../../domain/entities/Report';
|
||||
import { ReportFormat } from '../../domain/value-objects/ReportFormat';
|
||||
|
||||
export interface GenerateReportRequest {
|
||||
title: string;
|
||||
format: 'html' | 'json' | 'pdf';
|
||||
filters?: ReportFilters;
|
||||
}
|
||||
|
||||
export interface GenerateReportResponse {
|
||||
reportId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export class GenerateReportCommand
|
||||
implements UseCase<GenerateReportRequest, GenerateReportResponse, string>
|
||||
{
|
||||
constructor(
|
||||
private readonly reportRepository: IReportRepository,
|
||||
private readonly eventBus: EventBus
|
||||
) {}
|
||||
|
||||
async execute(request: GenerateReportRequest): Promise<Result<GenerateReportResponse, string>> {
|
||||
let format: ReportFormat;
|
||||
try {
|
||||
format = ReportFormat.fromString(request.format);
|
||||
} catch {
|
||||
return Err(`Invalid format: ${request.format}`);
|
||||
}
|
||||
|
||||
const report = Report.create({
|
||||
title: request.title,
|
||||
format,
|
||||
filters: request.filters ?? {},
|
||||
});
|
||||
|
||||
await this.reportRepository.save(report);
|
||||
|
||||
const events = report.clearEvents();
|
||||
for (const event of events) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
return Ok({ reportId: report.id.toString(), status: report.status.value });
|
||||
}
|
||||
}
|
||||
90
src/modules/reporting/domain/entities/Report.ts
Normal file
90
src/modules/reporting/domain/entities/Report.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { ReportFormat } from '../value-objects/ReportFormat';
|
||||
import { ReportStatus } from '../value-objects/ReportStatus';
|
||||
import { ReportRequested } from '../events/ReportRequested';
|
||||
import { ReportGenerated } from '../events/ReportGenerated';
|
||||
import { ReportFailed } from '../events/ReportFailed';
|
||||
|
||||
export interface ReportFilters {
|
||||
sessionId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
severity?: string;
|
||||
}
|
||||
|
||||
export interface ReportProps {
|
||||
title: string;
|
||||
format: ReportFormat;
|
||||
status: ReportStatus;
|
||||
filters: ReportFilters;
|
||||
filePath?: string;
|
||||
errorMessage?: string;
|
||||
totalFindings: number;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
export class Report extends AggregateRoot<ReportProps> {
|
||||
static create(props: { title: string; format: ReportFormat; filters: ReportFilters }, id?: UniqueId): Report {
|
||||
const reportId = id ?? UniqueId.create();
|
||||
const report = new Report(
|
||||
{
|
||||
...props,
|
||||
status: ReportStatus.pending(),
|
||||
totalFindings: 0,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
reportId
|
||||
);
|
||||
report.addDomainEvent(
|
||||
new ReportRequested(reportId.toString(), {
|
||||
title: props.title,
|
||||
format: props.format.value,
|
||||
filters: props.filters as Record<string, unknown>,
|
||||
})
|
||||
);
|
||||
return report;
|
||||
}
|
||||
|
||||
static reconstitute(props: ReportProps, id: UniqueId): Report {
|
||||
return new Report(props, id);
|
||||
}
|
||||
|
||||
get title(): string { return this.props.title; }
|
||||
get format(): ReportFormat { return this.props.format; }
|
||||
get status(): ReportStatus { return this.props.status; }
|
||||
get filters(): ReportFilters { return this.props.filters; }
|
||||
get filePath(): string | undefined { return this.props.filePath; }
|
||||
get errorMessage(): string | undefined { return this.props.errorMessage; }
|
||||
get totalFindings(): number { return this.props.totalFindings; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
get completedAt(): Date | undefined { return this.props.completedAt; }
|
||||
|
||||
markGenerating(): void {
|
||||
this.props.status = ReportStatus.generating();
|
||||
}
|
||||
|
||||
markReady(filePath: string, totalFindings: number): void {
|
||||
this.props.status = ReportStatus.ready();
|
||||
this.props.filePath = filePath;
|
||||
this.props.totalFindings = totalFindings;
|
||||
this.props.completedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new ReportGenerated(this.id.toString(), {
|
||||
filePath,
|
||||
totalFindings,
|
||||
format: this.props.format.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
markFailed(errorMessage: string): void {
|
||||
this.props.status = ReportStatus.failed();
|
||||
this.props.errorMessage = errorMessage;
|
||||
this.props.completedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new ReportFailed(this.id.toString(), { errorMessage })
|
||||
);
|
||||
}
|
||||
}
|
||||
9
src/modules/reporting/domain/events/ReportFailed.ts
Normal file
9
src/modules/reporting/domain/events/ReportFailed.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class ReportFailed implements DomainEvent {
|
||||
readonly eventId = randomUUID();
|
||||
readonly eventName = 'reporting.report_failed';
|
||||
readonly occurredOn = new Date();
|
||||
constructor(readonly aggregateId: string, readonly payload: Record<string, unknown>) {}
|
||||
}
|
||||
9
src/modules/reporting/domain/events/ReportGenerated.ts
Normal file
9
src/modules/reporting/domain/events/ReportGenerated.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class ReportGenerated implements DomainEvent {
|
||||
readonly eventId = randomUUID();
|
||||
readonly eventName = 'reporting.report_generated';
|
||||
readonly occurredOn = new Date();
|
||||
constructor(readonly aggregateId: string, readonly payload: Record<string, unknown>) {}
|
||||
}
|
||||
9
src/modules/reporting/domain/events/ReportRequested.ts
Normal file
9
src/modules/reporting/domain/events/ReportRequested.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class ReportRequested implements DomainEvent {
|
||||
readonly eventId = randomUUID();
|
||||
readonly eventName = 'reporting.report_requested';
|
||||
readonly occurredOn = new Date();
|
||||
constructor(readonly aggregateId: string, readonly payload: Record<string, unknown>) {}
|
||||
}
|
||||
6
src/modules/reporting/domain/ports/IReportGenerator.ts
Normal file
6
src/modules/reporting/domain/ports/IReportGenerator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Finding } from '../../../findings/domain/entities/Finding';
|
||||
import { Report } from '../entities/Report';
|
||||
|
||||
export interface IReportGenerator {
|
||||
generate(report: Report, findings: Finding[]): Promise<string>;
|
||||
}
|
||||
8
src/modules/reporting/domain/ports/IReportRepository.ts
Normal file
8
src/modules/reporting/domain/ports/IReportRepository.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Report } from '../entities/Report';
|
||||
|
||||
export interface IReportRepository {
|
||||
save(report: Report): Promise<void>;
|
||||
findById(id: string): Promise<Report | undefined>;
|
||||
findAll(): Promise<Report[]>;
|
||||
update(report: Report): Promise<void>;
|
||||
}
|
||||
16
src/modules/reporting/domain/value-objects/DateRange.ts
Normal file
16
src/modules/reporting/domain/value-objects/DateRange.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
interface DateRangeProps {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export class DateRange extends ValueObject<DateRangeProps> {
|
||||
get startDate(): Date { return this.props.startDate; }
|
||||
get endDate(): Date { return this.props.endDate; }
|
||||
|
||||
static create(startDate: Date, endDate: Date): DateRange {
|
||||
if (startDate > endDate) throw new Error('startDate must be before endDate');
|
||||
return new DateRange({ startDate, endDate });
|
||||
}
|
||||
}
|
||||
20
src/modules/reporting/domain/value-objects/ReportFormat.ts
Normal file
20
src/modules/reporting/domain/value-objects/ReportFormat.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
interface ReportFormatProps {
|
||||
value: 'html' | 'json' | 'pdf';
|
||||
}
|
||||
|
||||
export class ReportFormat extends ValueObject<ReportFormatProps> {
|
||||
get value(): 'html' | 'json' | 'pdf' { return this.props.value; }
|
||||
|
||||
static html(): ReportFormat { return new ReportFormat({ value: 'html' }); }
|
||||
static json(): ReportFormat { return new ReportFormat({ value: 'json' }); }
|
||||
static pdf(): ReportFormat { return new ReportFormat({ value: 'pdf' }); }
|
||||
|
||||
static fromString(s: string): ReportFormat {
|
||||
if (s === 'html' || s === 'json' || s === 'pdf') {
|
||||
return new ReportFormat({ value: s });
|
||||
}
|
||||
throw new Error(`Invalid report format: ${s}`);
|
||||
}
|
||||
}
|
||||
21
src/modules/reporting/domain/value-objects/ReportStatus.ts
Normal file
21
src/modules/reporting/domain/value-objects/ReportStatus.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
interface ReportStatusProps {
|
||||
value: 'pending' | 'generating' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
export class ReportStatus extends ValueObject<ReportStatusProps> {
|
||||
get value(): 'pending' | 'generating' | 'ready' | 'failed' { return this.props.value; }
|
||||
|
||||
static pending(): ReportStatus { return new ReportStatus({ value: 'pending' }); }
|
||||
static generating(): ReportStatus { return new ReportStatus({ value: 'generating' }); }
|
||||
static ready(): ReportStatus { return new ReportStatus({ value: 'ready' }); }
|
||||
static failed(): ReportStatus { return new ReportStatus({ value: 'failed' }); }
|
||||
|
||||
static fromString(s: string): ReportStatus {
|
||||
if (s === 'pending' || s === 'generating' || s === 'ready' || s === 'failed') {
|
||||
return new ReportStatus({ value: s });
|
||||
}
|
||||
throw new Error(`Invalid report status: ${s}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IReportGenerator } from '../../domain/ports/IReportGenerator';
|
||||
import { Report } from '../../domain/entities/Report';
|
||||
import { Finding } from '../../../findings/domain/entities/Finding';
|
||||
|
||||
export class HTMLReportGenerator implements IReportGenerator {
|
||||
async generate(report: Report, findings: Finding[]): Promise<string> {
|
||||
const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const severityCounts: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const f of findings) {
|
||||
const sev = f.severity.value;
|
||||
severityCounts[sev] = (severityCounts[sev] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const findingsHtml = findings.map(f => `
|
||||
<div class="finding severity-${f.severity.value}">
|
||||
<div class="finding-header">
|
||||
<span class="badge badge-${f.severity.value}">${f.severity.value.toUpperCase()}</span>
|
||||
<span class="finding-type">${f.type.value}</span>
|
||||
<span class="finding-status">${f.status.value}</span>
|
||||
</div>
|
||||
<p class="finding-desc">${escapeHtml(f.description)}</p>
|
||||
<small class="finding-meta">
|
||||
Session: ${f.sessionId} ·
|
||||
${new Date(f.createdAt).toLocaleString()}
|
||||
</small>
|
||||
</div>
|
||||
`).join('\n');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(report.title)}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; color: #1a1a1a; }
|
||||
h1 { font-size: 1.75rem; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
|
||||
.meta { color: #64748b; font-size: 0.875rem; margin-bottom: 2rem; }
|
||||
.stats { display: flex; gap: 1rem; margin: 1.5rem 0; }
|
||||
.stat-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.5rem; min-width: 100px; text-align: center; }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; }
|
||||
.stat-card .label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.finding { border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
||||
.finding-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 700; }
|
||||
.badge-critical { background: #fee2e2; color: #dc2626; }
|
||||
.badge-high { background: #ffedd5; color: #ea580c; }
|
||||
.badge-medium { background: #fef9c3; color: #ca8a04; }
|
||||
.badge-low { background: #dbeafe; color: #2563eb; }
|
||||
.finding-type { font-family: monospace; font-size: 0.8rem; color: #475569; }
|
||||
.finding-status { margin-left: auto; font-size: 0.75rem; color: #64748b; }
|
||||
.finding-desc { margin: 0.25rem 0; font-size: 0.9rem; }
|
||||
.finding-meta { color: #94a3b8; font-size: 0.75rem; }
|
||||
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.75rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeHtml(report.title)}</h1>
|
||||
<div class="meta">
|
||||
Generated by ABE · ${new Date().toLocaleString()}
|
||||
${report.filters.sessionId ? ` · Session: ${report.filters.sessionId}` : ''}
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="value">${findings.length}</div>
|
||||
<div class="label">Total</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" style="color:#dc2626">${severityCounts['critical'] ?? 0}</div>
|
||||
<div class="label">Critical</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" style="color:#ea580c">${severityCounts['high'] ?? 0}</div>
|
||||
<div class="label">High</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" style="color:#ca8a04">${severityCounts['medium'] ?? 0}</div>
|
||||
<div class="label">Medium</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" style="color:#2563eb">${severityCounts['low'] ?? 0}</div>
|
||||
<div class="label">Low</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Findings (${findings.length})</h2>
|
||||
${findings.length === 0 ? '<p style="color:#64748b">No findings match the selected filters.</p>' : findingsHtml}
|
||||
|
||||
<footer>Generated by ABE — Autonomous Bug Explorer</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const filePath = path.join(outputDir, `report.html`);
|
||||
fs.writeFileSync(filePath, html, 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IReportGenerator } from '../../domain/ports/IReportGenerator';
|
||||
import { Report } from '../../domain/entities/Report';
|
||||
import { Finding } from '../../../findings/domain/entities/Finding';
|
||||
|
||||
export class JSONReportGenerator implements IReportGenerator {
|
||||
async generate(report: Report, findings: Finding[]): Promise<string> {
|
||||
const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const data = {
|
||||
reportId: report.id.toString(),
|
||||
title: report.title,
|
||||
generatedAt: new Date().toISOString(),
|
||||
filters: report.filters,
|
||||
summary: {
|
||||
total: findings.length,
|
||||
bySeverity: buildSeverityCount(findings),
|
||||
byStatus: buildStatusCount(findings),
|
||||
},
|
||||
findings: findings.map(f => ({
|
||||
id: f.id.toString(),
|
||||
sessionId: f.sessionId,
|
||||
type: f.type.value,
|
||||
severity: f.severity.value,
|
||||
description: f.description,
|
||||
status: f.status.value,
|
||||
browser: f.browser,
|
||||
createdAt: f.createdAt.toISOString(),
|
||||
resolvedAt: f.resolvedAt?.toISOString() ?? null,
|
||||
evidence: f.evidence.toJSON(),
|
||||
actionTraceLength: f.actionTrace.length,
|
||||
})),
|
||||
};
|
||||
|
||||
const filePath = path.join(outputDir, 'report.json');
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeverityCount(findings: Finding[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const f of findings) {
|
||||
const s = f.severity.value;
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function buildStatusCount(findings: Finding[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const f of findings) {
|
||||
const s = f.status.value;
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { IReportGenerator } from '../../domain/ports/IReportGenerator';
|
||||
import { Report } from '../../domain/entities/Report';
|
||||
import { Finding } from '../../../findings/domain/entities/Finding';
|
||||
import { HTMLReportGenerator } from './HTMLReportGenerator';
|
||||
|
||||
/**
|
||||
* PDF report generator — uses Playwright to render the HTML report to PDF.
|
||||
* Requires Playwright + Chromium to be installed.
|
||||
*/
|
||||
export class PDFReportGenerator implements IReportGenerator {
|
||||
private readonly htmlGenerator = new HTMLReportGenerator();
|
||||
|
||||
async generate(report: Report, findings: Finding[]): Promise<string> {
|
||||
// First generate the HTML version
|
||||
const htmlPath = await this.htmlGenerator.generate(report, findings);
|
||||
const outputDir = path.dirname(htmlPath);
|
||||
const pdfPath = path.join(outputDir, 'report.pdf');
|
||||
|
||||
// Use Playwright to convert HTML to PDF
|
||||
let chromium;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pw = require('playwright') as typeof import('playwright');
|
||||
chromium = pw.chromium;
|
||||
} catch {
|
||||
throw new Error('Playwright not available — install playwright to generate PDF reports');
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const htmlContent = fs.readFileSync(htmlPath, 'utf8');
|
||||
await page.setContent(htmlContent, { waitUntil: 'networkidle' });
|
||||
await page.pdf({
|
||||
path: pdfPath,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' },
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return pdfPath;
|
||||
}
|
||||
}
|
||||
131
src/modules/reporting/infrastructure/http/ReportingController.ts
Normal file
131
src/modules/reporting/infrastructure/http/ReportingController.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { GenerateReportCommand } from '../../application/commands/GenerateReportCommand';
|
||||
import { IReportRepository } from '../../domain/ports/IReportRepository';
|
||||
import { IJobQueue } from '../../../../jobs/JobQueue';
|
||||
import { REPORT_JOB_TYPE, ReportJobPayload } from '../../../../jobs/workers/ReportWorker';
|
||||
|
||||
export interface ReportingControllerDeps {
|
||||
generateReport: GenerateReportCommand;
|
||||
reportRepository: IReportRepository;
|
||||
jobQueue: IJobQueue;
|
||||
}
|
||||
|
||||
export function createReportingRouter(deps: ReportingControllerDeps): Router {
|
||||
const router = Router();
|
||||
|
||||
// POST /api/reports — create and enqueue report
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const { title, format, filters } = req.body as {
|
||||
title?: string;
|
||||
format?: string;
|
||||
filters?: {
|
||||
sessionId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
severity?: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (!title || !format) {
|
||||
res.status(400).json({ error: 'title and format are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deps.generateReport.execute({
|
||||
title,
|
||||
format: format as 'html' | 'json' | 'pdf',
|
||||
filters: filters
|
||||
? {
|
||||
sessionId: filters.sessionId,
|
||||
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
|
||||
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
|
||||
severity: filters.severity,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Enqueue background job
|
||||
await deps.jobQueue.enqueue(REPORT_JOB_TYPE, {
|
||||
reportId: result.value.reportId,
|
||||
format: format as 'html' | 'json' | 'pdf',
|
||||
filters: filters as ReportJobPayload['filters'],
|
||||
});
|
||||
|
||||
res.status(201).json(result.value);
|
||||
});
|
||||
|
||||
// GET /api/reports — list all reports
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const reports = await deps.reportRepository.findAll();
|
||||
res.json(
|
||||
reports.map(r => ({
|
||||
id: r.id.toString(),
|
||||
title: r.title,
|
||||
format: r.format.value,
|
||||
status: r.status.value,
|
||||
totalFindings: r.totalFindings,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
completedAt: r.completedAt?.toISOString() ?? null,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
// GET /api/reports/:id — report detail
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const report = await deps.reportRepository.findById(req.params['id'] as string);
|
||||
if (!report) {
|
||||
res.status(404).json({ error: 'Report not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
id: report.id.toString(),
|
||||
title: report.title,
|
||||
format: report.format.value,
|
||||
status: report.status.value,
|
||||
filters: report.filters,
|
||||
totalFindings: report.totalFindings,
|
||||
errorMessage: report.errorMessage,
|
||||
createdAt: report.createdAt.toISOString(),
|
||||
completedAt: report.completedAt?.toISOString() ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/reports/:id/download — download the generated file
|
||||
router.get('/:id/download', async (req: Request, res: Response) => {
|
||||
const report = await deps.reportRepository.findById(req.params['id'] as string);
|
||||
if (!report) {
|
||||
res.status(404).json({ error: 'Report not found' });
|
||||
return;
|
||||
}
|
||||
if (report.status.value !== 'ready' || !report.filePath) {
|
||||
res.status(409).json({ error: 'Report is not ready yet', status: report.status.value });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(report.filePath)) {
|
||||
res.status(410).json({ error: 'Report file no longer exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(report.filePath);
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.json': 'application/json',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
const contentType = contentTypes[ext] ?? 'application/octet-stream';
|
||||
const filename = `report-${report.id.toString()}${ext}`;
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
fs.createReadStream(report.filePath).pipe(res);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database, ReportTable } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { IReportRepository } from '../../domain/ports/IReportRepository';
|
||||
import { Report, ReportProps, ReportFilters } from '../../domain/entities/Report';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { ReportFormat } from '../../domain/value-objects/ReportFormat';
|
||||
import { ReportStatus } from '../../domain/value-objects/ReportStatus';
|
||||
|
||||
export class KyselyReportRepository implements IReportRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(report: Report): Promise<void> {
|
||||
const row: ReportTable = {
|
||||
id: report.id.toString(),
|
||||
title: report.title,
|
||||
format: report.format.value,
|
||||
status: report.status.value,
|
||||
filters_json: JSON.stringify(report.filters),
|
||||
file_path: report.filePath ?? null,
|
||||
error_message: report.errorMessage ?? null,
|
||||
total_findings: report.totalFindings,
|
||||
created_at: report.createdAt.getTime(),
|
||||
completed_at: report.completedAt ? report.completedAt.getTime() : null,
|
||||
};
|
||||
await this.db.insertInto('reports').values(row).execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Report | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('reports')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Report[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('reports')
|
||||
.selectAll()
|
||||
.orderBy('created_at', 'desc')
|
||||
.execute();
|
||||
return rows.map(r => this.toDomain(r));
|
||||
}
|
||||
|
||||
async update(report: Report): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('reports')
|
||||
.set({
|
||||
status: report.status.value,
|
||||
file_path: report.filePath ?? null,
|
||||
error_message: report.errorMessage ?? null,
|
||||
total_findings: report.totalFindings,
|
||||
completed_at: report.completedAt ? report.completedAt.getTime() : null,
|
||||
})
|
||||
.where('id', '=', report.id.toString())
|
||||
.execute();
|
||||
}
|
||||
|
||||
private toDomain(row: ReportTable): Report {
|
||||
const filters = this.parseJson<ReportFilters>(row.filters_json, {});
|
||||
const props: ReportProps = {
|
||||
title: row.title,
|
||||
format: ReportFormat.fromString(row.format),
|
||||
status: ReportStatus.fromString(row.status),
|
||||
filters: {
|
||||
sessionId: filters.sessionId,
|
||||
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
|
||||
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
|
||||
severity: filters.severity,
|
||||
},
|
||||
filePath: row.file_path ?? undefined,
|
||||
errorMessage: row.error_message ?? undefined,
|
||||
totalFindings: row.total_findings,
|
||||
createdAt: new Date(row.created_at),
|
||||
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
|
||||
};
|
||||
return Report.reconstitute(props, UniqueId.from(row.id));
|
||||
}
|
||||
|
||||
private parseJson<T>(json: string, fallback: T): T {
|
||||
try { return JSON.parse(json) as T; } catch { return fallback; }
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,19 @@ export interface AuthSessionTable {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ReportTable {
|
||||
id: string;
|
||||
title: string;
|
||||
format: string;
|
||||
status: string;
|
||||
filters_json: string;
|
||||
file_path: string | null;
|
||||
error_message: string | null;
|
||||
total_findings: number;
|
||||
created_at: number;
|
||||
completed_at: number | null;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
sessions: SessionTable;
|
||||
states: StateTable;
|
||||
@@ -218,6 +231,7 @@ export interface Database {
|
||||
org_members: OrgMemberTable;
|
||||
api_keys: ApiKeyTable;
|
||||
auth_sessions: AuthSessionTable;
|
||||
reports: ReportTable;
|
||||
}
|
||||
|
||||
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
|
||||
|
||||
Reference in New Issue
Block a user