From 54c65e7474bfabd4b46eda441189091b4a00ffa0 Mon Sep 17 00:00:00 2001 From: Nutomic Date: Tue, 21 Jan 2025 11:39:10 +0000 Subject: [PATCH] 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 --- .woodpecker.yml | 2 +- Cargo.lock | 47 +++++- Cargo.toml | 3 +- .../down.sql | 5 +- .../up.sql | 26 +-- .../2023-11-28-150402_ibis_setup/down.sql | 24 ++- .../2023-11-28-150402_ibis_setup/up.sql | 92 +++++------ .../2024-11-11-111150_instances_url/down.sql | 7 +- .../2024-11-11-111150_instances_url/up.sql | 7 +- .../down.sql | 4 +- .../up.sql | 8 +- .../down.sql | 12 +- .../2024-11-12-131724_article-approval/up.sql | 12 +- .../2024-12-18-120344_user_bio/down.sql | 8 +- migrations/2024-12-18-120344_user_bio/up.sql | 8 +- .../2024-12-18-214511_site-stats/down.sql | 4 +- .../2024-12-18-214511_site-stats/up.sql | 23 ++- .../2025-01-17-090151_edit_pending/down.sql | 4 +- .../2025-01-17-090151_edit_pending/up.sql | 4 +- .../2025-01-17-150059_comments/down.sql | 36 +++++ migrations/2025-01-17-150059_comments/up.sql | 96 +++++++++++ src/backend/api/article.rs | 57 ++++--- src/backend/api/comment.rs | 112 +++++++++++++ src/backend/api/mod.rs | 12 +- src/backend/database/article.rs | 15 +- src/backend/database/comment.rs | 127 +++++++++++++++ src/backend/database/instance.rs | 20 ++- src/backend/database/instance_stats.rs | 1 + src/backend/database/mod.rs | 1 + src/backend/database/schema.rs | 21 +++ src/backend/database/user.rs | 2 +- src/backend/federation/activities/announce.rs | 80 +++++++++ .../comment/create_or_update_comment.rs | 95 +++++++++++ .../activities/comment/delete_comment.rs | 96 +++++++++++ .../federation/activities/comment/mod.rs | 13 ++ .../activities/comment/undo_delete_comment.rs | 92 +++++++++++ .../federation/activities/create_article.rs | 4 +- src/backend/federation/activities/follow.rs | 2 +- src/backend/federation/activities/mod.rs | 2 + src/backend/federation/activities/reject.rs | 2 +- .../activities/update_local_article.rs | 2 +- .../activities/update_remote_article.rs | 2 +- src/backend/federation/mod.rs | 23 ++- src/backend/federation/objects/article.rs | 10 +- .../federation/objects/article_or_comment.rs | 78 +++++++++ src/backend/federation/objects/comment.rs | 110 +++++++++++++ src/backend/federation/objects/edit.rs | 5 +- src/backend/federation/objects/instance.rs | 8 +- src/backend/federation/objects/mod.rs | 2 + src/backend/federation/routes.rs | 47 +++++- src/backend/mod.rs | 2 +- src/backend/server/nodeinfo.rs | 2 +- src/backend/utils/validate.rs | 14 ++ src/common/article.rs | 4 +- src/common/comment.rs | 57 +++++++ src/common/mod.rs | 1 + src/common/newtypes.rs | 4 + src/frontend/api.rs | 29 +++- src/frontend/app.rs | 2 + .../{editor.rs => article_editor.rs} | 6 +- src/frontend/components/article_nav.rs | 15 +- src/frontend/components/comment.rs | 146 +++++++++++++++++ src/frontend/components/comment_editor.rs | 115 +++++++++++++ src/frontend/components/mod.rs | 4 +- src/frontend/markdown/article_link.rs | 32 ++-- src/frontend/markdown/math_equation.rs | 29 ++-- src/frontend/markdown/mod.rs | 44 +++-- src/frontend/mod.rs | 13 +- src/frontend/pages/article/create.rs | 2 +- src/frontend/pages/article/discussion.rs | 90 +++++++++++ src/frontend/pages/article/edit.rs | 6 +- src/frontend/pages/article/mod.rs | 1 + src/frontend/pages/article/read.rs | 4 +- src/frontend/pages/mod.rs | 6 +- src/frontend/pages/user_profile.rs | 4 +- tests/test.rs | 153 +++++++++++++++++- 76 files changed, 2025 insertions(+), 233 deletions(-) create mode 100644 migrations/2025-01-17-150059_comments/down.sql create mode 100644 migrations/2025-01-17-150059_comments/up.sql create mode 100644 src/backend/api/comment.rs create mode 100644 src/backend/database/comment.rs create mode 100644 src/backend/federation/activities/announce.rs create mode 100644 src/backend/federation/activities/comment/create_or_update_comment.rs create mode 100644 src/backend/federation/activities/comment/delete_comment.rs create mode 100644 src/backend/federation/activities/comment/mod.rs create mode 100644 src/backend/federation/activities/comment/undo_delete_comment.rs create mode 100644 src/backend/federation/objects/article_or_comment.rs create mode 100644 src/backend/federation/objects/comment.rs create mode 100644 src/common/comment.rs rename src/frontend/components/{editor.rs => article_editor.rs} (90%) create mode 100644 src/frontend/components/comment.rs create mode 100644 src/frontend/components/comment_editor.rs create mode 100644 src/frontend/pages/article/discussion.rs diff --git a/.woodpecker.yml b/.woodpecker.yml index 4bb1dda..3dc2740 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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" diff --git a/Cargo.lock b/Cargo.lock index b7d2466..a1768b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index eb522ea..346d0b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql index a9f5260..5e29318 100644 --- a/migrations/00000000000000_diesel_initial_setup/down.sql +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -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(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql index d68895b..246f5e4 100644 --- a/migrations/00000000000000_diesel_initial_setup/up.sql +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -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; + diff --git a/migrations/2023-11-28-150402_ibis_setup/down.sql b/migrations/2023-11-28-150402_ibis_setup/down.sql index abbbd6f..2b1c80f 100644 --- a/migrations/2023-11-28-150402_ibis_setup/down.sql +++ b/migrations/2023-11-28-150402_ibis_setup/down.sql @@ -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; + diff --git a/migrations/2023-11-28-150402_ibis_setup/up.sql b/migrations/2023-11-28-150402_ibis_setup/up.sql index 2f5fcb8..cb143fe 100644 --- a/migrations/2023-11-28-150402_ibis_setup/up.sql +++ b/migrations/2023-11-28-150402_ibis_setup/up.sql @@ -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 diff --git a/migrations/2024-11-11-111150_instances_url/down.sql b/migrations/2024-11-11-111150_instances_url/down.sql index fdd4cc0..1cd5f2f 100644 --- a/migrations/2024-11-11-111150_instances_url/down.sql +++ b/migrations/2024-11-11-111150_instances_url/down.sql @@ -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; diff --git a/migrations/2024-11-11-111150_instances_url/up.sql b/migrations/2024-11-11-111150_instances_url/up.sql index 7addfa6..083f8c4 100644 --- a/migrations/2024-11-11-111150_instances_url/up.sql +++ b/migrations/2024-11-11-111150_instances_url/up.sql @@ -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; diff --git a/migrations/2024-11-11-142910_conflict-constraint/down.sql b/migrations/2024-11-11-142910_conflict-constraint/down.sql index 9e13a3e..deb75de 100644 --- a/migrations/2024-11-11-142910_conflict-constraint/down.sql +++ b/migrations/2024-11-11-142910_conflict-constraint/down.sql @@ -1 +1,3 @@ -select 1; \ No newline at end of file +SELECT + 1; + diff --git a/migrations/2024-11-11-142910_conflict-constraint/up.sql b/migrations/2024-11-11-142910_conflict-constraint/up.sql index 33dff81..705e10c 100644 --- a/migrations/2024-11-11-142910_conflict-constraint/up.sql +++ b/migrations/2024-11-11-142910_conflict-constraint/up.sql @@ -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; \ No newline at end of file +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; + diff --git a/migrations/2024-11-12-131724_article-approval/down.sql b/migrations/2024-11-12-131724_article-approval/down.sql index 18d5121..e93b450 100644 --- a/migrations/2024-11-12-131724_article-approval/down.sql +++ b/migrations/2024-11-12-131724_article-approval/down.sql @@ -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; \ No newline at end of file diff --git a/migrations/2024-11-12-131724_article-approval/up.sql b/migrations/2024-11-12-131724_article-approval/up.sql index 3d60114..640aa25 100644 --- a/migrations/2024-11-12-131724_article-approval/up.sql +++ b/migrations/2024-11-12-131724_article-approval/up.sql @@ -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; \ No newline at end of file diff --git a/migrations/2024-12-18-120344_user_bio/down.sql b/migrations/2024-12-18-120344_user_bio/down.sql index bdc89f0..7ff5895 100644 --- a/migrations/2024-12-18-120344_user_bio/down.sql +++ b/migrations/2024-12-18-120344_user_bio/down.sql @@ -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; + diff --git a/migrations/2024-12-18-120344_user_bio/up.sql b/migrations/2024-12-18-120344_user_bio/up.sql index e40e899..87a1dd1 100644 --- a/migrations/2024-12-18-120344_user_bio/up.sql +++ b/migrations/2024-12-18-120344_user_bio/up.sql @@ -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); + diff --git a/migrations/2024-12-18-214511_site-stats/down.sql b/migrations/2024-12-18-214511_site-stats/down.sql index 778e7da..580548d 100644 --- a/migrations/2024-12-18-214511_site-stats/down.sql +++ b/migrations/2024-12-18-214511_site-stats/down.sql @@ -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; diff --git a/migrations/2024-12-18-214511_site-stats/up.sql b/migrations/2024-12-18-214511_site-stats/up.sql index 3d0cb79..796f5ec 100644 --- a/migrations/2024-12-18-214511_site-stats/up.sql +++ b/migrations/2024-12-18-214511_site-stats/up.sql @@ -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')); + diff --git a/migrations/2025-01-17-090151_edit_pending/down.sql b/migrations/2025-01-17-090151_edit_pending/down.sql index 2a57939..8c174f4 100644 --- a/migrations/2025-01-17-090151_edit_pending/down.sql +++ b/migrations/2025-01-17-090151_edit_pending/down.sql @@ -1 +1,3 @@ -alter table edit drop column pending; \ No newline at end of file +ALTER TABLE edit + DROP COLUMN pending; + diff --git a/migrations/2025-01-17-090151_edit_pending/up.sql b/migrations/2025-01-17-090151_edit_pending/up.sql index 3cfcd84..99f2f21 100644 --- a/migrations/2025-01-17-090151_edit_pending/up.sql +++ b/migrations/2025-01-17-090151_edit_pending/up.sql @@ -1 +1,3 @@ -alter table edit add column pending bool not null default false; \ No newline at end of file +ALTER TABLE edit + ADD COLUMN pending bool NOT NULL DEFAULT FALSE; + diff --git a/migrations/2025-01-17-150059_comments/down.sql b/migrations/2025-01-17-150059_comments/down.sql new file mode 100644 index 0000000..a3d8481 --- /dev/null +++ b/migrations/2025-01-17-150059_comments/down.sql @@ -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; +$$; + diff --git a/migrations/2025-01-17-150059_comments/up.sql b/migrations/2025-01-17-150059_comments/up.sql new file mode 100644 index 0000000..fc423c9 --- /dev/null +++ b/migrations/2025-01-17-150059_comments/up.sql @@ -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; +$$; + diff --git a/src/backend/api/article.rs b/src/backend/api/article.rs index aa31a42..abf9ee1 100644 --- a/src/backend/api/article.rs +++ b/src/backend/api/article.rs @@ -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, data: Data, Form(mut params): Form, -) -> MyResult> { +) -> MyResult> { params.title = validate_article_title(¶ms.title)?; + validate_not_empty(¶ms.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, data: Data, - Form(mut edit_form): Form, + Form(mut params): Form, ) -> MyResult>> { + validate_not_empty(¶ms.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(¶ms.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, ¶ms.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(¶ms.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, data: Data, -) -> MyResult> { +) -> MyResult> { 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, data: Data, Form(mut params): Form, -) -> MyResult> { +) -> MyResult> { // 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(¶ms.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, data: Data, -) -> MyResult> { +) -> MyResult> { 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, })) } diff --git a/src/backend/api/comment.rs b/src/backend/api/comment.rs new file mode 100644 index 0000000..13e3d02 --- /dev/null +++ b/src/backend/api/comment.rs @@ -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, + data: Data, + Form(params): Form, +) -> MyResult> { + validate_not_empty(¶ms.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, + data: Data, + Form(params): Form, +) -> MyResult> { + if let Some(content) = ¶ms.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)) +} diff --git a/src/backend/api/mod.rs b/src/backend/api/mod.rs index 7d7ed47..e4a8001 100644 --- a/src/backend/api/mod.rs +++ b/src/backend/api/mod.rs @@ -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)) diff --git a/src/backend/database/article.rs b/src/backend/database/article.rs index 30cdb54..658b5f6 100644 --- a/src/backend/database/article.rs +++ b/src/backend/database/article.rs @@ -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::(conn.deref_mut())?) } - pub fn read_view(id: ArticleId, data: &IbisData) -> MyResult { + pub fn read_view(id: ArticleId, data: &IbisData) -> MyResult { 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, data: &IbisData, - ) -> MyResult { + ) -> MyResult { 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, }) } diff --git a/src/backend/database/comment.rs b/src/backend/database/comment.rs new file mode 100644 index 0000000..3ac1816 --- /dev/null +++ b/src/backend/database/comment.rs @@ -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, + pub content: String, + pub depth: i32, + pub ap_id: Option>, + pub local: bool, + pub deleted: bool, + pub published: DateTime, + pub updated: Option>, +} + +#[derive(AsChangeset, Default)] +#[diesel(table_name = comment, check_for_backend(diesel::pg::Pg))] +pub struct DbCommentUpdateForm { + pub content: Option, + pub deleted: Option, + pub ap_id: Option>, + pub updated: Option>, +} + +impl DbComment { + pub fn create(form: DbCommentInsertForm, data: &IbisData) -> MyResult { + 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 { + 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 { + 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 { + let mut conn = data.db_pool.get()?; + Ok(comment::table + .find(id) + .get_result::(conn.deref_mut())?) + } + + pub fn read_view(id: CommentId, data: &IbisData) -> MyResult { + let mut conn = data.db_pool.get()?; + let comment = comment::table + .find(id) + .get_result::(conn.deref_mut())?; + let creator = DbPerson::read(comment.creator_id, data)?; + Ok(DbCommentView { comment, creator }) + } + + pub fn read_from_ap_id(ap_id: &ObjectId, data: &IbisData) -> MyResult { + 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> { + 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()) + } +} diff --git a/src/backend/database/instance.rs b/src/backend/database/instance.rs index e248789..22c02fc 100644 --- a/src/backend/database/instance.rs +++ b/src/backend/database/instance.rs @@ -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 { + pub fn read_local(data: &IbisData) -> MyResult { 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, data: &Data) -> MyResult { 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) -> MyResult { + 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())?) + } } diff --git a/src/backend/database/instance_stats.rs b/src/backend/database/instance_stats.rs index bcab19a..5131ee6 100644 --- a/src/backend/database/instance_stats.rs +++ b/src/backend/database/instance_stats.rs @@ -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 { diff --git a/src/backend/database/mod.rs b/src/backend/database/mod.rs index 0bac711..d2f7fa0 100644 --- a/src/backend/database/mod.rs +++ b/src/backend/database/mod.rs @@ -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; diff --git a/src/backend/database/schema.rs b/src/backend/database/schema.rs index 9e58f0f..e7ee154 100644 --- a/src/backend/database/schema.rs +++ b/src/backend/database/schema.rs @@ -15,6 +15,23 @@ diesel::table! { } } +diesel::table! { + comment (id) { + id -> Int4, + creator_id -> Int4, + article_id -> Int4, + parent_id -> Nullable, + content -> Text, + depth -> Int4, + #[max_length = 255] + ap_id -> Varchar, + local -> Bool, + deleted -> Bool, + published -> Timestamptz, + updated -> Nullable, + } +} + 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, diff --git a/src/backend/database/user.rs b/src/backend/database/user.rs index 1e7d4a4..23d1e9f 100644 --- a/src/backend/database/user.rs +++ b/src/backend/database/user.rs @@ -61,7 +61,7 @@ impl DbPerson { .get_result::(conn.deref_mut())?) } - pub fn read(id: PersonId, data: &Data) -> MyResult { + pub fn read(id: PersonId, data: &IbisData) -> MyResult { let mut conn = data.db_pool.get()?; Ok(person::table.find(id).get_result(conn.deref_mut())?) } diff --git a/src/backend/federation/activities/announce.rs b/src/backend/federation/activities/announce.rs new file mode 100644 index 0000000..6270eca --- /dev/null +++ b/src/backend/federation/activities/announce.rs @@ -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, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + 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) -> 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) -> MyResult<()> { + Ok(()) + } + + #[tracing::instrument(skip_all)] + async fn receive(self, context: &Data) -> MyResult<()> { + self.object.verify(context).await?; + self.object.receive(context).await + } +} diff --git a/src/backend/federation/activities/comment/create_or_update_comment.rs b/src/backend/federation/activities/comment/create_or_update_comment.rs new file mode 100644 index 0000000..8bfdcf0 --- /dev/null +++ b/src/backend/federation/activities/comment/create_or_update_comment.rs @@ -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, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + 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) -> 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) -> 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) -> 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(()) + } +} diff --git a/src/backend/federation/activities/comment/delete_comment.rs b/src/backend/federation/activities/comment/delete_comment.rs new file mode 100644 index 0000000..e527177 --- /dev/null +++ b/src/backend/federation/activities/comment/delete_comment.rs @@ -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, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + pub(crate) object: ObjectId, + #[serde(rename = "type")] + pub(crate) kind: DeleteType, + pub(crate) id: Url, +} + +impl DeleteComment { + pub fn new( + comment: &DbComment, + creator: &DbPerson, + instance: &DbInstance, + data: &Data, + ) -> MyResult { + 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) -> 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) -> 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) -> 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(()) + } +} diff --git a/src/backend/federation/activities/comment/mod.rs b/src/backend/federation/activities/comment/mod.rs new file mode 100644 index 0000000..d4fe389 --- /dev/null +++ b/src/backend/federation/activities/comment/mod.rs @@ -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> { + let followers_url = format!("{}/followers", &instance.ap_id); + Ok(vec![public(), followers_url.parse()?]) +} diff --git a/src/backend/federation/activities/comment/undo_delete_comment.rs b/src/backend/federation/activities/comment/undo_delete_comment.rs new file mode 100644 index 0000000..c189d0e --- /dev/null +++ b/src/backend/federation/activities/comment/undo_delete_comment.rs @@ -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, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + 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) -> 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) -> 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) -> 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(()) + } +} diff --git a/src/backend/federation/activities/create_article.rs b/src/backend/federation/activities/create_article.rs index 28245c0..8965a58 100644 --- a/src/backend/federation/activities/create_article.rs +++ b/src/backend/federation/activities/create_article.rs @@ -33,7 +33,7 @@ pub struct CreateArticle { impl CreateArticle { pub async fn send_to_followers(article: DbArticle, data: &Data) -> 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) -> 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(()) diff --git a/src/backend/federation/activities/follow.rs b/src/backend/federation/activities/follow.rs index 99df4e8..2dc2f23 100644 --- a/src/backend/federation/activities/follow.rs +++ b/src/backend/federation/activities/follow.rs @@ -60,7 +60,7 @@ impl ActivityHandler for Follow { async fn receive(self, data: &Data) -> 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)?; diff --git a/src/backend/federation/activities/mod.rs b/src/backend/federation/activities/mod.rs index f8e507f..9db93a4 100644 --- a/src/backend/federation/activities/mod.rs +++ b/src/backend/federation/activities/mod.rs @@ -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; diff --git a/src/backend/federation/activities/reject.rs b/src/backend/federation/activities/reject.rs index fe0cb1c..7c0d5fc 100644 --- a/src/backend/federation/activities/reject.rs +++ b/src/backend/federation/activities/reject.rs @@ -40,7 +40,7 @@ impl RejectEdit { user_instance: DbInstance, data: &Data, ) -> 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(), diff --git a/src/backend/federation/activities/update_local_article.rs b/src/backend/federation/activities/update_local_article.rs index 7ba98d3..69d9e6e 100644 --- a/src/backend/federation/activities/update_local_article.rs +++ b/src/backend/federation/activities/update_local_article.rs @@ -39,7 +39,7 @@ impl UpdateLocalArticle { data: &Data, ) -> 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())); diff --git a/src/backend/federation/activities/update_remote_article.rs b/src/backend/federation/activities/update_remote_article.rs index 5a9ecc4..a089a86 100644 --- a/src/backend/federation/activities/update_remote_article.rs +++ b/src/backend/federation/activities/update_remote_article.rs @@ -47,7 +47,7 @@ impl UpdateRemoteArticle { article_instance: DbInstance, data: &Data, ) -> 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(), diff --git a/src/backend/federation/mod.rs b/src/backend/federation/mod.rs index 2e2a7a9..d5c9a8a 100644 --- a/src/backend/federation/mod.rs +++ b/src/backend/federation/mod.rs @@ -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, +) -> 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); diff --git a/src/backend/federation/objects/article.rs b/src/backend/federation/objects/article.rs index 9bbf694..d742d00 100644 --- a/src/backend/federation/objects/article.rs +++ b/src/backend/federation/objects/article.rs @@ -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) -> Result { - 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, + data: &Data, ) -> Result<(), Self::Error> { verify_domains_match(json.id.inner(), expected_domain)?; + verify_is_remote_object(&json.id, data)?; Ok(()) } diff --git a/src/backend/federation/objects/article_or_comment.rs b/src/backend/federation/objects/article_or_comment.rs new file mode 100644 index 0000000..874c568 --- /dev/null +++ b/src/backend/federation/objects/article_or_comment.rs @@ -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), + Comment(Box), +} + +#[async_trait::async_trait] +impl Object for DbArticleOrComment { + type DataType = IbisData; + type Kind = ApubArticleOrComment; + type Error = Error; + + fn last_refreshed_at(&self) -> Option> { + None + } + + async fn read_from_id(object_id: Url, data: &Data) -> MyResult> { + 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) -> MyResult<()> { + match self { + Self::Article(p) => p.delete(data).await, + Self::Comment(c) => c.delete(data).await, + } + } + + async fn into_json(self, data: &Data) -> MyResult { + 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, + ) -> 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) -> MyResult { + 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?), + }) + } +} diff --git a/src/backend/federation/objects/comment.rs b/src/backend/federation/objects/comment.rs new file mode 100644 index 0000000..5cbd287 --- /dev/null +++ b/src/backend/federation/objects/comment.rs @@ -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, + pub attributed_to: ObjectId, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub to: Vec, + content: String, + pub in_reply_to: ObjectId, + pub published: Option>, + pub updated: Option>, +} + +#[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, + ) -> Result, Self::Error> { + Ok(DbComment::read_from_ap_id(&object_id.into(), data).ok()) + } + + async fn into_json(self, data: &Data) -> Result { + 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, + ) -> 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) -> Result { + 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)?) + } +} diff --git a/src/backend/federation/objects/edit.rs b/src/backend/federation/objects/edit.rs index 8d0945c..e16aead 100644 --- a/src/backend/federation/objects/edit.rs +++ b/src/backend/federation/objects/edit.rs @@ -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, + data: &Data, ) -> Result<(), Self::Error> { verify_domains_match(json.id.inner(), expected_domain)?; + verify_is_remote_object(&json.id, data)?; Ok(()) } diff --git a/src/backend/federation/objects/instance.rs b/src/backend/federation/objects/instance.rs index 80ecaf4..7353662 100644 --- a/src/backend/federation/objects/instance.rs +++ b/src/backend/federation/objects/instance.rs @@ -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, + data: &Data, ) -> Result<(), Self::Error> { verify_domains_match(json.id.inner(), expected_domain)?; + verify_is_remote_object(&json.id, data)?; Ok(()) } diff --git a/src/backend/federation/objects/mod.rs b/src/backend/federation/objects/mod.rs index 4f83dbf..84c7d5f 100644 --- a/src/backend/federation/objects/mod.rs +++ b/src/backend/federation/objects/mod.rs @@ -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; diff --git a/src/backend/federation/routes.rs b/src/backend/federation/routes.rs index 50134a8..429cd22 100644 --- a/src/backend/federation/routes.rs +++ b/src/backend/federation/routes.rs @@ -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, ) -> MyResult>> { - 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, + data: Data, +) -> MyResult>> { + 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] diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 0913376..70cdb28 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -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?; } diff --git a/src/backend/server/nodeinfo.rs b/src/backend/server/nodeinfo.rs index 6dd212e..4a5ed0b 100644 --- a/src/backend/server/nodeinfo.rs +++ b/src/backend/server/nodeinfo.rs @@ -47,7 +47,7 @@ async fn node_info(data: Data) -> MyResult> { 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(), diff --git a/src/backend/utils/validate.rs b/src/backend/utils/validate.rs index 16adb56..f5eb938 100644 --- a/src/backend/utils/validate.rs +++ b/src/backend/utils/validate.rs @@ -35,6 +35,20 @@ pub fn validate_display_name(name: &Option) -> 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() { diff --git a/src/common/article.rs b/src/common/article.rs index a277a16..d197290 100644 --- a/src/common/article.rs +++ b/src/common/article.rs @@ -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, pub latest_version: EditVersion, } diff --git a/src/common/comment.rs b/src/common/comment.rs new file mode 100644 index 0000000..53bab96 --- /dev/null +++ b/src/common/comment.rs @@ -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, + pub content: String, + pub depth: i32, + #[cfg(feature = "ssr")] + pub ap_id: ObjectId, + #[cfg(not(feature = "ssr"))] + pub ap_id: String, + pub local: bool, + pub deleted: bool, + pub published: DateTime, + pub updated: Option>, +} + +#[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, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct EditCommentForm { + pub id: CommentId, + pub content: Option, + pub deleted: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct DeleteCommentForm { + pub id: CommentId, +} diff --git a/src/common/mod.rs b/src/common/mod.rs index a855285..17d1514 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,4 +1,5 @@ pub mod article; +pub mod comment; pub mod instance; pub mod newtypes; pub mod user; diff --git a/src/common/newtypes.rs b/src/common/newtypes.rs index f4187a0..9ea5f94 100644 --- a/src/common/newtypes.rs +++ b/src/common/newtypes.rs @@ -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); diff --git a/src/frontend/api.rs b/src/frontend/api.rs index b004cc0..58591e9 100644 --- a/src/frontend/api.rs +++ b/src/frontend/api.rs @@ -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 { + pub async fn get_article(&self, data: GetArticleForm) -> Option { self.get("/api/v1/article", Some(data)).await } @@ -79,7 +80,7 @@ impl ApiClient { pub async fn create_article( &self, data: &CreateArticleForm, - ) -> Result { + ) -> Result { 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 { + pub async fn edit_article(&self, edit_form: &EditArticleForm) -> Option { 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 { + self.post("/api/v1/comment", Some(&data)).await + } + + pub async fn edit_comment( + &self, + data: &EditCommentForm, + ) -> Result { + self.send(Method::PATCH, "/api/v1/comment", Some(&data)) + .await + } + pub async fn notifications_list(&self) -> Option> { 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 { + pub async fn fork_article( + &self, + form: &ForkArticleForm, + ) -> Result { 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 { + pub async fn resolve_article(&self, id: Url) -> Result { let resolve_object = ResolveObject { id }; self.send(Method::GET, "/api/v1/article/resolve", Some(resolve_object)) .await diff --git a/src/frontend/app.rs b/src/frontend/app.rs index 4af89eb..9bc970c 100644 --- a/src/frontend/app.rs +++ b/src/frontend/app.rs @@ -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 { + , set_content: WriteSignal, ) -> 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 diff --git a/src/frontend/components/article_nav.rs b/src/frontend/components/article_nav.rs index ca72c42..4f4ca66 100644 --- a/src/frontend/components/article_nav.rs +++ b/src/frontend/components/article_nav.rs @@ -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, active_tab: ActiveTab) -> impl IntoView { +pub fn ArticleNav(article: Resource, active_tab: ActiveTab) -> impl IntoView { let tab_classes = tab_classes(&active_tab); view! { @@ -35,6 +36,13 @@ pub fn ArticleNav(article: Resource, active_tab: ActiveTab) -> impl "Read" + + "Discussion" + , 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, diff --git a/src/frontend/components/comment.rs b/src/frontend/components/comment.rs new file mode 100644 index 0000000..6a7e959 --- /dev/null +++ b/src/frontend/components/comment.rs @@ -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, + comment: DbCommentView, + show_editor: (ReadSignal, WriteSignal), +) -> 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! { + + } +} + +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" + } +} diff --git a/src/frontend/components/comment_editor.rs b/src/frontend/components/comment_editor.rs new file mode 100644 index 0000000..c7bfca1 --- /dev/null +++ b/src/frontend/components/comment_editor.rs @@ -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, + pub set_is_editing: WriteSignal, +} + +#[component] +pub fn CommentEditorView( + article: Resource, + parent_id: Option, + /// Set this to CommentId(-1) to hide all editors + set_show_editor: Option>, + /// If this is present we are editing an existing comment + edit_params: Option, +) -> impl IntoView { + let textarea_ref = NodeRef:: +
+ + + + +

