/** * getpgdata — Node.js приложение для доступа к PostgreSQL (managed k8s, internal-only). * * Точка входа для Nubes NodeJS managed service (аналогично ipwhitelist-app). * Подключение к БД — через переменные окружения DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS/DB_SSLMODE. * * Функции: * GET / — состояние подключения + список таблиц * GET /table?schema=&name=&page= — содержимое таблицы с пагинацией * POST /query — SQL-консоль (read-only, только SELECT) * GET /healthz — liveness-проба для k8s */ const path = require('path'); const express = require('express'); const { Pool } = require('pg'); const app = express(); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); const APP_ENV = process.env.APP_ENV || 'production'; const QUERY_MAX_ROWS = parseInt(process.env.QUERY_MAX_ROWS || '200', 10); // Разрешить произвольный SQL (INSERT/UPDATE/DELETE/DDL) через POST /api/sql. // По умолчанию — только SELECT (read-only). Для включения записи: WRITE_SQL=1. const WRITE_SQL = (process.env.WRITE_SQL || '').toLowerCase() === '1'; // --------------------------------------------------------------------------- // Конфигурация подключения к PostgreSQL // --------------------------------------------------------------------------- function dbConfig() { return { host: process.env.DB_HOST || 'postgresqlk8s-master.60bdf3e3-5087-41ff-b760-fe6ea544a80e.svc.cluster.local', port: parseInt(process.env.DB_PORT || '5432', 10), database: process.env.DB_NAME || 'ipwhitelist', user: process.env.DB_USER || 'super', password: process.env.DB_PASS || '', ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false, connectionTimeoutMillis: parseInt(process.env.DB_CONNECT_TIMEOUT || '5000', 10), }; } // Пул создаётся при старте; если БД недоступна — приложение не падает, // а отдаёт ошибку на страницах (важно для health-проб k8s). const pool = new Pool(dbConfig()); // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use('/public', express.static(path.join(__dirname, 'public'))); // --------------------------------------------------------------------------- // Маршруты // --------------------------------------------------------------------------- // Liveness-проба app.get('/healthz', async (req, res) => { try { await pool.query('SELECT 1'); res.status(200).json({ status: 'ok', db: 'connected' }); } catch (e) { res.status(503).json({ status: 'degraded', db: 'unreachable', error: e.message }); } }); // Главная: минимальный вывод информации на экран. // Не зависит от EJS-шаблонов — всегда отдаёт HTML, даже если БД недоступна. app.get('/', async (req, res) => { const cfg = dbConfig(); let dbStatus = 'нет данных'; let tablesHtml = ''; try { const r = await pool.query('SELECT version() AS v'); dbStatus = 'подключено: ' + (r.rows[0] && r.rows[0].v ? r.rows[0].v : 'ok'); } catch (e) { dbStatus = 'недоступна: ' + (e && e.message ? e.message : String(e)); } try { const t = await pool.query( `SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') ORDER BY schemaname, tablename` ); if (t.rows && t.rows.length) { tablesHtml = t.rows .map((r) => `${esc(r.schemaname)}${esc(r.tablename)}`) .join(''); } else { tablesHtml = 'таблиц нет'; } } catch (e) { tablesHtml = 'не удалось получить таблицы'; } res .status(200) .set('Content-Type', 'text/html; charset=utf-8') .send(html(cfg, dbStatus, tablesHtml)); }); // Экранирование HTML function esc(v) { return String(v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // Простая HTML-страница с информацией function html(cfg, dbStatus, tablesHtml) { return ` getpgdata — информация

getpgdata

Node.js ${esc(process.version)} · env=${esc(APP_ENV)} · PID=${esc(String(process.pid))}

База данных

Host${esc(cfg.host)}
Port${esc(String(cfg.port))}
Database${esc(cfg.database)}
User${esc(cfg.user)}
Статус${esc(dbStatus)}

Таблицы

