283 lines
No EOL
6.1 KiB
TypeScript
283 lines
No EOL
6.1 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
import { Queue } from 'bullmq';
|
|
import Redis from 'ioredis';
|
|
import { prisma } from '../utils/prisma';
|
|
import { authenticate } from '../middleware/auth';
|
|
import { getWorkerConfig } from '@wcag-ada/config';
|
|
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
|
|
|
|
const scanRoutes = new Hono();
|
|
|
|
// Get worker config for queue connection
|
|
const workerConfig = getWorkerConfig();
|
|
|
|
// Create queue connection
|
|
const scanQueue = new Queue('accessibility-scans', {
|
|
connection: new Redis({
|
|
host: workerConfig.redis.host,
|
|
port: workerConfig.redis.port,
|
|
password: workerConfig.redis.password,
|
|
db: workerConfig.redis.db,
|
|
}),
|
|
});
|
|
|
|
// Apply authentication to all routes
|
|
scanRoutes.use('*', authenticate);
|
|
|
|
// Validation schemas
|
|
const createScanSchema = z.object({
|
|
websiteId: z.string(),
|
|
url: z.string().url().optional(),
|
|
options: z.object({
|
|
wcagLevel: z.object({
|
|
level: z.enum(['A', 'AA', 'AAA']),
|
|
version: z.enum(['2.0', '2.1', '2.2']),
|
|
}).optional(),
|
|
viewport: z.object({
|
|
width: z.number(),
|
|
height: z.number(),
|
|
deviceScaleFactor: z.number().optional(),
|
|
isMobile: z.boolean().optional(),
|
|
}).optional(),
|
|
includeScreenshots: z.boolean().optional(),
|
|
excludeSelectors: z.array(z.string()).optional(),
|
|
waitForSelector: z.string().optional(),
|
|
timeout: z.number().optional(),
|
|
}).optional(),
|
|
});
|
|
|
|
// Start a new scan
|
|
scanRoutes.post('/', zValidator('json', createScanSchema), async (c) => {
|
|
const userId = c.get('userId');
|
|
const { websiteId, url, options } = c.req.valid('json');
|
|
|
|
// Get website
|
|
const website = await prisma.website.findFirst({
|
|
where: {
|
|
id: websiteId,
|
|
userId,
|
|
active: true,
|
|
},
|
|
});
|
|
|
|
if (!website) {
|
|
return c.json({ error: 'Website not found' }, 404);
|
|
}
|
|
|
|
// Merge options with website defaults
|
|
const scanOptions: AccessibilityScanOptions = {
|
|
url: url || website.url,
|
|
...website.scanOptions as any,
|
|
...options,
|
|
authenticate: website.authConfig as any,
|
|
};
|
|
|
|
// Create scan job
|
|
const job = await prisma.scanJob.create({
|
|
data: {
|
|
websiteId,
|
|
userId,
|
|
url: scanOptions.url,
|
|
options: scanOptions as any,
|
|
status: 'PENDING',
|
|
},
|
|
});
|
|
|
|
// Queue the scan
|
|
await scanQueue.add('scan', {
|
|
jobId: job.id,
|
|
websiteId,
|
|
userId,
|
|
options: scanOptions,
|
|
}, {
|
|
jobId: job.id,
|
|
});
|
|
|
|
return c.json({
|
|
job: {
|
|
id: job.id,
|
|
status: job.status,
|
|
url: job.url,
|
|
scheduledAt: job.scheduledAt,
|
|
},
|
|
message: 'Scan queued successfully',
|
|
}, 202);
|
|
});
|
|
|
|
// Get scan status
|
|
scanRoutes.get('/:id', async (c) => {
|
|
const userId = c.get('userId');
|
|
const scanId = c.req.param('id');
|
|
|
|
const job = await prisma.scanJob.findFirst({
|
|
where: {
|
|
id: scanId,
|
|
userId,
|
|
},
|
|
include: {
|
|
result: {
|
|
select: {
|
|
id: true,
|
|
summary: true,
|
|
wcagCompliance: true,
|
|
createdAt: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!job) {
|
|
return c.json({ error: 'Scan not found' }, 404);
|
|
}
|
|
|
|
return c.json(job);
|
|
});
|
|
|
|
// Get scan result
|
|
scanRoutes.get('/:id/result', async (c) => {
|
|
const userId = c.get('userId');
|
|
const scanId = c.req.param('id');
|
|
|
|
const result = await prisma.scanResult.findFirst({
|
|
where: {
|
|
jobId: scanId,
|
|
job: {
|
|
userId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!result) {
|
|
return c.json({ error: 'Scan result not found' }, 404);
|
|
}
|
|
|
|
return c.json(result);
|
|
});
|
|
|
|
// Get scan violations
|
|
scanRoutes.get('/:id/violations', async (c) => {
|
|
const userId = c.get('userId');
|
|
const scanId = c.req.param('id');
|
|
const { impact, tag } = c.req.query();
|
|
|
|
const result = await prisma.scanResult.findFirst({
|
|
where: {
|
|
jobId: scanId,
|
|
job: {
|
|
userId,
|
|
},
|
|
},
|
|
select: {
|
|
violations: true,
|
|
},
|
|
});
|
|
|
|
if (!result) {
|
|
return c.json({ error: 'Scan result not found' }, 404);
|
|
}
|
|
|
|
let violations = result.violations as any[];
|
|
|
|
// Filter by impact if provided
|
|
if (impact) {
|
|
violations = violations.filter(v => v.impact === impact);
|
|
}
|
|
|
|
// Filter by tag if provided
|
|
if (tag) {
|
|
violations = violations.filter(v => v.tags.includes(tag));
|
|
}
|
|
|
|
return c.json({ violations });
|
|
});
|
|
|
|
// Cancel a pending scan
|
|
scanRoutes.delete('/:id', async (c) => {
|
|
const userId = c.get('userId');
|
|
const scanId = c.req.param('id');
|
|
|
|
const job = await prisma.scanJob.findFirst({
|
|
where: {
|
|
id: scanId,
|
|
userId,
|
|
status: 'PENDING',
|
|
},
|
|
});
|
|
|
|
if (!job) {
|
|
return c.json({ error: 'Scan not found or cannot be cancelled' }, 404);
|
|
}
|
|
|
|
// Update job status
|
|
await prisma.scanJob.update({
|
|
where: { id: scanId },
|
|
data: {
|
|
status: 'FAILED',
|
|
error: 'Cancelled by user',
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Remove from queue if possible
|
|
const queueJob = await scanQueue.getJob(scanId);
|
|
if (queueJob) {
|
|
await queueJob.remove();
|
|
}
|
|
|
|
return c.json({ message: 'Scan cancelled successfully' });
|
|
});
|
|
|
|
// Get user's scan history
|
|
scanRoutes.get('/', async (c) => {
|
|
const userId = c.get('userId');
|
|
const { page = '1', limit = '20', status, websiteId } = c.req.query();
|
|
|
|
const pageNum = parseInt(page);
|
|
const limitNum = parseInt(limit);
|
|
const skip = (pageNum - 1) * limitNum;
|
|
|
|
const where = {
|
|
userId,
|
|
...(status && { status }),
|
|
...(websiteId && { websiteId }),
|
|
};
|
|
|
|
const [jobs, total] = await Promise.all([
|
|
prisma.scanJob.findMany({
|
|
where,
|
|
skip,
|
|
take: limitNum,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
website: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
url: true,
|
|
},
|
|
},
|
|
result: {
|
|
select: {
|
|
id: true,
|
|
summary: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
prisma.scanJob.count({ where }),
|
|
]);
|
|
|
|
return c.json({
|
|
scans: jobs,
|
|
pagination: {
|
|
page: pageNum,
|
|
limit: limitNum,
|
|
total,
|
|
totalPages: Math.ceil(total / limitNum),
|
|
},
|
|
});
|
|
});
|
|
|
|
export { scanRoutes }; |