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:
1 parent
594aa9196a
commit
2873638b1c
6 files changed
+368
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '' }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user