Merge remote-tracking branch 'upstream/develop' into registration-workflow
[akkoma] / lib / pleroma / web / mastodon_api / controllers / mastodon_api_controller.ex
index ee644abe30da46dbac18c9e76a153e7d6fd487f5..9cf682c7b8a5dd163b35701ef805c2c8d927fffd 100644 (file)
 # Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
-  use Pleroma.Web, :controller
+  @moduledoc """
+  Contains stubs for unimplemented Mastodon API endpoints.
 
-  import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
+  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.
+  """
 
-  alias Pleroma.Activity
-  alias Pleroma.Bookmark
-  alias Pleroma.Config
-  alias Pleroma.HTTP
-  alias Pleroma.Object
-  alias Pleroma.Pagination
-  alias Pleroma.Plugs.OAuthScopesPlug
-  alias Pleroma.Plugs.RateLimiter
-  alias Pleroma.Repo
-  alias Pleroma.Stats
-  alias Pleroma.User
-  alias Pleroma.Web
-  alias Pleroma.Web.ActivityPub.ActivityPub
-  alias Pleroma.Web.ActivityPub.Visibility
-  alias Pleroma.Web.CommonAPI
-  alias Pleroma.Web.MastodonAPI.AccountView
-  alias Pleroma.Web.MastodonAPI.AppView
-  alias Pleroma.Web.MastodonAPI.MastodonView
-  alias Pleroma.Web.MastodonAPI.StatusView
-  alias Pleroma.Web.MediaProxy
-  alias Pleroma.Web.OAuth.App
-  alias Pleroma.Web.OAuth.Authorization
-  alias Pleroma.Web.OAuth.Scopes
-  alias Pleroma.Web.OAuth.Token
-  alias Pleroma.Web.TwitterAPI.TwitterAPI
+  use Pleroma.Web, :controller
 
   require Logger
 
-  @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
-
-  # Note: :index action handles attempt of unauthenticated access to private instance with redirect
-  plug(
-    OAuthScopesPlug,
-    Map.merge(@unauthenticated_access, %{scopes: ["read"], skip_instance_privacy_check: true})
-    when action == :index
-  )
-
-  plug(
-    OAuthScopesPlug,
-    %{scopes: ["read"]} when action in [:suggestions, :verify_app_credentials]
-  )
-
-  plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :put_settings)
-
-  plug(
-    OAuthScopesPlug,
-    %{@unauthenticated_access | scopes: ["read:statuses"]} when action == :get_poll
-  )
-
-  plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :poll_vote)
-
-  plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites)
-
-  plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action in [:upload, :update_media])
-
-  plug(
-    OAuthScopesPlug,
-    %{scopes: ["follow", "read:blocks"]} when action == :blocks
-  )
-
-  # To do: POST /api/v1/follows is not present in Mastodon; consider removing the action
   plug(
-    OAuthScopesPlug,
-    %{scopes: ["follow", "write:follows"]} when action == :follows
+    :skip_plug,
+    [Pleroma.Web.Plugs.OAuthScopesPlug, Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug]
+    when action in [:empty_array, :empty_object]
   )
 
