1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2025-01-23 16:35:48 +00:00

Implement comments for articles (fixes #10) (#112)

* Move files into subfolders

* Implement comments for articles (fixes #10)

* wip

* backend mostly done

* tests wip

* test working

* wip federation

* partial federation

* comment federation working!

* federation and tests working with delete

* wip frontend

* basic comment rendering

* various

* wip comment tree rendering

* all working

* update rust

* comment markdown

* only one comment editor at a time

* display comment creation time

* fedilink

* live handling of delete/restore

* comment editing
This commit is contained in:
Nutomic 2025-01-21 11:39:10 +00:00 committed by GitHub
parent cd0a9323fd
commit 54c65e7474
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 2025 additions and 233 deletions

View file

@ -1,5 +1,5 @@
variables:
- &rust_image "rust:1.81"
- &rust_image "rust:1.84"
- &install_binstall "wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz && tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz && cp cargo-binstall /usr/local/cargo/bin"
- &install_cargo_leptos "cargo-binstall -y cargo-leptos@0.2.24"

47
Cargo.lock generated
View file

@ -5,8 +5,7 @@ version = 4
[[package]]
name = "activitypub_federation"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee819cada736b6e26c59706f9e6ff89a48060e635c0546ff984d84baefc8c13a"
source = "git+https://github.com/LemmyNet/activitypub-federation-rust.git?branch=verify_is_remote_object#4aa9658710d3e43d38f7ecce386ed5c70700aeda"
dependencies = [
"activitystreams-kinds",
"async-trait",
@ -2037,6 +2036,7 @@ dependencies = [
"sha2",
"smart-default",
"time",
"timeago",
"tokio",
"tower 0.5.2",
"tower-http",
@ -2268,6 +2268,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "isolang"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe50d48c77760c55188549098b9a7f6e37ae980c586a24693d6b01c3b2010c3c"
dependencies = [
"phf",
]
[[package]]
name = "itertools"
version = "0.10.5"
@ -3239,6 +3248,24 @@ dependencies = [
"sha2",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.8"
@ -4232,6 +4259,12 @@ dependencies = [
"time",
]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.9"
@ -4606,6 +4639,16 @@ dependencies = [
"time-core",
]
[[package]]
name = "timeago"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1710e589de0a76aaf295cd47a6699f6405737dbfd3cf2b75c92d000b548d0e6"
dependencies = [
"chrono",
"isolang",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"

View file

@ -64,6 +64,7 @@ web-sys = "0.3.77"
http = "1.2.0"
serde_urlencoded = "0.7.1"
github-slugger = "0.1.0"
timeago = "0.4.2"
# backend-only deps
[target.'cfg(not(target_family = "wasm"))'.dependencies]
@ -76,7 +77,7 @@ tower-http = { version = "0.6.2", features = [
"fs",
"compression-full",
] }
activitypub_federation = { version = "0.6.1", features = [
activitypub_federation = { git = "https://github.com/LemmyNet/activitypub-federation-rust.git", branch = "verify_is_remote_object", features = [
"axum",
"diesel",
], default-features = false }

View file

@ -1,6 +1,7 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at (_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at ();
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View file

@ -1,10 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
@ -16,21 +12,25 @@
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
CREATE OR REPLACE FUNCTION diesel_manage_updated_at (_tbl regclass)
RETURNS VOID
AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
$$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
CREATE OR REPLACE FUNCTION diesel_set_updated_at ()
RETURNS TRIGGER
AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
IF (NEW IS DISTINCT FROM OLD AND NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at) THEN
NEW.updated_at := CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
$$
LANGUAGE plpgsql;

View file

@ -1,8 +1,16 @@
drop table conflict;
drop table edit;
drop table article;
drop table instance_follow;
drop table local_user;
drop table person;
drop table instance;
drop table jwt_secret;
DROP TABLE CONFLICT;
DROP TABLE edit;
DROP TABLE article;
DROP TABLE instance_follow;
DROP TABLE local_user;
DROP TABLE person;
DROP TABLE instance;
DROP TABLE jwt_secret;

View file

@ -1,72 +1,72 @@
create table instance (
id serial primary key,
domain text not null unique,
ap_id varchar(255) not null unique,
CREATE TABLE instance (
id serial PRIMARY KEY,
domain text NOT NULL UNIQUE,
ap_id varchar(255) NOT NULL UNIQUE,
description text,
articles_url varchar(255) not null unique,
inbox_url varchar(255) not null,
public_key text not null,
articles_url varchar(255) NOT NULL UNIQUE,
inbox_url varchar(255) NOT NULL,
public_key text NOT NULL,
private_key text,
last_refreshed_at timestamptz not null default now(),
local bool not null
last_refreshed_at timestamptz NOT NULL DEFAULT now(),
local bool NOT NULL
);
create table person (
id serial primary key,
username text not null,
ap_id varchar(255) not null unique,
inbox_url varchar(255) not null,
public_key text not null,
CREATE TABLE person (
id serial PRIMARY KEY,
username text NOT NULL,
ap_id varchar(255) NOT NULL UNIQUE,
inbox_url varchar(255) NOT NULL,
public_key text NOT NULL,
private_key text,
last_refreshed_at timestamptz not null default now(),
local bool not null
last_refreshed_at timestamptz NOT NULL DEFAULT now(),
local bool NOT NULL
);
create table local_user (
id serial primary key,
password_encrypted text not null,
CREATE TABLE local_user (
id serial PRIMARY KEY,
password_encrypted text NOT NULL,
person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
admin bool not null
admin bool NOT NULL
);
create table instance_follow (
id serial primary key,
CREATE TABLE instance_follow (
id serial PRIMARY KEY,
instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
follower_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
pending boolean not null,
unique(instance_id, follower_id)
pending boolean NOT NULL,
UNIQUE (instance_id, follower_id)
);
create table article (
id serial primary key,
title text not null,
text text not null,
ap_id varchar(255) not null unique,
CREATE TABLE article (
id serial PRIMARY KEY,
title text NOT NULL,
text text NOT NULL,
ap_id varchar(255) NOT NULL UNIQUE,
instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
local bool not null,
protected bool not null
local bool NOT NULL,
protected bool NOT NULL
);
create table edit (
id serial primary key,
CREATE TABLE edit (
id serial PRIMARY KEY,
creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
hash uuid not null,
ap_id varchar(255) not null unique,
diff text not null,
summary text not null,
hash uuid NOT NULL,
ap_id varchar(255) NOT NULL UNIQUE,
diff text NOT NULL,
summary text NOT NULL,
article_id int REFERENCES article ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
previous_version_id uuid not null,
created timestamptz not null
previous_version_id uuid NOT NULL,
created timestamptz NOT NULL
);
create table conflict (
id serial primary key,
hash uuid not null,
diff text not null,
summary text not null,
CREATE TABLE CONFLICT (
id serial PRIMARY KEY,
hash uuid NOT NULL,
diff text NOT NULL,
summary text NOT NULL,
creator_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
article_id int REFERENCES article ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
previous_version_id uuid not null
previous_version_id uuid NOT NULL
);
-- generate a jwt secret

View file

@ -1,3 +1,6 @@
alter table instance drop column instances_url;
ALTER TABLE instance
DROP COLUMN instances_url;
ALTER TABLE instance
ALTER COLUMN articles_url SET NOT NULL;
alter table instance alter column articles_url set not null;

View file

@ -1,3 +1,6 @@
alter table instance add column instances_url varchar(255) unique;
ALTER TABLE instance
ADD COLUMN instances_url varchar(255) UNIQUE;
ALTER TABLE instance
ALTER COLUMN articles_url DROP NOT NULL;
alter table instance alter column articles_url drop not null;

View file

@ -1 +1,3 @@
select 1;
SELECT
1;

View file

@ -1,2 +1,6 @@
ALTER TABLE conflict DROP CONSTRAINT conflict_creator_id_fkey;
ALTER TABLE conflict ADD CONSTRAINT conflict_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person(id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE CONFLICT
DROP CONSTRAINT conflict_creator_id_fkey;
ALTER TABLE CONFLICT
ADD CONSTRAINT conflict_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE;

View file

@ -1,7 +1,11 @@
alter table article drop column approved;
ALTER TABLE article
DROP COLUMN approved;
alter table article drop column published;
ALTER TABLE article
DROP COLUMN published;
alter table conflict drop column published;
ALTER TABLE CONFLICT
DROP COLUMN published;
ALTER TABLE edit RENAME COLUMN published TO created;
alter table edit rename column published to created;

View file

@ -1,7 +1,11 @@
alter table article add column approved bool not null default true;
ALTER TABLE article
ADD COLUMN approved bool NOT NULL DEFAULT TRUE;
alter table article add column published timestamptz not null default now();
ALTER TABLE article
ADD COLUMN published timestamptz NOT NULL DEFAULT now();
alter table conflict add column published timestamptz not null default now();
ALTER TABLE CONFLICT
ADD COLUMN published timestamptz NOT NULL DEFAULT now();
ALTER TABLE edit RENAME COLUMN created TO published;
alter table edit rename column created to published;

View file

@ -1,2 +1,6 @@
alter table person drop display_name;
alter table person drop bio;
ALTER TABLE person
DROP display_name;
ALTER TABLE person
DROP bio;

View file

@ -1,2 +1,6 @@
alter table person add column display_name varchar(20);
alter table person add column bio varchar(1000);
ALTER TABLE person
ADD COLUMN display_name varchar(20);
ALTER TABLE person
ADD COLUMN bio varchar(1000);

View file

@ -8,7 +8,5 @@ DROP TRIGGER instance_stats_article_insert ON article;
DROP TRIGGER instance_stats_article_delete ON article;
DROP FUNCTION instance_stats_local_user_insert,
instance_stats_local_user_delete, instance_stats_article_insert,
instance_stats_article_delete, instance_stats_activity;
DROP FUNCTION instance_stats_local_user_insert, instance_stats_local_user_delete, instance_stats_article_insert, instance_stats_article_delete, instance_stats_activity;

View file

@ -8,9 +8,20 @@ CREATE TABLE instance_stats (
INSERT INTO instance_stats (users, articles)
SELECT
(SELECT count(*) FROM local_user) AS users,
(SELECT count(*) FROM article WHERE local = TRUE) AS article
FROM instance;
(
SELECT
count(*)
FROM
local_user) AS users,
(
SELECT
count(*)
FROM
article
WHERE
local = TRUE) AS article
FROM
instance;
CREATE FUNCTION instance_stats_local_user_insert ()
RETURNS TRIGGER
@ -103,15 +114,14 @@ DECLARE
BEGIN
SELECT
count(users) INTO count_
FROM (
SELECT DISTINCT
FROM ( SELECT DISTINCT
e.creator_id
FROM
edit e
INNER JOIN person p ON e.creator_id = p.id
WHERE
e.published > ('now'::timestamp - i::interval)
AND p.local = TRUE) as users;
AND p.local = TRUE) AS users;
RETURN count_;
END;
$$;
@ -133,3 +143,4 @@ SET
*
FROM
instance_stats_activity ('6 months'));

View file

@ -1 +1,3 @@
alter table edit drop column pending;
ALTER TABLE edit
DROP COLUMN pending;

View file

@ -1 +1,3 @@
alter table edit add column pending bool not null default false;
ALTER TABLE edit
ADD COLUMN pending bool NOT NULL DEFAULT FALSE;

View file

@ -0,0 +1,36 @@
DROP TRIGGER instance_stats_comment_insert ON comment;
DROP TRIGGER instance_stats_comment_delete ON comment;
DROP FUNCTION instance_stats_comment_insert;
DROP FUNCTION instance_stats_comment_delete;
ALTER TABLE instance_stats
DROP COLUMN comments;
DROP TABLE comment;
DROP FUNCTION generate_unique_comment_id;
CREATE OR REPLACE FUNCTION instance_stats_activity (i text)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
count_ integer;
BEGIN
SELECT
count(users) INTO count_
FROM ( SELECT DISTINCT
e.creator_id
FROM
edit e
INNER JOIN person p ON e.creator_id = p.id
WHERE
e.published > ('now'::timestamp - i::interval)
AND p.local = TRUE) AS users;
RETURN count_;
END;
$$;

View file

@ -0,0 +1,96 @@
CREATE OR REPLACE FUNCTION generate_unique_comment_id ()
RETURNS text
LANGUAGE sql
AS $$
SELECT
'http://example.com/' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '')
FROM
generate_series(1, 20)
$$;
CREATE TABLE comment (
id serial PRIMARY KEY,
creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
article_id int REFERENCES article ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
parent_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,
content text NOT NULL,
depth int not null,
ap_id varchar(255) NOT NULL UNIQUE DEFAULT generate_unique_comment_id(),
local boolean NOT NULL,
deleted boolean NOT NULL,
published timestamptz NOT NULL DEFAULT now(),
updated timestamptz
);
ALTER TABLE instance_stats
ADD COLUMN comments int NOT NULL DEFAULT 0;
CREATE FUNCTION instance_stats_comment_insert ()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE
instance_stats
SET
comments = comments + 1;
RETURN NULL;
END
$$;
CREATE FUNCTION instance_stats_comment_delete ()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE
instance_stats ia
SET
comments = comments - 1;
RETURN NULL;
END
$$;
CREATE TRIGGER instance_stats_comment_insert
AFTER INSERT ON comment
FOR EACH ROW
WHEN (NEW.local = TRUE)
EXECUTE PROCEDURE instance_stats_comment_insert ();
CREATE TRIGGER instance_stats_comment_delete
AFTER DELETE ON comment
FOR EACH ROW
WHEN (OLD.local = TRUE)
EXECUTE PROCEDURE instance_stats_comment_delete ();
CREATE OR REPLACE FUNCTION instance_stats_activity (i text)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
count_ integer;
BEGIN
SELECT
count(a) INTO count_
FROM (
SELECT
e.creator_id
FROM
edit e
INNER JOIN person p ON e.creator_id = p.id
WHERE
e.published > ('now'::timestamp - i::interval)
AND p.local = TRUE
UNION
SELECT
c.creator_id
FROM
comment c
INNER JOIN person p ON p.creator_id = c.id
WHERE
c.published > ('now'::timestamp - i::interval)
AND p.local = TRUE) a;
RETURN count_;
END;
$$;

View file

@ -8,15 +8,19 @@ use crate::{
IbisData,
},
federation::activities::{create_article::CreateArticle, submit_article_update},
utils::{error::MyResult, generate_article_version, validate::validate_article_title},
utils::{
error::MyResult,
generate_article_version,
validate::{validate_article_title, validate_not_empty},
},
},
common::{
article::{
ApiConflict,
ApproveArticleForm,
ArticleView,
CreateArticleForm,
DbArticle,
DbArticleView,
DbEdit,
DeleteConflictForm,
EditArticleForm,
@ -27,6 +31,7 @@ use crate::{
ProtectArticleForm,
SearchArticleForm,
},
comment::DbComment,
instance::DbInstance,
user::LocalUserView,
utils::{extract_domain, http_protocol_str},
@ -47,10 +52,11 @@ pub(in crate::backend::api) async fn create_article(
user: Extension<LocalUserView>,
data: Data<IbisData>,
Form(mut params): Form<CreateArticleForm>,
) -> MyResult<Json<ArticleView>> {
) -> MyResult<Json<DbArticleView>> {
params.title = validate_article_title(&params.title)?;
validate_not_empty(&params.text)?;
let local_instance = DbInstance::read_local_instance(&data)?;
let local_instance = DbInstance::read_local(&data)?;
let ap_id = ObjectId::parse(&format!(
"{}://{}/article/{}",
http_protocol_str(),
@ -98,38 +104,39 @@ pub(in crate::backend::api) async fn create_article(
pub(in crate::backend::api) async fn edit_article(
Extension(user): Extension<LocalUserView>,
data: Data<IbisData>,
Form(mut edit_form): Form<EditArticleForm>,
Form(mut params): Form<EditArticleForm>,
) -> MyResult<Json<Option<ApiConflict>>> {
validate_not_empty(&params.new_text)?;
// resolve conflict if any
if let Some(resolve_conflict_id) = edit_form.resolve_conflict_id {
if let Some(resolve_conflict_id) = params.resolve_conflict_id {
DbConflict::delete(resolve_conflict_id, user.person.id, &data)?;
}
let original_article = DbArticle::read_view(edit_form.article_id, &data)?;
if edit_form.new_text == original_article.article.text {
let original_article = DbArticle::read_view(params.article_id, &data)?;
if params.new_text == original_article.article.text {
return Err(anyhow!("Edit contains no changes").into());
}
if edit_form.summary.is_empty() {
if params.summary.is_empty() {
return Err(anyhow!("No summary given").into());
}
can_edit_article(&original_article.article, user.local_user.admin)?;
// ensure trailing newline for clean diffs
if !edit_form.new_text.ends_with('\n') {
edit_form.new_text.push('\n');
if !params.new_text.ends_with('\n') {
params.new_text.push('\n');
}
let local_link = format!("](https://{}", data.config.federation.domain);
if edit_form.new_text.contains(&local_link) {
if params.new_text.contains(&local_link) {
return Err(anyhow!("Links to local instance don't work over federation").into());
}
// Markdown formatting
let new_text = fmtm::format(&edit_form.new_text, Some(80))?;
let new_text = fmtm::format(&params.new_text, Some(80))?;
if edit_form.previous_version_id == original_article.latest_version {
if params.previous_version_id == original_article.latest_version {
// No intermediate changes, simply submit new version
submit_article_update(
new_text.clone(),
edit_form.summary.clone(),
edit_form.previous_version_id,
params.summary.clone(),
params.previous_version_id,
&original_article.article,
user.person.id,
&data,
@ -140,14 +147,14 @@ pub(in crate::backend::api) async fn edit_article(
// There have been other changes since this edit was initiated. Get the common ancestor
// version and generate a diff to find out what exactly has changed.
let edits = DbEdit::list_for_article(original_article.article.id, &data)?;
let ancestor = generate_article_version(&edits, &edit_form.previous_version_id)?;
let ancestor = generate_article_version(&edits, &params.previous_version_id)?;
let patch = create_patch(&ancestor, &new_text);
let previous_version = DbEdit::read(&edit_form.previous_version_id, &data)?;
let previous_version = DbEdit::read(&params.previous_version_id, &data)?;
let form = DbConflictForm {
hash: EditVersion::new(&patch.to_string()),
diff: patch.to_string(),
summary: edit_form.summary.clone(),
summary: params.summary.clone(),
creator_id: user.person.id,
article_id: original_article.article.id,
previous_version_id: previous_version.hash,
@ -162,7 +169,7 @@ pub(in crate::backend::api) async fn edit_article(
pub(in crate::backend::api) async fn get_article(
Query(query): Query<GetArticleForm>,
data: Data<IbisData>,
) -> MyResult<Json<ArticleView>> {
) -> MyResult<Json<DbArticleView>> {
match (query.title, query.id) {
(Some(title), None) => Ok(Json(DbArticle::read_view_title(
&title,
@ -199,12 +206,12 @@ pub(in crate::backend::api) async fn fork_article(
Extension(_user): Extension<LocalUserView>,
data: Data<IbisData>,
Form(mut params): Form<ForkArticleForm>,
) -> MyResult<Json<ArticleView>> {
) -> MyResult<Json<DbArticleView>> {
// TODO: lots of code duplicated from create_article(), can move it into helper
let original_article = DbArticle::read_view(params.article_id, &data)?;
params.new_title = validate_article_title(&params.new_title)?;
let local_instance = DbInstance::read_local_instance(&data)?;
let local_instance = DbInstance::read_local(&data)?;
let ap_id = ObjectId::parse(&format!(
"{}://{}/article/{}",
http_protocol_str(),
@ -253,13 +260,15 @@ pub(in crate::backend::api) async fn fork_article(
pub(super) async fn resolve_article(
Query(query): Query<ResolveObject>,
data: Data<IbisData>,
) -> MyResult<Json<ArticleView>> {
) -> MyResult<Json<DbArticleView>> {
let article: DbArticle = ObjectId::from(query.id).dereference(&data).await?;
let instance = DbInstance::read(article.instance_id, &data)?;
let comments = DbComment::read_for_article(article.id, &data)?;
let latest_version = article.latest_edit_version(&data)?;
Ok(Json(ArticleView {
Ok(Json(DbArticleView {
article,
instance,
comments,
latest_version,
}))
}

112
src/backend/api/comment.rs Normal file
View file

@ -0,0 +1,112 @@
use crate::{
backend::{
database::{
comment::{DbCommentInsertForm, DbCommentUpdateForm},
IbisData,
},
federation::activities::comment::{
create_or_update_comment::CreateOrUpdateComment,
delete_comment::DeleteComment,
undo_delete_comment::UndoDeleteComment,
},
utils::{
error::MyResult,
validate::{validate_comment_max_depth, validate_not_empty},
},
},
common::{
comment::{CreateCommentForm, DbComment, DbCommentView, EditCommentForm},
user::LocalUserView,
utils::http_protocol_str,
},
};
use activitypub_federation::config::Data;
use anyhow::anyhow;
use axum::{Extension, Form, Json};
use axum_macros::debug_handler;
use chrono::Utc;
#[debug_handler]
pub(in crate::backend::api) async fn create_comment(
user: Extension<LocalUserView>,
data: Data<IbisData>,
Form(params): Form<CreateCommentForm>,
) -> MyResult<Json<DbCommentView>> {
validate_not_empty(&params.content)?;
let mut depth = 0;
if let Some(parent_id) = params.parent_id {
let parent = DbComment::read(parent_id, &data)?;
if parent.deleted {
return Err(anyhow!("Cant reply to deleted comment").into());
}
if parent.article_id != params.article_id {
return Err(anyhow!("Invalid article_id/parent_id combination").into());
}
depth = parent.depth + 1;
validate_comment_max_depth(depth)?;
}
let form = DbCommentInsertForm {
creator_id: user.person.id,
article_id: params.article_id,
parent_id: params.parent_id,
content: params.content,
depth,
ap_id: None,
local: true,
deleted: false,
published: Utc::now(),
updated: None,
};
let comment = DbComment::create(form, &data)?;
// Set the ap_id which contains db id (so it is not know before inserting)
let proto = http_protocol_str();
let ap_id = format!("{}://{}/comment/{}", proto, data.domain(), comment.id.0).parse()?;
let form = DbCommentUpdateForm {
ap_id: Some(ap_id),
..Default::default()
};
let comment = DbComment::update(form, comment.id, &data)?;
CreateOrUpdateComment::send(&comment.comment, &data).await?;
Ok(Json(comment))
}
#[debug_handler]
pub(in crate::backend::api) async fn edit_comment(
user: Extension<LocalUserView>,
data: Data<IbisData>,
Form(params): Form<EditCommentForm>,
) -> MyResult<Json<DbCommentView>> {
if let Some(content) = &params.content {
validate_not_empty(content)?;
}
if params.content.is_none() && params.deleted.is_none() {
return Err(anyhow!("Edit has no parameters").into());
}
let orig_comment = DbComment::read(params.id, &data)?;
if orig_comment.creator_id != user.person.id {
return Err(anyhow!("Cannot edit comment created by another user").into());
}
let form = DbCommentUpdateForm {
content: params.content,
deleted: params.deleted,
updated: Some(Utc::now()),
..Default::default()
};
let comment = DbComment::update(form, params.id, &data)?;
// federate
if orig_comment.content != comment.comment.content {
CreateOrUpdateComment::send(&comment.comment, &data).await?;
}
if !orig_comment.deleted && comment.comment.deleted {
DeleteComment::send(&comment.comment, &data).await?;
}
if orig_comment.deleted && !comment.comment.deleted {
UndoDeleteComment::send(&comment.comment, &data).await?;
}
Ok(Json(comment))
}

View file

@ -12,6 +12,7 @@ use crate::{
resolve_article,
search_article,
},
comment::{create_comment, edit_comment},
instance::{follow_instance, get_instance, resolve_instance},
user::{get_user, login_user, logout_user, register_user},
},
@ -29,7 +30,7 @@ use anyhow::anyhow;
use article::{approve_article, delete_conflict};
use axum::{
extract::Query,
routing::{delete, get, post},
routing::{delete, get, patch, post},
Extension,
Json,
Router,
@ -38,9 +39,10 @@ use axum_macros::debug_handler;
use instance::list_remote_instances;
use user::{count_notifications, list_notifications, update_user_profile};
pub mod article;
pub mod instance;
pub mod user;
mod article;
mod comment;
mod instance;
pub(super) mod user;
pub fn api_routes() -> Router<()> {
Router::new()
@ -55,6 +57,8 @@ pub fn api_routes() -> Router<()> {
.route("/article/approve", post(approve_article))
.route("/edit/list", get(edit_list))
.route("/conflict", delete(delete_conflict))
.route("/comment", post(create_comment))
.route("/comment", patch(edit_comment))
.route("/instance", get(get_instance))
.route("/instance/follow", post(follow_instance))
.route("/instance/resolve", get(resolve_instance))

View file

@ -8,7 +8,8 @@ use crate::{
utils::error::MyResult,
},
common::{
article::{ArticleView, DbArticle, EditVersion},
article::{DbArticle, DbArticleView, EditVersion},
comment::DbComment,
instance::DbInstance,
newtypes::{ArticleId, InstanceId},
},
@ -95,17 +96,19 @@ impl DbArticle {
.get_result::<Self>(conn.deref_mut())?)
}
pub fn read_view(id: ArticleId, data: &IbisData) -> MyResult<ArticleView> {
pub fn read_view(id: ArticleId, data: &IbisData) -> MyResult<DbArticleView> {
let mut conn = data.db_pool.get()?;
let query = article::table
.find(id)
.inner_join(instance::table)
.into_boxed();
let (article, instance): (DbArticle, DbInstance) = query.get_result(conn.deref_mut())?;
let comments = DbComment::read_for_article(article.id, data)?;
let latest_version = article.latest_edit_version(data)?;
Ok(ArticleView {
Ok(DbArticleView {
article,
instance,
comments,
latest_version,
})
}
@ -114,7 +117,7 @@ impl DbArticle {
title: &str,
domain: Option<String>,
data: &IbisData,
) -> MyResult<ArticleView> {
) -> MyResult<DbArticleView> {
let mut conn = data.db_pool.get()?;
let (article, instance): (DbArticle, DbInstance) = {
let query = article::table
@ -128,10 +131,12 @@ impl DbArticle {
};
query.get_result(conn.deref_mut())?
};
let comments = DbComment::read_for_article(article.id, data)?;
let latest_version = article.latest_edit_version(data)?;
Ok(ArticleView {
Ok(DbArticleView {
article,
instance,
comments,
latest_version,
})
}

View file

@ -0,0 +1,127 @@
use super::{
schema::{comment, person},
IbisData,
};
use crate::{
backend::utils::error::MyResult,
common::{
comment::{DbComment, DbCommentView},
newtypes::{ArticleId, CommentId, PersonId},
user::DbPerson,
},
};
use activitypub_federation::fetch::object_id::ObjectId;
use chrono::{DateTime, Utc};
use diesel::{
dsl::insert_into,
update,
AsChangeset,
ExpressionMethods,
Insertable,
QueryDsl,
RunQueryDsl,
};
use std::ops::DerefMut;
#[derive(Insertable, AsChangeset, Debug)]
#[diesel(table_name = comment, check_for_backend(diesel::pg::Pg))]
pub struct DbCommentInsertForm {
pub creator_id: PersonId,
pub article_id: ArticleId,
pub parent_id: Option<CommentId>,
pub content: String,
pub depth: i32,
pub ap_id: Option<ObjectId<DbComment>>,
pub local: bool,
pub deleted: bool,
pub published: DateTime<Utc>,
pub updated: Option<DateTime<Utc>>,
}
#[derive(AsChangeset, Default)]
#[diesel(table_name = comment, check_for_backend(diesel::pg::Pg))]
pub struct DbCommentUpdateForm {
pub content: Option<String>,
pub deleted: Option<bool>,
pub ap_id: Option<ObjectId<DbComment>>,
pub updated: Option<DateTime<Utc>>,
}
impl DbComment {
pub fn create(form: DbCommentInsertForm, data: &IbisData) -> MyResult<Self> {
let mut conn = data.db_pool.get()?;
Ok(insert_into(comment::table)
.values(form)
.get_result(conn.deref_mut())?)
}
pub fn update(
form: DbCommentUpdateForm,
id: CommentId,
data: &IbisData,
) -> MyResult<DbCommentView> {
let mut conn = data.db_pool.get()?;
let comment: DbComment = update(comment::table.find(id))
.set(form)
.get_result(conn.deref_mut())?;
let creator = DbPerson::read(comment.creator_id, data)?;
Ok(DbCommentView { comment, creator })
}
pub fn create_or_update(form: DbCommentInsertForm, data: &IbisData) -> MyResult<Self> {
let mut conn = data.db_pool.get()?;
Ok(insert_into(comment::table)
.values(&form)
.on_conflict(comment::dsl::ap_id)
.do_update()
.set(&form)
.get_result(conn.deref_mut())?)
}
pub fn read(id: CommentId, data: &IbisData) -> MyResult<Self> {
let mut conn = data.db_pool.get()?;
Ok(comment::table
.find(id)
.get_result::<Self>(conn.deref_mut())?)
}
pub fn read_view(id: CommentId, data: &IbisData) -> MyResult<DbCommentView> {
let mut conn = data.db_pool.get()?;
let comment = comment::table
.find(id)
.get_result::<Self>(conn.deref_mut())?;
let creator = DbPerson::read(comment.creator_id, data)?;
Ok(DbCommentView { comment, creator })
}
pub fn read_from_ap_id(ap_id: &ObjectId<DbComment>, data: &IbisData) -> MyResult<Self> {
let mut conn = data.db_pool.get()?;
Ok(comment::table
.filter(comment::dsl::ap_id.eq(ap_id))
.get_result(conn.deref_mut())?)
}
pub fn read_for_article(
article_id: ArticleId,
data: &IbisData,
) -> MyResult<Vec<DbCommentView>> {
let mut conn = data.db_pool.get()?;
let comments = comment::table
.inner_join(person::table)
.filter(comment::article_id.eq(article_id))
.order_by(comment::published.desc())
.get_results::<(DbComment, DbPerson)>(conn.deref_mut())?;
// Clear content of deleted comments. comments themselves are returned
// so that tree can be rendered.
Ok(comments
.into_iter()
.map(|(mut comment, creator)| {
if comment.deleted {
comment.content = String::new()
};
DbCommentView { comment, creator }
})
.collect())
}
}

View file

@ -1,7 +1,7 @@
use crate::{
backend::{
database::{
schema::{instance, instance_follow},
schema::{article, comment, instance, instance_follow},
IbisData,
},
federation::objects::{
@ -12,7 +12,7 @@ use crate::{
},
common::{
instance::{DbInstance, InstanceView},
newtypes::InstanceId,
newtypes::{CommentId, InstanceId},
user::DbPerson,
},
};
@ -73,7 +73,7 @@ impl DbInstance {
.get_result(conn.deref_mut())?)
}
pub fn read_local_instance(data: &IbisData) -> MyResult<Self> {
pub fn read_local(data: &IbisData) -> MyResult<Self> {
let mut conn = data.db_pool.get()?;
Ok(instance::table
.filter(instance::local.eq(true))
@ -83,7 +83,7 @@ impl DbInstance {
pub fn read_view(id: Option<InstanceId>, data: &Data<IbisData>) -> MyResult<InstanceView> {
let instance = match id {
Some(id) => DbInstance::read(id, data),
None => DbInstance::read_local_instance(data),
None => DbInstance::read_local(data),
}?;
let followers = DbInstance::read_followers(instance.id, data)?;
@ -133,4 +133,16 @@ impl DbInstance {
.filter(instance::local.eq(false))
.get_results(conn.deref_mut())?)
}
/// Read the instance where an article is hosted, based on a comment id.
/// Note this may be different from the instance where the comment is hosted.
pub fn read_for_comment(comment_id: CommentId, data: &Data<IbisData>) -> MyResult<DbInstance> {
let mut conn = data.db_pool.get()?;
Ok(instance::table
.inner_join(article::table)
.inner_join(comment::table.on(comment::article_id.eq(article::id)))
.filter(comment::id.eq(comment_id))
.select(instance::all_columns)
.get_result(conn.deref_mut())?)
}
}

View file

@ -11,6 +11,7 @@ pub struct InstanceStats {
pub users_active_month: i32,
pub users_active_half_year: i32,
pub articles: i32,
pub comments: i32,
}
impl InstanceStats {

View file

@ -8,6 +8,7 @@ use diesel::{
use std::ops::DerefMut;
pub mod article;
pub mod comment;
pub mod conflict;
pub mod edit;
pub mod instance;

View file

@ -15,6 +15,23 @@ diesel::table! {
}
}
diesel::table! {
comment (id) {
id -> Int4,
creator_id -> Int4,
article_id -> Int4,
parent_id -> Nullable<Int4>,
content -> Text,
depth -> Int4,
#[max_length = 255]
ap_id -> Varchar,
local -> Bool,
deleted -> Bool,
published -> Timestamptz,
updated -> Nullable<Timestamptz>,
}
}
diesel::table! {
conflict (id) {
id -> Int4,
@ -80,6 +97,7 @@ diesel::table! {
users_active_month -> Int4,
users_active_half_year -> Int4,
articles -> Int4,
comments -> Int4,
}
}
@ -119,6 +137,8 @@ diesel::table! {
}
diesel::joinable!(article -> instance (instance_id));
diesel::joinable!(comment -> article (article_id));
diesel::joinable!(comment -> person (creator_id));
diesel::joinable!(conflict -> article (article_id));
diesel::joinable!(conflict -> person (creator_id));
diesel::joinable!(edit -> article (article_id));
@ -129,6 +149,7 @@ diesel::joinable!(local_user -> person (person_id));
diesel::allow_tables_to_appear_in_same_query!(
article,
comment,
conflict,
edit,
instance,

View file

@ -61,7 +61,7 @@ impl DbPerson {
.get_result::<DbPerson>(conn.deref_mut())?)
}
pub fn read(id: PersonId, data: &Data<IbisData>) -> MyResult<DbPerson> {
pub fn read(id: PersonId, data: &IbisData) -> MyResult<DbPerson> {
let mut conn = data.db_pool.get()?;
Ok(person::table.find(id).get_result(conn.deref_mut())?)
}

View file

@ -0,0 +1,80 @@
use crate::{
backend::{
database::IbisData,
federation::{routes::AnnouncableActivities, send_activity},
utils::{
error::{Error, MyResult},
generate_activity_id,
},
},
common::instance::DbInstance,
};
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::{activity::AnnounceType, public},
protocol::helpers::deserialize_one_or_many,
traits::{ActivityHandler, Actor},
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) actor: ObjectId<DbInstance>,
#[serde(deserialize_with = "deserialize_one_or_many")]
pub(crate) to: Vec<Url>,
pub(crate) object: AnnouncableActivities,
#[serde(rename = "type")]
pub(crate) kind: AnnounceType,
pub(crate) id: Url,
}
impl AnnounceActivity {
pub async fn send(object: AnnouncableActivities, context: &Data<IbisData>) -> MyResult<()> {
let id = generate_activity_id(context)?;
let instance = DbInstance::read_local(context)?;
let announce = AnnounceActivity {
actor: instance.id().into(),
to: vec![public()],
object,
kind: AnnounceType::Announce,
id,
};
// Send to followers of instance
let follower_inboxes = DbInstance::read_followers(instance.id, context)?
.into_iter()
.map(|f| f.inbox_url())
.collect();
send_activity(&instance, announce, follower_inboxes, context).await?;
Ok(())
}
}
#[async_trait::async_trait]
impl ActivityHandler for AnnounceActivity {
type DataType = IbisData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
#[tracing::instrument(skip_all)]
async fn verify(&self, _context: &Data<Self::DataType>) -> MyResult<()> {
Ok(())
}
#[tracing::instrument(skip_all)]
async fn receive(self, context: &Data<Self::DataType>) -> MyResult<()> {
self.object.verify(context).await?;
self.object.receive(context).await
}
}

View file

@ -0,0 +1,95 @@
use super::generate_comment_activity_to;
use crate::{
backend::{
database::IbisData,
federation::{
objects::comment::ApubComment,
routes::AnnouncableActivities,
send_activity_to_instance,
},
generate_activity_id,
utils::error::{Error, MyResult},
},
common::{comment::DbComment, instance::DbInstance, user::DbPerson},
};
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
protocol::{helpers::deserialize_one_or_many, verification::verify_domains_match},
traits::{ActivityHandler, Object},
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum CreateOrUpdateType {
Create,
Update,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateOrUpdateComment {
pub(crate) actor: ObjectId<DbPerson>,
#[serde(deserialize_with = "deserialize_one_or_many")]
pub(crate) to: Vec<Url>,
pub(crate) object: ApubComment,
#[serde(rename = "type")]
pub(crate) kind: CreateOrUpdateType,
pub(crate) id: Url,
}
impl CreateOrUpdateComment {
pub async fn send(comment: &DbComment, data: &Data<IbisData>) -> MyResult<()> {
let instance = DbInstance::read_for_comment(comment.id, data)?;
let kind = if comment.updated.is_none() {
CreateOrUpdateType::Create
} else {
CreateOrUpdateType::Update
};
let object = comment.clone().into_json(data).await?;
let id = generate_activity_id(data)?;
let activity = Self {
actor: object.attributed_to.clone(),
object,
to: generate_comment_activity_to(&instance)?,
kind,
id,
};
let activity = AnnouncableActivities::CreateOrUpdateComment(activity);
let creator = DbPerson::read(comment.creator_id, data)?;
send_activity_to_instance(&creator, activity, &instance, data).await?;
Ok(())
}
}
#[async_trait::async_trait]
impl ActivityHandler for CreateOrUpdateComment {
type DataType = IbisData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.object.id.inner())?;
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let comment = DbComment::from_json(self.object, data).await?;
let instance = DbInstance::read_for_comment(comment.id, data)?;
if instance.local {
Self::send(&comment, data).await?;
}
Ok(())
}
}

View file

@ -0,0 +1,96 @@
use super::generate_comment_activity_to;
use crate::{
backend::{
database::{comment::DbCommentUpdateForm, IbisData},
federation::{routes::AnnouncableActivities, send_activity_to_instance},
utils::{
error::{Error, MyResult},
generate_activity_id,
},
},
common::{comment::DbComment, instance::DbInstance, user::DbPerson},
};
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::DeleteType,
protocol::{helpers::deserialize_one_or_many, verification::verify_domains_match},
traits::ActivityHandler,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteComment {
pub(crate) actor: ObjectId<DbPerson>,
#[serde(deserialize_with = "deserialize_one_or_many")]
pub(crate) to: Vec<Url>,
pub(crate) object: ObjectId<DbComment>,
#[serde(rename = "type")]
pub(crate) kind: DeleteType,
pub(crate) id: Url,
}
impl DeleteComment {
pub fn new(
comment: &DbComment,
creator: &DbPerson,
instance: &DbInstance,
data: &Data<IbisData>,
) -> MyResult<Self> {
let id = generate_activity_id(data)?;
Ok(DeleteComment {
actor: creator.ap_id.clone(),
object: comment.ap_id.clone(),
to: generate_comment_activity_to(instance)?,
kind: Default::default(),
id,
})
}
pub async fn send(comment: &DbComment, data: &Data<IbisData>) -> MyResult<()> {
let instance = DbInstance::read_for_comment(comment.id, data)?;
let creator = DbPerson::read(comment.creator_id, data)?;
let activity = Self::new(comment, &creator, &instance, data)?;
let activity = AnnouncableActivities::DeleteComment(activity);
send_activity_to_instance(&creator, activity, &instance, data).await?;
Ok(())
}
}
#[async_trait::async_trait]
impl ActivityHandler for DeleteComment {
type DataType = IbisData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(self.actor.inner(), &self.id)?;
verify_domains_match(self.actor.inner(), self.object.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let form = DbCommentUpdateForm {
deleted: Some(true),
updated: Some(Utc::now()),
..Default::default()
};
let comment = self.object.dereference(data).await?;
DbComment::update(form, comment.id, data)?;
let instance = DbInstance::read_for_comment(comment.id, data)?;
if instance.local {
Self::send(&comment, data).await?;
}
Ok(())
}
}

View file

@ -0,0 +1,13 @@
use crate::{backend::utils::error::MyResult, common::instance::DbInstance};
use activitypub_federation::kinds::public;
use url::Url;
pub mod create_or_update_comment;
pub mod delete_comment;
pub mod undo_delete_comment;
/// Parameter is the return value from DbInstance::read_for_comment() for this comment.
fn generate_comment_activity_to(instance: &DbInstance) -> MyResult<Vec<Url>> {
let followers_url = format!("{}/followers", &instance.ap_id);
Ok(vec![public(), followers_url.parse()?])
}

View file

@ -0,0 +1,92 @@
use super::{delete_comment::DeleteComment, generate_comment_activity_to};
use crate::{
backend::{
database::{comment::DbCommentUpdateForm, IbisData},
federation::{routes::AnnouncableActivities, send_activity_to_instance},
utils::{
error::{Error, MyResult},
generate_activity_id,
},
},
common::{comment::DbComment, instance::DbInstance, user::DbPerson},
};
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::UndoType,
protocol::{
helpers::deserialize_one_or_many,
verification::{verify_domains_match, verify_urls_match},
},
traits::ActivityHandler,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UndoDeleteComment {
pub(crate) actor: ObjectId<DbPerson>,
#[serde(deserialize_with = "deserialize_one_or_many")]
pub(crate) to: Vec<Url>,
pub(crate) object: DeleteComment,
#[serde(rename = "type")]
pub(crate) kind: UndoType,
pub(crate) id: Url,
}
impl UndoDeleteComment {
pub async fn send(comment: &DbComment, data: &Data<IbisData>) -> MyResult<()> {
let instance = DbInstance::read_for_comment(comment.id, data)?;
let id = generate_activity_id(data)?;
let creator = DbPerson::read(comment.creator_id, data)?;
let object = DeleteComment::new(comment, &creator, &instance, data)?;
let activity = UndoDeleteComment {
actor: creator.ap_id.clone(),
object,
to: generate_comment_activity_to(&instance)?,
kind: Default::default(),
id,
};
let activity = AnnouncableActivities::UndoDeleteComment(activity);
send_activity_to_instance(&creator, activity, &instance, data).await?;
Ok(())
}
}
#[async_trait::async_trait]
impl ActivityHandler for UndoDeleteComment {
type DataType = IbisData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_urls_match(self.actor.inner(), self.object.actor.inner())?;
verify_domains_match(self.actor.inner(), &self.id)?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let form = DbCommentUpdateForm {
deleted: Some(false),
updated: Some(Utc::now()),
..Default::default()
};
let comment = self.object.object.dereference(data).await?;
DbComment::update(form, comment.id, data)?;
let instance = DbInstance::read_for_comment(comment.id, data)?;
if instance.local {
Self::send(&comment, data).await?;
}
Ok(())
}
}

View file

@ -33,7 +33,7 @@ pub struct CreateArticle {
impl CreateArticle {
pub async fn send_to_followers(article: DbArticle, data: &Data<IbisData>) -> MyResult<()> {
let local_instance = DbInstance::read_local_instance(data)?;
let local_instance = DbInstance::read_local(data)?;
let object = article.clone().into_json(data).await?;
let id = generate_activity_id(data)?;
let to = local_instance.follower_ids(data)?;
@ -70,7 +70,7 @@ impl ActivityHandler for CreateArticle {
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let article = DbArticle::from_json(self.object.clone(), data).await?;
if article.local {
let local_instance = DbInstance::read_local_instance(data)?;
let local_instance = DbInstance::read_local(data)?;
local_instance.send_to_followers(self, vec![], data).await?;
}
Ok(())

View file

@ -60,7 +60,7 @@ impl ActivityHandler for Follow {
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let actor = self.actor.dereference(data).await?;
let local_instance = DbInstance::read_local_instance(data)?;
let local_instance = DbInstance::read_local(data)?;
verify_urls_match(self.object.inner(), local_instance.ap_id.inner())?;
DbInstance::follow(&actor, &local_instance, false, data)?;

View file

@ -16,6 +16,8 @@ use crate::{
use activitypub_federation::config::Data;
pub mod accept;
pub mod announce;
pub mod comment;
pub mod create_article;
pub mod follow;
pub mod reject;

View file

@ -40,7 +40,7 @@ impl RejectEdit {
user_instance: DbInstance,
data: &Data<IbisData>,
) -> MyResult<()> {
let local_instance = DbInstance::read_local_instance(data)?;
let local_instance = DbInstance::read_local(data)?;
let id = generate_activity_id(data)?;
let reject = RejectEdit {
actor: local_instance.ap_id.clone(),

View file

@ -39,7 +39,7 @@ impl UpdateLocalArticle {
data: &Data<IbisData>,
) -> MyResult<()> {
debug_assert!(article.local);
let local_instance = DbInstance::read_local_instance(data)?;
let local_instance = DbInstance::read_local(data)?;
let id = generate_activity_id(data)?;
let mut to = local_instance.follower_ids(data)?;
to.extend(extra_recipients.iter().map(|i| i.ap_id.inner().clone()));

View file

@ -47,7 +47,7 @@ impl UpdateRemoteArticle {
article_instance: DbInstance,
data: &Data<IbisData>,
) -> MyResult<()> {
let local_instance = DbInstance::read_local_instance(data)?;
let local_instance = DbInstance::read_local(data)?;
let id = generate_activity_id(data)?;
let update = UpdateRemoteArticle {
actor: local_instance.ap_id.clone(),

View file

@ -1,4 +1,9 @@
use crate::backend::{config::IbisConfig, database::IbisData};
use super::utils::error::MyResult;
use crate::{
backend::{config::IbisConfig, database::IbisData},
common::{instance::DbInstance, user::DbPerson},
};
use activities::announce::AnnounceActivity;
use activitypub_federation::{
activity_queue::queue_activity,
config::{Data, UrlVerifier},
@ -7,6 +12,7 @@ use activitypub_federation::{
traits::{ActivityHandler, Actor},
};
use async_trait::async_trait;
use routes::AnnouncableActivities;
use serde::Serialize;
use std::fmt::Debug;
use url::Url;
@ -30,6 +36,21 @@ where
Ok(())
}
pub async fn send_activity_to_instance(
actor: &DbPerson,
activity: AnnouncableActivities,
instance: &DbInstance,
data: &Data<IbisData>,
) -> MyResult<()> {
if instance.local {
AnnounceActivity::send(activity, data).await?;
} else {
let inbox_url = instance.inbox_url.parse()?;
send_activity(actor, activity, vec![inbox_url], data).await?;
}
Ok(())
}
#[derive(Clone)]
pub struct VerifyUrlData(pub IbisConfig);

View file

@ -13,7 +13,10 @@ use activitypub_federation::{
config::Data,
fetch::{collection_id::CollectionId, object_id::ObjectId},
kinds::{object::ArticleType, public},
protocol::{helpers::deserialize_one_or_many, verification::verify_domains_match},
protocol::{
helpers::deserialize_one_or_many,
verification::{verify_domains_match, verify_is_remote_object},
},
traits::Object,
};
use serde::{Deserialize, Serialize};
@ -50,7 +53,7 @@ impl Object for DbArticle {
}
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let local_instance = DbInstance::read_local_instance(data)?;
let local_instance = DbInstance::read_local(data)?;
Ok(ApubArticle {
kind: Default::default(),
id: self.ap_id.clone(),
@ -67,9 +70,10 @@ impl Object for DbArticle {
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
verify_is_remote_object(&json.id, data)?;
Ok(())
}

View file

@ -0,0 +1,78 @@
use super::{article::ApubArticle, comment::ApubComment};
use crate::{
backend::{
database::IbisData,
utils::error::{Error, MyResult},
},
common::{article::DbArticle, comment::DbComment},
};
use activitypub_federation::{config::Data, traits::Object};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use url::Url;
#[derive(Clone, Debug)]
pub enum DbArticleOrComment {
Article(DbArticle),
Comment(DbComment),
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum ApubArticleOrComment {
Article(Box<ApubArticle>),
Comment(Box<ApubComment>),
}
#[async_trait::async_trait]
impl Object for DbArticleOrComment {
type DataType = IbisData;
type Kind = ApubArticleOrComment;
type Error = Error;
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
None
}
async fn read_from_id(object_id: Url, data: &Data<Self::DataType>) -> MyResult<Option<Self>> {
let post = DbArticle::read_from_id(object_id.clone(), data).await?;
Ok(match post {
Some(o) => Some(Self::Article(o)),
None => DbComment::read_from_id(object_id, data)
.await?
.map(Self::Comment),
})
}
async fn delete(self, data: &Data<Self::DataType>) -> MyResult<()> {
match self {
Self::Article(p) => p.delete(data).await,
Self::Comment(c) => c.delete(data).await,
}
}
async fn into_json(self, data: &Data<Self::DataType>) -> MyResult<Self::Kind> {
Ok(match self {
Self::Article(p) => Self::Kind::Article(Box::new(p.into_json(data).await?)),
Self::Comment(c) => Self::Kind::Comment(Box::new(c.into_json(data).await?)),
})
}
async fn verify(
apub: &Self::Kind,
expected_domain: &Url,
data: &Data<Self::DataType>,
) -> MyResult<()> {
match apub {
Self::Kind::Article(a) => DbArticle::verify(a, expected_domain, data).await,
Self::Kind::Comment(a) => DbComment::verify(a, expected_domain, data).await,
}
}
async fn from_json(apub: Self::Kind, context: &Data<Self::DataType>) -> MyResult<Self> {
Ok(match apub {
Self::Kind::Article(p) => Self::Article(DbArticle::from_json(*p, context).await?),
Self::Kind::Comment(n) => Self::Comment(DbComment::from_json(*n, context).await?),
})
}
}

View file

@ -0,0 +1,110 @@
use super::article_or_comment::DbArticleOrComment;
use crate::{
backend::{
database::{comment::DbCommentInsertForm, IbisData},
utils::{error::Error, validate::validate_comment_max_depth},
},
common::{article::DbArticle, comment::DbComment, user::DbPerson},
};
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::{object::NoteType, public},
protocol::{
helpers::deserialize_one_or_many,
verification::{verify_domains_match, verify_is_remote_object},
},
traits::Object,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ApubComment {
#[serde(rename = "type")]
pub kind: NoteType,
pub id: ObjectId<DbComment>,
pub attributed_to: ObjectId<DbPerson>,
#[serde(deserialize_with = "deserialize_one_or_many")]
pub to: Vec<Url>,
content: String,
pub in_reply_to: ObjectId<DbArticleOrComment>,
pub published: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
}
#[async_trait::async_trait]
impl Object for DbComment {
type DataType = IbisData;
type Kind = ApubComment;
type Error = Error;
async fn read_from_id(
object_id: Url,
data: &Data<Self::DataType>,
) -> Result<Option<Self>, Self::Error> {
Ok(DbComment::read_from_ap_id(&object_id.into(), data).ok())
}
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let creator = DbPerson::read(self.creator_id, data)?;
let in_reply_to = if let Some(parent_comment_id) = self.parent_id {
let comment = DbComment::read(parent_comment_id, data)?;
comment.ap_id.into_inner().into()
} else {
let article = DbArticle::read(self.article_id, data)?;
article.ap_id.into_inner().into()
};
Ok(ApubComment {
kind: NoteType::Note,
id: self.ap_id,
attributed_to: creator.ap_id,
to: vec![public()],
content: self.content,
in_reply_to,
published: Some(self.published),
updated: self.updated,
})
}
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
verify_is_remote_object(&json.id, data)?;
Ok(())
}
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
let parent = json.in_reply_to.dereference(data).await?;
let (article_id, parent_id, depth) = match parent {
DbArticleOrComment::Article(db_article) => (db_article.id, None, 0),
DbArticleOrComment::Comment(db_comment) => (
db_comment.article_id,
Some(db_comment.id),
db_comment.depth + 1,
),
};
let creator = json.attributed_to.dereference(data).await?;
validate_comment_max_depth(depth)?;
let form = DbCommentInsertForm {
article_id,
creator_id: creator.id,
parent_id,
ap_id: Some(json.id),
local: false,
deleted: false,
published: json.published.unwrap_or_else(Utc::now),
updated: json.updated,
content: json.content,
depth,
};
Ok(DbComment::create_or_update(form, data)?)
}
}

View file

@ -11,7 +11,7 @@ use crate::{
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
protocol::verification::verify_domains_match,
protocol::verification::{verify_domains_match, verify_is_remote_object},
traits::Object,
};
use chrono::{DateTime, Utc};
@ -73,9 +73,10 @@ impl Object for DbEdit {
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
verify_is_remote_object(&json.id, data)?;
Ok(())
}

View file

@ -11,7 +11,10 @@ use activitypub_federation::{
config::Data,
fetch::{collection_id::CollectionId, object_id::ObjectId},
kinds::actor::ServiceType,
protocol::{public_key::PublicKey, verification::verify_domains_match},
protocol::{
public_key::PublicKey,
verification::{verify_domains_match, verify_is_remote_object},
},
traits::{ActivityHandler, Actor, Object},
};
use chrono::{DateTime, Utc};
@ -97,9 +100,10 @@ impl Object for DbInstance {
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
verify_is_remote_object(&json.id, data)?;
Ok(())
}

View file

@ -1,5 +1,7 @@
pub mod article;
mod article_or_comment;
pub mod articles_collection;
pub mod comment;
pub mod edit;
pub mod edits_collection;
pub mod instance;

View file

@ -1,10 +1,21 @@
use super::objects::instance_collection::{DbInstanceCollection, InstanceCollection};
use super::{
activities::comment::{
create_or_update_comment::CreateOrUpdateComment,
delete_comment::DeleteComment,
undo_delete_comment::UndoDeleteComment,
},
objects::{
comment::ApubComment,
instance_collection::{DbInstanceCollection, InstanceCollection},
},
};
use crate::{
backend::{
database::IbisData,
federation::{
activities::{
accept::Accept,
announce::AnnounceActivity,
create_article::CreateArticle,
follow::Follow,
reject::RejectEdit,
@ -21,7 +32,13 @@ use crate::{
},
utils::error::{Error, MyResult},
},
common::{article::DbArticle, instance::DbInstance, user::DbPerson},
common::{
article::DbArticle,
comment::DbComment,
instance::DbInstance,
newtypes::CommentId,
user::DbPerson,
},
};
use activitypub_federation::{
axum::{
@ -51,6 +68,7 @@ pub fn federation_routes() -> Router<()> {
.route("/linked_instances", get(http_get_linked_instances))
.route("/article/:title", get(http_get_article))
.route("/article/:title/edits", get(http_get_article_edits))
.route("/comment/:id", get(http_get_comment))
.route("/inbox", post(http_post_inbox))
}
@ -58,7 +76,7 @@ pub fn federation_routes() -> Router<()> {
async fn http_get_instance(
data: Data<IbisData>,
) -> MyResult<FederationJson<WithContext<ApubInstance>>> {
let local_instance = DbInstance::read_local_instance(&data)?;
let local_instance = DbInstance::read_local(&data)?;
let json_instance = local_instance.into_json(&data).await?;
Ok(FederationJson(WithContext::new_default(json_instance)))
}
@ -109,6 +127,16 @@ async fn http_get_article_edits(
Ok(FederationJson(WithContext::new_default(json)))
}
#[debug_handler]
async fn http_get_comment(
Path(id): Path<i32>,
data: Data<IbisData>,
) -> MyResult<FederationJson<WithContext<ApubComment>>> {
let comment = DbComment::read(CommentId(id), &data)?;
let json = comment.into_json(&data).await?;
Ok(FederationJson(WithContext::new_default(json)))
}
/// List of all activities which this actor can receive.
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
@ -120,6 +148,17 @@ pub enum InboxActivities {
UpdateLocalArticle(UpdateLocalArticle),
UpdateRemoteArticle(UpdateRemoteArticle),
RejectEdit(RejectEdit),
AnnounceActivity(AnnounceActivity),
AnnouncableActivities(AnnouncableActivities),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
#[enum_delegate::implement(ActivityHandler)]
pub enum AnnouncableActivities {
CreateOrUpdateComment(CreateOrUpdateComment),
DeleteComment(DeleteComment),
UndoDeleteComment(UndoDeleteComment),
}
#[debug_handler]
@ -147,7 +186,7 @@ pub enum PersonOrInstance {
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum PersonOrInstanceType {
Person,
Group,
Service,
}
#[async_trait::async_trait]

View file

@ -69,7 +69,7 @@ pub async fn start(
.build()
.await?;
if DbInstance::read_local_instance(&data).is_err() {
if DbInstance::read_local(&data).is_err() {
info!("Running setup for new instance");
setup(&data.to_request_data()).await?;
}

View file

@ -47,7 +47,7 @@ async fn node_info(data: Data<IbisData>) -> MyResult<Json<NodeInfo>> {
active_halfyear: stats.users_active_half_year,
},
local_posts: stats.articles,
local_comments: 0,
local_comments: stats.comments,
},
open_registrations: data.config.options.registration_open,
services: Default::default(),

View file

@ -35,6 +35,20 @@ pub fn validate_display_name(name: &Option<String>) -> MyResult<()> {
Ok(())
}
pub fn validate_comment_max_depth(depth: i32) -> MyResult<()> {
if depth > 50 {
return Err(anyhow!("Max comment depth reached").into());
}
Ok(())
}
pub fn validate_not_empty(text: &str) -> MyResult<()> {
if text.trim().len() < 2 {
return Err(anyhow!("Empty text submitted").into());
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn test_validate_article_title() {

View file

@ -1,4 +1,5 @@
use super::{
comment::DbCommentView,
instance::DbInstance,
newtypes::{ArticleId, ConflictId, EditId, InstanceId, PersonId},
user::DbPerson,
@ -31,9 +32,10 @@ pub struct ListArticlesForm {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "ssr", derive(Queryable))]
#[cfg_attr(feature = "ssr", diesel(table_name = article, check_for_backend(diesel::pg::Pg)))]
pub struct ArticleView {
pub struct DbArticleView {
pub article: DbArticle,
pub instance: DbInstance,
pub comments: Vec<DbCommentView>,
pub latest_version: EditVersion,
}

57
src/common/comment.rs Normal file
View file

@ -0,0 +1,57 @@
use super::{
newtypes::{ArticleId, CommentId, PersonId},
user::DbPerson,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use {
crate::backend::database::schema::comment,
activitypub_federation::fetch::object_id::ObjectId,
diesel::{Identifiable, Queryable, Selectable},
};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Identifiable))]
#[cfg_attr(feature = "ssr", diesel(table_name = comment, check_for_backend(diesel::pg::Pg), belongs_to(DbArticle, foreign_key = instance_id)))]
pub struct DbComment {
pub id: CommentId,
pub creator_id: PersonId,
pub article_id: ArticleId,
pub parent_id: Option<CommentId>,
pub content: String,
pub depth: i32,
#[cfg(feature = "ssr")]
pub ap_id: ObjectId<DbComment>,
#[cfg(not(feature = "ssr"))]
pub ap_id: String,
pub local: bool,
pub deleted: bool,
pub published: DateTime<Utc>,
pub updated: Option<DateTime<Utc>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct DbCommentView {
pub comment: DbComment,
pub creator: DbPerson,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct CreateCommentForm {
pub content: String,
pub article_id: ArticleId,
pub parent_id: Option<CommentId>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct EditCommentForm {
pub id: CommentId,
pub content: Option<String>,
pub deleted: Option<bool>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct DeleteCommentForm {
pub id: CommentId,
}

View file

@ -1,4 +1,5 @@
pub mod article;
pub mod comment;
pub mod instance;
pub mod newtypes;
pub mod user;

View file

@ -21,3 +21,7 @@ pub struct InstanceId(pub i32);
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(DieselNewType))]
pub struct ConflictId(pub i32);
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(DieselNewType))]
pub struct CommentId(pub i32);

View file

@ -6,6 +6,7 @@ use crate::common::{
utils::http_protocol_str,
*,
};
use comment::{CreateCommentForm, DbCommentView, EditCommentForm};
use http::{Method, StatusCode};
use leptos::{prelude::ServerFnError, server_fn::error::NoCustomError};
use log::{error, info};
@ -56,7 +57,7 @@ impl ApiClient {
Self { hostname, ssl }
}
pub async fn get_article(&self, data: GetArticleForm) -> Option<ArticleView> {
pub async fn get_article(&self, data: GetArticleForm) -> Option<DbArticleView> {
self.get("/api/v1/article", Some(data)).await
}
@ -79,7 +80,7 @@ impl ApiClient {
pub async fn create_article(
&self,
data: &CreateArticleForm,
) -> Result<ArticleView, ServerFnError> {
) -> Result<DbArticleView, ServerFnError> {
self.send(Method::POST, "/api/v1/article", Some(&data))
.await
}
@ -93,7 +94,7 @@ impl ApiClient {
}
#[cfg(debug_assertions)]
pub async fn edit_article(&self, edit_form: &EditArticleForm) -> Option<ArticleView> {
pub async fn edit_article(&self, edit_form: &EditArticleForm) -> Option<DbArticleView> {
let edit_res = self
.edit_article_with_conflict(edit_form)
.await
@ -109,6 +110,21 @@ impl ApiClient {
.await
}
pub async fn create_comment(
&self,
data: &CreateCommentForm,
) -> Result<DbCommentView, ServerFnError> {
self.post("/api/v1/comment", Some(&data)).await
}
pub async fn edit_comment(
&self,
data: &EditCommentForm,
) -> Result<DbCommentView, ServerFnError> {
self.send(Method::PATCH, "/api/v1/comment", Some(&data))
.await
}
pub async fn notifications_list(&self) -> Option<Vec<Notification>> {
self.get("/api/v1/user/notifications/list", None::<()>)
.await
@ -189,7 +205,10 @@ impl ApiClient {
result_to_option(self.post("/api/v1/account/logout", None::<()>).await)
}
pub async fn fork_article(&self, form: &ForkArticleForm) -> Result<ArticleView, ServerFnError> {
pub async fn fork_article(
&self,
form: &ForkArticleForm,
) -> Result<DbArticleView, ServerFnError> {
self.post("/api/v1/article/fork", Some(form)).await
}
@ -200,7 +219,7 @@ impl ApiClient {
self.post("/api/v1/article/protect", Some(params)).await
}
pub async fn resolve_article(&self, id: Url) -> Result<ArticleView, ServerFnError> {
pub async fn resolve_article(&self, id: Url) -> Result<DbArticleView, ServerFnError> {
let resolve_object = ResolveObject { id };
self.send(Method::GET, "/api/v1/article/resolve", Some(resolve_object))
.await

View file

@ -8,6 +8,7 @@ use crate::{
article::{
actions::ArticleActions,
create::CreateArticle,
discussion::ArticleDiscussion,
edit::EditArticle,
history::ArticleHistory,
list::ListArticles,
@ -105,6 +106,7 @@ pub fn App() -> impl IntoView {
<Routes fallback=|| "Page not found.".into_view()>
<Route path=path!("/") view=ReadArticle />
<Route path=path!("/article/:title") view=ReadArticle />
<Route path=path!("/article/:title/discussion") view=ArticleDiscussion />
<Route path=path!("/article/:title/history") view=ArticleHistory />
<IbisProtectedRoute
path=path!("/article/:title/edit/:conflict_id?")

View file

@ -1,4 +1,4 @@
use crate::frontend::{markdown::render_markdown, use_cookie};
use crate::frontend::{markdown::render_article_markdown, use_cookie};
use leptos::{ev::beforeunload, html::Textarea, prelude::*};
use leptos_use::{use_event_listener, use_window};
@ -8,7 +8,7 @@ pub fn EditorView(
content: Signal<String>,
set_content: WriteSignal<String>,
) -> impl IntoView {
let (preview, set_preview) = signal(render_markdown(&content.get_untracked()));
let (preview, set_preview) = signal(render_article_markdown(&content.get_untracked()));
let cookie = use_cookie("editor_preview");
let show_preview = Signal::derive(move || cookie.0.get().unwrap_or(true));
@ -29,7 +29,7 @@ pub fn EditorView(
class="text-base resize-none grow textarea textarea-primary min-h-80"
on:input=move |evt| {
let val = event_target_value(&evt);
set_preview.set(render_markdown(&val));
set_preview.set(render_article_markdown(&val));
set_content.set(val);
}
node_ref=textarea_ref

View file

@ -1,5 +1,5 @@
use crate::{
common::{article::ArticleView, validation::can_edit_article},
common::{article::DbArticleView, validation::can_edit_article},
frontend::{
app::{is_admin, is_logged_in},
article_path,
@ -11,13 +11,14 @@ use leptos_router::components::A;
pub enum ActiveTab {
Read,
Discussion,
History,
Edit,
Actions,
}
#[component]
pub fn ArticleNav(article: Resource<ArticleView>, active_tab: ActiveTab) -> impl IntoView {
pub fn ArticleNav(article: Resource<DbArticleView>, active_tab: ActiveTab) -> impl IntoView {
let tab_classes = tab_classes(&active_tab);
view! {
@ -35,6 +36,13 @@ pub fn ArticleNav(article: Resource<ArticleView>, active_tab: ActiveTab) -> impl
<A href=article_link.clone() {..} class=tab_classes.read>
"Read"
</A>
<A
href=format!("{article_link}/discussion")
{..}
class=tab_classes.discussion
>
"Discussion"
</A>
<A
href=format!("{article_link}/history")
{..}
@ -89,6 +97,7 @@ pub fn ArticleNav(article: Resource<ArticleView>, active_tab: ActiveTab) -> impl
struct ActiveTabClasses {
read: &'static str,
discussion: &'static str,
history: &'static str,
edit: &'static str,
actions: &'static str,
@ -99,12 +108,14 @@ fn tab_classes(active_tab: &ActiveTab) -> ActiveTabClasses {
const TAB_ACTIVE: &str = "tab tab-active";
let mut classes = ActiveTabClasses {
read: TAB_INACTIVE,
discussion: TAB_INACTIVE,
history: TAB_INACTIVE,
edit: TAB_INACTIVE,
actions: TAB_INACTIVE,
};
match active_tab {
ActiveTab::Read => classes.read = TAB_ACTIVE,
ActiveTab::Discussion => classes.discussion = TAB_ACTIVE,
ActiveTab::History => classes.history = TAB_ACTIVE,
ActiveTab::Edit => classes.edit = TAB_ACTIVE,
ActiveTab::Actions => classes.actions = TAB_ACTIVE,

View file

@ -0,0 +1,146 @@
use crate::{
common::{
article::DbArticleView,
comment::{DbComment, DbCommentView, EditCommentForm},
newtypes::CommentId,
},
frontend::{
api::CLIENT,
app::{site, DefaultResource},
components::comment_editor::{CommentEditorView, EditParams},
markdown::render_comment_markdown,
time_ago,
user_link,
},
};
use leptos::prelude::*;
#[component]
pub fn CommentView(
article: Resource<DbArticleView>,
comment: DbCommentView,
show_editor: (ReadSignal<CommentId>, WriteSignal<CommentId>),
) -> impl IntoView {
let is_editing = signal(false);
let comment_change_signal = signal(comment.comment.clone());
let render_comment = move || render_content(comment_change_signal.0.get());
let delete_restore_label = move || delete_restore_label(comment_change_signal.0.get());
// css class is not included because its dynamically generated, need to use raw css instead of class
let margin = comment.comment.depth * 2;
let style_ = format!("margin-left: {margin}rem;");
let comment_id = format!("comment-{}", comment.comment.id.0);
let comment_link = format!(
"/article/{}/discussion#{comment_id}",
article
.get()
.map(|a| a.article.title.clone())
.unwrap_or_default(),
);
let delete_restore_comment_action = Action::new(move |_: &()| async move {
let form = EditCommentForm {
id: comment.comment.id,
deleted: Some(!comment_change_signal.0.get_untracked().deleted),
content: None,
};
let comment = CLIENT.edit_comment(&form).await.unwrap();
comment_change_signal.1.set(comment.comment);
});
let is_creator = site().with_default(|site| site.my_profile.as_ref().map(|p| p.person.id))
== Some(comment.comment.creator_id);
let edit_params = EditParams {
comment: comment.comment.clone(),
set_comment: comment_change_signal.1,
set_is_editing: is_editing.1,
};
view! {
<div style=style_ id=comment_id>
<div class="py-2">
<div class="flex text-xs">
<span class="grow">{user_link(&comment.creator)}</span>
<a href=comment_link class="link">
{time_ago(comment.comment.published)}
</a>
</div>
<Show
when=move || !is_editing.0.get()
fallback=move || {
view! {
<CommentEditorView
article=article
parent_id=Some(comment.comment.id)
set_show_editor=Some(show_editor.1)
edit_params=Some(edit_params.clone())
/>
}
}
>
<div class="my-2 prose prose-slate" inner_html=render_comment></div>
<div class="text-xs">
<Show when=move || !comment.comment.deleted>
<a class="link" on:click=move |_| show_editor.1.set(comment.comment.id)>
Reply
</a>
" | "
</Show>
<a class="link" href=comment.comment.ap_id.to_string()>
Fedilink
</a>
" | "
<Show when=move || is_creator && !comment_change_signal.0.get().deleted>
<a
class="link"
on:click=move |_| {
is_editing.1.set(true);
}
>
Edit
</a>
" | "
</Show>
<Show when=move || is_creator>
<a
class="link"
on:click=move |_| {
delete_restore_comment_action.dispatch(());
}
>
{delete_restore_label}
</a>
</Show>
<Show when=move || show_editor.0.get() == comment.comment.id>
<CommentEditorView
article=article
parent_id=Some(comment.comment.id)
set_show_editor=Some(show_editor.1)
edit_params=None
/>
</Show>
</div>
</Show>
</div>
<div class="m-0 divider"></div>
</div>
}
}
fn render_content(comment: DbComment) -> String {
let content = if comment.deleted {
"*deleted*"
} else {
&comment.content
};
render_comment_markdown(content)
}
fn delete_restore_label(comment: DbComment) -> &'static str {
if comment.deleted {
"Restore"
} else {
"Delete"
}
}

View file

@ -0,0 +1,115 @@
use crate::{
common::{
article::DbArticleView,
comment::{CreateCommentForm, DbComment, EditCommentForm},
newtypes::CommentId,
},
frontend::api::CLIENT,
};
use leptos::{html::Textarea, prelude::*};
use leptos_use::{use_textarea_autosize, UseTextareaAutosizeReturn};
#[derive(Clone)]
pub struct EditParams {
pub comment: DbComment,
pub set_comment: WriteSignal<DbComment>,
pub set_is_editing: WriteSignal<bool>,
}
#[component]
pub fn CommentEditorView(
article: Resource<DbArticleView>,
parent_id: Option<CommentId>,
/// Set this to CommentId(-1) to hide all editors
set_show_editor: Option<WriteSignal<CommentId>>,
/// If this is present we are editing an existing comment
edit_params: Option<EditParams>,
) -> impl IntoView {
let textarea_ref = NodeRef::<Textarea>::new();
let UseTextareaAutosizeReturn {
content,
set_content,
trigger_resize: _,
} = use_textarea_autosize(textarea_ref);
let set_is_editing = edit_params.as_ref().map(|e| e.set_is_editing);
if let Some(edit_params) = &edit_params {
set_content.set(edit_params.comment.content.clone())
};
let submit_comment_action = Action::new(move |_: &()| {
let edit_params = edit_params.clone();
async move {
if let Some(edit_params) = edit_params {
let form = EditCommentForm {
id: edit_params.comment.id,
content: Some(content.get_untracked()),
deleted: None,
};
let comment = CLIENT.edit_comment(&form).await.unwrap();
edit_params.set_comment.set(comment.comment);
edit_params.set_is_editing.set(false);
} else {
let form = CreateCommentForm {
content: content.get_untracked(),
article_id: article.await.article.id,
parent_id,
};
CLIENT.create_comment(&form).await.unwrap();
article.refetch();
if let Some(set_show_editor) = set_show_editor {
set_show_editor.set(CommentId(-1));
}
}
}
});
view! {
<div class="my-2">
<textarea
prop:value=content
placeholder="Your comment..."
class="w-full resize-none textarea textarea-secondary min-h-10"
on:input=move |evt| {
let val = event_target_value(&evt);
set_content.set(val);
}
node_ref=textarea_ref
></textarea>
<div class="flex items-center mt-2 h-min">
<button
class="btn btn-secondary btn-sm"
on:click=move |_| {
submit_comment_action.dispatch(());
}
>
Submit
</button>
<Show when=move || set_show_editor.is_some()>
<button
class="ml-2 btn btn-secondary btn-sm"
on:click=move |_| {
if let Some(set_show_editor) = set_show_editor {
set_show_editor.set(CommentId(-1));
}
if let Some(set_is_editing) = set_is_editing {
set_is_editing.set(false);
}
}
>
Cancel
</button>
</Show>
<p class="mx-2">
<a
class="link link-secondary"
href="https://ibis.wiki/article/Markdown_Guide"
target="blank_"
>
Markdown
</a>
" formatting is supported"
</p>
</div>
</div>
}
}

View file

@ -1,8 +1,10 @@
pub mod article_editor;
pub mod article_nav;
pub mod comment;
pub mod comment_editor;
pub mod connect;
pub mod credentials;
pub mod edit_list;
pub mod editor;
pub mod instance_follow_button;
pub mod nav;
pub mod protected_route;

View file

@ -57,20 +57,22 @@ impl InlineRule for ArticleLinkScanner {
}
}
#[test]
fn test_markdown_article_link() {
let parser = super::markdown_parser();
let plain = parser.parse("[[Title@example.com]]").render();
assert_eq!(
"<p><a href=\"/article/Title@example.com\">Title</a></p>\n",
plain
);
#[cfg(test)]
mod test {
use crate::frontend::markdown::render_article_markdown;
let with_label = parser
.parse("[[Title@example.com|Example Article]]")
.render();
assert_eq!(
"<p><a href=\"/article/Title@example.com\">Example Article</a></p>\n",
with_label
);
#[test]
fn test_markdown_article_link() {
let plain = render_article_markdown("[[Title@example.com]]");
assert_eq!(
"<p><a href=\"/article/Title@example.com\">Title</a></p>\n",
plain
);
let with_label = render_article_markdown("[[Title@example.com|Example Article]]");
assert_eq!(
"<p><a href=\"/article/Title@example.com\">Example Article</a></p>\n",
with_label
);
}
}

View file

@ -56,17 +56,20 @@ impl InlineRule for MathEquationScanner {
}
}
#[test]
#[expect(clippy::unwrap_used)]
fn test_markdown_equation_katex() {
let parser = super::markdown_parser();
let rendered = parser
.parse("here is a math equation: $$E=mc^2$$. Pretty cool, right?")
.render();
assert_eq!(
"<p>here is a math equation: ".to_owned()
+ &katex::render("E=mc^2").unwrap()
+ ". Pretty cool, right?</p>\n",
rendered
);
#[cfg(test)]
mod test {
use crate::frontend::markdown::render_article_markdown;
#[test]
#[expect(clippy::unwrap_used)]
fn test_markdown_equation_katex() {
let rendered =
render_article_markdown("here is a math equation: $$E=mc^2$$. Pretty cool, right?");
assert_eq!(
"<p>here is a math equation: ".to_owned()
+ &katex::render("E=mc^2").unwrap()
+ ". Pretty cool, right?</p>\n",
rendered
);
}
}

View file

@ -13,9 +13,9 @@ pub mod article_link;
pub mod math_equation;
pub mod table_of_contents;
pub fn render_markdown(text: &str) -> String {
pub fn render_article_markdown(text: &str) -> String {
static INSTANCE: OnceLock<MarkdownIt> = OnceLock::new();
let mut parsed = INSTANCE.get_or_init(markdown_parser).parse(text);
let mut parsed = INSTANCE.get_or_init(article_markdown).parse(text);
// Make markdown headings one level smaller, so that h1 becomes h2 etc, and markdown titles
// are smaller than page title.
@ -30,7 +30,32 @@ pub fn render_markdown(text: &str) -> String {
parsed.render()
}
fn markdown_parser() -> MarkdownIt {
pub fn render_comment_markdown(text: &str) -> String {
static INSTANCE: OnceLock<MarkdownIt> = OnceLock::new();
INSTANCE.get_or_init(common_markdown).parse(text).render()
}
fn article_markdown() -> MarkdownIt {
let mut parser = common_markdown();
let p = &mut parser;
// Extensions from various authors
markdown_it_heading_anchors::add(p);
markdown_it_block_spoiler::add(p);
markdown_it_footnote::add(p);
markdown_it_sub::add(p);
markdown_it_sup::add(p);
// Ibis custom extensions
parser.inline.add_rule::<ArticleLinkScanner>();
parser.inline.add_rule::<MathEquationScanner>();
parser.inline.add_rule::<TocMarkerScanner>();
parser.add_rule::<TocScanner>();
parser
}
fn common_markdown() -> MarkdownIt {
let mut parser = MarkdownIt::new();
let p = &mut parser;
{
@ -68,18 +93,5 @@ fn markdown_parser() -> MarkdownIt {
typographer::add(p);
}
// Extensions from various authors
markdown_it_heading_anchors::add(p);
markdown_it_block_spoiler::add(p);
markdown_it_footnote::add(p);
markdown_it_sub::add(p);
markdown_it_sup::add(p);
// Ibis custom extensions
parser.inline.add_rule::<ArticleLinkScanner>();
parser.inline.add_rule::<MathEquationScanner>();
parser.inline.add_rule::<TocMarkerScanner>();
parser.add_rule::<TocScanner>();
parser
}

View file

@ -3,6 +3,8 @@ use chrono::{DateTime, Duration, Local, Utc};
use codee::string::FromToStringCodec;
use leptos::prelude::*;
use leptos_use::{use_cookie_with_options, SameSite, UseCookieOptions};
use std::sync::OnceLock;
use timeago::Formatter;
pub mod api;
pub mod app;
@ -56,9 +58,9 @@ fn user_title(person: &DbPerson) -> String {
.clone()
.unwrap_or(person.username.clone());
if person.local {
name.clone()
format!("@{name}")
} else {
format!("{}@{}", name, extract_domain(&person.ap_id))
format!("@{}@{}", name, extract_domain(&person.ap_id))
}
}
@ -94,3 +96,10 @@ fn use_cookie(name: &str) -> (Signal<Option<bool>>, WriteSignal<Option<bool>>) {
.same_site(SameSite::Strict);
use_cookie_with_options::<bool, FromToStringCodec>(name, cookie_options)
}
fn time_ago(time: DateTime<Utc>) -> String {
static INSTANCE: OnceLock<Formatter> = OnceLock::new();
let secs = Utc::now().signed_duration_since(time).num_seconds();
let duration = std::time::Duration::from_secs(secs.try_into().unwrap_or_default());
INSTANCE.get_or_init(Formatter::new).convert(duration)
}

View file

@ -3,7 +3,7 @@ use crate::{
frontend::{
api::CLIENT,
app::{is_admin, site, DefaultResource},
components::editor::EditorView,
components::article_editor::EditorView,
},
};
use leptos::{html::Textarea, prelude::*};

View file

@ -0,0 +1,90 @@
use crate::{
common::{comment::DbCommentView, newtypes::CommentId},
frontend::{
components::{
article_nav::{ActiveTab, ArticleNav},
comment::CommentView,
comment_editor::CommentEditorView,
},
pages::article_resource,
},
};
use leptos::prelude::*;
use std::collections::HashMap;
#[component]
pub fn ArticleDiscussion() -> impl IntoView {
let article = article_resource();
let show_editor = signal(CommentId(-1));
view! {
<ArticleNav article=article active_tab=ActiveTab::Discussion />
<Suspense fallback=|| view! { "Loading..." }>
<CommentEditorView
article=article
parent_id=None
set_show_editor=None
edit_params=None
/>
<div>
<For
each=move || {
article.get().map(|a| build_comments_tree(a.comments)).unwrap_or_default()
}
key=|comment| comment.comment.id
children=move |comment: DbCommentView| {
view! { <CommentView article comment show_editor /> }
}
/>
</div>
</Suspense>
}
}
#[derive(Clone)]
struct CommentNode {
view: DbCommentView,
children: Vec<CommentNode>,
}
impl CommentNode {
fn new(view: DbCommentView) -> Self {
Self {
view,
children: vec![],
}
}
/// Visit the tree depth-first to build flat array from tree.
fn flatten(self) -> Vec<DbCommentView> {
let mut res = vec![self.view];
for c in self.children {
res.append(&mut c.flatten());
}
res
}
}
fn build_comments_tree(comments: Vec<DbCommentView>) -> Vec<DbCommentView> {
// First create a map of CommentId -> CommentView
let mut map: HashMap<CommentId, CommentNode> = comments
.iter()
.map(|v| (v.comment.id, CommentNode::new(v.clone())))
.collect();
// Move top-level comments directly into tree vec. For comments having parent_id, move them
// `children` of respective parent. This preserves existing order.
let mut tree = Vec::<CommentNode>::new();
for view in comments {
let child = map.get(&view.comment.id).unwrap().clone();
if let Some(parent_id) = &view.comment.parent_id {
let parent = map.get_mut(parent_id).unwrap();
parent.children.push(child);
} else {
tree.push(child);
}
}
// Now convert it back to flat array with correct order for rendering
tree.into_iter().flat_map(|t| t.flatten()).collect()
}

View file

@ -1,6 +1,6 @@
use crate::{
common::{
article::{ApiConflict, ArticleView, EditArticleForm},
article::{ApiConflict, DbArticleView, EditArticleForm},
newtypes::ConflictId,
Notification,
MAIN_PAGE_NAME,
@ -8,8 +8,8 @@ use crate::{
frontend::{
api::CLIENT,
components::{
article_editor::EditorView,
article_nav::{ActiveTab, ArticleNav},
editor::EditorView,
},
pages::article_resource,
},
@ -71,7 +71,7 @@ pub fn EditArticle() -> impl IntoView {
move |(new_text, summary, article, edit_response): &(
String,
String,
ArticleView,
DbArticleView,
EditResponse,
)| {
let new_text = new_text.clone();

View file

@ -1,5 +1,6 @@
pub mod actions;
pub mod create;
pub mod discussion;
pub mod edit;
pub mod history;
pub mod list;

View file

@ -1,6 +1,6 @@
use crate::frontend::{
components::article_nav::{ActiveTab, ArticleNav},
markdown::render_markdown,
markdown::render_article_markdown,
pages::article_resource,
};
use leptos::prelude::*;
@ -25,7 +25,7 @@ pub fn ReadArticle() -> impl IntoView {
view! {
<div
class="max-w-full prose prose-slate"
inner_html=render_markdown(&article.article.text)
inner_html=render_article_markdown(&article.article.text)
></div>
}
})

View file

@ -1,6 +1,6 @@
use crate::{
common::{
article::{ArticleView, EditView, GetArticleForm},
article::{DbArticleView, EditView, GetArticleForm},
MAIN_PAGE_NAME,
},
frontend::api::CLIENT,
@ -18,7 +18,7 @@ pub(crate) mod search;
pub(crate) mod user_edit_profile;
pub(crate) mod user_profile;
fn article_resource() -> Resource<ArticleView> {
fn article_resource() -> Resource<DbArticleView> {
let params = use_params_map();
let title = move || params.get().get("title").clone();
Resource::new(title, move |title| async move {
@ -38,7 +38,7 @@ fn article_resource() -> Resource<ArticleView> {
.unwrap()
})
}
fn article_edits_resource(article: Resource<ArticleView>) -> Resource<Vec<EditView>> {
fn article_edits_resource(article: Resource<DbArticleView>) -> Resource<Vec<EditView>> {
Resource::new(
move || article.get(),
move |_| async move {

View file

@ -3,7 +3,7 @@ use crate::{
frontend::{
api::CLIENT,
components::edit_list::EditList,
markdown::render_markdown,
markdown::render_article_markdown,
user_title,
},
};
@ -58,7 +58,7 @@ pub fn UserProfile() -> impl IntoView {
<div
class="mb-2 max-w-full prose prose-slate"
inner_html=render_markdown(&person.bio.unwrap_or_default())
inner_html=render_article_markdown(&person.bio.unwrap_or_default())
></div>
<h2 class="font-serif text-xl font-bold">Edits</h2>

View file

@ -6,8 +6,8 @@ use crate::common::{TestData, TEST_ARTICLE_DEFAULT_TEXT};
use anyhow::Result;
use ibis::common::{
article::{
ArticleView,
CreateArticleForm,
DbArticleView,
EditArticleForm,
ForkArticleForm,
GetArticleForm,
@ -15,12 +15,15 @@ use ibis::common::{
ProtectArticleForm,
SearchArticleForm,
},
comment::{CreateCommentForm, EditCommentForm},
user::{GetUserForm, LoginUserForm, RegisterUserForm},
utils::extract_domain,
Notification,
};
use pretty_assertions::{assert_eq, assert_ne};
use retry_future::{LinearRetryStrategy, RetryFuture, RetryPolicy};
use std::time::Duration;
use tokio::time::sleep;
use url::Url;
#[tokio::test]
@ -437,7 +440,7 @@ async fn test_federated_edit_conflict() -> Result<()> {
assert!(create_res.article.local);
// fetch article to gamma
let resolve_res: ArticleView = gamma
let resolve_res: DbArticleView = gamma
.resolve_article(create_res.article.ap_id.inner().clone())
.await
.unwrap();
@ -734,7 +737,7 @@ async fn test_lock_article() -> Result<()> {
let lock_res = alpha.protect_article(&lock_form).await.unwrap();
assert!(lock_res.protected);
let resolve_res: ArticleView = gamma
let resolve_res: DbArticleView = gamma
.resolve_article(create_res.article.ap_id.inner().clone())
.await
.unwrap();
@ -836,3 +839,147 @@ async fn test_article_approval_required() -> Result<()> {
TestData::stop(alpha, beta, gamma)
}
#[tokio::test]
async fn test_comment_create_edit() -> Result<()> {
let TestData(alpha, beta, gamma) = TestData::start(true).await;
beta.follow_instance_with_resolve(&alpha.hostname)
.await
.unwrap();
gamma
.follow_instance_with_resolve(&alpha.hostname)
.await
.unwrap();
// create article
let form = CreateArticleForm {
title: "Manu_Chao".to_string(),
text: TEST_ARTICLE_DEFAULT_TEXT.to_string(),
summary: "create article".to_string(),
};
let alpha_article = alpha.create_article(&form).await.unwrap();
// fetch article on beta and create comment
let beta_article = beta
.resolve_article(alpha_article.article.ap_id.inner().clone())
.await
.unwrap();
let form = CreateCommentForm {
content: "top comment".to_string(),
article_id: beta_article.article.id,
parent_id: None,
};
let top_comment = beta.create_comment(&form).await.unwrap().comment;
assert_eq!(top_comment.content, form.content);
assert_eq!(top_comment.article_id, beta_article.article.id);
assert_eq!(top_comment.depth, 0);
assert!(top_comment.parent_id.is_none());
assert!(top_comment.local);
assert!(!top_comment.deleted);
assert!(top_comment.updated.is_none());
sleep(Duration::from_secs(1)).await;
// now create child comment on alpha
let get_form = GetArticleForm {
title: Some(alpha_article.article.title),
domain: Some(alpha.hostname.clone()),
..Default::default()
};
let article = alpha.get_article(get_form.clone()).await.unwrap();
assert_eq!(1, article.comments.len());
let form = CreateCommentForm {
content: "child comment".to_string(),
article_id: article.article.id,
parent_id: Some(article.comments[0].comment.id),
};
let child_comment = alpha.create_comment(&form).await.unwrap().comment;
assert_eq!(child_comment.parent_id, Some(top_comment.id));
assert_eq!(child_comment.depth, 1);
// edit comment text
let edit_form = EditCommentForm {
id: child_comment.id,
content: Some("edited comment".to_string()),
deleted: None,
};
let edited_comment = alpha.edit_comment(&edit_form).await.unwrap().comment;
assert_eq!(edited_comment.article_id, article.article.id);
assert_eq!(Some(&edited_comment.content), edit_form.content.as_ref());
let beta_comments = beta.get_article(get_form.clone()).await.unwrap().comments;
assert_eq!(2, beta_comments.len());
assert_eq!(beta_comments[1].comment.content, top_comment.content);
assert_eq!(
Some(&beta_comments[0].comment.content),
edit_form.content.as_ref()
);
let gamma_comments = gamma.get_article(get_form).await.unwrap().comments;
assert_eq!(2, gamma_comments.len());
assert_eq!(edited_comment.content, gamma_comments[0].comment.content);
TestData::stop(alpha, beta, gamma)
}
#[tokio::test]
async fn test_comment_delete_restore() -> Result<()> {
let TestData(alpha, beta, gamma) = TestData::start(true).await;
beta.follow_instance_with_resolve(&alpha.hostname)
.await
.unwrap();
// create article and comment
let form = CreateArticleForm {
title: "Manu_Chao".to_string(),
text: TEST_ARTICLE_DEFAULT_TEXT.to_string(),
summary: "create article".to_string(),
};
let alpha_article = alpha.create_article(&form).await.unwrap();
let form = CreateCommentForm {
content: "my comment".to_string(),
article_id: alpha_article.article.id,
parent_id: None,
};
let comment = alpha.create_comment(&form).await.unwrap();
let get_form = GetArticleForm {
title: Some(alpha_article.article.title),
domain: Some(alpha.hostname.clone()),
..Default::default()
};
// delete comment
let mut form = EditCommentForm {
id: comment.comment.id,
deleted: Some(true),
content: None,
};
alpha.edit_comment(&form).await.unwrap();
let alpha_comments = alpha.get_article(get_form.clone()).await.unwrap().comments;
assert!(alpha_comments[0].comment.deleted);
assert!(alpha_comments[0].comment.content.is_empty());
sleep(Duration::from_secs(1)).await;
// check that comment is deleted on beta
let beta_comments = beta.get_article(get_form.clone()).await.unwrap().comments;
assert_eq!(comment.comment.ap_id, beta_comments[0].comment.ap_id);
assert!(beta_comments[0].comment.deleted);
assert!(beta_comments[0].comment.content.is_empty());
// restore comment
form.deleted = Some(false);
alpha.edit_comment(&form).await.unwrap();
let alpha_comments = alpha.get_article(get_form.clone()).await.unwrap().comments;
assert!(!alpha_comments[0].comment.deleted);
assert!(!alpha_comments[0].comment.content.is_empty());
sleep(Duration::from_secs(1)).await;
// check that comment is restored on beta
let beta_comments = beta.get_article(get_form).await.unwrap().comments;
assert!(!beta_comments[0].comment.deleted);
assert!(!beta_comments[0].comment.content.is_empty());
TestData::stop(alpha, beta, gamma)
}