Merge remote-tracking branch 'origin/develop' into pleroma-conversations
authorlain <lain@soykaf.club>
Wed, 14 Aug 2019 13:30:40 +0000 (15:30 +0200)
committerlain <lain@soykaf.club>
Wed, 14 Aug 2019 13:30:40 +0000 (15:30 +0200)
1  2 
CHANGELOG.md
lib/pleroma/user.ex
lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
lib/pleroma/web/mastodon_api/views/status_view.ex
lib/pleroma/web/router.ex

diff --combined CHANGELOG.md
index f8c90a73b67ef5c83e4bb413c7db6f0b2caa50f0,358287096295542b6c177faaf39e9daee4433412..2d6eac4c5d6e235767cf35fd84756c4660d75c5f
@@@ -23,9 -23,12 +23,12 @@@ The format is based on [Keep a Changelo
  - Not being able to pin unlisted posts
  - Objects being re-embedded to activities after being updated (e.g faved/reposted). Running 'mix pleroma.database prune_objects' again is advised.
  - Metadata rendering errors resulting in the entire page being inaccessible
+ - `federation_incoming_replies_max_depth` option being ignored in certain cases
  - Federation/MediaProxy not working with instances that have wrong certificate order
  - Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`)
  - Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity
+ - Mastodon API: follower/following counters not being nullified, when `hide_follows`/`hide_followers` is set
+ - Mastodon API: `muted` in the Status entity, using author's account to determine if the tread was muted
  - Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`)
  - Mastodon API, streaming: Fix filtering of notifications based on blocks/mutes/thread mutes
  - ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set
  - Invalid SemVer version generation, when the current branch does not have commits ahead of tag/checked out on a tag
  - Pleroma.Upload base_url was not automatically whitelisted by MediaProxy. Now your custom CDN or file hosting will be accessed directly as expected.
  - Report email not being sent to admins when the reporter is a remote user
+ - MRF: ensure that subdomain_match calls are case-insensitive
  
  ### Added
 +- Conversations: Add Pleroma-specific conversation endpoints and status posting extensions. Run the `bump_all_conversations` task again to create the necessary data.
+ - **Breaking:** MRF describe API, which adds support for exposing configuration information about MRF policies to NodeInfo.
+   Custom modules will need to be updated by adding, at the very least, `def describe, do: {:ok, %{}}` to the MRF policy modules.
  - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`)
  - MRF: Support for excluding specific domains from Transparency.
  - MRF: Support for filtering posts based on who they mention (`Pleroma.Web.ActivityPub.MRF.MentionPolicy`)
+ - MRF: Support for filtering posts based on ActivityStreams vocabulary (`Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`)
  - MRF (Simple Policy): Support for wildcard domains.
  - Support for wildcard domains in user domain blocks setting.
  - Configuration: `quarantined_instances` support wildcard domains.
@@@ -66,6 -72,7 +73,7 @@@
  - Added synchronization of following/followers counters for external users
  - Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`.
  - Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options.
+ - Configuration: `user_bio_length` and `user_name_length` options.
  - Addressable lists
  - Twitter API: added rate limit for `/api/account/password_reset` endpoint.
  - ActivityPub: Add an internal service actor for fetching ActivityPub objects.
@@@ -73,6 -80,8 +81,8 @@@
  - Admin API: Endpoint for fetching latest user's statuses
  - Pleroma API: Add `/api/v1/pleroma/accounts/confirmation_resend?email=<email>` for resending account confirmation.
  - Relays: Added a task to list relay subscriptions.
+ - Mix Tasks: `mix pleroma.database fix_likes_collections`
+ - Federation: Remove `likes` from objects.
  
  ### Changed
  - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text
@@@ -83,6 -92,7 +93,7 @@@
  ### Removed
  - Emoji: Remove longfox emojis.
  - Remove `Reply-To` header from report emails for admins.
+ - ActivityPub: The `accept_blocks` configuration setting.
  
  ## [1.0.1] - 2019-07-14
  ### Security
  - Rich media: Do not crawl private IP ranges
  
  ### Added
+ - Digest email for inactive users
  - Add a generic settings store for frontends / clients to use.
  - Explicit addressing option for posting.
  - Optional SSH access mode. (Needs `erlang-ssh` package on some distributions).
  - Configuration: `notify_email` option
  - Configuration: Media proxy `whitelist` option
  - Configuration: `report_uri` option
+ - Configuration: `email_notifications` option
  - Configuration: `limit_to_local_content` option
  - Pleroma API: User subscriptions
  - Pleroma API: Healthcheck endpoint
