diff --git a/README.md b/README.md index ba73c2c..0bd973f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Rust webserver for comments, that you can easily embed in a website. * Admin notification on new comment via Matrix * Embedded one-file webserver * [Tera](https://github.com/Keats/tera) templates +* Comment frequency limit per IP ## Use diff --git a/src/cleaner.rs b/src/cleaner.rs new file mode 100644 index 0000000..a1017f6 --- /dev/null +++ b/src/cleaner.rs @@ -0,0 +1,30 @@ +use crate::{config::Config, db::*}; + +use std::{sync::Arc, time::Duration}; + +pub async fn run_cleaner(config: Arc, dbs: Dbs) { + let mut last_db_clean = 0; + loop { + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + if time > last_db_clean + 3600 { + clean_antispam(config.clone(), dbs.clone(), time); + last_db_clean = time; + } + + tokio::time::sleep(Duration::from_secs(60)).await; + } +} + +fn clean_antispam(config: Arc, dbs: Dbs, time: u64) { + for (addr, (last_mutation, _mutation_count)) in + dbs.client_mutation.iter().filter_map(|o| o.ok()) + { + if last_mutation + config.antispam_duration < time { + dbs.client_mutation.remove(&addr).unwrap(); + } + } +} diff --git a/src/config.rs b/src/config.rs index fb11da0..746eacb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,10 +8,18 @@ const CONFIG_FILE: &str = "config.toml"; #[derive(Deserialize, Serialize)] pub struct Config { - //#[serde(default = "Config::default_admin_emails")] - //pub admin_emails: Vec, #[serde(default = "Config::default_admin_passwords")] pub admin_passwords: Vec, + /// (seconds) + #[serde(default = "Config::default_antispam_duration")] + pub antispam_duration: u64, + #[serde(default = "Config::default_antispam_enable")] + pub antispam_enable: bool, + /// Maximum number of mutations by IP within antispam_duration + #[serde(default = "Config::default_antispam_mutation_limit")] + pub antispam_mutation_limit: u32, + #[serde(default = "Config::default_antispam_whitelist")] + pub antispam_whitelist: Vec, /// New or edited comments need admin's approval before being public #[serde(default = "Config::default_comment_approve")] pub comment_approve: bool, @@ -43,17 +51,30 @@ pub struct Config { pub matrix_server: String, #[serde(default = "Config::default_matrix_user")] pub matrix_user: String, + /// Are we behind a reverse proxy? + /// Determines whether we assume client address is in a Forwarded header or socket address. + #[serde(default = "Config::default_reverse_proxy")] + pub reverse_proxy: bool, #[serde(default = "Config::default_root_url")] pub root_url: String, } impl Config { - /*fn default_admin_emails() -> Vec { - vec![] - }*/ fn default_admin_passwords() -> Vec { vec![] } + fn default_antispam_duration() -> u64 { + 3600 + } + fn default_antispam_enable() -> bool { + true + } + fn default_antispam_mutation_limit() -> u32 { + 10 + } + fn default_antispam_whitelist() -> Vec { + vec![[127u8, 0, 0, 1].into(), [0u8; 4].into(), [0u8; 16].into()] + } fn default_comment_approve() -> bool { true } @@ -96,6 +117,9 @@ impl Config { fn default_matrix_user() -> String { "@tuxmain:matrix.txmn.tk".into() } + fn default_reverse_proxy() -> bool { + false + } fn default_root_url() -> String { "/".into() } @@ -104,8 +128,11 @@ impl Config { impl Default for Config { fn default() -> Self { Self { - //admin_emails: Self::default_admin_emails(), admin_passwords: Self::default_admin_passwords(), + antispam_duration: Self::default_antispam_duration(), + antispam_enable: Self::default_antispam_enable(), + antispam_mutation_limit: Self::default_antispam_mutation_limit(), + antispam_whitelist: Self::default_antispam_whitelist(), comment_approve: Self::default_comment_approve(), comment_edit_timeout: Self::default_comment_edit_timeout(), comment_author_max_len: Self::default_comment_author_max_len(), @@ -120,6 +147,7 @@ impl Default for Config { matrix_room: Self::default_matrix_room(), matrix_server: Self::default_matrix_server(), matrix_user: Self::default_matrix_user(), + reverse_proxy: Self::default_reverse_proxy(), root_url: Self::default_root_url(), } } diff --git a/src/db.rs b/src/db.rs index 9d964b7..90144ea 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::path::Path; +use std::{net::IpAddr, path::Path}; pub use typed_sled::Tree; const DB_DIR: &str = "db"; @@ -12,6 +12,8 @@ pub struct Dbs { pub comment: Tree, pub comment_approved: Tree<(TopicHash, Time, CommentId), ()>, pub comment_pending: Tree<(TopicHash, Time, CommentId), ()>, + /// client_addr -> (last_mutation, mutation_count) + pub client_mutation: Tree, } pub fn load_dbs(path: Option<&Path>) -> Dbs { @@ -28,6 +30,7 @@ pub fn load_dbs(path: Option<&Path>) -> Dbs { comment: Tree::open(&db, "comment"), comment_approved: Tree::open(&db, "comment_approved"), comment_pending: Tree::open(&db, "comment_pending"), + client_mutation: Tree::open(&db, "client_mutation"), } } diff --git a/src/helpers.rs b/src/helpers.rs index 7f4388b..4a537e3 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -1,6 +1,7 @@ -use crate::db::*; +use crate::{config::Config, db::*}; use log::error; +use std::{net::IpAddr, str::FromStr}; pub fn new_pending_comment(comment: &Comment, dbs: &Dbs) -> Result { let comment_id = CommentId::new(); @@ -80,7 +81,87 @@ pub fn iter_pending_comments_by_topic( iter_comments_by_topic(topic_hash, &dbs.comment_pending, dbs) } -//pub enum DbHelperError {} +/// Returns Some(time_left) if the client is banned. +pub fn antispam_check_client_mutation( + addr: &IpAddr, + dbs: &Dbs, + config: &Config, +) -> Result, sled::Error> { + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + Ok(dbs + .client_mutation + .get(addr)? + .and_then(|(last_mutation, mutation_count)| { + let timeout = last_mutation + config.antispam_duration; + if timeout > time && mutation_count >= config.antispam_mutation_limit { + Some(timeout - time) + } else { + None + } + })) +} + +pub fn antispam_update_client_mutation(addr: &IpAddr, dbs: &Dbs) -> Result<(), sled::Error> { + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + dbs.client_mutation.fetch_and_update(addr, |entry| { + if let Some((_last_mutation, mutation_count)) = entry { + Some((time, mutation_count.saturating_add(1))) + } else { + Some((time, 1)) + } + })?; + Ok(()) +} + +/*pub fn new_client_mutation( + addr: &IpAddr, + dbs: &Dbs, + config: &Config, +) -> Result, sled::Error> { + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut res = None; + dbs.client_mutation.fetch_and_update(addr, |entry| { + if let Some((last_mutation, mutation_count)) = entry { + if last_mutation + config.antispam_duration > time { + if mutation_count >= config.antispam_mutation_limit { + res = Some(last_mutation + config.antispam_duration); + Some((last_mutation, mutation_count)) + } else { + Some((time, mutation_count.saturating_add(1))) + } + } else { + Some((time, 1)) + } + } else { + Some((time, 1)) + } + })?; + Ok(res) +}*/ + +pub fn get_client_addr( + config: &Config, + req: &tide::Request, +) -> Option> { + Some(IpAddr::from_str( + if config.reverse_proxy { + req.remote() + } else { + req.peer_addr() + }? + .rsplit_once(':')? + .0, + )) +} #[cfg(test)] mod test { diff --git a/src/main.rs b/src/main.rs index 4a18cc6..c351c99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod cleaner; mod cli; mod config; mod db; @@ -12,6 +13,7 @@ use argon2::{ Argon2, }; use clap::Parser; +use std::sync::Arc; #[tokio::main] async fn main() { @@ -23,7 +25,10 @@ async fn main() { } cli::MainSubcommand::Start(subopt) => { let (config, dbs, templates) = init_all(opt.opt, subopt); - server::start_server(config, dbs, templates).await + let config = Arc::new(config); + let templates = Arc::new(templates); + tokio::spawn(cleaner::run_cleaner(config.clone(), dbs.clone())); + server::run_server(config, dbs, templates).await; } cli::MainSubcommand::Psw => { let mut config = config::read_config(&opt.opt.dir.0); diff --git a/src/server.rs b/src/server.rs index f734f09..6177aa3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2,16 +2,13 @@ use crate::{config::*, db::*, helpers, queries::*, templates::*}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use crossbeam_channel::Sender; -use log::error; +use log::{error, warn}; use std::sync::Arc; use tera::Context; -pub async fn start_server(config: Config, dbs: Dbs, templates: Templates) { +pub async fn run_server(config: Arc, dbs: Dbs, templates: Arc) { tide::log::start(); - let templates = Arc::new(templates); - let config = Arc::new(config); - let (notify_send, notify_recv) = crossbeam_channel::bounded(10); tokio::spawn(crate::notify::run_notifier(config.clone(), notify_recv)); @@ -61,6 +58,8 @@ async fn serve_comments<'a>( dbs: Dbs, errors: &[String], ) -> tide::Result { + dbg!(req.peer_addr()); + let Ok(topic) = req.param("topic") else { return Err(tide::Error::from_str(404, "No topic")) }; @@ -119,10 +118,12 @@ async fn serve_comments<'a>( .collect::>(), ); - Ok(tide::Response::builder(200) - .content_type(tide::http::mime::HTML) - .body(templates.tera.render("comments.html", &context)?) - .build()) + Ok( + tide::Response::builder(if errors.is_empty() { 200 } else { 400 }) + .content_type(tide::http::mime::HTML) + .body(templates.tera.render("comments.html", &context)?) + .build(), + ) } async fn serve_admin<'a>( @@ -190,6 +191,28 @@ async fn handle_post_comments( dbs: Dbs, notify_send: Sender<()>, ) -> tide::Result { + let client_addr = if config.antispam_enable { + match helpers::get_client_addr(&config, &req) { + Some(Ok(addr)) => { + if config.antispam_whitelist.contains(&addr) { + None + } else { + Some(addr) + } + } + Some(Err(e)) => { + warn!("Unable to parse client addr: {}", e); + None + } + None => { + warn!("No client addr"); + None + } + } + } else { + None + }; + let mut errors = Vec::new(); match req.body_form::().await? { @@ -219,8 +242,22 @@ async fn handle_post_comments( config.comment_text_max_len )); } + if let Some(client_addr) = &client_addr { + if let Some(antispam_timeout) = + helpers::antispam_check_client_mutation(client_addr, &dbs, &config).unwrap() + { + errors.push(format!( + "The edition quota from your IP is reached. You will be unblocked in {}s.", + antispam_timeout + )); + } + } if errors.is_empty() { + if let Some(client_addr) = &client_addr { + helpers::antispam_update_client_mutation(client_addr, &dbs).unwrap(); + } + let topic_hash = TopicHash::from_topic(topic); let time = std::time::SystemTime::now()