import 'express-async-errors'; 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 { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import sharp from 'sharp'; 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, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } 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}`; // On Liara, set UPLOADS_DIR to a mounted Disk path (e.g. /var/lib/uploads) so // uploaded images survive redeploys. Defaults to a local folder for dev. const uploadsDir = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads'); // Only needed for local-disk fallback. On Liara uploads go to object storage and // the app dir may be read-only — never let a failed mkdir crash startup. try { if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); } catch (e) { console.warn(`[uploads] could not create ${uploadsDir}: ${e.message} (ok when using object storage)`); } // buffer the upload in memory so we can transcode it to WebP before writing to disk const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (/^image\/(jpe?g|png|webp|gif|avif|tiff?)$/.test(file.mimetype)) cb(null, true); else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)')); }, }); // raw (non-image) uploads — e.g. videos — streamed straight to object storage. const uploadRaw = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (/^(video\/(mp4|webm|ogg|quicktime|x-matroska)|audio\/(mpeg|mp3|wav|x-wav|ogg|webm|aac|x-m4a|mp4)|application\/pdf)$/.test(file.mimetype)) cb(null, true); else cb(new Error('Only video, audio, or pdf uploads are allowed')); }, }); // ── Object storage (Liara / any S3-compatible) ── // Enabled when all LIARA_* env vars are set; otherwise uploads fall back to the local disk. const S3_ENABLED = !!( process.env.LIARA_ENDPOINT && process.env.LIARA_BUCKET && process.env.LIARA_ACCESS_KEY && process.env.LIARA_SECRET_KEY ); const s3Host = (process.env.LIARA_ENDPOINT || '').replace(/^https?:\/\//, '').replace(/\/+$/, ''); const s3 = S3_ENABLED ? new S3Client({ region: 'default', endpoint: `https://${s3Host}`, credentials: { accessKeyId: process.env.LIARA_ACCESS_KEY, secretAccessKey: process.env.LIARA_SECRET_KEY, }, forcePathStyle: true, }) : null; // upload a buffer to the bucket and return its public URL (bucket must allow public reads) async function putObject(key, body, contentType) { await s3.send(new PutObjectCommand({ Bucket: process.env.LIARA_BUCKET, Key: key, Body: body, ContentType: contentType, })); return `https://${process.env.LIARA_BUCKET}.${s3Host}/${key}`; } const app = express(); app.set('trust proxy', 1); // honor X-Forwarded-Proto from the reverse proxy // Send the session cookie with Secure whenever the request actually arrived over HTTPS, // so a forgotten NODE_ENV can't downgrade the cookie to plaintext behind a TLS proxy. const cookieSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https'; 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: '50mb' })); 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) { let token = req.cookies?.session; if (!token) { const auth = req.headers['authorization']; if (auth?.startsWith('Bearer ')) token = auth.slice(7); } 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' }); } } // only an 'owner' admin may manage other admin accounts function ownerRequired(req, res, next) { if (req.user?.role !== 'owner') return res.status(403).json({ error: 'forbidden' }); next(); } async function memberRequired(req, res, next) { // accept the HttpOnly member cookie first, then a Bearer header — never a URL query param let token = req.cookies?.member_session; if (!token) { const auth = req.headers['authorization']; if (auth?.startsWith('Bearer ')) token = auth.slice(7); } if (!token) return res.status(401).json({ error: 'unauthorized' }); try { const payload = jwt.verify(token, JWT_SECRET); if (payload.role !== 'member') return res.status(403).json({ error: 'forbidden' }); // a 30-day token must not outlive a deactivated account — verify per request const acct = await db.prepare('SELECT is_active FROM members WHERE id = ?').get(payload.sub); if (!acct || !acct.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' }); req.member = payload; next(); } catch { return res.status(401).json({ error: 'invalid_token' }); } } // member session cookie options — cross-site capable when served over HTTPS function memberCookieOpts(req) { const secure = cookieSecure(req); return { httpOnly: true, secure, sameSite: secure ? 'none' : 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, path: '/', }; } app.post('/api/auth/login', loginLimiter, async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'username_password_required' }); const row = await db.prepare('SELECT id, username, password_hash, role 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, role: row.role }, JWT_SECRET, { expiresIn: '24h' }); res.cookie('session', token, { httpOnly: true, secure: cookieSecure(req), sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000, path: '/', }); // token is delivered only via the HttpOnly cookie — never in the body (XSS can't read it) res.json({ user: { id: row.id, username: row.username, role: row.role } }); }); app.post('/api/auth/logout', (req, res) => { res.clearCookie('session', { httpOnly: true, secure: cookieSecure(req), 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', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); const { category, type } = req.query; let query = `SELECT ${PUBLIC_FIELDS} FROM articles`; const params = []; const conditions = []; if (category) { conditions.push('category = ?'); params.push(category); } if (type) { conditions.push('type = ?'); params.push(type); } if (conditions.length) query += ' WHERE ' + conditions.join(' AND '); query += ' ORDER BY created_at DESC LIMIT ?'; params.push(limit); const rows = await db.prepare(query).all(...params); res.json(rows.map(rowToArticle)); }); app.get('/api/articles/:id', async (req, res) => { const row = await 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'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'no_file' }); try { const original = req.file.buffer; const originalSize = original.length; const isAnimated = req.file.mimetype === 'image/gif'; // cap very large images, then transcode to WebP. Step quality down until the // output is ≤ ~25% of the original (or we hit the quality floor). const target = Math.round(originalSize * 0.25); let out = null; for (const quality of [80, 70, 60, 50, 40, 32]) { const buf = await sharp(original, { animated: isAnimated }) .rotate() // respect EXIF orientation .resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true }) .webp({ quality, effort: 4 }) .toBuffer(); out = buf; if (buf.length <= target) break; } // never write a file larger than the original const finalBuf = out.length < originalSize ? out : await sharp(original, { animated: isAnimated }) .webp({ quality: 80 }).toBuffer(); const filename = `${nanoid(12)}.webp`; let url, absoluteUrl; if (s3) { absoluteUrl = await putObject(filename, finalBuf, 'image/webp'); url = absoluteUrl; // store the full bucket URL so the frontend loads it directly } else { fs.writeFileSync(path.join(uploadsDir, filename), finalBuf); url = `/uploads/${filename}`; absoluteUrl = `${PUBLIC_ORIGIN}${url}`; } res.json({ url, absoluteUrl, originalSize, size: finalBuf.length, reduction: Math.round((1 - finalBuf.length / originalSize) * 100), }); } catch (err) { console.error('[upload] webp conversion failed:', err.message); res.status(500).json({ error: 'conversion_failed' }); } }); // raw file upload (videos / pdf) → object storage. Requires S3 to be configured. app.post('/api/uploads/raw', authRequired, uploadRaw.single('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'no_file' }); if (!s3) return res.status(503).json({ error: 'object_storage_not_configured' }); try { const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase(); const key = `${nanoid(12)}${ext}`; const url = await putObject(key, req.file.buffer, req.file.mimetype); res.json({ url, absoluteUrl: url, size: req.file.size, contentType: req.file.mimetype }); } catch (err) { console.error('[upload:raw] failed:', err.message); res.status(500).json({ error: 'upload_failed' }); } }); 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, async (req, res) => { const a = normalizeArticleBody(req.body || {}); if (!a.title) return res.status(400).json({ error: 'title_required' }); const id = nanoid(14); await 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 = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id); res.status(201).json(rowToArticle(row)); }); app.put('/api/articles/:id', authRequired, async (req, res) => { const existing = await 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' }); await 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 = now() WHERE id = @id `).run({ id: req.params.id, ...a }); const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id); res.json(rowToArticle(row)); }); app.delete('/api/articles/:id', authRequired, async (req, res) => { const info = await 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(); }); // ---------- bulk import (Gemini JSON) ---------- // Gemini grounding leaves [cite: 1921] / [cite: 1921, 1931] markers in the text — strip them // (and the whitespace artifacts they leave behind) before anything hits the DB. function stripCites(s) { return s .replace(/\[cite:[^\]]*\]/g, '') .replace(/[ \t]+([.،؛:!؟])/g, '$1') // space before punctuation .replace(/[ \t]{2,}/g, ' ') // runs of spaces .replace(/[ \t]+\n/g, '\n') // trailing space before newline (keeps \n\n) .trim(); } function deepSanitize(v) { if (typeof v === 'string') return stripCites(v); if (Array.isArray(v)) return v.map(deepSanitize); if (v && typeof v === 'object') { const o = {}; for (const k of Object.keys(v)) o[k] = deepSanitize(v[k]); return o; } return v; } app.post('/api/admin/import', authRequired, async (req, res) => { const { report, radar_items = [], fa_summary_long, key_data } = deepSanitize(req.body || {}); if (!report?.title) return res.status(400).json({ error: 'report.title required' }); const results = { article: null, radar: [] }; // upsert article const articleId = report.id || nanoid(14); const body = fa_summary_long ? (key_data ? fa_summary_long + '\n\n---KEY_DATA---\n' + JSON.stringify(key_data, null, 2) : fa_summary_long) : (key_data ? JSON.stringify(key_data, null, 2) : null); await db.prepare(` INSERT INTO articles (id, title, category, type, author, author_role, author_initial, publish_date, pages, price, is_free, summary, body, tags, cover_image, featured, status) VALUES (@id, @title, @category, @type, @author, @author_role, @author_initial, @publish_date, @pages, @price, @is_free, @summary, @body, @tags, @cover_image, @featured, 'published') ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, category = EXCLUDED.category, type = EXCLUDED.type, author = EXCLUDED.author, author_role = EXCLUDED.author_role, author_initial = EXCLUDED.author_initial, publish_date = EXCLUDED.publish_date, pages = EXCLUDED.pages, price = EXCLUDED.price, is_free = EXCLUDED.is_free, summary = EXCLUDED.summary, body = EXCLUDED.body, tags = EXCLUDED.tags, cover_image = EXCLUDED.cover_image, featured = EXCLUDED.featured, status = EXCLUDED.status, updated_at = now() `).run({ id: articleId, title: report.title, category: report.category || null, type: report.type || 'special', author: report.author || null, author_role: report.authorRole || report.author_role || null, author_initial: report.authorInitial || report.author_initial || null, publish_date: report.publishDate || report.publish_date || null, pages: Number(report.pages) || 0, price: Number(report.price) || 0, is_free: (report.isFree ?? report.is_free ?? false) ? 1 : 0, summary: report.summary || null, body, tags: JSON.stringify(Array.isArray(report.tags) ? report.tags : []), cover_image: report.coverImage || report.cover_image || null, featured: report.featured ? 1 : 0, }); results.article = articleId; // upsert radar items const insertRadar = db.prepare(` INSERT INTO radar_items (id, category, img, title_fa, excerpt_fa, date_fa, author_name, sort_order) VALUES (@id, @category, @img, @title_fa, @excerpt_fa, @date_fa, @author_name, @sort_order) ON CONFLICT (id) DO UPDATE SET category = EXCLUDED.category, img = EXCLUDED.img, title_fa = EXCLUDED.title_fa, excerpt_fa = EXCLUDED.excerpt_fa, date_fa = EXCLUDED.date_fa, author_name = EXCLUDED.author_name, sort_order = EXCLUDED.sort_order, updated_at = now() `); for (let i = 0; i < radar_items.length; i++) { const item = radar_items[i]; const rid = item.id || nanoid(12); await insertRadar.run({ id: rid, category: item.category || 'market', img: item.img || item.image || report.coverImage || '', title_fa: item.title || '', excerpt_fa: item.summary || item.excerpt_fa || '', date_fa: item.date || '', author_name: report.author || '', sort_order: i, }); results.radar.push(rid); } res.json({ ok: true, ...results }); }); // ---------- events ---------- const EVENT_FIELDS = `id, title_fa, title_en, date_fa, date_en, month_fa, month_en, year, type, location_fa, location_en, city_fa, city_en, description_fa, description_en, registration_open, sort_order, created_at, updated_at`; app.get('/api/events', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); const rows = await db.prepare(`SELECT ${EVENT_FIELDS} FROM events ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToEvent)); }); app.get('/api/events/:id', async (req, res) => { const row = await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToEvent(row)); }); function normalizeEventBody(b) { return { title_fa: String(b.titleFa || '').trim(), title_en: b.titleEn || null, date_fa: b.dateFa || null, date_en: b.dateEn || null, month_fa: b.monthFa || null, month_en: b.monthEn || null, year: b.year || null, type: ['conference','exhibition','seminar','international'].includes(b.type) ? b.type : null, location_fa: b.locationFa || null, location_en: b.locationEn || null, city_fa: b.cityFa || null, city_en: b.cityEn || null, description_fa: b.descriptionFa || null, description_en: b.descriptionEn || null, registration_open: b.registrationOpen ? 1 : 0, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/events', authRequired, async (req, res) => { const e = normalizeEventBody(req.body || {}); if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO events (id,title_fa,title_en,date_fa,date_en,month_fa,month_en,year,type, location_fa,location_en,city_fa,city_en,description_fa,description_en,registration_open,sort_order) VALUES (@id,@title_fa,@title_en,@date_fa,@date_en,@month_fa,@month_en,@year,@type, @location_fa,@location_en,@city_fa,@city_en,@description_fa,@description_en,@registration_open,@sort_order) `).run({ id, ...e }); res.status(201).json(rowToEvent(await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(id))); }); app.put('/api/events/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const e = normalizeEventBody(req.body || {}); if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' }); await db.prepare(`UPDATE events SET title_fa=@title_fa,title_en=@title_en,date_fa=@date_fa,date_en=@date_en, month_fa=@month_fa,month_en=@month_en,year=@year,type=@type,location_fa=@location_fa,location_en=@location_en, city_fa=@city_fa,city_en=@city_en,description_fa=@description_fa,description_en=@description_en, registration_open=@registration_open,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...e }); res.json(rowToEvent(await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id))); }); app.delete('/api/events/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- radar ---------- const RADAR_FIELDS = `id, category, img, author_name, author_avatar, title_fa, date_fa, excerpt_fa, body_fa, analysis_fa, title_en, date_en, excerpt_en, body_en, analysis_en, sort_order, created_at, updated_at`; app.get('/api/radar', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 500); const rows = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToRadarItem)); }); app.get('/api/radar/:id', async (req, res) => { const row = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToRadarItem(row)); }); function normalizeRadarBody(b) { return { category: ['market','tech','commodity','geo','energy'].includes(b.category) ? b.category : null, img: b.img || null, author_name: b.authorName || null, author_avatar: b.authorAvatar || null, title_fa: String(b.titleFa || '').trim(), date_fa: b.dateFa || null, excerpt_fa: b.excerptFa || null, body_fa: b.bodyFa || null, analysis_fa: b.analysisFa || null, title_en: b.titleEn || null, date_en: b.dateEn || null, excerpt_en: b.excerptEn || null, body_en: b.bodyEn || null, analysis_en: b.analysisEn || null, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/radar', authRequired, async (req, res) => { const r = normalizeRadarBody(req.body || {}); if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar, title_fa,date_fa,excerpt_fa,body_fa,analysis_fa, title_en,date_en,excerpt_en,body_en,analysis_en,sort_order) VALUES (@id,@category,@img,@author_name,@author_avatar, @title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa, @title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@sort_order) `).run({ id, ...r }); res.status(201).json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id))); }); app.put('/api/radar/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM radar_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const r = normalizeRadarBody(req.body || {}); if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' }); await db.prepare(`UPDATE radar_items SET category=@category,img=@img,author_name=@author_name, author_avatar=@author_avatar,title_fa=@title_fa,date_fa=@date_fa,excerpt_fa=@excerpt_fa, body_fa=@body_fa,analysis_fa=@analysis_fa, title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en, body_en=@body_en,analysis_en=@analysis_en, sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...r }); res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id))); }); app.delete('/api/radar/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM radar_items WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- radar pages (per-category hero + banner) ---------- const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en, latest_heading_fa, latest_heading_en, featured_title_fa, featured_title_en, featured_date_fa, featured_date_en, featured_img, banner_kicker_fa, banner_kicker_en, banner_title_fa, banner_title_en, banner_desc_fa, banner_desc_en, banner_book_img, sort_order, created_at, updated_at`; app.get('/api/radar-pages', async (req, res) => { const rows = await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages ORDER BY sort_order ASC`).all(); res.json(rows.map(rowToRadarPage)); }); app.get('/api/radar-pages/:slug', async (req, res) => { const row = await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(req.params.slug); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToRadarPage(row)); }); function normalizeRadarPageBody(b) { return { source_categories: JSON.stringify(Array.isArray(b.sourceCategories) ? b.sourceCategories : []), label_fa: String(b.labelFa || '').trim(), label_en: b.labelEn || null, latest_heading_fa: b.latestHeadingFa || null, latest_heading_en: b.latestHeadingEn || null, featured_title_fa: b.featuredTitleFa || null, featured_title_en: b.featuredTitleEn || null, featured_date_fa: b.featuredDateFa || null, featured_date_en: b.featuredDateEn || null, featured_img: b.featuredImg || null, banner_kicker_fa: b.bannerKickerFa || null, banner_kicker_en: b.bannerKickerEn || null, banner_title_fa: b.bannerTitleFa || null, banner_title_en: b.bannerTitleEn || null, banner_desc_fa: b.bannerDescFa || null, banner_desc_en: b.bannerDescEn || null, banner_book_img: b.bannerBookImg || null, sort_order: Number(b.sortOrder) || 0, }; } // upsert by slug (slug is the stable key for the 5 fixed pages) app.put('/api/radar-pages/:slug', authRequired, async (req, res) => { const slug = req.params.slug; const r = normalizeRadarPageBody(req.body || {}); if (!r.label_fa) return res.status(400).json({ error: 'label_fa_required' }); const exists = await db.prepare('SELECT slug FROM radar_pages WHERE slug = ?').get(slug); if (exists) { await db.prepare(`UPDATE radar_pages SET source_categories=@source_categories,label_fa=@label_fa,label_en=@label_en, latest_heading_fa=@latest_heading_fa,latest_heading_en=@latest_heading_en, featured_title_fa=@featured_title_fa,featured_title_en=@featured_title_en, featured_date_fa=@featured_date_fa,featured_date_en=@featured_date_en,featured_img=@featured_img, banner_kicker_fa=@banner_kicker_fa,banner_kicker_en=@banner_kicker_en, banner_title_fa=@banner_title_fa,banner_title_en=@banner_title_en, banner_desc_fa=@banner_desc_fa,banner_desc_en=@banner_desc_en,banner_book_img=@banner_book_img, sort_order=@sort_order,updated_at=now() WHERE slug=@slug `).run({ slug, ...r }); } else { await db.prepare(`INSERT INTO radar_pages (slug,source_categories,label_fa,label_en, latest_heading_fa,latest_heading_en,featured_title_fa,featured_title_en, featured_date_fa,featured_date_en,featured_img,banner_kicker_fa,banner_kicker_en, banner_title_fa,banner_title_en,banner_desc_fa,banner_desc_en,banner_book_img,sort_order) VALUES (@slug,@source_categories,@label_fa,@label_en,@latest_heading_fa,@latest_heading_en, @featured_title_fa,@featured_title_en,@featured_date_fa,@featured_date_en,@featured_img, @banner_kicker_fa,@banner_kicker_en,@banner_title_fa,@banner_title_en, @banner_desc_fa,@banner_desc_en,@banner_book_img,@sort_order) `).run({ slug, ...r }); } res.json(rowToRadarPage(await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(slug))); }); // ---------- factory reports ---------- const FACTORY_REPORT_FIELDS = `id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en, excerpt_fa, excerpt_en, scroll_dir, sort_order, created_at, updated_at`; app.get('/api/factory-reports', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); const rows = await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToFactoryReport)); }); app.get('/api/factory-reports/:id', async (req, res) => { const row = await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToFactoryReport(row)); }); function normalizeFactoryReportBody(b) { return { img: b.img || null, tag_fa: b.tagFa || null, tag_en: b.tagEn || null, title_fa: String(b.titleFa || '').trim(), title_en: b.titleEn || null, date_fa: b.dateFa || null, date_en: b.dateEn || null, excerpt_fa: b.excerptFa || null, excerpt_en: b.excerptEn || null, scroll_dir: ['left','right','up'].includes(b.scrollDir) ? b.scrollDir : 'up', sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/factory-reports', authRequired, async (req, res) => { const r = normalizeFactoryReportBody(req.body || {}); if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO factory_reports (id,img,tag_fa,tag_en,title_fa,title_en,date_fa,date_en, excerpt_fa,excerpt_en,scroll_dir,sort_order) VALUES (@id,@img,@tag_fa,@tag_en,@title_fa,@title_en,@date_fa,@date_en, @excerpt_fa,@excerpt_en,@scroll_dir,@sort_order) `).run({ id, ...r }); res.status(201).json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id))); }); app.put('/api/factory-reports/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM factory_reports WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const r = normalizeFactoryReportBody(req.body || {}); if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' }); await db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en, date_fa=@date_fa,date_en=@date_en,excerpt_fa=@excerpt_fa,excerpt_en=@excerpt_en, scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...r }); res.json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id))); }); app.delete('/api/factory-reports/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM factory_reports WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- integrations ---------- const INTEGRATION_FIELDS = `id, name, icon, logo, accent, grid_col, grid_row, sort_order, created_at, updated_at`; const INTEGRATION_ICONS = ['exchange','globe','chart','activity','database','newspaper','building','trending','coins']; app.get('/api/integrations', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); const rows = await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToIntegration)); }); app.get('/api/integrations/:id', async (req, res) => { const row = await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToIntegration(row)); }); function normalizeIntegrationBody(b) { return { name: String(b.name || '').trim(), icon: INTEGRATION_ICONS.includes(b.icon) ? b.icon : 'globe', logo: b.logo || null, accent: b.accent || null, grid_col: Number(b.gridCol) || 1, grid_row: Number(b.gridRow) || 1, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/integrations', authRequired, async (req, res) => { const it = normalizeIntegrationBody(req.body || {}); if (!it.name) return res.status(400).json({ error: 'name_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO integrations (id,name,icon,logo,accent,grid_col,grid_row,sort_order) VALUES (@id,@name,@icon,@logo,@accent,@grid_col,@grid_row,@sort_order) `).run({ id, ...it }); res.status(201).json(rowToIntegration(await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(id))); }); app.put('/api/integrations/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM integrations WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const it = normalizeIntegrationBody(req.body || {}); if (!it.name) return res.status(400).json({ error: 'name_required' }); await db.prepare(`UPDATE integrations SET name=@name,icon=@icon,logo=@logo,accent=@accent, grid_col=@grid_col,grid_row=@grid_row,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...it }); res.json(rowToIntegration(await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id))); }); app.delete('/api/integrations/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM integrations WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- institute stats ---------- const INSTITUTE_STATS_FIELDS = `id,label_fa,label_en,value,suffix_fa,sort_order,created_at,updated_at`; app.get('/api/institute-stats', async (_req, res) => { res.json((await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats ORDER BY sort_order ASC`).all()).map(rowToInstituteStat)); }); app.get('/api/institute-stats/:id', async (req, res) => { const row = await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToInstituteStat(row)); }); function normalizeInstituteStatBody(b) { return { label_fa: String(b.labelFa || '').trim(), label_en: b.labelEn || null, value: Number(b.value) || 0, suffix_fa: b.suffixFa || null, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/institute-stats', authRequired, async (req, res) => { const s = normalizeInstituteStatBody(req.body || {}); if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' }); const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14); await db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order) VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order) `).run({ id, ...s }); res.status(201).json(rowToInstituteStat(await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(id))); }); app.put('/api/institute-stats/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM institute_stats WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const s = normalizeInstituteStatBody(req.body || {}); if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' }); await db.prepare(`UPDATE institute_stats SET label_fa=@label_fa,label_en=@label_en,value=@value, suffix_fa=@suffix_fa,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...s }); res.json(rowToInstituteStat(await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id))); }); app.delete('/api/institute-stats/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM institute_stats WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- market prices ---------- const MARKET_PRICE_FIELDS = `id,name,value,unit,change,change_percent,trend,sort_order,created_at,updated_at`; app.get('/api/market-prices', async (_req, res) => { res.json((await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices ORDER BY sort_order ASC`).all()).map(rowToMarketPrice)); }); app.get('/api/market-prices/:id', async (req, res) => { const row = await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToMarketPrice(row)); }); function normalizeMarketPriceBody(b) { return { name: String(b.name || '').trim(), value: Number(b.value) || 0, unit: b.unit || null, change: Number(b.change) || 0, change_percent: Number(b.changePercent) || 0, trend: ['up','down','flat'].includes(b.trend) ? b.trend : 'flat', sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/market-prices', authRequired, async (req, res) => { const p = normalizeMarketPriceBody(req.body || {}); if (!p.name) return res.status(400).json({ error: 'name_required' }); const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14); await db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order) VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order) `).run({ id, ...p }); res.status(201).json(rowToMarketPrice(await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(id))); }); app.put('/api/market-prices/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM market_prices WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const p = normalizeMarketPriceBody(req.body || {}); if (!p.name) return res.status(400).json({ error: 'name_required' }); await db.prepare(`UPDATE market_prices SET name=@name,value=@value,unit=@unit,change=@change, change_percent=@change_percent,trend=@trend,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...p }); res.json(rowToMarketPrice(await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id))); }); app.delete('/api/market-prices/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM market_prices WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- market chart points (history | export | comparison) ---------- const CHART_POINT_FIELDS = `id,series,label,value,meta,sort_order,created_at,updated_at`; const ALLOWED_SERIES = new Set(['history','export','comparison']); app.get('/api/market-chart-points', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 200, 1000); const series = ALLOWED_SERIES.has(req.query.series) ? req.query.series : null; let q = `SELECT ${CHART_POINT_FIELDS} FROM market_chart_points`; const params = []; if (series) { q += ' WHERE series = ?'; params.push(series); } q += ' ORDER BY series ASC, sort_order ASC LIMIT ?'; params.push(limit); res.json((await db.prepare(q).all(...params)).map(rowToMarketChartPoint)); }); app.get('/api/market-chart-points/:id', async (req, res) => { const row = await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToMarketChartPoint(row)); }); function normalizeChartPointBody(b) { let meta = null; if (b.meta != null && b.meta !== '') { meta = typeof b.meta === 'string' ? b.meta : JSON.stringify(b.meta); } return { series: ALLOWED_SERIES.has(b.series) ? b.series : null, label: String(b.label || '').trim(), value: Number(b.value) || 0, meta, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/market-chart-points', authRequired, async (req, res) => { const c = normalizeChartPointBody(req.body || {}); if (!c.series) return res.status(400).json({ error: 'series_required' }); if (!c.label) return res.status(400).json({ error: 'label_required' }); const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14); await db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order) VALUES (@id,@series,@label,@value,@meta,@sort_order) `).run({ id, ...c }); res.status(201).json(rowToMarketChartPoint(await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(id))); }); app.put('/api/market-chart-points/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM market_chart_points WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const c = normalizeChartPointBody(req.body || {}); if (!c.series) return res.status(400).json({ error: 'series_required' }); if (!c.label) return res.status(400).json({ error: 'label_required' }); await db.prepare(`UPDATE market_chart_points SET series=@series,label=@label,value=@value,meta=@meta, sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...c }); res.json(rowToMarketChartPoint(await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id))); }); app.delete('/api/market-chart-points/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM market_chart_points WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- vision items (About page) ---------- const VISION_FIELDS = `id, icon, title_fa, title_en, desc_fa, desc_en, sort_order, created_at, updated_at`; app.get('/api/vision-items', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); const rows = await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToVisionItem)); }); app.get('/api/vision-items/:id', async (req, res) => { const row = await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToVisionItem(row)); }); function normalizeVisionBody(b) { return { icon: b.icon || null, title_fa: String(b.titleFa || '').trim(), title_en: b.titleEn || null, desc_fa: b.descFa || null, desc_en: b.descEn || null, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/vision-items', authRequired, async (req, res) => { const v = normalizeVisionBody(req.body || {}); if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO vision_items (id,icon,title_fa,title_en,desc_fa,desc_en,sort_order) VALUES (@id,@icon,@title_fa,@title_en,@desc_fa,@desc_en,@sort_order) `).run({ id, ...v }); res.status(201).json(rowToVisionItem(await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(id))); }); app.put('/api/vision-items/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM vision_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const v = normalizeVisionBody(req.body || {}); if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' }); await db.prepare(`UPDATE vision_items SET icon=@icon,title_fa=@title_fa,title_en=@title_en, desc_fa=@desc_fa,desc_en=@desc_en,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...v }); res.json(rowToVisionItem(await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id))); }); app.delete('/api/vision-items/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM vision_items WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- advisory board (About page) ---------- const ADVISORY_FIELDS = `id, name_fa, name_en, role_fa, role_en, sort_order, created_at, updated_at`; app.get('/api/advisory-board', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); const rows = await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToAdvisoryMember)); }); app.get('/api/advisory-board/:id', async (req, res) => { const row = await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToAdvisoryMember(row)); }); function normalizeAdvisoryBody(b) { return { name_fa: String(b.nameFa || '').trim(), name_en: b.nameEn || null, role_fa: b.roleFa || null, role_en: b.roleEn || null, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/advisory-board', authRequired, async (req, res) => { const m = normalizeAdvisoryBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO advisory_board (id,name_fa,name_en,role_fa,role_en,sort_order) VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@sort_order) `).run({ id, ...m }); res.status(201).json(rowToAdvisoryMember(await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(id))); }); app.put('/api/advisory-board/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM advisory_board WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const m = normalizeAdvisoryBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); await db.prepare(`UPDATE advisory_board SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa, role_en=@role_en,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...m }); res.json(rowToAdvisoryMember(await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id))); }); app.delete('/api/advisory-board/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM advisory_board WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- team members & experts ---------- const TEAM_FIELDS = `id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo, report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order,created_at,updated_at`; app.get('/api/team', async (req, res) => { const isExpert = req.query.expert === '1' ? 1 : req.query.expert === '0' ? 0 : null; let query = `SELECT ${TEAM_FIELDS} FROM team_members`; const params = []; if (isExpert !== null) { query += ' WHERE is_expert = ?'; params.push(isExpert); } query += ' ORDER BY sort_order ASC, created_at DESC'; res.json((await db.prepare(query).all(...params)).map(rowToTeamMember)); }); app.get('/api/team/:id', async (req, res) => { const row = await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToTeamMember(row)); }); function normalizeTeamBody(b) { return { name_fa: String(b.nameFa || '').trim(), name_en: b.nameEn || null, role_fa: b.roleFa || null, role_en: b.roleEn || null, bio_fa: b.bioFa || null, bio_en: b.bioEn || null, expertise: JSON.stringify(Array.isArray(b.expertise) ? b.expertise : []), email: b.email || null, initial: b.initial || null, photo: b.photo || null, report_count: Number(b.reportCount) || 0, is_expert: b.isExpert ? 1 : 0, expert_room_fa: b.expertRoomFa || null, expert_room_en: b.expertRoomEn || null, telegram: b.telegram || null, linkedin: b.linkedin || null, twitter: b.twitter || null, website: b.website || null, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/team', authRequired, async (req, res) => { const m = normalizeTeamBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO team_members (id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo, report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order) VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@bio_fa,@bio_en,@expertise,@email,@initial,@photo, @report_count,@is_expert,@expert_room_fa,@expert_room_en,@telegram,@linkedin,@twitter,@website,@sort_order) `).run({ id, ...m }); res.status(201).json(rowToTeamMember(await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(id))); }); app.put('/api/team/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM team_members WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const m = normalizeTeamBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); await db.prepare(`UPDATE team_members SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,role_en=@role_en, bio_fa=@bio_fa,bio_en=@bio_en,expertise=@expertise,email=@email,initial=@initial,photo=@photo, report_count=@report_count,is_expert=@is_expert,expert_room_fa=@expert_room_fa,expert_room_en=@expert_room_en, telegram=@telegram,linkedin=@linkedin,twitter=@twitter,website=@website, sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...m }); res.json(rowToTeamMember(await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id))); }); app.delete('/api/team/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM team_members WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- plans ---------- const PLAN_FIELDS = `id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order,created_at,updated_at`; app.get('/api/plans', async (req, res) => { res.json((await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans ORDER BY sort_order ASC`).all()).map(rowToPlan)); }); app.get('/api/plans/:id', async (req, res) => { const row = await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToPlan(row)); }); function normalizePlanBody(b) { return { name_fa: String(b.nameFa || '').trim(), name_en: b.nameEn || null, price: Number(b.price) || 0, period_fa: b.periodFa || null, period_en: b.periodEn || null, features: JSON.stringify(Array.isArray(b.features) ? b.features : []), badge_fa: b.badgeFa || null, badge_en: b.badgeEn || null, is_featured: b.isFeatured ? 1 : 0, cta_fa: b.ctaFa || null, cta_en: b.ctaEn || null, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/plans', authRequired, async (req, res) => { const p = normalizePlanBody(req.body || {}); if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO plans (id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order) VALUES (@id,@name_fa,@name_en,@price,@period_fa,@period_en,@features,@badge_fa,@badge_en,@is_featured,@cta_fa,@cta_en,@sort_order) `).run({ id, ...p }); res.status(201).json(rowToPlan(await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(id))); }); app.put('/api/plans/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM plans WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const p = normalizePlanBody(req.body || {}); if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' }); await db.prepare(`UPDATE plans SET name_fa=@name_fa,name_en=@name_en,price=@price,period_fa=@period_fa,period_en=@period_en, features=@features,badge_fa=@badge_fa,badge_en=@badge_en,is_featured=@is_featured,cta_fa=@cta_fa,cta_en=@cta_en, sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...p }); res.json(rowToPlan(await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id))); }); app.delete('/api/plans/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM plans 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', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); const rows = await 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', async (req, res) => { const row = await 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, async (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); await 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 = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(id); res.status(201).json(rowToRiskSignal(row)); }); app.put('/api/risks/:id', authRequired, async (req, res) => { const existing = await 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' }); await db.prepare(` UPDATE risk_signals SET quote = @quote, name = @name, date = @date, level = @level, sort_order = @sort_order, updated_at = now() WHERE id = @id `).run({ id: req.params.id, ...r }); const row = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id); res.json(rowToRiskSignal(row)); }); app.delete('/api/risks/:id', authRequired, async (req, res) => { const info = await 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, async (_req, res) => { const rows = await db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id').all(); res.json(rows.map((r) => ({ id: r.id, username: r.username, role: r.role, createdAt: r.created_at }))); }); app.post('/api/users', authRequired, ownerRequired, async (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 = await 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 role = req.body?.role === 'owner' ? 'owner' : 'admin'; const info = await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(username, hash, role); res.status(201).json({ id: info.lastInsertRowid, username, role }); }); app.put('/api/users/:id', authRequired, async (req, res) => { const id = Number(req.params.id); const row = await 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 = await 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); await db.prepare('UPDATE users SET username = ?, password_hash = ? WHERE id = ?').run(username, hash, id); } else { await db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id); } res.json({ id, username }); }); app.delete('/api/users/:id', authRequired, ownerRequired, async (req, res) => { const id = Number(req.params.id); if (req.user?.sub === id) return res.status(400).json({ error: 'cannot_delete_self' }); const count = (await 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 = await 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', async (_req, res) => { const rows = await 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' }); } }); // ---------- banners ---------- const BANNER_FIELDS = `id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order,created_at,updated_at`; app.get('/api/banners', async (req, res) => { const onlyActive = req.query.active === '1'; let q = `SELECT ${BANNER_FIELDS} FROM banners`; if (onlyActive) q += ' WHERE active = 1'; q += ' ORDER BY sort_order ASC, created_at DESC'; res.json((await db.prepare(q).all()).map(rowToBanner)); }); app.get('/api/banners/:id', async (req, res) => { const row = await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToBanner(row)); }); function normalizeBannerBody(b) { return { overline_fa: b.overlineFa || null, overline_en: b.overlineEn || null, title_fa: String(b.titleFa || '').trim(), title_en: b.titleEn || null, subtitle_fa: b.subtitleFa || null, subtitle_en: b.subtitleEn || null, cta_label_fa: b.ctaLabelFa || null, cta_label_en: b.ctaLabelEn || null, cta_url: b.ctaUrl || null, image: b.image || null, position: b.position || 'hero', active: b.active !== false ? 1 : 0, sort_order: Number(b.sortOrder) || 0, }; } app.post('/api/banners', authRequired, async (req, res) => { const bn = normalizeBannerBody(req.body || {}); if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); await db.prepare(`INSERT INTO banners (id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order) VALUES (@id,@overline_fa,@overline_en,@title_fa,@title_en,@subtitle_fa,@subtitle_en,@cta_label_fa,@cta_label_en,@cta_url,@image,@position,@active,@sort_order) `).run({ id, ...bn }); res.status(201).json(rowToBanner(await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(id))); }); app.put('/api/banners/:id', authRequired, async (req, res) => { if (!await db.prepare('SELECT id FROM banners WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const bn = normalizeBannerBody(req.body || {}); if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' }); await db.prepare(`UPDATE banners SET overline_fa=@overline_fa,overline_en=@overline_en,title_fa=@title_fa,title_en=@title_en,subtitle_fa=@subtitle_fa,subtitle_en=@subtitle_en, cta_label_fa=@cta_label_fa,cta_label_en=@cta_label_en,cta_url=@cta_url,image=@image, position=@position,active=@active,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...bn }); res.json(rowToBanner(await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id))); }); app.delete('/api/banners/:id', authRequired, async (req, res) => { const info = await db.prepare('DELETE FROM banners WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); 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; if (!mailTo) { console.error('Mail error: MAIL_TO is not configured'); return res.status(500).json({ error: 'mail failed' }); } 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' }); } }); // ---------- PDF analyser ---------- const SECTION_KEYWORDS = { market: { words: ['بازار','قیمت','صادرات','واردات','معامله','تجارت','فولاد','آهن','سنگ آهن','بلوم','تختال','کویل','عرضه','تقاضا','تولید','اقتصاد','market','price','trade','export','import','steel','iron ore','billet','slab','coil','supply','demand','production'], label: 'بازار و زنجیره فولاد', }, technology: { words: ['فناوری','هوش مصنوعی','دیجیتال','اتوماسیون','نرمافزار','ربات','هوشمند','نوآوری','تکنولوژی','داده','technology','ai','digital','automation','software','robot','smart','innovation','iot','machine learning'], label: 'فناوری صنعتی', }, geopolitics: { words: ['ژئوپلیتیک','تحریم','سیاست','دیپلماسی','جنگ','تنش','بینالملل','ژئواکونومی','قدرت','geopolitics','sanction','policy','diplomacy','war','tension','international','brics','conflict'], label: 'ژئواکونومی', }, sustainability: { words: ['محیط زیست','کربن','پایداری','سبز','آلودگی','اقلیم','انرژی پاک','انتشار','تجدیدپذیر','esg','carbon','sustainability','environment','green','climate','emission','renewable','clean energy'], label: 'پایداری محیطی', }, foresight: { words: ['آینده','سناریو','پیشبینی','روند','چشمانداز','آیندهنگاری','استراتژی','رصد','تحلیل','future','scenario','forecast','trend','outlook','foresight','strategy','horizon','intelligence'], label: 'آیندهنگاری', }, }; function classifyPdfText(text) { const lower = text.toLowerCase(); const scores = {}; for (const [cat, data] of Object.entries(SECTION_KEYWORDS)) { let score = 0; const matched = []; for (const kw of data.words) { const hits = (lower.match(new RegExp(kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; if (hits > 0) { score += hits; matched.push(kw); } } scores[cat] = { score, matched, label: data.label }; } const sorted = Object.entries(scores).sort((a, b) => b[1].score - a[1].score); const [topCat, topData] = sorted[0]; const secondScore = sorted[1]?.[1].score || 0; const confidence = topData.score > 0 ? Math.min(100, Math.round(((topData.score - secondScore) / (topData.score + 1)) * 100 + 40)) : 0; return { category: topCat, label: topData.label, score: topData.score, confidence, keywords: [...new Set(topData.matched)].slice(0, 8), allScores: Object.fromEntries(sorted.map(([c, d]) => [c, { score: d.score, label: d.label }])), }; } function extractPdfTitle(text) { const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 8 && l.length < 180); return lines[0] || 'بدون عنوان'; } app.post('/api/pdf-analyze', authRequired, async (req, res) => { try { const { base64 } = req.body || {}; if (!base64) return res.status(400).json({ error: 'فایل PDF ارسال نشده' }); // cap before decode: ~15MB base64 ≈ 11MB PDF — prevents memory DoS if (typeof base64 !== 'string' || base64.length > 15_000_000) { return res.status(413).json({ error: 'حجم فایل بیش از حد مجاز است' }); } const buf = Buffer.from(base64, 'base64'); const { PDFParse } = await import('pdf-parse'); const parser = new PDFParse({ data: buf }); const result = await parser.getText(); const text = result.text || ''; const pageCount = result.total || result.pages?.length || 0; await parser.destroy(); // detect garbled text: check ratio of Persian/Arabic Unicode chars const persianChars = (text.match(/[-ۿ]/g) || []).length; const meaningfulChars = text.replace(/\s/g, '').length; const isGarbled = meaningfulChars > 50 && (persianChars / meaningfulChars) < 0.15; const title = isGarbled ? '' : extractPdfTitle(text); const excerpt = isGarbled ? '' : text.replace(/\n{3,}/g, '\n\n').trim().slice(0, 800); const classification = isGarbled ? null : classifyPdfText(text); res.json({ title, excerpt, text_length: text.length, page_count: pageCount, classification, is_garbled: isGarbled }); } catch (err) { console.error('PDF parse error:', err); res.status(500).json({ error: 'خطا در تجزیه PDF: ' + err.message }); } }); app.post('/api/pdf-generate', authRequired, async (req, res) => { const apiKey = process.env.AI_API_KEY || process.env.ANTHROPIC_API_KEY; if (!apiKey) return res.status(503).json({ error: 'AI_API_KEY در فایل .env تنظیم نشده' }); const { text, category } = req.body || {}; if (!text || text.trim().length < 50) return res.status(400).json({ error: 'متن کافی ارسال نشده' }); const prompt = `متن زیر از یک گزارش تخصصی در حوزه صنعت فولاد است. بر اساس این متن، یک مقاله جامع و تحلیلی به زبان فارسی بنویس که شامل این بخشها باشد: ۱. **عنوان** (یک عنوان جذاب و حرفهای) ۲. **خلاصه اجرایی** (۲ تا ۳ پاراگراف — مهمترین یافتهها) ۳. **نکات کلیدی** (۴ تا ۶ گلوله — bullet points) ۴. **تحلیل تفصیلی** (۳ تا ۵ پاراگراف — بررسی عمیق) ۵. **نتیجهگیری** (۱ تا ۲ پاراگراف) خروجی باید کاملاً فارسی، حرفهای، و مناسب برای یک اندیشکده تخصصی باشد. ${category ? `حوزه: ${category}` : ''} متن منبع: --- ${text.slice(0, 6000)} --- فقط محتوای مقاله را بنویس، بدون توضیح اضافه.`; try { const { default: OpenAI } = await import('openai'); const clientOpts = { apiKey }; if (process.env.AI_BASE_URL) clientOpts.baseURL = process.env.AI_BASE_URL; const client = new OpenAI(clientOpts); const model = process.env.AI_MODEL || 'gpt-4o-mini'; const completion = await client.chat.completions.create({ model, max_tokens: 2048, messages: [{ role: 'user', content: prompt }], }); const generated = completion.choices[0]?.message?.content || ''; res.json({ generated }); } catch (err) { console.error('AI generate error:', err); res.status(500).json({ error: 'خطا در تولید محتوا: ' + err.message }); } }); // ────────────────────────────────────────── // Member auth & profile (public users, not admins) // ────────────────────────────────────────── app.post('/api/members/register', loginLimiter, async (req, res) => { const { name, email, password } = req.body || {}; if (!name || !email || !password) return res.status(400).json({ error: 'همه فیلدها الزامی است' }); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' }); if (password.length < 6) return res.status(400).json({ error: 'رمز عبور باید حداقل ۶ کاراکتر باشد' }); const exists = await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email); if (exists) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' }); const hash = bcrypt.hashSync(password, 10); const info = await db.prepare('INSERT INTO members (name, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash); const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' }); res.cookie('member_session', token, memberCookieOpts(req)); res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } }); }); app.post('/api/members/login', loginLimiter, async (req, res) => { const { email, password } = req.body || {}; if (!email || !password) return res.status(400).json({ error: 'ایمیل و رمز عبور الزامی است' }); const row = await db.prepare('SELECT id, name, email, password_hash, is_active FROM members WHERE email = ?').get(email.toLowerCase().trim()); if (!row || !bcrypt.compareSync(password, row.password_hash)) return res.status(401).json({ error: 'ایمیل یا رمز عبور اشتباه است' }); if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' }); const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' }); res.cookie('member_session', token, memberCookieOpts(req)); res.json({ user: { id: row.id, name: row.name, email: row.email } }); }); app.post('/api/members/logout', (req, res) => { res.clearCookie('member_session', { ...memberCookieOpts(req), maxAge: undefined }); res.json({ ok: true }); }); app.get('/api/members/me', memberRequired, async (req, res) => { const row = await db.prepare('SELECT id, name, email, phone, avatar, created_at FROM members WHERE id = ?').get(req.member.sub); if (!row) return res.status(404).json({ error: 'not_found' }); res.json({ id: row.id, name: row.name, email: row.email, phone: row.phone, avatar: row.avatar, createdAt: row.created_at }); }); app.put('/api/members/me', memberRequired, async (req, res) => { const { name, phone } = req.body || {}; const row = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.member.sub); if (!row) return res.status(404).json({ error: 'not_found' }); const newName = name?.trim() || req.member.name; await db.prepare("UPDATE members SET name = ?, phone = ?, updated_at = now() WHERE id = ?").run(newName, phone || null, req.member.sub); res.json({ ok: true }); }); app.put('/api/members/me/password', memberRequired, async (req, res) => { const { currentPassword, newPassword } = req.body || {}; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'فیلدها الزامی است' }); if (newPassword.length < 6) return res.status(400).json({ error: 'رمز جدید باید حداقل ۶ کاراکتر باشد' }); const row = await db.prepare('SELECT password_hash FROM members WHERE id = ?').get(req.member.sub); if (!row || !bcrypt.compareSync(currentPassword, row.password_hash)) return res.status(401).json({ error: 'رمز فعلی اشتباه است' }); const hash = bcrypt.hashSync(newPassword, 10); await db.prepare("UPDATE members SET password_hash = ?, updated_at = now() WHERE id = ?").run(hash, req.member.sub); res.json({ ok: true }); }); app.get('/api/members/me/purchases', memberRequired, async (req, res) => { const rows = await db.prepare(` SELECT p.id, p.amount, p.purchased_at, a.id AS article_id, a.title, a.category, a.type, a.cover_image, a.pages, a.summary, a.author, a.publish_date FROM purchases p JOIN articles a ON p.article_id = a.id WHERE p.member_id = ? ORDER BY p.purchased_at DESC `).all(req.member.sub); res.json(rows.map(r => ({ id: r.id, amount: r.amount, purchasedAt: r.purchased_at, article: { id: r.article_id, title: r.title, category: r.category, type: r.type, coverImage: r.cover_image, pages: r.pages, summary: r.summary, author: r.author, publishDate: r.publish_date }, }))); }); // admin: grant purchase to a member app.post('/api/members/:memberId/purchases', authRequired, async (req, res) => { const { articleId, amount = 0 } = req.body || {}; if (!articleId) return res.status(400).json({ error: 'articleId الزامی است' }); const member = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId); if (!member) return res.status(404).json({ error: 'عضو پیدا نشد' }); const article = await db.prepare('SELECT id, cover_image FROM articles WHERE id = ?').get(articleId); if (!article) return res.status(404).json({ error: 'مقاله پیدا نشد' }); const info = await db.prepare('INSERT INTO purchases (member_id, article_id, amount) VALUES (?, ?, ?) ON CONFLICT (member_id, article_id) DO NOTHING').run(req.params.memberId, articleId, amount); if (info.changes === 0) return res.status(409).json({ error: 'قبلاً خریداری شده' }); res.status(201).json({ ok: true }); }); // download purchased article file (cover image for now; swap for PDF path later) // auth rides on the HttpOnly member_session cookie sent with the navigation — no token in the URL app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, async (req, res) => { const row = await db.prepare(` SELECT a.cover_image, a.title FROM purchases p JOIN articles a ON p.article_id = a.id WHERE p.id = ? AND p.member_id = ? `).get(req.params.purchaseId, req.member.sub); if (!row) return res.status(404).json({ error: 'خرید پیدا نشد' }); if (!row.cover_image) return res.status(404).json({ error: 'فایل موجود نیست' }); // object-storage assets are full URLs → redirect; local uploads are served from disk if (/^https?:\/\//i.test(row.cover_image)) return res.redirect(row.cover_image); const filePath = path.join(uploadsDir, path.basename(row.cover_image)); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'فایل موجود نیست' }); res.download(filePath, row.title + path.extname(filePath)); }); 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'); } });