From e46c1c7063f30b88ebb8e1cff1906d95d792d101 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 4 Feb 2026 10:24:06 -0600 Subject: [PATCH] Initial commit: lightweight form capture microservice --- .gitignore | 6 ++ README.md | 94 ++++++++++++++++++++++++ index.js | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 17 +++++ 4 files changed, 317 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fea0cc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/ +*.db +*.db-journal +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5c1541 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Form Capture + +Lightweight form capture microservice. ~100 lines, SQLite storage, zero config. + +## Deploy to Railway + +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template) + +1. Create new project on Railway +2. Connect this repo +3. Add environment variables (optional): + - `API_KEY` - Protect admin endpoints (recommended) + - `ALLOWED_ORIGINS` - Comma-separated origins (default: `https://chatlabsai.com`) +4. Add a volume mounted at `/app/data` for persistent storage +5. Deploy + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3000` | Server port | +| `API_KEY` | none | Protect `/emails`, `/export`, `/stats` | +| `ALLOWED_ORIGINS` | `https://chatlabsai.com` | CORS origins (comma-separated) | +| `DATABASE_PATH` | `./data/forms.db` | SQLite database path | + +## API Endpoints + +### Submit Form +```bash +POST /submit +Content-Type: application/json + +{ + "email": "user@example.com", + "form_name": "newsletter", # optional, default: "default" + "source": "homepage" # optional +} +``` + +### List Submissions (protected) +```bash +GET /emails?form_name=newsletter&limit=100&offset=0 +Authorization: Bearer YOUR_API_KEY +``` + +### Export CSV (protected) +```bash +GET /export?form_name=newsletter +Authorization: Bearer YOUR_API_KEY +``` + +### Stats (protected) +```bash +GET /stats +Authorization: Bearer YOUR_API_KEY +``` + +### Health Check +```bash +GET /health +``` + +## Frontend Usage + +```javascript +// Submit form +const response = await fetch('https://your-app.railway.app/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'user@example.com', + form_name: 'android-waitlist', + source: window.location.pathname + }) +}); + +if (response.ok) { + // Show success message +} +``` + +## Local Development + +```bash +npm install +npm start +``` + +Test submission: +```bash +curl -X POST http://localhost:3000/submit \ + -H "Content-Type: application/json" \ + -d '{"email": "test@example.com", "form_name": "test"}' +``` diff --git a/index.js b/index.js new file mode 100644 index 0000000..0528211 --- /dev/null +++ b/index.js @@ -0,0 +1,200 @@ +const express = require('express'); +const cors = require('cors'); +const Database = require('better-sqlite3'); +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(','); + +// Initialize SQLite database +const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'data', 'forms.db'); +const db = new Database(dbPath); + +// Create tables +db.exec(` + 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 + ); + CREATE INDEX IF NOT EXISTS idx_form_name ON submissions(form_name); + CREATE INDEX IF NOT EXISTS idx_email ON submissions(email); + CREATE INDEX IF NOT EXISTS idx_created_at ON submissions(created_at); +`); + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(cors({ + origin: (origin, callback) => { + // Allow requests with no origin (mobile apps, curl, etc.) + 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'], + 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; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + // Basic email validation + 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; + + const stmt = db.prepare(` + INSERT INTO submissions (form_name, email, source, ip, user_agent) + VALUES (?, ?, ?, ?, ?) + `); + + const result = stmt.run(form_name, email.toLowerCase().trim(), source, ip, userAgent); + + res.json({ + success: true, + id: result.lastInsertRowid, + message: 'Submission received' + }); + } catch (error) { + console.error('Submit error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Auth middleware for protected routes +const requireAuth = (req, res, next) => { + if (!API_KEY) return next(); // No auth if no API_KEY set + + const authHeader = req.headers.authorization; + if (!authHeader || authHeader !== `Bearer ${API_KEY}`) { + return res.status(401).json({ error: 'Unauthorized' }); + } + next(); +}; + +// List submissions (protected) +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 submissions = db.prepare(query).all(...params); + const countQuery = form_name + ? db.prepare('SELECT COUNT(*) as count FROM submissions WHERE form_name = ?').get(form_name) + : db.prepare('SELECT COUNT(*) as count FROM submissions').get(); + + res.json({ + submissions, + total: countQuery.count, + limit: parseInt(limit), + offset: parseInt(offset) + }); + } catch (error) { + console.error('List error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Export as CSV (protected) +app.get('/export', requireAuth, (req, res) => { + try { + const { form_name } = req.query; + + let query = 'SELECT email, form_name, source, created_at FROM submissions'; + const params = []; + + if (form_name) { + query += ' WHERE form_name = ?'; + params.push(form_name); + } + + query += ' ORDER BY created_at DESC'; + + const submissions = db.prepare(query).all(...params); + + const csv = [ + 'email,form_name,source,created_at', + ...submissions.map(s => `${s.email},${s.form_name},${s.source || ''},${s.created_at}`) + ].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 (protected) +app.get('/stats', requireAuth, (req, res) => { + try { + const stats = 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 + `).all(); + + const total = db.prepare('SELECT COUNT(*) as count FROM submissions').get(); + + res.json({ + total: total.count, + by_form: stats + }); + } catch (error) { + console.error('Stats error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Ensure data directory exists +const fs = require('fs'); +const dataDir = path.dirname(dbPath); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +app.listen(PORT, () => { + console.log(`Form capture service running on port ${PORT}`); + console.log(`Allowed origins: ${ALLOWED_ORIGINS.join(', ')}`); + console.log(`API key protection: ${API_KEY ? 'enabled' : 'disabled'}`); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..85e5698 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "form-capture", + "version": "1.0.0", + "description": "Lightweight form capture microservice", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "better-sqlite3": "^11.7.0", + "cors": "^2.8.5", + "express": "^4.21.2" + }, + "engines": { + "node": ">=18" + } +}