Delete server.js
This commit is contained in:
parent
750979b233
commit
6cad7a9da2
417
server.js
417
server.js
|
|
@ -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: <subject line>\nBODY:\n<email body, 3-5 short paragraphs max, sign off with "[Your name]">`
|
|
||||||
: `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;
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue