From ab720b09d0eea68c1fc37b9a3bc67fee94d5afa4 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 20 May 2026 09:02:31 +0200 Subject: [PATCH] Initial setup: snakkimo API server with PostgreSQL connection Node.js/Express API server that connects to PostgreSQL via environment variables. Includes health check, table listing, row queries, and raw SQL endpoint. Designed for deployment in Coolify alongside the snakkimo PostgreSQL container. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 10 +++++++ .gitignore | 4 +++ Dockerfile | 12 ++++++++ package.json | 20 ++++++++++++++ src/db.js | 24 ++++++++++++++++ src/index.js | 38 +++++++++++++++++++++++++ src/routes/index.js | 67 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 175 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 package.json create mode 100644 src/db.js create mode 100644 src/index.js create mode 100644 src/routes/index.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff37954 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# PostgreSQL connection (set these in Coolify as environment variables) +DB_HOST=your-postgres-service-name # In Coolify: der interne Service-Name des PostgreSQL-Containers +DB_PORT=5432 +DB_NAME=snakkimo +DB_USER=postgres +DB_PASSWORD=your-password +DB_SSL=false + +# API +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daf1dd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7cec5f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src/ ./src/ + +EXPOSE 3000 + +CMD ["node", "src/index.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..632cc52 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "snakkimo-api", + "version": "1.0.0", + "description": "API server for snakkimo PostgreSQL", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "express": "^4.19.2", + "pg": "^8.11.3", + "dotenv": "^16.4.5", + "cors": "^2.8.5", + "express-validator": "^7.1.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..d3517fe --- /dev/null +++ b/src/db.js @@ -0,0 +1,24 @@ +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle PostgreSQL client', err); + process.exit(-1); +}); + +const query = (text, params) => pool.query(text, params); + +const getClient = () => pool.connect(); + +module.exports = { query, getClient, pool }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..8e23903 --- /dev/null +++ b/src/index.js @@ -0,0 +1,38 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const { pool } = require('./db'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +// Health check +app.get('/health', async (req, res) => { + try { + await pool.query('SELECT 1'); + res.json({ status: 'ok', db: 'connected' }); + } catch (err) { + res.status(503).json({ status: 'error', db: 'disconnected', message: err.message }); + } +}); + +// Routes +app.use('/api', require('./routes/index')); + +// 404 +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +// Error handler +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Internal server error' }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`snakkimo-API running on port ${PORT}`); +}); diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..b00fbbc --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,67 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +// List all tables in the database +router.get('/tables', async (req, res, next) => { + try { + const result = await query( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name` + ); + res.json(result.rows); + } catch (err) { + next(err); + } +}); + +// Get rows from any table (with optional limit) +router.get('/tables/:table', async (req, res, next) => { + try { + const { table } = req.params; + const limit = Math.min(parseInt(req.query.limit) || 100, 1000); + const offset = parseInt(req.query.offset) || 0; + + // Validate table name to prevent SQL injection + const tableCheck = await query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1`, + [table] + ); + if (tableCheck.rows.length === 0) { + return res.status(404).json({ error: `Table "${table}" not found` }); + } + + const result = await query( + `SELECT * FROM "${table}" LIMIT $1 OFFSET $2`, + [limit, offset] + ); + res.json({ table, count: result.rows.length, rows: result.rows }); + } catch (err) { + next(err); + } +}); + +// Execute raw SQL (POST, restricted — use carefully) +router.post('/query', async (req, res, next) => { + try { + const { sql, params } = req.body; + if (!sql) return res.status(400).json({ error: 'Missing "sql" field' }); + + // Block destructive statements without explicit confirmation header + const dangerous = /^\s*(drop|truncate|delete\s+from\s+\w+\s*;)/i; + if (dangerous.test(sql) && req.headers['x-confirm-destructive'] !== 'yes') { + return res.status(400).json({ + error: 'Destructive statement detected. Set header X-Confirm-Destructive: yes to proceed.', + }); + } + + const result = await query(sql, params || []); + res.json({ rowCount: result.rowCount, rows: result.rows }); + } catch (err) { + next(err); + } +}); + +module.exports = router;