-  plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes)
-
-  # Note: scope not present in Mastodon: read:bookmarks
-  plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :bookmarks)
-
-  # An extra safety measure for possible actions not guarded by OAuth permissions specification
-  plug(
-    Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
-    when action not in [
-           :create_app,
-           :index,
-           :login,
-           :logout,
-           :password_reset,
-           :masto_instance,
-           :peers,
-           :custom_emojis
-         ]
-  )
-
-  plug(RateLimiter, :password_reset when action == :password_reset)
-
-  @local_mastodon_name "Mastodon-Local"
-
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
-  def create_app(conn, params) do
-    scopes = Scopes.fetch_scopes(params, ["read"])
-
-    app_attrs =
-      params
-      |> Map.drop(["scope", "scopes"])
-      |> Map.put("scopes", scopes)
-
-    with cs <- App.register_changeset(%App{}, app_attrs),
-         false <- cs.changes[:client_name] == @local_mastodon_name,
-         {:ok, app} <- Repo.insert(cs) do
-      conn
-      |> put_view(AppView)
-      |> render("show.json", %{app: app})
-    end
-  end
-
-  def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
-    with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
-      conn
-      |> put_view(AppView)
-      |> render("short.json", %{app: app})
-    end
-  end
-
-  @mastodon_api_level "2.7.2"
-
-  def masto_instance(conn, _params) do
-    instance = Config.get(:instance)
-
-    response = %{
-      uri: Web.base_url(),
-      title: Keyword.get(instance, :name),
-      description: Keyword.get(instance, :description),
-      version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
-      email: Keyword.get(instance, :email),
-      urls: %{
-        streaming_api: Pleroma.Web.Endpoint.websocket_url()
-      },
-      stats: Stats.get_stats(),
-      thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
-      languages: ["en"],
-      registrations: Pleroma.Config.get([:instance, :registrations_open]),
-      # Extra (not present in Mastodon):
-      max_toot_chars: Keyword.get(instance, :limit),
-      poll_limits: Keyword.get(instance, :poll_limits)
-    }
-
-    json(conn, response)
-  end
-
-  def peers(conn, _params) do
-    json(conn, Stats.get_peers())
-  end
-
-  defp mastodonized_emoji do
-    Pleroma.Emoji.get_all()
-    |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} ->
-      url = to_string(URI.merge(Web.base_url(), relative_url))
-
-      %{
-        "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 -- ["Custom"]) |> Enum.join(",")
-      }
-    end)
-  end
-
-  def custom_emojis(conn, _params) do
-    mastodon_emoji = mastodonized_emoji()
-    json(conn, mastodon_emoji)
-  end
-
-  def get_poll(%{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
-      conn
-      |> put_view(StatusView)
-      |> try_render("poll.json", %{object: object, for: user})
-    else
-      error when is_nil(error) or error == false ->
-        render_error(conn, :not_found, "Record not found")
-    end
-  end
-
-  defp get_cached_vote_or_vote(user, object, choices) do
-    idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
-
-    {_, res} =
-      Cachex.fetch(:idempotency_cache, idempotency_key, fn _ ->
-        case CommonAPI.vote(user, object, choices) do
-          {:error, _message} = res -> {:ignore, res}
-          res -> {:commit, res}
-        end
-      end)
-
-    res
-  end
-
-  def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do
-    with %Object{} = object <- Object.get_by_id(id),
-         true <- object.data["type"] == "Question",
-         %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
-      conn
-      |> put_view(StatusView)
-      |> try_render("poll.json", %{object: object, for: user})
-    else
-      nil ->
-        render_error(conn, :not_found, "Record not found")
-
-      false ->
-        render_error(conn, :not_found, "Record not found")
-
-      {:error, message} ->
-        conn
-        |> put_status(:unprocessable_entity)
-        |> json(%{error: message})
-    end
-  end
-
-  def update_media(
-        %{assigns: %{user: user}} = conn,
-        %{"id" => id, "description" => description} = _
-      )
-      when is_binary(description) do
-    with %Object{} = object <- Repo.get(Object, id),
-         true <- Object.authorize_mutation(object, user),
-         {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do
-      attachment_data = Map.put(data, "id", object.id)
-
-      conn
-      |> put_view(StatusView)
-      |> render("attachment.json", %{attachment: attachment_data})
-    end
-  end
-
-  def update_media(_conn, _data), do: {:error, :bad_request}
-
-  def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) 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_view(StatusView)
-      |> render("attachment.json", %{attachment: attachment_data})
-    end
-  end
-
-  def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
-    with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
-         {_, true} <- {:followed, follower.id != followed.id},
-         {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
-      conn
-      |> put_view(AccountView)
-      |> render("show.json", %{user: followed, for: follower})
-    else
-      {:followed, _} ->
-        {:error, :not_found}
-
-      {:error, message} ->
-        conn
-        |> put_status(:forbidden)
-        |> json(%{error: message})
-    end
-  end
-
-  def mutes(%{assigns: %{user: user}} = conn, _) do
-    with muted_accounts <- User.muted_users(user) do
-      res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user)
-      json(conn, res)
-    end
-  end
-
-  def blocks(%{assigns: %{user: user}} = conn, _) do
-    with blocked_accounts <- User.blocked_users(user) do
-      res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user)
-      json(conn, res)
-    end
-  end
-
-  def favourites(%{assigns: %{user: user}} = conn, params) do
-    params =
-      params
-      |> Map.put("type", "Create")
-      |> Map.put("favorited_by", user.ap_id)
-      |> Map.put("blocking_user", user)
-
-    activities =
-      ActivityPub.fetch_activities([], params)
-      |> Enum.reverse()
-
-    conn
-    |> add_link_headers(activities)
-    |> put_view(StatusView)
-    |> render("index.json", %{activities: activities, for: user, as: :activity})
-  end
-
-  def bookmarks(%{assigns: %{user: user}} = conn, params) do
-    user = User.get_cached_by_id(user.id)
-
-    bookmarks =
-      Bookmark.for_user_query(user.id)
-      |> 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)
-    |> put_view(StatusView)
-    |> render("index.json", %{activities: activities, for: user, as: :activity})
-  end
-
-  def index(%{assigns: %{user: user}} = conn, _params) do
-    token = get_session(conn, :oauth_token)
-
-    if user && token do
-      mastodon_emoji = mastodonized_emoji()
-
-      limit = Config.get([:instance, :limit])
-
-      accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
-
-      initial_state =
-        %{
-          meta: %{
-            streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
-            access_token: token,
-            locale: "en",
-            domain: Pleroma.Web.Endpoint.host(),
-            admin: "1",
-            me: "#{user.id}",
-            unfollow_modal: false,
-            boost_modal: false,
-            delete_modal: true,
-            auto_play_gif: false,
-            display_sensitive_media: false,
-            reduce_motion: false,
-            max_toot_chars: limit,
-            mascot: User.get_mascot(user)["url"]
-          },
-          poll_limits: Config.get([:instance, :poll_limits]),
-          rights: %{
-            delete_others_notice: present?(user.info.is_moderator),
-            admin: present?(user.info.is_admin)
-          },
-          compose: %{
-            me: "#{user.id}",
-            default_privacy: user.info.default_scope,
-            default_sensitive: false,
-            allow_content_types: Config.get([:instance, :allowed_post_formats])
-          },
-          media_attachments: %{
-            accept_content_types: [
-              ".jpg",
-              ".jpeg",
-              ".png",
-              ".gif",
-              ".webm",
-              ".mp4",
-              ".m4v",
-              "image\/jpeg",
-              "image\/png",
-              "image\/gif",
-              "video\/webm",
-              "video\/mp4"
-            ]
-          },
-          settings:
-            user.info.settings ||
-              %{
-                onboarded: true,
-                home: %{
-                  shows: %{
-                    reblog: true,
-                    reply: true
-                  }
-                },
-                notifications: %{
-                  alerts: %{
-                    follow: true,
-                    favourite: true,
-                    reblog: true,
-                    mention: true
-                  },
-                  shows: %{
-                    follow: true,
-                    favourite: true,
-                    reblog: true,
-                    mention: true
-                  },
-                  sounds: %{
-                    follow: true,
-                    favourite: true,
-                    reblog: true,
-                    mention: true
-                  }
-                }
-              },
-          push_subscription: nil,
-          accounts: accounts,
-          custom_emojis: mastodon_emoji,
-          char_limit: limit
-        }
-        |> Jason.encode!()
-
-      conn
-      |> put_layout(false)
-      |> put_view(MastodonView)
-      |> render("index.html", %{initial_state: initial_state})
-    else
-      conn
-      |> put_session(:return_to, conn.request_path)
-      |> redirect(to: "/web/login")
-    end
-  end
-
-  def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
-    with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do
-      json(conn, %{})
-    else
-      e ->
-        conn
-        |> put_status(:internal_server_error)
-        |> json(%{error: inspect(e)})
-    end
-  end
-
-  def login(%{assigns: %{user: %User{}}} = conn, _params) do
-    redirect(conn, to: local_mastodon_root_path(conn))
-  end
-
-  @doc "Local Mastodon FE login init action"
-  def login(conn, %{"code" => auth_token}) do
-    with {:ok, app} <- get_or_make_app(),
-         {:ok, auth} <- Authorization.get_by_token(app, auth_token),
-         {:ok, token} <- Token.exchange_token(app, auth) do
-      conn
-      |> put_session(:oauth_token, token.token)
-      |> redirect(to: local_mastodon_root_path(conn))
-    end
-  end
-
-  @doc "Local Mastodon FE callback action"
-  def login(conn, _) do
-    with {:ok, app} <- get_or_make_app() do
-      path =
-        o_auth_path(conn, :authorize,
-          response_type: "code",
-          client_id: app.client_id,
-          redirect_uri: ".",
-          scope: Enum.join(app.scopes, " ")
-        )
-
-      redirect(conn, to: path)
-    end
-  end
-
-  defp local_mastodon_root_path(conn) do
-    case get_session(conn, :return_to) do
-      nil ->
-        mastodon_api_path(conn, :index, ["getting-started"])
-
-      return_to ->
-        delete_session(conn, :return_to)
-        return_to
-    end
-  end
-
-  @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
-  defp get_or_make_app do
-    App.get_or_make(
-      %{client_name: @local_mastodon_name, redirect_uris: "."},
-      ["read", "write", "follow", "push"]
-    )
-  end
-
-  def logout(conn, _) do
-    conn
-    |> clear_session
-    |> redirect(to: "/")
-  end
-
-  # Stubs for unimplemented mastodon api
-  #
   def empty_array(conn, _) do
