From de006443f0bc8cfb3ad28b29b2d8ea9581e760b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= <me@mkljczk.pl>
Date: Sun, 26 Dec 2021 02:35:17 +0000
Subject: [PATCH] MastoAPI: Profile directory

---
 config/config.exs                             |  3 +-
 config/description.exs                        |  5 ++
 .../API/differences_in_mastoapi_responses.md  |  6 --
 lib/pleroma/user.ex                           | 13 +++
 lib/pleroma/user/query.ex                     |  5 ++
 lib/pleroma/web/activity_pub/activity_pub.ex  |  5 ++
 lib/pleroma/web/activity_pub/side_effects.ex  |  1 +
 .../operations/directory_operation.ex         | 41 ++++++++++
 .../controllers/directory_controller.ex       | 82 +++++++++++++++++++
 .../web/mastodon_api/views/account_view.ex    |  1 +
 .../web/mastodon_api/views/instance_view.ex   |  3 +
 lib/pleroma/web/router.ex                     |  2 +
 ...1222165256_add_last_status_at_to_users.exs | 11 +++
 ...802_add_is_discoverable_index_to_users.exs |  7 ++
 .../controllers/directory_controller_test.exs | 46 +++++++++++
 .../mastodon_api/views/account_view_test.exs  |  2 +
 16 files changed, 226 insertions(+), 7 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/directory_operation.ex
 create mode 100644 lib/pleroma/web/mastodon_api/controllers/directory_controller.ex
 create mode 100644 priv/repo/migrations/20211222165256_add_last_status_at_to_users.exs
 create mode 100644 priv/repo/migrations/20211225154802_add_is_discoverable_index_to_users.exs
 create mode 100644 test/pleroma/web/mastodon_api/controllers/directory_controller_test.exs

diff --git a/config/config.exs b/config/config.exs
index c9592511f..23c41eddd 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -254,7 +254,8 @@
     ]
   ],
   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: [
diff --git a/config/description.exs b/config/description.exs
index 1c8c3b4a0..517077acf 100644
--- a/config/description.exs
+++ b/config/description.exs
@@ -936,6 +936,11 @@
         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."
       }
     ]
   },
diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md
index 6c1ecb559..518aca114 100644
--- a/docs/development/API/differences_in_mastoapi_responses.md
+++ b/docs/development/API/differences_in_mastoapi_responses.md
@@ -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*
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
index c25023dc1..390de1e2d 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -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 @@ def active_user_count(days \\ 30) 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
diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex
index 6d4a4ead6..bf78cb32d 100644
--- a/lib/pleroma/user/query.ex
+++ b/lib/pleroma/user/query.ex
@@ -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 @@ defp compose_query({:is_suggested, bool}, 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)
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index 8324ca22c..756096952 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -81,6 +81,10 @@ def decrease_note_count_if_public(actor, object) 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 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
          _ <- 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
diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index d55a4b340..39d37fbcb 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -199,6 +199,7 @@ def handle(%{data: %{"type" => "Create"}} = activity, meta) 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
index 000000000..9be965feb
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/directory_operation.ex
@@ -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
index 000000000..45ef227fb
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex
@@ -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
diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex
index 3c8dd0353..4b15b1635 100644
--- a/lib/pleroma/web/mastodon_api/views/account_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/account_view.ex
@@ -270,6 +270,7 @@ defp do_render("show.json", %{user: user} = opts) 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
diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex
index ec7d150a9..7072d5d61 100644
--- a/lib/pleroma/web/mastodon_api/views/instance_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex
@@ -87,6 +87,9 @@ def features 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)
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 9ce35ad6b..e3659b87a 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -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
index 000000000..906178216
--- /dev/null
+++ b/priv/repo/migrations/20211222165256_add_last_status_at_to_users.exs
@@ -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
index 000000000..9f8f52b65
--- /dev/null
+++ b/priv/repo/migrations/20211225154802_add_is_discoverable_index_to_users.exs
@@ -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
index 000000000..b8f55f832
--- /dev/null
+++ b/test/pleroma/web/mastodon_api/controllers/directory_controller_test.exs
@@ -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
diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs
index 39b9b0cef..c23ffb966 100644
--- a/test/pleroma/web/mastodon_api/views/account_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs
@@ -74,6 +74,7 @@ test "Represent a user account" 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 @@ test "Represent a Service(bot) account" do
         fields: []
       },
       fqn: "shp@shitposter.club",
+      last_status_at: nil,
       pleroma: %{
         ap_id: user.ap_id,
         also_known_as: [],