From 8dc2e9c91f583cb4a12d6eef347b7e3a5efa1f8c Mon Sep 17 00:00:00 2001 From: Skyler Golden Date: Thu, 5 Mar 2026 19:53:37 +0000 Subject: [PATCH 1/2] Add inquiry-notifier service for EPK form submissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Express.js service that handles: - POST /inquiry - booking inquiries → Telegram notification - POST /subscribe - mailing list signups → Telegram notification Security features: - Rate limiting (5 requests/min per IP) - Honeypot field detection - Input sanitization and validation - CORS whitelist - Request size limits Requires deployment: - Add to docker-compose with TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID - Add Caddy route for api.cryptograss.live --- inquiry-notifier/Dockerfile | 14 ++ inquiry-notifier/README.md | 95 ++++++++++++ inquiry-notifier/package.json | 13 ++ inquiry-notifier/server.js | 270 ++++++++++++++++++++++++++++++++++ 4 files changed, 392 insertions(+) create mode 100644 inquiry-notifier/Dockerfile create mode 100644 inquiry-notifier/README.md create mode 100644 inquiry-notifier/package.json create mode 100644 inquiry-notifier/server.js diff --git a/inquiry-notifier/Dockerfile b/inquiry-notifier/Dockerfile new file mode 100644 index 0000000..0ad1622 --- /dev/null +++ b/inquiry-notifier/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --production + +COPY server.js ./ + +EXPOSE 3001 + +USER node + +CMD ["node", "server.js"] diff --git a/inquiry-notifier/README.md b/inquiry-notifier/README.md new file mode 100644 index 0000000..ce62940 --- /dev/null +++ b/inquiry-notifier/README.md @@ -0,0 +1,95 @@ +# Inquiry Notifier + +Simple notification service for Justin Holmes EPK inquiries and mailing list signups. + +## Features + +- Receives booking inquiries from EPK contact form +- Receives mailing list signups +- Sends notifications to Telegram +- CORS-enabled for cross-origin requests + +## Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `PORT` | Server port (default: 3001) | No | +| `TELEGRAM_BOT_TOKEN` | Telegram bot token from @BotFather | Yes | +| `TELEGRAM_CHAT_ID` | Chat/channel ID to send notifications to | Yes | +| `ALLOWED_ORIGINS` | Comma-separated list of allowed CORS origins | No | + +## Setup Telegram Bot + +1. Message @BotFather on Telegram +2. Send `/newbot` and follow prompts +3. Copy the bot token +4. Create a channel or group, add the bot as admin +5. Get chat ID: + - For channels: forward a message to @userinfobot + - For groups: add @userinfobot to the group, it will show the chat ID + +## Endpoints + +### `POST /inquiry` +Booking inquiry form submission. + +```json +{ + "name": "John Doe", + "email": "john@example.com", + "message": "I'd like to book Justin for our event..." +} +``` + +### `POST /subscribe` +Mailing list signup. + +```json +{ + "email": "john@example.com" +} +``` + +### `GET /health` +Health check endpoint. + +## Local Development + +```bash +npm install +TELEGRAM_BOT_TOKEN=xxx TELEGRAM_CHAT_ID=xxx npm start +``` + +## Docker + +```bash +docker build -t inquiry-notifier . +docker run -p 3001:3001 \ + -e TELEGRAM_BOT_TOKEN=xxx \ + -e TELEGRAM_CHAT_ID=xxx \ + -e ALLOWED_ORIGINS=https://justinholmes.com,http://localhost:5173 \ + inquiry-notifier +``` + +## Adding to Maybelle + +Add to docker-compose.maybelle.yml: + +```yaml +inquiry-notifier: + build: + context: ../../inquiry-notifier + dockerfile: Dockerfile + container_name: inquiry-notifier + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID} + - ALLOWED_ORIGINS=https://justinholmes.com,https://cryptograss.live + ports: + - "127.0.0.1:3001:3001" + networks: + - memory-lane-net + restart: unless-stopped +``` + +Add Caddy route for `api.cryptograss.live` or similar. diff --git a/inquiry-notifier/package.json b/inquiry-notifier/package.json new file mode 100644 index 0000000..23a9756 --- /dev/null +++ b/inquiry-notifier/package.json @@ -0,0 +1,13 @@ +{ + "name": "inquiry-notifier", + "version": "1.0.0", + "description": "Notification service for EPK inquiries and mailing list signups", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2" + } +} diff --git a/inquiry-notifier/server.js b/inquiry-notifier/server.js new file mode 100644 index 0000000..4d44f4e --- /dev/null +++ b/inquiry-notifier/server.js @@ -0,0 +1,270 @@ +const express = require('express'); +const cors = require('cors'); + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Config from environment +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; +const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID; +const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173').split(','); + +// ============================================ +// SECURITY: Rate limiting (in-memory) +// ============================================ +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 5; // max 5 requests per minute per IP + +function getRateLimitKey(req) { + return req.ip || req.headers['x-forwarded-for'] || 'unknown'; +} + +function checkRateLimit(req) { + const key = getRateLimitKey(req); + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW; + + if (!rateLimitMap.has(key)) { + rateLimitMap.set(key, []); + } + + const requests = rateLimitMap.get(key).filter(time => time > windowStart); + requests.push(now); + rateLimitMap.set(key, requests); + + return requests.length <= RATE_LIMIT_MAX_REQUESTS; +} + +// Clean up old rate limit entries every 5 minutes +setInterval(() => { + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW; + for (const [key, times] of rateLimitMap.entries()) { + const valid = times.filter(time => time > windowStart); + if (valid.length === 0) { + rateLimitMap.delete(key); + } else { + rateLimitMap.set(key, valid); + } + } +}, 5 * 60 * 1000); + +// ============================================ +// SECURITY: Input sanitization +// ============================================ +function escapeHtml(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function sanitizeInput(text, maxLength = 1000) { + if (!text) return ''; + return String(text).slice(0, maxLength).trim(); +} + +function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email) && email.length <= 254; +} + +// ============================================ +// Middleware +// ============================================ + +// Request size limit (prevent large payload attacks) +app.use(express.json({ limit: '10kb' })); +app.use(express.urlencoded({ extended: true, limit: '10kb' })); + +// CORS - strict origin checking +app.use(cors({ + origin: function(origin, callback) { + // Allow requests with no origin (like mobile apps or curl) + // but in production you might want to reject these + if (!origin) return callback(null, true); + + if (ALLOWED_ORIGINS.includes(origin)) { + callback(null, true); + } else { + console.log(`Blocked request from origin: ${origin}`); + callback(new Error('Not allowed by CORS')); + } + }, + methods: ['POST', 'OPTIONS'], + allowedHeaders: ['Content-Type'] +})); + +// Rate limiting middleware +app.use((req, res, next) => { + if (req.method === 'POST') { + if (!checkRateLimit(req)) { + console.log(`Rate limit exceeded for ${getRateLimitKey(req)}`); + return res.status(429).json({ error: 'Too many requests. Please wait a minute.' }); + } + } + next(); +}); + +// ============================================ +// Telegram notification +// ============================================ +async function sendTelegram(message) { + if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) { + console.log('Telegram not configured, skipping notification'); + console.log('Message would be:', message); + return { ok: false, reason: 'not_configured' }; + } + + const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: TELEGRAM_CHAT_ID, + text: message, + parse_mode: 'HTML' + }) + }); + + const data = await response.json(); + if (!data.ok) { + console.error('Telegram API error:', data); + } + return data; + } catch (error) { + console.error('Failed to send Telegram message:', error); + return { ok: false, error: error.message }; + } +} + +// ============================================ +// Endpoints +// ============================================ + +// Health check +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + telegram_configured: !!(TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID), + allowed_origins: ALLOWED_ORIGINS + }); +}); + +// Booking inquiry endpoint +app.post('/inquiry', async (req, res) => { + const { name, email, message, website } = req.body; + + // SECURITY: Honeypot field - bots often fill hidden fields + if (website) { + console.log(`Honeypot triggered from ${getRateLimitKey(req)}`); + // Pretend success but don't actually process + return res.json({ success: true, message: 'Inquiry received' }); + } + + // Validate required fields + if (!name || !email) { + return res.status(400).json({ error: 'Name and email are required' }); + } + + // Sanitize inputs + const cleanName = sanitizeInput(name, 100); + const cleanEmail = sanitizeInput(email, 254); + const cleanMessage = sanitizeInput(message, 2000); + + // Validate email format + if (!isValidEmail(cleanEmail)) { + return res.status(400).json({ error: 'Invalid email address' }); + } + + const timestamp = new Date().toISOString(); + const clientIP = getRateLimitKey(req); + + const telegramMessage = ` +New Booking Inquiry + +From: ${escapeHtml(cleanName)} +Email: ${escapeHtml(cleanEmail)} +Time: ${timestamp} +IP: ${clientIP} + +Message: +${escapeHtml(cleanMessage) || '(no message)'} + `.trim(); + + console.log(`[${timestamp}] Inquiry from ${cleanName} <${cleanEmail}> (${clientIP})`); + + const telegramResult = await sendTelegram(telegramMessage); + + res.json({ + success: true, + message: 'Inquiry received' + }); +}); + +// Mailing list signup endpoint +app.post('/subscribe', async (req, res) => { + const { email, website } = req.body; + + // SECURITY: Honeypot field + if (website) { + console.log(`Honeypot triggered from ${getRateLimitKey(req)}`); + return res.json({ success: true, message: 'Subscribed successfully' }); + } + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + const cleanEmail = sanitizeInput(email, 254); + + if (!isValidEmail(cleanEmail)) { + return res.status(400).json({ error: 'Invalid email address' }); + } + + const timestamp = new Date().toISOString(); + const clientIP = getRateLimitKey(req); + + const telegramMessage = ` +New Mailing List Signup + +Email: ${escapeHtml(cleanEmail)} +Time: ${timestamp} +IP: ${clientIP} + `.trim(); + + console.log(`[${timestamp}] Mailing list signup: ${cleanEmail} (${clientIP})`); + + // TODO: Store in PostgreSQL for actual mailing list + + const telegramResult = await sendTelegram(telegramMessage); + + res.json({ + success: true, + message: 'Subscribed successfully' + }); +}); + +// Catch-all for undefined routes +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +// Error handler +app.use((err, req, res, next) => { + console.error('Server error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Inquiry notifier listening on port ${PORT}`); + console.log(`Telegram configured: ${!!(TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID)}`); + console.log(`Allowed origins: ${ALLOWED_ORIGINS.join(', ')}`); + console.log(`Rate limit: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW/1000}s`); +}); From 2a10436fd49b27b2473c4713f47d83662c2fa5c8 Mon Sep 17 00:00:00 2001 From: Skyler Golden Date: Thu, 5 Mar 2026 19:57:28 +0000 Subject: [PATCH 2/2] Add additional security measures to inquiry-notifier - Block disposable email domains (tempmail, mailinator, etc.) - Detect script injection attempts (