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)
+})