add: cloudflare turnstile captcha

add: "popular communities" card in communities list
This commit is contained in:
trisua 2025-04-02 23:26:43 -04:00
parent 53cf75b53c
commit 131a38abb9
15 changed files with 288 additions and 11 deletions

69
Cargo.lock generated
View file

@ -483,6 +483,22 @@ dependencies = [
"shlex",
]
[[package]]
name = "cf-turnstile"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3e7381ca451b439579a09feb1d41d4b07c0e903bf8c74602b9e95219c40e47b"
dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"secrecy",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "cfg-expr"
version = "0.15.8"
@ -638,6 +654,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@ -1236,6 +1262,7 @@ dependencies = [
"hyper",
"hyper-util",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@ -1799,7 +1826,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@ -2592,12 +2619,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework 3.2.0",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
@ -2660,6 +2700,15 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secrecy"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@ -2667,7 +2716,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.0",
"core-foundation",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-foundation-sys",
"libc",
"security-framework-sys",
@ -2961,7 +3023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.0",
"core-foundation",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@ -3056,6 +3118,7 @@ version = "0.1.0"
dependencies = [
"axum",
"axum-extra",
"cf-turnstile",
"image",
"mime_guess",
"pathbufd",

View file

@ -32,6 +32,8 @@ Your first start of Tetratto might be a little slow as it's going to download al
In the directory you're running Tetratto from, you should create a `tetratto.toml` file. This file follows the configuration schema defined [here](https://trisuaso.github.io/tetratto/tetratto/config/struct.Config.html)!
Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are listed [here](https://developers.cloudflare.com/turnstile/troubleshooting/testing/). You can _technically_ disable the captcha by using the always passing, invisible keys.
## Usage (as a user)
Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out!

View file

@ -29,3 +29,4 @@ reqwest = { version = "0.12.15", features = ["json", "stream"] }
regex = "1.11.1"
serde_json = "1.0.140"
mime_guess = "2.0.5"
cf-turnstile = "0.2.0"

View file

@ -48,6 +48,8 @@ version = "1.0.0"
"communities:action.select" = "Select"
"communities:label.create_new" = "Create new community"
"communities:label.name" = "Name"
"communities:label.my_communities" = "My communities"
"communities:label.popular_communities" = "Popular communities"
"communities:action.join" = "Join"
"communities:action.cancel_request" = "Cancel request"
"communities:action.leave" = "Leave"

View file

@ -149,6 +149,11 @@ article {
}
/* typo */
ul,
ol {
margin-left: 1rem;
}
pre,
code {
font-family: "Jetbrains Mono", "Fire Code", monospace;

View file

@ -1,6 +1,11 @@
{% extends "auth/base.html" %} {% block head %}
<title>Register</title>
{% endblock %} {% block title %}Register{% endblock %} {% block content %}
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
defer
></script>
<form class="w-full flex flex-col gap-4" onsubmit="register(event)">
<div class="flex flex-col gap-1">
<label for="username"><b>Username</b></label>
@ -24,6 +29,48 @@
/>
</div>
<hr />
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "scroll-text" }}
<b>Policies</b>
</div>
<div class="card secondary flex flex-col gap-2">
<span>By continuing, you agree to the following policies:</span>
<ul>
<li>
<a href="{{ config.policies.terms_of_service }}"
>Terms of service</a
>
</li>
<li>
<a href="{{ config.policies.privacy }}">Privacy policy</a>
</li>
</ul>
<div class="flex gap-2">
<input
type="checkbox"
name="policy_consent"
id="policy_consent"
class="w-content"
required
/>
<label for="policy_consent">I agree</label>
</div>
</div>
</div>
<div
class="cf-turnstile"
data-sitekey="{{ config.turnstile.site_key }}"
></div>
<hr />
<button>Submit</button>
</form>
@ -38,6 +85,10 @@
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value,
policy_consent: e.target.policy_consent.checked,
captcha_response: e.target.querySelector(
"[name=cf-turnstile-response]",
).value,
}),
})
.then((res) => res.json())

View file

@ -30,8 +30,31 @@
</button>
</form>
</div>
{% endif %} {% for item in list %} {{
components::community_listing_card(community=item) }} {% endfor %}
{% endif %}
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "award" }}
<span>{{ text "communities:label.my_communities" }}</span>
</div>
<div class="card flex flex-col gap-2">
{% for item in list %} {{
components::community_listing_card(community=item) }} {% endfor %}
</div>
</div>
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "trending-up" }}
<span>{{ text "communities:label.popular_communities" }}</span>
</div>
<div class="card flex flex-col gap-2">
{% for item in popular_list %} {{
components::community_listing_card(community=item) }} {% endfor %}
</div>
</div>
</main>
<script>

View file

