feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit

- infra/turnstile.tf: provision Turnstile widget (managed mode, quotes.elijah.run domain) with site_key and secret_key outputs
- infra/variables.tf: add var.domain (default: quotes.elijah.run)
- src/lib.rs: add cf_turnstile_token: Option<String> (#[serde(default)]) to CreateQuoteInput; update doctest
- Cargo.toml: add reqwest (0.12, rustls-tls) under native-only dependencies
- src/bin/api/handlers/mod.rs: add verify_turnstile() and CAPTCHA gate in create_handler, both gated on #[cfg(not(target_arch = "wasm32"))]
- src/bin/api/db/native.rs: add cf_turnstile_token: None to all CreateQuoteInput struct literals in tests
- api/openapi.yaml: document cf_turnstile_token field in QuoteCreateRequest schema
- index.html: add Turnstile JS script tag
- src/bin/ui/pages/submit.rs: add turnstile_token state, use_effect_with callback registration, widget div, token included in CreateQuoteInput
- docs/LOCAL_DEV.md: add Cloudflare Turnstile CAPTCHA section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent fb93483f5c
commit 2ccad33921

400
quotesdb/Cargo.lock generated

@ -95,6 +95,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
@ -138,6 +144,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@ -672,6 +684,24 @@ dependencies = [
"pin-utils",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.4.0",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@ -680,13 +710,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http 1.4.0",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@ -829,6 +867,22 @@ dependencies = [
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "itoa"
version = "1.0.17"
@ -895,6 +949,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchit"
version = "0.7.3"
@ -1104,6 +1164,61 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.44"
@ -1123,8 +1238,10 @@ dependencies = [
"getrandom 0.3.4",
"getrandom 0.4.1",
"gloo",
"http 1.4.0",
"js-sys",
"rand",
"reqwest",
"rusqlite",
"serde",
"serde_json",
@ -1134,6 +1251,7 @@ dependencies = [
"tokio",
"tokio-rusqlite",
"tower",
"tower-service",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
@ -1187,6 +1305,58 @@ dependencies = [
"bitflags",
]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"http 1.4.0",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "route-recognizer"
version = "0.3.1"
@ -1207,6 +1377,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.4"
@ -1220,6 +1396,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@ -1389,6 +1600,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@ -1415,6 +1632,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@ -1490,6 +1710,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
@ -1529,6 +1764,16 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@ -1590,6 +1835,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http 1.4.0",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@ -1634,6 +1897,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -1652,6 +1921,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@ -1700,6 +1975,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -1840,19 +2124,47 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
"windows-targets 0.53.5",
]
[[package]]
@ -1864,6 +2176,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
@ -1871,58 +2199,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
@ -2239,6 +2615,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

@ -41,6 +41,8 @@ axum = { version = "0.8", features = ["json"] }
rusqlite = { version = "0.32", features = ["bundled"] }
# Async wrapper around rusqlite for use with Tokio.
tokio-rusqlite = "0.6"
# HTTP client for verifying Cloudflare Turnstile tokens server-side.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# WASM-only dependencies (Workers API binary + UI binary).
# workers-rs, getrandom/wasm_js, and UI libraries are wasm32-specific.

@ -127,6 +127,10 @@ components:
description: >
Optional custom auth code. If omitted, a 4-word passphrase is
auto-generated by the server and returned in the response.
cf_turnstile_token:
type: string
description: Cloudflare Turnstile CAPTCHA token. Required when the server has TURNSTILE_SECRET_KEY configured.
nullable: true
# Request body for POST /api/quotes/:id (update — all fields optional).
QuoteUpdateRequest:

@ -115,3 +115,25 @@ trunk build --release
```
Output: `dist/`
## Cloudflare Turnstile CAPTCHA
The submit form includes a Cloudflare Turnstile CAPTCHA widget. In production,
the API verifies tokens using the `TURNSTILE_SECRET_KEY` environment variable.
**Local development**: The API server skips CAPTCHA verification when
`TURNSTILE_SECRET_KEY` is not set. You can submit quotes without completing
the CAPTCHA challenge.
To test CAPTCHA locally, set the secret key:
```sh
export TURNSTILE_SECRET_KEY="your-secret-key"
cargo run
```
Obtain the secret key from:
```sh
cd infra && tofu output -raw turnstile_secret_key
```

@ -7,6 +7,8 @@
<link data-trunk rel="css" href="src/bin/ui/style.css"/>
<link data-trunk rel="rust" data-bin="ui" />
<link data-trunk rel="copy-file" href="_redirects"/>
<!-- Cloudflare Turnstile CAPTCHA widget -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<div id="app"></div>

@ -0,0 +1,26 @@
# Turnstile CAPTCHA widget protecting the quote submission form.
# Provides a site_key (public, embedded in the UI) and secret_key
# (private, used by the API to verify tokens server-side).
resource "cloudflare_turnstile_widget" "submit" {
# The Cloudflare account to create the widget in.
account_id = var.cloudflare_account_id
name = "quotesdb-submit"
# "managed" mode: Turnstile decides whether to show a visible challenge.
mode = "managed"
# Restrict the widget to the production domain.
domains = [var.domain]
}
output "turnstile_site_key" {
description = "Turnstile site key — safe to embed in the UI."
value = cloudflare_turnstile_widget.submit.id
}
output "turnstile_secret_key" {
description = "Turnstile secret key — inject into Workers via wrangler secret."
value = cloudflare_turnstile_widget.submit.secret
sensitive = true
}

@ -19,3 +19,10 @@ variable "cloudflare_zone_id" {
description = "Cloudflare zone ID for the elijah.run domain."
type = string
}
# Production domain for the quotesdb application.
variable "domain" {
description = "Production domain where quotesdb is hosted (e.g. quotes.elijah.run)."
type = string
default = "quotes.elijah.run"
}

@ -531,6 +531,7 @@ mod tests {
date: None,
tags: vec![],
auth_code: None,
cf_turnstile_token: None,
}
}
@ -544,6 +545,7 @@ mod tests {
date: None,
tags: vec!["test".to_owned()],
auth_code: Some("word-word-word-word".to_owned()),
cf_turnstile_token: None,
};
let (quote, auth) = repo.create_quote(input).await.unwrap();
assert_eq!(auth, "word-word-word-word");
@ -605,6 +607,7 @@ mod tests {
date: None,
tags: vec!["rust".to_owned()],
auth_code: None,
cf_turnstile_token: None,
})
.await
.unwrap();
@ -637,6 +640,7 @@ mod tests {
date: date.map(|d| d.to_owned()),
tags: vec![],
auth_code: None,
cf_turnstile_token: None,
})
.await
.unwrap();
@ -700,6 +704,7 @@ mod tests {
date: None,
tags: vec!["old".to_owned()],
auth_code: None,
cf_turnstile_token: None,
})
.await
.unwrap();
@ -734,6 +739,7 @@ mod tests {
date: None,
tags: vec![],
auth_code: Some("correct-code-here-xx".to_owned()),
cf_turnstile_token: None,
})
.await
.unwrap();

@ -255,13 +255,65 @@ async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) ->
}
}
/// Verify a Cloudflare Turnstile token against the siteverify API.
///
/// Returns `true` if the token is valid, `false` otherwise.
/// Failures are treated conservatively as invalid (returns `false`).
#[cfg(not(target_arch = "wasm32"))]
async fn verify_turnstile(token: &str, secret: &str) -> bool {
#[derive(serde::Deserialize)]
struct TurnstileResponse {
success: bool,
}
let params = [("secret", secret), ("response", token)];
let Ok(client) = reqwest::Client::builder().build() else {
return false;
};
let Ok(resp) = client
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
.form(&params)
.send()
.await
else {
return false;
};
let Ok(body) = resp.json::<TurnstileResponse>().await else {
return false;
};
body.success
}
/// `PUT /api/quotes` — create a new quote.
///
/// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created`
/// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only
/// time it is returned — the client must store it.
///
/// When the `TURNSTILE_SECRET_KEY` environment variable is set, a valid
/// Cloudflare Turnstile token must be provided in the `cf_turnstile_token`
/// field. This check is skipped on wasm32 targets (Workers runtime).
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn create_handler(State(repo): State<Repo>, Json(input): Json<CreateQuoteInput>) -> Response {
// Verify Cloudflare Turnstile token (native builds only; skipped on wasm32).
#[cfg(not(target_arch = "wasm32"))]
{
if let Ok(secret) = std::env::var("TURNSTILE_SECRET_KEY") {
let token = match input.cf_turnstile_token.as_deref() {
Some(t) if !t.is_empty() => t.to_owned(),
_ => {
return error_response(StatusCode::BAD_REQUEST, "CAPTCHA token required")
.into_response()
}
};
let verified = verify_turnstile(&token, &secret).await;
if !verified {
return error_response(StatusCode::FORBIDDEN, "CAPTCHA verification failed")
.into_response();
}
}
}
match repo.create_quote(input).await {
Ok((quote, auth_code)) => (
StatusCode::CREATED,

@ -5,6 +5,7 @@ use crate::components::error::ErrorDisplay;
use crate::storage;
use crate::Route;
use quotesdb::CreateQuoteInput;
use wasm_bindgen::closure::Closure;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlInputElement;
use yew::prelude::*;
@ -27,6 +28,25 @@ pub fn submit_page() -> Html {
let submitting = use_state(|| false);
let error: UseStateHandle<Option<String>> = use_state(|| None);
let success: UseStateHandle<Option<(String, String)>> = use_state(|| None); // (quote_id, auth_code)
let turnstile_token: UseStateHandle<Option<String>> = use_state(|| None);
// Register the Turnstile callback in the global window object.
{
let turnstile_token = turnstile_token.clone();
use_effect_with((), move |_| {
let callback = Closure::wrap(Box::new(move |token: String| {
turnstile_token.set(Some(token));
}) as Box<dyn Fn(String)>);
if let Some(window) = web_sys::window() {
let _ = js_sys::Reflect::set(
&window,
&wasm_bindgen::JsValue::from_str("quotesdb_turnstile_callback"),
callback.as_ref(),
);
}
callback.forget(); // leak the closure so it remains valid for the page lifetime
});
}
let onsubmit = {
let text = text.clone();
@ -38,6 +58,7 @@ pub fn submit_page() -> Html {
let submitting = submitting.clone();
let error = error.clone();
let success = success.clone();
let turnstile_token = turnstile_token.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if *submitting {
@ -49,6 +70,7 @@ pub fn submit_page() -> Html {
let date_val = (*date).clone();
let tags_val = (*tags).clone();
let auth_val = (*custom_auth).clone();
let token_val = (*turnstile_token).clone();
if text_val.is_empty() {
error.set(Some("Quote text is required.".to_string()));
@ -84,6 +106,7 @@ pub fn submit_page() -> Html {
} else {
Some(auth_val)
},
cf_turnstile_token: token_val,
};
submitting.set(true);
@ -254,6 +277,14 @@ pub fn submit_page() -> Html {
/>
</div>
<div class="submit-form__field">
<div
class="cf-turnstile"
data-sitekey="0x4AAAAAAA_PLACEHOLDER_KEY"
data-callback="quotesdb_turnstile_callback"
/>
</div>
<div class="submit-form__actions">
<button
type="submit"

@ -132,6 +132,7 @@ pub struct Quote {
/// date: None,
/// tags: vec!["classic".to_string()],
/// auth_code: None,
/// cf_turnstile_token: None,
/// };
/// assert_eq!(input.author, "Shakespeare");
/// ```
@ -150,6 +151,10 @@ pub struct CreateQuoteInput {
pub tags: Vec<String>,
/// Optional custom auth code. If not provided, one is auto-generated.
pub auth_code: Option<String>,
/// Cloudflare Turnstile token from the submit form widget.
/// Required when `TURNSTILE_SECRET_KEY` is set in the environment.
#[serde(default)]
pub cf_turnstile_token: Option<String>,
}
/// Input payload for updating an existing quote (POST /api/quotes/:id).

Loading…
Cancel
Save