steelforesight/panel/server.js

1876 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, GetObjectCommand } 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}`;
// Canonical base for media URLs. The object-storage host blocks browser User-Agents,
// so we never hand browsers a direct storage URL — they go through this app's /media proxy.
const MEDIA_BASE = (process.env.MEDIA_BASE || PUBLIC_ORIGIN).replace(/\/+$/, '');
// 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 `${MEDIA_BASE}/media/${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());
// any localhost/127.0.0.1 origin (any port) is allowed in addition to ALLOWED_ORIGINS —
// the panel serves its own SPA, whose module scripts fetch same-origin with an Origin
// header; rejecting that 500s app.js. Disallowed origins get cb(null,false) (no CORS
// headers) rather than a thrown error, so a bad cross-origin request never 500s static assets.
const isLocalhost = (o) => /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o);
app.use(cors({
origin: (origin, cb) => {
cb(null, !origin || ALLOWED_ORIGINS.includes(origin) || isLocalhost(origin));
},
credentials: true,
}));
app.use(cookieParser());
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:", ...(s3Host && process.env.LIARA_BUCKET ? [`https://${process.env.LIARA_BUCKET}.${s3Host}`] : [])],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
frameguard: { action: 'deny' },
}));
app.use(express.json({ limit: '50mb' }));
// Public media proxy: streams an object from storage server-side and relays it from
// our own domain. Browsers are blocked from the storage host by a User-Agent filter,
// so this is the only way an <img>/<video> can load uploaded media in a browser.
app.get('/media/:key', async (req, res) => {
if (!s3) return res.status(404).end();
try {
const range = req.headers.range;
const obj = await s3.send(new GetObjectCommand({
Bucket: process.env.LIARA_BUCKET, Key: req.params.key, Range: range || undefined,
}));
if (obj.ContentType) res.setHeader('Content-Type', obj.ContentType);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
// allow the site (different origin) to embed this media — overrides helmet's same-origin CORP
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
if (obj.ContentRange) { res.status(206); res.setHeader('Content-Range', obj.ContentRange); }
if (obj.ContentLength != null) res.setHeader('Content-Length', String(obj.ContentLength));
obj.Body.pipe(res);
} catch {
res.status(404).end();
}
});
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: '7d' });
res.cookie('session', token, {
httpOnly: true,
secure: cookieSecure(req),
sameSite: 'strict',
maxAge: 7 * 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,
blocks, sources, 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,
blocks: JSON.stringify(Array.isArray(b.blocks) ? b.blocks : []),
sources: JSON.stringify(Array.isArray(b.sources) ? b.sources : []),
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,
blocks, sources, tags, featured
) VALUES (
@id, @title, @category, @type, @author, @author_role, @author_initial,
@publish_date, @pages, @price, @is_free, @summary, @body, @cover_image,
@blocks, @sources, @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,
blocks = @blocks, sources = @sources,
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,
tags, source, 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,
tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
source: b.source || 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,tags,source,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,@tags,@source,@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,
tags=@tags,source=@source,
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();
});
// ---------- site members (phone/OTP users) — admin management ----------
app.get('/api/members', authRequired, async (_req, res) => {
const rows = await db.prepare(`
SELECT m.id, m.name, m.email, m.phone, m.is_active, m.created_at, m.last_login,
(SELECT COUNT(*) FROM purchases p WHERE p.member_id = m.id) AS purchases
FROM members m ORDER BY m.last_login DESC NULLS LAST, m.created_at DESC
`).all();
res.json(rows.map((r) => ({
id: r.id, name: r.name, email: r.email, phone: r.phone,
isActive: !!r.is_active, createdAt: r.created_at, lastLogin: r.last_login,
purchases: Number(r.purchases) || 0,
})));
});
app.get('/api/members/:id', authRequired, async (req, res) => {
const id = Number(req.params.id);
const r = await db.prepare('SELECT id, name, email, phone, avatar, is_active, created_at, updated_at, last_login FROM members WHERE id = ?').get(id);
if (!r) return res.status(404).json({ error: 'not_found' });
const purchases = await db.prepare(`
SELECT p.article_id, p.amount, p.purchased_at, a.title
FROM purchases p LEFT JOIN articles a ON a.id = p.article_id
WHERE p.member_id = ? ORDER BY p.purchased_at DESC
`).all(id);
res.json({
id: r.id, name: r.name, email: r.email, phone: r.phone, avatar: r.avatar,
isActive: !!r.is_active, createdAt: r.created_at, updatedAt: r.updated_at, lastLogin: r.last_login,
purchases: purchases.map((p) => ({ articleId: p.article_id, title: p.title, amount: p.amount, purchasedAt: p.purchased_at })),
});
});
// block / unblock — body { isActive: boolean }
app.patch('/api/members/:id', authRequired, async (req, res) => {
const id = Number(req.params.id);
const isActive = req.body?.isActive ? 1 : 0;
const info = await db.prepare('UPDATE members SET is_active = ?, updated_at = now() WHERE id = ?').run(isActive, id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.json({ id, isActive: !!isActive });
});
app.delete('/api/members/:id', authRequired, ownerRequired, async (req, res) => {
const info = await db.prepare('DELETE FROM members WHERE id = ?').run(Number(req.params.id));
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// admin: recent activity (page views + clicks) for one member
app.get('/api/members/:id/activity', authRequired, async (req, res) => {
const rows = await db.prepare(
'SELECT kind, path, label, created_at FROM member_activity WHERE member_id = ? ORDER BY created_at DESC LIMIT 200'
).all(Number(req.params.id));
res.json(rows.map((r) => ({ kind: r.kind, path: r.path, label: r.label, at: r.created_at })));
});
// ---------- newsletter subscribers ----------
// public: footer signup. dedupes silently on repeat email.
app.post('/api/newsletter', contactLimiter, async (req, res) => {
const email = String(req.body?.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' });
await db.prepare('INSERT INTO subscribers (email) VALUES (?) ON CONFLICT (email) DO NOTHING').run(email);
res.status(201).json({ ok: true });
});
// admin: list subscribers
app.get('/api/newsletter', authRequired, async (_req, res) => {
const rows = await db.prepare('SELECT id, email, created_at FROM subscribers ORDER BY created_at DESC').all();
res.json(rows.map((r) => ({ id: r.id, email: r.email, createdAt: r.created_at })));
});
app.delete('/api/newsletter/:id', authRequired, async (req, res) => {
const info = await db.prepare('DELETE FROM subscribers WHERE id = ?').run(Number(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', 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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: `<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' });
}
});
// ---------- 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, phone } = 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, phone) VALUES (?, ?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash, phone ? String(phone).trim() : null);
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
});
app.post('/api/members/login', loginLimiter, async (req, res) => {
const { email, phone, password } = req.body || {};
const identifier = String(email || phone || '').trim();
if (!identifier || !password) return res.status(400).json({ error: 'شماره موبایل یا ایمیل و رمز عبور الزامی است' });
// accept either an email or a phone number as the login identifier
const row = await db.prepare('SELECT id, name, email, password_hash, is_active FROM members WHERE email = ? OR phone = ?').get(identifier.toLowerCase(), identifier);
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));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(row.id);
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 });
});
/* ── OTP login/signup via Kavenegar (verify/lookup) ──
SFsignin → existing numbers, SFsignup → new numbers (auto-created on verify). */
const otpStore = new Map(); // phone -> { code, expires }
async function sendOtpSms(phone, code, template) {
const key = process.env.KAVENEGAR_API_KEY;
if (!key) {
// ponytail: dev fallback — no SMS provider configured (local only). Print the code
// so OTP login can be tested without Kavenegar. In prod the key is always set.
console.log(`\n[otp:dev] no KAVENEGAR_API_KEY — code for ${phone}: ${code}\n`);
return;
}
const url = `https://api.kavenegar.com/v1/${key}/verify/lookup.json?receptor=${encodeURIComponent(phone)}&token=${encodeURIComponent(code)}&template=${encodeURIComponent(template)}`;
const r = await fetch(url);
const j = await r.json().catch(() => null);
if (!j || j.return?.status !== 200) throw new Error('sms_failed');
}
app.post('/api/members/otp/send', loginLimiter, async (req, res) => {
const phone = String(req.body?.phone || '').trim();
if (!/^09\d{9}$/.test(phone)) return res.status(400).json({ error: 'شماره موبایل معتبر وارد کنید' });
const existing = await db.prepare('SELECT id FROM members WHERE phone = ?').get(phone);
const code = String(Math.floor(100000 + Math.random() * 900000));
otpStore.set(phone, { code, expires: Date.now() + 5 * 60 * 1000 });
try {
await sendOtpSms(phone, code, existing ? 'SFsignin' : 'SFsignup');
res.json({ ok: true, isNew: !existing });
} catch (err) {
console.error('[otp] send failed:', err.message);
res.status(502).json({ error: 'ارسال پیامک ناموفق بود' });
}
});
app.post('/api/members/otp/verify', loginLimiter, async (req, res) => {
const phone = String(req.body?.phone || '').trim();
const code = String(req.body?.code || '').trim();
const rec = otpStore.get(phone);
if (!rec || rec.expires < Date.now() || rec.code !== code) return res.status(401).json({ error: 'کد نامعتبر یا منقضی است' });
otpStore.delete(phone);
let member = await db.prepare('SELECT id, name, email, is_active FROM members WHERE phone = ?').get(phone);
if (!member) {
// auto-signup: create a member keyed by phone (random password, placeholder email)
const hash = bcrypt.hashSync(nanoid(16), 10);
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
.run('کاربر جدید', `${phone}@otp.fstt.ir`, hash, phone);
member = { id: info.lastInsertRowid, name: 'کاربر جدید', email: `${phone}@otp.fstt.ir`, is_active: true };
}
if (!member.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
const token = jwt.sign({ sub: member.id, email: member.email, name: member.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(member.id);
res.json({ user: { id: member.id, name: member.name, email: member.email } });
});
/* verify the SFsignup OTP code, then create the account with the real form data */
app.post('/api/members/register-verify', loginLimiter, async (req, res) => {
const { name, email, password, code } = req.body || {};
const phone = String(req.body?.phone || '').trim();
if (!name || !email || !password || !phone) 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 rec = otpStore.get(phone);
if (!rec || rec.expires < Date.now() || rec.code !== String(code || '').trim()) return res.status(401).json({ error: 'کد تأیید نامعتبر یا منقضی است' });
if (await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email.toLowerCase().trim())) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
if (await db.prepare('SELECT 1 FROM members WHERE phone = ?').get(phone)) return res.status(409).json({ error: 'این شماره موبایل قبلاً ثبت شده است' });
otpStore.delete(phone);
const hash = bcrypt.hashSync(password, 10);
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
.run(name.trim(), email.toLowerCase().trim(), hash, phone);
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
});
/* activity beacon — logged-in members only. fire-and-forget from the site. */
app.post('/api/members/activity', memberRequired, async (req, res) => {
const kind = req.body?.kind === 'click' ? 'click' : 'view';
const path = String(req.body?.path || '').slice(0, 300);
const label = req.body?.label != null ? String(req.body.label).slice(0, 200) : null;
await db.prepare('INSERT INTO member_activity (member_id, kind, path, label) VALUES (?, ?, ?, ?)')
.run(req.member.sub, kind, path, label);
res.status(204).end();
});
/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);
if (!row || !row.phone) return res.status(400).json({ error: 'برای این حساب شماره موبایلی ثبت نشده است' });
const code = String(Math.floor(100000 + Math.random() * 900000));
otpStore.set(row.phone, { code, expires: Date.now() + 5 * 60 * 1000 });
try { await sendOtpSms(row.phone, code, 'SFsignin'); res.json({ ok: true }); }
catch (err) { console.error('[otp:pw] send failed:', err.message); res.status(502).json({ error: 'ارسال پیامک ناموفق بود' }); }
});
app.post('/api/members/me/password-otp/set', memberRequired, async (req, res) => {
const { code, newPassword } = req.body || {};
if (!newPassword || String(newPassword).length < 6) return res.status(400).json({ error: 'رمز جدید باید حداقل ۶ کاراکتر باشد' });
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);
const rec = row?.phone ? otpStore.get(row.phone) : null;
if (!rec || rec.expires < Date.now() || rec.code !== String(code || '').trim()) return res.status(401).json({ error: 'کد نامعتبر یا منقضی است' });
otpStore.delete(row.phone);
const hash = bcrypt.hashSync(String(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', 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');
}
});