Admin edit comment

This commit is contained in:
Pascal Engélibert 2022-10-22 17:56:24 +02:00
parent 34ae3d3ec4
commit dd6b8c76ba
Signed by: tuxmain
GPG key ID: 3504BC6D362F7DCA
6 changed files with 204 additions and 76 deletions

View file

@ -82,6 +82,7 @@ impl CommentId {
} }
pub fn from_base64(s: &str) -> Result<Self, base64::DecodeError> { pub fn from_base64(s: &str) -> Result<Self, base64::DecodeError> {
// TODO prevent panic when s is too long
let mut buf = [0; 16]; let mut buf = [0; 16];
base64::decode_config_slice(s, base64::URL_SAFE_NO_PAD, &mut buf).map(|_| Self(buf)) base64::decode_config_slice(s, base64::URL_SAFE_NO_PAD, &mut buf).map(|_| Self(buf))
} }

View file

@ -1,4 +1,4 @@
use crate::{config::Config, db::*}; use crate::{config::Config, db::*, queries::*};
use log::error; use log::error;
use std::{net::IpAddr, str::FromStr}; use std::{net::IpAddr, str::FromStr};
@ -30,13 +30,18 @@ pub fn approve_comment(comment_id: CommentId, dbs: &Dbs) -> Result<(), sled::Err
Ok(()) Ok(())
} }
pub fn remove_pending_comment( pub fn remove_comment(comment_id: CommentId, dbs: &Dbs) -> Result<Option<Comment>, sled::Error> {
comment_id: CommentId,
dbs: &Dbs,
) -> Result<Option<Comment>, sled::Error> {
if let Some(comment) = dbs.comment.remove(&comment_id)? { if let Some(comment) = dbs.comment.remove(&comment_id)? {
dbs.comment_pending dbs.comment_pending.remove(&(
.remove(&(comment.topic_hash.clone(), comment.post_time, comment_id))?; comment.topic_hash.clone(),
comment.post_time,
comment_id.clone(),
))?;
dbs.comment_approved.remove(&(
comment.topic_hash.clone(),
comment.post_time,
comment_id,
))?;
return Ok(Some(comment)); return Ok(Some(comment));
} }
Ok(None) Ok(None)
@ -163,6 +168,30 @@ pub fn get_client_addr<State>(
)) ))
} }
pub fn check_comment(config: &Config, comment: &CommentForm, errors: &mut Vec<String>) {
if comment.author.len() > config.comment_author_max_len {
errors.push(format!(
"Author name length is {} but maximum is {}.",
comment.author.len(),
config.comment_author_max_len
));
}
if comment.email.len() > config.comment_email_max_len {
errors.push(format!(
"E-mail length is {} but maximum is {}.",
comment.email.len(),
config.comment_email_max_len
));
}
if comment.text.len() > config.comment_text_max_len {
errors.push(format!(
"Comment length is {} but maximum is {}.",
comment.text.len(),
config.comment_text_max_len
));
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View file

@ -1,4 +1,4 @@
use serde::{Deserialize, Serialize}; use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AdminLoginQuery { pub struct AdminLoginQuery {
@ -8,37 +8,35 @@ pub struct AdminLoginQuery {
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AdminEditCommentQuery { pub struct AdminEditCommentQuery {
pub author: String, pub author: String,
pub comment_id: String, pub id: String,
pub email: String, pub email: String,
pub text: String, pub text: String,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AdminRmCommentQuery { pub struct AdminRmCommentQuery {
pub comment_id: String, pub id: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize)]
pub struct NewCommentQuery { pub struct NewCommentQuery {
pub author: String, #[serde(flatten)]
pub email: String, pub comment: CommentForm,
pub text: String,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct EditCommentQuery { pub struct EditCommentQuery {
pub author: String, #[serde(flatten)]
pub comment_id: String, pub comment: CommentForm,
pub email: String, pub id: String,
pub text: String, //pub token: String,
pub token: String,
} }
#[derive(Clone, Debug, Deserialize)] /*#[derive(Clone, Debug, Deserialize)]
pub struct RmCommentQuery { pub struct RmCommentQuery {
pub comment_id: String, pub id: String,
pub token: String, //pub token: String,
} }*/
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
#[serde(tag = "a")] #[serde(tag = "a")]
@ -47,8 +45,8 @@ pub enum CommentQuery {
NewComment(NewCommentQuery), NewComment(NewCommentQuery),
#[serde(rename = "edit_comment")] #[serde(rename = "edit_comment")]
EditComment(EditCommentQuery), EditComment(EditCommentQuery),
#[serde(rename = "rm_comment")] /*#[serde(rename = "rm_comment")]
RmComment(RmCommentQuery), RmComment(RmCommentQuery),*/
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@ -71,3 +69,15 @@ pub struct ApproveQuery {
pub struct RemoveQuery { pub struct RemoveQuery {
pub remove: String, pub remove: String,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct EditQuery {
pub edit: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct CommentForm {
pub author: String,
pub email: String,
pub text: String,
}

View file

@ -23,8 +23,8 @@ pub async fn run_server(config: Arc<Config>, dbs: Dbs, templates: Arc<Templates>
config.clone(), config.clone(),
templates.clone(), templates.clone(),
dbs.clone(), dbs.clone(),
&[],
Context::new(), Context::new(),
200,
) )
} }
}); });
@ -63,8 +63,8 @@ async fn serve_comments<'a>(
config: Arc<Config>, config: Arc<Config>,
templates: Arc<Templates>, templates: Arc<Templates>,
dbs: Dbs, dbs: Dbs,
errors: &[String],
mut context: Context, mut context: Context,
status_code: u16,
) -> tide::Result<tide::Response> { ) -> tide::Result<tide::Response> {
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"))
@ -76,10 +76,8 @@ async fn serve_comments<'a>(
let topic_hash = TopicHash::from_topic(topic); let topic_hash = TopicHash::from_topic(topic);
//let mut context = Context::new();
context.insert("config", &config); context.insert("config", &config);
context.insert("admin", &admin); context.insert("admin", &admin);
context.insert("new_comment_errors", errors);
if admin { if admin {
if let Ok(query) = req.query::<ApproveQuery>() { if let Ok(query) = req.query::<ApproveQuery>() {
@ -91,17 +89,28 @@ async fn serve_comments<'a>(
} }
if let Ok(query) = req.query::<RemoveQuery>() { if let Ok(query) = req.query::<RemoveQuery>() {
if let Ok(comment_id) = CommentId::from_base64(&query.remove) { if let Ok(comment_id) = CommentId::from_base64(&query.remove) {
helpers::remove_pending_comment(comment_id, &dbs) helpers::remove_comment(comment_id, &dbs)
.map_err(|e| error!("Removing comment: {:?}", e)) .map_err(|e| error!("Removing comment: {:?}", e))
.ok(); .ok();
} }
} }
if let Ok(query) = req.query::<EditQuery>() {
if let Ok(comment_id) = CommentId::from_base64(&query.edit) {
if let Some(comment) = dbs.comment.get(&comment_id).unwrap() {
context.insert("edit_comment", &comment_id.to_base64());
context.insert("edit_comment_author", &comment.author);
context.insert("edit_comment_email", &comment.email);
context.insert("edit_comment_text", &comment.text);
}
}
}
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)| CommentWithId {
author: comment.author, author: comment.author,
editable: admin,
id: comment_id.to_base64(), id: comment_id.to_base64(),
needs_approval: true, needs_approval: true,
post_time: comment.post_time, post_time: comment.post_time,
@ -116,6 +125,7 @@ async fn serve_comments<'a>(
&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 {
author: comment.author, author: comment.author,
editable: admin,
id: comment_id.to_base64(), id: comment_id.to_base64(),
needs_approval: false, needs_approval: false,
post_time: comment.post_time, post_time: comment.post_time,
@ -124,12 +134,10 @@ async fn serve_comments<'a>(
.collect::<Vec<CommentWithId>>(), .collect::<Vec<CommentWithId>>(),
); );
Ok( Ok(tide::Response::builder(status_code)
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>(
@ -161,6 +169,7 @@ async fn serve_admin<'a>(
})?; })?;
Some(CommentWithId { Some(CommentWithId {
author: comment.author, author: comment.author,
editable: true,
id: comment_id.to_base64(), id: comment_id.to_base64(),
needs_approval: true, needs_approval: true,
post_time: comment.post_time, post_time: comment.post_time,
@ -197,7 +206,11 @@ 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 { let admin = req.cookie("admin").map_or(false, |psw| {
check_admin_password_hash(&config, &String::from(psw.value()))
});
let client_addr = if !admin && config.antispam_enable {
match helpers::get_client_addr(&config, &req) { match helpers::get_client_addr(&config, &req) {
Some(Ok(addr)) => { Some(Ok(addr)) => {
if config.antispam_whitelist.contains(&addr) { if config.antispam_whitelist.contains(&addr) {
@ -228,27 +241,8 @@ async fn handle_post_comments(
return Err(tide::Error::from_str(404, "No topic")) return Err(tide::Error::from_str(404, "No topic"))
}; };
if query.author.len() > config.comment_author_max_len { helpers::check_comment(&config, &query.comment, &mut errors);
errors.push(format!(
"Author name length is {} but maximum is {}.",
query.author.len(),
config.comment_author_max_len
));
}
if query.email.len() > config.comment_email_max_len {
errors.push(format!(
"E-mail length is {} but maximum is {}.",
query.email.len(),
config.comment_email_max_len
));
}
if query.text.len() > config.comment_text_max_len {
errors.push(format!(
"Comment length is {} but maximum is {}.",
query.text.len(),
config.comment_text_max_len
));
}
if let Some(client_addr) = &client_addr { if let Some(client_addr) = &client_addr {
if let Some(antispam_timeout) = if let Some(antispam_timeout) =
helpers::antispam_check_client_mutation(client_addr, &dbs, &config).unwrap() helpers::antispam_check_client_mutation(client_addr, &dbs, &config).unwrap()
@ -274,29 +268,95 @@ async fn handle_post_comments(
let comment = Comment { let comment = Comment {
topic_hash, topic_hash,
author: query.author, author: query.comment.author,
email: if query.email.is_empty() { email: if query.comment.email.is_empty() {
None None
} else { } else {
Some(query.email) Some(query.comment.email)
}, },
last_edit_time: None, last_edit_time: None,
post_time: time, post_time: time,
text: query.text, text: query.comment.text,
}; };
helpers::new_pending_comment(&comment, &dbs) helpers::new_pending_comment(&comment, &dbs)
.map_err(|e| error!("Adding pending comment: {:?}", e)) .map_err(|e| error!("Adding pending comment: {:?}", e))
.ok(); .ok();
notify_send.send(()).ok(); notify_send.send(()).ok();
} else { } else {
context.insert("new_comment_author", &query.author); context.insert("new_comment_author", &query.comment.author);
context.insert("new_comment_email", &query.email); context.insert("new_comment_email", &query.comment.email);
context.insert("new_comment_text", &query.text); context.insert("new_comment_text", &query.comment.text);
}
context.insert("new_comment_errors", &errors);
}
CommentQuery::EditComment(query) => {
if !admin {
return Err(tide::Error::from_str(403, "Forbidden"));
}
helpers::check_comment(&config, &query.comment, &mut errors);
let comment_id = if let Ok(comment_id) = CommentId::from_base64(&query.id) {
comment_id
} else {
return Err(tide::Error::from_str(400, "Invalid comment id"));
};
let mut comment = if let Some(comment) = dbs.comment.get(&comment_id).unwrap() {
comment
} else {
return Err(tide::Error::from_str(404, "Not found"));
};
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();
} }
serve_comments(req, config, templates, dbs, &errors, context).await
let time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
comment.author = query.comment.author;
comment.email = if query.comment.email.is_empty() {
None
} else {
Some(query.comment.email)
};
comment.text = query.comment.text;
comment.last_edit_time = Some(time);
dbs.comment.insert(&comment_id, &comment).unwrap();
} else {
context.insert("edit_comment", &comment_id.to_base64());
context.insert("edit_comment_author", &query.comment.author);
context.insert("edit_comment_email", &query.comment.email);
context.insert("edit_comment_text", &query.comment.text);
}
context.insert("edit_comment_errors", &errors);
}
}
serve_comments(
req,
config,
templates,
dbs,
context,
if errors.is_empty() { 200 } else { 400 },
)
.await
} }
async fn handle_post_admin( async fn handle_post_admin(
@ -307,6 +367,7 @@ async fn handle_post_admin(
) -> tide::Result<tide::Response> { ) -> tide::Result<tide::Response> {
if let Some(psw) = req.cookie("admin") { if let Some(psw) = req.cookie("admin") {
if check_admin_password(&config, &String::from(psw.value())).is_some() { if check_admin_password(&config, &String::from(psw.value())).is_some() {
#[allow(clippy::match_single_binding)]
match req.body_form::<AdminQuery>().await? { match req.body_form::<AdminQuery>().await? {
_ => serve_admin(req, config, templates, dbs).await, _ => serve_admin(req, config, templates, dbs).await,
} }

View file

@ -38,6 +38,7 @@ impl Templates {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct CommentWithId { pub struct CommentWithId {
pub author: String, pub author: String,
pub editable: bool,
pub id: String, pub id: String,
pub needs_approval: bool, pub needs_approval: bool,
pub post_time: Time, pub post_time: Time,

View file

@ -8,14 +8,17 @@
{% if comments_pending %} {% if comments_pending %}
<div id="comments_pending"> <div id="comments_pending">
{% for comment in comments_pending %} {% for comment in comments_pending %}
<div class="comment{% if comment.needs_approval %} comment_pending{% endif %}" id="comment-{{ comment.id }}"> <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>
<span class="comment-date">{{ comment.post_time | date(format="%F %R", locale=config.lang) }}</span> <span class="comment-date">{{ comment.post_time | date(format="%F %R", locale=config.lang) }}</span>
{% if comment.editable %}
<a href="?edit={{ comment.id | safe }}#edit_comment-form">Edit</a>
{% endif %}
{% if admin and comment.needs_approval %} {% if admin and comment.needs_approval %}
<a href="?approve={{ comment.id }}">Approve</a> <a href="?approve={{ comment.id | safe }}">Approve</a>
{% endif %} {% endif %}
{% if admin %} {% if admin %}
<a href="?remove={{ comment.id }}">Remove</a> <a href="?remove={{ comment.id | safe }}">Remove</a>
{% endif %} {% endif %}
<p class="comment-text">{{ comment.text }}</p> <p class="comment-text">{{ comment.text }}</p>
</div> </div>
@ -24,14 +27,17 @@
{% endif %} {% endif %}
<div id="comments"> <div id="comments">
{% for comment in comments %} {% for comment in comments %}
<div class="comment{% if comment.needs_approval %} comment_pending{% endif %}" id="comment-{{ comment.id }}"> <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>
<span class="comment-date">{{ comment.post_time | date(format="%F %R", locale=config.lang) }}</span> <span class="comment-date">{{ comment.post_time | date(format="%F %R", locale=config.lang) }}</span>
{% if comment.editable %}
<a href="?edit={{ comment.id | safe }}#edit_comment-form">Edit</a>
{% endif %}
{% if admin and comment.needs_approval %} {% if admin and comment.needs_approval %}
<a href="?approve={{ comment.id }}">Approve</a> <a href="?approve={{ comment.id | safe }}">Approve</a>
{% endif %} {% endif %}
{% if admin %} {% if admin %}
<a href="?remove={{ comment.id }}">Remove</a> <a href="?remove={{ comment.id | safe }}">Remove</a>
{% endif %} {% endif %}
<p class="comment-text">{{ comment.text }}</p> <p class="comment-text">{{ comment.text }}</p>
</div> </div>
@ -47,12 +53,32 @@
</ul> </ul>
{% endif %} {% endif %}
<label for="new_comment-author">Your name:</label> <label for="new_comment-author">Your name:</label>
<input type="text" id="new_comment-author" name="author" maxlength="{{ config.comment_author_max_len }}"{% if new_comment_author %} value="{{ new_comment_author }}"{% endif %}/><br/> <input type="text" id="new_comment-author" name="author" maxlength="{{ config.comment_author_max_len | safe }}"{% if new_comment_author %} value="{{ new_comment_author }}"{% endif %}/><br/>
<label for="new_comment-email">Your e-mail:</label> <label for="new_comment-email">Your e-mail:</label>
<input type="email" id="new_comment-email" name="email" maxlength="{{ config.comment_email_max_len }}"{% if new_comment_email %} value="{{ new_comment_email }}"{% endif %}/><br/> <input type="email" id="new_comment-email" name="email" maxlength="{{ config.comment_email_max_len | safe }}"{% if new_comment_email %} value="{{ new_comment_email }}"{% endif %}/><br/>
<label for="new_comment-text">Your comment:</label><br/> <label for="new_comment-text">Your comment:</label><br/>
<textarea id="new_comment-text" name="text" maxlength="{{ config.comment_text_max_len }}">{% if new_comment_text %}{{ new_comment_text }}{% endif %}</textarea><br/> <textarea id="new_comment-text" name="text" maxlength="{{ config.comment_text_max_len | safe }}">{% if new_comment_text %}{{ new_comment_text }}{% endif %}</textarea><br/>
<button type="submit" name="a" value="new_comment">Post comment</button> <button type="submit" name="a" value="new_comment">Post comment</button>
</form> </form>
{% if edit_comment %}
<form id="edit_comment-form" action="#edit_comment-form" method="post">
{% if edit_comment_errors %}
<p>Whoops, the following error occurred:</p>
<ul id="edit_comment-errors" class="errors">
{% for error in edit_comment_errors %}
<li class="error">{{ error | safe }}</li>
{% endfor %}
</ul>
{% endif %}
<input type="hidden" name="id" value="{{ edit_comment | safe }}" autocomplete="off"/>
<label for="edit_comment-author">Your name:</label>
<input type="text" id="edit_comment-author" name="author" maxlength="{{ config.comment_author_max_len | safe }}"{% if edit_comment_author %} value="{{ edit_comment_author }}"{% endif %}/><br/>
<label for="edit_comment-email">Your e-mail:</label>
<input type="email" id="edit_comment-email" name="email" maxlength="{{ config.comment_email_max_len | safe }}"{% if edit_comment_email %} value="{{ edit_comment_email }}"{% endif %}/><br/>
<label for="edit_comment-text">Your comment:</label><br/>
<textarea id="edit_comment-text" name="text" maxlength="{{ config.comment_text_max_len | safe }}">{% if edit_comment_text %}{{ edit_comment_text }}{% endif %}</textarea><br/>
<button type="submit" name="a" value="edit_comment">Edit comment</button>
</form>
{% endif %}
</body> </body>
</html> </html>