Initial commit: lightweight form capture microservice
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Form Capture
|
||||||
|
|
||||||
|
Lightweight form capture microservice. ~100 lines, SQLite storage, zero config.
|
||||||
|
|
||||||
|
## Deploy to Railway
|
||||||
|
|
||||||
|
[](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"}'
|
||||||
|
```
|
||||||
200
index.js
Normal file
200
index.js
Normal file
@@ -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'}`);
|
||||||
|
});
|
||||||
17
package.json
Normal file
17
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user