diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile index b86618d85..afbdbbbe1 100644 --- a/docker/dev/Dockerfile +++ b/docker/dev/Dockerfile @@ -41,6 +41,9 @@ FROM alpine:3.12 # Install libpq for postgres RUN apk add libpq +# Install Espeak for captchas +RUN apk add espeak + # Copy resources COPY server/config/defaults.hjson /config/defaults.hjson COPY --from=rust /app/server/target/x86_64-unknown-linux-musl/debug/lemmy_server /app/lemmy diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile index 774387404..845df88de 100644 --- a/docker/prod/Dockerfile +++ b/docker/prod/Dockerfile @@ -50,6 +50,10 @@ FROM alpine:3.12 as lemmy # Install libpq for postgres RUN apk add libpq + +# Install Espeak for captchas +RUN apk add espeak + RUN addgroup -g 1000 lemmy RUN adduser -D -s /bin/sh -u 1000 -G lemmy lemmy diff --git a/docs/src/contributing_websocket_http_api.md b/docs/src/contributing_websocket_http_api.md index 1a7588041..7953bc9a2 100644 --- a/docs/src/contributing_websocket_http_api.md +++ b/docs/src/contributing_websocket_http_api.md @@ -390,7 +390,9 @@ Only the first user will be able to be the admin. email: Option, password: String, password_verify: String, - admin: bool + admin: bool, + captcha_uuid: Option, // Only checked if these are enabled in the server + captcha_answer: Option, } } ``` @@ -408,6 +410,34 @@ Only the first user will be able to be the admin. `POST /user/register` +#### Get Captcha + +These expire after 10 minutes. + +##### Request +```rust +{ + op: "GetCaptcha", +} +``` +##### Response +```rust +{ + op: "GetCaptcha", + data: { + ok?: { // Will be undefined if captchas are disabled + png: String, // A Base64 encoded png + wav: Option, // A Base64 encoded wav audio file + uuid: String, + } + } +} +``` + +##### HTTP + +`GET /user/get_captcha` + #### Get User Details ##### Request ```rust diff --git a/server/Cargo.lock b/server/Cargo.lock index 2054c7a31..3f19827be 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -31,7 +31,7 @@ checksum = "a9028932f36d45df020c92317ccb879ab77d8f066f57ff143dd5bee93ba3de0d" dependencies = [ "actix-rt", "actix_derive", - "bitflags", + "bitflags 1.2.1", "bytes", "crossbeam-channel", "derive_more", @@ -54,7 +54,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e55f0a5c2ca15795035d90c46bd0e73a5123b72f68f12596d6ba5282051380" dependencies = [ - "bitflags", + "bitflags 1.2.1", "bytes", "futures-core", "futures-sink", @@ -94,7 +94,7 @@ dependencies = [ "actix-http", "actix-service", "actix-web", - "bitflags", + "bitflags 1.2.1", "bytes", "derive_more", "futures-core", @@ -120,7 +120,7 @@ dependencies = [ "actix-tls", "actix-utils", "base64 0.12.3", - "bitflags", + "bitflags 1.2.1", "brotli2", "bytes", "cookie", @@ -280,7 +280,7 @@ dependencies = [ "actix-codec", "actix-rt", "actix-service", - "bitflags", + "bitflags 1.2.1", "bytes", "either", "futures", @@ -382,6 +382,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e" +[[package]] +name = "adler32" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b077b825e468cc974f0020d4082ee6e03132512f207ef1a02fd5d00d1f32d" + [[package]] name = "aho-corasick" version = "0.7.13" @@ -496,6 +502,15 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1" +[[package]] +name = "base64" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30e93c03064e7590d0466209155251b90c22e37fab1daf2771582598b5827557" +dependencies = [ + "byteorder", +] + [[package]] name = "base64" version = "0.9.3" @@ -539,6 +554,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "bitflags" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d" + [[package]] name = "bitflags" version = "1.2.1" @@ -642,6 +663,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" +[[package]] +name = "bytemuck" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7a1029718df60331e557c9e83a55523c955e5dd2a7bfeffad6bbd50b538ae9" + [[package]] name = "byteorder" version = "1.3.4" @@ -663,6 +690,26 @@ dependencies = [ "bytes", ] +[[package]] +name = "c_vec" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a318911dce53b5f1ca6539c44f5342c632269f0fa7ea3e35f32458c27a7c30" + +[[package]] +name = "captcha" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d060a3be43adb2fe89d3448e9a193149806139b1ce99281865fcab7aeaf04ed" +dependencies = [ + "base64 0.5.2", + "image", + "lodepng", + "rand 0.3.23", + "serde_json", + "time 0.1.43", +] + [[package]] name = "cc" version = "1.0.58" @@ -695,7 +742,7 @@ checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129" dependencies = [ "ansi_term", "atty", - "bitflags", + "bitflags 1.2.1", "strsim 0.8.0", "textwrap", "unicode-width", @@ -708,7 +755,7 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" dependencies = [ - "bitflags", + "bitflags 1.2.1", ] [[package]] @@ -717,9 +764,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4344512281c643ae7638bbabc3af17a11307803ec8f0fcad9fae512a8bf36467" dependencies = [ - "bitflags", + "bitflags 1.2.1", ] +[[package]] +name = "color_quant" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dbbb57365263e881e805dc77d94697c9118fd94d8da011240555aa7b23445bd" + [[package]] name = "comrak" version = "0.7.0" @@ -806,6 +859,43 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f02af974daeee82218205558e51ec8768b48cf524bd01d550abe5573a608285" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "maybe-uninit", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace" +dependencies = [ + "autocfg 1.0.0", + "cfg-if", + "crossbeam-utils", + "lazy_static", + "maybe-uninit", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-queue" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "maybe-uninit", +] + [[package]] name = "crossbeam-utils" version = "0.7.2" @@ -852,6 +942,16 @@ dependencies = [ "syn", ] +[[package]] +name = "deflate" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707b6a7b384888a70c8d2e8650b3e60170dfc6a67bb4aa67b6dfca57af4bedb4" +dependencies = [ + "adler32", + "byteorder", +] + [[package]] name = "derive_builder" version = "0.9.0" @@ -894,7 +994,7 @@ version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2de9deab977a153492a1468d1b1c0662c1cf39e5ea87d0c060ecd59ef18d8c" dependencies = [ - "bitflags", + "bitflags 1.2.1", "byteorder", "chrono", "diesel_derives", @@ -1072,6 +1172,15 @@ dependencies = [ "syn", ] +[[package]] +name = "enum_primitive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4551092f4d519593039259a9ed8daedf0da12e5109c5280338073eaeb81180" +dependencies = [ + "num-traits 0.1.43", +] + [[package]] name = "env_logger" version = "0.7.1" @@ -1167,7 +1276,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" dependencies = [ - "bitflags", + "bitflags 1.2.1", "fuchsia-zircon-sys", ] @@ -1311,6 +1420,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e41945ba23db3bf51b24756d73d81acb4f28d85c3dccc32c6fae904438c25f" +dependencies = [ + "color_quant", + "lzw", +] + [[package]] name = "gimli" version = "0.22.0" @@ -1455,6 +1574,23 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c3f4f5ea213ed9899eca760a8a14091d4b82d33e27cf8ced336ff730e9f6da8" +dependencies = [ + "byteorder", + "enum_primitive", + "gif", + "jpeg-decoder", + "num-iter", + "num-rational", + "num-traits 0.1.43", + "png", + "scoped_threadpool", +] + [[package]] name = "indexmap" version = "1.5.0" @@ -1465,6 +1601,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inflate" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1238524675af3938a7c74980899535854b88ba07907bb1c944abe5b8fc437e5" + [[package]] name = "instant" version = "0.1.6" @@ -1507,6 +1649,16 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" +[[package]] +name = "jpeg-decoder" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc797adac5f083b8ff0ca6f6294a999393d76e197c36488e2ef732c4715f6fa3" +dependencies = [ + "byteorder", + "rayon", +] + [[package]] name = "js-sys" version = "0.3.42" @@ -1585,6 +1737,7 @@ dependencies = [ "awc", "base64 0.12.3", "bcrypt", + "captcha", "chrono", "diesel", "diesel_migrations", @@ -1673,7 +1826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db65c6da02e61f55dae90a0ae427b2a5f6b3e8db09f58d10efab23af92592616" dependencies = [ "arrayvec", - "bitflags", + "bitflags 1.2.1", "cfg-if", "ryu", "static_assertions", @@ -1719,6 +1872,18 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lodepng" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac1dfdf85b7d5dea61a620e12c051a72078189366a0b3c0ab331e30847def2f" +dependencies = [ + "c_vec", + "cc", + "libc", + "rgb", +] + [[package]] name = "log" version = "0.4.11" @@ -1737,6 +1902,12 @@ dependencies = [ "linked-hash-map 0.5.3", ] +[[package]] +name = "lzw" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084" + [[package]] name = "maplit" version = "1.0.2" @@ -1755,12 +1926,27 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + [[package]] name = "memchr" version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" +[[package]] +name = "memoffset" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c198b026e1bbf08a937e94c6c60f9ec4a2267f5b0d2eec9c1b21b061ce2be55f" +dependencies = [ + "autocfg 1.0.0", +] + [[package]] name = "migrations_internals" version = "1.4.1" @@ -1920,6 +2106,27 @@ dependencies = [ "num-traits 0.2.12", ] +[[package]] +name = "num-iter" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6e6b7c748f995c4c29c5f5ae0248536e04a5739927c74ec0fa564805094b9f" +dependencies = [ + "autocfg 1.0.0", + "num-integer", + "num-traits 0.2.12", +] + +[[package]] +name = "num-rational" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee314c74bd753fc86b4780aa9475da469155f3848473a261d2d18e35245a784e" +dependencies = [ + "num-integer", + "num-traits 0.2.12", +] + [[package]] name = "num-traits" version = "0.1.43" @@ -1978,7 +2185,7 @@ version = "0.10.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d575eff3665419f9b83678ff2815858ad9d11567e082f5ac1814baba4e2bcb4" dependencies = [ - "bitflags", + "bitflags 1.2.1", "cfg-if", "foreign-types", "lazy_static", @@ -2153,6 +2360,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d36492546b6af1463394d46f0c834346f31548646f6ba10849802c9c9a27ac33" +[[package]] +name = "png" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f397b84083c2753ba53c7b56ad023edb94512b2885ffe227c66ff7edb61868" +dependencies = [ + "bitflags 0.7.0", + "deflate", + "inflate", + "num-iter", +] + [[package]] name = "ppv-lite86" version = "0.2.8" @@ -2225,6 +2444,16 @@ dependencies = [ "scheduled-thread-pool", ] +[[package]] +name = "rand" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c" +dependencies = [ + "libc", + "rand 0.4.6", +] + [[package]] name = "rand" version = "0.4.6" @@ -2385,6 +2614,31 @@ dependencies = [ "rand_core 0.3.1", ] +[[package]] +name = "rayon" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f02856753d04e03e26929f820d0a0a337ebe71f849801eea335d464b349080" +dependencies = [ + "autocfg 1.0.0", + "crossbeam-deque", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e92e15d89083484e11353891f1af602cc661426deb9564c298b270c726973280" +dependencies = [ + "crossbeam-deque", + "crossbeam-queue", + "crossbeam-utils", + "lazy_static", + "num_cpus", +] + [[package]] name = "rdrand" version = "0.4.0" @@ -2437,6 +2691,15 @@ dependencies = [ "quick-error", ] +[[package]] +name = "rgb" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ef54b45ae131327a88597e2463fee4098ad6c88ba7b6af4b3987db8aad4098" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.16.15" @@ -2521,6 +2784,12 @@ dependencies = [ "parking_lot 0.11.0", ] +[[package]] +name = "scoped_threadpool" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" + [[package]] name = "scopeguard" version = "1.1.0" @@ -2543,7 +2812,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64808902d7d99f78eaddd2b4e2509713babc3dc3c85ad6f4c447680f3c01e535" dependencies = [ - "bitflags", + "bitflags 1.2.1", "core-foundation", "core-foundation-sys", "libc", diff --git a/server/Cargo.toml b/server/Cargo.toml index 356cbced5..3a652f298 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -51,3 +51,4 @@ itertools = "0.9.0" uuid = { version = "0.8", features = ["serde", "v4"] } sha2 = "0.9" async-trait = "0.1.36" +captcha = "0.0.7" diff --git a/server/config/defaults.hjson b/server/config/defaults.hjson index 348368f19..5238455a7 100644 --- a/server/config/defaults.hjson +++ b/server/config/defaults.hjson @@ -59,6 +59,10 @@ # comma seperated list of instances with which federation is allowed allowed_instances: "" } + captcha: { + enabled: true + difficulty: medium # Can be easy, medium, or hard + } # # email sending configuration # email: { # # hostname and port of the smtp server diff --git a/server/lemmy_utils/src/settings.rs b/server/lemmy_utils/src/settings.rs index 102914779..b7cc2c45f 100644 --- a/server/lemmy_utils/src/settings.rs +++ b/server/lemmy_utils/src/settings.rs @@ -17,6 +17,7 @@ pub struct Settings { pub rate_limit: RateLimitConfig, pub email: Option, pub federation: Federation, + pub captcha: CaptchaConfig, } #[derive(Debug, Deserialize, Clone)] @@ -46,6 +47,12 @@ pub struct EmailConfig { pub use_tls: bool, } +#[derive(Debug, Deserialize, Clone)] +pub struct CaptchaConfig { + pub enabled: bool, + pub difficulty: String, // easy, medium, or hard +} + #[derive(Debug, Deserialize, Clone)] pub struct Database { pub user: String, diff --git a/server/src/api/site.rs b/server/src/api/site.rs index 3b8b9693a..adade080e 100644 --- a/server/src/api/site.rs +++ b/server/src/api/site.rs @@ -370,6 +370,8 @@ impl Perform for Oper { password_verify: setup.admin_password.to_owned(), admin: true, show_nsfw: true, + captcha_uuid: None, + captcha_answer: None, }; let login_response = Oper::new(register, self.client.clone()) .perform(pool, websocket_info.clone()) diff --git a/server/src/api/user.rs b/server/src/api/user.rs index f6548f8ca..c2b6955b5 100644 --- a/server/src/api/user.rs +++ b/server/src/api/user.rs @@ -2,8 +2,9 @@ use crate::{ api::{claims::Claims, is_admin, APIError, Oper, Perform}, apub::ApubObjectType, blocking, + captcha_espeak_wav_base64, websocket::{ - server::{JoinUserRoom, SendAllMessage, SendUserRoomMessage}, + server::{CaptchaItem, CheckCaptcha, JoinUserRoom, SendAllMessage, SendUserRoomMessage}, UserOperation, WebsocketInfo, }, @@ -11,6 +12,8 @@ use crate::{ LemmyError, }; use bcrypt::verify; +use captcha::{gen, Difficulty}; +use chrono::Duration; use lemmy_db::{ comment::*, comment_view::*, @@ -66,6 +69,23 @@ pub struct Register { pub password_verify: String, pub admin: bool, pub show_nsfw: bool, + pub captcha_uuid: Option, + pub captcha_answer: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct GetCaptcha {} + +#[derive(Serialize, Deserialize)] +pub struct GetCaptchaResponse { + ok: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct CaptchaResponse { + png: String, // A Base64 encoded png + wav: Option, // A Base64 encoded wav audio + uuid: String, } #[derive(Serialize, Deserialize)] @@ -303,7 +323,7 @@ impl Perform for Oper { async fn perform( &self, pool: &DbPool, - _websocket_info: Option, + websocket_info: Option, ) -> Result { let data: &Register = &self.data; @@ -320,6 +340,31 @@ impl Perform for Oper { return Err(APIError::err("passwords_dont_match").into()); } + // If its not the admin, check the captcha + if !data.admin && Settings::get().captcha.enabled { + match websocket_info { + Some(ws) => { + let check = ws + .chatserver + .send(CheckCaptcha { + uuid: data + .captcha_uuid + .to_owned() + .unwrap_or_else(|| "".to_string()), + answer: data + .captcha_answer + .to_owned() + .unwrap_or_else(|| "".to_string()), + }) + .await?; + if !check { + return Err(APIError::err("captcha_incorrect").into()); + } + } + None => return Err(APIError::err("captcha_incorrect").into()), + }; + } + if let Err(slurs) = slur_check(&data.username) { return Err(APIError::err(&slurs_vec_to_str(slurs)).into()); } @@ -439,6 +484,54 @@ impl Perform for Oper { } } +#[async_trait::async_trait(?Send)] +impl Perform for Oper { + type Response = GetCaptchaResponse; + + async fn perform( + &self, + _pool: &DbPool, + websocket_info: Option, + ) -> Result { + let captcha_settings = Settings::get().captcha; + + if !captcha_settings.enabled { + return Ok(GetCaptchaResponse { ok: None }); + } + + let captcha = match captcha_settings.difficulty.as_str() { + "easy" => gen(Difficulty::Easy), + "medium" => gen(Difficulty::Medium), + "hard" => gen(Difficulty::Hard), + _ => gen(Difficulty::Medium), + }; + + let answer = captcha.chars_as_string(); + + let png_byte_array = captcha.as_png().expect("failed to generate captcha"); + + let png = base64::encode(png_byte_array); + + let uuid = uuid::Uuid::new_v4().to_string(); + + let wav = captcha_espeak_wav_base64(&answer).ok(); + + let captcha_item = CaptchaItem { + answer, + uuid: uuid.to_owned(), + expires: naive_now() + Duration::minutes(10), // expires in 10 minutes + }; + + if let Some(ws) = websocket_info { + ws.chatserver.do_send(captcha_item); + } + + Ok(GetCaptchaResponse { + ok: Some(CaptchaResponse { png, uuid, wav }), + }) + } +} + #[async_trait::async_trait(?Send)] impl Perform for Oper { type Response = LoginResponse; diff --git a/server/src/lib.rs b/server/src/lib.rs index 5dff2ccbd..682efc774 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -7,7 +7,9 @@ pub extern crate lazy_static; pub extern crate failure; pub extern crate actix; pub extern crate actix_web; +pub extern crate base64; pub extern crate bcrypt; +pub extern crate captcha; pub extern crate chrono; pub extern crate diesel; pub extern crate dotenv; @@ -35,6 +37,7 @@ use lemmy_utils::{get_apub_protocol_string, settings::Settings}; use log::error; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use serde::Deserialize; +use std::process::Command; pub type DbPool = diesel::r2d2::Pool>; pub type ConnectionId = usize; @@ -224,9 +227,56 @@ where Ok(res) } +pub fn captcha_espeak_wav_base64(captcha: &str) -> Result { + let mut built_text = String::new(); + + // Building proper speech text for espeak + for mut c in captcha.chars() { + let new_str = if c.is_alphabetic() { + if c.is_lowercase() { + c.make_ascii_uppercase(); + format!("lower case {} ... ", c) + } else { + c.make_ascii_uppercase(); + format!("capital {} ... ", c) + } + } else { + format!("{} ...", c) + }; + + built_text.push_str(&new_str); + } + + espeak_wav_base64(&built_text) +} + +pub fn espeak_wav_base64(text: &str) -> Result { + // Make a temp file path + let uuid = uuid::Uuid::new_v4().to_string(); + let file_path = format!("/tmp/lemmy_espeak_{}.wav", &uuid); + + // Write the wav file + Command::new("espeak") + .arg("-w") + .arg(&file_path) + .arg(text) + .status()?; + + // Read the wav file bytes + let bytes = std::fs::read(&file_path)?; + + // Delete the file + std::fs::remove_file(file_path)?; + + // Convert to base64 + let base64 = base64::encode(bytes); + + Ok(base64) +} + #[cfg(test)] mod tests { - use crate::is_image_content_type; + use crate::{captcha_espeak_wav_base64, is_image_content_type}; #[test] fn test_image() { @@ -241,6 +291,11 @@ mod tests { }); } + #[test] + fn test_espeak() { + assert!(captcha_espeak_wav_base64("WxRt2l").is_ok()) + } + // These helped with testing // #[test] // fn test_iframely() { diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs index 31888156e..f524cf411 100644 --- a/server/src/routes/api.rs +++ b/server/src/routes/api.rs @@ -140,6 +140,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) { .route("/ban", web::post().to(route_post::)) // Account actions. I don't like that they're in /user maybe /accounts .route("/login", web::post().to(route_post::)) + .route("/get_captcha", web::get().to(route_post::)) .route( "/delete_account", web::post().to(route_post::), diff --git a/server/src/websocket/mod.rs b/server/src/websocket/mod.rs index 5f3157b1b..2b0cd1bde 100644 --- a/server/src/websocket/mod.rs +++ b/server/src/websocket/mod.rs @@ -20,6 +20,7 @@ use std::{ pub enum UserOperation { Login, Register, + GetCaptcha, CreateCommunity, CreatePost, ListCommunities, diff --git a/server/src/websocket/server.rs b/server/src/websocket/server.rs index cc6de1150..6c3eed4be 100644 --- a/server/src/websocket/server.rs +++ b/server/src/websocket/server.rs @@ -16,6 +16,7 @@ use crate::{ UserId, }; use actix_web::client::Client; +use lemmy_db::naive_now; /// Chat server sends this messages to session #[derive(Message)] @@ -134,6 +135,21 @@ pub struct SessionInfo { pub ip: IPAddr, } +#[derive(Message, Debug)] +#[rtype(result = "()")] +pub struct CaptchaItem { + pub uuid: String, + pub answer: String, + pub expires: chrono::NaiveDateTime, +} + +#[derive(Message)] +#[rtype(bool)] +pub struct CheckCaptcha { + pub uuid: String, + pub answer: String, +} + /// `ChatServer` manages chat rooms and responsible for coordinating chat /// session. pub struct ChatServer { @@ -158,6 +174,9 @@ pub struct ChatServer { /// Rate limiting based on rate type and IP addr rate_limiter: RateLimit, + /// A list of the current captchas + captchas: Vec, + /// An HTTP Client client: Client, } @@ -176,6 +195,7 @@ impl ChatServer { rng: rand::thread_rng(), pool, rate_limiter, + captchas: Vec::new(), client, } } @@ -441,6 +461,7 @@ impl ChatServer { // User ops UserOperation::Login => do_user_operation::(args).await, UserOperation::Register => do_user_operation::(args).await, + UserOperation::GetCaptcha => do_user_operation::(args).await, UserOperation::GetUserDetails => do_user_operation::(args).await, UserOperation::GetReplies => do_user_operation::(args).await, UserOperation::AddAdmin => do_user_operation::(args).await, @@ -635,7 +656,7 @@ impl Handler for ChatServer { Box::pin(async move { match fut.await { Ok(m) => { - info!("Message Sent: {}", m); + // info!("Message Sent: {}", m); Ok(m) } Err(e) => { @@ -774,3 +795,30 @@ where }; Ok(serde_json::to_string(&response)?) } + +impl Handler for ChatServer { + type Result = (); + + fn handle(&mut self, msg: CaptchaItem, _: &mut Context) { + self.captchas.push(msg); + } +} + +impl Handler for ChatServer { + type Result = bool; + + fn handle(&mut self, msg: CheckCaptcha, _: &mut Context) -> Self::Result { + // Remove all the ones that are past the expire time + self.captchas.retain(|x| x.expires.gt(&naive_now())); + + let check = self + .captchas + .iter() + .any(|r| r.uuid == msg.uuid && r.answer == msg.answer); + + // Remove this uuid so it can't be re-checked (Checks only work once) + self.captchas.retain(|x| x.uuid != msg.uuid); + + check + } +} diff --git a/ui/src/components/create-post.tsx b/ui/src/components/create-post.tsx index 9d6cbb89a..eb86d8f88 100644 --- a/ui/src/components/create-post.tsx +++ b/ui/src/components/create-post.tsx @@ -110,7 +110,7 @@ export class CreatePost extends Component { return lastLocation.split('/c/')[1]; } } - return undefined; + return; } handlePostCreate(id: number) { diff --git a/ui/src/components/login.tsx b/ui/src/components/login.tsx index 94de499ee..bdee3a97e 100644 --- a/ui/src/components/login.tsx +++ b/ui/src/components/login.tsx @@ -9,6 +9,7 @@ import { UserOperation, PasswordResetForm, GetSiteResponse, + GetCaptchaResponse, WebSocketJsonResponse, Site, } from '../interfaces'; @@ -21,11 +22,8 @@ interface State { registerForm: RegisterForm; loginLoading: boolean; registerLoading: boolean; - mathQuestion: { - a: number; - b: number; - answer: number; - }; + captcha: GetCaptchaResponse; + captchaPlaying: boolean; site: Site; } @@ -43,14 +41,13 @@ export class Login extends Component { password_verify: undefined, admin: false, show_nsfw: false, + captcha_uuid: undefined, + captcha_answer: undefined, }, loginLoading: false, registerLoading: false, - mathQuestion: { - a: Math.floor(Math.random() * 10) + 1, - b: Math.floor(Math.random() * 10) + 1, - answer: undefined, - }, + captcha: undefined, + captchaPlaying: false, site: { id: undefined, name: undefined, @@ -81,6 +78,7 @@ export class Login extends Component { ); WebSocketService.Instance.getSite(); + WebSocketService.Instance.getCaptcha(); } componentWillUnmount() { @@ -172,6 +170,7 @@ export class Login extends Component { ); } + registerForm() { return (
@@ -258,23 +257,37 @@ export class Login extends Component { /> -
- -
- + {this.state.captcha && ( +
+ + {this.showCaptcha()} +
+ +
-
+ )} {this.state.site.enable_nsfw && (
@@ -295,11 +308,7 @@ export class Login extends Component { )}
- + )} + + )} +
+ ); + } + handleLoginSubmit(i: Login, event: any) { event.preventDefault(); i.state.loginLoading = true; @@ -335,10 +374,7 @@ export class Login extends Component { event.preventDefault(); i.state.registerLoading = true; i.setState(i.state); - - if (!i.mathCheck) { - WebSocketService.Instance.register(i.state.registerForm); - } + WebSocketService.Instance.register(i.state.registerForm); } handleRegisterUsernameChange(i: Login, event: any) { @@ -369,11 +405,16 @@ export class Login extends Component { i.setState(i.state); } - handleMathAnswerChange(i: Login, event: any) { - i.state.mathQuestion.answer = event.target.value; + handleRegisterCaptchaAnswerChange(i: Login, event: any) { + i.state.registerForm.captcha_answer = event.target.value; i.setState(i.state); } + handleRegenCaptcha(_i: Login, _event: any) { + event.preventDefault(); + WebSocketService.Instance.getCaptcha(); + } + handlePasswordReset(i: Login) { event.preventDefault(); let resetForm: PasswordResetForm = { @@ -382,11 +423,21 @@ export class Login extends Component { WebSocketService.Instance.passwordReset(resetForm); } - get mathCheck(): boolean { - return ( - this.state.mathQuestion.answer != - this.state.mathQuestion.a + this.state.mathQuestion.b - ); + handleCaptchaPlay(i: Login) { + event.preventDefault(); + let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.ok.wav); + snd.play(); + i.state.captchaPlaying = true; + i.setState(i.state); + snd.addEventListener('ended', () => { + snd.currentTime = 0; + i.state.captchaPlaying = false; + i.setState(this.state); + }); + } + + captchaPngSrc() { + return `data:image/png;base64,${this.state.captcha.ok.png}`; } parseMessage(msg: WebSocketJsonResponse) { @@ -394,6 +445,9 @@ export class Login extends Component { if (msg.error) { toast(i18n.t(msg.error), 'danger'); this.state = this.emptyState; + this.state.registerForm.captcha_answer = undefined; + // Refetch another captcha + WebSocketService.Instance.getCaptcha(); this.setState(this.state); return; } else { @@ -412,6 +466,13 @@ export class Login extends Component { UserService.Instance.login(data); WebSocketService.Instance.userJoin(); this.props.history.push('/communities'); + } else if (res.op == UserOperation.GetCaptcha) { + let data = res.data as GetCaptchaResponse; + if (data.ok) { + this.state.captcha = data; + this.state.registerForm.captcha_uuid = data.ok.uuid; + this.setState(this.state); + } } else if (res.op == UserOperation.PasswordReset) { toast(i18n.t('reset_password_mail_sent')); } else if (res.op == UserOperation.GetSite) { diff --git a/ui/src/components/setup.tsx b/ui/src/components/setup.tsx index 196a2e56d..7da143791 100644 --- a/ui/src/components/setup.tsx +++ b/ui/src/components/setup.tsx @@ -29,6 +29,9 @@ export class Setup extends Component { password_verify: undefined, admin: true, show_nsfw: true, + // The first admin signup doesn't need a captcha + captcha_uuid: '', + captcha_answer: '', }, doneRegisteringUser: false, userLoading: false, diff --git a/ui/src/components/symbols.tsx b/ui/src/components/symbols.tsx index 48d5a5b61..bd7021431 100644 --- a/ui/src/components/symbols.tsx +++ b/ui/src/components/symbols.tsx @@ -15,6 +15,12 @@ export class Symbols extends Component { xmlnsXlink="http://www.w3.org/1999/xlink" > + + + + + + @@ -193,10 +199,10 @@ export class Symbols extends Component { - + - + diff --git a/ui/src/interfaces.ts b/ui/src/interfaces.ts index b88045220..87aa400a6 100644 --- a/ui/src/interfaces.ts +++ b/ui/src/interfaces.ts @@ -1,6 +1,7 @@ export enum UserOperation { Login, Register, + GetCaptcha, CreateCommunity, CreatePost, ListCommunities, @@ -572,6 +573,16 @@ export interface RegisterForm { password_verify: string; admin: boolean; show_nsfw: boolean; + captcha_uuid?: string; + captcha_answer?: string; +} + +export interface GetCaptchaResponse { + ok?: { + png: string; + wav?: string; + uuid: string; + }; } export interface LoginResponse { @@ -1010,6 +1021,7 @@ type ResponseType = | CommentResponse | UserMentionResponse | LoginResponse + | GetCaptchaResponse | GetModlogResponse | SearchResponse | BanFromCommunityResponse diff --git a/ui/src/services/WebSocketService.ts b/ui/src/services/WebSocketService.ts index 5d9916600..03233ef9e 100644 --- a/ui/src/services/WebSocketService.ts +++ b/ui/src/services/WebSocketService.ts @@ -115,6 +115,10 @@ export class WebSocketService { this.ws.send(this.wsSendWrapper(UserOperation.Register, registerForm)); } + public getCaptcha() { + this.ws.send(this.wsSendWrapper(UserOperation.GetCaptcha, {})); + } + public createCommunity(form: CommunityForm) { this.setAuth(form); this.ws.send(this.wsSendWrapper(UserOperation.CreateCommunity, form)); diff --git a/ui/translations/en.json b/ui/translations/en.json index c22c97c85..4ee0907a5 100644 --- a/ui/translations/en.json +++ b/ui/translations/en.json @@ -264,6 +264,8 @@ "password_incorrect": "Password incorrect.", "passwords_dont_match": "Passwords do not match.", "no_password_reset": "You will not be able to reset your password without an email.", + "captcha_incorrect": "Captcha incorrect.", + "enter_code": "Enter Code", "invalid_username": "Invalid username.", "admin_already_created": "Sorry, there's already an admin.", "user_already_exists": "User already exists.", @@ -281,5 +283,6 @@ "cake_day_title": "Cake day:", "cake_day_info": "It's {{ creator_name }}'s cake day today!", "invalid_post_title": "Invalid post title", - "invalid_url": "Invalid URL." + "invalid_url": "Invalid URL.", + "play_captcha_audio": "Play Captcha Audio" }