diff --git a/server.js b/server.js new file mode 100644 index 0000000..3b902c6 --- /dev/null +++ b/server.js @@ -0,0 +1,425 @@ +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.intendedModel || c.intendedQty) { + lines.push(`Specific model interest: ${c.intendedModel || '(model unspecified)'}${c.intendedQty ? `, quantity: ${c.intendedQty}` : ''}`); + } + if (c.lastContact) lines.push(`Last contact date: ${c.lastContact}`); + if (c.notes) lines.push(`Background research on this customer: ${c.notes}`); + if (c.tags) lines.push(`Additional remarks: ${c.tags}`); + if (c.isVip) lines.push('This is a VIP / priority customer — extra attentive tone.'); + if (c.hasOrdered) lines.push('This customer has already placed an order before — this follow-up is with an existing buyer, not a cold lead.'); + 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' }); + // multer/busboy 默认按 latin1 解析 multipart 里的文件名字段(这是老版HTTP规范的行为), + // 但浏览器实际发送的中文文件名是 UTF-8 字节,所以这里要把它从 latin1 转回 UTF-8, + // 不然中文文件名会变成乱码存进 data.json。 + const originalName = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); + res.json({ fileName: req.file.filename, 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; +});