diff --git a/Cargo.lock b/Cargo.lock index 3f82443..b697fa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2411,18 +2411,18 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53f64bb4ba0191d6d0676e1b141ca55047d83b74f5607e6d8eb88126c52c2dc" +checksum = "256b9932320c590e707b94576e3cc1f7c9024d0ee6612dfbcf1cb106cbe8e055" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55492425aa53521babf6137309e7d34c20bbfbbfcfe2c7f3a047fd1f6b92c0c" +checksum = "b4eae9b04cbffdfd550eb462ed33bc6a1b68c935127d008b27444d08380f94e4" dependencies = [ "proc-macro2", "quote", @@ -2860,9 +2860,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76ce4a75fb488c605c54bf610f221cea8b0dafb53333c1a67e8ee199dcd2ae3" +checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46" dependencies = [ "autocfg", "bytes", @@ -2873,7 +2873,7 @@ dependencies = [ "pin-project-lite 0.2.9", "socket2", "tokio-macros", - "winapi", + "windows-sys", ] [[package]] @@ -3011,9 +3011,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" [[package]] name = "ucd-trie" diff --git a/Cargo.toml b/Cargo.toml index 0156190..64ea791 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,12 +23,12 @@ petname = { version = "1.1.3", optional = true, default-features = false, featur rand = "0.8.5" rand_core = { version = "0.6.4", features = ["std"] } rpassword = "7.2.0" -serde = { version = "1.0.148", features = ["derive", "rc"] } +serde = { version = "1.0.149", features = ["derive", "rc"] } sha2 = "0.10.6" sled = "0.34.7" tera = { version = "1.17.1", features = ["builtins", "date-locale"] } tide = { version = "0.16.0", default-features = false, features = ["h1-server", "cookies", "logger"] } -tokio = { version = "1.22.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.23.0", features = ["macros", "rt-multi-thread"] } toml_edit = { version = "0.15.0", features = ["easy"] } typed-sled = "0.2.0" unic-langid = { version = "0.9.1", features = ["macros"] } diff --git a/locales/en.ftl b/locales/en.ftl index 0cf2300..8ce2d08 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -18,4 +18,6 @@ comment_form-email = Your email: comment_form-edit_button = Edit comment comment_form-new_button = Post comment comment_form-text = Your comment: +new_comment-success = Your comment has been published. You can edit it with this link. +new_comment-success_pending = Your comment has been saved. It will be reviewed by an administrator before being published. You can edit it with this link. title = Comments diff --git a/src/db.rs b/src/db.rs index b76a626..544137e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -36,12 +36,41 @@ pub fn load_dbs(path: Option<&Path>) -> Dbs { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Comment { - pub topic_hash: TopicHash, pub author: String, pub email: Option, pub last_edit_time: Option, + pub mutation_token: MutationToken, pub post_time: u64, pub text: String, + pub topic_hash: TopicHash, +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct MutationToken(pub [u8; 16]); + +impl MutationToken { + pub fn new() -> Self { + Self(rand::random()) + } + + pub fn to_base64(&self) -> String { + base64::encode_config(self.0, base64::URL_SAFE_NO_PAD) + } + + pub fn from_base64(s: &str) -> Result { + std::panic::catch_unwind(|| { + let mut buf = [0; 16]; + base64::decode_config_slice(s, base64::URL_SAFE_NO_PAD, &mut buf)?; + Ok(Self(buf)) + }) + .map_err(|_| base64::DecodeError::InvalidLength)? + } +} + +impl AsRef<[u8]> for MutationToken { + fn as_ref(&self) -> &[u8] { + &self.0 + } } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] @@ -82,12 +111,12 @@ impl CommentId { } pub fn from_base64(s: &str) -> Result { - let mut buf = [0; 16]; - std::panic::catch_unwind(move || { - base64::decode_config_slice(s, base64::URL_SAFE_NO_PAD, &mut buf) + std::panic::catch_unwind(|| { + let mut buf = [0; 16]; + base64::decode_config_slice(s, base64::URL_SAFE_NO_PAD, &mut buf)?; + Ok(Self(buf)) }) .map_err(|_| base64::DecodeError::InvalidLength)? - .map(|_| Self(buf)) } } @@ -112,6 +141,17 @@ mod test { assert_eq!(iter.next(), Some(Ok(((123, 456), ())))); } + #[test] + fn test_comment_id_base64() { + for _ in 0..10 { + let comment_id = CommentId::new(); + assert_eq!( + CommentId::from_base64(&comment_id.to_base64()), + Ok(comment_id) + ); + } + } + #[test] fn test_from_base64_dont_panic() { assert_eq!(CommentId::from_base64("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), Err(base64::DecodeError::InvalidLength)); diff --git a/src/helpers.rs b/src/helpers.rs index 93c09bc..7ccd231 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -217,6 +217,31 @@ pub fn check_comment( } } +pub fn check_can_edit_comment<'a>( + config: &Config, + comment: &Comment, + mutation_token: &MutationToken, +) -> Result<(), &'a str> { + if &comment.mutation_token != mutation_token { + return Err("bad mutation token"); + } + + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + if time + > comment + .post_time + .saturating_add(config.comment_edit_timeout) + { + return Err("mutation timeout expired"); + } + + Ok(()) +} + #[cfg(test)] mod test { use super::*; @@ -228,12 +253,13 @@ mod test { author: String::from("Emmanuel Goldstein"), email: None, last_edit_time: None, + mutation_token: MutationToken::new(), post_time: 42, text: String::from("Hello world!"), }; let dbs = load_dbs(None); - let comment_id = new_pending_comment(&comment, &dbs).unwrap(); + let comment_id = new_pending_comment(&comment, None, &dbs).unwrap(); let mut iter = dbs.comment.iter(); assert_eq!(iter.next(), Some(Ok((comment_id.clone(), comment.clone())))); @@ -248,7 +274,7 @@ mod test { comment.post_time, comment_id.clone() ), - () + None ))) ); assert_eq!(iter.next(), None); diff --git a/src/locales.rs b/src/locales.rs index a23738d..757d3bf 100644 --- a/src/locales.rs +++ b/src/locales.rs @@ -28,6 +28,8 @@ impl Locales { .iter() .map(|(lang, raw)| { let mut bundle = FluentBundle::new_concurrent(vec![lang.clone()]); + // We don't want dangerous zero-width bidi chars everywhere! (issue #26) + bundle.set_use_isolating(false); bundle .add_resource( FluentResource::try_new(raw.to_string()).unwrap_or_else(|e| { @@ -68,7 +70,6 @@ impl Locales { NegotiationStrategy::Filtering, ) { if let Some(bundle) = self.bundles.get(prefered_lang.as_ref()) { - println!("got bundle"); if let Some(message) = bundle.get_message(key) { let mut errors = Vec::new(); let ret = bundle.format_pattern(message.value().unwrap(), args, &mut errors); diff --git a/src/server.rs b/src/server.rs index ab0880f..b9acac4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -39,6 +39,43 @@ pub async fn run_server( } }); app.at(&format!("{}t/:topic", config.root_url)).post({ + let dbs = dbs.clone(); + let notify_send = notify_send.clone(); + move |req: tide::Request<()>| { + handle_post_comments( + req, + config, + templates, + dbs.clone(), + locales, + notify_send.clone(), + ) + } + }); + app.at(&format!( + "{}t/:topic/edit/:comment_id/:mutation_token", + config.root_url + )) + .get({ + let dbs = dbs.clone(); + move |req: tide::Request<()>| { + let client_langs = get_client_langs(&req); + serve_edit_comment( + req, + config, + templates, + dbs.clone(), + client_langs, + Context::new(), + 200, + ) + } + }); + app.at(&format!( + "{}t/:topic/edit/:comment_id/:mutation_token", + config.root_url + )) + .post({ let dbs = dbs.clone(); move |req: tide::Request<()>| { handle_post_comments( @@ -63,6 +100,53 @@ pub async fn run_server( app.listen(config.listen).await.unwrap(); } +async fn serve_edit_comment<'a>( + req: tide::Request<()>, + config: &Config, + templates: &Templates, + dbs: Dbs, + client_langs: Vec, + mut context: Context, + status_code: u16, +) -> tide::Result { + let (Ok(comment_id_str), Ok(mutation_token_str)) = (req.param("comment_id"), req.param("mutation_token")) else { + context.insert("log", &["no comment id or no token"]); + return serve_comments(req, config, templates, dbs, client_langs, context, 400).await; + }; + + let (Ok(comment_id), Ok(mutation_token)) = (CommentId::from_base64(comment_id_str), MutationToken::from_base64(mutation_token_str)) else { + context.insert("log", &["badly encoded comment id or token"]); + return serve_comments(req, config, templates, dbs, client_langs, context, 400).await; + }; + + let Some(comment) = dbs.comment.get(&comment_id).unwrap() else { + context.insert("log", &["not found comment"]); + return serve_comments(req, config, templates, dbs, client_langs, context, 404).await; + }; + + if let Err(e) = helpers::check_can_edit_comment(config, &comment, &mutation_token) { + context.insert("log", &[e]); + return serve_comments(req, config, templates, dbs, client_langs, context, 403).await; + } + + context.insert("edit_comment", &comment_id.to_base64()); + context.insert("edit_comment_mutation_token", &mutation_token.to_base64()); + context.insert("edit_comment_author", &comment.author); + context.insert("edit_comment_email", &comment.email); + context.insert("edit_comment_text", &comment.text); + + serve_comments( + req, + config, + templates, + dbs, + client_langs, + context, + status_code, + ) + .await +} + async fn serve_comments<'a>( req: tide::Request<()>, config: &Config, @@ -337,17 +421,43 @@ async fn handle_post_comments( Some(query.comment.email) }, last_edit_time: None, + mutation_token: MutationToken::new(), post_time: time, text: query.comment.text, }; - helpers::new_pending_comment(&comment, client_addr, &dbs) - .map_err(|e| error!("Adding pending comment: {:?}", e)) - .ok(); - notify_send - .send(Notification { - topic: topic.to_string(), - }) - .ok(); + match helpers::new_pending_comment(&comment, client_addr, &dbs) { + Ok(comment_id) => { + notify_send + .send(Notification { + topic: topic.to_string(), + }) + .ok(); + context.insert( + "log", + &[locales + .tr( + &client_langs, + if config.comment_approve { + "new_comment-success_pending" + } else { + "new_comment-success" + }, + Some(&FluentArgs::from_iter([( + "edit_link", + format!( + "{}t/{}/edit/{}/{}", + &config.root_url, + topic, + comment_id.to_base64(), + comment.mutation_token.to_base64(), + ), + )])), + ) + .unwrap()], + ); + } + Err(e) => error!("Adding pending comment: {:?}", e), + } } else { context.insert("new_comment_author", &query.comment.author); context.insert("new_comment_email", &query.comment.email); @@ -356,51 +466,69 @@ async fn handle_post_comments( context.insert("new_comment_errors", &errors); } CommentQuery::EditComment(query) => { - if !admin { - return Err(tide::Error::from_str(403, "Forbidden")); - } - helpers::check_comment(config, locales, &client_langs, &query.comment, &mut errors); - let comment_id = if let Ok(comment_id) = CommentId::from_base64(&query.id) { - comment_id - } else { + let Ok(comment_id) = CommentId::from_base64(&query.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 { + let Some(mut comment) = dbs.comment.get(&comment_id).unwrap() else { return Err(tide::Error::from_str(404, "Not found")); }; - // We're admin - /*if let Some(client_addr) = &client_addr { - if let Some(antispam_timeout) = - helpers::antispam_check_client_mutation(client_addr, &dbs, config).unwrap() - { - let client_langs = get_client_langs(&req); - errors.push( - locales - .tr( - &client_langs, - "error-antispam", - Some(&FluentArgs::from_iter([( - "antispam_timeout", - antispam_timeout, - )])), - ) - .unwrap() - .into_owned(), - ); + let mutation_token = if admin { + None + } else { + 'mutation_token: { + let Ok(mutation_token_str) = req.param("mutation_token") else { + errors.push("no mutation token".into()); + break 'mutation_token None; + }; + + let Ok(mutation_token) = MutationToken::from_base64(mutation_token_str) else { + errors.push("badly encoded token".into()); + break 'mutation_token None; + }; + + if let Err(e) = + helpers::check_can_edit_comment(config, &comment, &mutation_token) + { + errors.push(e.to_string()); + } + + Some(mutation_token) } - }*/ + }; + + if !admin { + if let Some(client_addr) = &client_addr { + if let Some(antispam_timeout) = + helpers::antispam_check_client_mutation(client_addr, &dbs, config).unwrap() + { + let client_langs = get_client_langs(&req); + errors.push( + locales + .tr( + &client_langs, + "error-antispam", + Some(&FluentArgs::from_iter([( + "antispam_timeout", + antispam_timeout, + )])), + ) + .unwrap() + .into_owned(), + ); + } + } + } if errors.is_empty() { - // We're admin - /*if let Some(client_addr) = &client_addr { - helpers::antispam_update_client_mutation(client_addr, &dbs).unwrap(); - }*/ + if !admin { + if let Some(client_addr) = &client_addr { + helpers::antispam_update_client_mutation(client_addr, &dbs).unwrap(); + } + } let time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -423,9 +551,16 @@ async fn handle_post_comments( dbs.comment.insert(&comment_id, &comment).unwrap(); } else { context.insert("edit_comment", &comment_id.to_base64()); + if let Some(mutation_token) = &mutation_token { + context.insert("edit_comment_mutation_token", &mutation_token.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); + + return serve_edit_comment(req, config, templates, dbs, client_langs, context, 400) + .await; } context.insert("edit_comment_errors", &errors); } diff --git a/templates/comments.html b/templates/comments.html index 3960fa5..4740e56 100644 --- a/templates/comments.html +++ b/templates/comments.html @@ -5,6 +5,14 @@ {{ tr(l=l,k="title")|safe }} + {% if log %} +
    + {% for log_msg in log %} +
  • {{ log_msg | safe }}
  • + {% endfor %} +
+ {% endif %} + {% if comments_pending %}
{% for comment in comments_pending %} @@ -74,6 +82,9 @@ {% endif %} + {% if edit_comment_mutation_token %} + + {% endif %}