"base64 0.13.1",
"bcrypt",
"captcha",
+ "chrono",
"lemmy_api_common",
"lemmy_db_schema",
"lemmy_db_views",
"actix-web",
"async-trait",
"bcrypt",
+ "chrono",
"lemmy_api_common",
"lemmy_db_schema",
"lemmy_db_views",
captcha = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
+chrono = { workspace = true }
[dev-dependencies]
serial_test = { workspace = true }
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};
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);
--- /dev/null
+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 }),
+ })
+ }
+}
mod block;
mod change_password;
mod change_password_after_reset;
+mod get_captcha;
mod list_banned;
mod login;
mod notifications;
url = { workspace = true }
async-trait = { workspace = true }
webmention = "0.4.0"
+chrono = { worspace = true }
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},
use lemmy_db_schema::{
aggregates::structs::PersonAggregates,
source::{
+ captcha_answer::CaptchaAnswer,
local_user::{LocalUser, LocalUserInsertForm},
person::{Person, PersonInsertForm},
registration_application::{RegistrationApplication, RegistrationApplicationInsertForm},
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/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::*;
comment (id) {
id -> Int4,
- creator_id -> Int4,
- post_id -> Int4,
- content -> Text,
--- /dev/null
+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());
+ }
+}
pub mod activity;
pub mod actor_language;
+pub mod captcha_answer;
pub mod comment;
pub mod comment_reply;
pub mod comment_report;
}
}
+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;
admin_purge_community,
admin_purge_person,
admin_purge_post,
+ captcha_answer,
comment,
comment_aggregates,
comment_like,
--- /dev/null
+#[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,
+}
#[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;
--- /dev/null
+drop table captcha_answer;
\ No newline at end of file
--- /dev/null
+create table captcha_answer (
+ uuid text not null primary key,
+ answer text not null,
+ expires timestamp not null
+);
ChangePassword,
DeleteAccount,
GetBannedPersons,
+ GetCaptcha,
GetPersonDetails,
GetPersonMentions,
GetReplies,
.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")