wip
This commit is contained in:
parent
5deba2fdb1
commit
9fe5d3c70e
45 changed files with 5311 additions and 815 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
|||
/target
|
||||
/webui/dist
|
||||
|
|
926
Cargo.lock
generated
926
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
44
Cargo.toml
44
Cargo.toml
|
@ -1,38 +1,6 @@
|
|||
[package]
|
||||
name = "webcomment"
|
||||
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.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"]
|
||||
[workspace]
|
||||
members = [
|
||||
"common",
|
||||
"server",
|
||||
"webui",
|
||||
]
|
||||
|
|
27
README.md
27
README.md
|
@ -9,12 +9,27 @@ 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)
|
||||
* Admin approval
|
||||
* 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
|
||||
* Comment frequency limit per IP
|
||||
* i18n
|
||||
* Petnames! (anonymous comment authors get a funny random name)
|
||||
* 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 serve --release
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
|
@ -47,16 +62,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.
|
||||
|
||||
## 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
|
||||
|
||||
CopyLeft 2022-2023 Pascal Engélibert [(why copyleft?)](https://txmn.tk/blog/why-copyleft/)
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
0
client/js/jquery.js → client-js/js/jquery.js
vendored
0
client/js/jquery.js → client-js/js/jquery.js
vendored
278
client-js/js/webcomment.js
Normal file
278
client-js/js/webcomment.js
Normal 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();
|
||||
}
|
|
@ -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
20
common/Cargo.toml
Normal 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.4.1"
|
||||
base64 = "0.21.0"
|
||||
log = "0.4.17"
|
||||
percent-encoding = "2.2.0"
|
||||
rand = "0.8.5"
|
||||
rand_core = { version = "0.6.4", features = ["std"] }
|
||||
serde = { version = "1.0.154", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.94"
|
||||
sha2 = "0.10.6"
|
||||
unic-langid = { version = "0.9.1", features = ["macros"] }
|
2
common/src/api.rs
Normal file
2
common/src/api.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod queries;
|
||||
pub mod resps;
|
34
common/src/api/queries.rs
Normal file
34
common/src/api/queries.rs
Normal 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
90
common/src/api/resps.rs
Normal 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
2
common/src/lib.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod api;
|
||||
pub mod types;
|
|
@ -1,14 +1,6 @@
|
|||
use base64::engine::Engine;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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;
|
||||
|
||||
|
@ -18,53 +10,6 @@ pub const BASE64: base64::engine::general_purpose::GeneralPurpose =
|
|||
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)]
|
||||
pub struct MutationToken(pub [u8; 16]);
|
||||
|
||||
|
@ -146,34 +91,21 @@ impl AsRef<[u8]> for CommentId {
|
|||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
#[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,
|
||||
}
|
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"]
|
|
@ -190,11 +190,11 @@ pub fn read_config(dir: &Path) -> Config {
|
|||
|
||||
if !path.is_file() {
|
||||
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");
|
||||
config
|
||||
} else {
|
||||
toml_edit::easy::from_str(
|
||||
toml::from_str(
|
||||
std::str::from_utf8(&std::fs::read(path).expect("Cannot read 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) {
|
||||
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");
|
||||
}
|
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));
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
use crate::{config::Config, db::*, locales::Locales};
|
||||
use webcomment_common::types::*;
|
||||
|
||||
use fluent_bundle::FluentArgs;
|
||||
use log::error;
|
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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ 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;
|
||||
|
@ -685,7 +686,7 @@ async fn handle_post_admin(
|
|||
dbs: Dbs,
|
||||
) -> tide::Result<tide::Response> {
|
||||
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)]
|
||||
match req.body_form::<AdminQuery>().await? {
|
||||
_ => {
|
|
@ -1,4 +1,5 @@
|
|||
use crate::{config::Config, db::*};
|
||||
use crate::config::Config;
|
||||
use webcomment_common::types::*;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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
2
webui/.cargo/config.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
target = "wasm32-unknown-unknown"
|
27
webui/Cargo.toml
Normal file
27
webui/Cargo.toml
Normal file
|
@ -0,0 +1,27 @@
|
|||
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.8", features = ["js"] }
|
||||
gloo = "0.8"
|
||||
js-sys = "0.3"
|
||||
parking_lot = "0.12.1"
|
||||
serde = { version = "1.0.154", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.94"
|
||||
yew = { version = "0.20.0", features = ["csr"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4.34"
|
8
webui/index.html
Normal file
8
webui/index.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Webcomment</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
57
webui/src/api.rs
Normal file
57
webui/src/api.rs
Normal file
|
@ -0,0 +1,57 @@
|
|||
use crate::types::*;
|
||||
|
||||
use webcomment_common::{api::*, types::*};
|
||||
|
||||
use gloo::{console, net::http};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct ApiInner {
|
||||
pub admin_psw: Option<String>,
|
||||
pub comments: HashMap<CommentId, StoredComment>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
pub struct Api {
|
||||
pub inner: 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(),
|
||||
)
|
||||
.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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
3
webui/src/components.rs
Normal file
3
webui/src/components.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod comment;
|
||||
|
||||
pub use comment::*;
|
47
webui/src/components/comment.rs
Normal file
47
webui/src/components/comment.rs
Normal file
|
@ -0,0 +1,47 @@
|
|||
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,
|
||||
}
|
||||
|
||||
impl Component for CommentComponent {
|
||||
type Message = ();
|
||||
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>
|
||||
}
|
||||
}
|
||||
}
|
89
webui/src/lib.rs
Normal file
89
webui/src/lib.rs
Normal file
|
@ -0,0 +1,89 @@
|
|||
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 crate::{components::*, types::*};
|
||||
|
||||
pub enum Msg {
|
||||
Increment,
|
||||
Decrement,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AppProps {
|
||||
root_id: String,
|
||||
}
|
||||
|
||||
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 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"),
|
||||
},
|
||||
};
|
||||
html! {
|
||||
<div id={ props.root_id.clone() }>
|
||||
<CommentComponent ..comment_props />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
async fn main_js() {
|
||||
/*let api = api::Api {
|
||||
inner: RwLock::new(api::ApiInner { admin_psw: None, comments: Default::default(), url: "http://127.0.0.1:31720".into() })
|
||||
};
|
||||
api.get_comments_by_topic("test".into()).await;*/
|
||||
yew::Renderer::<App>::new().render();
|
||||
}
|
||||
|
||||
fn main() {}
|
22
webui/src/types.rs
Normal file
22
webui/src/types.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use webcomment_common::types::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[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,
|
||||
}
|
Loading…
Reference in a new issue