diff --git a/privat/CV/andreknie.de/Caddyfile b/privat/CV/andreknie.de/Caddyfile
index d53386c..bf4090a 100644
--- a/privat/CV/andreknie.de/Caddyfile
+++ b/privat/CV/andreknie.de/Caddyfile
@@ -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
+}
diff --git a/privat/CV/andreknie.de/content/meta/stats.yaml b/privat/CV/andreknie.de/content/meta/stats.yaml
index fb5cd25..2bb95c9 100644
--- a/privat/CV/andreknie.de/content/meta/stats.yaml
+++ b/privat/CV/andreknie.de/content/meta/stats.yaml
@@ -1,6 +1,6 @@
items:
- - value: "50+"
- label: "Projekte"
+ - value: "100+"
+ label: "öffentliche Beiträge"
- value: "13+"
label: "Jahre KI"
- value: "60+"
diff --git a/privat/CV/andreknie.de/docker-compose.yml b/privat/CV/andreknie.de/docker-compose.yml
index ca89fb2..19041b8 100644
--- a/privat/CV/andreknie.de/docker-compose.yml
+++ b/privat/CV/andreknie.de/docker-compose.yml
@@ -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:
diff --git a/privat/CV/andreknie.de/index.html b/privat/CV/andreknie.de/index.html
index bde9757..01dd01f 100644
--- a/privat/CV/andreknie.de/index.html
+++ b/privat/CV/andreknie.de/index.html
@@ -2,10 +2,12 @@
-
+
Dr. André Knie — KI, Transformation & Leadership
+
+
diff --git a/privat/CV/andreknie.de/server/routes/resource-download.js b/privat/CV/andreknie.de/server/routes/resource-download.js
index 2b0ff4f..cf89800 100644
--- a/privat/CV/andreknie.de/server/routes/resource-download.js
+++ b/privat/CV/andreknie.de/server/routes/resource-download.js
@@ -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` })
diff --git a/privat/CV/andreknie.de/server/routes/speaker-cv.js b/privat/CV/andreknie.de/server/routes/speaker-cv.js
index 60064ce..c68b6f4 100644
--- a/privat/CV/andreknie.de/server/routes/speaker-cv.js
+++ b/privat/CV/andreknie.de/server/routes/speaker-cv.js
@@ -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)