const http = require('http'); const url = require('url'); const puppeteer = require('puppeteer-core'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { marked } = require('marked'); const payments = require('./payments'); const x402 = require('./x402'); const PORT = process.env.PORT || 3001; const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome'; const API_KEYS_FILE = path.join(__dirname, 'api-keys.json'); const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute const MAX_CONCURRENT = 3; // Load or create API keys let apiKeys = {}; try { apiKeys = JSON.parse(fs.readFileSync(API_KEYS_FILE, 'utf8')); } catch {} // Generate a demo key if none exist if (Object.keys(apiKeys).length === 0) { const demoKey = 'demo_' + crypto.randomBytes(16).toString('hex'); apiKeys[demoKey] = { name: 'demo', tier: 'free', limit: 100, // per month used: 0, resetMonth: new Date().toISOString().slice(0, 7), created: new Date().toISOString(), }; fs.writeFileSync(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2)); console.log(`Demo API key created: ${demoKey}`); } // Rate limiting const rateLimits = new Map(); let activeTasks = 0; function checkRateLimit(apiKey) { const now = Date.now(); const entry = rateLimits.get(apiKey) || { count: 0, windowStart: now }; if (now - entry.windowStart > RATE_LIMIT_WINDOW) { entry.count = 0; entry.windowStart = now; } entry.count++; rateLimits.set(apiKey, entry); return entry.count <= 10; // 10 requests per minute } function resetMonthlyUsage(keyData) { const currentMonth = new Date().toISOString().slice(0, 7); if (keyData.resetMonth !== currentMonth) { keyData.used = 0; keyData.resetMonth = currentMonth; } } function sendJson(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify(data)); } function sendError(res, status, message) { sendJson(res, status, { error: message }); } // Authenticate via x402 micropayment or API key // Returns { ok: true, keyData, settleFn } or { ok: false } (response already sent) async function authenticate(req, res, parsed) { if (x402.hasX402Payment(req)) { const payResult = await x402.processPayment(req); if (!payResult.allowed) { const headers = { 'Access-Control-Allow-Origin': '*', ...payResult.headers }; res.writeHead(payResult.status, headers); res.end(payResult.isHtml ? payResult.body : JSON.stringify(payResult.body)); return { ok: false }; } return { ok: true, keyData: null, settleFn: payResult.settle }; } const apiKey = req.headers['x-api-key'] || parsed.query.api_key; if (!apiKey) { // No auth - return x402 payment requirements const payResult = await x402.processPayment(req); if (!payResult.allowed && payResult.status === 402) { const headers = { 'Access-Control-Allow-Origin': '*', ...payResult.headers }; res.writeHead(402, headers); res.end(payResult.isHtml ? payResult.body : JSON.stringify(payResult.body)); return { ok: false }; } sendError(res, 401, 'Auth required: X-API-Key header, ?api_key= param, or x402 Payment-Signature header.'); return { ok: false }; } if (!apiKeys[apiKey]) { sendError(res, 401, 'Invalid API key.'); return { ok: false }; } const keyData = apiKeys[apiKey]; resetMonthlyUsage(keyData); if (keyData.used >= keyData.limit) { sendError(res, 429, `Monthly limit reached (${keyData.limit}). Upgrade your plan.`); return { ok: false }; } if (!checkRateLimit(apiKey)) { sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.'); return { ok: false }; } return { ok: true, keyData, settleFn: null }; } // Analytics let stats = { total_captures: 0, screenshots: 0, pdfs: 0, md_conversions: 0, errors: 0 }; const ANALYTICS_FILE = path.join(__dirname, 'analytics.json'); let analytics = { daily: {}, total_views: 0 }; try { analytics = JSON.parse(fs.readFileSync(ANALYTICS_FILE, 'utf8')); } catch {} function trackPageView() { const today = new Date().toISOString().split('T')[0]; analytics.daily[today] = (analytics.daily[today] || 0) + 1; analytics.total_views++; if (analytics.total_views % 10 === 0) { fs.writeFile(ANALYTICS_FILE, JSON.stringify(analytics, null, 2), () => {}); } } // Read POST body function readBody(req, maxSize = 1024 * 1024) { return new Promise((resolve, reject) => { let body = ''; let size = 0; req.on('data', chunk => { size += chunk.length; if (size > maxSize) { reject(new Error('Body too large (max 1MB)')); req.destroy(); return; } body += chunk; }); req.on('end', () => resolve(body)); req.on('error', reject); }); } // Markdown-to-HTML with styling function renderMarkdownHTML(markdown, options = {}) { const htmlContent = marked(markdown); const theme = options.theme === 'dark' ? ` body { background: #1a1a2e; color: #e0e0e0; } a { color: #64b5f6; } code { background: #2d2d44; } pre { background: #2d2d44; } blockquote { border-left-color: #64b5f6; color: #b0b0b0; } table th { background: #2d2d44; } table td, table th { border-color: #3d3d54; } hr { border-color: #3d3d54; } ` : ` body { background: #ffffff; color: #24292f; } a { color: #0969da; } code { background: #f6f8fa; } pre { background: #f6f8fa; } blockquote { border-left-color: #d0d7de; color: #57606a; } table th { background: #f6f8fa; } table td, table th { border-color: #d0d7de; } hr { border-color: #d0d7de; } `; return ` ${htmlContent} `; } // Convert markdown to PDF or PNG using Puppeteer async function convertMarkdown(markdown, options = {}) { const html = renderMarkdownHTML(markdown, options); const browser = await puppeteer.launch({ executablePath: CHROME_PATH, headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--single-process'], }); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'load' }); let result; if (options.format === 'png' || options.format === 'jpeg') { const width = Math.min(Math.max(parseInt(options.viewportWidth) || 1280, 320), 3840); await page.setViewport({ width, height: 800 }); await page.setContent(html, { waitUntil: 'load' }); result = await page.screenshot({ type: options.format, fullPage: true, quality: options.format === 'jpeg' ? Math.min(parseInt(options.quality) || 85, 100) : undefined, }); } else { result = await page.pdf({ format: options.paperSize || 'A4', printBackground: true, margin: { top: options.marginTop || '20mm', bottom: options.marginBottom || '20mm', left: options.marginLeft || '15mm', right: options.marginRight || '15mm', }, landscape: options.landscape === true || options.landscape === 'true', }); } stats.md_conversions++; stats.total_captures++; return result; } finally { await browser.close(); } } async function captureScreenshot(targetUrl, options = {}) { const browser = await puppeteer.launch({ executablePath: CHROME_PATH, headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--single-process', ], }); try { const page = await browser.newPage(); const width = Math.min(Math.max(parseInt(options.width) || 1280, 320), 3840); const height = Math.min(Math.max(parseInt(options.height) || 800, 200), 2160); await page.setViewport({ width, height }); if (options.userAgent) { await page.setUserAgent(options.userAgent); } const timeout = Math.min(Math.max(parseInt(options.timeout) || 30000, 5000), 60000); await page.goto(targetUrl, { waitUntil: options.waitUntil || 'networkidle2', timeout, }); if (options.delay) { await new Promise(r => setTimeout(r, Math.min(parseInt(options.delay), 10000))); } let result; if (options.format === 'pdf') { result = await page.pdf({ format: options.paperSize || 'A4', printBackground: options.printBackground !== false, landscape: options.landscape === true || options.landscape === 'true', }); stats.pdfs++; } else { result = await page.screenshot({ type: options.imageType || 'png', fullPage: options.fullPage === true || options.fullPage === 'true', quality: options.imageType === 'jpeg' ? Math.min(parseInt(options.quality) || 80, 100) : undefined, }); stats.screenshots++; } stats.total_captures++; return result; } finally { await browser.close(); } } const server = http.createServer(async (req, res) => { // CORS preflight if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-API-Key, Payment-Signature, X-Payment', 'Access-Control-Expose-Headers': 'Payment-Required, Payment-Response', }); res.end(); return; } const parsed = url.parse(req.url, true); // Health/status endpoint (no auth required) if (parsed.pathname === '/api/status') { sendJson(res, 200, { service: 'screenshot-api', status: 'ok', stats, page_views: analytics.total_views, active_tasks: activeTasks, max_concurrent: MAX_CONCURRENT, timestamp: new Date().toISOString(), }); return; } // x402 discovery endpoint - machine-readable service catalog for agents if (parsed.pathname === '/x402') { sendJson(res, 200, { protocol: 'x402', version: '2.3', service: { name: 'SnapAPI', description: 'Screenshot & document generation API with x402 micropayments', provider: 'OpSpawn', website: 'https://opspawn.com', github: 'https://github.com/opspawn/screenshot-api', }, payment: { network: x402.NETWORK, token: 'USDC', facilitator: 'https://facilitator.payai.network', wallet: x402.WALLET_ADDRESS, }, endpoints: [ { method: 'GET', path: '/api/capture', description: 'Capture screenshot or PDF from a URL', price: x402.PRICES.capture, input: { url: { type: 'string', required: true, description: 'URL to capture' }, format: { type: 'string', required: false, description: 'png, jpeg, or pdf', default: 'png' }, width: { type: 'number', required: false, description: 'Viewport width (320-3840)', default: 1280 }, height: { type: 'number', required: false, description: 'Viewport height (200-2160)', default: 800 }, fullPage: { type: 'boolean', required: false, description: 'Capture full scrollable page' }, }, output: { type: 'binary', mimeTypes: ['image/png', 'image/jpeg', 'application/pdf'] }, }, { method: 'POST', path: '/api/md2pdf', description: 'Convert Markdown to PDF document', price: x402.PRICES.md2pdf, input: { markdown: { type: 'string', required: true, description: 'Markdown content' }, theme: { type: 'string', required: false, description: 'light or dark', default: 'light' }, paperSize: { type: 'string', required: false, description: 'A4, Letter, Legal, Tabloid', default: 'A4' }, }, output: { type: 'binary', mimeTypes: ['application/pdf'] }, }, { method: 'POST', path: '/api/md2png', description: 'Convert Markdown to PNG image', price: x402.PRICES.md2png, input: { markdown: { type: 'string', required: true, description: 'Markdown content' }, theme: { type: 'string', required: false, description: 'light or dark', default: 'light' }, width: { type: 'number', required: false, description: 'Viewport width', default: 800 }, }, output: { type: 'binary', mimeTypes: ['image/png', 'image/jpeg'] }, }, { method: 'POST', path: '/api/md2html', description: 'Convert Markdown to styled HTML (free, no payment required)', price: '$0.00', input: { markdown: { type: 'string', required: true, description: 'Markdown content' }, theme: { type: 'string', required: false, description: 'light or dark', default: 'light' }, }, output: { type: 'text', mimeTypes: ['text/html'] }, }, ], mcp: { description: 'Also available as MCP tools for Claude Code/Desktop', tools: ['capture_screenshot', 'markdown_to_pdf', 'markdown_to_image', 'markdown_to_html', 'api_status'], install: 'npx github:opspawn/screenshot-api/mcp-server.mjs', }, bazaar: { description: 'x402 Bazaar discovery extensions included in 402 responses', specification: 'https://github.com/x402/x402/tree/main/extensions/bazaar', }, }); return; } // API docs - JSON if (parsed.pathname === '/api') { sendJson(res, 200, { service: 'SnapAPI - Document Generation Suite', version: '3.0.0', description: 'Screenshots, PDFs, and Markdown conversion API with x402 micropayments', built_by: 'OpSpawn (AI Agent) - transparent about AI authorship', payment: { x402: { description: 'Pay-per-request via x402 protocol (no signup needed)', network: 'Base (USDC)', prices: x402.PRICES, protocol: 'https://x402.org', how: 'Send Payment-Signature header with signed USDC authorization', }, subscription: { description: 'Monthly subscription via USDC on Polygon', endpoint: 'POST /api/subscribe', }, }, endpoints: { 'GET /api/capture': { description: 'Capture a screenshot or PDF from a URL', auth: 'X-API-Key header, ?api_key= query param, OR x402 Payment-Signature header', x402_price: x402.PRICES.capture, params: { url: 'Target URL (required)', format: 'png (default), jpeg, or pdf', width: 'Viewport width (320-3840, default 1280)', height: 'Viewport height (200-2160, default 800)', fullPage: 'Capture full page (true/false)', delay: 'Wait ms after load (max 10000)', quality: 'JPEG quality (1-100, default 80)', paperSize: 'PDF paper size (A4, Letter, etc)', landscape: 'PDF landscape mode (true/false)', }, }, 'POST /api/md2pdf': { description: 'Convert Markdown to PDF', auth: 'X-API-Key header OR x402 Payment-Signature header', x402_price: x402.PRICES.md2pdf, body: 'JSON: { markdown, theme?, paperSize?, landscape?, fontSize?, margins? }', }, 'POST /api/md2png': { description: 'Convert Markdown to PNG image', auth: 'X-API-Key header OR x402 Payment-Signature header', x402_price: x402.PRICES.md2png, body: 'JSON: { markdown, theme?, width?, fontSize? }', }, 'POST /api/md2html': { description: 'Convert Markdown to styled HTML (no auth required)', body: 'JSON: { markdown, theme?, fontSize? }', }, 'GET /api/status': { description: 'Service health and stats' }, }, }); return; } // Favicon if (parsed.pathname === '/favicon.svg') { const faviconPath = path.join(__dirname, 'public', 'favicon.svg'); try { const data = fs.readFileSync(faviconPath); res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' }); res.end(data); } catch { res.writeHead(404); res.end('Not found'); } return; } // Landing page if (parsed.pathname === '/') { trackPageView(); const landingPage = fs.readFileSync(path.join(__dirname, 'landing.html'), 'utf8'); res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' }); res.end(landingPage); return; } // Capture endpoint if (parsed.pathname === '/api/capture') { // Auth: x402 micropayment OR API key const auth = await authenticate(req, res, parsed); if (!auth.ok) return; const { keyData, settleFn } = auth; // Concurrency limit if (activeTasks >= MAX_CONCURRENT) { return sendError(res, 503, 'Server busy. Try again in a few seconds.'); } // Validate URL const targetUrl = parsed.query.url; if (!targetUrl) { return sendError(res, 400, 'Missing required parameter: url'); } let parsedTarget; try { parsedTarget = new URL(targetUrl); } catch { return sendError(res, 400, 'Invalid URL format'); } // Block private/internal URLs const hostname = parsedTarget.hostname.toLowerCase(); if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0' || hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') || hostname === '::1' || hostname === 'metadata.google.internal') { return sendError(res, 400, 'Cannot capture internal/private URLs'); } if (!['http:', 'https:'].includes(parsedTarget.protocol)) { return sendError(res, 400, 'Only http and https URLs are supported'); } activeTasks++; try { const buffer = await captureScreenshot(targetUrl, { format: parsed.query.format, width: parsed.query.width, height: parsed.query.height, fullPage: parsed.query.fullPage, delay: parsed.query.delay, imageType: parsed.query.format === 'jpeg' ? 'jpeg' : 'png', quality: parsed.query.quality, paperSize: parsed.query.paperSize, landscape: parsed.query.landscape, printBackground: parsed.query.printBackground, waitUntil: parsed.query.waitUntil, timeout: parsed.query.timeout, userAgent: parsed.query.userAgent, }); if (keyData) { keyData.used++; fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {}); } const contentType = parsed.query.format === 'pdf' ? 'application/pdf' : parsed.query.format === 'jpeg' ? 'image/jpeg' : 'image/png'; const responseHeaders = { 'Content-Type': contentType, 'Content-Length': buffer.length, 'Access-Control-Allow-Origin': '*', }; if (keyData) { responseHeaders['X-Captures-Used'] = keyData.used; responseHeaders['X-Captures-Limit'] = keyData.limit; } // Settle x402 payment after successful capture if (settleFn) { const settleResult = await settleFn(); if (settleResult && settleResult.headers) { Object.assign(responseHeaders, settleResult.headers); } } res.writeHead(200, responseHeaders); res.end(buffer); } catch (err) { stats.errors++; sendError(res, 500, 'Capture failed: ' + err.message); } finally { activeTasks--; } return; } // Markdown to HTML (no auth required - lightweight) if (parsed.pathname === '/api/md2html' && req.method === 'POST') { try { const body = JSON.parse(await readBody(req)); if (!body.markdown) { return sendError(res, 400, 'Missing required field: markdown'); } const html = renderMarkdownHTML(body.markdown, { theme: body.theme, fontSize: body.fontSize, width: body.width, }); res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' }); res.end(html); } catch (err) { sendError(res, 400, 'Invalid JSON body: ' + err.message); } return; } // Markdown to PDF (auth required - uses Puppeteer) if (parsed.pathname === '/api/md2pdf' && req.method === 'POST') { const auth = await authenticate(req, res, parsed); if (!auth.ok) return; const { keyData, settleFn } = auth; if (activeTasks >= MAX_CONCURRENT) { return sendError(res, 503, 'Server busy. Try again in a few seconds.'); } try { const body = JSON.parse(await readBody(req)); if (!body.markdown) { return sendError(res, 400, 'Missing required field: markdown'); } activeTasks++; try { const buffer = await convertMarkdown(body.markdown, { format: 'pdf', theme: body.theme, paperSize: body.paperSize, landscape: body.landscape, fontSize: body.fontSize, marginTop: body.margins?.top, marginBottom: body.margins?.bottom, marginLeft: body.margins?.left, marginRight: body.margins?.right, }); if (keyData) { keyData.used++; fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {}); } const responseHeaders = { 'Content-Type': 'application/pdf', 'Content-Length': buffer.length, 'Content-Disposition': 'inline; filename="document.pdf"', 'Access-Control-Allow-Origin': '*', }; if (keyData) { responseHeaders['X-Captures-Used'] = keyData.used; responseHeaders['X-Captures-Limit'] = keyData.limit; } if (settleFn) { const settleResult = await settleFn(); if (settleResult && settleResult.headers) { Object.assign(responseHeaders, settleResult.headers); } } res.writeHead(200, responseHeaders); res.end(buffer); } finally { activeTasks--; } } catch (err) { stats.errors++; sendError(res, err.message.includes('JSON') ? 400 : 500, err.message); } return; } // Markdown to PNG (auth required - uses Puppeteer) if (parsed.pathname === '/api/md2png' && req.method === 'POST') { const auth = await authenticate(req, res, parsed); if (!auth.ok) return; const { keyData, settleFn } = auth; if (activeTasks >= MAX_CONCURRENT) { return sendError(res, 503, 'Server busy. Try again in a few seconds.'); } try { const body = JSON.parse(await readBody(req)); if (!body.markdown) { return sendError(res, 400, 'Missing required field: markdown'); } activeTasks++; try { const buffer = await convertMarkdown(body.markdown, { format: body.format === 'jpeg' ? 'jpeg' : 'png', theme: body.theme, viewportWidth: body.width, fontSize: body.fontSize, quality: body.quality, }); if (keyData) { keyData.used++; fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {}); } const imgType = body.format === 'jpeg' ? 'jpeg' : 'png'; const responseHeaders = { 'Content-Type': `image/${imgType}`, 'Content-Length': buffer.length, 'Content-Disposition': `inline; filename="document.${imgType}"`, 'Access-Control-Allow-Origin': '*', }; if (keyData) { responseHeaders['X-Captures-Used'] = keyData.used; responseHeaders['X-Captures-Limit'] = keyData.limit; } if (settleFn) { const settleResult = await settleFn(); if (settleResult && settleResult.headers) { Object.assign(responseHeaders, settleResult.headers); } } res.writeHead(200, responseHeaders); res.end(buffer); } finally { activeTasks--; } } catch (err) { stats.errors++; sendError(res, err.message.includes('JSON') ? 400 : 500, err.message); } return; } // --- Payment Endpoints --- // Create subscription invoice if (parsed.pathname === '/api/subscribe' && req.method === 'POST') { try { const body = JSON.parse(await readBody(req)); const plan = body.plan || 'pro'; const email = body.email || null; const invoice = payments.createInvoice(plan, email); sendJson(res, 201, { invoice_id: invoice.id, plan: invoice.plan, amount: invoice.amount, token: 'USDC', network: 'Polygon', wallet: invoice.wallet, expires_at: invoice.expires_at, instructions: `Send exactly ${invoice.amount} USDC to ${invoice.wallet} on Polygon network. The unique amount helps us identify your payment.`, check_url: `/api/subscribe/${invoice.id}` }); } catch (err) { sendError(res, 400, err.message); } return; } // Check invoice status if (parsed.pathname.startsWith('/api/subscribe/') && req.method === 'GET') { const invoiceId = parsed.pathname.split('/api/subscribe/')[1]; if (!invoiceId) return sendError(res, 400, 'Missing invoice ID'); try { const invoice = await payments.checkPayment(invoiceId); if (!invoice) return sendError(res, 404, 'Invoice not found'); const response = { invoice_id: invoice.id, status: invoice.status, plan: invoice.plan, amount: invoice.amount }; if (invoice.status === 'paid') { response.api_key = invoice.api_key; response.tx_hash = invoice.tx_hash; response.message = 'Payment confirmed! Your API key is ready to use.'; // Also register the key in apiKeys if (invoice.api_key && !apiKeys[invoice.api_key]) { const planData = payments.PLANS[invoice.plan]; apiKeys[invoice.api_key] = { name: invoice.email || invoice.plan, tier: invoice.plan, limit: planData.limit, used: 0, resetMonth: new Date().toISOString().slice(0, 7), created: new Date().toISOString() }; fs.writeFileSync(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2)); } } else if (invoice.status === 'pending') { response.wallet = invoice.wallet; response.network = 'Polygon'; response.message = `Waiting for ${invoice.amount} USDC payment to ${invoice.wallet}`; } else if (invoice.status === 'expired') { response.message = 'Invoice expired. Create a new one at POST /api/subscribe'; } sendJson(res, 200, response); } catch (err) { sendError(res, 500, err.message); } return; } // List plans/pricing if (parsed.pathname === '/api/pricing') { sendJson(res, 200, { plans: Object.entries(payments.PLANS).map(([id, plan]) => ({ id, name: plan.name, price: plan.price, currency: 'USDC', network: 'Polygon', limit: plan.limit, period: plan.period, features: id === 'pro' ? ['1,000 captures/month', 'All formats (PNG, JPEG, PDF)', 'Markdown conversion', 'Priority support'] : ['10,000 captures/month', 'All formats (PNG, JPEG, PDF)', 'Markdown conversion', 'Priority support', 'Higher rate limits'] })), wallet: payments.WALLET_ADDRESS, subscribe_url: 'POST /api/subscribe' }); return; } sendError(res, 404, 'Not found. Visit / for API documentation.'); }); server.listen(PORT, async () => { console.log(`Screenshot API running at http://localhost:${PORT}`); console.log(`API keys: ${Object.keys(apiKeys).length} configured`); // Start payment polling (check every 30 seconds) payments.startPolling(30000); // Initialize x402 micropayment system try { await x402.initX402(); } catch (err) { console.error('[x402] Failed to initialize:', err.message); console.log('[x402] API key auth still works. x402 payments disabled.'); } });