From 8a086c82405bc8e2c8cb2fbbcceb10418f231d1b Mon Sep 17 00:00:00 2001
From: TKilFree <tristanfreeman1@gmail.com>
Date: Fri, 23 Jun 2023 10:47:12 +0100
Subject: [PATCH] feat: re-added captcha checks (#3249)

---
 Cargo.lock                                    |   2 +
 crates/api/Cargo.toml                         |   1 +
 crates/api/src/lib.rs                         |  16 ++
 crates/api/src/local_user/get_captcha.rs      |  53 ++++++
 crates/api/src/local_user/mod.rs              |   1 +
 crates/api_crud/Cargo.toml                    |   1 +
 crates/api_crud/src/user/create.rs            |  18 ++
 crates/db_schema/src/diesel_ltree.patch       |  26 +--
 crates/db_schema/src/impls/captcha_answer.rs  | 164 ++++++++++++++++++
 crates/db_schema/src/impls/mod.rs             |   1 +
 crates/db_schema/src/schema.rs                |   9 +
 crates/db_schema/src/source/captcha_answer.rs |  14 ++
 crates/db_schema/src/source/mod.rs            |   1 +
 .../2023-06-21-153242_add_captcha/down.sql    |   1 +
 .../2023-06-21-153242_add_captcha/up.sql      |   5 +
 src/api_routes_http.rs                        |   7 +
 16 files changed, 300 insertions(+), 20 deletions(-)
 create mode 100644 crates/api/src/local_user/get_captcha.rs
 create mode 100644 crates/db_schema/src/impls/captcha_answer.rs
 create mode 100644 crates/db_schema/src/source/captcha_answer.rs
 create mode 100644 migrations/2023-06-21-153242_add_captcha/down.sql
 create mode 100644 migrations/2023-06-21-153242_add_captcha/up.sql

diff --git a/Cargo.lock b/Cargo.lock
index cee02f79..08437ecf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2526,6 +2526,7 @@ dependencies = [
  "base64 0.13.1",
  "bcrypt",
  "captcha",
+ "chrono",
  "lemmy_api_common",
  "lemmy_db_schema",
  "lemmy_db_views",
@@ -2576,6 +2577,7 @@ dependencies = [
  "actix-web",
  "async-trait",
  "bcrypt",
+ "chrono",
  "lemmy_api_common",
  "lemmy_db_schema",
  "lemmy_db_views",
diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml
index 2488f2c2..ca792809 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 9ff1677d..615a8a31 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<LemmyContext>) -> Result<Self::Response, LemmyError>;
 }
 
+/// 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<u8> = 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 00000000..6dbc3482
--- /dev/null
+++ b/crates/api/src/local_user/get_captcha.rs
@@ -0,0 +1,53 @@
+use crate::{captcha_as_wav_base64, Perform};
+use actix_web::web::Data;
+use captcha::{gen, Difficulty};
+use chrono::Duration;
+use lemmy_api_common::{
+  context::LemmyContext,
+  person::{CaptchaResponse, GetCaptcha, GetCaptchaResponse},
+};
+use lemmy_db_schema::{
+  source::{captcha_answer::CaptchaAnswer, local_site::LocalSite},
+  utils::naive_now,
+};
+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<LemmyContext>) -> Result<Self::Response, LemmyError> {
+    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 uuid = uuid::Uuid::new_v4().to_string();
+
+    let wav = captcha_as_wav_base64(&captcha);
+
+    let captcha: CaptchaAnswer = CaptchaAnswer {
+      answer,
+      uuid: uuid.clone(),
+      expires: naive_now() + Duration::minutes(10), // expires in 10 minutes
+    };
+    // Stores the captcha item in the db
+    CaptchaAnswer::insert(context.pool(), &captcha).await?;
+
+    Ok(GetCaptchaResponse {
+      ok: Some(CaptchaResponse { png, wav, uuid }),
+    })
+  }
+}
diff --git a/crates/api/src/local_user/mod.rs b/crates/api/src/local_user/mod.rs
index 9244f825..3a92beda 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 1fb1e5a6..21320a3c 100644
--- a/crates/api_crud/Cargo.toml
+++ b/crates/api_crud/Cargo.toml
@@ -22,3 +22,4 @@ tracing = { workspace = true }
 url = { workspace = true }
 async-trait = { workspace = true }
 webmention = "0.4.0"
+chrono = { worspace = true }
diff --git a/crates/api_crud/src/user/create.rs b/crates/api_crud/src/user/create.rs
index f5a26f75..871a05d6 100644
--- a/crates/api_crud/src/user/create.rs
+++ b/crates/api_crud/src/user/create.rs
@@ -1,6 +1,7 @@
 use crate::PerformCrud;
 use activitypub_federation::http_signatures::generate_actor_keypair;
 use actix_web::web::Data;
