diff --git a/Cargo.lock b/Cargo.lock index b01dd41c6..39ef0d807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1370,6 +1370,7 @@ dependencies = [ "itoa", "pq-sys", "serde_json", + "uuid", ] [[package]] @@ -2577,6 +2578,7 @@ dependencies = [ "base64 0.13.1", "bcrypt", "captcha", + "chrono", "lemmy_api_common", "lemmy_db_schema", "lemmy_db_views", @@ -2627,6 +2629,7 @@ dependencies = [ "actix-web", "async-trait", "bcrypt", + "chrono", "lemmy_api_common", "lemmy_db_schema", "lemmy_db_views", @@ -2635,6 +2638,7 @@ dependencies = [ "serde", "tracing", "url", + "uuid", "webmention", ] @@ -2710,6 +2714,7 @@ dependencies = [ "ts-rs", "typed-builder", "url", + "uuid", ] [[package]] diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 2488f2c2c..ca792809b 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -29,6 +29,7 @@ async-trait = { workspace = true } captcha = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } +chrono = { workspace = true } [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 9ff1677d0..615a8a314 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,4 +1,5 @@ use actix_web::web::Data; +use captcha::Captcha; use lemmy_api_common::{context::LemmyContext, utils::local_site_to_slur_regex}; use lemmy_db_schema::source::local_site::LocalSite; use lemmy_utils::{error::LemmyError, utils::slurs::check_slurs}; @@ -20,6 +21,21 @@ pub trait Perform { async fn perform(&self, context: &Data) -> Result; } +/// Converts the captcha to a base64 encoded wav audio file +pub(crate) fn captcha_as_wav_base64(captcha: &Captcha) -> String { + let letters = captcha.as_wav(); + + let mut concat_letters: Vec = Vec::new(); + + for letter in letters { + let bytes = letter.unwrap_or_default(); + concat_letters.extend(bytes); + } + + // Convert to base64 + base64::encode(concat_letters) +} + /// Check size of report and remove whitespace pub(crate) fn check_report_reason(reason: &str, local_site: &LocalSite) -> Result<(), LemmyError> { let slur_regex = &local_site_to_slur_regex(local_site); diff --git a/crates/api/src/local_user/get_captcha.rs b/crates/api/src/local_user/get_captcha.rs new file mode 100644 index 000000000..133044248 --- /dev/null +++ b/crates/api/src/local_user/get_captcha.rs @@ -0,0 +1,50 @@ +use crate::{captcha_as_wav_base64, Perform}; +use actix_web::web::Data; +use captcha::{gen, Difficulty}; +use lemmy_api_common::{ + context::LemmyContext, + person::{CaptchaResponse, GetCaptcha, GetCaptchaResponse}, +}; +use lemmy_db_schema::source::{ + captcha_answer::{CaptchaAnswer, CaptchaAnswerForm}, + local_site::LocalSite, +}; +use lemmy_utils::error::LemmyError; + +#[async_trait::async_trait(?Send)] +impl Perform for GetCaptcha { + type Response = GetCaptchaResponse; + + #[tracing::instrument(skip(context))] + async fn perform(&self, context: &Data) -> Result { + let local_site = LocalSite::read(context.pool()).await?; + + if !local_site.captcha_enabled { + return Ok(GetCaptchaResponse { ok: None }); + } + + let captcha = gen(match local_site.captcha_difficulty.as_str() { + "easy" => Difficulty::Easy, + "hard" => Difficulty::Hard, + _ => Difficulty::Medium, + }); + + let answer = captcha.chars_as_string(); + + let png = captcha.as_base64().expect("failed to generate captcha"); + + let wav = captcha_as_wav_base64(&captcha); + + let captcha_form: CaptchaAnswerForm = CaptchaAnswerForm { answer }; + // Stores the captcha item in the db + let captcha = CaptchaAnswer::insert(context.pool(), &captcha_form).await?; + + Ok(GetCaptchaResponse { + ok: Some(CaptchaResponse { + png, + wav, + uuid: captcha.uuid.to_string(), + }), + }) + } +} diff --git a/crates/api/src/local_user/mod.rs b/crates/api/src/local_user/mod.rs index 9244f825d..3a92beda5 100644 --- a/crates/api/src/local_user/mod.rs +++ b/crates/api/src/local_user/mod.rs @@ -3,6 +3,7 @@ mod ban_person; mod block; mod change_password; mod change_password_after_reset; +mod get_captcha; mod list_banned; mod login; mod notifications; diff --git a/crates/api_crud/Cargo.toml b/crates/api_crud/Cargo.toml index 1fb1e5a66..abe747b15 100644 --- a/crates/api_crud/Cargo.toml +++ b/crates/api_crud/Cargo.toml @@ -22,3 +22,5 @@ tracing = { workspace = true } url = { workspace = true } async-trait = { workspace = true } webmention = "0.4.0" +chrono = { worspace = true } +uuid = { workspace = true } \ No newline at end of file diff --git a/crates/api_crud/src/user/create.rs b/crates/api_crud/src/user/create.rs index f5a26f756..302e2f98e 100644 --- a/crates/api_crud/src/user/create.rs +++ b/crates/api_crud/src/user/create.rs @@ -19,6 +19,7 @@ use lemmy_api_common::{ use lemmy_db_schema::{ aggregates::structs::PersonAggregates, source::{ + captcha_answer::{CaptchaAnswer, CheckCaptchaAnswer}, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, registration_application::{RegistrationApplication, RegistrationApplicationInsertForm}, @@ -71,6 +72,25 @@ impl PerformCrud for Register { return Err(LemmyError::from_message("passwords_dont_match")); } + if local_site.site_setup && local_site.captcha_enabled { + if let Some(captcha_uuid) = &data.captcha_uuid { + let uuid = uuid::Uuid::parse_str(captcha_uuid)?; + let check = CaptchaAnswer::check_captcha( + context.pool(), + CheckCaptchaAnswer { + uuid, + answer: data.captcha_answer.clone().unwrap_or_default(), + }, + ) + .await?; + if !check { + return Err(LemmyError::from_message("captcha_incorrect")); + } + } else { + return Err(LemmyError::from_message("captcha_incorrect")); + } + } + let slur_regex = local_site_to_slur_regex(&local_site); check_slurs(&data.username, &slur_regex)?; check_slurs_opt(&data.answer, &slur_regex)?; diff --git a/crates/db_schema/Cargo.toml b/crates/db_schema/Cargo.toml index e99f3cd1c..69affde88 100644 --- a/crates/db_schema/Cargo.toml +++ b/crates/db_schema/Cargo.toml @@ -29,7 +29,7 @@ serde_json = { workspace = true, optional = true } activitypub_federation = { workspace = true, optional = true } lemmy_utils = { workspace = true, optional = true } bcrypt = { workspace = true, optional = true } -diesel = { workspace = true, features = ["postgres","chrono", "serde_json"], optional = true } +diesel = { workspace = true, features = ["postgres","chrono", "serde_json", "uuid"], optional = true } diesel-derive-newtype = { workspace = true, optional = true } diesel-derive-enum = { workspace = true, optional = true } diesel_migrations = { workspace = true, optional = true } @@ -48,6 +48,7 @@ rustls = { workspace = true } futures-util = { workspace = true } tokio-postgres = { workspace = true } tokio-postgres-rustls = { workspace = true } +uuid = { features = ["v4"] } [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/db_schema/src/diesel_ltree.patch b/crates/db_schema/src/diesel_ltree.patch index d7d49f03e..ecbeb2193 100644 --- a/crates/db_schema/src/diesel_ltree.patch +++ b/crates/db_schema/src/diesel_ltree.patch @@ -19,8 +19,8 @@ index 255c6422..f2ccf5e2 100644 #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "sort_type_enum"))] -@@ -67,13 +63,13 @@ diesel::table! { - when_ -> Timestamp, +@@ -76,13 +76,13 @@ diesel::table! { + published -> Timestamp, } } diff --git a/crates/db_schema/src/impls/captcha_answer.rs b/crates/db_schema/src/impls/captcha_answer.rs new file mode 100644 index 000000000..de5fac65e --- /dev/null +++ b/crates/db_schema/src/impls/captcha_answer.rs @@ -0,0 +1,118 @@ +use crate::{ + schema::captcha_answer::dsl::{answer, captcha_answer, uuid}, + source::captcha_answer::{CaptchaAnswer, CaptchaAnswerForm, CheckCaptchaAnswer}, + utils::{functions::lower, get_conn, DbPool}, +}; +use diesel::{ + delete, + dsl::exists, + insert_into, + result::Error, + select, + ExpressionMethods, + QueryDsl, +}; +use diesel_async::RunQueryDsl; + +impl CaptchaAnswer { + pub async fn insert(pool: &DbPool, captcha: &CaptchaAnswerForm) -> Result { + let conn = &mut get_conn(pool).await?; + + insert_into(captcha_answer) + .values(captcha) + .get_result::(conn) + .await + } + + pub async fn check_captcha(pool: &DbPool, to_check: CheckCaptchaAnswer) -> Result { + let conn = &mut get_conn(pool).await?; + + // fetch requested captcha + let captcha_exists = select(exists( + captcha_answer + .filter((uuid).eq(to_check.uuid)) + .filter(lower(answer).eq(to_check.answer.to_lowercase().clone())), + )) + .get_result::(conn) + .await?; + + // delete checked captcha + delete(captcha_answer.filter(uuid.eq(to_check.uuid))) + .execute(conn) + .await?; + + Ok(captcha_exists) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + source::captcha_answer::{CaptchaAnswer, CaptchaAnswerForm, CheckCaptchaAnswer}, + utils::build_db_pool_for_tests, + }; + use serial_test::serial; + + #[tokio::test] + #[serial] + async fn test_captcha_happy_path() { + let pool = &build_db_pool_for_tests().await; + + let inserted = CaptchaAnswer::insert( + pool, + &CaptchaAnswerForm { + answer: "XYZ".to_string(), + }, + ) + .await + .expect("should not fail to insert captcha"); + + let result = CaptchaAnswer::check_captcha( + pool, + CheckCaptchaAnswer { + uuid: inserted.uuid, + answer: "xyz".to_string(), + }, + ) + .await; + + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[tokio::test] + #[serial] + async fn test_captcha_repeat_answer_fails() { + let pool = &build_db_pool_for_tests().await; + + let inserted = CaptchaAnswer::insert( + pool, + &CaptchaAnswerForm { + answer: "XYZ".to_string(), + }, + ) + .await + .expect("should not fail to insert captcha"); + + let _result = CaptchaAnswer::check_captcha( + pool, + CheckCaptchaAnswer { + uuid: inserted.uuid, + answer: "xyz".to_string(), + }, + ) + .await; + + let result_repeat = CaptchaAnswer::check_captcha( + pool, + CheckCaptchaAnswer { + uuid: inserted.uuid, + answer: "xyz".to_string(), + }, + ) + .await; + + assert!(result_repeat.is_ok()); + assert!(!result_repeat.unwrap()); + } +} diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index 915d1c8e2..f13004d01 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -1,5 +1,6 @@ pub mod activity; pub mod actor_language; +pub mod captcha_answer; pub mod comment; pub mod comment_reply; pub mod comment_report; diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index abd3ca22a..42946d699 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -64,6 +64,15 @@ diesel::table! { } } +diesel::table! { + captcha_answer (id) { + id -> Int4, + uuid -> Uuid, + answer -> Text, + published -> Timestamp, + } +} + diesel::table! { use diesel::sql_types::*; use diesel_ltree::sql_types::Ltree; @@ -914,6 +923,7 @@ diesel::allow_tables_to_appear_in_same_query!( admin_purge_community, admin_purge_person, admin_purge_post, + captcha_answer, comment, comment_aggregates, comment_like, diff --git a/crates/db_schema/src/source/captcha_answer.rs b/crates/db_schema/src/source/captcha_answer.rs new file mode 100644 index 000000000..e3e64c4eb --- /dev/null +++ b/crates/db_schema/src/source/captcha_answer.rs @@ -0,0 +1,33 @@ +#[cfg(feature = "full")] +use crate::schema::captcha_answer; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use uuid::Uuid; + +#[skip_serializing_none] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Queryable))] +#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))] +pub struct CaptchaAnswer { + pub id: i32, + pub uuid: Uuid, + pub answer: String, + pub published: chrono::NaiveDateTime, +} + +#[skip_serializing_none] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Queryable))] +#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))] +pub struct CheckCaptchaAnswer { + pub uuid: Uuid, + pub answer: String, +} + +#[skip_serializing_none] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))] +pub struct CaptchaAnswerForm { + pub answer: String, +} diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index 9aab4b90b..926e23e73 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -1,6 +1,7 @@ #[cfg(feature = "full")] pub mod activity; pub mod actor_language; +pub mod captcha_answer; pub mod comment; pub mod comment_reply; pub mod comment_report; diff --git a/migrations/2023-06-21-153242_add_captcha/down.sql b/migrations/2023-06-21-153242_add_captcha/down.sql new file mode 100644 index 000000000..4e5b83042 --- /dev/null +++ b/migrations/2023-06-21-153242_add_captcha/down.sql @@ -0,0 +1 @@ +drop table captcha_answer; \ No newline at end of file diff --git a/migrations/2023-06-21-153242_add_captcha/up.sql b/migrations/2023-06-21-153242_add_captcha/up.sql new file mode 100644 index 000000000..5c566bc92 --- /dev/null +++ b/migrations/2023-06-21-153242_add_captcha/up.sql @@ -0,0 +1,6 @@ +create table captcha_answer ( + id serial primary key, + uuid uuid not null unique default gen_random_uuid(), + answer text not null, + published timestamp not null default now() +); diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index a2abfa690..375630a92 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -38,6 +38,7 @@ use lemmy_api_common::{ ChangePassword, DeleteAccount, GetBannedPersons, + GetCaptcha, GetPersonDetails, GetPersonMentions, GetReplies, @@ -272,6 +273,12 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .wrap(rate_limit.register()) .route(web::post().to(route_post_crud::)), ) + .service( + // Handle captcha separately + web::resource("/user/get_captcha") + .wrap(rate_limit.post()) + .route(web::get().to(route_get::)), + ) // User actions .service( web::scope("/user") diff --git a/src/scheduled_tasks.rs b/src/scheduled_tasks.rs index c67dac0a4..4d3c936e8 100644 --- a/src/scheduled_tasks.rs +++ b/src/scheduled_tasks.rs @@ -13,7 +13,7 @@ use diesel::{ use diesel::{sql_query, PgConnection, RunQueryDsl}; use lemmy_api_common::context::LemmyContext; use lemmy_db_schema::{ - schema::{activity, comment, community_person_ban, instance, person, post}, + schema::{activity, captcha_answer, comment, community_person_ban, instance, person, post}, source::instance::{Instance, InstanceForm}, utils::{naive_now, DELETED_REPLACEMENT_TEXT}, }; @@ -49,6 +49,13 @@ pub fn setup( update_hot_ranks(&mut conn, true); }); + // Delete any captcha answers older than ten minutes, every ten minutes + let url = db_url.clone(); + scheduler.every(CTimeUnits::minutes(10)).run(move || { + let mut conn = PgConnection::establish(&url).expect("could not establish connection"); + delete_expired_captcha_answers(&mut conn); + }); + // Clear old activities every week let url = db_url.clone(); scheduler.every(CTimeUnits::weeks(1)).run(move || { @@ -181,6 +188,21 @@ fn process_hot_ranks_in_batches( ); } +fn delete_expired_captcha_answers(conn: &mut PgConnection) { + match diesel::delete( + captcha_answer::table.filter(captcha_answer::published.lt(now - IntervalDsl::minutes(10))), + ) + .execute(conn) + { + Ok(_) => { + info!("Done."); + } + Err(e) => { + error!("Failed to clear old captcha answers: {}", e) + } + } +} + /// Clear old activities (this table gets very large) fn clear_old_activities(conn: &mut PgConnection) { info!("Clearing old activities...");