diff --git a/.woodpecker.yml b/.woodpecker.yml index a0115d08a..4fe4d08d8 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -91,6 +91,66 @@ steps: when: - event: pull_request + cargo_clippy: + image: *rust_image + environment: + CARGO_HOME: .cargo_home + commands: + - rustup component add clippy + - cargo clippy --workspace --tests --all-targets -- -D warnings + when: *slow_check_paths + + cargo_test: + image: *rust_image + environment: + LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy + RUST_BACKTRACE: "1" + CARGO_HOME: .cargo_home + LEMMY_TEST_FAST_FEDERATION: "1" + LEMMY_CONFIG_LOCATION: ../../config/config.hjson + commands: + - cargo test --workspace --no-fail-fast + when: *slow_check_paths + + check_ts_bindings: + image: *rust_image + environment: + CARGO_HOME: .cargo_home + commands: + - ./scripts/ts_bindings_check.sh + when: + - event: pull_request + + # `DROP OWNED` doesn't work for default user + create_database_user: + image: postgres:16-alpine + environment: + PGUSER: postgres + PGPASSWORD: password + PGHOST: database + PGDATABASE: lemmy + commands: + - psql -c "CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;" + when: *slow_check_paths + + cargo_test: + image: *rust_image + environment: + LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy + RUST_BACKTRACE: "1" + CARGO_HOME: .cargo_home + LEMMY_TEST_FAST_FEDERATION: "1" + LEMMY_CONFIG_LOCATION: ../../config/config.hjson + commands: + # Install pg_dump for the schema setup test (must match server version) + - apt update && apt install -y lsb-release + - sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + - wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - + - apt update && apt install -y postgresql-client-16 + # Run tests + - cargo test --workspace --no-fail-fast + when: *slow_check_paths + # make sure api builds with default features (used by other crates relying on lemmy api) check_api_common_default_features: image: *rust_image @@ -126,15 +186,6 @@ steps: - diff config/defaults.hjson config/defaults_current.hjson when: *slow_check_paths - cargo_clippy: - image: *rust_image - environment: - CARGO_HOME: .cargo_home - commands: - - rustup component add clippy - - cargo clippy --workspace --tests --all-targets -- -D warnings - when: *slow_check_paths - cargo_build: image: *rust_image environment: @@ -144,18 +195,6 @@ steps: - mv target/debug/lemmy_server target/lemmy_server when: *slow_check_paths - # `DROP OWNED` doesn't work for default user - create_database_user: - image: postgres:16-alpine - environment: - PGUSER: postgres - PGPASSWORD: password - PGHOST: database - PGDATABASE: lemmy - commands: - - psql -c "CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;" - when: *slow_check_paths - check_diesel_schema: image: *rust_image environment: @@ -171,33 +210,6 @@ steps: - diff tmp.schema crates/db_schema/src/schema.rs when: *slow_check_paths - cargo_test: - image: *rust_image - environment: - LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy - RUST_BACKTRACE: "1" - CARGO_HOME: .cargo_home - LEMMY_TEST_FAST_FEDERATION: "1" - LEMMY_CONFIG_LOCATION: ../../config/config.hjson - commands: - # Install pg_dump for the schema setup test (must match server version) - - apt update && apt install -y lsb-release - - sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' - - wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - - - apt update && apt install -y postgresql-client-16 - # Run tests - - cargo test --workspace --no-fail-fast - when: *slow_check_paths - - check_ts_bindings: - image: *rust_image - environment: - CARGO_HOME: .cargo_home - commands: - - ./scripts/ts_bindings_check.sh - when: - - event: pull_request - check_db_perf_tool: image: *rust_image environment: diff --git a/Cargo.lock b/Cargo.lock index d04c11738..f9a32d3de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "8f27d075294830fcab6f66e320dab524bc6d048f4a151698e153205559113772" [[package]] name = "activitypub_federation" -version = "0.6.0-alpha2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4877d467ddf2fac85e9ee33aba6f2560df14125b8bfa864f85ab40e9b87753a9" +checksum = "ee819cada736b6e26c59706f9e6ff89a48060e635c0546ff984d84baefc8c13a" dependencies = [ "activitystreams-kinds", "actix-web", @@ -2520,7 +2520,6 @@ dependencies = [ "encoding_rs", "enum-map", "futures", - "getrandom", "jsonwebtoken", "lemmy_db_schema", "lemmy_db_views", @@ -2563,7 +2562,6 @@ dependencies = [ "lemmy_db_views", "lemmy_db_views_actor", "lemmy_utils", - "moka", "serde", "serde_json", "serde_with", @@ -2597,6 +2595,7 @@ dependencies = [ "moka", "pretty_assertions", "reqwest 0.12.8", + "semver", "serde", "serde_json", "serde_with", @@ -2646,7 +2645,6 @@ dependencies = [ "futures-util", "i-love-jesus", "lemmy_utils", - "moka", "pretty_assertions", "regex", "rustls 0.23.16", @@ -2833,6 +2831,7 @@ dependencies = [ "markdown-it-ruby", "markdown-it-sub", "markdown-it-sup", + "moka", "pretty_assertions", "regex", "reqwest-middleware", diff --git a/Cargo.toml b/Cargo.toml index d9c868ee7..b1553be5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,7 @@ lemmy_db_views = { version = "=0.19.6-beta.7", path = "./crates/db_views" } lemmy_db_views_actor = { version = "=0.19.6-beta.7", path = "./crates/db_views_actor" } lemmy_db_views_moderator = { version = "=0.19.6-beta.7", path = "./crates/db_views_moderator" } lemmy_federate = { version = "=0.19.6-beta.7", path = "./crates/federate" } -activitypub_federation = { version = "0.6.0-alpha2", default-features = false, features = [ +activitypub_federation = { version = "0.6.1", default-features = false, features = [ "actix-web", ] } diesel = "2.2.4" @@ -131,7 +131,7 @@ chrono = { version = "0.4.38", features = [ ], default-features = false } serde_json = { version = "1.0.132", features = ["preserve_order"] } base64 = "0.22.1" -uuid = { version = "1.11.0", features = ["serde", "v4"] } +uuid = { version = "1.11.0", features = ["serde"] } async-trait = "0.1.83" captcha = "0.0.9" anyhow = { version = "1.0.93", features = [ diff --git a/api_tests/src/image.spec.ts b/api_tests/src/image.spec.ts index ed96451a2..7ac6e7221 100644 --- a/api_tests/src/image.spec.ts +++ b/api_tests/src/image.spec.ts @@ -41,6 +41,9 @@ afterAll(async () => { }); test("Upload image and delete it", async () => { + const healthz = await fetch(alphaUrl + "/pictrs/healthz"); + expect(healthz.status).toBe(200); + // Before running this test, you need to delete all previous images in the DB await deleteAllImages(alpha); diff --git a/api_tests/src/private_community.spec.ts b/api_tests/src/private_community.spec.ts index 76faf800f..65340a1dd 100644 --- a/api_tests/src/private_community.spec.ts +++ b/api_tests/src/private_community.spec.ts @@ -1,6 +1,6 @@ jest.setTimeout(120000); -import { FollowCommunity } from "lemmy-js-client"; +import { FollowCommunity, LemmyHttp } from "lemmy-js-client"; import { alpha, setupLogins, @@ -21,6 +21,9 @@ import { resolveComment, likeComment, waitUntil, + gamma, + getPosts, + getComments, } from "./shared"; beforeAll(setupLogins); @@ -47,6 +50,7 @@ test("Follow a private community", async () => { await resolveCommunity(user, community.community_view.community.actor_id) ).community; expect(betaCommunity).toBeDefined(); + expect(betaCommunity?.community.visibility).toBe("Private"); const betaCommunityId = betaCommunity!.community.id; const follow_form: FollowCommunity = { community_id: betaCommunityId, @@ -148,16 +152,7 @@ test("Only followers can view and interact with private community content", asyn follow: true, }; await user.followCommunity(follow_form); - const pendingFollows1 = await waitUntil( - () => listCommunityPendingFollows(alpha), - f => f.items.length == 1, - ); - const approve = await approveCommunityPendingFollow( - alpha, - alphaCommunityId, - pendingFollows1.items[0].person.id, - ); - expect(approve.success).toBe(true); + approveFollower(alpha, alphaCommunityId); // now user can fetch posts and comments in community (using signed fetch), and create posts await waitUntil( @@ -212,3 +207,151 @@ test("Reject follower", async () => { c => c.community_view.subscribed == "NotSubscribed", ); }); + +test("Follow a private community and receive activities", async () => { + // create private community + const community = await createCommunity(alpha, randomString(10), "Private"); + expect(community.community_view.community.visibility).toBe("Private"); + const alphaCommunityId = community.community_view.community.id; + + // follow with users from beta and gamma + const betaCommunity = ( + await resolveCommunity(beta, community.community_view.community.actor_id) + ).community; + expect(betaCommunity).toBeDefined(); + const betaCommunityId = betaCommunity!.community.id; + const follow_form_beta: FollowCommunity = { + community_id: betaCommunityId, + follow: true, + }; + await beta.followCommunity(follow_form_beta); + await approveFollower(alpha, alphaCommunityId); + + const gammaCommunityId = ( + await resolveCommunity(gamma, community.community_view.community.actor_id) + ).community!.community.id; + const follow_form_gamma: FollowCommunity = { + community_id: gammaCommunityId, + follow: true, + }; + await gamma.followCommunity(follow_form_gamma); + await approveFollower(alpha, alphaCommunityId); + + // Follow is confirmed + await waitUntil( + () => getCommunity(beta, betaCommunityId), + c => c.community_view.subscribed == "Subscribed", + ); + await waitUntil( + () => getCommunity(gamma, gammaCommunityId), + c => c.community_view.subscribed == "Subscribed", + ); + + // create a post and comment from gamma + const post = await createPost(gamma, gammaCommunityId); + const post_id = post.post_view.post.id; + expect(post_id).toBeDefined(); + const comment = await createComment(gamma, post_id); + const comment_id = comment.comment_view.comment.id; + expect(comment_id).toBeDefined(); + + // post and comment were federated to beta + let posts = await waitUntil( + () => getPosts(beta, "All", betaCommunityId), + c => c.posts.length == 1, + ); + expect(posts.posts[0].post.ap_id).toBe(post.post_view.post.ap_id); + expect(posts.posts[0].post.name).toBe(post.post_view.post.name); + let comments = await waitUntil( + () => getComments(beta, posts.posts[0].post.id), + c => c.comments.length == 1, + ); + expect(comments.comments[0].comment.ap_id).toBe( + comment.comment_view.comment.ap_id, + ); + expect(comments.comments[0].comment.content).toBe( + comment.comment_view.comment.content, + ); +}); + +test("Fetch remote content in private community", async () => { + // create private community + const community = await createCommunity(alpha, randomString(10), "Private"); + expect(community.community_view.community.visibility).toBe("Private"); + const alphaCommunityId = community.community_view.community.id; + + const betaCommunityId = ( + await resolveCommunity(beta, community.community_view.community.actor_id) + ).community!.community.id; + const follow_form_beta: FollowCommunity = { + community_id: betaCommunityId, + follow: true, + }; + await beta.followCommunity(follow_form_beta); + await approveFollower(alpha, alphaCommunityId); + + // Follow is confirmed + await waitUntil( + () => getCommunity(beta, betaCommunityId), + c => c.community_view.subscribed == "Subscribed", + ); + + // beta creates post and comment + const post = await createPost(beta, betaCommunityId); + const post_id = post.post_view.post.id; + expect(post_id).toBeDefined(); + const comment = await createComment(beta, post_id); + const comment_id = comment.comment_view.comment.id; + expect(comment_id).toBeDefined(); + + // Wait for it to federate + await waitUntil( + () => resolveComment(alpha, comment.comment_view.comment), + p => p?.comment?.comment.id != undefined, + ); + + // create gamma user + const gammaCommunityId = ( + await resolveCommunity(gamma, community.community_view.community.actor_id) + ).community!.community.id; + const follow_form: FollowCommunity = { + community_id: gammaCommunityId, + follow: true, + }; + + // cannot fetch post yet + await expect(resolvePost(gamma, post.post_view.post)).rejects.toStrictEqual( + Error("not_found"), + ); + // follow community and approve + await gamma.followCommunity(follow_form); + await approveFollower(alpha, alphaCommunityId); + + // now user can fetch posts and comments in community (using signed fetch), and create posts. + // for this to work, beta checks with alpha if gamma is really an approved follower. + let resolvedPost = await waitUntil( + () => resolvePost(gamma, post.post_view.post), + p => p?.post?.post.id != undefined, + ); + expect(resolvedPost.post?.post.ap_id).toBe(post.post_view.post.ap_id); + const resolvedComment = await waitUntil( + () => resolveComment(gamma, comment.comment_view.comment), + p => p?.comment?.comment.id != undefined, + ); + expect(resolvedComment?.comment?.comment.ap_id).toBe( + comment.comment_view.comment.ap_id, + ); +}); + +async function approveFollower(user: LemmyHttp, community_id: number) { + let pendingFollows1 = await waitUntil( + () => listCommunityPendingFollows(user), + f => f.items.length == 1, + ); + const approve = await approveCommunityPendingFollow( + alpha, + community_id, + pendingFollows1.items[0].person.id, + ); + expect(approve.success).toBe(true); +} diff --git a/crates/api_common/Cargo.toml b/crates/api_common/Cargo.toml index 76f531ddb..3ae14717d 100644 --- a/crates/api_common/Cargo.toml +++ b/crates/api_common/Cargo.toml @@ -36,6 +36,7 @@ full = [ "futures", "jsonwebtoken", "mime", + "moka", ] [dependencies] @@ -58,7 +59,7 @@ uuid = { workspace = true, optional = true } tokio = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true } -moka.workspace = true +moka = { workspace = true, optional = true } anyhow.workspace = true actix-web = { workspace = true, optional = true } enum-map = { workspace = true } @@ -70,11 +71,6 @@ webpage = { version = "2.0", default-features = false, features = [ ], optional = true } encoding_rs = { version = "0.8.35", optional = true } jsonwebtoken = { version = "9.3.0", optional = true } -# necessary for wasmt compilation -getrandom = { version = "0.2.15", features = ["js"] } - -[package.metadata.cargo-shear] -ignored = ["getrandom"] [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/api_common/src/build_response.rs b/crates/api_common/src/build_response.rs index d40f4c23d..b73c0e482 100644 --- a/crates/api_common/src/build_response.rs +++ b/crates/api_common/src/build_response.rs @@ -17,8 +17,10 @@ use lemmy_db_schema::{ actor_language::CommunityLanguage, comment::Comment, comment_reply::{CommentReply, CommentReplyInsertForm}, + community::Community, person::Person, person_mention::{PersonMention, PersonMentionInsertForm}, + post::Post, }, traits::Crud, }; @@ -101,17 +103,28 @@ pub async fn send_local_notifs( let mut recipient_ids = Vec::new(); let inbox_link = format!("{}/inbox", context.settings().get_protocol_and_hostname()); - // let person = my_local_user.person; - // Read the comment view to get extra info - let comment_view = CommentView::read( - &mut context.pool(), - comment_id, - local_user_view.map(|view| &view.local_user), - ) - .await?; - let comment = comment_view.comment; - let post = comment_view.post; - let community = comment_view.community; + // When called from api code, we have local user view and can read with CommentView + // to reduce db queries. But when receiving a federated comment the user view is None, + // which means that comments inside private communities cant be read. As a workaround + // we need to read the items manually to bypass this check. + let (comment, post, community) = if let Some(local_user_view) = local_user_view { + let comment_view = CommentView::read( + &mut context.pool(), + comment_id, + Some(&local_user_view.local_user), + ) + .await?; + ( + comment_view.comment, + comment_view.post, + comment_view.community, + ) + } else { + let comment = Comment::read(&mut context.pool(), comment_id).await?; + let post = Post::read(&mut context.pool(), comment.post_id).await?; + let community = Community::read(&mut context.pool(), post.community_id).await?; + (comment, post, community) + }; // Send the local mentions for mention in mentions diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index 48b339e65..d46e57749 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -60,6 +60,7 @@ use lemmy_utils::{ slurs::{build_slur_regex, remove_slurs}, validation::clean_urls_in_text, }, + CacheLock, CACHE_DURATION_FEDERATION, }; use moka::future::Cache; @@ -535,7 +536,7 @@ pub fn local_site_opt_to_slur_regex(local_site: &Option) -> Option LemmyResult { - static URL_BLOCKLIST: LazyLock> = LazyLock::new(|| { + static URL_BLOCKLIST: CacheLock = LazyLock::new(|| { Cache::builder() .max_capacity(1) .time_to_live(CACHE_DURATION_FEDERATION) diff --git a/crates/api_crud/Cargo.toml b/crates/api_crud/Cargo.toml index 723864705..3f1a00ccd 100644 --- a/crates/api_crud/Cargo.toml +++ b/crates/api_crud/Cargo.toml @@ -25,7 +25,6 @@ tracing = { workspace = true } url = { workspace = true } futures.workspace = true uuid = { workspace = true } -moka.workspace = true anyhow.workspace = true chrono.workspace = true webmention = "0.6.0" diff --git a/crates/api_crud/src/site/read.rs b/crates/api_crud/src/site/read.rs index 47fd1f154..6bee0fda6 100644 --- a/crates/api_crud/src/site/read.rs +++ b/crates/api_crud/src/site/read.rs @@ -16,11 +16,11 @@ use lemmy_db_schema::source::{ use lemmy_db_views::structs::{LocalUserView, SiteView}; use lemmy_db_views_actor::structs::{CommunityFollowerView, CommunityModeratorView, PersonView}; use lemmy_utils::{ - error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult}, - CACHE_DURATION_API, + build_cache, + error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, + CacheLock, VERSION, }; -use moka::future::Cache; use std::sync::LazyLock; #[tracing::instrument(skip(context))] @@ -28,41 +28,10 @@ pub async fn get_site( local_user_view: Option, context: Data, ) -> LemmyResult> { - static CACHE: LazyLock> = LazyLock::new(|| { - Cache::builder() - .max_capacity(1) - .time_to_live(CACHE_DURATION_API) - .build() - }); - // This data is independent from the user account so we can cache it across requests + static CACHE: CacheLock = LazyLock::new(build_cache); let mut site_response = CACHE - .try_get_with::<_, LemmyError>((), async { - let site_view = SiteView::read_local(&mut context.pool()).await?; - let admins = PersonView::admins(&mut context.pool()).await?; - let all_languages = Language::read_all(&mut context.pool()).await?; - let discussion_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?; - let blocked_urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?; - let tagline = Tagline::get_random(&mut context.pool()).await.ok(); - let admin_oauth_providers = OAuthProvider::get_all(&mut context.pool()).await?; - let oauth_providers = - OAuthProvider::convert_providers_to_public(admin_oauth_providers.clone()); - - Ok(GetSiteResponse { - site_view, - admins, - version: VERSION.to_string(), - my_user: None, - all_languages, - discussion_languages, - blocked_urls, - tagline, - oauth_providers: Some(oauth_providers), - admin_oauth_providers: Some(admin_oauth_providers), - taglines: vec![], - custom_emojis: vec![], - }) - }) + .try_get_with((), read_site(&context)) .await .map_err(|e| anyhow::anyhow!("Failed to construct site response: {e}"))?; @@ -112,3 +81,29 @@ pub async fn get_site( Ok(Json(site_response)) } + +async fn read_site(context: &LemmyContext) -> LemmyResult { + let site_view = SiteView::read_local(&mut context.pool()).await?; + let admins = PersonView::admins(&mut context.pool()).await?; + let all_languages = Language::read_all(&mut context.pool()).await?; + let discussion_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?; + let blocked_urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?; + let tagline = Tagline::get_random(&mut context.pool()).await.ok(); + let admin_oauth_providers = OAuthProvider::get_all(&mut context.pool()).await?; + let oauth_providers = OAuthProvider::convert_providers_to_public(admin_oauth_providers.clone()); + + Ok(GetSiteResponse { + site_view, + admins, + version: VERSION.to_string(), + my_user: None, + all_languages, + discussion_languages, + blocked_urls, + tagline, + oauth_providers: Some(oauth_providers), + admin_oauth_providers: Some(admin_oauth_providers), + taglines: vec![], + custom_emojis: vec![], + }) +} diff --git a/crates/apub/Cargo.toml b/crates/apub/Cargo.toml index 057e9bd38..8124fda01 100644 --- a/crates/apub/Cargo.toml +++ b/crates/apub/Cargo.toml @@ -45,6 +45,7 @@ html2md = "0.2.14" html2text = "0.12.6" stringreader = "0.1.1" enum_delegate = "0.2.0" +semver = "1.0.23" [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/apub/assets/lemmy/activities/create_or_update/create_note.json b/crates/apub/assets/lemmy/activities/create_or_update/create_comment.json similarity index 100% rename from crates/apub/assets/lemmy/activities/create_or_update/create_note.json rename to crates/apub/assets/lemmy/activities/create_or_update/create_comment.json diff --git a/crates/apub/assets/lemmy/activities/create_or_update/create_private_message.json b/crates/apub/assets/lemmy/activities/create_or_update/create_private_message.json index 54ee39350..e7dbdd0f9 100644 --- a/crates/apub/assets/lemmy/activities/create_or_update/create_private_message.json +++ b/crates/apub/assets/lemmy/activities/create_or_update/create_private_message.json @@ -3,7 +3,7 @@ "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["http://ds9.lemmy.ml/u/lemmy_alpha"], "object": { - "type": "ChatMessage", + "type": "Note", "id": "http://enterprise.lemmy.ml/private_message/1", "attributedTo": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["http://ds9.lemmy.ml/u/lemmy_alpha"], diff --git a/crates/apub/assets/lemmy/objects/note.json b/crates/apub/assets/lemmy/objects/comment.json similarity index 100% rename from crates/apub/assets/lemmy/objects/note.json rename to crates/apub/assets/lemmy/objects/comment.json diff --git a/crates/apub/assets/lemmy/objects/chat_message.json b/crates/apub/assets/lemmy/objects/private_message.json similarity index 93% rename from crates/apub/assets/lemmy/objects/chat_message.json rename to crates/apub/assets/lemmy/objects/private_message.json index 95b37322e..a3579523e 100644 --- a/crates/apub/assets/lemmy/objects/chat_message.json +++ b/crates/apub/assets/lemmy/objects/private_message.json @@ -1,6 +1,6 @@ { "id": "https://enterprise.lemmy.ml/private_message/1621", - "type": "ChatMessage", + "type": "Note", "attributedTo": "https://enterprise.lemmy.ml/u/picard", "to": ["https://queer.hacktivis.me/users/lanodan"], "content": "

