This commit is contained in:
Pascal Engélibert 2023-07-13 11:36:31 +02:00
parent 5deba2fdb1
commit 4e8f84480d
48 changed files with 6355 additions and 1192 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target /target
/webui/dist

1999
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,38 +1,7 @@
[package] [workspace]
name = "webcomment" resolver = "2"
version = "0.1.0" members = [
authors = ["tuxmain <tuxmain@zettascript.org>"] "common",
license = "AGPL-3.0-only" "server",
repository = "https://git.txmn.tk/tuxmain/webcomment" "webui",
description = "Templatable comment web server" ]
edition = "2021"
[dependencies]
argon2 = "0.4.1"
base64 = "0.21.0"
clap = { version = "4.0.32", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
crossbeam-channel = "0.5.6"
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.152", features = ["derive", "rc"] }
serde_json = "1.0.91"
sha2 = "0.10.6"
sled = "0.34.7"
tera = { version = "1.17.1", features = ["builtins", "date-locale"] }
tide = { version = "0.16.0", default-features = false, features = ["h1-server", "cookies", "logger"] }
tokio = { version = "1.24.1", features = ["macros", "rt-multi-thread"] }
toml_edit = { version = "0.17.1", features = ["easy"] }
typed-sled = "0.2.3"
unic-langid = { version = "0.9.1", features = ["macros"] }
[features]
default = ["petname"]

View file

@ -9,12 +9,28 @@ Rust webserver for comments, that you can easily embed in a website.
* List and post comments by topic (e.g. each article in your blog is a topic) * List and post comments by topic (e.g. each article in your blog is a topic)
* Admin approval * Admin approval
* Admin notification on new comment via Matrix * Admin notification on new comment via Matrix
* Embedded one-file webserver * Single-file webserver, WASM client for browsers
* Customizable [Tera](https://github.com/Keats/tera) templates * Customizable [Tera](https://github.com/Keats/tera) templates
* Comment frequency limit per IP * Comment frequency limit per IP
* i18n * i18n
* Petnames! (anonymous comment authors get a funny random name) * Petnames! (anonymous comment authors get a funny random name)
* Designed for privacy and moderation * Designed for privacy and moderation
* JSON API
## Build
```bash
# Install trunk
cargo install trunk
# Run server
cargo run --release -- start
# Build and serve client
cd webui
trunk build --release
python -m http.server -d dist
```
## Use ## Use
@ -47,16 +63,6 @@ Uses no cookie, no unique user identifier. At each mutation (i.e. new comment or
However, keep in mind that if a reverse proxy (or any other intermediate tool) is used, IP addresses and other metadata may be logged somewhere. However, keep in mind that if a reverse proxy (or any other intermediate tool) is used, IP addresses and other metadata may be logged somewhere.
## API
/api/post_comment
/api/comments_by_topic
/api/edit_comment
/api/remove_comment
/api/get_comment
/api/admin/approve_comment
/api/admin/remove_comment
## License ## License
CopyLeft 2022-2023 Pascal Engélibert [(why copyleft?)](https://txmn.tk/blog/why-copyleft/) CopyLeft 2022-2023 Pascal Engélibert [(why copyleft?)](https://txmn.tk/blog/why-copyleft/)

View file

@ -9,6 +9,7 @@
</head> </head>
<body> <body>
<div id="comments"></div> <div id="comments"></div>
<input type="button" value="Admin" onclick="webcomments['comments'].prompt_admin_psw()"/>
<script type="text/javascript">webcomment_topic("comments", "http://127.0.0.1:31720", "test");</script> <script type="text/javascript">webcomment_topic("comments", "http://127.0.0.1:31720", "test");</script>
</body> </body>
</html> </html>

278
client-js/js/webcomment.js Normal file
View file

@ -0,0 +1,278 @@
/*
CopyLeft 2022-2023 Pascal Engélibert (why copyleft? -> https://txmn.tk/blog/why-copyleft/)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.
*/
var webcomments = {};
const MODE_TOPIC = 1;// param: {topic:str}
const ORDER_BY_DATE_ASC = 1;
const ORDER_BY_DATE_DESC = 2;
const DEFAULT_CONFIG = {
default_order: ORDER_BY_DATE_ASC,
/*template_comment: `
<div class="comment" id="{{root.id}}-{{comment.id}}">
<p class="comment-meta">{{comment.author}} {{comment.post_time}}</p>
<p class="comment-text">{{comment.text}}</p>
</div>`,*/
template_pending_comment: `
<div class="comment comment-{{comment.id}}" id="{{root.id}}-pending-{{comment.id}}">
<p class="comment-meta">
<a class="comment-post_time" aria-label="Permalink" title="Permalink" href="#{{root.id}}-{{comment.id}}">{{comment.post_time}}</a>
<span class="comment-author"></span>
<span class="comment-email"></span>
<span class="comment-addr"></span>
<a class="comment-edition comment-edition-remove" href="#">Remove</a>
</p>
<p class="comment-text"></p>
</div>`,
template_approved_comment: `
<div class="comment comment-{{comment.id}}" id="{{root.id}}-{{comment.id}}">
<p class="comment-meta">
<a class="comment-post_time" aria-label="Permalink" title="Permalink" href="#{{root.id}}-{{comment.id}}">{{comment.post_time}}</a>
<span class="comment-author"></span>
<span class="comment-email"></span>
<a class="comment-edition comment-edition-remove" href="#">Remove</a>
</p>
<p class="comment-text"></p>
</div>`,
template_widget: `
<div class="comments"></div>
<form class="comment-new-form" action="#" onsubmit="event.preventDefault();post_new_comment('{{root.id}}')">
<fieldset>
<legend>New comment</legend>
<label>
Your name:
<input type="text" name="author" class="comment-form-author"/>
</label><br/>
<label>
Your email:
<input type="email" name="email" class="comment-form-email"/>
</label><br/>
<textarea class="comment-form-text" name="text"></textarea><br/>
<input type="submit" value="Post"/>
</fieldset>
</form>
`,
};
class Webcomment {
constructor(root_id, api, mode, mode_param, config) {
this.root_id = root_id;
this.api = api;
this.mode = mode;
this.mode_param = mode_param;
this.config = config;
this.root = document.getElementById(root_id);
this.root.innerHTML = config.template_widget.replaceAll("{{root.id}}", this.root_id);;
this.elem_comments = this.root.getElementsByClassName("comments")[0];
this.comments = [];
switch(mode) {
case MODE_TOPIC:
var this_ = this;
this.query_comments_by_topic(mode_param.topic, function(resp) {
this_.append_comments(resp.approved_comments);
});
break;
default:
console.log("Webcomment: invalid mode");
}
}
query_comments_by_topic(topic, success) {
if("admin_psw" in this) {
$.ajax({
method: "POST",
url: this.api+"/api/admin/comments_by_topic",
data: JSON.stringify({
admin_psw: this.admin_psw,
topic: topic,
}),
success: success,
dataType: "json",
contentType: "application/json; charset=utf-8",
});
} else {
$.ajax({
method: "POST",
url: this.api+"/api/comments_by_topic",
data: JSON.stringify({
mutation_token: "",
topic: topic,
}),
success: success,
dataType: "json",
contentType: "application/json; charset=utf-8",
});
}
}
query_new_comment(topic, author, email, text, success) {
$.ajax({
method: "POST",
url: this.api+"/api/new_comment",
data: JSON.stringify({
author: author,
email: email,
text: text,
topic: topic,
}),
success: success,
dataType: "json",
contentType: "application/json; charset=utf-8",
});
}
post_new_comment() {
var elem_author = $("#comments .comment-new-form [name=author]")[0];
var elem_email = $("#comments .comment-new-form [name=email]")[0];
var elem_text = $("#comments .comment-new-form [name=text]")[0];
switch(this.mode) {
case MODE_TOPIC:
var comment = {
topic: this.mode_param.topic,
author: elem_author.value,
email: elem_email.value,
text: elem_text.value,
};
var this_ = this;
this.query_new_comment(comment.topic, comment.author, comment.email, comment.text, function(resp) {
if(resp.id) {
comment.id = resp.id;
comment.post_time = resp.post_time;
this_.append_comments([], [comment]);
elem_text.value = "";
}
});
break;
default:
console.log("Webcomment: invalid mode");
}
}
append_comments(approved_comments, pending_comments=[]) {
var this_ = this;
for(var i in pending_comments) {
var comment = pending_comments[i];
this.comments[comment.id] = comment;
var post_time = new Date(comment.post_time*1000);
var comment_html = this.config.template_pending_comment;
comment_html = comment_html.replaceAll("{{root.id}}", this.root_id);
comment_html = comment_html.replaceAll("{{comment.id}}", comment.id);
//comment_html = comment_html.replaceAll("{{comment.author}}", comment.author);
comment_html = comment_html.replaceAll("{{comment.post_time}}", post_time.toLocaleDateString()+" "+post_time.toLocaleTimeString());
//comment_html = comment_html.replaceAll("{{comment.text}}", comment.text);
$(this.elem_comments).append(comment_html);
var elem = document.getElementById(this.root_id+"-pending-"+comment.id);
elem.getElementsByClassName("comment-author")[0].innerHTML = comment.author;
elem.getElementsByClassName("comment-text")[0].innerHTML = comment.text;
if("email" in comment)
elem.getElementsByClassName("comment-email")[0].innerHTML = comment.email;
else
elem.getElementsByClassName("comment-email")[0].remove();
if("addr" in comment)
elem.getElementsByClassName("comment-addr")[0].innerHTML = comment.addr;
else
elem.getElementsByClassName("comment-addr")[0].remove();
if(comment.editable) {
var edition_remove_elems = elem.getElementsByClassName("comment-edition-remove");
console.log(edition_remove_elems);
for(var j = 0; j < edition_remove_elems.length; j ++) {
edition_remove_elems[j].onclick = function() {
this_.remove_comment(comment.id);
};
}
} else {
var edition_elems = elem.getElementsByClassName("comment-edition");
for(var j = 0; j < edition_elems.length; j ++) {
edition_elems[j].remove();
}
}
}
for(var i in approved_comments) {
var comment = approved_comments[i];
this.comments[comment.id] = comment;
var post_time = new Date(comment.post_time*1000);
var comment_html = this.config.template_approved_comment;
comment_html = comment_html.replaceAll("{{root.id}}", this.root_id);
comment_html = comment_html.replaceAll("{{comment.id}}", comment.id);
//comment_html = comment_html.replaceAll("{{comment.author}}", comment.author);
comment_html = comment_html.replaceAll("{{comment.post_time}}", post_time.toLocaleDateString()+" "+post_time.toLocaleTimeString());
//comment_html = comment_html.replaceAll("{{comment.text}}", comment.text);
$(this.elem_comments).append(comment_html);
var elem = document.getElementById(this.root_id+"-"+comment.id);
elem.getElementsByClassName("comment-author")[0].innerHTML = comment.author;
elem.getElementsByClassName("comment-text")[0].innerHTML = comment.text;
if("email" in comment)
elem.getElementsByClassName("comment-email")[0].innerHTML = comment.email;
else
elem.getElementsByClassName("comment-email")[0].remove();
}
}
remove_comment(comment_id) {
var this_ = this;
if(this.admin_psw) {
$.ajax({
method: "POST",
url: this.api+"/api/admin/remove_comment",
data: JSON.stringify({
admin_psw: this.admin_psw,
comment_id: comment_id,
}),
success: function(resp) {
console.log(resp);
// TODO check resp
var comment_elems = this_.elem_comments.getElementsByClassName("comment-"+comment_id);
for(var j = 0; j < comment_elems.length; j ++) {
comment_elems[j].remove();
}
var comment_elems = this_.elem_comments.getElementsByClassName("comment-pending-"+comment_id);
for(var j = 0; j < comment_elems.length; j ++) {
comment_elems[j].remove();
}
this_.comments[comment_id]
},
dataType: "json",
contentType: "application/json; charset=utf-8",
});
}
}
prompt_admin_psw() {
this.admin_psw = prompt("Admin password");
if(this.admin_psw == null)
return;
switch(this.mode) {
case MODE_TOPIC:
var this_ = this;
this.query_comments_by_topic(this.mode_param.topic, function(resp) {
this_.elem_comments.innerHTML = "";
this_.append_comments(resp.approved_comments, resp.pending_comments);
});
break;
default:
console.log("Webcomment: invalid mode");
}
}
}
function webcomment_topic(root_id, api, topic, config=DEFAULT_CONFIG) {
webcomments[root_id] = (new Webcomment(root_id, api, MODE_TOPIC, {topic: topic}, config));
}
function post_new_comment(root_id) {
webcomments[root_id].post_new_comment();
}

View file

@ -1,156 +0,0 @@
/*
CopyLeft 2022-2023 Pascal Engélibert (why copyleft? -> https://txmn.tk/blog/why-copyleft/)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.
*/
var webcomments = {};
const MODE_TOPIC = 1;// param: {topic:str}
const ORDER_BY_DATE_ASC = 1;
const ORDER_BY_DATE_DESC = 2;
const DEFAULT_CONFIG = {
default_order: ORDER_BY_DATE_ASC,
/*template_comment: `
<div class="comment" id="{{root.id}}-{{comment.id}}">
<p class="comment-meta">{{comment.author}} {{comment.post_time}}</p>
<p class="comment-text">{{comment.text}}</p>
</div>`,*/
template_comment: `
<div class="comment" id="{{root.id}}-{{comment.id}}">
<p class="comment-meta">
<a class="comment-post_time" aria-label="Permalink" title="Permalink" href="#{{root.id}}-{{comment.id}}">{{comment.post_time}}</a>
<span class="comment-author"></span></p>
<p class="comment-text"></p>
</div>`,
template_widget: `
<div class="comments"></div>
<form class="comment-new-form" action="#" onsubmit="event.preventDefault();post_new_comment('{{root.id}}')">
<fieldset>
<legend>New comment</legend>
<label>
Your name:
<input type="text" name="author" class="comment-form-author"/>
</label><br/>
<label>
Your email:
<input type="email" name="email" class="comment-form-email"/>
</label><br/>
<textarea class="comment-form-text" name="text"></textarea><br/>
<input type="submit" value="Post"/>
</fieldset>
</form>
`,
};
class Webcomment {
constructor(root_id, api, mode, mode_param, config) {
this.root_id = root_id;
this.api = api;
this.mode = mode;
this.mode_param = mode_param;
this.config = config;
this.root = document.getElementById(root_id);
this.root.innerHTML = config.template_widget.replaceAll("{{root.id}}", this.root_id);;
this.elem_comments = this.root.getElementsByClassName("comments")[0];
switch(mode) {
case MODE_TOPIC:
var this_ = this;
this.query_comments_by_topic(mode_param.topic, function(resp) {
this_.append_comments(resp.comments);
});
break;
default:
console.log("Webcomment: invalid mode");
}
}
query_comments_by_topic(topic, success) {
$.ajax({
method: "POST",
url: this.api+"/api/comments_by_topic",
data: JSON.stringify({
mutation_token: "",
topic: topic,
}),
success: success,
dataType: "json",
contentType: "application/json; charset=utf-8",
});
}
query_new_comment(topic, author, email, text, success) {
$.ajax({
method: "POST",
url: this.api+"/api/new_comment",
data: JSON.stringify({
author: author,
email: email,
text: text,
topic: topic,
}),
success: success,
dataType: "json",
contentType: "application/json; charset=utf-8",
});
}
post_new_comment() {
var elem_author = $("#comments .comment-new-form [name=author]")[0];
var elem_email = $("#comments .comment-new-form [name=email]")[0];
var elem_text = $("#comments .comment-new-form [name=text]")[0];
switch(this.mode) {
case MODE_TOPIC:
var comment = {
topic: this.mode_param.topic,
author: elem_author.value,
email: elem_email.value,
text: elem_text.value,
};
var this_ = this;
this.query_new_comment(comment.topic, comment.author, comment.email, comment.text, function(resp) {
if(resp.id) {
comment.id = resp.id;
comment.post_time = resp.post_time;
this_.append_comments([comment]);
elem_text.value = "";
}
});
break;
default:
console.log("Webcomment: invalid mode");
}
}
append_comments(comments) {
for(var i in comments) {
var comment = comments[i];
var post_time = new Date(comment.post_time*1000);
var comment_html = this.config.template_comment;
comment_html = comment_html.replaceAll("{{root.id}}", this.root_id);
comment_html = comment_html.replaceAll("{{comment.id}}", comment.id);
//comment_html = comment_html.replaceAll("{{comment.author}}", comment.author);
comment_html = comment_html.replaceAll("{{comment.post_time}}", post_time.toLocaleDateString()+" "+post_time.toLocaleTimeString());
//comment_html = comment_html.replaceAll("{{comment.text}}", comment.text);
$(this.elem_comments).append(comment_html);
var elem = document.getElementById(this.root_id+"-"+comment.id);
elem.getElementsByClassName("comment-author")[0].innerHTML = comment.author;
elem.getElementsByClassName("comment-text")[0].innerHTML = comment.text;
}
}
}
function webcomment_topic(root_id, api, topic, config=DEFAULT_CONFIG) {
webcomments[root_id] = (new Webcomment(root_id, api, MODE_TOPIC, {topic: topic}, config));
}
function post_new_comment(root_id) {
webcomments[root_id].post_new_comment();
}

20
common/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "webcomment-common"
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"
[dependencies]
argon2 = "0.5.0"
base64 = "0.21.2"
log = "0.4.19"
percent-encoding = "2.3.0"
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["std"] }
serde = { version = "1.0.171", features = ["derive", "rc"] }
serde_json = "1.0.100"
sha2 = "0.10.7"
unic-langid = { version = "0.9.1", features = ["macros"] }

2
common/src/api.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod queries;
pub mod resps;

34
common/src/api/queries.rs Normal file
View file

@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
/*#[derive(Deserialize)]
pub struct Admin<T> {
pub admin_psw: String,
#[serde(flatten)]
pub query: T,
}*/
#[derive(Deserialize, Serialize)]
pub struct CommentsByTopic {
pub mutation_token: Option<String>,
pub topic: String,
}
#[derive(Deserialize, Serialize)]
pub struct CommentsByTopicAdmin {
pub admin_psw: String,
pub topic: String,
}
#[derive(Deserialize, Serialize)]
pub struct NewComment {
pub author: String,
pub email: String,
pub text: String,
pub topic: String,
}
#[derive(Deserialize, Serialize)]
pub struct RemoveCommentAdmin {
pub admin_psw: String,
pub comment_id: String,
}

90
common/src/api/resps.rs Normal file
View file

@ -0,0 +1,90 @@
use crate::types::*;
use serde::{Deserialize, Serialize};
/*/// Use Ok only when there is no dedicated struct
/// (because serde doesn't allow flattening enums)
#[derive(Debug, Serialize)]
pub enum Result {
Ok,
#[serde(rename = "error")]
Err(Error),
}*/
pub type Result = std::result::Result<Response, Error>;
#[derive(Debug, Deserialize, Serialize)]
pub enum Error {
Antispam {
timeout: Time,
},
BadAdminAuth,
IllegalContent,
Internal,
InvalidRequest,
/// Admin only! Error messages may contain sensitive information.
Message(String),
}
#[derive(Debug, Deserialize, Serialize)]
pub enum Response {
CommentsByTopic(CommentsByTopic),
CommentsByTopicAdmin(CommentsByTopicAdmin),
NewComment(NewComment),
Ok,
}
/*#[derive(Serialize)]
pub struct GenericOk {}*/
#[derive(Debug, Deserialize, Serialize)]
pub struct CommentsByTopic {
pub approved_comments: Vec<ApprovedCommentWithMeta>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CommentsByTopicAdmin {
pub approved_comments: Vec<ApprovedCommentWithMeta>,
pub pending_comments: Vec<PendingCommentWithMeta>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OriginalComment {
pub author: String,
pub editable: bool,
pub last_edit_time: Option<Time>,
pub post_time: Time,
pub text: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ApprovedCommentWithMeta {
pub email: Option<String>,
pub author: String,
pub editable: bool,
pub id: String,
pub last_edit_time: Option<Time>,
pub post_time: Time,
pub status: CommentStatus,
pub text: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PendingCommentWithMeta {
pub addr: Option<String>,
pub email: Option<String>,
pub author: String,
pub editable: bool,
pub id: String,
pub last_edit_time: Option<Time>,
pub post_time: Time,
pub status: CommentStatus,
pub text: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct NewComment {
pub id: String,
pub mutation_token: String,
pub post_time: Time,
}

2
common/src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod api;
pub mod types;

View file

@ -1,14 +1,6 @@
use base64::engine::Engine; use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{net::IpAddr, path::Path};
pub use sled::transaction::{
ConflictableTransactionError, ConflictableTransactionResult, TransactionError,
};
pub use typed_sled::Tree;
const DB_DIR: &str = "db";
pub type Time = u64; pub type Time = u64;
@ -18,56 +10,15 @@ pub const BASE64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::NO_PAD, base64::engine::general_purpose::NO_PAD,
); );
#[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"),
}
}
#[repr(u8)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum CommentStatus {
Pending = 0,
Approved = 1,
ApprovedEdited(Comment) = 2,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Comment {
pub author: String,
pub email: Option<String>,
pub last_edit_time: Option<u64>,
pub mutation_token: MutationToken,
pub post_time: u64,
pub text: String,
pub topic_hash: TopicHash,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct MutationToken(pub [u8; 16]); pub struct MutationToken(pub [u8; 16]);
impl Default for MutationToken {
fn default() -> Self {
Self([0; 16])
}
}
impl MutationToken { impl MutationToken {
pub fn new() -> Self { pub fn new() -> Self {
Self(rand::random()) Self(rand::random())
@ -96,6 +47,12 @@ impl AsRef<[u8]> for MutationToken {
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct TopicHash(pub [u8; 32]); pub struct TopicHash(pub [u8; 32]);
impl Default for TopicHash {
fn default() -> Self {
Self([0; 32])
}
}
impl TopicHash { impl TopicHash {
pub fn from_topic(topic: &str) -> Self { pub fn from_topic(topic: &str) -> Self {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
@ -113,6 +70,12 @@ impl AsRef<[u8]> for TopicHash {
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct CommentId(pub [u8; 16]); pub struct CommentId(pub [u8; 16]);
impl Default for CommentId {
fn default() -> Self {
Self([0; 16])
}
}
impl CommentId { impl CommentId {
pub fn new() -> Self { pub fn new() -> Self {
Self(rand::random()) Self(rand::random())
@ -146,34 +109,21 @@ impl AsRef<[u8]> for CommentId {
} }
} }
#[cfg(test)] #[repr(u8)]
mod test { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
use super::*; pub enum CommentStatus {
Pending = 0,
#[test] Approved = 1,
fn test_typed_sled() { ApprovedEdited(Comment) = 2,
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] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
fn test_comment_id_base64() { pub struct Comment {
for _ in 0..10 { pub author: String,
let comment_id = CommentId::new(); pub email: Option<String>,
assert_eq!( pub last_edit_time: Option<u64>,
CommentId::from_base64(&comment_id.to_base64()), pub mutation_token: MutationToken,
Ok(comment_id) pub post_time: u64,
); pub text: String,
} pub topic_hash: TopicHash,
}
#[test]
fn test_from_base64_dont_panic() {
assert_eq!(CommentId::from_base64("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), Err(base64::DecodeError::InvalidLength));
}
} }

3523
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

41
server/Cargo.toml Normal file
View 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.2"
clap = { version = "4.3.11", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
crossbeam-channel = "0.5.8"
directories = "5.0.1"
fluent-bundle = "0.15.2"
fluent-langneg = "0.13.0"
intl-memoizer = "0.5.1"
log = "0.4.19"
matrix-sdk = { version = "0.6.2", default-features = false, features = ["rustls-tls"] }
percent-encoding = "2.3.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.171", features = ["derive", "rc"] }
serde_json = "1.0.100"
sha2 = "0.10.7"
sled = "0.34.7"
tera = { version = "1.19.0", features = ["builtins", "date-locale"] }
tide = { version = "0.16.0", default-features = false, features = ["h1-server", "cookies", "logger"] }
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread"] }
toml = "0.7.6"
typed-sled = "0.2.3"
unic-langid = { version = "0.9.1", features = ["macros"] }
[features]
default = ["petname"]

View file

@ -131,13 +131,13 @@ impl Config {
3600 3600
} }
fn default_matrix_room() -> String { fn default_matrix_room() -> String {
"#maintenance:matrix.txmn.tk".into() "#maintenance:txmn.tk".into()
} }
fn default_matrix_server() -> String { fn default_matrix_server() -> String {
"https://matrix.txmn.tk".into() "https://txmn.tk".into()
} }
fn default_matrix_user() -> String { fn default_matrix_user() -> String {
"@tuxmain:matrix.txmn.tk".into() "@tuxmain:txmn.tk".into()
} }
fn default_public_address() -> String { fn default_public_address() -> String {
"http://127.0.0.1:31720".into() "http://127.0.0.1:31720".into()
@ -190,11 +190,11 @@ pub fn read_config(dir: &Path) -> Config {
if !path.is_file() { if !path.is_file() {
let config = Config::default(); let config = Config::default();
std::fs::write(path, toml_edit::easy::to_string_pretty(&config).unwrap()) std::fs::write(path, toml::to_string_pretty(&config).unwrap())
.expect("Cannot write config file"); .expect("Cannot write config file");
config config
} else { } else {
toml_edit::easy::from_str( toml::from_str(
std::str::from_utf8(&std::fs::read(path).expect("Cannot read config file")) std::str::from_utf8(&std::fs::read(path).expect("Cannot read config file"))
.expect("Bad encoding in config file"), .expect("Bad encoding in config file"),
) )
@ -204,6 +204,6 @@ pub fn read_config(dir: &Path) -> Config {
pub fn write_config(dir: &Path, config: &Config) { pub fn write_config(dir: &Path, config: &Config) {
let path = dir.join(CONFIG_FILE); let path = dir.join(CONFIG_FILE);
std::fs::write(path, toml_edit::easy::to_string_pretty(&config).unwrap()) std::fs::write(path, toml::to_string_pretty(&config).unwrap())
.expect("Cannot write config file"); .expect("Cannot write config file");
} }

70
server/src/db.rs Normal file
View 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));
}
}

View file

@ -1,4 +1,5 @@
use crate::{config::Config, db::*, locales::Locales}; use crate::{config::Config, db::*, locales::Locales};
use webcomment_common::types::*;
use fluent_bundle::FluentArgs; use fluent_bundle::FluentArgs;
use log::error; use log::error;

View file

@ -23,6 +23,7 @@ pub struct Locales {
impl Locales { impl Locales {
pub fn new(config: &Config) -> Self { pub fn new(config: &Config) -> Self {
let mut langs = Vec::new(); let mut langs = Vec::new();
#[allow(suspicious_double_ref_op)]
Self { Self {
bundles: LOCALE_FILES bundles: LOCALE_FILES
.iter() .iter()

283
server/src/server/api.rs Normal file
View 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))
}
}
}

View file

@ -7,6 +7,7 @@ use super::{check_admin_password, check_admin_password_hash};
use crate::{config::*, db::*, helpers, locales::*, notify::Notification}; use crate::{config::*, db::*, helpers, locales::*, notify::Notification};
use queries::*; use queries::*;
use templates::*; use templates::*;
use webcomment_common::types::*;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use fluent_bundle::FluentArgs; use fluent_bundle::FluentArgs;
@ -685,7 +686,7 @@ async fn handle_post_admin(
dbs: Dbs, dbs: Dbs,
) -> tide::Result<tide::Response> { ) -> tide::Result<tide::Response> {
if let Some(psw) = req.cookie("admin") { if let Some(psw) = req.cookie("admin") {
if check_admin_password(config, &String::from(psw.value())).is_some() { if check_admin_password_hash(config, &String::from(psw.value())) {
#[allow(clippy::match_single_binding)] #[allow(clippy::match_single_binding)]
match req.body_form::<AdminQuery>().await? { match req.body_form::<AdminQuery>().await? {
_ => { _ => {

View file

@ -1,4 +1,5 @@
use crate::{config::Config, db::*}; use crate::config::Config;
use webcomment_common::types::*;
use serde::Serialize; use serde::Serialize;
use std::path::Path; use std::path::Path;

View file

@ -1,197 +0,0 @@
mod queries;
mod resps;
use crate::{config::*, db::*, helpers, notify::Notification};
use crossbeam_channel::Sender;
use log::{error, warn};
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/new_comment", config.root_url))
.post({
move |req: tide::Request<()>| {
query_new_comment(req, config, dbs.clone(), notify_send.clone())
}
});
}
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 Ok(tide::Response::builder(400)
.content_type(tide::http::mime::JSON)
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
.body(
tide::Body::from_json(&resps::Error::InvalidRequest).unwrap(),
)
.build());
};
let topic_hash = TopicHash::from_topic(&topic);
Ok(tide::Response::builder(200)
.content_type(tide::http::mime::JSON)
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
.body(
tide::Body::from_json(&resps::CommentsByTopic {
comments: helpers::iter_approved_comments_by_topic(topic_hash, &dbs)
.map(
|(comment_id, comment, _comment_status)| resps::CommentWithId {
addr: None,
author: comment.author,
editable: false,
id: comment_id.to_base64(),
last_edit_time: comment.last_edit_time,
post_time: comment.post_time,
status: None,
text: comment.text,
},
)
.collect::<Vec<resps::CommentWithId>>(),
})
.map_err(|e| {
error!("Serializing CommentsByTopicResp to json: {e:?}");
tide::Error::from_str(500, "Internal server error")
})?,
)
.build())
}
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 Ok(tide::Response::builder(400)
.content_type(tide::http::mime::JSON)
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
.body(
tide::Body::from_json(&resps::Error::InvalidRequest).unwrap(),
)
.build());
};
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 Ok(tide::Response::builder(400)
.content_type(tide::http::mime::JSON)
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
.body(tide::Body::from_json(&resps::Error::IllegalContent).unwrap())
.build());
}
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 Ok(tide::Response::builder(403)
.content_type(tide::http::mime::JSON)
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
.body(
tide::Body::from_json(&resps::Error::Antispam {
timeout: antispam_timeout,
})
.unwrap(),
)
.build());
}
}
}
// 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();
Ok(tide::Response::builder(200)
.content_type(tide::http::mime::JSON)
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
.body(
tide::Body::from_json(&resps::NewComment {
id: comment_id.to_base64(),
mutation_token: comment.mutation_token.to_base64(),
post_time: time,
})
.unwrap(),
)
.build())
}
// TODO add message to client log and change http code
Err(e) => {
error!("Adding pending comment: {:?}", e);
Ok(tide::Response::builder(500)
.content_type(tide::http::mime::JSON)
.header("Access-Control-Allow-Origin", &config.cors_allow_origin)
.body(tide::Body::from_json(&resps::Error::Internal).unwrap())
.build())
}
}
}

View file

@ -1,15 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CommentsByTopic {
pub mutation_token: Option<String>,
pub topic: String,
}
#[derive(Deserialize)]
pub struct NewComment {
pub author: String,
pub email: String,
pub text: String,
pub topic: String,
}

View file

@ -1,44 +0,0 @@
use crate::db::*;
use serde::Serialize;
#[derive(Serialize)]
pub enum Error {
Antispam { timeout: Time },
IllegalContent,
Internal,
InvalidRequest,
}
#[derive(Serialize)]
pub struct CommentsByTopic {
pub comments: Vec<CommentWithId>,
}
#[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,
}
#[derive(Serialize)]
pub struct CommentWithId {
pub addr: Option<String>,
pub author: String,
pub editable: bool,
pub id: String,
pub last_edit_time: Option<Time>,
pub status: Option<OriginalComment>,
pub post_time: Time,
pub text: String,
}
#[derive(Serialize)]
pub struct NewComment {
pub id: String,
pub mutation_token: String,
pub post_time: Time,
}

2
webui/.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[build]
target = "wasm32-unknown-unknown"

29
webui/Cargo.toml Normal file
View file

@ -0,0 +1,29 @@
cargo-features = ["per-package-target"]
[package]
name = "webcomment-webui"
version = "0.1.0"
authors = ["tuxmain <tuxmain@zettascript.org>"]
license = "AGPL-3.0-only"
repository = "https://git.txmn.tk/tuxmain/webcomment"
description = "Comment web client"
edition = "2021"
forced-target = "wasm32-unknown-unknown"
[lib]
crate-type = ["cdylib"]
[dependencies]
webcomment-common = { path = "../common" }
getrandom = { version = "0.2.10", features = ["js"] }
gloo = "0.8"
js-sys = "0.3"
lazy_static = "1.4.0"
parking_lot = "0.12.1"
serde = { version = "1.0.171", features = ["derive", "rc"] }
serde_json = "1.0.100"
yew = { version = "0.20.0", features = ["csr"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4.37"
web-sys = { version = "0.3.64", features = ["HtmlFormElement"] }

19
webui/index.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Webcomment</title>
<style type="text/css">
@media (prefers-color-scheme: dark) {
html, input, textarea {
background-color: black;
color: white;
}
a, a:visited {
color: #f80;
}
}
</style>
</head>
<body></body>
</html>

118
webui/src/api.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::types::*;
use webcomment_common::{api::*, types::*};
use gloo::{console, net::http};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
pub struct ApiInner {
pub admin_psw: Option<String>,
pub comments: HashMap<CommentId, StoredComment>,
/// Comments that are not yet attributed CommentId by the server
pub local_comments: HashMap<CommentId, StoredComment>,
pub url: String,
}
#[derive(Clone)]
pub struct Api {
pub inner: Arc<RwLock<ApiInner>>,
}
impl Api {
pub async fn get_comments_by_topic(&self, topic: String) {
match http::Request::post(&format!("{}/api/comments_by_topic", &self.inner.read().url))
.body(
serde_json::to_string(&queries::CommentsByTopic {
mutation_token: None,
topic,
})
.unwrap(),
)
.unwrap()
.send()
.await
{
Ok(resp) => {
let Ok(Ok(resps::Response::CommentsByTopic(resp))) = resp.json::<resps::Result>().await else {
// TODO error
return;
};
let mut inner = self.inner.write();
for comment in resp.approved_comments {
let Ok(comment_id) = CommentId::from_base64(&comment.id) else {
continue
};
inner.comments.insert(
comment_id,
StoredComment {
author: comment.author,
email: comment.email,
last_edit_time: comment.last_edit_time,
post_time: comment.post_time,
text: comment.text,
},
);
}
}
Err(e) => console::log!("get_comments_by_topic: {}", e.to_string()),
}
}
pub async fn new_comment(&self, new_comment: StoredComment, topic: String) {
let local_id = CommentId::new();
self.inner
.write()
.local_comments
.insert(local_id.clone(), new_comment.clone());
match http::Request::post(&format!("{}/api/new_comment", &self.inner.read().url))
.body(
serde_json::to_string(&queries::NewComment {
author: new_comment.author,
topic,
email: new_comment.email.unwrap_or_default(),
text: new_comment.text,
})
.unwrap(),
)
.unwrap()
.send()
.await
{
Ok(resp) => {
let Ok(Ok(resps::Response::NewComment(resp))) = resp.json::<resps::Result>().await else {
// TODO error
return;
};
let mut inner = self.inner.write();
let Ok(comment_id) = CommentId::from_base64(&resp.id) else {
// TODO error
return;
};
let Some(comment) = inner.local_comments.remove(&local_id) else {
// TODO error
return;
};
inner.comments.insert(comment_id, comment);
}
Err(e) => console::log!("get_comments_by_topic: {}", e.to_string()),
}
}
}
impl PartialEq<Api> for Api {
fn eq(&self, rhs: &Self) -> bool {
true
}
}
/*pub enum Msg {
NewComment {
new_comment: StoredComment, topic: String
}
}
pub async fn msg_handler(yew::platform::pinned::mpsc::) {
}*/

9
webui/src/components.rs Normal file
View file

@ -0,0 +1,9 @@
pub mod admin_login_form;
pub mod comment;
pub mod comments;
pub mod new_comment_form;
pub use admin_login_form::*;
pub use comment::*;
pub use comments::*;
pub use new_comment_form::*;

View file

@ -0,0 +1,69 @@
use crate::api::Api;
use gloo::console;
use web_sys::HtmlFormElement;
use yew::{html, Component, Context, Html, Properties, SubmitEvent, html::TargetCast};
pub struct AdminLoginFormComponent {}
#[derive(Properties, PartialEq)]
pub struct AdminLoginFormProps {
pub root_id: String, // TODO maybe opti
pub api: Api,
}
pub enum Msg {
Login(HtmlFormElement)
}
impl Component for AdminLoginFormComponent {
type Message = Msg;
type Properties = AdminLoginFormProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Login(form) => {
console::log!("{:?}", &form);
let formdata = web_sys::FormData::new_with_form(&form).unwrap();
console::log!("{:?}", &formdata);
let password = formdata.get("password").as_string().unwrap();
let mut api = ctx.props().api.inner.write();
api.admin_psw = if password.is_empty() {
None
} else {
Some(password)
};
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let elem_id = format!("{}-admin_login_form", props.root_id);
html! {
<form
id={ elem_id }
method="post"
action="#"
onsubmit={ctx.link().callback(|event: SubmitEvent| {
event.prevent_default();
Msg::Login(event.target_unchecked_into())
})}
>
<fieldset>
<legend>{ "Admin Login" }</legend>
<label>
{ "Password:" }
<input type="password" name="password"/>
</label><br/>
<input type="submit" value="Login"/>
</fieldset>
</form>
}
}
}

View file

@ -0,0 +1,50 @@
use crate::types::*;
use yew::{html, Component, Context, Html, Properties};
pub struct CommentComponent {}
#[derive(Properties, PartialEq)]
pub struct CommentProps {
pub root_id: String, // TODO maybe opti
pub comment: FullComment,
}
pub enum Msg {
}
impl Component for CommentComponent {
type Message = Msg;
type Properties = CommentProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {}
}
fn update(&mut self, _ctx: &Context<Self>, _msg: Self::Message) -> bool {
false
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let comment_id = props.comment.id.to_base64();
let elem_id = format!("{}-{}", props.root_id, comment_id);
html! {
<div class={ format!("comment comment-{}", comment_id) } id={ elem_id.clone() }>
<p class="comment-meta">
<a class="comment-post_time" aria-label="Permalink" title="Permalink" href={ format!("#{elem_id}") }>{ props.comment.post_time }</a>
<span class="comment-author"></span>
{
if let Some(email) = &props.comment.email {
html! { <span class="comment-email">{ email }</span> }
} else {
html! {}
}
}
<a class="comment-edition comment-edition-remove" href="#">{ "Remove" }</a>
</p>
<p class="comment-text">{ &props.comment.text }</p>
</div>
}
}
}

View file

@ -0,0 +1,49 @@
use crate::{types::*, components::comment::*, api::*};
use webcomment_common::types::*;
use yew::{html, Component, Context, Html, Properties};
pub struct CommentsComponent {}
#[derive(Properties, PartialEq)]
pub struct CommentsProps {
pub root_id: String, // TODO maybe opti
pub api: Api,
}
pub enum Msg {
}
impl Component for CommentsComponent {
type Message = Msg;
type Properties = CommentsProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {}
}
fn update(&mut self, _ctx: &Context<Self>, _msg: Self::Message) -> bool {
false
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let comment_props = CommentProps {
root_id: String::from("comments"),
comment: FullComment {
author: String::from("Toto"),
email: Some(String::from("toto@fai.tld")),
id: CommentId::new(),
last_edit_time: Some(123),
post_time: 42,
text: String::from("Bonjour"),
},
};
let element_id = format!("{}-comments", props.root_id);
html! {
<div id={ element_id }>
<CommentComponent ..comment_props />
</div>
}
}
}

View file

@ -0,0 +1,93 @@
use crate::{types::*, api::Api};
use gloo::console;
use wasm_bindgen::JsValue;
use web_sys::HtmlFormElement;
use yew::{html, Callback, Component, Context, Html, Properties, SubmitEvent, html::TargetCast};
pub struct NewCommentFormComponent {}
#[derive(Properties, PartialEq)]
pub struct NewCommentFormProps {
pub root_id: String, // TODO maybe opti
pub api: Api,
pub topic: String,
pub comment: NotSentComment,
}
pub enum Msg {
Submit(HtmlFormElement)
}
impl Component for NewCommentFormComponent {
type Message = Msg;
type Properties = NewCommentFormProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Submit(form) => {
console::log!("{:?}", &form);
let formdata = web_sys::FormData::new_with_form(&form).unwrap();
console::log!("{:?}", &formdata);
let email = formdata.get("email").as_string().unwrap();
let api = ctx.props().api.clone();
let topic = ctx.props().topic.clone();
yew::platform::spawn_local(async move {
let email_trimmed = email.trim();
api.new_comment(
StoredComment {
author: formdata.get("author").as_string().unwrap(),
email: if email_trimmed.is_empty() {None} else {Some(email_trimmed.to_string())},
last_edit_time: None,
post_time: 0,// TODO
text: formdata.get("text").as_string().unwrap()
},
topic,
).await;
});
// TODO move req to dedicated async part
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let elem_id = format!("{}-new_comment_form", props.root_id);
html! {
<form
id={ elem_id }
method="post"
action="#"
onsubmit={ctx.link().callback(|event: SubmitEvent| {
event.prevent_default();
Msg::Submit(event.target_unchecked_into())
})}
>
<fieldset>
<legend>{ "New comment" }</legend>
<label>
{ "Your name:" }
<input type="text" name="author" class="comment-form-author"/>
</label><br/>
<label>
{ "Your email:" }
<input type="email" name="email" class="comment-form-email"/>
</label><br/>
<textarea class="comment-form-text" name="text"></textarea><br/>
<input type="submit" value="Post"/>
</fieldset>
</form>
}
}
}
/*impl NewCommentFormComponent {
fn submit(&mut self, element: HtmlElement) {
}
}*/

109
webui/src/lib.rs Normal file
View file

@ -0,0 +1,109 @@
mod api;
mod components;
mod types;
use gloo::console;
use js_sys::Date;
use parking_lot::RwLock;
use wasm_bindgen::prelude::*;
use webcomment_common::types::*;
use yew::{html, Component, Context, Html, Properties};
use std::sync::Arc;
use crate::{components::{*, admin_login_form::AdminLoginFormProps}, types::*};
pub enum Msg {
Increment,
Decrement,
}
#[derive(Properties, PartialEq)]
pub struct AppProps {
root_id: String,
api: api::Api,
}
/*impl Default for AppProps {
fn default() -> Self {
Self {
root_id: String::from("comments"),
}
}
}*/
pub struct App {
value: i64,
}
impl Component for App {
type Message = Msg;
type Properties = AppProps;
fn create(_ctx: &Context<Self>) -> Self {
Self { value: 0 }
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Increment => {
self.value += 1;
console::log!("plus one"); // Will output a string to the browser console
true // Return true to cause the displayed change to update
}
Msg::Decrement => {
self.value -= 1;
console::log!("minus one");
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let comments_props = CommentsProps {
root_id: String::from("comments"),
api: props.api.clone(),
};
let new_comment_form_props = NewCommentFormProps {
root_id: String::from("comments"),
api: props.api.clone(),
topic: String::from("test"),
comment: Default::default(),
};
let admin_login_form_props = AdminLoginFormProps {
root_id: String::from("comments"),
api: props.api.clone(),
};
html! {
<div id={ props.root_id.clone() }>
<CommentsComponent ..comments_props />
<NewCommentFormComponent ..new_comment_form_props />
<AdminLoginFormComponent ..admin_login_form_props />
</div>
}
}
}
#[wasm_bindgen(start)]
async fn main_js() {
let api = api::Api {
inner: Arc::new(RwLock::new(api::ApiInner { admin_psw: None, comments: Default::default(), url: "http://127.0.0.1:31720".into(), local_comments: Default::default() }))
};
/*let (tx, mut rx) = yew::platform::pinned::mpsc::unbounded::<AttrValue>();
// A thread is needed because of async
spawn_local(async move {
while let Some(msg) = rx.next().await {
sleep(ONE_SEC).await;
let score = joke.len() as i16;
fun_score_cb.emit(score);
}
});*/
/*api.get_comments_by_topic("test".into()).await;*/
yew::Renderer::<App>::with_props(AppProps {
root_id: String::from("comments"),
api,
}).render();
}

29
webui/src/types.rs Normal file
View file

@ -0,0 +1,29 @@
use webcomment_common::types::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct NotSentComment {
pub author: String,
pub email: String,
pub text: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct StoredComment {
pub author: String,
pub email: Option<String>,
pub last_edit_time: Option<u64>,
pub post_time: u64,
pub text: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct FullComment {
pub author: String,
pub email: Option<String>,
pub id: CommentId,
pub last_edit_time: Option<u64>,
pub post_time: u64,
pub text: String,
}