Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

139 lines · 8.4 KB · executableRaw History
1#!/usr/bin/env bash
2# Cloudflare Turnstile for the security report form (and open sign-up, when SIGNUP=open).
3# Run by scripts/bootstrap.sh on every deploy, and by scripts/ensure-secrets.sh.
4#
5# scripts/turnstile.sh
6# Finds the account's Turnstile widget named "diffinery <DIFFINERY_DOMAIN>", creating it
7# (managed mode, for that hostname) when there is none, and prints its site key: the only
8# thing this prints on stdout. Bootstrap passes it to scripts/render-config.mjs, which deploys
9# it as the TURNSTILE_SITE_KEY var. Prints nothing when the token can't manage Turnstile,
10# or when wrangler.jsonc sets TURNSTILE_SITE_KEY itself (then Turnstile is the operator's).
11# scripts/turnstile.sh secret <site key> [wrangler args...]
12# Stores that widget's secret key as the Worker secret TURNSTILE_SECRET. ensure-secrets.sh
13# does this after the deploy when TURNSTILE_SECRET is missing.
14#
15# The secret key goes from Cloudflare's API straight to `wrangler secret put`; it is never printed.
16#
17# Which secret goes with which site key: Worker secrets can't be read back, so the marker is the
18# TURNSTILE_SITE_KEY var deployed with it. When the Worker has TURNSTILE_SECRET but its deployed
19# site key isn't this widget's (the widget was deleted and created again, or the operator's own
20# keys came before), the secret belongs to another widget, and this replaces it before the new
21# site key is deployed. If it can't, it prints no site key, so the new key never goes live with the
22# old secret; anonymous reports stay refused, and the next deploy tries again.
23#
24# Cloudflare API (https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/):
25# List: GET /accounts/{account_id}/challenges/widgets?per_page=1000 (items have no secret)
26# Create: POST /accounts/{account_id}/challenges/widgets {name, domains, mode}
27# https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/
28# Get: GET /accounts/{account_id}/challenges/widgets/{sitekey} (with .result.secret)
29# Deployed bindings, to compare site keys: GET /accounts/{account_id}/workers/scripts/diffinery/settings
30# https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/script_and_version_settings/methods/get/
31# The token needs Account → Turnstile → Edit ("Turnstile Sites Write" in the API reference;
32# https://developers.cloudflare.com/turnstile/get-started/widget-management/api/). Without it this
33# warns, naming it, and the deploy goes on: people who aren't signed in can't send security reports
34# until someone gives the token that permission or sets the keys by hand (docs/self-hosting/configuration.md).
35#
36# Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl. Optional: DIFFINERY_DOMAIN.
37set -euo pipefail
38
39: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}"
40: "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}"
41DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}"
42ROOT="$(cd "$(dirname "$0")/.." && pwd)"
43APP="$ROOT/apps/diffinery"
44WRANGLER=(pnpm --dir "$APP" exec wrangler)
45WORKER=diffinery
46API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID"
47WIDGET_NAME="diffinery $DOMAIN"
48PERMISSION="Account → Turnstile → Edit (\"Turnstile Sites Write\" in the API reference)"
49
50cf() { # cf METHOD PATH [JSON]
51 local method="$1" path="$2" body="${3:-}"
52 if [[ -n "$body" ]]; then
53 curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" --data "$body"
54 else
55 curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
56 fi
57}
58say() { echo "$*" >&2; } # stdout carries only the site key
59warn() { echo "::warning::$*" >&2; }
60# Only the error codes and messages: a widget's response may hold its secret.
61api_errors() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1" 2>/dev/null || echo "not JSON"; }
62
63# put_secret SITEKEY [wrangler args...]: the widget's secret key, from the API to the Worker secret.
64put_secret() {
65 local sitekey="$1" res secret
66 shift
67 res="$(cf GET "/challenges/widgets/$sitekey" || true)"
68 secret="$(jq -r '.result.secret // empty' <<<"$res" 2>/dev/null || true)"
69 if [[ -z "$secret" ]]; then
70 warn "Could not read the secret key of Turnstile widget $sitekey. The token needs $PERMISSION. Cloudflare said: $(api_errors "$res")"
71 return 1
72 fi
73 printf '%s' "$secret" | "${WRANGLER[@]}" secret put TURNSTILE_SECRET "$@" >/dev/null
74}
75
76if [[ "${1:-}" == "secret" ]]; then
77 [[ -n "${2:-}" ]] || { echo "usage: scripts/turnstile.sh secret <site key> [wrangler args...]" >&2; exit 2; }
78 put_secret "${@:2}"
79 exit
80fi
81
82# Keys set by hand in wrangler.jsonc are the operator's: leave them, and their secret, alone.
83OWN="$(node --input-type=module -e '
84 import { readFileSync } from "node:fs";
85 import { pathToFileURL } from "node:url";
86 const { parseJsonc } = await import(pathToFileURL(process.argv[1]).href);
87 process.stdout.write(parseJsonc(readFileSync(process.argv[2], "utf8")).vars?.TURNSTILE_SITE_KEY ?? "");
88' "$ROOT/scripts/jsonc.mjs" "$APP/wrangler.jsonc")"
89if [[ -n "$OWN" ]]; then
90 say "wrangler.jsonc sets TURNSTILE_SITE_KEY, so the deploy leaves Turnstile and TURNSTILE_SECRET to you"
91 exit 0
92fi
93
94# The widget, found by name; created only when missing.
95LIST="$(cf GET "/challenges/widgets?per_page=1000" || true)"
96if ! jq -e '.success' >/dev/null 2>&1 <<<"$LIST"; then
97 warn "Could not read the account's Turnstile widgets, so the deploy can't set up Turnstile and only people who are signed in can send security reports. The token needs $PERMISSION. Cloudflare said: $(api_errors "$LIST")"
98 exit 0
99fi
100SITEKEY="$(jq -r --arg n "$WIDGET_NAME" '[.result[]? | select(.name == $n)][0].sitekey // empty' <<<"$LIST")"
101if [[ -n "$SITEKEY" ]]; then
102 say "Turnstile widget \"$WIDGET_NAME\" exists: $SITEKEY"
103 jq -e --arg n "$WIDGET_NAME" --arg d "$DOMAIN" 'any([.result[]? | select(.name == $n)][0].domains[]?; . == $d)' >/dev/null <<<"$LIST" \
104 || warn "Turnstile widget \"$WIDGET_NAME\" doesn't list $DOMAIN among its hostnames, so its challenge fails there. Add $DOMAIN to it in the dashboard (Turnstile → the widget → Hostname management)."
105else
106 BODY="$(jq -nc --arg n "$WIDGET_NAME" --arg d "$DOMAIN" '{name: $n, domains: [$d], mode: "managed"}')"
107 RES="$(cf POST /challenges/widgets "$BODY" || true)"
108 SITEKEY="$(jq -r 'select(.success) | .result.sitekey // empty' <<<"$RES" 2>/dev/null || true)"
109 if [[ -z "$SITEKEY" ]]; then
110 warn "Could not create the Turnstile widget \"$WIDGET_NAME\", so only people who are signed in can send security reports. The token needs $PERMISSION. Cloudflare said: $(api_errors "$RES")"
111 exit 0
112 fi
113 say "created Turnstile widget \"$WIDGET_NAME\" (managed, for $DOMAIN): $SITEKEY"
114fi
115
116# Does the Worker's TURNSTILE_SECRET belong to this widget? Its deployed TURNSTILE_SITE_KEY says.
117SETTINGS="$(cf GET "/workers/scripts/$WORKER/settings" || true)"
118if jq -e '.success' >/dev/null 2>&1 <<<"$SETTINGS"; then
119 DEPLOYED="$(jq -r '[.result.bindings[]? | select(.type == "plain_text" and .name == "TURNSTILE_SITE_KEY") | .text][0] // empty' <<<"$SETTINGS")"
120 if ! jq -e '[.result.bindings[]? | select(.type == "secret_text" and .name == "TURNSTILE_SECRET")] | length > 0' >/dev/null <<<"$SETTINGS"; then
121 say "TURNSTILE_SECRET isn't set yet; scripts/ensure-secrets.sh sets it after the deploy"
122 elif [[ "$DEPLOYED" != "$SITEKEY" ]]; then
123 if put_secret "$SITEKEY" --name "$WORKER"; then
124 say "replaced TURNSTILE_SECRET: it went with site key ${DEPLOYED:-(none)}, not $SITEKEY"
125 else
126 warn "TURNSTILE_SECRET goes with another Turnstile widget and couldn't be replaced, so this deploy leaves TURNSTILE_SITE_KEY unset and only people who are signed in can send security reports. The next deploy tries again."
127 exit 0
128 fi
129 else
130 say "TURNSTILE_SECRET goes with site key $SITEKEY"
131 fi
132elif jq -e 'any(.errors[]?; .code == 10007)' >/dev/null 2>&1 <<<"$SETTINGS"; then
133 # 10007: no such Worker yet (the first deploy; wrangler's WORKER_NOT_FOUND_ERR_CODE). ensure-secrets.sh sets TURNSTILE_SECRET after it.
134 say "the Worker isn't deployed yet; scripts/ensure-secrets.sh sets TURNSTILE_SECRET after the deploy"
135else
136 warn "Could not read the $WORKER Worker's settings to check which Turnstile widget TURNSTILE_SECRET goes with, so this deploy leaves TURNSTILE_SITE_KEY unset. The token needs Account → Workers Scripts → Edit. Cloudflare said: $(api_errors "$SETTINGS")"
137 exit 0
138fi
139echo "$SITEKEY"