diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index 5e23f4a..fa9a251 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -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' } diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 2d83d5f..fff127a 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -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} 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) } diff --git a/test/previewRunner.test.js b/test/previewRunner.test.js index 29db6bd..ba02f47 100644 --- a/test/previewRunner.test.js +++ b/test/previewRunner.test.js @@ -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 }) diff --git a/test/subdomainSperre.test.js b/test/subdomainSperre.test.js new file mode 100644 index 0000000..1e2135a --- /dev/null +++ b/test/subdomainSperre.test.js @@ -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') +})