diff --combined lib/pleroma/user.ex
index 302adb1bc1f05edbf26185a7c723a77f98978bce,b67743846877596a4fffe812f3c76e173f7ea236..29712f25761fa4f7a9ca54349a2da3e65ce84420
@@@ -57,6 -57,7 +57,7 @@@ defmodule Pleroma.User d
      field(:search_type, :integer, virtual: true)
      field(:tags, {:array, :string}, default: [])
      field(:last_refreshed_at, :naive_datetime_usec)
+     field(:last_digest_emailed_at, :naive_datetime)
      has_many(:notifications, Notification)
      has_many(:registrations, Registration)
      embeds_one(:info, User.Info)
    end
  
    def remote_user_creation(params) do
-     params =
-       params
-       |> Map.put(:info, params[:info] || %{})
+     bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+     name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
  
+     params = Map.put(params, :info, params[:info] || %{})
      info_cng = User.Info.remote_user_creation(%User.Info{}, params[:info])
  
      changes =
        |> validate_required([:name, :ap_id])
        |> unique_constraint(:nickname)
        |> validate_format(:nickname, @email_regex)
-       |> validate_length(:bio, max: 5000)
-       |> validate_length(:name, max: 100)
+       |> validate_length(:bio, max: bio_limit)
+       |> validate_length(:name, max: name_limit)
        |> put_change(:local, false)
        |> put_embed(:info, info_cng)
  
    end
  
    def update_changeset(struct, params \\ %{}) do
+     bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+     name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
      struct
      |> cast(params, [:bio, :name, :avatar, :following])
      |> unique_constraint(:nickname)
      |> validate_format(:nickname, local_nickname_regex())
-     |> validate_length(:bio, max: 5000)
-     |> validate_length(:name, min: 1, max: 100)
+     |> validate_length(:bio, max: bio_limit)
+     |> validate_length(:name, min: 1, max: name_limit)
    end
  
    def upgrade_changeset(struct, params \\ %{}) do
-     params =
-       params
-       |> Map.put(:last_refreshed_at, NaiveDateTime.utc_now())
+     bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+     name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
  
-     info_cng =
-       struct.info
-       |> User.Info.user_upgrade(params[:info])
+     params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now())
+     info_cng = User.Info.user_upgrade(struct.info, params[:info])
  
      struct
      |> cast(params, [
      ])
      |> unique_constraint(:nickname)
      |> validate_format(:nickname, local_nickname_regex())
-     |> validate_length(:bio, max: 5000)
-     |> validate_length(:name, max: 100)
+     |> validate_length(:bio, max: bio_limit)
+     |> validate_length(:name, max: name_limit)
      |> put_embed(:info, info_cng)
    end
  
    end
  
    def register_changeset(struct, params \\ %{}, opts \\ []) do
+     bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+     name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
      need_confirmation? =
        if is_nil(opts[:need_confirmation]) do
          Pleroma.Config.get([:instance, :account_activation_required])
        |> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames]))
        |> validate_format(:nickname, local_nickname_regex())
        |> validate_format(:email, @email_regex)
-       |> validate_length(:bio, max: 1000)
-       |> validate_length(:name, min: 1, max: 100)
+       |> validate_length(:bio, max: bio_limit)
+       |> validate_length(:name, min: 1, max: name_limit)
        |> put_change(:info, info_change)
  
      changeset =
      Repo.get_by(User, ap_id: ap_id)
    end
  
 +  def get_all_by_ap_id(ap_ids) do
 +    from(u in __MODULE__,
 +      where: u.ap_id in ^ap_ids
 +    )
 +    |> Repo.all()
 +  end
 +
    # This is mostly an SPC migration fix. This guesses the user nickname by taking the last part
    # of the ap_id and the domain and tries to get that user
    def get_by_guessed_nickname(ap_id) do
      target.ap_id not in user.info.muted_reblogs
    end
  
