347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
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();
|
|
});
|
|
|
|
// ---------- 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, '>').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: `<h2>درخواست دسترسی — نبض صنعت</h2>
|
|
<p><strong>نام:</strong> ${escHtml(name)}</p>
|
|
<p><strong>شماره همراه:</strong> ${escHtml(phone)}</p>
|
|
<p><strong>ایمیل:</strong> ${escHtml(email) || '—'}</p>`,
|
|
});
|
|
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');
|
|
}
|
|
});
|