Initial commit

This commit is contained in:
James Pattinson
2026-06-26 13:01:59 +00:00
commit 53d817419c
9 changed files with 365 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.agents
.codex
node_modules
npm-debug.log
.env
+21
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
npm-debug.log
.env
+19
View File
@@ -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"]
+58
View File
@@ -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: <WEBHOOK_SECRET>` by default, or whatever `WEBHOOK_SECRET_HEADER` names
- `Authorization: Bearer <WEBHOOK_SECRET>`
- `Authorization: <WEBHOOK_SECRET>`
- 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
```
+21
View File
@@ -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:
+12
View File
@@ -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"
}
}
+55
View File
@@ -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"
+170
View File
@@ -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`);
});