Merge remote-tracking branch 'remotes/upstream/develop' into 1234-mastodon-2-4-3...
authorIvan Tashkinov <ivantashkinov@gmail.com>
Sun, 6 Oct 2019 08:43:49 +0000 (11:43 +0300)
committerIvan Tashkinov <ivantashkinov@gmail.com>
Sun, 6 Oct 2019 08:43:49 +0000 (11:43 +0300)
# Conflicts:
# CHANGELOG.md
# lib/pleroma/web/mastodon_api/controllers/account_controller.ex
# lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex
# lib/pleroma/web/router.ex

1  2 
CHANGELOG.md
lib/pleroma/web/activity_pub/activity_pub_controller.ex
lib/pleroma/web/mastodon_api/controllers/account_controller.ex
lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex
lib/pleroma/web/mastodon_api/controllers/status_controller.ex
lib/pleroma/web/oauth/oauth_controller.ex
test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs
test/web/oauth/oauth_controller_test.exs

diff --cc CHANGELOG.md
index 87b6d1180d2b7997af1ef8195654e93de1536c09,db505591b703264ffef0dd1da291316d6634a46a..987a6135f0beb34931f1e60f53385d8ba792413a
@@@ -10,6 -11,8 +11,9 @@@ The format is based on [Keep a Changelo
  - Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition)
  - Pleroma API: `GET /api/v1/pleroma/accounts/:id/scrobbles` to get a list of recently scrobbled items
  - Pleroma API: `POST /api/v1/pleroma/scrobble` to scrobble a media item
+ - Mastodon API: Add `upload_limit`, `avatar_upload_limit`, `background_upload_limit`, and `banner_upload_limit` to `/api/v1/instance`
+ - Mastodon API: Add `pleroma.unread_conversation_count` to the Account entity
++- OAuth: support for hierarchical permissions / [Mastodon 2.4.3 OAuth permissions](https://docs.joinmastodon.org/api/permissions/)
  
  ### Changed
  - **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
index 3bc9ed8ae4a26ab464f95d31470e3f580c770538,a56f0e149d1f146f8300f28cf80a21a29418d359..e195f56c452c903c748ad43f047cad52a1be39c6
@@@ -338,7 -321,25 +357,29 @@@ defmodule Pleroma.Web.MastodonAPI.Accou
      end
    end
  
+   @doc "POST /api/v1/follows"
+   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
+       render(conn, "show.json", user: followed, for: follower)
+     else
+       {:followed, _} -> {:error, :not_found}
+       {:error, message} -> json_response(conn, :forbidden, %{error: message})
+     end
+   end
+   @doc "GET /api/v1/mutes"
+   def mutes(%{assigns: %{user: user}} = conn, _) do
+     render(conn, "index.json", users: User.muted_users(user), for: user, as: :user)
+   end
+   @doc "GET /api/v1/blocks"
+   def blocks(%{assigns: %{user: user}} = conn, _) do
+     render(conn, "index.json", users: User.blocked_users(user), for: user, as: :user)
+   end
++
 +  @doc "GET /api/v1/endorsements"
 +  def endorsements(conn, params),
 +    do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
  end
index ee644abe30da46dbac18c9e76a153e7d6fd487f5,7d839a8cf72918af7761d36586e03348683237c5..32077d4205b793abfe378de463abf53d3e1cea68
  defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
    use Pleroma.Web, :controller
  
-   import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
-   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
    require Logger
  
++  alias Pleroma.Plugs.OAuthScopesPlug
 +  @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
 +  )
 +
 +  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