const express = require('express'); const session = require('express-session'); const bcrypt = require('bcryptjs'); const multer = require('multer'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const PORT = process.env.PORT || 3210; // 127.0.0.1 by default: on a VPS, Nginx fronts this with TLS and is the only // thing that should be reachable from outside. On the old local-Windows setup // this was 0.0.0.0 for LAN access; set HOST=0.0.0.0 explicitly if you still // need that. COOKIE_SECURE should be "true" once you're behind real HTTPS. const HOST = process.env.HOST || '127.0.0.1'; const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true'; const DATA_FILE = path.join(__dirname, 'data.json'); const PID_FILE = path.join(__dirname, 'server.pid'); const FRONTEND_DIST = path.join(__dirname, '..', 'frontend', 'dist'); const UPLOADS_DIR = path.join(__dirname, 'uploads'); const AUTH_FILE = path.join(__dirname, 'auth.json'); const DEFAULT_USERNAME = 'admin'; const DEFAULT_PASSWORD = 'admin123'; if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); /* --------------------------------- auth ---------------------------------- */ function loadAuth() { if (!fs.existsSync(AUTH_FILE)) { const auth = { username: DEFAULT_USERNAME, passwordHash: bcrypt.hashSync(DEFAULT_PASSWORD, 10) }; fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); return auth; } try { return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')); } catch (e) { const auth = { username: DEFAULT_USERNAME, passwordHash: bcrypt.hashSync(DEFAULT_PASSWORD, 10) }; fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); return auth; } } function saveAuth(auth) { fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); } /* ------------------------------ secret / crypto -------------------------- */ // Used to encrypt the DeepSeek API key (and anything else sensitive) at // rest, so it isn't sitting around the filesystem as plain text. const SECRET_KEY_FILE = path.join(__dirname, '.secret-key'); function getSecretKey() { if (!fs.existsSync(SECRET_KEY_FILE)) { const key = crypto.randomBytes(32); fs.writeFileSync(SECRET_KEY_FILE, key.toString('base64'), { encoding: 'utf8', mode: 0o600 }); return key; } return Buffer.from(fs.readFileSync(SECRET_KEY_FILE, 'utf8').trim(), 'base64'); } const SECRET_KEY = getSecretKey(); function encrypt(text) { if (!text) return ''; const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', SECRET_KEY, iv); const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, encrypted]).toString('base64'); } function decrypt(b64) { if (!b64) return ''; try { const buf = Buffer.from(b64, 'base64'); const iv = buf.subarray(0, 12); const tag = buf.subarray(12, 28); const encrypted = buf.subarray(28); const decipher = crypto.createDecipheriv('aes-256-gcm', SECRET_KEY, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); } catch (e) { return ''; } } /* --------------------------------- AI config ------------------------------ */ const AI_CONFIG_FILE = path.join(__dirname, 'ai-config.json'); // Lets you point this at a different OpenAI-compatible endpoint if needed // (e.g. testing against a mock server); defaults to the real DeepSeek API. const DEEPSEEK_API_BASE = process.env.DEEPSEEK_API_BASE || 'https://api.deepseek.com'; function loadAiConfig() { try { const raw = JSON.parse(fs.readFileSync(AI_CONFIG_FILE, 'utf8')); return { apiKey: decrypt(raw.apiKeyEnc || '') }; } catch (e) { return { apiKey: '' }; } } function saveAiConfig(apiKey) { fs.writeFileSync(AI_CONFIG_FILE, JSON.stringify({ apiKeyEnc: encrypt(apiKey) }, null, 2), 'utf8'); } const REGION_LABELS = { africa: 'Africa', middle_east: 'Middle East', australia: 'Australia/Oceania', europe: 'Europe', sea: 'Southeast Asia', other: 'Other', }; function buildCustomerContext(c) { const lines = []; lines.push(`Company: ${c.company || '(unknown / not on file)'}`); if (c.contact) lines.push(`Contact person: ${c.contact}`); const place = [c.country, REGION_LABELS[c.region]].filter(Boolean).join(', '); if (place) lines.push(`Location: ${place}`); if (c.productLines && c.productLines.length) lines.push(`Product interest: ${c.productLines.join(', ')}`); if (c.productLinesNote) lines.push(`Specific model interest: ${c.productLinesNote}`); if (c.lastContact) lines.push(`Last contact date: ${c.lastContact}`); if (c.notes) lines.push(`Notes on file: ${c.notes}`); if (c.isVip) lines.push('This is a VIP / priority customer — extra attentive tone.'); return lines.join('\n'); } async function callDeepSeek(apiKey, messages) { const resp = await fetch(`${DEEPSEEK_API_BASE}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: 'deepseek-v4-flash', messages, temperature: 0.7, max_tokens: 600, }), }); if (!resp.ok) { const errText = await resp.text().catch(() => ''); throw new Error(`DeepSeek API error ${resp.status}: ${errText.slice(0, 200)}`); } const data = await resp.json(); const content = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; if (!content) throw new Error('DeepSeek API returned an empty response'); return content; } /* ------------------------------ data file ------------------------------ */ function readData() { try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch (e) { return {}; } } function writeData(data) { // write to a temp file then rename, avoids corrupting data.json if the // process is killed mid-write const tmp = DATA_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8'); fs.renameSync(tmp, DATA_FILE); } // only allow simple ids (our customer ids are like "c_169..._ab12c"), // this keeps the upload path from ever escaping the uploads/ folder function safeId(id) { return /^[A-Za-z0-9_-]+$/.test(id) ? id : null; } /* --------------------------------- app ---------------------------------- */ const app = express(); // behind Nginx, Express needs this to read X-Forwarded-* correctly — // otherwise req.ip is always Nginx's own address (breaks rate limiting) // and the "secure" cookie flag can't tell the request was HTTPS. app.set('trust proxy', 1); app.use(express.json({ limit: '10mb' })); app.use(session({ secret: crypto.randomBytes(32).toString('hex'), resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', secure: COOKIE_SECURE, maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }, })); function requireAuth(req, res, next) { if (req.session && req.session.authed) return next(); res.status(401).json({ error: 'not authenticated' }); } const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, message: { error: 'too many login attempts, please try again in a few minutes' }, }); /* -------------------------------- auth api ------------------------------- */ app.post('/api/auth/login', loginLimiter, (req, res) => { const body = req.body || {}; const username = body.username || ''; const password = body.password || ''; const auth = loadAuth(); if (username === auth.username && bcrypt.compareSync(password, auth.passwordHash)) { req.session.authed = true; req.session.username = username; return res.json({ ok: true, username }); } res.status(401).json({ error: 'invalid credentials' }); }); app.post('/api/auth/logout', (req, res) => { if (req.session) { req.session.destroy(() => res.json({ ok: true })); } else { res.json({ ok: true }); } }); app.get('/api/auth/check', (req, res) => { const authed = !!(req.session && req.session.authed); res.json({ authed, username: authed ? req.session.username : null }); }); app.post('/api/auth/change-password', requireAuth, (req, res) => { const body = req.body || {}; const currentPassword = body.currentPassword || ''; const newPassword = body.newPassword || ''; const auth = loadAuth(); if (!bcrypt.compareSync(currentPassword, auth.passwordHash)) { return res.status(400).json({ error: 'current password is incorrect' }); } if (newPassword.length < 4) { return res.status(400).json({ error: 'new password is too short' }); } auth.passwordHash = bcrypt.hashSync(newPassword, 10); saveAuth(auth); res.json({ ok: true }); }); /* ------------------------------ storage api ------------------------------ */ app.get('/api/storage/:key', requireAuth, (req, res) => { const data = readData(); const key = req.params.key; if (!(key in data)) return res.status(404).json({ error: 'not found' }); res.json({ value: data[key] }); }); app.put('/api/storage/:key', requireAuth, (req, res) => { const data = readData(); data[req.params.key] = req.body.value; writeData(data); res.json({ ok: true }); }); /* --------------------------------- ai api --------------------------------- */ app.get('/api/ai-config', requireAuth, (req, res) => { const cfg = loadAiConfig(); res.json({ hasKey: !!cfg.apiKey }); }); app.put('/api/ai-config', requireAuth, (req, res) => { const apiKey = ((req.body && req.body.apiKey) || '').trim(); if (!apiKey) return res.status(400).json({ error: 'apiKey is required' }); saveAiConfig(apiKey); res.json({ ok: true }); }); app.delete('/api/ai-config', requireAuth, (req, res) => { try { fs.unlinkSync(AI_CONFIG_FILE); } catch (e) {} res.json({ ok: true }); }); app.post('/api/ai-config/test', requireAuth, async (req, res) => { const cfg = loadAiConfig(); if (!cfg.apiKey) return res.status(400).json({ error: 'no API key configured' }); try { const content = await callDeepSeek(cfg.apiKey, [{ role: 'user', content: 'Reply with exactly: OK' }]); res.json({ ok: true, reply: content.trim() }); } catch (e) { res.status(400).json({ error: e.message }); } }); const aiLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'too many AI requests, please slow down' }, }); app.post('/api/ai/draft', requireAuth, aiLimiter, async (req, res) => { const body = req.body || {}; const customerId = body.customerId; const channel = body.channel; if (!customerId || !['email', 'whatsapp'].includes(channel)) { return res.status(400).json({ error: 'invalid request' }); } const cfg = loadAiConfig(); if (!cfg.apiKey) return res.status(400).json({ error: 'DeepSeek API key not configured' }); const data = readData(); let customers = []; try { customers = JSON.parse(data['crm:customers'] || '[]'); } catch (e) {} const customer = customers.find((c) => c.id === customerId); if (!customer) return res.status(404).json({ error: 'customer not found' }); const context = buildCustomerContext(customer); const systemPrompt = 'You are a sales assistant helping a B2B export salesperson at a Chinese power-equipment company (UPS systems, rectifiers, solar inverters, battery storage) write natural, professional follow-up outreach to overseas customers. Always write in English. Never sound like a generic template, reference specifics from the customer info given. Output only the requested content, with no meta-commentary, explanations, or markdown formatting.'; const userPrompt = channel === 'email' ? `Customer info:\n${context}\n\nWrite a short, warm but professional follow-up email to this customer. Respond in EXACTLY this format with no extra text:\nSUBJECT: \nBODY:\n` : `Customer info:\n${context}\n\nWrite a short, casual WhatsApp message to check in with this customer (2-4 sentences, friendly tone, no formal greeting like "Dear", a light emoji is fine if it feels natural). Respond with ONLY the message text, nothing else.`; try { const content = await callDeepSeek(cfg.apiKey, [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ]); if (channel === 'email') { const subjectMatch = content.match(/SUBJECT:\s*(.+)/i); const bodyMatch = content.match(/BODY:\s*([\s\S]*)/i); res.json({ subject: subjectMatch ? subjectMatch[1].trim() : 'Following up', body: bodyMatch ? bodyMatch[1].trim() : content.trim(), }); } else { res.json({ body: content.trim() }); } } catch (e) { res.status(502).json({ error: e.message }); } }); /* ------------------------- communication attachments ------------------------- */ const upload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => { const cid = safeId(req.params.customerId); if (!cid) return cb(new Error('invalid customer id')); const dir = path.join(UPLOADS_DIR, cid); fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (req, file, cb) => { const ts = Date.now(); const rand = Math.random().toString(36).slice(2, 8); const ext = path.extname(file.originalname).slice(0, 10); cb(null, `${ts}_${rand}${ext}`); }, }), limits: { fileSize: 25 * 1024 * 1024 }, // 25MB per file }); app.post('/api/uploads/:customerId', requireAuth, (req, res) => { if (!safeId(req.params.customerId)) return res.status(400).json({ error: 'invalid customer id' }); upload.single('file')(req, res, (err) => { if (err) return res.status(400).json({ error: err.message }); if (!req.file) return res.status(400).json({ error: 'no file received' }); res.json({ fileName: req.file.filename, originalName: req.file.originalname, size: req.file.size }); }); }); app.delete('/api/uploads/:customerId/:fileName', requireAuth, (req, res) => { const cid = safeId(req.params.customerId); const fname = req.params.fileName; const isSafeFile = fname && !fname.includes('..') && !fname.includes('/') && !fname.includes('\\'); if (!cid || !isSafeFile) return res.status(400).json({ error: 'invalid request' }); fs.unlink(path.join(UPLOADS_DIR, cid, fname), () => { // a missing file is also treated as success, so clicking delete twice never errors res.json({ ok: true }); }); }); app.use('/uploads', requireAuth, express.static(UPLOADS_DIR)); app.use(express.static(FRONTEND_DIST)); app.get('*', (req, res) => { res.sendFile(path.join(FRONTEND_DIST, 'index.html')); }); /* ------------------------------- bootstrap ------------------------------ */ loadAuth(); // make sure auth.json exists with default credentials on first run fs.writeFileSync(PID_FILE, String(process.pid), 'utf8'); process.on('exit', () => { try { fs.unlinkSync(PID_FILE); } catch (e) {} }); process.on('SIGINT', () => process.exit(0)); process.on('SIGTERM', () => process.exit(0)); const server = app.listen(PORT, HOST, () => { console.log(`[customer-crm] running at http://${HOST}:${PORT}`); }); server.on('error', (e) => { if (e.code === 'EADDRINUSE') { console.log('[customer-crm] another instance is already running on this port, exiting.'); try { fs.unlinkSync(PID_FILE); } catch (err) {} process.exit(0); } throw e; });