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..069eef0 --- /dev/null +++ b/inquiry-notifier/server.js @@ -0,0 +1,352 @@ +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; +} + +// Disposable email domains to block +const DISPOSABLE_DOMAINS = [ + 'tempmail.com', 'throwaway.email', 'guerrillamail.com', 'mailinator.com', + '10minutemail.com', 'temp-mail.org', 'fakeinbox.com', 'trashmail.com', + 'yopmail.com', 'sharklasers.com', 'getnada.com', 'maildrop.cc' +]; + +function isDisposableEmail(email) { + const domain = email.split('@')[1]?.toLowerCase(); + return DISPOSABLE_DOMAINS.some(d => domain === d || domain?.endsWith('.' + d)); +} + +// Detect suspicious content patterns +function containsSuspiciousContent(text) { + if (!text) return false; + const suspicious = [ + /