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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 09:02:31 +02:00
commit ab720b09d0
7 changed files with 175 additions and 0 deletions

10
.env.example Normal file
View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
*.log
.DS_Store

12
Dockerfile Normal file
View File

@@ -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"]

20
package.json Normal file
View File

@@ -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"
}
}

24
src/db.js Normal file
View File

@@ -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 };

38
src/index.js Normal file
View File

@@ -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}`);
});

67
src/routes/index.js Normal file
View File

@@ -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;