diff --git a/Dockerfile b/Dockerfile index 3f1470e..ea7ffd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN npm ci --only=production # Copy only the application code (NO config folder) COPY index.js ./ +COPY public ./public # ====================== PRODUCTION STAGE ====================== FROM node:20-alpine AS production @@ -25,6 +26,7 @@ WORKDIR /app COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/index.js ./ +COPY --from=builder --chown=nodejs:nodejs /app/public ./public # Create logs directory (will be mounted) RUN mkdir -p /app/logs && chown nodejs:nodejs /app/logs diff --git a/index.js b/index.js index 4804c85..98b3807 100644 --- a/index.js +++ b/index.js @@ -15,6 +15,7 @@ if (process.env.NODE_ENV === 'development') { import fs from 'fs/promises'; import fsSync from 'fs'; import path from 'path'; +import crypto from 'crypto'; import framework from 'webex-node-bot-framework'; import express from 'express'; import bodyParser from 'body-parser'; @@ -22,14 +23,20 @@ import fetch from 'node-fetch'; import cron from 'node-cron'; let config = {}; -let schedule = {}; +let schedule = []; let authorizedMembers = []; let responded = false; let Framework = null; +const CONFIG_DIR = './config'; +const SCHEDULE_PATH = path.join(CONFIG_DIR, 'schedule.json'); +const AUTHORIZED_PATH = path.join(CONFIG_DIR, 'authorized.json'); +const PUBLIC_DIR = path.resolve('./public'); + const app = express(); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: false })); +app.disable('x-powered-by'); +app.use(bodyParser.json({ limit: '512kb' })); +app.use(bodyParser.urlencoded({ extended: false, limit: '512kb' })); // Helper: Safe async JSON load async function loadJSON(filePath) { @@ -42,18 +49,138 @@ async function loadJSON(filePath) { } } -// Helper: Safe async JSON save +// Helper: Safe async JSON save (atomic + one-generation .bak rollback) async function saveJSON(jsonObject, filePath) { + const jsonData = JSON.stringify(jsonObject, null, 4); + const tmpPath = `${filePath}.tmp`; + const bakPath = `${filePath}.bak`; try { - const jsonData = JSON.stringify(jsonObject, null, 4); - await fs.writeFile(filePath, jsonData, 'utf8'); - logger('saveJSON', `Successfully wrote ${filePath}`); + await fs.writeFile(tmpPath, jsonData, 'utf8'); + try { + await fs.copyFile(filePath, bakPath); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + await fs.rename(tmpPath, filePath); + logger('saveJSON', `Successfully wrote ${filePath} (backup at ${bakPath})`); } catch (err) { - logger('saveJSON', `Error writing ${filePath}: ${err.message}`); + logger('saveJSON', `Error writing ${filePath}: ${err.message}`, 'ERROR'); + try { await fs.unlink(tmpPath); } catch { /* ignore */ } throw err; } } +// ====================== VALIDATION ====================== +const MAX_MEMBERS = 5000; +const MAX_MEMBER_LEN = 320; +const MAX_SCHEDULE_ENTRIES = 240; +const MAX_STRING_LEN = 2000; + +function isPlainString(v, { allowEmpty = false, max = MAX_STRING_LEN } = {}) { + if (typeof v !== 'string') return false; + if (!allowEmpty && v.trim() === '') return false; + if (v.length > max) return false; + // no NUL / control chars other than \n \r \t + return !/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(v); +} + +function validateAuthorized(input) { + if (!Array.isArray(input)) return { ok: false, error: 'Body must be a JSON array of usernames/emails.' }; + if (input.length === 0) return { ok: false, error: 'Refusing to save an empty authorized list.' }; + if (input.length > MAX_MEMBERS) return { ok: false, error: `Too many entries (max ${MAX_MEMBERS}).` }; + const cleaned = []; + const seen = new Set(); + for (let i = 0; i < input.length; i++) { + const item = input[i]; + if (typeof item !== 'string') return { ok: false, error: `Entry ${i} is not a string.` }; + const norm = item.trim().toLowerCase(); + if (!norm) return { ok: false, error: `Entry ${i} is empty.` }; + if (norm.length > MAX_MEMBER_LEN) return { ok: false, error: `Entry ${i} exceeds ${MAX_MEMBER_LEN} chars.` }; + if (!isPlainString(norm, { max: MAX_MEMBER_LEN })) return { ok: false, error: `Entry ${i} has invalid characters.` }; + if (!seen.has(norm)) { seen.add(norm); cleaned.push(norm); } + } + cleaned.sort(); + return { ok: true, value: cleaned }; +} + +// Shape required by buildCard() — every code/message field must be present. +const SCHEDULE_SHAPE = [ + ['aeAerie', 'usrow'], + ['aeAerie', 'mexico'], + ['toddSnyder', 'usrow', 'tstc'], + ['toddSnyder', 'usrow', 'thirdparty'], + ['unsubscribed', 'usrow'], + ['unsubscribed', 'thirdparty'], + ['unsubscribed', 'giftcard'] +]; + +function getAt(obj, pathArr) { + return pathArr.reduce((acc, k) => (acc == null ? acc : acc[k]), obj); +} + +function validateScheduleEntry(entry, i = 0) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return { ok: false, error: `Entry ${i} must be an object.` }; + } + if (!isPlainString(entry.month, { max: 60 })) return { ok: false, error: `Entry ${i}: "month" is required.` }; + if (!isPlainString(entry.start, { max: 40 })) return { ok: false, error: `Entry ${i}: "start" is required.` }; + if (!isPlainString(entry.end, { max: 40 })) return { ok: false, error: `Entry ${i}: "end" is required.` }; + const startMs = Date.parse(entry.start); + const endMs = Date.parse(entry.end); + if (isNaN(startMs)) return { ok: false, error: `Entry ${i}: "start" is not a parseable date (${entry.start}).` }; + if (isNaN(endMs)) return { ok: false, error: `Entry ${i}: "end" is not a parseable date (${entry.end}).` }; + if (endMs < startMs) return { ok: false, error: `Entry ${i}: "end" is before "start".` }; + + for (const shape of SCHEDULE_SHAPE) { + const node = getAt(entry, shape); + const label = shape.join('.'); + if (!node || typeof node !== 'object') { + return { ok: false, error: `Entry ${i}: missing "${label}".` }; + } + if (!isPlainString(node.code, { max: 200 })) { + return { ok: false, error: `Entry ${i}: "${label}.code" is required.` }; + } + if (typeof node.message !== 'string' || node.message.length > MAX_STRING_LEN) { + return { ok: false, error: `Entry ${i}: "${label}.message" must be a string ≤ ${MAX_STRING_LEN} chars.` }; + } + } + return { ok: true }; +} + +function validateScheduleArray(input) { + if (!Array.isArray(input)) return { ok: false, error: 'Schedule must be a JSON array.' }; + if (input.length === 0) return { ok: false, error: 'Refusing to save an empty schedule.' }; + if (input.length > MAX_SCHEDULE_ENTRIES) return { ok: false, error: `Too many schedule entries (max ${MAX_SCHEDULE_ENTRIES}).` }; + for (let i = 0; i < input.length; i++) { + const r = validateScheduleEntry(input[i], i); + if (!r.ok) return r; + } + return { ok: true, value: input }; +} + +// ====================== ADMIN AUTH ====================== +function safeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function requireAdmin(req, res, next) { + const expected = process.env.ADMIN_TOKEN; + if (!expected) { + return res.status(503).json({ error: 'Admin endpoints are disabled: ADMIN_TOKEN is not configured on the server.' }); + } + const header = req.get('authorization') || ''; + const m = header.match(/^Bearer\s+(.+)$/i); + const provided = m ? m[1].trim() : (req.get('x-admin-token') || '').trim(); + if (!provided || !safeEqual(provided, expected)) { + return res.status(401).json({ error: 'Unauthorized.' }); + } + next(); +} + // Logger function logger(section, message, level = 'INFO') { const now = new Date().toLocaleString(); @@ -63,38 +190,169 @@ function logger(section, message, level = 'INFO') { // Load configs on startup async function loadConfigs() { try { - config = await loadJSON('./config/config.json'); - schedule = await loadJSON('./config/schedule.json'); - authorizedMembers = (await loadJSON('./config/authorized.json')).map(item => + config = await loadJSON(path.join(CONFIG_DIR, 'config.json')); + const rawSchedule = await loadJSON(SCHEDULE_PATH); + schedule = Array.isArray(rawSchedule) ? rawSchedule : Object.values(rawSchedule || {}); + const rawAuthorized = await loadJSON(AUTHORIZED_PATH); + authorizedMembers = (rawAuthorized || []).map(item => typeof item === 'string' ? item.toLowerCase() : item ); - logger('startup', `Configs loaded. ${authorizedMembers.length} authorized members.`); + logger('startup', `Configs loaded. ${authorizedMembers.length} authorized members, ${schedule.length} schedule entries.`); } catch (err) { logger('startup', 'Failed to load one or more config files. Exiting.', 'ERROR'); process.exit(1); } } -// Express routes +// ====================== EXPRESS ROUTES ====================== app.get('/status', (req, res) => { res.status(200).json({ - status: `Alive and kicking. ${authorizedMembers.length} authorized members.` + status: `Alive and kicking. ${authorizedMembers.length} authorized members, ${schedule.length} schedule entries.` }); }); -app.post('/members', async (req, res) => { +// ---------- Members ---------- +app.get('/members', requireAdmin, (req, res) => { + res.status(200).json(authorizedMembers); +}); + +app.post('/members', requireAdmin, async (req, res) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: 'Content-Type must be application/json.' }); + } + const check = validateAuthorized(req.body); + if (!check.ok) { + logger('POST /members', `Rejected: ${check.error}`, 'WARN'); + return res.status(400).json({ error: check.error }); + } try { - await saveJSON(req.body, './config/authorized.json'); - authorizedMembers = (req.body || []).map(item => - typeof item === 'string' ? item.toLowerCase() : item - ); - logger('POST /members', 'Authorized users updated.'); - res.status(200).json({ status: 'Authorized users updated.' }); + await saveJSON(check.value, AUTHORIZED_PATH); + authorizedMembers = check.value; + logger('POST /members', `Authorized users updated (${authorizedMembers.length} entries).`); + res.status(200).json({ status: 'Authorized users updated.', count: authorizedMembers.length }); } catch (err) { res.status(500).json({ error: 'Failed to update authorized users.' }); } }); +// ---------- Schedule ---------- +app.get('/schedule', requireAdmin, (req, res) => { + res.status(200).json(schedule); +}); + +// Replace whole schedule +app.put('/schedule', requireAdmin, async (req, res) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: 'Content-Type must be application/json.' }); + } + const check = validateScheduleArray(req.body); + if (!check.ok) { + logger('PUT /schedule', `Rejected: ${check.error}`, 'WARN'); + return res.status(400).json({ error: check.error }); + } + try { + await saveJSON(check.value, SCHEDULE_PATH); + schedule = check.value; + logger('PUT /schedule', `Schedule replaced (${schedule.length} entries).`); + res.status(200).json({ status: 'Schedule replaced.', count: schedule.length }); + } catch (err) { + res.status(500).json({ error: 'Failed to save schedule.' }); + } +}); + +// Append a single entry +app.post('/schedule', requireAdmin, async (req, res) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: 'Content-Type must be application/json.' }); + } + const check = validateScheduleEntry(req.body, schedule.length); + if (!check.ok) { + return res.status(400).json({ error: check.error }); + } + if (schedule.length + 1 > MAX_SCHEDULE_ENTRIES) { + return res.status(400).json({ error: `Schedule is full (max ${MAX_SCHEDULE_ENTRIES}).` }); + } + const next = [...schedule, req.body]; + try { + await saveJSON(next, SCHEDULE_PATH); + schedule = next; + logger('POST /schedule', `Appended "${req.body.month}" (index ${schedule.length - 1}).`); + res.status(201).json({ status: 'Entry appended.', index: schedule.length - 1, count: schedule.length }); + } catch (err) { + res.status(500).json({ error: 'Failed to save schedule.' }); + } +}); + +// Replace a single entry +app.put('/schedule/:index', requireAdmin, async (req, res) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: 'Content-Type must be application/json.' }); + } + const idx = Number.parseInt(req.params.index, 10); + if (!Number.isInteger(idx) || idx < 0 || idx >= schedule.length) { + return res.status(404).json({ error: `Schedule index ${req.params.index} not found.` }); + } + const check = validateScheduleEntry(req.body, idx); + if (!check.ok) return res.status(400).json({ error: check.error }); + const next = schedule.slice(); + next[idx] = req.body; + try { + await saveJSON(next, SCHEDULE_PATH); + schedule = next; + logger('PUT /schedule/:index', `Updated index ${idx} ("${req.body.month}").`); + res.status(200).json({ status: 'Entry updated.', index: idx }); + } catch (err) { + res.status(500).json({ error: 'Failed to save schedule.' }); + } +}); + +// Delete a single entry +app.delete('/schedule/:index', requireAdmin, async (req, res) => { + const idx = Number.parseInt(req.params.index, 10); + if (!Number.isInteger(idx) || idx < 0 || idx >= schedule.length) { + return res.status(404).json({ error: `Schedule index ${req.params.index} not found.` }); + } + if (schedule.length <= 1) { + return res.status(400).json({ error: 'Refusing to delete the last remaining schedule entry.' }); + } + const removed = schedule[idx]; + const next = schedule.slice(); + next.splice(idx, 1); + try { + await saveJSON(next, SCHEDULE_PATH); + schedule = next; + logger('DELETE /schedule/:index', `Removed index ${idx} ("${removed.month}").`); + res.status(200).json({ status: 'Entry deleted.', count: schedule.length }); + } catch (err) { + res.status(500).json({ error: 'Failed to save schedule.' }); + } +}); + +// Preview the exact card text the bot would send for an entry (handy for the admin UI). +app.post('/preview', requireAdmin, async (req, res) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: 'Content-Type must be application/json.' }); + } + const check = validateScheduleEntry(req.body, 0); + if (!check.ok) return res.status(400).json({ error: check.error }); + try { + const preview = await buildCard(req.body); + res.status(200).json(preview); + } catch (err) { + res.status(500).json({ error: `Failed to build preview: ${err.message}` }); + } +}); + +// Admin UI (public HTML shell — all data endpoints still require the bearer token) +app.get('/admin', (req, res) => { + res.sendFile(path.join(PUBLIC_DIR, 'admin.html'), (err) => { + if (err) { + logger('GET /admin', `Failed to serve admin.html: ${err.message}`, 'ERROR'); + if (!res.headersSent) res.status(500).send('Admin UI unavailable.'); + } + }); +}); + // ====================== WEBEX FRAMEWORK ====================== // IMPORTANT: We initialize the framework INSIDE main() AFTER loadConfigs() @@ -399,8 +657,7 @@ async function checkExpiringDiscounts() { const targetLabel = targetDate.toLocaleDateString('en-US'); let covering = null; - for (const key in schedule) { - const entry = schedule[key]; + for (const entry of schedule) { const start = Date.parse(entry.start); const end = Date.parse(entry.end); if (!isNaN(start) && !isNaN(end) && targetMs >= start && targetMs <= end) { @@ -481,6 +738,14 @@ async function main() { logger('startup', `Bot token injected successfully (${process.env.NODE_ENV || 'development'} mode)`); + if (!process.env.ADMIN_TOKEN) { + logger('startup', + 'ADMIN_TOKEN is not set — /admin, /members, and /schedule endpoints will return 503. ' + + 'Add ADMIN_TOKEN= to .env to enable.', 'WARN'); + } else { + logger('startup', 'ADMIN_TOKEN detected — admin endpoints enabled.'); + } + // Optional: Support webhook URL from env (great for ngrok in dev) if (process.env.WEBHOOK_URL) { config.auth.webex.bot.webhookUrl = process.env.WEBHOOK_URL; @@ -531,8 +796,8 @@ async function main() { const now = Date.now(); let found = false; - for (const key in schedule) { - const entry = schedule[key]; + for (let idx = 0; idx < schedule.length; idx++) { + const entry = schedule[idx]; const start = Date.parse(entry.start); const end = Date.parse(entry.end); @@ -542,7 +807,7 @@ async function main() { const discount = await buildCard(entry); await bot.sendCard(discount.card, discount.text); } catch (err) { - logger('buildCard', `Error for ${key}: ${err.message}`, 'ERROR'); + logger('buildCard', `Error for entry ${idx}: ${err.message}`, 'ERROR'); } break; // Send only the first matching period } diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..c5be7a5 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,654 @@ + + + + + + + DiscountBot Admin + + + + +
+ +
+ + + +
+ + + +