Antispam
This commit is contained in:
parent
096390d533
commit
9fd7514927
7 changed files with 204 additions and 19 deletions
|
@ -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
|
||||
|
||||
|
|
30
src/cleaner.rs
Normal file
30
src/cleaner.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use crate::{config::Config, db::*};
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
pub async fn run_cleaner(config: Arc<Config>, 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<Config>, 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String>,
|
||||
#[serde(default = "Config::default_admin_passwords")]
|
||||
pub admin_passwords: Vec<String>,
|
||||
/// (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<IpAddr>,
|
||||
/// 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<String> {
|
||||
vec![]
|
||||
}*/
|
||||
fn default_admin_passwords() -> Vec<String> {
|
||||
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<IpAddr> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<CommentId, Comment>,
|
||||
pub comment_approved: Tree<(TopicHash, Time, CommentId), ()>,
|
||||
pub comment_pending: Tree<(TopicHash, Time, CommentId), ()>,
|
||||
/// client_addr -> (last_mutation, mutation_count)
|
||||
pub client_mutation: Tree<IpAddr, (Time, u32)>,
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<CommentId, sled::Error> {
|
||||
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<Option<Time>, 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<Option<Time>, 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<State>(
|
||||
config: &Config,
|
||||
req: &tide::Request<State>,
|
||||
) -> Option<Result<IpAddr, std::net::AddrParseError>> {
|
||||
Some(IpAddr::from_str(
|
||||
if config.reverse_proxy {
|
||||
req.remote()
|
||||
} else {
|
||||
req.peer_addr()
|
||||
}?
|
||||
.rsplit_once(':')?
|
||||
.0,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<Config>, dbs: Dbs, templates: Arc<Templates>) {
|
||||
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<tide::Response> {
|
||||
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::<Vec<CommentWithId>>(),
|
||||
);
|
||||
|
||||
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<tide::Response> {
|
||||
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::<CommentQuery>().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()
|
||||
|
|
Loading…
Reference in a new issue