- Updated all component headers and documentation
- Changed navbar and footer branding
- Updated homepage hero badge
- Modified page title in index.html
- Simplified footer text to 'Built with ❤️'
- Consistent V2 capitalization across all references
336 lines
14 KiB
JavaScript
336 lines
14 KiB
JavaScript
"use strict";
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.ExportService = void 0;
|
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
const path_1 = __importDefault(require("path"));
|
|
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
const handlebars_1 = __importDefault(require("handlebars"));
|
|
const markdown_it_1 = __importDefault(require("markdown-it"));
|
|
const logger_1 = require("../lib/logger");
|
|
const prisma_1 = require("../lib/prisma");
|
|
class ExportService {
|
|
markdownRenderer;
|
|
constructor() {
|
|
this.markdownRenderer = new markdown_it_1.default();
|
|
this.initializeHandlebarsHelpers();
|
|
}
|
|
initializeHandlebarsHelpers() {
|
|
handlebars_1.default.registerHelper('formatDate', (date) => {
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
timeZoneName: 'short'
|
|
}).format(date);
|
|
});
|
|
handlebars_1.default.registerHelper('formatDuration', (ms) => {
|
|
if (ms < 1000)
|
|
return `${ms}ms`;
|
|
if (ms < 60000)
|
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
return `${(ms / 60000).toFixed(2)}m`;
|
|
});
|
|
handlebars_1.default.registerHelper('formatUrl', (url, maxLength = 80) => {
|
|
if (url.length <= maxLength)
|
|
return url;
|
|
return url.substring(0, maxLength - 3) + '...';
|
|
});
|
|
handlebars_1.default.registerHelper('statusBadge', (status) => {
|
|
const badges = {
|
|
'OK': '🟢 OK',
|
|
'ERROR': '🔴 ERROR',
|
|
'TIMEOUT': '🟡 TIMEOUT',
|
|
'LOOP': '🟣 LOOP'
|
|
};
|
|
return badges[status] || `⚪ ${status}`;
|
|
});
|
|
handlebars_1.default.registerHelper('redirectType', (type) => {
|
|
if (!type)
|
|
return '-';
|
|
return type.replace('HTTP_', '').replace('_', ' ');
|
|
});
|
|
handlebars_1.default.registerHelper('if_eq', function (a, b, options) {
|
|
if (typeof options !== 'object' || !options.fn) {
|
|
return false;
|
|
}
|
|
if (a === b) {
|
|
return options.fn(this);
|
|
}
|
|
return options.inverse ? options.inverse(this) : '';
|
|
});
|
|
handlebars_1.default.registerHelper('lt', function (a, b, options) {
|
|
if (typeof options !== 'object' || !options.fn) {
|
|
return false;
|
|
}
|
|
if (a < b) {
|
|
return options.fn(this);
|
|
}
|
|
return options.inverse ? options.inverse(this) : '';
|
|
});
|
|
handlebars_1.default.registerHelper('gte', function (a, b, options) {
|
|
if (typeof options !== 'object' || !options.fn) {
|
|
return false;
|
|
}
|
|
if (a >= b) {
|
|
return options.fn(this);
|
|
}
|
|
return options.inverse ? options.inverse(this) : '';
|
|
});
|
|
}
|
|
generateMermaidDiagram(hops) {
|
|
if (hops.length === 0) {
|
|
return 'graph TD\n A[No redirects found]';
|
|
}
|
|
let diagram = 'graph TD\n';
|
|
for (let i = 0; i < hops.length; i++) {
|
|
const hop = hops[i];
|
|
const nodeId = `H${i}`;
|
|
const nextNodeId = `H${i + 1}`;
|
|
const statusCode = hop.statusCode ? ` (${hop.statusCode})` : '';
|
|
const domain = new URL(hop.url).hostname;
|
|
diagram += ` ${nodeId}["${domain}${statusCode}"]\n`;
|
|
if (i < hops.length - 1) {
|
|
const redirectType = hop.redirectType ? hop.redirectType.replace('HTTP_', '').replace('_', ' ') : 'REDIRECT';
|
|
diagram += ` ${nodeId} -->|"${redirectType}"| ${nextNodeId}\n`;
|
|
}
|
|
if (hop.statusCode) {
|
|
if (hop.statusCode >= 300 && hop.statusCode < 400) {
|
|
diagram += ` ${nodeId} --> ${nodeId}\n`;
|
|
diagram += ` class ${nodeId} redirect\n`;
|
|
}
|
|
else if (hop.statusCode >= 400) {
|
|
diagram += ` class ${nodeId} error\n`;
|
|
}
|
|
else {
|
|
diagram += ` class ${nodeId} success\n`;
|
|
}
|
|
}
|
|
}
|
|
diagram += `
|
|
classDef redirect fill:#ffd700,stroke:#333,stroke-width:2px
|
|
classDef error fill:#ff6b6b,stroke:#333,stroke-width:2px
|
|
classDef success fill:#51cf66,stroke:#333,stroke-width:2px
|
|
`;
|
|
return diagram;
|
|
}
|
|
async getExportData(checkId, userId) {
|
|
const check = await prisma_1.prisma.check.findUnique({
|
|
where: { id: checkId },
|
|
include: {
|
|
hops: {
|
|
orderBy: { hopIndex: 'asc' }
|
|
},
|
|
sslInspections: true,
|
|
seoFlags: true,
|
|
securityFlags: true,
|
|
project: {
|
|
include: {
|
|
organization: {
|
|
include: {
|
|
memberships: userId ? {
|
|
include: {
|
|
user: true
|
|
},
|
|
where: { userId },
|
|
take: 1
|
|
} : true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
if (!check) {
|
|
throw new Error('Check not found');
|
|
}
|
|
const exportData = {
|
|
check: {
|
|
id: check.id,
|
|
inputUrl: check.inputUrl,
|
|
finalUrl: check.finalUrl || '',
|
|
method: check.method,
|
|
status: check.status,
|
|
totalTimeMs: check.totalTimeMs || 0,
|
|
startedAt: check.startedAt,
|
|
finishedAt: check.finishedAt || check.startedAt,
|
|
redirectCount: check.hops?.length - 1 || 0,
|
|
loopDetected: this.detectLoop(check.hops || []),
|
|
error: undefined,
|
|
hops: (check.hops || []).map((hop) => ({
|
|
hopIndex: hop.hopIndex,
|
|
url: hop.url,
|
|
statusCode: hop.statusCode || undefined,
|
|
redirectType: hop.redirectType,
|
|
latencyMs: hop.latencyMs || undefined,
|
|
contentType: hop.contentType || undefined,
|
|
reason: hop.reason || undefined,
|
|
}))
|
|
},
|
|
analysis: {
|
|
ssl: (check.sslInspections || []).map((ssl) => ({
|
|
host: ssl.host,
|
|
daysToExpiry: ssl.daysToExpiry || undefined,
|
|
issuer: ssl.issuer || undefined,
|
|
protocol: ssl.protocol || undefined,
|
|
warningsJson: Array.isArray(ssl.warningsJson) ? ssl.warningsJson : [],
|
|
})),
|
|
seo: check.seoFlags ? {
|
|
robotsTxtStatus: check.seoFlags.robotsTxtStatus,
|
|
noindex: check.seoFlags.noindex,
|
|
nofollow: check.seoFlags.nofollow,
|
|
sitemapPresent: check.seoFlags.sitemapPresent,
|
|
} : undefined,
|
|
security: check.securityFlags ? {
|
|
safeBrowsingStatus: check.securityFlags.safeBrowsingStatus,
|
|
mixedContent: check.securityFlags.mixedContent,
|
|
httpsToHttp: check.securityFlags.httpsToHttp,
|
|
} : undefined,
|
|
},
|
|
organization: {
|
|
name: check.project?.organization?.name || 'Unknown',
|
|
plan: check.project?.organization?.plan || 'free',
|
|
},
|
|
user: check.project?.organization?.memberships?.[0]?.user ? {
|
|
name: check.project.organization.memberships[0].user.name,
|
|
email: check.project.organization.memberships[0].user.email,
|
|
} : undefined,
|
|
generatedAt: new Date(),
|
|
};
|
|
return exportData;
|
|
}
|
|
detectLoop(hops) {
|
|
const urls = new Set();
|
|
for (const hop of hops) {
|
|
if (urls.has(hop.url)) {
|
|
return true;
|
|
}
|
|
urls.add(hop.url);
|
|
}
|
|
return false;
|
|
}
|
|
async generateMarkdownReport(checkId, userId) {
|
|
try {
|
|
logger_1.logger.info(`Generating Markdown report for check: ${checkId}`);
|
|
const data = await this.getExportData(checkId, userId);
|
|
const template = await this.loadTemplate('markdown-report.hbs');
|
|
const compiledTemplate = handlebars_1.default.compile(template);
|
|
const mermaidDiagram = this.generateMermaidDiagram(data.check.hops);
|
|
const templateData = { ...data, mermaidDiagram };
|
|
const markdown = compiledTemplate(templateData);
|
|
logger_1.logger.info(`Markdown report generated successfully for check: ${checkId}`);
|
|
return markdown;
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error(`Failed to generate Markdown report for check ${checkId}:`, error);
|
|
throw new Error(`Failed to generate Markdown report: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
async generatePdfReport(checkId, userId) {
|
|
let browser = null;
|
|
try {
|
|
logger_1.logger.info(`Generating PDF report for check: ${checkId}`);
|
|
const data = await this.getExportData(checkId, userId);
|
|
const htmlTemplate = await this.loadTemplate('pdf-report.hbs');
|
|
const compiledTemplate = handlebars_1.default.compile(htmlTemplate);
|
|
const mermaidDiagram = this.generateMermaidDiagram(data.check.hops);
|
|
const templateData = { ...data, mermaidDiagram };
|
|
const html = compiledTemplate(templateData);
|
|
browser = await puppeteer_1.default.launch({
|
|
headless: true,
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-accelerated-2d-canvas',
|
|
'--no-first-run',
|
|
'--no-zygote',
|
|
'--single-process',
|
|
'--disable-gpu'
|
|
]
|
|
});
|
|
const page = await browser.newPage();
|
|
await page.setContent(html, { waitUntil: 'networkidle0' });
|
|
await page.waitForFunction(() => document.querySelector('.mermaid svg') !== null, { timeout: 10000 }).catch(() => {
|
|
logger_1.logger.warn('Mermaid diagram may not have rendered completely');
|
|
});
|
|
const pdf = await page.pdf({
|
|
format: 'A4',
|
|
printBackground: true,
|
|
margin: {
|
|
top: '20mm',
|
|
right: '15mm',
|
|
bottom: '20mm',
|
|
left: '15mm'
|
|
}
|
|
});
|
|
await browser.close();
|
|
browser = null;
|
|
logger_1.logger.info(`PDF report generated successfully for check: ${checkId}`);
|
|
return pdf;
|
|
}
|
|
catch (error) {
|
|
if (browser) {
|
|
await browser.close().catch(() => { });
|
|
}
|
|
logger_1.logger.error(`Failed to generate PDF report for check ${checkId}:`, error);
|
|
throw new Error(`Failed to generate PDF report: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
async loadTemplate(templateName) {
|
|
const templatePath = path_1.default.join(__dirname, '../templates', templateName);
|
|
try {
|
|
return await promises_1.default.readFile(templatePath, 'utf-8');
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error(`Failed to load template: ${templateName}`, error);
|
|
throw new Error(`Template not found: ${templateName}`);
|
|
}
|
|
}
|
|
async saveReport(content, checkId, format) {
|
|
try {
|
|
const reportsDir = path_1.default.join(process.cwd(), 'reports');
|
|
await promises_1.default.mkdir(reportsDir, { recursive: true });
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const fileName = `redirect-report-${checkId}-${timestamp}.${format}`;
|
|
const filePath = path_1.default.join(reportsDir, fileName);
|
|
if (typeof content === 'string') {
|
|
await promises_1.default.writeFile(filePath, content, 'utf-8');
|
|
}
|
|
else {
|
|
await promises_1.default.writeFile(filePath, content);
|
|
}
|
|
logger_1.logger.info(`Report saved: ${filePath}`);
|
|
return filePath;
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error(`Failed to save report:`, error);
|
|
throw new Error(`Failed to save report: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
async cleanupOldReports(maxAgeHours = 24) {
|
|
try {
|
|
const reportsDir = path_1.default.join(process.cwd(), 'reports');
|
|
const files = await promises_1.default.readdir(reportsDir);
|
|
const cutoff = Date.now() - (maxAgeHours * 60 * 60 * 1000);
|
|
for (const file of files) {
|
|
const filePath = path_1.default.join(reportsDir, file);
|
|
const stats = await promises_1.default.stat(filePath);
|
|
if (stats.mtime.getTime() < cutoff) {
|
|
await promises_1.default.unlink(filePath);
|
|
logger_1.logger.info(`Cleaned up old report: ${file}`);
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.warn('Failed to cleanup old reports:', error);
|
|
}
|
|
}
|
|
}
|
|
exports.ExportService = ExportService;
|
|
//# sourceMappingURL=export.service.js.map
|