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
|
* Admin notification on new comment via Matrix
|
||||||
* Embedded one-file webserver
|
* Embedded one-file webserver
|
||||||
* [Tera](https://github.com/Keats/tera) templates
|
* [Tera](https://github.com/Keats/tera) templates
|
||||||
|
* Comment frequency limit per IP
|
||||||
|
|
||||||
## Use
|
## 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)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
//#[serde(default = "Config::default_admin_emails")]
|
|
||||||
//pub admin_emails: Vec<String>,
|
|
||||||
#[serde(default = "Config::default_admin_passwords")]
|
#[serde(default = "Config::default_admin_passwords")]
|
||||||
pub admin_passwords: Vec<String>,
|
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
|
/// New or edited comments need admin's approval before being public
|
||||||
#[serde(default = "Config::default_comment_approve")]
|
#[serde(default = "Config::default_comment_approve")]
|
||||||
pub comment_approve: bool,
|
pub comment_approve: bool,
|
||||||
|
@ -43,17 +51,30 @@ pub struct Config {
|
||||||
pub matrix_server: String,
|
pub matrix_server: String,
|
||||||
#[serde(default = "Config::default_matrix_user")]
|
#[serde(default = "Config::default_matrix_user")]
|
||||||
pub matrix_user: String,
|
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")]
|
#[serde(default = "Config::default_root_url")]
|
||||||
pub root_url: String,
|
pub root_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/*fn default_admin_emails() -> Vec<String> {
|
|
||||||
vec![]
|
|
||||||
}*/
|
|
||||||
fn default_admin_passwords() -> Vec<String> {
|
fn default_admin_passwords() -> Vec<String> {
|
||||||
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<IpAddr> {
|
||||||
|
vec![[127u8, 0, 0, 1].into(), [0u8; 4].into(), [0u8; 16].into()]
|
||||||
|
}
|
||||||
fn default_comment_approve() -> bool {
|
fn default_comment_approve() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
@ -96,6 +117,9 @@ impl Config {
|
||||||
fn default_matrix_user() -> String {
|
fn default_matrix_user() -> String {
|
||||||
"@tuxmain:matrix.txmn.tk".into()
|
"@tuxmain:matrix.txmn.tk".into()
|
||||||
}
|
}
|
||||||
|
fn default_reverse_proxy() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
fn default_root_url() -> String {
|
fn default_root_url() -> String {
|
||||||
"/".into()
|
"/".into()
|
||||||
}
|
}
|
||||||
|
@ -104,8 +128,11 @@ impl Config {
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
//admin_emails: Self::default_admin_emails(),
|
|
||||||
admin_passwords: Self::default_admin_passwords(),
|
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_approve: Self::default_comment_approve(),
|
||||||
comment_edit_timeout: Self::default_comment_edit_timeout(),
|
comment_edit_timeout: Self::default_comment_edit_timeout(),
|
||||||
comment_author_max_len: Self::default_comment_author_max_len(),
|
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_room: Self::default_matrix_room(),
|
||||||
matrix_server: Self::default_matrix_server(),
|
matrix_server: Self::default_matrix_server(),
|
||||||
matrix_user: Self::default_matrix_user(),
|
matrix_user: Self::default_matrix_user(),
|
||||||
|
reverse_proxy: Self::default_reverse_proxy(),
|
||||||
root_url: Self::default_root_url(),
|
root_url: Self::default_root_url(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::path::Path;
|
use std::{net::IpAddr, path::Path};
|
||||||
pub use typed_sled::Tree;
|
pub use typed_sled::Tree;
|
||||||
|
|
||||||
const DB_DIR: &str = "db";
|
const DB_DIR: &str = "db";
|
||||||
|
@ -12,6 +12,8 @@ pub struct Dbs {
|
||||||
pub comment: Tree<CommentId, Comment>,
|
pub comment: Tree<CommentId, Comment>,
|
||||||
pub comment_approved: Tree<(TopicHash, Time, CommentId), ()>,
|
pub comment_approved: Tree<(TopicHash, Time, CommentId), ()>,
|
||||||
pub comment_pending: 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 {
|
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: Tree::open(&db, "comment"),
|
||||||
comment_approved: Tree::open(&db, "comment_approved"),
|
comment_approved: Tree::open(&db, "comment_approved"),
|
||||||
comment_pending: Tree::open(&db, "comment_pending"),
|
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 log::error;
|
||||||
|
use std::{net::IpAddr, str::FromStr};
|
||||||
|
|
||||||
pub fn new_pending_comment(comment: &Comment, dbs: &Dbs) -> Result<CommentId, sled::Error> {
|
pub fn new_pending_comment(comment: &Comment, dbs: &Dbs) -> Result<CommentId, sled::Error> {
|
||||||
let comment_id = CommentId::new();
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod cleaner;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
@ -12,6 +13,7 @@ use argon2::{
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
@ -23,7 +25,10 @@ async fn main() {
|
||||||
}
|
}
|
||||||
cli::MainSubcommand::Start(subopt) => {
|
cli::MainSubcommand::Start(subopt) => {
|
||||||
let (config, dbs, templates) = init_all(opt.opt, 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 => {
|
cli::MainSubcommand::Psw => {
|
||||||
let mut config = config::read_config(&opt.opt.dir.0);
|
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 argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
use crossbeam_channel::Sender;
|
use crossbeam_channel::Sender;
|
||||||
use log::error;
|
use log::{error, warn};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tera::Context;
|
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();
|
tide::log::start();
|
||||||
|
|
||||||
let templates = Arc::new(templates);
|
|
||||||
let config = Arc::new(config);
|
|
||||||
|
|
||||||
let (notify_send, notify_recv) = crossbeam_channel::bounded(10);
|
let (notify_send, notify_recv) = crossbeam_channel::bounded(10);
|
||||||
tokio::spawn(crate::notify::run_notifier(config.clone(), notify_recv));
|
tokio::spawn(crate::notify::run_notifier(config.clone(), notify_recv));
|
||||||
|
|
||||||
|
@ -61,6 +58,8 @@ async fn serve_comments<'a>(
|
||||||
dbs: Dbs,
|
dbs: Dbs,
|
||||||
errors: &[String],
|
errors: &[String],
|
||||||
) -> tide::Result<tide::Response> {
|
) -> tide::Result<tide::Response> {
|
||||||
|
dbg!(req.peer_addr());
|
||||||
|
|
||||||
let Ok(topic) = req.param("topic") else {
|
let Ok(topic) = req.param("topic") else {
|
||||||
return Err(tide::Error::from_str(404, "No topic"))
|
return Err(tide::Error::from_str(404, "No topic"))
|
||||||
};
|
};
|
||||||
|
@ -119,10 +118,12 @@ async fn serve_comments<'a>(
|
||||||
.collect::<Vec<CommentWithId>>(),
|
.collect::<Vec<CommentWithId>>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(tide::Response::builder(200)
|
Ok(
|
||||||
|
tide::Response::builder(if errors.is_empty() { 200 } else { 400 })
|
||||||
.content_type(tide::http::mime::HTML)
|
.content_type(tide::http::mime::HTML)
|
||||||
.body(templates.tera.render("comments.html", &context)?)
|
.body(templates.tera.render("comments.html", &context)?)
|
||||||
.build())
|
.build(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_admin<'a>(
|
async fn serve_admin<'a>(
|
||||||
|
@ -190,6 +191,28 @@ async fn handle_post_comments(
|
||||||
dbs: Dbs,
|
dbs: Dbs,
|
||||||
notify_send: Sender<()>,
|
notify_send: Sender<()>,
|
||||||
) -> tide::Result<tide::Response> {
|
) -> 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();
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
match req.body_form::<CommentQuery>().await? {
|
match req.body_form::<CommentQuery>().await? {
|
||||||
|
@ -219,8 +242,22 @@ async fn handle_post_comments(
|
||||||
config.comment_text_max_len
|
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 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 topic_hash = TopicHash::from_topic(topic);
|
||||||
|
|
||||||
let time = std::time::SystemTime::now()
|
let time = std::time::SystemTime::now()
|
||||||
|
|
Loading…
Reference in a new issue