MastoAPI: Profile directory
authormarcin mikołajczak <me@mkljczk.pl>
Sun, 26 Dec 2021 02:35:17 +0000 (02:35 +0000)
committerAlex Gleason <alex@alexgleason.me>
Sun, 26 Dec 2021 02:35:17 +0000 (02:35 +0000)
16 files changed:
config/config.exs
config/description.exs
docs/development/API/differences_in_mastoapi_responses.md
lib/pleroma/user.ex
lib/pleroma/user/query.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/side_effects.ex
lib/pleroma/web/api_spec/operations/directory_operation.ex [new file with mode: 0644]
lib/pleroma/web/mastodon_api/controllers/directory_controller.ex [new file with mode: 0644]
lib/pleroma/web/mastodon_api/views/account_view.ex
lib/pleroma/web/mastodon_api/views/instance_view.ex
lib/pleroma/web/router.ex
priv/repo/migrations/20211222165256_add_last_status_at_to_users.exs [new file with mode: 0644]
priv/repo/migrations/20211225154802_add_is_discoverable_index_to_users.exs [new file with mode: 0644]
test/pleroma/web/mastodon_api/controllers/directory_controller_test.exs [new file with mode: 0644]
test/pleroma/web/mastodon_api/views/account_view_test.exs

index c9592511f229dc6cd51061904e59bcb9b197ffd8..23c41edddaf789cd1fe51d43733b1be654154578 100644 (file)
@@ -254,7 +254,8 @@ config :pleroma, :instance,
     ]
   ],
   show_reactions: true,
-  password_reset_token_validity: 60 * 60 * 24
+  password_reset_token_validity: 60 * 60 * 24,
+  profile_directory: true
 
 config :pleroma, :welcome,
   direct_message: [
index 1c8c3b4a0e8af2e61d5705ac6e02214cfbb288c2..517077acf58be36d757a5a2b262932df3e44e06b 100644 (file)
@@ -936,6 +936,11 @@ config :pleroma, :config_description, [
         key: :show_reactions,
         type: :boolean,
         description: "Let favourites and emoji reactions be viewed through the API."
+      },
+      %{
+        key: :profile_directory,
+        type: :boolean,
+        description: "Enable profile directory."
       }
     ]
   },
index 6c1ecb559671e557032f59724a598baa8ceef543..518aca11429e0e49cdece9d5246b82b7bfe432e7 100644 (file)
@@ -383,12 +383,6 @@ Pleroma is generally compatible with the Mastodon 2.7.2 API, but some newer feat
 
 - `GET /api/v1/endorsements`: Returns an empty array, `[]`
 
-### Profile directory
-
-*Added in Mastodon 3.0.0*
-
-- `GET /api/v1/directory`: Returns HTTP 404
-
 ### Featured tags
 
 *Added in Mastodon 3.0.0*
index c25023dc12b1c1b8b9b20e1b3b95fae17ec4aa28..390de1e2d95c9326f9a558930ed5a7d9c873be0c 100644 (file)
@@ -149,6 +149,7 @@ defmodule Pleroma.User do
     field(:disclose_client, :boolean, default: true)
     field(:pinned_objects, :map, default: %{})
     field(:is_suggested, :boolean, default: false)
