diff options
author | Egor Kislitsyn <egor@kislitsyn.com> | 2020-06-01 17:38:57 +0400 |
---|---|---|
committer | Egor Kislitsyn <egor@kislitsyn.com> | 2020-06-01 17:38:57 +0400 |
commit | 6b84c62d4a51cb17192945d1b67999b2c56a23d2 (patch) | |
tree | 53744600bd910bb0a62be6ab5884c6972da2ffce /lib/pleroma/web | |
parent | cb8236cda62cddb72f4320af6347defae44b81ca (diff) | |
parent | e96765df6b04fe5e9766271a9c62e559392758b2 (diff) | |
download | pleroma-6b84c62d4a51cb17192945d1b67999b2c56a23d2.tar.gz |
Merge remote-tracking branch 'origin/develop' into feature/embeddable-posts
Diffstat (limited to 'lib/pleroma/web')
195 files changed, 12020 insertions, 3372 deletions
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d9f74b6a4..b8a2873d8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -118,13 +118,29 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def increase_poll_votes_if_vote(%{ "object" => %{"inReplyTo" => reply_ap_id, "name" => name}, - "type" => "Create" + "type" => "Create", + "actor" => actor }) do - Object.increase_vote_count(reply_ap_id, name) + Object.increase_vote_count(reply_ap_id, name, actor) end def increase_poll_votes_if_vote(_create_data), do: :noop + @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} + def persist(object, meta) do + with local <- Keyword.fetch!(meta, :local), + {recipients, _, _} <- get_recipients(object), + {:ok, activity} <- + Repo.insert(%Activity{ + data: object, + local: local, + recipients: recipients, + actor: object["actor"] + }) do + {:ok, activity, meta} + end + end + @spec insert(map(), boolean(), boolean(), boolean()) :: {:ok, Activity.t()} | {:error, any()} def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when is_map(map) do with nil <- Activity.normalize(map), @@ -154,12 +170,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) - Notification.create_notifications(activity) - - conversation = create_or_bump_conversation(activity, map["actor"]) - participations = get_participations(conversation) - stream_out(activity) - stream_out_participations(participations) {:ok, activity} else %Activity{} = activity -> @@ -182,6 +192,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + def notify_and_stream(activity) do + Notification.create_notifications(activity) + + conversation = create_or_bump_conversation(activity, activity.actor) + participations = get_participations(conversation) + stream_out(activity) + stream_out_participations(participations) + end + defp create_or_bump_conversation(activity, actor) do with {:ok, conversation} <- Conversation.create_or_bump_for(activity), %User{} = user <- User.get_cached_by_ap_id(actor), @@ -258,6 +277,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do _ <- increase_poll_votes_if_vote(create_data), {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:ok, _actor} <- increase_note_count_if_public(actor, activity), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -285,6 +305,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do additional ), {:ok, activity} <- insert(listen_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -309,6 +330,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} |> Utils.maybe_put("id", activity_id), {:ok, activity} <- insert(data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -328,169 +350,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do }, data <- Utils.maybe_put(data, "id", activity_id), {:ok, activity} <- insert(data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} end end - @spec react_with_emoji(User.t(), Object.t(), String.t(), keyword()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def react_with_emoji(user, object, emoji, options \\ []) do - with {:ok, result} <- - Repo.transaction(fn -> do_react_with_emoji(user, object, emoji, options) end) do - result - end - end - - defp do_react_with_emoji(user, object, emoji, options) do - with local <- Keyword.get(options, :local, true), - activity_id <- Keyword.get(options, :activity_id, nil), - true <- Pleroma.Emoji.is_unicode_emoji?(emoji), - reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id), - {:ok, activity} <- insert(reaction_data, local), - {:ok, object} <- add_emoji_reaction_to_object(activity, object), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - false -> {:error, false} - {:error, error} -> Repo.rollback(error) - end - end - - @spec unreact_with_emoji(User.t(), String.t(), keyword()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def unreact_with_emoji(user, reaction_id, options \\ []) do - with {:ok, result} <- - Repo.transaction(fn -> do_unreact_with_emoji(user, reaction_id, options) end) do - result - end - end - - defp do_unreact_with_emoji(user, reaction_id, options) do - with local <- Keyword.get(options, :local, true), - activity_id <- Keyword.get(options, :activity_id, nil), - user_ap_id <- user.ap_id, - %Activity{actor: ^user_ap_id} = reaction_activity <- Activity.get_by_ap_id(reaction_id), - object <- Object.normalize(reaction_activity), - unreact_data <- make_undo_data(user, reaction_activity, activity_id), - {:ok, activity} <- insert(unreact_data, local), - {:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - {:error, error} -> Repo.rollback(error) - end - end - - # TODO: This is weird, maybe we shouldn't check here if we can make the activity. - @spec like(User.t(), Object.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def like(user, object, activity_id \\ nil, local \\ true) do - with {:ok, result} <- Repo.transaction(fn -> do_like(user, object, activity_id, local) end) do - result - end - end - - defp do_like( - %User{ap_id: ap_id} = user, - %Object{data: %{"id" => _}} = object, - activity_id, - local - ) do - with nil <- get_existing_like(ap_id, object), - like_data <- make_like_data(user, object, activity_id), - {:ok, activity} <- insert(like_data, local), - {:ok, object} <- add_like_to_object(activity, object), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - %Activity{} = activity -> - {:ok, activity, object} - - {:error, error} -> - Repo.rollback(error) - end - end - - @spec unlike(User.t(), Object.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t(), Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()} - def unlike(%User{} = actor, %Object{} = object, activity_id \\ nil, local \\ true) do - with {:ok, result} <- - Repo.transaction(fn -> do_unlike(actor, object, activity_id, local) end) do - result - end - end - - defp do_unlike(actor, object, activity_id, local) do - with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object), - unlike_data <- make_unlike_data(actor, like_activity, activity_id), - {:ok, unlike_activity} <- insert(unlike_data, local), - {:ok, _activity} <- Repo.delete(like_activity), - {:ok, object} <- remove_like_from_object(like_activity, object), - :ok <- maybe_federate(unlike_activity) do - {:ok, unlike_activity, like_activity, object} - else - nil -> {:ok, object} - {:error, error} -> Repo.rollback(error) - end - end - - @spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def announce( - %User{ap_id: _} = user, - %Object{data: %{"id" => _}} = object, - activity_id \\ nil, - local \\ true, - public \\ true - ) do - with {:ok, result} <- - Repo.transaction(fn -> do_announce(user, object, activity_id, local, public) end) do - result - end - end - - defp do_announce(user, object, activity_id, local, public) do - with true <- is_announceable?(object, user, public), - announce_data <- make_announce_data(user, object, activity_id, public), - {:ok, activity} <- insert(announce_data, local), - {:ok, object} <- add_announce_to_object(activity, object), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - false -> {:error, false} - {:error, error} -> Repo.rollback(error) - end - end - - @spec unannounce(User.t(), Object.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()} - def unannounce( - %User{} = actor, - %Object{} = object, - activity_id \\ nil, - local \\ true - ) do - with {:ok, result} <- - Repo.transaction(fn -> do_unannounce(actor, object, activity_id, local) end) do - result - end - end - - defp do_unannounce(actor, object, activity_id, local) do - with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object), - unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id), - {:ok, unannounce_activity} <- insert(unannounce_data, local), - :ok <- maybe_federate(unannounce_activity), - {:ok, _activity} <- Repo.delete(announce_activity), - {:ok, object} <- remove_announce_from_object(announce_activity, object) do - {:ok, unannounce_activity, object} - else - nil -> {:ok, object} - {:error, error} -> Repo.rollback(error) - end - end - @spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: {:ok, Activity.t()} | {:error, any()} def follow(follower, followed, activity_id \\ nil, local \\ true) do @@ -503,8 +368,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp do_follow(follower, followed, activity_id, local) do with data <- make_follow_data(follower, followed, activity_id), {:ok, activity} <- insert(data, local), - :ok <- maybe_federate(activity), - _ <- User.set_follow_state_cache(follower.ap_id, followed.ap_id, activity.data["state"]) do + _ <- notify_and_stream(activity), + :ok <- maybe_federate(activity) do {:ok, activity} else {:error, error} -> Repo.rollback(error) @@ -525,6 +390,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"), unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id), {:ok, activity} <- insert(unfollow_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -533,57 +399,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - @spec delete(User.t() | Object.t(), keyword()) :: {:ok, User.t() | Object.t()} | {:error, any()} - def delete(entity, options \\ []) do - with {:ok, result} <- Repo.transaction(fn -> do_delete(entity, options) end) do - result - end - end - - defp do_delete(%User{ap_id: ap_id, follower_address: follower_address} = user, _) do - with data <- %{ - "to" => [follower_address], - "type" => "Delete", - "actor" => ap_id, - "object" => %{"type" => "Person", "id" => ap_id} - }, - {:ok, activity} <- insert(data, true, true, true), - :ok <- maybe_federate(activity) do - {:ok, user} - end - end - - defp do_delete(%Object{data: %{"id" => id, "actor" => actor}} = object, options) do - local = Keyword.get(options, :local, true) - activity_id = Keyword.get(options, :activity_id, nil) - actor = Keyword.get(options, :actor, actor) - - user = User.get_cached_by_ap_id(actor) - to = (object.data["to"] || []) ++ (object.data["cc"] || []) - - with create_activity <- Activity.get_create_by_object_ap_id(id), - data <- - %{ - "type" => "Delete", - "actor" => actor, - "object" => id, - "to" => to, - "deleted_activity_id" => create_activity && create_activity.id - } - |> maybe_put("id", activity_id), - {:ok, activity} <- insert(data, local, false), - {:ok, object, _create_activity} <- Object.delete(object), - stream_out_participations(object, user), - _ <- decrease_replies_count_if_reply(object), - {:ok, _actor} <- decrease_note_count_if_public(user, object), - :ok <- maybe_federate(activity) do - {:ok, activity} - else - {:error, error} -> - Repo.rollback(error) - end - end - @spec block(User.t(), User.t(), String.t() | nil, boolean()) :: {:ok, Activity.t()} | {:error, any()} def block(blocker, blocked, activity_id \\ nil, local \\ true) do @@ -594,7 +409,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end defp do_block(blocker, blocked, activity_id, local) do - outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) if unfollow_blocked do @@ -602,9 +416,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do if follow_activity, do: unfollow(blocker, blocked, nil, local) end - with true <- outgoing_blocks, - block_data <- make_block_data(blocker, blocked, activity_id), + with block_data <- make_block_data(blocker, blocked, activity_id), {:ok, activity} <- insert(block_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -612,27 +426,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - @spec unblock(User.t(), User.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t()} | {:error, any()} | nil - def unblock(blocker, blocked, activity_id \\ nil, local \\ true) do - with {:ok, result} <- - Repo.transaction(fn -> do_unblock(blocker, blocked, activity_id, local) end) do - result - end - end - - defp do_unblock(blocker, blocked, activity_id, local) do - with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked), - unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id), - {:ok, activity} <- insert(unblock_data, local), - :ok <- maybe_federate(activity) do - {:ok, activity} - else - nil -> nil - {:error, error} -> Repo.rollback(error) - end - end - @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} def flag( %{ @@ -659,6 +452,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do with flag_data <- make_flag_data(params, additional), {:ok, activity} <- insert(flag_data, local), {:ok, stripped_activity} <- strip_report_status_data(activity), + _ <- notify_and_stream(activity), :ok <- maybe_federate(stripped_activity) do User.all_superusers() |> Enum.filter(fn user -> not is_nil(user.email) end) @@ -682,7 +476,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do } with true <- origin.ap_id in target.also_known_as, - {:ok, activity} <- insert(params, local) do + {:ok, activity} <- insert(params, local), + _ <- notify_and_stream(activity) do maybe_federate(activity) BackgroundWorker.enqueue("move_following", %{ @@ -697,7 +492,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - defp fetch_activities_for_context_query(context, opts) do + def fetch_activities_for_context_query(context, opts) do public = [Constants.as_public()] recipients = @@ -743,14 +538,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Repo.one() end - @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] - def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do + @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()] + def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do opts = Map.drop(opts, ["user"]) - [Constants.as_public()] - |> fetch_activities_query(opts) - |> restrict_unlisted() - |> Pagination.fetch_paginated(opts, pagination) + query = fetch_activities_query([Constants.as_public()], opts) + + query = + if opts["restrict_unlisted"] do + restrict_unlisted(query) + else + query + end + + Pagination.fetch_paginated(query, opts, pagination) + end + + @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] + def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do + opts + |> Map.put("restrict_unlisted", true) + |> fetch_public_or_unlisted_activities(pagination) end @valid_visibilities ~w[direct unlisted public private] @@ -829,7 +637,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) - when visibility not in @valid_visibilities do + when visibility not in [nil | @valid_visibilities] do Logger.error("Could not exclude visibility to #{visibility}") query end @@ -1036,7 +844,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do raise "Can't use the child object without preloading!" end - defp restrict_media(query, %{"only_media" => val}) when val == "true" or val == "1" do + defp restrict_media(query, %{"only_media" => val}) when val in [true, "true", "1"] do from( [_activity, object] in query, where: fragment("not (?)->'attachment' = (?)", object.data, ^[]) @@ -1045,16 +853,51 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_media(query, _), do: query - defp restrict_replies(query, %{"exclude_replies" => val}) when val == "true" or val == "1" do + defp restrict_replies(query, %{"exclude_replies" => val}) when val in [true, "true", "1"] do from( [_activity, object] in query, where: fragment("?->>'inReplyTo' is null", object.data) ) end + defp restrict_replies(query, %{ + "reply_filtering_user" => user, + "reply_visibility" => "self" + }) do + from( + [activity, object] in query, + where: + fragment( + "?->>'inReplyTo' is null OR ? = ANY(?)", + object.data, + ^user.ap_id, + activity.recipients + ) + ) + end + + defp restrict_replies(query, %{ + "reply_filtering_user" => user, + "reply_visibility" => "following" + }) do + from( + [activity, object] in query, + where: + fragment( + "?->>'inReplyTo' is null OR ? && array_remove(?, ?) OR ? = ?", + object.data, + ^[user.ap_id | User.get_cached_user_friends_ap_ids(user)], + activity.recipients, + activity.actor, + activity.actor, + ^user.ap_id + ) + ) + end + defp restrict_replies(query, _), do: query - defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val == "true" or val == "1" do + defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val in [true, "true", "1"] do from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data)) end @@ -1133,7 +976,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do ) end - defp restrict_pinned(query, %{"pinned" => "true", "pinned_activity_ids" => ids}) do + # TODO: when all endpoints migrated to OpenAPI compare `pinned` with `true` (boolean) only, + # the same for `restrict_media/2`, `restrict_replies/2`, 'restrict_reblogs/2' + # and `restrict_muted/2` + + defp restrict_pinned(query, %{"pinned" => pinned, "pinned_activity_ids" => ids}) + when pinned in [true, "true", "1"] do from(activity in query, where: activity.id in ^ids) end @@ -1230,17 +1078,17 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp fetch_activities_query_ap_ids_ops(opts) do source_user = opts["muting_user"] - ap_id_relations = if source_user, do: [:mute, :reblog_mute], else: [] + ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: [] - ap_id_relations = - ap_id_relations ++ + ap_id_relationships = + ap_id_relationships ++ if opts["blocking_user"] && opts["blocking_user"] == source_user do [:block] else [] end - preloaded_ap_ids = User.outgoing_relations_ap_ids(source_user, ap_id_relations) + preloaded_ap_ids = User.outgoing_relationships_ap_ids(source_user, ap_id_relationships) restrict_blocked_opts = Map.merge(%{"blocked_users_ap_ids" => preloaded_ap_ids[:block]}, opts) restrict_muted_opts = Map.merge(%{"muted_users_ap_ids" => preloaded_ap_ids[:mute]}, opts) @@ -1266,6 +1114,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> maybe_set_thread_muted_field(opts) |> maybe_order(opts) |> restrict_recipients(recipients, opts["user"]) + |> restrict_replies(opts) |> restrict_tag(opts) |> restrict_tag_reject(opts) |> restrict_tag_all(opts) @@ -1280,7 +1129,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> restrict_media(opts) |> restrict_visibility(opts) |> restrict_thread_visibility(opts, config) - |> restrict_replies(opts) |> restrict_reblogs(opts) |> restrict_pinned(opts) |> restrict_muted_reblogs(restrict_muted_reblogs_opts) @@ -1310,7 +1158,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Activity.with_joined_object() |> Object.with_joined_activity() |> select([_like, object, activity], %{activity | object: object}) - |> order_by([like, _, _], desc: like.id) + |> order_by([like, _, _], desc_nulls_last: like.id) |> Pagination.fetch_paginated( Map.merge(params, %{"skip_order" => true}), pagination, @@ -1370,6 +1218,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + @spec get_actor_url(any()) :: binary() | nil + defp get_actor_url(url) when is_binary(url), do: url + defp get_actor_url(%{"href" => href}) when is_binary(href), do: href + + defp get_actor_url(url) when is_list(url) do + url + |> List.first() + |> get_actor_url() + end + + defp get_actor_url(_url), do: nil + defp object_to_user_data(data) do avatar = data["icon"]["url"] && @@ -1391,18 +1251,44 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) + emojis = + data + |> Map.get("tag", []) + |> Enum.filter(fn + %{"type" => "Emoji"} -> true + _ -> false + end) + |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> + Map.put(acc, String.trim(name, ":"), url) + end) + locked = data["manuallyApprovesFollowers"] || false data = Transmogrifier.maybe_fix_user_object(data) discoverable = data["discoverable"] || false invisible = data["invisible"] || false actor_type = data["type"] || "Person" + public_key = + if is_map(data["publicKey"]) && is_binary(data["publicKey"]["publicKeyPem"]) do + data["publicKey"]["publicKeyPem"] + else + nil + end + + shared_inbox = + if is_map(data["endpoints"]) && is_binary(data["endpoints"]["sharedInbox"]) do + data["endpoints"]["sharedInbox"] + else + nil + end + user_data = %{ ap_id: data["id"], + uri: get_actor_url(data["url"]), ap_enabled: true, - source_data: data, banner: banner, fields: fields, + emoji: emojis, locked: locked, discoverable: discoverable, invisible: invisible, @@ -1412,7 +1298,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do following_address: data["following"], bio: data["summary"], actor_type: actor_type, - also_known_as: Map.get(data, "alsoKnownAs", []) + also_known_as: Map.get(data, "alsoKnownAs", []), + public_key: public_key, + inbox: data["inbox"], + shared_inbox: shared_inbox } # nickname can be nil because of virtual actors @@ -1453,21 +1342,34 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp normalize_counter(counter) when is_integer(counter), do: counter defp normalize_counter(_), do: 0 - defp maybe_update_follow_information(data) do + def maybe_update_follow_information(user_data) do with {:enabled, true} <- {:enabled, Config.get([:instance, :external_user_synchronization])}, - {:ok, info} <- fetch_follow_information_for_user(data) do - info = Map.merge(data[:info] || %{}, info) - Map.put(data, :info, info) + {_, true} <- {:user_type_check, user_data[:type] in ["Person", "Service"]}, + {_, true} <- + {:collections_available, + !!(user_data[:following_address] && user_data[:follower_address])}, + {:ok, info} <- + fetch_follow_information_for_user(user_data) do + info = Map.merge(user_data[:info] || %{}, info) + + user_data + |> Map.put(:info, info) else + {:user_type_check, false} -> + user_data + + {:collections_available, false} -> + user_data + {:enabled, false} -> - data + user_data e -> Logger.error( - "Follower/Following counter update for #{data.ap_id} failed.\n" <> inspect(e) + "Follower/Following counter update for #{user_data.ap_id} failed.\n" <> inspect(e) ) - data + user_data end end @@ -1514,11 +1416,22 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end def make_user_from_ap_id(ap_id) do - if _user = User.get_cached_by_ap_id(ap_id) do + user = User.get_cached_by_ap_id(ap_id) + + if user && !User.ap_enabled?(user) do Transmogrifier.upgrade_user_from_ap_id(ap_id) else with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do - User.insert_or_update_user(data) + if user do + user + |> User.remote_user_changeset(data) + |> User.update_and_set_cache() + else + data + |> User.remote_user_changeset() + |> Repo.insert() + |> User.set_cache() + end else e -> {:error, e} end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 8b9eb4a2c..28727d619 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -12,13 +12,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Plugs.EnsureAuthenticatedPlug alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.ObjectView + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Endpoint alias Pleroma.Web.FederatingPlug alias Pleroma.Web.Federator @@ -32,12 +35,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do plug( EnsureAuthenticatedPlug, - [unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions + [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions ) + # Note: :following and :followers must be served even without authentication (as via :api) plug( EnsureAuthenticatedPlug - when action in [:read_inbox, :update_outbox, :whoami, :upload_media, :following, :followers] + when action in [:read_inbox, :update_outbox, :whoami, :upload_media] ) plug( @@ -72,8 +76,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - def object(conn, %{"uuid" => uuid}) do - with ap_id <- o_status_url(conn, :object, uuid), + def object(conn, _) do + with ap_id <- Endpoint.url() <> conn.request_path, %Object{} = object <- Object.get_cached_by_ap_id(ap_id), {_, true} <- {:public?, Visibility.is_public?(object)} do conn @@ -98,8 +102,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do conn end - def activity(conn, %{"uuid" => uuid}) do - with ap_id <- o_status_url(conn, :activity, uuid), + def activity(conn, _params) do + with ap_id <- Endpoint.url() <> conn.request_path, %Activity{} = activity <- Activity.normalize(ap_id), {_, true} <- {:public?, Visibility.is_public?(activity)} do conn @@ -393,7 +397,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json(err) end - defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do + defp handle_user_activity( + %User{} = user, + %{"type" => "Create", "object" => %{"type" => "Note"}} = params + ) do object = params["object"] |> Map.merge(Map.take(params, ["to", "cc"])) @@ -412,7 +419,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do with %Object{} = object <- Object.normalize(params["object"]), true <- user.is_moderator || user.ap_id == object.data["actor"], - {:ok, delete} <- ActivityPub.delete(object) do + {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]), + {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do {:ok, delete} else _ -> {:error, dgettext("errors", "Can't delete object")} @@ -421,7 +429,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do defp handle_user_activity(%User{} = user, %{"type" => "Like"} = params) do with %Object{} = object <- Object.normalize(params["object"]), - {:ok, activity, _object} <- ActivityPub.like(user, object) do + {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(like_object, Keyword.put(meta, :local, true))} do {:ok, activity} else _ -> {:error, dgettext("errors", "Can't like object")} diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex new file mode 100644 index 000000000..51b74414a --- /dev/null +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -0,0 +1,146 @@ +defmodule Pleroma.Web.ActivityPub.Builder do + @moduledoc """ + This module builds the objects. Meant to be used for creating local objects. + + This module encodes our addressing policies and general shape of our objects. + """ + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + + require Pleroma.Constants + + @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()} + def emoji_react(actor, object, emoji) do + with {:ok, data, meta} <- object_action(actor, object) do + data = + data + |> Map.put("content", emoji) + |> Map.put("type", "EmojiReact") + + {:ok, data, meta} + end + end + + @spec undo(User.t(), Activity.t()) :: {:ok, map(), keyword()} + def undo(actor, object) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "type" => "Undo", + "object" => object.data["id"], + "to" => object.data["to"] || [], + "cc" => object.data["cc"] || [] + }, []} + end + + @spec delete(User.t(), String.t()) :: {:ok, map(), keyword()} + def delete(actor, object_id) do + object = Object.normalize(object_id, false) + + user = !object && User.get_cached_by_ap_id(object_id) + + to = + case {object, user} do + {%Object{}, _} -> + # We are deleting an object, address everyone who was originally mentioned + (object.data["to"] || []) ++ (object.data["cc"] || []) + + {_, %User{follower_address: follower_address}} -> + # We are deleting a user, address the followers of that user + [follower_address] + end + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object_id, + "to" => to, + "type" => "Delete" + }, []} + end + + @spec tombstone(String.t(), String.t()) :: {:ok, map(), keyword()} + def tombstone(actor, id) do + {:ok, + %{ + "id" => id, + "actor" => actor, + "type" => "Tombstone" + }, []} + end + + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} + def like(actor, object) do + with {:ok, data, meta} <- object_action(actor, object) do + data = + data + |> Map.put("type", "Like") + + {:ok, data, meta} + end + end + + @spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()} + def announce(actor, object, options \\ []) do + public? = Keyword.get(options, :public, false) + + to = + cond do + actor.ap_id == Relay.relay_ap_id() -> + [actor.follower_address] + + public? -> + [actor.follower_address, object.data["actor"], Pleroma.Constants.as_public()] + + true -> + [actor.follower_address, object.data["actor"]] + end + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object.data["id"], + "to" => to, + "context" => object.data["context"], + "type" => "Announce", + "published" => Utils.make_date() + }, []} + end + + @spec object_action(User.t(), Object.t()) :: {:ok, map(), keyword()} + defp object_action(actor, object) do + object_actor = User.get_cached_by_ap_id(object.data["actor"]) + + # Address the actor of the object, and our actor's follower collection if the post is public. + to = + if Visibility.is_public?(object) do + [actor.follower_address, object.data["actor"]] + else + [object.data["actor"]] + end + + # CC everyone who's been addressed in the object, except ourself and the object actor's + # follower collection + cc = + (object.data["to"] ++ (object.data["cc"] || [])) + |> List.delete(actor.ap_id) + |> List.delete(object_actor.follower_address) + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object.data["id"], + "to" => to, + "cc" => cc, + "context" => object.data["context"] + }, []} + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex index b3547ecd4..0270b96ae 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index d9a0acfd3..dfab105a3 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -12,17 +12,23 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do require Logger - @hackney_options [ - pool: :media, - recv_timeout: 10_000 + @options [ + pool: :media ] def perform(:prefetch, url) do Logger.debug("Prefetching #{inspect(url)}") + opts = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.put(@options, :recv_timeout, 10_000) + else + @options + end + url |> MediaProxy.url() - |> HTTP.get([], adapter: @hackney_options) + |> HTTP.get([], adapter: opts) end def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex index f67f48ab6..fc3475048 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index 4a8bc91ae..b0ccb63c8 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do @moduledoc "Filter activities depending on their age" @behaviour Pleroma.Web.ActivityPub.MRF - defp check_date(%{"published" => published} = message) do + defp check_date(%{"object" => %{"published" => published}} = message) do with %DateTime{} = now <- DateTime.utc_now(), {:ok, %DateTime{} = then, _} <- DateTime.from_iso8601(published), max_ttl <- Config.get([:mrf_object_age, :threshold]), @@ -96,5 +96,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do def filter(message), do: {:ok, message} @impl true - def describe, do: {:ok, %{}} + def describe do + mrf_object_age = + Pleroma.Config.get(:mrf_object_age) + |> Enum.into(%{}) + + {:ok, %{mrf_object_age: mrf_object_age}} + end end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 4edc007fd..b7dcb1b86 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -149,6 +149,21 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do defp check_banner_removal(_actor_info, object), do: {:ok, object} @impl true + def filter(%{"type" => "Delete", "actor" => actor} = object) do + %{host: actor_host} = URI.parse(actor) + + reject_deletes = + Pleroma.Config.get([:mrf_simple, :reject_deletes]) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(reject_deletes, actor_host) do + {:reject, nil} + else + {:ok, object} + end + end + + @impl true def filter(%{"actor" => actor} = object) do actor_info = URI.parse(actor) diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex new file mode 100644 index 000000000..2858af9eb --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do + require Logger + + alias Pleroma.Config + + @moduledoc "Detect new emojis by their shortcode and steals them" + @behaviour Pleroma.Web.ActivityPub.MRF + + defp remote_host?(host), do: host != Config.get([Pleroma.Web.Endpoint, :url, :host]) + + defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) + + defp steal_emoji({shortcode, url}) do + url = Pleroma.Web.MediaProxy.url(url) + {:ok, response} = Pleroma.HTTP.get(url) + size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000) + + if byte_size(response.body) <= size_limit do + emoji_dir_path = + Config.get( + [:mrf_steal_emoji, :path], + Path.join(Config.get([:instance, :static_dir]), "emoji/stolen") + ) + + extension = + url + |> URI.parse() + |> Map.get(:path) + |> Path.basename() + |> Path.extname() + + file_path = Path.join([emoji_dir_path, shortcode <> (extension || ".png")]) + + try do + :ok = File.write(file_path, response.body) + + shortcode + rescue + e -> + Logger.warn("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}") + nil + end + else + Logger.debug( + "MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{ + size_limit + } B)" + ) + + nil + end + rescue + e -> + Logger.warn("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}") + nil + end + + @impl true + def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = message) do + host = URI.parse(actor).host + + if remote_host?(host) and accept_host?(host) do + installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) + + new_emojis = + foreign_emojis + |> Enum.filter(fn {shortcode, _url} -> shortcode not in installed_emoji end) + |> Enum.filter(fn {shortcode, _url} -> + reject_emoji? = + Config.get([:mrf_steal_emoji, :rejected_shortcodes], []) + |> Enum.find(false, fn regex -> String.match?(shortcode, regex) end) + + !reject_emoji? + end) + |> Enum.map(&steal_emoji(&1)) + |> Enum.filter(& &1) + + if !Enum.empty?(new_emojis) do + Logger.info("Stole new emojis: #{inspect(new_emojis)}") + Pleroma.Emoji.reload() + end + end + + {:ok, message} + end + + def filter(message), do: {:ok, message} + + @impl true + def describe do + {:ok, %{}} + end +end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex new file mode 100644 index 000000000..2599067a8 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidator do + @moduledoc """ + This module is responsible for validating an object (which can be an activity) + and checking if it is both well formed and also compatible with our view of + the system. + """ + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator + + @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} + def validate(object, meta) + + def validate(%{"type" => "Undo"} = object, meta) do + with {:ok, object} <- + object + |> UndoValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + + def validate(%{"type" => "Delete"} = object, meta) do + with cng <- DeleteValidator.cast_and_validate(object), + do_not_federate <- DeleteValidator.do_not_federate?(cng), + {:ok, object} <- Ecto.Changeset.apply_action(cng, :insert) do + object = stringify_keys(object) + meta = Keyword.put(meta, :do_not_federate, do_not_federate) + {:ok, object, meta} + end + end + + def validate(%{"type" => "Like"} = object, meta) do + with {:ok, object} <- + object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object |> Map.from_struct()) + {:ok, object, meta} + end + end + + def validate(%{"type" => "EmojiReact"} = object, meta) do + with {:ok, object} <- + object + |> EmojiReactValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object |> Map.from_struct()) + {:ok, object, meta} + end + end + + def validate(%{"type" => "Announce"} = object, meta) do + with {:ok, object} <- + object + |> AnnounceValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object |> Map.from_struct()) + {:ok, object, meta} + end + end + + def stringify_keys(%{__struct__: _} = object) do + object + |> Map.from_struct() + |> stringify_keys + end + + def stringify_keys(object) do + object + |> Map.new(fn {key, val} -> {to_string(key), val} end) + end + + def fetch_actor(object) do + with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do + User.get_or_fetch_by_ap_id(actor) + end + end + + def fetch_actor_and_object(object) do + fetch_actor(object) + Object.normalize(object["object"], true) + :ok + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex new file mode 100644 index 000000000..40f861f47 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -0,0 +1,101 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do + use Ecto.Schema + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + require Pleroma.Constants + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:context, :string, autogenerate: {Utils, :generate_context_id, []}) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) + field(:published, Types.DateTime) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> fix_after_cast() + end + + def fix_after_cast(cng) do + cng + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Announce"]) + |> validate_required([:id, :type, :object, :actor, :to, :cc]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_existing_announce() + |> validate_announcable() + end + + def validate_announcable(cng) do + with actor when is_binary(actor) <- get_field(cng, :actor), + object when is_binary(object) <- get_field(cng, :object), + %User{} = actor <- User.get_cached_by_ap_id(actor), + %Object{} = object <- Object.get_cached_by_ap_id(object), + false <- Visibility.is_public?(object) do + same_actor = object.data["actor"] == actor.ap_id + is_public = Pleroma.Constants.as_public() in (get_field(cng, :to) ++ get_field(cng, :cc)) + + cond do + same_actor && is_public -> + cng + |> add_error(:actor, "can not announce this object publicly") + + !same_actor -> + cng + |> add_error(:actor, "can not announce this object") + + true -> + cng + end + else + _ -> cng + end + end + + def validate_existing_announce(cng) do + actor = get_field(cng, :actor) + object = get_field(cng, :object) + + if actor && object && Utils.get_existing_announce(actor, %{data: %{"id" => object}}) do + cng + |> add_error(:actor, "already announced this object") + |> add_error(:object, "already announced by this actor") + else + cng + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex new file mode 100644 index 000000000..aeef31945 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -0,0 +1,80 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do + import Ecto.Changeset + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + + def validate_recipients_presence(cng, fields \\ [:to, :cc]) do + non_empty = + fields + |> Enum.map(fn field -> get_field(cng, field) end) + |> Enum.any?(fn + [] -> false + _ -> true + end) + + if non_empty do + cng + else + fields + |> Enum.reduce(cng, fn field, cng -> + cng + |> add_error(field, "no recipients in any field") + end) + end + end + + def validate_actor_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :actor) + + cng + |> validate_change(field_name, fn field_name, actor -> + if User.get_cached_by_ap_id(actor) do + [] + else + [{field_name, "can't find user"}] + end + end) + end + + def validate_object_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :object) + allowed_types = Keyword.get(options, :allowed_types, false) + + cng + |> validate_change(field_name, fn field_name, object_id -> + object = Object.get_cached_by_ap_id(object_id) || Activity.get_by_ap_id(object_id) + + cond do + !object -> + [{field_name, "can't find object"}] + + object && allowed_types && object.data["type"] not in allowed_types -> + [{field_name, "object not in allowed types"}] + + true -> + [] + end + end) + end + + def validate_object_or_user_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :object) + options = Keyword.put(options, :field_name, field_name) + + actor_cng = + cng + |> validate_actor_presence(options) + + object_cng = + cng + |> validate_object_presence(options) + + if actor_cng.valid?, do: actor_cng, else: object_cng + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex new file mode 100644 index 000000000..926804ce7 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:actor, Types.ObjectID) + field(:type, :string) + field(:to, {:array, :string}) + field(:cc, {:array, :string}) + field(:bto, {:array, :string}, default: []) + field(:bcc, {:array, :string}, default: []) + + embeds_one(:object, NoteValidator) + end + + def cast_data(data) do + cast(%__MODULE__{}, data, __schema__(:fields)) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex new file mode 100644 index 000000000..f42c03510 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -0,0 +1,100 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:actor, Types.ObjectID) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) + field(:deleted_activity_id, Types.ObjectID) + field(:object, Types.ObjectID) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def add_deleted_activity_id(cng) do + object = + cng + |> get_field(:object) + + with %Activity{id: id} <- Activity.get_create_by_object_ap_id(object) do + cng + |> put_change(:deleted_activity_id, id) + else + _ -> cng + end + end + + @deletable_types ~w{ + Answer + Article + Audio + Event + Note + Page + Question + Video + Tombstone + } + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Delete"]) + |> validate_actor_presence() + |> validate_deletion_rights() + |> validate_object_or_user_presence(allowed_types: @deletable_types) + |> add_deleted_activity_id() + end + + def do_not_federate?(cng) do + !same_domain?(cng) + end + + defp same_domain?(cng) do + actor_uri = + cng + |> get_field(:actor) + |> URI.parse() + + object_uri = + cng + |> get_field(:object) + |> URI.parse() + + object_uri.host == actor_uri.host + end + + def validate_deletion_rights(cng) do + actor = User.get_cached_by_ap_id(get_field(cng, :actor)) + + if User.superuser?(actor) || same_domain?(cng) do + cng + else + cng + |> add_error(:actor, "is not allowed to delete object") + end + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex new file mode 100644 index 000000000..e87519c59 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do + use Ecto.Schema + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:context, :string) + field(:content, :string) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> fix_after_cast() + end + + def fix_after_cast(cng) do + cng + |> fix_context() + end + + def fix_context(cng) do + object = get_field(cng, :object) + + with nil <- get_field(cng, :context), + %Object{data: %{"context" => context}} <- Object.get_cached_by_ap_id(object) do + cng + |> put_change(:context, context) + else + _ -> + cng + end + end + + def validate_emoji(cng) do + content = get_field(cng, :content) + + if Pleroma.Emoji.is_unicode_emoji?(content) do + cng + else + cng + |> add_error(:content, "must be a single character emoji") + end + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["EmojiReact"]) + |> validate_required([:id, :type, :object, :actor, :context, :to, :cc, :content]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_emoji() + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex new file mode 100644 index 000000000..034f25492 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -0,0 +1,99 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do + use Ecto.Schema + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Utils + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:context, :string) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> fix_after_cast() + end + + def fix_after_cast(cng) do + cng + |> fix_recipients() + |> fix_context() + end + + def fix_context(cng) do + object = get_field(cng, :object) + + with nil <- get_field(cng, :context), + %Object{data: %{"context" => context}} <- Object.get_cached_by_ap_id(object) do + cng + |> put_change(:context, context) + else + _ -> + cng + end + end + + def fix_recipients(cng) do + to = get_field(cng, :to) + cc = get_field(cng, :cc) + object = get_field(cng, :object) + + with {[], []} <- {to, cc}, + %Object{data: %{"actor" => actor}} <- Object.get_cached_by_ap_id(object), + {:ok, actor} <- Types.ObjectID.cast(actor) do + cng + |> put_change(:to, [actor]) + else + _ -> + cng + end + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Like"]) + |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_existing_like() + end + + def validate_existing_like(%{changes: %{actor: actor, object: object}} = cng) do + if Utils.get_existing_like(actor, %{data: %{"id" => object}}) do + cng + |> add_error(:actor, "already liked this object") + |> add_error(:object, "already liked by this actor") + else + cng + end + end + + def validate_existing_like(cng), do: cng +end diff --git a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex new file mode 100644 index 000000000..462a5620a --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) + field(:bto, {:array, :string}, default: []) + field(:bcc, {:array, :string}, default: []) + # TODO: Write type + field(:tag, {:array, :map}, default: []) + field(:type, :string) + field(:content, :string) + field(:context, :string) + field(:actor, Types.ObjectID) + field(:attributedTo, Types.ObjectID) + field(:summary, :string) + field(:published, Types.DateTime) + # TODO: Write type + field(:emoji, :map, default: %{}) + field(:sensitive, :boolean, default: false) + # TODO: Write type + field(:attachment, {:array, :map}, default: []) + field(:replies_count, :integer, default: 0) + field(:like_count, :integer, default: 0) + field(:announcement_count, :integer, default: 0) + field(:inRepyTo, :string) + field(:uri, Types.Uri) + + field(:likes, {:array, :string}, default: []) + field(:announcements, {:array, :string}, default: []) + + # see if needed + field(:conversation, :string) + field(:context_id, :string) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Note"]) + |> validate_required([:id, :actor, :to, :cc, :type, :content, :context]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex b/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex new file mode 100644 index 000000000..4f412fcde --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex @@ -0,0 +1,34 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTime do + @moduledoc """ + The AP standard defines the date fields in AP as xsd:DateTime. Elixir's + DateTime can't parse this, but it can parse the related iso8601. This + module punches the date until it looks like iso8601 and normalizes to + it. + + DateTimes without a timezone offset are treated as UTC. + + Reference: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-published + """ + use Ecto.Type + + def type, do: :string + + def cast(datetime) when is_binary(datetime) do + with {:ok, datetime, _} <- DateTime.from_iso8601(datetime) do + {:ok, DateTime.to_iso8601(datetime)} + else + {:error, :missing_offset} -> cast("#{datetime}Z") + _e -> :error + end + end + + def cast(_), do: :error + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex new file mode 100644 index 000000000..f71f76370 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex @@ -0,0 +1,23 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do + use Ecto.Type + + def type, do: :string + + def cast(object) when is_binary(object) do + # Host has to be present and scheme has to be an http scheme (for now) + case URI.parse(object) do + %URI{host: nil} -> :error + %URI{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, object} + _ -> :error + end + end + + def cast(%{"id" => object}), do: cast(object) + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex new file mode 100644 index 000000000..48fe61e1a --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -0,0 +1,34 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do + use Ecto.Type + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + + def type, do: {:array, ObjectID} + + def cast(object) when is_binary(object) do + cast([object]) + end + + def cast(data) when is_list(data) do + data + |> Enum.reduce({:ok, []}, fn element, acc -> + case {acc, ObjectID.cast(element)} do + {:error, _} -> :error + {_, :error} -> :error + {{:ok, list}, {:ok, id}} -> {:ok, [id | list]} + end + end) + end + + def cast(_) do + :error + end + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/uri.ex b/lib/pleroma/web/activity_pub/object_validators/types/uri.ex new file mode 100644 index 000000000..24845bcc0 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/uri.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Uri do + use Ecto.Type + + def type, do: :string + + def cast(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: nil} -> :error + %URI{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, uri} + _ -> :error + end + end + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex new file mode 100644 index 000000000..d0ba418e8 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Undo"]) + |> validate_required([:id, :type, :object, :actor, :to, :cc]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_undo_rights() + end + + def validate_undo_rights(cng) do + actor = get_field(cng, :actor) + object = get_field(cng, :object) + + with %Activity{data: %{"actor" => object_actor}} <- Activity.get_by_ap_id(object), + true <- object_actor != actor do + cng + |> add_error(:actor, "not the same as object actor") + else + _ -> cng + end + end +end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex new file mode 100644 index 000000000..0c54c4b23 --- /dev/null +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Pipeline do + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.SideEffects + alias Pleroma.Web.Federator + + @spec common_pipeline(map(), keyword()) :: + {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} + def common_pipeline(object, meta) do + case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do + {:ok, value} -> + value + + {:error, e} -> + {:error, e} + end + end + + def do_common_pipeline(object, meta) do + with {_, {:ok, validated_object, meta}} <- + {:validate_object, ObjectValidator.validate(object, meta)}, + {_, {:ok, mrfd_object}} <- {:mrf_object, MRF.filter(validated_object)}, + {_, {:ok, activity, meta}} <- + {:persist_object, ActivityPub.persist(mrfd_object, meta)}, + {_, {:ok, activity, meta}} <- + {:execute_side_effects, SideEffects.handle(activity, meta)}, + {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do + {:ok, activity, meta} + else + {:mrf_object, {:reject, _}} -> {:ok, nil, meta} + e -> {:error, e} + end + end + + defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} + + defp maybe_federate(%Activity{} = activity, meta) do + with {:ok, local} <- Keyword.fetch(meta, :local) do + do_not_federate = meta[:do_not_federate] || !Config.get([:instance, :federating]) + + if !do_not_federate && local do + Federator.publish(activity) + {:ok, :federated} + else + {:ok, :not_federated} + end + else + _e -> {:error, :badarg} + end + end +end diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 6c558e7f0..b70cbd043 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -141,8 +141,8 @@ defmodule Pleroma.Web.ActivityPub.Publisher do |> Enum.map(& &1.ap_id) end - defp maybe_use_sharedinbox(%User{source_data: data}), - do: (is_map(data["endpoints"]) && Map.get(data["endpoints"], "sharedInbox")) || data["inbox"] + defp maybe_use_sharedinbox(%User{shared_inbox: nil, inbox: inbox}), do: inbox + defp maybe_use_sharedinbox(%User{shared_inbox: shared_inbox}), do: shared_inbox @doc """ Determine a user inbox to use based on heuristics. These heuristics @@ -157,7 +157,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do """ def determine_inbox( %Activity{data: activity_data}, - %User{source_data: data} = user + %User{inbox: inbox} = user ) do to = activity_data["to"] || [] cc = activity_data["cc"] || [] @@ -174,7 +174,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do maybe_use_sharedinbox(user) true -> - data["inbox"] + inbox end end @@ -192,14 +192,13 @@ defmodule Pleroma.Web.ActivityPub.Publisher do inboxes = recipients |> Enum.filter(&User.ap_enabled?/1) - |> Enum.map(fn %{source_data: data} -> data["inbox"] end) + |> Enum.map(fn actor -> actor.inbox end) |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) |> Instances.filter_reachable() Repo.checkout(fn -> Enum.each(inboxes, fn {inbox, unreachable_since} -> - %User{ap_id: ap_id} = - Enum.find(recipients, fn %{source_data: data} -> data["inbox"] == inbox end) + %User{ap_id: ap_id} = Enum.find(recipients, fn actor -> actor.inbox == inbox end) # Get all the recipients on the same host and add them to cc. Otherwise, a remote # instance would only accept a first message for the first recipient and ignore the rest. diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index 729c23af7..484178edd 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -4,9 +4,10 @@ defmodule Pleroma.Web.ActivityPub.Relay do alias Pleroma.Activity - alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI require Logger @relay_nickname "relay" @@ -48,11 +49,11 @@ defmodule Pleroma.Web.ActivityPub.Relay do end end - @spec publish(any()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} + @spec publish(any()) :: {:ok, Activity.t()} | {:error, any()} def publish(%Activity{data: %{"type" => "Create"}} = activity) do with %User{} = user <- get_actor(), - %Object{} = object <- Object.normalize(activity) do - ActivityPub.announce(user, object, nil, true, false) + true <- Visibility.is_public?(activity) do + CommonAPI.repeat(activity.id, user) else error -> format_error(error) end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex new file mode 100644 index 000000000..fb6275450 --- /dev/null +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -0,0 +1,151 @@ +defmodule Pleroma.Web.ActivityPub.SideEffects do + @moduledoc """ + This module looks at an inserted object and executes the side effects that it + implies. For example, a `Like` activity will increase the like count on the + liked object, a `Follow` activity will add the user to the follower + collection, and so on. + """ + alias Pleroma.Activity + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Utils + + def handle(object, meta \\ []) + + # Tasks this handles: + # - Add like to object + # - Set up notification + def handle(%{data: %{"type" => "Like"}} = object, meta) do + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) + + Notification.create_notifications(object) + + {:ok, object, meta} + end + + # Tasks this handles: + # - Add announce to object + # - Set up notification + # - Stream out the announce + def handle(%{data: %{"type" => "Announce"}} = object, meta) do + announced_object = Object.get_by_ap_id(object.data["object"]) + user = User.get_cached_by_ap_id(object.data["actor"]) + + Utils.add_announce_to_object(object, announced_object) + + if !User.is_internal_user?(user) do + Notification.create_notifications(object) + ActivityPub.stream_out(object) + end + + {:ok, object, meta} + end + + def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do + with undone_object <- Activity.get_by_ap_id(undone_object), + :ok <- handle_undoing(undone_object) do + {:ok, object, meta} + end + end + + # Tasks this handles: + # - Add reaction to object + # - Set up notification + def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do + reacted_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_emoji_reaction_to_object(object, reacted_object) + + Notification.create_notifications(object) + + {:ok, object, meta} + end + + # Tasks this handles: + # - Delete and unpins the create activity + # - Replace object with Tombstone + # - Set up notification + # - Reduce the user note count + # - Reduce the reply count + # - Stream out the activity + def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do + deleted_object = + Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object) + + result = + case deleted_object do + %Object{} -> + with {:ok, deleted_object, activity} <- Object.delete(deleted_object), + %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do + User.remove_pinnned_activity(user, activity) + + {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) + + if in_reply_to = deleted_object.data["inReplyTo"] do + Object.decrease_replies_count(in_reply_to) + end + + ActivityPub.stream_out(object) + ActivityPub.stream_out_participations(deleted_object, user) + :ok + end + + %User{} -> + with {:ok, _} <- User.delete(deleted_object) do + :ok + end + end + + if result == :ok do + Notification.create_notifications(object) + {:ok, object, meta} + else + {:error, result} + end + end + + # Nothing to do + def handle(object, meta) do + {:ok, object, meta} + end + + def handle_undoing(%{data: %{"type" => "Like"}} = object) do + with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_like_from_object(object, liked_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do + with %Object{} = reacted_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_emoji_reaction_from_object(object, reacted_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing(%{data: %{"type" => "Announce"}} = object) do + with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_announce_from_object(object, liked_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing( + %{data: %{"type" => "Block", "actor" => blocker, "object" => blocked}} = object + ) do + with %User{} = blocker <- User.get_cached_by_ap_id(blocker), + %User{} = blocked <- User.get_cached_by_ap_id(blocked), + {:ok, _} <- User.unblock(blocker, blocked), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing(object), do: {:error, ["don't know how to handle", object]} +end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 9cd3de705..8443c284c 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -7,12 +7,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do A module to handle coding from internal to wire ActivityPub and back. """ alias Pleroma.Activity + alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator @@ -40,6 +45,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> fix_addressing |> fix_summary |> fix_type(options) + |> fix_content end def fix_summary(%{"summary" => nil} = object) do @@ -202,16 +208,46 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> Map.put("conversation", context) end + defp add_if_present(map, _key, nil), do: map + + defp add_if_present(map, key, value) do + Map.put(map, key, value) + end + def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do attachments = Enum.map(attachment, fn data -> - media_type = data["mediaType"] || data["mimeType"] - href = data["url"] || data["href"] - url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}] + url = + cond do + is_list(data["url"]) -> List.first(data["url"]) + is_map(data["url"]) -> data["url"] + true -> nil + end - data - |> Map.put("mediaType", media_type) - |> Map.put("url", url) + media_type = + cond do + is_map(url) && is_binary(url["mediaType"]) -> url["mediaType"] + is_binary(data["mediaType"]) -> data["mediaType"] + is_binary(data["mimeType"]) -> data["mimeType"] + true -> nil + end + + href = + cond do + is_map(url) && is_binary(url["href"]) -> url["href"] + is_binary(data["url"]) -> data["url"] + is_binary(data["href"]) -> data["href"] + end + + attachment_url = + %{"href" => href} + |> add_if_present("mediaType", media_type) + |> add_if_present("type", Map.get(url || %{}, "type")) + + %{"url" => [attachment_url]} + |> add_if_present("mediaType", media_type) + |> add_if_present("type", data["type"]) + |> add_if_present("name", data["name"]) end) Map.put(object, "attachment", attachments) @@ -229,7 +265,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do Map.put(object, "url", url["href"]) end - def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do + def fix_url(%{"type" => object_type, "url" => url} = object) + when object_type in ["Video", "Audio"] and is_list(url) do first_element = Enum.at(url, 0) link_element = Enum.find(url, fn x -> is_map(x) and x["mimeType"] == "text/html" end) @@ -323,6 +360,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_type(object, _), do: object + defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = object) + when is_binary(content) do + html_content = + content + |> Earmark.as_html!(%Earmark.Options{renderer: EarmarkRenderer}) + |> Pleroma.HTML.filter_tags() + + Map.merge(object, %{"content" => html_content, "mediaType" => "text/html"}) + end + + defp fix_content(object), do: object + defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do with true <- id =~ "follows", %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id), @@ -398,7 +447,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, options ) - when objtype in ["Article", "Event", "Note", "Video", "Page", "Question", "Answer"] do + when objtype in ["Article", "Event", "Note", "Video", "Page", "Question", "Answer", "Audio"] do actor = Containment.get_actor(data) data = @@ -490,7 +539,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, {_, {:ok, _}} <- {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "accept") do + {:ok, _relationship} <- + FollowingRelationship.update(follower, followed, :follow_accept) do ActivityPub.accept(%{ to: [follower.ap_id], actor: followed, @@ -500,7 +550,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do else {:user_blocked, true} -> {:ok, _} = Utils.update_follow_state_for_all(activity, "reject") - {:ok, _relationship} = FollowingRelationship.update(follower, followed, "reject") + {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_reject) ActivityPub.reject(%{ to: [follower.ap_id], @@ -511,7 +561,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {:follow, {:error, _}} -> {:ok, _} = Utils.update_follow_state_for_all(activity, "reject") - {:ok, _relationship} = FollowingRelationship.update(follower, followed, "reject") + {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_reject) ActivityPub.reject(%{ to: [follower.ap_id], @@ -521,7 +571,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do }) {:user_locked, true} -> - {:ok, _relationship} = FollowingRelationship.update(follower, followed, "pending") + {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_pending) :noop end @@ -541,7 +591,10 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {:ok, follow_activity} <- get_follow_activity(follow_object, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "accept") do + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do + User.update_follower_count(followed) + User.update_following_count(follower) + ActivityPub.accept(%{ to: follow_activity.data["to"], type: "Accept", @@ -551,7 +604,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do activity_id: id }) else - _e -> :error + _e -> + :error end end @@ -564,7 +618,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {:ok, follow_activity} <- get_follow_activity(follow_object, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "reject"), + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), {:ok, activity} <- ActivityPub.reject(%{ to: follow_activity.data["to"], @@ -608,53 +662,14 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> handle_incoming(options) end - def handle_incoming( - %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data, - _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id), - {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do - {:ok, activity} - else - _e -> :error - end - end - - def handle_incoming( - %{ - "type" => "EmojiReact", - "object" => object_id, - "actor" => _actor, - "id" => id, - "content" => emoji - } = data, - _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id), - {:ok, activity, _object} <- - ActivityPub.react_with_emoji(actor, object, emoji, activity_id: id, local: false) do - {:ok, activity} - else - _e -> :error - end - end - - def handle_incoming( - %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data, - _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_embedded_obj_helper(object_id, actor), - public <- Visibility.is_public?(data), - {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do + def handle_incoming(%{"type" => type} = data, _options) + when type in ["Like", "EmojiReact", "Announce"] do + with :ok <- ObjectValidator.fetch_actor_and_object(data), + {:ok, activity, _meta} <- + Pipeline.common_pipeline(data, local: false) do {:ok, activity} else - _e -> :error + e -> {:error, e} end end @@ -673,7 +688,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object) actor - |> User.upgrade_changeset(new_user_data, true) + |> User.remote_user_changeset(new_user_data) |> User.update_and_set_cache() ActivityPub.update(%{ @@ -691,55 +706,25 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - # TODO: We presently assume that any actor on the same origin domain as the object being - # deleted has the rights to delete that object. A better way to validate whether or not - # the object should be deleted is to refetch the object URI, which should return either - # an error or a tombstone. This would allow us to verify that a deletion actually took - # place. def handle_incoming( - %{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => id} = data, + %{"type" => "Delete"} = data, _options ) do - object_id = Utils.get_ap_id(object_id) - - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id), - :ok <- Containment.contain_origin(actor.ap_id, object.data), - {:ok, activity} <- - ActivityPub.delete(object, local: false, activity_id: id, actor: actor.ap_id) do + with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} else - nil -> - case User.get_cached_by_ap_id(object_id) do - %User{ap_id: ^actor} = user -> - User.delete(user) - - nil -> - :error + {:error, {:validate_object, _}} = e -> + # Check if we have a create activity for this + with {:ok, object_id} <- Types.ObjectID.cast(data["object"]), + %Activity{data: %{"actor" => actor}} <- + Activity.create_by_object_ap_id(object_id) |> Repo.one(), + # We have one, insert a tombstone and retry + {:ok, tombstone_data, _} <- Builder.tombstone(actor, object_id), + {:ok, _tombstone} <- Object.create(tombstone_data) do + handle_incoming(data) + else + _ -> e end - - _e -> - :error - end - end - - def handle_incoming( - %{ - "type" => "Undo", - "object" => %{"type" => "Announce", "object" => object_id}, - "actor" => _actor, - "id" => id - } = data, - _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id), - {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do - {:ok, activity} - else - _e -> :error end end @@ -765,39 +750,29 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def handle_incoming( %{ "type" => "Undo", - "object" => %{"type" => "EmojiReact", "id" => reaction_activity_id}, - "actor" => _actor, - "id" => id + "object" => %{"type" => type} } = data, _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, activity, _} <- - ActivityPub.unreact_with_emoji(actor, reaction_activity_id, - activity_id: id, - local: false - ) do + ) + when type in ["Like", "EmojiReact", "Announce", "Block"] do + with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} - else - _e -> :error end end + # For Undos that don't have the complete object attached, try to find it in our database. def handle_incoming( %{ "type" => "Undo", - "object" => %{"type" => "Block", "object" => blocked}, - "actor" => blocker, - "id" => id - } = _data, - _options - ) do - with %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked), - {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker), - {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do - User.unblock(blocker, blocked) - {:ok, activity} + "object" => object + } = activity, + options + ) + when is_binary(object) do + with %Activity{data: data} <- Activity.get_by_ap_id(object) do + activity + |> Map.put("object", data) + |> handle_incoming(options) else _e -> :error end @@ -820,43 +795,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def handle_incoming( %{ - "type" => "Undo", - "object" => %{"type" => "Like", "object" => object_id}, - "actor" => _actor, - "id" => id - } = data, - _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id), - {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do - {:ok, activity} - else - _e -> :error - end - end - - # For Undos that don't have the complete object attached, try to find it in our database. - def handle_incoming( - %{ - "type" => "Undo", - "object" => object - } = activity, - options - ) - when is_binary(object) do - with %Activity{data: data} <- Activity.get_by_ap_id(object) do - activity - |> Map.put("object", data) - |> handle_incoming(options) - else - _e -> :error - end - end - - def handle_incoming( - %{ "type" => "Move", "actor" => origin_actor, "object" => origin_actor, @@ -1107,14 +1045,16 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do Map.put(object, "tag", tags) end + # TODO These should be added on our side on insertion, it doesn't make much + # sense to regenerate these all the time def add_mention_tags(object) do - mentions = - object - |> Utils.get_notified_from_object() - |> Enum.map(&build_mention_tag/1) + to = object["to"] || [] + cc = object["cc"] || [] + mentioned = User.get_users_from_set(to ++ cc, local_only: false) - tags = object["tag"] || [] + mentions = Enum.map(mentioned, &build_mention_tag/1) + tags = object["tag"] || [] Map.put(object, "tag", tags ++ mentions) end @@ -1124,7 +1064,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def take_emoji_tags(%User{emoji: emoji}) do emoji - |> Enum.flat_map(&Map.to_list/1) + |> Map.to_list() |> Enum.map(&build_emoji_tag/1) end @@ -1153,6 +1093,10 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do Map.put(object, "conversation", object["context"]) end + def set_sensitive(%{"sensitive" => true} = object) do + object + end + def set_sensitive(object) do tags = object["tag"] || [] Map.put(object, "sensitive", "nsfw" in tags) @@ -1171,18 +1115,24 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def prepare_attachments(object) do attachments = - (object["attachment"] || []) + object + |> Map.get("attachment", []) |> Enum.map(fn data -> [%{"mediaType" => media_type, "href" => href} | _] = data["url"] - %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"} + + %{ + "url" => href, + "mediaType" => media_type, + "name" => data["name"], + "type" => "Document" + } end) Map.put(object, "attachment", attachments) end def strip_internal_fields(object) do - object - |> Map.drop(Pleroma.Constants.object_internal_fields()) + Map.drop(object, Pleroma.Constants.object_internal_fields()) end defp strip_internal_tags(%{"tag" => tags} = object) do @@ -1218,12 +1168,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def upgrade_user_from_ap_id(ap_id) do with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), - already_ap <- User.ap_enabled?(user), - {:ok, user} <- upgrade_user(user, data) do - if not already_ap do - TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) - end - + {:ok, user} <- update_user(user, data) do + TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) {:ok, user} else %User{} = user -> {:ok, user} @@ -1231,9 +1177,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - defp upgrade_user(user, data) do + defp update_user(user, data) do user - |> User.upgrade_changeset(data, true) + |> User.remote_user_changeset(data) |> User.update_and_set_cache() end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 15dd2ed45..f2375bcc4 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do alias Ecto.Changeset alias Ecto.UUID alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -169,8 +170,11 @@ defmodule Pleroma.Web.ActivityPub.Utils do Enqueues an activity for federation if it's local """ @spec maybe_federate(any()) :: :ok - def maybe_federate(%Activity{local: true} = activity) do - if Pleroma.Config.get!([:instance, :federating]) do + def maybe_federate(%Activity{local: true, data: %{"type" => type}} = activity) do + outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) + + with true <- Config.get!([:instance, :federating]), + true <- type != "Block" || outgoing_blocks do Pleroma.Web.Federator.publish(activity) end @@ -440,22 +444,19 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)]) |> Repo.update_all([]) - User.set_follow_state_cache(actor, object, state) - activity = Activity.get_by_id(activity.id) {:ok, activity} end def update_follow_state( - %Activity{data: %{"actor" => actor, "object" => object}} = activity, + %Activity{} = activity, state ) do new_data = Map.put(activity.data, "state", state) changeset = Changeset.change(activity, data: new_data) with {:ok, activity} <- Repo.update(changeset) do - User.set_follow_state_cache(actor, object, state) {:ok, activity} end end @@ -515,7 +516,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do #### Announce-related helpers @doc """ - Retruns an existing announce activity if the notice has already been announced + Returns an existing announce activity if the notice has already been announced """ @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do @@ -565,45 +566,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> maybe_put("id", activity_id) end - @doc """ - Make unannounce activity data for the given actor and object - """ - def make_unannounce_data( - %User{ap_id: ap_id} = user, - %Activity{data: %{"context" => context, "object" => object}} = activity, - activity_id - ) do - object = Object.normalize(object) - - %{ - "type" => "Undo", - "actor" => ap_id, - "object" => activity.data, - "to" => [user.follower_address, object.data["actor"]], - "cc" => [Pleroma.Constants.as_public()], - "context" => context - } - |> maybe_put("id", activity_id) - end - - def make_unlike_data( - %User{ap_id: ap_id} = user, - %Activity{data: %{"context" => context, "object" => object}} = activity, - activity_id - ) do - object = Object.normalize(object) - - %{ - "type" => "Undo", - "actor" => ap_id, - "object" => activity.data, - "to" => [user.follower_address, object.data["actor"]], - "cc" => [Pleroma.Constants.as_public()], - "context" => context - } - |> maybe_put("id", activity_id) - end - def make_undo_data( %User{ap_id: actor, follower_address: follower_address}, %Activity{ @@ -691,16 +653,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> maybe_put("id", activity_id) end - def make_unblock_data(blocker, blocked, block_activity, activity_id) do - %{ - "type" => "Undo", - "actor" => blocker.ap_id, - "to" => [blocked.ap_id], - "object" => block_activity.data - } - |> maybe_put("id", activity_id) - end - #### Create-related helpers def make_create_data(params, additional) do @@ -798,102 +750,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do ActivityPub.fetch_activities([], params, :offset) end - def parse_report_group(activity) do - reports = get_reports_by_status_id(activity["id"]) - max_date = Enum.max_by(reports, &NaiveDateTime.from_iso8601!(&1.data["published"])) - actors = Enum.map(reports, & &1.user_actor) - [%{data: %{"object" => [account_id | _]}} | _] = reports - - account = - AccountView.render("show.json", %{ - user: User.get_by_ap_id(account_id) - }) - - status = get_status_data(activity) - - %{ - date: max_date.data["published"], - account: account, - status: status, - actors: Enum.uniq(actors), - reports: reports - } - end - - defp get_status_data(status) do - case status["deleted"] do - true -> - %{ - "id" => status["id"], - "deleted" => true - } - - _ -> - Activity.get_by_ap_id(status["id"]) - end - end - - def get_reports_by_status_id(ap_id) do - from(a in Activity, - where: fragment("(?)->>'type' = 'Flag'", a.data), - where: fragment("(?)->'object' @> ?", a.data, ^[%{id: ap_id}]), - or_where: fragment("(?)->'object' @> ?", a.data, ^[ap_id]) - ) - |> Activity.with_preloaded_user_actor() - |> Repo.all() - end - - @spec get_reports_grouped_by_status([String.t()]) :: %{ - required(:groups) => [ - %{ - required(:date) => String.t(), - required(:account) => %{}, - required(:status) => %{}, - required(:actors) => [%User{}], - required(:reports) => [%Activity{}] - } - ] - } - def get_reports_grouped_by_status(activity_ids) do - parsed_groups = - activity_ids - |> Enum.map(fn id -> - id - |> build_flag_object() - |> parse_report_group() - end) - - %{ - groups: parsed_groups - } - end - - @spec get_reported_activities() :: [ - %{ - required(:activity) => String.t(), - required(:date) => String.t() - } - ] - def get_reported_activities do - reported_activities_query = - from(a in Activity, - where: fragment("(?)->>'type' = 'Flag'", a.data), - select: %{ - activity: fragment("jsonb_array_elements((? #- '{object,0}')->'object')", a.data) - }, - group_by: fragment("activity") - ) - - from(a in subquery(reported_activities_query), - distinct: true, - select: %{ - id: fragment("COALESCE(?->>'id'::text, ? #>> '{}')", a.activity, a.activity) - } - ) - |> Repo.all() - |> Enum.map(& &1.id) - end - def update_report_state(%Activity{} = activity, state) when state in @strip_status_report_states do {:ok, stripped_activity} = strip_report_status_data(activity) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index bc21ac6c7..34590b16d 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -79,10 +79,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do emoji_tags = Transmogrifier.take_emoji_tags(user) - fields = - user - |> User.fields() - |> Enum.map(&Map.put(&1, "type", "PropertyValue")) + fields = Enum.map(user.fields, &Map.put(&1, "type", "PropertyValue")) %{ "id" => user.ap_id, @@ -103,7 +100,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do }, "endpoints" => endpoints, "attachment" => fields, - "tag" => (user.source_data["tag"] || []) ++ emoji_tags, + "tag" => emoji_tags, "discoverable" => user.discoverable } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 175260bc2..783203c07 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Activity alias Pleroma.Config alias Pleroma.ConfigDB + alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.ReportNote @@ -17,8 +18,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.User alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView @@ -27,18 +31,20 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint - alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.MastodonAPI + alias Pleroma.Web.MastodonAPI.AppView + alias Pleroma.Web.OAuth.App alias Pleroma.Web.Router require Logger - @descriptions_json Pleroma.Docs.JSON.compile() + @descriptions Pleroma.Docs.JSON.compile() @users_page_size 50 plug( OAuthScopesPlug, %{scopes: ["read:accounts"], admin: true} - when action in [:list_users, :user_show, :right_get] + when action in [:list_users, :user_show, :right_get, :show_user_credentials] ) plug( @@ -46,6 +52,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do %{scopes: ["write:accounts"], admin: true} when action in [ :get_password_reset, + :force_password_reset, :user_delete, :users_create, :user_toggle_activation, @@ -54,7 +61,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :tag_users, :untag_users, :right_add, - :right_delete + :right_add_multiple, + :right_delete, + :disable_mfa, + :right_delete_multiple, + :update_user_credentials ] ) @@ -81,52 +92,60 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["write:reports"], admin: true} - when action in [:reports_update] + when action in [:reports_update, :report_notes_create, :report_notes_delete] ) plug( OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} - when action == :list_user_statuses - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:statuses"], admin: true} - when action in [:status_update, :status_delete] + when action in [:list_user_statuses, :list_instance_statuses] ) plug( OAuthScopesPlug, %{scopes: ["read"], admin: true} - when action in [:config_show, :list_log, :stats] + when action in [ + :config_show, + :list_log, + :stats, + :relay_list, + :config_descriptions, + :need_reboot + ] ) plug( OAuthScopesPlug, %{scopes: ["write"], admin: true} - when action == :config_update + when action in [ + :restart, + :config_update, + :resend_confirmation_email, + :confirm_email, + :oauth_app_create, + :oauth_app_list, + :oauth_app_update, + :oauth_app_delete, + :reload_emoji + ] ) - action_fallback(:errors) - - def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do - user = User.get_cached_by_nickname(nickname) - User.delete(user) + action_fallback(AdminAPI.FallbackController) - ModerationLog.insert_log(%{ - actor: admin, - subject: [user], - action: "delete" - }) - - conn - |> json(nickname) + def user_delete(conn, %{"nickname" => nickname}) do + user_delete(conn, %{"nicknames" => [nickname]}) end def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - User.delete(users) + users = + nicknames + |> Enum.map(&User.get_cached_by_nickname/1) + + users + |> Enum.each(fn user -> + {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) + Pipeline.common_pipeline(delete_data, local: true) + end) ModerationLog.insert_log(%{ actor: admin, @@ -256,7 +275,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do }) conn - |> put_view(Pleroma.Web.AdminAPI.StatusView) + |> put_view(AdminAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) end @@ -275,7 +294,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do }) conn - |> put_view(StatusView) + |> put_view(MastodonAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} @@ -369,29 +388,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do email: params["email"] } - with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)), - {:ok, users, count} <- filter_service_users(users, count), - do: - conn - |> json( - AccountView.render("index.json", - users: users, - count: count, - page_size: page_size - ) - ) - end - - defp filter_service_users(users, count) do - filtered_users = Enum.reject(users, &service_user?/1) - count = if Enum.any?(users, &service_user?/1), do: length(filtered_users), else: count - - {:ok, filtered_users, count} - end - - defp service_user?(user) do - String.match?(user.ap_id, ~r/.*\/relay$/) or - String.match?(user.ap_id, ~r/.*\/internal\/fetch$/) + with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do + json( + conn, + AccountView.render("index.json", users: users, count: count, page_size: page_size) + ) + end end @filters ~w(local external active deactivated is_admin is_moderator) @@ -575,9 +577,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do @doc "Sends registration invite via email" def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do - with true <- - Config.get([:instance, :invites_enabled]) && - !Config.get([:instance, :registrations_open]), + with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, + {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, {:ok, invite_token} <- UserInviteToken.create_invite(), email <- Pleroma.Emails.UserEmail.user_invitation_email( @@ -588,6 +589,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do ), {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + {:error, "To send invites you need to set the `registrations_open` option to false."} + + {:invites_enabled, _} -> + {:error, "To send invites you need to set the `invites_enabled` option to true."} end end @@ -658,6 +665,65 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do json_response(conn, :no_content, "") end + @doc "Disable mfa for user's account." + def disable_mfa(conn, %{"nickname" => nickname}) do + case User.get_by_nickname(nickname) do + %User{} = user -> + MFA.disable(user) + json(conn, nickname) + + _ -> + {:error, :not_found} + end + end + + @doc "Show a given user's credentials" + def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do + conn + |> put_view(AccountView) + |> render("credentials.json", %{user: user, for: admin}) + else + _ -> {:error, :not_found} + end + end + + @doc "Updates a given user" + def update_user_credentials( + %{assigns: %{user: admin}} = conn, + %{"nickname" => nickname} = params + ) do + with {_, %User{} = user} <- {:user, User.get_cached_by_nickname(nickname)}, + {:ok, _user} <- + User.update_as_admin(user, params) do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "updated_users" + }) + + if params["password"] do + User.force_password_reset_async(user) + end + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "force_password_reset" + }) + + json(conn, %{status: "success"}) + else + {:error, changeset} -> + errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end) + + json(conn, %{errors: errors}) + + _ -> + json(conn, %{error: "Unable to update user."}) + end + end + def list_reports(conn, params) do {page, page_size} = page_params(params) @@ -668,14 +734,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do |> render("index.json", %{reports: reports}) end - def list_grouped_reports(conn, _params) do - statuses = Utils.get_reported_activities() - - conn - |> put_view(ReportView) - |> render("index_grouped.json", Utils.get_reports_grouped_by_status(statuses)) - end - def report_show(conn, %{"id" => id}) do with %Activity{} = report <- Activity.get_by_id(id) do conn @@ -745,56 +803,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def list_statuses(%{assigns: %{user: _admin}} = conn, params) do - godmode = params["godmode"] == "true" || params["godmode"] == true - local_only = params["local_only"] == "true" || params["local_only"] == true - with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true - {page, page_size} = page_params(params) - - activities = - ActivityPub.fetch_statuses(nil, %{ - "godmode" => godmode, - "local_only" => local_only, - "limit" => page_size, - "offset" => (page - 1) * page_size, - "exclude_reblogs" => !with_reblogs && "true" - }) - - conn - |> put_view(Pleroma.Web.AdminAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity}) - end - - def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do - with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do - {:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"]) - - ModerationLog.insert_log(%{ - action: "status_update", - actor: admin, - subject: activity, - sensitive: sensitive, - visibility: params["visibility"] - }) - - conn - |> put_view(StatusView) - |> render("show.json", %{activity: activity}) - end - end - - def status_delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do - ModerationLog.insert_log(%{ - action: "status_delete", - actor: user, - subject_id: id - }) - - json(conn, %{}) - end - end - def list_log(conn, params) do {page, page_size} = page_params(params) @@ -814,13 +822,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end def config_descriptions(conn, _params) do - conn - |> Plug.Conn.put_resp_content_type("application/json") - |> Plug.Conn.send_resp(200, @descriptions_json) + descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) + + json(conn, descriptions) end def config_show(conn, %{"only_db" => true}) do - with :ok <- configurable_from_database(conn) do + with :ok <- configurable_from_database() do configs = Pleroma.Repo.all(ConfigDB) conn @@ -830,7 +838,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end def config_show(conn, _params) do - with :ok <- configurable_from_database(conn) do + with :ok <- configurable_from_database() do configs = ConfigDB.get_all_as_keyword() merged = @@ -864,23 +872,16 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end) |> List.flatten() - response = %{configs: merged} - - response = - if Restarter.Pleroma.need_reboot?() do - Map.put(response, :need_reboot, true) - else - response - end - - json(conn, response) + json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) end end def config_update(conn, %{"configs" => configs}) do - with :ok <- configurable_from_database(conn) do + with :ok <- configurable_from_database() do {_errors, results} = - Enum.map(configs, fn + configs + |> Enum.filter(&whitelisted_config?/1) + |> Enum.map(fn %{"group" => group, "key" => key, "delete" => true} = params -> ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) @@ -900,50 +901,67 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do Config.TransferTask.load_and_update_env(deleted, false) - need_reboot? = - Restarter.Pleroma.need_reboot?() || - Enum.any?(updated, fn config -> + if !Restarter.Pleroma.need_reboot?() do + changed_reboot_settings? = + (updated ++ deleted) + |> Enum.any?(fn config -> group = ConfigDB.from_string(config.group) key = ConfigDB.from_string(config.key) value = ConfigDB.from_binary(config.value) Config.TransferTask.pleroma_need_restart?(group, key, value) end) - response = %{configs: updated} - - response = - if need_reboot? do - Restarter.Pleroma.need_reboot() - Map.put(response, :need_reboot, need_reboot?) - else - response - end + if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() + end conn |> put_view(ConfigView) - |> render("index.json", response) + |> render("index.json", %{configs: updated, need_reboot: Restarter.Pleroma.need_reboot?()}) end end def restart(conn, _params) do - with :ok <- configurable_from_database(conn) do + with :ok <- configurable_from_database() do Restarter.Pleroma.restart(Config.get(:env), 50) json(conn, %{}) end end - defp configurable_from_database(conn) do + def need_reboot(conn, _params) do + json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()}) + end + + defp configurable_from_database do if Config.get(:configurable_from_database) do :ok else - errors( - conn, - {:error, "To use this endpoint you need to enable configuration from database."} - ) + {:error, "To use this endpoint you need to enable configuration from database."} end end + defp whitelisted_config?(group, key) do + if whitelisted_configs = Config.get(:database_config_whitelist) do + Enum.any?(whitelisted_configs, fn + {whitelisted_group} -> + group == inspect(whitelisted_group) + + {whitelisted_group, whitelisted_key} -> + group == inspect(whitelisted_group) && key == inspect(whitelisted_key) + end) + else + true + end + end + + defp whitelisted_config?(%{"group" => group, "key" => key}) do + whitelisted_config?(group, key) + end + + defp whitelisted_config?(%{:group => group} = config) do + whitelisted_config?(group, config[:key]) + end + def reload_emoji(conn, _params) do Pleroma.Emoji.reload() @@ -978,35 +996,88 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do conn |> json("") end - def stats(conn, _) do - count = Stats.get_status_visibility_count() + def oauth_app_create(conn, params) do + params = + if params["name"] do + Map.put(params, "client_name", params["name"]) + else + params + end - conn - |> json(%{"status_visibility" => count}) + result = + case App.create(params) do + {:ok, app} -> + AppView.render("show.json", %{app: app, admin: true}) + + {:error, changeset} -> + App.errors(changeset) + end + + json(conn, result) end - def errors(conn, {:error, :not_found}) do - conn - |> put_status(:not_found) - |> json(dgettext("errors", "Not found")) + def oauth_app_update(conn, params) do + params = + if params["name"] do + Map.put(params, "client_name", params["name"]) + else + params + end + + with {:ok, app} <- App.update(params) do + json(conn, AppView.render("show.json", %{app: app, admin: true})) + else + {:error, changeset} -> + json(conn, App.errors(changeset)) + + nil -> + json_response(conn, :bad_request, "") + end end - def errors(conn, {:error, reason}) do - conn - |> put_status(:bad_request) - |> json(reason) + def oauth_app_list(conn, params) do + {page, page_size} = page_params(params) + + search_params = %{ + client_name: params["name"], + client_id: params["client_id"], + page: page, + page_size: page_size + } + + search_params = + if Map.has_key?(params, "trusted") do + Map.put(search_params, :trusted, params["trusted"]) + else + search_params + end + + with {:ok, apps, count} <- App.search(search_params) do + json( + conn, + AppView.render("index.json", + apps: apps, + count: count, + page_size: page_size, + admin: true + ) + ) + end end - def errors(conn, {:param_cast, _}) do - conn - |> put_status(:bad_request) - |> json(dgettext("errors", "Invalid parameters")) + def oauth_app_delete(conn, params) do + with {:ok, _app} <- App.destroy(params["id"]) do + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end end - def errors(conn, _) do + def stats(conn, _) do + count = Stats.get_status_visibility_count() + conn - |> put_status(:internal_server_error) - |> json(dgettext("errors", "Something went wrong")) + |> json(%{"status_visibility" => count}) end defp page_params(params) do diff --git a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex new file mode 100644 index 000000000..82965936d --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FallbackController do + use Pleroma.Web, :controller + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(%{error: dgettext("errors", "Not found")}) + end + + def call(conn, {:error, reason}) do + conn + |> put_status(:bad_request) + |> json(%{error: reason}) + end + + def call(conn, {:param_cast, _}) do + conn + |> put_status(:bad_request) + |> json(dgettext("errors", "Invalid parameters")) + end + + def call(conn, _) do + conn + |> put_status(:internal_server_error) + |> json(%{error: dgettext("errors", "Something went wrong")}) + end +end diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex new file mode 100644 index 000000000..08cb9c10b --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.StatusController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} when action in [:index, :show]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"], admin: true} when action in [:update, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.StatusOperation + + def index(%{assigns: %{user: _admin}} = conn, params) do + activities = + ActivityPub.fetch_statuses(nil, %{ + "godmode" => params.godmode, + "local_only" => params.local_only, + "limit" => params.page_size, + "offset" => (params.page - 1) * params.page_size, + "exclude_reblogs" => not params.with_reblogs + }) + + render(conn, "index.json", activities: activities, as: :activity) + end + + def show(conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do + conn + |> put_view(MastodonAPI.StatusView) + |> render("show.json", %{activity: activity}) + else + nil -> {:error, :not_found} + end + end + + def update(%{assigns: %{user: admin}, body_params: params} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do + ModerationLog.insert_log(%{ + action: "status_update", + actor: admin, + subject: activity, + sensitive: params[:sensitive], + visibility: params[:visibility] + }) + + conn + |> put_view(MastodonAPI.StatusView) + |> render("show.json", %{activity: activity}) + end + end + + def delete(%{assigns: %{user: user}} = conn, %{id: id}) do + with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do + ModerationLog.insert_log(%{ + action: "status_delete", + actor: user, + subject_id: id + }) + + json(conn, %{}) + end + end +end diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex index 29cea1f44..c28efadd5 100644 --- a/lib/pleroma/web/admin_api/search.ex +++ b/lib/pleroma/web/admin_api/search.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.AdminAPI.Search do query = params |> Map.drop([:page, :page_size]) + |> Map.put(:exclude_service_users, true) |> User.Query.build() |> order_by([u], u.nickname) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 1e03849de..46dadb5ee 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -6,7 +6,9 @@ defmodule Pleroma.Web.AdminAPI.AccountView do use Pleroma.Web, :view alias Pleroma.User + alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView + alias Pleroma.Web.MastodonAPI alias Pleroma.Web.MediaProxy def render("index.json", %{users: users, count: count, page_size: page_size}) do @@ -23,6 +25,43 @@ defmodule Pleroma.Web.AdminAPI.AccountView do } end + def render("credentials.json", %{user: user, for: for_user}) do + user = User.sanitize_html(user, User.html_filter_policy(for_user)) + avatar = User.avatar_url(user) |> MediaProxy.url() + banner = User.banner_url(user) |> MediaProxy.url() + background = image_url(user.background) |> MediaProxy.url() + + user + |> Map.take([ + :id, + :bio, + :email, + :fields, + :name, + :nickname, + :locked, + :no_rich_text, + :default_scope, + :hide_follows, + :hide_followers_count, + :hide_follows_count, + :hide_followers, + :hide_favorites, + :allow_following_move, + :show_role, + :skip_thread_containment, + :pleroma_settings_store, + :raw_fields, + :discoverable, + :actor_type + ]) + |> Map.merge(%{ + "avatar" => avatar, + "banner" => banner, + "background" => background + }) + end + def render("show.json", %{user: user}) do avatar = User.avatar_url(user) |> MediaProxy.url() display_name = Pleroma.HTML.strip_tags(user.name || user.nickname) @@ -82,6 +121,13 @@ defmodule Pleroma.Web.AdminAPI.AccountView do } end + def merge_account_views(%User{} = user) do + MastodonAPI.AccountView.render("show.json", %{user: user}) + |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) + end + + def merge_account_views(_), do: %{} + defp parse_error([]), do: "" defp parse_error(errors) do @@ -104,4 +150,7 @@ defmodule Pleroma.Web.AdminAPI.AccountView do "" end end + + defp image_url(%{"url" => [%{"href" => href} | _]}), do: href + defp image_url(_), do: nil end diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index fc8733ce8..f432b8c2c 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -4,13 +4,16 @@ defmodule Pleroma.Web.AdminAPI.ReportView do use Pleroma.Web, :view - alias Pleroma.Activity + alias Pleroma.HTML alias Pleroma.User + alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView + defdelegate merge_account_views(user), to: AdminAPI.AccountView + def render("index.json", %{reports: reports}) do %{ reports: @@ -38,38 +41,16 @@ defmodule Pleroma.Web.AdminAPI.ReportView do actor: merge_account_views(user), content: content, created_at: created_at, - statuses: StatusView.render("index.json", %{activities: statuses, as: :activity}), + statuses: + StatusView.render("index.json", %{ + activities: statuses, + as: :activity + }), state: report.data["state"], notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}) } end - def render("index_grouped.json", %{groups: groups}) do - reports = - Enum.map(groups, fn group -> - status = - case group.status do - %Activity{} = activity -> StatusView.render("show.json", %{activity: activity}) - _ -> group.status - end - - %{ - date: group[:date], - account: group[:account], - status: Map.put_new(status, "deleted", false), - actors: Enum.map(group[:actors], &merge_account_views/1), - reports: - group[:reports] - |> Enum.map(&Report.extract_report_info(&1)) - |> Enum.map(&render(__MODULE__, "show.json", &1)) - } - end) - - %{ - reports: reports - } - end - def render("index_notes.json", %{notes: notes}) when is_list(notes) do Enum.map(notes, &render(__MODULE__, "show_note.json", &1)) end @@ -91,11 +72,4 @@ defmodule Pleroma.Web.AdminAPI.ReportView do created_at: Utils.to_masto_date(inserted_at) } end - - defp merge_account_views(%User{} = user) do - Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user}) - |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user})) - end - - defp merge_account_views(_), do: %{} end diff --git a/lib/pleroma/web/admin_api/views/status_view.ex b/lib/pleroma/web/admin_api/views/status_view.ex index 360ddc22c..500800be2 100644 --- a/lib/pleroma/web/admin_api/views/status_view.ex +++ b/lib/pleroma/web/admin_api/views/status_view.ex @@ -7,36 +7,19 @@ defmodule Pleroma.Web.AdminAPI.StatusView do require Pleroma.Constants - alias Pleroma.User + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.MastodonAPI + + defdelegate merge_account_views(user), to: AdminAPI.AccountView def render("index.json", opts) do safe_render_many(opts.activities, __MODULE__, "show.json", opts) end def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do - user = get_user(activity.data["actor"]) + user = MastodonAPI.StatusView.get_user(activity.data["actor"]) - Pleroma.Web.MastodonAPI.StatusView.render("show.json", opts) + MastodonAPI.StatusView.render("show.json", opts) |> Map.merge(%{account: merge_account_views(user)}) end - - defp merge_account_views(%User{} = user) do - Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user}) - |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user})) - end - - defp merge_account_views(_), do: %{} - - defp get_user(ap_id) do - cond do - user = User.get_cached_by_ap_id(ap_id) -> - user - - user = User.get_by_guessed_nickname(ap_id) -> - user - - true -> - User.error_user(ap_id) - end - end end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex new file mode 100644 index 000000000..79fd5f871 --- /dev/null +++ b/lib/pleroma/web/api_spec.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec do + alias OpenApiSpex.OpenApi + alias OpenApiSpex.Operation + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Router + + @behaviour OpenApi + + @impl OpenApi + def spec do + %OpenApi{ + servers: [ + # Populate the Server info from a phoenix endpoint + OpenApiSpex.Server.from_endpoint(Endpoint) + ], + info: %OpenApiSpex.Info{ + title: "Pleroma", + description: Application.spec(:pleroma, :description) |> to_string(), + version: Application.spec(:pleroma, :vsn) |> to_string() + }, + # populate the paths from a phoenix router + paths: OpenApiSpex.Paths.from_router(Router), + components: %OpenApiSpex.Components{ + parameters: %{ + "accountIdOrNickname" => + Operation.parameter(:id, :path, :string, "Account ID or nickname", + example: "123", + required: true + ) + }, + securitySchemes: %{ + "oAuth" => %OpenApiSpex.SecurityScheme{ + type: "oauth2", + flows: %OpenApiSpex.OAuthFlows{ + password: %OpenApiSpex.OAuthFlow{ + authorizationUrl: "/oauth/authorize", + tokenUrl: "/oauth/token", + scopes: %{ + "read" => "read", + "write" => "write", + "follow" => "follow", + "push" => "push" + } + } + } + } + } + } + } + # discover request/response schemas from path specs + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex new file mode 100644 index 000000000..bd9026237 --- /dev/null +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -0,0 +1,139 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2019-2020 Moxley Stratton, Mike Buhot <https://github.com/open-api-spex/open_api_spex>, MPL-2.0 +# Copyright © 2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.CastAndValidate do + @moduledoc """ + This plug is based on [`OpenApiSpex.Plug.CastAndValidate`] + (https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/plug/cast_and_validate.ex). + The main difference is ignoring unexpected query params instead of throwing + an error and a config option (`[Pleroma.Web.ApiSpec.CastAndValidate, :strict]`) + to disable this behavior. Also, the default rendering error module + is `Pleroma.Web.ApiSpec.RenderError`. + """ + + @behaviour Plug + + alias Plug.Conn + + @impl Plug + def init(opts) do + opts + |> Map.new() + |> Map.put_new(:render_error, Pleroma.Web.ApiSpec.RenderError) + end + + @impl Plug + def call(%{private: %{open_api_spex: private_data}} = conn, %{ + operation_id: operation_id, + render_error: render_error + }) do + spec = private_data.spec + operation = private_data.operation_lookup[operation_id] + + content_type = + case Conn.get_req_header(conn, "content-type") do + [header_value | _] -> + header_value + |> String.split(";") + |> List.first() + + _ -> + nil + end + + private_data = Map.put(private_data, :operation_id, operation_id) + conn = Conn.put_private(conn, :open_api_spex, private_data) + + case cast_and_validate(spec, operation, conn, content_type, strict?()) do + {:ok, conn} -> + conn + + {:error, reason} -> + opts = render_error.init(reason) + + conn + |> render_error.call(opts) + |> Plug.Conn.halt() + end + end + + def call( + %{ + private: %{ + phoenix_controller: controller, + phoenix_action: action, + open_api_spex: private_data + } + } = conn, + opts + ) do + operation = + case private_data.operation_lookup[{controller, action}] do + nil -> + operation_id = controller.open_api_operation(action).operationId + operation = private_data.operation_lookup[operation_id] + + operation_lookup = + private_data.operation_lookup + |> Map.put({controller, action}, operation) + + OpenApiSpex.Plug.Cache.adapter().put( + private_data.spec_module, + {private_data.spec, operation_lookup} + ) + + operation + + operation -> + operation + end + + if operation.operationId do + call(conn, Map.put(opts, :operation_id, operation.operationId)) + else + raise "operationId was not found in action API spec" + end + end + + def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts) + + defp cast_and_validate(spec, operation, conn, content_type, true = _strict) do + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + end + + defp cast_and_validate(spec, operation, conn, content_type, false = _strict) do + case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do + {:ok, conn} -> + {:ok, conn} + + # Remove unexpected query params and cast/validate again + {:error, errors} -> + query_params = + Enum.reduce(errors, conn.query_params, fn + %{reason: :unexpected_field, name: name, path: [name]}, params -> + Map.delete(params, name) + + %{reason: :invalid_enum, name: nil, path: path, value: value}, params -> + path = path |> Enum.reverse() |> tl() |> Enum.reverse() |> list_items_to_string() + update_in(params, path, &List.delete(&1, value)) + + _, params -> + params + end) + + conn = %Conn{conn | query_params: query_params} + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + end + end + + defp list_items_to_string(list) do + Enum.map(list, fn + i when is_atom(i) -> to_string(i) + i -> i + end) + end + + defp strict?, do: Pleroma.Config.get([__MODULE__, :strict], false) +end diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex new file mode 100644 index 000000000..a9cfe0fed --- /dev/null +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Helpers do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + def request_body(description, schema_ref, opts \\ []) do + media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"] + + content = + media_types + |> Enum.map(fn type -> + {type, + %OpenApiSpex.MediaType{ + schema: schema_ref, + example: opts[:example], + examples: opts[:examples] + }} + end) + |> Enum.into(%{}) + + %OpenApiSpex.RequestBody{ + description: description, + content: content, + required: opts[:required] || false + } + end + + def pagination_params do + [ + Operation.parameter(:max_id, :query, :string, "Return items older than this ID"), + Operation.parameter(:min_id, :query, :string, "Return the oldest items newer than this ID"), + Operation.parameter( + :since_id, + :query, + :string, + "Return the newest items newer than this ID" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" + ) + ] + end + + def with_relationships_param do + Operation.parameter( + :with_relationships, + :query, + BooleanLike, + "Embed relationships into accounts." + ) + end + + def empty_object_response do + Operation.response("Empty object", "application/json", %Schema{type: :object, example: %{}}) + end + + def empty_array_response do + Operation.response("Empty array", "application/json", %Schema{type: :array, example: []}) + end + + def no_content_response do + Operation.response("No Content", "application/json", %Schema{type: :string, example: ""}) + end +end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex new file mode 100644 index 000000000..20572f8ea --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -0,0 +1,730 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AccountOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Reference + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.List + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec create_operation() :: Operation.t() + def create_operation do + %Operation{ + tags: ["accounts"], + summary: "Register an account", + description: + "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.", + operationId: "AccountController.create", + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", create_response()), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError), + 429 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def verify_credentials_operation do + %Operation{ + tags: ["accounts"], + description: "Test to make sure that the user token works.", + summary: "Verify account credentials", + operationId: "AccountController.verify_credentials", + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => Operation.response("Account", "application/json", Account) + } + } + end + + def update_credentials_operation do + %Operation{ + tags: ["accounts"], + summary: "Update account credentials", + description: "Update the user's display and preferences.", + operationId: "AccountController.update_credentials", + security: [%{"oAuth" => ["write:accounts"]}], + requestBody: request_body("Parameters", update_creadentials_request(), required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", Account), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def relationships_operation do + %Operation{ + tags: ["accounts"], + summary: "Check relationships to other accounts", + operationId: "AccountController.relationships", + description: "Find out whether a given account is followed, blocked, muted, etc.", + security: [%{"oAuth" => ["read:follows"]}], + parameters: [ + Operation.parameter( + :id, + :query, + %Schema{ + oneOf: [%Schema{type: :array, items: %Schema{type: :string}}, %Schema{type: :string}] + }, + "Account IDs", + example: "123" + ) + ], + responses: %{ + 200 => Operation.response("Account", "application/json", array_of_relationships()) + } + } + end + + def show_operation do + %Operation{ + tags: ["accounts"], + summary: "Account", + operationId: "AccountController.show", + description: "View information about a profile.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Account", "application/json", Account), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def statuses_operation do + %Operation{ + tags: ["accounts"], + summary: "Statuses", + operationId: "AccountController.statuses", + description: + "Statuses posted to the given account. Public (for public statuses only), or user token + `read:statuses` (for private statuses the user is authorized to see)", + parameters: + [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:pinned, :query, BooleanLike, "Include only pinned statuses"), + Operation.parameter(:tagged, :query, :string, "With tag"), + Operation.parameter( + :only_media, + :query, + BooleanLike, + "Include only statuses with media attached" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include statuses from muted acccounts." + ), + Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), + Operation.parameter(:exclude_replies, :query, BooleanLike, "Exclude replies"), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude visibilities" + ) + ] ++ pagination_params(), + responses: %{ + 200 => Operation.response("Statuses", "application/json", array_of_statuses()), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def followers_operation do + %Operation{ + tags: ["accounts"], + summary: "Followers", + operationId: "AccountController.followers", + security: [%{"oAuth" => ["read:accounts"]}], + description: + "Accounts which follow the given account, if network is not hidden by the account owner.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + with_relationships_param() | pagination_params() + ], + responses: %{ + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + } + } + end + + def following_operation do + %Operation{ + tags: ["accounts"], + summary: "Following", + operationId: "AccountController.following", + security: [%{"oAuth" => ["read:accounts"]}], + description: + "Accounts which the given account is following, if network is not hidden by the account owner.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + with_relationships_param() | pagination_params() + ], + responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())} + } + end + + def lists_operation do + %Operation{ + tags: ["accounts"], + summary: "Lists containing this account", + operationId: "AccountController.lists", + security: [%{"oAuth" => ["read:lists"]}], + description: "User lists that you have added this account to.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{200 => Operation.response("Lists", "application/json", array_of_lists())} + } + end + + def follow_operation do + %Operation{ + tags: ["accounts"], + summary: "Follow", + operationId: "AccountController.follow", + security: [%{"oAuth" => ["follow", "write:follows"]}], + description: "Follow the given account", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter( + :reblogs, + :query, + BooleanLike, + "Receive this account's reblogs in home timeline? Defaults to true." + ) + ], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["accounts"], + summary: "Unfollow", + operationId: "AccountController.unfollow", + security: [%{"oAuth" => ["follow", "write:follows"]}], + description: "Unfollow the given account", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def mute_operation do + %Operation{ + tags: ["accounts"], + summary: "Mute", + operationId: "AccountController.mute", + security: [%{"oAuth" => ["follow", "write:mutes"]}], + requestBody: request_body("Parameters", mute_request()), + description: + "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter( + :notifications, + :query, + %Schema{allOf: [BooleanLike], default: true}, + "Mute notifications in addition to statuses? Defaults to `true`." + ) + ], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def unmute_operation do + %Operation{ + tags: ["accounts"], + summary: "Unmute", + operationId: "AccountController.unmute", + security: [%{"oAuth" => ["follow", "write:mutes"]}], + description: "Unmute the given account.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def block_operation do + %Operation{ + tags: ["accounts"], + summary: "Block", + operationId: "AccountController.block", + security: [%{"oAuth" => ["follow", "write:blocks"]}], + description: + "Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def unblock_operation do + %Operation{ + tags: ["accounts"], + summary: "Unblock", + operationId: "AccountController.unblock", + security: [%{"oAuth" => ["follow", "write:blocks"]}], + description: "Unblock the given account.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def follow_by_uri_operation do + %Operation{ + tags: ["accounts"], + summary: "Follow by URI", + operationId: "AccountController.follows", + security: [%{"oAuth" => ["follow", "write:follows"]}], + requestBody: request_body("Parameters", follow_by_uri_request(), required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def mutes_operation do + %Operation{ + tags: ["accounts"], + summary: "Muted accounts", + operationId: "AccountController.mutes", + description: "Accounts the user has muted.", + security: [%{"oAuth" => ["follow", "read:mutes"]}], + responses: %{ + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + } + } + end + + def blocks_operation do + %Operation{ + tags: ["accounts"], + summary: "Blocked users", + operationId: "AccountController.blocks", + description: "View your blocks. See also accounts/:id/{block,unblock}", + security: [%{"oAuth" => ["read:blocks"]}], + responses: %{ + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + } + } + end + + def endorsements_operation do + %Operation{ + tags: ["accounts"], + summary: "Endorsements", + operationId: "AccountController.endorsements", + description: "Not implemented", + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => empty_array_response() + } + } + end + + def identity_proofs_operation do + %Operation{ + tags: ["accounts"], + summary: "Identity proofs", + operationId: "AccountController.identity_proofs", + description: "Not implemented", + responses: %{ + 200 => empty_array_response() + } + } + end + + defp create_request do + %Schema{ + title: "AccountCreateRequest", + description: "POST body for creating an account", + type: :object, + required: [:username, :password, :agreement], + properties: %{ + reason: %Schema{ + type: :string, + nullable: true, + description: + "Text that will be reviewed by moderators if registrations require manual approval" + }, + username: %Schema{type: :string, description: "The desired username for the account"}, + email: %Schema{ + type: :string, + nullable: true, + description: + "The email address to be used for login. Required when `account_activation_required` is enabled.", + format: :email + }, + password: %Schema{ + type: :string, + description: "The password to be used for login", + format: :password + }, + agreement: %Schema{ + allOf: [BooleanLike], + description: + "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE." + }, + locale: %Schema{ + type: :string, + nullable: true, + description: "The language of the confirmation email that will be sent" + }, + # Pleroma-specific properties: + fullname: %Schema{type: :string, nullable: true, description: "Full name"}, + bio: %Schema{type: :string, description: "Bio", nullable: true, default: ""}, + captcha_solution: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha solution" + }, + captcha_token: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha token" + }, + captcha_answer_data: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha data" + }, + token: %Schema{ + type: :string, + nullable: true, + description: "Invite token required when the registrations aren't public" + } + }, + example: %{ + "username" => "cofe", + "email" => "cofe@example.com", + "password" => "secret", + "agreement" => "true", + "bio" => "☕️" + } + } + end + + defp create_response do + %Schema{ + title: "AccountCreateResponse", + description: "Response schema for an account", + type: :object, + properties: %{ + token_type: %Schema{type: :string}, + access_token: %Schema{type: :string}, + scope: %Schema{type: :array, items: %Schema{type: :string}}, + created_at: %Schema{type: :integer, format: :"date-time"} + }, + example: %{ + "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", + "created_at" => 1_585_918_714, + "scope" => ["read", "write", "follow", "push"], + "token_type" => "Bearer" + } + } + end + + defp update_creadentials_request do + %Schema{ + title: "AccountUpdateCredentialsRequest", + description: "POST body for creating an account", + type: :object, + properties: %{ + bot: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Whether the account has a bot flag." + }, + display_name: %Schema{ + type: :string, + nullable: true, + description: "The display name to use for the profile." + }, + note: %Schema{type: :string, description: "The account bio."}, + avatar: %Schema{ + type: :string, + nullable: true, + description: "Avatar image encoded using multipart/form-data", + format: :binary + }, + header: %Schema{ + type: :string, + nullable: true, + description: "Header image encoded using multipart/form-data", + format: :binary + }, + locked: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Whether manual approval of follow requests is required." + }, + fields_attributes: %Schema{ + nullable: true, + oneOf: [ + %Schema{type: :array, items: attribute_field()}, + %Schema{type: :object, additionalProperties: %Schema{type: attribute_field()}} + ] + }, + # NOTE: `source` field is not supported + # + # source: %Schema{ + # type: :object, + # properties: %{ + # privacy: %Schema{type: :string}, + # sensitive: %Schema{type: :boolean}, + # language: %Schema{type: :string} + # } + # }, + + # Pleroma-specific fields + no_rich_text: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "html tags are stripped from all statuses requested from the API" + }, + hide_followers: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's followers will be hidden" + }, + hide_follows: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's follows will be hidden" + }, + hide_followers_count: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's follower count will be hidden" + }, + hide_follows_count: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's follow count will be hidden" + }, + hide_favorites: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's favorites timeline will be hidden" + }, + show_role: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's role (e.g admin, moderator) will be exposed to anyone in the + API" + }, + default_scope: VisibilityScope, + pleroma_settings_store: %Schema{ + type: :object, + nullable: true, + description: "Opaque user settings to be saved on the backend." + }, + skip_thread_containment: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Skip filtering out broken threads" + }, + allow_following_move: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Allows automatically follow moved following accounts" + }, + pleroma_background_image: %Schema{ + type: :string, + nullable: true, + description: "Sets the background image of the user.", + format: :binary + }, + discoverable: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "Discovery of this account in search results and other services is allowed." + }, + actor_type: ActorType + }, + example: %{ + bot: false, + display_name: "cofe", + note: "foobar", + fields_attributes: [%{name: "foo", value: "bar"}], + no_rich_text: false, + hide_followers: true, + hide_follows: false, + hide_followers_count: false, + hide_follows_count: false, + hide_favorites: false, + show_role: false, + default_scope: "private", + pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, + skip_thread_containment: false, + allow_following_move: false, + discoverable: false, + actor_type: "Person" + } + } + end + + def array_of_accounts do + %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: Account, + example: [Account.schema().example] + } + end + + defp array_of_relationships do + %Schema{ + title: "ArrayOfRelationships", + description: "Response schema for account relationships", + type: :array, + items: AccountRelationship, + example: [ + %{ + "id" => "1", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "domain_blocking" => false, + "subscribing" => false, + "endorsed" => true + }, + %{ + "id" => "2", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => true, + "muting_notifications" => false, + "requested" => true, + "domain_blocking" => false, + "subscribing" => false, + "endorsed" => false + }, + %{ + "id" => "3", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => true, + "blocked_by" => false, + "muting" => true, + "muting_notifications" => false, + "requested" => false, + "domain_blocking" => true, + "subscribing" => true, + "endorsed" => false + } + ] + } + end + + defp follow_by_uri_request do + %Schema{ + title: "AccountFollowsRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + uri: %Schema{type: :string, nullable: true, format: :uri} + }, + required: [:uri] + } + end + + defp mute_request do + %Schema{ + title: "AccountMuteRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + notifications: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Mute notifications in addition to statuses? Defaults to true.", + default: true + } + }, + example: %{ + "notifications" => true + } + } + end + + defp array_of_lists do + %Schema{ + title: "ArrayOfLists", + description: "Response schema for lists", + type: :array, + items: List, + example: [ + %{"id" => "123", "title" => "my list"}, + %{"id" => "1337", "title" => "anotehr list"} + ] + } + end + + defp array_of_statuses do + %Schema{ + title: "ArrayOfStatuses", + type: :array, + items: Status + } + end + + defp attribute_field do + %Schema{ + title: "AccountAttributeField", + description: "Request schema for account custom fields", + type: :object, + properties: %{ + name: %Schema{type: :string}, + value: %Schema{type: :string} + }, + required: [:name, :value], + example: %{ + "name" => "Website", + "value" => "https://pleroma.com" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex new file mode 100644 index 000000000..0b138dc79 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -0,0 +1,165 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.StatusOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0] + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Statuses"], + operationId: "AdminAPI.StatusController.index", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :godmode, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see private statuses" + ), + Operation.parameter( + :local_only, + :query, + %Schema{type: :boolean, default: false}, + "Excludes remote statuses" + ), + Operation.parameter( + :with_reblogs, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see reblogs" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], + responses: %{ + 200 => + Operation.response("Array of statuses", "application/json", %Schema{ + type: :array, + items: status() + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Show Status", + operationId: "AdminAPI.StatusController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Change the scope of an individual reported status", + operationId: "AdminAPI.StatusController.update", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Delete an individual reported status", + operationId: "AdminAPI.StatusController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + responses: %{ + 200 => empty_object_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp status do + %Schema{ + anyOf: [ + Status, + %Schema{ + type: :object, + properties: %{ + account: %Schema{allOf: [Account, admin_account()]} + } + } + ] + } + end + + defp admin_account do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + avatar: %Schema{type: :string}, + nickname: %Schema{type: :string}, + display_name: %Schema{type: :string}, + deactivated: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + tags: %Schema{type: :string}, + confirmation_pending: %Schema{type: :string} + } + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + sensitive: %Schema{ + type: :boolean, + description: "Mark status and attached media as sensitive?" + }, + visibility: VisibilityScope + }, + example: %{ + "visibility" => "private", + "sensitive" => "false" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex new file mode 100644 index 000000000..ae01cbbec --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -0,0 +1,148 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AppOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec create_operation() :: Operation.t() + def create_operation do + %Operation{ + tags: ["apps"], + summary: "Create an application", + description: "Create a new application to obtain OAuth2 credentials", + operationId: "AppController.create", + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("App", "application/json", create_response()), + 422 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + type: :object, + description: + "If a required parameter is missing or improperly formatted, the request will fail.", + properties: %{ + error: %Schema{type: :string} + }, + example: %{ + "error" => "Validation failed: Redirect URI must be an absolute URI." + } + } + ) + } + } + end + + def verify_credentials_operation do + %Operation{ + tags: ["apps"], + summary: "Verify your app works", + description: "Confirm that the app's OAuth2 credentials work.", + operationId: "AppController.verify_credentials", + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("App", "application/json", %Schema{ + type: :object, + description: + "If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.", + properties: %{ + name: %Schema{type: :string}, + vapid_key: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true} + }, + example: %{ + "name" => "My App", + "vapid_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", + "website" => "https://myapp.com/" + } + }), + 422 => + Operation.response( + "Unauthorized", + "application/json", + %Schema{ + type: :object, + description: + "If the Authorization header contains an invalid token, is malformed, or is not present, an error will be returned indicating an authorization failure.", + properties: %{ + error: %Schema{type: :string} + }, + example: %{ + "error" => "The access token is invalid." + } + } + ) + } + } + end + + defp create_request do + %Schema{ + title: "AppCreateRequest", + description: "POST body for creating an app", + type: :object, + properties: %{ + client_name: %Schema{type: :string, description: "A name for your application."}, + redirect_uris: %Schema{ + type: :string, + description: + "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." + }, + scopes: %Schema{ + type: :string, + description: "Space separated list of scopes", + default: "read" + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of your app" + } + }, + required: [:client_name, :redirect_uris], + example: %{ + "client_name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/" + } + } + end + + defp create_response do + %Schema{ + title: "AppCreateResponse", + description: "Response schema for an app", + type: :object, + properties: %{ + id: %Schema{type: :string}, + name: %Schema{type: :string}, + client_id: %Schema{type: :string}, + client_secret: %Schema{type: :string}, + redirect_uri: %Schema{type: :string}, + vapid_key: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true} + }, + example: %{ + "id" => "123", + "name" => "My App", + "client_id" => "TWhM-tNSuncnqN7DBJmoyeLnk6K3iJJ71KKXxgL1hPM", + "client_secret" => "ZEaFUFmF0umgBX1qKJDjaU99Q31lDkOU8NutzTOoliw", + "vapid_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", + "website" => "https://myapp.com/" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/conversation_operation.ex b/lib/pleroma/web/api_spec/operations/conversation_operation.ex new file mode 100644 index 000000000..475468893 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/conversation_operation.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ConversationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Conversations"], + summary: "Show conversation", + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "ConversationController.index", + parameters: [ + Operation.parameter( + :recipients, + :query, + %Schema{type: :array, items: FlakeID}, + "Only return conversations with the given recipients (a list of user ids)" + ) + | pagination_params() + ], + responses: %{ + 200 => + Operation.response("Array of Conversation", "application/json", %Schema{ + type: :array, + items: Conversation, + example: [Conversation.schema().example] + }) + } + } + end + + def mark_as_read_operation do + %Operation{ + tags: ["Conversations"], + summary: "Mark as read", + operationId: "ConversationController.mark_as_read", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + ], + security: [%{"oAuth" => ["write:conversations"]}], + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex new file mode 100644 index 000000000..2f812ac77 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex @@ -0,0 +1,88 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.CustomEmojiOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Emoji + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["custom_emojis"], + summary: "List custom custom emojis", + description: "Returns custom emojis that are available on the server.", + operationId: "CustomEmojiController.index", + responses: %{ + 200 => Operation.response("Custom Emojis", "application/json", resposnse()) + } + } + end + + defp resposnse do + %Schema{ + title: "CustomEmojisResponse", + description: "Response schema for custom emojis", + type: :array, + items: custom_emoji(), + example: [ + %{ + "category" => "Fun", + "shortcode" => "blank", + "static_url" => "https://lain.com/emoji/blank.png", + "tags" => ["Fun"], + "url" => "https://lain.com/emoji/blank.png", + "visible_in_picker" => false + }, + %{ + "category" => "Gif,Fun", + "shortcode" => "firefox", + "static_url" => "https://lain.com/emoji/Firefox.gif", + "tags" => ["Gif", "Fun"], + "url" => "https://lain.com/emoji/Firefox.gif", + "visible_in_picker" => true + }, + %{ + "category" => "pack:mixed", + "shortcode" => "sadcat", + "static_url" => "https://lain.com/emoji/mixed/sadcat.png", + "tags" => ["pack:mixed"], + "url" => "https://lain.com/emoji/mixed/sadcat.png", + "visible_in_picker" => true + } + ] + } + end + + defp custom_emoji do + %Schema{ + title: "CustomEmoji", + description: "Schema for a CustomEmoji", + allOf: [ + Emoji, + %Schema{ + type: :object, + properties: %{ + category: %Schema{type: :string}, + tags: %Schema{type: :array} + } + } + ], + example: %{ + "category" => "Fun", + "shortcode" => "aaaa", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/007/118/original/aaaa.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/007/118/static/aaaa.png", + "visible_in_picker" => true, + "tags" => ["Gif", "Fun"] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex new file mode 100644 index 000000000..049bcf931 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["domain_blocks"], + summary: "Fetch domain blocks", + description: "View domains the user has blocked.", + security: [%{"oAuth" => ["follow", "read:blocks"]}], + operationId: "DomainBlockController.index", + responses: %{ + 200 => + Operation.response("Domain blocks", "application/json", %Schema{ + description: "Response schema for domain blocks", + type: :array, + items: %Schema{type: :string}, + example: ["google.com", "facebook.com"] + }) + } + } + end + + def create_operation do + %Operation{ + tags: ["domain_blocks"], + summary: "Block a domain", + description: """ + Block a domain to: + + - hide all public posts from it + - hide all notifications from it + - remove all followers from it + - prevent following new users from it (but does not remove existing follows) + """, + operationId: "DomainBlockController.create", + requestBody: domain_block_request(), + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{200 => empty_object_response()} + } + end + + def delete_operation do + %Operation{ + tags: ["domain_blocks"], + summary: "Unblock a domain", + description: "Remove a domain block, if it exists in the user's array of blocked domains.", + operationId: "DomainBlockController.delete", + requestBody: domain_block_request(), + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + defp domain_block_request do + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + domain: %Schema{type: :string} + }, + required: [:domain] + }, + required: true, + example: %{ + "domain" => "facebook.com" + } + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex new file mode 100644 index 000000000..1a49fece0 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex @@ -0,0 +1,104 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: + "Get an object of emoji to account mappings with accounts that reacted to the post", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji", + required: false + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "EmojiReactionController.index", + responses: %{ + 200 => array_of_reactions_response() + } + } + end + + def create_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: "React to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "EmojiReactionController.create", + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: "Remove a reaction to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "EmojiReactionController.delete", + responses: %{ + 200 => Operation.response("Status", "application/json", Status) + } + } + end + + defp array_of_reactions_response do + Operation.response("Array of Emoji Reactions", "application/json", %Schema{ + type: :array, + items: emoji_reaction(), + example: [emoji_reaction().example] + }) + end + + defp emoji_reaction do + %Schema{ + title: "EmojiReaction", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "Emoji"}, + count: %Schema{type: :integer, description: "Count of reactions with this emoji"}, + me: %Schema{type: :boolean, description: "Did I react with this emoji?"}, + accounts: %Schema{ + type: :array, + items: Account, + description: "Array of accounts reacted with this emoji" + } + }, + example: %{ + "name" => "😱", + "count" => 1, + "me" => false, + "accounts" => [Account.schema().example] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex new file mode 100644 index 000000000..31e576f99 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -0,0 +1,230 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.FilterOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["apps"], + summary: "View all filters", + operationId: "FilterController.index", + security: [%{"oAuth" => ["read:filters"]}], + responses: %{ + 200 => Operation.response("Filters", "application/json", array_of_filters()) + } + } + end + + def create_operation do + %Operation{ + tags: ["apps"], + summary: "Create a filter", + operationId: "FilterController.create", + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + security: [%{"oAuth" => ["write:filters"]}], + responses: %{200 => Operation.response("Filter", "application/json", filter())} + } + end + + def show_operation do + %Operation{ + tags: ["apps"], + summary: "View all filters", + parameters: [id_param()], + operationId: "FilterController.show", + security: [%{"oAuth" => ["read:filters"]}], + responses: %{ + 200 => Operation.response("Filter", "application/json", filter()) + } + } + end + + def update_operation do + %Operation{ + tags: ["apps"], + summary: "Update a filter", + parameters: [id_param()], + operationId: "FilterController.update", + requestBody: Helpers.request_body("Parameters", update_request(), required: true), + security: [%{"oAuth" => ["write:filters"]}], + responses: %{ + 200 => Operation.response("Filter", "application/json", filter()) + } + } + end + + def delete_operation do + %Operation{ + tags: ["apps"], + summary: "Remove a filter", + parameters: [id_param()], + operationId: "FilterController.delete", + security: [%{"oAuth" => ["write:filters"]}], + responses: %{ + 200 => + Operation.response("Filter", "application/json", %Schema{ + type: :object, + description: "Empty object" + }) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true) + end + + defp filter do + %Schema{ + title: "Filter", + type: :object, + properties: %{ + id: %Schema{type: :string}, + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: "The contexts in which the filter should be applied." + }, + expires_at: %Schema{ + type: :string, + format: :"date-time", + description: + "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.", + nullable: true + }, + irreversible: %Schema{ + type: :boolean, + description: + "Should matching entities in home and notifications be dropped by the server?" + }, + whole_word: %Schema{ + type: :boolean, + description: "Should the filter consider word boundaries?" + } + }, + example: %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + } + } + end + + defp array_of_filters do + %Schema{ + title: "ArrayOfFilters", + description: "Array of Filters", + type: :array, + items: filter(), + example: [ + %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + }, + %{ + "id" => "6191", + "phrase" => ":eurovision2019:", + "context" => [ + "home" + ], + "whole_word" => true, + "expires_at" => "2019-05-21T13:47:31.333Z", + "irreversible" => false + } + ] + } + end + + defp create_request do + %Schema{ + title: "FilterCreateRequest", + allOf: [ + update_request(), + %Schema{ + type: :object, + properties: %{ + irreversible: %Schema{ + allOf: [BooleanLike], + description: + "Should the server irreversibly drop matching entities from home and notifications?", + default: false + } + } + } + ], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + } + end + + defp update_request do + %Schema{ + title: "FilterUpdateRequest", + type: :object, + properties: %{ + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: + "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified." + }, + irreversible: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "Should the server irreversibly drop matching entities from home and notifications?" + }, + whole_word: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Consider word boundaries?", + default: true + } + # TODO: probably should implement filter expiration + # expires_in: %Schema{ + # type: :string, + # format: :"date-time", + # description: + # "ISO 8601 Datetime for when the filter expires. Otherwise, + # null for a filter that doesn't expire." + # } + }, + required: [:phrase, :context], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex new file mode 100644 index 000000000..ac4aee6da --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Follow Requests"], + summary: "Pending Follows", + security: [%{"oAuth" => ["read:follows", "follow"]}], + operationId: "FollowRequestController.index", + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account, + example: [Account.schema().example] + }) + } + } + end + + def authorize_operation do + %Operation{ + tags: ["Follow Requests"], + summary: "Accept Follow", + operationId: "FollowRequestController.authorize", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def reject_operation do + %Operation{ + tags: ["Follow Requests"], + summary: "Reject Follow", + operationId: "FollowRequestController.reject", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex new file mode 100644 index 000000000..d5c335d0c --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -0,0 +1,175 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.InstanceOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Instance"], + summary: "Fetch instance", + description: "Information about the server", + operationId: "InstanceController.show", + responses: %{ + 200 => Operation.response("Instance", "application/json", instance()) + } + } + end + + def peers_operation do + %Operation{ + tags: ["Instance"], + summary: "List of known hosts", + operationId: "InstanceController.peers", + responses: %{ + 200 => Operation.response("Array of domains", "application/json", array_of_domains()) + } + } + end + + defp instance do + %Schema{ + type: :object, + properties: %{ + uri: %Schema{type: :string, description: "The domain name of the instance"}, + title: %Schema{type: :string, description: "The title of the website"}, + description: %Schema{ + type: :string, + description: "Admin-defined description of the Pleroma site" + }, + version: %Schema{ + type: :string, + description: "The version of Pleroma installed on the instance" + }, + email: %Schema{ + type: :string, + description: "An email that may be contacted for any inquiries", + format: :email + }, + urls: %Schema{ + type: :object, + description: "URLs of interest for clients apps", + properties: %{ + streaming_api: %Schema{ + type: :string, + description: "Websockets address for push streaming" + } + } + }, + stats: %Schema{ + type: :object, + description: "Statistics about how much information the instance contains", + properties: %{ + user_count: %Schema{ + type: :integer, + description: "Users registered on this instance" + }, + status_count: %Schema{ + type: :integer, + description: "Statuses authored by users on instance" + }, + domain_count: %Schema{ + type: :integer, + description: "Domains federated with this instance" + } + } + }, + thumbnail: %Schema{ + type: :string, + description: "Banner image for the website", + nullable: true + }, + languages: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Primary langauges of the website and its staff" + }, + registrations: %Schema{type: :boolean, description: "Whether registrations are enabled"}, + # Extra (not present in Mastodon): + max_toot_chars: %Schema{ + type: :integer, + description: ": Posts character limit (CW/Subject included in the counter)" + }, + poll_limits: %Schema{ + type: :object, + description: "A map with poll limits for local polls", + properties: %{ + max_options: %Schema{ + type: :integer, + description: "Maximum number of options." + }, + max_option_chars: %Schema{ + type: :integer, + description: "Maximum number of characters per option." + }, + min_expiration: %Schema{ + type: :integer, + description: "Minimum expiration time (in seconds)." + }, + max_expiration: %Schema{ + type: :integer, + description: "Maximum expiration time (in seconds)." + } + } + }, + upload_limit: %Schema{ + type: :integer, + description: "File size limit of uploads (except for avatar, background, banner)" + }, + avatar_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + background_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + banner_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + background_image: %Schema{ + type: :string, + format: :uri, + description: "The background image for the website" + } + }, + example: %{ + "avatar_upload_limit" => 2_000_000, + "background_upload_limit" => 4_000_000, + "background_image" => "/static/image.png", + "banner_upload_limit" => 4_000_000, + "description" => "A Pleroma instance, an alternative fediverse server", + "email" => "lain@lain.com", + "languages" => ["en"], + "max_toot_chars" => 5000, + "poll_limits" => %{ + "max_expiration" => 31_536_000, + "max_option_chars" => 200, + "max_options" => 20, + "min_expiration" => 0 + }, + "registrations" => false, + "stats" => %{ + "domain_count" => 2996, + "status_count" => 15_802, + "user_count" => 5 + }, + "thumbnail" => "https://lain.com/instance/thumbnail.jpeg", + "title" => "lain.com", + "upload_limit" => 16_000_000, + "uri" => "https://lain.com", + "urls" => %{ + "streaming_api" => "wss://lain.com" + }, + "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)" + } + } + end + + defp array_of_domains do + %Schema{ + type: :array, + items: %Schema{type: :string}, + example: ["pleroma.site", "lain.com", "bikeshed.party"] + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex new file mode 100644 index 000000000..c88ed5dd0 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/list_operation.ex @@ -0,0 +1,188 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ListOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.List + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Lists"], + summary: "Show user's lists", + description: "Fetch all lists that the user owns", + security: [%{"oAuth" => ["read:lists"]}], + operationId: "ListController.index", + responses: %{ + 200 => Operation.response("Array of List", "application/json", array_of_lists()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Lists"], + summary: "Create a list", + description: "Fetch the list with the given ID. Used for verifying the title of a list.", + operationId: "ListController.create", + requestBody: create_update_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Lists"], + summary: "Show a single list", + description: "Fetch the list with the given ID. Used for verifying the title of a list.", + operationId: "ListController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Lists"], + summary: "Update a list", + description: "Change the title of a list", + operationId: "ListController.update", + parameters: [id_param()], + requestBody: create_update_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 422 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Lists"], + summary: "Delete a list", + operationId: "ListController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + def list_accounts_operation do + %Operation{ + tags: ["Lists"], + summary: "View accounts in list", + operationId: "ListController.list_accounts", + parameters: [id_param()], + security: [%{"oAuth" => ["read:lists"]}], + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account + }) + } + } + end + + def add_to_list_operation do + %Operation{ + tags: ["Lists"], + summary: "Add accounts to list", + description: "Add accounts to the given list.", + operationId: "ListController.add_to_list", + parameters: [id_param()], + requestBody: add_remove_accounts_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + def remove_from_list_operation do + %Operation{ + tags: ["Lists"], + summary: "Remove accounts from list", + operationId: "ListController.remove_from_list", + parameters: [id_param()], + requestBody: add_remove_accounts_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + defp array_of_lists do + %Schema{ + title: "ArrayOfLists", + description: "Response schema for lists", + type: :array, + items: List, + example: [ + %{"id" => "123", "title" => "my list"}, + %{"id" => "1337", "title" => "another list"} + ] + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "List ID", + example: "123", + required: true + ) + end + + defp create_update_request do + request_body( + "Parameters", + %Schema{ + description: "POST body for creating or updating a List", + type: :object, + properties: %{ + title: %Schema{type: :string, description: "List title"} + }, + required: [:title] + }, + required: true + ) + end + + defp add_remove_accounts_request do + request_body( + "Parameters", + %Schema{ + description: "POST body for adding/removing accounts to/from a List", + type: :object, + properties: %{ + account_ids: %Schema{type: :array, description: "Array of account IDs", items: FlakeID} + }, + required: [:account_ids] + }, + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex new file mode 100644 index 000000000..714ef1f99 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MarkerOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Markers"], + summary: "Get saved timeline position", + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "MarkerController.index", + parameters: [ + Operation.parameter( + :timeline, + :query, + %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications"]} + }, + "Array of markers to fetch. If not provided, an empty object will be returned." + ) + ], + responses: %{ + 200 => Operation.response("Marker", "application/json", response()), + 403 => Operation.response("Error", "application/json", api_error()) + } + } + end + + def upsert_operation do + %Operation{ + tags: ["Markers"], + summary: "Save position in timeline", + operationId: "MarkerController.upsert", + requestBody: Helpers.request_body("Parameters", upsert_request(), required: true), + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{ + 200 => Operation.response("Marker", "application/json", response()), + 403 => Operation.response("Error", "application/json", api_error()) + } + } + end + + defp marker do + %Schema{ + title: "Marker", + description: "Schema for a marker", + type: :object, + properties: %{ + last_read_id: %Schema{type: :string}, + version: %Schema{type: :integer}, + updated_at: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + unread_count: %Schema{type: :integer} + } + } + }, + example: %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 5} + } + } + end + + defp response do + %Schema{ + title: "MarkersResponse", + description: "Response schema for markers", + type: :object, + properties: %{ + notifications: %Schema{allOf: [marker()], nullable: true}, + home: %Schema{allOf: [marker()], nullable: true} + }, + items: %Schema{type: :string}, + example: %{ + "notifications" => %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 0} + }, + "home" => %{ + "last_read_id" => "103206604258487607", + "version" => 468, + "updated_at" => "2019-11-26T22:37:25.235Z", + "pleroma" => %{"unread_count" => 10} + } + } + } + end + + defp upsert_request do + %Schema{ + title: "MarkersUpsertRequest", + description: "Request schema for marker upsert", + type: :object, + properties: %{ + notifications: %Schema{ + type: :object, + nullable: true, + properties: %{ + last_read_id: %Schema{nullable: true, type: :string} + } + }, + home: %Schema{ + type: :object, + nullable: true, + properties: %{ + last_read_id: %Schema{nullable: true, type: :string} + } + } + }, + example: %{ + "home" => %{ + "last_read_id" => "103194548672408537", + "version" => 462, + "updated_at" => "2019-11-24T19:39:39.337Z" + } + } + } + end + + defp api_error do + %Schema{ + type: :object, + properties: %{error: %Schema{type: :string}} + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/media_operation.ex b/lib/pleroma/web/api_spec/operations/media_operation.ex new file mode 100644 index 000000000..d9c3c42db --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/media_operation.ex @@ -0,0 +1,132 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MediaOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.Attachment + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["media"], + summary: "Upload media as attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.create", + security: [%{"oAuth" => ["write:media"]}], + requestBody: Helpers.request_body("Parameters", create_request()), + responses: %{ + 200 => Operation.response("Media", "application/json", Attachment), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "MediaCreateRequest", + description: "POST body for creating an attachment", + type: :object, + required: [:file], + properties: %{ + file: %Schema{ + type: :string, + format: :binary, + description: "The file to be attached, using multipart form data." + }, + description: %Schema{ + type: :string, + description: "A plain-text description of the media, for accessibility purposes." + }, + focus: %Schema{ + type: :string, + description: "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0." + } + } + } + end + + def update_operation do + %Operation{ + tags: ["media"], + summary: "Upload media as attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.update", + security: [%{"oAuth" => ["write:media"]}], + parameters: [id_param()], + requestBody: Helpers.request_body("Parameters", update_request()), + responses: %{ + 200 => Operation.response("Media", "application/json", Attachment), + 400 => Operation.response("Media", "application/json", ApiError), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp update_request do + %Schema{ + title: "MediaUpdateRequest", + description: "POST body for updating an attachment", + type: :object, + properties: %{ + file: %Schema{ + type: :string, + format: :binary, + description: "The file to be attached, using multipart form data." + }, + description: %Schema{ + type: :string, + description: "A plain-text description of the media, for accessibility purposes." + }, + focus: %Schema{ + type: :string, + description: "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0." + } + } + } + end + + def show_operation do + %Operation{ + tags: ["media"], + summary: "Show Uploaded media attachment", + operationId: "MediaController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:media"]}], + responses: %{ + 200 => Operation.response("Media", "application/json", Attachment), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + def create2_operation do + %Operation{ + tags: ["media"], + summary: "Upload media as attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.create2", + security: [%{"oAuth" => ["write:media"]}], + requestBody: Helpers.request_body("Parameters", create_request()), + responses: %{ + 202 => Operation.response("Media", "application/json", Attachment), + 422 => Operation.response("Media", "application/json", ApiError), + 500 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "The ID of the Attachment entity") + end +end diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex new file mode 100644 index 000000000..46e72f8bf --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -0,0 +1,211 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.NotificationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Notifications"], + summary: "Get all notifications", + description: + "Notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and `id` values.", + operationId: "NotificationController.index", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: + [ + Operation.parameter( + :exclude_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Array of types to exclude" + ), + Operation.parameter( + :account_id, + :query, + %Schema{type: :string}, + "Return only notifications received from this account" + ), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude the notifications for activities with the given visibilities" + ), + Operation.parameter( + :include_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Include the notifications for activities with the given types" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include the notifications from muted users" + ) + ] ++ pagination_params(), + responses: %{ + 200 => + Operation.response("Array of notifications", "application/json", %Schema{ + type: :array, + items: notification() + }), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Notifications"], + summary: "Get a single notification", + description: "View information about a notification with a given ID.", + operationId: "NotificationController.show", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Notification", "application/json", notification()) + } + } + end + + def clear_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss all notifications", + description: "Clear all notifications from the server.", + operationId: "NotificationController.clear", + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a single notification", + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss", + parameters: [id_param()], + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_via_body_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a single notification", + deprecated: true, + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss_via_body", + requestBody: + request_body( + "Parameters", + %Schema{type: :object, properties: %{id: %Schema{type: :string}}}, + required: true + ), + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def destroy_multiple_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss multiple notifications", + operationId: "NotificationController.destroy_multiple", + security: [%{"oAuth" => ["write:notifications"]}], + parameters: [ + Operation.parameter( + :ids, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Array of notification IDs to dismiss", + required: true + ) + ], + responses: %{200 => empty_object_response()} + } + end + + def notification do + %Schema{ + title: "Notification", + description: "Response schema for a notification", + type: :object, + properties: %{ + id: %Schema{type: :string}, + type: notification_type(), + created_at: %Schema{type: :string, format: :"date-time"}, + account: %Schema{ + allOf: [Account], + description: "The account that performed the action that generated the notification." + }, + status: %Schema{ + allOf: [Status], + description: + "Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.", + nullable: true + } + }, + example: %{ + "id" => "34975861", + "type" => "mention", + "created_at" => "2019-11-23T07:49:02.064Z", + "account" => Account.schema().example, + "status" => Status.schema().example + } + } + end + + defp notification_type do + %Schema{ + type: :string, + enum: [ + "follow", + "favourite", + "reblog", + "mention", + "poll", + "pleroma:emoji_reaction", + "move", + "follow_request" + ], + description: """ + The type of event that resulted in the notification. + + - `follow` - Someone followed you + - `mention` - Someone mentioned you in their status + - `reblog` - Someone boosted one of your statuses + - `favourite` - Someone favourited one of your statuses + - `poll` - A poll you have voted in or created has ended + - `move` - Someone moved their account + - `pleroma:emoji_reaction` - Someone reacted with emoji to your status + """ + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Notification ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex new file mode 100644 index 000000000..90922c064 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -0,0 +1,187 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def confirmation_resend_operation do + %Operation{ + tags: ["Accounts"], + summary: "Resend confirmation email. Expects `email` or `nickname`", + operationId: "PleromaAPI.AccountController.confirmation_resend", + parameters: [ + Operation.parameter(:email, :query, :string, "Email of that needs to be verified", + example: "cofe@cofe.io" + ), + Operation.parameter( + :nickname, + :query, + :string, + "Nickname of user that needs to be verified", + example: "cofefe" + ) + ], + responses: %{ + 204 => no_content_response() + } + } + end + + def update_avatar_operation do + %Operation{ + tags: ["Accounts"], + summary: "Set/clear user avatar image", + operationId: "PleromaAPI.AccountController.update_avatar", + requestBody: + request_body("Parameters", update_avatar_or_background_request(), required: true), + security: [%{"oAuth" => ["write:accounts"]}], + responses: %{ + 200 => update_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def update_banner_operation do + %Operation{ + tags: ["Accounts"], + summary: "Set/clear user banner image", + operationId: "PleromaAPI.AccountController.update_banner", + requestBody: request_body("Parameters", update_banner_request(), required: true), + security: [%{"oAuth" => ["write:accounts"]}], + responses: %{ + 200 => update_response() + } + } + end + + def update_background_operation do + %Operation{ + tags: ["Accounts"], + summary: "Set/clear user background image", + operationId: "PleromaAPI.AccountController.update_background", + security: [%{"oAuth" => ["write:accounts"]}], + requestBody: + request_body("Parameters", update_avatar_or_background_request(), required: true), + responses: %{ + 200 => update_response() + } + } + end + + def favourites_operation do + %Operation{ + tags: ["Accounts"], + summary: "Returns favorites timeline of any user", + operationId: "PleromaAPI.AccountController.favourites", + parameters: [id_param() | pagination_params()], + security: [%{"oAuth" => ["read:favourites"]}], + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def subscribe_operation do + %Operation{ + tags: ["Accounts"], + summary: "Subscribe to receive notifications for all statuses posted by a user", + operationId: "PleromaAPI.AccountController.subscribe", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unsubscribe_operation do + %Operation{ + tags: ["Accounts"], + summary: "Unsubscribe to stop receiving notifications from user statuses", + operationId: "PleromaAPI.AccountController.unsubscribe", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Account ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp update_avatar_or_background_request do + %Schema{ + title: "PleromaAccountUpdateAvatarOrBackgroundRequest", + type: :object, + properties: %{ + img: %Schema{ + nullable: true, + type: :string, + format: :binary, + description: "Image encoded using `multipart/form-data` or an empty string to clear" + } + } + } + end + + defp update_banner_request do + %Schema{ + title: "PleromaAccountUpdateBannerRequest", + type: :object, + properties: %{ + banner: %Schema{ + type: :string, + nullable: true, + format: :binary, + description: "Image encoded using `multipart/form-data` or an empty string to clear" + } + } + } + end + + defp update_response do + Operation.response("PleromaAccountUpdateResponse", "application/json", %Schema{ + type: :object, + properties: %{ + url: %Schema{ + type: :string, + format: :uri, + nullable: true, + description: "Image URL" + } + }, + example: %{ + "url" => + "https://cofe.party/media/9d0add56-bcb6-4c0f-8225-cbbd0b6dd773/13eadb6972c9ccd3f4ffa3b8196f0e0d38b4d2f27594457c52e52946c054cd9a.gif" + } + }) + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex new file mode 100644 index 000000000..e885eab20 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaConversationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Conversations"], + summary: "The conversation with the given ID", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaAPI.ConversationController.show", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def statuses_operation do + %Operation{ + tags: ["Conversations"], + summary: "Timeline for a given conversation", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + | pagination_params() + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaAPI.ConversationController.statuses", + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ) + } + } + end + + def update_operation do + %Operation{ + tags: ["Conversations"], + summary: "Update a conversation. Used to change the set of recipients.", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ), + Operation.parameter( + :recipients, + :query, + %Schema{type: :array, items: FlakeID}, + "A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though.", + required: true + ) + ], + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaAPI.ConversationController.update", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def mark_as_read_operation do + %Operation{ + tags: ["Conversations"], + summary: "Marks all user's conversations as read", + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaAPI.ConversationController.mark_as_read", + responses: %{ + 200 => + Operation.response( + "Array of Conversations that were marked as read", + "application/json", + %Schema{ + type: :array, + items: Conversation, + example: [Conversation.schema().example] + } + ) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex new file mode 100644 index 000000000..567688ff5 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -0,0 +1,390 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaEmojiPackOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def remote_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Make request to another instance for emoji packs list", + security: [%{"oAuth" => ["write"]}], + parameters: [url_param()], + operationId: "PleromaAPI.EmojiPackController.remote", + responses: %{ + 200 => emoji_packs_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def index_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Lists local custom emoji packs", + operationId: "PleromaAPI.EmojiPackController.index", + responses: %{ + 200 => emoji_packs_response() + } + } + end + + def show_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Show emoji pack", + operationId: "PleromaAPI.EmojiPackController.show", + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Emoji Pack", "application/json", emoji_pack()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def archive_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Requests a local pack archive from the instance", + operationId: "PleromaAPI.EmojiPackController.archive", + parameters: [name_param()], + responses: %{ + 200 => + Operation.response("Archive file", "application/octet-stream", %Schema{ + type: :string, + format: :binary + }), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def download_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Download pack from another instance", + operationId: "PleromaAPI.EmojiPackController.download", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", download_request(), required: true), + responses: %{ + 200 => ok_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp download_request do + %Schema{ + type: :object, + required: [:url, :name], + properties: %{ + url: %Schema{ + type: :string, + format: :uri, + description: "URL of the instance to download from" + }, + name: %Schema{type: :string, format: :uri, description: "Pack Name"}, + as: %Schema{type: :string, format: :uri, description: "Save as"} + } + } + end + + def create_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Create an empty pack", + operationId: "PleromaAPI.EmojiPackController.create", + security: [%{"oAuth" => ["write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Not Found", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Delete a custom emoji pack", + operationId: "PleromaAPI.EmojiPackController.delete", + security: [%{"oAuth" => ["write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Updates (replaces) pack metadata", + operationId: "PleromaAPI.EmojiPackController.update", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Metadata", "application/json", metadata()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def add_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiPackController.add_file", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", add_file_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError) + } + } + end + + defp add_file_request do + %Schema{ + type: :object, + required: [:file], + properties: %{ + file: %Schema{ + description: + "File needs to be uploaded with the multipart request or link to remote file", + anyOf: [ + %Schema{type: :string, format: :binary}, + %Schema{type: :string, format: :uri} + ] + }, + shortcode: %Schema{ + type: :string, + description: + "Shortcode for new emoji, must be unique for all emoji. If not sended, shortcode will be taken from original filename." + }, + filename: %Schema{ + type: :string, + description: + "New emoji file name. If not specified will be taken from original filename." + } + } + } + end + + def update_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiPackController.update_file", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_file_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError) + } + } + end + + defp update_file_request do + %Schema{ + type: :object, + required: [:shortcode, :new_shortcode, :new_filename], + properties: %{ + shortcode: %Schema{ + type: :string, + description: "Emoji file shortcode" + }, + new_shortcode: %Schema{ + type: :string, + description: "New emoji file shortcode" + }, + new_filename: %Schema{ + type: :string, + description: "New filename for emoji file" + }, + force: %Schema{ + type: :boolean, + description: "With true value to overwrite existing emoji with new shortcode", + default: false + } + } + } + end + + def delete_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Delete emoji file from pack", + operationId: "PleromaAPI.EmojiPackController.delete_file", + security: [%{"oAuth" => ["write"]}], + parameters: [ + name_param(), + Operation.parameter(:shortcode, :query, :string, "File shortcode", + example: "cofe", + required: true + ) + ], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def import_from_filesystem_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Imports packs from filesystem", + operationId: "PleromaAPI.EmojiPackController.import", + security: [%{"oAuth" => ["write"]}], + responses: %{ + 200 => + Operation.response("Array of imported pack names", "application/json", %Schema{ + type: :array, + items: %Schema{type: :string} + }) + } + } + end + + defp name_param do + Operation.parameter(:name, :path, :string, "Pack Name", example: "cofe", required: true) + end + + defp url_param do + Operation.parameter( + :url, + :query, + %Schema{type: :string, format: :uri}, + "URL of the instance", + required: true + ) + end + + defp ok_response do + Operation.response("Ok", "application/json", %Schema{type: :string, example: "ok"}) + end + + defp emoji_packs_response do + Operation.response( + "Object with pack names as keys and pack contents as values", + "application/json", + %Schema{ + type: :object, + additionalProperties: emoji_pack(), + example: %{ + "emojos" => emoji_pack().example + } + } + ) + end + + defp emoji_pack do + %Schema{ + title: "EmojiPack", + type: :object, + properties: %{ + files: files_object(), + pack: %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "can-download": %Schema{type: :boolean}, + "share-files": %Schema{type: :boolean}, + "download-sha256": %Schema{type: :string} + } + } + }, + example: %{ + "files" => %{"emacs" => "emacs.png", "guix" => "guix.png"}, + "pack" => %{ + "license" => "Test license", + "homepage" => "https://pleroma.social", + "description" => "Test description", + "can-download" => true, + "share-files" => true, + "download-sha256" => "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238" + } + } + } + end + + defp files_object do + %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: "Object with emoji names as keys and filenames as values" + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + metadata: %Schema{ + type: :object, + description: "Metadata to replace the old one", + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + } + } + end + + defp metadata do + %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex new file mode 100644 index 000000000..8c5f37ea6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaMascotOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Mascot"], + summary: "Gets user mascot image", + security: [%{"oAuth" => ["read:accounts"]}], + operationId: "PleromaAPI.MascotController.show", + responses: %{ + 200 => Operation.response("Mascot", "application/json", mascot()) + } + } + end + + def update_operation do + %Operation{ + tags: ["Mascot"], + summary: "Set/clear user avatar image", + description: + "Behaves exactly the same as `POST /api/v1/upload`. Can only accept images - any attempt to upload non-image files will be met with `HTTP 415 Unsupported Media Type`.", + operationId: "PleromaAPI.MascotController.update", + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + file: %Schema{type: :string, format: :binary} + } + }, + required: true + ), + security: [%{"oAuth" => ["write:accounts"]}], + responses: %{ + 200 => Operation.response("Mascot", "application/json", mascot()), + 415 => Operation.response("Unsupported Media Type", "application/json", ApiError) + } + } + end + + defp mascot do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + type: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + mime_type: %Schema{type: :string} + } + } + }, + example: %{ + "id" => "abcdefg", + "url" => "https://pleroma.example.org/media/abcdefg.png", + "type" => "image", + "pleroma" => %{ + "mime_type" => "image/png" + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex new file mode 100644 index 000000000..b0c8db863 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaNotificationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.NotificationOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def mark_as_read_operation do + %Operation{ + tags: ["Notifications"], + summary: "Mark notifications as read. Query parameters are mutually exclusive.", + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer, description: "A single notification ID to read"}, + max_id: %Schema{type: :integer, description: "Read all notifications up to this ID"} + } + }), + security: [%{"oAuth" => ["write:notifications"]}], + operationId: "PleromaAPI.NotificationController.mark_as_read", + responses: %{ + 200 => + Operation.response( + "A Notification or array of Motifications", + "application/json", + %Schema{ + anyOf: [ + %Schema{type: :array, items: NotificationOperation.notification()}, + NotificationOperation.notification() + ] + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex new file mode 100644 index 000000000..85a22aa0b --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex @@ -0,0 +1,102 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Reference + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Scrobbles"], + summary: "Creates a new Listen activity for an account", + security: [%{"oAuth" => ["write"]}], + operationId: "PleromaAPI.ScrobbleController.create", + requestBody: request_body("Parameters", create_request(), requried: true), + responses: %{ + 200 => Operation.response("Scrobble", "application/json", scrobble()) + } + } + end + + def index_operation do + %Operation{ + tags: ["Scrobbles"], + summary: "Requests a list of current and recent Listen activities for an account", + operationId: "PleromaAPI.ScrobbleController.index", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} | pagination_params() + ], + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("Array of Scrobble", "application/json", %Schema{ + type: :array, + items: scrobble() + }) + } + } + end + + defp create_request do + %Schema{ + type: :object, + required: [:title], + properties: %{ + title: %Schema{type: :string, description: "The title of the media playing"}, + album: %Schema{type: :string, description: "The album of the media playing"}, + artist: %Schema{type: :string, description: "The artist of the media playing"}, + length: %Schema{type: :integer, description: "The length of the media playing"}, + visibility: %Schema{ + allOf: [VisibilityScope], + default: "public", + description: "Scrobble visibility" + } + }, + example: %{ + "title" => "Some Title", + "artist" => "Some Artist", + "album" => "Some Album", + "length" => 180_000 + } + } + end + + defp scrobble do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + account: Account, + title: %Schema{type: :string, description: "The title of the media playing"}, + album: %Schema{type: :string, description: "The album of the media playing"}, + artist: %Schema{type: :string, description: "The artist of the media playing"}, + length: %Schema{ + type: :integer, + description: "The length of the media playing", + nullable: true + }, + created_at: %Schema{type: :string, format: :"date-time"} + }, + example: %{ + "id" => "1234", + "account" => Account.schema().example, + "title" => "Some Title", + "artist" => "Some Artist", + "album" => "Some Album", + "length" => 180_000, + "created_at" => "2019-09-28T12:40:45.000Z" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex new file mode 100644 index 000000000..e15c7dc95 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex @@ -0,0 +1,76 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PollOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Poll + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Polls"], + summary: "View a poll", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + operationId: "PollController.show", + responses: %{ + 200 => Operation.response("Poll", "application/json", Poll), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def vote_operation do + %Operation{ + tags: ["Polls"], + summary: "Vote on a poll", + parameters: [id_param()], + operationId: "PollController.vote", + requestBody: vote_request(), + security: [%{"oAuth" => ["write:statuses"]}], + responses: %{ + 200 => Operation.response("Poll", "application/json", Poll), + 422 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Poll ID", + example: "123", + required: true + ) + end + + defp vote_request do + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + choices: %Schema{ + type: :array, + items: %Schema{type: :integer}, + description: "Array of own votes containing index for each option (starting from 0)" + } + }, + required: [:choices] + }, + required: true, + example: %{ + "choices" => [0, 1, 2] + } + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex new file mode 100644 index 000000000..b9b4c4f79 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/report_operation.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["reports"], + summary: "File a report", + description: "Report problematic users to your moderators", + operationId: "ReportController.create", + security: [%{"oAuth" => ["follow", "write:reports"]}], + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Report", "application/json", create_response()), + 400 => Operation.response("Report", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "ReportCreateRequest", + description: "POST body for creating a report", + type: :object, + properties: %{ + account_id: %Schema{type: :string, description: "ID of the account to report"}, + status_ids: %Schema{ + type: :array, + nullable: true, + items: %Schema{type: :string}, + description: "Array of Statuses to attach to the report, for context" + }, + comment: %Schema{ + type: :string, + nullable: true, + description: "Reason for the report" + }, + forward: %Schema{ + allOf: [BooleanLike], + nullable: true, + default: false, + description: + "If the account is remote, should the report be forwarded to the remote admin?" + } + }, + required: [:account_id], + example: %{ + "account_id" => "123", + "status_ids" => ["1337"], + "comment" => "bad status!", + "forward" => "false" + } + } + end + + defp create_response do + %Schema{ + title: "ReportResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Report ID"}, + action_taken: %Schema{type: :boolean, description: "Is action taken?"} + }, + example: %{ + "id" => "123", + "action_taken" => false + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex new file mode 100644 index 000000000..fe675a923 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex @@ -0,0 +1,96 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ScheduledActivityOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "View scheduled statuses", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: pagination_params(), + operationId: "ScheduledActivity.index", + responses: %{ + 200 => + Operation.response("Array of ScheduledStatus", "application/json", %Schema{ + type: :array, + items: ScheduledStatus + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "View a single scheduled status", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + operationId: "ScheduledActivity.show", + responses: %{ + 200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "Schedule a status", + operationId: "ScheduledActivity.update", + security: [%{"oAuth" => ["write:statuses"]}], + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + scheduled_at: %Schema{ + type: :string, + format: :"date-time", + description: + "ISO 8601 Datetime at which the status will be published. Must be at least 5 minutes into the future." + } + } + }), + responses: %{ + 200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "Cancel a scheduled status", + security: [%{"oAuth" => ["write:statuses"]}], + parameters: [id_param()], + operationId: "ScheduledActivity.delete", + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Poll ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex new file mode 100644 index 000000000..169c36d87 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -0,0 +1,208 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.SearchOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.Tag + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + # Note: `with_relationships` param is not supported (PleromaFE uses this op for autocomplete) + def account_search_operation do + %Operation{ + tags: ["Search"], + summary: "Search for matching accounts by username or display name", + operationId: "SearchController.account_search", + parameters: [ + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 40}, + "Maximum number of results" + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup. Use this when `q` is an exact address." + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ) + ], + responses: %{ + 200 => + Operation.response( + "Array of Account", + "application/json", + AccountOperation.array_of_accounts() + ) + } + } + end + + def search_operation do + %Operation{ + tags: ["Search"], + summary: "Search results", + security: [%{"oAuth" => ["read:search"]}], + operationId: "SearchController.search", + deprecated: true, + parameters: [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", required: true), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer}, + "Offset" + ), + with_relationships_param() | pagination_params() + ], + responses: %{ + 200 => Operation.response("Results", "application/json", results()) + } + } + end + + def search2_operation do + %Operation{ + tags: ["Search"], + summary: "Search results", + security: [%{"oAuth" => ["read:search"]}], + operationId: "SearchController.search2", + parameters: [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + with_relationships_param() | pagination_params() + ], + responses: %{ + 200 => Operation.response("Results", "application/json", results2()) + } + } + end + + defp results2 do + %Schema{ + title: "SearchResults", + type: :object, + properties: %{ + accounts: %Schema{ + type: :array, + items: Account, + description: "Accounts which match the given query" + }, + statuses: %Schema{ + type: :array, + items: Status, + description: "Statuses which match the given query" + }, + hashtags: %Schema{ + type: :array, + items: Tag, + description: "Hashtags which match the given query" + } + }, + example: %{ + "accounts" => [Account.schema().example], + "statuses" => [Status.schema().example], + "hashtags" => [Tag.schema().example] + } + } + end + + defp results do + %Schema{ + title: "SearchResults", + type: :object, + properties: %{ + accounts: %Schema{ + type: :array, + items: Account, + description: "Accounts which match the given query" + }, + statuses: %Schema{ + type: :array, + items: Status, + description: "Statuses which match the given query" + }, + hashtags: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Hashtags which match the given query" + } + }, + example: %{ + "accounts" => [Account.schema().example], + "statuses" => [Status.schema().example], + "hashtags" => ["cofe"] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex new file mode 100644 index 000000000..ca9db01e5 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -0,0 +1,518 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.StatusOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Statuses"], + summary: "Get multiple statuses by IDs", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :ids, + :query, + %Schema{type: :array, items: FlakeID}, + "Array of status IDs" + ) + ], + operationId: "StatusController.index", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Statuses"], + summary: "Publish new status", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Post a new status", + operationId: "StatusController.create", + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => + Operation.response( + "Status. When `scheduled_at` is present, ScheduledStatus is returned instead", + "application/json", + %Schema{oneOf: [Status, ScheduledStatus]} + ), + 422 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Statuses"], + summary: "View specific status", + description: "View information about a status", + operationId: "StatusController.show", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Statuses"], + summary: "Delete status", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Delete one of your own statuses", + operationId: "StatusController.delete", + parameters: [id_param()], + responses: %{ + 200 => empty_object_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def reblog_operation do + %Operation{ + tags: ["Statuses"], + summary: "Boost", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Share a status", + operationId: "StatusController.reblog", + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + visibility: %Schema{allOf: [VisibilityScope], default: "public"} + } + }), + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unreblog_operation do + %Operation{ + tags: ["Statuses"], + summary: "Undo boost", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Undo a reshare of a status", + operationId: "StatusController.unreblog", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def favourite_operation do + %Operation{ + tags: ["Statuses"], + summary: "Favourite", + security: [%{"oAuth" => ["write:favourites"]}], + description: "Add a status to your favourites list", + operationId: "StatusController.favourite", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unfavourite_operation do + %Operation{ + tags: ["Statuses"], + summary: "Undo favourite", + security: [%{"oAuth" => ["write:favourites"]}], + description: "Remove a status from your favourites list", + operationId: "StatusController.unfavourite", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def pin_operation do + %Operation{ + tags: ["Statuses"], + summary: "Pin to profile", + security: [%{"oAuth" => ["write:accounts"]}], + description: "Feature one of your own public statuses at the top of your profile", + operationId: "StatusController.pin", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unpin_operation do + %Operation{ + tags: ["Statuses"], + summary: "Unpin to profile", + security: [%{"oAuth" => ["write:accounts"]}], + description: "Unfeature a status from the top of your profile", + operationId: "StatusController.unpin", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def bookmark_operation do + %Operation{ + tags: ["Statuses"], + summary: "Bookmark", + security: [%{"oAuth" => ["write:bookmarks"]}], + description: "Privately bookmark a status", + operationId: "StatusController.bookmark", + parameters: [id_param()], + responses: %{ + 200 => status_response() + } + } + end + + def unbookmark_operation do + %Operation{ + tags: ["Statuses"], + summary: "Undo bookmark", + security: [%{"oAuth" => ["write:bookmarks"]}], + description: "Remove a status from your private bookmarks", + operationId: "StatusController.unbookmark", + parameters: [id_param()], + responses: %{ + 200 => status_response() + } + } + end + + def mute_conversation_operation do + %Operation{ + tags: ["Statuses"], + summary: "Mute conversation", + security: [%{"oAuth" => ["write:mutes"]}], + description: "Do not receive notifications for the thread that this status is part of.", + operationId: "StatusController.mute_conversation", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unmute_conversation_operation do + %Operation{ + tags: ["Statuses"], + summary: "Unmute conversation", + security: [%{"oAuth" => ["write:mutes"]}], + description: + "Start receiving notifications again for the thread that this status is part of", + operationId: "StatusController.unmute_conversation", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def card_operation do + %Operation{ + tags: ["Statuses"], + deprecated: true, + summary: "Preview card", + description: "Deprecated in favor of card property inlined on Status entity", + operationId: "StatusController.card", + parameters: [id_param()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => + Operation.response("Card", "application/json", %Schema{ + type: :object, + nullable: true, + properties: %{ + type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]}, + provider_name: %Schema{type: :string, nullable: true}, + provider_url: %Schema{type: :string, format: :uri}, + url: %Schema{type: :string, format: :uri}, + image: %Schema{type: :string, nullable: true, format: :uri}, + title: %Schema{type: :string}, + description: %Schema{type: :string} + } + }) + } + } + end + + def favourited_by_operation do + %Operation{ + tags: ["Statuses"], + summary: "Favourited by", + description: "View who favourited a given status", + operationId: "StatusController.favourited_by", + security: [%{"oAuth" => ["read:accounts"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def reblogged_by_operation do + %Operation{ + tags: ["Statuses"], + summary: "Boosted by", + description: "View who boosted a given status", + operationId: "StatusController.reblogged_by", + security: [%{"oAuth" => ["read:accounts"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def context_operation do + %Operation{ + tags: ["Statuses"], + summary: "Parent and child statuses", + description: "View statuses above and below this status in the thread", + operationId: "StatusController.context", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Context", "application/json", context()) + } + } + end + + def favourites_operation do + %Operation{ + tags: ["Statuses"], + summary: "Favourited statuses", + description: "Statuses the user has favourited", + operationId: "StatusController.favourites", + parameters: pagination_params(), + security: [%{"oAuth" => ["read:favourites"]}], + responses: %{ + 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) + } + } + end + + def bookmarks_operation do + %Operation{ + tags: ["Statuses"], + summary: "Bookmarked statuses", + description: "Statuses the user has bookmarked", + operationId: "StatusController.bookmarks", + parameters: pagination_params(), + security: [%{"oAuth" => ["read:bookmarks"]}], + responses: %{ + 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) + } + } + end + + def array_of_statuses do + %Schema{type: :array, items: Status, example: [Status.schema().example]} + end + + defp create_request do + %Schema{ + title: "StatusCreateRequest", + type: :object, + properties: %{ + status: %Schema{ + type: :string, + nullable: true, + description: + "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided." + }, + media_ids: %Schema{ + nullable: true, + type: :array, + items: %Schema{type: :string}, + description: "Array of Attachment ids to be attached as media." + }, + poll: %Schema{ + nullable: true, + type: :object, + required: [:options], + properties: %{ + options: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Array of possible answers. Must be provided with `poll[expires_in]`." + }, + expires_in: %Schema{ + type: :integer, + nullable: true, + description: + "Duration the poll should be open, in seconds. Must be provided with `poll[options]`" + }, + multiple: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Allow multiple choices?" + }, + hide_totals: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Hide vote counts until the poll ends?" + } + } + }, + in_reply_to_id: %Schema{ + nullable: true, + allOf: [FlakeID], + description: "ID of the status being replied to, if status is a reply" + }, + sensitive: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Mark status and attached media as sensitive?" + }, + spoiler_text: %Schema{ + type: :string, + nullable: true, + description: + "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field." + }, + scheduled_at: %Schema{ + type: :string, + format: :"date-time", + nullable: true, + description: + "ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future." + }, + language: %Schema{ + type: :string, + nullable: true, + description: "ISO 639 language code for this status." + }, + # Pleroma-specific properties: + preview: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "If set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example" + }, + content_type: %Schema{ + type: :string, + nullable: true, + description: + "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint." + }, + to: %Schema{ + type: :array, + nullable: true, + items: %Schema{type: :string}, + description: + "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply" + }, + visibility: %Schema{ + nullable: true, + anyOf: [ + VisibilityScope, + %Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"} + ], + description: + "Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`" + }, + expires_in: %Schema{ + nullable: true, + type: :integer, + description: + "The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour." + }, + in_reply_to_conversation_id: %Schema{ + nullable: true, + type: :string, + description: + "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." + } + }, + example: %{ + "status" => "What time is it?", + "sensitive" => "false", + "poll" => %{ + "options" => ["Cofe", "Adventure"], + "expires_in" => 420 + } + } + } + end + + def id_param do + Operation.parameter(:id, :path, FlakeID, "Status ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp status_response do + Operation.response("Status", "application/json", Status) + end + + defp context do + %Schema{ + title: "StatusContext", + description: + "Represents the tree around a given status. Used for reconstructing threads of statuses.", + type: :object, + required: [:ancestors, :descendants], + properties: %{ + ancestors: array_of_statuses(), + descendants: array_of_statuses() + }, + example: %{ + "ancestors" => [Status.schema().example], + "descendants" => [Status.schema().example] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex new file mode 100644 index 000000000..c575a87e6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -0,0 +1,227 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.PushSubscription + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Subscribe to push notifications", + description: + "Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.", + operationId: "SubscriptionController.create", + security: [%{"oAuth" => ["push"]}], + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Push Subscription", "application/json", PushSubscription), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Get current subscription", + description: "View the PushSubscription currently associated with this access token.", + operationId: "SubscriptionController.show", + security: [%{"oAuth" => ["push"]}], + responses: %{ + 200 => Operation.response("Push Subscription", "application/json", PushSubscription), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Change types of notifications", + description: + "Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.", + operationId: "SubscriptionController.update", + security: [%{"oAuth" => ["push"]}], + requestBody: Helpers.request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Push Subscription", "application/json", PushSubscription), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Remove current subscription", + description: "Removes the current Web Push API subscription.", + operationId: "SubscriptionController.delete", + security: [%{"oAuth" => ["push"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "SubscriptionCreateRequest", + description: "POST body for creating a push subscription", + type: :object, + properties: %{ + subscription: %Schema{ + type: :object, + properties: %{ + endpoint: %Schema{ + type: :string, + description: "Endpoint URL that is called when a notification event occurs." + }, + keys: %Schema{ + type: :object, + properties: %{ + p256dh: %Schema{ + type: :string, + description: + "User agent public key. Base64 encoded string of public key of ECDH key using `prime256v1` curve." + }, + auth: %Schema{ + type: :string, + description: "Auth secret. Base64 encoded string of 16 bytes of random data." + } + }, + required: [:p256dh, :auth] + } + }, + required: [:endpoint, :keys] + }, + data: %Schema{ + nullable: true, + type: :object, + properties: %{ + alerts: %Schema{ + nullable: true, + type: :object, + properties: %{ + follow: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive follow notifications?" + }, + favourite: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive favourite notifications?" + }, + reblog: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive poll notifications?" + } + } + } + } + } + }, + required: [:subscription], + example: %{ + "subscription" => %{ + "endpoint" => "https://example.com/example/1234", + "keys" => %{ + "auth" => "8eDyX_uCN0XRhSbY5hs7Hg==", + "p256dh" => + "BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA=" + } + }, + "data" => %{ + "alerts" => %{ + "follow" => true, + "mention" => true, + "poll" => false + } + } + } + } + end + + defp update_request do + %Schema{ + title: "SubscriptionUpdateRequest", + type: :object, + properties: %{ + data: %Schema{ + nullable: true, + type: :object, + properties: %{ + alerts: %Schema{ + nullable: true, + type: :object, + properties: %{ + follow: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive follow notifications?" + }, + favourite: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive favourite notifications?" + }, + reblog: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive poll notifications?" + } + } + } + } + } + }, + example: %{ + "data" => %{ + "alerts" => %{ + "follow" => true, + "favourite" => true, + "reblog" => true, + "mention" => true, + "poll" => true + } + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex new file mode 100644 index 000000000..8e19bace7 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -0,0 +1,191 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.TimelineOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def home_operation do + %Operation{ + tags: ["Timelines"], + summary: "Home timeline", + description: "View statuses from followed users", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + local_param(), + with_muted_param(), + exclude_visibilities_param(), + reply_visibility_param() | pagination_params() + ], + operationId: "TimelineController.home", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def direct_operation do + %Operation{ + tags: ["Timelines"], + summary: "Direct timeline", + description: + "View statuses with a “direct” privacy, from your account or in your notifications", + deprecated: true, + parameters: [with_muted_param() | pagination_params()], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "TimelineController.direct", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def public_operation do + %Operation{ + tags: ["Timelines"], + summary: "Public timeline", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + local_param(), + only_media_param(), + with_muted_param(), + exclude_visibilities_param(), + reply_visibility_param() | pagination_params() + ], + operationId: "TimelineController.public", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def hashtag_operation do + %Operation{ + tags: ["Timelines"], + summary: "Hashtag timeline", + description: "View public statuses containing the given hashtag", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :tag, + :path, + %Schema{type: :string}, + "Content of a #hashtag, not including # symbol.", + required: true + ), + Operation.parameter( + :any, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that also includes any of these tags" + ), + Operation.parameter( + :all, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that also includes all of these tags" + ), + Operation.parameter( + :none, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that do not include these tags" + ), + local_param(), + only_media_param(), + with_muted_param(), + exclude_visibilities_param() | pagination_params() + ], + operationId: "TimelineController.hashtag", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def list_operation do + %Operation{ + tags: ["Timelines"], + summary: "List timeline", + description: "View statuses in the given list timeline", + security: [%{"oAuth" => ["read:lists"]}], + parameters: [ + Operation.parameter( + :list_id, + :path, + %Schema{type: :string}, + "Local ID of the list in the database", + required: true + ), + with_muted_param(), + exclude_visibilities_param() | pagination_params() + ], + operationId: "TimelineController.list", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + defp array_of_statuses do + %Schema{ + title: "ArrayOfStatuses", + type: :array, + items: Status, + example: [Status.schema().example] + } + end + + defp local_param do + Operation.parameter( + :local, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Show only local statuses?" + ) + end + + defp with_muted_param do + Operation.parameter(:with_muted, :query, BooleanLike, "Includeactivities by muted users") + end + + defp exclude_visibilities_param do + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude the statuses with the given visibilities" + ) + end + + defp reply_visibility_param do + Operation.parameter( + :reply_visibility, + :query, + %Schema{type: :string, enum: ["following", "self"]}, + "Filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you." + ) + end + + defp only_media_param do + Operation.parameter( + :only_media, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Show only statuses with media attached?" + ) + end +end diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex new file mode 100644 index 000000000..d476b8ef3 --- /dev/null +++ b/lib/pleroma/web/api_spec/render_error.ex @@ -0,0 +1,234 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.RenderError do + @behaviour Plug + + import Plug.Conn, only: [put_status: 2] + import Phoenix.Controller, only: [json: 2] + import Pleroma.Web.Gettext + + @impl Plug + def init(opts), do: opts + + @impl Plug + + def call(conn, errors) do + errors = + Enum.map(errors, fn + %{name: nil, reason: :invalid_enum} = err -> + %OpenApiSpex.Cast.Error{err | name: err.value} + + %{name: nil} = err -> + %OpenApiSpex.Cast.Error{err | name: List.last(err.path)} + + err -> + err + end) + + conn + |> put_status(:bad_request) + |> json(%{ + error: errors |> Enum.map(&message/1) |> Enum.join(" "), + errors: errors |> Enum.map(&render_error/1) + }) + end + + defp render_error(error) do + pointer = OpenApiSpex.path_to_string(error) + + %{ + title: "Invalid value", + source: %{ + pointer: pointer + }, + message: OpenApiSpex.Cast.Error.message(error) + } + end + + defp message(%{reason: :invalid_schema_type, type: type, name: name}) do + gettext("%{name} - Invalid schema.type. Got: %{type}.", + name: name, + type: inspect(type) + ) + end + + defp message(%{reason: :null_value, name: name} = error) do + case error.type do + nil -> + gettext("%{name} - null value.", name: name) + + type -> + gettext("%{name} - null value where %{type} expected.", + name: name, + type: type + ) + end + end + + defp message(%{reason: :all_of, meta: %{invalid_schema: invalid_schema}}) do + gettext( + "Failed to cast value as %{invalid_schema}. Value must be castable using `allOf` schemas listed.", + invalid_schema: invalid_schema + ) + end + + defp message(%{reason: :any_of, meta: %{failed_schemas: failed_schemas}}) do + gettext("Failed to cast value using any of: %{failed_schemas}.", + failed_schemas: failed_schemas + ) + end + + defp message(%{reason: :one_of, meta: %{failed_schemas: failed_schemas}}) do + gettext("Failed to cast value to one of: %{failed_schemas}.", failed_schemas: failed_schemas) + end + + defp message(%{reason: :min_length, length: length, name: name}) do + gettext("%{name} - String length is smaller than minLength: %{length}.", + name: name, + length: length + ) + end + + defp message(%{reason: :max_length, length: length, name: name}) do + gettext("%{name} - String length is larger than maxLength: %{length}.", + name: name, + length: length + ) + end + + defp message(%{reason: :unique_items, name: name}) do + gettext("%{name} - Array items must be unique.", name: name) + end + + defp message(%{reason: :min_items, length: min, value: array, name: name}) do + gettext("%{name} - Array length %{length} is smaller than minItems: %{min}.", + name: name, + length: length(array), + min: min + ) + end + + defp message(%{reason: :max_items, length: max, value: array, name: name}) do + gettext("%{name} - Array length %{length} is larger than maxItems: %{}.", + name: name, + length: length(array), + max: max + ) + end + + defp message(%{reason: :multiple_of, length: multiple, value: count, name: name}) do + gettext("%{name} - %{count} is not a multiple of %{multiple}.", + name: name, + count: count, + multiple: multiple + ) + end + + defp message(%{reason: :exclusive_max, length: max, value: value, name: name}) + when value >= max do + gettext("%{name} - %{value} is larger than exclusive maximum %{max}.", + name: name, + value: value, + max: max + ) + end + + defp message(%{reason: :maximum, length: max, value: value, name: name}) + when value > max do + gettext("%{name} - %{value} is larger than inclusive maximum %{max}.", + name: name, + value: value, + max: max + ) + end + + defp message(%{reason: :exclusive_multiple, length: min, value: value, name: name}) + when value <= min do + gettext("%{name} - %{value} is smaller than exclusive minimum %{min}.", + name: name, + value: value, + min: min + ) + end + + defp message(%{reason: :minimum, length: min, value: value, name: name}) + when value < min do + gettext("%{name} - %{value} is smaller than inclusive minimum %{min}.", + name: name, + value: value, + min: min + ) + end + + defp message(%{reason: :invalid_type, type: type, value: value, name: name}) do + gettext("%{name} - Invalid %{type}. Got: %{value}.", + name: name, + value: OpenApiSpex.TermType.type(value), + type: type + ) + end + + defp message(%{reason: :invalid_format, format: format, name: name}) do + gettext("%{name} - Invalid format. Expected %{format}.", name: name, format: inspect(format)) + end + + defp message(%{reason: :invalid_enum, name: name}) do + gettext("%{name} - Invalid value for enum.", name: name) + end + + defp message(%{reason: :polymorphic_failed, type: polymorphic_type}) do + gettext("Failed to cast to any schema in %{polymorphic_type}", + polymorphic_type: polymorphic_type + ) + end + + defp message(%{reason: :unexpected_field, name: name}) do + gettext("Unexpected field: %{name}.", name: safe_string(name)) + end + + defp message(%{reason: :no_value_for_discriminator, name: field}) do + gettext("Value used as discriminator for `%{field}` matches no schemas.", name: field) + end + + defp message(%{reason: :invalid_discriminator_value, name: field}) do + gettext("No value provided for required discriminator `%{field}`.", name: field) + end + + defp message(%{reason: :unknown_schema, name: name}) do + gettext("Unknown schema: %{name}.", name: name) + end + + defp message(%{reason: :missing_field, name: name}) do + gettext("Missing field: %{name}.", name: name) + end + + defp message(%{reason: :missing_header, name: name}) do + gettext("Missing header: %{name}.", name: name) + end + + defp message(%{reason: :invalid_header, name: name}) do + gettext("Invalid value for header: %{name}.", name: name) + end + + defp message(%{reason: :max_properties, meta: meta}) do + gettext( + "Object property count %{property_count} is greater than maxProperties: %{max_properties}.", + property_count: meta.property_count, + max_properties: meta.max_properties + ) + end + + defp message(%{reason: :min_properties, meta: meta}) do + gettext( + "Object property count %{property_count} is less than minProperties: %{min_properties}", + property_count: meta.property_count, + min_properties: meta.min_properties + ) + end + + defp safe_string(string) do + to_string(string) |> String.slice(0..39) + end +end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex new file mode 100644 index 000000000..d54e2158d --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -0,0 +1,167 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Account do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AccountField + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.Emoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Account", + description: "Response schema for an account", + type: :object, + properties: %{ + acct: %Schema{type: :string}, + avatar_static: %Schema{type: :string, format: :uri}, + avatar: %Schema{type: :string, format: :uri}, + bot: %Schema{type: :boolean}, + created_at: %Schema{type: :string, format: "date-time"}, + display_name: %Schema{type: :string}, + emojis: %Schema{type: :array, items: Emoji}, + fields: %Schema{type: :array, items: AccountField}, + follow_requests_count: %Schema{type: :integer}, + followers_count: %Schema{type: :integer}, + following_count: %Schema{type: :integer}, + header_static: %Schema{type: :string, format: :uri}, + header: %Schema{type: :string, format: :uri}, + id: FlakeID, + locked: %Schema{type: :boolean}, + note: %Schema{type: :string, format: :html}, + statuses_count: %Schema{type: :integer}, + url: %Schema{type: :string, format: :uri}, + username: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + allow_following_move: %Schema{type: :boolean}, + background_image: %Schema{type: :string, nullable: true}, + chat_token: %Schema{type: :string}, + confirmation_pending: %Schema{type: :boolean}, + hide_favorites: %Schema{type: :boolean}, + hide_followers_count: %Schema{type: :boolean}, + hide_followers: %Schema{type: :boolean}, + hide_follows_count: %Schema{type: :boolean}, + hide_follows: %Schema{type: :boolean}, + is_admin: %Schema{type: :boolean}, + is_moderator: %Schema{type: :boolean}, + skip_thread_containment: %Schema{type: :boolean}, + tags: %Schema{type: :array, items: %Schema{type: :string}}, + unread_conversation_count: %Schema{type: :integer}, + notification_settings: %Schema{ + type: :object, + properties: %{ + followers: %Schema{type: :boolean}, + follows: %Schema{type: :boolean}, + non_followers: %Schema{type: :boolean}, + non_follows: %Schema{type: :boolean}, + privacy_option: %Schema{type: :boolean} + } + }, + relationship: AccountRelationship, + settings_store: %Schema{ + type: :object + } + } + }, + source: %Schema{ + type: :object, + properties: %{ + fields: %Schema{type: :array, items: AccountField}, + note: %Schema{type: :string}, + privacy: VisibilityScope, + sensitive: %Schema{type: :boolean}, + pleroma: %Schema{ + type: :object, + properties: %{ + actor_type: ActorType, + discoverable: %Schema{type: :boolean}, + no_rich_text: %Schema{type: :boolean}, + show_role: %Schema{type: :boolean} + } + } + } + } + }, + example: %{ + "acct" => "foobar", + "avatar" => "https://mypleroma.com/images/avi.png", + "avatar_static" => "https://mypleroma.com/images/avi.png", + "bot" => false, + "created_at" => "2020-03-24T13:05:58.000Z", + "display_name" => "foobar", + "emojis" => [], + "fields" => [], + "follow_requests_count" => 0, + "followers_count" => 0, + "following_count" => 1, + "header" => "https://mypleroma.com/images/banner.png", + "header_static" => "https://mypleroma.com/images/banner.png", + "id" => "9tKi3esbG7OQgZ2920", + "locked" => false, + "note" => "cofe", + "pleroma" => %{ + "allow_following_move" => true, + "background_image" => nil, + "confirmation_pending" => true, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "skip_thread_containment" => false, + "chat_token" => + "SFMyNTY.g3QAAAACZAAEZGF0YW0AAAASOXRLaTNlc2JHN09RZ1oyOTIwZAAGc2lnbmVkbgYARNplS3EB.Mb_Iaqew2bN1I1o79B_iP7encmVCpTKC4OtHZRxdjKc", + "unread_conversation_count" => 0, + "tags" => [], + "notification_settings" => %{ + "followers" => true, + "follows" => true, + "non_followers" => true, + "non_follows" => true, + "privacy_option" => false + }, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + }, + "settings_store" => %{ + "pleroma-fe" => %{} + } + }, + "source" => %{ + "fields" => [], + "note" => "foobar", + "pleroma" => %{ + "actor_type" => "Person", + "discoverable" => false, + "no_rich_text" => false, + "show_role" => true + }, + "privacy" => "public", + "sensitive" => false + }, + "statuses_count" => 0, + "url" => "https://mypleroma.com/users/foobar", + "username" => "foobar" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_field.ex b/lib/pleroma/web/api_spec/schemas/account_field.ex new file mode 100644 index 000000000..fa97073a0 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_field.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountField do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountField", + description: "Response schema for account custom fields", + type: :object, + properties: %{ + name: %Schema{type: :string}, + value: %Schema{type: :string, format: :html}, + verified_at: %Schema{type: :string, format: :"date-time", nullable: true} + }, + example: %{ + "name" => "Website", + "value" => + "<a href=\"https://pleroma.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">pleroma.com</span><span class=\"invisible\"></span></a>", + "verified_at" => "2019-08-29T04:14:55.571+00:00" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship.ex b/lib/pleroma/web/api_spec/schemas/account_relationship.ex new file mode 100644 index 000000000..8b982669e --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_relationship.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationship do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountRelationship", + description: "Response schema for relationship", + type: :object, + properties: %{ + blocked_by: %Schema{type: :boolean}, + blocking: %Schema{type: :boolean}, + domain_blocking: %Schema{type: :boolean}, + endorsed: %Schema{type: :boolean}, + followed_by: %Schema{type: :boolean}, + following: %Schema{type: :boolean}, + id: FlakeID, + muting: %Schema{type: :boolean}, + muting_notifications: %Schema{type: :boolean}, + requested: %Schema{type: :boolean}, + showing_reblogs: %Schema{type: :boolean}, + subscribing: %Schema{type: :boolean} + }, + example: %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/actor_type.ex b/lib/pleroma/web/api_spec/schemas/actor_type.ex new file mode 100644 index 000000000..ac9b46678 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/actor_type.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ActorType do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ActorType", + type: :string, + enum: ["Application", "Group", "Organization", "Person", "Service"] + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/api_error.ex b/lib/pleroma/web/api_spec/schemas/api_error.ex new file mode 100644 index 000000000..5815df94c --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/api_error.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ApiError do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ApiError", + description: "Response schema for API error", + type: :object, + properties: %{error: %Schema{type: :string}}, + example: %{ + "error" => "Something went wrong" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/attachment.ex b/lib/pleroma/web/api_spec/schemas/attachment.ex new file mode 100644 index 000000000..c6edf6d36 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/attachment.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Attachment do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Attachment", + description: "Represents a file or media attachment that can be added to a status.", + type: :object, + requried: [:id, :url, :preview_url], + properties: %{ + id: %Schema{type: :string, description: "The ID of the attachment in the database."}, + url: %Schema{ + type: :string, + format: :uri, + description: "The location of the original full-size attachment" + }, + remote_url: %Schema{ + type: :string, + format: :uri, + description: + "The location of the full-size original attachment on the remote website. String (URL), or null if the attachment is local", + nullable: true + }, + preview_url: %Schema{ + type: :string, + format: :uri, + description: "The location of a scaled-down preview of the attachment" + }, + text_url: %Schema{ + type: :string, + format: :uri, + description: "A shorter URL for the attachment" + }, + description: %Schema{ + type: :string, + nullable: true, + description: + "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load" + }, + type: %Schema{ + type: :string, + enum: ["image", "video", "audio", "unknown"], + description: "The type of the attachment" + }, + pleroma: %Schema{ + type: :object, + properties: %{ + mime_type: %Schema{type: :string, description: "mime type of the attachment"} + } + } + }, + example: %{ + id: "1638338801", + type: "image", + url: "someurl", + remote_url: "someurl", + preview_url: "someurl", + text_url: "someurl", + description: nil, + pleroma: %{mime_type: "image/png"} + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/boolean_like.ex b/lib/pleroma/web/api_spec/schemas/boolean_like.ex new file mode 100644 index 000000000..f3bfb74da --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/boolean_like.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BooleanLike", + description: """ + The following values will be treated as `false`: + - false + - 0 + - "0", + - "f", + - "F", + - "false", + - "FALSE", + - "off", + - "OFF" + + All other non-null values will be treated as `true` + """, + anyOf: [ + %Schema{type: :boolean}, + %Schema{type: :string}, + %Schema{type: :integer} + ] + }) + + def after_cast(value, _schmea) do + {:ok, Pleroma.Web.ControllerHelper.truthy_param?(value)} + end +end diff --git a/lib/pleroma/web/api_spec/schemas/conversation.ex b/lib/pleroma/web/api_spec/schemas/conversation.ex new file mode 100644 index 000000000..d8ff5ba26 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/conversation.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Conversation do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.Status + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Conversation", + description: "Represents a conversation with \"direct message\" visibility.", + type: :object, + required: [:id, :accounts, :unread], + properties: %{ + id: %Schema{type: :string}, + accounts: %Schema{ + type: :array, + items: Account, + description: "Participants in the conversation" + }, + unread: %Schema{ + type: :boolean, + description: "Is the conversation currently marked as unread?" + }, + # last_status: Status + last_status: %Schema{ + allOf: [Status], + description: "The last status in the conversation, to be used for optional display" + } + }, + example: %{ + "id" => "418450", + "unread" => true, + "accounts" => [Account.schema().example], + "last_status" => Status.schema().example + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/emoji.ex b/lib/pleroma/web/api_spec/schemas/emoji.ex new file mode 100644 index 000000000..26f35e648 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/emoji.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Emoji do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Emoji", + description: "Response schema for an emoji", + type: :object, + properties: %{ + shortcode: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + static_url: %Schema{type: :string, format: :uri}, + visible_in_picker: %Schema{type: :boolean} + }, + example: %{ + "shortcode" => "fatyoshi", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", + "visible_in_picker" => true + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/flake_id.ex b/lib/pleroma/web/api_spec/schemas/flake_id.ex new file mode 100644 index 000000000..3b5f6477a --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/flake_id.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.FlakeID do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "FlakeID", + description: + "Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings", + type: :string + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex new file mode 100644 index 000000000..b7d1685c9 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/list.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.List do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "List", + description: "Represents a list of users", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "The internal database ID of the list"}, + title: %Schema{type: :string, description: "The user-defined title of the list"} + }, + example: %{ + "id" => "12249", + "title" => "Friends" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex new file mode 100644 index 000000000..c62096db0 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Poll do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Emoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Poll", + description: "Represents a poll attached to a status", + type: :object, + properties: %{ + id: FlakeID, + expires_at: %Schema{ + type: :string, + format: :"date-time", + nullable: true, + description: "When the poll ends" + }, + expired: %Schema{type: :boolean, description: "Is the poll currently expired?"}, + multiple: %Schema{ + type: :boolean, + description: "Does the poll allow multiple-choice answers?" + }, + votes_count: %Schema{ + type: :integer, + nullable: true, + description: "How many votes have been received. Number, or null if `multiple` is false." + }, + voted: %Schema{ + type: :boolean, + nullable: true, + description: + "When called with a user token, has the authorized user voted? Boolean, or null if no current user." + }, + emojis: %Schema{ + type: :array, + items: Emoji, + description: "Custom emoji to be used for rendering poll options." + }, + options: %Schema{ + type: :array, + items: %Schema{ + title: "PollOption", + type: :object, + properties: %{ + title: %Schema{type: :string}, + votes_count: %Schema{type: :integer} + } + }, + description: "Possible answers for the poll." + } + }, + example: %{ + id: "34830", + expires_at: "2019-12-05T04:05:08.302Z", + expired: true, + multiple: false, + votes_count: 10, + voters_count: nil, + voted: true, + own_votes: [ + 1 + ], + options: [ + %{ + title: "accept", + votes_count: 6 + }, + %{ + title: "deny", + votes_count: 4 + } + ], + emojis: [] + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/push_subscription.ex b/lib/pleroma/web/api_spec/schemas/push_subscription.ex new file mode 100644 index 000000000..cc91b95b8 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/push_subscription.ex @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.PushSubscription do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PushSubscription", + description: "Response schema for a push subscription", + type: :object, + properties: %{ + id: %Schema{ + anyOf: [%Schema{type: :string}, %Schema{type: :integer}], + description: "The id of the push subscription in the database." + }, + endpoint: %Schema{type: :string, description: "Where push alerts will be sent to."}, + server_key: %Schema{type: :string, description: "The streaming server's VAPID key."}, + alerts: %Schema{ + type: :object, + description: "Which alerts should be delivered to the endpoint.", + properties: %{ + follow: %Schema{ + type: :boolean, + description: "Receive a push notification when someone has followed you?" + }, + favourite: %Schema{ + type: :boolean, + description: + "Receive a push notification when a status you created has been favourited by someone else?" + }, + reblog: %Schema{ + type: :boolean, + description: + "Receive a push notification when a status you created has been boosted by someone else?" + }, + mention: %Schema{ + type: :boolean, + description: + "Receive a push notification when someone else has mentioned you in a status?" + }, + poll: %Schema{ + type: :boolean, + description: + "Receive a push notification when a poll you voted in or created has ended? " + } + } + } + }, + example: %{ + "id" => "328_183", + "endpoint" => "https://yourdomain.example/listener", + "alerts" => %{ + "follow" => true, + "favourite" => true, + "reblog" => true, + "mention" => true, + "poll" => true + }, + "server_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex new file mode 100644 index 000000000..0520d0848 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Attachment + alias Pleroma.Web.ApiSpec.Schemas.Poll + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ScheduledStatus", + description: "Represents a status that will be published at a future scheduled date.", + type: :object, + required: [:id, :scheduled_at, :params], + properties: %{ + id: %Schema{type: :string}, + scheduled_at: %Schema{type: :string, format: :"date-time"}, + media_attachments: %Schema{type: :array, items: Attachment}, + params: %Schema{ + type: :object, + required: [:text, :visibility], + properties: %{ + text: %Schema{type: :string, nullable: true}, + media_ids: %Schema{type: :array, nullable: true, items: %Schema{type: :string}}, + sensitive: %Schema{type: :boolean, nullable: true}, + spoiler_text: %Schema{type: :string, nullable: true}, + visibility: %Schema{type: VisibilityScope, nullable: true}, + scheduled_at: %Schema{type: :string, format: :"date-time", nullable: true}, + poll: %Schema{type: Poll, nullable: true}, + in_reply_to_id: %Schema{type: :string, nullable: true} + } + } + }, + example: %{ + id: "3221", + scheduled_at: "2019-12-05T12:33:01.000Z", + params: %{ + text: "test content", + media_ids: nil, + sensitive: nil, + spoiler_text: nil, + visibility: nil, + scheduled_at: nil, + poll: nil, + idempotency: nil, + in_reply_to_id: nil + }, + media_attachments: [Attachment.schema().example] + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex new file mode 100644 index 000000000..8b87cb25b --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -0,0 +1,325 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Status do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.Attachment + alias Pleroma.Web.ApiSpec.Schemas.Emoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Poll + alias Pleroma.Web.ApiSpec.Schemas.Tag + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Status", + description: "Response schema for a status", + type: :object, + properties: %{ + account: %Schema{allOf: [Account], description: "The account that authored this status"}, + application: %Schema{ + description: "The application used to post this status", + type: :object, + properties: %{ + name: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true, format: :uri} + } + }, + bookmarked: %Schema{type: :boolean, description: "Have you bookmarked this status?"}, + card: %Schema{ + type: :object, + nullable: true, + description: "Preview card for links included within status content", + required: [:url, :title, :description, :type], + properties: %{ + type: %Schema{ + type: :string, + enum: ["link", "photo", "video", "rich"], + description: "The type of the preview card" + }, + provider_name: %Schema{ + type: :string, + nullable: true, + description: "The provider of the original resource" + }, + provider_url: %Schema{ + type: :string, + format: :uri, + description: "A link to the provider of the original resource" + }, + url: %Schema{type: :string, format: :uri, description: "Location of linked resource"}, + image: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "Preview thumbnail" + }, + title: %Schema{type: :string, description: "Title of linked resource"}, + description: %Schema{type: :string, description: "Description of preview"} + } + }, + content: %Schema{type: :string, format: :html, description: "HTML-encoded status content"}, + created_at: %Schema{ + type: :string, + format: "date-time", + description: "The date when this status was created" + }, + emojis: %Schema{ + type: :array, + items: Emoji, + description: "Custom emoji to be used when rendering status content" + }, + favourited: %Schema{type: :boolean, description: "Have you favourited this status?"}, + favourites_count: %Schema{ + type: :integer, + description: "How many favourites this status has received" + }, + id: FlakeID, + in_reply_to_account_id: %Schema{ + allOf: [FlakeID], + nullable: true, + description: "ID of the account being replied to" + }, + in_reply_to_id: %Schema{ + allOf: [FlakeID], + nullable: true, + description: "ID of the status being replied" + }, + language: %Schema{ + type: :string, + nullable: true, + description: "Primary language of this status" + }, + media_attachments: %Schema{ + type: :array, + items: Attachment, + description: "Media that is attached to this status" + }, + mentions: %Schema{ + type: :array, + description: "Mentions of users within the status content", + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "The account id of the mentioned user"}, + acct: %Schema{ + type: :string, + description: + "The webfinger acct: URI of the mentioned user. Equivalent to `username` for local users, or `username@domain` for remote users." + }, + username: %Schema{type: :string, description: "The username of the mentioned user"}, + url: %Schema{ + type: :string, + format: :uri, + description: "The location of the mentioned user's profile" + } + } + } + }, + muted: %Schema{ + type: :boolean, + description: "Have you muted notifications for this status's conversation?" + }, + pinned: %Schema{ + type: :boolean, + description: "Have you pinned this status? Only appears if the status is pinnable." + }, + pleroma: %Schema{ + type: :object, + properties: %{ + content: %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: + "A map consisting of alternate representations of the `content` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain`" + }, + conversation_id: %Schema{ + type: :integer, + description: "The ID of the AP context the status is associated with (if any)" + }, + direct_conversation_id: %Schema{ + type: :integer, + nullable: true, + description: + "The ID of the Mastodon direct message conversation the status is associated with (if any)" + }, + emoji_reactions: %Schema{ + type: :array, + description: + "A list with emoji / reaction maps. Contains no information about the reacting users, for that use the /statuses/:id/reactions endpoint.", + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + count: %Schema{type: :integer}, + me: %Schema{type: :boolean} + } + } + }, + expires_at: %Schema{ + type: :string, + format: "date-time", + nullable: true, + description: + "A datetime (ISO 8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire" + }, + in_reply_to_account_acct: %Schema{ + type: :string, + nullable: true, + description: "The `acct` property of User entity for replied user (if any)" + }, + local: %Schema{ + type: :boolean, + description: "`true` if the post was made on the local instance" + }, + spoiler_text: %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: + "A map consisting of alternate representations of the `spoiler_text` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain`." + }, + thread_muted: %Schema{ + type: :boolean, + description: "`true` if the thread the post belongs to is muted" + } + } + }, + poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"}, + reblog: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true, + description: "The status being reblogged" + }, + reblogged: %Schema{type: :boolean, description: "Have you boosted this status?"}, + reblogs_count: %Schema{ + type: :integer, + description: "How many boosts this status has received" + }, + replies_count: %Schema{ + type: :integer, + description: "How many replies this status has received" + }, + sensitive: %Schema{ + type: :boolean, + description: "Is this status marked as sensitive content?" + }, + spoiler_text: %Schema{ + type: :string, + description: + "Subject or summary line, below which status content is collapsed until expanded" + }, + tags: %Schema{type: :array, items: Tag}, + uri: %Schema{ + type: :string, + format: :uri, + description: "URI of the status used for federation" + }, + url: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "A link to the status's HTML representation" + }, + visibility: %Schema{ + allOf: [VisibilityScope], + description: "Visibility of this status" + } + }, + example: %{ + "account" => %{ + "acct" => "nick6", + "avatar" => "http://localhost:4001/images/avi.png", + "avatar_static" => "http://localhost:4001/images/avi.png", + "bot" => false, + "created_at" => "2020-04-07T19:48:51.000Z", + "display_name" => "Test テスト User 6", + "emojis" => [], + "fields" => [], + "followers_count" => 1, + "following_count" => 0, + "header" => "http://localhost:4001/images/banner.png", + "header_static" => "http://localhost:4001/images/banner.png", + "id" => "9toJCsKN7SmSf3aj5c", + "locked" => false, + "note" => "Tester Number 6", + "pleroma" => %{ + "background_image" => nil, + "confirmation_pending" => false, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => true, + "id" => "9toJCsKN7SmSf3aj5c", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + }, + "skip_thread_containment" => false, + "tags" => [] + }, + "source" => %{ + "fields" => [], + "note" => "Tester Number 6", + "pleroma" => %{"actor_type" => "Person", "discoverable" => false}, + "sensitive" => false + }, + "statuses_count" => 1, + "url" => "http://localhost:4001/users/nick6", + "username" => "nick6" + }, + "application" => %{"name" => "Web", "website" => nil}, + "bookmarked" => false, + "card" => nil, + "content" => "foobar", + "created_at" => "2020-04-07T19:48:51.000Z", + "emojis" => [], + "favourited" => false, + "favourites_count" => 0, + "id" => "9toJCu5YZW7O7gfvH6", + "in_reply_to_account_id" => nil, + "in_reply_to_id" => nil, + "language" => nil, + "media_attachments" => [], + "mentions" => [], + "muted" => false, + "pinned" => false, + "pleroma" => %{ + "content" => %{"text/plain" => "foobar"}, + "conversation_id" => 345_972, + "direct_conversation_id" => nil, + "emoji_reactions" => [], + "expires_at" => nil, + "in_reply_to_account_acct" => nil, + "local" => true, + "spoiler_text" => %{"text/plain" => ""}, + "thread_muted" => false + }, + "poll" => nil, + "reblog" => nil, + "reblogged" => false, + "reblogs_count" => 0, + "replies_count" => 0, + "sensitive" => false, + "spoiler_text" => "", + "tags" => [], + "uri" => "http://localhost:4001/objects/0f5dad44-0e9e-4610-b377-a2631e499190", + "url" => "http://localhost:4001/notice/9toJCu5YZW7O7gfvH6", + "visibility" => "private" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/tag.ex b/lib/pleroma/web/api_spec/schemas/tag.ex new file mode 100644 index 000000000..e693fb83e --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/tag.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Tag do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Tag", + description: "Represents a hashtag used within the content of a status", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "The value of the hashtag after the # sign"}, + url: %Schema{ + type: :string, + format: :uri, + description: "A link to the hashtag on the instance" + } + }, + example: %{ + name: "cofe", + url: "https://lain.com/tag/cofe" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/visibility_scope.ex b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex new file mode 100644 index 000000000..831734e27 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.VisibilityScope do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "VisibilityScope", + description: "Status visibility", + type: :string, + enum: ["public", "unlisted", "private", "direct", "list"] + }) +end diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index cb09664ce..200ca03dc 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -16,11 +16,12 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do def get_user(%Plug.Conn{} = conn) do with {:ok, {name, password}} <- fetch_credentials(conn), {_, %User{} = user} <- {:user, fetch_user(name)}, - {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do + {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)}, + {:ok, user} <- AuthenticationPlug.maybe_update_password(user, password) do {:ok, user} else - error -> - {:error, error} + {:error, _reason} = error -> error + error -> {:error, error} end end diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex new file mode 100644 index 000000000..1794e407c --- /dev/null +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.TOTPAuthenticator do + alias Pleroma.MFA + alias Pleroma.MFA.TOTP + alias Pleroma.Plugs.AuthenticationPlug + alias Pleroma.User + + @doc "Verify code or check backup code." + @spec verify(String.t(), User.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def verify( + token, + %User{ + multi_factor_authentication_settings: + %{enabled: true, totp: %{secret: secret, confirmed: true}} = _ + } = _user + ) + when is_binary(token) and byte_size(token) > 0 do + TOTP.validate_token(secret, token) + end + + def verify(_, _), do: {:error, :invalid_token} + + @spec verify_recovery_code(User.t(), String.t()) :: + {:ok, :pass} | {:error, :invalid_token} + def verify_recovery_code( + %User{multi_factor_authentication_settings: %{enabled: true, backup_codes: codes}} = user, + code + ) + when is_list(codes) and is_binary(code) do + hash_code = Enum.find(codes, fn hash -> AuthenticationPlug.checkpw(code, hash) end) + + if hash_code do + MFA.invalidate_backup_code(user, hash_code) + {:ok, :pass} + else + {:error, :invalid_token} + end + end + + def verify_recovery_code(_, _), do: {:error, :invalid_token} +end diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index 38ec774f7..bce27897f 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -23,6 +23,7 @@ defmodule Pleroma.Web.ChatChannel do if String.length(text) in 1..Pleroma.Config.get([:instance, :chat_limit]) do author = User.get_cached_by_nickname(user_name) author = Pleroma.Web.MastodonAPI.AccountView.render("show.json", user: author) + message = ChatChannelState.add_message(%{text: text, author: author}) broadcast!(socket, "new_msg", message) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index c4356f93b..3f1a50b96 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -58,16 +58,16 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end defp put_params(draft, params) do - params = Map.put_new(params, "in_reply_to_status_id", params["in_reply_to_id"]) + params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id]) %__MODULE__{draft | params: params} end - defp status(%{params: %{"status" => status}} = draft) do + defp status(%{params: %{status: status}} = draft) do %__MODULE__{draft | status: String.trim(status)} end defp summary(%{params: params} = draft) do - %__MODULE__{draft | summary: Map.get(params, "spoiler_text", "")} + %__MODULE__{draft | summary: Map.get(params, :spoiler_text, "")} end defp full_payload(%{status: status, summary: summary} = draft) do @@ -84,16 +84,20 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do %__MODULE__{draft | attachments: attachments} end - defp in_reply_to(draft) do - case Map.get(draft.params, "in_reply_to_status_id") do - "" -> draft - nil -> draft - id -> %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)} - end + defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft + + defp in_reply_to(%{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do + %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)} end + defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do + %__MODULE__{draft | in_reply_to: in_reply_to} + end + + defp in_reply_to(draft), do: draft + defp in_reply_to_conversation(draft) do - in_reply_to_conversation = Participation.get(draft.params["in_reply_to_conversation_id"]) + in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id]) %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} end @@ -108,7 +112,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end defp expires_at(draft) do - case CommonAPI.check_expiry_date(draft.params["expires_in"]) do + case CommonAPI.check_expiry_date(draft.params[:expires_in]) do {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} {:error, message} -> add_error(draft, message) end @@ -140,7 +144,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do addressed_users = draft.mentions |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) - |> Utils.get_addressed_users(draft.params["to"]) + |> Utils.get_addressed_users(draft.params[:to]) {to, cc} = Utils.get_to_and_cc( @@ -160,7 +164,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end defp sensitive(draft) do - sensitive = draft.params["sensitive"] || Enum.member?(draft.tags, {"#nsfw", "nsfw"}) + sensitive = draft.params[:sensitive] || Enum.member?(draft.tags, {"#nsfw", "nsfw"}) %__MODULE__{draft | sensitive: sensitive} end @@ -187,7 +191,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end defp preview?(draft) do - preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params["preview"]) || false + preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params[:preview]) %__MODULE__{draft | preview?: preview?} end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 091011c6b..dbb3d7ade 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -7,11 +7,14 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation alias Pleroma.FollowingRelationship + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.UserRelationship alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -19,6 +22,26 @@ defmodule Pleroma.Web.CommonAPI do import Pleroma.Web.CommonAPI.Utils require Pleroma.Constants + require Logger + + def unblock(blocker, blocked) do + with {_, %Activity{} = block} <- {:fetch_block, Utils.fetch_latest_block(blocker, blocked)}, + {:ok, unblock_data, _} <- Builder.undo(blocker, block), + {:ok, unblock, _} <- Pipeline.common_pipeline(unblock_data, local: true) do + {:ok, unblock} + else + {:fetch_block, nil} -> + if User.blocks?(blocker, blocked) do + User.unblock(blocker, blocked) + {:ok, :no_activity} + else + {:error, :not_blocking} + end + + e -> + e + end + end def follow(follower, followed) do timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) @@ -39,10 +62,10 @@ defmodule Pleroma.Web.CommonAPI do end def accept_follow_request(follower, followed) do - with {:ok, follower} <- User.follow(follower, followed), - %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + {:ok, follower} <- User.follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "accept"), + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept), {:ok, _activity} <- ActivityPub.accept(%{ to: [follower.ap_id], @@ -57,7 +80,8 @@ defmodule Pleroma.Web.CommonAPI do def reject_follow_request(follower, followed) do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "reject"), + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), + {:ok, _notifications} <- Notification.dismiss(follow_activity), {:ok, _activity} <- ActivityPub.reject(%{ to: [follower.ap_id], @@ -70,64 +94,125 @@ defmodule Pleroma.Web.CommonAPI do end def delete(activity_id, user) do - with {_, %Activity{data: %{"object" => _}} = activity} <- - {:find_activity, Activity.get_by_id_with_object(activity_id)}, - %Object{} = object <- Object.normalize(activity), + with {_, %Activity{data: %{"object" => _, "type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(activity_id)}, + {_, %Object{} = object, _} <- + {:find_object, Object.normalize(activity, false), activity}, true <- User.superuser?(user) || user.ap_id == object.data["actor"], - {:ok, _} <- unpin(activity_id, user), - {:ok, delete} <- ActivityPub.delete(object) do + {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]), + {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do {:ok, delete} else - {:find_activity, _} -> {:error, :not_found} - _ -> {:error, dgettext("errors", "Could not delete")} + {:find_activity, _} -> + {:error, :not_found} + + {:find_object, nil, %Activity{data: %{"actor" => actor, "object" => object}}} -> + # We have the create activity, but not the object, it was probably pruned. + # Insert a tombstone and try again + with {:ok, tombstone_data, _} <- Builder.tombstone(actor, object), + {:ok, _tombstone} <- Object.create(tombstone_data) do + delete(activity_id, user) + else + _ -> + Logger.error( + "Could not insert tombstone for missing object on deletion. Object is #{object}." + ) + + {:error, dgettext("errors", "Could not delete")} + end + + _ -> + {:error, dgettext("errors", "Could not delete")} end end - def repeat(id_or_ap_id, user, params \\ %{}) do - with {_, %Activity{} = activity} <- {:find_activity, get_by_id_or_ap_id(id_or_ap_id)}, - object <- Object.normalize(activity), - announce_activity <- Utils.get_existing_announce(user.ap_id, object), - public <- public_announce?(object, params) do - if announce_activity do - {:ok, announce_activity, object} - else - ActivityPub.announce(user, object, nil, true, public) - end + def repeat(id, user, params \\ %{}) do + with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), + object = %Object{} <- Object.normalize(activity, false), + {_, nil} <- {:existing_announce, Utils.get_existing_announce(user.ap_id, object)}, + public = public_announce?(object, params), + {:ok, announce, _} <- Builder.announce(user, object, public: public), + {:ok, activity, _} <- Pipeline.common_pipeline(announce, local: true) do + {:ok, activity} else - {:find_activity, _} -> {:error, :not_found} - _ -> {:error, dgettext("errors", "Could not repeat")} + {:existing_announce, %Activity{} = announce} -> + {:ok, announce} + + _ -> + {:error, :not_found} end end - def unrepeat(id_or_ap_id, user) do - with {_, %Activity{} = activity} <- {:find_activity, get_by_id_or_ap_id(id_or_ap_id)} do - object = Object.normalize(activity) - ActivityPub.unannounce(user, object) + def unrepeat(id, user) do + with {_, %Activity{data: %{"type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(id)}, + %Object{} = note <- Object.normalize(activity, false), + %Activity{} = announce <- Utils.get_existing_announce(user.ap_id, note), + {:ok, undo, _} <- Builder.undo(user, announce), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} else {:find_activity, _} -> {:error, :not_found} _ -> {:error, dgettext("errors", "Could not unrepeat")} end end - def favorite(id_or_ap_id, user) do - with {_, %Activity{} = activity} <- {:find_activity, get_by_id_or_ap_id(id_or_ap_id)}, - object <- Object.normalize(activity), - like_activity <- Utils.get_existing_like(user.ap_id, object) do - if like_activity do - {:ok, like_activity, object} - else - ActivityPub.like(user, object) - end + @spec favorite(User.t(), binary()) :: {:ok, Activity.t() | :already_liked} | {:error, any()} + def favorite(%User{} = user, id) do + case favorite_helper(user, id) do + {:ok, _} = res -> + res + + {:error, :not_found} = res -> + res + + {:error, e} -> + Logger.error("Could not favorite #{id}. Error: #{inspect(e, pretty: true)}") + {:error, dgettext("errors", "Could not favorite")} + end + end + + def favorite_helper(user, id) do + with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)}, + {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(like_object, Keyword.put(meta, :local, true))} do + {:ok, activity} else - {:find_activity, _} -> {:error, :not_found} - _ -> {:error, dgettext("errors", "Could not favorite")} + {:find_object, _} -> + {:error, :not_found} + + {:common_pipeline, + { + :error, + { + :validate_object, + { + :error, + changeset + } + } + }} = e -> + if {:object, {"already liked by this actor", []}} in changeset.errors do + {:ok, :already_liked} + else + {:error, e} + end + + e -> + {:error, e} end end - def unfavorite(id_or_ap_id, user) do - with {_, %Activity{} = activity} <- {:find_activity, get_by_id_or_ap_id(id_or_ap_id)} do - object = Object.normalize(activity) - ActivityPub.unlike(user, object) + def unfavorite(id, user) do + with {_, %Activity{data: %{"type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(id)}, + %Object{} = note <- Object.normalize(activity, false), + %Activity{} = like <- Utils.get_existing_like(user.ap_id, note), + {:ok, undo, _} <- Builder.undo(user, like), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} else {:find_activity, _} -> {:error, :not_found} _ -> {:error, dgettext("errors", "Could not unfavorite")} @@ -136,8 +221,10 @@ defmodule Pleroma.Web.CommonAPI do def react_with_emoji(id, user, emoji) do with %Activity{} = activity <- Activity.get_by_id(id), - object <- Object.normalize(activity) do - ActivityPub.react_with_emoji(user, object, emoji) + object <- Object.normalize(activity), + {:ok, emoji_react, _} <- Builder.emoji_react(user, object, emoji), + {:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do + {:ok, activity} else _ -> {:error, dgettext("errors", "Could not add reaction emoji")} @@ -145,8 +232,10 @@ defmodule Pleroma.Web.CommonAPI do end def unreact_with_emoji(id, user, emoji) do - with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji) do - ActivityPub.unreact_with_emoji(user, reaction_activity.data["id"]) + with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji), + {:ok, undo, _} <- Builder.undo(user, reaction_activity), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} else _ -> {:error, dgettext("errors", "Could not remove reaction emoji")} @@ -208,7 +297,7 @@ defmodule Pleroma.Web.CommonAPI do end end - def public_announce?(_, %{"visibility" => visibility}) + def public_announce?(_, %{visibility: visibility}) when visibility in ~w{public unlisted private direct}, do: visibility in ~w(public unlisted) @@ -218,11 +307,11 @@ defmodule Pleroma.Web.CommonAPI do def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} - def get_visibility(%{"visibility" => visibility}, in_reply_to, _) + def get_visibility(%{visibility: visibility}, in_reply_to, _) when visibility in ~w{public unlisted private direct}, do: {visibility, get_replied_to_visibility(in_reply_to)} - def get_visibility(%{"visibility" => "list:" <> list_id}, in_reply_to, _) do + def get_visibility(%{visibility: "list:" <> list_id}, in_reply_to, _) do visibility = {:list, String.to_integer(list_id)} {visibility, get_replied_to_visibility(in_reply_to)} end @@ -259,11 +348,14 @@ defmodule Pleroma.Web.CommonAPI do |> check_expiry_date() end - def listen(user, %{"title" => _} = data) do - with visibility <- data["visibility"] || "public", - {to, cc} <- get_to_and_cc(user, [], nil, visibility, nil), + def listen(user, data) do + visibility = Map.get(data, :visibility, "public") + + with {to, cc} <- get_to_and_cc(user, [], nil, visibility, nil), listen_data <- - Map.take(data, ["album", "artist", "title", "length"]) + data + |> Map.take([:album, :artist, :title, :length]) + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Audio") |> Map.put("to", to) |> Map.put("cc", cc) @@ -280,7 +372,7 @@ defmodule Pleroma.Web.CommonAPI do end end - def post(user, %{"status" => _} = data) do + def post(user, %{status: _} = data) do with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do draft.changes |> ActivityPub.create(draft.preview?) @@ -296,32 +388,12 @@ defmodule Pleroma.Web.CommonAPI do defp maybe_create_activity_expiration(result, _), do: result - # Updates the emojis for a user based on their profile - def update(user) do - emoji = emoji_from_profile(user) - source_data = Map.put(user.source_data, "tag", emoji) - - user = - case User.update_source_data(user, source_data) do - {:ok, user} -> user - _ -> user - end - - ActivityPub.update(%{ - local: true, - to: [Pleroma.Constants.as_public(), user.follower_address], - cc: [], - actor: user.ap_id, - object: Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) - }) - end - - def pin(id_or_ap_id, %{ap_id: user_ap_id} = user) do + def pin(id, %{ap_id: user_ap_id} = user) do with %Activity{ actor: ^user_ap_id, data: %{"type" => "Create"}, object: %Object{data: %{"type" => object_type}} - } = activity <- get_by_id_or_ap_id(id_or_ap_id), + } = activity <- Activity.get_by_id_with_object(id), true <- object_type in ["Note", "Article", "Question"], true <- Visibility.is_public?(activity), {:ok, _user} <- User.add_pinnned_activity(user, activity) do @@ -332,8 +404,8 @@ defmodule Pleroma.Web.CommonAPI do end end - def unpin(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), + def unpin(id, user) do + with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), {:ok, _user} <- User.remove_pinnned_activity(user, activity) do {:ok, activity} else @@ -358,12 +430,12 @@ defmodule Pleroma.Web.CommonAPI do def thread_muted?(%{id: nil} = _user, _activity), do: false def thread_muted?(user, activity) do - ThreadMute.check_muted(user.id, activity.data["context"]) != [] + ThreadMute.exists?(user.id, activity.data["context"]) end - def report(user, %{"account_id" => account_id} = data) do - with {:ok, account} <- get_reported_account(account_id), - {:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]), + def report(user, data) do + with {:ok, account} <- get_reported_account(data.account_id), + {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), {:ok, statuses} <- get_report_statuses(account, data) do ActivityPub.flag(%{ context: Utils.generate_context_id(), @@ -371,13 +443,11 @@ defmodule Pleroma.Web.CommonAPI do account: account, statuses: statuses, content: content_html, - forward: data["forward"] || false + forward: Map.get(data, :forward, false) }) end end - def report(_user, _params), do: {:error, dgettext("errors", "Valid `account_id` required")} - defp get_reported_account(account_id) do case User.get_cached_by_id(account_id) do %User{} = account -> {:ok, account} @@ -411,11 +481,11 @@ defmodule Pleroma.Web.CommonAPI do end end - defp toggle_sensitive(activity, %{"sensitive" => sensitive}) when sensitive in ~w(true false) do - toggle_sensitive(activity, %{"sensitive" => String.to_existing_atom(sensitive)}) + defp toggle_sensitive(activity, %{sensitive: sensitive}) when sensitive in ~w(true false) do + toggle_sensitive(activity, %{sensitive: String.to_existing_atom(sensitive)}) end - defp toggle_sensitive(%Activity{object: object} = activity, %{"sensitive" => sensitive}) + defp toggle_sensitive(%Activity{object: object} = activity, %{sensitive: sensitive}) when is_boolean(sensitive) do new_data = Map.put(object.data, "sensitive", sensitive) @@ -429,7 +499,7 @@ defmodule Pleroma.Web.CommonAPI do defp toggle_sensitive(activity, _), do: {:ok, activity} - defp set_visibility(activity, %{"visibility" => visibility}) do + defp set_visibility(activity, %{visibility: visibility}) do Utils.update_activity_visibility(activity, visibility) end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 635e7cd38..6ec489f9a 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Conversation.Participation - alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.Plugs.AuthenticationPlug @@ -18,35 +17,16 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy require Logger require Pleroma.Constants - # This is a hack for twidere. - def get_by_id_or_ap_id(id) do - activity = - with true <- FlakeId.flake_id?(id), - %Activity{} = activity <- Activity.get_by_id_with_object(id) do - activity - else - _ -> Activity.get_create_by_object_ap_id_with_object(id) - end - - activity && - if activity.data["type"] == "Create" do - activity - else - Activity.get_create_by_object_ap_id_with_object(activity.data["object"]) - end - end - - def attachments_from_ids(%{"media_ids" => ids, "descriptions" => desc} = _) do + def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do attachments_from_ids_descs(ids, desc) end - def attachments_from_ids(%{"media_ids" => ids} = _) do + def attachments_from_ids(%{media_ids: ids}) do attachments_from_ids_no_descs(ids) end @@ -57,11 +37,11 @@ defmodule Pleroma.Web.CommonAPI.Utils do def attachments_from_ids_no_descs(ids) do Enum.map(ids, fn media_id -> case Repo.get(Object, media_id) do - %Object{data: data} = _ -> data + %Object{data: data} -> data _ -> nil end end) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) end def attachments_from_ids_descs([], _), do: [] @@ -71,14 +51,14 @@ defmodule Pleroma.Web.CommonAPI.Utils do Enum.map(ids, fn media_id -> case Repo.get(Object, media_id) do - %Object{data: data} = _ -> + %Object{data: data} -> Map.put(data, "name", descs[media_id]) _ -> nil end end) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) end @spec get_to_and_cc( @@ -122,7 +102,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do end def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct", _) do - if inReplyTo do + # If the OP is a DM already, add the implicit actor. + if inReplyTo && Visibility.is_direct?(inReplyTo) do {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []} else {mentioned_users, []} @@ -160,7 +141,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> make_poll_data() end - def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data) + def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data) when is_list(options) do limits = Pleroma.Config.get([:instance, :poll_limits]) @@ -175,7 +156,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do "replies" => %{"type" => "Collection", "totalItems" => 0} } - {note, Map.merge(emoji, Emoji.Formatter.get_emoji_map(option))} + {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))} end) end_time = @@ -183,7 +164,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> DateTime.add(expires_in) |> DateTime.to_iso8601() - key = if truthy_param?(data["poll"]["multiple"]), do: "anyOf", else: "oneOf" + key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf" poll = %{"type" => "Question", key => option_notes, "closed" => end_time} {:ok, {poll, emoji}} @@ -233,7 +214,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> Map.get("attachment_links", Config.get([:instance, :attachment_links])) |> truthy_param?() - content_type = get_content_type(data["content_type"]) + content_type = get_content_type(data[:content_type]) options = if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do @@ -415,13 +396,16 @@ defmodule Pleroma.Web.CommonAPI.Utils do def to_masto_date(_), do: "" defp shortname(name) do - if String.length(name) < 30 do - name + with max_length when max_length > 0 <- + Config.get([Pleroma.Upload, :filename_display_max_length], 30), + true <- String.length(name) > max_length do + String.slice(name, 0..max_length) <> "…" else - String.slice(name, 0..30) <> "…" + _ -> name end end + @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()} def confirm_current_password(user, password) do with %User{local: true} = db_user <- User.get_cached_by_id(user.id), true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do @@ -431,19 +415,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do end end - def emoji_from_profile(%User{bio: bio, name: name}) do - [bio, name] - |> Enum.map(&Emoji.Formatter.get_emoji/1) - |> Enum.concat() - |> Enum.map(fn {shortcode, %Emoji{file: path}} -> - %{ - "type" => "Emoji", - "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{path}"}, - "name" => ":#{shortcode}:" - } - end) - end - def maybe_notify_to_recipients( recipients, %Activity{data: %{"to" => to, "type" => _type}} = _activity @@ -499,6 +470,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> Enum.map(& &1.ap_id) recipients ++ subscriber_ids + else + _e -> recipients end end @@ -510,6 +483,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> User.get_followers() |> Enum.map(& &1.ap_id) |> Enum.concat(recipients) + else + _e -> recipients end end @@ -537,7 +512,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do end end - def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do + def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids}) + when is_list(status_ids) do {:ok, Activity.all_by_actor_and_id(actor, status_ids)} end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index ad293cda9..5a1316a5f 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -5,10 +5,16 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller - # As in MastoAPI, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html + # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] - def truthy_param?(blank_value) when blank_value in [nil, ""], do: nil - def truthy_param?(value), do: value not in @falsy_param_values + + def explicitly_falsy_param?(value), do: value in @falsy_param_values + + # Note: `nil` and `""` are considered falsy values in Pleroma + def falsy_param?(value), + do: explicitly_falsy_param?(value) or value in [nil, ""] + + def truthy_param?(value), do: not falsy_param?(value) def json_response(conn, status, json) do conn @@ -34,7 +40,12 @@ defmodule Pleroma.Web.ControllerHelper do defp param_to_integer(_, default), do: default - def add_link_headers(conn, activities, extra_params \\ %{}) do + def add_link_headers(conn, activities, extra_params \\ %{}) + + def add_link_headers(%{assigns: %{skip_link_headers: true}} = conn, _activities, _extra_params), + do: conn + + def add_link_headers(conn, activities, extra_params) do case List.last(activities) do %{id: max_id} -> params = @@ -69,8 +80,9 @@ defmodule Pleroma.Web.ControllerHelper do end end - def assign_account_by_id(%{params: %{"id" => id}} = conn, _) do - case Pleroma.User.get_cached_by_id(id) do + def assign_account_by_id(conn, _) do + # TODO: use `conn.params[:id]` only after moving to OpenAPI + case Pleroma.User.get_cached_by_id(conn.params[:id] || conn.params["id"]) do %Pleroma.User{} = account -> assign(conn, :account, account) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() end @@ -91,4 +103,17 @@ defmodule Pleroma.Web.ControllerHelper do def put_if_exist(map, _key, nil), do: map def put_if_exist(map, key, value), do: Map.put(map, key, value) + + @doc """ + Returns true if request specifies to include embedded relationships in account objects. + May only be used in selected account-related endpoints; has no effect for status- or + notification-related endpoints. + """ + # Intended for PleromaFE: https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838 + def embed_relationships?(params) do + # To do once OpenAPI transition mess is over: just `truthy_param?(params[:with_relationships])` + params + |> Map.get(:with_relationships, params["with_relationships"]) + |> truthy_param?() + end end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 4f665db12..226d42c2c 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.Endpoint do use Phoenix.Endpoint, otp_app: :pleroma + require Pleroma.Constants + socket("/socket", Pleroma.Web.UserSocket) plug(Pleroma.Plugs.SetLocalePlug) @@ -34,8 +36,7 @@ defmodule Pleroma.Web.Endpoint do Plug.Static, at: "/", from: :pleroma, - only: - ~w(index.html robots.txt static finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css), + only: Pleroma.Constants.static_only_files(), # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength gzip: true, cache_control_for_etags: @static_cache_control, diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index c13518030..0d9d578fc 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,7 +4,9 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller + require Logger + alias Pleroma.User alias Pleroma.Web.Metadata diff --git a/lib/pleroma/web/federator/federator.ex b/lib/pleroma/web/federator/federator.ex index fd904ef0a..f5803578d 100644 --- a/lib/pleroma/web/federator/federator.ex +++ b/lib/pleroma/web/federator/federator.ex @@ -72,19 +72,24 @@ defmodule Pleroma.Web.Federator do # actor shouldn't be acting on objects outside their own AP server. with {:ok, _user} <- ap_enabled_actor(params["actor"]), nil <- Activity.normalize(params["id"]), - :ok <- Containment.contain_origin_from_id(params["actor"], params), + {_, :ok} <- + {:correct_origin?, Containment.contain_origin_from_id(params["actor"], params)}, {:ok, activity} <- Transmogrifier.handle_incoming(params) do {:ok, activity} else + {:correct_origin?, _} -> + Logger.debug("Origin containment failure for #{params["id"]}") + {:error, :origin_containment_failed} + %Activity{} -> Logger.debug("Already had #{params["id"]}") - :error + {:error, :already_present} - _e -> + e -> # Just drop those for now Logger.debug("Unhandled activity") Logger.debug(Jason.encode!(params, pretty: true)) - :error + {:error, e} end end diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index e18adaea8..1ae03e7e2 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Web.Feed.FeedView do def pub_date(%DateTime{} = date), do: Timex.format!(date, "{RFC822}") def prepare_activity(activity, opts \\ []) do - object = activity_object(activity) + object = Object.normalize(activity) actor = if opts[:actor] do @@ -33,7 +33,6 @@ defmodule Pleroma.Web.Feed.FeedView do %{ activity: activity, data: Map.get(object, :data), - object: object, actor: actor } end @@ -68,9 +67,7 @@ defmodule Pleroma.Web.Feed.FeedView do def last_activity(activities), do: List.last(activities) - def activity_object(activity), do: Object.normalize(activity) - - def activity_title(%{data: %{"content" => content}}, opts \\ %{}) do + def activity_title(%{"content" => content}, opts \\ %{}) do content |> Pleroma.Web.Metadata.Utils.scrub_html() |> Pleroma.Emoji.Formatter.demojify() @@ -78,7 +75,7 @@ defmodule Pleroma.Web.Feed.FeedView do |> escape() end - def activity_content(%{data: %{"content" => content}}) do + def activity_content(%{"content" => content}) do content |> String.replace(~r/[\n\r]/, "") |> escape() diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index e27f85929..5a6fc9de0 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.Feed.UserController do when format in ["json", "activity+json"] do with %{halted: false} = conn <- Pleroma.Plugs.EnsureAuthenticatedPlug.call(conn, - unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 ) do ActivityPubController.call(conn, :user) end @@ -56,7 +56,7 @@ defmodule Pleroma.Web.Feed.UserController do "actor_id" => user.ap_id } |> put_if_exist("max_id", params["max_id"]) - |> ActivityPub.fetch_public_activities() + |> ActivityPub.fetch_public_or_unlisted_activities() conn |> put_resp_content_type("application/#{format}+xml") diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 43649ad26..d0d8bc8eb 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -5,19 +5,25 @@ defmodule Pleroma.Web.MastoFEController do use Pleroma.Web, :controller + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :put_settings) # Note: :index action handles attempt of unauthenticated access to private instance with redirect + plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action == :index) + plug( OAuthScopesPlug, - %{scopes: ["read"], fallback: :proceed_unauthenticated, skip_instance_privacy_check: true} + %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :index ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :index) + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :manifest + ) @doc "GET /web/*path" def index(%{assigns: %{user: user, token: token}} = conn, _params) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 6dbf11ac9..47649d41d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -6,9 +6,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] - - alias Pleroma.Emoji + only: [ + add_link_headers: 2, + truthy_param?: 1, + assign_account_by_id: 2, + embed_relationships?: 1, + json_response: 3 + ] + + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.User @@ -16,20 +22,33 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do 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.Token alias Pleroma.Web.TwitterAPI.TwitterAPI + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(:skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :create) + + plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:show, :statuses]) + plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action == :show + 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 [:endorsements, :verify_credentials, :followers, :following] + when action in [:verify_credentials, :endorsements, :identity_proofs] ) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials) @@ -48,52 +67,35 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships) - # Note: :follows (POST /api/v1/follows) is the same as :follow, consider removing :follows plug( OAuthScopesPlug, - %{scopes: ["follow", "write:follows"]} when action in [:follows, :follow, :unfollow] + %{scopes: ["follow", "write:follows"]} when action in [:follow_by_uri, :follow, :unfollow] ) plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes) plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute]) + @relationship_actions [:follow, :unfollow] + @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a + plug( - Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug - when action not in [:create, :show, :statuses] + RateLimiter, + [name: :relation_id_action, params: [:id, :uri]] when action in @relationship_actions ) - @relations [:follow, :unfollow] - @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a - - plug(RateLimiter, [name: :relations_id_action, params: ["id", "uri"]] when action in @relations) - plug(RateLimiter, [name: :relations_actions] when action in @relations) + 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) - @doc "POST /api/v1/accounts" - def create( - %{assigns: %{app: app}} = conn, - %{"username" => nickname, "password" => _, "agreement" => true} = params - ) do - params = - params - |> Map.take([ - "email", - "captcha_solution", - "captcha_token", - "captcha_answer_data", - "token", - "password" - ]) - |> Map.put("nickname", nickname) - |> Map.put("fullname", params["fullname"] || nickname) - |> Map.put("bio", params["bio"] || "") - |> Map.put("confirm", params["password"]) + 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, need_confirmation: true), {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do json(conn, %{ @@ -103,7 +105,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do created_at: Token.Utils.format_created_at(token) }) else - {:error, errors} -> json_response(conn, :bad_request, errors) + {:error, error} -> json_response(conn, :bad_request, %{error: error}) end end @@ -115,11 +117,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do render_error(conn, :forbidden, "Invalid credentials") end - defp validate_email_param(%{"email" => _}), do: :ok + 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, %{"error" => "Missing parameters"}} + true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")} _ -> :ok end end @@ -137,19 +139,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "PATCH /api/v1/accounts/update_credentials" - def update_credentials(%{assigns: %{user: original_user}} = conn, params) do + def update_credentials(%{assigns: %{user: original_user}, body_params: params} = conn, _params) do user = original_user params = - if Map.has_key?(params, "fields_attributes") do - Map.update!(params, "fields_attributes", fn fields -> - fields - |> normalize_fields_attributes() - |> Enum.filter(fn %{"name" => n} -> n != "" end) - end) - else - params - end + params + |> Enum.filter(fn {_, value} -> not is_nil(value) end) + |> Enum.into(%{}) user_params = [ @@ -166,54 +162,27 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :discoverable ] |> Enum.reduce(%{}, fn key, acc -> - add_if_present(acc, params, to_string(key), key, &{:ok, truthy_param?(&1)}) - end) - |> add_if_present(params, "display_name", :name) - |> add_if_present(params, "note", :bio, fn value -> {:ok, User.parse_bio(value, user)} end) - |> add_if_present(params, "avatar", :avatar, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :avatar) do - {:ok, object.data} - end - end) - |> add_if_present(params, "header", :banner, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :banner) do - {:ok, object.data} - end + add_if_present(acc, params, key, key, &{:ok, truthy_param?(&1)}) end) - |> add_if_present(params, "pleroma_background_image", :background, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :background) do - {:ok, object.data} - end - end) - |> add_if_present(params, "fields_attributes", :fields, fn fields -> - fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) - - {:ok, fields} - end) - |> add_if_present(params, "fields_attributes", :raw_fields) - |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value -> - {:ok, Map.merge(user.pleroma_settings_store, value)} - end) - |> add_if_present(params, "default_scope", :default_scope) - |> add_if_present(params, "actor_type", :actor_type) - - emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") + |> add_if_present(params, :display_name, :name) + |> add_if_present(params, :note, :bio) + |> add_if_present(params, :avatar, :avatar) + |> add_if_present(params, :header, :banner) + |> add_if_present(params, :pleroma_background_image, :background) + |> add_if_present( + params, + :fields_attributes, + :raw_fields, + &{:ok, normalize_fields_attributes(&1)} + ) + |> add_if_present(params, :pleroma_settings_store, :pleroma_settings_store) + |> add_if_present(params, :default_scope, :default_scope) + |> add_if_present(params["source"], "privacy", :default_scope) + |> add_if_present(params, :actor_type, :actor_type) - user_emojis = - user - |> Map.get(:emoji, []) - |> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text)) - |> Enum.dedup() - - user_params = Map.put(user_params, :emoji, user_emojis) changeset = User.update_changeset(user, user_params) with {:ok, user} <- User.update_and_set_cache(changeset) do - if original_user != user, do: CommonAPI.update(user) - render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) else _e -> render_error(conn, :forbidden, "Invalid request") @@ -221,8 +190,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do - with true <- Map.has_key?(params, params_field), - {:ok, new_value} <- value_function.(params[params_field]) do + with true <- is_map(params), + true <- Map.has_key?(params, params_field), + {:ok, new_value} <- value_function.(Map.get(params, params_field)) do Map.put(map, map_field, new_value) else _ -> map @@ -233,12 +203,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do if Enum.all?(fields, &is_tuple/1) do Enum.map(fields, fn {_, v} -> v end) else - fields + 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 + 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) @@ -248,7 +221,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do 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}) do + def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), true <- User.visible_for?(user, for_user) do render(conn, "show.json", user: user, for: for_user) @@ -259,19 +232,25 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @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), + with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user), true <- User.visible_for?(user, reading_user) do params = params - |> Map.put("tag", params["tagged"]) - |> Map.delete("godmode") + |> Map.delete(:tagged) + |> Enum.filter(&(not is_nil(&1))) + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> 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) + |> render("index.json", + activities: activities, + for: reading_user, + as: :activity + ) else _e -> render_error(conn, :not_found, "Can't find user") end @@ -279,6 +258,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @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) @@ -288,11 +272,22 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do conn |> add_link_headers(followers) - |> render("index.json", for: for_user, users: followers, as: :user) + # 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) @@ -302,7 +297,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do conn |> add_link_headers(followers) - |> render("index.json", for: for_user, users: followers, as: :user) + # 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" @@ -316,11 +317,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts/:id/follow" def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do - {:error, :not_found} + {:error, "Can not follow yourself"} end - def follow(%{assigns: %{user: follower, account: followed}} = conn, _params) do - with {:ok, follower} <- MastodonAPI.follow(follower, followed, conn.params) do + def follow(%{assigns: %{user: follower, account: followed}} = conn, params) 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}) @@ -329,7 +330,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts/:id/unfollow" def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do - {:error, :not_found} + {:error, "Can not unfollow yourself"} end def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do @@ -339,10 +340,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "POST /api/v1/accounts/:id/mute" - def mute(%{assigns: %{user: muter, account: muted}} = conn, params) do - notifications? = params |> Map.get("notifications", true) |> truthy_param?() - - with {:ok, _user_relationships} <- User.mute(muter, muted, notifications?) do + def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do + with {:ok, _user_relationships} <- User.mute(muter, muted, params.notifications) do render(conn, "relationship.json", user: muter, target: muted) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) @@ -370,8 +369,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts/:id/unblock" def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do - with {:ok, _user_block} <- User.unblock(blocker, blocked), - {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) 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}) @@ -379,14 +377,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do 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}) + 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 @@ -403,6 +402,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/endorsements" - def endorsements(conn, params), - do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params) + def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, params) + + @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/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index 5e2871f18..a516b6c20 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.AppController do use Pleroma.Web, :controller + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.Web.OAuth.App @@ -13,18 +14,28 @@ defmodule Pleroma.Web.MastodonAPI.AppController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] + when action == :create + ) + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + @local_mastodon_name "Mastodon-Local" + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AppOperation + @doc "POST /api/v1/apps" - def create(conn, params) do + def create(%{body_params: params} = conn, _params) do scopes = Scopes.fetch_scopes(params, ["read"]) app_attrs = params - |> Map.drop(["scope", "scopes"]) - |> Map.put("scopes", scopes) + |> Map.take([:client_name, :redirect_uris, :website]) + |> Map.put(:scopes, scopes) with cs <- App.register_changeset(%App{}, app_attrs), false <- cs.changes[:client_name] == @local_mastodon_name, diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index 37b389382..753b3db3e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -13,10 +13,10 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - @local_mastodon_name "Mastodon-Local" - plug(Pleroma.Plugs.RateLimiter, [name: :password_reset] when action == :password_reset) + @local_mastodon_name "Mastodon-Local" + @doc "GET /web/login" def login(%{assigns: %{user: %User{}}} = conn, _params) do redirect(conn, to: local_mastodon_root_path(conn)) diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index 7c9b11bf1..69f0e3846 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -13,13 +13,15 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do 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 == :read) + plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action != :index) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ConversationOperation @doc "GET /api/v1/conversations" def index(%{assigns: %{user: user}} = conn, params) do + params = stringify_pagination_params(params) participations = Participation.for_user_with_last_activity_id(user, params) conn @@ -28,11 +30,27 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do end @doc "POST /api/v1/conversations/:id/read" - def read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do + 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 + + defp stringify_pagination_params(params) do + atom_keys = + Pleroma.Pagination.page_keys() + |> Enum.map(&String.to_atom(&1)) + + str_keys = + params + |> Map.take(atom_keys) + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + + params + |> Map.delete(atom_keys) + |> Map.merge(str_keys) + 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 index d82de1db5..c5f47c5df 100644 --- a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -5,6 +5,16 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do use Pleroma.Web, :controller + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + :skip_plug, + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] + 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 diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex index e4156cbe6..825b231ab 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -8,6 +8,9 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockController do alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User + plug(Pleroma.Web.ApiSpec.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DomainBlockOperation + plug( OAuthScopesPlug, %{scopes: ["follow", "read:blocks"]} when action == :index @@ -18,21 +21,19 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockController do %{scopes: ["follow", "write:blocks"]} when action != :index ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @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}} = conn, %{"domain" => domain}) do + def create(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do User.block_domain(blocker, domain) json(conn, %{}) end @doc "DELETE /api/v1/domain_blocks" - def delete(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do + def delete(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do User.unblock_domain(blocker, domain) json(conn, %{}) end diff --git a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex index 0a257f604..8af557b61 100644 --- a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex @@ -20,6 +20,10 @@ defmodule Pleroma.Web.MastodonAPI.FallbackController 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) diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex index 7b0b937a2..abbf0ce02 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do @oauth_read_actions [:show, :index] + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions) plug( @@ -17,62 +18,60 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do %{scopes: ["write:filters"]} when action not in @oauth_read_actions ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation @doc "GET /api/v1/filters" def index(%{assigns: %{user: user}} = conn, _) do filters = Filter.get_filters(user) - render(conn, "filters.json", filters: filters) + render(conn, "index.json", filters: filters) end @doc "POST /api/v1/filters" - def create( - %{assigns: %{user: user}} = conn, - %{"phrase" => phrase, "context" => context} = params - ) do + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do query = %Filter{ user_id: user.id, - phrase: phrase, - context: context, - hide: Map.get(params, "irreversible", false), - whole_word: Map.get(params, "boolean", true) - # expires_at + phrase: params.phrase, + context: params.context, + hide: params.irreversible, + whole_word: params.whole_word + # TODO: support `expires_in` parameter (as in Mastodon API) } {:ok, response} = Filter.create(query) - render(conn, "filter.json", filter: response) + render(conn, "show.json", filter: response) end @doc "GET /api/v1/filters/:id" - def show(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do + def show(%{assigns: %{user: user}} = conn, %{id: filter_id}) do filter = Filter.get(filter_id, user) - render(conn, "filter.json", filter: filter) + render(conn, "show.json", filter: filter) end @doc "PUT /api/v1/filters/:id" def update( - %{assigns: %{user: user}} = conn, - %{"phrase" => phrase, "context" => context, "id" => filter_id} = params + %{assigns: %{user: user}, body_params: params} = conn, + %{id: filter_id} ) do - query = %Filter{ - user_id: user.id, - filter_id: filter_id, - phrase: phrase, - context: context, - hide: Map.get(params, "irreversible", nil), - whole_word: Map.get(params, "boolean", true) - # expires_at - } - - {:ok, response} = Filter.update(query) - render(conn, "filter.json", filter: response) + params = + params + |> Map.delete(:irreversible) + |> Map.put(:hide, params[:irreversible]) + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + + # TODO: support `expires_in` parameter (as in Mastodon API) + + with %Filter{} = filter <- Filter.get(filter_id, user), + {:ok, %Filter{} = filter} <- Filter.update(filter, params) do + render(conn, "show.json", filter: filter) + end end @doc "DELETE /api/v1/filters/:id" - def delete(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do + def delete(%{assigns: %{user: user}} = conn, %{id: filter_id}) do query = %Filter{ user_id: user.id, filter_id: filter_id diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex index 1ca86f63f..748b6b475 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do alias Pleroma.Web.CommonAPI plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:assign_follower when action != :index) action_fallback(:errors) @@ -21,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do %{scopes: ["follow", "write:follows"]} when action != :index ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation @doc "GET /api/v1/follow_requests" def index(%{assigns: %{user: followed}} = conn, _params) do @@ -44,7 +45,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do end end - defp assign_follower(%{params: %{"id" => id}} = conn, _) do + 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() diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index 27b5b1a52..d8859731d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -5,6 +5,16 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do use Pleroma.Web, :controller + plug(OpenApiSpex.Plug.CastAndValidate) + + plug( + :skip_plug, + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] + 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") diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex index dac4daa7b..acdc76fd2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -9,20 +9,17 @@ defmodule Pleroma.Web.MastodonAPI.ListController do alias Pleroma.User alias Pleroma.Web.MastodonAPI.AccountView - plug(:list_by_id_and_user when action not in [:index, :create]) - - plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in [:index, :show, :list_accounts]) - - plug( - OAuthScopesPlug, - %{scopes: ["write:lists"]} - when action in [:create, :update, :delete, :add_to_list, :remove_from_list] - ) + @oauth_read_actions [:index, :show, :list_accounts] - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + 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) @@ -30,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do end # POST /api/v1/lists - def create(%{assigns: %{user: user}} = conn, %{"title" => title}) do + 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 @@ -42,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do end # PUT /api/v1/lists/:id - def update(%{assigns: %{list: list}} = conn, %{"title" => title}) do + 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 @@ -65,7 +62,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do end # POST /api/v1/lists/:id/accounts - def add_to_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do + 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) @@ -76,7 +73,10 @@ defmodule Pleroma.Web.MastodonAPI.ListController do end # DELETE /api/v1/lists/:id/accounts - def remove_from_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do + def remove_from_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.unfollow(list, followed) @@ -86,7 +86,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do json(conn, %{}) end - defp list_by_id_and_user(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do + 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() diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex index 58e8a30c2..85310edfa 100644 --- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do use Pleroma.Web, :controller alias Pleroma.Plugs.OAuthScopesPlug + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, %{scopes: ["read:statuses"]} @@ -13,17 +15,21 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do ) plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + 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"]) + markers = Pleroma.Marker.get_markers(user, params[:timeline]) render(conn, "markers.json", %{markers: markers}) end # POST /api/v1/markers - def upsert(%{assigns: %{user: user}} = conn, params) do + 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}) diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index 14075307d..e7767de4e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -3,21 +3,33 @@ # 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_plug, + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] + when action in [:empty_array, :empty_object] + ) + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - # 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 end diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index 2b6f00952..513de279f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -11,19 +11,21 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do alias Pleroma.Web.ActivityPub.ActivityPub action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) - plug(OAuthScopesPlug, %{scopes: ["write:media"]}) + plug(OAuthScopesPlug, %{scopes: ["read:media"]} when action == :show) + plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action != :show) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MediaOperation @doc "POST /api/v1/media" - def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + 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") + description: Map.get(data, :description) ) do attachment_data = Map.put(object.data, "id", object.id) @@ -31,11 +33,30 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do 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}} = conn, %{"id" => id, "description" => description}) - when is_binary(description) do + def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{id: id}) do with %Object{} = object <- Object.get_by_id(id), - true <- Object.authorize_mutation(object, user), + :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) @@ -43,5 +64,17 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do end end - def update(_conn, _data), do: {:error, :bad_request} + 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 index 0c9218454..bcd12c73f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -13,6 +13,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do @oauth_read_actions [:show, :index] + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, %{scopes: ["read:notifications"]} when action in @oauth_read_actions @@ -20,16 +22,16 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation # GET /api/v1/notifications - def index(conn, %{"account_id" => account_id} = params) do + 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) + |> Map.delete(:account_id) + |> Map.put(:account_ap_id, account_ap_id) index(conn, params) @@ -41,15 +43,19 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do end def index(%{assigns: %{user: user}} = conn, params) do + params = Map.new(params, fn {k, v} -> {to_string(k), v} end) notifications = MastodonAPI.get_notifications(user, params) conn |> add_link_headers(notifications) - |> render("index.json", notifications: notifications, for: user) + |> render("index.json", + notifications: notifications, + for: user + ) end # GET /api/v1/notifications/:id - def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + 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 @@ -66,8 +72,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do json(conn, %{}) end - # POST /api/v1/notifications/dismiss - def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + # 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 @@ -78,8 +85,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do 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 + def destroy_multiple(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do Notification.destroy_multiple(user, ids) json(conn, %{}) end diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index d9f894118..db46ffcfc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.PollController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show @@ -22,10 +24,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation @doc "GET /api/v1/polls/:id" - def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + 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 @@ -37,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do end @doc "POST /api/v1/polls/:id/votes" - def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do + 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), diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex index f5782be13..405167108 100644 --- a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -9,12 +9,13 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ReportOperation @doc "POST /api/v1/reports" - def create(%{assigns: %{user: user}} = conn, params) do + 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 diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex index e1e6bd89b..1719c67ea 100644 --- a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -11,19 +11,21 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do alias Pleroma.ScheduledActivity alias Pleroma.Web.MastodonAPI.MastodonAPI - plug(:assign_scheduled_activity when action != :index) - @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(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + 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) @@ -37,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do end @doc "PUT /api/v1/scheduled_statuses/:id" - def update(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, params) do + 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 @@ -50,7 +52,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do end end - defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do + 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() diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index fcab4ef63..77e2224e4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -17,25 +17,33 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do require Logger + 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}) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + # Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped) plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search]) - def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do + 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) + |> 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 + defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do options = search_options(params, user) timeout = Keyword.get(Repo.config(), :timeout, 15_000) default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []} @@ -43,7 +51,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do result = default_values |> Enum.map(fn {resource, default_value} -> - if params["type"] in [nil, resource] do + if params[:type] in [nil, resource] do {resource, fn -> resource_search(version, resource, query, options) end} else {resource, fn -> default_value end} @@ -66,12 +74,13 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do defp search_options(params, user) do [ - resolve: params["resolve"] == "true", - following: params["following"] == "true", - limit: ControllerHelper.fetch_integer_param(params, "limit"), - offset: ControllerHelper.fetch_integer_param(params, "offset"), - type: params["type"], + resolve: params[:resolve], + following: params[:following], + limit: params[: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)) @@ -79,12 +88,23 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do 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], as: :user) + + AccountView.render("index.json", + users: accounts, + for: options[:for_user], + as: :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) + + StatusView.render("index.json", + activities: statuses, + for: options[:for_user], + as: :activity + ) end defp resource_search(:v2, "hashtags", query, _options) do @@ -121,7 +141,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do end end - defp get_author(%{"account_id" => account_id}) when is_binary(account_id), + 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 diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 37afe6949..f20157a5f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -5,7 +5,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [try_render: 3, add_link_headers: 2] + import Pleroma.Web.ControllerHelper, + only: [try_render: 3, add_link_headers: 2] require Ecto.Query @@ -23,6 +24,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.ScheduledActivityView + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :show]) + @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} plug( @@ -76,19 +80,17 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [:index, :show]) - @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"]] + [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"]] + [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: [:id]] when action in ~w(favourite unfavourite)a ) @@ -96,12 +98,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do 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}) do + def index(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do limit = 100 activities = @@ -110,7 +114,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do |> Activity.all_by_ids_with_object() |> Enum.filter(&Visibility.visible_for_user?(&1, user)) - render(conn, "index.json", activities: activities, for: user, as: :activity) + render(conn, "index.json", + activities: activities, + for: user, + as: :activity + ) end @doc """ @@ -119,20 +127,29 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do Creates a scheduled status when `scheduled_at` param is present and it's far enough """ def create( - %{assigns: %{user: user}} = conn, - %{"status" => _, "scheduled_at" => scheduled_at} = params - ) do - params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + %{ + 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]) + + 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)}, - attrs <- %{"params" => params, "scheduled_at" => scheduled_at}, {:ok, scheduled_activity} <- ScheduledActivity.create(user, attrs) do conn |> put_view(ScheduledActivityView) |> render("show.json", scheduled_activity: scheduled_activity) else {:far_enough, _} -> - create(conn, Map.drop(params, ["scheduled_at"])) + params = Map.drop(params, [:scheduled_at]) + create(%Plug.Conn{conn | body_params: params}, %{}) error -> error @@ -144,8 +161,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do Creates a regular status """ - def create(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do - params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + 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]) with {:ok, activity} <- CommonAPI.post(user, params) do try_render(conn, "show.json", @@ -162,12 +179,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end end - def create(%{assigns: %{user: _user}} = conn, %{"media_ids" => _} = params) do - create(conn, Map.put(params, "status", "")) + 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" - def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def show(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.visible_for_user?(activity, user) do try_render(conn, "show.json", @@ -181,7 +199,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "DELETE /api/v1/statuses/:id" - def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def delete(%{assigns: %{user: user}} = conn, %{id: id}) do with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do json(conn, %{}) else @@ -191,53 +209,53 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "POST /api/v1/statuses/:id/reblog" - def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do - with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user, params), + 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" => ap_id_or_id}) do - with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do + 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" => ap_id_or_id}) do - with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + 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" => ap_id_or_id}) do - with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + 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 + 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) end end @doc "POST /api/v1/statuses/:id/unpin" - def unpin(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + 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 + 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), @@ -247,7 +265,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "POST /api/v1/statuses/:id/unbookmark" - def unbookmark(%{assigns: %{user: user}} = conn, %{"id" => id}) do + 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), @@ -257,7 +275,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "POST /api/v1/statuses/:id/mute" - def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def mute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id), {:ok, activity} <- CommonAPI.add_mute(user, activity) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -265,7 +283,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "POST /api/v1/statuses/:id/unmute" - def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + 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) @@ -274,7 +292,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do @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 + 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) @@ -285,7 +303,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "GET /api/v1/statuses/:id/favourited_by" - def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def favourited_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: %{"likes" => likes}} <- Object.normalize(activity) do @@ -305,7 +323,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "GET /api/v1/statuses/:id/reblogged_by" - def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do + 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}} <- @@ -337,7 +355,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "GET /api/v1/statuses/:id/context" - def context(%{assigns: %{user: user}} = conn, %{"id" => id}) do + 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"], %{ @@ -351,16 +369,21 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "GET /api/v1/favourites" - def favourites(%{assigns: %{user: user}} = conn, params) do - activities = - ActivityPub.fetch_favourites( - user, - Map.take(params, Pleroma.Pagination.page_keys()) - ) + def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do + params = + params + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.take(Pleroma.Pagination.page_keys()) + + activities = ActivityPub.fetch_favourites(user, params) conn |> add_link_headers(activities) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) end @doc "GET /api/v1/bookmarks" @@ -378,6 +401,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do conn |> add_link_headers(bookmarks) - |> render("index.json", %{activities: activities, for: user, as: :activity}) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex index 11df6fc4a..34eac97c5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex @@ -6,47 +6,42 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do @moduledoc "The module represents functions to manage user subscriptions." use Pleroma.Web, :controller - alias Pleroma.Web.MastodonAPI.PushSubscriptionView, as: View alias Pleroma.Web.Push alias Pleroma.Web.Push.Subscription action_fallback(:errors) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:restrict_push_enabled) plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]}) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SubscriptionOperation # Creates PushSubscription # POST /api/v1/push/subscription # - def create(%{assigns: %{user: user, token: token}} = conn, params) do - with true <- Push.enabled(), - {:ok, _} <- Subscription.delete_if_exists(user, token), + 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 - view = View.render("push_subscription.json", subscription: subscription) - json(conn, view) + render(conn, "show.json", subscription: subscription) end end # Gets PushSubscription # GET /api/v1/push/subscription # - def get(%{assigns: %{user: user, token: token}} = conn, _params) do - with true <- Push.enabled(), - {:ok, subscription} <- Subscription.get(user, token) do - view = View.render("push_subscription.json", subscription: subscription) - json(conn, view) + 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}} = conn, params) do - with true <- Push.enabled(), - {:ok, subscription} <- Subscription.update(user, token, params) do - view = View.render("push_subscription.json", subscription: subscription) - json(conn, view) + 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 @@ -54,17 +49,26 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do # DELETE /api/v1/push/subscription # def delete(%{assigns: %{user: user, token: token}} = conn, _params) do - with true <- Push.enabled(), - {:ok, _response} <- Subscription.delete(user, token), + 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(dgettext("errors", "Not found")) + |> json(%{error: dgettext("errors", "Record not found")}) end def errors(conn, _) do diff --git a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex index 0cdc7bd8d..f91df9ab7 100644 --- a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -7,8 +7,26 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionController do require Logger - @doc "GET /api/v1/suggestions" - def index(conn, _) do - json(conn, []) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action == :index) + + 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 + + @doc "GET /api/v1/suggestions" + def index(conn, params), + do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params) end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 91f41416d..958567510 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -6,17 +6,20 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1] + only: [add_link_headers: 2, add_link_headers: 3] alias Pleroma.Pagination + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - # TODO: Replace with a macro when there is a Phoenix release with + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:skip_plug, EnsurePublicOrAuthenticatedPlug 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 - # in it plug(RateLimiter, [name: :timeline, bucket_name: :direct_timeline] when action == :direct) plug(RateLimiter, [name: :timeline, bucket_name: :public_timeline] when action == :public) @@ -27,17 +30,25 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :public) + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} + when action in [:public, :hashtag] + ) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) + 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.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", ["Create", "Announce"]) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) + |> Map.put("reply_filtering_user", user) |> Map.put("user", user) recipients = [user.ap_id | User.following(user)] @@ -49,13 +60,18 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do conn |> add_link_headers(activities) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) end # GET /api/v1/timelines/direct def direct(%{assigns: %{user: user}} = conn, params) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Create") |> Map.put("blocking_user", user) |> Map.put("user", user) @@ -68,12 +84,18 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do conn |> add_link_headers(activities) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) end # GET /api/v1/timelines/public def public(%{assigns: %{user: user}} = conn, params) do - local_only = truthy_param?(params["local"]) + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) + + local_only = params["local"] cfg_key = if local_only do @@ -84,24 +106,29 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do restrict? = Pleroma.Config.get([:restrict_unauthenticated, :timelines, cfg_key]) - if not (restrict? and is_nil(user)) do + if restrict? and is_nil(user) do + render_error(conn, :unauthorized, "authorization required for timeline view") + else activities = params |> Map.put("type", ["Create", "Announce"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) + |> Map.put("reply_filtering_user", user) |> ActivityPub.fetch_public_activities() conn |> add_link_headers(activities, %{"local" => local_only}) - |> render("index.json", activities: activities, for: user, as: :activity) - else - render_error(conn, :unauthorized, "authorization required for timeline view") + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) end end - def hashtag_fetching(params, user, local_only) do + defp hashtag_fetching(params, user, local_only) do tags = [params["tag"], params["any"]] |> List.flatten() @@ -134,20 +161,25 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do # GET /api/v1/timelines/tag/:tag def hashtag(%{assigns: %{user: user}} = conn, params) do - local_only = truthy_param?(params["local"]) - + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) + local_only = params["local"] activities = hashtag_fetching(params, user, local_only) conn |> add_link_headers(activities, %{"local" => local_only}) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) end # GET /api/v1/timelines/list/:list_id - def list(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do + 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.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Create") |> Map.put("blocking_user", user) |> Map.put("user", user) @@ -164,7 +196,11 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do |> ActivityPub.fetch_activities_bounded(following, params) |> Enum.reverse() - render(conn, "index.json", activities: activities, for: user, as: :activity) + render(conn, "index.json", + activities: activities, + for: user, + as: :activity + ) else _e -> render_error(conn, :forbidden, "Error.") end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 341dc2c91..04c419d2f 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -5,21 +5,41 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do use Pleroma.Web, :view + alias Pleroma.FollowingRelationship alias Pleroma.User + 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 = Map.put(opts, :relationships, relationships_opt) + users |> render_many(AccountView, "show.json", opts) |> Enum.filter(&Enum.any?/1) end def render("show.json", %{user: user} = opts) do - if User.visible_for?(user, opts[:for]), - do: do_render("show.json", opts), - else: %{} + if User.visible_for?(user, opts[:for]) do + do_render("show.json", opts) + else + %{} + end end def render("mention.json", %{user: user}) do @@ -27,7 +47,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do id: to_string(user.id), acct: user.nickname, username: username_from_nickname(user.nickname), - url: User.profile_url(user) + url: user.uri || user.ap_id } end @@ -35,34 +55,107 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do %{} end - def render("relationship.json", %{user: %User{} = user, target: %User{} = target}) do - follow_state = User.get_cached_follow_state(user, target) + 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) - requested = - if follow_state && !User.following?(user, target) do - follow_state == "pending" + User.get_follow_state(reading_user, target, user_to_target_following_relation) else - false + 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 + + # NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags %{ id: to_string(target.id), - following: User.following?(user, target), - followed_by: User.following?(target, user), - blocking: User.blocks_user?(user, target), - blocked_by: User.blocks_user?(target, user), - muting: User.mutes?(user, target), - muting_notifications: User.muted_notifications?(user, target), - subscribing: User.subscribed_to?(user, target), - requested: requested, - domain_blocking: User.blocks_domain?(user, target), - showing_reblogs: User.showing_reblogs?(user, target), + 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: + UserRelationship.exists?( + user_relationships, + :inverse_subscription, + target, + reading_user, + &User.subscribed_to?(&2, &1) + ), + 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) + ), endorsed: false } end - def render("relationships.json", %{user: user, targets: targets}) do - render_many(targets, AccountView, "relationship.json", user: user, as: :target) + 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 @@ -89,18 +182,27 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do bot = user.actor_type in ["Application", "Service"] emojis = - (user.source_data["tag"] || []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) - |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> + Enum.map(user.emoji, fn {shortcode, raw_url} -> + url = MediaProxy.url(raw_url) + %{ - "shortcode" => String.trim(name, ":"), - "url" => MediaProxy.url(url), - "static_url" => MediaProxy.url(url), - "visible_in_picker" => false + shortcode: shortcode, + url: url, + static_url: url, + visible_in_picker: false } end) - relationship = render("relationship.json", %{user: opts[:for], target: user}) + relationship = + if opts[:embed_relationships] do + render("relationship.json", %{ + user: opts[:for], + target: user, + relationships: opts[:relationships] + }) + else + %{} + end %{ id: to_string(user.id), @@ -113,7 +215,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do following_count: following_count, statuses_count: user.note_count, note: user.bio || "", - url: User.profile_url(user), + url: user.uri || user.ap_id, avatar: image, avatar_static: image, header: header, @@ -122,7 +224,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do fields: user.fields, bot: bot, source: %{ - note: Pleroma.HTML.strip_tags((user.bio || "") |> String.replace("<br>", "\n")), + note: prepare_user_bio(user), sensitive: false, fields: user.raw_fields, pleroma: %{ @@ -154,8 +256,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do |> 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]) + end + + defp prepare_user_bio(%User{bio: ""}), do: "" + + defp prepare_user_bio(%User{bio: bio}) when is_binary(bio) do + bio + |> String.replace(~r(<br */?>), "\n") + |> Pleroma.HTML.strip_tags() + |> HtmlEntities.decode() end + defp prepare_user_bio(_), do: "" + defp username_from_nickname(string) when is_binary(string) do hd(String.split(string, "@")) end @@ -224,7 +338,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do defp maybe_put_role(data, _, _), do: data defp maybe_put_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do - Kernel.put_in(data, [:pleroma, :notification_settings], user.notification_settings) + Kernel.put_in( + data, + [:pleroma, :notification_settings], + Map.from_struct(user.notification_settings) + ) end defp maybe_put_notification_settings(data, _, _), do: data @@ -251,6 +369,16 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do 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 image_url(%{"url" => [%{"href" => href} | _]}), do: href defp image_url(_), do: nil end diff --git a/lib/pleroma/web/mastodon_api/views/app_view.ex b/lib/pleroma/web/mastodon_api/views/app_view.ex index d934e2107..36071cd25 100644 --- a/lib/pleroma/web/mastodon_api/views/app_view.ex +++ b/lib/pleroma/web/mastodon_api/views/app_view.ex @@ -7,6 +7,21 @@ defmodule Pleroma.Web.MastodonAPI.AppView do 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, diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex index 97fd1e83f..aeff646f5 100644 --- a/lib/pleroma/web/mastodon_api/views/filter_view.ex +++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex @@ -7,11 +7,11 @@ defmodule Pleroma.Web.MastodonAPI.FilterView do alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.FilterView - def render("filters.json", %{filters: filters} = opts) do - render_many(filters, FilterView, "filter.json", opts) + def render("index.json", %{filters: filters}) do + render_many(filters, FilterView, "show.json") end - def render("filter.json", %{filter: filter}) do + def render("show.json", %{filter: filter}) do expires_at = if filter.expires_at do Utils.to_masto_date(filter.expires_at) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 67214dbea..6a630eafa 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -5,10 +5,13 @@ 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 = Pleroma.Config.get(:instance) + instance = Config.get(:instance) %{ uri: Pleroma.Web.base_url(), @@ -20,7 +23,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), - thumbnail: Pleroma.Web.base_url() <> "/instance/thumbnail.jpeg", + thumbnail: instance_thumbnail(), languages: ["en"], registrations: Keyword.get(instance, :registrations_open), # Extra (not present in Mastodon): @@ -29,7 +32,64 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do 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) + banner_upload_limit: Keyword.get(instance, :banner_upload_limit), + background_image: Keyword.get(instance, :background_image), + pleroma: %{ + metadata: %{ + features: features(), + federation: federation() + }, + vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } } end + + def features do + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:chat, :enabled]) do + "chat" + 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" + ] + |> Enum.filter(& &1) + end + + def federation do + quarantined = Config.get([:instance, :quarantined_instances], []) + + if Config.get([:instance, :mrf_transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.merge(%{quarantined_instances: quarantined}) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + end + + defp instance_thumbnail do + Pleroma.Config.get([:instance, :instance_thumbnail]) || + "#{Pleroma.Web.base_url()}/instance/thumbnail.jpeg" + end end diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 985368fe5..21d535d54 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -6,12 +6,16 @@ defmodule Pleroma.Web.MastodonAPI.MarkerView do use Pleroma.Web, :view def render("markers.json", %{markers: markers}) do - Enum.reduce(markers, %{}, fn m, acc -> - Map.put_new(acc, m.timeline, %{ - last_read_id: m.last_read_id, - version: m.lock_version, - updated_at: NaiveDateTime.to_iso8601(m.updated_at) - }) + 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/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 33145c484..c46ddcf55 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -8,24 +8,87 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.User + alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - def render("index.json", %{notifications: notifications, for: user}) do - safe_render_many(notifications, NotificationView, "show.json", %{for: user}) + def render("index.json", %{notifications: notifications, for: reading_user} = opts) do + activities = Enum.map(notifications, & &1.activity) + + parent_activities = + activities + |> Enum.filter( + &(Activity.mastodon_notification_type(&1) in [ + "favourite", + "reblog", + "pleroma:emoji_reaction" + ]) + ) + |> Enum.map(& &1.data["object"]) + |> 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(&(Activity.mastodon_notification_type(&1) == "move")) + |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + + 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: user - }) do + 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 = Activity.get_create_by_object_ap_id(activity.data["object"]) + + parent_activity_fn = fn -> + if opts[:parent_activities] do + Activity.Queries.find_by_object_ap_id(opts[:parent_activities], activity.data["object"]) + else + Activity.get_create_by_object_ap_id(activity.data["object"]) + end + end + mastodon_type = Activity.mastodon_notification_type(activity) - with %{id: _} = account <- AccountView.render("show.json", %{user: actor, for: user}) do + # Note: :relationships contain user mutes (needed for :muted flag in :status) + status_render_opts = %{relationships: opts[:relationships]} + + with %{id: _} = account <- + AccountView.render( + "show.json", + %{user: actor, for: reading_user} + ) do response = %{ id: to_string(notification.id), type: mastodon_type, @@ -38,22 +101,24 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do case mastodon_type do "mention" -> - put_status(response, activity, user) + put_status(response, activity, reading_user, status_render_opts) "favourite" -> - put_status(response, parent_activity, user) + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "reblog" -> - put_status(response, parent_activity, user) + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "move" -> - put_target(response, activity, user) + put_target(response, activity, reading_user, %{}) - "follow" -> + "pleroma:emoji_reaction" -> response + |> put_status(parent_activity_fn.(), reading_user, status_render_opts) + |> put_emoji(activity) - "pleroma:emoji_reaction" -> - put_status(response, parent_activity, user) |> put_emoji(activity) + type when type in ["follow", "follow_request"] -> + response _ -> nil @@ -64,16 +129,21 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do end defp put_emoji(response, activity) do - response - |> Map.put(:emoji, activity.data["content"]) + Map.put(response, :emoji, activity.data["content"]) end - defp put_status(response, activity, user) do - Map.put(response, :status, StatusView.render("show.json", %{activity: activity, for: user})) + 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, user) do - target = User.get_cached_by_ap_id(activity.data["target"]) - Map.put(response, :target, AccountView.render("show.json", %{user: target, for: user})) + 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 index 40edbb213..59a5deb28 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -19,6 +19,7 @@ defmodule Pleroma.Web.MastodonAPI.PollView do expired: expired, multiple: multiple, votes_count: votes_count, + voters_count: (multiple || nil) && voters_count(object), options: options, voted: voted?(params), emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]) @@ -62,6 +63,12 @@ defmodule Pleroma.Web.MastodonAPI.PollView do end) end + defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do + length(voters) + end + + defp voters_count(_), do: 0 + defp voted?(%{object: object} = opts) do if opts[:for] do existing_votes = Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index f7469cdff..8e3715093 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do 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 @@ -44,7 +45,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end) end - defp get_user(ap_id) do + def get_user(ap_id, fake_record_fallback \\ true) do cond do user = User.get_cached_by_ap_id(ap_id) -> user @@ -52,8 +53,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do user = User.get_by_guessed_nickname(ap_id) -> user - true -> + fake_record_fallback -> + # TODO: refactor (fake records is never a good idea) User.error_user(ap_id) + + true -> + nil end end @@ -71,10 +76,47 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end def render("index.json", opts) do - replied_to_activities = get_replied_to_activities(opts.activities) - opts = Map.put(opts, :replied_to_activities, replied_to_activities) + reading_user = opts[:for] + + # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list + activities = Enum.filter(opts.activities, & &1) + 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).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] - safe_render_many(opts.activities, StatusView, "show.json", opts) + is_nil(reading_user) -> + UserRelationship.view_relationships_option(nil, []) + + true -> + # Note: unresolved users are filtered out + actors = + (activities ++ parent_activities) + |> Enum.map(&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( @@ -85,17 +127,25 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do created_at = Utils.to_masto_date(activity.data["published"]) activity_object = Object.normalize(activity) - reblogged_activity = - Activity.create_by_object_ap_id(activity_object.data["id"]) - |> Activity.with_preloaded_bookmark(opts[:for]) - |> Activity.with_set_thread_muted_field(opts[:for]) - |> Repo.one() + reblogged_parent_activity = + if opts[:parent_activities] do + Activity.Queries.find_by_object_ap_id( + opts[:parent_activities], + activity_object.data["id"] + ) + else + Activity.create_by_object_ap_id(activity_object.data["id"]) + |> Activity.with_preloaded_bookmark(opts[:for]) + |> Activity.with_set_thread_muted_field(opts[:for]) + |> Repo.one() + end - reblogged = render("show.json", Map.put(opts, :activity, reblogged_activity)) + 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 (activity_object.data["likes"] || []) - bookmarked = Activity.get_bookmark(reblogged_activity, opts[:for]) != nil + bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil mentions = activity.recipients @@ -107,7 +157,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do id: to_string(activity.id), uri: activity_object.data["id"], url: activity_object.data["id"], - account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + account: + AccountView.render("show.json", %{ + user: user, + for: opts[:for] + }), in_reply_to_id: nil, in_reply_to_account_id: nil, reblog: reblogged, @@ -116,7 +170,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do reblogs_count: 0, replies_count: 0, favourites_count: 0, - reblogged: reblogged?(reblogged_activity, opts[:for]), + reblogged: reblogged?(reblogged_parent_activity, opts[:for]), favourited: present?(favorited), bookmarked: present?(bookmarked), muted: false, @@ -183,9 +237,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end thread_muted? = - case activity.thread_muted? do - thread_muted? when is_boolean(thread_muted?) -> thread_muted? - nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false + 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"] || [] @@ -253,11 +308,26 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do _ -> [] end + # 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 + ) + %{ id: to_string(activity.id), uri: object.data["id"], url: url, - account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + 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, @@ -270,7 +340,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do reblogged: reblogged?(activity, opts[:for]), favourited: present?(favorited), bookmarked: present?(bookmarked), - muted: thread_muted? || User.mutes?(opts[:for], user), + muted: muted, pinned: pinned?(activity, user), sensitive: sensitive, spoiler_text: summary, @@ -366,27 +436,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do } end - def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do - object = Object.normalize(activity) - - user = get_user(activity.data["actor"]) - created_at = Utils.to_masto_date(activity.data["published"]) - - %{ - id: activity.id, - account: AccountView.render("show.json", %{user: user, for: opts[:for]}), - created_at: created_at, - title: object.data["title"] |> HTML.strip_tags(), - artist: object.data["artist"] |> HTML.strip_tags(), - album: object.data["album"] |> HTML.strip_tags(), - length: object.data["length"] - } - end - - def render("listens.json", opts) do - safe_render_many(opts.activities, StatusView, "listen.json", opts) - end - def render("context.json", %{activity: activity, activities: activities, user: user}) do %{ancestors: ancestors, descendants: descendants} = activities @@ -421,7 +470,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end def render_content(%{data: %{"type" => object_type}} = object) - when object_type in ["Video", "Event"] do + when object_type in ["Video", "Event", "Audio"] do with name when not is_nil(name) and name != "" <- object.data["name"] do "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}" else @@ -453,11 +502,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do """ @spec build_tags(list(any())) :: list(map()) def build_tags(object_tags) when is_list(object_tags) do - object_tags = for tag when is_binary(tag) <- object_tags, do: tag - - Enum.reduce(object_tags, [], fn tag, tags -> - tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}] - end) + object_tags + |> Enum.filter(&is_binary/1) + |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"}) end def build_tags(_), do: [] diff --git a/lib/pleroma/web/mastodon_api/views/push_subscription_view.ex b/lib/pleroma/web/mastodon_api/views/subscription_view.ex index d32cef6e2..7c67cc924 100644 --- a/lib/pleroma/web/mastodon_api/views/push_subscription_view.ex +++ b/lib/pleroma/web/mastodon_api/views/subscription_view.ex @@ -2,11 +2,11 @@ # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.MastodonAPI.PushSubscriptionView do +defmodule Pleroma.Web.MastodonAPI.SubscriptionView do use Pleroma.Web, :view alias Pleroma.Web.Push - def render("push_subscription.json", %{subscription: subscription}) do + def render("show.json", %{subscription: subscription}) do %{ id: to_string(subscription.id), endpoint: subscription.endpoint, diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 5652a37c1..94e4595d8 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -12,29 +12,19 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do @behaviour :cowboy_websocket - @streams [ - "public", - "public:local", - "public:media", - "public:local:media", - "user", - "user:notification", - "direct", - "list", - "hashtag" - ] - @anonymous_streams ["public", "public:local", "hashtag"] - - # Handled by periodic keepalive in Pleroma.Web.Streamer.Ping. - @timeout :infinity + # 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 <- :cow_qs.parse_qs(qs), + with params <- Enum.into(:cow_qs.parse_qs(qs), %{}), sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil), - access_token <- List.keyfind(params, "access_token", 0), - {_, stream} <- List.keyfind(params, "stream", 0), - {:ok, user} <- allow_request(stream, [access_token, sec_websocket]), - topic when is_binary(topic) <- expand_topic(stream, params) do + access_token <- Map.get(params, "access_token"), + {:ok, user} <- authenticate_request(access_token, sec_websocket), + {:ok, topic} <- Streamer.get_topic(Map.get(params, "stream"), user, params) do req = if sec_websocket do :cowboy_req.set_resp_header("sec-websocket-protocol", sec_websocket, req) @@ -42,43 +32,70 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do req end - {:cowboy_websocket, req, %{user: user, topic: topic}, %{idle_timeout: @timeout}} + {:cowboy_websocket, req, %{user: user, topic: topic, count: 0, timer: nil}, + %{idle_timeout: @timeout}} else - {:error, code} -> - Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}") - {:ok, req} = :cowboy_req.reply(code, req) + {:error, :bad_topic} -> + Logger.debug("#{__MODULE__} bad topic #{inspect(req)}") + {:ok, req} = :cowboy_req.reply(404, req) {:ok, req, state} - error -> - Logger.debug("#{__MODULE__} denied connection: #{inspect(error)} - #{inspect(req)}") - {:ok, req} = :cowboy_req.reply(400, req) + {:error, :unauthorized} -> + Logger.debug("#{__MODULE__} authentication error: #{inspect(req)}") + {:ok, req} = :cowboy_req.reply(401, req) {:ok, req, state} end end def websocket_init(state) do - send(self(), :subscribe) - {:ok, state} - end - - # We never receive messages. - def websocket_handle(_frame, state) do - {:ok, state} - end - - def websocket_info(:subscribe, state) do Logger.debug( "#{__MODULE__} accepted websocket connection for user #{ (state.user || %{id: "anonymous"}).id }, topic #{state.topic}" ) - Streamer.add_socket(state.topic, streamer_socket(state)) + Streamer.add_socket(state.topic, state.user) + {: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 never receive messages. + 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 - {:reply, {:text, message}, state} + # 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 terminate(reason, _req, state) do @@ -88,56 +105,29 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do }, topic #{state.topic || "?"}: #{inspect(reason)}" ) - Streamer.remove_socket(state.topic, streamer_socket(state)) + Streamer.remove_socket(state.topic) :ok end # Public streams without authentication. - defp allow_request(stream, [nil, nil]) when stream in @anonymous_streams do + defp authenticate_request(nil, nil) do {:ok, nil} end # Authenticated streams. - defp allow_request(stream, [access_token, sec_websocket]) when stream in @streams do - token = - with {"access_token", token} <- access_token do - token - else - _ -> sec_websocket - end + defp authenticate_request(access_token, sec_websocket) do + token = access_token || sec_websocket with true <- is_bitstring(token), %Token{user_id: user_id} <- Repo.get_by(Token, token: token), user = %User{} <- User.get_cached_by_id(user_id) do {:ok, user} else - _ -> {:error, 403} + _ -> {:error, :unauthorized} end end - # Not authenticated. - defp allow_request(stream, _) when stream in @streams, do: {:error, 403} - - # No matching stream. - defp allow_request(_, _), do: {:error, 404} - - defp expand_topic("hashtag", params) do - case List.keyfind(params, "tag", 0) do - {_, tag} -> "hashtag:#{tag}" - _ -> nil - end - end - - defp expand_topic("list", params) do - case List.keyfind(params, "list", 0) do - {_, list} -> "list:#{list}" - _ -> nil - end - end - - defp expand_topic(topic, _), do: topic - - defp streamer_socket(state) do - %{transport_pid: self(), assigns: state} + defp timer do + Process.send_after(self(), :tick, @tick) end end diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex new file mode 100644 index 000000000..c037ff13e --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy.Invalidation do + @moduledoc false + + @callback purge(list(String.t()), map()) :: {:ok, String.t()} | {:error, String.t()} + + alias Pleroma.Config + + @spec purge(list(String.t())) :: {:ok, String.t()} | {:error, String.t()} + def purge(urls) do + [:media_proxy, :invalidation, :enabled] + |> Config.get() + |> do_purge(urls) + end + + defp do_purge(true, urls) do + provider = Config.get([:media_proxy, :invalidation, :provider]) + options = Config.get(provider) + provider.purge(urls, options) + end + + defp do_purge(_, _), do: :ok +end diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex new file mode 100644 index 000000000..07248df6e --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy.Invalidation.Http do + @moduledoc false + @behaviour Pleroma.Web.MediaProxy.Invalidation + + require Logger + + @impl Pleroma.Web.MediaProxy.Invalidation + def purge(urls, opts) do + method = Map.get(opts, :method, :purge) + headers = Map.get(opts, :headers, []) + options = Map.get(opts, :options, []) + + Logger.debug("Running cache purge: #{inspect(urls)}") + + Enum.each(urls, fn url -> + with {:error, error} <- do_purge(method, url, headers, options) do + Logger.error("Error while cache purge: url - #{url}, error: #{inspect(error)}") + end + end) + + {:ok, "success"} + end + + defp do_purge(method, url, headers, options) do + case Pleroma.HTTP.request(method, url, "", headers, options) do + {:ok, %{status: status} = env} when 400 <= status and status < 500 -> + {:error, env} + + {:error, error} = error -> + error + + _ -> + {:ok, "success"} + end + end +end diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex new file mode 100644 index 000000000..6be782132 --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy.Invalidation.Script do + @moduledoc false + + @behaviour Pleroma.Web.MediaProxy.Invalidation + + require Logger + + @impl Pleroma.Web.MediaProxy.Invalidation + def purge(urls, %{script_path: script_path} = _options) do + args = + urls + |> List.wrap() + |> Enum.uniq() + |> Enum.join(" ") + + path = Path.expand(script_path) + + Logger.debug("Running cache purge: #{inspect(urls)}, #{path}") + + case do_purge(path, [args]) do + {result, exit_status} when exit_status > 0 -> + Logger.error("Error while cache purge: #{inspect(result)}") + {:error, inspect(result)} + + _ -> + {:ok, "success"} + end + end + + def purge(_, _), do: {:error, "not found script path"} + + defp do_purge(path, args) do + System.cmd(path, args) + rescue + error -> {inspect(error), 1} + end +end diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 1a09ac62a..4657a4383 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do use Pleroma.Web, :controller + alias Pleroma.ReverseProxy alias Pleroma.Web.MediaProxy diff --git a/lib/pleroma/web/metadata.ex b/lib/pleroma/web/metadata.ex index c9aac27dc..a9f70c43e 100644 --- a/lib/pleroma/web/metadata.ex +++ b/lib/pleroma/web/metadata.ex @@ -6,7 +6,12 @@ defmodule Pleroma.Web.Metadata do alias Phoenix.HTML def build_tags(params) do - Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), "", fn parser, acc -> + providers = [ + Pleroma.Web.Metadata.Providers.RestrictIndexing + | Pleroma.Config.get([__MODULE__, :providers], []) + ] + + Enum.reduce(providers, "", fn parser, acc -> rendered_html = params |> parser.build_tags() diff --git a/lib/pleroma/web/metadata/opengraph.ex b/lib/pleroma/web/metadata/opengraph.ex index 21446ac77..68c871e71 100644 --- a/lib/pleroma/web/metadata/opengraph.ex +++ b/lib/pleroma/web/metadata/opengraph.ex @@ -68,7 +68,7 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraph do property: "og:title", content: Utils.user_name_string(user) ], []}, - {:meta, [property: "og:url", content: User.profile_url(user)], []}, + {:meta, [property: "og:url", content: user.uri || user.ap_id], []}, {:meta, [property: "og:description", content: truncated_bio], []}, {:meta, [property: "og:type", content: "website"], []}, {:meta, [property: "og:image", content: Utils.attachment_url(User.avatar_url(user))], []}, diff --git a/lib/pleroma/web/metadata/restrict_indexing.ex b/lib/pleroma/web/metadata/restrict_indexing.ex new file mode 100644 index 000000000..f15607896 --- /dev/null +++ b/lib/pleroma/web/metadata/restrict_indexing.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.RestrictIndexing do + @behaviour Pleroma.Web.Metadata.Providers.Provider + + @moduledoc """ + Restricts indexing of remote users. + """ + + @impl true + def build_tags(%{user: %{local: false}}) do + [ + {:meta, + [ + name: "robots", + content: "noindex, noarchive" + ], []} + ] + end + + @impl true + def build_tags(%{user: %{local: true}}), do: [] +end diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index 04d823b36..6cbbe8fd8 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do use Pleroma.Web, :controller - alias Comeonin.Pbkdf2 + alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.User @@ -14,7 +14,7 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do plug(RateLimiter, [name: :authentication, params: ["user"]] when action == :check_password) def user_exists(conn, %{"user" => username}) do - with %User{} <- Repo.get_by(User, nickname: username, local: true) do + with %User{} <- Repo.get_by(User, nickname: username, local: true, deactivated: false) do conn |> json(true) else @@ -26,9 +26,9 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do end def check_password(conn, %{"user" => username, "pass" => password}) do - with %User{password_hash: password_hash} <- + with %User{password_hash: password_hash, deactivated: false} <- Repo.get_by(User, nickname: username, local: true), - true <- Pbkdf2.checkpw(password, password_hash) do + true <- AuthenticationPlug.checkpw(password, password_hash) do conn |> json(true) else diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 30838b1eb..721b599d4 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.Federator.Publisher + alias Pleroma.Web.MastodonAPI.InstanceView def schemas(conn, _params) do response = %{ @@ -34,50 +34,12 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do def raw_nodeinfo do stats = Stats.get_stats() - quarantined = Config.get([:instance, :quarantined_instances], []) - staff_accounts = User.all_superusers() |> Enum.map(fn u -> u.ap_id end) - federation_response = - if Config.get([:instance, :mrf_transparency]) do - {:ok, data} = MRF.describe() - - data - |> Map.merge(%{quarantined_instances: quarantined}) - else - %{} - end - |> Map.put(:enabled, Config.get([:instance, :federating])) - - features = - [ - "pleroma_api", - "mastodon_api", - "mastodon_api_streaming", - "polls", - "pleroma_explicit_addressing", - "shareable_emoji_packs", - "multifetch", - "pleroma:api/v1/notifications:include_types_filter", - if Config.get([:media_proxy, :enabled]) do - "media_proxy" - end, - if Config.get([:gopher, :enabled]) do - "gopher" - end, - if Config.get([:chat, :enabled]) do - "chat" - end, - if Config.get([:instance, :allow_relay]) do - "relay" - end, - if Config.get([:instance, :safe_dm_mentions]) do - "safe_dm_mentions" - end - ] - |> Enum.filter(& &1) + features = InstanceView.features() + federation = InstanceView.federation() %{ version: "2.0", @@ -105,7 +67,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do enabled: false }, staffAccounts: staff_accounts, - federation: federation_response, + federation: federation, pollLimits: Config.get([:instance, :poll_limits]), postFormats: Config.get([:instance, :allowed_post_formats]), uploadLimits: %{ diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index 01ed326f4..6a6d5f2e2 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.App do use Ecto.Schema import Ecto.Changeset + import Ecto.Query alias Pleroma.Repo @type t :: %__MODULE__{} @@ -16,14 +17,24 @@ defmodule Pleroma.Web.OAuth.App do field(:website, :string) field(:client_id, :string) field(:client_secret, :string) + field(:trusted, :boolean, default: false) + + has_many(:oauth_authorizations, Pleroma.Web.OAuth.Authorization, on_delete: :delete_all) + has_many(:oauth_tokens, Pleroma.Web.OAuth.Token, on_delete: :delete_all) timestamps() end + @spec changeset(App.t(), map()) :: Ecto.Changeset.t() + def changeset(struct, params) do + cast(struct, params, [:client_name, :redirect_uris, :scopes, :website, :trusted]) + end + + @spec register_changeset(App.t(), map()) :: Ecto.Changeset.t() def register_changeset(struct, params \\ %{}) do changeset = struct - |> cast(params, [:client_name, :redirect_uris, :scopes, :website]) + |> changeset(params) |> validate_required([:client_name, :redirect_uris, :scopes]) if changeset.valid? do @@ -41,6 +52,21 @@ defmodule Pleroma.Web.OAuth.App do end end + @spec create(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def create(params) do + with changeset <- __MODULE__.register_changeset(%__MODULE__{}, params) do + Repo.insert(changeset) + end + end + + @spec update(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def update(params) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, params["id"]), + changeset <- changeset(app, params) do + Repo.update(changeset) + end + end + @doc """ Gets app by attrs or create new with attrs. And updates the scopes if need. @@ -65,4 +91,58 @@ defmodule Pleroma.Web.OAuth.App do |> change(%{scopes: scopes}) |> Repo.update() end + + @spec search(map()) :: {:ok, [App.t()], non_neg_integer()} + def search(params) do + query = from(a in __MODULE__) + + query = + if params[:client_name] do + from(a in query, where: a.client_name == ^params[:client_name]) + else + query + end + + query = + if params[:client_id] do + from(a in query, where: a.client_id == ^params[:client_id]) + else + query + end + + query = + if Map.has_key?(params, :trusted) do + from(a in query, where: a.trusted == ^params[:trusted]) + else + query + end + + query = + from(u in query, + limit: ^params[:page_size], + offset: ^((params[:page] - 1) * params[:page_size]) + ) + + count = Repo.aggregate(__MODULE__, :count, :id) + + {:ok, Repo.all(query), count} + end + + @spec destroy(pos_integer()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def destroy(id) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do + Repo.delete(app) + end + end + + @spec errors(Ecto.Changeset.t()) :: map() + def errors(changeset) do + Enum.reduce(changeset.errors, %{}, fn + {:client_name, {error, _}}, acc -> + Map.put(acc, :name, error) + + {key, {error, _}}, acc -> + Map.put(acc, key, error) + end) + end end diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex new file mode 100644 index 000000000..53e19f82e --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_controller.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.MFAController do + @moduledoc """ + The model represents api to use Multi Factor authentications. + """ + + use Pleroma.Web, :controller + + alias Pleroma.MFA + alias Pleroma.Web.Auth.TOTPAuthenticator + alias Pleroma.Web.OAuth.MFAView, as: View + alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.OAuth.Token + + plug(:fetch_session when action in [:show, :verify]) + plug(:fetch_flash when action in [:show, :verify]) + + @doc """ + Display form to input mfa code or recovery code. + """ + def show(conn, %{"mfa_token" => mfa_token} = params) do + template = Map.get(params, "challenge_type", "totp") + + conn + |> put_view(View) + |> render("#{template}.html", %{ + mfa_token: mfa_token, + redirect_uri: params["redirect_uri"], + state: params["state"] + }) + end + + @doc """ + Verification code and continue authorization. + """ + def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do + with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), + {:ok, _} <- validates_challenge(user, mfa_params) do + conn + |> OAuthController.after_create_authorization(auth, %{ + "authorization" => %{ + "redirect_uri" => mfa_params["redirect_uri"], + "state" => mfa_params["state"] + } + }) + else + _ -> + conn + |> put_flash(:error, "Two-factor authentication failed.") + |> put_status(:unauthorized) + |> show(mfa_params) + end + end + + @doc """ + Verification second step of MFA (or recovery) and returns access token. + + ## Endpoint + POST /oauth/mfa/challenge + + params: + `client_id` + `client_secret` + `mfa_token` - access token to check second step of mfa + `challenge_type` - 'totp' or 'recovery' + `code` + + """ + def challenge(conn, %{"mfa_token" => mfa_token} = params) do + with {:ok, app} <- Token.Utils.fetch_app(conn), + {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), + {:ok, _} <- validates_challenge(user, params), + {:ok, token} <- Token.exchange_token(app, auth) do + json(conn, Token.Response.build(user, token)) + else + _error -> + conn + |> put_status(400) + |> json(%{error: "Invalid code"}) + end + end + + # Verify TOTP Code + defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do + TOTPAuthenticator.verify(code, user) + end + + # Verify Recovery Code + defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do + TOTPAuthenticator.verify_recovery_code(user, code) + end + + defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type} +end diff --git a/lib/pleroma/web/oauth/mfa_view.ex b/lib/pleroma/web/oauth/mfa_view.ex new file mode 100644 index 000000000..41d5578dc --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_view.ex @@ -0,0 +1,8 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.MFAView do + use Pleroma.Web, :view + import Phoenix.HTML.Form +end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 46688db7e..7c804233c 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller alias Pleroma.Helpers.UriHelper + alias Pleroma.MFA alias Pleroma.Plugs.RateLimiter alias Pleroma.Registration alias Pleroma.Repo @@ -14,6 +15,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.ControllerHelper alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.MFAController alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken @@ -25,6 +27,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do plug(:fetch_session) plug(:fetch_flash) + + plug(:skip_plug, [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]) + plug(RateLimiter, [name: :authentication] when action == :create_authorization) action_fallback(Pleroma.Web.OAuth.FallbackController) @@ -118,7 +123,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do %{"authorization" => _} = params, opts \\ [] ) do - with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do + with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]), + {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do after_create_authorization(conn, auth, params) else error -> @@ -178,6 +184,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do defp handle_create_authorization_error( %Plug.Conn{} = conn, + {:mfa_required, user, auth, _}, + params + ) do + {:ok, token} = MFA.Token.create_token(user, auth) + + data = %{ + "mfa_token" => token.token, + "redirect_uri" => params["authorization"]["redirect_uri"], + "state" => params["authorization"]["state"] + } + + MFAController.show(conn, data) + end + + defp handle_create_authorization_error( + %Plug.Conn{} = conn, {:account_status, :password_reset_pending}, %{"authorization" => _} = params ) do @@ -228,7 +250,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do json(conn, Token.Response.build(user, token, response_attrs)) else - _error -> render_invalid_credentials_error(conn) + error -> + handle_token_exchange_error(conn, error) end end @@ -241,6 +264,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do {:account_status, :active} <- {:account_status, User.account_status(user)}, {:ok, scopes} <- validate_scopes(app, params), {:ok, auth} <- Authorization.create_authorization(app, user, scopes), + {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)}, {:ok, token} <- Token.exchange_token(app, auth) do json(conn, Token.Response.build(user, token)) else @@ -267,13 +291,20 @@ defmodule Pleroma.Web.OAuth.OAuthController do {:ok, token} <- Token.exchange_token(app, auth) do json(conn, Token.Response.build_for_client_credentials(token)) else - _error -> render_invalid_credentials_error(conn) + _error -> + handle_token_exchange_error(conn, :invalid_credentails) end end # Bad request def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params) + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do + conn + |> put_status(:forbidden) + |> json(build_and_response_mfa_token(user, auth)) + end + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do render_error( conn, @@ -431,7 +462,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn), %Registration{} = registration <- Repo.get(Registration, registration_id), - {_, {:ok, auth}} <- {:create_authorization, do_create_authorization(conn, params)}, + {_, {:ok, auth, _user}} <- + {:create_authorization, do_create_authorization(conn, params)}, %User{} = user <- Repo.preload(auth, :user).user, {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do conn @@ -497,8 +529,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do %App{} = app <- Repo.get_by(App, client_id: client_id), true <- redirect_uri in String.split(app.redirect_uris), {:ok, scopes} <- validate_scopes(app, auth_attrs), - {:account_status, :active} <- {:account_status, User.account_status(user)} do - Authorization.create_authorization(app, user, scopes) + {:account_status, :active} <- {:account_status, User.account_status(user)}, + {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do + {:ok, auth, user} end end @@ -512,6 +545,12 @@ defmodule Pleroma.Web.OAuth.OAuthController do defp put_session_registration_id(%Plug.Conn{} = conn, registration_id), do: put_session(conn, :registration_id, registration_id) + defp build_and_response_mfa_token(user, auth) do + with {:ok, token} <- MFA.Token.create_token(user, auth) do + Token.Response.build_for_mfa_token(user, token) + end + end + @spec validate_scopes(App.t(), map()) :: {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} defp validate_scopes(%App{} = app, params) do diff --git a/lib/pleroma/web/oauth/scopes.ex b/lib/pleroma/web/oauth/scopes.ex index 8ecf901f3..6f06f1431 100644 --- a/lib/pleroma/web/oauth/scopes.ex +++ b/lib/pleroma/web/oauth/scopes.ex @@ -15,9 +15,10 @@ defmodule Pleroma.Web.OAuth.Scopes do Note: `scopes` is used by Mastodon — supporting it but sticking to OAuth's standard `scope` wherever we control it """ - @spec fetch_scopes(map(), list()) :: list() + @spec fetch_scopes(map() | struct(), list()) :: list() + def fetch_scopes(params, default) do - parse_scopes(params["scope"] || params["scopes"], default) + parse_scopes(params["scope"] || params["scopes"] || params[:scopes], default) end def parse_scopes(scopes, _default) when is_list(scopes) do diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex new file mode 100644 index 000000000..e3aa4eb7e --- /dev/null +++ b/lib/pleroma/web/oauth/token/clean_worker.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Token.CleanWorker do + @moduledoc """ + The module represents functions to clean an expired OAuth and MFA tokens. + """ + use GenServer + + @ten_seconds 10_000 + @one_day 86_400_000 + + alias Pleroma.MFA + alias Pleroma.Web.OAuth + alias Pleroma.Workers.BackgroundWorker + + def start_link(_), do: GenServer.start_link(__MODULE__, %{}) + + def init(_) do + Process.send_after(self(), :perform, @ten_seconds) + {:ok, nil} + end + + @doc false + def handle_info(:perform, state) do + BackgroundWorker.enqueue("clean_expired_tokens", %{}) + interval = Pleroma.Config.get([:oauth2, :clean_expired_tokens_interval], @one_day) + + Process.send_after(self(), :perform, interval) + {:noreply, state} + end + + def perform(:clean) do + OAuth.Token.delete_expired_tokens() + MFA.Token.delete_expired_tokens() + end +end diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex index 6f4713dee..0e72c31e9 100644 --- a/lib/pleroma/web/oauth/token/response.ex +++ b/lib/pleroma/web/oauth/token/response.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.Token.Response do @moduledoc false + alias Pleroma.MFA alias Pleroma.User alias Pleroma.Web.OAuth.Token.Utils @@ -32,5 +33,13 @@ defmodule Pleroma.Web.OAuth.Token.Response do } end + def build_for_mfa_token(user, mfa_token) do + %{ + error: "mfa_required", + mfa_token: mfa_token.token, + supported_challenge_types: MFA.supported_methods(user) + } + end + defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) end diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 6fd3cfce5..de1b0b3f0 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Router plug(Pleroma.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 ) plug( @@ -32,13 +32,13 @@ defmodule Pleroma.Web.OStatus.OStatusController do action_fallback(:errors) - def object(%{assigns: %{format: format}} = conn, %{"uuid" => _uuid}) + def object(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do ActivityPubController.call(conn, :object) end - def object(%{assigns: %{format: format}} = conn, %{"uuid" => uuid}) do - with id <- o_status_url(conn, :object, uuid), + def object(%{assigns: %{format: format}} = conn, _params) do + with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.get_create_by_object_ap_id_with_object(id)}, {_, true} <- {:public?, Visibility.is_public?(activity)} do @@ -54,13 +54,13 @@ defmodule Pleroma.Web.OStatus.OStatusController do end end - def activity(%{assigns: %{format: format}} = conn, %{"uuid" => _uuid}) + def activity(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do ActivityPubController.call(conn, :activity) end - def activity(%{assigns: %{format: format}} = conn, %{"uuid" => uuid}) do - with id <- o_status_url(conn, :activity, uuid), + def activity(%{assigns: %{format: format}} = conn, _params) do + with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.normalize(id)}, {_, true} <- {:public?, Visibility.is_public?(activity)} do case format do diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index dcba67d03..0a3f45620 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -9,16 +9,28 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2] alias Ecto.Changeset + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView require Pleroma.Constants plug( + OpenApiSpex.Plug.PutApiSpec, + [module: Pleroma.Web.ApiSpec] when action == :confirmation_resend + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirmation_resend + ) + + plug( OAuthScopesPlug, %{scopes: ["follow", "write:follows"]} when action in [:subscribe, :unsubscribe] ) @@ -34,21 +46,21 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do ] ) - plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) - - # An extra safety measure for possible actions not guarded by OAuth permissions specification plug( - Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug - when action != :confirmation_resend + OAuthScopesPlug, + %{scopes: ["read:favourites"], fallback: :proceed_unauthenticated} when action == :favourites ) plug(RateLimiter, [name: :account_confirmation_resend] when action == :confirmation_resend) + plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe]) plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaAccountOperation + @doc "POST /api/v1/pleroma/accounts/confirmation_resend" def confirmation_resend(conn, params) do - nickname_or_email = params["email"] || params["nickname"] + nickname_or_email = params[:email] || params[:nickname] with %User{} = user <- User.get_by_nickname_or_email(nickname_or_email), {:ok, _} <- User.try_send_confirmation_email(user) do @@ -57,39 +69,33 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do end @doc "PATCH /api/v1/pleroma/accounts/update_avatar" - def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do - {:ok, user} = + def update_avatar(%{assigns: %{user: user}, body_params: %{img: ""}} = conn, _) do + {:ok, _user} = user |> Changeset.change(%{avatar: nil}) |> User.update_and_set_cache() - CommonAPI.update(user) - json(conn, %{url: nil}) end - def update_avatar(%{assigns: %{user: user}} = conn, params) do + def update_avatar(%{assigns: %{user: user}, body_params: params} = conn, _params) do {:ok, %{data: data}} = ActivityPub.upload(params, type: :avatar) - {:ok, user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() + {:ok, _user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() %{"url" => [%{"href" => href} | _]} = data - CommonAPI.update(user) - json(conn, %{url: href}) end @doc "PATCH /api/v1/pleroma/accounts/update_banner" - def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do - with {:ok, user} <- User.update_banner(user, %{}) do - CommonAPI.update(user) + def update_banner(%{assigns: %{user: user}, body_params: %{banner: ""}} = conn, _) do + with {:ok, _user} <- User.update_banner(user, %{}) do json(conn, %{url: nil}) end end - def update_banner(%{assigns: %{user: user}} = conn, params) do - with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner), - {:ok, user} <- User.update_banner(user, object.data) do - CommonAPI.update(user) + def update_banner(%{assigns: %{user: user}, body_params: params} = conn, _) do + with {:ok, object} <- ActivityPub.upload(%{img: params[:banner]}, type: :banner), + {:ok, _user} <- User.update_banner(user, object.data) do %{"url" => [%{"href" => href} | _]} = object.data json(conn, %{url: href}) @@ -97,13 +103,13 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do end @doc "PATCH /api/v1/pleroma/accounts/update_background" - def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do + def update_background(%{assigns: %{user: user}, body_params: %{img: ""}} = conn, _) do with {:ok, _user} <- User.update_background(user, %{}) do json(conn, %{url: nil}) end end - def update_background(%{assigns: %{user: user}} = conn, params) do + def update_background(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, object} <- ActivityPub.upload(params, type: :background), {:ok, _user} <- User.update_background(user, object.data) do %{"url" => [%{"href" => href} | _]} = object.data @@ -120,6 +126,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Create") |> Map.put("favorited_by", user.ap_id) |> Map.put("blocking_user", for_user) @@ -139,7 +146,11 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do conn |> add_link_headers(activities) |> put_view(StatusView) - |> render("index.json", activities: activities, for: for_user, as: :activity) + |> render("index.json", + activities: activities, + for: for_user, + as: :activity + ) end @doc "POST /api/v1/pleroma/accounts/:id/subscribe" diff --git a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex new file mode 100644 index 000000000..21d5eb8d5 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex @@ -0,0 +1,95 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ConversationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Conversation.Participation + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.StatusView + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:put_view, Pleroma.Web.MastodonAPI.ConversationView) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:show, :statuses]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:conversations"]} when action in [:update, :mark_as_read] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaConversationOperation + + def show(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: participation_id}) do + with %Participation{user_id: ^user_id} = participation <- Participation.get(participation_id) do + render(conn, "participation.json", participation: participation, for: user) + else + _error -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Unknown conversation id"}) + end + end + + def statuses( + %{assigns: %{user: %{id: user_id} = user}} = conn, + %{id: participation_id} = params + ) do + with %Participation{user_id: ^user_id} = participation <- + Participation.get(participation_id, preload: [:conversation]) do + params = + params + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + + activities = + participation.conversation.ap_id + |> ActivityPub.fetch_activities_for_context_query(params) + |> Pleroma.Pagination.fetch_paginated(Map.put(params, "total", false)) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> put_view(StatusView) + |> render("index.json", activities: activities, for: user, as: :activity) + else + _error -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Unknown conversation id"}) + end + end + + def update( + %{assigns: %{user: %{id: user_id} = user}} = conn, + %{id: participation_id, recipients: recipients} + ) do + with %Participation{user_id: ^user_id} = participation <- Participation.get(participation_id), + {:ok, participation} <- Participation.set_recipients(participation, recipients) do + render(conn, "participation.json", participation: participation, for: user) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + + _error -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Unknown conversation id"}) + end + end + + def mark_as_read(%{assigns: %{user: user}} = conn, _params) do + with {:ok, _, participations} <- Participation.mark_all_as_read(user) do + conn + |> add_link_headers(participations) + |> render("participations.json", participations: participations, for: user) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex deleted file mode 100644 index 03e95e020..000000000 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ /dev/null @@ -1,643 +0,0 @@ -defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do - use Pleroma.Web, :controller - - alias Pleroma.Plugs.OAuthScopesPlug - - require Logger - - plug( - OAuthScopesPlug, - %{scopes: ["write"], admin: true} - when action in [ - :create, - :delete, - :download_from, - :list_from, - :import_from_fs, - :update_file, - :update_metadata - ] - ) - - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - - def emoji_dir_path do - Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) - end - - @doc """ - Lists packs from the remote instance. - - Since JS cannot ask remote instances for their packs due to CPS, it has to - be done by the server - """ - def list_from(conn, %{"instance_address" => address}) do - address = String.trim(address) - - if shareable_packs_available(address) do - list_resp = - "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!() - - json(conn, list_resp) - else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) - end - end - - @doc """ - Lists the packs available on the instance as JSON. - - The information is public and does not require authentication. The format is - a map of "pack directory name" to pack.json contents. - """ - def list_packs(conn, _params) do - # Create the directory first if it does not exist. This is probably the first request made - # with the API so it should be sufficient - with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())}, - {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do - pack_infos = - results - |> Enum.filter(&has_pack_json?/1) - |> Enum.map(&load_pack/1) - # Check if all the files are in place and can be sent - |> Enum.map(&validate_pack/1) - # Transform into a map of pack-name => pack-data - |> Enum.into(%{}) - - json(conn, pack_infos) - else - {:create_dir, {:error, e}} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"}) - - {:ls, {:error, e}} -> - conn - |> put_status(:internal_server_error) - |> json(%{ - error: - "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}" - }) - end - end - - defp has_pack_json?(file) do - dir_path = Path.join(emoji_dir_path(), file) - # Filter to only use the pack.json packs - File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json")) - end - - defp load_pack(pack_name) do - pack_path = Path.join(emoji_dir_path(), pack_name) - pack_file = Path.join(pack_path, "pack.json") - - {pack_name, Jason.decode!(File.read!(pack_file))} - end - - defp validate_pack({name, pack}) do - pack_path = Path.join(emoji_dir_path(), name) - - if can_download?(pack, pack_path) do - archive_for_sha = make_archive(name, pack, pack_path) - archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16() - - pack = - pack - |> put_in(["pack", "can-download"], true) - |> put_in(["pack", "download-sha256"], archive_sha) - - {name, pack} - else - {name, put_in(pack, ["pack", "can-download"], false)} - end - end - - defp can_download?(pack, pack_path) do - # If the pack is set as shared, check if it can be downloaded - # That means that when asked, the pack can be packed and sent to the remote - # Otherwise, they'd have to download it from external-src - pack["pack"]["share-files"] && - Enum.all?(pack["files"], fn {_, path} -> - File.exists?(Path.join(pack_path, path)) - end) - end - - defp create_archive_and_cache(name, pack, pack_dir, md5) do - files = - ['pack.json'] ++ - (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end)) - - {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)]) - - cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) - cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files)) - - Cachex.put!( - :emoji_packs_cache, - name, - # if pack.json MD5 changes, the cache is not valid anymore - %{pack_json_md5: md5, pack_data: zip_result}, - # Add a minute to cache time for every file in the pack - ttl: cache_ms - ) - - Logger.debug("Created an archive for the '#{name}' emoji pack, \ -keeping it in cache for #{div(cache_ms, 1000)}s") - - zip_result - end - - defp make_archive(name, pack, pack_dir) do - # Having a different pack.json md5 invalidates cache - pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json"))) - - case Cachex.get!(:emoji_packs_cache, name) do - %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} -> - Logger.debug("Using cache for the '#{name}' shared emoji pack") - zip_result - - _ -> - create_archive_and_cache(name, pack, pack_dir, pack_file_md5) - end - end - - @doc """ - An endpoint for other instances (via admin UI) or users (via browser) - to download packs that the instance shares. - """ - def download_shared(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) - pack_file = Path.join(pack_dir, "pack.json") - - with {_, true} <- {:exists?, File.exists?(pack_file)}, - pack = Jason.decode!(File.read!(pack_file)), - {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do - zip_result = make_archive(name, pack, pack_dir) - send_download(conn, {:binary, zip_result}, filename: "#{name}.zip") - else - {:can_download?, _} -> - conn - |> put_status(:forbidden) - |> json(%{ - error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\ - was disabled for this pack or some files are missing" - }) - - {:exists?, _} -> - conn - |> put_status(:not_found) - |> json(%{error: "Pack #{name} does not exist"}) - end - end - - defp shareable_packs_available(address) do - "#{address}/.well-known/nodeinfo" - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> Map.get("links") - |> List.last() - |> Map.get("href") - # Get the actual nodeinfo address and fetch it - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> get_in(["metadata", "features"]) - |> Enum.member?("shareable_emoji_packs") - end - - @doc """ - An admin endpoint to request downloading a pack named `pack_name` from the instance - `instance_address`. - - If the requested instance's admin chose to share the pack, it will be downloaded - from that instance, otherwise it will be downloaded from the fallback source, if there is one. - """ - def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do - address = String.trim(address) - - if shareable_packs_available(address) do - full_pack = - "#{address}/api/pleroma/emoji/packs/list" - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> Map.get(name) - - pack_info_res = - case full_pack["pack"] do - %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> - {:ok, - %{ - sha: sha, - uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}" - }} - - %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> - {:ok, - %{ - sha: sha, - uri: src, - fallback: true - }} - - _ -> - {:error, - "The pack was not set as shared and there is no fallback src to download from"} - end - - with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res, - %{body: emoji_archive} <- Tesla.get!(uri), - {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do - local_name = data["as"] || name - pack_dir = Path.join(emoji_dir_path(), local_name) - File.mkdir_p!(pack_dir) - - files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end) - # Fallback cannot contain a pack.json file - files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files - - {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files) - - # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 - # in it to depend on itself - if pinfo[:fallback] do - pack_file_path = Path.join(pack_dir, "pack.json") - - File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true)) - end - - json(conn, "ok") - else - {:error, e} -> - conn |> put_status(:internal_server_error) |> json(%{error: e}) - - {:checksum, _} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) - end - else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) - end - end - - @doc """ - Creates an empty pack named `name` which then can be updated via the admin UI. - """ - def create(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) - - if not File.exists?(pack_dir) do - File.mkdir_p!(pack_dir) - - pack_file_p = Path.join(pack_dir, "pack.json") - - File.write!( - pack_file_p, - Jason.encode!(%{pack: %{}, files: %{}}, pretty: true) - ) - - conn |> json("ok") - else - conn - |> put_status(:conflict) - |> json(%{error: "A pack named \"#{name}\" already exists"}) - end - end - - @doc """ - Deletes the pack `name` and all it's files. - """ - def delete(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) - - case File.rm_rf(pack_dir) do - {:ok, _} -> - conn |> json("ok") - - {:error, _, _} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Couldn't delete the pack #{name}"}) - end - end - - @doc """ - An endpoint to update `pack_names`'s metadata. - - `new_data` is the new metadata for the pack, that will replace the old metadata. - """ - def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do - pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"]) - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - # The new fallback-src is in the new data and it's not the same as it was in the old data - should_update_fb_sha = - not is_nil(new_data["fallback-src"]) and - new_data["fallback-src"] != full_pack["pack"]["fallback-src"] - - with {_, true} <- {:should_update?, should_update_fb_sha}, - %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]), - {:ok, flist} <- :zip.unzip(pack_arch, [:memory]), - {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do - fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16() - - new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha) - update_metadata_and_send(conn, full_pack, new_data, pack_file_p) - else - {:should_update?, _} -> - update_metadata_and_send(conn, full_pack, new_data, pack_file_p) - - {:has_all_files?, _} -> - conn - |> put_status(:bad_request) - |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) - end - end - - # Check if all files from the pack.json are in the archive - defp has_all_files?(%{"files" => files}, flist) do - Enum.all?(files, fn {_, from_manifest} -> - Enum.find(flist, fn {from_archive, _} -> - to_string(from_archive) == from_manifest - end) - end) - end - - defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do - full_pack = Map.put(full_pack, "pack", new_data) - File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true)) - - # Send new data back with fallback sha filled - json(conn, new_data) - end - - defp get_filename(%{"filename" => filename}), do: filename - - defp get_filename(%{"file" => file}) do - case file do - %Plug.Upload{filename: filename} -> filename - url when is_binary(url) -> Path.basename(url) - end - end - - defp empty?(str), do: String.trim(str) == "" - - defp update_file_and_send(conn, updated_full_pack, pack_file_p) do - # Write the emoji pack file - File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true)) - - # Return the modified file list - json(conn, updated_full_pack["files"]) - end - - @doc """ - Updates a file in a pack. - - Updating can mean three things: - - - `add` adds an emoji named `shortcode` to the pack `pack_name`, - that means that the emoji file needs to be uploaded with the request - (thus requiring it to be a multipart request) and be named `file`. - There can also be an optional `filename` that will be the new emoji file name - (if it's not there, the name will be taken from the uploaded file). - - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file - (from the current filename to `new_filename`) - - `remove` removes the emoji named `shortcode` and it's associated file - """ - - # Add - def update_file( - conn, - %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params - ) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, - filename <- get_filename(params), - false <- empty?(shortcode), - false <- empty?(filename) do - file_path = Path.join(pack_dir, filename) - - # If the name contains directories, create them - if String.contains?(file_path, "/") do - File.mkdir_p!(Path.dirname(file_path)) - end - - case params["file"] do - %Plug.Upload{path: upload_path} -> - # Copy the uploaded file from the temporary directory - File.copy!(upload_path, file_path) - - url when is_binary(url) -> - # Download and write the file - file_contents = Tesla.get!(url).body - File.write!(file_path, file_contents) - end - - updated_full_pack = put_in(full_pack, ["files", shortcode], filename) - update_file_and_send(conn, updated_full_pack, pack_file_p) - else - {:has_shortcode, _} -> - conn - |> put_status(:conflict) - |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) - - true -> - conn - |> put_status(:bad_request) - |> json(%{error: "shortcode or filename cannot be empty"}) - end - end - - # Remove - def update_file(conn, %{ - "pack_name" => pack_name, - "action" => "remove", - "shortcode" => shortcode - }) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - if Map.has_key?(full_pack["files"], shortcode) do - {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) - - emoji_file_path = Path.join(pack_dir, emoji_file_path) - - # Delete the emoji file - File.rm!(emoji_file_path) - - # If the old directory has no more files, remove it - if String.contains?(emoji_file_path, "/") do - dir = Path.dirname(emoji_file_path) - - if Enum.empty?(File.ls!(dir)) do - File.rmdir!(dir) - end - end - - update_file_and_send(conn, updated_full_pack, pack_file_p) - else - conn - |> put_status(:bad_request) - |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - end - end - - # Update - def update_file( - conn, - %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params - ) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, - %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params, - false <- empty?(new_shortcode), - false <- empty?(new_filename) do - # First, remove the old shortcode, saving the old path - {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) - old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path) - new_emoji_file_path = Path.join(pack_dir, new_filename) - - # If the name contains directories, create them - if String.contains?(new_emoji_file_path, "/") do - File.mkdir_p!(Path.dirname(new_emoji_file_path)) - end - - # Move/Rename the old filename to a new filename - # These are probably on the same filesystem, so just rename should work - :ok = File.rename(old_emoji_file_path, new_emoji_file_path) - - # If the old directory has no more files, remove it - if String.contains?(old_emoji_file_path, "/") do - dir = Path.dirname(old_emoji_file_path) - - if Enum.empty?(File.ls!(dir)) do - File.rmdir!(dir) - end - end - - # Then, put in the new shortcode with the new path - updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename) - update_file_and_send(conn, updated_full_pack, pack_file_p) - else - {:has_shortcode, _} -> - conn - |> put_status(:bad_request) - |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - - true -> - conn - |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_filename cannot be empty"}) - - _ -> - conn - |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_file were not specified"}) - end - end - - def update_file(conn, %{"action" => action}) do - conn - |> put_status(:bad_request) - |> json(%{error: "Unknown action: #{action}"}) - end - - @doc """ - Imports emoji from the filesystem. - - Importing means checking all the directories in the - `$instance_static/emoji/` for directories which do not have - `pack.json`. If one has an emoji.txt file, that file will be used - to create a `pack.json` file with it's contents. If the directory has - neither, all the files with specific configured extenstions will be - assumed to be emojis and stored in the new `pack.json` file. - """ - def import_from_fs(conn, _params) do - emoji_path = emoji_dir_path() - - with {:ok, %{access: :read_write}} <- File.stat(emoji_path), - {:ok, results} <- File.ls(emoji_path) do - imported_pack_names = - results - |> Enum.filter(fn file -> - dir_path = Path.join(emoji_path, file) - # Find the directories that do NOT have pack.json - File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json")) - end) - |> Enum.map(&write_pack_json_contents/1) - - json(conn, imported_pack_names) - else - {:ok, %{access: _}} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Error: emoji pack directory must be writable"}) - - {:error, _} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Error accessing emoji pack directory"}) - end - end - - defp write_pack_json_contents(dir) do - dir_path = Path.join(emoji_dir_path(), dir) - emoji_txt_path = Path.join(dir_path, "emoji.txt") - - files_for_pack = files_for_pack(emoji_txt_path, dir_path) - pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack}) - - File.write!(Path.join(dir_path, "pack.json"), pack_json_contents) - - dir - end - - defp files_for_pack(emoji_txt_path, dir_path) do - if File.exists?(emoji_txt_path) do - # There's an emoji.txt file, it's likely from a pack installed by the pack manager. - # Make a pack.json file from the contents of that emoji.txt fileh - - # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 - - # Create a map of shortcodes to filenames from emoji.txt - File.read!(emoji_txt_path) - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.map(fn line -> - case String.split(line, ~r/,\s*/) do - # This matches both strings with and without tags - # and we don't care about tags here - [name, file | _] -> {name, file} - _ -> nil - end - end) - |> Enum.filter(fn x -> not is_nil(x) end) - |> Enum.into(%{}) - else - # If there's no emoji.txt, assume all files - # that are of certain extensions from the config are emojis and import them all - pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) - Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions) - end - end -end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex new file mode 100644 index 000000000..d1efdeb5d --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -0,0 +1,304 @@ +defmodule Pleroma.Web.PleromaAPI.EmojiPackController do + use Pleroma.Web, :controller + + alias Pleroma.Emoji.Pack + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + Pleroma.Plugs.OAuthScopesPlug, + %{scopes: ["write"], admin: true} + when action in [ + :import_from_filesystem, + :remote, + :download, + :create, + :update, + :delete, + :add_file, + :update_file, + :delete_file + ] + ) + + @skip_plugs [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] + plug(:skip_plug, @skip_plugs when action in [:archive, :show, :list]) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEmojiPackOperation + + def remote(conn, %{url: url}) do + with {:ok, packs} <- Pack.list_remote(url) do + json(conn, packs) + else + {:error, :not_shareable} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + end + end + + def index(conn, _params) do + emoji_path = + [:instance, :static_dir] + |> Pleroma.Config.get!() + |> Path.join("emoji") + + with {:ok, packs} <- Pack.list_local() do + json(conn, packs) + else + {:error, :create_dir, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Failed to create the emoji pack directory at #{emoji_path}: #{e}"}) + + {:error, :ls, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{ + error: "Failed to get the contents of the emoji pack directory at #{emoji_path}: #{e}" + }) + end + end + + def show(conn, %{name: name}) do + name = String.trim(name) + + with {:ok, pack} <- Pack.show(name) do + json(conn, pack) + else + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) + end + end + + def archive(conn, %{name: name}) do + with {:ok, archive} <- Pack.get_archive(name) do + send_download(conn, {:binary, archive}, filename: "#{name}.zip") + else + {:error, :cant_download} -> + conn + |> put_status(:forbidden) + |> json(%{ + error: + "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + end + end + + def download(%{body_params: %{url: url, name: name} = params} = conn, _) do + with {:ok, _pack} <- Pack.download(name, url, params[:as]) do + json(conn, "ok") + else + {:error, :not_shareable} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + + {:error, :invalid_checksum} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) + + {:error, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: e}) + end + end + + def create(conn, %{name: name}) do + name = String.trim(name) + + with {:ok, _pack} <- Pack.create(name) do + json(conn, "ok") + else + {:error, :eexist} -> + conn + |> put_status(:conflict) + |> json(%{error: "A pack named \"#{name}\" already exists"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while creating pack." + ) + end + end + + def delete(conn, %{name: name}) do + name = String.trim(name) + + with {:ok, deleted} when deleted != [] <- Pack.delete(name) do + json(conn, "ok") + else + {:ok, []} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) + + {:error, _, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Couldn't delete the pack #{name}"}) + end + end + + def update(%{body_params: %{metadata: metadata}} = conn, %{name: name}) do + with {:ok, pack} <- Pack.update_metadata(name, metadata) do + json(conn, pack.pack) + else + {:error, :incomplete} -> + conn + |> put_status(:bad_request) + |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating pack metadata." + ) + end + end + + def add_file(%{body_params: params} = conn, %{name: name}) do + filename = params[:filename] || get_filename(params[:file]) + shortcode = params[:shortcode] || Path.basename(filename, Path.extname(filename)) + + with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params[:file]) do + json(conn, pack.files) + else + {:error, :already_exists} -> + conn + |> put_status(:conflict) + |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) + + {:error, :not_found} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name, shortcode or filename cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while adding file to pack." + ) + end + end + + def update_file(%{body_params: %{shortcode: shortcode} = params} = conn, %{name: name}) do + new_shortcode = params[:new_shortcode] + new_filename = params[:new_filename] + force = params[:force] + + with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do + json(conn, pack.files) + else + {:error, :doesnt_exist} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + + {:error, :already_exists} -> + conn + |> put_status(:conflict) + |> json(%{ + error: + "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" + }) + + {:error, :not_found} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating file in pack." + ) + end + end + + def delete_file(conn, %{name: name, shortcode: shortcode}) do + with {:ok, pack} <- Pack.delete_file(name, shortcode) do + json(conn, pack.files) + else + {:error, :doesnt_exist} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + + {:error, :not_found} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name or shortcode cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while removing file from pack." + ) + end + end + + def import_from_filesystem(conn, _params) do + with {:ok, names} <- Pack.import_from_filesystem() do + json(conn, names) + else + {:error, :no_read_write} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Error: emoji pack directory must be writable"}) + + {:error, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Error accessing emoji pack directory"}) + end + end + + defp get_filename(%Plug.Upload{filename: filename}), do: filename + defp get_filename(url) when is_binary(url), do: Path.basename(url) +end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex new file mode 100644 index 000000000..19dcffdf3 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action in [:create, :delete]) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} + when action == :index + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.EmojiReactionOperation + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do + with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), + %Object{data: %{"reactions" => reactions}} when is_list(reactions) <- + Object.normalize(activity) do + reactions = filter(reactions, params) + render(conn, "index.json", emoji_reactions: reactions, user: user) + else + _e -> json(conn, []) + end + end + + defp filter(reactions, %{emoji: emoji}) when is_binary(emoji) do + Enum.filter(reactions, fn [e, _] -> e == emoji end) + end + + defp filter(reactions, _), do: reactions + + def create(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do + with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji) do + activity = Activity.get_by_id(activity_id) + + conn + |> put_view(StatusView) + |> render("show.json", activity: activity, for: user, as: :activity) + end + end + + def delete(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do + with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji) do + activity = Activity.get_by_id(activity_id) + + conn + |> put_view(StatusView) + |> render("show.json", activity: activity, for: user, as: :activity) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex index d9c1c8636..df6c50ca5 100644 --- a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex @@ -9,10 +9,11 @@ defmodule Pleroma.Web.PleromaAPI.MascotController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :show) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action != :show) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaMascotOperation @doc "GET /api/v1/pleroma/mascot" def show(%{assigns: %{user: user}} = conn, _params) do @@ -20,7 +21,7 @@ defmodule Pleroma.Web.PleromaAPI.MascotController do end @doc "PUT /api/v1/pleroma/mascot" - def update(%{assigns: %{user: user}} = conn, %{"file" => file}) do + def update(%{assigns: %{user: user}, body_params: %{file: file}} = conn, _) do with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)), # Reject if not an image %{type: "image"} = attachment <- render_attachment(object) do diff --git a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex new file mode 100644 index 000000000..3ed8bd294 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.NotificationController do + use Pleroma.Web, :controller + + alias Pleroma.Notification + alias Pleroma.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :mark_as_read) + plug(:put_view, Pleroma.Web.MastodonAPI.NotificationView) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaNotificationOperation + + def mark_as_read(%{assigns: %{user: user}, body_params: %{id: notification_id}} = conn, _) do + with {:ok, notification} <- Notification.read_one(user, notification_id) do + render(conn, "show.json", notification: notification, for: user) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + end + end + + def mark_as_read(%{assigns: %{user: user}, body_params: %{max_id: max_id}} = conn, _) do + notifications = + user + |> Notification.set_read_up_to(max_id) + |> Enum.take(80) + + render(conn, "index.json", notifications: notifications, for: user) + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex deleted file mode 100644 index dae7f0f2f..000000000 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ /dev/null @@ -1,196 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do - use Pleroma.Web, :controller - - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - - alias Pleroma.Activity - alias Pleroma.Conversation.Participation - alias Pleroma.Notification - alias Pleroma.Object - alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.MastodonAPI.ConversationView - alias Pleroma.Web.MastodonAPI.NotificationView - alias Pleroma.Web.MastodonAPI.StatusView - - plug( - OAuthScopesPlug, - %{scopes: ["read:statuses"]} - when action in [:conversation, :conversation_statuses] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:statuses"]} - when action in [:react_with_emoji, :unreact_with_emoji] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:conversations"]} when action == :update_conversation - ) - - plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification) - - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - - def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} = params) do - with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), - %Object{data: %{"reactions" => emoji_reactions}} when is_list(emoji_reactions) <- - Object.normalize(activity) do - reactions = - emoji_reactions - |> Enum.map(fn [emoji, user_ap_ids] -> - if params["emoji"] && params["emoji"] != emoji do - nil - else - users = - Enum.map(user_ap_ids, &User.get_cached_by_ap_id/1) - |> Enum.filter(& &1) - - %{ - name: emoji, - count: length(users), - accounts: AccountView.render("index.json", %{users: users, for: user, as: :user}), - me: !!(user && user.ap_id in user_ap_ids) - } - end - end) - |> Enum.filter(& &1) - - conn - |> json(reactions) - else - _e -> - conn - |> json([]) - end - end - - def react_with_emoji(%{assigns: %{user: user}} = conn, %{"id" => activity_id, "emoji" => emoji}) do - with {:ok, _activity, _object} <- CommonAPI.react_with_emoji(activity_id, user, emoji), - activity <- Activity.get_by_id(activity_id) do - conn - |> put_view(StatusView) - |> render("show.json", %{activity: activity, for: user, as: :activity}) - end - end - - def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{ - "id" => activity_id, - "emoji" => emoji - }) do - with {:ok, _activity, _object} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji), - activity <- Activity.get_by_id(activity_id) do - conn - |> put_view(StatusView) - |> render("show.json", %{activity: activity, for: user, as: :activity}) - end - end - - def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do - with %Participation{} = participation <- Participation.get(participation_id), - true <- user.id == participation.user_id do - conn - |> put_view(ConversationView) - |> render("participation.json", %{participation: participation, for: user}) - else - _error -> - conn - |> put_status(404) - |> json(%{"error" => "Unknown conversation id"}) - end - end - - def conversation_statuses( - %{assigns: %{user: user}} = conn, - %{"id" => participation_id} = params - ) do - with %Participation{} = participation <- - Participation.get(participation_id, preload: [:conversation]), - true <- user.id == participation.user_id do - params = - params - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - - activities = - participation.conversation.ap_id - |> ActivityPub.fetch_activities_for_context(params) - |> Enum.reverse() - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: user, as: :activity}) - else - _error -> - conn - |> put_status(404) - |> json(%{"error" => "Unknown conversation id"}) - end - end - - def update_conversation( - %{assigns: %{user: user}} = conn, - %{"id" => participation_id, "recipients" => recipients} - ) do - with %Participation{} = participation <- Participation.get(participation_id), - true <- user.id == participation.user_id, - {:ok, participation} <- Participation.set_recipients(participation, recipients) do - conn - |> put_view(ConversationView) - |> render("participation.json", %{participation: participation, for: user}) - else - {:error, message} -> - conn - |> put_status(:bad_request) - |> json(%{"error" => message}) - - _error -> - conn - |> put_status(404) - |> json(%{"error" => "Unknown conversation id"}) - end - end - - def read_conversations(%{assigns: %{user: user}} = conn, _params) do - with {:ok, _, participations} <- Participation.mark_all_as_read(user) do - conn - |> add_link_headers(participations) - |> put_view(ConversationView) - |> render("participations.json", participations: participations, for: user) - end - end - - def read_notification(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do - with {:ok, notification} <- Notification.read_one(user, notification_id) do - conn - |> put_view(NotificationView) - |> render("show.json", %{notification: notification, for: user}) - else - {:error, message} -> - conn - |> put_status(:bad_request) - |> json(%{"error" => message}) - end - end - - def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) do - with notifications <- Notification.set_read_up_to(user, max_id) do - notifications = Enum.take(notifications, 80) - - conn - |> put_view(NotificationView) - |> render("index.json", %{notifications: notifications, for: user}) - end - end -end diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 4463ec477..8665ca56c 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -5,32 +5,27 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.StatusView - plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :user_scrobbles) - plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) + plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :index + ) - def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do - params = - if !params["length"] do - params - else - params - |> Map.put("length", fetch_integer_param(params, "length")) - end + plug(OAuthScopesPlug, %{scopes: ["write"]} when action == :create) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaScrobbleOperation + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, activity} <- CommonAPI.listen(user, params) do - conn - |> put_view(StatusView) - |> render("listen.json", %{activity: activity, for: user}) + render(conn, "show.json", activity: activity, for: user) else {:error, message} -> conn @@ -39,16 +34,18 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do end end - def user_scrobbles(%{assigns: %{user: reading_user}} = conn, params) do - with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do - params = Map.put(params, "type", ["Listen"]) + def index(%{assigns: %{user: reading_user}} = conn, %{id: id} = params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(id, for: reading_user) do + params = + params + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put("type", ["Listen"]) activities = ActivityPub.fetch_user_abstract_activities(user, reading_user, params) conn |> add_link_headers(activities) - |> put_view(StatusView) - |> render("listens.json", %{ + |> render("index.json", %{ activities: activities, for: reading_user, as: :activity diff --git a/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex new file mode 100644 index 000000000..b86791d09 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex @@ -0,0 +1,133 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController do + @moduledoc "The module represents actions to manage MFA" + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.MFA + alias Pleroma.MFA.TOTP + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.CommonAPI.Utils + + plug(OAuthScopesPlug, %{scopes: ["read:security"]} when action in [:settings]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:security"]} when action in [:setup, :confirm, :disable, :backup_codes] + ) + + @doc """ + Gets user multi factor authentication settings + + ## Endpoint + GET /api/pleroma/accounts/mfa + + """ + def settings(%{assigns: %{user: user}} = conn, _params) do + json(conn, %{settings: MFA.mfa_settings(user)}) + end + + @doc """ + Prepare setup mfa method + + ## Endpoint + GET /api/pleroma/accounts/mfa/setup/[:method] + + """ + def setup(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = _params) do + with {:ok, user} <- MFA.setup_totp(user), + %{secret: secret} = _ <- user.multi_factor_authentication_settings.totp do + provisioning_uri = TOTP.provisioning_uri(secret, "#{user.email}") + + json(conn, %{provisioning_uri: provisioning_uri, key: secret}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def setup(conn, _params) do + json_response(conn, :bad_request, %{error: "undefined method"}) + end + + @doc """ + Confirms setup and enable mfa method + + ## Endpoint + POST /api/pleroma/accounts/mfa/confirm/:method + + - params: + `code` - confirmation code + `password` - current password + """ + def confirm( + %{assigns: %{user: user}} = conn, + %{"method" => "totp", "password" => _, "code" => _} = params + ) do + with {:ok, _user} <- Utils.confirm_current_password(user, params["password"]), + {:ok, _user} <- MFA.confirm_totp(user, params) do + json(conn, %{}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def confirm(conn, _) do + json_response(conn, :bad_request, %{error: "undefined mfa method"}) + end + + @doc """ + Disable mfa method and disable mfa if need. + """ + def disable(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = params) do + with {:ok, user} <- Utils.confirm_current_password(user, params["password"]), + {:ok, _user} <- MFA.disable_totp(user) do + json(conn, %{}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def disable(%{assigns: %{user: user}} = conn, %{"method" => "mfa"} = params) do + with {:ok, user} <- Utils.confirm_current_password(user, params["password"]), + {:ok, _user} <- MFA.disable(user) do + json(conn, %{}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def disable(conn, _) do + json_response(conn, :bad_request, %{error: "undefined mfa method"}) + end + + @doc """ + Generates backup codes. + + ## Endpoint + GET /api/pleroma/accounts/mfa/backup_codes + + ## Response + ### Success + `{codes: [codes]}` + + ### Error + `{error: [error_message]}` + + """ + def backup_codes(%{assigns: %{user: user}} = conn, _params) do + with {:ok, codes} <- MFA.generate_backup_codes(user) do + json(conn, %{codes: codes}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex new file mode 100644 index 000000000..84d2d303d --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.EmojiReactionView do + use Pleroma.Web, :view + + alias Pleroma.Web.MastodonAPI.AccountView + + def render("index.json", %{emoji_reactions: emoji_reactions} = opts) do + render_many(emoji_reactions, __MODULE__, "show.json", opts) + end + + def render("show.json", %{emoji_reaction: [emoji, user_ap_ids], user: user}) do + users = fetch_users(user_ap_ids) + + %{ + name: emoji, + count: length(users), + accounts: render(AccountView, "index.json", users: users, for: user, as: :user), + me: !!(user && user.ap_id in user_ap_ids) + } + end + + defp fetch_users(user_ap_ids) do + user_ap_ids + |> Enum.map(&Pleroma.User.get_cached_by_ap_id/1) + |> Enum.filter(fn + %{deactivated: false} -> true + _ -> false + end) + end +end diff --git a/lib/pleroma/web/pleroma_api/views/scrobble_view.ex b/lib/pleroma/web/pleroma_api/views/scrobble_view.ex new file mode 100644 index 000000000..bbff93abe --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/scrobble_view.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ScrobbleView do + use Pleroma.Web, :view + + require Pleroma.Constants + + alias Pleroma.Activity + alias Pleroma.HTML + alias Pleroma.Object + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("show.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do + object = Object.normalize(activity) + + user = StatusView.get_user(activity.data["actor"]) + created_at = Utils.to_masto_date(activity.data["published"]) + + %{ + id: activity.id, + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + created_at: created_at, + title: object.data["title"] |> HTML.strip_tags(), + artist: object.data["artist"] |> HTML.strip_tags(), + album: object.data["album"] |> HTML.strip_tags(), + length: object.data["length"] + } + end + + def render("index.json", opts) do + safe_render_many(opts.activities, __MODULE__, "show.json", opts) + end +end diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index afa510f08..691725702 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Web.Push.Impl do require Logger import Ecto.Query + defdelegate mastodon_notification_type(activity), to: Activity + @types ["Create", "Follow", "Announce", "Like", "Move"] @doc "Performs sending notifications for user subscriptions" @@ -24,40 +26,41 @@ defmodule Pleroma.Web.Push.Impl do %{ activity: %{data: %{"type" => activity_type}} = activity, user: %User{id: user_id} - } = notif + } = notification ) when activity_type in @types do - actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) + actor = User.get_cached_by_ap_id(notification.activity.data["actor"]) - type = Activity.mastodon_notification_type(notif.activity) + mastodon_type = mastodon_notification_type(notification.activity) gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) avatar_url = User.avatar_url(actor) object = Object.normalize(activity) user = User.get_cached_by_id(user_id) direct_conversation_id = Activity.direct_conversation_id(activity, user) - for subscription <- fetch_subsriptions(user_id), - get_in(subscription.data, ["alerts", type]) do + for subscription <- fetch_subscriptions(user_id), + Subscription.enabled?(subscription, mastodon_type) do %{ access_token: subscription.token.token, - notification_id: notif.id, - notification_type: type, + notification_id: notification.id, + notification_type: mastodon_type, icon: avatar_url, preferred_locale: "en", pleroma: %{ - activity_id: notif.activity.id, + activity_id: notification.activity.id, direct_conversation_id: direct_conversation_id } } - |> Map.merge(build_content(notif, actor, object)) + |> Map.merge(build_content(notification, actor, object, mastodon_type)) |> Jason.encode!() |> push_message(build_sub(subscription), gcm_api_key, subscription) end + |> (&{:ok, &1}).() end def perform(_) do Logger.warn("Unknown notification type") - :error + {:error, :unknown_type} end @doc "Push message to web" @@ -82,7 +85,7 @@ defmodule Pleroma.Web.Push.Impl do end @doc "Gets user subscriptions" - def fetch_subsriptions(user_id) do + def fetch_subscriptions(user_id) do Subscription |> where(user_id: ^user_id) |> preload(:token) @@ -99,28 +102,35 @@ defmodule Pleroma.Web.Push.Impl do } end + def build_content(notification, actor, object, mastodon_type \\ nil) + def build_content( %{ - activity: %{data: %{"directMessage" => true}}, user: %{notification_settings: %{privacy_option: true}} - }, - actor, - _ + } = notification, + _actor, + _object, + mastodon_type ) do - %{title: "New Direct Message", body: "@#{actor.nickname}"} + %{body: format_title(notification, mastodon_type)} end - def build_content(notif, actor, object) do + def build_content(notification, actor, object, mastodon_type) do + mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + %{ - title: format_title(notif), - body: format_body(notif, actor, object) + title: format_title(notification, mastodon_type), + body: format_body(notification, actor, object, mastodon_type) } end + def format_body(activity, actor, object, mastodon_type \\ nil) + def format_body( %{activity: %{data: %{"type" => "Create"}}}, actor, - %{data: %{"content" => content}} + %{data: %{"content" => content}}, + _mastodon_type ) do "@#{actor.nickname}: #{Utils.scrub_html_and_truncate(content, 80)}" end @@ -128,33 +138,44 @@ defmodule Pleroma.Web.Push.Impl do def format_body( %{activity: %{data: %{"type" => "Announce"}}}, actor, - %{data: %{"content" => content}} + %{data: %{"content" => content}}, + _mastodon_type ) do "@#{actor.nickname} repeated: #{Utils.scrub_html_and_truncate(content, 80)}" end def format_body( - %{activity: %{data: %{"type" => type}}}, + %{activity: %{data: %{"type" => type}}} = notification, actor, - _object + _object, + mastodon_type ) when type in ["Follow", "Like"] do - case type do - "Follow" -> "@#{actor.nickname} has followed you" - "Like" -> "@#{actor.nickname} has favorited your post" + mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + + case mastodon_type do + "follow" -> "@#{actor.nickname} has followed you" + "follow_request" -> "@#{actor.nickname} has requested to follow you" + "favourite" -> "@#{actor.nickname} has favorited your post" end end - def format_title(%{activity: %{data: %{"directMessage" => true}}}) do + def format_title(activity, mastodon_type \\ nil) + + def format_title(%{activity: %{data: %{"directMessage" => true}}}, _mastodon_type) do "New Direct Message" end - def format_title(%{activity: %{data: %{"type" => type}}}) do - case type do - "Create" -> "New Mention" - "Follow" -> "New Follower" - "Announce" -> "New Repeat" - "Like" -> "New Favorite" + def format_title(%{activity: activity}, mastodon_type) do + mastodon_type = mastodon_type || mastodon_notification_type(activity) + + case mastodon_type do + "mention" -> "New Mention" + "follow" -> "New Follower" + "follow_request" -> "New Follow Request" + "reblog" -> "New Repeat" + "favourite" -> "New Favorite" + type -> "New #{String.capitalize(type || "event")}" end end end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 5c448d6c9..3e401a490 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,20 +25,28 @@ defmodule Pleroma.Web.Push.Subscription do timestamps() end - @supported_alert_types ~w[follow favourite mention reblog] + @supported_alert_types ~w[follow favourite mention reblog]a - defp alerts(%{"data" => %{"alerts" => alerts}}) do + defp alerts(%{data: %{alerts: alerts}}) do alerts = Map.take(alerts, @supported_alert_types) %{"alerts" => alerts} end + def enabled?(subscription, "follow_request") do + enabled?(subscription, "follow") + end + + def enabled?(subscription, alert_type) do + get_in(subscription.data, ["alerts", alert_type]) + end + def create( %User{} = user, %Token{} = token, %{ - "subscription" => %{ - "endpoint" => endpoint, - "keys" => %{"auth" => key_auth, "p256dh" => key_p256dh} + subscription: %{ + endpoint: endpoint, + keys: %{auth: key_auth, p256dh: key_p256dh} } } = params ) do diff --git a/lib/pleroma/web/rel_me.ex b/lib/pleroma/web/rel_me.ex index e97c398dc..8e2b51508 100644 --- a/lib/pleroma/web/rel_me.ex +++ b/lib/pleroma/web/rel_me.ex @@ -3,11 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RelMe do - @hackney_options [ + @options [ pool: :media, - recv_timeout: 2_000, - max_body: 2_000_000, - with_body: true + max_body: 2_000_000 ] if Pleroma.Config.get(:env) == :test do @@ -25,8 +23,18 @@ defmodule Pleroma.Web.RelMe do def parse(_), do: {:error, "No URL provided"} defp parse_url(url) do + opts = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.merge(@options, + recv_timeout: 2_000, + with_body: true + ) + else + @options + end + with {:ok, %Tesla.Env{body: html, status: status}} when status in 200..299 <- - Pleroma.HTTP.get(url, [], adapter: @hackney_options), + Pleroma.HTTP.get(url, [], adapter: opts), {:ok, html_tree} <- Floki.parse_document(html), data <- Floki.attribute(html_tree, "link[rel~=me]", "href") ++ diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 0314535d2..9d3d7f978 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -64,5 +64,8 @@ defmodule Pleroma.Web.RichMedia.Helpers do def fetch_data_for_activity(_), do: %{} - def perform(:fetch, %Activity{} = activity), do: fetch_data_for_activity(activity) + def perform(:fetch, %Activity{} = activity) do + fetch_data_for_activity(activity) + :ok + end end diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index 0779065ee..40980def8 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -3,11 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parser do - @hackney_options [ + @options [ pool: :media, - recv_timeout: 2_000, - max_body: 2_000_000, - with_body: true + max_body: 2_000_000 ] defp parsers do @@ -77,8 +75,18 @@ defmodule Pleroma.Web.RichMedia.Parser do end defp parse_url(url) do + opts = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.merge(@options, + recv_timeout: 2_000, + with_body: true + ) + else + @options + end + try do - {:ok, %Tesla.Env{body: html}} = Pleroma.HTTP.get(url, [], adapter: @hackney_options) + {:ok, %Tesla.Env{body: html}} = Pleroma.HTTP.get(url, [], adapter: opts) html |> parse_html() diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index eef0a8023..a2626521e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -16,75 +16,70 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.UserEnabledPlug) end - pipeline :api do - plug(:accepts, ["json"]) - plug(:fetch_session) + pipeline :expect_authentication do + plug(Pleroma.Plugs.ExpectAuthenticatedCheckPlug) + end + + pipeline :expect_public_instance_or_authentication do + plug(Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug) + end + + pipeline :authenticate do plug(Pleroma.Plugs.OAuthPlug) plug(Pleroma.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Plugs.UserFetcherPlug) plug(Pleroma.Plugs.SessionAuthenticationPlug) plug(Pleroma.Plugs.LegacyAuthenticationPlug) plug(Pleroma.Plugs.AuthenticationPlug) + end + + pipeline :after_auth do plug(Pleroma.Plugs.UserEnabledPlug) plug(Pleroma.Plugs.SetUserSessionIdPlug) plug(Pleroma.Plugs.EnsureUserKeyPlug) - plug(Pleroma.Plugs.IdempotencyPlug) end - pipeline :authenticated_api do + pipeline :base_api do plug(:accepts, ["json"]) plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) + plug(:authenticate) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) + end + + pipeline :api do + plug(:expect_public_instance_or_authentication) + plug(:base_api) + plug(:after_auth) + plug(Pleroma.Plugs.IdempotencyPlug) + end + + pipeline :authenticated_api do + plug(:expect_authentication) + plug(:base_api) + plug(:after_auth) plug(Pleroma.Plugs.EnsureAuthenticatedPlug) plug(Pleroma.Plugs.IdempotencyPlug) end pipeline :admin_api do - plug(:accepts, ["json"]) - plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) + plug(:expect_authentication) + plug(:base_api) plug(Pleroma.Plugs.AdminSecretAuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) + plug(:after_auth) plug(Pleroma.Plugs.EnsureAuthenticatedPlug) plug(Pleroma.Plugs.UserIsAdminPlug) plug(Pleroma.Plugs.IdempotencyPlug) end pipeline :mastodon_html do - plug(:accepts, ["html"]) - plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) - plug(Pleroma.Plugs.EnsureUserKeyPlug) + plug(:browser) + plug(:authenticate) + plug(:after_auth) end pipeline :pleroma_html do - plug(:accepts, ["html"]) - plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) + plug(:browser) + plug(:authenticate) plug(Pleroma.Plugs.EnsureUserKeyPlug) end @@ -94,10 +89,12 @@ defmodule Pleroma.Web.Router do pipeline :config do plug(:accepts, ["json", "xml"]) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :pleroma_api do plug(:accepts, ["html", "json"]) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :mailbox_preview do @@ -135,6 +132,7 @@ defmodule Pleroma.Web.Router do post("/users/follow", AdminAPIController, :user_follow) post("/users/unfollow", AdminAPIController, :user_unfollow) + put("/users/disable_mfa", AdminAPIController, :disable_mfa) delete("/users", AdminAPIController, :user_delete) post("/users", AdminAPIController, :users_create) patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation) @@ -173,6 +171,8 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) patch("/users/force_password_reset", AdminAPIController, :force_password_reset) + get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials) + patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials) get("/users", AdminAPIController, :list_users) get("/users/:nickname", AdminAPIController, :user_show) @@ -184,46 +184,57 @@ defmodule Pleroma.Web.Router do patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email) get("/reports", AdminAPIController, :list_reports) - get("/grouped_reports", AdminAPIController, :list_grouped_reports) get("/reports/:id", AdminAPIController, :report_show) patch("/reports", AdminAPIController, :reports_update) post("/reports/:id/notes", AdminAPIController, :report_notes_create) delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete) - put("/statuses/:id", AdminAPIController, :status_update) - delete("/statuses/:id", AdminAPIController, :status_delete) - get("/statuses", AdminAPIController, :list_statuses) + get("/statuses/:id", StatusController, :show) + put("/statuses/:id", StatusController, :update) + delete("/statuses/:id", StatusController, :delete) + get("/statuses", StatusController, :index) get("/config", AdminAPIController, :config_show) post("/config", AdminAPIController, :config_update) get("/config/descriptions", AdminAPIController, :config_descriptions) + get("/need_reboot", AdminAPIController, :need_reboot) get("/restart", AdminAPIController, :restart) get("/moderation_log", AdminAPIController, :list_log) post("/reload_emoji", AdminAPIController, :reload_emoji) get("/stats", AdminAPIController, :stats) + + get("/oauth_app", AdminAPIController, :oauth_app_list) + post("/oauth_app", AdminAPIController, :oauth_app_create) + patch("/oauth_app/:id", AdminAPIController, :oauth_app_update) + delete("/oauth_app/:id", AdminAPIController, :oauth_app_delete) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do + # Modifying packs scope "/packs" do - # Modifying packs pipe_through(:admin_api) - post("/import_from_fs", EmojiAPIController, :import_from_fs) + get("/import", EmojiPackController, :import_from_filesystem) + get("/remote", EmojiPackController, :remote) + post("/download", EmojiPackController, :download) - post("/:pack_name/update_file", EmojiAPIController, :update_file) - post("/:pack_name/update_metadata", EmojiAPIController, :update_metadata) - put("/:name", EmojiAPIController, :create) - delete("/:name", EmojiAPIController, :delete) - post("/download_from", EmojiAPIController, :download_from) - post("/list_from", EmojiAPIController, :list_from) + post("/:name", EmojiPackController, :create) + patch("/:name", EmojiPackController, :update) + delete("/:name", EmojiPackController, :delete) + + post("/:name/files", EmojiPackController, :add_file) + patch("/:name/files", EmojiPackController, :update_file) + delete("/:name/files", EmojiPackController, :delete_file) end + # Pack info / downloading scope "/packs" do - # Pack info / downloading - get("/", EmojiAPIController, :list_packs) - get("/:name/download_shared/", EmojiAPIController, :download_shared) + pipe_through(:api) + get("/", EmojiPackController, :index) + get("/:name", EmojiPackController, :show) + get("/:name/archive", EmojiPackController, :archive) end end @@ -249,6 +260,16 @@ defmodule Pleroma.Web.Router do post("/follow_import", UtilController, :follow_import) end + scope "/api/pleroma", Pleroma.Web.PleromaAPI do + pipe_through(:authenticated_api) + + get("/accounts/mfa", TwoFactorAuthenticationController, :settings) + get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes) + get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup) + post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm) + delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable) + end + scope "/oauth", Pleroma.Web.OAuth do scope [] do pipe_through(:oauth) @@ -260,6 +281,10 @@ defmodule Pleroma.Web.Router do post("/revoke", OAuthController, :token_revoke) get("/registration_details", OAuthController, :registration_details) + post("/mfa/challenge", MFAController, :challenge) + post("/mfa/verify", MFAController, :verify, as: :mfa_verify) + get("/mfa", MFAController, :show) + scope [] do pipe_through(:browser) @@ -273,26 +298,22 @@ defmodule Pleroma.Web.Router do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do pipe_through(:api) - get("/statuses/:id/reactions/:emoji", PleromaAPIController, :emoji_reactions_by) - get("/statuses/:id/reactions", PleromaAPIController, :emoji_reactions_by) + get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index) + get("/statuses/:id/reactions", EmojiReactionController, :index) end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do scope [] do pipe_through(:authenticated_api) - get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) - get("/conversations/:id", PleromaAPIController, :conversation) - post("/conversations/read", PleromaAPIController, :read_conversations) - end - - scope [] do - pipe_through(:authenticated_api) + get("/conversations/:id/statuses", ConversationController, :statuses) + get("/conversations/:id", ConversationController, :show) + post("/conversations/read", ConversationController, :mark_as_read) + patch("/conversations/:id", ConversationController, :update) - patch("/conversations/:id", PleromaAPIController, :update_conversation) - put("/statuses/:id/reactions/:emoji", PleromaAPIController, :react_with_emoji) - delete("/statuses/:id/reactions/:emoji", PleromaAPIController, :unreact_with_emoji) - post("/notifications/read", PleromaAPIController, :read_notification) + put("/statuses/:id/reactions/:emoji", EmojiReactionController, :create) + delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete) + post("/notifications/read", NotificationController, :mark_as_read) patch("/accounts/update_avatar", AccountController, :update_avatar) patch("/accounts/update_banner", AccountController, :update_banner) @@ -301,7 +322,7 @@ defmodule Pleroma.Web.Router do get("/mascot", MascotController, :show) put("/mascot", MascotController, :update) - post("/scrobble", ScrobbleController, :new_scrobble) + post("/scrobble", ScrobbleController, :create) end scope [] do @@ -321,58 +342,92 @@ defmodule Pleroma.Web.Router do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do pipe_through(:api) - get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles) + get("/accounts/:id/scrobbles", ScrobbleController, :index) end scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:authenticated_api) get("/accounts/verify_credentials", AccountController, :verify_credentials) + patch("/accounts/update_credentials", AccountController, :update_credentials) get("/accounts/relationships", AccountController, :relationships) - get("/accounts/:id/lists", AccountController, :lists) - get("/accounts/:id/identity_proofs", MastodonAPIController, :empty_array) - - get("/follow_requests", FollowRequestController, :index) + get("/accounts/:id/identity_proofs", AccountController, :identity_proofs) + get("/endorsements", AccountController, :endorsements) get("/blocks", AccountController, :blocks) get("/mutes", AccountController, :mutes) - get("/timelines/home", TimelineController, :home) - get("/timelines/direct", TimelineController, :direct) + post("/follows", AccountController, :follow_by_uri) + post("/accounts/:id/follow", AccountController, :follow) + post("/accounts/:id/unfollow", AccountController, :unfollow) + post("/accounts/:id/block", AccountController, :block) + post("/accounts/:id/unblock", AccountController, :unblock) + post("/accounts/:id/mute", AccountController, :mute) + post("/accounts/:id/unmute", AccountController, :unmute) - get("/favourites", StatusController, :favourites) - get("/bookmarks", StatusController, :bookmarks) + get("/apps/verify_credentials", AppController, :verify_credentials) - get("/notifications", NotificationController, :index) - get("/notifications/:id", NotificationController, :show) - post("/notifications/clear", NotificationController, :clear) - post("/notifications/dismiss", NotificationController, :dismiss) - delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) + get("/conversations", ConversationController, :index) + post("/conversations/:id/read", ConversationController, :mark_as_read) - get("/scheduled_statuses", ScheduledActivityController, :index) - get("/scheduled_statuses/:id", ScheduledActivityController, :show) + get("/domain_blocks", DomainBlockController, :index) + post("/domain_blocks", DomainBlockController, :create) + delete("/domain_blocks", DomainBlockController, :delete) + + get("/filters", FilterController, :index) + + post("/filters", FilterController, :create) + get("/filters/:id", FilterController, :show) + put("/filters/:id", FilterController, :update) + delete("/filters/:id", FilterController, :delete) + + get("/follow_requests", FollowRequestController, :index) + post("/follow_requests/:id/authorize", FollowRequestController, :authorize) + post("/follow_requests/:id/reject", FollowRequestController, :reject) get("/lists", ListController, :index) get("/lists/:id", ListController, :show) get("/lists/:id/accounts", ListController, :list_accounts) - get("/domain_blocks", DomainBlockController, :index) + delete("/lists/:id", ListController, :delete) + post("/lists", ListController, :create) + put("/lists/:id", ListController, :update) + post("/lists/:id/accounts", ListController, :add_to_list) + delete("/lists/:id/accounts", ListController, :remove_from_list) - get("/filters", FilterController, :index) + get("/markers", MarkerController, :index) + post("/markers", MarkerController, :upsert) - get("/suggestions", SuggestionController, :index) + post("/media", MediaController, :create) + get("/media/:id", MediaController, :show) + put("/media/:id", MediaController, :update) - get("/conversations", ConversationController, :index) - post("/conversations/:id/read", ConversationController, :read) + get("/notifications", NotificationController, :index) + get("/notifications/:id", NotificationController, :show) - get("/endorsements", AccountController, :endorsements) + post("/notifications/:id/dismiss", NotificationController, :dismiss) + post("/notifications/clear", NotificationController, :clear) + delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) + # Deprecated: was removed in Mastodon v3, use `/notifications/:id/dismiss` instead + post("/notifications/dismiss", NotificationController, :dismiss_via_body) - patch("/accounts/update_credentials", AccountController, :update_credentials) + post("/polls/:id/votes", PollController, :vote) + + post("/reports", ReportController, :create) + + get("/scheduled_statuses", ScheduledActivityController, :index) + get("/scheduled_statuses/:id", ScheduledActivityController, :show) + + put("/scheduled_statuses/:id", ScheduledActivityController, :update) + delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) + + # Unlike `GET /api/v1/accounts/:id/favourites`, demands authentication + get("/favourites", StatusController, :favourites) + get("/bookmarks", StatusController, :bookmarks) post("/statuses", StatusController, :create) delete("/statuses/:id", StatusController, :delete) - post("/statuses/:id/reblog", StatusController, :reblog) post("/statuses/:id/unreblog", StatusController, :unreblog) post("/statuses/:id/favourite", StatusController, :favourite) @@ -384,49 +439,16 @@ defmodule Pleroma.Web.Router do post("/statuses/:id/mute", StatusController, :mute_conversation) post("/statuses/:id/unmute", StatusController, :unmute_conversation) - put("/scheduled_statuses/:id", ScheduledActivityController, :update) - delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) - - post("/polls/:id/votes", PollController, :vote) - - post("/media", MediaController, :create) - put("/media/:id", MediaController, :update) - - delete("/lists/:id", ListController, :delete) - post("/lists", ListController, :create) - put("/lists/:id", ListController, :update) - - post("/lists/:id/accounts", ListController, :add_to_list) - delete("/lists/:id/accounts", ListController, :remove_from_list) - - post("/filters", FilterController, :create) - get("/filters/:id", FilterController, :show) - put("/filters/:id", FilterController, :update) - delete("/filters/:id", FilterController, :delete) - - post("/reports", ReportController, :create) - - post("/follows", AccountController, :follows) - post("/accounts/:id/follow", AccountController, :follow) - post("/accounts/:id/unfollow", AccountController, :unfollow) - post("/accounts/:id/block", AccountController, :block) - post("/accounts/:id/unblock", AccountController, :unblock) - post("/accounts/:id/mute", AccountController, :mute) - post("/accounts/:id/unmute", AccountController, :unmute) - - post("/follow_requests/:id/authorize", FollowRequestController, :authorize) - post("/follow_requests/:id/reject", FollowRequestController, :reject) - - post("/domain_blocks", DomainBlockController, :create) - delete("/domain_blocks", DomainBlockController, :delete) - post("/push/subscription", SubscriptionController, :create) - get("/push/subscription", SubscriptionController, :get) + get("/push/subscription", SubscriptionController, :show) put("/push/subscription", SubscriptionController, :update) delete("/push/subscription", SubscriptionController, :delete) - get("/markers", MarkerController, :index) - post("/markers", MarkerController, :upsert) + get("/suggestions", SuggestionController, :index) + + get("/timelines/home", TimelineController, :home) + get("/timelines/direct", TimelineController, :direct) + get("/timelines/list/:list_id", TimelineController, :list) end scope "/api/web", Pleroma.Web do @@ -438,15 +460,24 @@ defmodule Pleroma.Web.Router do scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:api) - post("/accounts", AccountController, :create) get("/accounts/search", SearchController, :account_search) + get("/search", SearchController, :search) + + get("/accounts/:id/statuses", AccountController, :statuses) + get("/accounts/:id/followers", AccountController, :followers) + get("/accounts/:id/following", AccountController, :following) + get("/accounts/:id", AccountController, :show) + + post("/accounts", AccountController, :create) get("/instance", InstanceController, :show) get("/instance/peers", InstanceController, :peers) post("/apps", AppController, :create) - get("/apps/verify_credentials", AppController, :verify_credentials) + get("/statuses", StatusController, :index) + get("/statuses/:id", StatusController, :show) + get("/statuses/:id/context", StatusController, :context) get("/statuses/:id/card", StatusController, :card) get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) @@ -457,25 +488,15 @@ defmodule Pleroma.Web.Router do get("/timelines/public", TimelineController, :public) get("/timelines/tag/:tag", TimelineController, :hashtag) - get("/timelines/list/:list_id", TimelineController, :list) - - get("/statuses", StatusController, :index) - get("/statuses/:id", StatusController, :show) - get("/statuses/:id/context", StatusController, :context) get("/polls/:id", PollController, :show) - - get("/accounts/:id/statuses", AccountController, :statuses) - get("/accounts/:id/followers", AccountController, :followers) - get("/accounts/:id/following", AccountController, :following) - get("/accounts/:id", AccountController, :show) - - get("/search", SearchController, :search) end scope "/api/v2", Pleroma.Web.MastodonAPI do pipe_through(:api) get("/search", SearchController, :search2) + + post("/media", MediaController, :create2) end scope "/api", Pleroma.Web do @@ -499,17 +520,23 @@ defmodule Pleroma.Web.Router do ) end + scope "/api" do + pipe_through(:base_api) + + get("/openapi", OpenApiSpex.Plug.RenderSpec, []) + end + scope "/api", Pleroma.Web, as: :authenticated_twitter_api do pipe_through(:authenticated_api) get("/oauth_tokens", TwitterAPI.Controller, :oauth_tokens) delete("/oauth_tokens/:id", TwitterAPI.Controller, :revoke_token) - post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read) - end - - pipeline :ap_service_actor do - plug(:accepts, ["activity+json", "json"]) + post( + "/qvitter/statuses/notifications/read", + TwitterAPI.Controller, + :mark_notifications_as_read + ) end pipeline :ostatus do @@ -522,14 +549,17 @@ defmodule Pleroma.Web.Router do end scope "/", Pleroma.Web do - pipe_through(:ostatus) - pipe_through(:http_signature) + pipe_through([:ostatus, :http_signature]) get("/objects/:uuid", OStatus.OStatusController, :object) get("/activities/:uuid", OStatus.OStatusController, :activity) get("/notice/:id", OStatus.OStatusController, :notice) get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player) + # Mastodon compatibility routes + get("/users/:nickname/statuses/:id", OStatus.OStatusController, :object) + get("/users/:nickname/statuses/:id/activity", OStatus.OStatusController, :activity) + get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) @@ -541,13 +571,6 @@ defmodule Pleroma.Web.Router do get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) end - # Server to Server (S2S) AP interactions - pipeline :activitypub do - plug(:accepts, ["activity+json", "json"]) - plug(Pleroma.Web.Plugs.HTTPSignaturePlug) - plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) - end - scope "/", Pleroma.Web.ActivityPub do # XXX: not really ostatus pipe_through(:ostatus) @@ -555,19 +578,22 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/outbox", ActivityPubController, :outbox) end + pipeline :ap_service_actor do + plug(:accepts, ["activity+json", "json"]) + end + + # Server to Server (S2S) AP interactions + pipeline :activitypub do + plug(:ap_service_actor) + plug(:http_signature) + end + # Client to Server (C2S) AP interactions pipeline :activitypub_client do - plug(:accepts, ["activity+json", "json"]) + plug(:ap_service_actor) plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) - plug(Pleroma.Plugs.EnsureUserKeyPlug) + plug(:authenticate) + plug(:after_auth) end scope "/", Pleroma.Web.ActivityPub do @@ -579,6 +605,7 @@ defmodule Pleroma.Web.Router do post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/api/ap/upload_media", ActivityPubController, :upload_media) + # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) end @@ -641,12 +668,7 @@ defmodule Pleroma.Web.Router do get("/embed/:id", EmbedController, :show) end - pipeline :remote_media do - end - scope "/proxy/", Pleroma.Web.MediaProxy do - pipe_through(:remote_media) - get("/:sig/:url", MediaProxyController, :remote) get("/:sig/:url/:filename", MediaProxyController, :remote) end @@ -659,6 +681,34 @@ defmodule Pleroma.Web.Router do end end + # Test-only routes needed to test action dispatching and plug chain execution + if Pleroma.Config.get(:env) == :test do + @test_actions [ + :do_oauth_check, + :fallback_oauth_check, + :skip_oauth_check, + :fallback_oauth_skip_publicity_check, + :skip_oauth_skip_publicity_check, + :missing_oauth_check_definition + ] + + scope "/test/api", Pleroma.Tests do + pipe_through(:api) + + for action <- @test_actions do + get("/#{action}", AuthTestController, action) + end + end + + scope "/test/authenticated_api", Pleroma.Tests do + pipe_through(:authenticated_api) + + for action <- @test_actions do + get("/#{action}", AuthTestController, action) + end + end + end + scope "/", Pleroma.Web.MongooseIM do get("/user_exists", MongooseIMController, :user_exists) get("/check_password", MongooseIMController, :check_password) diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index 7f9464268..c3efb6651 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -18,7 +18,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do plug(:assign_id) plug(Pleroma.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 ) @page_keys ["max_id", "min_id", "limit", "since_id", "order"] @@ -60,7 +60,9 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do content = if data["content"] do - Pleroma.HTML.filter_tags(data["content"]) + data["content"] + |> Pleroma.HTML.filter_tags() + |> Pleroma.Emoji.Formatter.emojify(Map.get(data, "emoji", %{})) else nil end diff --git a/lib/pleroma/web/static_fe/static_fe_view.ex b/lib/pleroma/web/static_fe/static_fe_view.ex index 66d87620c..b3d1d1ec8 100644 --- a/lib/pleroma/web/static_fe/static_fe_view.ex +++ b/lib/pleroma/web/static_fe/static_fe_view.ex @@ -18,15 +18,6 @@ defmodule Pleroma.Web.StaticFE.StaticFEView do @media_types ["image", "audio", "video"] - def emoji_for_user(%User{} = user) do - user.source_data - |> Map.get("tag", []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) - |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> - {String.trim(name, ":"), url} - end) - end - def fetch_media_type(%{"mediaType" => mediaType}) do Utils.fetch_media_type(@media_types, mediaType) end diff --git a/lib/pleroma/web/streamer/ping.ex b/lib/pleroma/web/streamer/ping.ex deleted file mode 100644 index 7a08202a9..000000000 --- a/lib/pleroma/web/streamer/ping.ex +++ /dev/null @@ -1,37 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.Ping do - use GenServer - require Logger - - alias Pleroma.Web.Streamer.State - alias Pleroma.Web.Streamer.StreamerSocket - - @keepalive_interval :timer.seconds(30) - - def start_link(opts) do - ping_interval = Keyword.get(opts, :ping_interval, @keepalive_interval) - GenServer.start_link(__MODULE__, %{ping_interval: ping_interval}, name: __MODULE__) - end - - def init(%{ping_interval: ping_interval} = args) do - Process.send_after(self(), :ping, ping_interval) - {:ok, args} - end - - def handle_info(:ping, %{ping_interval: ping_interval} = state) do - State.get_sockets() - |> Map.values() - |> List.flatten() - |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid} -> - Logger.debug("Sending keepalive ping") - send(transport_pid, {:text, ""}) - end) - - Process.send_after(self(), :ping, ping_interval) - - {:noreply, state} - end -end diff --git a/lib/pleroma/web/streamer/state.ex b/lib/pleroma/web/streamer/state.ex deleted file mode 100644 index 999550b88..000000000 --- a/lib/pleroma/web/streamer/state.ex +++ /dev/null @@ -1,82 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.State do - use GenServer - require Logger - - alias Pleroma.Web.Streamer.StreamerSocket - - @env Mix.env() - - def start_link(_) do - GenServer.start_link(__MODULE__, %{sockets: %{}}, name: __MODULE__) - end - - def add_socket(topic, socket) do - GenServer.call(__MODULE__, {:add, topic, socket}) - end - - def remove_socket(topic, socket) do - do_remove_socket(@env, topic, socket) - end - - def get_sockets do - %{sockets: stream_sockets} = GenServer.call(__MODULE__, :get_state) - stream_sockets - end - - def init(init_arg) do - {:ok, init_arg} - end - - def handle_call(:get_state, _from, state) do - {:reply, state, state} - end - - def handle_call({:add, topic, socket}, _from, %{sockets: sockets} = state) do - internal_topic = internal_topic(topic, socket) - stream_socket = StreamerSocket.from_socket(socket) - - sockets_for_topic = - sockets - |> Map.get(internal_topic, []) - |> List.insert_at(0, stream_socket) - |> Enum.uniq() - - state = put_in(state, [:sockets, internal_topic], sockets_for_topic) - Logger.debug("Got new conn for #{topic}") - {:reply, state, state} - end - - def handle_call({:remove, topic, socket}, _from, %{sockets: sockets} = state) do - internal_topic = internal_topic(topic, socket) - stream_socket = StreamerSocket.from_socket(socket) - - sockets_for_topic = - sockets - |> Map.get(internal_topic, []) - |> List.delete(stream_socket) - - state = Kernel.put_in(state, [:sockets, internal_topic], sockets_for_topic) - {:reply, state, state} - end - - defp do_remove_socket(:test, _, _) do - :ok - end - - defp do_remove_socket(_env, topic, socket) do - GenServer.call(__MODULE__, {:remove, topic, socket}) - end - - defp internal_topic(topic, socket) - when topic in ~w[user user:notification direct] do - "#{topic}:#{socket.assigns[:user].id}" - end - - defp internal_topic(topic, _) do - topic - end -end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 814d5a729..0cf41189b 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -3,53 +3,288 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Streamer do - alias Pleroma.Web.Streamer.State - alias Pleroma.Web.Streamer.Worker + require Logger + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Conversation.Participation + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.StreamerView - @timeout 60_000 @mix_env Mix.env() + @registry Pleroma.Web.StreamerRegistry + + def registry, do: @registry + + @public_streams ["public", "public:local", "public:media", "public:local:media"] + @user_streams ["user", "user:notification", "direct"] + + @doc "Expands and authorizes a stream, and registers the process for streaming." + @spec get_topic_and_add_socket(stream :: String.t(), User.t() | nil, Map.t() | nil) :: + {:ok, topic :: String.t()} | {:error, :bad_topic} | {:error, :unauthorized} + def get_topic_and_add_socket(stream, user, params \\ %{}) do + case get_topic(stream, user, params) do + {:ok, topic} -> add_socket(topic, user) + error -> error + end + end + + @doc "Expand and authorizes a stream" + @spec get_topic(stream :: String.t(), User.t() | nil, Map.t()) :: + {:ok, topic :: String.t()} | {:error, :bad_topic} + def get_topic(stream, user, params \\ %{}) - def add_socket(topic, socket) do - State.add_socket(topic, socket) + # Allow all public steams. + def get_topic(stream, _, _) when stream in @public_streams do + {:ok, stream} end - def remove_socket(topic, socket) do - State.remove_socket(topic, socket) + # Allow all hashtags streams. + def get_topic("hashtag", _, %{"tag" => tag}) do + {:ok, "hashtag:" <> tag} end - def get_sockets do - State.get_sockets() + # Expand user streams. + def get_topic(stream, %User{} = user, _) when stream in @user_streams do + {:ok, stream <> ":" <> to_string(user.id)} end - def stream(topics, items) do - if should_send?() do - Task.async(fn -> - :poolboy.transaction( - :streamer_worker, - &Worker.stream(&1, topics, items), - @timeout - ) + def get_topic(stream, _, _) when stream in @user_streams do + {:error, :unauthorized} + end + + # List streams. + def get_topic("list", %User{} = user, %{"list" => id}) do + if Pleroma.List.get(id, user) do + {:ok, "list:" <> to_string(id)} + else + {:error, :bad_topic} + end + end + + def get_topic("list", _, _) do + {:error, :unauthorized} + end + + def get_topic(_, _, _) do + {:error, :bad_topic} + end + + @doc "Registers the process for streaming. Use `get_topic/3` to get the full authorized topic." + def add_socket(topic, user) do + if should_env_send?() do + auth? = if user, do: true + Registry.register(@registry, topic, auth?) + end + + {:ok, topic} + end + + def remove_socket(topic) do + if should_env_send?(), do: Registry.unregister(@registry, topic) + end + + def stream(topics, item) when is_list(topics) do + if should_env_send?() do + Enum.each(topics, fn t -> + spawn(fn -> do_stream(t, item) end) end) end + + :ok + end + + def stream(topic, items) when is_list(items) do + if should_env_send?() do + Enum.each(items, fn i -> + spawn(fn -> do_stream(topic, i) end) + end) + + :ok + end end - def supervisor, do: Pleroma.Web.Streamer.Supervisor + def stream(topic, item) do + if should_env_send?() do + spawn(fn -> do_stream(topic, item) end) + end - defp should_send? do - handle_should_send(@mix_env) + :ok end - defp handle_should_send(:test) do - case Process.whereis(:streamer_worker) do - nil -> - false + def filtered_by_user?(%User{} = user, %Activity{} = item) do + %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = + User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute]) + + recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids) + recipients = MapSet.new(item.recipients) + domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) - pid -> - Process.alive?(pid) + with parent <- Object.normalize(item) || item, + true <- + Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), + true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, + true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)), + true <- MapSet.disjoint?(recipients, recipient_blocks), + %{host: item_host} <- URI.parse(item.actor), + %{host: parent_host} <- URI.parse(parent.data["actor"]), + false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), + false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), + true <- thread_containment(item, user), + false <- CommonAPI.thread_muted?(user, parent) do + false + else + _ -> true end end - defp handle_should_send(:benchmark), do: false + def filtered_by_user?(%User{} = user, %Notification{activity: activity}) do + filtered_by_user?(user, activity) + end + + defp do_stream("direct", item) do + recipient_topics = + User.get_recipients_from_activity(item) + |> Enum.map(fn %{id: id} -> "direct:#{id}" end) + + Enum.each(recipient_topics, fn user_topic -> + Logger.debug("Trying to push direct message to #{user_topic}\n\n") + push_to_socket(user_topic, item) + end) + end + + defp do_stream("participation", participation) do + user_topic = "direct:#{participation.user_id}" + Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") + + push_to_socket(user_topic, participation) + end - defp handle_should_send(_), do: true + defp do_stream("list", item) do + # filter the recipient list if the activity is not public, see #270. + recipient_lists = + case Visibility.is_public?(item) do + true -> + Pleroma.List.get_lists_from_activity(item) + + _ -> + Pleroma.List.get_lists_from_activity(item) + |> Enum.filter(fn list -> + owner = User.get_cached_by_id(list.user_id) + + Visibility.visible_for_user?(item, owner) + end) + end + + recipient_topics = + recipient_lists + |> Enum.map(fn %{id: id} -> "list:#{id}" end) + + Enum.each(recipient_topics, fn list_topic -> + Logger.debug("Trying to push message to #{list_topic}\n\n") + push_to_socket(list_topic, item) + end) + end + + defp do_stream(topic, %Notification{} = item) + when topic in ["user", "user:notification"] do + Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list -> + Enum.each(list, fn {pid, _auth} -> + send(pid, {:render_with_user, StreamerView, "notification.json", item}) + end) + end) + end + + defp do_stream("user", item) do + Logger.debug("Trying to push to users") + + recipient_topics = + User.get_recipients_from_activity(item) + |> Enum.map(fn %{id: id} -> "user:#{id}" end) + + Enum.each(recipient_topics, fn topic -> + push_to_socket(topic, item) + end) + end + + defp do_stream(topic, item) do + Logger.debug("Trying to push to #{topic}") + Logger.debug("Pushing item to #{topic}") + push_to_socket(topic, item) + end + + defp push_to_socket(topic, %Participation{} = participation) do + rendered = StreamerView.render("conversation.json", participation) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _} -> + send(pid, {:text, rendered}) + end) + end) + end + + defp push_to_socket(topic, %Activity{ + data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id} + }) do + rendered = Jason.encode!(%{event: "delete", payload: to_string(deleted_activity_id)}) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _} -> + send(pid, {:text, rendered}) + end) + end) + end + + defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop + + defp push_to_socket(topic, item) do + anon_render = StreamerView.render("update.json", item) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, auth?} -> + if auth? do + send(pid, {:render_with_user, StreamerView, "update.json", item}) + else + send(pid, {:text, anon_render}) + end + end) + end) + end + + defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true + + defp thread_containment(activity, user) do + if Config.get([:instance, :skip_thread_containment]) do + true + else + ActivityPub.contain_activity(activity, user) + end + end + + # In test environement, only return true if the registry is started. + # In benchmark environment, returns false. + # In any other environment, always returns true. + cond do + @mix_env == :test -> + def should_env_send? do + case Process.whereis(@registry) do + nil -> + false + + pid -> + Process.alive?(pid) + end + end + + @mix_env == :benchmark -> + def should_env_send?, do: false + + true -> + def should_env_send?, do: true + end end diff --git a/lib/pleroma/web/streamer/streamer_socket.ex b/lib/pleroma/web/streamer/streamer_socket.ex deleted file mode 100644 index 7d5dcd34e..000000000 --- a/lib/pleroma/web/streamer/streamer_socket.ex +++ /dev/null @@ -1,35 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.StreamerSocket do - defstruct transport_pid: nil, user: nil - - alias Pleroma.User - alias Pleroma.Web.Streamer.StreamerSocket - - def from_socket(%{ - transport_pid: transport_pid, - assigns: %{user: nil} - }) do - %StreamerSocket{ - transport_pid: transport_pid - } - end - - def from_socket(%{ - transport_pid: transport_pid, - assigns: %{user: %User{} = user} - }) do - %StreamerSocket{ - transport_pid: transport_pid, - user: user - } - end - - def from_socket(%{transport_pid: transport_pid}) do - %StreamerSocket{ - transport_pid: transport_pid - } - end -end diff --git a/lib/pleroma/web/streamer/supervisor.ex b/lib/pleroma/web/streamer/supervisor.ex deleted file mode 100644 index bd9029bc0..000000000 --- a/lib/pleroma/web/streamer/supervisor.ex +++ /dev/null @@ -1,37 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.Supervisor do - use Supervisor - - def start_link(opts) do - Supervisor.start_link(__MODULE__, opts, name: __MODULE__) - end - - def init(args) do - children = [ - {Pleroma.Web.Streamer.State, args}, - {Pleroma.Web.Streamer.Ping, args}, - :poolboy.child_spec(:streamer_worker, poolboy_config()) - ] - - opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor] - Supervisor.init(children, opts) - end - - defp poolboy_config do - opts = - Pleroma.Config.get(:streamer, - workers: 3, - overflow_workers: 2 - ) - - [ - {:name, {:local, :streamer_worker}}, - {:worker_module, Pleroma.Web.Streamer.Worker}, - {:size, opts[:workers]}, - {:max_overflow, opts[:overflow_workers]} - ] - end -end diff --git a/lib/pleroma/web/streamer/worker.ex b/lib/pleroma/web/streamer/worker.ex deleted file mode 100644 index 29f992a67..000000000 --- a/lib/pleroma/web/streamer/worker.ex +++ /dev/null @@ -1,226 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.Worker do - use GenServer - - require Logger - - alias Pleroma.Activity - alias Pleroma.Config - alias Pleroma.Conversation.Participation - alias Pleroma.Notification - alias Pleroma.Object - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.Streamer.State - alias Pleroma.Web.Streamer.StreamerSocket - alias Pleroma.Web.StreamerView - - def start_link(_) do - GenServer.start_link(__MODULE__, %{}, []) - end - - def init(init_arg) do - {:ok, init_arg} - end - - def stream(pid, topics, items) do - GenServer.call(pid, {:stream, topics, items}) - end - - def handle_call({:stream, topics, item}, _from, state) when is_list(topics) do - Enum.each(topics, fn t -> - do_stream(%{topic: t, item: item}) - end) - - {:reply, state, state} - end - - def handle_call({:stream, topic, items}, _from, state) when is_list(items) do - Enum.each(items, fn i -> - do_stream(%{topic: topic, item: i}) - end) - - {:reply, state, state} - end - - def handle_call({:stream, topic, item}, _from, state) do - do_stream(%{topic: topic, item: item}) - - {:reply, state, state} - end - - defp do_stream(%{topic: "direct", item: item}) do - recipient_topics = - User.get_recipients_from_activity(item) - |> Enum.map(fn %{id: id} -> "direct:#{id}" end) - - Enum.each(recipient_topics, fn user_topic -> - Logger.debug("Trying to push direct message to #{user_topic}\n\n") - push_to_socket(State.get_sockets(), user_topic, item) - end) - end - - defp do_stream(%{topic: "participation", item: participation}) do - user_topic = "direct:#{participation.user_id}" - Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") - - push_to_socket(State.get_sockets(), user_topic, participation) - end - - defp do_stream(%{topic: "list", item: item}) do - # filter the recipient list if the activity is not public, see #270. - recipient_lists = - case Visibility.is_public?(item) do - true -> - Pleroma.List.get_lists_from_activity(item) - - _ -> - Pleroma.List.get_lists_from_activity(item) - |> Enum.filter(fn list -> - owner = User.get_cached_by_id(list.user_id) - - Visibility.visible_for_user?(item, owner) - end) - end - - recipient_topics = - recipient_lists - |> Enum.map(fn %{id: id} -> "list:#{id}" end) - - Enum.each(recipient_topics, fn list_topic -> - Logger.debug("Trying to push message to #{list_topic}\n\n") - push_to_socket(State.get_sockets(), list_topic, item) - end) - end - - defp do_stream(%{topic: topic, item: %Notification{} = item}) - when topic in ["user", "user:notification"] do - State.get_sockets() - |> Map.get("#{topic}:#{item.user_id}", []) - |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid, user: socket_user} -> - with %User{} = user <- User.get_cached_by_ap_id(socket_user.ap_id), - true <- should_send?(user, item) do - send(transport_pid, {:text, StreamerView.render("notification.json", socket_user, item)}) - end - end) - end - - defp do_stream(%{topic: "user", item: item}) do - Logger.debug("Trying to push to users") - - recipient_topics = - User.get_recipients_from_activity(item) - |> Enum.map(fn %{id: id} -> "user:#{id}" end) - - Enum.each(recipient_topics, fn topic -> - push_to_socket(State.get_sockets(), topic, item) - end) - end - - defp do_stream(%{topic: topic, item: item}) do - Logger.debug("Trying to push to #{topic}") - Logger.debug("Pushing item to #{topic}") - push_to_socket(State.get_sockets(), topic, item) - end - - defp should_send?(%User{} = user, %Activity{} = item) do - %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = - User.outgoing_relations_ap_ids(user, [:block, :mute, :reblog_mute]) - - recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids) - recipients = MapSet.new(item.recipients) - domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) - - with parent <- Object.normalize(item) || item, - true <- - Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), - true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, - true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)), - true <- MapSet.disjoint?(recipients, recipient_blocks), - %{host: item_host} <- URI.parse(item.actor), - %{host: parent_host} <- URI.parse(parent.data["actor"]), - false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), - false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), - true <- thread_containment(item, user), - false <- CommonAPI.thread_muted?(user, item) do - true - else - _ -> false - end - end - - defp should_send?(%User{} = user, %Notification{activity: activity}) do - should_send?(user, activity) - end - - def push_to_socket(topics, topic, %Activity{data: %{"type" => "Announce"}} = item) do - Enum.each(topics[topic] || [], fn %StreamerSocket{ - transport_pid: transport_pid, - user: socket_user - } -> - # Get the current user so we have up-to-date blocks etc. - if socket_user do - user = User.get_cached_by_ap_id(socket_user.ap_id) - - if should_send?(user, item) do - send(transport_pid, {:text, StreamerView.render("update.json", item, user)}) - end - else - send(transport_pid, {:text, StreamerView.render("update.json", item)}) - end - end) - end - - def push_to_socket(topics, topic, %Participation{} = participation) do - Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} -> - send(transport_pid, {:text, StreamerView.render("conversation.json", participation)}) - end) - end - - def push_to_socket(topics, topic, %Activity{ - data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id} - }) do - Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} -> - send( - transport_pid, - {:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()} - ) - end) - end - - def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop - - def push_to_socket(topics, topic, item) do - Enum.each(topics[topic] || [], fn %StreamerSocket{ - transport_pid: transport_pid, - user: socket_user - } -> - # Get the current user so we have up-to-date blocks etc. - if socket_user do - user = User.get_cached_by_ap_id(socket_user.ap_id) - - if should_send?(user, item) do - send(transport_pid, {:text, StreamerView.render("update.json", item, user)}) - end - else - send(transport_pid, {:text, StreamerView.render("update.json", item)}) - end - end) - end - - @spec thread_containment(Activity.t(), User.t()) :: boolean() - defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true - - defp thread_containment(activity, user) do - if Config.get([:instance, :skip_thread_containment]) do - true - else - ActivityPub.contain_activity(activity, user) - end - end -end diff --git a/lib/pleroma/web/templates/embed/show.html.eex b/lib/pleroma/web/templates/embed/show.html.eex index 6bf8fac29..05a3f0ee3 100644 --- a/lib/pleroma/web/templates/embed/show.html.eex +++ b/lib/pleroma/web/templates/embed/show.html.eex @@ -1,11 +1,11 @@ <div> <div class="p-author h-card"> - <a class="u-url" rel="author noopener" href="<%= User.profile_url(@author) %>"> + <a class="u-url" rel="author noopener" href="<%= @author.ap_id %>"> <div class="avatar"> <img src="<%= User.avatar_url(@author) |> MediaProxy.url %>" width="48" height="48" alt=""> </div> <span class="display-name" style="padding-left: 0.5em;"> - <bdi><%= raw (@author.name |> Formatter.emojify(emoji_for_user(@author))) %></bdi> + <bdi><%= raw (@author.name |> Formatter.emojify(@author.emoji)) %></bdi> <span class="nickname"><%= full_nickname(@author) %></span> </span> </a> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index ac8a75009..78350f2aa 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -2,10 +2,10 @@ <activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type> <activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb> <id><%= @data["id"] %></id> - <title><%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %></title> - <content type="html"><%= activity_content(@object) %></content> - <published><%= @data["published"] %></published> - <updated><%= @data["published"] %></updated> + <title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title> + <content type="html"><%= activity_content(@data) %></content> + <published><%= @activity.data["published"] %></published> + <updated><%= @activity.data["published"] %></updated> <ostatus:conversation ref="<%= activity_context(@activity) %>"> <%= activity_context(@activity) %> </ostatus:conversation> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index a4dbed638..a304a16af 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -2,10 +2,10 @@ <activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type> <activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb> <guid><%= @data["id"] %></guid> - <title><%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %></title> - <description><%= activity_content(@object) %></description> - <pubDate><%= @data["published"] %></pubDate> - <updated><%= @data["published"] %></updated> + <title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title> + <description><%= activity_content(@data) %></description> + <pubDate><%= @activity.data["published"] %></pubDate> + <updated><%= @activity.data["published"] %></updated> <ostatus:conversation ref="<%= activity_context(@activity) %>"> <%= activity_context(@activity) %> </ostatus:conversation> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index da4fa6d6c..cf5874a91 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -1,12 +1,12 @@ <entry> <activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type> <activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb> - + <%= render @view_module, "_tag_author.atom", assigns %> - + <id><%= @data["id"] %></id> - <title><%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %></title> - <content type="html"><%= activity_content(@object) %></content> + <title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title> + <content type="html"><%= activity_content(@data) %></content> <%= if @activity.local do %> <link type="application/atom+xml" href='<%= @data["id"] %>' rel="self"/> @@ -15,8 +15,8 @@ <link type="text/html" href='<%= @data["external_url"] %>' rel="alternate"/> <% end %> - <published><%= @data["published"] %></published> - <updated><%= @data["published"] %></updated> + <published><%= @activity.data["published"] %></published> + <updated><%= @activity.data["published"] %></updated> <ostatus:conversation ref="<%= activity_context(@activity) %>"> <%= activity_context(@activity) %> @@ -26,7 +26,7 @@ <%= if @data["summary"] do %> <summary><%= @data["summary"] %></summary> <% end %> - + <%= for id <- @activity.recipients do %> <%= if id == Pleroma.Constants.as_public() do %> <link rel="mentioned" @@ -40,7 +40,7 @@ <% end %> <% end %> <% end %> - + <%= for tag <- @data["tag"] || [] do %> <category term="<%= tag %>"></category> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex index 295574df1..2334e24a2 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex @@ -1,15 +1,14 @@ <item> - <title><%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %></title> - - + <title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title> + + <guid isPermalink="true"><%= activity_context(@activity) %></guid> <link><%= activity_context(@activity) %></link> - <pubDate><%= pub_date(@data["published"]) %></pubDate> - - <description><%= activity_content(@object) %></description> + <pubDate><%= pub_date(@activity.data["published"]) %></pubDate> + + <description><%= activity_content(@data) %></description> <%= for attachment <- @data["attachment"] || [] do %> <enclosure url="<%= attachment_href(attachment) %>" type="<%= attachment_type(attachment) %>"/> <% end %> - -</item> +</item> diff --git a/lib/pleroma/web/templates/layout/embed.html.eex b/lib/pleroma/web/templates/layout/embed.html.eex index 57ae4f802..8b905f070 100644 --- a/lib/pleroma/web/templates/layout/embed.html.eex +++ b/lib/pleroma/web/templates/layout/embed.html.eex @@ -7,6 +7,7 @@ <meta content='noindex' name='robots'> <%= Phoenix.HTML.raw(assigns[:meta] || "") %> <link rel="stylesheet" href="/embed.css"> + <base target="_parent"> </head> <body> <%= render @view_module, @view_template, assigns %> diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex index 819632cec..dc0ee2a5c 100644 --- a/lib/pleroma/web/templates/layout/static_fe.html.eex +++ b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -5,7 +5,7 @@ <meta name="viewport" content="width=device-width,initial-scale=1,minimal-ui" /> <title><%= Pleroma.Config.get([:instance, :name]) %></title> <%= Phoenix.HTML.raw(assigns[:meta] || "") %> - <link rel="stylesheet" href="/static/static-fe.css"> + <link rel="stylesheet" href="/static-fe/static-fe.css"> </head> <body> <div class="container"> diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex new file mode 100644 index 000000000..750f65386 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex @@ -0,0 +1,24 @@ +<%= if get_flash(@conn, :info) do %> +<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> +<% end %> +<%= if get_flash(@conn, :error) do %> +<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> +<% end %> + +<h2>Two-factor recovery</h2> + +<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +<div class="input"> + <%= label f, :code, "Recovery code" %> + <%= text_input f, :code %> + <%= hidden_input f, :mfa_token, value: @mfa_token %> + <%= hidden_input f, :state, value: @state %> + <%= hidden_input f, :redirect_uri, value: @redirect_uri %> + <%= hidden_input f, :challenge_type, value: "recovery" %> +</div> + +<%= submit "Verify" %> +<% end %> +<a href="<%= mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> + Enter a two-factor code +</a> diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex new file mode 100644 index 000000000..af6e546b0 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -0,0 +1,24 @@ +<%= if get_flash(@conn, :info) do %> +<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> +<% end %> +<%= if get_flash(@conn, :error) do %> +<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> +<% end %> + +<h2>Two-factor authentication</h2> + +<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +<div class="input"> + <%= label f, :code, "Authentication code" %> + <%= text_input f, :code %> + <%= hidden_input f, :mfa_token, value: @mfa_token %> + <%= hidden_input f, :state, value: @state %> + <%= hidden_input f, :redirect_uri, value: @redirect_uri %> + <%= hidden_input f, :challenge_type, value: "totp" %> +</div> + +<%= submit "Verify" %> +<% end %> +<a href="<%= mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> + Enter a two-factor recovery code +</a> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex index 7e04e9550..4853e7f4b 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex @@ -1,8 +1,8 @@ <%= case @mediaType do %> <% "audio" -> %> -<audio src="<%= @url %>" controls="controls"></audio> +<audio class="u-audio" src="<%= @url %>" controls="controls"></audio> <% "video" -> %> -<video src="<%= @url %>" controls="controls"></video> +<video class="u-video" src="<%= @url %>" controls="controls"></video> <% _ -> %> -<img src="<%= @url %>" alt="<%= @name %>" title="<%= @name %>"> +<img class="u-photo" src="<%= @url %>" alt="<%= @name %>" title="<%= @name %>"> <% end %> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex index df5e5eedd..df0244795 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex @@ -1,12 +1,16 @@ -<div class="activity" <%= if @selected do %> id="selected" <% end %>> +<div class="activity h-entry" <%= if @selected do %> id="selected" <% end %>> <p class="pull-right"> - <%= link format_date(@published), to: @link, class: "activity-link" %> + <a class="activity-link u-url u-uid" href="<%= @link %>"> + <time class="dt-published" datetime="<%= @published %>"> + <%= format_date(@published) %> + </time> + </a> </p> <%= render("_user_card.html", %{user: @user}) %> <div class="activity-content"> <%= if @title != "" do %> <details <%= if open_content?() do %>open<% end %>> - <summary><%= raw @title %></summary> + <summary class="p-name"><%= raw @title %></summary> <div class="e-content"><%= raw @content %></div> </details> <% else %> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex index c7789f9ac..977b894d3 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex @@ -1,10 +1,10 @@ <div class="p-author h-card"> - <a class="u-url" rel="author noopener" href="<%= User.profile_url(@user) %>"> + <a class="u-url" rel="author noopener" href="<%= (@user.uri || @user.ap_id) %>"> <div class="avatar"> - <img src="<%= User.avatar_url(@user) |> MediaProxy.url %>" width="48" height="48" alt=""> + <img class="u-photo" src="<%= User.avatar_url(@user) |> MediaProxy.url %>" width="48" height="48" alt=""> </div> <span class="display-name"> - <bdi><%= raw (@user.name |> Formatter.emojify(emoji_for_user(@user))) %></bdi> + <bdi class="p-name"><%= raw Formatter.emojify(@user.name, @user.emoji) %></bdi> <span class="nickname"><%= @user.nickname %></span> </span> </a> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex index 94063c92d..3191bf450 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex @@ -7,8 +7,8 @@ <input type="hidden" name="profile" value=""> <button type="submit" class="collapse">Remote follow</button> </form> - <%= raw Formatter.emojify(@user.name, emoji_for_user(@user)) %> | - <%= link "@#{@user.nickname}@#{Endpoint.host()}", to: User.profile_url(@user) %> + <%= raw Formatter.emojify(@user.name, @user.emoji) %> | + <%= link "@#{@user.nickname}@#{Endpoint.host()}", to: (@user.uri || @user.ap_id) %> </h3> <p><%= raw @user.bio %></p> </header> diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex new file mode 100644 index 000000000..adc3a3e3d --- /dev/null +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex @@ -0,0 +1,13 @@ +<%= if @error do %> +<h2><%= @error %></h2> +<% end %> +<h2>Two-factor authentication</h2> +<p><%= @followee.nickname %></p> +<img height="128" width="128" src="<%= avatar_url(@followee) %>"> +<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %> +<%= text_input f, :code, placeholder: "Authentication code", required: true %> +<br> +<%= hidden_input f, :id, value: @followee.id %> +<%= hidden_input f, :token, value: @mfa_token %> +<%= submit "Authorize" %> +<% end %> diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex index 89da760da..521dc9322 100644 --- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -8,10 +8,12 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do require Logger alias Pleroma.Activity + alias Pleroma.MFA alias Pleroma.Object.Fetcher alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web.Auth.Authenticator + alias Pleroma.Web.Auth.TOTPAuthenticator alias Pleroma.Web.CommonAPI @status_types ["Article", "Event", "Note", "Video", "Page", "Question"] @@ -68,6 +70,8 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do # POST /ostatus_subscribe # + # adds a remote account in followers if user already is signed in. + # def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => id}}) do with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, {:ok, _, _, _} <- CommonAPI.follow(user, followee) do @@ -78,9 +82,33 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do end end + # POST /ostatus_subscribe + # + # step 1. + # checks login\password and displays step 2 form of MFA if need. + # def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do - with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, + with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, {_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee}, + {_, _, _, false} <- {:mfa_required, followee, user, MFA.require?(user)}, + {:ok, _, _, _} <- CommonAPI.follow(user, followee) do + redirect(conn, to: "/users/#{followee.id}") + else + error -> + handle_follow_error(conn, error) + end + end + + # POST /ostatus_subscribe + # + # step 2 + # checks TOTP code. otherwise displays form with errors + # + def do_follow(conn, %{"mfa" => %{"code" => code, "token" => token, "id" => id}}) do + with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, + {_, _, {:ok, %{user: user}}} <- {:mfa_token, followee, MFA.Token.validate(token)}, + {_, _, _, {:ok, _}} <- + {:verify_mfa_code, followee, token, TOTPAuthenticator.verify(code, user)}, {:ok, _, _, _} <- CommonAPI.follow(user, followee) do redirect(conn, to: "/users/#{followee.id}") else @@ -94,6 +122,23 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do render(conn, "followed.html", %{error: "Insufficient permissions: follow | write:follows."}) end + defp handle_follow_error(conn, {:mfa_token, followee, _} = _) do + render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee}) + end + + defp handle_follow_error(conn, {:verify_mfa_code, followee, token, _} = _) do + render(conn, "follow_mfa.html", %{ + error: "Wrong authentication code", + followee: followee, + mfa_token: token + }) + end + + defp handle_follow_error(conn, {:mfa_required, followee, user, _} = _) do + {:ok, %{token: token}} = MFA.Token.create_token(user) + render(conn, "follow_mfa.html", %{followee: followee, mfa_token: token, error: false}) + end + defp handle_follow_error(conn, {:auth, _, followee} = _) do render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee}) end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 537f9f778..fd2aee175 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -25,13 +25,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do when action == :follow_import ) - # Note: follower can submit the form (with password auth) not being signed in (having no token) - plug( - OAuthScopesPlug, - %{fallback: :proceed_unauthenticated, scopes: ["follow", "write:follows"]} - when action == :do_remote_follow - ) - plug(OAuthScopesPlug, %{scopes: ["follow", "write:blocks"]} when action == :blocks_import) plug( @@ -199,15 +192,16 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do end def follow_import(%{assigns: %{user: follower}} = conn, %{"list" => list}) do - with lines <- String.split(list, "\n"), - followed_identifiers <- - Enum.map(lines, fn line -> - String.split(line, ",") |> List.first() - end) - |> List.delete("Account address") do - User.follow_import(follower, followed_identifiers) - json(conn, "job started") - end + followed_identifiers = + list + |> String.split("\n") + |> Enum.map(&(&1 |> String.split(",") |> List.first())) + |> List.delete("Account address") + |> Enum.map(&(&1 |> String.trim() |> String.trim_leading("@"))) + |> Enum.reject(&(&1 == "")) + + User.follow_import(follower, followed_identifiers) + json(conn, "job started") end def blocks_import(conn, %{"list" => %Plug.Upload{} = listfile}) do @@ -215,10 +209,9 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do end def blocks_import(%{assigns: %{user: blocker}} = conn, %{"list" => list}) do - with blocked_identifiers <- String.split(list) do - User.blocks_import(blocker, blocked_identifiers) - json(conn, "job started") - end + blocked_identifiers = list |> String.split() |> Enum.map(&String.trim_leading(&1, "@")) + User.blocks_import(blocker, blocked_identifiers) + json(conn, "job started") end def change_password(%{assigns: %{user: user}} = conn, params) do diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index f9c0994da..5cfb385ac 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -3,81 +3,39 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.TwitterAPI do + import Pleroma.Web.Gettext + alias Pleroma.Emails.Mailer alias Pleroma.Emails.UserEmail alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserInviteToken - require Pleroma.Constants - def register_user(params, opts \\ []) do - token = params["token"] - - params = %{ - nickname: params["nickname"], - name: params["fullname"], - bio: User.parse_bio(params["bio"]), - email: params["email"], - password: params["password"], - password_confirmation: params["confirm"], - captcha_solution: params["captcha_solution"], - captcha_token: params["captcha_token"], - captcha_answer_data: params["captcha_answer_data"] - } - - captcha_enabled = Pleroma.Config.get([Pleroma.Captcha, :enabled]) - # true if captcha is disabled or enabled and valid, false otherwise - captcha_ok = - if not captcha_enabled do - :ok - else - Pleroma.Captcha.validate( - params[:captcha_token], - params[:captcha_solution], - params[:captcha_answer_data] - ) - end - - # Captcha invalid - if captcha_ok != :ok do - {:error, error} = captcha_ok - # I have no idea how this error handling works - {:error, %{error: Jason.encode!(%{captcha: [error]})}} + params = + params + |> Map.take([:email, :token, :password]) + |> Map.put(:bio, params |> Map.get(:bio, "") |> User.parse_bio()) + |> Map.put(:nickname, params[:username]) + |> Map.put(:name, Map.get(params, :fullname, params[:username])) + |> Map.put(:password_confirmation, params[:password]) + + if Pleroma.Config.get([:instance, :registrations_open]) do + create_user(params, opts) else - registration_process( - params, - %{ - registrations_open: Pleroma.Config.get([:instance, :registrations_open]), - token: token - }, - opts - ) + create_user_with_invite(params, opts) end end - defp registration_process(params, %{registrations_open: true}, opts) do - create_user(params, opts) - end - - defp registration_process(params, %{token: token}, opts) do - invite = - unless is_nil(token) do - Repo.get_by(UserInviteToken, %{token: token}) - end - - valid_invite? = invite && UserInviteToken.valid_invite?(invite) - - case invite do - nil -> - {:error, "Invalid token"} - - invite when valid_invite? -> - UserInviteToken.update_usage!(invite) - create_user(params, opts) - - _ -> - {:error, "Expired token"} + defp create_user_with_invite(params, opts) do + with %{token: token} when is_binary(token) <- params, + %UserInviteToken{} = invite <- Repo.get_by(UserInviteToken, %{token: token}), + true <- UserInviteToken.valid_invite?(invite) do + UserInviteToken.update_usage!(invite) + create_user(params, opts) + else + nil -> {:error, "Invalid token"} + _ -> {:error, "Expired token"} end end @@ -90,16 +48,17 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do {:error, changeset} -> errors = - Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) + changeset + |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end) |> Jason.encode!() - {:error, %{error: errors}} + {:error, errors} end end def password_reset(nickname_or_email) do with true <- is_binary(nickname_or_email), - %User{local: true, email: email} = user when not is_nil(email) <- + %User{local: true, email: email} = user when is_binary(email) <- User.get_by_nickname_or_email(nickname_or_email), {:ok, token_record} <- Pleroma.PasswordResetToken.create_token(user) do user @@ -121,4 +80,58 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do {:error, "unknown user"} end end + + def validate_captcha(app, params) do + if app.trusted || not Pleroma.Captcha.enabled?() do + :ok + else + do_validate_captcha(params) + end + end + + defp do_validate_captcha(params) do + with :ok <- validate_captcha_presence(params), + :ok <- + Pleroma.Captcha.validate( + params[:captcha_token], + params[:captcha_solution], + params[:captcha_answer_data] + ) do + :ok + else + {:error, :captcha_error} -> + captcha_error(dgettext("errors", "CAPTCHA Error")) + + {:error, :invalid} -> + captcha_error(dgettext("errors", "Invalid CAPTCHA")) + + {:error, :kocaptcha_service_unavailable} -> + captcha_error(dgettext("errors", "Kocaptcha service unavailable")) + + {:error, :expired} -> + captcha_error(dgettext("errors", "CAPTCHA expired")) + + {:error, :already_used} -> + captcha_error(dgettext("errors", "CAPTCHA already used")) + + {:error, :invalid_answer_data} -> + captcha_error(dgettext("errors", "Invalid answer data")) + + {:error, error} -> + captcha_error(error) + end + end + + defp validate_captcha_presence(params) do + [:captcha_solution, :captcha_token, :captcha_answer_data] + |> Enum.find_value(:ok, fn key -> + unless is_binary(params[key]) do + error = dgettext("errors", "Invalid CAPTCHA (Missing parameter: %{name})", name: key) + {:error, error} + end + end) + end + + # For some reason FE expects error message to be a serialized JSON + defp captcha_error(error), do: {:error, Jason.encode!(%{captcha: [error]})} end diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 0229aea97..c2de26b0b 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do use Pleroma.Web, :controller alias Pleroma.Notification + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web.OAuth.Token @@ -13,9 +14,17 @@ defmodule Pleroma.Web.TwitterAPI.Controller do require Logger - plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) + plug( + OAuthScopesPlug, + %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read + ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirm_email + ) + + plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) action_fallback(:errors) @@ -44,13 +53,13 @@ defmodule Pleroma.Web.TwitterAPI.Controller do json_reply(conn, 201, "") end - def errors(conn, {:param_cast, _}) do + defp errors(conn, {:param_cast, _}) do conn |> put_status(400) |> json("Invalid parameters") end - def errors(conn, _) do + defp errors(conn, _) do conn |> put_status(500) |> json("Something went wrong") @@ -62,7 +71,10 @@ defmodule Pleroma.Web.TwitterAPI.Controller do |> send_resp(status, json) end - def notifications_read(%{assigns: %{user: user}} = conn, %{"latest_id" => latest_id} = params) do + def mark_notifications_as_read( + %{assigns: %{user: user}} = conn, + %{"latest_id" => latest_id} = params + ) do Notification.set_read_up_to(user, latest_id) notifications = Notification.for_user(user, params) @@ -73,7 +85,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do |> render("index.json", %{notifications: notifications, for: user}) end - def notifications_read(%{assigns: %{user: _user}} = conn, _) do + def mark_notifications_as_read(%{assigns: %{user: _user}} = conn, _) do bad_request_reply(conn, "You need to specify latest_id") end diff --git a/lib/pleroma/web/views/embed_view.ex b/lib/pleroma/web/views/embed_view.ex index 77536835b..5f50bd155 100644 --- a/lib/pleroma/web/views/embed_view.ex +++ b/lib/pleroma/web/views/embed_view.ex @@ -19,15 +19,6 @@ defmodule Pleroma.Web.EmbedView do @media_types ["image", "audio", "video"] - defp emoji_for_user(%User{} = user) do - user.source_data - |> Map.get("tag", []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) - |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> - {String.trim(name, ":"), url} - end) - end - defp fetch_media_type(%{"mediaType" => mediaType}) do Utils.fetch_media_type(@media_types, mediaType) end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 443868878..237b29ded 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Web.StreamerView do |> Jason.encode!() end - def render("notification.json", %User{} = user, %Notification{} = notify) do + def render("notification.json", %Notification{} = notify, %User{} = user) do %{ event: "notification", payload: diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index cf3ac1287..4f9281851 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -2,6 +2,11 @@ # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.Plug do + # Substitute for `call/2` which is defined with `use Pleroma.Web, :plug` + @callback perform(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() +end + defmodule Pleroma.Web do @moduledoc """ A module that keeps using definitions for controllers, @@ -20,11 +25,19 @@ defmodule Pleroma.Web do below. """ + alias Pleroma.Plugs.EnsureAuthenticatedPlug + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + alias Pleroma.Plugs.ExpectAuthenticatedCheckPlug + alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.PlugHelper + def controller do quote do use Phoenix.Controller, namespace: Pleroma.Web import Plug.Conn + import Pleroma.Web.Gettext import Pleroma.Web.Router.Helpers import Pleroma.Web.TranslationHelpers @@ -34,6 +47,79 @@ defmodule Pleroma.Web do defp set_put_layout(conn, _) do put_layout(conn, Pleroma.Config.get(:app_layout, "app.html")) end + + # Marks plugs intentionally skipped and blocks their execution if present in plugs chain + defp skip_plug(conn, plug_modules) do + plug_modules + |> List.wrap() + |> Enum.reduce( + conn, + fn plug_module, conn -> + try do + plug_module.skip_plug(conn) + rescue + UndefinedFunctionError -> + raise "`#{plug_module}` is not skippable. Append `use Pleroma.Web, :plug` to its code." + end + end + ) + end + + # Executed just before actual controller action, invokes before-action hooks (callbacks) + defp action(conn, params) do + with %{halted: false} = conn <- maybe_drop_authentication_if_oauth_check_ignored(conn), + %{halted: false} = conn <- maybe_perform_public_or_authenticated_check(conn), + %{halted: false} = conn <- maybe_perform_authenticated_check(conn), + %{halted: false} = conn <- maybe_halt_on_missing_oauth_scopes_check(conn) do + super(conn, params) + end + end + + # For non-authenticated API actions, drops auth info if OAuth scopes check was ignored + # (neither performed nor explicitly skipped) + defp maybe_drop_authentication_if_oauth_check_ignored(conn) do + if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) and + not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do + OAuthScopesPlug.drop_auth_info(conn) + else + conn + end + end + + # Ensures instance is public -or- user is authenticated if such check was scheduled + defp maybe_perform_public_or_authenticated_check(conn) do + if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) do + EnsurePublicOrAuthenticatedPlug.call(conn, %{}) + else + conn + end + end + + # Ensures user is authenticated if such check was scheduled + # Note: runs prior to action even if it was already executed earlier in plug chain + # (since OAuthScopesPlug has option of proceeding unauthenticated) + defp maybe_perform_authenticated_check(conn) do + if PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug) do + EnsureAuthenticatedPlug.call(conn, %{}) + else + conn + end + end + + # Halts if authenticated API action neither performs nor explicitly skips OAuth scopes check + defp maybe_halt_on_missing_oauth_scopes_check(conn) do + if PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug) and + not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do + conn + |> render_error( + :forbidden, + "Security violation: OAuth scopes check was neither handled nor explicitly skipped." + ) + |> halt() + else + conn + end + end end end @@ -96,6 +182,50 @@ defmodule Pleroma.Web do end end + def plug do + quote do + @behaviour Pleroma.Web.Plug + @behaviour Plug + + @doc """ + Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain. + """ + def skip_plug(conn) do + PlugHelper.append_to_private_list( + conn, + PlugHelper.skipped_plugs_list_id(), + __MODULE__ + ) + end + + @impl Plug + @doc """ + Before-plug hook that + * ensures the plug is not skipped + * processes `:if_func` / `:unless_func` functional pre-run conditions + * adds plug to the list of called plugs and calls `perform/2` if checks are passed + + Note: multiple invocations of the same plug (with different or same options) are allowed. + """ + def call(%Plug.Conn{} = conn, options) do + if PlugHelper.plug_skipped?(conn, __MODULE__) || + (options[:if_func] && !options[:if_func].(conn)) || + (options[:unless_func] && options[:unless_func].(conn)) do + conn + else + conn = + PlugHelper.append_to_private_list( + conn, + PlugHelper.called_plugs_list_id(), + __MODULE__ + ) + + apply(__MODULE__, :perform, [conn, options]) + end + end + end + end + @doc """ When used, dispatch to the appropriate controller/view/etc. """ diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index 43a81c75d..71ccf251a 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -86,54 +86,24 @@ defmodule Pleroma.Web.WebFinger do |> XmlBuilder.to_doc() end - defp get_magic_key("data:application/magic-public-key," <> magic_key) do - {:ok, magic_key} - end - - defp get_magic_key(nil) do - Logger.debug("Undefined magic key.") - {:ok, nil} - end + defp webfinger_from_xml(doc) do + subject = XML.string_from_xpath("//Subject", doc) - defp get_magic_key(_) do - {:error, "Missing magic key data."} - end + subscribe_address = + ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template} + |> XML.string_from_xpath(doc) - defp webfinger_from_xml(doc) do - with magic_key <- XML.string_from_xpath(~s{//Link[@rel="magic-public-key"]/@href}, doc), - {:ok, magic_key} <- get_magic_key(magic_key), - topic <- - XML.string_from_xpath( - ~s{//Link[@rel="http://schemas.google.com/g/2010#updates-from"]/@href}, - doc - ), - subject <- XML.string_from_xpath("//Subject", doc), - subscribe_address <- - XML.string_from_xpath( - ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template}, - doc - ), - ap_id <- - XML.string_from_xpath( - ~s{//Link[@rel="self" and @type="application/activity+json"]/@href}, - doc - ) do - data = %{ - "magic_key" => magic_key, - "topic" => topic, - "subject" => subject, - "subscribe_address" => subscribe_address, - "ap_id" => ap_id - } + ap_id = + ~s{//Link[@rel="self" and @type="application/activity+json"]/@href} + |> XML.string_from_xpath(doc) - {:ok, data} - else - {:error, e} -> - {:error, e} + data = %{ + "subject" => subject, + "subscribe_address" => subscribe_address, + "ap_id" => ap_id + } - e -> - {:error, e} - end + {:ok, data} end defp webfinger_from_json(doc) do @@ -146,9 +116,6 @@ defmodule Pleroma.Web.WebFinger do {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} -> Map.put(data, "ap_id", link["href"]) - {_, "http://ostatus.org/schema/1.0/subscribe"} -> - Map.put(data, "subscribe_address", link["template"]) - _ -> Logger.debug("Unhandled type: #{inspect(link["type"])}") data @@ -173,7 +140,8 @@ defmodule Pleroma.Web.WebFinger do get_template_from_xml(body) else _ -> - with {:ok, %{body: body}} <- HTTP.get("https://#{domain}/.well-known/host-meta", []) do + with {:ok, %{body: body, status: status}} when status in 200..299 <- + HTTP.get("https://#{domain}/.well-known/host-meta", []) do get_template_from_xml(body) else e -> {:error, "Can't find LRDD template: #{inspect(e)}"} @@ -193,19 +161,21 @@ defmodule Pleroma.Web.WebFinger do URI.parse(account).host end + encoded_account = URI.encode("acct:#{account}") + address = case find_lrdd_template(domain) do {:ok, template} -> - String.replace(template, "{uri}", URI.encode(account)) + String.replace(template, "{uri}", encoded_account) _ -> - "https://#{domain}/.well-known/webfinger?resource=acct:#{account}" + "https://#{domain}/.well-known/webfinger?resource=#{encoded_account}" end with response <- HTTP.get( address, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ), {:ok, %{status: status, body: body}} when status in 200..299 <- response do doc = XML.parse_document(body) |