Initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.env
|
||||
+19
@@ -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"]
|
||||
@@ -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
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
});
|
||||
Reference in New Issue
Block a user