/** * 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); // --------------------------------------------------------------------------- // Конфигурация подключения к 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 }); } }); // Главная: состояние подключения + список таблиц app.get('/', async (req, res) => { let tables = null; let error = null; try { const r = await pool.query( `SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') ORDER BY schemaname, tablename` ); tables = r.rows; } catch (e) { error = e.message; } res.render('index', { cfg: dbConfig(), tables, error, env: APP_ENV, }); }); // Просмотр таблицы с пагинацией 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 }); }); // Обработка 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;