Files
getpgdata/server.js
T

180 lines
6.3 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 });
}
});
// Главная: состояние подключения + список таблиц
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 || '5000', 10);
app.listen(PORT, '0.0.0.0', () => {
console.log(`getpgdata listening on port ${PORT} (env=${APP_ENV})`);
});
module.exports = app;