From 53d817419ccf4dbbbac5d15da315daf75d6d8025 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Fri, 26 Jun 2026 13:01:59 +0000 Subject: [PATCH] Initial commit --- .dockerignore | 6 ++ .env.example | 21 ++++++ .gitignore | 3 + Dockerfile | 19 ++++++ README.md | 58 ++++++++++++++++ compose.yml | 21 ++++++ package.json | 12 ++++ scripts/deploy.sh | 55 +++++++++++++++ src/server.js | 170 ++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 365 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yml create mode 100644 package.json create mode 100644 scripts/deploy.sh create mode 100644 src/server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..126e12b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.agents +.codex +node_modules +npm-debug.log +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aaa7123 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +PUBLIC_SITE_URL= +PUBLIC_PPR_API_BASE= +PUBLIC_WEATHER_BASE= +PUBLIC_WEATHER_MQTT_HOST= +DIRECTUS_URL= +DIRECTUS_PUBLIC_URL= +DIRECTUS_TOKEN= +DIRECTUS_HOMEPAGE_BANNER_FOLDER= +CF_WORKER_NAME= +CF_WORKER_COMPATIBILITY_DATE= +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= +DIRECTUS_ASSET_BASE_URL= + +WEBHOOK_SECRET= +WEBHOOK_SECRET_HEADER=x-webhook-secret +DEPLOY_REPO_URL=https://git.pattinson.org/jamesp/egfh-website.git +DEPLOY_BRANCH=main +DEPLOY_COMMAND=npm run deploy:worker:test +DEPLOY_DEBOUNCE_SECONDS=60 +HOST_PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9553c33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +npm-debug.log +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..739b4cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git openssh-client bash \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi + +COPY src ./src +COPY scripts ./scripts +RUN chmod +x /app/scripts/deploy.sh + +ENV PORT=3000 +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..804bfe6 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# site-pusher + +Small webhook listener that deploys `egfh-website` from inside this container. + +The container listens on `POST /webhook`, verifies a shared secret, clones or updates `https://git.pattinson.org/jamesp/egfh-website.git` in a Docker volume, installs dependencies, and runs: + +```sh +npm run deploy:worker:test +``` + +## Configure + +Add these values to `.env`: + +```sh +WEBHOOK_SECRET=change-this +WEBHOOK_SECRET_HEADER=x-webhook-secret +DEPLOY_REPO_URL=https://git.pattinson.org/jamesp/egfh-website.git +DEPLOY_BRANCH=main +DEPLOY_COMMAND=npm run deploy:worker:test +DEPLOY_DEBOUNCE_SECONDS=60 +HOST_PORT=3000 +``` + +All existing Cloudflare, Directus, and public environment variables in `.env` are passed into the deploy container. + +`DEPLOY_DEBOUNCE_SECONDS` controls how long the service waits after a webhook before deploying. If more webhooks arrive during that window, the timer resets so a noisy burst becomes one deploy. If a webhook arrives while a deploy is running, one follow-up deploy is scheduled after the running deploy completes. + +The webhook accepts any of these auth forms: + +- `x-webhook-secret: ` by default, or whatever `WEBHOOK_SECRET_HEADER` names +- `Authorization: Bearer ` +- `Authorization: ` +- Gitea `X-Gitea-Signature` HMAC SHA-256 using `WEBHOOK_SECRET` + +For Gitea, either set the webhook `Authorization Header` to the same value as `WEBHOOK_SECRET`, or put that value in Gitea's `Secret` field and leave signature verification to `X-Gitea-Signature`. + +## Run + +```sh +sudo docker compose up -d --build +sudo docker compose logs -f site-pusher +``` + +Test locally: + +```sh +curl -X POST http://localhost:3000/webhook \ + -H 'content-type: application/json' \ + -H "x-webhook-secret: $WEBHOOK_SECRET" \ + -d '{"manual":true}' +``` + +Health check: + +```sh +curl http://localhost:3000/healthz +``` diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..988c1ba --- /dev/null +++ b/compose.yml @@ -0,0 +1,21 @@ +services: + site-pusher: + build: . + restart: unless-stopped + env_file: + - .env + environment: + PORT: ${PORT:-3000} + DEPLOY_REPO_URL: ${DEPLOY_REPO_URL:-https://git.pattinson.org/jamesp/egfh-website.git} + DEPLOY_BRANCH: ${DEPLOY_BRANCH:-main} + DEPLOY_WORKDIR: ${DEPLOY_WORKDIR:-/workspace/egfh-website} + DEPLOY_COMMAND: ${DEPLOY_COMMAND:-npm run deploy:worker:test} + DEPLOY_DEBOUNCE_SECONDS: ${DEPLOY_DEBOUNCE_SECONDS:-60} + WEBHOOK_SECRET_HEADER: ${WEBHOOK_SECRET_HEADER:-x-webhook-secret} + ports: + - "${HOST_PORT:-3000}:3000" + volumes: + - site-pusher-workspace:/workspace + +volumes: + site-pusher-workspace: diff --git a/package.json b/package.json new file mode 100644 index 0000000..14b4d44 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "site-pusher", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "express": "^4.19.2" + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..43a7f01 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_url="${DEPLOY_REPO_URL:?DEPLOY_REPO_URL is required}" +branch="${DEPLOY_BRANCH:-main}" +workdir="${DEPLOY_WORKDIR:-/workspace/egfh-website}" +deploy_command="${DEPLOY_COMMAND:-npm run deploy:worker:test}" +worker_env_file="${WORKER_ENV_FILE:-.env.worker.test}" +worker_env_example="${WORKER_ENV_EXAMPLE:-${worker_env_file}.example}" + +mkdir -p "$(dirname "$workdir")" + +if [ ! -d "$workdir/.git" ]; then + rm -rf "$workdir" + git clone --branch "$branch" "$repo_url" "$workdir" +else + git -C "$workdir" remote set-url origin "$repo_url" + git -C "$workdir" fetch --prune origin "$branch" + git -C "$workdir" checkout "$branch" + git -C "$workdir" reset --hard "origin/$branch" +fi + +cd "$workdir" + +if [ -f "$worker_env_example" ]; then + : > "$worker_env_file" + + while IFS= read -r line || [ -n "$line" ]; do + key="" + default_value="" + + if [[ "$line" =~ ^[[:space:]]*#?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + default_value="${BASH_REMATCH[2]}" + else + continue + fi + + if [ -n "${!key+x}" ]; then + printf "%s=%s\n" "$key" "${!key}" >> "$worker_env_file" + elif [[ "$line" != \#* && "$default_value" != replace-with-* ]]; then + printf "%s=%s\n" "$key" "$default_value" >> "$worker_env_file" + fi + done < "$worker_env_example" + + echo "Generated $worker_env_file from container environment" +fi + +if [ -f package-lock.json ]; then + npm ci +else + npm install +fi + +exec bash -lc "$deploy_command" diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..20b8f45 --- /dev/null +++ b/src/server.js @@ -0,0 +1,170 @@ +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import express from "express"; + +const app = express(); +const port = Number(process.env.PORT || 3000); +const secret = process.env.WEBHOOK_SECRET; +const secretHeader = (process.env.WEBHOOK_SECRET_HEADER || "x-webhook-secret").toLowerCase(); +const debugAuth = String(process.env.DEBUG_AUTH || "true").toLowerCase() !== "false"; +const deployDebounceSeconds = Math.max(0, Number(process.env.DEPLOY_DEBOUNCE_SECONDS || 60)); + +let deployRunning = false; +let deployTimer = null; +let deployScheduledAt = null; +let queuedDeploy = false; + +app.use(express.json({ + limit: "1mb", + verify: (req, _res, buf) => { + req.rawBody = buf; + } +})); + +function timingSafeEqualText(a, b) { + const left = Buffer.from(String(a || "")); + const right = Buffer.from(String(b || "")); + if (left.length !== right.length) return false; + return crypto.timingSafeEqual(left, right); +} + +function describeRequest(req) { + return { + method: req.method, + path: req.path, + ip: req.ip, + userAgent: req.get("user-agent") || "", + contentType: req.get("content-type") || "", + hasConfiguredHeader: Boolean(req.get(secretHeader)), + configuredHeader: secretHeader, + hasAuthorization: Boolean(req.get("authorization")), + hasAuthorizationBearer: (req.get("authorization") || "").startsWith("Bearer "), + hasGiteaSignature: Boolean(req.get("x-gitea-signature")), + bodyBytes: req.rawBody ? req.rawBody.length : 0 + }; +} + +function logAuthDebug(message, req, extra = {}) { + if (!debugAuth) return; + console.log(`[auth] ${message}`, JSON.stringify({ + ...describeRequest(req), + ...extra + })); +} + +function hasValidSecret(req) { + if (!secret) { + logAuthDebug("rejected: WEBHOOK_SECRET is not set", req); + return false; + } + + const configuredHeaderValue = req.get(secretHeader); + if (configuredHeaderValue && timingSafeEqualText(configuredHeaderValue, secret)) { + logAuthDebug("accepted: configured secret header matched", req); + return true; + } + + const authorization = req.get("authorization") || ""; + if (authorization.startsWith("Bearer ") && timingSafeEqualText(authorization.slice(7), secret)) { + logAuthDebug("accepted: bearer token matched", req); + return true; + } + if (authorization && timingSafeEqualText(authorization, secret)) { + logAuthDebug("accepted: raw authorization header matched", req); + return true; + } + + const giteaSignature = req.get("x-gitea-signature"); + if (giteaSignature && req.rawBody) { + const expected = crypto.createHmac("sha256", secret).update(req.rawBody).digest("hex"); + if (timingSafeEqualText(giteaSignature, expected)) { + logAuthDebug("accepted: gitea signature matched", req); + return true; + } + } + + logAuthDebug("rejected: no auth method matched", req, { + secretConfigured: true + }); + return false; +} + +function runDeploy() { + deployTimer = null; + deployScheduledAt = null; + deployRunning = true; + const startedAt = new Date(); + console.log(`[deploy] starting at ${startedAt.toISOString()}`); + + const child = spawn("/app/scripts/deploy.sh", { + stdio: "inherit", + env: process.env + }); + + child.on("exit", (code, signal) => { + const finishedAt = new Date(); + deployRunning = false; + console.log(`[deploy] finished at ${finishedAt.toISOString()} code=${code} signal=${signal || ""}`); + + if (queuedDeploy) { + queuedDeploy = false; + console.log("[deploy] queued webhook found, scheduling one more deploy"); + scheduleDeploy("queued-after-running"); + } + }); +} + +function scheduleDeploy(reason) { + if (deployRunning) { + queuedDeploy = true; + console.log(`[deploy] deploy already running; marked queued reason=${reason}`); + return "queued"; + } + + if (deployTimer) { + clearTimeout(deployTimer); + console.log(`[deploy] reset debounce timer reason=${reason}`); + } else { + console.log(`[deploy] scheduled deploy reason=${reason}`); + } + + const delayMs = deployDebounceSeconds * 1000; + deployScheduledAt = new Date(Date.now() + delayMs); + deployTimer = setTimeout(runDeploy, delayMs); + + return delayMs === 0 ? "scheduled" : "debouncing"; +} + +app.get("/healthz", (_req, res) => { + res.json({ + ok: true, + deployRunning, + queuedDeploy, + deployDebounceSeconds, + deployScheduledAt: deployScheduledAt ? deployScheduledAt.toISOString() : null + }); +}); + +app.post("/webhook", (req, res) => { + logAuthDebug("received webhook", req); + + if (!hasValidSecret(req)) { + res.status(401).json({ ok: false, error: "unauthorized" }); + return; + } + + const status = scheduleDeploy("webhook"); + res.status(202).json({ + ok: true, + status, + deployScheduledAt: deployScheduledAt ? deployScheduledAt.toISOString() : null + }); +}); + +app.listen(port, () => { + if (!secret) { + console.warn("[server] WEBHOOK_SECRET is not set; webhook requests will be rejected"); + } + console.log(`[server] listening on :${port}`); + console.log(`[server] deploy debounce is ${deployDebounceSeconds}s`); +});