getpgdata: замена Flask на Node.js (Express + pg) приложение для доступа к PostgreSQL
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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 || 'postgres',
|
||||
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;
|
||||
Reference in New Issue
Block a user