feat(deploy): add umami tracking, fix CSP, and lock JSON writes
This commit is contained in:
@@ -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+"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user