From c9600dbbbf925d984e745a621fa03d1850c5cfd2 Mon Sep 17 00:00:00 2001 From: floatingghost Date: Tue, 2 Aug 2022 14:46:46 +0000 Subject: [PATCH] local-only-fixed (#138) Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/138 --- lib/pleroma/web/activity_pub/activity_pub.ex | 23 ++- lib/pleroma/web/activity_pub/visibility.ex | 8 +- .../controllers/timeline_controller.ex | 2 + ...read_visibility_to_be_local_only_aware.exs | 153 ++++++++++++++++++ .../activity_pub_controller_test.exs | 50 ++++++ .../controllers/account_controller_test.exs | 14 ++ .../controllers/filter_controller_test.exs | 6 +- .../controllers/status_controller_test.exs | 53 ++++-- .../controllers/timeline_controller_test.exs | 41 +++++ 9 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 priv/repo/migrations/20220509180452_change_thread_visibility_to_be_local_only_aware.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 29a1a40b5..8ab87bfee 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -486,9 +486,18 @@ def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do + includes_local_public = Map.get(opts, :includes_local_public, false) + opts = Map.delete(opts, :user) - [Constants.as_public()] + intended_recipients = + if includes_local_public do + [Constants.as_public(), as_local_public()] + else + [Constants.as_public()] + end + + intended_recipients |> fetch_activities_query(opts) |> restrict_unlisted(opts) |> fetch_paginated_optimized(opts, pagination) @@ -588,9 +597,11 @@ defp restrict_thread_visibility(query, %{user: %User{skip_thread_containment: tr do: query defp restrict_thread_visibility(query, %{user: %User{ap_id: ap_id}}, _) do + local_public = as_local_public() + from( a in query, - where: fragment("thread_visibility(?, (?)->>'id') = true", ^ap_id, a.data) + where: fragment("thread_visibility(?, (?)->>'id', ?) = true", ^ap_id, a.data, ^local_public) ) end @@ -677,8 +688,12 @@ defp fetch_activities_for_reading_user(reading_user, params) do defp user_activities_recipients(%{godmode: true}), do: [] defp user_activities_recipients(%{reading_user: reading_user}) do - if reading_user do - [Constants.as_public(), reading_user.ap_id | User.following(reading_user)] + if not is_nil(reading_user) and reading_user.local do + [ + Constants.as_public(), + as_local_public(), + reading_user.ap_id | User.following(reading_user) + ] else [Constants.as_public()] end diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 98b69f0b7..02d4e195a 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -85,11 +85,9 @@ def visible_for_user?(%{__struct__: module} = message, user) x = [user.ap_id | User.following(user)] y = [message.data["actor"]] ++ message.data["to"] ++ (message.data["cc"] || []) - if is_local_public?(message) do - user.local - else - is_public?(message) || Enum.any?(x, &(&1 in y)) - end + user_is_local = user.local + federatable = not is_local_public?(message) + (is_public?(message) || Enum.any?(x, &(&1 in y))) and (user_is_local || federatable) end def entire_thread_visible_for_user?(%Activity{} = activity, %User{} = user) do diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index b9978c05b..5f8acb2df 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -113,6 +113,8 @@ def public(%{assigns: %{user: user}} = conn, params) do |> Map.put(:muting_user, user) |> Map.put(:reply_filtering_user, user) |> Map.put(:instance, params[:instance]) + # Restricts unfederated content to authenticated users + |> Map.put(:includes_local_public, not is_nil(user)) |> ActivityPub.fetch_public_activities() conn diff --git a/priv/repo/migrations/20220509180452_change_thread_visibility_to_be_local_only_aware.exs b/priv/repo/migrations/20220509180452_change_thread_visibility_to_be_local_only_aware.exs new file mode 100644 index 000000000..ea6ae6c5c --- /dev/null +++ b/priv/repo/migrations/20220509180452_change_thread_visibility_to_be_local_only_aware.exs @@ -0,0 +1,153 @@ +defmodule Pleroma.Repo.Migrations.ChangeThreadVisibilityToBeLocalOnlyAware do + use Ecto.Migration + + def up do + execute("DROP FUNCTION IF EXISTS thread_visibility(actor varchar, activity_id varchar)") + execute(update_thread_visibility()) + end + + def down do + execute( + "DROP FUNCTION IF EXISTS thread_visibility(actor varchar, activity_id varchar, local_public varchar)" + ) + + execute(restore_thread_visibility()) + end + + def update_thread_visibility do + """ + CREATE OR REPLACE FUNCTION thread_visibility(actor varchar, activity_id varchar, local_public varchar default '') RETURNS boolean AS $$ + DECLARE + public varchar := 'https://www.w3.org/ns/activitystreams#Public'; + child objects%ROWTYPE; + activity activities%ROWTYPE; + author_fa varchar; + valid_recipients varchar[]; + actor_user_following varchar[]; + BEGIN + --- Fetch actor following + SELECT array_agg(following.follower_address) INTO actor_user_following FROM following_relationships + JOIN users ON users.id = following_relationships.follower_id + JOIN users AS following ON following.id = following_relationships.following_id + WHERE users.ap_id = actor; + + --- Fetch our initial activity. + SELECT * INTO activity FROM activities WHERE activities.data->>'id' = activity_id; + + LOOP + --- Ensure that we have an activity before continuing. + --- If we don't, the thread is not satisfiable. + IF activity IS NULL THEN + RETURN false; + END IF; + + --- We only care about Create activities. + IF activity.data->>'type' != 'Create' THEN + RETURN true; + END IF; + + --- Normalize the child object into child. + SELECT * INTO child FROM objects + INNER JOIN activities ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = objects.data->>'id' + WHERE COALESCE(activity.data->'object'->>'id', activity.data->>'object') = objects.data->>'id'; + + --- Fetch the author's AS2 following collection. + SELECT COALESCE(users.follower_address, '') INTO author_fa FROM users WHERE users.ap_id = activity.actor; + + --- Prepare valid recipients array. + valid_recipients := ARRAY[actor, public]; + --- If we specified local public, add it. + IF local_public <> '' THEN + valid_recipients := valid_recipients || local_public; + END IF; + IF ARRAY[author_fa] && actor_user_following THEN + valid_recipients := valid_recipients || author_fa; + END IF; + + --- Check visibility. + IF NOT valid_recipients && activity.recipients THEN + --- activity not visible, break out of the loop + RETURN false; + END IF; + + --- If there's a parent, load it and do this all over again. + IF (child.data->'inReplyTo' IS NOT NULL) AND (child.data->'inReplyTo' != 'null'::jsonb) THEN + SELECT * INTO activity FROM activities + INNER JOIN objects ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = objects.data->>'id' + WHERE child.data->>'inReplyTo' = objects.data->>'id'; + ELSE + RETURN true; + END IF; + END LOOP; + END; + $$ LANGUAGE plpgsql IMMUTABLE; + """ + end + + # priv/repo/migrations/20191007073319_create_following_relationships.exs + def restore_thread_visibility do + """ + CREATE OR REPLACE FUNCTION thread_visibility(actor varchar, activity_id varchar) RETURNS boolean AS $$ + DECLARE + public varchar := 'https://www.w3.org/ns/activitystreams#Public'; + child objects%ROWTYPE; + activity activities%ROWTYPE; + author_fa varchar; + valid_recipients varchar[]; + actor_user_following varchar[]; + BEGIN + --- Fetch actor following + SELECT array_agg(following.follower_address) INTO actor_user_following FROM following_relationships + JOIN users ON users.id = following_relationships.follower_id + JOIN users AS following ON following.id = following_relationships.following_id + WHERE users.ap_id = actor; + + --- Fetch our initial activity. + SELECT * INTO activity FROM activities WHERE activities.data->>'id' = activity_id; + + LOOP + --- Ensure that we have an activity before continuing. + --- If we don't, the thread is not satisfiable. + IF activity IS NULL THEN + RETURN false; + END IF; + + --- We only care about Create activities. + IF activity.data->>'type' != 'Create' THEN + RETURN true; + END IF; + + --- Normalize the child object into child. + SELECT * INTO child FROM objects + INNER JOIN activities ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = objects.data->>'id' + WHERE COALESCE(activity.data->'object'->>'id', activity.data->>'object') = objects.data->>'id'; + + --- Fetch the author's AS2 following collection. + SELECT COALESCE(users.follower_address, '') INTO author_fa FROM users WHERE users.ap_id = activity.actor; + + --- Prepare valid recipients array. + valid_recipients := ARRAY[actor, public]; + IF ARRAY[author_fa] && actor_user_following THEN + valid_recipients := valid_recipients || author_fa; + END IF; + + --- Check visibility. + IF NOT valid_recipients && activity.recipients THEN + --- activity not visible, break out of the loop + RETURN false; + END IF; + + --- If there's a parent, load it and do this all over again. + IF (child.data->'inReplyTo' IS NOT NULL) AND (child.data->'inReplyTo' != 'null'::jsonb) THEN + SELECT * INTO activity FROM activities + INNER JOIN objects ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = objects.data->>'id' + WHERE child.data->>'inReplyTo' = objects.data->>'id'; + ELSE + RETURN true; + END IF; + END LOOP; + END; + $$ LANGUAGE plpgsql IMMUTABLE; + """ + end +end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 511405624..da5c87bd8 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -247,6 +247,27 @@ test "returns local-only objects when authenticated", %{conn: conn} do assert json_response(response, 200) == ObjectView.render("object.json", %{object: object}) end + test "does not return local-only objects for remote users", %{conn: conn} do + user = insert(:user) + reader = insert(:user, local: false) + + {:ok, post} = + CommonAPI.post(user, %{status: "test @#{reader.nickname}", visibility: "local"}) + + assert Pleroma.Web.ActivityPub.Visibility.is_local_public?(post) + + object = Object.normalize(post, fetch: false) + uuid = String.split(object.data["id"], "/") |> List.last() + + assert response = + conn + |> assign(:user, reader) + |> put_req_header("accept", "application/activity+json") + |> get("/objects/#{uuid}") + + json_response(response, 404) + end + test "it returns a json representation of the object with accept application/json", %{ conn: conn } do @@ -1297,6 +1318,35 @@ test "it returns 200 even if there're no activities", %{conn: conn} do assert outbox_endpoint == result["id"] end + test "it returns a local note activity when authenticated as local user", %{conn: conn} do + user = insert(:user) + reader = insert(:user) + {:ok, note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"}) + ap_id = note_activity.data["id"] + + resp = + conn + |> assign(:user, reader) + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/outbox?page=true") + |> json_response(200) + + assert %{"orderedItems" => [%{"id" => ^ap_id}]} = resp + end + + test "it does not return a local note activity when unauthenticated", %{conn: conn} do + user = insert(:user) + {:ok, _note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"}) + + resp = + conn + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/outbox?page=true") + |> json_response(200) + + assert %{"orderedItems" => []} = resp + end + test "it returns a note activity in a collection", %{conn: conn} do note_activity = insert(:note_activity) note_object = Object.normalize(note_activity, fetch: false) diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index 402c71e77..dcdff6c09 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -414,6 +414,20 @@ test "unimplemented pinned statuses feature", %{conn: conn} do assert json_response_and_validate_schema(conn, 200) == [] end + test "gets local-only statuses for authenticated users", %{user: _user, conn: conn} do + user_one = insert(:user) + + {:ok, activity} = CommonAPI.post(user_one, %{status: "HI!!!", visibility: "local"}) + + resp = + conn + |> get("/api/v1/accounts/#{user_one.id}/statuses") + |> json_response_and_validate_schema(200) + + assert [%{"id" => id}] = resp + assert id == to_string(activity.id) + end + test "gets an users media, excludes reblogs", %{conn: conn} do note = insert(:note_activity) user = User.get_cached_by_ap_id(note.data["actor"]) diff --git a/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs index 90aa9398f..66f7ed579 100644 --- a/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs @@ -67,9 +67,11 @@ test "a filter with expires_in", %{conn: conn, user: user} do expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(in_seconds) - |> Pleroma.Web.CommonAPI.Utils.to_masto_date() - assert response["expires_at"] == expires_at + assert NaiveDateTime.diff( + NaiveDateTime.from_iso8601!(response["expires_at"]), + expires_at + ) < 5 filter = Filter.get(response["id"], user) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index b0efddb2a..1a3bc2990 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1855,23 +1855,50 @@ test "expires_at is nil for another user" do |> json_response_and_validate_schema(:ok) end - test "posting a local only status" do - %{user: _user, conn: conn} = oauth_access(["write:statuses"]) + describe "local-only statuses" do + test "posting a local only status" do + %{user: _user, conn: conn} = oauth_access(["write:statuses"]) - conn_one = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/statuses", %{ - "status" => "cofe", - "visibility" => "local" - }) + conn_one = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "cofe", + "visibility" => "local" + }) - local = Utils.as_local_public() + local = Utils.as_local_public() - assert %{"content" => "cofe", "id" => id, "visibility" => "local"} = - json_response_and_validate_schema(conn_one, 200) + assert %{"content" => "cofe", "id" => id, "visibility" => "local"} = + json_response_and_validate_schema(conn_one, 200) - assert %Activity{id: ^id, data: %{"to" => [^local]}} = Activity.get_by_id(id) + assert %Activity{id: ^id, data: %{"to" => [^local]}} = Activity.get_by_id(id) + end + + test "other users can read local-only posts" do + user = insert(:user) + %{user: _reader, conn: conn} = oauth_access(["read:statuses"]) + + {:ok, activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"}) + + received = + conn + |> get("/api/v1/statuses/#{activity.id}") + |> json_response_and_validate_schema(:ok) + + assert received["id"] == activity.id + end + + test "anonymous users cannot see local-only posts" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"}) + + _received = + build_conn() + |> get("/api/v1/statuses/#{activity.id}") + |> json_response_and_validate_schema(:not_found) + end end describe "muted reactions" do diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs index fe32a4955..cf60ba93e 100644 --- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs @@ -367,6 +367,47 @@ test "muted emotions", %{conn: conn} do } ] = result end + + test "should return local-only posts for authenticated users" do + user = insert(:user) + %{user: _reader, conn: conn} = oauth_access(["read:statuses"]) + + {:ok, %{id: id}} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"}) + + result = + conn + |> get("/api/v1/timelines/public") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id}] = result + end + + test "should not return local-only posts for users without read:statuses" do + user = insert(:user) + %{user: _reader, conn: conn} = oauth_access([]) + + {:ok, _activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"}) + + result = + conn + |> get("/api/v1/timelines/public") + |> json_response_and_validate_schema(200) + + assert [] = result + end + + test "should not return local-only posts for anonymous users" do + user = insert(:user) + + {:ok, _activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"}) + + result = + build_conn() + |> get("/api/v1/timelines/public") + |> json_response_and_validate_schema(200) + + assert [] = result + end end defp local_and_remote_activities do