wip
This commit is contained in:
parent
5deba2fdb1
commit
9fe5d3c70e
45 changed files with 5311 additions and 815 deletions
3523
server/Cargo.lock
generated
Normal file
3523
server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
41
server/Cargo.toml
Normal file
41
server/Cargo.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
[package]
|
||||
name = "webcomment-server"
|
||||
version = "0.1.0"
|
||||
authors = ["tuxmain <tuxmain@zettascript.org>"]
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://git.txmn.tk/tuxmain/webcomment"
|
||||
description = "Templatable comment web server"
|
||||
edition = "2021"
|
||||
default-run = "webcomment-server"
|
||||
|
||||
[dependencies]
|
||||
webcomment-common = { path = "../common" }
|
||||
|
||||
argon2 = "0.5.0"
|
||||
base64 = "0.21.0"
|
||||
clap = { version = "4.1.8", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
|
||||
crossbeam-channel = "0.5.7"
|
||||
directories = "4.0.1"
|
||||
fluent-bundle = "0.15.2"
|
||||
fluent-langneg = "0.13.0"
|
||||
intl-memoizer = "0.5.1"
|
||||
log = "0.4.17"
|
||||
matrix-sdk = { version = "0.6.2", default-features = false, features = ["rustls-tls"] }
|
||||
percent-encoding = "2.2.0"
|
||||
petname = { version = "1.1.3", optional = true, default-features = false, features = ["std_rng", "default_dictionary"] }
|
||||
rand = "0.8.5"
|
||||
rand_core = { version = "0.6.4", features = ["std"] }
|
||||
rpassword = "7.2.0"
|
||||
serde = { version = "1.0.154", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.94"
|
||||
sha2 = "0.10.6"
|
||||
sled = "0.34.7"
|
||||
tera = { version = "1.18.0", features = ["builtins", "date-locale"] }
|
||||
tide = { version = "0.16.0", default-features = false, features = ["h1-server", "cookies", "logger"] }
|
||||
tokio = { version = "1.26.0", features = ["macros", "rt-multi-thread"] }
|
||||
toml = "0.7.2"
|
||||
typed-sled = "0.2.3"
|
||||
unic-langid = { version = "0.9.1", features = ["macros"] }
|
||||
|
||||
[features]
|
||||
default = ["petname"]
|
||||
28
server/locales/en.ftl
Normal file
28
server/locales/en.ftl
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
admin-comment-approve = Approve
|
||||
admin-comment-approve-edit = Approve
|
||||
admin-comment-edit = Edit
|
||||
admin-comment-edit-edit = Edit
|
||||
admin-comment-remove = Remove
|
||||
admin-comment-remove-edit = Remove
|
||||
admin_login-password_prompt = Password:
|
||||
admin_login-submit_button = Login
|
||||
admin_login-title = Admin login | Comments
|
||||
edit_comment-success = Your edit has been published. <a href="{ $edit_link }#edit_comment-form">You can edit it with this link.</a>
|
||||
edit_comment-success_pending = Your edit has been saved. It will be reviewed by an administrator before being published. <a href="{ $edit_link }#edit_comment-form">You can edit it with this link.</a>
|
||||
error-antispam = The edition quota from your IP is reached. You will be unblocked in { $antispam_timeout }s.
|
||||
error-comment-author_name_too_long = Author name length is { $len } but maximum is { $max_len }.
|
||||
error-comment-email_too_long = E-mail length is { $len } but maximum is { $max_len }.
|
||||
error-comment-text_too_long = Comment length is { $len } but maximum is { $max_len }.
|
||||
error-list =
|
||||
{ $nb_errors ->
|
||||
[one] Whoops, the following error occurred:
|
||||
*[other] Whoops, the following { $nb_errors } errors occurred:
|
||||
}
|
||||
comment_form-author = Your name:
|
||||
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. <a href="{ $edit_link }#edit_comment-form">You can edit it with this link.</a>
|
||||
new_comment-success_pending = Your comment has been saved. It will be reviewed by an administrator before being published. <a href="{ $edit_link }#edit_comment-form">You can edit it with this link.</a>
|
||||
title = Comments
|
||||
18
server/locales/fr.ftl
Normal file
18
server/locales/fr.ftl
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
admin-comment-approve = Approuver
|
||||
admin-comment-edit = Modifier
|
||||
admin-comment-remove = Supprimer
|
||||
admin_login-password_prompt = Mot de passe :
|
||||
admin_login-submit_button = S'authentifier
|
||||
admin_login-title = Authentification admin | Commentaires
|
||||
error-antispam = Le quota d'édition de votre adresse IP est atteint, elle sera débloquée dans { $antispam_timeout }s.
|
||||
error-list =
|
||||
{ $nb_errors ->
|
||||
[one] Oups, l'erreur suivante est survenue :
|
||||
*[other] Oups, les { $nb_errors } erreurs suivantes sont survenues :
|
||||
}
|
||||
comment_form-author = Votre nom :
|
||||
comment_form-email = Votre e-mail :
|
||||
comment_form-edit_button = Modifier
|
||||
comment_form-new_button = Envoyer
|
||||
comment_form-text = Votre commentaire :
|
||||
title = Commentaires
|
||||
30
server/src/cleaner.rs
Normal file
30
server/src/cleaner.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use crate::{config::Config, db::*};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn run_cleaner(config: &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, dbs.clone(), time);
|
||||
last_db_clean = time;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_antispam(config: &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();
|
||||
}
|
||||
}
|
||||
}
|
||||
73
server/src/cli.rs
Normal file
73
server/src/cli.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use clap::Parser;
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConfigPath(pub PathBuf);
|
||||
|
||||
impl Default for ConfigPath {
|
||||
fn default() -> ConfigPath {
|
||||
ConfigPath(
|
||||
directories::ProjectDirs::from("tk", "txmn", "webcomment").map_or_else(
|
||||
|| Path::new(".config").to_path_buf(),
|
||||
|o| o.config_dir().into(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&OsStr> for ConfigPath {
|
||||
fn from(v: &OsStr) -> ConfigPath {
|
||||
ConfigPath(PathBuf::from(v))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for ConfigPath {
|
||||
fn to_string(&self) -> String {
|
||||
String::from(
|
||||
self.0
|
||||
.as_path()
|
||||
.to_str()
|
||||
.expect("Error: Config dir is not UTF-8!"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
#[clap(name = "webcomment")]
|
||||
pub struct MainOpt {
|
||||
#[clap(flatten)]
|
||||
pub opt: MainCommonOpt,
|
||||
|
||||
/// Subcommand
|
||||
#[clap(subcommand)]
|
||||
pub cmd: MainSubcommand,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
pub enum MainSubcommand {
|
||||
/// Initialize config & db
|
||||
Init,
|
||||
|
||||
/// Start server
|
||||
Start(StartOpt),
|
||||
|
||||
/// Add admin password
|
||||
Psw,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
pub struct MainCommonOpt {
|
||||
/// Directory
|
||||
#[clap(short, long, default_value_t=ConfigPath::default())]
|
||||
pub dir: ConfigPath,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
pub struct StartOpt {
|
||||
/// Temporary database
|
||||
#[clap(short, long)]
|
||||
pub tmp: bool,
|
||||
}
|
||||
209
server/src/config.rs
Normal file
209
server/src/config.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
#[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,
|
||||
/// Duration for which a comment can be edited
|
||||
#[serde(default = "Config::default_comment_edit_timeout")]
|
||||
pub comment_edit_timeout: u64,
|
||||
#[serde(default = "Config::default_comment_author_max_len")]
|
||||
pub comment_author_max_len: usize,
|
||||
#[serde(default = "Config::default_comment_email_max_len")]
|
||||
pub comment_email_max_len: usize,
|
||||
#[serde(default = "Config::default_comment_text_max_len")]
|
||||
pub comment_text_max_len: usize,
|
||||
#[serde(default = "Config::default_cookies_https_only")]
|
||||
pub cookies_https_only: bool,
|
||||
#[serde(default = "Config::default_cookies_domain")]
|
||||
pub cookies_domain: Option<String>,
|
||||
#[serde(default = "Config::default_cors_allow_origin")]
|
||||
pub cors_allow_origin: String,
|
||||
/// Format: "language_REGION"
|
||||
#[serde(default = "Config::default_default_lang")]
|
||||
pub default_lang: String,
|
||||
#[serde(default = "Config::default_listen")]
|
||||
pub listen: SocketAddr,
|
||||
/// Send a matrix message on new comment
|
||||
#[serde(default = "Config::default_matrix_notify")]
|
||||
pub matrix_notify: bool,
|
||||
#[serde(default = "Config::default_matrix_password")]
|
||||
pub matrix_password: String,
|
||||
/// If connection fails, retry connecting after minimum this duration (seconds)
|
||||
#[serde(default = "Config::default_matrix_retry_timeout")]
|
||||
pub matrix_retry_timeout: u64,
|
||||
#[serde(default = "Config::default_matrix_room")]
|
||||
pub matrix_room: String,
|
||||
#[serde(default = "Config::default_matrix_server")]
|
||||
pub matrix_server: String,
|
||||
#[serde(default = "Config::default_matrix_user")]
|
||||
pub matrix_user: String,
|
||||
/// Our address, with protocol and hostname but without path or trailing `/`
|
||||
/// It is used to make hyperlinks that should be clickable from anywhere.
|
||||
#[serde(default = "Config::default_public_address")]
|
||||
pub public_address: 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,
|
||||
/// Our root url path (without protocol and hostname)
|
||||
/// Useful when used behind a reverse proxy in a pseudo-subdirectory.
|
||||
/// Should start and end with `/`.
|
||||
#[serde(default = "Config::default_root_url")]
|
||||
pub root_url: String,
|
||||
/// Templates directory. May be absolute or relative to config/data directory.
|
||||
#[serde(default = "Config::default_templates_dir")]
|
||||
pub templates_dir: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
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
|
||||
}
|
||||
fn default_comment_edit_timeout() -> u64 {
|
||||
7 * 86400
|
||||
}
|
||||
fn default_comment_author_max_len() -> usize {
|
||||
64
|
||||
}
|
||||
fn default_comment_email_max_len() -> usize {
|
||||
128
|
||||
}
|
||||
fn default_comment_text_max_len() -> usize {
|
||||
128 * 1024
|
||||
}
|
||||
fn default_cookies_https_only() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_cookies_domain() -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn default_cors_allow_origin() -> String {
|
||||
"*".into()
|
||||
}
|
||||
fn default_default_lang() -> String {
|
||||
"en_US".into()
|
||||
}
|
||||
fn default_listen() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 31720)
|
||||
}
|
||||
fn default_matrix_notify() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_matrix_password() -> String {
|
||||
"".into()
|
||||
}
|
||||
fn default_matrix_retry_timeout() -> u64 {
|
||||
3600
|
||||
}
|
||||
fn default_matrix_room() -> String {
|
||||
"#maintenance:matrix.txmn.tk".into()
|
||||
}
|
||||
fn default_matrix_server() -> String {
|
||||
"https://matrix.txmn.tk".into()
|
||||
}
|
||||
fn default_matrix_user() -> String {
|
||||
"@tuxmain:matrix.txmn.tk".into()
|
||||
}
|
||||
fn default_public_address() -> String {
|
||||
"http://127.0.0.1:31720".into()
|
||||
}
|
||||
fn default_reverse_proxy() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_root_url() -> String {
|
||||
"/".into()
|
||||
}
|
||||
fn default_templates_dir() -> String {
|
||||
"templates".into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
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(),
|
||||
comment_email_max_len: Self::default_comment_email_max_len(),
|
||||
comment_text_max_len: Self::default_comment_text_max_len(),
|
||||
cookies_https_only: Self::default_cookies_https_only(),
|
||||
cookies_domain: Self::default_cookies_domain(),
|
||||
cors_allow_origin: Self::default_cors_allow_origin(),
|
||||
default_lang: Self::default_default_lang(),
|
||||
listen: Self::default_listen(),
|
||||
matrix_notify: Self::default_matrix_notify(),
|
||||
matrix_password: Self::default_matrix_password(),
|
||||
matrix_retry_timeout: Self::default_matrix_retry_timeout(),
|
||||
matrix_room: Self::default_matrix_room(),
|
||||
matrix_server: Self::default_matrix_server(),
|
||||
matrix_user: Self::default_matrix_user(),
|
||||
public_address: Self::default_public_address(),
|
||||
reverse_proxy: Self::default_reverse_proxy(),
|
||||
root_url: Self::default_root_url(),
|
||||
templates_dir: Self::default_templates_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_config(dir: &Path) -> Config {
|
||||
let path = dir.join(CONFIG_FILE);
|
||||
|
||||
if !path.is_file() {
|
||||
let config = Config::default();
|
||||
std::fs::write(path, toml::to_string_pretty(&config).unwrap())
|
||||
.expect("Cannot write config file");
|
||||
config
|
||||
} else {
|
||||
toml::from_str(
|
||||
std::str::from_utf8(&std::fs::read(path).expect("Cannot read config file"))
|
||||
.expect("Bad encoding in config file"),
|
||||
)
|
||||
.expect("Bad TOML in config file")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_config(dir: &Path, config: &Config) {
|
||||
let path = dir.join(CONFIG_FILE);
|
||||
std::fs::write(path, toml::to_string_pretty(&config).unwrap())
|
||||
.expect("Cannot write config file");
|
||||
}
|
||||
70
server/src/db.rs
Normal file
70
server/src/db.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
use webcomment_common::types::*;
|
||||
|
||||
use std::{net::IpAddr, path::Path};
|
||||
|
||||
pub use sled::transaction::{
|
||||
ConflictableTransactionError, ConflictableTransactionResult, TransactionError,
|
||||
};
|
||||
pub use typed_sled::Tree;
|
||||
|
||||
const DB_DIR: &str = "db";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Dbs {
|
||||
pub comment: Tree<CommentId, (Comment, CommentStatus)>,
|
||||
pub comment_approved: Tree<(TopicHash, Time, CommentId), ()>,
|
||||
/// -> (client_addr, is_edit)
|
||||
pub comment_pending: Tree<(TopicHash, Time, CommentId), (Option<IpAddr>, bool)>,
|
||||
/// client_addr -> (last_mutation, mutation_count)
|
||||
pub client_mutation: Tree<IpAddr, (Time, u32)>,
|
||||
}
|
||||
|
||||
pub fn load_dbs(path: Option<&Path>) -> Dbs {
|
||||
let db = sled::Config::new();
|
||||
let db = if let Some(path) = path {
|
||||
db.path(path.join(DB_DIR))
|
||||
} else {
|
||||
db.temporary(true)
|
||||
}
|
||||
.open()
|
||||
.expect("Cannot open db");
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_typed_sled() {
|
||||
let db = sled::Config::new().temporary(true).open().unwrap();
|
||||
let tree = typed_sled::Tree::<(u32, u32), ()>::open(&db, "test");
|
||||
tree.insert(&(123, 456), &()).unwrap();
|
||||
tree.flush().unwrap();
|
||||
let mut iter = tree.range((123, 0)..(124, 0));
|
||||
//let mut iter = tree.iter();
|
||||
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));
|
||||
}
|
||||
}
|
||||
696
server/src/helpers.rs
Normal file
696
server/src/helpers.rs
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
use crate::{config::Config, db::*, locales::Locales};
|
||||
use webcomment_common::types::*;
|
||||
|
||||
use fluent_bundle::FluentArgs;
|
||||
use log::error;
|
||||
use std::{net::IpAddr, str::FromStr};
|
||||
use typed_sled::transaction::Transactional;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
pub fn new_pending_comment(
|
||||
comment: &Comment,
|
||||
addr: Option<IpAddr>,
|
||||
dbs: &Dbs,
|
||||
) -> Result<CommentId, sled::Error> {
|
||||
let comment_id = CommentId::new();
|
||||
(&dbs.comment, &dbs.comment_pending)
|
||||
.transaction(|(db_comment, db_comment_pending)| {
|
||||
db_comment.insert(&comment_id, &(comment.clone(), CommentStatus::Pending))?;
|
||||
db_comment_pending.insert(
|
||||
&(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone(),
|
||||
),
|
||||
&(addr, false),
|
||||
)?;
|
||||
ConflictableTransactionResult::<_>::Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
Ok(comment_id)
|
||||
}
|
||||
|
||||
// TODO when approval disabled
|
||||
pub fn edit_comment(
|
||||
comment_id: CommentId,
|
||||
old_comment: Comment,
|
||||
comment_status: CommentStatus,
|
||||
edited_comment: Comment,
|
||||
addr: Option<IpAddr>,
|
||||
dbs: &Dbs,
|
||||
) -> Result<(), sled::Error> {
|
||||
match comment_status {
|
||||
CommentStatus::Pending => {
|
||||
dbs.comment
|
||||
.insert(&comment_id, &(edited_comment, CommentStatus::Pending))?;
|
||||
// TODO should we update ip address in comment_pending?
|
||||
}
|
||||
CommentStatus::Approved => {
|
||||
(&dbs.comment, &dbs.comment_pending)
|
||||
.transaction(|(db_comment, db_comment_pending)| {
|
||||
db_comment_pending.insert(
|
||||
&(
|
||||
edited_comment.topic_hash.clone(),
|
||||
edited_comment.post_time,
|
||||
comment_id.clone(),
|
||||
),
|
||||
&(addr, true),
|
||||
)?;
|
||||
db_comment.insert(
|
||||
&comment_id,
|
||||
&(
|
||||
old_comment.clone(),
|
||||
CommentStatus::ApprovedEdited(edited_comment.clone()),
|
||||
),
|
||||
)?;
|
||||
ConflictableTransactionResult::<_>::Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
CommentStatus::ApprovedEdited(_old_edited_comment) => {
|
||||
dbs.comment.insert(
|
||||
&comment_id,
|
||||
&(old_comment, CommentStatus::ApprovedEdited(edited_comment)),
|
||||
)?;
|
||||
// TODO should we update ip address in comment_pending?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn approve_comment(comment_id: CommentId, dbs: &Dbs) -> Result<(), sled::Error> {
|
||||
(&dbs.comment, &dbs.comment_approved, &dbs.comment_pending)
|
||||
.transaction(|(db_comment, db_comment_approved, db_comment_pending)| {
|
||||
if let Some((comment, CommentStatus::Pending)) = db_comment.get(&comment_id)? {
|
||||
db_comment_pending.remove(&(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone(),
|
||||
))?;
|
||||
db_comment_approved.insert(
|
||||
&(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone(),
|
||||
),
|
||||
&(),
|
||||
)?;
|
||||
db_comment.insert(&comment_id, &(comment, CommentStatus::Approved))?;
|
||||
}
|
||||
ConflictableTransactionResult::<_>::Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn approve_edit(comment_id: CommentId, dbs: &Dbs) -> Result<Option<Comment>, sled::Error> {
|
||||
Ok((&dbs.comment, &dbs.comment_pending)
|
||||
.transaction(|(db_comment, db_comment_pending)| {
|
||||
if let Some((comment, CommentStatus::ApprovedEdited(edited_comment))) =
|
||||
db_comment.get(&comment_id)?
|
||||
{
|
||||
db_comment_pending.remove(&(
|
||||
edited_comment.topic_hash.clone(),
|
||||
edited_comment.post_time,
|
||||
comment_id.clone(),
|
||||
))?;
|
||||
db_comment.insert(&comment_id, &(edited_comment, CommentStatus::Approved))?;
|
||||
return ConflictableTransactionResult::<_>::Ok(Some(comment));
|
||||
}
|
||||
ConflictableTransactionResult::<_>::Ok(None)
|
||||
})
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
pub fn remove_comment(
|
||||
comment_id: CommentId,
|
||||
dbs: &Dbs,
|
||||
) -> Result<Option<(Comment, CommentStatus)>, sled::Error> {
|
||||
Ok((&dbs.comment, &dbs.comment_approved, &dbs.comment_pending)
|
||||
.transaction(|(db_comment, db_comment_approved, db_comment_pending)| {
|
||||
if let Some((comment, edited_comment)) = db_comment.remove(&comment_id)? {
|
||||
match &edited_comment {
|
||||
CommentStatus::Pending => {
|
||||
db_comment_pending.remove(&(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone(),
|
||||
))?;
|
||||
}
|
||||
CommentStatus::Approved => {
|
||||
db_comment_approved.remove(&(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone(),
|
||||
))?;
|
||||
}
|
||||
CommentStatus::ApprovedEdited(edited_comment) => {
|
||||
db_comment_pending.remove(&(
|
||||
edited_comment.topic_hash.clone(),
|
||||
edited_comment.post_time,
|
||||
comment_id.clone(),
|
||||
))?;
|
||||
db_comment_approved.remove(&(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
return ConflictableTransactionResult::<_>::Ok(Some((comment, edited_comment)));
|
||||
}
|
||||
ConflictableTransactionResult::<_>::Ok(None)
|
||||
})
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
pub fn remove_edit(comment_id: CommentId, dbs: &Dbs) -> Result<Option<Comment>, sled::Error> {
|
||||
Ok((&dbs.comment, &dbs.comment_pending)
|
||||
.transaction(|(db_comment, db_comment_pending)| {
|
||||
if let Some((comment, CommentStatus::ApprovedEdited(edited_comment))) =
|
||||
db_comment.get(&comment_id)?
|
||||
{
|
||||
db_comment_pending.remove(&(
|
||||
edited_comment.topic_hash.clone(),
|
||||
edited_comment.post_time,
|
||||
comment_id.clone(),
|
||||
))?;
|
||||
db_comment.insert(&comment_id, &(comment.clone(), CommentStatus::Approved))?;
|
||||
return ConflictableTransactionResult::<_>::Ok(Some(comment));
|
||||
}
|
||||
ConflictableTransactionResult::<_>::Ok(None)
|
||||
})
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
pub fn iter_comments_by_topic<'a, V: typed_sled::KV>(
|
||||
topic_hash: TopicHash,
|
||||
tree: &'a Tree<(TopicHash, Time, CommentId), V>,
|
||||
dbs: &'a Dbs,
|
||||
) -> impl Iterator<Item = (CommentId, Comment, CommentStatus, V)> + 'a {
|
||||
tree.range(
|
||||
(topic_hash.clone(), 0, CommentId::zero())..=(topic_hash, Time::MAX, CommentId::max()),
|
||||
)
|
||||
.filter_map(|entry| {
|
||||
let ((_topic_hash, _time, comment_id), val) = entry
|
||||
.map_err(|e| error!("Reading comment index: {:?}", 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
|
||||
})?;
|
||||
Some((comment_id, comment, comment_status, val))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn iter_approved_comments_by_topic(
|
||||
topic_hash: TopicHash,
|
||||
dbs: &Dbs,
|
||||
) -> impl Iterator<Item = (CommentId, Comment, CommentStatus)> + '_ {
|
||||
iter_comments_by_topic(topic_hash, &dbs.comment_approved, dbs)
|
||||
.map(|(comment_id, comment, comment_status, ())| (comment_id, comment, comment_status))
|
||||
}
|
||||
|
||||
pub fn iter_pending_comments_by_topic(
|
||||
topic_hash: TopicHash,
|
||||
dbs: &Dbs,
|
||||
) -> impl Iterator<Item = (CommentId, Comment, Option<IpAddr>, CommentStatus)> + '_ {
|
||||
iter_comments_by_topic(topic_hash, &dbs.comment_pending, dbs).map(
|
||||
|(comment_id, comment, comment_status, (addr, _is_edit))| {
|
||||
(comment_id, comment, addr, comment_status)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn check_comment(
|
||||
config: &Config,
|
||||
locales: &Locales,
|
||||
langs: &[LanguageIdentifier],
|
||||
comment: &crate::server::page::queries::CommentForm,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if comment.author.len() > config.comment_author_max_len {
|
||||
let mut args = FluentArgs::new();
|
||||
args.set("len", comment.author.len());
|
||||
args.set("max_len", config.comment_author_max_len);
|
||||
errors.push(
|
||||
locales
|
||||
.tr(langs, "error-comment-author_name_too_long", Some(&args))
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if comment.email.len() > config.comment_email_max_len {
|
||||
let mut args = FluentArgs::new();
|
||||
args.set("len", comment.email.len());
|
||||
args.set("max_len", config.comment_email_max_len);
|
||||
errors.push(
|
||||
locales
|
||||
.tr(langs, "error-comment-email_too_long", Some(&args))
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if comment.text.len() > config.comment_text_max_len {
|
||||
let mut args = FluentArgs::new();
|
||||
args.set("len", comment.text.len());
|
||||
args.set("max_len", config.comment_text_max_len);
|
||||
errors.push(
|
||||
locales
|
||||
.tr(langs, "error-comment-text_too_long", Some(&args))
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
||||
#[test]
|
||||
fn test_comment() {
|
||||
// Post a comment
|
||||
|
||||
let comment = Comment {
|
||||
topic_hash: TopicHash::from_topic("test"),
|
||||
author: String::from("Jerry"),
|
||||
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, None, &dbs).unwrap();
|
||||
|
||||
let mut iter = dbs.comment.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
comment_id.clone(),
|
||||
(comment.clone(), CommentStatus::Pending)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_pending.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
(None, false)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
// Edit the comment
|
||||
|
||||
let comment2 = Comment {
|
||||
topic_hash: TopicHash::from_topic("test"),
|
||||
author: String::from("Jerry Smith"),
|
||||
email: Some(String::from("jerry.smith@example.tld")),
|
||||
last_edit_time: Some(137),
|
||||
mutation_token: comment.mutation_token.clone(),
|
||||
post_time: 42,
|
||||
text: String::from("Good bye world!"),
|
||||
};
|
||||
|
||||
edit_comment(
|
||||
comment_id.clone(),
|
||||
comment.clone(),
|
||||
CommentStatus::Pending,
|
||||
comment2.clone(),
|
||||
None,
|
||||
&dbs,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut iter = dbs.comment.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
comment_id.clone(),
|
||||
(comment2.clone(), CommentStatus::Pending)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_pending.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
(None, false)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
// Approve the comment
|
||||
|
||||
approve_comment(comment_id.clone(), &dbs).unwrap();
|
||||
|
||||
let mut iter = dbs.comment.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
comment_id.clone(),
|
||||
(comment2.clone(), CommentStatus::Approved)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_pending.iter();
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_approved.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
()
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
// Edit the approved comment
|
||||
|
||||
let comment3 = Comment {
|
||||
topic_hash: TopicHash::from_topic("test"),
|
||||
author: String::from("Jerry Smith is back"),
|
||||
email: Some(String::from("jerry.smith@example.tld")),
|
||||
last_edit_time: Some(666),
|
||||
mutation_token: comment.mutation_token.clone(),
|
||||
post_time: 42,
|
||||
text: String::from("Hello again!"),
|
||||
};
|
||||
|
||||
edit_comment(
|
||||
comment_id.clone(),
|
||||
comment2.clone(),
|
||||
CommentStatus::Approved,
|
||||
comment3.clone(),
|
||||
None,
|
||||
&dbs,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut iter = dbs.comment.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
comment_id.clone(),
|
||||
(
|
||||
comment2.clone(),
|
||||
CommentStatus::ApprovedEdited(comment3.clone())
|
||||
)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_pending.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
(None, true)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_approved.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
()
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
// Edit the edited approved comment
|
||||
|
||||
let comment4 = Comment {
|
||||
topic_hash: TopicHash::from_topic("test"),
|
||||
author: String::from("Jerry Smith is still back"),
|
||||
email: Some(String::from("jerry.smith@example.tld")),
|
||||
last_edit_time: Some(1337),
|
||||
mutation_token: comment.mutation_token.clone(),
|
||||
post_time: 42,
|
||||
text: String::from("Hello again one more time!"),
|
||||
};
|
||||
|
||||
edit_comment(
|
||||
comment_id.clone(),
|
||||
comment2.clone(),
|
||||
CommentStatus::ApprovedEdited(comment3.clone()),
|
||||
comment4.clone(),
|
||||
None,
|
||||
&dbs,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut iter = dbs.comment.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
comment_id.clone(),
|
||||
(comment2, CommentStatus::ApprovedEdited(comment4.clone()))
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_pending.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
(None, true)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_approved.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
()
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
// Approve the edit
|
||||
|
||||
approve_edit(comment_id.clone(), &dbs).unwrap();
|
||||
|
||||
let mut iter = dbs.comment.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
comment_id.clone(),
|
||||
(comment4.clone(), CommentStatus::Approved)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_pending.iter();
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_approved.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
()
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
// Edit and remove the edit
|
||||
|
||||
edit_comment(
|
||||
comment_id.clone(),
|
||||
comment4.clone(),
|
||||
CommentStatus::Approved,
|
||||
comment.clone(),
|
||||
None,
|
||||
&dbs,
|
||||
)
|
||||
.unwrap();
|
||||
remove_edit(comment_id.clone(), &dbs).unwrap();
|
||||
|
||||
let mut iter = dbs.comment.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
comment_id.clone(),
|
||||
(comment4.clone(), CommentStatus::Approved)
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_pending.iter();
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = dbs.comment_approved.iter();
|
||||
assert_eq!(
|
||||
iter.next(),
|
||||
Some(Ok((
|
||||
(
|
||||
comment.topic_hash.clone(),
|
||||
comment.post_time,
|
||||
comment_id.clone()
|
||||
),
|
||||
()
|
||||
)))
|
||||
);
|
||||
assert_eq!(iter.next(), None);
|
||||
}
|
||||
}
|
||||
121
server/src/locales.rs
Normal file
121
server/src/locales.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use crate::config::Config;
|
||||
|
||||
use fluent_bundle::{bundle::FluentBundle, FluentArgs, FluentResource, FluentValue};
|
||||
use fluent_langneg::{
|
||||
accepted_languages, negotiate::filter_matches, negotiate_languages, NegotiationStrategy,
|
||||
};
|
||||
use intl_memoizer::concurrent::IntlLangMemoizer;
|
||||
use log::error;
|
||||
use std::{borrow::Cow, collections::HashMap, ops::Deref, str::FromStr};
|
||||
use unic_langid::{langid, LanguageIdentifier};
|
||||
|
||||
static LOCALE_FILES: &[(LanguageIdentifier, &str)] = &[
|
||||
(langid!("en"), include_str!("../locales/en.ftl")),
|
||||
(langid!("fr"), include_str!("../locales/fr.ftl")),
|
||||
];
|
||||
|
||||
pub struct Locales {
|
||||
bundles: HashMap<LanguageIdentifier, FluentBundle<FluentResource, IntlLangMemoizer>>,
|
||||
pub default_lang: LanguageIdentifier,
|
||||
langs: Vec<LanguageIdentifier>,
|
||||
}
|
||||
|
||||
impl Locales {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
let mut langs = Vec::new();
|
||||
Self {
|
||||
bundles: LOCALE_FILES
|
||||
.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| {
|
||||
panic!("Failed parsing `{lang}` locale: {e:?}")
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
langs.push(lang.clone());
|
||||
(lang.clone(), bundle)
|
||||
})
|
||||
.collect::<HashMap<LanguageIdentifier, FluentBundle<FluentResource, IntlLangMemoizer>>>(
|
||||
),
|
||||
default_lang: filter_matches(
|
||||
&[LanguageIdentifier::from_str(&config.default_lang)
|
||||
.expect("Invalid default language")],
|
||||
&langs,
|
||||
NegotiationStrategy::Filtering,
|
||||
)
|
||||
.get(0)
|
||||
.expect("Unavailable default language")
|
||||
.deref()
|
||||
.clone(),
|
||||
langs,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO fix fluent-langneg's weird API
|
||||
pub fn tr<'a>(
|
||||
&'a self,
|
||||
langs: &[LanguageIdentifier],
|
||||
key: &str,
|
||||
args: Option<&'a FluentArgs>,
|
||||
) -> Option<Cow<str>> {
|
||||
for prefered_lang in negotiate_languages(
|
||||
langs,
|
||||
&self.langs,
|
||||
Some(&self.default_lang),
|
||||
NegotiationStrategy::Filtering,
|
||||
) {
|
||||
if let Some(bundle) = self.bundles.get(prefered_lang.as_ref()) {
|
||||
if let Some(message) = bundle.get_message(key) {
|
||||
let mut errors = Vec::new();
|
||||
let ret = bundle.format_pattern(message.value().unwrap(), args, &mut errors);
|
||||
for error in errors {
|
||||
error!("Formatting message `{key}` in lang `{prefered_lang}`: {error}");
|
||||
}
|
||||
return Some(ret);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_client_langs<State>(req: &tide::Request<State>) -> Vec<LanguageIdentifier> {
|
||||
if let Some(header) = req.header("Accept-Language") {
|
||||
accepted_languages::parse(header.as_str())
|
||||
} else {
|
||||
println!("NO HEADER");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the first language that is likely to be usable with chrono
|
||||
pub fn get_time_lang(langs: &[LanguageIdentifier]) -> Option<String> {
|
||||
for lang in langs {
|
||||
if let Some(region) = &lang.region {
|
||||
return Some(format!("{}_{}", lang.language.as_str(), region.as_str()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn tera_to_fluent(val: &tera::Value) -> FluentValue {
|
||||
match val {
|
||||
tera::Value::Null => FluentValue::None,
|
||||
tera::Value::Number(v) => {
|
||||
if v.is_i64() {
|
||||
FluentValue::Number(v.as_i64().unwrap().into())
|
||||
} else if v.is_u64() {
|
||||
FluentValue::Number(v.as_u64().unwrap().into())
|
||||
} else {
|
||||
FluentValue::Number(v.as_f64().unwrap().into())
|
||||
}
|
||||
}
|
||||
tera::Value::String(v) => FluentValue::String(v.into()),
|
||||
_ => FluentValue::Error,
|
||||
}
|
||||
}
|
||||
93
server/src/main.rs
Normal file
93
server/src/main.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
mod cleaner;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod db;
|
||||
mod helpers;
|
||||
mod locales;
|
||||
mod notify;
|
||||
mod server;
|
||||
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use clap::Parser;
|
||||
use log::warn;
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let opt = cli::MainOpt::parse();
|
||||
|
||||
match opt.cmd {
|
||||
cli::MainSubcommand::Init => {
|
||||
init_all(opt.opt, cli::StartOpt { tmp: false });
|
||||
}
|
||||
cli::MainSubcommand::Start(subopt) => {
|
||||
let (config, dbs, templates) = init_all(opt.opt, subopt);
|
||||
|
||||
// These will never be dropped nor mutated
|
||||
let templates = Box::leak(Box::new(templates));
|
||||
let config = Box::leak(Box::new(config));
|
||||
let locales = Box::leak(Box::new(locales::Locales::new(config)));
|
||||
|
||||
templates.tera.register_function(
|
||||
"tr",
|
||||
Box::new(
|
||||
|args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
|
||||
let langs = if let Some(tera::Value::Array(langs)) = args.get("l") {
|
||||
langs
|
||||
.iter()
|
||||
.filter_map(|lang| lang.as_str())
|
||||
.filter_map(|lang| LanguageIdentifier::from_str(lang).ok())
|
||||
.collect()
|
||||
} else {
|
||||
vec![locales.default_lang.clone()]
|
||||
};
|
||||
let key = args
|
||||
.get("k")
|
||||
.ok_or_else(|| tera::Error::from("Missing argument `k`"))?
|
||||
.as_str()
|
||||
.ok_or_else(|| tera::Error::from("Argument `k` must be string"))?;
|
||||
let args_iter = fluent_bundle::FluentArgs::from_iter(
|
||||
args.iter().map(|(k, v)| (k, locales::tera_to_fluent(v))),
|
||||
);
|
||||
let res = locales.tr(&langs, key, Some(&args_iter));
|
||||
if res.is_none() {
|
||||
warn!("(calling `tr` in template) translation key `{key}` not found");
|
||||
}
|
||||
Ok(res.into())
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
tokio::spawn(cleaner::run_cleaner(config, dbs.clone()));
|
||||
server::run_server(config, dbs, templates, locales).await;
|
||||
}
|
||||
cli::MainSubcommand::Psw => {
|
||||
let mut config = config::read_config(&opt.opt.dir.0);
|
||||
let password = rpassword::prompt_password("Additional admin password: ").unwrap();
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
config.admin_passwords.push(password_hash);
|
||||
config::write_config(&opt.opt.dir.0, &config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_all(
|
||||
opt: cli::MainCommonOpt,
|
||||
subopt: cli::StartOpt,
|
||||
) -> (config::Config, db::Dbs, server::page::templates::Templates) {
|
||||
std::fs::create_dir_all(&opt.dir.0).expect("Cannot create dir");
|
||||
let config = config::read_config(&opt.dir.0);
|
||||
let dbs = db::load_dbs((!subopt.tmp).then_some(&opt.dir.0));
|
||||
let templates = server::page::templates::Templates::new(&opt.dir.0, &config);
|
||||
|
||||
(config, dbs, templates)
|
||||
}
|
||||
149
server/src/notify.rs
Normal file
149
server/src/notify.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use crate::config::Config;
|
||||
|
||||
use crossbeam_channel::Receiver;
|
||||
use log::error;
|
||||
use matrix_sdk::ruma;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
pub struct Notification {
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
enum OptionSince<T> {
|
||||
Some(T),
|
||||
NoneSince(SystemTime),
|
||||
}
|
||||
|
||||
impl<T> OptionSince<T> {
|
||||
fn from_result<E, F: FnOnce(E)>(result: Result<T, E>, f: F) -> Self {
|
||||
match result {
|
||||
Ok(val) => Self::Some(val),
|
||||
Err(e) => {
|
||||
f(e);
|
||||
Self::NoneSince(SystemTime::now())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Option<T>> for OptionSince<T> {
|
||||
fn from(opt: Option<T>) -> Self {
|
||||
match opt {
|
||||
Some(val) => Self::Some(val),
|
||||
None => Self::NoneSince(SystemTime::now()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Notifier {
|
||||
matrix: Option<OptionSince<(matrix_sdk::Client, matrix_sdk::room::Joined)>>,
|
||||
}
|
||||
|
||||
impl Notifier {
|
||||
async fn new(config: &Config) -> Self {
|
||||
Self {
|
||||
matrix: if config.matrix_notify {
|
||||
Some(OptionSince::from_result(init_matrix(config).await, |e| {
|
||||
error!("Cannot init Matrix: {:?}", e)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn notify(&mut self, config: &Config, notification: Notification) {
|
||||
match &self.matrix {
|
||||
None => {}
|
||||
Some(OptionSince::Some((_client, room))) => {
|
||||
let decoded_topic = tera::escape_html(
|
||||
&percent_encoding::percent_decode_str(¬ification.topic).decode_utf8_lossy(),
|
||||
);
|
||||
if let Err(e) = room
|
||||
.send(
|
||||
ruma::events::room::message::RoomMessageEventContent::text_html(
|
||||
format!(
|
||||
"New comment on topic \"{}\": {}{}t/{}",
|
||||
decoded_topic,
|
||||
config.public_address,
|
||||
config.root_url,
|
||||
notification.topic,
|
||||
),
|
||||
format!(
|
||||
"<a href=\"{}{}t/{}\">New comment on topic \"<em>{}</em>\".</a>",
|
||||
config.public_address,
|
||||
config.root_url,
|
||||
notification.topic,
|
||||
decoded_topic,
|
||||
),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Sending Matrix message: {:?}", e);
|
||||
}
|
||||
}
|
||||
Some(OptionSince::NoneSince(earlier)) => {
|
||||
if SystemTime::now().duration_since(*earlier).unwrap()
|
||||
> Duration::from_secs(config.matrix_retry_timeout)
|
||||
{
|
||||
self.matrix = Some(OptionSince::from_result(init_matrix(config).await, |e| {
|
||||
error!("Cannot init Matrix: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_notifier(config: &Config, recv: Receiver<Notification>) {
|
||||
let mut notifier = Notifier::new(config).await;
|
||||
for notification in recv {
|
||||
notifier.notify(config, notification).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MatrixError {
|
||||
CannotConnect(matrix_sdk::ClientBuildError),
|
||||
CannotLogin(matrix_sdk::Error),
|
||||
CannotSync(matrix_sdk::Error),
|
||||
RoomNotJoined,
|
||||
UnknownRoom,
|
||||
}
|
||||
|
||||
async fn init_matrix(
|
||||
config: &Config,
|
||||
) -> Result<(matrix_sdk::Client, matrix_sdk::room::Joined), MatrixError> {
|
||||
let user = ruma::UserId::parse(&config.matrix_user)
|
||||
.expect("Matrix username should be in format `@user:homeserver`");
|
||||
let room_id = <&ruma::RoomId>::try_from(config.matrix_room.as_str())
|
||||
.expect("Matrix room should be in format `#roomname:homeserver` or `!roomid:homeserver`");
|
||||
|
||||
let client = matrix_sdk::Client::builder()
|
||||
.homeserver_url(&config.matrix_server)
|
||||
.user_agent("Webcomment")
|
||||
.handle_refresh_tokens()
|
||||
.build()
|
||||
.await
|
||||
.map_err(MatrixError::CannotConnect)?;
|
||||
|
||||
client
|
||||
.login_username(&user, &config.matrix_password)
|
||||
.send()
|
||||
.await
|
||||
.map_err(MatrixError::CannotLogin)?;
|
||||
client
|
||||
.sync_once(matrix_sdk::config::SyncSettings::default())
|
||||
.await
|
||||
.map_err(MatrixError::CannotSync)?;
|
||||
|
||||
let room = client.get_room(room_id).ok_or(MatrixError::UnknownRoom)?;
|
||||
|
||||
if let matrix_sdk::room::Room::Joined(room) = room {
|
||||
Ok((client, room))
|
||||
} else {
|
||||
Err(MatrixError::RoomNotJoined)
|
||||
}
|
||||
}
|
||||
54
server/src/server.rs
Normal file
54
server/src/server.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
pub mod api;
|
||||
pub mod page;
|
||||
|
||||
use crate::{config::*, db::*, locales::*};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
|
||||
pub async fn run_server(
|
||||
config: &'static Config,
|
||||
dbs: Dbs,
|
||||
templates: &'static page::templates::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();
|
||||
|
||||
// CORS sucks
|
||||
app.at(&format!("{}*", config.root_url))
|
||||
.options(|_req: tide::Request<()>| async {
|
||||
Ok(tide::Response::builder(200)
|
||||
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
|
||||
.header("Access-Control-Allow-Headers", "*")
|
||||
.build())
|
||||
});
|
||||
|
||||
api::init_routes(&mut app, config, dbs.clone(), notify_send.clone()).await;
|
||||
page::init_routes(&mut app, config, dbs, templates, locales, notify_send).await;
|
||||
|
||||
app.listen(config.listen).await.unwrap();
|
||||
}
|
||||
|
||||
fn check_admin_password(config: &Config, password: &str) -> Option<String> {
|
||||
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)
|
||||
}
|
||||
283
server/src/server/api.rs
Normal file
283
server/src/server/api.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
use crate::{config::*, db::*, helpers, notify::Notification, server::check_admin_password};
|
||||
use webcomment_common::{api::*, types::*};
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use log::{error, warn};
|
||||
use serde::Serialize;
|
||||
|
||||
pub async fn init_routes(
|
||||
app: &mut tide::Server<()>,
|
||||
config: &'static Config,
|
||||
dbs: Dbs,
|
||||
notify_send: Sender<Notification>,
|
||||
) {
|
||||
// TODO pagination
|
||||
app.at(&format!("{}api/comments_by_topic", config.root_url))
|
||||
.post({
|
||||
let dbs = dbs.clone();
|
||||
move |req: tide::Request<()>| query_comments_by_topic(req, config, dbs.clone())
|
||||
});
|
||||
app.at(&format!("{}api/admin/comments_by_topic", config.root_url))
|
||||
.post({
|
||||
let dbs = dbs.clone();
|
||||
move |req: tide::Request<()>| query_comments_by_topic_admin(req, config, dbs.clone())
|
||||
});
|
||||
app.at(&format!("{}api/admin/remove_comment", config.root_url))
|
||||
.post({
|
||||
let dbs = dbs.clone();
|
||||
move |req: tide::Request<()>| query_remove_comment_admin(req, config, dbs.clone())
|
||||
});
|
||||
app.at(&format!("{}api/new_comment", config.root_url))
|
||||
.post({
|
||||
move |req: tide::Request<()>| {
|
||||
query_new_comment(req, config, dbs.clone(), notify_send.clone())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_resp<S, B, E>(config: &Config, status: S, body: B) -> Result<tide::Response, E>
|
||||
where
|
||||
S: TryInto<tide::StatusCode>,
|
||||
S::Error: std::fmt::Debug,
|
||||
B: Serialize,
|
||||
{
|
||||
Ok(tide::Response::builder(status)
|
||||
.content_type(tide::http::mime::JSON)
|
||||
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
|
||||
.body(tide::Body::from_json(&body).unwrap())
|
||||
.build())
|
||||
}
|
||||
|
||||
// TODO using mutation_token:
|
||||
// * add pending comments
|
||||
// * add status
|
||||
// * add email
|
||||
// * add editable
|
||||
async fn query_comments_by_topic(
|
||||
mut req: tide::Request<()>,
|
||||
config: &Config,
|
||||
dbs: Dbs,
|
||||
) -> tide::Result<tide::Response> {
|
||||
let Ok(queries::CommentsByTopic {
|
||||
mutation_token: _mutation_token,
|
||||
topic,
|
||||
}) = req.body_json().await else {
|
||||
return build_resp(config, 400, resps::Result::Err(resps::Error::InvalidRequest));
|
||||
};
|
||||
|
||||
let topic_hash = TopicHash::from_topic(&topic);
|
||||
|
||||
build_resp(
|
||||
config,
|
||||
200,
|
||||
resps::CommentsByTopic {
|
||||
approved_comments: helpers::iter_approved_comments_by_topic(topic_hash, &dbs)
|
||||
.map(
|
||||
|(comment_id, comment, _comment_status)| resps::ApprovedCommentWithMeta {
|
||||
editable: false,
|
||||
email: None,
|
||||
author: comment.author,
|
||||
id: comment_id.to_base64(),
|
||||
last_edit_time: comment.last_edit_time,
|
||||
post_time: comment.post_time,
|
||||
status: CommentStatus::Approved,
|
||||
text: comment.text,
|
||||
},
|
||||
)
|
||||
.collect::<Vec<resps::ApprovedCommentWithMeta>>(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn query_comments_by_topic_admin(
|
||||
mut req: tide::Request<()>,
|
||||
config: &Config,
|
||||
dbs: Dbs,
|
||||
) -> tide::Result<tide::Response> {
|
||||
let Ok(queries::CommentsByTopicAdmin {admin_psw,
|
||||
topic,
|
||||
}) = req.body_json().await else {
|
||||
return build_resp(config, 400, resps::Result::Err(resps::Error::InvalidRequest));
|
||||
};
|
||||
|
||||
if check_admin_password(config, &admin_psw).is_none() {
|
||||
return build_resp(config, 403, resps::Result::Err(resps::Error::BadAdminAuth));
|
||||
}
|
||||
|
||||
let topic_hash = TopicHash::from_topic(&topic);
|
||||
|
||||
build_resp(
|
||||
config,
|
||||
200,
|
||||
resps::CommentsByTopicAdmin {
|
||||
approved_comments: helpers::iter_approved_comments_by_topic(topic_hash.clone(), &dbs)
|
||||
.map(
|
||||
|(comment_id, comment, comment_status)| resps::ApprovedCommentWithMeta {
|
||||
editable: true,
|
||||
email: comment.email,
|
||||
author: comment.author,
|
||||
id: comment_id.to_base64(),
|
||||
last_edit_time: comment.last_edit_time,
|
||||
post_time: comment.post_time,
|
||||
status: comment_status,
|
||||
text: comment.text,
|
||||
},
|
||||
)
|
||||
.collect::<Vec<resps::ApprovedCommentWithMeta>>(),
|
||||
pending_comments: helpers::iter_pending_comments_by_topic(topic_hash, &dbs)
|
||||
.map(
|
||||
|(comment_id, comment, addr, comment_status)| resps::PendingCommentWithMeta {
|
||||
addr: addr.as_ref().map(std::net::IpAddr::to_string),
|
||||
editable: true,
|
||||
email: comment.email,
|
||||
author: comment.author,
|
||||
id: comment_id.to_base64(),
|
||||
last_edit_time: comment.last_edit_time,
|
||||
post_time: comment.post_time,
|
||||
status: comment_status,
|
||||
text: comment.text,
|
||||
},
|
||||
)
|
||||
.collect::<Vec<resps::PendingCommentWithMeta>>(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn query_remove_comment_admin(
|
||||
mut req: tide::Request<()>,
|
||||
config: &Config,
|
||||
dbs: Dbs,
|
||||
) -> tide::Result<tide::Response> {
|
||||
let Ok(queries::RemoveCommentAdmin {admin_psw,
|
||||
comment_id,
|
||||
}) = req.body_json().await else {
|
||||
return build_resp(config, 400, resps::Result::Err(resps::Error::InvalidRequest));
|
||||
};
|
||||
|
||||
if check_admin_password(config, &admin_psw).is_none() {
|
||||
return build_resp(config, 403, resps::Result::Err(resps::Error::BadAdminAuth));
|
||||
}
|
||||
|
||||
let Ok(comment_id) = CommentId::from_base64(&comment_id) else {
|
||||
return build_resp(
|
||||
config,
|
||||
400,
|
||||
resps::Result::Err(resps::Error::InvalidRequest),
|
||||
);
|
||||
};
|
||||
|
||||
match helpers::remove_comment(comment_id, &dbs) {
|
||||
Ok(_) => build_resp(config, 200, resps::Result::Ok(resps::Response::Ok)),
|
||||
Err(e) => build_resp(
|
||||
config,
|
||||
200,
|
||||
resps::Result::Err(resps::Error::Message(e.to_string())),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_new_comment(
|
||||
mut req: tide::Request<()>,
|
||||
config: &Config,
|
||||
dbs: Dbs,
|
||||
notify_send: Sender<Notification>,
|
||||
) -> tide::Result<tide::Response> {
|
||||
let Ok(query) = req.body_json::<queries::NewComment>().await else {
|
||||
return build_resp(config, 400, resps::Result::Err(resps::Error::InvalidRequest));
|
||||
};
|
||||
|
||||
if query.author.len() > config.comment_author_max_len
|
||||
|| query.email.len() > config.comment_email_max_len
|
||||
|| query.text.len() > config.comment_text_max_len
|
||||
{
|
||||
return build_resp(
|
||||
config,
|
||||
400,
|
||||
resps::Result::Err(resps::Error::IllegalContent),
|
||||
);
|
||||
}
|
||||
|
||||
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 = config.antispam_enable
|
||||
&& client_addr
|
||||
.as_ref()
|
||||
.map_or(false, |addr| !config.antispam_whitelist.contains(addr));
|
||||
|
||||
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()
|
||||
{
|
||||
return build_resp(
|
||||
config,
|
||||
403,
|
||||
resps::Result::Err(resps::Error::Antispam {
|
||||
timeout: antispam_timeout,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// It's OK
|
||||
|
||||
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(&query.topic);
|
||||
|
||||
let time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let comment = Comment {
|
||||
topic_hash,
|
||||
author: if query.author.is_empty() {
|
||||
petname::Petnames::large().generate_one(2, " ")
|
||||
} else {
|
||||
query.author
|
||||
},
|
||||
email: if query.email.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(query.email)
|
||||
},
|
||||
last_edit_time: None,
|
||||
mutation_token: MutationToken::new(),
|
||||
post_time: time,
|
||||
text: query.text,
|
||||
};
|
||||
match helpers::new_pending_comment(&comment, client_addr, &dbs) {
|
||||
Ok(comment_id) => {
|
||||
notify_send.send(Notification { topic: query.topic }).ok();
|
||||
build_resp(
|
||||
config,
|
||||
200,
|
||||
resps::NewComment {
|
||||
id: comment_id.to_base64(),
|
||||
mutation_token: comment.mutation_token.to_base64(),
|
||||
post_time: time,
|
||||
},
|
||||
)
|
||||
}
|
||||
// TODO add message to client log and change http code
|
||||
Err(e) => {
|
||||
error!("Adding pending comment: {:?}", e);
|
||||
build_resp(config, 500, resps::Result::Err(resps::Error::Internal))
|
||||
}
|
||||
}
|
||||
}
|
||||
727
server/src/server/page.rs
Normal file
727
server/src/server/page.rs
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
pub mod queries;
|
||||
pub mod templates;
|
||||
|
||||
use super::{check_admin_password, check_admin_password_hash};
|
||||
use crate::{config::*, db::*, helpers, locales::*, notify::Notification};
|
||||
use queries::*;
|
||||
use templates::*;
|
||||
use webcomment_common::types::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use fluent_bundle::FluentArgs;
|
||||
use log::{error, warn};
|
||||
use tera::Context;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
pub async fn init_routes(
|
||||
app: &mut tide::Server<()>,
|
||||
config: &'static Config,
|
||||
dbs: Dbs,
|
||||
templates: &'static Templates,
|
||||
locales: &'static Locales,
|
||||
notify_send: Sender<Notification>,
|
||||
) {
|
||||
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({
|
||||
move |req: tide::Request<()>| handle_post_admin(req, config, templates, dbs.clone())
|
||||
});
|
||||
}
|
||||
|
||||
async fn serve_edit_comment<'a>(
|
||||
req: tide::Request<()>,
|
||||
config: &Config,
|
||||
templates: &Templates,
|
||||
dbs: Dbs,
|
||||
client_langs: Vec<LanguageIdentifier>,
|
||||
mut context: Context,
|
||||
status_code: u16,
|
||||
) -> tide::Result<tide::Response> {
|
||||
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<LanguageIdentifier>,
|
||||
mut context: Context,
|
||||
status_code: u16,
|
||||
) -> tide::Result<tide::Response> {
|
||||
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::<Vec<&str>>(),
|
||||
);
|
||||
|
||||
if admin {
|
||||
if let Ok(query) = req.query::<ApproveQuery>() {
|
||||
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::<ApproveEditQuery>() {
|
||||
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::<RemoveQuery>() {
|
||||
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::<RemoveEditQuery>() {
|
||||
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::<EditQuery>() {
|
||||
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::<Vec<CommentWithId>>(),
|
||||
);
|
||||
}
|
||||
|
||||
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::<Vec<CommentWithId>>(),
|
||||
);
|
||||
|
||||
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<tide::Response> {
|
||||
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::<Vec<&str>>(),
|
||||
);
|
||||
|
||||
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::<Vec<CommentWithId>>(),
|
||||
);
|
||||
|
||||
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<LanguageIdentifier>,
|
||||
) -> tide::Result<tide::Response> {
|
||||
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::<Vec<&str>>(),
|
||||
);
|
||||
|
||||
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<Notification>,
|
||||
) -> tide::Result<tide::Response> {
|
||||
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::<CommentQuery>().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<tide::Response> {
|
||||
if let Some(psw) = req.cookie("admin") {
|
||||
if check_admin_password_hash(config, &String::from(psw.value())) {
|
||||
#[allow(clippy::match_single_binding)]
|
||||
match req.body_form::<AdminQuery>().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::<AdminQuery>().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
|
||||
}
|
||||
}
|
||||
93
server/src/server/page/queries.rs
Normal file
93
server/src/server/page/queries.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminLoginQuery {
|
||||
pub psw: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminEditCommentQuery {
|
||||
pub author: String,
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminRmCommentQuery {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NewCommentQuery {
|
||||
#[serde(flatten)]
|
||||
pub comment: CommentForm,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct EditCommentQuery {
|
||||
#[serde(flatten)]
|
||||
pub comment: CommentForm,
|
||||
pub id: String,
|
||||
//pub token: String,
|
||||
}
|
||||
|
||||
/*#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RmCommentQuery {
|
||||
pub id: String,
|
||||
//pub token: String,
|
||||
}*/
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "a")]
|
||||
pub enum CommentQuery {
|
||||
#[serde(rename = "new_comment")]
|
||||
NewComment(NewCommentQuery),
|
||||
#[serde(rename = "edit_comment")]
|
||||
EditComment(EditCommentQuery),
|
||||
/*#[serde(rename = "rm_comment")]
|
||||
RmComment(RmCommentQuery),*/
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "a")]
|
||||
pub enum AdminQuery {
|
||||
#[serde(rename = "login")]
|
||||
Login(AdminLoginQuery),
|
||||
#[serde(rename = "edit_comment")]
|
||||
EditComment(AdminEditCommentQuery),
|
||||
#[serde(rename = "rm_comment")]
|
||||
RmComment(AdminRmCommentQuery),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ApproveQuery {
|
||||
pub approve: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ApproveEditQuery {
|
||||
pub approve_edit: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RemoveQuery {
|
||||
pub remove: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RemoveEditQuery {
|
||||
pub remove_edit: 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,
|
||||
}
|
||||
62
server/src/server/page/templates.rs
Normal file
62
server/src/server/page/templates.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use crate::config::Config;
|
||||
use webcomment_common::types::*;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tera::Tera;
|
||||
|
||||
static TEMPLATE_FILES: &[(&str, &str)] = &[
|
||||
(
|
||||
"comments.html",
|
||||
include_str!("../../../templates/comments.html"),
|
||||
),
|
||||
(
|
||||
"admin_login.html",
|
||||
include_str!("../../../templates/admin_login.html"),
|
||||
),
|
||||
];
|
||||
|
||||
pub struct Templates {
|
||||
pub tera: Tera,
|
||||
}
|
||||
|
||||
impl Templates {
|
||||
pub fn new(dir: &Path, config: &Config) -> Self {
|
||||
let dir = dir.join(&config.templates_dir);
|
||||
std::fs::create_dir_all(&dir).expect("Cannot create templates dir");
|
||||
|
||||
for &(file, default) in TEMPLATE_FILES {
|
||||
let file_path = dir.join(file);
|
||||
if !file_path.is_file() {
|
||||
std::fs::write(file_path, default)
|
||||
.unwrap_or_else(|_| panic!("Cannot write template file {file}"));
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
tera: Tera::new(dir.join("**/*").to_str().unwrap()).expect("Failed to parse templates"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CommentWithId {
|
||||
pub addr: Option<String>,
|
||||
pub author: String,
|
||||
pub editable: bool,
|
||||
pub id: String,
|
||||
pub last_edit_time: Option<Time>,
|
||||
pub needs_approval: bool,
|
||||
pub original: Option<OriginalComment>,
|
||||
pub post_time: Time,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OriginalComment {
|
||||
pub author: String,
|
||||
pub editable: bool,
|
||||
pub last_edit_time: Option<Time>,
|
||||
pub post_time: Time,
|
||||
pub text: String,
|
||||
}
|
||||
14
server/templates/admin_login.html
Normal file
14
server/templates/admin_login.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="{{ time_lang }}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>{{ tr(l=l,k="admin_login-title")|safe }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<form action="#" method="post">
|
||||
<label for="login-psw">{{ tr(l=l,k="admin_login-password_prompt")|safe }}</label>
|
||||
<input type="password" id="login-psw" name="psw"/><br/>
|
||||
<button type="submit" name="a" value="login">{{ tr(l=l,k="admin_login-submit_button")|safe }}</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
132
server/templates/comments.html
Normal file
132
server/templates/comments.html
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<!doctype html>
|
||||
<html lang="{{ time_lang }}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>{{ tr(l=l,k="title")|safe }}</title>
|
||||
</head>
|
||||
<body>
|
||||
{% if log %}
|
||||
<ul id="log">
|
||||
{% for log_msg in log %}
|
||||
<li class="log-msg">{{ log_msg | safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if comments_pending %}
|
||||
<div id="comments_pending">
|
||||
{% for comment in comments_pending %}
|
||||
<div class="comment{% if comment.needs_approval %} comment_pending{% endif %}" id="comment-{{ comment.id | safe }}">
|
||||
<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>
|
||||
{% if comment.last_edit_time %}
|
||||
<span class="comment-date">{{ comment.last_edit_time | date(format="%F %R", locale=time_lang) }}</span>
|
||||
{% endif %}
|
||||
{% if comment.editable %}
|
||||
{% if comment.original %}
|
||||
<a href="?edit_edit={{ comment.id | safe }}#edit_comment-form">{{ tr(l=l,k="admin-comment-edit-edit")|safe }}</a>
|
||||
{% else %}
|
||||
<a href="?edit={{ comment.id | safe }}#edit_comment-form">{{ tr(l=l,k="admin-comment-edit")|safe }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if admin %}
|
||||
{% if comment.needs_approval %}
|
||||
{% if comment.original %}
|
||||
<a href="?approve_edit={{ comment.id | safe }}">{{ tr(l=l,k="admin-comment-approve-edit")|safe }}</a>
|
||||
{% else %}
|
||||
<a href="?approve={{ comment.id | safe }}">{{ tr(l=l,k="admin-comment-approve")|safe }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if comment.original %}
|
||||
<a href="?remove_edit={{ comment.id | safe }}">{{ tr(l=l,k="admin-comment-remove-edit")|safe }}</a>
|
||||
{% else %}
|
||||
<a href="?remove={{ comment.id | safe }}">{{ tr(l=l,k="admin-comment-remove")|safe }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p class="comment-text">{{ comment.text }}</p>
|
||||
{% if admin and comment.original %}
|
||||
<div class="comment comment_original" id="comment-{{ comment.id | safe }}-original">
|
||||
<span class="comment-author">{{ comment.original.author }}</span>
|
||||
<span class="comment-date">{{ comment.original.post_time | date(format="%F %R", locale=time_lang) }}</span>
|
||||
{% if comment.original.last_edit_time %}
|
||||
<span class="comment-date">{{ comment.original.last_edit_time | date(format="%F %R", locale=time_lang) }}</span>
|
||||
{% endif %}
|
||||
{% if comment.editable %}
|
||||
<a href="?edit={{ comment.id | safe }}#edit_comment-form">{{ tr(l=l,k="admin-comment-edit")|safe }}</a>
|
||||
{% endif %}
|
||||
{% if admin %}
|
||||
<a href="?remove={{ comment.id | safe }}">{{ tr(l=l,k="admin-comment-remove")|safe }}</a>
|
||||
{% endif %}
|
||||
<p class="comment-text">{{ comment.original.text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="comments">
|
||||
{% for comment in comments %}
|
||||
<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-date">{{ comment.post_time | date(format="%F %R", locale=time_lang) }}</span>
|
||||
{% if comment.last_edit_time %}
|
||||
<span class="comment-date">{{ comment.last_edit_time | date(format="%F %R", locale=time_lang) }}</span>
|
||||
{% endif %}
|
||||
{% if comment.editable %}
|
||||
<a href="?edit={{ comment.id | safe }}#edit_comment-form">{{ tr(l=l,k="admin-comment-edit")|safe }}</a>
|
||||
{% endif %}
|
||||
{% if admin and comment.needs_approval %}
|
||||
<a href="?approve={{ comment.id | safe }}">{{ tr(l=l,k="admin-comment-approve")|safe }}</a>
|
||||
{% endif %}
|
||||
{% if admin %}
|
||||
<a href="?remove={{ comment.id | safe }}">{{ tr(l=l,k="admin-comment-remove")|safe }}</a>
|
||||
{% endif %}
|
||||
<p class="comment-text">{{ comment.text }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form id="new_comment-form" action="#new_comment-form" method="post">
|
||||
{% if new_comment_errors %}
|
||||
<p>{{ tr(l=l,k="error-list",nb_errors=new_comment_errors|length)|safe }}</p>
|
||||
<ul id="new_comment-errors" class="errors">
|
||||
{% for error in new_comment_errors %}
|
||||
<li class="error">{{ error | safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<label for="new_comment-author">{{ tr(l=l,k="comment_form-author")|safe }}</label>
|
||||
<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">{{ tr(l=l,k="comment_form-email")|safe }}</label>
|
||||
<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">{{ tr(l=l,k="comment_form-text")|safe }}</label><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">{{ tr(l=l,k="comment_form-new_button")|safe }}</button>
|
||||
</form>
|
||||
{% if edit_comment %}
|
||||
<form id="edit_comment-form" action="#edit_comment-form" method="post">
|
||||
{% if edit_comment_errors %}
|
||||
<p>{{ tr(l=l,k="error-list",nb_errors=edit_comment_errors|length)|safe }}</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"/>
|
||||
{% if edit_comment_mutation_token %}
|
||||
<input type="hidden" name="mutation_token" value="{{ edit_comment_mutation_token | safe }}" autocomplete="off"/>
|
||||
{% endif %}
|
||||
<label for="edit_comment-author">{{ tr(l=l,k="comment_form-author")|safe }}</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">{{ tr(l=l,k="comment_form-email")|safe }}</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">{{ tr(l=l,k="comment_form-text")|safe }}</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">{{ tr(l=l,k="comment_form-edit_button")|safe }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue