Geheimer Vorschau-Link: /vorschau/<subdomain>/<token> 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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-26 17:59:23 +00:00
1 parent 594aa9196a
commit 2873638b1c
6 files changed
+368

No files matched your search

+4
View File
@@ -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()
+18
View File
@@ -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
+59
View File
@@ -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(`<!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, nofollow"><title>${titel}</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f6f7fb;color:#1a1f36;margin:0;display:grid;place-items:center;min-height:100vh;padding:16px;box-sizing:border-box}
main{background:#fff;border-radius:12px;padding:32px;max-width:420px;box-shadow:0 1px 3px rgba(0,0,0,.08)}h1{font-size:20px;margin:0 0 12px}p{margin:0;line-height:1.5;color:#4f566b}</style>
</head><body><main><h1>${titel}</h1><p>${text}</p></main></body></html>`)
}
/** 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
}
+119
View File
@@ -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/<subdomain>/<token>
* -> Cookie wk_vorschau_<subdomain>=<token> 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: '' }
}