- 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>
120 lines
4.5 KiB
JavaScript
120 lines
4.5 KiB
JavaScript
/**
|
|
* 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: '' }
|
|
}
|