From 2873638b1c5aa77120c28586502cd24b198638e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:59:23 +0000 Subject: [PATCH] Geheimer Vorschau-Link: /vorschau// setzt ein Cookie nur fuer diese Preview - websiteProjects.shareToken (scripts/vorschau-link-setup.mjs) - POST/DELETE /api/admin/website-projects/:id/vorschau-link fuer Mitarbeiter (neu erzeugen / abschalten) - GET /api/vorschau-link/pruefen fuer die Login-Pruefung der Previews (preview-portal /auth/verify) Co-Authored-By: Claude Opus 5.5 --- scripts/vorschau-link-setup.mjs | 44 +++++++++ server/index.js | 4 + server/routes/admin/websiteProjects.js | 18 ++++ server/routes/vorschauLink.js | 59 ++++++++++++ server/services/vorschauLink.js | 119 ++++++++++++++++++++++++ test/vorschauLink.test.js | 124 +++++++++++++++++++++++++ 6 files changed, 368 insertions(+) create mode 100644 scripts/vorschau-link-setup.mjs create mode 100644 server/routes/vorschauLink.js create mode 100644 server/services/vorschauLink.js create mode 100644 test/vorschauLink.test.js diff --git a/scripts/vorschau-link-setup.mjs b/scripts/vorschau-link-setup.mjs new file mode 100644 index 0000000..bd340fa --- /dev/null +++ b/scripts/vorschau-link-setup.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Geheimer Vorschau-Link (2026-10-26): legt websiteProjects.shareToken an. + * Idempotent: ein vorhandenes Attribut wird uebersprungen. + * Usage: + * node scripts/vorschau-link-setup.mjs --check (nur melden, nichts anlegen) + * node scripts/vorschau-link-setup.mjs (fehlendes Attribut anlegen) + */ +import { pathToFileURL } from 'node:url' +import { createAppwriteClient } from './preview-runner-setup.mjs' + +export const VORSCHAU_LINK_ATTRIBUTES = [{ key: 'shareToken', type: 'string', size: 64, required: false }] + +export async function main({ check = false, client, log = console.log } = {}) { + const api = client || createAppwriteClient() + const existing = new Set((await api.listAttributes()).map((a) => a.key)) + const missing = VORSCHAU_LINK_ATTRIBUTES.filter((a) => !existing.has(a.key)) + for (const attr of VORSCHAU_LINK_ATTRIBUTES) { + if (existing.has(attr.key)) log(`= Attribut ${attr.key} (existiert)`) + else if (check) log(`- Attribut ${attr.key} FEHLT`) + } + if (check) { + log(missing.length ? `${missing.length} Attribut(e) fehlen.` : 'Alle Attribute vorhanden.') + return { missing } + } + for (const attr of missing) { + await api.createAttribute(attr) + log(`+ Attribut ${attr.key}`) + } + for (const attr of missing) await api.waitForAttribute(attr.key) + log('Fertig.') + return { missing } +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const check = process.argv.includes('--check') + main({ check }) + .then(({ missing }) => process.exit(check && missing.length ? 1 : 0)) + .catch((e) => { + console.error(e.message) + process.exit(1) + }) +} diff --git a/server/index.js b/server/index.js index 79fa936..3196ab5 100644 --- a/server/index.js +++ b/server/index.js @@ -41,6 +41,7 @@ import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, t import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' +import { vorschauLinkSeite, vorschauLinkPruefung } from './routes/vorschauLink.js' import adminGiteaRoutes from './routes/admin/gitea.js' import adminPreviewRoutes from './routes/admin/preview.js' import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js' @@ -132,6 +133,9 @@ app.use('/api/admin/finanzen', adminFinanzenRoutes) app.use('/webhook', giteaWebhookRoutes) // Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token) app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes) +// Geheimer Vorschau-Link: Seite ohne Login (Cookie + Weiterleitung) und Pruefung fuer die Preview-Anmeldung +app.use('/vorschau', vorschauLinkSeite()) +app.use('/api/vorschau-link', vorschauLinkPruefung()) app.get('/api/health', async (_req, res) => { const dbAccess = await verifyDatabaseAccess() diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index 1930d35..d108bc7 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -7,6 +7,7 @@ import { import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { completeAssignment } from '../../services/projectKindAssign.js' import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' +import { renewShareLink, removeShareLink } from '../../services/vorschauLink.js' const router = Router() @@ -127,4 +128,21 @@ router.post('/:id/pushes', async (req, res) => { } }) +// Geheimer Vorschau-Link: neu erzeugen (alter Link wird ungueltig) oder abschalten +router.post('/:id/vorschau-link', async (req, res) => { + try { + return res.json({ ok: true, ...(await renewShareLink(req.params.id)) }) + } catch (err) { + return res.status(err.status || 500).json({ error: err.message || 'Link konnte nicht erzeugt werden' }) + } +}) + +router.delete('/:id/vorschau-link', async (req, res) => { + try { + return res.json({ ok: true, ...(await removeShareLink(req.params.id)) }) + } catch (err) { + return res.status(err.status || 500).json({ error: err.message || 'Link konnte nicht abgeschaltet werden' }) + } +}) + export default router diff --git a/server/routes/vorschauLink.js b/server/routes/vorschauLink.js new file mode 100644 index 0000000..c9aab7d --- /dev/null +++ b/server/routes/vorschauLink.js @@ -0,0 +1,59 @@ +import { Router } from 'express' +import { checkShare, cookieName, previewUrl, shareCookieOptions } from '../services/vorschauLink.js' + +// Geheimer Vorschau-Link, siehe services/vorschauLink.js + +function seite(res, status, titel, text) { + res + .status(status) + .set('Cache-Control', 'no-store') + .set('X-Robots-Tag', 'noindex, nofollow') + .type('html') + .send(` + +${titel} + +

