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)