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:
1 parent
583a2d33c2
commit
eb616ddbe8
4 files changed
+169
No files matched your search
@@ -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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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))'))
|
||||||
|
})
|
||||||
Reference in new issue
Block a user