const http = require('http');
const fs = require('fs');
const path = require('path');
const db = require('../database');
const { channels } = require('../config');
const PORT = 3220;
const STATIC_DIR = path.join(__dirname);
let botInstance = null;
let subscribersCache = {};
let subscribersCacheTime = 0;
const SUBS_CACHE_TTL = 5 * 60 * 1000;
const MIME = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.ico': 'image/x-icon'
};
const DASH_USER = process.env.DASH_USER || 'admin';
const DASH_PASS = process.env.DASH_PASS || 'changeme';
function log(msg) {
const ts = new Date().toLocaleString('en-CA', { timeZone: 'America/Vancouver' });
console.log(`[${ts}] [dashboard] ${msg}`);
}
// HTTP Basic Auth
function checkAuth(req, res) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Basic ')) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="KZ Channels Dashboard"', 'Content-Type': 'text/plain' });
res.end('Unauthorized');
return false;
}
const credentials = Buffer.from(header.slice(6), 'base64').toString();
const [user, pass] = credentials.split(':');
if (user !== DASH_USER || pass !== DASH_PASS) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="KZ Channels Dashboard"', 'Content-Type': 'text/plain' });
res.end('Invalid credentials');
return false;
}
return true;
}
async function fetchSubscribers() {
if (!botInstance) return {};
const now = Date.now();
if (now - subscribersCacheTime < SUBS_CACHE_TTL && Object.keys(subscribersCache).length > 0) {
return subscribersCache;
}
const result = {};
for (const [key, ch] of Object.entries(channels)) {
if (!ch.chatId) { result[key] = null; continue; }
try {
result[key] = await botInstance.api.getChatMemberCount(ch.chatId);
} catch (err) {
log(`⚠️ Subscribers ${key}: ${err.message}`);
result[key] = subscribersCache[key] || null;
}
}
subscribersCache = result;
subscribersCacheTime = now;
return result;
}
async function getStats() {
const subs = await fetchSubscribers();
const result = {};
for (const key of Object.keys(channels)) {
const todayPosts = db.getTodayPostCount(key);
const todayAds = db.getTodayAdCount(key);
const weekStats = db.getStats(key, 7);
const weekTotal = weekStats.reduce((s, r) => s + r.posts_count, 0);
const lastPost = db.getLastPostTime(key);
result[key] = { today: todayPosts, ads: todayAds, week: weekTotal, lastPost, subscribers: subs[key] || 0 };
}
result._queueCount = db.getQueueCount();
return result;
}
// Parse JSON body (лимит 1MB)
const MAX_BODY_SIZE = 1024 * 1024;
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > MAX_BODY_SIZE) {
req.destroy();
reject(new Error('Body too large'));
}
});
req.on('end', () => {
try { resolve(body ? JSON.parse(body) : {}); }
catch { reject(new Error('Invalid JSON')); }
});
});
}
function json(res, data, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify(data));
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
// CORS preflight
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
});
res.end();
return;
}
// Health endpoint (no auth)
if (pathname === '/api/health' && req.method === 'GET') {
const { getErrorCount } = require('../error-log');
const uptime = process.uptime();
const lastPosts = {};
for (const key of Object.keys(channels)) {
lastPosts[key] = db.getLastPostTime(key);
}
return json(res, {
status: 'ok',
uptime: Math.floor(uptime),
uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
channels: Object.keys(channels).length,
errors: getErrorCount(),
lastPosts
});
}
// Auth check на все запросы
if (!checkAuth(req, res)) return;
// --- API ---
// GET /api/ads
if (pathname === '/api/ads' && req.method === 'GET') {
const adHistory = db.getAdStats(7);
const totalAds = db.getAdTotals();
// Сумма рекламы за сегодня и за неделю
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Almaty' });
let todayAds = 0, weekAds = 0;
const byChannel = {};
for (const key of Object.keys(channels)) { byChannel[key] = { today: 0, week: 0 }; }
for (const row of adHistory) {
weekAds += row.ad_posts_count;
if (byChannel[row.channel_id]) byChannel[row.channel_id].week += row.ad_posts_count;
if (row.date === today) {
todayAds += row.ad_posts_count;
if (byChannel[row.channel_id]) byChannel[row.channel_id].today += row.ad_posts_count;
}
}
const templates = db.getAdTemplateStats();
return json(res, { success: true, todayAds, weekAds, totalAds, byChannel, templates });
}
// GET /api/sources/health — проверить доступность RSS фидов
if (pathname === '/api/sources/health' && req.method === 'GET') {
const axios = require('axios');
const results = {};
for (const key of Object.keys(channels)) {
const sources = db.getApprovedSources(key);
results[key] = [];
for (const src of sources) {
if (src.type !== 'rss') { results[key].push({ name: src.name, status: 'skip', type: src.type }); continue; }
try {
const start = Date.now();
await axios.head(src.url, { timeout: 5000, headers: { 'User-Agent': 'KZBot/1.0' } });
results[key].push({ name: src.name, status: 'ok', ms: Date.now() - start });
} catch (err) {
results[key].push({ name: src.name, status: 'error', error: err.code || err.message });
}
}
}
return json(res, { success: true, data: results });
}
// GET /api/errors
if (pathname === '/api/errors' && req.method === 'GET') {
const { getErrors, getErrorCount } = require('../error-log');
const limit = Number(url.searchParams.get('limit')) || 20;
return json(res, { success: true, data: getErrors(limit), total: getErrorCount() });
}
// GET /api/stats
if (pathname === '/api/stats' && req.method === 'GET') {
return json(res, await getStats());
}
// GET /api/posts?channel=almaty-news&limit=3
if (pathname === '/api/posts' && req.method === 'GET') {
const channelId = url.searchParams.get('channel');
if (!channelId) return json(res, { error: 'channel required' }, 400);
const limit = Math.min(Number(url.searchParams.get('limit')) || 3, 10);
const posts = db.getRecentPosts(channelId, limit);
return json(res, { success: true, data: posts });
}
// GET /api/sources?channel=almaty-news
if (pathname === '/api/sources' && req.method === 'GET') {
const channelId = url.searchParams.get('channel');
if (!channelId) return json(res, { error: 'channel required' }, 400);
const sources = db.getSources(channelId);
return json(res, { success: true, data: sources });
}
// POST /api/sources — добавить новый источник (pending)
if (pathname === '/api/sources' && req.method === 'POST') {
try {
const body = await parseBody(req);
if (!body.channelId || !body.name || !body.url) {
return json(res, { error: 'channelId, name, url required' }, 400);
}
const result = db.addSource({
channelId: body.channelId,
name: body.name,
url: body.url,
type: body.type || 'rss',
addedBy: body.addedBy || 'dashboard'
});
log(`Source added: ${body.name} → ${body.channelId} (pending)`);
return json(res, { success: true, id: result.lastInsertRowid }, 201);
} catch (err) {
return json(res, { error: err.message }, 400);
}
}
// PATCH /api/sources/:id/approve
const approveMatch = pathname.match(/^\/api\/sources\/(\d+)\/approve$/);
if (approveMatch && req.method === 'PATCH') {
db.approveSource(Number(approveMatch[1]));
log(`Source ${approveMatch[1]} approved`);
return json(res, { success: true });
}
// PATCH /api/sources/:id/reject
const rejectMatch = pathname.match(/^\/api\/sources\/(\d+)\/reject$/);
if (rejectMatch && req.method === 'PATCH') {
db.rejectSource(Number(rejectMatch[1]));
log(`Source ${rejectMatch[1]} rejected`);
return json(res, { success: true });
}
// DELETE /api/sources/:id
const deleteMatch = pathname.match(/^\/api\/sources\/(\d+)$/);
if (deleteMatch && req.method === 'DELETE') {
db.deleteSource(Number(deleteMatch[1]));
log(`Source ${deleteMatch[1]} deleted`);
return json(res, { success: true });
}
// POST /api/post — ручная публикация
if (pathname === '/api/post' && req.method === 'POST') {
try {
const body = await parseBody(req);
if (!body.channelId || !body.text) {
return json(res, { error: 'channelId and text required' }, 400);
}
const { sendManualPost } = require('../scheduler');
const msgId = await sendManualPost(body.channelId, body.text, body.imageUrl || null);
log(`Manual post to ${body.channelId} → msg ${msgId}`);
return json(res, { success: true, messageId: msgId });
} catch (err) {
log(`Manual post error: ${err.message}`);
return json(res, { error: err.message }, 500);
}
}
// --- Post Queue (модерация) ---
// GET /api/queue
if (pathname === '/api/queue' && req.method === 'GET') {
const queue = db.getQueue('pending');
const count = db.getQueueCount();
return json(res, { success: true, data: queue, count });
}
// PATCH /api/queue/:id/approve — одобрить и отправить в канал
const qApproveMatch = pathname.match(/^\/api\/queue\/(\d+)\/approve$/);
if (qApproveMatch && req.method === 'PATCH') {
const id = Number(qApproveMatch[1]);
const item = db.getQueueItem(id);
if (!item) return json(res, { error: 'Not found' }, 404);
try {
// Отправить пост в канал
const { sendApprovedPost } = require('../scheduler');
await sendApprovedPost(item);
db.approveQueueItem(id);
log(`Queue item ${id} approved and posted to ${item.channel_id}`);
return json(res, { success: true });
} catch (err) {
log(`Queue approve error: ${err.message}`);
return json(res, { error: err.message }, 500);
}
}
// PATCH /api/queue/:id/reject
const qRejectMatch = pathname.match(/^\/api\/queue\/(\d+)\/reject$/);
if (qRejectMatch && req.method === 'PATCH') {
const id = Number(qRejectMatch[1]);
const item = db.getQueueItem(id);
db.rejectQueueItem(id);
// Удалить orphan image файл
if (item && item.image_path) {
try { fs.unlinkSync(item.image_path); } catch {}
}
log(`Queue item ${qRejectMatch[1]} rejected`);
return json(res, { success: true });
}
// --- Static files ---
let filePath = pathname === '/' ? '/index.html' : pathname;
filePath = path.join(STATIC_DIR, filePath);
if (!filePath.startsWith(STATIC_DIR)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const ext = path.extname(filePath);
const contentType = MIME[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;"
});
res.end(data);
});
});
function startDashboard(bot) {
botInstance = bot || null;
server.listen(PORT, () => {
log(`Dashboard listening on port ${PORT}`);
});
}
module.exports = { startDashboard };
📜 Git History
0413c12fix: 9 багов — caption limit, orphan cleanup, security hardening7 weeks ago
1d04825fix: промпты — запрет мета-комментариев + maxTokens 500→8007 weeks ago
045688afeat: dashboard v0.3 — 9 новых источников + 10 улучшений дашборда7 weeks ago
c92426bfeat: HTTP Basic Auth for dashboard7 weeks ago
5481deffeat: post moderation queue for new sources7 weeks ago
6ff5fa6feat: source management with moderation via dashboard modal7 weeks ago
0d25952feat: add dashboard on arb.szhub.space (port 3220)7 weeks ago
Show last diff
Loading...