Preview: Deploy auf fremde Subdomain sperren

Ein kopiertes Repo (git clone + push, Vorlage) bringt die
.webklar-preview.json des Originals mit. Sein erster Push ueberschrieb
die Preview des Originals, danach scheiterte der Webhook mit 500
(Unique-Index auf subdomain). Fast passiert bei Olzinger -> HSK-Vorlage,
04.10.2026.

- foreignSubdomainOwner: Subdomain gehoert einem anderen Repo oder einem
  Projekt ohne Repo; ein in Gitea umgezogenes Repo (Weiterleitung) zaehlt
  nicht als fremd
- Webhook: fremde Subdomain -> kein Deploy, Projekt ohne Preview (neu:
  synced, Repo-Name statt fremdem Anzeigenamen); eine schon vergebene
  eigene Preview bleibt, WOMS zeigt den Grund als Preview-Fehler
- runDeploy prueft fuer alle anderen Wege (WOMS, Reaktivieren), vor dem
  Runner, damit ein gesperrter Aufruf keinen laufenden Bau verwaist;
  Appwrite-Fehler bei der Pruefung blockieren dort nicht (Webhook streng)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 19:46:27 +00:00
1 parent 4fc4c70b7d
commit 75794f14d5
4 files changed
+370 -5

No files matched your search