-    Logger.debug("Unimplemented, returning an empty array")
+    Logger.debug("Unimplemented, returning an empty array (list)")
     json(conn, [])
   end
 
   def empty_object(conn, _) do
-    Logger.debug("Unimplemented, returning an empty object")
+    Logger.debug("Unimplemented, returning an empty object (map)")
     json(conn, %{})
   end
-
-  def suggestions(%{assigns: %{user: user}} = conn, _) do
-    suggestions = Config.get(:suggestions)
-
-    if Keyword.get(suggestions, :enabled, false) do
-      api = Keyword.get(suggestions, :third_party_engine, "")
-      timeout = Keyword.get(suggestions, :timeout, 5000)
-      limit = Keyword.get(suggestions, :limit, 23)
-
-      host = Config.get([Pleroma.Web.Endpoint, :url, :host])
-
-      user = user.nickname
-
-      url =
-        api
-        |> String.replace("{{host}}", host)
-        |> String.replace("{{user}}", user)
-
-      with {:ok, %{status: 200, body: body}} <-
-             HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
-           {:ok, data} <- Jason.decode(body) do
-        data =
-          data
-          |> Enum.slice(0, limit)
-          |> Enum.map(fn x ->
-            x
-            |> Map.put("id", fetch_suggestion_id(x))
-            |> Map.put("avatar", MediaProxy.url(x["avatar"]))
-            |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
-          end)
-
-        json(conn, data)
-      else
-        e ->
-          Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
-      end
-    else
-      json(conn, [])
-    end
-  end
-
-  defp fetch_suggestion_id(attrs) do
-    case User.get_or_fetch(attrs["acct"]) do
-      {:ok, %User{id: id}} -> id
-      _ -> 0
-    end
-  end
-
-  def password_reset(conn, params) do
-    nickname_or_email = params["email"] || params["nickname"]
-
-    with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
-      conn
-      |> put_status(:no_content)
-      |> json("")
-    else
-      {:error, "unknown user"} ->
-        send_resp(conn, :not_found, "")
-
-      {:error, _} ->
-        send_resp(conn, :bad_request, "")
-    end
-  end
-
-  def try_render(conn, target, params)
-      when is_binary(target) do
-    case render(conn, target, params) do
-      nil -> render_error(conn, :not_implemented, "Can't display this activity")
-      res -> res
-    end
-  end
-
-  def try_render(conn, _, _) do
-    render_error(conn, :not_implemented, "Can't display this activity")
-  end
-
-  defp present?(nil), do: false
-  defp present?(false), do: false
-  defp present?(_), do: true
 end