commit 5619a2710162b346aa6101ea0c05471947e81198 Author: evann Date: Fri Nov 24 02:10:05 2023 +0100 First Version diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7caa9a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.github +target \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..7d425ef --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,59 @@ +name: Docker Build and Push with Version + +on: + push: + branches: + - master + - action-test + +env: + DOCKER_REGISTRY: r.regnault.dev + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + PORTAINER_API_WEBHOOK: ${{ secrets.PORTAINER_API_WEBHOOK }} + +jobs: + build_and_push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ env.DOCKER_USERNAME }} + password: ${{ env.DOCKER_PASSWORD }} + + - name: Install cargo-semver + uses: actions-rs/install@v0.1.2 + with: + crate: cargo-get + version: latest + + - name: Semver + run: + echo "VERSION=$(cargo get package.version --pretty)" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/captchuccino:latest + ${{ env.DOCKER_REGISTRY }}/captchuccino:${{ env.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy to production + uses: fjogeleit/http-request-action@v1.14.1 + with: + url: ${{ format('{0}?TAG={1}',env.PORTAINER_API_WEBHOOK, env.VERSION) }} + method: 'POST' + preventFailureOnNoResponse: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7660e36 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "captchuccino" +version = "1.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +captcha-rs="0.2.10" +serenity = { version="0.11", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] } +tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] } +image = "0.24.7" +r_i18n = "1.0.1" + +[dependencies.uuid] +version = "1.6.1" +features = [ + "v4", # Lets you generate random UUIDs + "fast-rng", # Use a faster (but still sufficiently random) RNG + "macro-diagnostics", # Enable better diagnostics for compile-time UUIDs +] \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed7f741 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM rust:slim-bookworm AS base +RUN apt-get update +RUN apt-get install libssl-dev pkg-config -y +RUN cargo install cargo-chef +WORKDIR app + +FROM base AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM base AS builder +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +COPY . . +RUN cargo build --release --bin captchuccino + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install ca-certificates libssl3 -y && apt-get clean autoclean && apt-get autoremove --yes && rm -rf /var/lib/{apt,dpkg,cache,log}/ +WORKDIR /root/ +COPY --from=builder /app/target/release/captchuccino . +COPY translations/ translations/ +CMD ["./captchuccino"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6a9fb0 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ + +# Captchuccino + +A Self-Hosted Discord bot that adds a captcha to gatekeep a discord server + +## Environment Variables + +To run this project, you will need to add the following environment variables to your .env file + +`DISCORD_TOKEN` : The token you'll use to run this bot. + +`LANG` : The locale that the bot will use, either `fr` or `en` for now. + +`GUILD_ID` : The ID of the server the bot will run in. + +`ROLE_ID` : The ID of the Unverified role on your server. + +## Deployment + +> ⚠️ The bot needs to have the SERVER MEMBERS INTENT enabled in the developper dashboard. + +Get the docker image from [my registry](https://registry.regnault.dev) +```bash + docker pull r.regnault.dev/captchuccino:latest +``` + +Launch the docker image with environment variables + +```bash + docker run r.regnault.dev/captchuccino:latest \ + -e DISCORD_TOKEN= \ + -e LANG= \ + -e GUILD_ID= \ + -e ROLE_ID= +``` + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6eae3de --- /dev/null +++ b/src/main.rs @@ -0,0 +1,185 @@ +mod utils; + +use std::collections::HashMap; +use std::env; +use std::fs::remove_file; +use std::sync::{Arc, RwLock}; +use captcha_rs::Captcha; +use tokio::fs::{create_dir, File, try_exists}; +use image::ImageFormat; +use serenity::{async_trait, Client}; +use serenity::client::Context; +use serenity::model::channel::{Message}; +use serenity::model::gateway::Ready; +use serenity::model::guild::Member; +use serenity::model::id::{RoleId, UserId}; +use serenity::model::prelude::Guild; +use serenity::prelude::{EventHandler, GatewayIntents, TypeMapKey}; +use crate::utils::captcha_builder::build_captcha; +use crate::utils::i18n::{get_translation, get_env_error_message, get_server_message}; + +struct Handler; + +struct CaptchaData; + +impl TypeMapKey for CaptchaData { + type Value = Arc>>; +} + +async fn get_lock(_ctx: &Context) -> Arc>> { + let captcha_data = _ctx.data.read().await; + + captcha_data.get::() + .expect(get_translation("captcha-get-data-error").as_str()).clone() +} + +async fn create_captcha(_ctx: &Context, user_id: UserId) -> Captcha { + let captcha = build_captcha(); + + let captcha_data_lock = get_lock(&_ctx).await; + { + let mut data = captcha_data_lock.write() + .expect(get_translation("mutex-lock-error").as_str()); + + let clone_captcha = captcha.text.clone().to_owned(); + let entry = data.entry(user_id).or_insert(clone_captcha.clone()); + *entry = clone_captcha; + }; + + captcha +} + +async fn send_captcha(_ctx: &Context, _new_member: Member, msg: &str) { + let private_channel = _new_member.user.create_dm_channel(&_ctx).await; + let captcha = create_captcha(&_ctx, _new_member.user.id).await; + + let file_name_string = format!("captcha/{}.jpg", uuid::Uuid::new_v4()); + let file_name = file_name_string.as_str(); + captcha.image.save_with_format(&file_name, ImageFormat::Jpeg) + .expect(get_translation("save-image-error").as_str()); + + if let Ok(channel) = private_channel { + let file = File::open(&file_name).await + .expect(get_translation("open-image-error").as_str()); + + let files = vec![(&file, file_name)]; + + channel.send_files(&_ctx, files, |m| m.content(msg)).await + .expect(get_translation("server-cantsendmessage-error").as_str()); + + remove_file(file_name_string.as_str()) + .expect(get_translation("delete-image-error").as_str()); + } +} + +#[async_trait] +impl EventHandler for Handler { + async fn guild_member_addition(&self, _ctx: Context, mut _new_member: Member) { + _new_member.add_role(&_ctx, &get_role_id()).await + .expect(get_translation("server-cantaddrole-error").as_str()); + + let guild = Guild::get(&_ctx, get_guild_id()).await + .expect(get_translation("server-cantgetguild-error").as_str()); + + send_captcha(&_ctx, _new_member, + get_server_message( + "server-captcha-prompt", + guild.name.as_str() + ).as_str() + ).await; + } + + async fn message(&self, _ctx: Context, _new_message: Message) { + let guild = Guild::get(&_ctx, get_guild_id()).await + .expect(get_translation("server-cantgetguild-error").as_str()); + match guild.member(&_ctx, _new_message.author.id).await { + Ok(member) => { + if !member.roles.contains(&get_role_id()) { + return; + } + } + Err(_) => { return; } + } + + + let mut member = guild.member(&_ctx, _new_message.author.id).await + .expect(get_translation("server-cantgetmember-error").as_str()); + + if let Some(channel) = _new_message.channel(&_ctx).await + .expect(get_translation("server-cantgetchannel-error").as_str()).private() + { + let captcha_data_lock = get_lock(&_ctx).await; + + let res = { + let mut data = captcha_data_lock.write() + .expect(get_translation("mutex-lock-error").as_str()); + + match data.get(&member.user.id) { + None => None, + Some(captcha_text) => { + let res = captcha_text.eq(&_new_message.content); + if res { + data.remove(&member.user.id) + .expect(get_translation("captcha-delete-data-error").as_str()); + } + Some(res) + } + } + }; + + match res { + Some(true) => { + member.remove_role(&_ctx, &get_role_id()).await + .expect(get_translation("server-cantremoverole-error").as_str()); + + channel.send_message(&_ctx, |m| + m.content(get_server_message("server-captcha-validated", guild.name.as_str())) + ).await + .expect(get_translation("server-cantsendmessage-error").as_str()); + }, + _ => { + send_captcha(&_ctx, member, get_translation("server-captcha-incorrect").as_str()).await + } + }; + } + } + + async fn ready(&self, _ctx: Context, _data_about_bot: Ready) { + println!("{}", get_translation("bot-started")); + } +} +fn get_role_id() -> RoleId { + let role_id = env::var("ROLE_ID").expect(get_env_error_message("ROLE_ID").as_str()); + RoleId::from(role_id.parse::().expect("Cannot parse ROLE_ID to int")) +} + +fn get_guild_id() -> u64 { + let guild_id = env::var("GUILD_ID").expect(get_env_error_message("GUILD_ID").as_str()); + guild_id.parse::().expect("Cannot parse GUILD_ID to int") +} + +#[tokio::main] +async fn main() { + let token = env::var("DISCORD_TOKEN").expect(get_env_error_message("DISCORD_TOKEN").as_str()); + + if !try_exists("captcha").await.expect(get_translation("exist-dir-error").as_str()) { + create_dir("captcha").await.expect(get_translation("create-dir-error").as_str()); + } + + + let intents = GatewayIntents::DIRECT_MESSAGES + | GatewayIntents::GUILD_MEMBERS; + + let mut client = Client::builder(&token, intents) + .event_handler(Handler).await.expect(get_translation("client-build-error").as_str()); + + { + let mut data = client.data.write().await; + data.insert::(Arc::new(RwLock::new(HashMap::default()))); + } + + + if let Err(why) = client.start().await { + println!("{}: {why:?}", get_translation("bot-error")) + } +} diff --git a/src/utils/captcha_builder.rs b/src/utils/captcha_builder.rs new file mode 100644 index 0000000..c451749 --- /dev/null +++ b/src/utils/captcha_builder.rs @@ -0,0 +1,12 @@ +use captcha_rs::{Captcha, CaptchaBuilder}; + +pub fn build_captcha() -> Captcha { + CaptchaBuilder::new() + .length(5) + .width(200) + .height(100) + .dark_mode(true) + .complexity(5) + .compression(40) + .build() +} \ No newline at end of file diff --git a/src/utils/i18n.rs b/src/utils/i18n.rs new file mode 100644 index 0000000..1dd9413 --- /dev/null +++ b/src/utils/i18n.rs @@ -0,0 +1,38 @@ +use std::env; +use r_i18n::{I18n, I18nConfig}; + + + +pub fn get_translation(translation_key: &str) -> String { + let config: I18nConfig = I18nConfig{ + locales: &["en", "fr"], + directory: "translations" + }; + let mut r_i18n = I18n::configure(&config); + let locale = env::var("LANG") + .expect(get_env_error_message("LANG").as_str()); + + r_i18n.set_current_lang(locale.as_str()); + r_i18n.t(translation_key).to_string() +} + +pub fn get_server_message(translation_key: &str, server_name: &str) -> String { + get_translation(translation_key).replace("SERVER_NAME", server_name) +} + +pub fn get_env_error_message(env_name: &str) -> String { + let config: I18nConfig = I18nConfig{ + locales: &["en", "fr"], + directory: "translations" + }; + let mut r_i18n = I18n::configure(&config); + let locale = env::var("LANG") + .expect("LANG ENV NOT SET"); + + r_i18n.set_current_lang(locale.as_str()); + let error_msg = r_i18n.t("env-error"); + match error_msg.to_string().contains("ENV_VAR") { + true => {error_msg.to_string().replace("ENV_VAR", env_name)} + false => panic!("Cannot find env-error") + } +} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..3d485f8 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod captcha_builder; +pub mod i18n; + diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..7f8fad5 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,32 @@ +{ + "captcha-get-data-error": "Cannot get the captchas data", + "captcha-delete-data-error": "Cannot delete the captcha data", + + "env-error": "The ENV_VAR environment variable is not set", + "translation-error": "The 'TRANSLATION_KEY' translation key doesn't exist for the LOCALE locale", + + "client-build-error": "Cannot create the bot", + "bot-error": "The bot errored while starting", + "bot-started": "The bot started !", + + "exist-dir-error": "Cannot test if the directory exist", + "create-dir-error": "Cannot create directory", + + "save-image-error": "Cannot save the image", + "open-image-error": "Cannot open the image", + "delete-image-error": "Cannot delete the image", + + "mutex-lock-error": "Cannot get the Mutex Lock", + + + "server-captcha-validated": "The captcha has been validated !\nWelcome to SERVER_NAME :sparkles:", + "server-captcha-incorrect": "The captcha is incorrect, please try again.", + "server-captcha-prompt": "Please complete the captcha below in order to join SERVER_NAME !", + + "server-cantgetmember-error": "Cannot get the member", + "server-cantgetguild-error": "Cannot get the server", + "server-cantaddrole-error": "Cannot add a role", + "server-cantremoverole-error": "Cannot remove a role", + "server-cantgetchannel-error": "Cannot get the channel", + "server-cantsendmessage-error": "Cannot send a message" +} \ No newline at end of file diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..29a5c5e --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,32 @@ +{ + "captcha-get-data-error": "Impossible de récuperer les données des captchas", + "captcha-delete-data-error": "Impossible de supprimer les données du captcha", + + "env-error": "La variable d'environnement ENV_VAR n'existe pas", + "translation-error": "La clé de traduction 'TRANSLATION_KEY' n'existe pas pour la locale LOCALE", + + "client-build-error": "Impossible de créer le bot", + "bot-error": "Le bot n'a pas réussi a se lancer", + "bot-started": "Le bot a démarré !", + + "exist-dir-error": "Impossible de tester si le dossier existe", + "create-dir-error": "Impossible de créer le dossier", + + "save-image-error": "Impossible de sauvegarder l'image", + "open-image-error": "Impossible d'ouvrir l'image", + "delete-image-error": "Impossible de supprimer l'image", + + "mutex-lock-error": "Impossible de récuperer le Lock du Mutex", + + + "server-captcha-validated": "Le captcha a été validé !\nNous vous souhaitons la bienvenue sur SERVER_NAME :sparkles:", + "server-captcha-incorrect": "Le captcha est invalide, veuillez réessayer.", + "server-captcha-prompt": "Veuillez remplir le captcha ci-dessous pour rejoindre SERVER_NAME !", + + "server-cantgetmember-error": "Impossible de récuperer le membre", + "server-cantgetguild-error": "Impossible de récuperer le serveur", + "server-cantaddrole-error": "Impossible d'ajouter un role", + "server-cantremoverole-error": "Impossible de retirer un role", + "server-cantgetchannel-error": "Impossible de récuperer le salon", + "server-cantsendmessage-error": "Impossible d'envoyer un message" +} \ No newline at end of file