+27 -5
View File
@@ -1,11 +1,12 @@
import crypto from 'node:crypto'
import { Router } from 'express'
import { config } from '../../config.js'
import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js'
import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js'
import {
fetchPreviewConfig,
defaultPreviewConfig,
runDeploy,
foreignSubdomainBlock,
deployKeepsStatus,
buildPreviewUrl,
} from '../../services/previewDeploy.js'
@@ -107,18 +108,24 @@ router.post('/gitea', async (req, res) => {
}
}
const previewConfig = previewConfigFile || defaultPreviewConfig(name)
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
const wantsPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
// Kopiertes Repo mit der .webklar-preview.json eines anderen Projekts: dessen Subdomain weder
// beliefern noch uebernehmen - das Projekt bleibt ohne Preview, bis WOMS ihm eine eigene gibt.
const blocked = wantsPreview
? await foreignSubdomainBlock(name, previewConfig.subdomain || name.split('/').pop())
: null
const hasPreview = wantsPreview && !blocked
// Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : ''
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
let deployResult = { deployed: false, skipped: true }
let deployResult = blocked || { deployed: false, skipped: true }
const configBranch = previewConfig.branch || 'main'
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
try {
deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha })
deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha, subdomainChecked: true })
} catch (deployErr) {
console.error('[webhook] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
@@ -141,7 +148,8 @@ router.post('/gitea', async (req, res) => {
const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase()
const projectPayload = {
projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain,
// der Anzeigename einer fremden Config gehoert ebenfalls dem Original
projectName: (blocked ? '' : previewConfig.displayName) || (payloadNameStillCurrent ? repo.name : '') || subdomain,
templateName: previewConfig.templateName || config.gitea.templateRepo,
giteaRepoUrl: payloadNameStillCurrent
? repo.html_url || `${config.gitea.baseUrl}/${current}`
@@ -161,6 +169,20 @@ router.post('/gitea', async (req, res) => {
} else if (hasPreview) {
projectPayload.status = 'pending'
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
} else if (blocked) {
// eine schon vergebene eigene Preview bleibt, wie sie ist - sonst Projekt ohne Preview;
// ein vorhandener Projektname auch (der Repo-Name waere nur der Ersatz fuer neue Projekte)
const existing = (await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', current),
Query.limit(1),
]))[0]
if (!existing?.subdomain) projectPayload.status = 'synced'
else {
// sonst stuende im WOMS weiter der alte Bau als aktuell da (Fehlertext nur bei 'failed')
projectPayload.previewState = 'failed'
projectPayload.previewError = `Neuer Stand nicht veröffentlicht: In .webklar-preview.json steht die Subdomain „${blocked.subdomain}“, die gehört ${blocked.owner}. Projekt im WOMS bearbeiten und speichern, dann stimmt die Datei wieder.`.slice(0, 500)
}
if (existing?.projectName) delete projectPayload.projectName
} else {
projectPayload.status = 'synced'
}
+68
View File
@@ -802,7 +802,75 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project
}
}
/**
* Gehoert `subdomain` schon einem ANDEREN Projekt? Ein kopiertes Repo (git clone + push, Vorlage)
* bringt die .webklar-preview.json des Originals mit - ohne diese Pruefung ueberschrieb sein erster
* Push die Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). Ein in Gitea umbenanntes
* oder umgezogenes Repo ist nicht fremd: Gitea leitet den alten Namen auf `repoFullName` weiter.
* Ein Projekt ohne Repo (z. B. die Relaunch-Vorschau) haelt seine Subdomain ebenfalls.
* @returns {Promise<string>} Repo-Name bzw. Projekt des fremden Besitzers, sonst ''
*/
export async function foreignSubdomainOwner(repoFullName, subdomain) {
const own = String(repoFullName || '').toLowerCase()
// ohne Repo gibt es nichts zu klonen - und ein Projekt ohne Repo sperrt sich nicht selbst
if (!subdomain || !own) return ''
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(5),
])
for (const doc of docs) {
const other = String(doc.repoFullName || '')
if (!other) return `Projekt „${doc.projectName || doc.$id}“ (ohne Repo)`
if (other.toLowerCase() === own) continue
let resolved = ''
try {
resolved = String((await giteaFetch(`/repos/${other}`))?.full_name || '')
} catch {
// geloescht oder Gitea nicht erreichbar: im Zweifel fremd
}
if (resolved.toLowerCase() !== own) return other
}
return ''
}
/**
* Ergebnis fuer einen gesperrten Deploy oder null. `failOpen`: ist die Pruefung selbst nicht
* moeglich (Appwrite weg), trotzdem deployen - so nutzt es runDeploy; der Webhook prueft vorher
* streng, und die WOMS-Wege lesen das Projekt ohnehin zuerst aus Appwrite.
*/
export async function foreignSubdomainBlock(repoFullName, subdomain, { failOpen = false } = {}) {
let owner
try {
owner = await foreignSubdomainOwner(repoFullName, subdomain)
} catch (err) {
if (!failOpen) throw err
console.error(`[previewDeploy] ${repoFullName}: Besitzer der Subdomain "${subdomain}" nicht pruefbar, deploye trotzdem:`, err.message)
return null
}
if (!owner) return null
console.warn(`[previewDeploy] ${repoFullName}: Subdomain "${subdomain}" gehoert ${owner} - kein Deploy`)
return { deployed: false, blocked: true, reason: 'subdomain_foreign', subdomain, owner }
}
/**
* Muss runDeploy den Besitzer der Subdomain pruefen? Nicht, wenn der Aufrufer es schon getan hat
* (`subdomainChecked`, der Webhook) oder wenn es die schon vergebene Subdomain des uebergebenen
* Projekts selbst ist.
*/
function needsOwnerCheck(repoFullName, subdomain, { project, subdomainChecked } = {}) {
if (subdomainChecked) return false
const same = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase()
return !(project?.subdomain && same(project.subdomain, subdomain) && same(project.repoFullName, repoFullName))
}
export async function runDeploy(repoFullName, branch, previewConfig, options = {}) {
// Vor dem Runner pruefen: ein gesperrter Aufruf belegt dort keinen Platz und laesst so den
// Verfolger eines laufenden Baus desselben Repos in Ruhe.
const target = previewConfig.subdomain || repoFullName.split('/').pop()
if (needsOwnerCheck(repoFullName, target, options)) {
const blocked = await foreignSubdomainBlock(repoFullName, target, { failOpen: true })
if (blocked) return blocked
}
if (config.previewRunner.enabled) {
return runDeployViaRunner(repoFullName, branch, previewConfig, options)
}
+26
View File
@@ -103,6 +103,32 @@ test('Schalter aus: kein Runner-Aufruf, alter Pfad', async () => {
assert.equal(rs.calls.length, 0)
})
test('fremde Subdomain (kopierte Config): kein Runner-Auftrag, Projekt unverändert', async () => {
const original = await project({ subdomain: 'original-seite' })
const kopie = await project()
const r = await pd.runDeploy(kopie.repo, 'main', { ...cfg(kopie.repo), subdomain: 'original-seite' }, { project: kopie.doc, sha: SHA })
assert.equal(r.blocked, true)
assert.equal(r.owner, original.repo)
assert.equal(submits().length, 0)
assert.equal(aw.get('websiteProjects', kopie.id).previewState, undefined)
// danach laeuft ein normaler Auftrag fuer dasselbe Repo wieder an
const ok = await pd.runDeploy(kopie.repo, 'main', cfg(kopie.repo), { project: kopie.doc, sha: SHA })
assert.equal(ok.queued, true)
await pd.waitForRunFollowers()
})
test('gesperrter Aufruf waehrend eines laufenden Baus: der Verfolger bringt den Bau trotzdem online', async () => {
await project({ subdomain: 'andere-seite' })
const { id, repo, doc } = await project()
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
assert.equal(r.queued, true)
const blocked = await pd.runDeploy(repo, 'main', { ...cfg(repo), subdomain: 'andere-seite' }, { project: doc, sha: SHA })
assert.equal(blocked.blocked, true)
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewState, 'online')
assert.equal(submits().length, 1)
})
test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => {
const { id, repo, doc } = await project({ projectName: 'Mein Projekt', previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) })
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
+249
View File
@@ -0,0 +1,249 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHmac } from 'node:crypto'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import express from 'express'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
// Sperre gegen fremde Subdomains: Ein kopiertes Repo (git clone + push, Vorlage) bringt die
// .webklar-preview.json des Originals mit. Ohne Sperre ueberschrieb sein erster Push die
// Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026).
const WEBHOOK_TOKEN = 's3cret'
const ORIGINAL = 'WEBklar/Olzinger'
const KOPIE = 'WEBklar/HSK-Kopie'
const previewJson = (subdomain) =>
JSON.stringify({ enabled: true, type: 'static', branch: 'main', displayName: 'Olzinger', subdomain })
let gitea
let aw
let tmp
let originalCwd
let server
let baseUrl
let foreignSubdomainOwner
let runDeploy
let foreignSubdomainBlock
function pushPayload(repo, { sha, branch = 'main', pusher = 'knso' }) {
return {
ref: `refs/heads/${branch}`,
repository: { full_name: repo, name: repo.split('/')[1], html_url: `${gitea.baseUrl}/${repo}` },
commits: [{ id: sha, message: `Commit ${sha}`, author: { name: pusher } }],
pusher: { username: pusher },
after: sha,
}
}
async function webhook(payload) {
const bodyText = JSON.stringify(payload)
const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex')
const res = await fetch(`${baseUrl}/webhook/gitea`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature },
body: bodyText,
})
return { status: res.status, body: await res.json().catch(() => null) }
}
const docOf = (repo) => aw.all('websiteProjects').find((d) => d.repoFullName === repo)
before(async () => {
gitea = await startFakeGitea({
repos: [
{ full_name: ORIGINAL },
{ full_name: KOPIE },
{ full_name: 'WEBklar/neu-name' },
{ full_name: 'WEBklar/relaunch-kopie' },
{ full_name: 'WEBklar/eigene-seite' },
],
redirects: { 'knso/alt-name': 'WEBklar/neu-name' },
contents: {
[`${ORIGINAL}:.webklar-preview.json`]: previewJson('olzinger'),
[`${KOPIE}:.webklar-preview.json`]: previewJson('olzinger'),
'WEBklar/relaunch-kopie:.webklar-preview.json': previewJson('relaunch'),
// hat schon eine eigene Preview, Config von Hand auf eine fremde Subdomain gestellt
'WEBklar/eigene-seite:.webklar-preview.json': previewJson('olzinger'),
},
})
aw = await startFakeAppwrite({
unique: { websiteProjects: ['repoFullName', 'subdomain'] },
seed: {
websiteProjects: [
{ $id: 'olz', repoFullName: ORIGINAL, projectName: 'Olzinger', subdomain: 'olzinger', customerId: 'c1', ticketId: 't1', status: 'deployed' },
// per Gitea umgezogen (alter Name leitet weiter), Dokument noch unter dem alten Namen
{ $id: 'umzug', repoFullName: 'knso/alt-name', projectName: 'Umzug', subdomain: 'umzug-seite' },
// Repo in Gitea geloescht, Dokument haelt die Subdomain noch
{ $id: 'weg', repoFullName: 'knso/geloescht', projectName: 'Weg', subdomain: 'weg-seite' },
// gehostet ohne Repo (wie die Relaunch-Vorschau)
{ $id: 'rel', repoFullName: '', projectName: 'WEBklar Relaunch', subdomain: 'relaunch' },
{ $id: 'eigen', repoFullName: 'WEBklar/eigene-seite', projectName: 'Eigene Seite', subdomain: 'eigene-seite', status: 'deployed', provisioningStatus: 'ready' },
],
workorders: [{ $id: 't1', woid: '100130', customerName: 'Olzinger', topic: 'Website', status: 'Open' }],
customers: [{ $id: 'c1', name: 'Olzinger GmbH' }],
employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }],
},
})
originalCwd = process.cwd()
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'subdomain-sperre-'))
process.chdir(tmp)
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN,
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: '',
TRAEFIK_DYNAMIC_DIR: '',
PUSH_TIME_SECRET: 'w'.repeat(40),
})
const [{ default: giteaWebhookRoutes }, previewDeploy, mailer] = await Promise.all([
import('../server/routes/webhook/gitea.js'),
import('../server/services/previewDeploy.js'),
import('../server/services/mailer.js'),
])
foreignSubdomainOwner = previewDeploy.foreignSubdomainOwner
runDeploy = previewDeploy.runDeploy
foreignSubdomainBlock = previewDeploy.foreignSubdomainBlock
mailer.setMailTransportForTests({ sendMail: async () => ({}) })
const app = express()
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
app.use('/webhook', giteaWebhookRoutes)
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
baseUrl = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
if (server) await new Promise((resolve) => server.close(resolve))
if (originalCwd) process.chdir(originalCwd)
await gitea?.close()
await aw?.close()
if (tmp) await fs.rm(tmp, { recursive: true, force: true })
})
test('foreignSubdomainOwner: freie Subdomain und eigenes Repo sind nicht fremd', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'noch-frei'), '')
assert.equal(await foreignSubdomainOwner(ORIGINAL, 'olzinger'), '')
assert.equal(await foreignSubdomainOwner('webklar/OLZINGER', 'olzinger'), '', 'Schreibweise egal')
assert.equal(await foreignSubdomainOwner(KOPIE, ''), '')
})
test('foreignSubdomainOwner: Subdomain eines anderen Repos ist fremd', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'olzinger'), ORIGINAL)
})
test('foreignSubdomainOwner: in Gitea umgezogenes Repo ist nicht fremd, geloeschtes schon', async () => {
assert.equal(await foreignSubdomainOwner('WEBklar/neu-name', 'umzug-seite'), '')
assert.equal(await foreignSubdomainOwner(KOPIE, 'weg-seite'), 'knso/geloescht')
})
test('foreignSubdomainOwner: Projekt ohne Repo haelt seine Subdomain, sperrt sich aber nicht selbst', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'relaunch'), 'Projekt „WEBklar Relaunch“ (ohne Repo)')
assert.equal(await foreignSubdomainOwner('', 'relaunch'), '')
})
test('runDeploy verweigert eine fremde Subdomain, bevor irgendetwas geklont wird', async () => {
const before = gitea.calls.length
const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.deployed, false)
assert.equal(r.blocked, true)
assert.equal(r.reason, 'subdomain_foreign')
assert.equal(r.subdomain, 'olzinger')
assert.equal(r.owner, ORIGINAL)
// nur die Besitzer-Pruefung fragt Gitea (nach dem Original), fuer die Kopie wird nichts geholt
assert.deepEqual(gitea.calls.slice(before).map((c) => `${c.method} ${c.path}`), [`GET /api/v1/repos/${ORIGINAL}`])
})
test('runDeploy: Pruefung nicht moeglich (Appwrite-Fehler) → Deploy laeuft trotzdem', async () => {
aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 })
const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.blocked, undefined)
assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
})
test('foreignSubdomainBlock ohne failOpen (Webhook): Appwrite-Fehler wird weitergereicht', async () => {
aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 })
await assert.rejects(foreignSubdomainBlock(KOPIE, 'olzinger'))
assert.equal((await foreignSubdomainBlock(KOPIE, 'olzinger')).owner, ORIGINAL)
})
test('runDeploy: eigene Subdomain des uebergebenen Projekts und subdomainChecked brauchen keine Abfrage', async () => {
const calls = aw.calls.length
const own = await runDeploy(ORIGINAL, 'main', { subdomain: 'olzinger' }, { project: aw.get('websiteProjects', 'olz') })
assert.equal(own.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
const checked = await runDeploy(KOPIE, 'main', { subdomain: 'olzinger' }, { subdomainChecked: true })
assert.equal(checked.blocked, undefined, 'der Aufrufer hat schon geprueft')
assert.equal(aw.calls.length, calls)
})
test('runDeploy: eigene Subdomain laeuft weiter wie bisher', async () => {
const r = await runDeploy(ORIGINAL, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.blocked, undefined)
assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
})
test('Webhook: Push einer Kopie mit fremder Subdomain → kein Deploy, Projekt ohne Preview', async () => {
const r = await webhook(pushPayload(KOPIE, { sha: 'aaaa1111' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, '')
assert.equal(r.body.previewUrl, '')
assert.equal(r.body.deploy.blocked, true)
assert.equal(r.body.deploy.owner, ORIGINAL)
const kopie = docOf(KOPIE)
assert.ok(kopie, 'Dokument der Kopie angelegt')
assert.ok(!kopie.subdomain, 'Kopie bekommt die fremde Subdomain nicht')
assert.equal(kopie.status, 'synced')
assert.equal(kopie.projectName, 'HSK-Kopie', 'nicht der Anzeigename aus der fremden Config')
const original = aw.get('websiteProjects', 'olz')
assert.equal(original.subdomain, 'olzinger')
assert.equal(original.status, 'deployed')
})
test('Webhook: Kopie auf anderem Branch bekommt die fremde Subdomain auch nicht', async () => {
const r = await webhook(pushPayload(KOPIE, { sha: 'bbbb2222', branch: 'entwurf' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, '')
assert.ok(!docOf(KOPIE).subdomain)
})
test('Webhook: Kopie mit der Subdomain eines Projekts ohne Repo wird gesperrt', async () => {
const r = await webhook(pushPayload('WEBklar/relaunch-kopie', { sha: 'cccc3333' }))
assert.equal(r.status, 200)
assert.equal(r.body.deploy.blocked, true)
assert.equal(aw.get('websiteProjects', 'rel').subdomain, 'relaunch')
})
test('Webhook: Push des Originals deployt wie bisher', async () => {
const r = await webhook(pushPayload(ORIGINAL, { sha: 'dddd4444' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, 'olzinger')
assert.equal(r.body.deploy.blocked, undefined)
assert.equal(r.body.deploy.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
assert.equal(docOf(ORIGINAL).subdomain, 'olzinger')
})
test('Webhook: Projekt mit eigener Preview und fremder Subdomain in der Config bleibt, wie es ist', async () => {
const r = await webhook(pushPayload('WEBklar/eigene-seite', { sha: 'ffff6666' }))
assert.equal(r.status, 200)
assert.equal(r.body.deploy.blocked, true)
const doc = aw.get('websiteProjects', 'eigen')
assert.equal(doc.subdomain, 'eigene-seite')
assert.equal(doc.status, 'deployed')
assert.equal(doc.provisioningStatus, 'ready')
assert.equal(doc.projectName, 'Eigene Seite')
assert.equal(doc.previewState, 'failed', 'WOMS zeigt den Fehlertext nur bei failed')
assert.match(doc.previewError, /„olzinger“, die gehört WEBklar\/Olzinger/)
assert.equal(aw.get('websiteProjects', 'olz').subdomain, 'olzinger')
})