+    field(:last_status_at, :naive_datetime)
 
     embeds_one(
       :notification_settings,
@@ -2499,4 +2500,16 @@ defmodule Pleroma.User do
     |> where([u], u.local == true)
     |> Repo.aggregate(:count)
   end
+
+  def update_last_status_at(user) do
+    User
+    |> where(id: ^user.id)
+    |> update([u], set: [last_status_at: fragment("NOW()")])
+    |> select([u], u)
+    |> Repo.update_all([])
+    |> case do
+      {1, [user]} -> set_cache(user)
+      _ -> {:error, user}
+    end
+  end
 end
index 6d4a4ead6c480797ebcc2bed1efdf94389ed99fc..bf78cb32d3f4336460684418a82e7e976da79c5b 100644 (file)
@@ -47,6 +47,7 @@ defmodule Pleroma.User.Query do
             is_admin: boolean(),
             is_moderator: boolean(),
             is_suggested: boolean(),
+            is_discoverable: boolean(),
             super_users: boolean(),
             invisible: boolean(),
             internal: boolean(),
@@ -172,6 +173,10 @@ defmodule Pleroma.User.Query do
     where(query, [u], u.is_suggested == ^bool)
   end
 
+  defp compose_query({:is_discoverable, bool}, query) do
+    where(query, [u], u.is_discoverable == ^bool)
+  end
+
   defp compose_query({:followers, %User{id: id}}, query) do
     query
     |> where([u], u.id != ^id)
index 8324ca22c80bcedace3bd1a2d1f8985d941c0945..7560969525f041506c629c488a700aa92cee0d92 100644 (file)
@@ -81,6 +81,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     if is_public?(object), do: User.decrease_note_count(actor), else: {:ok, actor}
   end
 
+  def update_last_status_at_if_public(actor, object) do
+    if is_public?(object), do: User.update_last_status_at(actor), else: {:ok, actor}
+  end
+
   defp increase_replies_count_if_reply(%{
          "object" => %{"inReplyTo" => reply_ap_id} = object,
          "type" => "Create"
@@ -288,6 +292,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          _ <- increase_replies_count_if_reply(create_data),
          {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
          {:ok, _actor} <- increase_note_count_if_public(actor, activity),
+         {:ok, _actor} <- update_last_status_at_if_public(actor, activity),
          _ <- notify_and_stream(activity),
          :ok <- maybe_schedule_poll_notifications(activity),
          :ok <- maybe_federate(activity) do
index d55a4b340c9a189f193928add8076bbe3c3dda65..39d37fbcb8210e982d43021b02c7325d6f5bdc5a 100644 (file)
@@ -199,6 +199,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
          %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do
       {:ok, notifications} = Notification.create_notifications(activity, do_send: false)
       {:ok, _user} = ActivityPub.increase_note_count_if_public(user, object)
+      {:ok, _user} = ActivityPub.update_last_status_at_if_public(user, object)
 
       if in_reply_to = object.data["type"] != "Answer" && object.data["inReplyTo"] do
         Object.increase_replies_count(in_reply_to)
diff --git a/lib/pleroma/web/api_spec/operations/directory_operation.ex b/lib/pleroma/web/api_spec/operations/directory_operation.ex
new file mode 100644 (file)
index 0000000..9be965f
--- /dev/null
@@ -0,0 +1,41 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.DirectoryOperation do
+  alias OpenApiSpex.Operation
+  alias Pleroma.Web.ApiSpec.AccountOperation
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Directory"],
+      summary: "Profile directory",
+      operationId: "DirectoryController.index",
+      parameters:
+        [
+          Operation.parameter(
+            :order,
+            :query,
+            :string,
+            "Order by recent activity or account creation",
+            required: nil
+          ),
+          Operation.parameter(:local, :query, BooleanLike, "Include local users only")
+        ] ++ pagination_params(),
+      responses: %{
+        200 =>
+          Operation.response("Accounts", "application/json", AccountOperation.array_of_accounts()),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex b/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex
new file mode 100644 (file)
index 0000000..45ef227
--- /dev/null
@@ -0,0 +1,82 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.DirectoryController do
+  use Pleroma.Web, :controller
+
+  import Ecto.Query
+  alias Pleroma.Pagination
+  alias Pleroma.User
+  alias Pleroma.UserRelationship
+  alias Pleroma.Web.MastodonAPI.AccountView
+
+  require Logger
+
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+  plug(:skip_auth when action == "index")
+
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DirectoryOperation
+
+  @doc "GET /api/v1/directory"
+  def index(%{assigns: %{user: user}} = conn, params) do
+    with true <- Pleroma.Config.get([:instance, :profile_directory]) do
+      limit = Map.get(params, :limit, 20) |> min(80)
+
+      users =
+        User.Query.build(%{is_discoverable: true, invisible: false, limit: limit})
+        |> order_by_creation_date(params)
+        |> exclude_remote(params)
+        |> exclude_user(user)
+        |> exclude_relationships(user, [:block, :mute])
+        |> Pagination.fetch_paginated(params, :offset)
+
+      conn
+      |> put_view(AccountView)
+      |> render("index.json", for: user, users: users, as: :user)
+    else
+      _ -> json(conn, [])
+    end
+  end
+
+  defp order_by_creation_date(query, %{order: "new"}) do
+    query
+  end
+
+  defp order_by_creation_date(query, _params) do
+    query
+    |> order_by([u], desc_nulls_last: u.last_status_at)
+  end
+
+  defp exclude_remote(query, %{local: true}) do
+    where(query, [u], u.local == true)
+  end
+
+  defp exclude_remote(query, _params) do
+    query
+  end
+
+  defp exclude_user(query, %User{id: user_id}) do
+    where(query, [u], u.id != ^user_id)
+  end
+
+  defp exclude_user(query, _user) do
+    query
+  end
+
+  defp exclude_relationships(query, %User{id: user_id}, relationship_types) do
+    query
+    |> join(:left, [u], r in UserRelationship,
+      as: :user_relationships,
+      on:
+        r.target_id == u.id and r.source_id == ^user_id and
+          r.relationship_type in ^relationship_types
+    )
+    |> where([user_relationships: r], is_nil(r.target_id))
+  end
+
+  defp exclude_relationships(query, _user, _relationship_types) do
+    query
+  end
+end
index 3c8dd03537b5d79860ffec4a0c9e58e733b43700..4b15b163582b824074c53d9a5cabac21dc9716fd 100644 (file)
@@ -270,6 +270,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do
           actor_type: user.actor_type
         }
       },
+      last_status_at: user.last_status_at,
 
       # Pleroma extensions
       # Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub
index ec7d150a97848603303d16ecee3950bd1d3c22ac..7072d5d61b1489a88e705496ef01a3f31afe823f 100644 (file)
@@ -87,6 +87,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       "pleroma_chat_messages",
       if Config.get([:instance, :show_reactions]) do
         "exposable_reactions"
+      end,
+      if Config.get([:instance, :profile_directory]) do
+        "profile_directory"
       end
     ]
     |> Enum.filter(& &1)
index 9ce35ad6bd597d68ead6fd7af1bc72b4b139bfbc..e3659b87aa0beb765cf397612d50a23d29670bab 100644 (file)
@@ -600,6 +600,8 @@ defmodule Pleroma.Web.Router do
     get("/timelines/tag/:tag", TimelineController, :hashtag)
 
     get("/polls/:id", PollController, :show)
+
+    get("/directory", DirectoryController, :index)
   end
 
   scope "/api/v2", Pleroma.Web.MastodonAPI do
diff --git a/priv/repo/migrations/20211222165256_add_last_status_at_to_users.exs b/priv/repo/migrations/20211222165256_add_last_status_at_to_users.exs
new file mode 100644 (file)
index 0000000..9061782
--- /dev/null
@@ -0,0 +1,11 @@
+defmodule Pleroma.Repo.Migrations.AddLastStatusAtToUsers do
+  use Ecto.Migration
+
+  def change do
+    alter table(:users) do
+      add(:last_status_at, :naive_datetime)
+    end
+
+    create_if_not_exists(index(:users, [:last_status_at]))
+  end
+end
diff --git a/priv/repo/migrations/20211225154802_add_is_discoverable_index_to_users.exs b/priv/repo/migrations/20211225154802_add_is_discoverable_index_to_users.exs
new file mode 100644 (file)
index 0000000..9f8f52b
--- /dev/null
@@ -0,0 +1,7 @@
+defmodule Pleroma.Repo.Migrations.AddIsDiscoverableIndexToUsers do
+  use Ecto.Migration
+
+  def change do
+    create(index(:users, [:is_discoverable]))
+  end
+end
diff --git a/test/pleroma/web/mastodon_api/controllers/directory_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/directory_controller_test.exs
new file mode 100644 (file)
index 0000000..b8f55f8
--- /dev/null
@@ -0,0 +1,46 @@
+defmodule Pleroma.Web.MastodonAPI.DirectoryControllerTest do
+  use Pleroma.Web.ConnCase, async: true
+  alias Pleroma.Web.CommonAPI
+  import Pleroma.Factory
+
+  test "GET /api/v1/directory with :profile_directory disabled returns empty array", %{conn: conn} do
+    clear_config([:instance, :profile_directory], false)
+
+    insert(:user, is_discoverable: true)
+    insert(:user, is_discoverable: true)
+
+    result =
+      conn
+      |> get("/api/v1/directory")
+      |> json_response_and_validate_schema(200)
+
+    assert result == []
+  end
+
+  test "GET /api/v1/directory returns discoverable users only", %{conn: conn} do
+    %{id: user_id} = insert(:user, is_discoverable: true)
+    insert(:user, is_discoverable: false)
+
+    result =
+      conn
+      |> get("/api/v1/directory")
+      |> json_response_and_validate_schema(200)
+
+    assert [%{"id" => ^user_id}] = result
+  end
+
+  test "GET /api/v1/directory returns users sorted by most recent statuses", %{conn: conn} do
+    insert(:user, is_discoverable: true)
+    %{id: user_id} = user = insert(:user, is_discoverable: true)
+    insert(:user, is_discoverable: true)
+
+    {:ok, _activity} = CommonAPI.post(user, %{status: "yay i'm discoverable"})
+
+    result =
+      conn
+      |> get("/api/v1/directory?order=active")
+      |> json_response_and_validate_schema(200)
+
+    assert [%{"id" => ^user_id} | _tail] = result
+  end
+end
index 39b9b0cef5278faaef45f70bd1c08284c3c37766..c23ffb966d38d0ce99f9679c147a16e0adda0d14 100644 (file)
@@ -74,6 +74,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         fields: []
       },
       fqn: "shp@shitposter.club",
+      last_status_at: nil,
       pleroma: %{
         ap_id: user.ap_id,
         also_known_as: ["https://shitposter.zone/users/shp"],
@@ -175,6 +176,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         fields: []
       },
       fqn: "shp@shitposter.club",
+      last_status_at: nil,
       pleroma: %{
         ap_id: user.ap_id,
         also_known_as: [],