@ -51,7 +51,7 @@ community %}
/>
{% endif %} {%- endmacro %} {% macro community_listing_card(community) -%}
<a
class="card w-full flex items-center gap-4"
class="card secondary w-full flex items-center gap-4"
href="/community/{{ community.title }}"
>
{{ components::community_avatar(id=community.id, community=community,

View file

@ -3,7 +3,7 @@ pub mod ipbans;
pub mod profile;
pub mod social;
use super::AuthProps;
use super::{LoginProps, RegisterProps};
use crate::{
State, get_user_from_token,
model::{ApiReturn, Error, auth::User},
@ -16,12 +16,14 @@ use axum::{
use axum_extra::extract::CookieJar;
use tetratto_shared::hash::hash;
use cf_turnstile::{SiteVerifyRequest, TurnstileClient};
/// `/api/v1/auth/register`
pub async fn register_request(
headers: HeaderMap,
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<AuthProps>,
Json(props): Json<RegisterProps>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!(jar, data);
@ -50,8 +52,31 @@ pub async fn register_request(
return (None, Json(Error::NotAllowed.into()));
}
// check captcha
let client = TurnstileClient::new(data.0.turnstile.secret_key.clone().into());
let validated = match client
.siteverify(SiteVerifyRequest {
response: props.captcha_response,
..Default::default()
})
.await
{
Ok(v) => v,
Err(e) => return (None, Json(Error::MiscError(e.to_string()).into())),
};
if !validated.success | !props.policy_consent {
return (
None,
Json(Error::MiscError("Captcha failed".to_string()).into()),
);
}
// ...
let mut user = User::new(props.username, props.password);
user.settings.policy_consent = true;
let (initial_token, t) = User::create_token(&real_ip);
user.tokens.push(t);
@ -81,7 +106,7 @@ pub async fn login_request(
headers: HeaderMap,
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<AuthProps>,
Json(props): Json<LoginProps>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!(jar, data);

View file

@ -188,11 +188,19 @@ pub fn routes() -> Router {
}
#[derive(Deserialize)]
pub struct AuthProps {
pub struct LoginProps {
pub username: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct RegisterProps {
pub username: String,
pub password: String,
pub policy_consent: bool,
pub captcha_response: String,
}
#[derive(Deserialize)]
pub struct CreateCommunity {
pub title: String,

View file

@ -108,6 +108,11 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let popular_list = match data.0.get_popular_communities().await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let mut communities: Vec<Community> = Vec::new();
for membership in &list {
match data.0.get_community_by_id(membership.community).await {
@ -118,7 +123,9 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
context.insert("list", &communities);
context.insert("popular_list", &popular_list);
// return
Ok(Html(

View file

@ -109,6 +109,47 @@ impl Default for DatabaseConfig {
}
}
/// Policies config (TOS/privacy)
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct PoliciesConfig {
/// The link to your terms of service page.
/// This is relative to `/auth/register` on the site.
///
/// If your TOS is an HTML file located in `./public`, you can put
/// `/public/tos.html` here (or something).
pub terms_of_service: String,
/// The link to your privacy policy page.
/// This is relative to `/auth/register` on the site.
///
/// Same deal as terms of service page.
pub privacy: String,
}
impl Default for PoliciesConfig {
fn default() -> Self {
Self {
terms_of_service: "/public/tos.html".to_string(),
privacy: "/public/privacy.html".to_string(),
}
}
}
/// Cloudflare Turnstile configuration
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct TurnstileConfig {
pub site_key: String,
pub secret_key: String,
}
impl Default for TurnstileConfig {
fn default() -> Self {
Self {
site_key: "1x00000000000000000000AA".to_string(), // always passing, visible
secret_key: "1x0000000000000000000000000000000AA".to_string(), // always passing
}
}
}
/// Configuration file
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Config {
@ -148,6 +189,12 @@ pub struct Config {
/// A list of usernames which cannot be used. This also includes community names.
#[serde(default = "default_banned_usernames")]
pub banned_usernames: Vec<String>,
/// Configuration for your site's policies (terms of service, privacy).
#[serde(default = "default_policies")]
pub policies: PoliciesConfig,
/// Configuration for Cloudflare Turnstile.
#[serde(default = "default_turnstile")]
pub turnstile: TurnstileConfig,
}
fn default_name() -> String {
@ -199,6 +246,14 @@ fn default_banned_usernames() -> Vec<String> {
]
}
fn default_policies() -> PoliciesConfig {
PoliciesConfig::default()
}
fn default_turnstile() -> TurnstileConfig {
TurnstileConfig::default()
}
impl Default for Config {
fn default() -> Self {
Self {
@ -212,6 +267,8 @@ impl Default for Config {
dirs: default_dirs(),
no_track: default_no_track(),
banned_usernames: default_banned_usernames(),
policies: default_policies(),
turnstile: default_turnstile(),
}
}
}

View file

@ -9,7 +9,7 @@ use crate::model::{
communities::{CommunityReadAccess, CommunityWriteAccess},
permissions::FinePermission,
};
use crate::{auto_method, execute, get, query_row};
use crate::{auto_method, execute, get, query_row, query_rows};
use pathbufd::PathBufD;
use std::fs::{exists, remove_file};
@ -119,6 +119,32 @@ impl DataManager {
auto_method!(get_community_by_id_no_void()@get_community_from_row -> "SELECT * FROM communities WHERE id = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}");
auto_method!(get_community_by_title_no_void(&str)@get_community_from_row -> "SELECT * FROM communities WHERE title = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}");
/// Get the top 12 most popular (most likes) communities.
pub async fn get_popular_communities(&self) -> Result<Vec<Community>> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
#[cfg(feature = "sqlite")]
let empty = [];
#[cfg(feature = "postgres")]
let empty = &[];
let res = query_rows!(
&conn,
"SELECT * FROM communities ORDER BY likes DESC LIMIT 12",
empty,
|x| { Self::get_community_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("communities".to_string()));
}
Ok(res.unwrap())
}
/// Create a new community in the database.
///
/// # Arguments

View file

@ -41,6 +41,8 @@ impl Default for ThemePreference {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserSettings {
#[serde(default)]
pub policy_consent: bool,
#[serde(default)]
pub display_name: String,
#[serde(default)]
@ -58,6 +60,7 @@ pub struct UserSettings {
impl Default for UserSettings {
fn default() -> Self {
Self {
policy_consent: false,
display_name: String::new(),
biography: String::new(),
private_profile: false,

View file

@ -7,3 +7,7 @@ registration_enabled = true
[dirs]
templates = "html"
assets = "public"
[turnstile]
site_key = "1x00000000000000000000AA"
secret_key = "1x0000000000000000000000000000000AA"