feat(deploy): add umami tracking, fix CSP, and lock JSON writes
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
andreknie.de {
|
www.andreknie.de {
|
||||||
root * /opt/andreknie/site
|
redir https://andreknie.de{uri}
|
||||||
|
}
|
||||||
|
|
||||||
|
localhost, andreknie.de {
|
||||||
|
root * /dist
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
header {
|
header {
|
||||||
@@ -8,10 +12,11 @@ andreknie.de {
|
|||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Permissions-Policy "camera=(), geolocation=(), microphone=()"
|
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/* {
|
handle /api/* {
|
||||||
reverse_proxy localhost:3003
|
reverse_proxy backend:3003
|
||||||
}
|
}
|
||||||
|
|
||||||
@assets path /assets/*
|
@assets path /assets/*
|
||||||
@@ -26,3 +31,7 @@ andreknie.de {
|
|||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stats.andreknie.de {
|
||||||
|
reverse_proxy umami:3000
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
items:
|
items:
|
||||||
- value: "50+"
|
- value: "100+"
|
||||||
label: "Projekte"
|
label: "öffentliche Beiträge"
|
||||||
- value: "13+"
|
- value: "13+"
|
||||||
label: "Jahre KI"
|
label: "Jahre KI"
|
||||||
- value: "60+"
|
- value: "60+"
|
||||||
|
|||||||
@@ -8,3 +8,42 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
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">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Dr. André Knie — KI, Transformation & Leadership</title>
|
<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." />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Router } from 'express'
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
import { Mutex } from 'async-mutex'
|
||||||
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
||||||
import { antiSpam } from '../middleware/antiSpam.js'
|
import { antiSpam } from '../middleware/antiSpam.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json')
|
const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json')
|
||||||
|
const leadsMutex = new Mutex()
|
||||||
|
|
||||||
// Only alphanumeric + hyphen resource IDs are allowed. This prevents path
|
// Only alphanumeric + hyphen resource IDs are allowed. This prevents path
|
||||||
// traversal (e.g. "../../etc/passwd") when building the download URL.
|
// traversal (e.g. "../../etc/passwd") when building the download URL.
|
||||||
@@ -24,7 +26,7 @@ function saveLeads(leads) {
|
|||||||
|
|
||||||
const router = Router()
|
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 { name, email, company, resource_id } = req.body
|
||||||
const errors = {}
|
const errors = {}
|
||||||
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
|
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 })
|
return res.status(400).json({ errors })
|
||||||
}
|
}
|
||||||
|
|
||||||
const leads = loadLeads()
|
await leadsMutex.runExclusive(async () => {
|
||||||
leads.push({
|
const leads = loadLeads()
|
||||||
name,
|
leads.push({
|
||||||
email,
|
name,
|
||||||
company,
|
email,
|
||||||
resource_id,
|
company,
|
||||||
timestamp: new Date().toISOString(),
|
resource_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
saveLeads(leads)
|
||||||
})
|
})
|
||||||
saveLeads(leads)
|
|
||||||
|
|
||||||
// Return download URL (resource files are in public/resources/)
|
// Return download URL (resource files are in public/resources/)
|
||||||
res.json({ ok: true, download_url: `/resources/${resource_id}.pdf` })
|
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 { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
import { Mutex } from 'async-mutex'
|
||||||
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
||||||
import { antiSpam } from '../middleware/antiSpam.js'
|
import { antiSpam } from '../middleware/antiSpam.js'
|
||||||
import { sendSpeakerCv } from '../services/mailer.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 __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const LEADS_FILE = join(__dirname, '..', 'data', 'speaker-cv-leads.json')
|
const LEADS_FILE = join(__dirname, '..', 'data', 'speaker-cv-leads.json')
|
||||||
const PDF_PATH = join(__dirname, '..', '..', 'public', 'resources', 'speaker-cv-andre-knie.pdf')
|
const PDF_PATH = join(__dirname, '..', '..', 'public', 'resources', 'speaker-cv-andre-knie.pdf')
|
||||||
|
const leadsMutex = new Mutex()
|
||||||
|
|
||||||
function loadLeads() {
|
function loadLeads() {
|
||||||
if (!existsSync(LEADS_FILE)) return []
|
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 lead = { name, email, message, timestamp: new Date().toISOString() }
|
||||||
const leads = loadLeads()
|
await leadsMutex.runExclusive(async () => {
|
||||||
leads.push(lead)
|
const leads = loadLeads()
|
||||||
saveLeads(leads)
|
leads.push(lead)
|
||||||
|
saveLeads(leads)
|
||||||
|
})
|
||||||
|
|
||||||
await sendSpeakerCv(email, lead, PDF_PATH)
|
await sendSpeakerCv(email, lead, PDF_PATH)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user