#![allow(clippy::too_many_arguments)] use crate::{ config::*, db::*, helpers, locales::*, notify::Notification, queries::*, templates::*, }; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use crossbeam_channel::Sender; use fluent_bundle::FluentArgs; use log::{error, warn}; use tera::Context; use unic_langid::LanguageIdentifier; pub async fn run_server( config: &'static Config, dbs: Dbs, templates: &'static Templates, locales: &'static Locales, ) { tide::log::start(); let (notify_send, notify_recv) = crossbeam_channel::bounded(10); tokio::spawn(crate::notify::run_notifier(config, notify_recv)); let mut app = tide::new(); app.at(&format!("{}t/:topic", config.root_url)).get({ let dbs = dbs.clone(); move |req: tide::Request<()>| { let client_langs = get_client_langs(&req); serve_comments( req, config, templates, dbs.clone(), client_langs, Context::new(), 200, ) } }); 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( req, config, templates, dbs.clone(), locales, notify_send.clone(), ) } }); app.at(&format!("{}admin", config.root_url)) .get(move |req: tide::Request<()>| { let client_langs = get_client_langs(&req); serve_admin_login(req, config, templates, client_langs) }); app.at(&format!("{}admin", config.root_url)).post({ let dbs = dbs.clone(); move |req: tide::Request<()>| handle_post_admin(req, config, templates, dbs.clone()) }); 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, _edited_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, templates: &Templates, dbs: Dbs, client_langs: Vec, mut context: Context, status_code: u16, ) -> tide::Result { let Ok(topic) = req.param("topic") else { return Err(tide::Error::from_str(404, "No topic")) }; let admin = req.cookie("admin").map_or(false, |psw| { check_admin_password_hash(config, &String::from(psw.value())) }); let topic_hash = TopicHash::from_topic(topic); context.insert("config", &config); context.insert("admin", &admin); let time_lang = get_time_lang(&client_langs); context.insert( "time_lang", time_lang.as_ref().unwrap_or(&config.default_lang), ); context.insert( "l", &client_langs .iter() .map(|lang| lang.language.as_str()) .collect::>(), ); if admin { if let Ok(query) = req.query::() { if let Ok(comment_id) = CommentId::from_base64(&query.approve) { helpers::approve_comment(comment_id, &dbs) .map_err(|e| error!("Approving comment: {:?}", e)) .ok(); } } if let Ok(query) = req.query::() { if let Ok(comment_id) = CommentId::from_base64(&query.approve_edit) { helpers::approve_edit(comment_id, &dbs) .map_err(|e| error!("Approving edit: {:?}", e)) .ok(); } } if let Ok(query) = req.query::() { if let Ok(comment_id) = CommentId::from_base64(&query.remove) { helpers::remove_comment(comment_id, &dbs) .map_err(|e| error!("Removing comment: {:?}", e)) .ok(); } } if let Ok(query) = req.query::() { if let Ok(comment_id) = CommentId::from_base64(&query.remove_edit) { helpers::remove_edit(comment_id, &dbs) .map_err(|e| error!("Removing edit: {:?}", e)) .ok(); } } if let Ok(query) = req.query::() { if let Ok(comment_id) = CommentId::from_base64(&query.edit) { if let Some((comment, _comment_status)) = 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( "comments_pending", &helpers::iter_pending_comments_by_topic(topic_hash.clone(), &dbs) .map(|(comment_id, comment, addr, comment_status)| { if let CommentStatus::ApprovedEdited(edited_comment) = comment_status { CommentWithId { addr: addr.map(|addr| addr.to_string()), author: edited_comment.author, editable: true, id: comment_id.to_base64(), last_edit_time: edited_comment.last_edit_time, needs_approval: true, original: Some(OriginalComment { author: comment.author, editable: true, last_edit_time: comment.last_edit_time, post_time: comment.post_time, text: comment.text, }), post_time: edited_comment.post_time, text: edited_comment.text, } } else { CommentWithId { addr: addr.map(|addr| addr.to_string()), author: comment.author, editable: true, id: comment_id.to_base64(), last_edit_time: comment.last_edit_time, needs_approval: true, original: None, post_time: comment.post_time, text: comment.text, } } }) .collect::>(), ); } context.insert( "comments", &helpers::iter_approved_comments_by_topic(topic_hash, &dbs) .map(|(comment_id, comment, _comment_status)| CommentWithId { addr: None, author: comment.author, editable: admin, id: comment_id.to_base64(), last_edit_time: comment.last_edit_time, needs_approval: false, original: None, post_time: comment.post_time, text: comment.text, }) .collect::>(), ); Ok(tide::Response::builder(status_code) .content_type(tide::http::mime::HTML) .body(templates.tera.render("comments.html", &context)?) .build()) } async fn serve_admin<'a>( _req: tide::Request<()>, config: &Config, templates: &Templates, dbs: Dbs, client_langs: &[LanguageIdentifier], ) -> tide::Result { let mut context = Context::new(); context.insert("config", &config); context.insert("admin", &true); let time_lang = get_time_lang(client_langs); context.insert( "time_lang", time_lang.as_ref().unwrap_or(&config.default_lang), ); context.insert( "l", &client_langs .iter() .map(|lang| lang.language.as_str()) .collect::>(), ); context.insert( "comments", &dbs.comment_pending .iter() .filter_map(|entry| { let ((_topic_hash, _time, comment_id), (addr, _is_edit)) = entry .map_err(|e| error!("Reading comment_pending: {:?}", e)) .ok()?; let (comment, comment_status) = dbs .comment .get(&comment_id) .map_err(|e| error!("Reading comment: {:?}", e)) .ok()? .or_else(|| { error!("Comment not found"); None })?; if let CommentStatus::ApprovedEdited(edited_comment) = comment_status { Some(CommentWithId { addr: addr.map(|addr| addr.to_string()), author: edited_comment.author, editable: true, id: comment_id.to_base64(), last_edit_time: edited_comment.last_edit_time, needs_approval: true, original: Some(OriginalComment { author: comment.author, editable: true, last_edit_time: comment.last_edit_time, post_time: comment.post_time, text: comment.text, }), post_time: edited_comment.post_time, text: edited_comment.text, }) } else { Some(CommentWithId { addr: addr.map(|addr| addr.to_string()), author: comment.author, editable: true, id: comment_id.to_base64(), last_edit_time: comment.last_edit_time, needs_approval: true, original: None, post_time: comment.post_time, text: comment.text, }) } }) .collect::>(), ); Ok(tide::Response::builder(200) .content_type(tide::http::mime::HTML) .body(templates.tera.render("comments.html", &context)?) .build()) } async fn serve_admin_login( _req: tide::Request<()>, config: &Config, templates: &Templates, client_langs: Vec, ) -> tide::Result { let mut context = Context::new(); context.insert("config", &config); let time_lang = get_time_lang(&client_langs); context.insert( "time_lang", time_lang.as_ref().unwrap_or(&config.default_lang), ); context.insert( "l", &client_langs .iter() .map(|lang| lang.language.as_str()) .collect::>(), ); Ok(tide::Response::builder(200) .content_type(tide::http::mime::HTML) .body(templates.tera.render("admin_login.html", &context)?) .build()) } async fn handle_post_comments( mut req: tide::Request<()>, config: &Config, templates: &Templates, dbs: Dbs, locales: &Locales, notify_send: Sender, ) -> tide::Result { let admin = req.cookie("admin").map_or(false, |psw| { check_admin_password_hash(config, &String::from(psw.value())) }); let client_langs = get_client_langs(&req); let client_addr = match helpers::get_client_addr(config, &req) { Some(Ok(addr)) => Some(addr), Some(Err(e)) => { warn!("Unable to parse client addr: {}", e); None } None => { warn!("No client addr"); 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 context = Context::new(); match req.body_form::().await? { CommentQuery::NewComment(query) => { let Ok(topic) = req.param("topic") else { return Err(tide::Error::from_str(404, "No topic")) }; helpers::check_comment(config, locales, &client_langs, &query.comment, &mut errors); if let Some(client_addr) = &client_addr { if antispam_enabled { if let Some(antispam_timeout) = helpers::antispam_check_client_mutation(client_addr, &dbs, config).unwrap() { errors.push( locales .tr( &client_langs, "error-antispam", Some(&FluentArgs::from_iter([( "antispam_timeout", antispam_timeout, )])), ) .unwrap() .into_owned(), ); } } } if errors.is_empty() { if let Some(client_addr) = &client_addr { if antispam_enabled { helpers::antispam_update_client_mutation(client_addr, &dbs).unwrap(); } } let topic_hash = TopicHash::from_topic(topic); let time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); let comment = Comment { topic_hash, author: if query.comment.author.is_empty() { petname::Petnames::large().generate_one(2, " ") } else { query.comment.author }, email: if query.comment.email.is_empty() { None } else { Some(query.comment.email) }, last_edit_time: None, mutation_token: MutationToken::new(), post_time: time, text: query.comment.text, }; 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()], ); } // TODO add message to client log and change http code Err(e) => error!("Adding pending comment: {:?}", e), } } else { context.insert("new_comment_author", &query.comment.author); context.insert("new_comment_email", &query.comment.email); context.insert("new_comment_text", &query.comment.text); } context.insert("new_comment_errors", &errors); } CommentQuery::EditComment(query) => { let Ok(topic) = req.param("topic") else { return Err(tide::Error::from_str(404, "No topic")) }; let Ok(comment_id) = CommentId::from_base64(&query.id) else { return Err(tide::Error::from_str(400, "Invalid comment id")); }; let Some((old_comment, old_edited_comment)) = dbs.comment.get(&comment_id).unwrap() else { return Err(tide::Error::from_str(404, "Not found")); }; helpers::check_comment(config, locales, &client_langs, &query.comment, &mut errors); 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, &old_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() { 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) .unwrap() .as_secs(); let mut comment = old_comment.clone(); comment.author = if query.comment.author.is_empty() { petname::Petnames::large().generate_one(2, " ") } else { 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); match helpers::edit_comment( comment_id.clone(), old_comment, old_edited_comment, comment.clone(), client_addr, &dbs, ) { Ok(()) => { context.insert( "log", &[locales .tr( &client_langs, if config.comment_approve { "edit_comment-success_pending" } else { "edit_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()], ); } // TODO add message to client log and change http code Err(e) => error!("Editing comment: {:?}", e), } } 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); } } serve_comments( req, config, templates, dbs, client_langs, context, if errors.is_empty() { 200 } else { 400 }, ) .await } async fn handle_post_admin( mut req: tide::Request<()>, config: &Config, templates: &Templates, dbs: Dbs, ) -> tide::Result { if let Some(psw) = req.cookie("admin") { if check_admin_password(config, &String::from(psw.value())).is_some() { #[allow(clippy::match_single_binding)] match req.body_form::().await? { _ => { let client_langs = get_client_langs(&req); serve_admin(req, config, templates, dbs, &client_langs).await } } } else { let client_langs = get_client_langs(&req); serve_admin_login(req, config, templates, client_langs).await } } else if let AdminQuery::Login(query) = req.body_form::().await? { if let Some(password_hash) = check_admin_password(config, &query.psw) { let client_langs = get_client_langs(&req); serve_admin(req, config, templates, dbs, &client_langs) .await .map(|mut r| { let mut cookie = tide::http::Cookie::new("admin", password_hash); cookie.set_http_only(Some(true)); cookie.set_path(config.root_url.clone()); if let Some(domain) = &config.cookies_domain { cookie.set_domain(domain.clone()); } if config.cookies_https_only { cookie.set_secure(Some(true)); } r.insert_cookie(cookie); r }) } else { let client_langs = get_client_langs(&req); serve_admin_login(req, config, templates, client_langs).await } } else { let client_langs = get_client_langs(&req); serve_admin_login(req, config, templates, client_langs).await } } fn check_admin_password(config: &Config, password: &str) -> Option { let argon2 = Argon2::default(); config .admin_passwords .iter() .filter_map(|admin_password| PasswordHash::new(admin_password).ok()) .find(|admin_password| { argon2 .verify_password(password.as_bytes(), admin_password) .is_ok() }) .map(|password_hash| password_hash.to_string()) } fn check_admin_password_hash(config: &Config, password_hash: &str) -> bool { config.admin_passwords.iter().any(|h| h == password_hash) }