| 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. |
| 37 | set -euo pipefail |
| 38 | |
| 39 | : "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}" |
| 40 | : "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}" |
| 41 | DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}" |
| 42 | ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 43 | APP="$ROOT/apps/diffinery" |
| 44 | WRANGLER=(pnpm --dir "$APP" exec wrangler) |
| 45 | WORKER=diffinery |
| 46 | API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" |
| 47 | WIDGET_NAME="diffinery $DOMAIN" |
| 48 | PERMISSION="Account → Turnstile → Edit (\"Turnstile Sites Write\" in the API reference)" |
| 49 | |
| 50 | cf() { # 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 | } |
| 58 | say() { echo "$*" >&2; } # stdout carries only the site key |
| 59 | warn() { echo "::warning::$*" >&2; } |
| 60 | # Only the error codes and messages: a widget's response may hold its secret. |
| 61 | api_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. |
| 64 | put_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 | |
| 76 | if [[ "${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 |
| 80 | fi |
| 81 | |
| 82 | # Keys set by hand in wrangler.jsonc are the operator's: leave them, and their secret, alone. |
| 83 | OWN="$(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")" |
| 89 | if [[ -n "$OWN" ]]; then |
| 90 | say "wrangler.jsonc sets TURNSTILE_SITE_KEY, so the deploy leaves Turnstile and TURNSTILE_SECRET to you" |
| 91 | exit 0 |
| 92 | fi |
| 93 | |
| 94 | # The widget, found by name; created only when missing. |
| 95 | LIST="$(cf GET "/challenges/widgets?per_page=1000" || true)" |
| 96 | if ! 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 |
| 99 | fi |
| 100 | SITEKEY="$(jq -r --arg n "$WIDGET_NAME" '[.result[]? | select(.name == $n)][0].sitekey // empty' <<<"$LIST")" |
| 101 | if [[ -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)." |
| 105 | else |
| 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" |
| 114 | fi |
| 115 | |
| 116 | # Does the Worker's TURNSTILE_SECRET belong to this widget? Its deployed TURNSTILE_SITE_KEY says. |
| 117 | SETTINGS="$(cf GET "/workers/scripts/$WORKER/settings" || true)" |
| 118 | if 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 |
| 132 | elif 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" |
| 135 | else |
| 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 |
| 138 | fi |
| 139 | echo "$SITEKEY" |