commit 53d817419ccf4dbbbac5d15da315daf75d6d8025 Author: James Pattinson Date: Fri Jun 26 13:01:59 2026 +0000 Initial commit 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`); +});