+   @doc """
+   The function returns a query to get users with no activity for given interval of days.
+   Inactive users are those who didn't read any notification, or had any activity where
+   the user is the activity's actor, during `inactivity_threshold` days.
+   Deactivated users will not appear in this list.
+   ## Examples
+       iex> Pleroma.User.list_inactive_users()
+       %Ecto.Query{}
+   """
+   @spec list_inactive_users_query(integer()) :: Ecto.Query.t()
+   def list_inactive_users_query(inactivity_threshold \\ 7) do
+     negative_inactivity_threshold = -inactivity_threshold
+     now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+     # Subqueries are not supported in `where` clauses, join gets too complicated.
+     has_read_notifications =
+       from(n in Pleroma.Notification,
+         where: n.seen == true,
+         group_by: n.id,
+         having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"),
+         select: n.user_id
+       )
+       |> Pleroma.Repo.all()
+     from(u in Pleroma.User,
+       left_join: a in Pleroma.Activity,
+       on: u.ap_id == a.actor,
+       where: not is_nil(u.nickname),
+       where: fragment("not (?->'deactivated' @> 'true')", u.info),
+       where: u.id not in ^has_read_notifications,
+       group_by: u.id,
+       having:
+         max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or
+           is_nil(max(a.inserted_at))
+     )
+   end
+   @doc """
+   Enable or disable email notifications for user
+   ## Examples
+       iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => false}}}, "digest", true)
+       Pleroma.User{info: %{email_notifications: %{"digest" => true}}}
+       iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => true}}}, "digest", false)
+       Pleroma.User{info: %{email_notifications: %{"digest" => false}}}
+   """
+   @spec switch_email_notifications(t(), String.t(), boolean()) ::
+           {:ok, t()} | {:error, Ecto.Changeset.t()}
+   def switch_email_notifications(user, type, status) do
+     info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status})
+     change(user)
+     |> put_embed(:info, info)
+     |> update_and_set_cache()
+   end
+   @doc """
+   Set `last_digest_emailed_at` value for the user to current time
+   """
+   @spec touch_last_digest_emailed_at(t()) :: t()
+   def touch_last_digest_emailed_at(user) do
+     now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+     {:ok, updated_user} =
+       user
+       |> change(%{last_digest_emailed_at: now})
+       |> update_and_set_cache()
+     updated_user
+   end
    @spec toggle_confirmation(User.t()) :: {:ok, User.t()} | {:error, Changeset.t()}
    def toggle_confirmation(%User{} = user) do
      need_confirmation? = !user.info.confirmation_pending
