aboutsummaryrefslogtreecommitdiff
path: root/lib/pleroma/web/mastodon_api
diff options
context:
space:
mode:
authordcc <dcc@logografos.com>2023-09-02 00:52:52 -0700
committerdcc <dcc@logografos.com>2023-09-02 00:52:52 -0700
commit3a4773c3c2bd0bbef244eb519b07208da9108e49 (patch)
tree973567a6f3abb37bfb0f785b1cad14ed55840ef5 /lib/pleroma/web/mastodon_api
downloadanni-3a4773c3c2bd0bbef244eb519b07208da9108e49.tar.gz
anni-3a4773c3c2bd0bbef244eb519b07208da9108e49.tar.bz2
anni-3a4773c3c2bd0bbef244eb519b07208da9108e49.zip
First
Diffstat (limited to 'lib/pleroma/web/mastodon_api')
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/account_controller.ex579
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex60
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/app_controller.ex57
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/auth_controller.ex24
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex48
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex17
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/directory_controller.ex82
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex50
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex44
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/filter_controller.ex86
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex59
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/instance_controller.ex23
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/list_controller.ex99
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/marker_controller.ex38
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex31
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/media_controller.ex80
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/notification_controller.ex112
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/poll_controller.ex67
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/report_controller.ex21
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex61
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/search_controller.ex194
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/status_controller.ex498
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex77
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex120
-rw-r--r--lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex210
-rw-r--r--lib/pleroma/web/mastodon_api/mastodon_api.ex121
-rw-r--r--lib/pleroma/web/mastodon_api/views/account_view.ex467
-rw-r--r--lib/pleroma/web/mastodon_api/views/announcement_view.ex15
-rw-r--r--lib/pleroma/web/mastodon_api/views/app_view.ex50
-rw-r--r--lib/pleroma/web/mastodon_api/views/conversation_view.ex58
-rw-r--r--lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex28
-rw-r--r--lib/pleroma/web/mastodon_api/views/filter_view.ex31
-rw-r--r--lib/pleroma/web/mastodon_api/views/follow_request_view.ex10
-rw-r--r--lib/pleroma/web/mastodon_api/views/instance_view.ex142
-rw-r--r--lib/pleroma/web/mastodon_api/views/list_view.ex19
-rw-r--r--lib/pleroma/web/mastodon_api/views/marker_view.ex21
-rw-r--r--lib/pleroma/web/mastodon_api/views/media_view.ex10
-rw-r--r--lib/pleroma/web/mastodon_api/views/notification_view.ex176
-rw-r--r--lib/pleroma/web/mastodon_api/views/poll_view.ex102
-rw-r--r--lib/pleroma/web/mastodon_api/views/report_view.ex14
-rw-r--r--lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex45
-rw-r--r--lib/pleroma/web/mastodon_api/views/status_view.ex757
-rw-r--r--lib/pleroma/web/mastodon_api/views/subscription_view.ex19
-rw-r--r--lib/pleroma/web/mastodon_api/views/suggestion_view.ex28
-rw-r--r--lib/pleroma/web/mastodon_api/views/timeline_view.ex10
-rw-r--r--lib/pleroma/web/mastodon_api/websocket_handler.ex140
46 files changed, 5000 insertions, 0 deletions
diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex
new file mode 100644
index 0000000..ea6e593
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex
@@ -0,0 +1,579 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.AccountController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper,
+ only: [
+ add_link_headers: 2,
+ assign_account_by_id: 2,
+ embed_relationships?: 1,
+ json_response: 3
+ ]
+
+ alias Pleroma.Maps
+ alias Pleroma.User
+ alias Pleroma.UserNote
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.Builder
+ alias Pleroma.Web.ActivityPub.Pipeline
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.MastodonAPI.ListView
+ alias Pleroma.Web.MastodonAPI.MastodonAPI
+ alias Pleroma.Web.MastodonAPI.MastodonAPIController
+ alias Pleroma.Web.MastodonAPI.StatusView
+ alias Pleroma.Web.OAuth.OAuthController
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+ alias Pleroma.Web.Plugs.RateLimiter
+ alias Pleroma.Web.TwitterAPI.TwitterAPI
+ alias Pleroma.Web.Utils.Params
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(:skip_auth when action in [:create, :lookup])
+
+ plug(:skip_public_check when action in [:show, :statuses])
+
+ plug(
+ OAuthScopesPlug,
+ %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
+ when action in [:show, :followers, :following]
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]}
+ when action == :statuses
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["read:accounts"]}
+ when action in [:verify_credentials, :endorsements, :identity_proofs]
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["write:accounts"]}
+ when action in [:update_credentials, :note, :endorse, :unendorse]
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["follow", "read:blocks"]} when action == :blocks
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock]
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["follow", "write:follows"]}
+ when action in [:follow_by_uri, :follow, :unfollow, :remove_from_followers]
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes)
+
+ plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute])
+
+ @relationship_actions [:follow, :unfollow, :remove_from_followers]
+ @needs_account ~W(
+ followers following lists follow unfollow mute unmute block unblock
+ note endorse unendorse remove_from_followers
+ )a
+
+ plug(
+ RateLimiter,
+ [name: :relation_id_action, params: [:id, :uri]] when action in @relationship_actions
+ )
+
+ plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions)
+ plug(RateLimiter, [name: :app_account_creation] when action == :create)
+ plug(:assign_account_by_id when action in @needs_account)
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation
+
+ @doc "POST /api/v1/accounts"
+ def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do
+ with :ok <- validate_email_param(params),
+ :ok <- TwitterAPI.validate_captcha(app, params),
+ {:ok, user} <- TwitterAPI.register_user(params),
+ {_, {:ok, token}} <-
+ {:login, OAuthController.login(user, app, app.scopes)} do
+ OAuthController.after_token_exchange(conn, %{user: user, token: token})
+ else
+ {:login, {:account_status, :confirmation_pending}} ->
+ json_response(conn, :ok, %{
+ message: "You have been registered. Please check your email for further instructions.",
+ identifier: "missing_confirmed_email"
+ })
+
+ {:login, {:account_status, :approval_pending}} ->
+ json_response(conn, :ok, %{
+ message:
+ "You have been registered. You'll be able to log in once your account is approved.",
+ identifier: "awaiting_approval"
+ })
+
+ {:login, _} ->
+ json_response(conn, :ok, %{
+ message:
+ "You have been registered. Some post-registration steps may be pending. " <>
+ "Please log in manually.",
+ identifier: "manual_login_required"
+ })
+
+ {:error, error} ->
+ json_response(conn, :bad_request, %{error: error})
+ end
+ end
+
+ def create(%{assigns: %{app: _app}} = conn, _) do
+ render_error(conn, :bad_request, "Missing parameters")
+ end
+
+ def create(conn, _) do
+ render_error(conn, :forbidden, "Invalid credentials")
+ end
+
+ defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok
+
+ defp validate_email_param(_) do
+ case Pleroma.Config.get([:instance, :account_activation_required]) do
+ true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")}
+ _ -> :ok
+ end
+ end
+
+ @doc "GET /api/v1/accounts/verify_credentials"
+ def verify_credentials(%{assigns: %{user: user}} = conn, _) do
+ chat_token = Phoenix.Token.sign(conn, "user socket", user.id)
+
+ render(conn, "show.json",
+ user: user,
+ for: user,
+ with_pleroma_settings: true,
+ with_chat_token: chat_token
+ )
+ end
+
+ @doc "PATCH /api/v1/accounts/update_credentials"
+ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _params) do
+ params =
+ params
+ |> Enum.filter(fn {_, value} -> not is_nil(value) end)
+ |> Enum.into(%{})
+
+ # We use an empty string as a special value to reset
+ # avatars, banners, backgrounds
+ user_image_value = fn
+ "" -> {:ok, nil}
+ value -> {:ok, value}
+ end
+
+ user_params =
+ [
+ :no_rich_text,
+ :hide_followers_count,
+ :hide_follows_count,
+ :hide_followers,
+ :hide_follows,
+ :hide_favorites,
+ :show_role,
+ :skip_thread_containment,
+ :allow_following_move,
+ :also_known_as,
+ :accepts_chat_messages,
+ :show_birthday
+ ]
+ |> Enum.reduce(%{}, fn key, acc ->
+ Maps.put_if_present(acc, key, params[key], &{:ok, Params.truthy_param?(&1)})
+ end)
+ |> Maps.put_if_present(:name, params[:display_name])
+ |> Maps.put_if_present(:bio, params[:note])
+ |> Maps.put_if_present(:raw_bio, params[:note])
+ |> Maps.put_if_present(:avatar, params[:avatar], user_image_value)
+ |> Maps.put_if_present(:banner, params[:header], user_image_value)
+ |> Maps.put_if_present(:background, params[:pleroma_background_image], user_image_value)
+ |> Maps.put_if_present(
+ :raw_fields,
+ params[:fields_attributes],
+ &{:ok, normalize_fields_attributes(&1)}
+ )
+ |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store])
+ |> Maps.put_if_present(:default_scope, params[:default_scope])
+ |> Maps.put_if_present(:default_scope, params["source"]["privacy"])
+ |> Maps.put_if_present(:actor_type, params[:bot], fn bot ->
+ if bot, do: {:ok, "Service"}, else: {:ok, "Person"}
+ end)
+ |> Maps.put_if_present(:actor_type, params[:actor_type])
+ |> Maps.put_if_present(:also_known_as, params[:also_known_as])
+ # Note: param name is indeed :locked (not an error)
+ |> Maps.put_if_present(:is_locked, params[:locked])
+ # Note: param name is indeed :discoverable (not an error)
+ |> Maps.put_if_present(:is_discoverable, params[:discoverable])
+ |> Maps.put_if_present(:birthday, params[:birthday])
+ |> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language]))
+
+ # What happens here:
+ #
+ # We want to update the user through the pipeline, but the ActivityPub
+ # update information is not quite enough for this, because this also
+ # contains local settings that don't federate and don't even appear
+ # in the Update activity.
+ #
+ # So we first build the normal local changeset, then apply it to the
+ # user data, but don't persist it. With this, we generate the object
+ # data for our update activity. We feed this and the changeset as meta
+ # inforation into the pipeline, where they will be properly updated and
+ # federated.
+ with changeset <- User.update_changeset(user, user_params),
+ {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update),
+ updated_object <-
+ Pleroma.Web.ActivityPub.UserView.render("user.json", user: unpersisted_user)
+ |> Map.delete("@context"),
+ {:ok, update_data, []} <- Builder.update(user, updated_object),
+ {:ok, _update, _} <-
+ Pipeline.common_pipeline(update_data,
+ local: true,
+ user_update_changeset: changeset
+ ) do
+ render(conn, "show.json",
+ user: unpersisted_user,
+ for: unpersisted_user,
+ with_pleroma_settings: true
+ )
+ else
+ {:error, %Ecto.Changeset{errors: [avatar: {"file is too large", _}]}} ->
+ render_error(conn, :request_entity_too_large, "File is too large")
+
+ {:error, %Ecto.Changeset{errors: [banner: {"file is too large", _}]}} ->
+ render_error(conn, :request_entity_too_large, "File is too large")
+
+ {:error, %Ecto.Changeset{errors: [background: {"file is too large", _}]}} ->
+ render_error(conn, :request_entity_too_large, "File is too large")
+
+ _e ->
+ render_error(conn, :forbidden, "Invalid request")
+ end
+ end
+
+ defp normalize_fields_attributes(fields) do
+ if Enum.all?(fields, &is_tuple/1) do
+ Enum.map(fields, fn {_, v} -> v end)
+ else
+ Enum.map(fields, fn
+ %{} = field -> %{"name" => field.name, "value" => field.value}
+ field -> field
+ end)
+ end
+ end
+
+ @doc "GET /api/v1/accounts/relationships"
+ def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do
+ targets = User.get_all_by_ids(List.wrap(id))
+
+ render(conn, "relationships.json", user: user, targets: targets)
+ end
+
+ # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
+ def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, [])
+
+ @doc "GET /api/v1/accounts/:id"
+ def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id} = params) do
+ with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user),
+ :visible <- User.visible_for(user, for_user) do
+ render(conn, "show.json",
+ user: user,
+ for: for_user,
+ embed_relationships: embed_relationships?(params)
+ )
+ else
+ error -> user_visibility_error(conn, error)
+ end
+ end
+
+ @doc "GET /api/v1/accounts/:id/statuses"
+ def statuses(%{assigns: %{user: reading_user}} = conn, params) do
+ with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user),
+ :visible <- User.visible_for(user, reading_user) do
+ params =
+ params
+ |> Map.delete(:tagged)
+ |> Map.put(:tag, params[:tagged])
+
+ activities = ActivityPub.fetch_user_activities(user, reading_user, params)
+
+ conn
+ |> add_link_headers(activities)
+ |> put_view(StatusView)
+ |> render("index.json",
+ activities: activities,
+ for: reading_user,
+ as: :activity,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ else
+ error -> user_visibility_error(conn, error)
+ end
+ end
+
+ defp user_visibility_error(conn, error) do
+ case error do
+ :restrict_unauthenticated ->
+ render_error(conn, :unauthorized, "This API requires an authenticated user")
+
+ _ ->
+ render_error(conn, :not_found, "Can't find user")
+ end
+ end
+
+ @doc "GET /api/v1/accounts/:id/followers"
+ def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do
+ params =
+ params
+ |> Enum.map(fn {key, value} -> {to_string(key), value} end)
+ |> Enum.into(%{})
+
+ followers =
+ cond do
+ for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params)
+ user.hide_followers -> []
+ true -> MastodonAPI.get_followers(user, params)
+ end
+
+ conn
+ |> add_link_headers(followers)
+ # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
+ |> render("index.json",
+ for: for_user,
+ users: followers,
+ as: :user,
+ embed_relationships: embed_relationships?(params)
+ )
+ end
+
+ @doc "GET /api/v1/accounts/:id/following"
+ def following(%{assigns: %{user: for_user, account: user}} = conn, params) do
+ params =
+ params
+ |> Enum.map(fn {key, value} -> {to_string(key), value} end)
+ |> Enum.into(%{})
+
+ followers =
+ cond do
+ for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params)
+ user.hide_follows -> []
+ true -> MastodonAPI.get_friends(user, params)
+ end
+
+ conn
+ |> add_link_headers(followers)
+ # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
+ |> render("index.json",
+ for: for_user,
+ users: followers,
+ as: :user,
+ embed_relationships: embed_relationships?(params)
+ )
+ end
+
+ @doc "GET /api/v1/accounts/:id/lists"
+ def lists(%{assigns: %{user: user, account: account}} = conn, _params) do
+ lists = Pleroma.List.get_lists_account_belongs(user, account)
+
+ conn
+ |> put_view(ListView)
+ |> render("index.json", lists: lists)
+ end
+
+ @doc "POST /api/v1/accounts/:id/follow"
+ def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
+ {:error, "Can not follow yourself"}
+ end
+
+ def follow(%{body_params: params, assigns: %{user: follower, account: followed}} = conn, _) do
+ with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do
+ render(conn, "relationship.json", user: follower, target: followed)
+ else
+ {:error, message} -> json_response(conn, :forbidden, %{error: message})
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/unfollow"
+ def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
+ {:error, "Can not unfollow yourself"}
+ end
+
+ def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do
+ with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do
+ render(conn, "relationship.json", user: follower, target: followed)
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/mute"
+ def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do
+ params =
+ params
+ |> Map.put_new(:duration, Map.get(params, :expires_in, 0))
+
+ with {:ok, _user_relationships} <- User.mute(muter, muted, params) do
+ render(conn, "relationship.json", user: muter, target: muted)
+ else
+ {:error, message} -> json_response(conn, :forbidden, %{error: message})
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/unmute"
+ def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do
+ with {:ok, _user_relationships} <- User.unmute(muter, muted) do
+ render(conn, "relationship.json", user: muter, target: muted)
+ else
+ {:error, message} -> json_response(conn, :forbidden, %{error: message})
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/block"
+ def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
+ with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do
+ render(conn, "relationship.json", user: blocker, target: blocked)
+ else
+ {:error, message} -> json_response(conn, :forbidden, %{error: message})
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/unblock"
+ def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
+ with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do
+ render(conn, "relationship.json", user: blocker, target: blocked)
+ else
+ {:error, message} -> json_response(conn, :forbidden, %{error: message})
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/note"
+ def note(
+ %{assigns: %{user: noter, account: target}, body_params: %{comment: comment}} = conn,
+ _params
+ ) do
+ with {:ok, _user_note} <- UserNote.create(noter, target, comment) do
+ render(conn, "relationship.json", user: noter, target: target)
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/pin"
+ def endorse(%{assigns: %{user: endorser, account: endorsed}} = conn, _params) do
+ with {:ok, _user_relationships} <- User.endorse(endorser, endorsed) do
+ render(conn, "relationship.json", user: endorser, target: endorsed)
+ else
+ {:error, message} -> json_response(conn, :bad_request, %{error: message})
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/unpin"
+ def unendorse(%{assigns: %{user: endorser, account: endorsed}} = conn, _params) do
+ with {:ok, _user_relationships} <- User.unendorse(endorser, endorsed) do
+ render(conn, "relationship.json", user: endorser, target: endorsed)
+ else
+ {:error, message} -> json_response(conn, :forbidden, %{error: message})
+ end
+ end
+
+ @doc "POST /api/v1/accounts/:id/remove_from_followers"
+ def remove_from_followers(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
+ {:error, "Can not unfollow yourself"}
+ end
+
+ def remove_from_followers(%{assigns: %{user: followed, account: follower}} = conn, _params) do
+ with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do
+ render(conn, "relationship.json", user: followed, target: follower)
+ else
+ nil ->
+ render_error(conn, :not_found, "Record not found")
+ end
+ end
+
+ @doc "POST /api/v1/follows"
+ def follow_by_uri(%{body_params: %{uri: uri}} = conn, _) do
+ case User.get_cached_by_nickname(uri) do
+ %User{} = user ->
+ conn
+ |> assign(:account, user)
+ |> follow(%{})
+
+ nil ->
+ {:error, :not_found}
+ end
+ end
+
+ @doc "GET /api/v1/mutes"
+ def mutes(%{assigns: %{user: user}} = conn, params) do
+ users =
+ user
+ |> User.muted_users_relation(_restrict_deactivated = true)
+ |> Pleroma.Pagination.fetch_paginated(params)
+
+ conn
+ |> add_link_headers(users)
+ |> render("index.json",
+ users: users,
+ for: user,
+ as: :user,
+ embed_relationships: embed_relationships?(params),
+ mutes: true
+ )
+ end
+
+ @doc "GET /api/v1/blocks"
+ def blocks(%{assigns: %{user: user}} = conn, params) do
+ users =
+ user
+ |> User.blocked_users_relation(_restrict_deactivated = true)
+ |> Pleroma.Pagination.fetch_paginated(params)
+
+ conn
+ |> add_link_headers(users)
+ |> render("index.json", users: users, for: user, as: :user)
+ end
+
+ @doc "GET /api/v1/accounts/lookup"
+ def lookup(conn, %{acct: nickname} = _params) do
+ with %User{} = user <- User.get_by_nickname(nickname) do
+ render(conn, "show.json",
+ user: user,
+ skip_visibility_check: true
+ )
+ else
+ error -> user_visibility_error(conn, error)
+ end
+ end
+
+ @doc "GET /api/v1/endorsements"
+ def endorsements(%{assigns: %{user: user}} = conn, params) do
+ users =
+ user
+ |> User.endorsed_users_relation(_restrict_deactivated = true)
+ |> Pleroma.Repo.all()
+
+ conn
+ |> render("index.json",
+ users: users,
+ for: user,
+ as: :user,
+ embed_relationships: embed_relationships?(params)
+ )
+ end
+
+ @doc "GET /api/v1/identity_proofs"
+ def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex
new file mode 100644
index 0000000..080af96
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex
@@ -0,0 +1,60 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.AnnouncementController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper,
+ only: [
+ json_response: 3
+ ]
+
+ alias Pleroma.Announcement
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ # Mastodon docs say this only requires a user token, no scopes needed
+ # As the op `|` requires at least one scope to be present, we use `&` here.
+ plug(
+ OAuthScopesPlug,
+ %{scopes: [], op: :&}
+ when action in [:index]
+ )
+
+ # Same as in MastodonAPI specs
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["write:accounts"]}
+ when action in [:mark_read]
+ )
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AnnouncementOperation
+
+ @doc "GET /api/v1/announcements"
+ def index(%{assigns: %{user: user}} = conn, _params) do
+ render(conn, "index.json", announcements: all_visible(), user: user)
+ end
+
+ def index(conn, _params) do
+ render(conn, "index.json", announcements: all_visible(), user: nil)
+ end
+
+ defp all_visible do
+ Announcement.list_all_visible()
+ end
+
+ @doc "POST /api/v1/announcements/:id/dismiss"
+ def mark_read(%{assigns: %{user: user}} = conn, %{id: id} = _params) do
+ with announcement when not is_nil(announcement) <- Announcement.get_by_id(id),
+ {:ok, _} <- Announcement.mark_read_by(announcement, user) do
+ json_response(conn, :ok, %{})
+ else
+ _ ->
+ {:error, :not_found}
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex
new file mode 100644
index 0000000..844673a
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex
@@ -0,0 +1,57 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.AppController do
+ @moduledoc """
+ Controller for supporting app-related actions.
+ If authentication is an option, app tokens (user-unbound) must be supported.
+ """
+
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Maps
+ alias Pleroma.Repo
+ alias Pleroma.User
+ alias Pleroma.Web.OAuth.App
+ alias Pleroma.Web.OAuth.Scopes
+ alias Pleroma.Web.OAuth.Token
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ plug(:skip_auth when action in [:create, :verify_credentials])
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AppOperation
+
+ @doc "POST /api/v1/apps"
+ def create(%{body_params: params} = conn, _params) do
+ scopes = Scopes.fetch_scopes(params, ["read"])
+ user_id = get_user_id(conn)
+
+ app_attrs =
+ params
+ |> Map.take([:client_name, :redirect_uris, :website])
+ |> Map.put(:scopes, scopes)
+ |> Maps.put_if_present(:user_id, user_id)
+
+ with cs <- App.register_changeset(%App{}, app_attrs),
+ {:ok, app} <- Repo.insert(cs) do
+ render(conn, "show.json", app: app)
+ end
+ end
+
+ defp get_user_id(%{assigns: %{user: %User{id: user_id}}}), do: user_id
+ defp get_user_id(_conn), do: nil
+
+ @doc """
+ GET /api/v1/apps/verify_credentials
+ Gets compact non-secret representation of the app. Supports app tokens and user tokens.
+ """
+ def verify_credentials(%{assigns: %{token: %Token{} = token}} = conn, _) do
+ with %{app: %App{} = app} <- Repo.preload(token, :app) do
+ render(conn, "compact_non_secret.json", app: app)
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex
new file mode 100644
index 0000000..fbb54a1
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex
@@ -0,0 +1,24 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.AuthController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper, only: [json_response: 3]
+
+ alias Pleroma.Web.TwitterAPI.TwitterAPI
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ plug(Pleroma.Web.Plugs.RateLimiter, [name: :password_reset] when action == :password_reset)
+
+ @doc "POST /auth/password"
+ def password_reset(conn, params) do
+ nickname_or_email = params["email"] || params["nickname"]
+
+ TwitterAPI.password_reset(nickname_or_email)
+
+ json_response(conn, :no_content, "")
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
new file mode 100644
index 0000000..9cc6225
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
@@ -0,0 +1,48 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ConversationController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
+
+ alias Pleroma.Conversation.Participation
+ alias Pleroma.Repo
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index)
+ plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action != :index)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ConversationOperation
+
+ @doc "GET /api/v1/conversations"
+ def index(%{assigns: %{user: user}} = conn, params) do
+ participations = Participation.for_user_with_last_activity_id(user, params)
+
+ conn
+ |> add_link_headers(participations)
+ |> render("participations.json", participations: participations, for: user)
+ end
+
+ @doc "POST /api/v1/conversations/:id/read"
+ def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do
+ with %Participation{} = participation <-
+ Repo.get_by(Participation, id: participation_id, user_id: user.id),
+ {:ok, participation} <- Participation.mark_as_read(participation) do
+ render(conn, "participation.json", participation: participation, for: user)
+ end
+ end
+
+ @doc "DELETE /api/v1/conversations/:id"
+ def delete(%{assigns: %{user: user}} = conn, %{id: participation_id}) do
+ with %Participation{} = participation <-
+ Repo.get_by(Participation, id: participation_id, user_id: user.id),
+ {:ok, _} <- Participation.delete(participation) do
+ json(conn, %{})
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex
new file mode 100644
index 0000000..8b27b0b
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex
@@ -0,0 +1,17 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do
+ use Pleroma.Web, :controller
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(:skip_auth when action == :index)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.CustomEmojiOperation
+
+ def index(conn, _params) do
+ render(conn, "index.json", custom_emojis: Pleroma.Emoji.get_all())
+ 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 0000000..253f06c
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex
@@ -0,0 +1,82 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 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/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex
new file mode 100644
index 0000000..b2e347e
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex
@@ -0,0 +1,50 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.DomainBlockController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.User
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DomainBlockOperation
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["follow", "read:blocks"]} when action == :index
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["follow", "write:blocks"]} when action != :index
+ )
+
+ @doc "GET /api/v1/domain_blocks"
+ def index(%{assigns: %{user: user}} = conn, _) do
+ json(conn, Map.get(user, :domain_blocks, []))
+ end
+
+ @doc "POST /api/v1/domain_blocks"
+ def create(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do
+ User.block_domain(blocker, domain)
+ json(conn, %{})
+ end
+
+ def create(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do
+ User.block_domain(blocker, domain)
+ json(conn, %{})
+ end
+
+ @doc "DELETE /api/v1/domain_blocks"
+ def delete(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do
+ User.unblock_domain(blocker, domain)
+ json(conn, %{})
+ end
+
+ def delete(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do
+ User.unblock_domain(blocker, domain)
+ json(conn, %{})
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex
new file mode 100644
index 0000000..1c650eb
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex
@@ -0,0 +1,44 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.FallbackController do
+ use Pleroma.Web, :controller
+
+ def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
+ error_message =
+ changeset
+ |> Ecto.Changeset.traverse_errors(fn {message, _opt} -> message end)
+ |> Enum.map_join(", ", fn {_k, v} -> v end)
+
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: error_message})
+ end
+
+ def call(conn, {:error, :not_found}) do
+ render_error(conn, :not_found, "Record not found")
+ end
+
+ def call(conn, {:error, :forbidden}) do
+ render_error(conn, :forbidden, "Access denied")
+ end
+
+ def call(conn, {:error, error_message}) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: error_message})
+ end
+
+ def call(conn, {:error, status, message}) do
+ conn
+ |> put_status(status)
+ |> json(%{error: message})
+ end
+
+ def call(conn, _) do
+ conn
+ |> put_status(:internal_server_error)
+ |> json(dgettext("errors", "Something went wrong"))
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
new file mode 100644
index 0000000..0959b4b
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
@@ -0,0 +1,86 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.FilterController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Filter
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ @oauth_read_actions [:show, :index]
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["write:filters"]} when action not in @oauth_read_actions
+ )
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ @doc "GET /api/v1/filters"
+ def index(%{assigns: %{user: user}} = conn, _) do
+ filters = Filter.get_filters(user)
+
+ render(conn, "index.json", filters: filters)
+ end
+
+ @doc "POST /api/v1/filters"
+ def create(%{assigns: %{user: user}, body_params: params} = conn, _) do
+ with {:ok, response} <-
+ params
+ |> Map.put(:user_id, user.id)
+ |> Map.put(:hide, params[:irreversible])
+ |> Map.delete(:irreversible)
+ |> Filter.create() do
+ render(conn, "show.json", filter: response)
+ end
+ end
+
+ @doc "GET /api/v1/filters/:id"
+ def show(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
+ with %Filter{} = filter <- Filter.get(filter_id, user) do
+ render(conn, "show.json", filter: filter)
+ else
+ nil -> {:error, :not_found}
+ end
+ end
+
+ @doc "PUT /api/v1/filters/:id"
+ def update(
+ %{assigns: %{user: user}, body_params: params} = conn,
+ %{id: filter_id}
+ ) do
+ params =
+ if is_boolean(params[:irreversible]) do
+ params
+ |> Map.put(:hide, params[:irreversible])
+ |> Map.delete(:irreversible)
+ else
+ params
+ end
+
+ with %Filter{} = filter <- Filter.get(filter_id, user),
+ {:ok, %Filter{} = filter} <- Filter.update(filter, params) do
+ render(conn, "show.json", filter: filter)
+ else
+ nil -> {:error, :not_found}
+ error -> error
+ end
+ end
+
+ @doc "DELETE /api/v1/filters/:id"
+ def delete(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
+ with %Filter{} = filter <- Filter.get(filter_id, user),
+ {:ok, _} <- Filter.delete(filter) do
+ json(conn, %{})
+ else
+ nil -> {:error, :not_found}
+ error -> error
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
new file mode 100644
index 0000000..ba6d074
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
@@ -0,0 +1,59 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.User
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(:assign_follower when action != :index)
+
+ action_fallback(:errors)
+
+ plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]} when action == :index)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["follow", "write:follows"]} when action != :index
+ )
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation
+
+ @doc "GET /api/v1/follow_requests"
+ def index(%{assigns: %{user: followed}} = conn, _params) do
+ follow_requests = User.get_follow_requests(followed)
+
+ render(conn, "index.json", for: followed, users: follow_requests, as: :user)
+ end
+
+ @doc "POST /api/v1/follow_requests/:id/authorize"
+ def authorize(%{assigns: %{user: followed, follower: follower}} = conn, _params) do
+ with {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do
+ render(conn, "relationship.json", user: followed, target: follower)
+ end
+ end
+
+ @doc "POST /api/v1/follow_requests/:id/reject"
+ def reject(%{assigns: %{user: followed, follower: follower}} = conn, _params) do
+ with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do
+ render(conn, "relationship.json", user: followed, target: follower)
+ end
+ end
+
+ defp assign_follower(%{params: %{id: id}} = conn, _) do
+ case User.get_cached_by_id(id) do
+ %User{} = follower -> assign(conn, :follower, follower)
+ nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt()
+ end
+ end
+
+ defp errors(conn, {:error, message}) do
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: message})
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
new file mode 100644
index 0000000..6410e87
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
@@ -0,0 +1,23 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.InstanceController do
+ use Pleroma.Web, :controller
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(:skip_auth when action in [:show, :peers])
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation
+
+ @doc "GET /api/v1/instance"
+ def show(conn, _params) do
+ render(conn, "show.json")
+ end
+
+ @doc "GET /api/v1/instance/peers"
+ def peers(conn, _params) do
+ json(conn, Pleroma.Stats.get_peers())
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
new file mode 100644
index 0000000..2117aae
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
@@ -0,0 +1,99 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ListController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.User
+ alias Pleroma.Web.MastodonAPI.AccountView
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ @oauth_read_actions [:index, :show, :list_accounts]
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(:list_by_id_and_user when action not in [:index, :create])
+ plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in @oauth_read_actions)
+ plug(OAuthScopesPlug, %{scopes: ["write:lists"]} when action not in @oauth_read_actions)
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ListOperation
+
+ # GET /api/v1/lists
+ def index(%{assigns: %{user: user}} = conn, opts) do
+ lists = Pleroma.List.for_user(user, opts)
+ render(conn, "index.json", lists: lists)
+ end
+
+ # POST /api/v1/lists
+ def create(%{assigns: %{user: user}, body_params: %{title: title}} = conn, _) do
+ with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do
+ render(conn, "show.json", list: list)
+ end
+ end
+
+ # GET /api/v1/lists/:id
+ def show(%{assigns: %{list: list}} = conn, _) do
+ render(conn, "show.json", list: list)
+ end
+
+ # PUT /api/v1/lists/:id
+ def update(%{assigns: %{list: list}, body_params: %{title: title}} = conn, _) do
+ with {:ok, list} <- Pleroma.List.rename(list, title) do
+ render(conn, "show.json", list: list)
+ end
+ end
+
+ # DELETE /api/v1/lists/:id
+ def delete(%{assigns: %{list: list}} = conn, _) do
+ with {:ok, _list} <- Pleroma.List.delete(list) do
+ json(conn, %{})
+ end
+ end
+
+ # GET /api/v1/lists/:id/accounts
+ def list_accounts(%{assigns: %{user: user, list: list}} = conn, _) do
+ with {:ok, users} <- Pleroma.List.get_following(list) do
+ conn
+ |> put_view(AccountView)
+ |> render("index.json", for: user, users: users, as: :user)
+ end
+ end
+
+ # POST /api/v1/lists/:id/accounts
+ def add_to_list(%{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn, _) do
+ Enum.each(account_ids, fn account_id ->
+ with %User{} = followed <- User.get_cached_by_id(account_id) do
+ Pleroma.List.follow(list, followed)
+ end
+ end)
+
+ json(conn, %{})
+ end
+
+ # DELETE /api/v1/lists/:id/accounts
+ def remove_from_list(
+ %{assigns: %{list: list}, params: %{account_ids: account_ids}} = conn,
+ _
+ ) do
+ Enum.each(account_ids, fn account_id ->
+ with %User{} = followed <- User.get_cached_by_id(account_id) do
+ Pleroma.List.unfollow(list, followed)
+ end
+ end)
+
+ json(conn, %{})
+ end
+
+ def remove_from_list(%{body_params: params} = conn, _) do
+ remove_from_list(%{conn | params: params}, %{})
+ end
+
+ defp list_by_id_and_user(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do
+ case Pleroma.List.get(id, user) do
+ %Pleroma.List{} = list -> assign(conn, :list, list)
+ nil -> conn |> render_error(:not_found, "List not found") |> halt()
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
new file mode 100644
index 0000000..4ad30f3
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
@@ -0,0 +1,38 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.MarkerController do
+ use Pleroma.Web, :controller
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["read:statuses"]}
+ when action == :index
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert)
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MarkerOperation
+
+ # GET /api/v1/markers
+ def index(%{assigns: %{user: user}} = conn, params) do
+ markers = Pleroma.Marker.get_markers(user, params[:timeline])
+ render(conn, "markers.json", %{markers: markers})
+ end
+
+ # POST /api/v1/markers
+ def upsert(%{assigns: %{user: user}, body_params: params} = conn, _) do
+ params = Map.new(params, fn {key, value} -> {to_string(key), value} end)
+
+ with {:ok, result} <- Pleroma.Marker.upsert(user, params),
+ markers <- Map.values(result) do
+ render(conn, "markers.json", %{markers: markers})
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex
new file mode 100644
index 0000000..0aa7b37
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
+ @moduledoc """
+ Contains stubs for unimplemented Mastodon API endpoints.
+
+ Note: instead of routing directly to this controller's action,
+ it's preferable to define an action in relevant (non-generic) controller,
+ set up OAuth rules for it and call this controller's function from it.
+ """
+
+ use Pleroma.Web, :controller
+
+ require Logger
+
+ plug(:skip_auth when action in [:empty_array, :empty_object])
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ def empty_array(conn, _) do
+ Logger.debug("Unimplemented, returning an empty array (list)")
+ json(conn, [])
+ end
+
+ def empty_object(conn, _) do
+ Logger.debug("Unimplemented, returning an empty object (map)")
+ json(conn, %{})
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex
new file mode 100644
index 0000000..7d9a63c
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex
@@ -0,0 +1,80 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.MediaController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Object
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+ plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:create, :create2])
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(OAuthScopesPlug, %{scopes: ["read:media"]} when action == :show)
+ plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action != :show)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MediaOperation
+
+ @doc "POST /api/v1/media"
+ def create(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do
+ with {:ok, object} <-
+ ActivityPub.upload(
+ file,
+ actor: User.ap_id(user),
+ description: Map.get(data, :description)
+ ) do
+ attachment_data = Map.put(object.data, "id", object.id)
+
+ render(conn, "attachment.json", %{attachment: attachment_data})
+ end
+ end
+
+ def create(_conn, _data), do: {:error, :bad_request}
+
+ @doc "POST /api/v2/media"
+ def create2(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do
+ with {:ok, object} <-
+ ActivityPub.upload(
+ file,
+ actor: User.ap_id(user),
+ description: Map.get(data, :description)
+ ) do
+ attachment_data = Map.put(object.data, "id", object.id)
+
+ conn
+ |> put_status(202)
+ |> render("attachment.json", %{attachment: attachment_data})
+ end
+ end
+
+ def create2(_conn, _data), do: {:error, :bad_request}
+
+ @doc "PUT /api/v1/media/:id"
+ def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{id: id}) do
+ with %Object{} = object <- Object.get_by_id(id),
+ :ok <- Object.authorize_access(object, user),
+ {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do
+ attachment_data = Map.put(data, "id", object.id)
+
+ render(conn, "attachment.json", %{attachment: attachment_data})
+ end
+ end
+
+ def update(conn, data), do: show(conn, data)
+
+ @doc "GET /api/v1/media/:id"
+ def show(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Object{data: data, id: object_id} = object <- Object.get_by_id(id),
+ :ok <- Object.authorize_access(object, user) do
+ attachment_data = Map.put(data, "id", object_id)
+
+ render(conn, "attachment.json", %{attachment: attachment_data})
+ end
+ end
+
+ def show(_conn, _data), do: {:error, :bad_request}
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
new file mode 100644
index 0000000..a490e83
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
@@ -0,0 +1,112 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.NotificationController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
+
+ alias Pleroma.Notification
+ alias Pleroma.Web.MastodonAPI.MastodonAPI
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ @oauth_read_actions [:show, :index]
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["read:notifications"]} when action in @oauth_read_actions
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation
+
+ # GET /api/v1/notifications
+ def index(conn, %{account_id: account_id} = params) do
+ case Pleroma.User.get_cached_by_id(account_id) do
+ %{ap_id: account_ap_id} ->
+ params =
+ params
+ |> Map.delete(:account_id)
+ |> Map.put(:account_ap_id, account_ap_id)
+
+ index(conn, params)
+
+ _ ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{"error" => "Account is not found"})
+ end
+ end
+
+ @default_notification_types ~w{
+ mention
+ follow
+ follow_request
+ reblog
+ favourite
+ move
+ pleroma:emoji_reaction
+ poll
+ update
+ }
+ def index(%{assigns: %{user: user}} = conn, params) do
+ params =
+ Map.new(params, fn {k, v} -> {to_string(k), v} end)
+ |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types))
+
+ notifications = MastodonAPI.get_notifications(user, params)
+
+ conn
+ |> add_link_headers(notifications)
+ |> render("index.json",
+ notifications: notifications,
+ for: user
+ )
+ end
+
+ # GET /api/v1/notifications/:id
+ def show(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with {:ok, notification} <- Notification.get(user, id) do
+ render(conn, "show.json", notification: notification, for: user)
+ else
+ {:error, reason} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{"error" => reason})
+ end
+ end
+
+ # POST /api/v1/notifications/clear
+ def clear(%{assigns: %{user: user}} = conn, _params) do
+ Notification.clear(user)
+ json(conn, %{})
+ end
+
+ # POST /api/v1/notifications/:id/dismiss
+
+ def dismiss(%{assigns: %{user: user}} = conn, %{id: id} = _params) do
+ with {:ok, _notif} <- Notification.dismiss(user, id) do
+ json(conn, %{})
+ else
+ {:error, reason} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{"error" => reason})
+ end
+ end
+
+ # POST /api/v1/notifications/dismiss (deprecated)
+ def dismiss_via_body(%{body_params: params} = conn, _) do
+ dismiss(conn, params)
+ end
+
+ # DELETE /api/v1/notifications/destroy_multiple
+ def destroy_multiple(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do
+ Notification.destroy_multiple(user, ids)
+ json(conn, %{})
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
new file mode 100644
index 0000000..002c210
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
@@ -0,0 +1,67 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.PollController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper, only: [try_render: 3, json_response: 3]
+
+ alias Pleroma.Activity
+ alias Pleroma.Object
+ alias Pleroma.Web.ActivityPub.Visibility
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation
+
+ @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
+ @doc "GET /api/v1/polls/:id"
+ def show(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),
+ %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
+ true <- Visibility.visible_for_user?(activity, user) do
+ try_render(conn, "show.json", %{object: object, for: user})
+ else
+ error when is_nil(error) or error == false ->
+ render_error(conn, :not_found, "Record not found")
+ end
+ end
+
+ @doc "POST /api/v1/polls/:id/votes"
+ def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do
+ with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id),
+ %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
+ true <- Visibility.visible_for_user?(activity, user),
+ {:ok, _activities, object} <- get_cached_vote_or_vote(user, object, choices) do
+ try_render(conn, "show.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, message} -> json_response(conn, :unprocessable_entity, %{error: message})
+ end
+ end
+
+ defp get_cached_vote_or_vote(user, object, choices) do
+ idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
+
+ @cachex.fetch!(:idempotency_cache, idempotency_key, fn _ ->
+ case CommonAPI.vote(user, object, choices) do
+ {:error, _message} = res -> {:ignore, res}
+ res -> {:commit, res}
+ end
+ end)
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex
new file mode 100644
index 0000000..3db80d7
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ReportController do
+ use Pleroma.Web, :controller
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ReportOperation
+
+ @doc "POST /api/v1/reports"
+ def create(%{assigns: %{user: user}, body_params: params} = conn, _) do
+ with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do
+ render(conn, "show.json", activity: activity)
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
new file mode 100644
index 0000000..0392fce
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
@@ -0,0 +1,61 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
+
+ alias Pleroma.ScheduledActivity
+ alias Pleroma.Web.MastodonAPI.MastodonAPI
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ @oauth_read_actions [:show, :index]
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions)
+ plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions)
+ plug(:assign_scheduled_activity when action != :index)
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ScheduledActivityOperation
+
+ @doc "GET /api/v1/scheduled_statuses"
+ def index(%{assigns: %{user: user}} = conn, params) do
+ params = Map.new(params, fn {key, value} -> {to_string(key), value} end)
+
+ with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do
+ conn
+ |> add_link_headers(scheduled_activities)
+ |> render("index.json", scheduled_activities: scheduled_activities)
+ end
+ end
+
+ @doc "GET /api/v1/scheduled_statuses/:id"
+ def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do
+ render(conn, "show.json", scheduled_activity: scheduled_activity)
+ end
+
+ @doc "PUT /api/v1/scheduled_statuses/:id"
+ def update(%{assigns: %{scheduled_activity: scheduled_activity}, body_params: params} = conn, _) do
+ with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do
+ render(conn, "show.json", scheduled_activity: scheduled_activity)
+ end
+ end
+
+ @doc "DELETE /api/v1/scheduled_statuses/:id"
+ def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do
+ with {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do
+ render(conn, "show.json", scheduled_activity: scheduled_activity)
+ end
+ end
+
+ defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do
+ case ScheduledActivity.get(user, id) do
+ %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity)
+ nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt()
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex
new file mode 100644
index 0000000..5e6e047
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex
@@ -0,0 +1,194 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.SearchController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Activity
+ alias Pleroma.Repo
+ alias Pleroma.User
+ alias Pleroma.Web.ControllerHelper
+ alias Pleroma.Web.Endpoint
+ alias Pleroma.Web.MastodonAPI.AccountView
+ alias Pleroma.Web.MastodonAPI.StatusView
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+ alias Pleroma.Web.Plugs.RateLimiter
+
+ require Logger
+
+ @search_limit 40
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
+ plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
+
+ # Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped)
+
+ plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation
+
+ def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do
+ accounts = User.search(query, search_options(params, user))
+
+ conn
+ |> put_view(AccountView)
+ |> render("index.json",
+ users: accounts,
+ for: user,
+ as: :user
+ )
+ end
+
+ def search2(conn, params), do: do_search(:v2, conn, params)
+ def search(conn, params), do: do_search(:v1, conn, params)
+
+ defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do
+ query = String.trim(query)
+ options = search_options(params, user)
+ timeout = Keyword.get(Repo.config(), :timeout, 15_000)
+ default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
+
+ result =
+ default_values
+ |> Enum.map(fn {resource, default_value} ->
+ if params[:type] in [nil, resource] do
+ {resource, fn -> resource_search(version, resource, query, options) end}
+ else
+ {resource, fn -> default_value end}
+ end
+ end)
+ |> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end,
+ timeout: timeout,
+ on_timeout: :kill_task
+ )
+ |> Enum.reduce(default_values, fn
+ {:ok, {resource, result}}, acc ->
+ Map.put(acc, resource, result)
+
+ _error, acc ->
+ acc
+ end)
+
+ json(conn, result)
+ end
+
+ defp search_options(params, user) do
+ [
+ resolve: params[:resolve],
+ following: params[:following],
+ limit: min(params[:limit], @search_limit),
+ offset: params[:offset],
+ type: params[:type],
+ author: get_author(params),
+ embed_relationships: ControllerHelper.embed_relationships?(params),
+ for_user: user
+ ]
+ |> Enum.filter(&elem(&1, 1))
+ end
+
+ defp resource_search(_, "accounts", query, options) do
+ accounts = with_fallback(fn -> User.search(query, options) end)
+
+ AccountView.render("index.json",
+ users: accounts,
+ for: options[:for_user],
+ embed_relationships: options[:embed_relationships]
+ )
+ end
+
+ defp resource_search(_, "statuses", query, options) do
+ statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end)
+
+ StatusView.render("index.json",
+ activities: statuses,
+ for: options[:for_user],
+ as: :activity
+ )
+ end
+
+ defp resource_search(:v2, "hashtags", query, options) do
+ tags_path = Endpoint.url() <> "/tag/"
+
+ query
+ |> prepare_tags(options)
+ |> Enum.map(fn tag ->
+ %{name: tag, url: tags_path <> tag}
+ end)
+ end
+
+ defp resource_search(:v1, "hashtags", query, options) do
+ prepare_tags(query, options)
+ end
+
+ defp prepare_tags(query, options) do
+ tags =
+ query
+ |> preprocess_uri_query()
+ |> String.split(~r/[^#\w]+/u, trim: true)
+ |> Enum.uniq_by(&String.downcase/1)
+
+ explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end)
+
+ tags =
+ if Enum.any?(explicit_tags) do
+ explicit_tags
+ else
+ tags
+ end
+
+ tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end)
+
+ tags =
+ if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do
+ add_joined_tag(tags)
+ else
+ tags
+ end
+
+ Pleroma.Pagination.paginate(tags, options)
+ end
+
+ defp add_joined_tag(tags) do
+ tags
+ |> Kernel.++([joined_tag(tags)])
+ |> Enum.uniq_by(&String.downcase/1)
+ end
+
+ # If `query` is a URI, returns last component of its path, otherwise returns `query`
+ defp preprocess_uri_query(query) do
+ if query =~ ~r/https?:\/\// do
+ query
+ |> String.trim_trailing("/")
+ |> URI.parse()
+ |> Map.get(:path)
+ |> String.split("/")
+ |> Enum.at(-1)
+ else
+ query
+ end
+ end
+
+ defp joined_tag(tags) do
+ tags
+ |> Enum.map(fn tag -> String.capitalize(tag) end)
+ |> Enum.join()
+ end
+
+ defp with_fallback(f, fallback \\ []) do
+ try do
+ f.()
+ rescue
+ error ->
+ Logger.error("#{__MODULE__} search error: #{inspect(error)}")
+ fallback
+ end
+ end
+
+ defp get_author(%{account_id: account_id}) when is_binary(account_id),
+ do: User.get_cached_by_id(account_id)
+
+ defp get_author(_params), do: nil
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
new file mode 100644
index 0000000..e594ea4
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
@@ -0,0 +1,498 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.StatusController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper,
+ only: [try_render: 3, add_link_headers: 2]
+
+ require Ecto.Query
+
+ alias Pleroma.Activity
+ alias Pleroma.Bookmark
+ alias Pleroma.Object
+ alias Pleroma.Repo
+ alias Pleroma.ScheduledActivity
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.Visibility
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.MastodonAPI.AccountView
+ alias Pleroma.Web.MastodonAPI.ScheduledActivityView
+ alias Pleroma.Web.OAuth.Token
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+ alias Pleroma.Web.Plugs.RateLimiter
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(:skip_public_check when action in [:index, :show])
+
+ @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
+
+ plug(
+ OAuthScopesPlug,
+ %{@unauthenticated_access | scopes: ["read:statuses"]}
+ when action in [
+ :index,
+ :show,
+ :card,
+ :context,
+ :show_history,
+ :show_source
+ ]
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["write:statuses"]}
+ when action in [
+ :create,
+ :delete,
+ :reblog,
+ :unreblog,
+ :update
+ ]
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["write:favourites"]} when action in [:favourite, :unfavourite]
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["write:mutes"]} when action in [:mute_conversation, :unmute_conversation]
+ )
+
+ plug(
+ OAuthScopesPlug,
+ %{@unauthenticated_access | scopes: ["read:accounts"]}
+ when action in [:favourited_by, :reblogged_by]
+ )
+
+ plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action in [:pin, :unpin])
+
+ # Note: scope not present in Mastodon: read:bookmarks
+ plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :bookmarks)
+
+ # Note: scope not present in Mastodon: write:bookmarks
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark]
+ )
+
+ @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a
+
+ plug(
+ RateLimiter,
+ [name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: [:id]]
+ when action in ~w(reblog unreblog)a
+ )
+
+ plug(
+ RateLimiter,
+ [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: [:id]]
+ when action in ~w(favourite unfavourite)a
+ )
+
+ plug(RateLimiter, [name: :statuses_actions] when action in @rate_limited_status_actions)
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.StatusOperation
+
+ @doc """
+ GET `/api/v1/statuses?ids[]=1&ids[]=2`
+
+ `ids` query param is required
+ """
+ def index(%{assigns: %{user: user}} = conn, %{ids: ids} = params) do
+ limit = 100
+
+ activities =
+ ids
+ |> Enum.take(limit)
+ |> Activity.all_by_ids_with_object()
+ |> Enum.filter(&Visibility.visible_for_user?(&1, user))
+
+ render(conn, "index.json",
+ activities: activities,
+ for: user,
+ as: :activity,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ end
+
+ @doc """
+ POST /api/v1/statuses
+ """
+ # Creates a scheduled status when `scheduled_at` param is present and it's far enough
+ def create(
+ %{
+ assigns: %{user: user},
+ body_params: %{status: _, scheduled_at: scheduled_at} = params
+ } = conn,
+ _
+ )
+ when not is_nil(scheduled_at) do
+ params =
+ Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id])
+ |> put_application(conn)
+
+ attrs = %{
+ params: Map.new(params, fn {key, value} -> {to_string(key), value} end),
+ scheduled_at: scheduled_at
+ }
+
+ with {:far_enough, true} <- {:far_enough, ScheduledActivity.far_enough?(scheduled_at)},
+ {:ok, scheduled_activity} <- ScheduledActivity.create(user, attrs) do
+ conn
+ |> put_view(ScheduledActivityView)
+ |> render("show.json", scheduled_activity: scheduled_activity)
+ else
+ {:far_enough, _} ->
+ params = Map.drop(params, [:scheduled_at])
+ create(%Plug.Conn{conn | body_params: params}, %{})
+
+ error ->
+ error
+ end
+ end
+
+ # Creates a regular status
+ def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, _) do
+ params =
+ Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id])
+ |> put_application(conn)
+
+ with {:ok, activity} <- CommonAPI.post(user, params) do
+ try_render(conn, "show.json",
+ activity: activity,
+ for: user,
+ as: :activity,
+ with_direct_conversation_id: true
+ )
+ else
+ {:error, {:reject, message}} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: message})
+
+ {:error, message} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: message})
+ end
+ end
+
+ def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = conn, _) do
+ params = Map.put(params, :status, "")
+ create(%Plug.Conn{conn | body_params: params}, %{})
+ end
+
+ @doc "GET /api/v1/statuses/:id/history"
+ def show_history(%{assigns: assigns} = conn, %{id: id} = params) do
+ with user = assigns[:user],
+ %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ true <- Visibility.visible_for_user?(activity, user) do
+ try_render(conn, "history.json",
+ activity: activity,
+ for: user,
+ with_direct_conversation_id: true,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc "GET /api/v1/statuses/:id/source"
+ def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do
+ with user = assigns[:user],
+ %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ true <- Visibility.visible_for_user?(activity, user) do
+ try_render(conn, "source.json",
+ activity: activity,
+ for: user
+ )
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc "PUT /api/v1/statuses/:id"
+ def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} = params) do
+ with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)},
+ {_, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+ {_, true} <- {:is_create, activity.data["type"] == "Create"},
+ actor <- Activity.user_actor(activity),
+ {_, true} <- {:own_status, actor.id == user.id},
+ changes <- body_params |> put_application(conn),
+ {_, {:ok, _update_activity}} <- {:pipeline, CommonAPI.update(user, activity, changes)},
+ {_, %Activity{}} = {_, activity} <- {:refetched, Activity.get_by_id_with_object(id)} do
+ try_render(conn, "show.json",
+ activity: activity,
+ for: user,
+ with_direct_conversation_id: true,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ else
+ {:own_status, _} -> {:error, :forbidden}
+ {:pipeline, _} -> {:error, :internal_server_error}
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc "GET /api/v1/statuses/:id"
+ def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do
+ with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ true <- Visibility.visible_for_user?(activity, user) do
+ try_render(conn, "show.json",
+ activity: activity,
+ for: user,
+ with_direct_conversation_id: true,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc "DELETE /api/v1/statuses/:id"
+ def delete(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ {:ok, %Activity{}} <- CommonAPI.delete(id, user) do
+ try_render(conn, "show.json",
+ activity: activity,
+ for: user,
+ with_direct_conversation_id: true,
+ with_source: true
+ )
+ else
+ _e -> {:error, :not_found}
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/reblog"
+ def reblog(%{assigns: %{user: user}, body_params: params} = conn, %{id: ap_id_or_id}) do
+ with {:ok, announce} <- CommonAPI.repeat(ap_id_or_id, user, params),
+ %Activity{} = announce <- Activity.normalize(announce.data) do
+ try_render(conn, "show.json", %{activity: announce, for: user, as: :activity})
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/unreblog"
+ def unreblog(%{assigns: %{user: user}} = conn, %{id: activity_id}) do
+ with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user),
+ %Activity{} = activity <- Activity.get_by_id(activity_id) do
+ try_render(conn, "show.json", %{activity: activity, for: user, as: :activity})
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/favourite"
+ def favourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do
+ with {:ok, _fav} <- CommonAPI.favorite(user, activity_id),
+ %Activity{} = activity <- Activity.get_by_id(activity_id) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/unfavourite"
+ def unfavourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do
+ with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user),
+ %Activity{} = activity <- Activity.get_by_id(activity_id) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/pin"
+ def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do
+ with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ else
+ {:error, :pinned_statuses_limit_reached} ->
+ {:error, "You have already pinned the maximum number of statuses"}
+
+ {:error, :ownership_error} ->
+ {:error, :unprocessable_entity, "Someone else's status cannot be pinned"}
+
+ {:error, :visibility_error} ->
+ {:error, :unprocessable_entity, "Non-public status cannot be pinned"}
+
+ error ->
+ error
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/unpin"
+ def unpin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do
+ with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/bookmark"
+ def bookmark(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ %User{} = user <- User.get_cached_by_nickname(user.nickname),
+ true <- Visibility.visible_for_user?(activity, user),
+ {:ok, _bookmark} <- Bookmark.create(user.id, activity.id) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/unbookmark"
+ def unbookmark(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ %User{} = user <- User.get_cached_by_nickname(user.nickname),
+ true <- Visibility.visible_for_user?(activity, user),
+ {:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/mute"
+ def mute_conversation(%{assigns: %{user: user}, body_params: params} = conn, %{id: id}) do
+ with %Activity{} = activity <- Activity.get_by_id(id),
+ {:ok, activity} <- CommonAPI.add_mute(user, activity, params) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ end
+ end
+
+ @doc "POST /api/v1/statuses/:id/unmute"
+ def unmute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Activity{} = activity <- Activity.get_by_id(id),
+ {:ok, activity} <- CommonAPI.remove_mute(user, activity) do
+ try_render(conn, "show.json", activity: activity, for: user, as: :activity)
+ end
+ end
+
+ @doc "GET /api/v1/statuses/:id/card"
+ @deprecated "https://github.com/tootsuite/mastodon/pull/11213"
+ def card(%{assigns: %{user: user}} = conn, %{id: status_id}) do
+ with %Activity{} = activity <- Activity.get_by_id(status_id),
+ true <- Visibility.visible_for_user?(activity, user) do
+ data = Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
+ render(conn, "card.json", data)
+ else
+ _ -> render_error(conn, :not_found, "Record not found")
+ end
+ end
+
+ @doc "GET /api/v1/statuses/:id/favourited_by"
+ def favourited_by(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with true <- Pleroma.Config.get([:instance, :show_reactions]),
+ %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+ %Object{data: %{"likes" => likes}} <- Object.normalize(activity, fetch: false) do
+ users =
+ User
+ |> Ecto.Query.where([u], u.ap_id in ^likes)
+ |> Repo.all()
+ |> Enum.filter(&(not User.blocks?(user, &1)))
+
+ conn
+ |> put_view(AccountView)
+ |> render("index.json", for: user, users: users, as: :user)
+ else
+ {:visible, false} -> {:error, :not_found}
+ _ -> json(conn, [])
+ end
+ end
+
+ @doc "GET /api/v1/statuses/:id/reblogged_by"
+ def reblogged_by(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+ %Object{data: %{"announcements" => announces, "id" => ap_id}} <-
+ Object.normalize(activity, fetch: false) do
+ announces =
+ "Announce"
+ |> Activity.Queries.by_type()
+ |> Ecto.Query.where([a], a.actor in ^announces)
+ # this is to use the index
+ |> Activity.Queries.by_object_id(ap_id)
+ |> Repo.all()
+ |> Enum.filter(&Visibility.visible_for_user?(&1, user))
+ |> Enum.map(& &1.actor)
+ |> Enum.uniq()
+
+ users =
+ User
+ |> Ecto.Query.where([u], u.ap_id in ^announces)
+ |> Repo.all()
+ |> Enum.filter(&(not User.blocks?(user, &1)))
+
+ conn
+ |> put_view(AccountView)
+ |> render("index.json", for: user, users: users, as: :user)
+ else
+ {:visible, false} -> {:error, :not_found}
+ _ -> json(conn, [])
+ end
+ end
+
+ @doc "GET /api/v1/statuses/:id/context"
+ def context(%{assigns: %{user: user}} = conn, %{id: id}) do
+ with %Activity{} = activity <- Activity.get_by_id(id) do
+ activities =
+ ActivityPub.fetch_activities_for_context(activity.data["context"], %{
+ blocking_user: user,
+ user: user,
+ exclude_id: activity.id
+ })
+
+ render(conn, "context.json", activity: activity, activities: activities, user: user)
+ end
+ end
+
+ @doc "GET /api/v1/favourites"
+ def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do
+ activities = ActivityPub.fetch_favourites(user, params)
+
+ conn
+ |> add_link_headers(activities)
+ |> render("index.json",
+ activities: activities,
+ for: user,
+ as: :activity
+ )
+ end
+
+ @doc "GET /api/v1/bookmarks"
+ def bookmarks(%{assigns: %{user: user}} = conn, params) do
+ user = User.get_cached_by_id(user.id)
+
+ bookmarks =
+ user.id
+ |> Bookmark.for_user_query()
+ |> Pleroma.Pagination.fetch_paginated(params)
+
+ activities =
+ bookmarks
+ |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
+
+ conn
+ |> add_link_headers(bookmarks)
+ |> render("index.json",
+ activities: activities,
+ for: user,
+ as: :activity
+ )
+ end
+
+ defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do
+ if user.disclose_client do
+ %{client_name: client_name, website: website} = Repo.preload(token, :app).app
+ Map.put(params, :generator, %{type: "Application", name: client_name, url: website})
+ else
+ Map.put(params, :generator, nil)
+ end
+ end
+
+ defp put_application(params, _), do: Map.put(params, :generator, nil)
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
new file mode 100644
index 0000000..9cc0071
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
@@ -0,0 +1,77 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
+ @moduledoc "The module represents functions to manage user subscriptions."
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Web.Push
+ alias Pleroma.Web.Push.Subscription
+
+ action_fallback(:errors)
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(:restrict_push_enabled)
+ plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["push"]})
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SubscriptionOperation
+
+ # Creates PushSubscription
+ # POST /api/v1/push/subscription
+ #
+ def create(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do
+ with {:ok, _} <- Subscription.delete_if_exists(user, token),
+ {:ok, subscription} <- Subscription.create(user, token, params) do
+ render(conn, "show.json", subscription: subscription)
+ end
+ end
+
+ # Gets PushSubscription
+ # GET /api/v1/push/subscription
+ #
+ def show(%{assigns: %{user: user, token: token}} = conn, _params) do
+ with {:ok, subscription} <- Subscription.get(user, token) do
+ render(conn, "show.json", subscription: subscription)
+ end
+ end
+
+ # Updates PushSubscription
+ # PUT /api/v1/push/subscription
+ #
+ def update(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do
+ with {:ok, subscription} <- Subscription.update(user, token, params) do
+ render(conn, "show.json", subscription: subscription)
+ end
+ end
+
+ # Deletes PushSubscription
+ # DELETE /api/v1/push/subscription
+ #
+ def delete(%{assigns: %{user: user, token: token}} = conn, _params) do
+ with {:ok, _response} <- Subscription.delete(user, token),
+ do: json(conn, %{})
+ end
+
+ defp restrict_push_enabled(conn, _) do
+ if Push.enabled() do
+ conn
+ else
+ conn
+ |> render_error(:forbidden, "Web push subscription is disabled on this Pleroma instance")
+ |> halt()
+ end
+ end
+
+ # fallback action
+ #
+ def errors(conn, {:error, :not_found}) do
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: dgettext("errors", "Record not found")})
+ end
+
+ def errors(conn, _) do
+ Pleroma.Web.MastodonAPI.FallbackController.call(conn, nil)
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex
new file mode 100644
index 0000000..69ae70a
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex
@@ -0,0 +1,120 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.SuggestionController do
+ use Pleroma.Web, :controller
+ import Ecto.Query
+ alias Pleroma.FollowingRelationship
+ alias Pleroma.User
+ alias Pleroma.UserRelationship
+
+ require Logger
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action in [:index, :index2])
+ plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["write"]} when action in [:dismiss])
+
+ def open_api_operation(action) do
+ operation = String.to_existing_atom("#{action}_operation")
+ apply(__MODULE__, operation, [])
+ end
+
+ def index_operation do
+ %OpenApiSpex.Operation{
+ tags: ["Suggestions"],
+ summary: "Follow suggestions (Not implemented)",
+ operationId: "SuggestionController.index",
+ responses: %{
+ 200 => Pleroma.Web.ApiSpec.Helpers.empty_array_response()
+ }
+ }
+ end
+
+ def index2_operation do
+ %OpenApiSpex.Operation{
+ tags: ["Suggestions"],
+ summary: "Follow suggestions",
+ operationId: "SuggestionController.index2",
+ responses: %{
+ 200 => Pleroma.Web.ApiSpec.Helpers.empty_array_response()
+ }
+ }
+ end
+
+ def dismiss_operation do
+ %OpenApiSpex.Operation{
+ tags: ["Suggestions"],
+ summary: "Remove a suggestion",
+ operationId: "SuggestionController.dismiss",
+ parameters: [
+ OpenApiSpex.Operation.parameter(
+ :account_id,
+ :path,
+ %OpenApiSpex.Schema{type: :string},
+ "Account to dismiss",
+ required: true
+ )
+ ],
+ responses: %{
+ 200 => Pleroma.Web.ApiSpec.Helpers.empty_object_response()
+ }
+ }
+ end
+
+ @doc "GET /api/v1/suggestions"
+ def index(conn, params),
+ do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
+
+ @doc "GET /api/v2/suggestions"
+ def index2(%{assigns: %{user: user}} = conn, params) do
+ limit = Map.get(params, :limit, 40) |> min(80)
+
+ users =
+ %{is_suggested: true, invisible: false, limit: limit}
+ |> User.Query.build()
+ |> exclude_user(user)
+ |> exclude_relationships(user, [:block, :mute, :suggestion_dismiss])
+ |> exclude_following(user)
+ |> Pleroma.Repo.all()
+
+ render(conn, "index.json", %{
+ users: users,
+ source: :staff,
+ for: user,
+ skip_visibility_check: true
+ })
+ end
+
+ defp exclude_user(query, %User{id: user_id}) do
+ where(query, [u], u.id != ^user_id)
+ 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_following(query, %User{id: user_id}) do
+ query
+ |> join(:left, [u], r in FollowingRelationship,
+ as: :following_relationships,
+ on: r.following_id == u.id and r.follower_id == ^user_id and r.state == :follow_accept
+ )
+ |> where([following_relationships: r], is_nil(r.following_id))
+ end
+
+ @doc "DELETE /api/v1/suggestions/:account_id"
+ def dismiss(%{assigns: %{user: source}} = conn, %{account_id: user_id}) do
+ with %User{} = target <- User.get_cached_by_id(user_id),
+ {:ok, _} <- UserRelationship.create(:suggestion_dismiss, source, target) do
+ json(conn, %{})
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex
new file mode 100644
index 0000000..293c61b
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex
@@ -0,0 +1,210 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.TimelineController do
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper,
+ only: [add_link_headers: 2, add_link_headers: 3]
+
+ alias Pleroma.Config
+ alias Pleroma.Pagination
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+ alias Pleroma.Web.Plugs.RateLimiter
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(:skip_public_check when action in [:public, :hashtag])
+
+ # TODO: Replace with a macro when there is a Phoenix release with the following commit in it:
+ # https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e
+
+ plug(RateLimiter, [name: :timeline, bucket_name: :direct_timeline] when action == :direct)
+ plug(RateLimiter, [name: :timeline, bucket_name: :public_timeline] when action == :public)
+ plug(RateLimiter, [name: :timeline, bucket_name: :home_timeline] when action == :home)
+ plug(RateLimiter, [name: :timeline, bucket_name: :hashtag_timeline] when action == :hashtag)
+ plug(RateLimiter, [name: :timeline, bucket_name: :list_timeline] when action == :list)
+
+ plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct])
+ plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated}
+ when action in [:public, :hashtag]
+ )
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation
+
+ # GET /api/v1/timelines/home
+ def home(%{assigns: %{user: user}} = conn, params) do
+ params =
+ params
+ |> Map.put(:type, ["Create", "Announce"])
+ |> Map.put(:blocking_user, user)
+ |> Map.put(:muting_user, user)
+ |> Map.put(:reply_filtering_user, user)
+ |> Map.put(:announce_filtering_user, user)
+ |> Map.put(:user, user)
+ |> Map.put(:local_only, params[:local])
+ |> Map.delete(:local)
+
+ activities =
+ [user.ap_id | User.following(user)]
+ |> ActivityPub.fetch_activities(params)
+ |> Enum.reverse()
+
+ conn
+ |> add_link_headers(activities)
+ |> render("index.json",
+ activities: activities,
+ for: user,
+ as: :activity,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ end
+
+ # GET /api/v1/timelines/direct
+ def direct(%{assigns: %{user: user}} = conn, params) do
+ params =
+ params
+ |> Map.put(:type, "Create")
+ |> Map.put(:blocking_user, user)
+ |> Map.put(:user, user)
+ |> Map.put(:visibility, "direct")
+
+ activities =
+ [user.ap_id]
+ |> ActivityPub.fetch_activities_query(params)
+ |> Pagination.fetch_paginated(params)
+
+ conn
+ |> add_link_headers(activities)
+ |> render("index.json",
+ activities: activities,
+ for: user,
+ as: :activity
+ )
+ end
+
+ defp restrict_unauthenticated?(true = _local_only) do
+ Config.restrict_unauthenticated_access?(:timelines, :local)
+ end
+
+ defp restrict_unauthenticated?(_) do
+ Config.restrict_unauthenticated_access?(:timelines, :federated)
+ end
+
+ # GET /api/v1/timelines/public
+ def public(%{assigns: %{user: user}} = conn, params) do
+ local_only = params[:local]
+
+ if is_nil(user) and restrict_unauthenticated?(local_only) do
+ fail_on_bad_auth(conn)
+ else
+ activities =
+ params
+ |> Map.put(:type, ["Create"])
+ |> Map.put(:local_only, local_only)
+ |> Map.put(:blocking_user, user)
+ |> 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
+ |> add_link_headers(activities, %{"local" => local_only})
+ |> render("index.json",
+ activities: activities,
+ for: user,
+ as: :activity,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ end
+ end
+
+ defp fail_on_bad_auth(conn) do
+ render_error(conn, :unauthorized, "authorization required for timeline view")
+ end
+
+ defp hashtag_fetching(params, user, local_only) do
+ # Note: not sanitizing tag options at this stage (may be mix-cased, have duplicates etc.)
+ tags_any =
+ [params[:tag], params[:any]]
+ |> List.flatten()
+ |> Enum.filter(& &1)
+
+ tag_all = Map.get(params, :all, [])
+ tag_reject = Map.get(params, :none, [])
+
+ params
+ |> Map.put(:type, "Create")
+ |> Map.put(:local_only, local_only)
+ |> Map.put(:blocking_user, user)
+ |> Map.put(:muting_user, user)
+ |> Map.put(:user, user)
+ |> Map.put(:tag, tags_any)
+ |> Map.put(:tag_all, tag_all)
+ |> Map.put(:tag_reject, tag_reject)
+ |> ActivityPub.fetch_public_activities()
+ end
+
+ # GET /api/v1/timelines/tag/:tag
+ def hashtag(%{assigns: %{user: user}} = conn, params) do
+ local_only = params[:local]
+
+ if is_nil(user) and restrict_unauthenticated?(local_only) do
+ fail_on_bad_auth(conn)
+ else
+ activities = hashtag_fetching(params, user, local_only)
+
+ conn
+ |> add_link_headers(activities, %{"local" => local_only})
+ |> render("index.json",
+ activities: activities,
+ for: user,
+ as: :activity,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ end
+ end
+
+ # GET /api/v1/timelines/list/:list_id
+ def list(%{assigns: %{user: user}} = conn, %{list_id: id} = params) do
+ with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do
+ params =
+ params
+ |> Map.put(:type, "Create")
+ |> Map.put(:blocking_user, user)
+ |> Map.put(:user, user)
+ |> Map.put(:muting_user, user)
+ |> Map.put(:local_only, params[:local])
+
+ # we must filter the following list for the user to avoid leaking statuses the user
+ # does not actually have permission to see (for more info, peruse security issue #270).
+
+ user_following = User.following(user)
+
+ activities =
+ following
+ |> Enum.filter(fn x -> x in user_following end)
+ |> ActivityPub.fetch_activities_bounded(following, params)
+ |> Enum.reverse()
+
+ conn
+ |> add_link_headers(activities)
+ |> render("index.json",
+ activities: activities,
+ for: user,
+ as: :activity,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ else
+ _e -> render_error(conn, :forbidden, "Error.")
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex
new file mode 100644
index 0000000..467dc2f
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex
@@ -0,0 +1,121 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
+ import Ecto.Query
+ import Ecto.Changeset
+
+ alias Pleroma.Notification
+ alias Pleroma.Pagination
+ alias Pleroma.ScheduledActivity
+ alias Pleroma.User
+ alias Pleroma.Web.CommonAPI
+
+ @spec follow(User.t(), User.t(), map) :: {:ok, User.t()} | {:error, String.t()}
+ def follow(follower, followed, params \\ %{}) do
+ result =
+ if not User.following?(follower, followed) do
+ CommonAPI.follow(follower, followed)
+ else
+ {:ok, follower, followed, nil}
+ end
+
+ with {:ok, follower, _followed, _} <- result do
+ options = cast_params(params)
+ set_reblogs_visibility(options[:reblogs], result)
+ set_subscription(options[:notify], result)
+ {:ok, follower}
+ end
+ end
+
+ defp set_reblogs_visibility(false, {:ok, follower, followed, _}) do
+ CommonAPI.hide_reblogs(follower, followed)
+ end
+
+ defp set_reblogs_visibility(_, {:ok, follower, followed, _}) do
+ CommonAPI.show_reblogs(follower, followed)
+ end
+
+ defp set_subscription(true, {:ok, follower, followed, _}) do
+ User.subscribe(follower, followed)
+ end
+
+ defp set_subscription(false, {:ok, follower, followed, _}) do
+ User.unsubscribe(follower, followed)
+ end
+
+ defp set_subscription(_, _), do: {:ok, nil}
+
+ @spec get_followers(User.t(), map()) :: list(User.t())
+ def get_followers(user, params \\ %{}) do
+ user
+ |> User.get_followers_query()
+ |> Pagination.fetch_paginated(params)
+ end
+
+ def get_friends(user, params \\ %{}) do
+ user
+ |> User.get_friends_query()
+ |> Pagination.fetch_paginated(params)
+ end
+
+ def get_notifications(user, params \\ %{}) do
+ options =
+ cast_params(params) |> Map.update(:include_types, [], fn include_types -> include_types end)
+
+ options =
+ if ("pleroma:report" not in options.include_types and
+ User.privileged?(user, :reports_manage_reports)) or
+ User.privileged?(user, :reports_manage_reports) do
+ options
+ else
+ options
+ |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types ->
+ current_exclude_types ++ ["pleroma:report"]
+ end)
+ end
+
+ user
+ |> Notification.for_user_query(options)
+ |> restrict(:types, options)
+ |> restrict(:exclude_types, options)
+ |> restrict(:account_ap_id, options)
+ |> Pagination.fetch_paginated(params)
+ end
+
+ def get_scheduled_activities(user, params \\ %{}) do
+ user
+ |> ScheduledActivity.for_user_query()
+ |> Pagination.fetch_paginated(params)
+ end
+
+ defp cast_params(params) do
+ param_types = %{
+ exclude_types: {:array, :string},
+ types: {:array, :string},
+ exclude_visibilities: {:array, :string},
+ reblogs: :boolean,
+ with_muted: :boolean,
+ account_ap_id: :string,
+ notify: :boolean
+ }
+
+ changeset = cast({%{}, param_types}, params, Map.keys(param_types))
+ changeset.changes
+ end
+
+ defp restrict(query, :types, %{types: mastodon_types = [_ | _]}) do
+ where(query, [n], n.type in ^mastodon_types)
+ end
+
+ defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do
+ where(query, [n], n.type not in ^mastodon_types)
+ end
+
+ defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do
+ where(query, [n, a], a.actor == ^account_ap_id)
+ end
+
+ defp restrict(query, _, _), do: query
+end
diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex
new file mode 100644
index 0000000..cc3e358
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/account_view.ex
@@ -0,0 +1,467 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.AccountView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.FollowingRelationship
+ alias Pleroma.User
+ alias Pleroma.UserNote
+ alias Pleroma.UserRelationship
+ alias Pleroma.Web.CommonAPI.Utils
+ alias Pleroma.Web.MastodonAPI.AccountView
+ alias Pleroma.Web.MediaProxy
+
+ def render("index.json", %{users: users} = opts) do
+ reading_user = opts[:for]
+
+ relationships_opt =
+ cond do
+ Map.has_key?(opts, :relationships) ->
+ opts[:relationships]
+
+ is_nil(reading_user) || !opts[:embed_relationships] ->
+ UserRelationship.view_relationships_option(nil, [])
+
+ true ->
+ UserRelationship.view_relationships_option(reading_user, users)
+ end
+
+ opts =
+ opts
+ |> Map.merge(%{relationships: relationships_opt, as: :user})
+ |> Map.delete(:users)
+
+ users
+ |> render_many(AccountView, "show.json", opts)
+ |> Enum.filter(&Enum.any?/1)
+ end
+
+ @doc """
+ Renders specified user account.
+ :skip_visibility_check option skips visibility check and renders any user (local or remote)
+ regardless of [:pleroma, :restrict_unauthenticated] setting.
+ :for option specifies the requester and can be a User record or nil.
+ Only use `user: user, for: user` when `user` is the actual requester of own profile.
+ """
+ def render("show.json", %{user: _user, skip_visibility_check: true} = opts) do
+ do_render("show.json", opts)
+ end
+
+ def render("show.json", %{user: user, for: for_user_or_nil} = opts) do
+ if User.visible_for(user, for_user_or_nil) == :visible do
+ do_render("show.json", opts)
+ else
+ %{}
+ end
+ end
+
+ def render("show.json", _) do
+ raise "In order to prevent account accessibility issues, " <>
+ ":skip_visibility_check or :for option is required."
+ end
+
+ def render("mention.json", %{user: user}) do
+ %{
+ id: to_string(user.id),
+ acct: user.nickname,
+ username: username_from_nickname(user.nickname),
+ url: user.uri || user.ap_id
+ }
+ end
+
+ def render("relationship.json", %{user: nil, target: _target}) do
+ %{}
+ end
+
+ def render(
+ "relationship.json",
+ %{user: %User{} = reading_user, target: %User{} = target} = opts
+ ) do
+ user_relationships = get_in(opts, [:relationships, :user_relationships])
+ following_relationships = get_in(opts, [:relationships, :following_relationships])
+
+ follow_state =
+ if following_relationships do
+ user_to_target_following_relation =
+ FollowingRelationship.find(following_relationships, reading_user, target)
+
+ User.get_follow_state(reading_user, target, user_to_target_following_relation)
+ else
+ User.get_follow_state(reading_user, target)
+ end
+
+ followed_by =
+ if following_relationships do
+ case FollowingRelationship.find(following_relationships, target, reading_user) do
+ %{state: :follow_accept} -> true
+ _ -> false
+ end
+ else
+ User.following?(target, reading_user)
+ end
+
+ subscribing =
+ UserRelationship.exists?(
+ user_relationships,
+ :inverse_subscription,
+ target,
+ reading_user,
+ &User.subscribed_to?(&2, &1)
+ )
+
+ # NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags
+ %{
+ id: to_string(target.id),
+ following: follow_state == :follow_accept,
+ followed_by: followed_by,
+ blocking:
+ UserRelationship.exists?(
+ user_relationships,
+ :block,
+ reading_user,
+ target,
+ &User.blocks_user?(&1, &2)
+ ),
+ blocked_by:
+ UserRelationship.exists?(
+ user_relationships,
+ :block,
+ target,
+ reading_user,
+ &User.blocks_user?(&1, &2)
+ ),
+ muting:
+ UserRelationship.exists?(
+ user_relationships,
+ :mute,
+ reading_user,
+ target,
+ &User.mutes?(&1, &2)
+ ),
+ muting_notifications:
+ UserRelationship.exists?(
+ user_relationships,
+ :notification_mute,
+ reading_user,
+ target,
+ &User.muted_notifications?(&1, &2)
+ ),
+ subscribing: subscribing,
+ notifying: subscribing,
+ requested: follow_state == :follow_pending,
+ domain_blocking: User.blocks_domain?(reading_user, target),
+ showing_reblogs:
+ not UserRelationship.exists?(
+ user_relationships,
+ :reblog_mute,
+ reading_user,
+ target,
+ &User.muting_reblogs?(&1, &2)
+ ),
+ note:
+ UserNote.show(
+ reading_user,
+ target
+ ),
+ endorsed:
+ UserRelationship.exists?(
+ user_relationships,
+ :endorsement,
+ target,
+ reading_user,
+ &User.endorses?(&2, &1)
+ )
+ }
+ end
+
+ def render("relationships.json", %{user: user, targets: targets} = opts) do
+ relationships_opt =
+ cond do
+ Map.has_key?(opts, :relationships) ->
+ opts[:relationships]
+
+ is_nil(user) ->
+ UserRelationship.view_relationships_option(nil, [])
+
+ true ->
+ UserRelationship.view_relationships_option(user, targets)
+ end
+
+ render_opts = %{as: :target, user: user, relationships: relationships_opt}
+ render_many(targets, AccountView, "relationship.json", render_opts)
+ end
+
+ defp do_render("show.json", %{user: user} = opts) do
+ user = User.sanitize_html(user, User.html_filter_policy(opts[:for]))
+ display_name = user.name || user.nickname
+
+ avatar = User.avatar_url(user) |> MediaProxy.url()
+ avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(static: true)
+ header = User.banner_url(user) |> MediaProxy.url()
+ header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true)
+
+ following_count =
+ if !user.hide_follows_count or !user.hide_follows or opts[:for] == user,
+ do: user.following_count,
+ else: 0
+
+ followers_count =
+ if !user.hide_followers_count or !user.hide_followers or opts[:for] == user,
+ do: user.follower_count,
+ else: 0
+
+ bot = user.actor_type == "Service"
+
+ emojis =
+ Enum.map(user.emoji, fn {shortcode, raw_url} ->
+ url = MediaProxy.url(raw_url)
+
+ %{
+ shortcode: shortcode,
+ url: url,
+ static_url: url,
+ visible_in_picker: false
+ }
+ end)
+
+ relationship =
+ if opts[:embed_relationships] do
+ render("relationship.json", %{
+ user: opts[:for],
+ target: user,
+ relationships: opts[:relationships]
+ })
+ else
+ %{}
+ end
+
+ favicon =
+ if Pleroma.Config.get([:instances_favicons, :enabled]) do
+ user
+ |> Map.get(:ap_id, "")
+ |> URI.parse()
+ |> URI.merge("/")
+ |> Pleroma.Instances.Instance.get_or_update_favicon()
+ |> MediaProxy.url()
+ else
+ nil
+ end
+
+ %{
+ id: to_string(user.id),
+ username: username_from_nickname(user.nickname),
+ acct: user.nickname,
+ display_name: display_name,
+ locked: user.is_locked,
+ created_at: Utils.to_masto_date(user.inserted_at),
+ followers_count: followers_count,
+ following_count: following_count,
+ statuses_count: user.note_count,
+ note: user.bio,
+ url: user.uri || user.ap_id,
+ avatar: avatar,
+ avatar_static: avatar_static,
+ header: header,
+ header_static: header_static,
+ emojis: emojis,
+ fields: user.fields,
+ bot: bot,
+ source: %{
+ note: user.raw_bio || "",
+ sensitive: false,
+ fields: user.raw_fields,
+ pleroma: %{
+ discoverable: user.is_discoverable,
+ 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
+ fqn: User.full_nickname(user),
+ pleroma: %{
+ ap_id: user.ap_id,
+ also_known_as: user.also_known_as,
+ is_confirmed: user.is_confirmed,
+ is_suggested: user.is_suggested,
+ tags: user.tags,
+ hide_followers_count: user.hide_followers_count,
+ hide_follows_count: user.hide_follows_count,
+ hide_followers: user.hide_followers,
+ hide_follows: user.hide_follows,
+ hide_favorites: user.hide_favorites,
+ relationship: relationship,
+ skip_thread_containment: user.skip_thread_containment,
+ background_image: image_url(user.background) |> MediaProxy.url(),
+ accepts_chat_messages: user.accepts_chat_messages,
+ favicon: favicon
+ }
+ }
+ |> maybe_put_role(user, opts[:for])
+ |> maybe_put_settings(user, opts[:for], opts)
+ |> maybe_put_notification_settings(user, opts[:for])
+ |> maybe_put_settings_store(user, opts[:for], opts)
+ |> maybe_put_chat_token(user, opts[:for], opts)
+ |> maybe_put_activation_status(user, opts[:for])
+ |> maybe_put_follow_requests_count(user, opts[:for])
+ |> maybe_put_allow_following_move(user, opts[:for])
+ |> maybe_put_unread_conversation_count(user, opts[:for])
+ |> maybe_put_unread_notification_count(user, opts[:for])
+ |> maybe_put_email_address(user, opts[:for])
+ |> maybe_put_mute_expires_at(user, opts[:for], opts)
+ |> maybe_show_birthday(user, opts[:for])
+ end
+
+ defp username_from_nickname(string) when is_binary(string) do
+ hd(String.split(string, "@"))
+ end
+
+ defp username_from_nickname(_), do: nil
+
+ defp maybe_put_follow_requests_count(
+ data,
+ %User{id: user_id} = user,
+ %User{id: user_id}
+ ) do
+ count =
+ User.get_follow_requests(user)
+ |> length()
+
+ data
+ |> Kernel.put_in([:follow_requests_count], count)
+ end
+
+ defp maybe_put_follow_requests_count(data, _, _), do: data
+
+ defp maybe_put_settings(
+ data,
+ %User{id: user_id} = user,
+ %User{id: user_id},
+ _opts
+ ) do
+ data
+ |> Kernel.put_in([:source, :privacy], user.default_scope)
+ |> Kernel.put_in([:source, :pleroma, :show_role], user.show_role)
+ |> Kernel.put_in([:source, :pleroma, :no_rich_text], user.no_rich_text)
+ |> Kernel.put_in([:source, :pleroma, :show_birthday], user.show_birthday)
+ end
+
+ defp maybe_put_settings(data, _, _, _), do: data
+
+ defp maybe_put_settings_store(data, %User{} = user, %User{}, %{
+ with_pleroma_settings: true
+ }) do
+ data
+ |> Kernel.put_in([:pleroma, :settings_store], user.pleroma_settings_store)
+ end
+
+ defp maybe_put_settings_store(data, _, _, _), do: data
+
+ defp maybe_put_chat_token(data, %User{id: id}, %User{id: id}, %{
+ with_chat_token: token
+ }) do
+ data
+ |> Kernel.put_in([:pleroma, :chat_token], token)
+ end
+
+ defp maybe_put_chat_token(data, _, _, _), do: data
+
+ defp maybe_put_role(data, %User{show_role: true} = user, _) do
+ put_role(data, user)
+ end
+
+ defp maybe_put_role(data, %User{id: user_id} = user, %User{id: user_id}) do
+ put_role(data, user)
+ end
+
+ defp maybe_put_role(data, _, _), do: data
+
+ defp put_role(data, user) do
+ data
+ |> Kernel.put_in([:pleroma, :is_admin], user.is_admin)
+ |> Kernel.put_in([:pleroma, :is_moderator], user.is_moderator)
+ |> Kernel.put_in([:pleroma, :privileges], User.privileges(user))
+ end
+
+ defp maybe_put_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do
+ Kernel.put_in(
+ data,
+ [:pleroma, :notification_settings],
+ Map.from_struct(user.notification_settings)
+ )
+ end
+
+ defp maybe_put_notification_settings(data, _, _), do: data
+
+ defp maybe_put_allow_following_move(data, %User{id: user_id} = user, %User{id: user_id}) do
+ Kernel.put_in(data, [:pleroma, :allow_following_move], user.allow_following_move)
+ end
+
+ defp maybe_put_allow_following_move(data, _, _), do: data
+
+ defp maybe_put_activation_status(data, user, user_for) do
+ if User.privileged?(user_for, :users_manage_activation_state),
+ do: Kernel.put_in(data, [:pleroma, :deactivated], !user.is_active),
+ else: data
+ end
+
+ defp maybe_put_unread_conversation_count(data, %User{id: user_id} = user, %User{id: user_id}) do
+ data
+ |> Kernel.put_in(
+ [:pleroma, :unread_conversation_count],
+ Pleroma.Conversation.Participation.unread_count(user)
+ )
+ end
+
+ defp maybe_put_unread_conversation_count(data, _, _), do: data
+
+ defp maybe_put_unread_notification_count(data, %User{id: user_id}, %User{id: user_id} = user) do
+ Kernel.put_in(
+ data,
+ [:pleroma, :unread_notifications_count],
+ Pleroma.Notification.unread_notifications_count(user)
+ )
+ end
+
+ defp maybe_put_unread_notification_count(data, _, _), do: data
+
+ defp maybe_put_email_address(data, %User{id: user_id}, %User{id: user_id} = user) do
+ Kernel.put_in(
+ data,
+ [:pleroma, :email],
+ user.email
+ )
+ end
+
+ defp maybe_put_email_address(data, _, _), do: data
+
+ defp maybe_put_mute_expires_at(data, %User{} = user, target, %{mutes: true}) do
+ Map.put(
+ data,
+ :mute_expires_at,
+ UserRelationship.get_mute_expire_date(target, user)
+ )
+ end
+
+ defp maybe_put_mute_expires_at(data, _, _, _), do: data
+
+ defp maybe_show_birthday(data, %User{id: user_id} = user, %User{id: user_id}) do
+ data
+ |> Kernel.put_in([:pleroma, :birthday], user.birthday)
+ end
+
+ defp maybe_show_birthday(data, %User{show_birthday: true} = user, _) do
+ data
+ |> Kernel.put_in([:pleroma, :birthday], user.birthday)
+ end
+
+ defp maybe_show_birthday(data, _, _) do
+ data
+ end
+
+ defp image_url(%{"url" => [%{"href" => href} | _]}), do: href
+ defp image_url(_), do: nil
+end
diff --git a/lib/pleroma/web/mastodon_api/views/announcement_view.ex b/lib/pleroma/web/mastodon_api/views/announcement_view.ex
new file mode 100644
index 0000000..93fdfb1
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/announcement_view.ex
@@ -0,0 +1,15 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.AnnouncementView do
+ use Pleroma.Web, :view
+
+ def render("index.json", %{announcements: announcements, user: user}) do
+ render_many(announcements, __MODULE__, "show.json", user: user)
+ end
+
+ def render("show.json", %{announcement: announcement, user: user}) do
+ Pleroma.Announcement.render_json(announcement, for: user)
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/app_view.ex b/lib/pleroma/web/mastodon_api/views/app_view.ex
new file mode 100644
index 0000000..92cccd4
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/app_view.ex
@@ -0,0 +1,50 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.AppView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.Web.OAuth.App
+
+ def render("index.json", %{apps: apps, count: count, page_size: page_size, admin: true}) do
+ %{
+ apps: render_many(apps, Pleroma.Web.MastodonAPI.AppView, "show.json", %{admin: true}),
+ count: count,
+ page_size: page_size
+ }
+ end
+
+ def render("show.json", %{admin: true, app: %App{} = app} = assigns) do
+ "show.json"
+ |> render(Map.delete(assigns, :admin))
+ |> Map.put(:trusted, app.trusted)
+ |> Map.put(:id, app.id)
+ end
+
+ def render("show.json", %{app: %App{} = app}) do
+ %{
+ id: app.id |> to_string,
+ name: app.client_name,
+ client_id: app.client_id,
+ client_secret: app.client_secret,
+ redirect_uri: app.redirect_uris,
+ website: app.website
+ }
+ |> with_vapid_key()
+ end
+
+ def render("compact_non_secret.json", %{app: %App{website: website, client_name: name}}) do
+ %{
+ name: name,
+ website: website
+ }
+ |> with_vapid_key()
+ end
+
+ defp with_vapid_key(data) do
+ vapid_key = Application.get_env(:web_push_encryption, :vapid_details, [])[:public_key]
+
+ Pleroma.Maps.put_if_present(data, "vapid_key", vapid_key)
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex
new file mode 100644
index 0000000..f6577cd
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex
@@ -0,0 +1,58 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ConversationView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.Activity
+ alias Pleroma.Repo
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.MastodonAPI.AccountView
+ alias Pleroma.Web.MastodonAPI.StatusView
+
+ def render("participations.json", %{participations: participations, for: user}) do
+ safe_render_many(participations, __MODULE__, "participation.json", %{
+ as: :participation,
+ for: user
+ })
+ end
+
+ def render("participation.json", %{participation: participation, for: user}) do
+ participation = Repo.preload(participation, conversation: [], recipients: [])
+
+ last_activity_id =
+ with nil <- participation.last_activity_id do
+ ActivityPub.fetch_latest_direct_activity_id_for_context(
+ participation.conversation.ap_id,
+ %{
+ user: user,
+ blocking_user: user
+ }
+ )
+ end
+
+ activity = Activity.get_by_id_with_object(last_activity_id)
+
+ # Conversations return all users except the current user,
+ # except when the current user is the only participant
+ users =
+ if length(participation.recipients) > 1 do
+ Enum.reject(participation.recipients, &(&1.id == user.id))
+ else
+ participation.recipients
+ end
+
+ %{
+ id: participation.id |> to_string(),
+ accounts: render(AccountView, "index.json", users: users, for: user),
+ unread: !participation.read,
+ last_status:
+ render(StatusView, "show.json",
+ activity: activity,
+ direct_conversation_id: participation.id,
+ for: user
+ )
+ }
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex
new file mode 100644
index 0000000..cd59ab9
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex
@@ -0,0 +1,28 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.CustomEmojiView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.Emoji
+ alias Pleroma.Web.Endpoint
+
+ def render("index.json", %{custom_emojis: custom_emojis}) do
+ render_many(custom_emojis, __MODULE__, "show.json")
+ end
+
+ def render("show.json", %{custom_emoji: {shortcode, %Emoji{file: relative_url, tags: tags}}}) do
+ url = Endpoint.url() |> URI.merge(relative_url) |> to_string()
+
+ %{
+ "shortcode" => shortcode,
+ "static_url" => url,
+ "visible_in_picker" => true,
+ "url" => url,
+ "tags" => tags,
+ # Assuming that a comma is authorized in the category name
+ "category" => tags |> List.delete("Custom") |> Enum.join(",")
+ }
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex
new file mode 100644
index 0000000..0c97061
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.FilterView do
+ use Pleroma.Web, :view
+ alias Pleroma.Web.CommonAPI.Utils
+ alias Pleroma.Web.MastodonAPI.FilterView
+
+ def render("index.json", %{filters: filters}) do
+ render_many(filters, FilterView, "show.json")
+ end
+
+ def render("show.json", %{filter: filter}) do
+ expires_at =
+ if filter.expires_at do
+ Utils.to_masto_date(filter.expires_at)
+ else
+ nil
+ end
+
+ %{
+ id: to_string(filter.filter_id),
+ phrase: filter.phrase,
+ context: filter.context,
+ expires_at: expires_at,
+ irreversible: filter.hide,
+ whole_word: filter.whole_word
+ }
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/follow_request_view.ex b/lib/pleroma/web/mastodon_api/views/follow_request_view.ex
new file mode 100644
index 0000000..5e50bc2
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/follow_request_view.ex
@@ -0,0 +1,10 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.FollowRequestView do
+ use Pleroma.Web, :view
+ alias Pleroma.Web.MastodonAPI
+
+ def render(view, opts), do: MastodonAPI.AccountView.render(view, opts)
+end
diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex
new file mode 100644
index 0000000..abf7f29
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex
@@ -0,0 +1,142 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.InstanceView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.Config
+ alias Pleroma.Web.ActivityPub.MRF
+
+ @mastodon_api_level "2.7.2"
+
+ def render("show.json", _) do
+ instance = Config.get(:instance)
+
+ %{
+ uri: Pleroma.Web.Endpoint.url(),
+ title: Keyword.get(instance, :name),
+ description: Keyword.get(instance, :description),
+ short_description: Keyword.get(instance, :short_description),
+ version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
+ email: Keyword.get(instance, :email),
+ urls: %{
+ streaming_api: Pleroma.Web.Endpoint.websocket_url()
+ },
+ stats: Pleroma.Stats.get_stats(),
+ thumbnail:
+ URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail))
+ |> to_string,
+ languages: Keyword.get(instance, :languages, ["en"]),
+ registrations: Keyword.get(instance, :registrations_open),
+ approval_required: Keyword.get(instance, :account_approval_required),
+ # Extra (not present in Mastodon):
+ max_toot_chars: Keyword.get(instance, :limit),
+ max_media_attachments: Keyword.get(instance, :max_media_attachments),
+ poll_limits: Keyword.get(instance, :poll_limits),
+ upload_limit: Keyword.get(instance, :upload_limit),
+ avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit),
+ background_upload_limit: Keyword.get(instance, :background_upload_limit),
+ banner_upload_limit: Keyword.get(instance, :banner_upload_limit),
+ background_image: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image),
+ shout_limit: Config.get([:shout, :limit]),
+ description_limit: Keyword.get(instance, :description_limit),
+ pleroma: %{
+ metadata: %{
+ account_activation_required: Keyword.get(instance, :account_activation_required),
+ features: features(),
+ federation: federation(),
+ fields_limits: fields_limits(),
+ post_formats: Config.get([:instance, :allowed_post_formats]),
+ birthday_required: Config.get([:instance, :birthday_required]),
+ birthday_min_age: Config.get([:instance, :birthday_min_age])
+ },
+ stats: %{mau: Pleroma.User.active_user_count()},
+ vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
+ }
+ }
+ end
+
+ def features do
+ [
+ "pleroma_api",
+ "mastodon_api",
+ "mastodon_api_streaming",
+ "polls",
+ "v2_suggestions",
+ "pleroma_explicit_addressing",
+ "shareable_emoji_packs",
+ "multifetch",
+ "pleroma:api/v1/notifications:include_types_filter",
+ "editing",
+ if Config.get([:activitypub, :blockers_visible]) do
+ "blockers_visible"
+ end,
+ if Config.get([:media_proxy, :enabled]) do
+ "media_proxy"
+ end,
+ if Config.get([:gopher, :enabled]) do
+ "gopher"
+ end,
+ # backwards compat
+ if Config.get([:shout, :enabled]) do
+ "chat"
+ end,
+ if Config.get([:shout, :enabled]) do
+ "shout"
+ end,
+ if Config.get([:instance, :allow_relay]) do
+ "relay"
+ end,
+ if Config.get([:instance, :safe_dm_mentions]) do
+ "safe_dm_mentions"
+ end,
+ "pleroma_emoji_reactions",
+ "pleroma_chat_messages",
+ if Config.get([:instance, :show_reactions]) do
+ "exposable_reactions"
+ end,
+ if Config.get([:instance, :profile_directory]) do
+ "profile_directory"
+ end,
+ "pleroma:get:main/ostatus"
+ ]
+ |> Enum.filter(& &1)
+ end
+
+ def federation do
+ quarantined = Config.get([:instance, :quarantined_instances], [])
+
+ if Config.get([:mrf, :transparency]) do
+ {:ok, data} = MRF.describe()
+
+ data
+ |> Map.put(
+ :quarantined_instances,
+ Enum.map(quarantined, fn {instance, _reason} -> instance end)
+ )
+ # This is for backwards compatibility. We originally didn't sent
+ # extra info like a reason why an instance was rejected/quarantined/etc.
+ # Because we didn't want to break backwards compatibility it was decided
+ # to add an extra "info" key.
+ |> Map.put(:quarantined_instances_info, %{
+ "quarantined_instances" =>
+ quarantined
+ |> Enum.map(fn {instance, reason} -> {instance, %{"reason" => reason}} end)
+ |> Map.new()
+ })
+ else
+ %{}
+ end
+ |> Map.put(:enabled, Config.get([:instance, :federating]))
+ end
+
+ def fields_limits do
+ %{
+ max_fields: Config.get([:instance, :max_account_fields]),
+ max_remote_fields: Config.get([:instance, :max_remote_account_fields]),
+ name_length: Config.get([:instance, :account_field_name_length]),
+ value_length: Config.get([:instance, :account_field_value_length])
+ }
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/list_view.ex b/lib/pleroma/web/mastodon_api/views/list_view.ex
new file mode 100644
index 0000000..a7ae7c5
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/list_view.ex
@@ -0,0 +1,19 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ListView do
+ use Pleroma.Web, :view
+ alias Pleroma.Web.MastodonAPI.ListView
+
+ def render("index.json", %{lists: lists} = opts) do
+ render_many(lists, ListView, "show.json", opts)
+ end
+
+ def render("show.json", %{list: list}) do
+ %{
+ id: to_string(list.id),
+ title: list.title
+ }
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex
new file mode 100644
index 0000000..944769b
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.MarkerView do
+ use Pleroma.Web, :view
+
+ def render("markers.json", %{markers: markers}) do
+ Map.new(markers, fn m ->
+ {m.timeline,
+ %{
+ last_read_id: m.last_read_id,
+ version: m.lock_version,
+ updated_at: NaiveDateTime.to_iso8601(m.updated_at),
+ pleroma: %{
+ unread_count: m.unread_count
+ }
+ }}
+ end)
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/media_view.ex b/lib/pleroma/web/mastodon_api/views/media_view.ex
new file mode 100644
index 0000000..4db72de
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/media_view.ex
@@ -0,0 +1,10 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.MediaView do
+ use Pleroma.Web, :view
+ alias Pleroma.Web.MastodonAPI
+
+ def render(view, opts), do: MastodonAPI.StatusView.render(view, opts)
+end
diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex
new file mode 100644
index 0000000..b5b5b23
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex
@@ -0,0 +1,176 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.NotificationView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.Activity
+ alias Pleroma.Chat.MessageReference
+ alias Pleroma.Notification
+ alias Pleroma.Object
+ alias Pleroma.User
+ alias Pleroma.UserRelationship
+ alias Pleroma.Web.AdminAPI.Report
+ alias Pleroma.Web.AdminAPI.ReportView
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.MastodonAPI.AccountView
+ alias Pleroma.Web.MastodonAPI.NotificationView
+ alias Pleroma.Web.MastodonAPI.StatusView
+ alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView
+
+ defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
+
+ defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
+
+ @parent_types ~w{Like Announce EmojiReact Update}
+
+ def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
+ activities = Enum.map(notifications, & &1.activity)
+
+ parent_activities =
+ activities
+ |> Enum.filter(fn
+ %{data: %{"type" => type}} ->
+ type in @parent_types
+ end)
+ |> Enum.map(&object_id_for/1)
+ |> Activity.create_by_object_ap_id()
+ |> Activity.with_preloaded_object(:left)
+ |> Pleroma.Repo.all()
+
+ relationships_opt =
+ cond do
+ Map.has_key?(opts, :relationships) ->
+ opts[:relationships]
+
+ is_nil(reading_user) ->
+ UserRelationship.view_relationships_option(nil, [])
+
+ true ->
+ move_activities_targets =
+ activities
+ |> Enum.filter(&(&1.data["type"] == "Move"))
+ |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"]))
+ |> Enum.filter(& &1)
+
+ actors =
+ activities
+ |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end)
+ |> Enum.filter(& &1)
+ |> Kernel.++(move_activities_targets)
+
+ UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
+ end
+
+ opts =
+ opts
+ |> Map.put(:parent_activities, parent_activities)
+ |> Map.put(:relationships, relationships_opt)
+
+ safe_render_many(notifications, NotificationView, "show.json", opts)
+ end
+
+ def render(
+ "show.json",
+ %{
+ notification: %Notification{activity: activity} = notification,
+ for: reading_user
+ } = opts
+ ) do
+ actor = User.get_cached_by_ap_id(activity.data["actor"])
+
+ parent_activity_fn = fn ->
+ if opts[:parent_activities] do
+ Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity))
+ else
+ Activity.get_create_by_object_ap_id(object_id_for(activity))
+ end
+ end
+
+ # Note: :relationships contain user mutes (needed for :muted flag in :status)
+ status_render_opts = %{relationships: opts[:relationships]}
+ account = AccountView.render("show.json", %{user: actor, for: reading_user})
+
+ response = %{
+ id: to_string(notification.id),
+ type: notification.type,
+ created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at),
+ account: account,
+ pleroma: %{
+ is_muted: User.mutes?(reading_user, actor),
+ is_seen: notification.seen
+ }
+ }
+
+ case notification.type do
+ "mention" ->
+ put_status(response, activity, reading_user, status_render_opts)
+
+ "favourite" ->
+ put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
+
+ "reblog" ->
+ put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
+
+ "update" ->
+ put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
+
+ "move" ->
+ put_target(response, activity, reading_user, %{})
+
+ "poll" ->
+ put_status(response, activity, reading_user, status_render_opts)
+
+ "pleroma:emoji_reaction" ->
+ response
+ |> put_status(parent_activity_fn.(), reading_user, status_render_opts)
+ |> put_emoji(activity)
+
+ "pleroma:chat_mention" ->
+ put_chat_message(response, activity, reading_user, status_render_opts)
+
+ "pleroma:report" ->
+ put_report(response, activity)
+
+ type when type in ["follow", "follow_request"] ->
+ response
+ end
+ end
+
+ defp put_report(response, activity) do
+ report_render = ReportView.render("show.json", Report.extract_report_info(activity))
+
+ Map.put(response, :report, report_render)
+ end
+
+ defp put_emoji(response, activity) do
+ Map.put(response, :emoji, activity.data["content"])
+ end
+
+ defp put_chat_message(response, activity, reading_user, opts) do
+ object = Object.normalize(activity, fetch: false)
+ author = User.get_cached_by_ap_id(object.data["actor"])
+ chat = Pleroma.Chat.get(reading_user.id, author.ap_id)
+ cm_ref = MessageReference.for_chat_and_object(chat, object)
+ render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref})
+ chat_message_render = MessageReferenceView.render("show.json", render_opts)
+
+ Map.put(response, :chat_message, chat_message_render)
+ end
+
+ defp put_status(response, activity, reading_user, opts) do
+ status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user})
+ status_render = StatusView.render("show.json", status_render_opts)
+
+ Map.put(response, :status, status_render)
+ end
+
+ defp put_target(response, activity, reading_user, opts) do
+ target_user = User.get_cached_by_ap_id(activity.data["target"])
+ target_render_opts = Map.merge(opts, %{user: target_user, for: reading_user})
+ target_render = AccountView.render("show.json", target_render_opts)
+
+ Map.put(response, :target, target_render)
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex
new file mode 100644
index 0000000..34e2387
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex
@@ -0,0 +1,102 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.PollView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.Web.CommonAPI.Utils
+
+ def render("show.json", %{object: object, multiple: multiple, options: options} = params) do
+ {end_time, expired} = end_time_and_expired(object)
+ {options, votes_count} = options_and_votes_count(options)
+
+ poll = %{
+ # Mastodon uses separate ids for polls, but an object can't have
+ # more than one poll embedded so object id is fine
+ id: to_string(object.id),
+ expires_at: end_time,
+ expired: expired,
+ multiple: multiple,
+ votes_count: votes_count,
+ voters_count: voters_count(object),
+ options: options,
+ emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"])
+ }
+
+ if params[:for] do
+ # when unauthenticated Mastodon doesn't include `voted` & `own_votes` keys in response
+ {voted, own_votes} = voted_and_own_votes(params, options)
+ Map.merge(poll, %{voted: voted, own_votes: own_votes})
+ else
+ poll
+ end
+ end
+
+ def render("show.json", %{object: object} = params) do
+ case object.data do
+ %{"anyOf" => [_ | _] = options} ->
+ render(__MODULE__, "show.json", Map.merge(params, %{multiple: true, options: options}))
+
+ %{"oneOf" => [_ | _] = options} ->
+ render(__MODULE__, "show.json", Map.merge(params, %{multiple: false, options: options}))
+
+ _ ->
+ nil
+ end
+ end
+
+ defp end_time_and_expired(object) do
+ if object.data["closed"] do
+ end_time = NaiveDateTime.from_iso8601!(object.data["closed"])
+ expired = NaiveDateTime.compare(end_time, NaiveDateTime.utc_now()) == :lt
+
+ {Utils.to_masto_date(end_time), expired}
+ else
+ {nil, false}
+ end
+ end
+
+ defp options_and_votes_count(options) do
+ Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
+ current_count = option["replies"]["totalItems"] || 0
+
+ {%{
+ title: name,
+ votes_count: current_count
+ }, current_count + count}
+ end)
+ end
+
+ defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do
+ length(voters)
+ end
+
+ defp voters_count(_), do: 0
+
+ defp voted_and_own_votes(%{object: object} = params, options) do
+ if params[:for] do
+ existing_votes =
+ Pleroma.Web.ActivityPub.Utils.get_existing_votes(params[:for].ap_id, object)
+
+ voted = existing_votes != [] or params[:for].ap_id == object.data["actor"]
+
+ own_votes =
+ if voted do
+ titles = Enum.map(options, & &1[:title])
+
+ Enum.reduce(existing_votes, [], fn vote, acc ->
+ data = vote |> Map.get(:object) |> Map.get(:data)
+ index = Enum.find_index(titles, &(&1 == data["name"]))
+ [index | acc]
+ end)
+ else
+ []
+ end
+
+ {voted, own_votes}
+ else
+ {false, []}
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/report_view.ex b/lib/pleroma/web/mastodon_api/views/report_view.ex
new file mode 100644
index 0000000..983f7bd
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/report_view.ex
@@ -0,0 +1,14 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ReportView do
+ use Pleroma.Web, :view
+
+ def render("show.json", %{activity: activity}) do
+ %{
+ id: to_string(activity.id),
+ action_taken: false
+ }
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex
new file mode 100644
index 0000000..772d22f
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex
@@ -0,0 +1,45 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do
+ use Pleroma.Web, :view
+
+ alias Pleroma.ScheduledActivity
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.MastodonAPI.StatusView
+
+ def render("index.json", %{scheduled_activities: scheduled_activities}) do
+ render_many(scheduled_activities, __MODULE__, "show.json")
+ end
+
+ def render("show.json", %{scheduled_activity: %ScheduledActivity{} = scheduled_activity}) do
+ %{
+ id: to_string(scheduled_activity.id),
+ scheduled_at: CommonAPI.Utils.to_masto_date(scheduled_activity.scheduled_at),
+ params: status_params(scheduled_activity.params)
+ }
+ |> with_media_attachments(scheduled_activity)
+ end
+
+ defp with_media_attachments(data, %{params: %{"media_attachments" => media_attachments}}) do
+ attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment)
+ Map.put(data, :media_attachments, attachments)
+ end
+
+ defp with_media_attachments(data, _), do: data
+
+ defp status_params(params) do
+ %{
+ text: params["status"],
+ sensitive: params["sensitive"],
+ spoiler_text: params["spoiler_text"],
+ visibility: params["visibility"],
+ scheduled_at: params["scheduled_at"],
+ poll: params["poll"],
+ in_reply_to_id: params["in_reply_to_id"],
+ expires_in: params["expires_in"]
+ }
+ |> Pleroma.Maps.put_if_present(:media_ids, params["media_ids"])
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex
new file mode 100644
index 0000000..0a8c98b
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/status_view.ex
@@ -0,0 +1,757 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.StatusView do
+ use Pleroma.Web, :view
+
+ require Pleroma.Constants
+
+ alias Pleroma.Activity
+ alias Pleroma.HTML
+ alias Pleroma.Maps
+ alias Pleroma.Object
+ alias Pleroma.Repo
+ alias Pleroma.User
+ alias Pleroma.UserRelationship
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.CommonAPI.Utils
+ alias Pleroma.Web.MastodonAPI.AccountView
+ alias Pleroma.Web.MastodonAPI.PollView
+ alias Pleroma.Web.MastodonAPI.StatusView
+ alias Pleroma.Web.MediaProxy
+ alias Pleroma.Web.PleromaAPI.EmojiReactionController
+
+ import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
+
+ # This is a naive way to do this, just spawning a process per activity
+ # to fetch the preview. However it should be fine considering
+ # pagination is restricted to 40 activities at a time
+ defp fetch_rich_media_for_activities(activities) do
+ Enum.each(activities, fn activity ->
+ spawn(fn ->
+ Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
+ end)
+ end)
+ end
+
+ # TODO: Add cached version.
+ defp get_replied_to_activities([]), do: %{}
+
+ defp get_replied_to_activities(activities) do
+ activities
+ |> Enum.map(fn
+ %{data: %{"type" => "Create"}} = activity ->
+ object = Object.normalize(activity, fetch: false)
+ object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
+
+ _ ->
+ nil
+ end)
+ |> Enum.filter(& &1)
+ |> Activity.create_by_object_ap_id_with_object()
+ |> Repo.all()
+ |> Enum.reduce(%{}, fn activity, acc ->
+ object = Object.normalize(activity, fetch: false)
+ if object, do: Map.put(acc, object.data["id"], activity), else: acc
+ end)
+ end
+
+ # DEPRECATED This field seems to be a left-over from the StatusNet era.
+ # If your application uses `pleroma.conversation_id`: this field is deprecated.
+ # It is currently stubbed instead by doing a CRC32 of the context, and
+ # clearing the MSB to avoid overflow exceptions with signed integers on the
+ # different clients using this field (Java/Kotlin code, mostly; see Husky.)
+ # This should be removed in a future version of Pleroma. Pleroma-FE currently
+ # depends on this field, as well.
+ defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do
+ import Bitwise
+
+ :erlang.crc32(context)
+ |> band(bnot(0x8000_0000))
+ end
+
+ defp get_context_id(_), do: nil
+
+ # Check if the user reblogged this status
+ defp reblogged?(activity, %User{ap_id: ap_id}) do
+ with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
+ Object.normalize(activity, fetch: false) do
+ ap_id in announcements
+ else
+ _ -> false
+ end
+ end
+
+ # False if the user is logged out
+ defp reblogged?(_activity, _user), do: false
+
+ def render("index.json", opts) do
+ reading_user = opts[:for]
+
+ # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
+ activities = Enum.filter(opts.activities, & &1)
+
+ # Start fetching rich media before doing anything else, so that later calls to get the cards
+ # only block for timeout in the worst case, as opposed to
+ # length(activities_with_links) * timeout
+ fetch_rich_media_for_activities(activities)
+ replied_to_activities = get_replied_to_activities(activities)
+
+ parent_activities =
+ activities
+ |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
+ |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
+ |> Activity.create_by_object_ap_id()
+ |> Activity.with_preloaded_object(:left)
+ |> Activity.with_preloaded_bookmark(reading_user)
+ |> Activity.with_set_thread_muted_field(reading_user)
+ |> Repo.all()
+
+ relationships_opt =
+ cond do
+ Map.has_key?(opts, :relationships) ->
+ opts[:relationships]
+
+ is_nil(reading_user) ->
+ UserRelationship.view_relationships_option(nil, [])
+
+ true ->
+ # Note: unresolved users are filtered out
+ actors =
+ (activities ++ parent_activities)
+ |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
+ |> Enum.filter(& &1)
+
+ UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
+ end
+
+ opts =
+ opts
+ |> Map.put(:replied_to_activities, replied_to_activities)
+ |> Map.put(:parent_activities, parent_activities)
+ |> Map.put(:relationships, relationships_opt)
+
+ safe_render_many(activities, StatusView, "show.json", opts)
+ end
+
+ def render(
+ "show.json",
+ %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
+ ) do
+ user = CommonAPI.get_user(activity.data["actor"])
+ created_at = Utils.to_masto_date(activity.data["published"])
+ object = Object.normalize(activity, fetch: false)
+
+ reblogged_parent_activity =
+ if opts[:parent_activities] do
+ Activity.Queries.find_by_object_ap_id(
+ opts[:parent_activities],
+ object.data["id"]
+ )
+ else
+ Activity.create_by_object_ap_id(object.data["id"])
+ |> Activity.with_preloaded_bookmark(opts[:for])
+ |> Activity.with_set_thread_muted_field(opts[:for])
+ |> Repo.one()
+ end
+
+ reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
+ reblogged = render("show.json", reblog_rendering_opts)
+
+ favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
+
+ bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
+
+ mentions =
+ activity.recipients
+ |> Enum.map(fn 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)
+
+ {pinned?, pinned_at} = pin_data(object, user)
+
+ %{
+ id: to_string(activity.id),
+ uri: object.data["id"],
+ url: object.data["id"],
+ account:
+ AccountView.render("show.json", %{
+ user: user,
+ for: opts[:for]
+ }),
+ in_reply_to_id: nil,
+ in_reply_to_account_id: nil,
+ reblog: reblogged,
+ content: reblogged[:content] || "",
+ created_at: created_at,
+ reblogs_count: 0,
+ replies_count: 0,
+ favourites_count: 0,
+ reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
+ favourited: present?(favorited),
+ bookmarked: present?(bookmarked),
+ muted: false,
+ pinned: pinned?,
+ sensitive: false,
+ spoiler_text: "",
+ visibility: get_visibility(activity),
+ media_attachments: reblogged[:media_attachments] || [],
+ mentions: mentions,
+ tags: reblogged[:tags] || [],
+ application: build_application(object.data["generator"]),
+ language: nil,
+ emojis: [],
+ pleroma: %{
+ local: activity.local,
+ pinned_at: pinned_at
+ }
+ }
+ end
+
+ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
+ object = Object.normalize(activity, fetch: false)
+
+ user = CommonAPI.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
+
+ hashtags = Object.hashtags(object)
+ sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
+
+ tags = Object.tags(object)
+
+ tag_mentions =
+ tags
+ |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
+ |> Enum.map(fn tag -> tag["href"] end)
+
+ mentions =
+ (object.data["to"] ++ tag_mentions)
+ |> Enum.uniq()
+ |> 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)
+
+ favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
+
+ bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
+
+ client_posted_this_activity = opts[:for] && user.id == opts[:for].id
+
+ expires_at =
+ with true <- client_posted_this_activity,
+ %Oban.Job{scheduled_at: scheduled_at} <-
+ Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
+ scheduled_at
+ else
+ _ -> nil
+ end
+
+ thread_muted? =
+ cond do
+ is_nil(opts[:for]) -> false
+ is_boolean(activity.thread_muted?) -> activity.thread_muted?
+ true -> CommonAPI.thread_muted?(opts[:for], activity)
+ end
+
+ attachment_data = object.data["attachment"] || []
+ attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
+
+ created_at = Utils.to_masto_date(object.data["published"])
+
+ edited_at =
+ with %{"updated" => updated} <- object.data,
+ date <- Utils.to_masto_date(updated),
+ true <- date != "" do
+ date
+ else
+ _ ->
+ nil
+ end
+
+ reply_to = get_reply_to(activity, opts)
+
+ reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
+
+ history_len =
+ 1 +
+ (Object.Updater.history_for(object.data)
+ |> Map.get("orderedItems")
+ |> length())
+
+ # See render("history.json", ...) for more details
+ # Here the implicit index of the current content is 0
+ chrono_order = history_len - 1
+
+ content =
+ object
+ |> render_content()
+
+ content_html =
+ content
+ |> Activity.HTML.get_cached_scrubbed_html_for_activity(
+ User.html_filter_policy(opts[:for]),
+ activity,
+ "mastoapi:content:#{chrono_order}"
+ )
+
+ content_plaintext =
+ content
+ |> Activity.HTML.get_cached_stripped_html_for_activity(
+ activity,
+ "mastoapi:content:#{chrono_order}"
+ )
+
+ summary = object.data["summary"] || ""
+
+ card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
+
+ url =
+ if user.local do
+ Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
+ else
+ object.data["url"] || object.data["external_url"] || object.data["id"]
+ end
+
+ direct_conversation_id =
+ with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
+ {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
+ {_, %User{} = for_user} <- {:for_user, opts[:for]} do
+ Activity.direct_conversation_id(activity, for_user)
+ else
+ {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
+ participation_id
+
+ _e ->
+ nil
+ end
+
+ emoji_reactions =
+ object.data
+ |> Map.get("reactions", [])
+ |> EmojiReactionController.filter_allowed_users(
+ opts[:for],
+ Map.get(opts, :with_muted, false)
+ )
+ |> Stream.map(fn {emoji, users} ->
+ build_emoji_map(emoji, users, opts[:for])
+ end)
+ |> Enum.to_list()
+
+ # Status muted state (would do 1 request per status unless user mutes are preloaded)
+ muted =
+ thread_muted? ||
+ UserRelationship.exists?(
+ get_in(opts, [:relationships, :user_relationships]),
+ :mute,
+ opts[:for],
+ user,
+ fn for_user, user -> User.mutes?(for_user, user) end
+ )
+
+ {pinned?, pinned_at} = pin_data(object, user)
+
+ %{
+ id: to_string(activity.id),
+ uri: object.data["id"],
+ url: url,
+ account:
+ AccountView.render("show.json", %{
+ user: user,
+ for: opts[:for]
+ }),
+ in_reply_to_id: reply_to && to_string(reply_to.id),
+ in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
+ reblog: nil,
+ card: card,
+ content: content_html,
+ text: opts[:with_source] && get_source_text(object.data["source"]),
+ created_at: created_at,
+ edited_at: edited_at,
+ reblogs_count: announcement_count,
+ replies_count: object.data["repliesCount"] || 0,
+ favourites_count: like_count,
+ reblogged: reblogged?(activity, opts[:for]),
+ favourited: present?(favorited),
+ bookmarked: present?(bookmarked),
+ muted: muted,
+ pinned: pinned?,
+ sensitive: sensitive,
+ spoiler_text: summary,
+ visibility: get_visibility(object),
+ media_attachments: attachments,
+ poll: render(PollView, "show.json", object: object, for: opts[:for]),
+ mentions: mentions,
+ tags: build_tags(tags),
+ application: build_application(object.data["generator"]),
+ language: nil,
+ emojis: build_emojis(object.data["emoji"]),
+ pleroma: %{
+ local: activity.local,
+ conversation_id: get_context_id(activity),
+ context: object.data["context"],
+ in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
+ content: %{"text/plain" => content_plaintext},
+ spoiler_text: %{"text/plain" => summary},
+ expires_at: expires_at,
+ direct_conversation_id: direct_conversation_id,
+ thread_muted: thread_muted?,
+ emoji_reactions: emoji_reactions,
+ parent_visible: visible_for_user?(reply_to, opts[:for]),
+ pinned_at: pinned_at
+ }
+ }
+ end
+
+ def render("show.json", _) do
+ nil
+ end
+
+ def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
+ object = Object.normalize(activity, fetch: false)
+
+ hashtags = Object.hashtags(object)
+
+ user = CommonAPI.get_user(activity.data["actor"])
+
+ past_history =
+ Object.Updater.history_for(object.data)
+ |> Map.get("orderedItems")
+ |> Enum.map(&Map.put(&1, "id", object.data["id"]))
+ |> Enum.map(&%Object{data: &1, id: object.id})
+
+ history =
+ [object | past_history]
+ # Mastodon expects the original to be at the first
+ |> Enum.reverse()
+ |> Enum.with_index()
+ |> Enum.map(fn {object, chrono_order} ->
+ %{
+ # The history is prepended every time there is a new edit.
+ # In chrono_order, the oldest item is always at 0, and so on.
+ # The chrono_order is an invariant kept between edits.
+ chrono_order: chrono_order,
+ object: object
+ }
+ end)
+
+ individual_opts =
+ opts
+ |> Map.put(:as, :item)
+ |> Map.put(:user, user)
+ |> Map.put(:hashtags, hashtags)
+
+ render_many(history, StatusView, "history_item.json", individual_opts)
+ end
+
+ def render(
+ "history_item.json",
+ %{
+ activity: activity,
+ user: user,
+ item: %{object: object, chrono_order: chrono_order},
+ hashtags: hashtags
+ } = opts
+ ) do
+ sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
+
+ attachment_data = object.data["attachment"] || []
+ attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
+
+ created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
+
+ content =
+ object
+ |> render_content()
+
+ content_html =
+ content
+ |> Activity.HTML.get_cached_scrubbed_html_for_activity(
+ User.html_filter_policy(opts[:for]),
+ activity,
+ "mastoapi:content:#{chrono_order}"
+ )
+
+ summary = object.data["summary"] || ""
+
+ %{
+ account:
+ AccountView.render("show.json", %{
+ user: user,
+ for: opts[:for]
+ }),
+ content: content_html,
+ sensitive: sensitive,
+ spoiler_text: summary,
+ created_at: created_at,
+ media_attachments: attachments,
+ emojis: build_emojis(object.data["emoji"]),
+ poll: render(PollView, "show.json", object: object, for: opts[:for])
+ }
+ end
+
+ def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
+ object = Object.normalize(activity, fetch: false)
+
+ %{
+ id: activity.id,
+ text: get_source_text(Map.get(object.data, "source", "")),
+ spoiler_text: Map.get(object.data, "summary", ""),
+ content_type: get_source_content_type(object.data["source"])
+ }
+ end
+
+ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
+ page_url_data = URI.parse(page_url)
+
+ page_url_data =
+ if is_binary(rich_media["url"]) do
+ URI.merge(page_url_data, URI.parse(rich_media["url"]))
+ else
+ page_url_data
+ end
+
+ page_url = page_url_data |> to_string
+
+ image_url_data =
+ if is_binary(rich_media["image"]) do
+ URI.parse(rich_media["image"])
+ else
+ nil
+ end
+
+ image_url = build_image_url(image_url_data, page_url_data)
+
+ %{
+ type: "link",
+ provider_name: page_url_data.host,
+ provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
+ url: page_url,
+ image: image_url |> MediaProxy.url(),
+ title: rich_media["title"] || "",
+ description: rich_media["description"] || "",
+ pleroma: %{
+ opengraph: rich_media
+ }
+ }
+ end
+
+ def render("card.json", _), do: nil
+
+ def render("attachment.json", %{attachment: attachment}) do
+ [attachment_url | _] = attachment["url"]
+ media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
+ href = attachment_url["href"] |> MediaProxy.url()
+ href_preview = attachment_url["href"] |> MediaProxy.preview_url()
+ meta = render("attachment_meta.json", %{attachment: attachment})
+
+ type =
+ cond do
+ String.contains?(media_type, "image") -> "image"
+ String.contains?(media_type, "video") -> "video"
+ String.contains?(media_type, "audio") -> "audio"
+ true -> "unknown"
+ end
+
+ attachment_id =
+ with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
+ {_, %Object{data: _object_data, id: object_id}} <-
+ {:object, Object.get_by_ap_id(ap_id)} do
+ to_string(object_id)
+ else
+ _ ->
+ <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
+ to_string(attachment["id"] || hash_id)
+ end
+
+ %{
+ id: attachment_id,
+ url: href,
+ remote_url: href,
+ preview_url: href_preview,
+ text_url: href,
+ type: type,
+ description: attachment["name"],
+ pleroma: %{mime_type: media_type},
+ blurhash: attachment["blurhash"]
+ }
+ |> Maps.put_if_present(:meta, meta)
+ end
+
+ def render("attachment_meta.json", %{
+ attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
+ })
+ when is_integer(width) and is_integer(height) do
+ %{
+ original: %{
+ width: width,
+ height: height,
+ aspect: width / height
+ }
+ }
+ end
+
+ def render("attachment_meta.json", _), do: nil
+
+ def render("context.json", %{activity: activity, activities: activities, user: user}) do
+ %{ancestors: ancestors, descendants: descendants} =
+ activities
+ |> Enum.reverse()
+ |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
+ |> Map.put_new(:ancestors, [])
+ |> Map.put_new(:descendants, [])
+
+ %{
+ ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
+ descendants: render("index.json", for: user, activities: descendants, as: :activity)
+ }
+ end
+
+ def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
+ object = Object.normalize(activity, fetch: false)
+
+ with nil <- replied_to_activities[object.data["inReplyTo"]] do
+ # If user didn't participate in the thread
+ Activity.get_in_reply_to_activity(activity)
+ end
+ end
+
+ def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
+ object = Object.normalize(activity, fetch: false)
+
+ if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
+ Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
+ else
+ nil
+ end
+ end
+
+ def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
+ url = object.data["url"] || object.data["id"]
+
+ "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
+ end
+
+ def render_content(object), do: object.data["content"] || ""
+
+ @doc """
+ Builds a dictionary tags.
+
+ ## Examples
+
+ iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
+ [{"name": "fediverse", "url": "/tag/fediverse"},
+ {"name": "nextcloud", "url": "/tag/nextcloud"}]
+
+ """
+ @spec build_tags(list(any())) :: list(map())
+ def build_tags(object_tags) when is_list(object_tags) do
+ object_tags
+ |> Enum.filter(&is_binary/1)
+ |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
+ end
+
+ def build_tags(_), do: []
+
+ @doc """
+ Builds list emojis.
+
+ Arguments: `nil` or list tuple of name and url.
+
+ Returns list emojis.
+
+ ## Examples
+
+ iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
+ [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
+
+ """
+ @spec build_emojis(nil | list(tuple())) :: list(map())
+ def build_emojis(nil), do: []
+
+ def build_emojis(emojis) do
+ emojis
+ |> Enum.map(fn {name, url} ->
+ name = HTML.strip_tags(name)
+
+ url =
+ url
+ |> HTML.strip_tags()
+ |> MediaProxy.url()
+
+ %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
+ end)
+ end
+
+ defp present?(nil), do: false
+ defp present?(false), do: false
+ defp present?(_), do: true
+
+ defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
+ if pinned_at = pinned_objects[object_id] do
+ {true, Utils.to_masto_date(pinned_at)}
+ else
+ {false, nil}
+ end
+ end
+
+ defp build_emoji_map(emoji, users, current_user) do
+ %{
+ name: emoji,
+ count: length(users),
+ me: !!(current_user && current_user.ap_id in users)
+ }
+ end
+
+ @spec build_application(map() | nil) :: map() | nil
+ defp build_application(%{"type" => _type, "name" => name, "url" => url}),
+ do: %{name: name, website: url}
+
+ defp build_application(_), do: nil
+
+ # Workaround for Elixir issue #10771
+ # Avoid applying URI.merge unless necessary
+ # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
+ # when Elixir 1.12 is the minimum supported version
+ @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
+ defp build_image_url(
+ %URI{scheme: image_scheme, host: image_host} = image_url_data,
+ %URI{} = _page_url_data
+ )
+ when not is_nil(image_scheme) and not is_nil(image_host) do
+ image_url_data |> to_string
+ end
+
+ defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
+ URI.merge(page_url_data, image_url_data) |> to_string
+ end
+
+ defp build_image_url(_, _), do: nil
+
+ defp get_source_text(%{"content" => content} = _source) do
+ content
+ end
+
+ defp get_source_text(source) when is_binary(source) do
+ source
+ end
+
+ defp get_source_text(_) do
+ ""
+ end
+
+ defp get_source_content_type(%{"mediaType" => type} = _source) do
+ type
+ end
+
+ defp get_source_content_type(_source) do
+ Utils.get_content_type(nil)
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/subscription_view.ex b/lib/pleroma/web/mastodon_api/views/subscription_view.ex
new file mode 100644
index 0000000..baa1e03
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/subscription_view.ex
@@ -0,0 +1,19 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.SubscriptionView do
+ use Pleroma.Web, :view
+ alias Pleroma.Web.Push
+
+ def render("show.json", %{subscription: subscription}) do
+ %{
+ id: to_string(subscription.id),
+ endpoint: subscription.endpoint,
+ alerts: Map.get(subscription.data, "alerts"),
+ server_key: server_key()
+ }
+ end
+
+ defp server_key, do: Keyword.get(Push.vapid_config(), :public_key)
+end
diff --git a/lib/pleroma/web/mastodon_api/views/suggestion_view.ex b/lib/pleroma/web/mastodon_api/views/suggestion_view.ex
new file mode 100644
index 0000000..d3df4ef
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/suggestion_view.ex
@@ -0,0 +1,28 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.SuggestionView do
+ use Pleroma.Web, :view
+ alias Pleroma.Web.MastodonAPI.AccountView
+
+ @source_types [:staff, :global, :past_interactions]
+
+ def render("index.json", %{users: users} = opts) do
+ Enum.map(users, fn user ->
+ opts =
+ opts
+ |> Map.put(:user, user)
+ |> Map.delete(:users)
+
+ render("show.json", opts)
+ end)
+ end
+
+ def render("show.json", %{source: source, user: _user} = opts) when source in @source_types do
+ %{
+ source: source,
+ account: AccountView.render("show.json", opts)
+ }
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/views/timeline_view.ex b/lib/pleroma/web/mastodon_api/views/timeline_view.ex
new file mode 100644
index 0000000..702eb7e
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/timeline_view.ex
@@ -0,0 +1,10 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.TimelineView do
+ use Pleroma.Web, :view
+ alias Pleroma.Web.MastodonAPI
+
+ def render(view, opts), do: MastodonAPI.StatusView.render(view, opts)
+end
diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex
new file mode 100644
index 0000000..8844410
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex
@@ -0,0 +1,140 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
+ require Logger
+
+ alias Pleroma.Repo
+ alias Pleroma.User
+ alias Pleroma.Web.OAuth.Token
+ alias Pleroma.Web.Streamer
+
+ @behaviour :cowboy_websocket
+
+ # Client ping period.
+ @tick :timer.seconds(30)
+ # Cowboy timeout period.
+ @timeout :timer.seconds(60)
+ # Hibernate every X messages
+ @hibernate_every 100
+
+ def init(%{qs: qs} = req, state) do
+ with params <- Enum.into(:cow_qs.parse_qs(qs), %{}),
+ sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil),
+ access_token <- Map.get(params, "access_token"),
+ {:ok, user, oauth_token} <- authenticate_request(access_token, sec_websocket),
+ {:ok, topic} <- Streamer.get_topic(params["stream"], user, oauth_token, params) do
+ req =
+ if sec_websocket do
+ :cowboy_req.set_resp_header("sec-websocket-protocol", sec_websocket, req)
+ else
+ req
+ end
+
+ {:cowboy_websocket, req,
+ %{user: user, topic: topic, oauth_token: oauth_token, count: 0, timer: nil},
+ %{idle_timeout: @timeout}}
+ else
+ {:error, :bad_topic} ->
+ Logger.debug("#{__MODULE__} bad topic #{inspect(req)}")
+ req = :cowboy_req.reply(404, req)
+ {:ok, req, state}
+
+ {:error, :unauthorized} ->
+ Logger.debug("#{__MODULE__} authentication error: #{inspect(req)}")
+ req = :cowboy_req.reply(401, req)
+ {:ok, req, state}
+ end
+ end
+
+ def websocket_init(state) do
+ Logger.debug(
+ "#{__MODULE__} accepted websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topic #{state.topic}"
+ )
+
+ Streamer.add_socket(state.topic, state.oauth_token)
+ {:ok, %{state | timer: timer()}}
+ end
+
+ # Client's Pong frame.
+ def websocket_handle(:pong, state) do
+ if state.timer, do: Process.cancel_timer(state.timer)
+ {:ok, %{state | timer: timer()}}
+ end
+
+ # We only receive pings for now
+ def websocket_handle(:ping, state), do: {:ok, state}
+
+ def websocket_handle(frame, state) do
+ Logger.error("#{__MODULE__} received frame: #{inspect(frame)}")
+ {:ok, state}
+ end
+
+ def websocket_info({:render_with_user, view, template, item}, state) do
+ user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
+
+ unless Streamer.filtered_by_user?(user, item) do
+ websocket_info({:text, view.render(template, item, user)}, %{state | user: user})
+ else
+ {:ok, state}
+ end
+ end
+
+ def websocket_info({:text, message}, state) do
+ # If the websocket processed X messages, force an hibernate/GC.
+ # We don't hibernate at every message to balance CPU usage/latency with RAM usage.
+ if state.count > @hibernate_every do
+ {:reply, {:text, message}, %{state | count: 0}, :hibernate}
+ else
+ {:reply, {:text, message}, %{state | count: state.count + 1}}
+ end
+ end
+
+ # Ping tick. We don't re-queue a timer there, it is instead queued when :pong is received.
+ # As we hibernate there, reset the count to 0.
+ # If the client misses :pong, Cowboy will automatically timeout the connection after
+ # `@idle_timeout`.
+ def websocket_info(:tick, state) do
+ {:reply, :ping, %{state | timer: nil, count: 0}, :hibernate}
+ end
+
+ def websocket_info(:close, state) do
+ {:stop, state}
+ end
+
+ # State can be `[]` only in case we terminate before switching to websocket,
+ # we already log errors for these cases in `init/1`, so just do nothing here
+ def terminate(_reason, _req, []), do: :ok
+
+ def terminate(reason, _req, state) do
+ Logger.debug(
+ "#{__MODULE__} terminating websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topic #{state.topic || "?"}: #{inspect(reason)}"
+ )
+
+ Streamer.remove_socket(state.topic)
+ :ok
+ end
+
+ # Public streams without authentication.
+ defp authenticate_request(nil, nil) do
+ {:ok, nil, nil}
+ end
+
+ # Authenticated streams.
+ defp authenticate_request(access_token, sec_websocket) do
+ token = access_token || sec_websocket
+
+ with true <- is_bitstring(token),
+ oauth_token = %Token{user_id: user_id} <- Repo.get_by(Token, token: token),
+ user = %User{} <- User.get_cached_by_id(user_id) do
+ {:ok, user, oauth_token}
+ else
+ _ -> {:error, :unauthorized}
+ end
+ end
+
+ defp timer do
+ Process.send_after(self(), :tick, @tick)
+ end
+end