/**
* 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;