import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import multer from 'multer'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { nanoid } from 'nanoid'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { db, rowToArticle, rowToRiskSignal, rowToPrice } from './db.js'; import { startScraperLoop, scrapeOnce } from './scraper.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 3001); const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET || JWT_SECRET.length < 32) { console.error('FATAL: JWT_SECRET env var is missing or shorter than 32 chars. Set it before starting.'); process.exit(1); } const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`; const uploadsDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadsDir), filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase().slice(0, 8) || '.bin'; cb(null, `${nanoid(12)}${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (/^image\/(jpe?g|png|webp|gif)$/.test(file.mimetype)) cb(null, true); else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)')); }, }); const app = express(); const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173') .split(',').map(s => s.trim()); app.use(cors({ origin: (origin, cb) => { if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true); else cb(new Error('Not allowed by CORS')); }, credentials: true, })); app.use(cookieParser()); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "blob:"], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], frameAncestors: ["'none'"], }, }, frameguard: { action: 'deny' }, })); app.use(express.json({ limit: '2mb' })); app.use('/uploads', express.static(uploadsDir, { maxAge: '7d' })); app.use('/', express.static(path.join(__dirname, 'public'))); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, message: { error: 'too_many_attempts' }, standardHeaders: true, legacyHeaders: false, }); const contactLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 3, message: { error: 'too_many_requests' }, standardHeaders: true, legacyHeaders: false, }); function authRequired(req, res, next) { const token = req.cookies?.session; if (!token) return res.status(401).json({ error: 'unauthorized' }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { return res.status(401).json({ error: 'invalid_token' }); } } app.post('/api/auth/login', loginLimiter, (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'username_password_required' }); const row = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get(username); if (!row || !bcrypt.compareSync(password, row.password_hash)) { return res.status(401).json({ error: 'bad_credentials' }); } const token = jwt.sign({ sub: row.id, username: row.username }, JWT_SECRET, { expiresIn: '24h' }); const isProd = process.env.NODE_ENV === 'production'; res.cookie('session', token, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000, path: '/', }); res.json({ user: { id: row.id, username: row.username } }); }); app.post('/api/auth/logout', (_req, res) => { const isProd = process.env.NODE_ENV === 'production'; res.clearCookie('session', { httpOnly: true, secure: isProd, sameSite: 'strict', path: '/' }); res.json({ ok: true }); }); app.get('/api/auth/me', authRequired, (req, res) => { res.json({ user: req.user }); }); const PUBLIC_FIELDS = ` id, title, category, type, author, author_role, author_initial, publish_date, pages, price, is_free, summary, body, cover_image, tags, featured, created_at, updated_at `; app.get('/api/articles', (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); const rows = db .prepare(`SELECT ${PUBLIC_FIELDS} FROM articles ORDER BY created_at DESC LIMIT ?`) .all(limit); res.json(rows.map(rowToArticle)); }); app.get('/api/articles/:id', (req, res) => { const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToArticle(row)); }); app.post('/api/uploads', authRequired, upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'no_file' }); const relative = `/uploads/${req.file.filename}`; res.json({ url: relative, absoluteUrl: `${PUBLIC_ORIGIN}${relative}` }); }); function normalizeArticleBody(b) { return { title: String(b.title || '').trim(), category: b.category || null, type: b.type || null, author: b.author || null, author_role: b.authorRole || null, author_initial: b.authorInitial || null, publish_date: b.publishDate || null, pages: Number(b.pages) || 0, price: Number(b.price) || 0, is_free: b.isFree ? 1 : 0, summary: b.summary || null, body: b.body || null, cover_image: b.coverImage || null, tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []), featured: b.featured ? 1 : 0, }; } app.post('/api/articles', authRequired, (req, res) => { const a = normalizeArticleBody(req.body || {}); if (!a.title) return res.status(400).json({ error: 'title_required' }); const id = nanoid(14); db.prepare(` INSERT INTO articles ( id, title, category, type, author, author_role, author_initial, publish_date, pages, price, is_free, summary, body, cover_image, tags, featured ) VALUES ( @id, @title, @category, @type, @author, @author_role, @author_initial, @publish_date, @pages, @price, @is_free, @summary, @body, @cover_image, @tags, @featured ) `).run({ id, ...a }); const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id); res.status(201).json(rowToArticle(row)); }); app.put('/api/articles/:id', authRequired, (req, res) => { const existing = db.prepare('SELECT id FROM articles WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'not_found' }); const a = normalizeArticleBody(req.body || {}); if (!a.title) return res.status(400).json({ error: 'title_required' }); db.prepare(` UPDATE articles SET title = @title, category = @category, type = @type, author = @author, author_role = @author_role, author_initial = @author_initial, publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free, summary = @summary, body = @body, cover_image = @cover_image, tags = @tags, featured = @featured, updated_at = datetime('now') WHERE id = @id `).run({ id: req.params.id, ...a }); const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id); res.json(rowToArticle(row)); }); app.delete('/api/articles/:id', authRequired, (req, res) => { const info = db.prepare('DELETE FROM articles WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- risk signals ---------- const RISK_FIELDS = `id, quote, name, date, level, sort_order, created_at, updated_at`; app.get('/api/risks', (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); const rows = db .prepare(`SELECT ${RISK_FIELDS} FROM risk_signals ORDER BY sort_order ASC, created_at DESC LIMIT ?`) .all(limit); res.json(rows.map(rowToRiskSignal)); }); app.get('/api/risks/:id', (req, res) => { const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToRiskSignal(row)); }); const ALLOWED_LEVELS = new Set(['critical', 'high', 'medium', 'low', 'opportunity']); function normalizeRiskBody(b) { const level = ALLOWED_LEVELS.has(b.level) ? b.level : null; return { quote: String(b.quote || '').trim(), name: String(b.name || '').trim(), date: b.date || null, level, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/risks', authRequired, (req, res) => { const r = normalizeRiskBody(req.body || {}); if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' }); const id = nanoid(14); db.prepare(` INSERT INTO risk_signals (id, quote, name, date, level, sort_order) VALUES (@id, @quote, @name, @date, @level, @sort_order) `).run({ id, ...r }); const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(id); res.status(201).json(rowToRiskSignal(row)); }); app.put('/api/risks/:id', authRequired, (req, res) => { const existing = db.prepare('SELECT id FROM risk_signals WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'not_found' }); const r = normalizeRiskBody(req.body || {}); if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' }); db.prepare(` UPDATE risk_signals SET quote = @quote, name = @name, date = @date, level = @level, sort_order = @sort_order, updated_at = datetime('now') WHERE id = @id `).run({ id: req.params.id, ...r }); const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id); res.json(rowToRiskSignal(row)); }); app.delete('/api/risks/:id', authRequired, (req, res) => { const info = db.prepare('DELETE FROM risk_signals WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- users (admin management) ---------- // password_hash is NEVER returned to the client. app.get('/api/users', authRequired, (_req, res) => { const rows = db.prepare('SELECT id, username, created_at FROM users ORDER BY id').all(); res.json(rows.map((r) => ({ id: r.id, username: r.username, createdAt: r.created_at }))); }); app.post('/api/users', authRequired, (req, res) => { const username = String(req.body?.username || '').trim(); const password = String(req.body?.password || ''); if (username.length < 3) return res.status(400).json({ error: 'username_too_short' }); if (password.length < 8) return res.status(400).json({ error: 'password_too_short' }); const exists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(username); if (exists) return res.status(409).json({ error: 'username_taken' }); const hash = bcrypt.hashSync(password, 10); const info = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, hash); res.status(201).json({ id: info.lastInsertRowid, username }); }); app.put('/api/users/:id', authRequired, (req, res) => { const id = Number(req.params.id); const row = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id); if (!row) return res.status(404).json({ error: 'not_found' }); const username = req.body?.username != null ? String(req.body.username).trim() : row.username; const password = req.body?.password != null ? String(req.body.password) : ''; if (username.length < 3) return res.status(400).json({ error: 'username_too_short' }); if (username !== row.username) { const taken = db.prepare('SELECT 1 FROM users WHERE username = ? AND id != ?').get(username, id); if (taken) return res.status(409).json({ error: 'username_taken' }); } if (password) { if (password.length < 8) return res.status(400).json({ error: 'password_too_short' }); const hash = bcrypt.hashSync(password, 10); db.prepare('UPDATE users SET username = ?, password_hash = ? WHERE id = ?').run(username, hash, id); } else { db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id); } res.json({ id, username }); }); app.delete('/api/users/:id', authRequired, (req, res) => { const id = Number(req.params.id); if (req.user?.sub === id) return res.status(400).json({ error: 'cannot_delete_self' }); const count = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; if (count <= 1) return res.status(400).json({ error: 'cannot_delete_last_user' }); const info = db.prepare('DELETE FROM users WHERE id = ?').run(id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- prices (scraped from tgju.org every 2 min) ---------- app.get('/api/prices', (_req, res) => { const rows = db .prepare('SELECT symbol, name, value, unit, change_value, change_pct, source_url, fetched_at FROM prices ORDER BY name') .all(); res.json(rows.map(rowToPrice)); }); app.post('/api/prices/refresh', authRequired, async (_req, res) => { try { const n = await scrapeOnce(); res.json({ ok: true, scraped: n }); } catch (err) { console.error('[scraper] refresh error:', err); res.status(500).json({ error: 'scrape_failed' }); } }); app.get('/api/health', (_req, res) => res.json({ ok: true })); /* ── Contact form → email ─────────────────────────────── */ import nodemailer from 'nodemailer'; function escHtml(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } app.post('/api/contact', contactLimiter, async (req, res) => { const { name, phone, email } = req.body || {}; if (!name || !phone) return res.status(400).json({ error: 'name and phone required' }); const mailTo = process.env.MAIL_TO || 'alirezaameria2@gmail.com, mtdemne@gmail.com'; const transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS }, }); try { await transporter.sendMail({ from: process.env.MAIL_USER, to: mailTo, subject: `درخواست دسترسی نبض صنعت — ${escHtml(name)}`, html: `

درخواست دسترسی — نبض صنعت

نام: ${escHtml(name)}

شماره همراه: ${escHtml(phone)}

ایمیل: ${escHtml(email) || '—'}

`, }); res.json({ ok: true }); } catch (err) { console.error('Mail error:', err.message); res.status(500).json({ error: 'mail failed' }); } }); app.listen(PORT, () => { console.log(`Panel running at ${PUBLIC_ORIGIN}`); const intervalMs = Number(process.env.SCRAPER_INTERVAL_MS) || 2 * 60 * 1000; if (process.env.SCRAPER_DISABLED !== '1') { startScraperLoop(intervalMs); console.log(`[scraper] tgju.org polling every ${intervalMs / 1000}s`); } else { console.log('[scraper] disabled via SCRAPER_DISABLED=1'); } });