diff --git a/.env.example b/.env.example index a23ab30..940b7d5 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,18 @@ MONGO_URI=mongodb://mongodb:27017/LibreChat PORT=3002 API_KEY=change-me-to-a-secure-key + +# Webhook security +WEBHOOK_SECRET=change-me-to-a-random-secret +WEBHOOK_BASE_URL=https://your-domain.com/balance-manager +WEBHOOK_TTL_HOURS=72 + +# SMTP (Mailgun or any SMTP server) +SMTP_HOST=smtp.mailgun.org +SMTP_PORT=587 +SMTP_USER=postmaster@mg.yourdomain.com +SMTP_PASS=your-smtp-password +SMTP_FROM=noreply@yourdomain.com + +# Notification recipients (comma-separated for multiple) +NOTIFY_TO=admin@yourdomain.com diff --git a/package-lock.json b/package-lock.json index 972ef90..fa86b55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "dotenv": "^16.4.5", "express": "^4.21.0", - "mongodb": "^6.12.0" + "mongodb": "^6.12.0", + "nodemailer": "^8.0.4" } }, "node_modules/@mongodb-js/saslprep": { @@ -634,6 +635,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 32ea499..2f0f5a5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dependencies": { "dotenv": "^16.4.5", "express": "^4.21.0", - "mongodb": "^6.12.0" + "mongodb": "^6.12.0", + "nodemailer": "^8.0.4" } } diff --git a/routes/requests.js b/routes/requests.js index 7228d93..72d0468 100644 --- a/routes/requests.js +++ b/routes/requests.js @@ -2,6 +2,7 @@ const { Router } = require('express'); const requestService = require('../services/requests'); const balanceService = require('../services/balance'); const history = require('../services/history'); +const { sendApprovalEmail } = require('../services/email'); const { MongoClient } = require('mongodb'); const publicRouter = Router(); @@ -19,6 +20,9 @@ publicRouter.post('/', (req, res) => { return res.status(400).json({ error: 'Invalid email address' }); } const request = requestService.createRequest(email, omgJobNumber); + sendApprovalEmail(request).catch(err => + console.error('[email] Failed to send approval notification:', err) + ); res.json({ success: true, id: request.id }); } catch (err) { res.status(500).json({ error: err.message }); diff --git a/routes/webhooks.js b/routes/webhooks.js new file mode 100644 index 0000000..e2ea50d --- /dev/null +++ b/routes/webhooks.js @@ -0,0 +1,240 @@ +const { Router } = require('express'); +const { MongoClient } = require('mongodb'); +const { verifyToken } = require('../services/email'); +const requestService = require('../services/requests'); +const balanceService = require('../services/balance'); +const history = require('../services/history'); + +const router = Router(); + +function htmlPage(title, message, success) { + const color = success ? '#4ade80' : '#f87171'; + const icon = success ? '✓' : '✗'; + return ` + +${title} + + +
${icon}

${title}

${message}

`; +} + +async function getDbAndUser(email) { + const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/LibreChat'; + const client = new MongoClient(MONGO_URI); + await client.connect(); + const db = client.db(); + const user = await db.collection('users').findOne( + { email: email.toLowerCase() }, + { projection: { _id: 1, email: 1 } }, + ); + return { client, db, user }; +} + +router.get('/approve/:requestId', async (req, res) => { + try { + const { token, amount, expires } = req.query; + + if (!verifyToken(req.params.requestId, amount, expires, token)) { + return res.status(403).send(htmlPage('Link Expired or Invalid', 'This approval link has expired or is invalid. Please use the admin dashboard to process this request.', false)); + } + + const requests = requestService.getAll(); + const request = requests.find(r => r.id === req.params.requestId); + + if (!request) { + return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false)); + } + if (request.status !== 'pending') { + return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}${request.amountApproved ? ' with ' + (request.amountApproved / 1000000).toFixed(1) + 'M tokens' : ''}.`, false)); + } + + const numAmount = parseInt(amount); + const { client, db, user } = await getDbAndUser(request.email); + + if (!user) { + await client.close(); + return res.status(404).send(htmlPage('User Not Found', `No LibreChat user found with email: ${request.email}`, false)); + } + + const result = await balanceService.addBalance(db, user._id.toString(), numAmount); + await client.close(); + + history.log({ + email: request.email, + action: 'approve', + amount: numAmount, + source: 'email', + omgJobNumber: request.omgJobNumber, + balanceAfter: result.tokenCredits, + }); + + requestService.updateRequest(req.params.requestId, 'approved', numAmount); + + const formatted = (numAmount / 1000000).toFixed(1) + 'M'; + res.send(htmlPage('Request Approved', `${formatted} tokens added to ${request.email}. New balance: ${result.tokenCredits.toLocaleString()} tokens.`, true)); + } catch (err) { + console.error('[webhook] Approve error:', err); + res.status(500).send(htmlPage('Error', 'Something went wrong processing this approval. Please use the admin dashboard.', false)); + } +}); + +router.get('/reject/:requestId', async (req, res) => { + try { + const { token, expires } = req.query; + + if (!verifyToken(req.params.requestId, 'reject', expires, token)) { + return res.status(403).send(htmlPage('Link Expired or Invalid', 'This rejection link has expired or is invalid.', false)); + } + + const requests = requestService.getAll(); + const request = requests.find(r => r.id === req.params.requestId); + + if (!request) { + return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false)); + } + if (request.status !== 'pending') { + return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}.`, false)); + } + + requestService.updateRequest(req.params.requestId, 'rejected', null); + res.send(htmlPage('Request Rejected', `Credit request from ${request.email} (${request.omgJobNumber}) has been rejected.`, true)); + } catch (err) { + console.error('[webhook] Reject error:', err); + res.status(500).send(htmlPage('Error', 'Something went wrong. Please use the admin dashboard.', false)); + } +}); + +router.get('/custom/:requestId', async (req, res) => { + try { + const { token, expires } = req.query; + + if (!verifyToken(req.params.requestId, 'custom', expires, token)) { + return res.status(403).send(htmlPage('Link Expired or Invalid', 'This link has expired or is invalid.', false)); + } + + const requests = requestService.getAll(); + const request = requests.find(r => r.id === req.params.requestId); + + if (!request) { + return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false)); + } + if (request.status !== 'pending') { + return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}.`, false)); + } + + res.send(` + +Custom Approve - ${request.email} + + + +
+

Custom Approve

+

Choose an amount to approve for this request

+
+

Email

+

${request.email}

+

OMG Job #

+

${request.omgJobNumber}

+
+
+ + + + +
+ 1M + 5M + 10M + 12M + 20M + 50M +
+ +
+
+`); + } catch (err) { + console.error('[webhook] Custom page error:', err); + res.status(500).send(htmlPage('Error', 'Something went wrong. Please use the admin dashboard.', false)); + } +}); + +router.use('/custom/:requestId', require('express').urlencoded({ extended: false })); + +router.post('/custom/:requestId', async (req, res) => { + try { + const { token, expires, amount } = req.body; + + if (!verifyToken(req.params.requestId, 'custom', expires, token)) { + return res.status(403).send(htmlPage('Link Expired or Invalid', 'This link has expired or is invalid.', false)); + } + + const numAmount = parseInt(amount); + if (isNaN(numAmount) || numAmount <= 0) { + return res.status(400).send(htmlPage('Invalid Amount', 'Please enter a valid positive number.', false)); + } + + const requests = requestService.getAll(); + const request = requests.find(r => r.id === req.params.requestId); + + if (!request) { + return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false)); + } + if (request.status !== 'pending') { + return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}.`, false)); + } + + const { client, db, user } = await getDbAndUser(request.email); + + if (!user) { + await client.close(); + return res.status(404).send(htmlPage('User Not Found', `No LibreChat user found with email: ${request.email}`, false)); + } + + const result = await balanceService.addBalance(db, user._id.toString(), numAmount); + await client.close(); + + history.log({ + email: request.email, + action: 'approve', + amount: numAmount, + source: 'email', + omgJobNumber: request.omgJobNumber, + balanceAfter: result.tokenCredits, + }); + + requestService.updateRequest(req.params.requestId, 'approved', numAmount); + + const formatted = (numAmount / 1000000).toFixed(1) + 'M'; + res.send(htmlPage('Request Approved', `${formatted} tokens added to ${request.email}. New balance: ${result.tokenCredits.toLocaleString()} tokens.`, true)); + } catch (err) { + console.error('[webhook] Custom approve error:', err); + res.status(500).send(htmlPage('Error', 'Something went wrong. Please use the admin dashboard.', false)); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 7009671..d9a1127 100644 --- a/server.js +++ b/server.js @@ -36,10 +36,14 @@ app.use(express.static(path.join(__dirname, 'public'))); const apiRoutes = require('./routes/api'); const requestRoutes = require('./routes/requests'); +const webhookRoutes = require('./routes/webhooks'); // Public: submit a credit request (no auth needed) app.use('/api/requests', requestRoutes.publicRouter); +// Public: email webhook approve/reject (secured by HMAC tokens) +app.use('/api/webhooks', webhookRoutes); + // Admin: manage credit requests (auth required) app.use('/api/admin/requests', authMiddleware, requestRoutes.adminRouter); diff --git a/services/email.js b/services/email.js new file mode 100644 index 0000000..72cd3e1 --- /dev/null +++ b/services/email.js @@ -0,0 +1,135 @@ +const crypto = require('crypto'); +const nodemailer = require('nodemailer'); + +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'change-me-webhook-secret'; +const WEBHOOK_BASE_URL = (process.env.WEBHOOK_BASE_URL || 'http://localhost:3002').replace(/\/$/, ''); +const WEBHOOK_TTL_HOURS = parseInt(process.env.WEBHOOK_TTL_HOURS || '72'); +const NOTIFY_TO = process.env.NOTIFY_TO || ''; + +function getTransporter() { + return nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'smtp.mailgun.org', + port: parseInt(process.env.SMTP_PORT || '587'), + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); +} + +function generateToken(requestId, amount, expiresAt) { + const payload = `${requestId}|${amount}|${expiresAt}`; + return crypto.createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex'); +} + +function verifyToken(requestId, amount, expires, token) { + if (Date.now() > parseInt(expires)) return false; + const expected = generateToken(requestId, amount, expires); + try { + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex')); + } catch { + return false; + } +} + +function buildWebhookUrl(path, requestId, amount, expiresAt) { + const token = generateToken(requestId, amount, expiresAt); + return `${WEBHOOK_BASE_URL}/api/webhooks/${path}/${requestId}?token=${token}&amount=${amount}&expires=${expiresAt}`; +} + +function buildEmail(request) { + const expiresAt = Date.now() + (WEBHOOK_TTL_HOURS * 60 * 60 * 1000); + const expiryDate = new Date(expiresAt).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); + + const presets = [ + { label: 'Approve 5M', amount: 5000000, color: '#4ade80' }, + { label: 'Approve 10M', amount: 10000000, color: '#4ade80' }, + { label: 'Approve 20M', amount: 20000000, color: '#4ade80' }, + ]; + + const approveButtons = presets.map(p => { + const url = buildWebhookUrl('approve', request.id, p.amount, expiresAt); + return `${p.label}`; + }).join('\n '); + + const customUrl = buildWebhookUrl('custom', request.id, 'custom', expiresAt); + const rejectUrl = buildWebhookUrl('reject', request.id, 'reject', expiresAt); + + return ` + + + + + + + +
+ + + + + + + + + + + + + +
+
💳
+

New Credit Request

+
+ + + + +
+

Email

+

${request.email}

+

OMG Job Number

+

${request.omgJobNumber}

+
+
+

Choose an action:

+ ${approveButtons} +
+ Custom Amount + Reject +
+

These links expire on ${expiryDate}

+
+
+ +`; +} + +async function sendApprovalEmail(request) { + if (!NOTIFY_TO) { + console.log('[email] NOTIFY_TO not configured, skipping email'); + return; + } + if (!process.env.SMTP_USER || !process.env.SMTP_PASS) { + console.log('[email] SMTP credentials not configured, skipping email'); + return; + } + + const transporter = getTransporter(); + const html = buildEmail(request); + const recipients = NOTIFY_TO.split(',').map(e => e.trim()).filter(Boolean); + + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: recipients.join(', '), + subject: `Credit Request: ${request.email} — ${request.omgJobNumber}`, + html, + }); + + console.log(`[email] Approval notification sent to ${recipients.join(', ')} for request ${request.id}`); +} + +module.exports = { generateToken, verifyToken, sendApprovalEmail };