+use chrono::NaiveDateTime;
 use lemmy_api_common::{
   context::LemmyContext,
   person::{LoginResponse, Register},
@@ -19,6 +20,7 @@ use lemmy_api_common::{
 use lemmy_db_schema::{
   aggregates::structs::PersonAggregates,
   source::{
+    captcha_answer::CaptchaAnswer,
     local_user::{LocalUser, LocalUserInsertForm},
     person::{Person, PersonInsertForm},
     registration_application::{RegistrationApplication, RegistrationApplicationInsertForm},
@@ -71,6 +73,22 @@ impl PerformCrud for Register {
       return Err(LemmyError::from_message("passwords_dont_match"));
     }
 
+    if local_site.site_setup && local_site.captcha_enabled {
+      let check = CaptchaAnswer::check_captcha(
+        context.pool(),
+        CaptchaAnswer {
+          uuid: data.captcha_uuid.clone().unwrap_or_default(),
+          answer: data.captcha_answer.clone().unwrap_or_default(),
+          // not used when checking
+          expires: NaiveDateTime::MIN,
+        },
+      )
+      .await?;
+      if !check {
+        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/src/diesel_ltree.patch b/crates/db_schema/src/diesel_ltree.patch
index d7d49f03..2607eb68 100644
--- a/crates/db_schema/src/diesel_ltree.patch
+++ b/crates/db_schema/src/diesel_ltree.patch
@@ -1,28 +1,17 @@
-diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs
-index 255c6422..f2ccf5e2 100644
---- a/crates/db_schema/src/schema.rs
-+++ b/crates/db_schema/src/schema.rs
-@@ -2,16 +2,12 @@
- 
- pub mod sql_types {
-     #[derive(diesel::sql_types::SqlType)]
-     #[diesel(postgres_type(name = "listing_type_enum"))]
+--- schema.rs	2023-06-21 22:25:50.252384233 +0100
++++ "schema copy.rs"	2023-06-21 22:26:50.452378651 +0100
+@@ -6,10 +6,6 @@
      pub struct ListingTypeEnum;
  
--    #[derive(diesel::sql_types::SqlType)]
+     #[derive(diesel::sql_types::SqlType)]
 -    #[diesel(postgres_type(name = "ltree"))]
 -    pub struct Ltree;
 -
-     #[derive(diesel::sql_types::SqlType)]
+-    #[derive(diesel::sql_types::SqlType)]
      #[diesel(postgres_type(name = "registration_mode_enum"))]
      pub struct RegistrationModeEnum;
  
-     #[derive(diesel::sql_types::SqlType)]
-     #[diesel(postgres_type(name = "sort_type_enum"))]
-@@ -67,13 +63,13 @@ diesel::table! {
-         when_ -> Timestamp,
-     }
- }
+@@ -78,7 +74,7 @@
  
  diesel::table! {
      use diesel::sql_types::*;
@@ -31,6 +20,3 @@ index 255c6422..f2ccf5e2 100644
  
      comment (id) {
          id -> Int4,
-         creator_id -> Int4,
-         post_id -> Int4,
-         content -> Text,
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 00000000..afd18181
--- /dev/null
+++ b/crates/db_schema/src/impls/captcha_answer.rs
@@ -0,0 +1,164 @@
+use crate::{
+  schema::captcha_answer,
+  source::captcha_answer::CaptchaAnswer,
+  utils::{functions::lower, get_conn, naive_now, 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: &CaptchaAnswer) -> Result<Self, Error> {
+    let conn = &mut get_conn(pool).await?;
+
+    insert_into(captcha_answer::table)
+      .values(captcha)
+      .get_result::<Self>(conn)
+      .await
+  }
+
+  pub async fn check_captcha(pool: &DbPool, to_check: CaptchaAnswer) -> Result<bool, Error> {
+    let conn = &mut get_conn(pool).await?;
+
+    // delete any expired captchas
+    delete(captcha_answer::table.filter(captcha_answer::expires.lt(&naive_now())))
+      .execute(conn)
+      .await?;
+
+    // fetch requested captcha
+    let captcha_exists = select(exists(
+      captcha_answer::dsl::captcha_answer
+        .filter((captcha_answer::dsl::uuid).eq(to_check.uuid.clone()))
+        .filter(lower(captcha_answer::dsl::answer).eq(to_check.answer.to_lowercase().clone())),
+    ))
+    .get_result::<bool>(conn)
+    .await?;
+
+    // delete checked captcha
+    delete(captcha_answer::table.filter(captcha_answer::uuid.eq(to_check.uuid.clone())))
+      .execute(conn)
+      .await?;
+
+    Ok(captcha_exists)
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use crate::{
+    source::captcha_answer::CaptchaAnswer,
+    utils::{build_db_pool_for_tests, naive_now},
+  };
+  use chrono::Duration;
+  use serial_test::serial;
+
+  #[tokio::test]
+  #[serial]
+  async fn test_captcha_happy_path() {
+    let pool = &build_db_pool_for_tests().await;
+
+    let captcha_a_id = "a".to_string();
+
+    let _ = CaptchaAnswer::insert(
+      pool,
+      &CaptchaAnswer {
+        uuid: captcha_a_id.clone(),
+        answer: "XYZ".to_string(),
+        expires: naive_now() + Duration::minutes(10),
+      },
+    )
+    .await;
+
+    let result = CaptchaAnswer::check_captcha(
+      pool,
+      CaptchaAnswer {
+        uuid: captcha_a_id.clone(),
+        answer: "xyz".to_string(),
+        expires: chrono::NaiveDateTime::MIN,
+      },
+    )
+    .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 captcha_a_id = "a".to_string();
+
+    let _ = CaptchaAnswer::insert(
+      pool,
+      &CaptchaAnswer {
+        uuid: captcha_a_id.clone(),
+        answer: "XYZ".to_string(),
+        expires: naive_now() + Duration::minutes(10),
+      },
+    )
+    .await;
+
+    let result = CaptchaAnswer::check_captcha(
+      pool,
+      CaptchaAnswer {
+        uuid: captcha_a_id.clone(),
+        answer: "xyz".to_string(),
+        expires: chrono::NaiveDateTime::MIN,
+      },
+    )
+    .await;
+
+    let result_repeat = CaptchaAnswer::check_captcha(
+      pool,
+      CaptchaAnswer {
+        uuid: captcha_a_id.clone(),
+        answer: "xyz".to_string(),
+        expires: chrono::NaiveDateTime::MIN,
+      },
+    )
+    .await;
+
+    assert!(result_repeat.is_ok());
+    assert!(!result_repeat.unwrap());
+  }
+
+  #[tokio::test]
+  #[serial]
+  async fn test_captcha_expired_fails() {
+    let pool = &build_db_pool_for_tests().await;
+
+    let expired_id = "already_expired".to_string();
+
+    let _ = CaptchaAnswer::insert(
+      pool,
+      &CaptchaAnswer {
+        uuid: expired_id.clone(),
+        answer: "xyz".to_string(),
+        expires: naive_now() - Duration::seconds(1),
+      },
+    )
+    .await;
+
+    let expired_result = CaptchaAnswer::check_captcha(
+      pool,
+      CaptchaAnswer {
+        uuid: expired_id.clone(),
+        answer: "xyz".to_string(),
+        expires: chrono::NaiveDateTime::MIN,
+      },
+    )
+    .await;
+
+    assert!(expired_result.is_ok());
+    assert!(!expired_result.unwrap());
+  }
+}
diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs
index 915d1c8e..f13004d0 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 ac4ddc47..f244ae66 100644
--- a/crates/db_schema/src/schema.rs
+++ b/crates/db_schema/src/schema.rs
@@ -64,6 +64,14 @@ diesel::table! {
     }
 }
 
+diesel::table! {
+    captcha_answer (uuid) {
+        uuid -> Text,
+        answer -> Text,
+        expires -> Timestamp,
+    }
+}
+
 diesel::table! {
     use diesel::sql_types::{Bool, Int4, Nullable, Text, Timestamp, Varchar};
     use diesel_ltree::sql_types::Ltree;
@@ -916,6 +924,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 00000000..113b7c96
--- /dev/null
+++ b/crates/db_schema/src/source/captcha_answer.rs
@@ -0,0 +1,14 @@
+#[cfg(feature = "full")]
+use crate::schema::captcha_answer;
+use serde::{Deserialize, Serialize};
+use serde_with::skip_serializing_none;
+
+#[skip_serializing_none]
+#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
+#[cfg_attr(feature = "full", derive(Queryable, Insertable, AsChangeset))]
+#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))]
+pub struct CaptchaAnswer {
+  pub uuid: String,
+  pub answer: String,
+  pub expires: chrono::NaiveDateTime,
+}
diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs
index 9aab4b90..926e23e7 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 00000000..4e5b8304
--- /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 00000000..71467be6
--- /dev/null
+++ b/migrations/2023-06-21-153242_add_captcha/up.sql
@@ -0,0 +1,5 @@
+create table captcha_answer (
+    uuid text not null primary key,
+    answer text not null,
+    expires timestamp not null
+);
diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs
index a2abfa69..375630a9 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::<Register>)),
       )
+      .service(
+        // Handle captcha separately
+        web::resource("/user/get_captcha")
+          .wrap(rate_limit.post())
+          .route(web::get().to(route_get::<GetCaptcha>)),
+      )
       // User actions
       .service(
         web::scope("/user")
-- 
2.44.1