index eb2351eb7d63fef2e1791ee7196772f1fd2db1a9,7ce2b5b0608d2c2059dd7179a67dea79d4cd4d35..8fe7be8beaca8dd8c400a6f3877d55c7b2275157
@@@ -5,8 -5,7 +5,8 @@@
  defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
    use Pleroma.Web, :controller
  
 -  import Pleroma.Web.ControllerHelper, only: [json_response: 3]
 +  import Pleroma.Web.ControllerHelper,
 +    only: [json_response: 3, add_link_headers: 5, add_link_headers: 4, add_link_headers: 3]
  
    alias Ecto.Changeset
    alias Pleroma.Activity
      json(conn, mastodon_emoji)
    end
  
 -  defp add_link_headers(conn, method, activities, param \\ nil, params \\ %{}) do
 -    params =
 -      conn.params
 -      |> Map.drop(["since_id", "max_id", "min_id"])
 -      |> Map.merge(params)
 -
 -    last = List.last(activities)
 -
 -    if last do
 -      max_id = last.id
 -
 -      limit =
 -        params
 -        |> Map.get("limit", "20")
 -        |> String.to_integer()
 -
 -      min_id =
 -        if length(activities) <= limit do
 -          activities
 -          |> List.first()
 -          |> Map.get(:id)
 -        else
 -          activities
 -          |> Enum.at(limit * -1)
 -          |> Map.get(:id)
 -        end
 -
 -      {next_url, prev_url} =
 -        if param do
 -          {
 -            mastodon_api_url(
 -              Pleroma.Web.Endpoint,
 -              method,
 -              param,
 -              Map.merge(params, %{max_id: max_id})
 -            ),
 -            mastodon_api_url(
 -              Pleroma.Web.Endpoint,
 -              method,
 -              param,
 -              Map.merge(params, %{min_id: min_id})
 -            )
 -          }
 -        else
 -          {
 -            mastodon_api_url(
 -              Pleroma.Web.Endpoint,
 -              method,
 -              Map.merge(params, %{max_id: max_id})
 -            ),
 -            mastodon_api_url(
 -              Pleroma.Web.Endpoint,
 -              method,
 -              Map.merge(params, %{min_id: min_id})
 -            )
 -          }
 -        end
 -
 -      conn
 -      |> put_resp_header("link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"")
 -    else
 -      conn
 -    end
 -  end
 -
    def home_timeline(%{assigns: %{user: user}} = conn, params) do
      params =
        params
        |> Map.put("local_only", local_only)
        |> Map.put("blocking_user", user)
        |> Map.put("muting_user", user)
+       |> Map.put("user", user)
        |> ActivityPub.fetch_public_activities()
        |> Enum.reverse()
  
           activities <-
             ActivityPub.fetch_activities_for_context(activity.data["context"], %{
               "blocking_user" => user,
-              "user" => user
+              "user" => user,
+              "exclude_id" => activity.id
             }),
-          activities <-
-            activities |> Enum.filter(fn %{id: aid} -> to_string(aid) != to_string(id) end),
-          activities <-
-            activities |> Enum.filter(fn %{data: %{"type" => type}} -> type == "Create" end),
           grouped_activities <- Enum.group_by(activities, fn %{id: id} -> id < activity.id end) do
        result = %{
          ancestors:
        |> put_view(StatusView)
        |> try_render("poll.json", %{object: object, for: user})
      else
-       nil -> render_error(conn, :not_found, "Record not found")
-       false -> render_error(conn, :not_found, "Record not found")
+       error when is_nil(error) or error == false ->
+         render_error(conn, :not_found, "Record not found")
      end
    end
  
    end
  
    def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do
-     with %Activity{data: %{"object" => object}} <- Activity.get_by_id(id),
-          %Object{data: %{"likes" => likes}} <- Object.normalize(object) do
+     with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+          %Object{data: %{"likes" => likes}} <- Object.normalize(activity) do
        q = from(u in User, where: u.ap_id in ^likes)
  
        users =
    end
  
    def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do
-     with %Activity{data: %{"object" => object}} <- Activity.get_by_id(id),
-          %Object{data: %{"announcements" => announces}} <- Object.normalize(object) do
+     with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+          %Object{data: %{"announcements" => announces}} <- Object.normalize(activity) do
        q = from(u in User, where: u.ap_id in ^announces)
  
        users =
        |> Map.put("local_only", local_only)
        |> Map.put("blocking_user", user)
        |> Map.put("muting_user", user)
+       |> Map.put("user", user)
        |> Map.put("tag", tags)
        |> Map.put("tag_all", tag_all)
        |> Map.put("tag_reject", tag_reject)
          params
          |> Map.put("type", "Create")
          |> Map.put("blocking_user", user)
+         |> Map.put("user", user)
          |> Map.put("muting_user", user)
  
        # we must filter the following list for the user to avoid leaking statuses the user
          |> String.replace("{{user}}", user)
  
        with {:ok, %{status: 200, body: body}} <-
-              HTTP.get(
-                url,
-                [],
-                adapter: [
-                  recv_timeout: timeout,
-                  pool: :default
-                ]
-              ),
+              HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
             {:ok, data} <- Jason.decode(body) do
          data =
            data
            |> Enum.slice(0, limit)
            |> Enum.map(fn x ->
-             Map.put(
-               x,
-               "id",
-               case User.get_or_fetch(x["acct"]) do
-                 {:ok, %User{id: id}} -> id
-                 _ -> 0
-               end
-             )
-           end)
-           |> Enum.map(fn x ->
-             Map.put(x, "avatar", MediaProxy.url(x["avatar"]))
-           end)
-           |> Enum.map(fn x ->
-             Map.put(x, "avatar_static", MediaProxy.url(x["avatar_static"]))
+             x
+             |> Map.put("id", fetch_suggestion_id(x))
+             |> Map.put("avatar", MediaProxy.url(x["avatar"]))
+             |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
            end)
  
-         conn
-         |> json(data)
+         json(conn, data)
        else
-         e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
+         e ->
+           Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
        end
      else
        json(conn, [])
      end
+   end
+   defp fetch_suggestion_id(attrs) do
+     case User.get_or_fetch(attrs["acct"]) do
+       {:ok, %User{id: id}} -> id
+       _ -> 0
+     end
    end
  
    def status_card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do
  
      conversations =
        Enum.map(participations, fn participation ->
 -        ConversationView.render("participation.json", %{participation: participation, user: user})
 +        ConversationView.render("participation.json", %{participation: participation, for: user})
        end)
  
      conn
             Repo.get_by(Participation, id: participation_id, user_id: user.id),
           {:ok, participation} <- Participation.mark_as_read(participation) do
        participation_view =
 -        ConversationView.render("participation.json", %{participation: participation, user: user})
 +        ConversationView.render("participation.json", %{participation: participation, for: user})
  
        conn
        |> json(participation_view)
index a862554b1d4b1f85b1ec4eff76231bfae0b5caa0,492af170206bbdc3256901e3f397676b5bb95baf..d3aea2aaf8861c243fe302ec8f6b326cb78c3ed4
@@@ -5,9 -5,9 +5,11 @@@
  defmodule Pleroma.Web.MastodonAPI.StatusView do
    use Pleroma.Web, :view
  
+   require Pleroma.Constants
    alias Pleroma.Activity
 +  alias Pleroma.Conversation
 +  alias Pleroma.Conversation.Participation
    alias Pleroma.HTML
    alias Pleroma.Object
    alias Pleroma.Repo
    defp get_replied_to_activities(activities) do
      activities
      |> Enum.map(fn
-       %{data: %{"type" => "Create", "object" => object}} ->
-         object = Object.normalize(object)
-         object.data["inReplyTo"] != "" && object.data["inReplyTo"]
+       %{data: %{"type" => "Create"}} = activity ->
+         object = Object.normalize(activity)
+         object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
  
        _ ->
          nil
      end)
      |> Enum.filter(& &1)
-     |> Activity.create_by_object_ap_id()
+     |> Activity.create_by_object_ap_id_with_object()
      |> Repo.all()
      |> Enum.reduce(%{}, fn activity, acc ->
        object = Object.normalize(activity)
-       Map.put(acc, object.data["id"], activity)
+       if object, do: Map.put(acc, object.data["id"], activity), else: acc
      end)
    end
  
@@@ -90,6 -90,7 +92,7 @@@
      reblogged_activity =
        Activity.create_by_object_ap_id(activity_object.data["id"])
        |> Activity.with_preloaded_bookmark(opts[:for])
+       |> Activity.with_set_thread_muted_field(opts[:for])
        |> Repo.one()
  
      reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity))
      object = Object.normalize(activity)
  
      user = get_user(activity.data["actor"])
