Container-ready via docker/ compose (frontend nginx + backend Node). Compose adjusted for Coolify on the prod server: frontend uses expose:80 (no host binding — host 8080 is taken by the Coolify proxy; Traefik routes visigine.de), backend ALLOWED_ORIGINS=https://visigine.de. Secrets stay in server/.env (git-ignored); see server/.env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
3.7 KiB
JavaScript
107 lines
3.7 KiB
JavaScript
import 'dotenv/config'
|
|
import express from 'express'
|
|
import cors from 'cors'
|
|
import rateLimit from 'express-rate-limit'
|
|
import analyzeRoute from './routes/analyze.js'
|
|
import adminRoute from './routes/admin.js'
|
|
import adminMonitoringRoute from './routes/admin-monitoring.js'
|
|
import demoMonitoringRoute from './routes/demo-monitoring.js'
|
|
// Importing the db module here ensures SQLite is opened and the schema is
|
|
// applied at startup, before any route handler can hit it.
|
|
import './db/index.js'
|
|
|
|
const app = express()
|
|
const PORT = Number(process.env.PORT) || 3001
|
|
|
|
// Comma-separated whitelist; missing/empty means deny all browser origins.
|
|
// Legacy single-origin var is still honored for backwards compat.
|
|
const allowedOrigins = (
|
|
process.env.ALLOWED_ORIGINS ||
|
|
process.env.ALLOWED_ORIGIN ||
|
|
''
|
|
)
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
|
|
app.set('trust proxy', 1)
|
|
|
|
app.use(
|
|
cors({
|
|
origin(origin, cb) {
|
|
// No Origin header: same-origin, curl, server-to-server, health probes.
|
|
if (!origin) return cb(null, true)
|
|
if (allowedOrigins.includes(origin)) return cb(null, true)
|
|
cb(new Error('CORS_NOT_ALLOWED'))
|
|
},
|
|
methods: ['POST', 'GET'],
|
|
allowedHeaders: ['Content-Type', 'X-Admin-Token'],
|
|
exposedHeaders: ['X-Cache', 'X-Request-Id'],
|
|
})
|
|
)
|
|
|
|
app.use(express.json({ limit: '64kb' }))
|
|
|
|
const analyzeLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 20,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Zu viele Anfragen. Bitte einen Moment warten.' },
|
|
})
|
|
|
|
// Public demo endpoint: 5 / hour / IP. Reserved for iteration 4b landing-page
|
|
// teaser; backend works today, no UI exposes it yet.
|
|
const demoMonitoringLimiter = rateLimit({
|
|
windowMs: 60 * 60 * 1000,
|
|
max: 5,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' },
|
|
})
|
|
|
|
// Admin middleware: 401 unconditionally when ADMIN_TOKEN is empty/missing.
|
|
// This is the kill-switch for production deployments where admin is disabled.
|
|
function requireAdmin(req, res, next) {
|
|
const token = req.get('X-Admin-Token')
|
|
const expected = process.env.ADMIN_TOKEN
|
|
if (!expected || !token || token !== expected) {
|
|
return res.status(401).json({ error: 'Unauthorized' })
|
|
}
|
|
next()
|
|
}
|
|
|
|
app.get('/health', (_req, res) => res.json({ ok: true }))
|
|
app.use('/api/analyze', analyzeLimiter, analyzeRoute)
|
|
// Public demo for iteration 4b. Backend wired now, no UI yet.
|
|
app.use('/api/demo/monitoring', demoMonitoringLimiter, demoMonitoringRoute)
|
|
// Admin: token gate first, then the routers. No rate limiter is applied here
|
|
// because admin routes are gated by ADMIN_TOKEN and intended for the owner.
|
|
// The public /api/autofix/zip endpoint is intentionally NOT mounted — the
|
|
// public site only shows a teaser; downloadable files are paid-product only.
|
|
app.use('/api/admin/monitoring', requireAdmin, adminMonitoringRoute)
|
|
app.use('/api/admin', requireAdmin, adminRoute)
|
|
|
|
// Generic error handler — never leak stack traces.
|
|
app.use((err, _req, res, _next) => {
|
|
if (err?.message === 'CORS_NOT_ALLOWED') {
|
|
if (res.headersSent) return
|
|
return res.status(403).json({ error: 'Zugriff verweigert.' })
|
|
}
|
|
console.error('[express-error]', err?.message || err)
|
|
if (res.headersSent) return
|
|
res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
|
|
})
|
|
|
|
// Bind 0.0.0.0 so containers / reverse proxies can reach the listener.
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`[visigine-server] listening on 0.0.0.0:${PORT}`)
|
|
})
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('[unhandledRejection]', reason)
|
|
})
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[uncaughtException]', err?.message || err)
|
|
})
|