local-only-fixed (#138)
authorfloatingghost <hannah@coffee-and-dreams.uk>
Tue, 2 Aug 2022 14:46:46 +0000 (14:46 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Tue, 2 Aug 2022 14:46:46 +0000 (14:46 +0000)
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/138

lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/visibility.ex
lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex
priv/repo/migrations/20220509180452_change_thread_visibility_to_be_local_only_aware.exs [new file with mode: 0644]
test/pleroma/web/activity_pub/activity_pub_controller_test.exs
test/pleroma/web/mastodon_api/controllers/account_controller_test.exs
test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs
test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs

index 29a1a40b5fcc6403f32783102a7cd86e972665fe..8ab87bfee8742e9030573af86fc2bd1277dcd23a 100644 (file)
@@ -486,9 +486,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub 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 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     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 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub 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
index 98b69f0b780dc75fbc0c49ce9cb3065ec14f92da..02d4e195a030c9a00ffaa31027995f57ab9d440a 100644 (file)
@@ -85,11 +85,9 @@ defmodule Pleroma.Web.ActivityPub.Visibility do
     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
index b9978c05bb54b0d30b1f8a40672bf0ae19329b3d..5f8acb2df3cd7e4b06b4401f6d46354b7211240c 100644 (file)
@@ -113,6 +113,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController 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 (file)
index 0000000..ea6ae6c
--- /dev/null
@@ -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
index 5114056241194dd0c21d7cdbdd2c7729868deda9..da5c87bd840fe101f01276c3d0a5747c25169a09 100644 (file)
@@ -247,6 +247,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest 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 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest 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)
index 402c71e7786dc25fcbd3eb3c3156b09ebaad3684..dcdff6c094472e9e26011be83638ef3fc6430f7c 100644 (file)
@@ -414,6 +414,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest 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"])
index 90aa9398f9aaebe0f81a954f71185002fcd62531..66f7ed5793877a5948cd7ed6425ff681ddd4ead2 100644 (file)
@@ -67,9 +67,11 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest 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)
 
index b0efddb2a3d542fddab7d7140558124fd93c2245..1a3bc2990bb45702c0f7e08b0542a289dbb9b807 100644 (file)
@@ -1855,23 +1855,50 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest 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)
+    end
+
+    test "other users can read local-only posts" do
+      user = insert(:user)
+      %{user: _reader, conn: conn} = oauth_access(["read:statuses"])
 
-    assert %Activity{id: ^id, data: %{"to" => [^local]}} = Activity.get_by_id(id)
+      {: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
index fe32a49554878601534e54d71dda7ce1b65c02d8..cf60ba93e26aa39e66d3f7d0a66ef15dfdc95377 100644 (file)
@@ -367,6 +367,47 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest 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