Shop H2: Standalone-Seiten GET /shop/:siteKey und /shop/:siteKey/widerruf mit neutraler Seite, Schutz-Headern und Route-Test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:07:48 +00:00
1 parent 583a2d33c2
commit eb616ddbe8
4 files changed
+169

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Shop</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
.seitenkopf{padding:16px 24px;background:#fff;border-bottom:1px solid #e5e7eb;font-weight:700}
main{max-width:960px;margin:0 auto;padding:24px 16px 48px}
.ausweich{margin:0;padding:16px;color:#4b5563}
</style>
</head>
<body>
<header class="seitenkopf" id="seitenkopf">Shop</header>
<main>
<div data-webklar-shop="__SITEKEY__" data-ansicht="__ANSICHT__" data-api=""><p class="ausweich">Der Shop wird geladen …</p></div>
<noscript><p class="ausweich">Für den Shop wird JavaScript benötigt.</p></noscript>
</main>
<script src="/embed/shop.js" defer></script>
</body>
</html>
+3
View File
@@ -19,6 +19,7 @@ import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js' import termineRoutes from './routes/termine.js'
import shopRoutes from './routes/shop.js' import shopRoutes from './routes/shop.js'
import shopWebhookRoutes from './routes/shopWebhook.js' import shopWebhookRoutes from './routes/shopWebhook.js'
import { shopSeiteRouter } from './routes/shopSeite.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js' import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js' import { purgePendingChatUploads } from './services/chatAttachments.js'
@@ -120,6 +121,8 @@ app.use('/embed/', (_req, res, next) => {
}) })
// Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static) // Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static)
app.use('/rechtliches', legalRouter()) app.use('/rechtliches', legalRouter())
// Webshop-Seiten fuer Kaeufer: /shop/:siteKey und /shop/:siteKey/widerruf (vor express.static)
app.use(shopSeiteRouter())
// Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem // Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem
app.use('/admin.html', legacyAdminRedirect.admin) app.use('/admin.html', legacyAdminRedirect.admin)
app.use('/kalkulation.html', legacyAdminRedirect.kalkulation) app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)
+62
View File
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Router } from 'express'
/**
* Oeffentliche Standalone-Seiten des Webshops (Plan 2026-10-05-webshop.md, H2; Spec 5.4):
* GET /shop/:siteKey Shop mit dem Widget public/embed/shop.js
* GET /shop/:siteKey/widerruf dieselbe Seite, das Widget oeffnet gleich die Widerrufsfunktion
* Die Seite verraet nicht, ob der Schluessel existiert oder der Shop aktiv ist: jeder syntaktisch gueltige Schluessel
* bekommt dieselbe Seite, ob der Shop laeuft entscheidet das Widget ueber die oeffentliche Schnittstelle. Alles andere ist 404.
*/
const SITE_KEY_RE = /^[a-z2-7]{16}$/
const DATEI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'shop-seite.html')
const CSP = [
"default-src 'self'",
"img-src 'self' data:",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'", // das Widget legt ein <style>-Element an
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'self'",
].join('; ')
let vorlage = null
export function resetShopSeiteForTests() {
vorlage = null
}
function escapeAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function seite(res, next, siteKey, ansicht) {
if (!SITE_KEY_RE.test(siteKey)) return res.status(404).type('text/plain').send('Not found')
try {
if (vorlage === null) vorlage = readFileSync(DATEI, 'utf8')
} catch (err) {
return next(err)
}
const html = vorlage.replace('__SITEKEY__', escapeAttr(siteKey)).replace('__ANSICHT__', escapeAttr(ansicht))
res.set({
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': CSP,
'X-Robots-Tag': 'noindex',
})
return res.status(200).send(html)
}
export function shopSeiteRouter() {
const router = Router()
router.get('/shop/:siteKey', (req, res, next) => seite(res, next, req.params.siteKey, ''))
router.get('/shop/:siteKey/widerruf', (req, res, next) => seite(res, next, req.params.siteKey, 'widerruf'))
return router
}
export default shopSeiteRouter
+81
View File
@@ -0,0 +1,81 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { useTestEnv } from './support/testEnv.js'
// Task H2: oeffentliche Shop-Seiten /shop/:siteKey und /shop/:siteKey/widerruf
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp } = await import('./support/testApp.js')
const { shopSeiteRouter, resetShopSeiteForTests } = await import('../server/routes/shopSeite.js')
resetShopSeiteForTests()
const app = await startTestApp({ '/': shopSeiteRouter() })
after(() => app.close())
const KEY = 'abcdefghijklmnop'.replace(/[ilo]/g, 'a') // nur a-z und 2-7 wie siteKeys
const get = async (path) => {
const r = await fetch(app.url + path)
return { status: r.status, headers: r.headers, text: await r.text() }
}
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
test('gueltiger Schluessel: 200 HTML mit Schluessel im Container und den Schutz-Headern', async () => {
const r = await get(`/shop/${KEY}`)
assert.equal(r.status, 200)
assert.match(r.headers.get('content-type'), /^text\/html; charset=utf-8/)
assert.match(r.text, new RegExp(`<div data-webklar-shop="${KEY}" data-ansicht="" data-api="">`))
assert.doesNotMatch(r.text, /__SITEKEY__|__ANSICHT__/)
assert.match(r.text, /<script src="\/embed\/shop\.js" defer><\/script>/)
assert.equal(r.headers.get('cache-control'), 'no-store')
assert.equal(r.headers.get('x-content-type-options'), 'nosniff')
assert.equal(r.headers.get('referrer-policy'), 'strict-origin-when-cross-origin')
assert.equal(r.headers.get('x-robots-tag'), 'noindex')
const csp = r.headers.get('content-security-policy')
for (const d of ["default-src 'self'", "script-src 'self'", "connect-src 'self'", "img-src 'self' data:", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'self'"]) {
assert.ok(csp.includes(d), d)
}
assert.ok(!/script-src[^;]*unsafe/.test(csp), 'keine unsicheren Skripte')
})
test('/widerruf setzt data-ansicht="widerruf"', async () => {
const r = await get(`/shop/${KEY}/widerruf`)
assert.equal(r.status, 200)
assert.match(r.text, new RegExp(`data-webklar-shop="${KEY}" data-ansicht="widerruf"`))
assert.equal(r.headers.get('x-robots-tag'), 'noindex')
})
test('jeder syntaktisch gueltige Schluessel liefert dieselbe Seite (kein Orakel)', async () => {
const a = await get(`/shop/${KEY}`)
const b = await get('/shop/2222222222222222')
assert.equal(b.status, 200)
assert.equal(a.text.replaceAll(KEY, 'X'), b.text.replaceAll('2222222222222222', 'X'))
})
test('ungueltige Schluessel und fremde Pfade: 404', async () => {
for (const p of ['/shop/xyz', `/shop/${KEY.toUpperCase()}`, `/shop/${KEY}a`, '/shop/abcdefghijklmn01', '/shop/xyz/abc', `/shop/${KEY}/agb`, `/shop/${KEY}/widerruf/x`, '/shop/%3Cscript%3E', '/shop', '/shop/']) {
const r = await get(p)
assert.equal(r.status, 404, p)
assert.doesNotMatch(r.text, /data-webklar-shop/, p)
}
})
test('nur GET', async () => {
const r = await fetch(`${app.url}/shop/${KEY}`, { method: 'POST' })
assert.equal(r.status, 404)
})
test('shop-seite.html: kein Inline-Skript, nur das Widget; in server/index.js vor express.static gemountet', () => {
const html = lies('public/shop-seite.html')
const skripte = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
assert.equal(skripte.length, 1)
assert.match(skripte[0][1], /\bsrc="\/embed\/shop\.js"/)
assert.equal(skripte[0][2].trim(), '')
assert.doesNotMatch(html, /\son[a-z]+\s*=/i, 'keine Inline-Handler')
assert.doesNotMatch(html, /javascript:/i)
assert.match(html, /<html lang="de">/)
assert.match(html, /<title>Shop<\/title>/)
assert.match(html, /Der Shop wird geladen …/)
const index = lies('server/index.js')
const mount = index.indexOf('app.use(shopSeiteRouter())')
assert.ok(mount > 0 && mount < index.indexOf('app.use(express.static(publicDir))'))
})