feat(deploy): add umami tracking, fix CSP, and lock JSON writes

This commit is contained in:
2026-07-22 15:23:28 +02:00
parent f3cfacfd11
commit 1ab41bf3ec
6 changed files with 76 additions and 18 deletions
+12 -3
View File
@@ -1,5 +1,9 @@
andreknie.de {
root * /opt/andreknie/site
www.andreknie.de {
redir https://andreknie.de{uri}
}
localhost, andreknie.de {
root * /dist
encode zstd gzip
header {
@@ -8,10 +12,11 @@ andreknie.de {
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), geolocation=(), microphone=()"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://stats.andreknie.de http://localhost:3000; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://stats.andreknie.de http://localhost:3000"
}
handle /api/* {
reverse_proxy localhost:3003
reverse_proxy backend:3003
}
@assets path /assets/*
@@ -26,3 +31,7 @@ andreknie.de {
file_server
}
}
stats.andreknie.de {
reverse_proxy umami:3000
}
@@ -1,6 +1,6 @@
items:
- value: "50+"
label: "Projekte"
- value: "100+"
label: "öffentliche Beiträge"
- value: "13+"
label: "Jahre KI"
- value: "60+"
+39
View File
@@ -8,3 +8,42 @@ services:
env_file:
- .env
restart: unless-stopped
caddy:
image: caddy:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./site:/dist
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- backend
umami-db:
image: postgres:15-alpine
env_file:
- .env
volumes:
- umami_db_data:/var/lib/postgresql/data
restart: unless-stopped
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
ports:
- "3000:3000"
env_file:
- .env
environment:
- PORT=3000
depends_on:
- umami-db
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
umami_db_data:
+3 -1
View File
@@ -2,10 +2,12 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logos/favicon.svg" />
<link rel="icon" type="image/png" href="/logos/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dr. André Knie — KI, Transformation & Leadership</title>
<meta name="description" content="Nüchterne KI-Expertise für Mittelstand, Verwaltung und Hochschulen. Souverän, praktisch, evidenzbasiert." />
<!-- Umami Analytics -->
<script async src="http://localhost:3000/script.js" data-website-id="dd098c2f-dbc7-411b-a391-47e5e60eb6db"></script>
</head>
<body>
<div id="root"></div>
@@ -2,11 +2,13 @@ import { Router } from 'express'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { Mutex } from 'async-mutex'
import { resourceLimiter } from '../middleware/rateLimiter.js'
import { antiSpam } from '../middleware/antiSpam.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json')
const leadsMutex = new Mutex()
// Only alphanumeric + hyphen resource IDs are allowed. This prevents path
// traversal (e.g. "../../etc/passwd") when building the download URL.
@@ -24,7 +26,7 @@ function saveLeads(leads) {
const router = Router()
router.post('/', resourceLimiter, antiSpam, (req, res) => {
router.post('/', resourceLimiter, antiSpam, async (req, res) => {
const { name, email, company, resource_id } = req.body
const errors = {}
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
@@ -38,15 +40,17 @@ router.post('/', resourceLimiter, antiSpam, (req, res) => {
return res.status(400).json({ errors })
}
const leads = loadLeads()
leads.push({
name,
email,
company,
resource_id,
timestamp: new Date().toISOString(),
await leadsMutex.runExclusive(async () => {
const leads = loadLeads()
leads.push({
name,
email,
company,
resource_id,
timestamp: new Date().toISOString(),
})
saveLeads(leads)
})
saveLeads(leads)
// Return download URL (resource files are in public/resources/)
res.json({ ok: true, download_url: `/resources/${resource_id}.pdf` })
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { Mutex } from 'async-mutex'
import { resourceLimiter } from '../middleware/rateLimiter.js'
import { antiSpam } from '../middleware/antiSpam.js'
import { sendSpeakerCv } from '../services/mailer.js'
@@ -9,6 +10,7 @@ import { sendSpeakerCv } from '../services/mailer.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const LEADS_FILE = join(__dirname, '..', 'data', 'speaker-cv-leads.json')
const PDF_PATH = join(__dirname, '..', '..', 'public', 'resources', 'speaker-cv-andre-knie.pdf')
const leadsMutex = new Mutex()
function loadLeads() {
if (!existsSync(LEADS_FILE)) return []
@@ -36,9 +38,11 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => {
}
const lead = { name, email, message, timestamp: new Date().toISOString() }
const leads = loadLeads()
leads.push(lead)
saveLeads(leads)
await leadsMutex.runExclusive(async () => {
const leads = loadLeads()
leads.push(lead)
saveLeads(leads)
})
await sendSpeakerCv(email, lead, PDF_PATH)