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

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

* Move files into subfolders

* Implement comments for articles (fixes #10)

* wip

* backend mostly done

* tests wip

* test working

* wip federation

* partial federation

* comment federation working!

* federation and tests working with delete

* wip frontend

* basic comment rendering

* various

* wip comment tree rendering

* all working

* update rust

* comment markdown

* only one comment editor at a time

* display comment creation time

* fedilink

* live handling of delete/restore

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

View file

@ -1,5 +1,5 @@
variables: 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_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" - &install_cargo_leptos "cargo-binstall -y cargo-leptos@0.2.24"

47
Cargo.lock generated
View file

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

View file

@ -64,6 +64,7 @@ web-sys = "0.3.77"
http = "1.2.0" http = "1.2.0"
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
github-slugger = "0.1.0" github-slugger = "0.1.0"
timeago = "0.4.2"
# backend-only deps # backend-only deps
[target.'cfg(not(target_family = "wasm"))'.dependencies] [target.'cfg(not(target_family = "wasm"))'.dependencies]
@ -76,7 +77,7 @@ tower-http = { version = "0.6.2", features = [
"fs", "fs",
"compression-full", "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", "axum",
"diesel", "diesel",
], default-features = false } ], default-features = false }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,6 +35,20 @@ pub fn validate_display_name(name: &Option<String>) -> MyResult<()> {
Ok(()) 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] #[test]
#[expect(clippy::unwrap_used)] #[expect(clippy::unwrap_used)]
fn test_validate_article_title() { fn test_validate_article_title() {

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,8 +6,8 @@ use crate::common::{TestData, TEST_ARTICLE_DEFAULT_TEXT};
use anyhow::Result; use anyhow::Result;
use ibis::common::{ use ibis::common::{
article::{ article::{
ArticleView,
CreateArticleForm, CreateArticleForm,
DbArticleView,
EditArticleForm, EditArticleForm,
ForkArticleForm, ForkArticleForm,
GetArticleForm, GetArticleForm,
@ -15,12 +15,15 @@ use ibis::common::{
ProtectArticleForm, ProtectArticleForm,
SearchArticleForm, SearchArticleForm,
}, },
comment::{CreateCommentForm, EditCommentForm},
user::{GetUserForm, LoginUserForm, RegisterUserForm}, user::{GetUserForm, LoginUserForm, RegisterUserForm},
utils::extract_domain, utils::extract_domain,
Notification, Notification,
}; };
use pretty_assertions::{assert_eq, assert_ne}; use pretty_assertions::{assert_eq, assert_ne};
use retry_future::{LinearRetryStrategy, RetryFuture, RetryPolicy}; use retry_future::{LinearRetryStrategy, RetryFuture, RetryPolicy};
use std::time::Duration;
use tokio::time::sleep;
use url::Url; use url::Url;
#[tokio::test] #[tokio::test]
@ -437,7 +440,7 @@ async fn test_federated_edit_conflict() -> Result<()> {
assert!(create_res.article.local); assert!(create_res.article.local);
// fetch article to gamma // fetch article to gamma
let resolve_res: ArticleView = gamma let resolve_res: DbArticleView = gamma
.resolve_article(create_res.article.ap_id.inner().clone()) .resolve_article(create_res.article.ap_id.inner().clone())
.await .await
.unwrap(); .unwrap();
@ -734,7 +737,7 @@ async fn test_lock_article() -> Result<()> {
let lock_res = alpha.protect_article(&lock_form).await.unwrap(); let lock_res = alpha.protect_article(&lock_form).await.unwrap();
assert!(lock_res.protected); assert!(lock_res.protected);
let resolve_res: ArticleView = gamma let resolve_res: DbArticleView = gamma
.resolve_article(create_res.article.ap_id.inner().clone()) .resolve_article(create_res.article.ap_id.inner().clone())
.await .await
.unwrap(); .unwrap();
@ -836,3 +839,147 @@ async fn test_article_approval_required() -> Result<()> {
TestData::stop(alpha, beta, gamma) 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)
}