← Back
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...