diff --git a/server.js b/server.js deleted file mode 100644 index d431b01..0000000 --- a/server.js +++ /dev/null @@ -1,417 +0,0 @@ -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; -});