+ + Markdown + + " formatting is supported" +

+
+ + } +} diff --git a/src/frontend/components/mod.rs b/src/frontend/components/mod.rs index e86b57d..e60aebe 100644 --- a/src/frontend/components/mod.rs +++ b/src/frontend/components/mod.rs @@ -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; diff --git a/src/frontend/markdown/article_link.rs b/src/frontend/markdown/article_link.rs index ae81f79..1b0f7cf 100644 --- a/src/frontend/markdown/article_link.rs +++ b/src/frontend/markdown/article_link.rs @@ -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!( - "

Title

\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!( - "

Example Article

\n", - with_label - ); + #[test] + fn test_markdown_article_link() { + let plain = render_article_markdown("[[Title@example.com]]"); + assert_eq!( + "

Title

\n", + plain + ); + + let with_label = render_article_markdown("[[Title@example.com|Example Article]]"); + assert_eq!( + "

Example Article

\n", + with_label + ); + } } diff --git a/src/frontend/markdown/math_equation.rs b/src/frontend/markdown/math_equation.rs index 88ccecf..4668bb8 100644 --- a/src/frontend/markdown/math_equation.rs +++ b/src/frontend/markdown/math_equation.rs @@ -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!( - "

here is a math equation: ".to_owned() - + &katex::render("E=mc^2").unwrap() - + ". Pretty cool, right?

\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!( + "

here is a math equation: ".to_owned() + + &katex::render("E=mc^2").unwrap() + + ". Pretty cool, right?

\n", + rendered + ); + } } diff --git a/src/frontend/markdown/mod.rs b/src/frontend/markdown/mod.rs index 7bfc588..b683cef 100644 --- a/src/frontend/markdown/mod.rs +++ b/src/frontend/markdown/mod.rs @@ -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 = 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 = 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::(); + parser.inline.add_rule::(); + parser.inline.add_rule::(); + parser.add_rule::(); + + 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::(); - parser.inline.add_rule::(); - parser.inline.add_rule::(); - parser.add_rule::(); - parser } diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 7ccc32d..bd6e474 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -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>, WriteSignal>) { .same_site(SameSite::Strict); use_cookie_with_options::(name, cookie_options) } + +fn time_ago(time: DateTime) -> String { + static INSTANCE: OnceLock = 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) +} diff --git a/src/frontend/pages/article/create.rs b/src/frontend/pages/article/create.rs index 3e59e9b..7373eed 100644 --- a/src/frontend/pages/article/create.rs +++ b/src/frontend/pages/article/create.rs @@ -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::*}; diff --git a/src/frontend/pages/article/discussion.rs b/src/frontend/pages/article/discussion.rs new file mode 100644 index 0000000..feb6ae0 --- /dev/null +++ b/src/frontend/pages/article/discussion.rs @@ -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! { + + + +
+ } + } + /> +
+
+ } +} + +#[derive(Clone)] +struct CommentNode { + view: DbCommentView, + children: Vec, +} + +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 { + let mut res = vec![self.view]; + for c in self.children { + res.append(&mut c.flatten()); + } + res + } +} + +fn build_comments_tree(comments: Vec) -> Vec { + // First create a map of CommentId -> CommentView + let mut map: HashMap = 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::::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() +} diff --git a/src/frontend/pages/article/edit.rs b/src/frontend/pages/article/edit.rs index 43854df..f479675 100644 --- a/src/frontend/pages/article/edit.rs +++ b/src/frontend/pages/article/edit.rs @@ -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(); diff --git a/src/frontend/pages/article/mod.rs b/src/frontend/pages/article/mod.rs index 287d7af..013523d 100644 --- a/src/frontend/pages/article/mod.rs +++ b/src/frontend/pages/article/mod.rs @@ -1,5 +1,6 @@ pub mod actions; pub mod create; +pub mod discussion; pub mod edit; pub mod history; pub mod list; diff --git a/src/frontend/pages/article/read.rs b/src/frontend/pages/article/read.rs index 2b515cb..ee63d15 100644 --- a/src/frontend/pages/article/read.rs +++ b/src/frontend/pages/article/read.rs @@ -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! {
} }) diff --git a/src/frontend/pages/mod.rs b/src/frontend/pages/mod.rs index ff30315..f67a31f 100644 --- a/src/frontend/pages/mod.rs +++ b/src/frontend/pages/mod.rs @@ -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 { +fn article_resource() -> Resource { 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 { .unwrap() }) } -fn article_edits_resource(article: Resource) -> Resource> { +fn article_edits_resource(article: Resource) -> Resource> { Resource::new( move || article.get(), move |_| async move { diff --git a/src/frontend/pages/user_profile.rs b/src/frontend/pages/user_profile.rs index 3e7159c..65683a5 100644 --- a/src/frontend/pages/user_profile.rs +++ b/src/frontend/pages/user_profile.rs @@ -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 {

Edits

diff --git a/tests/test.rs b/tests/test.rs index 815130a..7a74987 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -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) +}