UNPKG

@helptheweb/accessibility-engine

Version:

Custom accessibility testing engine for WCAG compliance

487 lines (423 loc) 14.4 kB
import express from 'express'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; import { JSDOM, VirtualConsole } from 'jsdom'; import fetch from 'node-fetch'; import createAccessibilityEngine from '../index.js'; import { getAllRules, getRuleById } from '../rules/index.js'; // Create a testUrl function that mimics the CLI behavior async function testUrl(url, options = {}) { console.log(`testUrl: Starting scan for ${url}`); try { // Fetch the HTML with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), options.timeout || 30000); console.log(`testUrl: Fetching ${url}...`); const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'HelpTheWeb Accessibility Engine API/1.0' } }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`); } console.log(`testUrl: Got response, reading HTML...`); const html = await response.text(); console.log(`testUrl: HTML length: ${html.length} characters`); // Create DOM const virtualConsole = new VirtualConsole(); if (options.silent) { virtualConsole.on('error', () => {}); virtualConsole.on('warn', () => {}); virtualConsole.on('info', () => {}); virtualConsole.on('jsdomError', () => {}); } console.log(`testUrl: Creating JSDOM...`); const dom = new JSDOM(html, { url: url, contentType: 'text/html', includeNodeLocations: true, storageQuota: 10000000, pretendToBeVisual: true, resources: undefined, // Don't load external resources runScripts: undefined, // Don't run any scripts virtualConsole }); // Set up globals const window = dom.window; const document = window.document; global.window = window; global.document = document; global.navigator = window.navigator; global.getComputedStyle = window.getComputedStyle.bind(window); global.Element = window.Element; global.Node = window.Node; try { console.log(`testUrl: Waiting for DOM ready...`); // Wait for DOM to be ready with timeout await Promise.race([ new Promise(resolve => { if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', resolve, { once: true }); } else { resolve(); } }), new Promise((_, reject) => setTimeout(() => reject(new Error('DOM ready timeout')), 5000) ) ]); console.log(`testUrl: Creating accessibility engine...`); // For large sites, use more conservative settings const isLargeSite = html.length > 100000; const quickScan = options.quickScan || isLargeSite; const maxElements = quickScan ? 50 : (options.maxElements || 1000); console.log(`testUrl: Site size: ${isLargeSite ? 'LARGE' : 'normal'}, quickScan: ${quickScan}, maxElements: ${maxElements}`); // Create and configure engine const engine = createAccessibilityEngine({ runOnly: options.rules || (quickScan ? 'wcag22a' : 'wcag22aa'), resultTypes: ['violations'], maxElements: maxElements, timeout: 10000, // Shorter timeout for the engine itself silent: options.silent !== false }); console.log(`testUrl: Running accessibility tests...`); // Run tests with timeout const results = await Promise.race([ engine.run(document), new Promise((_, reject) => setTimeout(() => reject(new Error('Accessibility test timeout')), 10000) ) ]); console.log(`testUrl: Tests complete, found ${results.violations?.length || 0} violations`); return results; } finally { // Clean up globals delete global.window; delete global.document; delete global.navigator; delete global.getComputedStyle; delete global.Element; delete global.Node; // Close JSDOM window.close(); } } catch (error) { console.error(`testUrl: Error during scan:`, error.message); // For timeout errors, return partial results if (error.message.includes('timeout')) { console.log('testUrl: Returning empty results due to timeout'); return { violations: [], passes: [], incomplete: [], inapplicable: [] }; } throw error; } } const app = express(); const PORT = process.env.PORT || 3000; const API_VERSION = 'v1'; // Middleware app.use(cors()); app.use(express.json()); // Rate limiting configuration const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again later.', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers // Skip rate limiting for trusted sources skip: (req) => { // Skip rate limiting for localhost if (req.ip === '127.0.0.1' || req.ip === '::1') { return true; } // Skip rate limiting for specific IPs (add your server IPs here) const trustedIPs = process.env.TRUSTED_IPS ? process.env.TRUSTED_IPS.split(',') : []; if (trustedIPs.includes(req.ip)) { return true; } // Skip rate limiting for internal API keys const apiKey = req.headers['x-api-key']; const internalApiKey = process.env.INTERNAL_API_KEY; if (internalApiKey && apiKey === internalApiKey) { return true; } // Skip rate limiting based on origin (for your own websites) const origin = req.headers.origin || req.headers.referer; const defaultTrustedOrigins = [ 'https://helptheweb.org', 'https://www.helptheweb.org', 'https://app.helptheweb.org', 'http://localhost:3000', 'http://localhost:5173' // Vite dev server ]; // Add any additional trusted origins from environment variable const additionalOrigins = process.env.TRUSTED_ORIGINS ? process.env.TRUSTED_ORIGINS.split(',') : []; const trustedOrigins = [...defaultTrustedOrigins, ...additionalOrigins]; if (origin && trustedOrigins.some(trusted => origin.startsWith(trusted))) { return true; } return false; } }); // Apply rate limiting to all API routes app.use(`/api/${API_VERSION}/`, limiter); // Health check app.get('/health', (req, res) => { res.json({ status: 'healthy', version: API_VERSION, timestamp: new Date().toISOString() }); }); // API version info app.get(`/api/${API_VERSION}`, (req, res) => { res.json({ version: API_VERSION, endpoints: { scan: `POST /api/${API_VERSION}/scan`, scanBatch: `POST /api/${API_VERSION}/scan/batch`, scanSitemap: `POST /api/${API_VERSION}/scan/sitemap`, rules: `GET /api/${API_VERSION}/rules`, ruleDetails: `GET /api/${API_VERSION}/rules/:ruleId`, health: 'GET /health' } }); }); // Test endpoint for debugging app.post(`/api/${API_VERSION}/test`, (req, res) => { console.log('Test endpoint hit'); console.log('Request body:', req.body); res.json({ message: 'Test successful', received: req.body, timestamp: new Date().toISOString() }); }); // Single URL scan app.post(`/api/${API_VERSION}/scan`, async (req, res) => { const { url, options = {} } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required', example: { url: 'https://example.com', options: { maxElements: 1000 } } }); } try { console.log(`Scanning ${url}...`); const startTime = Date.now(); const results = await testUrl(url, { maxElements: options.maxElements || 1000, timeout: options.timeout || 30000, silent: true, rules: options.rules, // optional rule filtering quickScan: options.quickScan // NEW: option for faster scans on large sites }); const endTime = Date.now(); const scanDuration = endTime - startTime; // Generate summary const summary = { totalIssues: results.violations.length, critical: results.violations.filter(v => v.impact === 'critical').length, serious: results.violations.filter(v => v.impact === 'serious').length, moderate: results.violations.filter(v => v.impact === 'moderate').length, minor: results.violations.filter(v => v.impact === 'minor').length }; res.json({ url, timestamp: new Date().toISOString(), scanDuration: `${scanDuration}ms`, summary, violations: results.violations.map(v => ({ rule: v.id, impact: v.impact, message: v.help, explanation: v.description, selector: v.nodes?.[0]?.target?.[0] || 'N/A', html: v.nodes?.[0]?.html || 'N/A', howToFix: v.nodes?.[0]?.failureSummary || v.help })) }); } catch (error) { console.error('Scan error:', error); res.status(500).json({ error: 'Failed to scan URL', message: error.message }); } }); // Batch URL scan app.post(`/api/${API_VERSION}/scan/batch`, async (req, res) => { const { urls, options = {} } = req.body; if (!urls || !Array.isArray(urls)) { return res.status(400).json({ error: 'URLs array is required', example: { urls: ['https://example.com', 'https://example.com/about'] } }); } if (urls.length > 10) { return res.status(400).json({ error: 'Maximum 10 URLs allowed per batch request' }); } try { const results = []; for (const url of urls) { console.log(`Batch scanning ${url}...`); try { const scanResult = await testUrl(url, { maxElements: options.maxElements || 1000, timeout: options.timeout || 30000, silent: true }); results.push({ url, status: 'success', summary: { totalIssues: scanResult.violations.length, critical: scanResult.violations.filter(v => v.impact === 'critical').length, serious: scanResult.violations.filter(v => v.impact === 'serious').length, moderate: scanResult.violations.filter(v => v.impact === 'moderate').length, minor: scanResult.violations.filter(v => v.impact === 'minor').length }, violations: scanResult.violations.map(v => ({ rule: v.id, impact: v.impact, message: v.help, selector: v.nodes?.[0]?.target?.[0] || 'N/A' })) }); } catch (error) { results.push({ url, status: 'error', error: error.message }); } } res.json({ timestamp: new Date().toISOString(), totalUrls: urls.length, successful: results.filter(r => r.status === 'success').length, failed: results.filter(r => r.status === 'error').length, results }); } catch (error) { console.error('Batch scan error:', error); res.status(500).json({ error: 'Failed to complete batch scan', message: error.message }); } }); // Sitemap scan app.post(`/api/${API_VERSION}/scan/sitemap`, async (req, res) => { const { sitemapUrl, options = {} } = req.body; if (!sitemapUrl) { return res.status(400).json({ error: 'Sitemap URL is required', example: { sitemapUrl: 'https://example.com/sitemap.xml' } }); } try { // Import the sitemap parser const { getUrlsFromSitemap } = await import('../utils/sitemap-parser.js'); console.log(`Fetching sitemap from ${sitemapUrl}...`); const urls = await getUrlsFromSitemap(sitemapUrl); const maxUrls = options.maxUrls || 10; const urlsToScan = urls.slice(0, maxUrls); res.json({ message: `Found ${urls.length} URLs in sitemap. Scanning first ${urlsToScan.length} URLs...`, totalUrlsFound: urls.length, urlsToScan: urlsToScan.length, status: 'processing', note: 'Use the batch endpoint with these URLs for actual scanning', urls: urlsToScan }); } catch (error) { console.error('Sitemap scan error:', error); res.status(500).json({ error: 'Failed to process sitemap', message: error.message }); } }); // Get all rules app.get(`/api/${API_VERSION}/rules`, (req, res) => { const rules = getAllRules(); const { tag, impact } = req.query; let filteredRules = rules; if (tag) { filteredRules = filteredRules.filter(rule => rule.tags.includes(tag) ); } if (impact) { filteredRules = filteredRules.filter(rule => rule.impact === impact ); } res.json({ total: filteredRules.length, filters: { tag, impact }, rules: filteredRules.map(rule => ({ id: rule.id, tags: rule.tags, impact: rule.impact, explanation: rule.explanation })) }); }); // Get specific rule app.get(`/api/${API_VERSION}/rules/:ruleId`, (req, res) => { const { ruleId } = req.params; const rule = getRuleById(ruleId); if (!rule) { return res.status(404).json({ error: `Rule '${ruleId}' not found` }); } res.json({ id: rule.id, selector: rule.selector, tags: rule.tags, impact: rule.impact, explanation: rule.explanation, evaluate: rule.evaluate.toString() // Show the evaluation logic }); }); // 404 handler app.use((req, res) => { res.status(404).json({ error: 'Endpoint not found', availableVersions: [API_VERSION], documentation: `/api/${API_VERSION}` }); }); // Error handler app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: 'Something went wrong!', message: process.env.NODE_ENV === 'development' ? err.message : undefined }); }); // Start server export function startServer() { app.listen(PORT, () => { console.log(`HelpTheWeb Accessibility API Server`); console.log(`Version: ${API_VERSION}`); console.log(`Listening on port ${PORT}`); console.log(`Health check: http://localhost:${PORT}/health`); console.log(`API endpoints: http://localhost:${PORT}/api/${API_VERSION}`); }); } // Export app for testing export { app };