diff --git a/quotesdb/Cargo.lock b/quotesdb/Cargo.lock index e388b39..2f7bf65 100644 --- a/quotesdb/Cargo.lock +++ b/quotesdb/Cargo.lock @@ -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" diff --git a/quotesdb/Cargo.toml b/quotesdb/Cargo.toml index 07b0f72..f4bc41a 100644 --- a/quotesdb/Cargo.toml +++ b/quotesdb/Cargo.toml @@ -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. diff --git a/quotesdb/api/openapi.yaml b/quotesdb/api/openapi.yaml index 5e09a62..b4d04fa 100644 --- a/quotesdb/api/openapi.yaml +++ b/quotesdb/api/openapi.yaml @@ -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: diff --git a/quotesdb/docs/LOCAL_DEV.md b/quotesdb/docs/LOCAL_DEV.md index 871c3f3..a1e051b 100644 --- a/quotesdb/docs/LOCAL_DEV.md +++ b/quotesdb/docs/LOCAL_DEV.md @@ -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 +``` diff --git a/quotesdb/index.html b/quotesdb/index.html index 921f6be..f761992 100644 --- a/quotesdb/index.html +++ b/quotesdb/index.html @@ -7,6 +7,8 @@ + +
diff --git a/quotesdb/infra/turnstile.tf b/quotesdb/infra/turnstile.tf new file mode 100644 index 0000000..562c1cf --- /dev/null +++ b/quotesdb/infra/turnstile.tf @@ -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 +} diff --git a/quotesdb/infra/variables.tf b/quotesdb/infra/variables.tf index 9224457..37a2dae 100644 --- a/quotesdb/infra/variables.tf +++ b/quotesdb/infra/variables.tf @@ -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" +} diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index a4b4547..d3d12b1 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -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(); diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index ffe881f..0685dc6 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -255,13 +255,65 @@ async fn get_quote_handler(State(repo): State, Path(id): Path) -> } } +/// 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(¶ms) + .send() + .await + else { + return false; + }; + let Ok(body) = resp.json::().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, Json(input): Json) -> 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, diff --git a/quotesdb/src/bin/ui/pages/submit.rs b/quotesdb/src/bin/ui/pages/submit.rs index 6759e59..922ad17 100644 --- a/quotesdb/src/bin/ui/pages/submit.rs +++ b/quotesdb/src/bin/ui/pages/submit.rs @@ -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> = use_state(|| None); let success: UseStateHandle> = use_state(|| None); // (quote_id, auth_code) + let turnstile_token: UseStateHandle> = 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); + 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 { /> +
+
+
+