const express = require('express'); const cors = require('cors'); const initSqlJs = require('sql.js'); const fs = require('fs'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const API_KEY = process.env.API_KEY || null; const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'https://chatlabsai.com').split(','); // Database setup const dataDir = process.env.DATA_DIR || path.join(__dirname, 'data'); const dbPath = path.join(dataDir, 'forms.db'); let db; // Ensure data directory exists if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } // Save database to file periodically function saveDb() { if (db) { const data = db.export(); fs.writeFileSync(dbPath, Buffer.from(data)); } } // Initialize database async function initDb() { const SQL = await initSqlJs(); // Load existing database or create new one if (fs.existsSync(dbPath)) { const fileBuffer = fs.readFileSync(dbPath); db = new SQL.Database(fileBuffer); } else { db = new SQL.Database(); } // Create tables db.run(` CREATE TABLE IF NOT EXISTS submissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, form_name TEXT NOT NULL, email TEXT NOT NULL, source TEXT, ip TEXT, user_agent TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); db.run(`CREATE INDEX IF NOT EXISTS idx_form_name ON submissions(form_name)`); db.run(`CREATE INDEX IF NOT EXISTS idx_email ON submissions(email)`); // Migration: add message column for free-text submissions (e.g. review intercept) const columns = []; const pragma = db.prepare(`PRAGMA table_info(submissions)`); while (pragma.step()) columns.push(pragma.getAsObject().name); pragma.free(); if (!columns.includes('message')) { db.run(`ALTER TABLE submissions ADD COLUMN message TEXT`); } saveDb(); // Auto-save every 30 seconds setInterval(saveDb, 30000); } // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cors({ origin: (origin, callback) => { if (!origin) return callback(null, true); if (ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('*')) { return callback(null, true); } return callback(new Error('Not allowed by CORS')); }, methods: ['GET', 'POST', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'] })); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Submit form app.post('/submit', (req, res) => { try { const { email, form_name = 'default', source = null } = req.body; let { message = null } = req.body; if (message != null) { message = String(message).trim().slice(0, 10000) || null; } // Either a valid email or a free-text message is required if (!email && !message) { return res.status(400).json({ error: 'Email or message is required' }); } if (email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.status(400).json({ error: 'Invalid email format' }); } } const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress; const userAgent = req.headers['user-agent'] || null; db.run( `INSERT INTO submissions (form_name, email, source, ip, user_agent, message, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`, [form_name, email ? email.toLowerCase().trim() : '', source, ip, userAgent, message] ); saveDb(); res.json({ success: true, message: 'Submission received' }); } catch (error) { console.error('Submit error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Auth middleware const requireAuth = (req, res, next) => { if (!API_KEY) return next(); const authHeader = req.headers.authorization; if (!authHeader || authHeader !== `Bearer ${API_KEY}`) { return res.status(401).json({ error: 'Unauthorized' }); } next(); }; // List submissions app.get('/emails', requireAuth, (req, res) => { try { const { form_name, limit = 100, offset = 0 } = req.query; let query = 'SELECT * FROM submissions'; const params = []; if (form_name) { query += ' WHERE form_name = ?'; params.push(form_name); } query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; params.push(parseInt(limit), parseInt(offset)); const stmt = db.prepare(query); stmt.bind(params); const submissions = []; while (stmt.step()) { submissions.push(stmt.getAsObject()); } stmt.free(); const countStmt = form_name ? db.prepare('SELECT COUNT(*) as count FROM submissions WHERE form_name = ?') : db.prepare('SELECT COUNT(*) as count FROM submissions'); if (form_name) countStmt.bind([form_name]); countStmt.step(); const total = countStmt.getAsObject().count; countStmt.free(); res.json({ submissions, total, limit: parseInt(limit), offset: parseInt(offset) }); } catch (error) { console.error('List error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Export CSV app.get('/export', requireAuth, (req, res) => { try { const { form_name } = req.query; let query = 'SELECT email, form_name, source, message, created_at FROM submissions'; if (form_name) query += ' WHERE form_name = ?'; query += ' ORDER BY created_at DESC'; const stmt = db.prepare(query); if (form_name) stmt.bind([form_name]); const rows = []; while (stmt.step()) { rows.push(stmt.getAsObject()); } stmt.free(); // Quote fields that may contain commas, quotes, or newlines const csvEscape = (v) => { const s = v == null ? '' : String(v); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const csv = [ 'email,form_name,source,message,created_at', ...rows.map(s => [s.email, s.form_name, s.source, s.message, s.created_at].map(csvEscape).join(',')) ].join('\n'); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename=submissions-${Date.now()}.csv`); res.send(csv); } catch (error) { console.error('Export error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Stats app.get('/stats', requireAuth, (req, res) => { try { const stmt = db.prepare(` SELECT form_name, COUNT(*) as count, MIN(created_at) as first_submission, MAX(created_at) as last_submission FROM submissions GROUP BY form_name ORDER BY count DESC `); const stats = []; while (stmt.step()) { stats.push(stmt.getAsObject()); } stmt.free(); const totalStmt = db.prepare('SELECT COUNT(*) as count FROM submissions'); totalStmt.step(); const total = totalStmt.getAsObject().count; totalStmt.free(); res.json({ total, by_form: stats }); } catch (error) { console.error('Stats error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Delete all submissions (protected) app.delete('/clear', requireAuth, (req, res) => { try { db.run('DELETE FROM submissions'); saveDb(); res.json({ success: true, message: 'All submissions deleted' }); } catch (error) { console.error('Clear error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Start server after DB init initDb().then(() => { app.listen(PORT, () => { console.log(`Form capture running on port ${PORT}`); console.log(`Allowed origins: ${ALLOWED_ORIGINS.join(', ')}`); console.log(`API key: ${API_KEY ? 'enabled' : 'disabled'}`); }); }).catch(err => { console.error('Failed to init database:', err); process.exit(1); });