Hello hello, testing

\n", diff --git a/crates/apub/assets/mastodon/activities/private_message.json b/crates/apub/assets/mastodon/activities/private_message.json new file mode 100644 index 000000000..b542859b5 --- /dev/null +++ b/crates/apub/assets/mastodon/activities/private_message.json @@ -0,0 +1,49 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "https://mastodon.world/users/nutomic/statuses/110854468010322301", + "type": "Note", + "summary": null, + "inReplyTo": "https://mastodon.world/users/nutomic/statuses/110854464248188528", + "published": "2023-08-08T14:29:04Z", + "url": "https://mastodon.world/@nutomic/110854468010322301", + "attributedTo": "https://mastodon.world/users/nutomic", + "to": ["https://ds9.lemmy.ml/u/nutomic"], + "cc": [], + "sensitive": false, + "atomUri": "https://mastodon.world/users/nutomic/statuses/110854468010322301", + "inReplyToAtomUri": "https://mastodon.world/users/nutomic/statuses/110854464248188528", + "conversation": "tag:mastodon.world,2023-08-08:objectId=121377096:objectType=Conversation", + "content": "

@nutomic@ds9.lemmy.ml 444

", + "contentMap": { + "es": "

@nutomic@ds9.lemmy.ml 444

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://ds9.lemmy.ml/u/nutomic", + "name": "@nutomic@ds9.lemmy.ml" + } + ], + "replies": { + "id": "https://mastodon.world/users/nutomic/statuses/110854468010322301/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://mastodon.world/users/nutomic/statuses/110854468010322301/replies?only_other_accounts=true&page=true", + "partOf": "https://mastodon.world/users/nutomic/statuses/110854468010322301/replies", + "items": [] + } + } +} diff --git a/crates/apub/assets/pleroma/objects/chat_message.json b/crates/apub/assets/pleroma/objects/chat_message.json deleted file mode 100644 index 6a2afc82e..000000000 --- a/crates/apub/assets/pleroma/objects/chat_message.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://queer.hacktivis.me/schemas/litepub-0.1.jsonld", - { - "@language": "und" - } - ], - "attributedTo": "https://queer.hacktivis.me/users/lanodan", - "content": "Hi!", - "id": "https://queer.hacktivis.me/objects/2", - "published": "2020-02-12T14:08:20Z", - "to": ["https://enterprise.lemmy.ml/u/picard"], - "type": "ChatMessage" -} diff --git a/crates/apub/src/activities/community/announce.rs b/crates/apub/src/activities/community/announce.rs index b31e4b74f..9d714e304 100644 --- a/crates/apub/src/activities/community/announce.rs +++ b/crates/apub/src/activities/community/announce.rs @@ -64,16 +64,17 @@ impl ActivityHandler for RawAnnouncableActivities { // verify and receive activity activity.verify(context).await?; - activity.clone().receive(context).await?; + let actor_id = activity.actor().clone().into(); + activity.receive(context).await?; // if community is local, send activity to followers if let Some(community) = community { if community.local { - let actor_id = activity.actor().clone().into(); verify_person_in_community(&actor_id, &community, context).await?; AnnounceActivity::send(self, &community, context).await?; } } + Ok(()) } } @@ -215,7 +216,7 @@ async fn can_accept_activity_in_community( ) -> LemmyResult<()> { if let Some(community) = community { // Local only community can't federate - if community.visibility != CommunityVisibility::Public { + if community.visibility == CommunityVisibility::LocalOnly { return Err(LemmyErrorType::NotFound.into()); } if !community.local { diff --git a/crates/apub/src/activities/community/mod.rs b/crates/apub/src/activities/community/mod.rs index 59b8fadbb..93c6e5c77 100644 --- a/crates/apub/src/activities/community/mod.rs +++ b/crates/apub/src/activities/community/mod.rs @@ -42,7 +42,7 @@ pub(crate) async fn send_activity_in_community( context: &Data, ) -> LemmyResult<()> { // If community is local only, don't send anything out - if community.visibility != CommunityVisibility::Public { + if community.visibility == CommunityVisibility::LocalOnly { return Ok(()); } diff --git a/crates/apub/src/activities/create_or_update/comment.rs b/crates/apub/src/activities/create_or_update/comment.rs index 90ab0153f..93cac92ee 100644 --- a/crates/apub/src/activities/create_or_update/comment.rs +++ b/crates/apub/src/activities/create_or_update/comment.rs @@ -43,6 +43,7 @@ use lemmy_utils::{ error::{LemmyError, LemmyResult}, utils::mention::scrape_text_for_mentions, }; +use serde_json::{from_value, to_value}; use url::Url; impl CreateOrUpdateNote { @@ -98,7 +99,8 @@ impl CreateOrUpdateNote { inboxes.add_inbox(person.shared_inbox_or_inbox()); } - let activity = AnnouncableActivities::CreateOrUpdateComment(create_or_update); + let activity = + AnnouncableActivities::CreateOrUpdateNoteWrapper(from_value(to_value(create_or_update)?)?); send_activity_in_community(activity, &person, &community, inboxes, false, &context).await } } @@ -171,6 +173,9 @@ impl ActivityHandler for CreateOrUpdateNote { // TODO: for compatibility with other projects, it would be much better to read this from cc or // tags let mentions = scrape_text_for_mentions(&comment.content); + + // TODO: this fails in local community comment as CommentView::read() returns nothing + // without passing LocalUser send_local_notifs(mentions, comment.id, &actor, do_send_email, context, None).await?; Ok(()) } diff --git a/crates/apub/src/activities/create_or_update/mod.rs b/crates/apub/src/activities/create_or_update/mod.rs index c69e00e91..b442e5fa3 100644 --- a/crates/apub/src/activities/create_or_update/mod.rs +++ b/crates/apub/src/activities/create_or_update/mod.rs @@ -1,3 +1,4 @@ pub mod comment; +pub(crate) mod note_wrapper; pub mod post; pub mod private_message; diff --git a/crates/apub/src/activities/create_or_update/note_wrapper.rs b/crates/apub/src/activities/create_or_update/note_wrapper.rs new file mode 100644 index 000000000..9206d0c05 --- /dev/null +++ b/crates/apub/src/activities/create_or_update/note_wrapper.rs @@ -0,0 +1,66 @@ +use crate::{ + objects::{community::ApubCommunity, note_wrapper::is_public}, + protocol::{ + activities::create_or_update::{ + note::CreateOrUpdateNote, + note_wrapper::CreateOrUpdateNoteWrapper, + private_message::CreateOrUpdatePrivateMessage, + }, + InCommunity, + }, +}; +use activitypub_federation::{config::Data, traits::ActivityHandler}; +use lemmy_api_common::context::LemmyContext; +use lemmy_utils::error::{FederationError, LemmyError, LemmyResult}; +use serde_json::{from_value, to_value}; +use url::Url; + +#[async_trait::async_trait] +impl ActivityHandler for CreateOrUpdateNoteWrapper { + type DataType = LemmyContext; + type Error = LemmyError; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + &self.actor + } + + #[tracing::instrument(skip_all)] + async fn verify(&self, context: &Data) -> LemmyResult<()> { + let val = to_value(self)?; + if is_public(&self.to, &self.cc) { + CreateOrUpdateNote::verify(&from_value(val)?, context).await?; + } else { + CreateOrUpdatePrivateMessage::verify(&from_value(val)?, context).await?; + } + Ok(()) + } + + #[tracing::instrument(skip_all)] + async fn receive(self, context: &Data) -> LemmyResult<()> { + let is_public = is_public(&self.to, &self.cc); + let val = to_value(self)?; + if is_public { + CreateOrUpdateNote::receive(from_value(val)?, context).await?; + } else { + CreateOrUpdatePrivateMessage::receive(from_value(val)?, context).await?; + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl InCommunity for CreateOrUpdateNoteWrapper { + #[tracing::instrument(skip(self, context))] + async fn community(&self, context: &Data) -> LemmyResult { + if is_public(&self.to, &self.cc) { + let comment: CreateOrUpdateNote = from_value(to_value(self)?)?; + comment.community(context).await + } else { + Err(FederationError::ObjectIsNotPublic.into()) + } + } +} diff --git a/crates/apub/src/activities/create_or_update/private_message.rs b/crates/apub/src/activities/create_or_update/private_message.rs index 6bba4e374..b6e7478ef 100644 --- a/crates/apub/src/activities/create_or_update/private_message.rs +++ b/crates/apub/src/activities/create_or_update/private_message.rs @@ -3,7 +3,7 @@ use crate::{ insert_received_activity, objects::{person::ApubPerson, private_message::ApubPrivateMessage}, protocol::activities::{ - create_or_update::chat_message::CreateOrUpdateChatMessage, + create_or_update::private_message::CreateOrUpdatePrivateMessage, CreateOrUpdateType, }, }; @@ -30,7 +30,7 @@ pub(crate) async fn send_create_or_update_pm( kind.clone(), &context.settings().get_protocol_and_hostname(), )?; - let create_or_update = CreateOrUpdateChatMessage { + let create_or_update = CreateOrUpdatePrivateMessage { id: id.clone(), actor: actor.id().into(), to: [recipient.id().into()], @@ -44,7 +44,7 @@ pub(crate) async fn send_create_or_update_pm( } #[async_trait::async_trait] -impl ActivityHandler for CreateOrUpdateChatMessage { +impl ActivityHandler for CreateOrUpdatePrivateMessage { type DataType = LemmyContext; type Error = LemmyError; diff --git a/crates/apub/src/activities/deletion/mod.rs b/crates/apub/src/activities/deletion/mod.rs index 15118a476..941ac4237 100644 --- a/crates/apub/src/activities/deletion/mod.rs +++ b/crates/apub/src/activities/deletion/mod.rs @@ -36,7 +36,7 @@ use lemmy_db_schema::{ community::{Community, CommunityUpdateForm}, person::Person, post::{Post, PostUpdateForm}, - private_message::{PrivateMessage, PrivateMessageUpdateForm}, + private_message::{PrivateMessage as DbPrivateMessage, PrivateMessageUpdateForm}, }, traits::Crud, }; @@ -82,7 +82,7 @@ pub(crate) async fn send_apub_delete_in_community( #[tracing::instrument(skip_all)] pub(crate) async fn send_apub_delete_private_message( actor: &ApubPerson, - pm: PrivateMessage, + pm: DbPrivateMessage, deleted: bool, context: Data, ) -> LemmyResult<()> { @@ -298,7 +298,7 @@ async fn receive_delete_action( } } DeletableObjects::PrivateMessage(pm) => { - PrivateMessage::update( + DbPrivateMessage::update( &mut context.pool(), pm.id, &PrivateMessageUpdateForm { diff --git a/crates/apub/src/activity_lists.rs b/crates/apub/src/activity_lists.rs index 7ed1d8baf..0a11e30a0 100644 --- a/crates/apub/src/activity_lists.rs +++ b/crates/apub/src/activity_lists.rs @@ -11,11 +11,7 @@ use crate::{ report::Report, update::UpdateCommunity, }, - create_or_update::{ - chat_message::CreateOrUpdateChatMessage, - note::CreateOrUpdateNote, - page::CreateOrUpdatePage, - }, + create_or_update::{note_wrapper::CreateOrUpdateNoteWrapper, page::CreateOrUpdatePage}, deletion::{delete::Delete, undo_delete::UndoDelete}, following::{ accept::AcceptFollow, @@ -48,47 +44,17 @@ pub enum SharedInboxActivities { AcceptFollow(AcceptFollow), RejectFollow(RejectFollow), UndoFollow(UndoFollow), - CreateOrUpdatePrivateMessage(CreateOrUpdateChatMessage), Report(Report), AnnounceActivity(AnnounceActivity), /// This is a catch-all and needs to be last RawAnnouncableActivities(RawAnnouncableActivities), } -/// List of activities which the group inbox can handle. -#[derive(Debug, Deserialize, Serialize)] -#[serde(untagged)] -#[enum_delegate::implement(ActivityHandler)] -pub enum GroupInboxActivities { - Follow(Follow), - UndoFollow(UndoFollow), - Report(Report), - /// This is a catch-all and needs to be last - AnnouncableActivities(RawAnnouncableActivities), -} - -/// List of activities which the person inbox can handle. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(untagged)] -#[enum_delegate::implement(ActivityHandler)] -pub enum PersonInboxActivities { - Follow(Follow), - AcceptFollow(AcceptFollow), - RejectFollow(RejectFollow), - UndoFollow(UndoFollow), - CreateOrUpdatePrivateMessage(CreateOrUpdateChatMessage), - Delete(Delete), - UndoDelete(UndoDelete), - AnnounceActivity(AnnounceActivity), - /// User can also receive some "announcable" activities, eg a comment mention. - AnnouncableActivities(AnnouncableActivities), -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] #[enum_delegate::implement(ActivityHandler)] pub enum AnnouncableActivities { - CreateOrUpdateComment(CreateOrUpdateNote), + CreateOrUpdateNoteWrapper(CreateOrUpdateNoteWrapper), CreateOrUpdatePost(CreateOrUpdatePage), Vote(Vote), UndoVote(UndoVote), @@ -111,7 +77,7 @@ impl InCommunity for AnnouncableActivities { async fn community(&self, context: &Data) -> LemmyResult { use AnnouncableActivities::*; match self { - CreateOrUpdateComment(a) => a.community(context).await, + CreateOrUpdateNoteWrapper(a) => a.community(context).await, CreateOrUpdatePost(a) => a.community(context).await, Vote(a) => a.community(context).await, UndoVote(a) => a.community(context).await, @@ -133,40 +99,32 @@ impl InCommunity for AnnouncableActivities { mod tests { use crate::{ - activity_lists::{GroupInboxActivities, PersonInboxActivities, SharedInboxActivities}, + activity_lists::SharedInboxActivities, protocol::tests::{test_json, test_parse_lemmy_item}, }; use lemmy_utils::error::LemmyResult; - #[test] - fn test_group_inbox() -> LemmyResult<()> { - test_parse_lemmy_item::("assets/lemmy/activities/following/follow.json")?; - test_parse_lemmy_item::( - "assets/lemmy/activities/create_or_update/create_note.json", - )?; - Ok(()) - } - - #[test] - fn test_person_inbox() -> LemmyResult<()> { - test_parse_lemmy_item::( - "assets/lemmy/activities/following/accept.json", - )?; - test_parse_lemmy_item::( - "assets/lemmy/activities/create_or_update/create_note.json", - )?; - test_parse_lemmy_item::( - "assets/lemmy/activities/create_or_update/create_private_message.json", - )?; - test_json::("assets/mastodon/activities/follow.json")?; - Ok(()) - } - #[test] fn test_shared_inbox() -> LemmyResult<()> { test_parse_lemmy_item::( "assets/lemmy/activities/deletion/delete_user.json", )?; + test_parse_lemmy_item::( + "assets/lemmy/activities/following/accept.json", + )?; + test_parse_lemmy_item::( + "assets/lemmy/activities/create_or_update/create_comment.json", + )?; + test_parse_lemmy_item::( + "assets/lemmy/activities/create_or_update/create_private_message.json", + )?; + test_parse_lemmy_item::( + "assets/lemmy/activities/following/follow.json", + )?; + test_parse_lemmy_item::( + "assets/lemmy/activities/create_or_update/create_comment.json", + )?; + test_json::("assets/mastodon/activities/follow.json")?; Ok(()) } } diff --git a/crates/apub/src/http/community.rs b/crates/apub/src/http/community.rs index 96a917d91..dbcc51258 100644 --- a/crates/apub/src/http/community.rs +++ b/crates/apub/src/http/community.rs @@ -6,28 +6,41 @@ use crate::{ community_moderators::ApubCommunityModerators, community_outbox::ApubCommunityOutbox, }, + fetcher::site_or_community_or_user::SiteOrCommunityOrUser, http::{check_community_fetchable, create_apub_response, create_apub_tombstone_response}, objects::community::ApubCommunity, }; use activitypub_federation::{ + actix_web::signing_actor, config::Data, + fetch::object_id::ObjectId, traits::{Collection, Object}, }; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{ + web::{Path, Query}, + HttpRequest, + HttpResponse, +}; use lemmy_api_common::context::LemmyContext; -use lemmy_db_schema::{source::community::Community, traits::ApubActor}; +use lemmy_db_schema::{source::community::Community, traits::ApubActor, CommunityVisibility}; +use lemmy_db_views_actor::structs::CommunityFollowerView; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serde::Deserialize; #[derive(Deserialize, Clone)] -pub(crate) struct CommunityQuery { +pub(crate) struct CommunityPath { community_name: String, } +#[derive(Deserialize, Clone)] +pub struct CommunityIsFollowerQuery { + is_follower: Option>, +} + /// Return the ActivityPub json representation of a local community over HTTP. #[tracing::instrument(skip_all)] pub(crate) async fn get_apub_community_http( - info: web::Path, + info: Path, context: Data, ) -> LemmyResult { let community: ApubCommunity = @@ -47,21 +60,59 @@ pub(crate) async fn get_apub_community_http( /// Returns an empty followers collection, only populating the size (for privacy). pub(crate) async fn get_apub_community_followers( - info: web::Path, + info: Path, + query: Query, context: Data, + request: HttpRequest, ) -> LemmyResult { let community = Community::read_from_name(&mut context.pool(), &info.community_name, false) .await? .ok_or(LemmyErrorType::NotFound)?; + if let Some(is_follower) = &query.is_follower { + return check_is_follower(community, is_follower, context, request).await; + } check_community_fetchable(&community)?; let followers = ApubCommunityFollower::read_local(&community.into(), &context).await?; create_apub_response(&followers) } +/// Checks if a given actor follows the private community. Returns status 200 if true. +async fn check_is_follower( + community: Community, + is_follower: &ObjectId, + context: Data, + request: HttpRequest, +) -> LemmyResult { + if community.visibility != CommunityVisibility::Private { + return Ok(HttpResponse::BadRequest().body("must be a private community")); + } + // also check for http sig so that followers are not exposed publicly + let signing_actor = signing_actor::(&request, None, &context).await?; + CommunityFollowerView::check_has_followers_from_instance( + community.id, + signing_actor.instance_id(), + &mut context.pool(), + ) + .await?; + + let instance_id = is_follower.dereference(&context).await?.instance_id(); + let has_followers = CommunityFollowerView::check_has_followers_from_instance( + community.id, + instance_id, + &mut context.pool(), + ) + .await; + if has_followers.is_ok() { + Ok(HttpResponse::Ok().finish()) + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + /// Returns the community outbox, which is populated by a maximum of 20 posts (but no other /// activities like votes or comments). pub(crate) async fn get_apub_community_outbox( - info: web::Path, + info: Path, context: Data, request: HttpRequest, ) -> LemmyResult { @@ -77,7 +128,7 @@ pub(crate) async fn get_apub_community_outbox( #[tracing::instrument(skip_all)] pub(crate) async fn get_apub_community_moderators( - info: web::Path, + info: Path, context: Data, ) -> LemmyResult { let community: ApubCommunity = @@ -92,7 +143,7 @@ pub(crate) async fn get_apub_community_moderators( /// Returns collection of featured (stickied) posts. pub(crate) async fn get_apub_community_featured( - info: web::Path, + info: Path, context: Data, request: HttpRequest, ) -> LemmyResult { @@ -181,17 +232,17 @@ pub(crate) mod tests { let request = TestRequest::default().to_http_request(); // fetch invalid community - let query = CommunityQuery { + let query = CommunityPath { community_name: "asd".to_string(), }; let res = get_apub_community_http(query.into(), context.reset_request_count()).await; assert!(res.is_err()); // fetch valid community - let query = CommunityQuery { + let path = CommunityPath { community_name: community.name.clone(), }; - let res = get_apub_community_http(query.clone().into(), context.reset_request_count()).await?; + let res = get_apub_community_http(path.clone().into(), context.reset_request_count()).await?; assert_eq!(200, res.status()); let res_group: Group = decode_response(res).await?; let community: ApubCommunity = community.into(); @@ -199,20 +250,26 @@ pub(crate) mod tests { assert_eq!(group, res_group); let res = get_apub_community_featured( - query.clone().into(), + path.clone().into(), + context.reset_request_count(), + request.clone(), + ) + .await?; + assert_eq!(200, res.status()); + let query = Query(CommunityIsFollowerQuery { is_follower: None }); + let res = get_apub_community_followers( + path.clone().into(), + query, context.reset_request_count(), request.clone(), ) .await?; assert_eq!(200, res.status()); let res = - get_apub_community_followers(query.clone().into(), context.reset_request_count()).await?; + get_apub_community_moderators(path.clone().into(), context.reset_request_count()).await?; assert_eq!(200, res.status()); let res = - get_apub_community_moderators(query.clone().into(), context.reset_request_count()).await?; - assert_eq!(200, res.status()); - let res = - get_apub_community_outbox(query.into(), context.reset_request_count(), request).await?; + get_apub_community_outbox(path.into(), context.reset_request_count(), request).await?; assert_eq!(200, res.status()); Instance::delete(&mut context.pool(), instance.id).await?; @@ -227,28 +284,35 @@ pub(crate) mod tests { let request = TestRequest::default().to_http_request(); // should return tombstone - let query = CommunityQuery { + let path: Path = CommunityPath { community_name: community.name.clone(), - }; - let res = get_apub_community_http(query.clone().into(), context.reset_request_count()).await?; + } + .into(); + let res = get_apub_community_http(path.clone().into(), context.reset_request_count()).await?; assert_eq!(410, res.status()); let res_tombstone = decode_response::(res).await; assert!(res_tombstone.is_ok()); let res = get_apub_community_featured( - query.clone().into(), + path.clone().into(), + context.reset_request_count(), + request.clone(), + ) + .await; + assert!(res.is_err()); + let query = Query(CommunityIsFollowerQuery { is_follower: None }); + let res = get_apub_community_followers( + path.clone().into(), + query, context.reset_request_count(), request.clone(), ) .await; assert!(res.is_err()); let res = - get_apub_community_followers(query.clone().into(), context.reset_request_count()).await; + get_apub_community_moderators(path.clone().into(), context.reset_request_count()).await; assert!(res.is_err()); - let res = - get_apub_community_moderators(query.clone().into(), context.reset_request_count()).await; - assert!(res.is_err()); - let res = get_apub_community_outbox(query.into(), context.reset_request_count(), request).await; + let res = get_apub_community_outbox(path, context.reset_request_count(), request).await; assert!(res.is_err()); //Community::delete(&mut context.pool(), community.id).await?; @@ -263,25 +327,32 @@ pub(crate) mod tests { let (instance, community) = init(false, CommunityVisibility::LocalOnly, &context).await?; let request = TestRequest::default().to_http_request(); - let query = CommunityQuery { + let path: Path = CommunityPath { community_name: community.name.clone(), - }; - let res = get_apub_community_http(query.clone().into(), context.reset_request_count()).await; + } + .into(); + let res = get_apub_community_http(path.clone().into(), context.reset_request_count()).await; assert!(res.is_err()); let res = get_apub_community_featured( - query.clone().into(), + path.clone().into(), + context.reset_request_count(), + request.clone(), + ) + .await; + assert!(res.is_err()); + let query = Query(CommunityIsFollowerQuery { is_follower: None }); + let res = get_apub_community_followers( + path.clone().into(), + query, context.reset_request_count(), request.clone(), ) .await; assert!(res.is_err()); let res = - get_apub_community_followers(query.clone().into(), context.reset_request_count()).await; + get_apub_community_moderators(path.clone().into(), context.reset_request_count()).await; assert!(res.is_err()); - let res = - get_apub_community_moderators(query.clone().into(), context.reset_request_count()).await; - assert!(res.is_err()); - let res = get_apub_community_outbox(query.into(), context.reset_request_count(), request).await; + let res = get_apub_community_outbox(path, context.reset_request_count(), request).await; assert!(res.is_err()); Instance::delete(&mut context.pool(), instance.id).await?; diff --git a/crates/apub/src/http/mod.rs b/crates/apub/src/http/mod.rs index 8780f8789..fc2fbf0d3 100644 --- a/crates/apub/src/http/mod.rs +++ b/crates/apub/src/http/mod.rs @@ -8,6 +8,7 @@ use activitypub_federation::{ actix_web::{inbox::receive_activity, signing_actor}, config::Data, protocol::context::WithContext, + traits::Actor, FEDERATION_CONTENT_TYPE, }; use actix_web::{web, web::Bytes, HttpRequest, HttpResponse}; @@ -145,14 +146,27 @@ async fn check_community_content_fetchable( // from the fetching instance then fetching is allowed Private => { let signing_actor = signing_actor::(request, None, context).await?; - Ok( - CommunityFollowerView::check_has_followers_from_instance( - community.id, - signing_actor.instance_id(), - &mut context.pool(), + if community.local { + Ok( + CommunityFollowerView::check_has_followers_from_instance( + community.id, + signing_actor.instance_id(), + &mut context.pool(), + ) + .await?, ) - .await?, - ) + } else if let Some(followers_url) = community.followers_url.clone() { + let mut followers_url = followers_url.inner().clone(); + followers_url + .query_pairs_mut() + .append_pair("is_follower", signing_actor.id().as_str()); + let req = context.client().get(followers_url.as_str()); + let req = context.sign_request(req, Bytes::new()).await?; + context.client().execute(req).await?.error_for_status()?; + Ok(()) + } else { + Err(LemmyErrorType::NotFound.into()) + } } } } diff --git a/crates/apub/src/lib.rs b/crates/apub/src/lib.rs index 213f6439d..028d673c2 100644 --- a/crates/apub/src/lib.rs +++ b/crates/apub/src/lib.rs @@ -11,6 +11,7 @@ use lemmy_db_schema::{ }; use lemmy_utils::{ error::{FederationError, LemmyError, LemmyErrorType, LemmyResult}, + CacheLock, CACHE_DURATION_FEDERATION, }; use moka::future::Cache; @@ -139,7 +140,7 @@ pub(crate) async fn local_site_data_cached( // multiple times. This causes a huge number of database reads if we hit the db directly. So we // cache these values for a short time, which will already make a huge difference and ensures that // changes take effect quickly. - static CACHE: LazyLock>> = LazyLock::new(|| { + static CACHE: CacheLock> = LazyLock::new(|| { Cache::builder() .max_capacity(1) .time_to_live(CACHE_DURATION_FEDERATION) diff --git a/crates/apub/src/objects/comment.rs b/crates/apub/src/objects/comment.rs index dc0721404..2c8ed9f9d 100644 --- a/crates/apub/src/objects/comment.rs +++ b/crates/apub/src/objects/comment.rs @@ -266,7 +266,7 @@ pub(crate) mod tests { let url = Url::parse("https://enterprise.lemmy.ml/comment/38741")?; let data = prepare_comment_test(&url, &context).await?; - let json: Note = file_to_json_object("assets/lemmy/objects/note.json")?; + let json: Note = file_to_json_object("assets/lemmy/objects/comment.json")?; ApubComment::verify(&json, &url, &context).await?; let comment = ApubComment::from_json(json.clone(), &context).await?; diff --git a/crates/apub/src/objects/mod.rs b/crates/apub/src/objects/mod.rs index f837f7ad3..58841b29e 100644 --- a/crates/apub/src/objects/mod.rs +++ b/crates/apub/src/objects/mod.rs @@ -15,6 +15,7 @@ use std::fmt::Debug; pub mod comment; pub mod community; pub mod instance; +pub mod note_wrapper; pub mod person; pub mod post; pub mod private_message; diff --git a/crates/apub/src/objects/note_wrapper.rs b/crates/apub/src/objects/note_wrapper.rs new file mode 100644 index 000000000..5d613c7ae --- /dev/null +++ b/crates/apub/src/objects/note_wrapper.rs @@ -0,0 +1,85 @@ +use super::comment::ApubComment; +use crate::{ + objects::private_message::ApubPrivateMessage, + protocol::objects::note_wrapper::NoteWrapper, +}; +use activitypub_federation::{config::Data, kinds::public, traits::Object}; +use chrono::{DateTime, Utc}; +use lemmy_api_common::{context::LemmyContext, LemmyErrorType}; +use lemmy_utils::error::{LemmyError, LemmyResult}; +use serde_json::{from_value, to_value}; +use url::Url; + +/// Private messages and public comments are quite awkward in Activitypub, because the json +/// format looks identical. They only way to differentiate them is to check for the presence +/// or absence of `https://www.w3.org/ns/activitystreams#Public` in `to` or `cc` which this +/// wrapper does. +#[derive(Debug)] +pub(crate) struct ApubNote {} + +#[async_trait::async_trait] +impl Object for ApubNote { + type DataType = LemmyContext; + type Kind = NoteWrapper; + type Error = LemmyError; + + fn last_refreshed_at(&self) -> Option> { + None + } + + #[tracing::instrument(skip_all)] + async fn read_from_id( + _object_id: Url, + _context: &Data, + ) -> LemmyResult> { + Err(LemmyErrorType::Unknown("not implemented".to_string()).into()) + } + + #[tracing::instrument(skip_all)] + async fn delete(self, _context: &Data) -> LemmyResult<()> { + Err(LemmyErrorType::Unknown("not implemented".to_string()).into()) + } + + async fn verify( + note: &NoteWrapper, + expected_domain: &Url, + context: &Data, + ) -> LemmyResult<()> { + let val = to_value(note)?; + if is_public(¬e.to, ¬e.cc) { + ApubComment::verify(&from_value(val)?, expected_domain, context).await?; + } else { + ApubPrivateMessage::verify(&from_value(val)?, expected_domain, context).await?; + } + Ok(()) + } + + async fn from_json(note: NoteWrapper, context: &Data) -> LemmyResult { + let is_public = is_public(¬e.to, ¬e.cc); + let val = to_value(note)?; + if is_public { + ApubComment::from_json(from_value(val)?, context).await?; + } else { + ApubPrivateMessage::from_json(from_value(val)?, context).await?; + } + Ok(ApubNote {}) + } + + async fn into_json(self, _context: &Data) -> LemmyResult { + Err(LemmyErrorType::Unknown("not implemented".to_string()).into()) + } +} + +pub(crate) fn is_public(to: &Option>, cc: &Option>) -> bool { + if let Some(to) = to { + if to.contains(&public()) { + return true; + } + } + if let Some(cc) = cc { + if cc.contains(&public()) { + return true; + } + } + false +} diff --git a/crates/apub/src/objects/private_message.rs b/crates/apub/src/objects/private_message.rs index 9ada5f657..ba64bd593 100644 --- a/crates/apub/src/objects/private_message.rs +++ b/crates/apub/src/objects/private_message.rs @@ -4,7 +4,7 @@ use crate::{ fetcher::markdown_links::markdown_rewrite_remote_links, objects::read_from_string_or_source, protocol::{ - objects::chat_message::{ChatMessage, ChatMessageType}, + objects::private_message::{PrivateMessage, PrivateMessageType}, Source, }, }; @@ -25,10 +25,11 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ source::{ + instance::Instance, local_site::LocalSite, person::Person, person_block::PersonBlock, - private_message::{PrivateMessage, PrivateMessageInsertForm}, + private_message::{PrivateMessage as DbPrivateMessage, PrivateMessageInsertForm}, }, traits::Crud, }; @@ -37,21 +38,22 @@ use lemmy_utils::{ error::{FederationError, LemmyError, LemmyErrorType, LemmyResult}, utils::markdown::markdown_to_html, }; +use semver::{Version, VersionReq}; use std::ops::Deref; use url::Url; #[derive(Clone, Debug)] -pub struct ApubPrivateMessage(pub(crate) PrivateMessage); +pub struct ApubPrivateMessage(pub(crate) DbPrivateMessage); impl Deref for ApubPrivateMessage { - type Target = PrivateMessage; + type Target = DbPrivateMessage; fn deref(&self) -> &Self::Target { &self.0 } } -impl From for ApubPrivateMessage { - fn from(pm: PrivateMessage) -> Self { +impl From for ApubPrivateMessage { + fn from(pm: DbPrivateMessage) -> Self { ApubPrivateMessage(pm) } } @@ -59,7 +61,7 @@ impl From for ApubPrivateMessage { #[async_trait::async_trait] impl Object for ApubPrivateMessage { type DataType = LemmyContext; - type Kind = ChatMessage; + type Kind = PrivateMessage; type Error = LemmyError; fn last_refreshed_at(&self) -> Option> { @@ -72,7 +74,7 @@ impl Object for ApubPrivateMessage { context: &Data, ) -> LemmyResult> { Ok( - PrivateMessage::read_from_apub_id(&mut context.pool(), object_id) + DbPrivateMessage::read_from_apub_id(&mut context.pool(), object_id) .await? .map(Into::into), ) @@ -84,15 +86,26 @@ impl Object for ApubPrivateMessage { } #[tracing::instrument(skip_all)] - async fn into_json(self, context: &Data) -> LemmyResult { + async fn into_json(self, context: &Data) -> LemmyResult { let creator_id = self.creator_id; let creator = Person::read(&mut context.pool(), creator_id).await?; let recipient_id = self.recipient_id; let recipient = Person::read(&mut context.pool(), recipient_id).await?; - let note = ChatMessage { - r#type: ChatMessageType::ChatMessage, + let instance = Instance::read(&mut context.pool(), recipient.instance_id).await?; + let mut kind = PrivateMessageType::Note; + + // Deprecated: For Lemmy versions before 0.20, send private messages with old type + if let (Some(software), Some(version)) = (instance.software, &instance.version) { + let req = VersionReq::parse("<0.20")?; + if software == "lemmy" && req.matches(&Version::parse(version)?) { + kind = PrivateMessageType::ChatMessage + } + } + + let note = PrivateMessage { + kind, id: self.ap_id.clone().into(), attributed_to: creator.actor_id.into(), to: [recipient.actor_id.into()], @@ -107,7 +120,7 @@ impl Object for ApubPrivateMessage { #[tracing::instrument(skip_all)] async fn verify( - note: &ChatMessage, + note: &PrivateMessage, expected_domain: &Url, context: &Data, ) -> LemmyResult<()> { @@ -128,7 +141,7 @@ impl Object for ApubPrivateMessage { #[tracing::instrument(skip_all)] async fn from_json( - note: ChatMessage, + note: PrivateMessage, context: &Data, ) -> LemmyResult { let creator = note.attributed_to.dereference(context).await?; @@ -161,7 +174,7 @@ impl Object for ApubPrivateMessage { local: Some(false), }; let timestamp = note.updated.or(note.published).unwrap_or_else(Utc::now); - let pm = PrivateMessage::insert_apub(&mut context.pool(), timestamp, &form).await?; + let pm = DbPrivateMessage::insert_apub(&mut context.pool(), timestamp, &form).await?; Ok(pm.into()) } } @@ -213,7 +226,7 @@ mod tests { let context = LemmyContext::init_test_context().await; let url = Url::parse("https://enterprise.lemmy.ml/private_message/1621")?; let data = prepare_comment_test(&url, &context).await?; - let json: ChatMessage = file_to_json_object("assets/lemmy/objects/chat_message.json")?; + let json: PrivateMessage = file_to_json_object("assets/lemmy/objects/private_message.json")?; ApubPrivateMessage::verify(&json, &url, &context).await?; let pm = ApubPrivateMessage::from_json(json.clone(), &context).await?; @@ -225,27 +238,7 @@ mod tests { let to_apub = pm.into_json(&context).await?; assert_json_include!(actual: json, expected: to_apub); - PrivateMessage::delete(&mut context.pool(), pm_id).await?; - cleanup(data, &context).await?; - Ok(()) - } - - #[tokio::test] - #[serial] - async fn test_parse_pleroma_pm() -> LemmyResult<()> { - let context = LemmyContext::init_test_context().await; - let url = Url::parse("https://enterprise.lemmy.ml/private_message/1621")?; - let data = prepare_comment_test(&url, &context).await?; - let pleroma_url = Url::parse("https://queer.hacktivis.me/objects/2")?; - let json = file_to_json_object("assets/pleroma/objects/chat_message.json")?; - ApubPrivateMessage::verify(&json, &pleroma_url, &context).await?; - let pm = ApubPrivateMessage::from_json(json, &context).await?; - - assert_eq!(pm.ap_id, pleroma_url.into()); - assert_eq!(pm.content.len(), 3); - assert_eq!(context.request_count(), 0); - - PrivateMessage::delete(&mut context.pool(), pm.id).await?; + DbPrivateMessage::delete(&mut context.pool(), pm_id).await?; cleanup(data, &context).await?; Ok(()) } diff --git a/crates/apub/src/protocol/activities/create_or_update/mod.rs b/crates/apub/src/protocol/activities/create_or_update/mod.rs index 3d9dbbb1d..d34be93c8 100644 --- a/crates/apub/src/protocol/activities/create_or_update/mod.rs +++ b/crates/apub/src/protocol/activities/create_or_update/mod.rs @@ -1,14 +1,16 @@ -pub mod chat_message; pub mod note; +pub(crate) mod note_wrapper; pub mod page; +pub mod private_message; #[cfg(test)] mod tests { + use super::note_wrapper::CreateOrUpdateNoteWrapper; use crate::protocol::{ activities::create_or_update::{ - chat_message::CreateOrUpdateChatMessage, note::CreateOrUpdateNote, page::CreateOrUpdatePage, + private_message::CreateOrUpdatePrivateMessage, }, tests::test_parse_lemmy_item, }; @@ -23,9 +25,15 @@ mod tests { "assets/lemmy/activities/create_or_update/update_page.json", )?; test_parse_lemmy_item::( - "assets/lemmy/activities/create_or_update/create_note.json", + "assets/lemmy/activities/create_or_update/create_comment.json", )?; - test_parse_lemmy_item::( + test_parse_lemmy_item::( + "assets/lemmy/activities/create_or_update/create_private_message.json", + )?; + test_parse_lemmy_item::( + "assets/lemmy/activities/create_or_update/create_comment.json", + )?; + test_parse_lemmy_item::( "assets/lemmy/activities/create_or_update/create_private_message.json", )?; Ok(()) diff --git a/crates/apub/src/protocol/activities/create_or_update/note_wrapper.rs b/crates/apub/src/protocol/activities/create_or_update/note_wrapper.rs new file mode 100644 index 000000000..bc53c80fd --- /dev/null +++ b/crates/apub/src/protocol/activities/create_or_update/note_wrapper.rs @@ -0,0 +1,16 @@ +use crate::protocol::objects::note_wrapper::NoteWrapper; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use url::Url; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateOrUpdateNoteWrapper { + object: NoteWrapper, + pub(crate) id: Url, + pub(crate) actor: Url, + pub(crate) to: Option>, + pub(crate) cc: Option>, + #[serde(flatten)] + other: Map, +} diff --git a/crates/apub/src/protocol/activities/create_or_update/chat_message.rs b/crates/apub/src/protocol/activities/create_or_update/private_message.rs similarity index 75% rename from crates/apub/src/protocol/activities/create_or_update/chat_message.rs rename to crates/apub/src/protocol/activities/create_or_update/private_message.rs index 30e94a28d..0c08f3991 100644 --- a/crates/apub/src/protocol/activities/create_or_update/chat_message.rs +++ b/crates/apub/src/protocol/activities/create_or_update/private_message.rs @@ -1,6 +1,6 @@ use crate::{ objects::person::ApubPerson, - protocol::{activities::CreateOrUpdateType, objects::chat_message::ChatMessage}, + protocol::{activities::CreateOrUpdateType, objects::private_message::PrivateMessage}, }; use activitypub_federation::{fetch::object_id::ObjectId, protocol::helpers::deserialize_one}; use serde::{Deserialize, Serialize}; @@ -8,12 +8,12 @@ use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CreateOrUpdateChatMessage { +pub struct CreateOrUpdatePrivateMessage { pub(crate) id: Url, pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one")] pub(crate) to: [ObjectId; 1], - pub(crate) object: ChatMessage, + pub(crate) object: PrivateMessage, #[serde(rename = "type")] pub(crate) kind: CreateOrUpdateType, } diff --git a/crates/apub/src/protocol/mod.rs b/crates/apub/src/protocol/mod.rs index a4774ac1d..9f218e351 100644 --- a/crates/apub/src/protocol/mod.rs +++ b/crates/apub/src/protocol/mod.rs @@ -116,6 +116,7 @@ pub(crate) mod tests { // parse file into hashmap, which ensures that every field is included let raw = file_to_json_object::>(path)?; // assert that all fields are identical, otherwise print diff + //dbg!(&parsed, &raw); assert_json_include!(actual: &parsed, expected: raw); Ok(parsed) } diff --git a/crates/apub/src/protocol/objects/mod.rs b/crates/apub/src/protocol/objects/mod.rs index 00fe26d2b..757f49ae4 100644 --- a/crates/apub/src/protocol/objects/mod.rs +++ b/crates/apub/src/protocol/objects/mod.rs @@ -8,12 +8,13 @@ use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use url::Url; -pub(crate) mod chat_message; pub(crate) mod group; pub(crate) mod instance; pub(crate) mod note; +pub(crate) mod note_wrapper; pub(crate) mod page; pub(crate) mod person; +pub(crate) mod private_message; pub(crate) mod tombstone; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] @@ -101,14 +102,15 @@ impl LanguageTag { #[cfg(test)] mod tests { + use super::note_wrapper::NoteWrapper; use crate::protocol::{ objects::{ - chat_message::ChatMessage, group::Group, instance::Instance, note::Note, page::Page, person::Person, + private_message::PrivateMessage, tombstone::Tombstone, }, tests::{test_json, test_parse_lemmy_item}, @@ -121,8 +123,10 @@ mod tests { test_parse_lemmy_item::("assets/lemmy/objects/group.json")?; test_parse_lemmy_item::("assets/lemmy/objects/person.json")?; test_parse_lemmy_item::("assets/lemmy/objects/page.json")?; - test_parse_lemmy_item::("assets/lemmy/objects/note.json")?; - test_parse_lemmy_item::("assets/lemmy/objects/chat_message.json")?; + test_parse_lemmy_item::("assets/lemmy/objects/comment.json")?; + test_parse_lemmy_item::("assets/lemmy/objects/private_message.json")?; + test_parse_lemmy_item::("assets/lemmy/objects/comment.json")?; + test_parse_lemmy_item::("assets/lemmy/objects/private_message.json")?; test_parse_lemmy_item::("assets/lemmy/objects/tombstone.json")?; Ok(()) } @@ -131,7 +135,6 @@ mod tests { fn test_parse_objects_pleroma() -> LemmyResult<()> { test_json::("assets/pleroma/objects/person.json")?; test_json::("assets/pleroma/objects/note.json")?; - test_json::("assets/pleroma/objects/chat_message.json")?; Ok(()) } diff --git a/crates/apub/src/protocol/objects/note_wrapper.rs b/crates/apub/src/protocol/objects/note_wrapper.rs new file mode 100644 index 000000000..f1bcf605b --- /dev/null +++ b/crates/apub/src/protocol/objects/note_wrapper.rs @@ -0,0 +1,14 @@ +use activitypub_federation::kinds::object::NoteType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use url::Url; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NoteWrapper { + pub(crate) r#type: NoteType, + pub(crate) to: Option>, + pub(crate) cc: Option>, + #[serde(flatten)] + other: Map, +} diff --git a/crates/apub/src/protocol/objects/chat_message.rs b/crates/apub/src/protocol/objects/private_message.rs similarity index 79% rename from crates/apub/src/protocol/objects/chat_message.rs rename to crates/apub/src/protocol/objects/private_message.rs index 8cb83e664..bf7fe90cb 100644 --- a/crates/apub/src/protocol/objects/chat_message.rs +++ b/crates/apub/src/protocol/objects/private_message.rs @@ -16,8 +16,9 @@ use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ChatMessage { - pub(crate) r#type: ChatMessageType, +pub struct PrivateMessage { + #[serde(rename = "type")] + pub(crate) kind: PrivateMessageType, pub(crate) id: ObjectId, pub(crate) attributed_to: ObjectId, #[serde(deserialize_with = "deserialize_one")] @@ -31,8 +32,10 @@ pub struct ChatMessage { pub(crate) updated: Option>, } -/// https://docs.pleroma.social/backend/development/ap_extensions/#chatmessages #[derive(Clone, Debug, Deserialize, Serialize)] -pub enum ChatMessageType { +pub enum PrivateMessageType { + /// For compatibility with Lemmy 0.19 and earlier + /// https://docs.pleroma.social/backend/development/ap_extensions/#chatmessages ChatMessage, + Note, } diff --git a/crates/db_schema/Cargo.toml b/crates/db_schema/Cargo.toml index 2ea816ef5..7c38066e0 100644 --- a/crates/db_schema/Cargo.toml +++ b/crates/db_schema/Cargo.toml @@ -75,11 +75,10 @@ tokio = { workspace = true, optional = true } tokio-postgres = { workspace = true, optional = true } tokio-postgres-rustls = { workspace = true, optional = true } rustls = { workspace = true, optional = true } -uuid = { workspace = true, features = ["v4"] } +uuid.workspace = true i-love-jesus = { workspace = true, optional = true } anyhow = { workspace = true } diesel-bind-if-some = { workspace = true, optional = true } -moka.workspace = true derive-new.workspace = true tuplex = { workspace = true, optional = true } diff --git a/crates/db_schema/replaceable_schema/triggers.sql b/crates/db_schema/replaceable_schema/triggers.sql index 6c55ce3d6..e5b3e22d0 100644 --- a/crates/db_schema/replaceable_schema/triggers.sql +++ b/crates/db_schema/replaceable_schema/triggers.sql @@ -384,6 +384,44 @@ END; $$); +CALL r.create_triggers ('post_report', $$ +BEGIN + UPDATE + post_aggregates AS a + SET + report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count + FROM ( + SELECT + (post_report).post_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (post_report).resolved), 0) AS unresolved_report_count + FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (post_report).post_id) AS diff +WHERE (diff.report_count, diff.unresolved_report_count) != (0, 0) + AND a.post_id = diff.post_id; + +RETURN NULL; + +END; + +$$); + +CALL r.create_triggers ('comment_report', $$ +BEGIN + UPDATE + comment_aggregates AS a + SET + report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count + FROM ( + SELECT + (comment_report).comment_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (comment_report).resolved), 0) AS unresolved_report_count + FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (comment_report).comment_id) AS diff +WHERE (diff.report_count, diff.unresolved_report_count) != (0, 0) + AND a.comment_id = diff.comment_id; + +RETURN NULL; + +END; + +$$); + -- These triggers create and update rows in each aggregates table to match its associated table's rows. -- Deleting rows and updating IDs are already handled by `CASCADE` in foreign key constraints. CREATE FUNCTION r.comment_aggregates_from_comment () diff --git a/crates/db_schema/src/aggregates/structs.rs b/crates/db_schema/src/aggregates/structs.rs index c2e54ae5c..7a97666aa 100644 --- a/crates/db_schema/src/aggregates/structs.rs +++ b/crates/db_schema/src/aggregates/structs.rs @@ -39,6 +39,8 @@ pub struct CommentAggregates { pub hot_rank: f64, #[serde(skip)] pub controversy_rank: f64, + pub report_count: i16, + pub unresolved_report_count: i16, } #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] @@ -146,6 +148,8 @@ pub struct PostAggregates { /// A rank that amplifies smaller communities #[serde(skip)] pub scaled_rank: f64, + pub report_count: i16, + pub unresolved_report_count: i16, } #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] diff --git a/crates/db_schema/src/impls/local_site.rs b/crates/db_schema/src/impls/local_site.rs index 926814c48..bdbe4ac6c 100644 --- a/crates/db_schema/src/impls/local_site.rs +++ b/crates/db_schema/src/impls/local_site.rs @@ -5,8 +5,7 @@ use crate::{ }; use diesel::{dsl::insert_into, result::Error}; use diesel_async::RunQueryDsl; -use lemmy_utils::{error::LemmyResult, CACHE_DURATION_API}; -use moka::future::Cache; +use lemmy_utils::{build_cache, error::LemmyResult, CacheLock}; use std::sync::LazyLock; impl LocalSite { @@ -18,12 +17,7 @@ impl LocalSite { .await } pub async fn read(pool: &mut DbPool<'_>) -> LemmyResult { - static CACHE: LazyLock> = LazyLock::new(|| { - Cache::builder() - .max_capacity(1) - .time_to_live(CACHE_DURATION_API) - .build() - }); + static CACHE: CacheLock = LazyLock::new(build_cache); Ok( CACHE .try_get_with((), async { diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index d6359cd44..4635be4ed 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -130,6 +130,8 @@ diesel::table! { child_count -> Int4, hot_rank -> Float8, controversy_rank -> Float8, + report_count -> Int2, + unresolved_report_count -> Int2, } } @@ -777,6 +779,8 @@ diesel::table! { controversy_rank -> Float8, instance_id -> Int4, scaled_rank -> Float8, + report_count -> Int2, + unresolved_report_count -> Int2, } } diff --git a/crates/db_schema/src/utils.rs b/crates/db_schema/src/utils.rs index bb63a6e46..4619ba7eb 100644 --- a/crates/db_schema/src/utils.rs +++ b/crates/db_schema/src/utils.rs @@ -96,7 +96,7 @@ pub async fn get_conn<'a, 'b: 'a>(pool: &'a mut DbPool<'b>) -> Result }) } -impl<'a> Deref for DbConn<'a> { +impl Deref for DbConn<'_> { type Target = AsyncPgConnection; fn deref(&self) -> &Self::Target { @@ -107,7 +107,7 @@ impl<'a> Deref for DbConn<'a> { } } -impl<'a> DerefMut for DbConn<'a> { +impl DerefMut for DbConn<'_> { fn deref_mut(&mut self) -> &mut Self::Target { match self { DbConn::Pool(conn) => conn.deref_mut(), diff --git a/crates/db_views/src/comment_report_view.rs b/crates/db_views/src/comment_report_view.rs index 278dc5c22..b4a23a0da 100644 --- a/crates/db_views/src/comment_report_view.rs +++ b/crates/db_views/src/comment_report_view.rs @@ -444,6 +444,8 @@ mod tests { child_count: 0, hot_rank: RANK_DEFAULT, controversy_rank: 0.0, + report_count: 2, + unresolved_report_count: 2, }, my_vote: None, resolver: None, @@ -511,6 +513,10 @@ mod tests { .updated = read_jessica_report_view_after_resolve .comment_report .updated; + expected_jessica_report_view_after_resolve + .counts + .unresolved_report_count = 1; + expected_sara_report_view.counts.unresolved_report_count = 1; expected_jessica_report_view_after_resolve.resolver = Some(Person { id: inserted_timmy.id, name: inserted_timmy.name.clone(), diff --git a/crates/db_views/src/comment_view.rs b/crates/db_views/src/comment_view.rs index 22b7b3de4..1037cf6ff 100644 --- a/crates/db_views/src/comment_view.rs +++ b/crates/db_views/src/comment_view.rs @@ -50,9 +50,12 @@ use lemmy_db_schema::{ ListingType, }; +type QueriesReadTypes<'a> = (CommentId, Option<&'a LocalUser>); +type QueriesListTypes<'a> = (CommentQuery<'a>, &'a Site); + fn queries<'a>() -> Queries< - impl ReadFn<'a, CommentView, (CommentId, Option<&'a LocalUser>)>, - impl ListFn<'a, CommentView, (CommentQuery<'a>, &'a Site)>, + impl ReadFn<'a, CommentView, QueriesReadTypes<'a>>, + impl ListFn<'a, CommentView, QueriesListTypes<'a>>, > { let creator_is_admin = exists( local_user::table.filter( @@ -308,10 +311,10 @@ fn queries<'a>() -> Queries< } impl CommentView { - pub async fn read<'a>( + pub async fn read( pool: &mut DbPool<'_>, comment_id: CommentId, - my_local_user: Option<&'a LocalUser>, + my_local_user: Option<&'_ LocalUser>, ) -> Result { // If a person is given, then my_vote (res.9), if None, should be 0, not null // Necessary to differentiate between other person's votes @@ -345,7 +348,7 @@ pub struct CommentQuery<'a> { pub max_depth: Option, } -impl<'a> CommentQuery<'a> { +impl CommentQuery<'_> { pub async fn list(self, site: &Site, pool: &mut DbPool<'_>) -> Result, Error> { Ok( queries() @@ -1065,6 +1068,8 @@ mod tests { child_count: 5, hot_rank: RANK_DEFAULT, controversy_rank: 0.0, + report_count: 0, + unresolved_report_count: 0, }, }) } diff --git a/crates/db_views/src/post_report_view.rs b/crates/db_views/src/post_report_view.rs index c6c19bf6f..9429c258f 100644 --- a/crates/db_views/src/post_report_view.rs +++ b/crates/db_views/src/post_report_view.rs @@ -232,6 +232,7 @@ mod tests { structs::LocalUserView, }; use lemmy_db_schema::{ + aggregates::structs::PostAggregates, assert_length, source::{ community::{Community, CommunityInsertForm, CommunityModerator, CommunityModeratorForm}, @@ -336,6 +337,10 @@ mod tests { let read_jessica_report_view = PostReportView::read(pool, inserted_jessica_report.id, inserted_timmy.id).await?; + // Make sure the triggers are reading the aggregates correctly. + let agg_1 = PostAggregates::read(pool, inserted_post.id).await?; + let agg_2 = PostAggregates::read(pool, inserted_post_2.id).await?; + assert_eq!( read_jessica_report_view.post_report, inserted_jessica_report @@ -346,6 +351,10 @@ mod tests { assert_eq!(read_jessica_report_view.post_creator.id, inserted_timmy.id); assert_eq!(read_jessica_report_view.my_vote, None); assert_eq!(read_jessica_report_view.resolver, None); + assert_eq!(agg_1.report_count, 1); + assert_eq!(agg_1.unresolved_report_count, 1); + assert_eq!(agg_2.report_count, 1); + assert_eq!(agg_2.unresolved_report_count, 1); // Do a batch read of timmys reports let reports = PostReportQuery::default().list(pool, &timmy_view).await?; @@ -379,6 +388,16 @@ mod tests { Some(inserted_timmy.id) ); + // Make sure the unresolved_post report got decremented in the trigger + let agg_2 = PostAggregates::read(pool, inserted_post_2.id).await?; + assert_eq!(agg_2.report_count, 1); + assert_eq!(agg_2.unresolved_report_count, 0); + + // Make sure the other unresolved report isn't changed + let agg_1 = PostAggregates::read(pool, inserted_post.id).await?; + assert_eq!(agg_1.report_count, 1); + assert_eq!(agg_1.unresolved_report_count, 1); + // Do a batch read of timmys reports // It should only show saras, which is unresolved let reports_after_resolve = PostReportQuery { diff --git a/crates/db_views/src/post_view.rs b/crates/db_views/src/post_view.rs index 4363d2a3e..c6d1b036f 100644 --- a/crates/db_views/src/post_view.rs +++ b/crates/db_views/src/post_view.rs @@ -62,9 +62,12 @@ use lemmy_db_schema::{ use tracing::debug; use PostSortType::*; +type QueriesReadTypes<'a> = (PostId, Option<&'a LocalUser>, bool); +type QueriesListTypes<'a> = (PostQuery<'a>, &'a Site); + fn queries<'a>() -> Queries< - impl ReadFn<'a, PostView, (PostId, Option<&'a LocalUser>, bool)>, - impl ListFn<'a, PostView, (PostQuery<'a>, &'a Site)>, + impl ReadFn<'a, PostView, QueriesReadTypes<'a>>, + impl ListFn<'a, PostView, QueriesListTypes<'a>>, > { let creator_is_admin = exists( local_user::table.filter( @@ -431,10 +434,10 @@ fn queries<'a>() -> Queries< } impl PostView { - pub async fn read<'a>( + pub async fn read( pool: &mut DbPool<'_>, post_id: PostId, - my_local_user: Option<&'a LocalUser>, + my_local_user: Option<&'_ LocalUser>, is_mod_or_admin: bool, ) -> Result { queries() @@ -1735,6 +1738,8 @@ mod tests { community_id: inserted_post.community_id, creator_id: inserted_post.creator_id, instance_id: data.inserted_instance.id, + report_count: 0, + unresolved_report_count: 0, }, subscribed: SubscribedType::NotSubscribed, read: false, diff --git a/crates/db_views_actor/src/community_follower_view.rs b/crates/db_views_actor/src/community_follower_view.rs index 56f4cca93..c32ccb5b8 100644 --- a/crates/db_views_actor/src/community_follower_view.rs +++ b/crates/db_views_actor/src/community_follower_view.rs @@ -232,6 +232,25 @@ impl CommunityFollowerView { .then_some(()) .ok_or(diesel::NotFound) } + + pub async fn is_follower( + community_id: CommunityId, + instance_id: InstanceId, + pool: &mut DbPool<'_>, + ) -> Result<(), Error> { + let conn = &mut get_conn(pool).await?; + select(exists( + action_query(community_actions::followed) + .inner_join(person::table.on(community_actions::person_id.eq(person::id))) + .filter(community_actions::community_id.eq(community_id)) + .filter(person::instance_id.eq(instance_id)) + .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), + )) + .get_result::(conn) + .await? + .then_some(()) + .ok_or(diesel::NotFound) + } } #[cfg(test)] diff --git a/crates/db_views_actor/src/community_view.rs b/crates/db_views_actor/src/community_view.rs index da349c93e..f6ce82d37 100644 --- a/crates/db_views_actor/src/community_view.rs +++ b/crates/db_views_actor/src/community_view.rs @@ -34,9 +34,12 @@ use lemmy_db_schema::{ }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; +type QueriesReadTypes<'a> = (CommunityId, Option<&'a LocalUser>, bool); +type QueriesListTypes<'a> = (CommunityQuery<'a>, &'a Site); + fn queries<'a>() -> Queries< - impl ReadFn<'a, CommunityView, (CommunityId, Option<&'a LocalUser>, bool)>, - impl ListFn<'a, CommunityView, (CommunityQuery<'a>, &'a Site)>, + impl ReadFn<'a, CommunityView, QueriesReadTypes<'a>>, + impl ListFn<'a, CommunityView, QueriesListTypes<'a>>, > { let all_joins = |query: community::BoxedQuery<'a, Pg>, my_local_user: Option<&'a LocalUser>| { query @@ -166,10 +169,10 @@ fn queries<'a>() -> Queries< } impl CommunityView { - pub async fn read<'a>( + pub async fn read( pool: &mut DbPool<'_>, community_id: CommunityId, - my_local_user: Option<&'a LocalUser>, + my_local_user: Option<&'_ LocalUser>, is_mod_or_admin: bool, ) -> Result { queries() @@ -253,7 +256,7 @@ pub struct CommunityQuery<'a> { pub limit: Option, } -impl<'a> CommunityQuery<'a> { +impl CommunityQuery<'_> { pub async fn list(self, site: &Site, pool: &mut DbPool<'_>) -> Result, Error> { queries().list(pool, (self, site)).await } diff --git a/crates/federate/src/send.rs b/crates/federate/src/send.rs index 01d620eb0..d77ebe9f7 100644 --- a/crates/federate/src/send.rs +++ b/crates/federate/src/send.rs @@ -84,7 +84,7 @@ pub(crate) struct SendRetryTask<'a> { pub stop: CancellationToken, } -impl<'a> SendRetryTask<'a> { +impl SendRetryTask<'_> { // this function will return successfully when (a) send succeeded or (b) worker cancelled // and will return an error if an internal error occurred (send errors cause an infinite loop) pub async fn send_retry_loop(self) -> Result<()> { diff --git a/crates/routes/src/images.rs b/crates/routes/src/images.rs index 54fe4ea83..fe91fa42c 100644 --- a/crates/routes/src/images.rs +++ b/crates/routes/src/images.rs @@ -38,7 +38,8 @@ pub fn config( ) // This has optional query params: /image/{filename}?format=jpg&thumbnail=256 .service(web::resource("/pictrs/image/{filename}").route(web::get().to(full_res))) - .service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete))); + .service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete))) + .service(web::resource("/pictrs/healthz").route(web::get().to(healthz))); } trait ProcessUrl { @@ -250,6 +251,25 @@ async fn delete( Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream()))) } +async fn healthz( + req: HttpRequest, + client: web::Data, + context: web::Data, +) -> LemmyResult { + let pictrs_config = context.settings().pictrs_config()?; + let url = format!("{}healthz", pictrs_config.url); + + let mut client_req = adapt_request(&req, &client, url); + + if let Some(addr) = req.head().peer_addr { + client_req = client_req.header("X-Forwarded-For", addr.to_string()); + } + + let res = client_req.send().await?; + + Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream()))) +} + pub async fn image_proxy( Query(params): Query, req: HttpRequest, diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index cd4ea5e9b..7ed4c0476 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -23,30 +23,31 @@ workspace = true [features] full = [ - "dep:ts-rs", - "dep:diesel", - "dep:rosetta-i18n", - "dep:actix-web", - "dep:reqwest-middleware", - "dep:tracing", - "dep:actix-web", - "dep:serde_json", - "dep:anyhow", - "dep:http", - "dep:deser-hjson", - "dep:regex", - "dep:urlencoding", - "dep:doku", - "dep:url", - "dep:smart-default", - "dep:enum-map", - "dep:futures", - "dep:tokio", - "dep:html2text", - "dep:lettre", - "dep:uuid", - "dep:itertools", - "dep:markdown-it", + "ts-rs", + "diesel", + "rosetta-i18n", + "actix-web", + "reqwest-middleware", + "tracing", + "actix-web", + "serde_json", + "anyhow", + "http", + "deser-hjson", + "regex", + "urlencoding", + "doku", + "url", + "smart-default", + "enum-map", + "futures", + "tokio", + "html2text", + "lettre", + "uuid", + "itertools", + "markdown-it", + "moka", ] [package.metadata.cargo-shear] @@ -89,6 +90,7 @@ markdown-it-block-spoiler = "1.0.0" markdown-it-sub = "1.0.0" markdown-it-sup = "1.0.0" markdown-it-ruby = "1.0.0" +moka = { workspace = true, optional = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 1e0cbefbf..3367c91bb 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -42,7 +42,10 @@ macro_rules! location_info { }; } -#[cfg(feature = "full")] +cfg_if! { + if #[cfg(feature = "full")] { +use moka::future::Cache;use std::fmt::Debug;use std::hash::Hash; + /// tokio::spawn, but accepts a future that may fail and also /// * logs errors /// * attaches the spawned task to the tracing span of the caller for better logging @@ -60,3 +63,20 @@ pub fn spawn_try_task( * spawn was called */ ); } + +pub fn build_cache() -> Cache +where + K: Debug + Eq + Hash + Send + Sync + 'static, + V: Debug + Clone + Send + Sync + 'static, +{ + Cache::::builder() + .max_capacity(1) + .time_to_live(CACHE_DURATION_API) + .build() +} + +#[cfg(feature = "full")] +pub type CacheLock = std::sync::LazyLock>; + + } +} diff --git a/migrations/2024-11-21-195004_add_report_count/down.sql b/migrations/2024-11-21-195004_add_report_count/down.sql new file mode 100644 index 000000000..be418840f --- /dev/null +++ b/migrations/2024-11-21-195004_add_report_count/down.sql @@ -0,0 +1,8 @@ +ALTER TABLE post_aggregates + DROP COLUMN report_count, + DROP COLUMN unresolved_report_count; + +ALTER TABLE comment_aggregates + DROP COLUMN report_count, + DROP COLUMN unresolved_report_count; + diff --git a/migrations/2024-11-21-195004_add_report_count/up.sql b/migrations/2024-11-21-195004_add_report_count/up.sql new file mode 100644 index 000000000..c7d28e1ef --- /dev/null +++ b/migrations/2024-11-21-195004_add_report_count/up.sql @@ -0,0 +1,79 @@ +-- Adding report_count and unresolved_report_count +-- to the post and comment aggregate tables +ALTER TABLE post_aggregates + ADD COLUMN report_count smallint NOT NULL DEFAULT 0, + ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; + +ALTER TABLE comment_aggregates + ADD COLUMN report_count smallint NOT NULL DEFAULT 0, + ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; + +-- Update the historical counts +-- Posts +UPDATE + post_aggregates AS a +SET + report_count = cnt.count +FROM ( + SELECT + post_id, + count(*) AS count + FROM + post_report + GROUP BY + post_id) cnt +WHERE + a.post_id = cnt.post_id; + +-- The unresolved +UPDATE + post_aggregates AS a +SET + unresolved_report_count = cnt.count +FROM ( + SELECT + post_id, + count(*) AS count + FROM + post_report + WHERE + resolved = 'f' + GROUP BY + post_id) cnt +WHERE + a.post_id = cnt.post_id; + +-- Comments +UPDATE + comment_aggregates AS a +SET + report_count = cnt.count +FROM ( + SELECT + comment_id, + count(*) AS count + FROM + comment_report + GROUP BY + comment_id) cnt +WHERE + a.comment_id = cnt.comment_id; + +-- The unresolved +UPDATE + comment_aggregates AS a +SET + unresolved_report_count = cnt.count +FROM ( + SELECT + comment_id, + count(*) AS count + FROM + comment_report + WHERE + resolved = 'f' + GROUP BY + comment_id) cnt +WHERE + a.comment_id = cnt.comment_id; +