feat: log IP address for pending comments

This commit is contained in:
Pascal Engélibert 2022-12-04 15:45:52 +01:00
parent 17e61b8a67
commit e710e6678f
Signed by: tuxmain
GPG key ID: 3504BC6D362F7DCA
6 changed files with 71 additions and 49 deletions

View file

@ -2,7 +2,7 @@
Rust webserver for comments, that you can easily embed in a website. Rust webserver for comments, that you can easily embed in a website.
**Early development, not safe for production yet** **Early development, not safe for production yet** ([see milestones](https://git.txmn.tk/tuxmain/webcomment/milestone/1))
## Features ## Features
@ -14,6 +14,7 @@ Rust webserver for comments, that you can easily embed in a website.
* Comment frequency limit per IP * Comment frequency limit per IP
* i18n * i18n
* Petnames! (anonymous comment authors get a funny random name) * Petnames! (anonymous comment authors get a funny random name)
* Designed for privacy and moderation
## Use ## Use
@ -36,6 +37,16 @@ If enabled, a message can be sent to a Matrix room (private or public) on every
The account must have joined the room for Webcomment to be able to send messages to it. The account must have joined the room for Webcomment to be able to send messages to it.
## Moderation
New comments are not public before being approved by the administrator (by default).
## Privacy
Uses no cookie, no unique user identifier. At each mutation (i.e. new comment or edition), the client IP address is stored for a limited duration (configurable) only for antispam to work. (antispam can be disabled) The client IP address is also stored for each pending comment, but it is removed as soon as the comment gets approved.
However, keep in mind that if a reverse proxy (or any other intermediate tool) is used, IP addresses and other metadata may be logged somewhere.
## License ## License
CopyLeft 2022 Pascal Engélibert [(why copyleft?)](https://txmn.tk/blog/why-copyleft/) CopyLeft 2022 Pascal Engélibert [(why copyleft?)](https://txmn.tk/blog/why-copyleft/)

View file

@ -11,7 +11,7 @@ pub type Time = u64;
pub struct Dbs { 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), Option<IpAddr>>,
/// client_addr -> (last_mutation, mutation_count) /// client_addr -> (last_mutation, mutation_count)
pub client_mutation: Tree<IpAddr, (Time, u32)>, pub client_mutation: Tree<IpAddr, (Time, u32)>,
} }

View file

@ -3,7 +3,11 @@ use crate::{config::Config, db::*, queries::*};
use log::error; use log::error;
use std::{net::IpAddr, str::FromStr}; 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,
addr: Option<IpAddr>,
dbs: &Dbs,
) -> Result<CommentId, sled::Error> {
let comment_id = CommentId::new(); let comment_id = CommentId::new();
dbs.comment.insert(&comment_id, comment)?; dbs.comment.insert(&comment_id, comment)?;
dbs.comment_pending.insert( dbs.comment_pending.insert(
@ -12,7 +16,7 @@ pub fn new_pending_comment(comment: &Comment, dbs: &Dbs) -> Result<CommentId, sl
comment.post_time, comment.post_time,
comment_id.clone(), comment_id.clone(),
), ),
&(), &addr,
)?; )?;
Ok(comment_id) Ok(comment_id)
} }
@ -47,16 +51,16 @@ pub fn remove_comment(comment_id: CommentId, dbs: &Dbs) -> Result<Option<Comment
Ok(None) Ok(None)
} }
pub fn iter_comments_by_topic<'a>( pub fn iter_comments_by_topic<'a, V: typed_sled::KV>(
topic_hash: TopicHash, topic_hash: TopicHash,
tree: &'a Tree<(TopicHash, Time, CommentId), ()>, tree: &'a Tree<(TopicHash, Time, CommentId), V>,
dbs: &'a Dbs, dbs: &'a Dbs,
) -> impl Iterator<Item = (CommentId, Comment)> + 'a { ) -> impl Iterator<Item = (CommentId, Comment, V)> + 'a {
tree.range( tree.range(
(topic_hash.clone(), 0, CommentId::zero())..=(topic_hash, Time::MAX, CommentId::max()), (topic_hash.clone(), 0, CommentId::zero())..=(topic_hash, Time::MAX, CommentId::max()),
) )
.filter_map(|entry| { .filter_map(|entry| {
let ((_topic_hash, _time, comment_id), ()) = entry let ((_topic_hash, _time, comment_id), val) = entry
.map_err(|e| error!("Reading comment_by_topic_and_time: {:?}", e)) .map_err(|e| error!("Reading comment_by_topic_and_time: {:?}", e))
.ok()?; .ok()?;
let comment = dbs let comment = dbs
@ -68,7 +72,7 @@ pub fn iter_comments_by_topic<'a>(
error!("Comment not found"); error!("Comment not found");
None None
})?; })?;
Some((comment_id, comment)) Some((comment_id, comment, val))
}) })
} }
@ -77,12 +81,13 @@ pub fn iter_approved_comments_by_topic(
dbs: &Dbs, dbs: &Dbs,
) -> impl Iterator<Item = (CommentId, Comment)> + '_ { ) -> impl Iterator<Item = (CommentId, Comment)> + '_ {
iter_comments_by_topic(topic_hash, &dbs.comment_approved, dbs) iter_comments_by_topic(topic_hash, &dbs.comment_approved, dbs)
.map(|(comment_id, comment, ())| (comment_id, comment))
} }
pub fn iter_pending_comments_by_topic( pub fn iter_pending_comments_by_topic(
topic_hash: TopicHash, topic_hash: TopicHash,
dbs: &Dbs, dbs: &Dbs,
) -> impl Iterator<Item = (CommentId, Comment)> + '_ { ) -> impl Iterator<Item = (CommentId, Comment, Option<IpAddr>)> + '_ {
iter_comments_by_topic(topic_hash, &dbs.comment_pending, dbs) iter_comments_by_topic(topic_hash, &dbs.comment_pending, dbs)
} }