+     user_follower_address = user.follower_address
  
      like_count = object.data["like_count"] || 0
      announcement_count = object.data["announcement_count"] || 0
      mentions =
        (object.data["to"] ++ tag_mentions)
        |> Enum.uniq()
-       |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
+       |> Enum.map(fn
+         Pleroma.Constants.as_public() -> nil
+         ^user_follower_address -> nil
+         ap_id -> User.get_cached_by_ap_id(ap_id)
+       end)
        |> Enum.filter(& &1)
        |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
  
      thread_muted? =
        case activity.thread_muted? do
          thread_muted? when is_boolean(thread_muted?) -> thread_muted?
-         nil -> CommonAPI.thread_muted?(user, activity)
+         nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
        end
  
      attachment_data = object.data["attachment"] || []
          object.data["url"] || object.data["external_url"] || object.data["id"]
        end
  
 +    direct_conversation_id =
 +      with {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
 +           {_, %User{} = for_user} <- {:for_user, opts[:for]},
 +           %{data: %{"context" => context}} when is_binary(context) <- activity,
 +           %Conversation{} = conversation <- Conversation.get_for_ap_id(context),
 +           %Participation{id: participation_id} <-
 +             Participation.for_user_and_conversation(for_user, conversation) do
 +        participation_id
 +      else
 +        _e ->
 +          nil
 +      end
 +
      %{
        id: to_string(activity.id),
        uri: object.data["id"],
          conversation_id: get_context_id(activity),
          in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
          content: %{"text/plain" => content_plaintext},
 -        spoiler_text: %{"text/plain" => summary_plaintext}
 +        spoiler_text: %{"text/plain" => summary_plaintext},
 +        direct_conversation_id: direct_conversation_id
        }
      }
    end
index f0b6a02e98a40ef9a1ebbffc14df1cbbeb1cdf88,c8c1c22dd3c66b9e9484c73c09e167f130f4fe6e..9759268f9ac37e4859461005915bd0180539f239
@@@ -259,17 -259,6 +259,17 @@@ defmodule Pleroma.Web.Router d
      end
    end
  
 +  scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
 +    pipe_through(:authenticated_api)
 +
 +    scope [] do
 +      pipe_through(:oauth_write)
 +      get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)
 +      get("/conversations/:id", PleromaAPIController, :conversation)
 +      patch("/conversations/:id", PleromaAPIController, :update_conversation)
 +    end
 +  end
 +
    scope "/api/v1", Pleroma.Web.MastodonAPI do
      pipe_through(:authenticated_api)
  
      post("/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request)
      get("/push/subscriptions/:id", Websub.WebsubController, :websub_subscription_confirmation)
      post("/push/subscriptions/:id", Websub.WebsubController, :websub_incoming)
+     get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
    end
  
    pipeline :activitypub do