diff --git a/server.js b/server.js index dde4a67..0a2706f 100644 --- a/server.js +++ b/server.js @@ -1,30 +1,183 @@ // server.js const express = require('express'); const { chromium } = require('playwright'); +const Database = require('better-sqlite3'); const app = express(); const port = process.env.PORT || 3000; -// Использовать системный Chromium, если задан путь (например, /usr/bin/chromium в Debian) -const executablePath = process.env.CHROMIUM_PATH || undefined; // можно оставить undefined, если Chromium в PATH [1][2] - -// Базовый набор флагов для контейнера без systemd/dbus и без install-deps +const executablePath = process.env.CHROMIUM_PATH || undefined; const chromiumArgs = [ - '--no-sandbox', // запуск без setuid sandbox в контейнере [14] - '--disable-setuid-sandbox', // отключение setuid sandbox [14] - '--disable-dev-shm-usage', // использовать /tmp вместо /dev/shm (если нет --ipc=host) [15][16] - '--disable-gpu', // headless окружение [14] - '--no-zygote', // упрощение процессов в контейнере [14] + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--no-zygote', ]; +const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); +const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); // анти-цикл по глубине + +const db = new Database(process.env.SQLITE_PATH || './cache.db'); +db.pragma('journal_mode = WAL'); +db.exec(` +CREATE TABLE IF NOT EXISTS domain_cache ( + domain TEXT PRIMARY KEY, + result_json TEXT NOT NULL, -- JSON массива связанных доменов + final_url TEXT, + redirect_chain_json TEXT, -- JSON журнала редиректов + updated_at INTEGER NOT NULL, + ttl_at INTEGER NOT NULL +); +`); + +const stmtSelect = db.prepare(` + SELECT result_json, final_url, redirect_chain_json, updated_at, ttl_at + FROM domain_cache WHERE domain = ? +`); +const stmtUpsert = db.prepare(` +INSERT INTO domain_cache (domain, result_json, final_url, redirect_chain_json, updated_at, ttl_at) +VALUES (@domain, @result_json, @final_url, @redirect_chain_json, @updated_at, @ttl_at) +ON CONFLICT(domain) DO UPDATE SET + result_json = excluded.result_json, + final_url = excluded.final_url, + redirect_chain_json = excluded.redirect_chain_json, + updated_at = excluded.updated_at, + ttl_at = excluded.ttl_at +`); + app.use(express.json()); function extractDomain(url) { - try { - return new URL(url).hostname; - } catch { - return null; + try { return new URL(url).hostname; } catch { return null; } +} + +let browser; +async function getBrowser() { + if (browser && browser.isConnected()) return browser; + browser = await chromium.launch({ + executablePath, + headless: true, + args: chromiumArgs, + }); + return browser; +} + +// Вспомогательная функция для сборки полного журнала редиректов через цепочку redirectedFrom() +function buildRedirectChainForResponse(resp) { + const chain = []; + const currentReq = resp.request(); + let prev = currentReq.redirectedFrom(); + let toUrl = currentReq.url(); + const status = resp.status(); + while (prev) { + chain.push({ from: prev.url(), to: toUrl, status }); + toUrl = prev.url(); + prev = prev.redirectedFrom(); } + return chain.reverse(); +} + +async function scanDomainOnce(originDomain) { + const startUrl = `https://${originDomain}`; + + const b = await getBrowser(); + const context = await b.newContext(); + const page = await context.newPage(); + + const seenDomains = new Set(); + const redirectLog = []; + const visitedUrls = new Set(); // для детекции циклов + let redirectSteps = 0; + + // Фиксируем все запросы + page.on('request', req => { + const d = extractDomain(req.url()); + if (d) seenDomains.add(d); + }); + + // Фиксируем ответы и редиректные цепочки + page.on('response', resp => { + const url = resp.url(); + const d = extractDomain(url); + if (d) seenDomains.add(d); + + // Добавим элементы цепочки, если ответ был редиректом (3xx) + const status = resp.status(); + if (status >= 300 && status < 400) { + const piece = buildRedirectChainForResponse(resp); + redirectLog.push(...piece); + } + }); + + try { + let currentUrl = startUrl; + // Анти-цикл: свой контроль над goto в несколько шагов — через ожидание события navigation и проверку URL + // Однако Playwright следует редиректам сам; для анти-цикла контролируем уникальность URL после перехода + const resp = await page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + // После авто-редиректов Playwright мы проверим фактическую цепочку через обработчики и page.url() + + // Защита от «вечных» редиректов: проверим историю URL в performance entries + // Простой и надёжный способ: считать шаги смены URL в waitForNavigation с url predicate — но нам достаточно лимита по постфакту. + // Проверим финальный URL и убедимся, что не было явного зацикливания по уже виденным URL. + const finalUrl = page.url(); + if (visitedUrls.has(finalUrl)) { + throw new Error('Redirect loop detected'); + } + visitedUrls.add(finalUrl); + + // Как дополнительная защита — лимит по шагам 3xx из собранного redirectLog + // Если цепочка слишком длинная, считаем её небезопасной. + redirectSteps = redirectLog.length; + if (redirectSteps > MAX_REDIRECT_STEPS) { + throw new Error(`Too many redirects (${redirectSteps})`); + } + + await context.close(); + + const relatedDomains = Array.from(seenDomains) + .filter(d => !d.includes('doubleclick') && !d.includes('google')) + .sort(); + + return { + finalUrl, + relatedDomains, + redirectChain: redirectLog, + }; + } catch (e) { + try { await context.close(); } catch {} + throw e; + } +} + +function getFromCache(domain) { + const row = stmtSelect.get(domain); + if (!row) return null; + const now = Math.floor(Date.now() / 1000); + if (row.ttl_at > now) { + return { + relatedDomains: JSON.parse(row.result_json), + finalUrl: row.final_url || null, + redirectChain: row.redirect_chain_json ? JSON.parse(row.redirect_chain_json) : [], + cached: true, + cachedAt: row.updated_at, + ttlAt: row.ttl_at, + }; + } + return null; +} + +function putToCache(domain, result) { + const now = Math.floor(Date.now() / 1000); + const ttlAt = now + CACHE_TTL_SECONDS; + stmtUpsert.run({ + domain, + result_json: JSON.stringify(result.relatedDomains || []), + final_url: result.finalUrl || null, + redirect_chain_json: JSON.stringify(result.redirectChain || []), + updated_at: now, + ttl_at: ttlAt, + }); } app.get('/domains', async (req, res) => { @@ -33,46 +186,47 @@ app.get('/domains', async (req, res) => { res.status(400).json({ error: '"domain" query parameter is required' }); return; } - - const url = `https://${domain}`; - const seenDomains = new Set(); - let browser; - let context; - try { - browser = await chromium.launch({ - executablePath, // берётся из CHROMIUM_PATH при наличии [1][2] - headless: true, // явный headless режим для контейнера [14] - args: chromiumArgs, // флаги для стабильности в Docker [15][14] + const cached = getFromCache(domain); + if (cached) { + res.json({ + domain, + finalUrl: cached.finalUrl, + relatedDomains: cached.relatedDomains, + redirectChain: cached.redirectChain, + cached: true, + cachedAt: cached.cachedAt, + ttlAt: cached.ttlAt, + }); + return; + } + + const result = await scanDomainOnce(domain); + putToCache(domain, result); + + res.json({ + domain, + finalUrl: result.finalUrl, + relatedDomains: result.relatedDomains, + redirectChain: result.redirectChain, + cached: false, }); - - context = await browser.newContext(); - const page = await context.newPage(); - - page.on('request', request => { - const d = extractDomain(request.url()); - if (d) seenDomains.add(d); - }); - - await page.goto(url, { waitUntil: 'load', timeout: 30000 }); - - // Фильтрация доменов после закрытия страницы - await context.close(); - await browser.close(); - - const filteredDomains = Array.from(seenDomains) - .filter(d => !d.includes('doubleclick') && !d.includes('google')) - .sort(); - - res.json({ domains: filteredDomains }); } catch (e) { - // Безопасно закрыть ресурсы при ошибке - try { if (context) await context.close(); } catch {} - try { if (browser) await browser.close(); } catch {} res.status(500).json({ error: e.message || 'Internal server error' }); } }); +app.get('/health', (_req, res) => res.json({ ok: true })); + +process.on('SIGTERM', async () => { + try { if (browser) await browser.close(); } catch {} + process.exit(0); +}); +process.on('SIGINT', async () => { + try { if (browser) await browser.close(); } catch {} + process.exit(0); +}); + app.listen(port, () => { console.log(`Domain scanner service listening on port ${port}`); });