From 2aef6a5a338c9a6b5764fbc1ae42fa3edf096deb Mon Sep 17 00:00:00 2001
From: TKilFree <tristanfreeman1@gmail.com>
Date: Tue, 27 Jun 2023 11:38:53 +0100
Subject: [PATCH] feat: re-added captcha checks (#3289)

---
 Cargo.lock                                    |   5 +
 crates/api/Cargo.toml                         |   1 +
 crates/api/src/lib.rs                         |  16 +++
 crates/api/src/local_user/get_captcha.rs      |  50 ++++++++
 crates/api/src/local_user/mod.rs              |   1 +
 crates/api_crud/Cargo.toml                    |   2 +
 crates/api_crud/src/user/create.rs            |  20 +++
 crates/db_schema/Cargo.toml                   |   3 +-
 crates/db_schema/src/diesel_ltree.patch       |   4 +-
 crates/db_schema/src/impls/captcha_answer.rs  | 118 ++++++++++++++++++
 crates/db_schema/src/impls/mod.rs             |   1 +
 crates/db_schema/src/schema.rs                |  10 ++
 crates/db_schema/src/source/captcha_answer.rs |  33 +++++
 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      |   6 +
 src/api_routes_http.rs                        |   7 ++
 src/scheduled_tasks.rs                        |  24 +++-
 18 files changed, 299 insertions(+), 4 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 b01dd41c..39ef0d80 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 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..13304424
--- /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<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 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 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..abe747b1 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 f5a26f75..302e2f98 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 e99f3cd1..69affde8 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 d7d49f03..ecbeb219 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 00000000..de5fac65
--- /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<Self, Error> {
+    let conn = &mut get_conn(pool).await?;
+
+    insert_into(captcha_answer)
+      .values(captcha)
+      .get_result::<Self>(conn)
+      .await
+  }
+
+  pub async fn check_captcha(pool: &DbPool, to_check: CheckCaptchaAnswer) -> Result<bool, Error> {
+    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::<bool>(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 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 abd3ca22..42946d69 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 00000000..e3e64c4e
--- /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 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..5c566bc9
--- /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 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")
diff --git a/src/scheduled_tasks.rs b/src/scheduled_tasks.rs
index c67dac0a..4d3c936e 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...");
-- 
2.44.1