View file

@ -126,7 +126,8 @@ async fn serve_comments<'a>(
context.insert( context.insert(
"comments_pending", "comments_pending",
&helpers::iter_pending_comments_by_topic(topic_hash.clone(), &dbs) &helpers::iter_pending_comments_by_topic(topic_hash.clone(), &dbs)
.map(|(comment_id, comment)| CommentWithId { .map(|(comment_id, comment, addr)| CommentWithId {
addr: addr.map(|addr| addr.to_string()),
author: comment.author, author: comment.author,
editable: admin, editable: admin,
id: comment_id.to_base64(), id: comment_id.to_base64(),
@ -142,6 +143,7 @@ async fn serve_comments<'a>(
"comments", "comments",
&helpers::iter_approved_comments_by_topic(topic_hash, &dbs) &helpers::iter_approved_comments_by_topic(topic_hash, &dbs)
.map(|(comment_id, comment)| CommentWithId { .map(|(comment_id, comment)| CommentWithId {
addr: None,
author: comment.author, author: comment.author,
editable: admin, editable: admin,
id: comment_id.to_base64(), id: comment_id.to_base64(),
@ -186,7 +188,7 @@ async fn serve_admin<'a>(
&dbs.comment_pending &dbs.comment_pending
.iter() .iter()
.filter_map(|entry| { .filter_map(|entry| {
let ((_topic_hash, _time, comment_id), ()) = entry let ((_topic_hash, _time, comment_id), addr) = entry
.map_err(|e| error!("Reading comment_pending: {:?}", e)) .map_err(|e| error!("Reading comment_pending: {:?}", e))
.ok()?; .ok()?;
let comment = dbs let comment = dbs
@ -199,6 +201,7 @@ async fn serve_admin<'a>(
None None
})?; })?;
Some(CommentWithId { Some(CommentWithId {
addr: addr.map(|addr| addr.to_string()),
author: comment.author, author: comment.author,
editable: true, editable: true,
id: comment_id.to_base64(), id: comment_id.to_base64(),
@ -257,27 +260,22 @@ async fn handle_post_comments(
let client_langs = get_client_langs(&req); let client_langs = get_client_langs(&req);
let client_addr = if !admin && config.antispam_enable { let client_addr = match helpers::get_client_addr(config, &req) {
match helpers::get_client_addr(config, &req) { Some(Ok(addr)) => Some(addr),
Some(Ok(addr)) => { Some(Err(e)) => {
if config.antispam_whitelist.contains(&addr) { warn!("Unable to parse client addr: {}", e);
None None
} else { }
Some(addr) None => {
} warn!("No client addr");
} None
Some(Err(e)) => {
warn!("Unable to parse client addr: {}", e);
None
}
None => {
warn!("No client addr");
None
}
} }
} else {
None
}; };
let antispam_enabled = !admin
&& config.antispam_enable
&& client_addr
.as_ref()
.map_or(false, |addr| !config.antispam_whitelist.contains(addr));
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut context = Context::new(); let mut context = Context::new();
@ -291,28 +289,32 @@ async fn handle_post_comments(
helpers::check_comment(config, &query.comment, &mut errors); helpers::check_comment(config, &query.comment, &mut errors);
if let Some(client_addr) = &client_addr { if let Some(client_addr) = &client_addr {
if let Some(antispam_timeout) = if antispam_enabled {
helpers::antispam_check_client_mutation(client_addr, &dbs, config).unwrap() if let Some(antispam_timeout) =
{ helpers::antispam_check_client_mutation(client_addr, &dbs, config).unwrap()
errors.push( {
locales errors.push(
.tr( locales
&client_langs, .tr(
"error-antispam", &client_langs,
Some(&FluentArgs::from_iter([( "error-antispam",
"antispam_timeout", Some(&FluentArgs::from_iter([(
antispam_timeout, "antispam_timeout",
)])), antispam_timeout,
) )])),
.unwrap() )
.into_owned(), .unwrap()
); .into_owned(),
);
}
} }
} }
if errors.is_empty() { if errors.is_empty() {
if let Some(client_addr) = &client_addr { if let Some(client_addr) = &client_addr {
helpers::antispam_update_client_mutation(client_addr, &dbs).unwrap(); if antispam_enabled {
helpers::antispam_update_client_mutation(client_addr, &dbs).unwrap();
}
} }
let topic_hash = TopicHash::from_topic(topic); let topic_hash = TopicHash::from_topic(topic);
@ -338,7 +340,7 @@ async fn handle_post_comments(
post_time: time, post_time: time,
text: query.comment.text, text: query.comment.text,
}; };
helpers::new_pending_comment(&comment, &dbs) helpers::new_pending_comment(&comment, client_addr, &dbs)
.map_err(|e| error!("Adding pending comment: {:?}", e)) .map_err(|e| error!("Adding pending comment: {:?}", e))
.ok(); .ok();
notify_send notify_send

View file

@ -37,6 +37,7 @@ impl Templates {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct CommentWithId { pub struct CommentWithId {
pub addr: Option<String>,
pub author: String, pub author: String,
pub editable: bool, pub editable: bool,
pub id: String, pub id: String,

View file

@ -10,6 +10,9 @@
{% for comment in comments_pending %} {% for comment in comments_pending %}
<div class="comment{% if comment.needs_approval %} comment_pending{% endif %}" id="comment-{{ comment.id | safe }}"> <div class="comment{% if comment.needs_approval %} comment_pending{% endif %}" id="comment-{{ comment.id | safe }}">
<span class="comment-author">{{ comment.author }}</span> <span class="comment-author">{{ comment.author }}</span>
{% if comment.addr %}
<span class="comment-addr">{{ comment.addr }}</span>
{% endif %}
<span class="comment-date">{{ comment.post_time | date(format="%F %R", locale=time_lang) }}</span> <span class="comment-date">{{ comment.post_time | date(format="%F %R", locale=time_lang) }}</span>
{% if comment.editable %} {% if comment.editable %}
<a href="?edit={{ comment.id | safe }}#edit_comment-form">{{ tr(l=l,k="admin-comment-edit")|safe }}</a> <a href="?edit={{ comment.id | safe }}#edit_comment-form">{{ tr(l=l,k="admin-comment-edit")|safe }}</a>