"itoa",
"pq-sys",
"serde_json",
+ "uuid",
]
[[package]]
"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",
"serde",
"tracing",
"url",
+ "uuid",
"webmention",
]
"ts-rs",
"typed-builder",
"url",
+ "uuid",
]
[[package]]
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 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(),
+ }),
+ })
+ }
+}
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 }
+uuid = { workspace = true }
\ No newline at end of file
use lemmy_db_schema::{
aggregates::structs::PersonAggregates,
source::{
+ captcha_answer::{CaptchaAnswer, CheckCaptchaAnswer},
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 {
+ 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)?;
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 }
futures-util = { workspace = true }
tokio-postgres = { workspace = true }
tokio-postgres-rustls = { workspace = true }
+uuid = { features = ["v4"] }
[dev-dependencies]
serial_test = { workspace = true }
#[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,
}
}
--- /dev/null
+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());
+ }
+}
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 (id) {
+ id -> Int4,
+ uuid -> Uuid,
+ answer -> Text,
+ published -> Timestamp,
+ }
+}
+
diesel::table! {
use diesel::sql_types::*;
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;
+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,
+}
#[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 (
+ id serial primary key,
+ uuid uuid not null unique default gen_random_uuid(),
+ answer text not null,
+ published timestamp not null default now()
+);
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")
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},
};
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 || {
);
}
+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...");