${tablesHtml}
СхемаТаблица
`; } // Просмотр таблицы с пагинацией app.get('/table', async (req, res) => { const schema = (req.query.schema || 'public').replace(/[^a-zA-Z0-9_]/g, ''); const name = (req.query.name || '').replace(/[^a-zA-Z0-9_]/g, ''); const page = Math.max(parseInt(req.query.page || '1', 10) || 1, 1); const perPage = Math.min(Math.max(parseInt(req.query.per_page || '50', 10) || 50, 1), 500); if (!name) return res.redirect('/'); const fullName = `${schema}.${name}`; let rows = null; let columns = null; let count = null; let error = null; try { // Проверяем существование таблицы const chk = await pool.query('SELECT to_regclass($1) AS reg', [fullName]); if (!chk.rows[0].reg) { return res.status(404).render('error', { code: 404, message: `Таблица ${fullName} не найдена` }); } const cnt = await pool.query(`SELECT COUNT(*) AS n FROM ${fullName}`); count = parseInt(cnt.rows[0].n, 10); const offset = (page - 1) * perPage; const q = await pool.query(`SELECT * FROM ${fullName} ORDER BY 1 LIMIT $1 OFFSET $2`, [perPage, offset]); rows = q.rows; columns = q.fields.map((f) => f.name); } catch (e) { error = e.message; } res.render('table', { schema, name, columns, rows, count, page, perPage, error }); }); // SQL-консоль (read-only, только SELECT) app.get('/query', (req, res) => { res.render('query', { sql: '', columns: null, rows: null, error: null, elapsed: null }); }); app.post('/query', async (req, res) => { let sql = (req.body.sql || '').trim().replace(/;\s*$/, ''); let columns = null; let rows = null; let error = null; let elapsed = null; if (sql) { const startsWithSelect = /^\s*select\b/i.test(sql); if (!startsWithSelect) { error = 'Разрешены только SELECT-запросы (read-only режим)'; } else { const start = Date.now(); try { const q = await pool.query(sql); if (q.fields) { columns = q.fields.map((f) => f.name); rows = q.rows.slice(0, QUERY_MAX_ROWS); } } catch (e) { error = `Ошибка базы данных: ${e.message}`; } elapsed = ((Date.now() - start) / 1000).toFixed(3); } } res.render('query', { sql, columns, rows, error, elapsed, QUERY_MAX_ROWS }); }); // --------------------------------------------------------------------------- // JSON API для произвольных SQL-команд. // // POST /api/sql Body: { "sql": "...", "params": [...] } // SELECT → { ok:true, fields:[...], rows:[...] } (всегда доступен) // INSERT/UPDATE/DELETE/DDL → { ok:true, rowCount:n } // (доступно только при WRITE_SQL=1) // // Ограничение: выполняется ОДИН statement (первый) — предотвращает // многоступенчатые команды через API. Параметры — через $1..$n. // --------------------------------------------------------------------------- app.post('/api/sql', async (req, res) => { const sql = (req.body && typeof req.body.sql === 'string' ? req.body.sql : '').trim(); const params = Array.isArray(req.body && req.body.params) ? req.body.params : []; if (!sql) { return res.status(400).json({ ok: false, error: 'Пустой SQL' }); } // Берём первый statement (до ; и до разделителей) — защита от мульти-комманд const first = sql.split(';')[0].trim(); if (!first) { return res.status(400).json({ ok: false, error: 'Пустой SQL' }); } const isRead = /^(select|with|show|explain)\b/i.test(first); if (!isRead) { if (!WRITE_SQL) { return res.status(403).json({ ok: false, error: 'Write-SQL отключён (нужен WRITE_SQL=1)' }); } } try { const r = await pool.query(first, params); if (r.fields && r.fields.length) { return res.json({ ok: true, fields: r.fields.map((f) => f.name), rows: r.rows.slice(0, WRITE_SQL ? 10000 : QUERY_MAX_ROWS), rowCount: r.rowCount, }); } return res.json({ ok: true, rowCount: r.rowCount == null ? 0 : r.rowCount }); } catch (e) { return res.status(400).json({ ok: false, error: e.message }); } }); // Обработка 404 app.use((req, res) => { res.status(404).render('error', { code: 404, message: 'Страница не найдена' }); }); // Обработка ошибок app.use((err, req, res, next) => { res.status(500).render('error', { code: 500, message: err.message || 'Внутренняя ошибка' }); }); // --------------------------------------------------------------------------- // Запуск // --------------------------------------------------------------------------- const PORT = parseInt(process.env.PORT || '3000', 10); app.listen(PORT, '0.0.0.0', () => { console.log(`getpgdata listening on port ${PORT} (env=${APP_ENV})`); }); module.exports = app;