${titel}

${text}

`) +} + +/** Seite: GET /vorschau/:subdomain/:token -> Cookie setzen und auf die Preview weiterleiten */ +export function vorschauLinkSeite() { + const router = Router() + router.get('/:subdomain/:token', async (req, res) => { + let result + try { + result = await checkShare(req.params.subdomain, req.params.token) + } catch (err) { + console.error('[vorschau-link] Pruefung fehlgeschlagen:', err.message) + return seite(res, 500, 'Vorschau nicht erreichbar', 'Bitte in ein paar Minuten noch einmal versuchen.') + } + if (result.reason === 'not_ready') { + return seite(res, 503, 'Vorschau wird vorbereitet', 'Die Seite wird gerade gebaut. Bitte in ein paar Minuten noch einmal versuchen.') + } + if (!result.allowed) { + return seite(res, 404, 'Link nicht gültig', 'Dieser Vorschau-Link ist nicht (mehr) gültig. Bitte frag bei WEBklar nach einem neuen Link.') + } + res.cookie(cookieName(result.subdomain), req.params.token, shareCookieOptions()) + res.set('Cache-Control', 'no-store') + res.set('Referrer-Policy', 'no-referrer') + return res.redirect(302, previewUrl(result.subdomain)) + }) + return router +} + +/** Fuer die Login-Pruefung der Previews: GET /api/vorschau-link/pruefen?subdomain=..., Header x-vorschau-token */ +export function vorschauLinkPruefung() { + const router = Router() + router.get('/pruefen', async (req, res) => { + res.set('Cache-Control', 'no-store') + try { + const result = await checkShare(req.query.subdomain, req.get('x-vorschau-token')) + return res.status(result.allowed ? 200 : 403).json({ allowed: result.allowed, reason: result.reason }) + } catch (err) { + console.error('[vorschau-link] Pruefung fehlgeschlagen:', err.message) + return res.status(500).json({ allowed: false }) + } + }) + return router +} diff --git a/server/services/vorschauLink.js b/server/services/vorschauLink.js new file mode 100644 index 0000000..b8b7a5f --- /dev/null +++ b/server/services/vorschauLink.js @@ -0,0 +1,119 @@ +/** + * Geheimer Vorschau-Link (2026-10-26): Wer den Link kennt, sieht die Preview-Seite eines Projekts + * ohne Login. Der Link ist nirgends verlinkt, nur Mitarbeiter sehen ihn im Ticket. + * + * Ablauf: + * 1. WOMS erzeugt den Link (POST /api/admin/website-projects/:id/vorschau-link) -> websiteProjects.shareToken + * 2. Besucher oeffnet https://project.webklar.com/vorschau// + * -> Cookie wk_vorschau_= fuer .project.webklar.com, Weiterleitung auf die Subdomain + * 3. Die Login-Pruefung der Previews (preview-portal /auth/verify) fragt bei jedem Aufruf + * GET /api/vorschau-link/pruefen?subdomain=... mit dem Cookie-Wert im Header x-vorschau-token + * + * Ein neuer Link oder "Abschalten" macht alte Links und Cookies sofort ungueltig (nach hoechstens CACHE_MS). + */ +import crypto from 'node:crypto' +import { config } from '../config.js' +import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' +import { isPreviewProjectReady } from './previewReadiness.js' + +export const COOKIE_PREFIX = 'wk_vorschau_' +export const COOKIE_MAX_AGE_SEC = 30 * 24 * 60 * 60 +const CACHE_MS = 15 * 1000 +const SUBDOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ +const TOKEN = /^[A-Za-z0-9_-]{20,64}$/ + +export function newShareToken() { + return crypto.randomBytes(18).toString('base64url') +} + +export function cleanSubdomain(value) { + const s = String(value || '').trim().toLowerCase() + return SUBDOMAIN.test(s) ? s : '' +} + +export function cleanToken(value) { + const t = String(value || '').trim() + return TOKEN.test(t) ? t : '' +} + +export function cookieName(subdomain) { + return `${COOKIE_PREFIX}${subdomain}` +} + +export function shareUrl(subdomain, token) { + return `${config.publicBaseUrl}/vorschau/${subdomain}/${token}` +} + +export function previewUrl(subdomain) { + return `https://${subdomain}.${config.preview.baseHost}/` +} + +export function shareCookieOptions() { + return { + domain: config.preview.cookieDomain, + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: COOKIE_MAX_AGE_SEC * 1000, + } +} + +function sameToken(a, b) { + const x = Buffer.from(String(a)) + const y = Buffer.from(String(b)) + return x.length === y.length && crypto.timingSafeEqual(x, y) +} + +// Die Pruefung laeuft bei jedem Bild/Skript der Seite: kurz merken statt jedes Mal Appwrite fragen +const cache = new Map() + +export function clearShareCache() { + cache.clear() +} + +async function projectBySubdomain(subdomain) { + const hit = cache.get(subdomain) + if (hit && hit.until > Date.now()) return hit.project + const docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', subdomain), + Query.limit(1), + ]) + const project = docs[0] || null + cache.set(subdomain, { project, until: Date.now() + CACHE_MS }) + if (cache.size > 500) cache.delete(cache.keys().next().value) + return project +} + +/** { allowed, reason } - reason: bad_request | no_link | not_ready */ +export async function checkShare(subdomainRaw, tokenRaw) { + const subdomain = cleanSubdomain(subdomainRaw) + const token = cleanToken(tokenRaw) + if (!subdomain || !token) return { allowed: false, reason: 'bad_request' } + const project = await projectBySubdomain(subdomain) + if (!project || project.archived || !project.shareToken || !sameToken(project.shareToken, token)) { + return { allowed: false, reason: 'no_link' } + } + if (!isPreviewProjectReady(project)) return { allowed: false, reason: 'not_ready' } + return { allowed: true, subdomain } +} + +/** Neuer Link (ersetzt einen alten). Wirft 404/409 mit status. */ +export async function renewShareLink(projectId) { + const project = await getDocument(config.collections.websiteProjects, projectId).catch(() => null) + if (!project) throw Object.assign(new Error('Projekt nicht gefunden'), { status: 404 }) + const subdomain = cleanSubdomain(project.subdomain) + if (!subdomain) throw Object.assign(new Error('Das Projekt hat keine Preview-Subdomain.'), { status: 409 }) + const token = newShareToken() + await updateDocument(config.collections.websiteProjects, projectId, { shareToken: token }) + clearShareCache() + return { shareToken: token, url: shareUrl(subdomain, token) } +} + +export async function removeShareLink(projectId) { + const project = await getDocument(config.collections.websiteProjects, projectId).catch(() => null) + if (!project) throw Object.assign(new Error('Projekt nicht gefunden'), { status: 404 }) + await updateDocument(config.collections.websiteProjects, projectId, { shareToken: '' }) + clearShareCache() + return { shareToken: '' } +} diff --git a/test/vorschauLink.test.js b/test/vorschauLink.test.js new file mode 100644 index 0000000..33f5931 --- /dev/null +++ b/test/vorschauLink.test.js @@ -0,0 +1,124 @@ +// Geheimer Vorschau-Link: Link erzeugen, einloesen (Cookie + Weiterleitung), pruefen, abschalten +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +const ready = { subdomain: 'kunde-a', projectType: 'preview', provisioningStatus: 'deployed', customerId: 'c1' } + +let aw, server, baseUrl, svc +before(async () => { + aw = await startFakeAppwrite({ + seed: { + websiteProjects: [ + { $id: 'p1', ...ready }, + { $id: 'p2', ...ready, subdomain: 'baut-noch', provisioningStatus: 'pending', shareToken: 'Pending_Token_123456789' }, + { $id: 'p3', projectName: 'ohne Subdomain', subdomain: '' }, + { $id: 'p4', ...ready, subdomain: 'archiv', archived: true, shareToken: 'Archiv_Token_1234567890' }, + ], + }, + }) + Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', PREVIEW_SESSION_SECRET: 'x'.repeat(40) }) + svc = await import('../server/services/vorschauLink.js') + const { vorschauLinkSeite, vorschauLinkPruefung } = await import('../server/routes/vorschauLink.js') + const app = express() + app.use('/vorschau', vorschauLinkSeite()) + app.use('/api/vorschau-link', vorschauLinkPruefung()) + server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) }) + baseUrl = `http://127.0.0.1:${server.address().port}` +}) +after(async () => { + server.closeAllConnections() + await new Promise((r) => server.close(r)) + await aw.close() +}) + +const doc = (id) => aw.all('websiteProjects').find((d) => d.$id === id) +const pruefen = (sub, token) => + fetch(`${baseUrl}/api/vorschau-link/pruefen?subdomain=${sub}`, { headers: token ? { 'x-vorschau-token': token } : {} }) + +test('Token: zufaellig, URL-tauglich, lang genug', () => { + const a = svc.newShareToken() + assert.match(a, /^[A-Za-z0-9_-]{24}$/) + assert.notEqual(a, svc.newShareToken()) + assert.equal(svc.cleanToken(a), a) + assert.equal(svc.cleanToken('kurz'), '') + assert.equal(svc.cleanToken('../../etc/passwd/xxxxxxxxxxxxxxx'), '') + assert.equal(svc.cleanSubdomain('Kunde-A'), 'kunde-a') + assert.equal(svc.cleanSubdomain('-x'), '') + assert.equal(svc.cleanSubdomain('a.b'), '') +}) + +test('Link erzeugen speichert den Token und baut die URL', async () => { + const r = await svc.renewShareLink('p1') + assert.equal(doc('p1').shareToken, r.shareToken) + assert.equal(r.url, `https://project.webklar.com/vorschau/kunde-a/${r.shareToken}`) +}) + +test('Ohne Subdomain gibt es keinen Link (409), unbekanntes Projekt 404', async () => { + await assert.rejects(svc.renewShareLink('p3'), (e) => e.status === 409) + await assert.rejects(svc.renewShareLink('gibt-es-nicht'), (e) => e.status === 404) +}) + +test('Link einloesen: Cookie nur fuer diese Subdomain, Weiterleitung auf die Preview', async () => { + const { shareToken } = await svc.renewShareLink('p1') + const res = await fetch(`${baseUrl}/vorschau/kunde-a/${shareToken}`, { redirect: 'manual' }) + assert.equal(res.status, 302) + assert.equal(res.headers.get('location'), 'https://kunde-a.project.webklar.com/') + const cookie = res.headers.get('set-cookie') + assert.match(cookie, new RegExp(`^wk_vorschau_kunde-a=${shareToken};`)) + assert.match(cookie, /Domain=\.project\.webklar\.com/) + assert.match(cookie, /HttpOnly/) + assert.match(cookie, /SameSite=Lax/) + assert.equal(res.headers.get('cache-control'), 'no-store') +}) + +test('Falscher Token, fremde Subdomain oder Muell: 404-Seite ohne Cookie', async () => { + const { shareToken } = await svc.renewShareLink('p1') + for (const path of [ + `/vorschau/kunde-a/${shareToken.slice(0, -1)}X`, + `/vorschau/archiv/${shareToken}`, + `/vorschau/kunde-a/kurz`, + `/vorschau/KUNDE-A.evil/${shareToken}`, + ]) { + const res = await fetch(baseUrl + path, { redirect: 'manual' }) + assert.equal(res.status, 404, path) + assert.equal(res.headers.get('set-cookie'), null, path) + assert.match(await res.text(), /nicht \(mehr\) gültig/) + } +}) + +test('Archiviertes Projekt: Link gilt nicht', async () => { + const res = await pruefen('archiv', 'Archiv_Token_1234567890') + assert.equal(res.status, 403) +}) + +test('Preview noch nicht fertig: 503-Hinweis, Pruefung sagt not_ready', async () => { + const res = await fetch(`${baseUrl}/vorschau/baut-noch/Pending_Token_123456789`, { redirect: 'manual' }) + assert.equal(res.status, 503) + assert.equal(res.headers.get('set-cookie'), null) + const p = await pruefen('baut-noch', 'Pending_Token_123456789') + assert.deepEqual(await p.json(), { allowed: false, reason: 'not_ready' }) +}) + +test('Pruefung: richtiger Token 200, sonst 403', async () => { + const { shareToken } = await svc.renewShareLink('p1') + let res = await pruefen('kunde-a', shareToken) + assert.equal(res.status, 200) + assert.equal((await res.json()).allowed, true) + res = await pruefen('kunde-a', svc.newShareToken()) + assert.equal(res.status, 403) + res = await pruefen('kunde-a', '') + assert.equal(res.status, 403) +}) + +test('Neuer Link oder Abschalten macht den alten sofort ungueltig', async () => { + const alt = (await svc.renewShareLink('p1')).shareToken + assert.equal((await pruefen('kunde-a', alt)).status, 200) + const neu = (await svc.renewShareLink('p1')).shareToken + assert.equal((await pruefen('kunde-a', alt)).status, 403) + assert.equal((await pruefen('kunde-a', neu)).status, 200) + await svc.removeShareLink('p1') + assert.equal(doc('p1').shareToken, '') + assert.equal((await pruefen('kunde-a', neu)).status, 403) +})