This commit is contained in:
Pascal Engélibert 2022-10-21 19:33:20 +02:00
parent 096390d533
commit 9fd7514927
Signed by: tuxmain
GPG key ID: 3504BC6D362F7DCA
7 changed files with 204 additions and 19 deletions

View file

@ -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
View 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();
}
}
}

View file

@ -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(),
}
}

View file

@ -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"),
}
}

View file

@ -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 {

View file

@ -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);

View file

@ -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)
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())
.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()