229 lines
8.2 KiB
JavaScript
229 lines
8.2 KiB
JavaScript
/**
|
|
* 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 });
|
|
}
|
|
});
|
|
|
|
// Главная: минимальный вывод информации на экран.
|
|
// Не зависит от 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) => `<tr><td>${esc(r.schemaname)}</td><td>${esc(r.tablename)}</td></tr>`)
|
|
.join('');
|
|
} else {
|
|
tablesHtml = '<tr><td colspan="2">таблиц нет</td></tr>';
|
|
}
|
|
} catch (e) {
|
|
tablesHtml = '<tr><td colspan="2">не удалось получить таблицы</td></tr>';
|
|
}
|
|
|
|
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, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// Простая HTML-страница с информацией
|
|
function html(cfg, dbStatus, tablesHtml) {
|
|
return `<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>getpgdata — информация</title>
|
|
</head>
|
|
<body style="font-family: sans-serif; margin: 24px;">
|
|
<h1>getpgdata</h1>
|
|
<p>Node.js ${esc(process.version)} · env=${esc(APP_ENV)} · PID=${esc(String(process.pid))}</p>
|
|
|
|
<h2>База данных</h2>
|
|
<table border="1" cellspacing="0" cellpadding="6">
|
|
<tr><th>Host</th><td>${esc(cfg.host)}</td></tr>
|
|
<tr><th>Port</th><td>${esc(String(cfg.port))}</td></tr>
|
|
<tr><th>Database</th><td>${esc(cfg.database)}</td></tr>
|
|
<tr><th>User</th><td>${esc(cfg.user)}</td></tr>
|
|
<tr><th>Статус</th><td>${esc(dbStatus)}</td></tr>
|
|
</table>
|
|
|
|
<h2>Таблицы</h2>
|
|
<table border="1" cellspacing="0" cellpadding="6">
|
|
<tr><th>Схема</th><th>Таблица</th></tr>
|
|
${tablesHtml}
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// Просмотр таблицы с пагинацией
|
|
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;
|