/** * 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: '' } }