diff options
Diffstat (limited to 'lib/pleroma')
426 files changed, 21689 insertions, 6176 deletions
diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index c1065611b..6213d0eb7 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Activity do @@ -12,6 +12,7 @@ defmodule Pleroma.Activity do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.ReportNote alias Pleroma.ThreadMute alias Pleroma.User @@ -26,23 +27,27 @@ defmodule Pleroma.Activity do # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 @mastodon_notification_types %{ "Create" => "mention", - "Follow" => "follow", + "Follow" => ["follow", "follow_request"], "Announce" => "reblog", - "Like" => "favourite" + "Like" => "favourite", + "Move" => "move", + "EmojiReact" => "pleroma:emoji_reaction" } - @mastodon_to_ap_notification_types for {k, v} <- @mastodon_notification_types, - into: %{}, - do: {v, k} - schema "activities" do field(:data, :map) field(:local, :boolean, default: true) field(:actor, :string) field(:recipients, {:array, :string}, default: []) field(:thread_muted?, :boolean, virtual: true) + + # This is a fake relation, + # do not use outside of with_preloaded_user_actor/with_joined_user_actor + has_one(:user_actor, User, on_delete: :nothing, foreign_key: :id) # This is a fake relation, do not use outside of with_preloaded_bookmark/get_bookmark has_one(:bookmark, Bookmark) + # This is a fake relation, do not use outside of with_preloaded_report_notes + has_many(:report_notes, ReportNote) has_many(:notifications, Notification, on_delete: :delete_all) # Attention: this is a fake relation, don't try to preload it blindly and expect it to work! @@ -86,6 +91,30 @@ defmodule Pleroma.Activity do |> preload([activity, object: object], object: object) end + # Note: applies to fake activities (ActivityPub.Utils.get_notified_from_object/1 etc.) + def user_actor(%Activity{actor: nil}), do: nil + + def user_actor(%Activity{} = activity) do + with %User{} <- activity.user_actor do + activity.user_actor + else + _ -> User.get_cached_by_ap_id(activity.actor) + end + end + + def with_joined_user_actor(query, join_type \\ :inner) do + join(query, join_type, [activity], u in User, + on: u.ap_id == activity.actor, + as: :user_actor + ) + end + + def with_preloaded_user_actor(query, join_type \\ :inner) do + query + |> with_joined_user_actor(join_type) + |> preload([activity, user_actor: user_actor], user_actor: user_actor) + end + def with_preloaded_bookmark(query, %User{} = user) do from([a] in query, left_join: b in Bookmark, @@ -96,6 +125,16 @@ defmodule Pleroma.Activity do def with_preloaded_bookmark(query, _), do: query + def with_preloaded_report_notes(query) do + from([a] in query, + left_join: r in ReportNote, + on: a.id == r.activity_id, + preload: [report_notes: r] + ) + end + + def with_preloaded_report_notes(query, _), do: query + def with_set_thread_muted_field(query, %User{} = user) do from([a] in query, left_join: tm in ThreadMute, @@ -223,9 +262,10 @@ defmodule Pleroma.Activity do def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id_with_object(ap_id) def normalize(_), do: nil - def delete_by_ap_id(id) when is_binary(id) do + def delete_all_by_object_ap_id(id) when is_binary(id) do id |> Queries.by_object_id() + |> Queries.exclude_type("Delete") |> select([u], u) |> Repo.delete_all() |> elem(1) @@ -237,7 +277,7 @@ defmodule Pleroma.Activity do |> purge_web_resp_cache() end - def delete_by_ap_id(_), do: nil + def delete_all_by_object_ap_id(_), do: nil defp purge_web_resp_cache(%Activity{} = activity) do %{path: path} = URI.parse(activity.data["id"]) @@ -247,15 +287,43 @@ defmodule Pleroma.Activity do defp purge_web_resp_cache(nil), do: nil - for {ap_type, type} <- @mastodon_notification_types do + def follow_accepted?( + %Activity{data: %{"type" => "Follow", "object" => followed_ap_id}} = activity + ) do + with %User{} = follower <- Activity.user_actor(activity), + %User{} = followed <- User.get_cached_by_ap_id(followed_ap_id) do + Pleroma.FollowingRelationship.following?(follower, followed) + else + _ -> false + end + end + + def follow_accepted?(_), do: false + + @spec mastodon_notification_type(Activity.t()) :: String.t() | nil + + for {ap_type, type} <- @mastodon_notification_types, not is_list(type) do def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}), do: unquote(type) end + def mastodon_notification_type(%Activity{data: %{"type" => "Follow"}} = activity) do + if follow_accepted?(activity) do + "follow" + else + "follow_request" + end + end + def mastodon_notification_type(%Activity{}), do: nil + @spec from_mastodon_notification_type(String.t()) :: String.t() | nil + @doc "Converts Mastodon notification type to AR activity type" def from_mastodon_notification_type(type) do - Map.get(@mastodon_to_ap_notification_types, type) + with {k, _v} <- + Enum.find(@mastodon_notification_types, fn {_k, v} -> type in List.wrap(v) end) do + k + end end def all_by_actor_and_id(actor, status_ids \\ []) @@ -275,15 +343,33 @@ defmodule Pleroma.Activity do |> where([a], fragment("? ->> 'state' = 'pending'", a.data)) end + def following_requests_for_actor(%Pleroma.User{ap_id: ap_id}) do + Queries.by_type("Follow") + |> where([a], fragment("?->>'state' = 'pending'", a.data)) + |> where([a], a.actor == ^ap_id) + |> Repo.all() + end + def restrict_deactivated_users(query) do deactivated_users = - from(u in User.Query.build(deactivated: true), select: u.ap_id) + from(u in User.Query.build(%{deactivated: true}), select: u.ap_id) |> Repo.all() - from(activity in query, - where: activity.actor not in ^deactivated_users - ) + Activity.Queries.exclude_authors(query, deactivated_users) end defdelegate search(user, query, options \\ []), to: Pleroma.Activity.Search + + def direct_conversation_id(activity, for_user) do + alias Pleroma.Conversation.Participation + + with %{data: %{"context" => context}} when is_binary(context) <- activity, + %Pleroma.Conversation{} = conversation <- Pleroma.Conversation.get_for_ap_id(context), + %Participation{id: participation_id} <- + Participation.for_user_and_conversation(for_user, conversation) do + participation_id + else + _ -> nil + end + end end diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index 010897abc..9e65bedad 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Activity.Ir.Topics do @@ -39,7 +39,7 @@ defmodule Pleroma.Activity.Ir.Topics do end end - defp item_creation_tags(tags, %{data: %{"type" => "Create"}} = object, activity) do + defp item_creation_tags(tags, object, %{data: %{"type" => "Create"}} = activity) do tags ++ hashtags_to_topics(object) ++ attachment_topics(object, activity) end diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index 949f010a8..a34c20343 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Activity.Queries do @@ -7,11 +7,12 @@ defmodule Pleroma.Activity.Queries do Contains queries for Activity. """ - import Ecto.Query, only: [from: 2] + import Ecto.Query, only: [from: 2, where: 3] @type query :: Ecto.Queryable.t() | Activity.t() alias Pleroma.Activity + alias Pleroma.User @spec by_ap_id(query, String.t()) :: query def by_ap_id(query \\ Activity, ap_id) do @@ -29,6 +30,18 @@ defmodule Pleroma.Activity.Queries do ) end + @spec by_author(query, User.t()) :: query + def by_author(query \\ Activity, %User{ap_id: ap_id}) do + from(a in query, where: a.actor == ^ap_id) + end + + def find_by_object_ap_id(activities, object_ap_id) do + Enum.find( + activities, + &(object_ap_id in [is_map(&1.data["object"]) && &1.data["object"]["id"], &1.data["object"]]) + ) + end + @spec by_object_id(query, String.t() | [String.t()]) :: query def by_object_id(query \\ Activity, object_id) @@ -57,6 +70,22 @@ defmodule Pleroma.Activity.Queries do ) end + @spec by_object_in_reply_to_id(query, String.t(), keyword()) :: query + def by_object_in_reply_to_id(query, in_reply_to_id, opts \\ []) do + query = + if opts[:skip_preloading] do + Activity.with_joined_object(query) + else + Activity.with_preloaded_object(query) + end + + where( + query, + [activity, object: o], + fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(in_reply_to_id)) + ) + end + @spec by_type(query, String.t()) :: query def by_type(query \\ Activity, activity_type) do from( @@ -64,4 +93,16 @@ defmodule Pleroma.Activity.Queries do where: fragment("(?)->>'type' = ?", activity.data, ^activity_type) ) end + + @spec exclude_type(query, String.t()) :: query + def exclude_type(query \\ Activity, activity_type) do + from( + activity in query, + where: fragment("(?)->>'type' != ?", activity.data, ^activity_type) + ) + end + + def exclude_authors(query \\ Activity, actors) do + from(activity in query, where: activity.actor not in ^actors) + end end diff --git a/lib/pleroma/activity/search.ex b/lib/pleroma/activity/search.ex index f847ac238..ceb365bb3 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/activity/search.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Activity.Search do @@ -26,18 +26,23 @@ defmodule Pleroma.Activity.Search do |> query_with(index_type, search_query) |> maybe_restrict_local(user) |> maybe_restrict_author(author) + |> maybe_restrict_blocked(user) |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => limit}, :offset) |> maybe_fetch(user, search_query) end def maybe_restrict_author(query, %User{} = author) do - from([a, o] in query, - where: a.actor == ^author.ap_id - ) + Activity.Queries.by_author(query, author) end def maybe_restrict_author(query, _), do: query + def maybe_restrict_blocked(query, %User{} = user) do + Activity.Queries.exclude_authors(query, User.blocked_users_ap_ids(user)) + end + + def maybe_restrict_blocked(query, _), do: query + defp restrict_public(q) do from([a, o] in q, where: fragment("?->>'type' = 'Create'", a.data), @@ -86,7 +91,7 @@ defmodule Pleroma.Activity.Search do {:ok, object} <- Fetcher.fetch_object_from_id(search_query), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), true <- Visibility.visible_for_user?(activity, user) do - activities ++ [activity] + [activity | activities] else _ -> activities end diff --git a/lib/pleroma/activity_expiration.ex b/lib/pleroma/activity_expiration.ex index 7ea5c48ca..db9c88d84 100644 --- a/lib/pleroma/activity_expiration.ex +++ b/lib/pleroma/activity_expiration.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.ActivityExpiration do @@ -62,6 +62,6 @@ defmodule Pleroma.ActivityExpiration do def expires_late_enough?(scheduled_at) do now = NaiveDateTime.utc_now() diff = NaiveDateTime.diff(scheduled_at, now, :millisecond) - diff >= @min_activity_lifetime + diff > @min_activity_lifetime end end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index d681eecc8..9d3d92b38 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -1,11 +1,16 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Application do - import Cachex.Spec use Application + import Cachex.Spec + + alias Pleroma.Config + + require Logger + @name Mix.Project.config()[:name] @version Mix.Project.config()[:version] @repository Mix.Project.config()[:source_url] @@ -17,36 +22,67 @@ defmodule Pleroma.Application do def repository, do: @repository def user_agent do - info = "#{Pleroma.Web.base_url()} <#{Pleroma.Config.get([:instance, :email], "")}>" - named_version() <> "; " <> info + case Config.get([:http, :user_agent], :default) do + :default -> + info = "#{Pleroma.Web.base_url()} <#{Config.get([:instance, :email], "")}>" + named_version() <> "; " <> info + + custom -> + custom + end end # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do - Pleroma.Config.DeprecationWarnings.warn() + Pleroma.Config.Holder.save_default() + Pleroma.HTML.compile_scrubbers() + Config.DeprecationWarnings.warn() + Pleroma.Plugs.HTTPSecurityPlug.warn_if_disabled() + Pleroma.Repo.check_migrations_applied!() setup_instrumenters() + load_custom_modules() + + adapter = Application.get_env(:tesla, :adapter) + + if adapter == Tesla.Adapter.Gun do + if version = Pleroma.OTPVersion.version() do + [major, minor] = + version + |> String.split(".") + |> Enum.map(&String.to_integer/1) + |> Enum.take(2) + + if (major == 22 and minor < 2) or major < 22 do + raise " + !!!OTP VERSION WARNING!!! + You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. Please update your Erlang/OTP to at least 22.2. + " + end + else + raise " + !!!OTP VERSION WARNING!!! + To support correct handling of unordered certificates chains - OTP version must be > 22.2. + " + end + end # Define workers and child supervisors to be supervised children = [ Pleroma.Repo, - Pleroma.Scheduler, - Pleroma.Config.TransferTask, + Config.TransferTask, Pleroma.Emoji, - Pleroma.Captcha, - Pleroma.Daemons.ScheduledActivityDaemon, - Pleroma.Daemons.ActivityExpirationDaemon + Pleroma.Plugs.RateLimiter.Supervisor ] ++ cachex_children() ++ - hackney_pool_children() ++ + http_children(adapter, @env) ++ [ Pleroma.Stats, Pleroma.JobQueueMonitor, - {Oban, Pleroma.Config.get(Oban)} + {Oban, Config.get(Oban)} ] ++ task_children(@env) ++ - oauth_cleanup_child(oauth_cleanup_enabled?()) ++ streamer_child(@env) ++ chat_child(@env, chat_enabled?()) ++ [ @@ -60,6 +96,28 @@ defmodule Pleroma.Application do Supervisor.start_link(children, opts) end + def load_custom_modules do + dir = Config.get([:modules, :runtime_dir]) + + if dir && File.exists?(dir) do + dir + |> Pleroma.Utils.compile_dir() + |> case do + {:error, _errors, _warnings} -> + raise "Invalid custom modules" + + {:ok, modules, _warnings} -> + if @env != :test do + Enum.each(modules, fn mod -> + Logger.info("Custom module loaded: #{inspect(mod)}") + end) + end + + :ok + end + end + end + defp setup_instrumenters do require Prometheus.Registry @@ -80,20 +138,6 @@ defmodule Pleroma.Application do Pleroma.Web.Endpoint.Instrumenter.setup() end - def enabled_hackney_pools do - [:media] ++ - if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do - [:federation] - else - [] - end ++ - if Pleroma.Config.get([Pleroma.Upload, :proxy_remote]) do - [:upload] - else - [] - end - end - defp cachex_children do [ build_cachex("used_captcha", ttl_interval: seconds_valid_interval()), @@ -115,7 +159,7 @@ defmodule Pleroma.Application do do: expiration(default: :timer.seconds(6 * 60 * 60), interval: :timer.seconds(60)) defp seconds_valid_interval, - do: :timer.seconds(Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid])) + do: :timer.seconds(Config.get!([Pleroma.Captcha, :seconds_valid])) defp build_cachex(type, opts), do: %{ @@ -124,37 +168,27 @@ defmodule Pleroma.Application do type: :worker } - defp chat_enabled?, do: Pleroma.Config.get([:chat, :enabled]) + defp chat_enabled?, do: Config.get([:chat, :enabled]) - defp oauth_cleanup_enabled?, - do: Pleroma.Config.get([:oauth2, :clean_expired_tokens], false) - - defp streamer_child(:test), do: [] + defp streamer_child(env) when env in [:test, :benchmark], do: [] defp streamer_child(_) do - [Pleroma.Web.Streamer.supervisor()] + [ + {Registry, + [ + name: Pleroma.Web.Streamer.registry(), + keys: :duplicate, + partitions: System.schedulers_online() + ]} + ] end - defp oauth_cleanup_child(true), - do: [Pleroma.Web.OAuth.Token.CleanWorker] - - defp oauth_cleanup_child(_), do: [] - - defp chat_child(:test, _), do: [] - defp chat_child(_env, true) do [Pleroma.Web.ChatChannel.ChatChannelState] end defp chat_child(_, _), do: [] - defp hackney_pool_children do - for pool <- enabled_hackney_pools() do - options = Pleroma.Config.get([:hackney_pools, pool]) - :hackney_pool.child_spec(pool, options) - end - end - defp task_children(:test) do [ %{ @@ -179,4 +213,31 @@ defmodule Pleroma.Application do } ] end + + # start hackney and gun pools in tests + defp http_children(_, :test) do + hackney_options = Config.get([:hackney_pools, :federation]) + hackney_pool = :hackney_pool.child_spec(:federation, hackney_options) + [hackney_pool, Pleroma.Pool.Supervisor] + end + + defp http_children(Tesla.Adapter.Hackney, _) do + pools = [:federation, :media] + + pools = + if Config.get([Pleroma.Upload, :proxy_remote]) do + [:upload | pools] + else + pools + end + + for pool <- pools do + options = Config.get([:hackney_pools, pool]) + :hackney_pool.child_spec(pool, options) + end + end + + defp http_children(Tesla.Adapter.Gun, _), do: [Pleroma.Pool.Supervisor] + + defp http_children(_, _), do: [] end diff --git a/lib/pleroma/bbs/authenticator.ex b/lib/pleroma/bbs/authenticator.ex index 79f133ea6..815de7002 100644 --- a/lib/pleroma/bbs/authenticator.ex +++ b/lib/pleroma/bbs/authenticator.ex @@ -1,10 +1,10 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.BBS.Authenticator do use Sshd.PasswordAuthenticator - alias Comeonin.Pbkdf2 + alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.User def authenticate(username, password) do @@ -12,7 +12,7 @@ defmodule Pleroma.BBS.Authenticator do password = to_string(password) with %User{} = user <- User.get_by_nickname(username) do - Pbkdf2.checkpw(password, user.password_hash) + AuthenticationPlug.checkpw(password, user.password_hash) else _e -> false end diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex index fa838a4e4..12d64c2fe 100644 --- a/lib/pleroma/bbs/handler.ex +++ b/lib/pleroma/bbs/handler.ex @@ -1,10 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.BBS.Handler do use Sshd.ShellHandler alias Pleroma.Activity + alias Pleroma.HTML alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI @@ -44,7 +45,7 @@ defmodule Pleroma.BBS.Handler do def puts_activity(activity) do status = Pleroma.Web.MastodonAPI.StatusView.render("show.json", %{activity: activity}) IO.puts("-- #{status.id} by #{status.account.display_name} (#{status.account.acct})") - IO.puts(HtmlSanitizeEx.strip_tags(status.content)) + IO.puts(HTML.strip_tags(status.content)) IO.puts("") end @@ -65,7 +66,7 @@ defmodule Pleroma.BBS.Handler do with %Activity{} <- Activity.get_by_id(activity_id), {:ok, _activity} <- - CommonAPI.post(user, %{"status" => rest, "in_reply_to_status_id" => activity_id}) do + CommonAPI.post(user, %{status: rest, in_reply_to_status_id: activity_id}) do IO.puts("Replied!") else _e -> IO.puts("Could not reply...") @@ -77,7 +78,7 @@ defmodule Pleroma.BBS.Handler do def handle_command(%{user: user} = state, "p " <> text) do text = String.trim(text) - with {:ok, _activity} <- CommonAPI.post(user, %{"status" => text}) do + with {:ok, _activity} <- CommonAPI.post(user, %{status: text}) do IO.puts("Posted!") else _e -> IO.puts("Could not post...") @@ -97,7 +98,7 @@ defmodule Pleroma.BBS.Handler do |> Map.put("user", user) activities = - [user.ap_id | user.following] + [user.ap_id | Pleroma.User.following(user)] |> ActivityPub.fetch_activities(params) Enum.each(activities, fn activity -> diff --git a/lib/pleroma/bookmark.ex b/lib/pleroma/bookmark.ex index 221a94f34..e6ddbce1b 100644 --- a/lib/pleroma/bookmark.ex +++ b/lib/pleroma/bookmark.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Bookmark do diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index c2765a5b8..6ab754b6f 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -1,56 +1,25 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha do - import Pleroma.Web.Gettext - alias Calendar.DateTime alias Plug.Crypto.KeyGenerator alias Plug.Crypto.MessageEncryptor - use GenServer - - @doc false - def start_link(_) do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - @doc false - def init(_) do - {:ok, nil} - end - @doc """ Ask the configured captcha service for a new captcha """ def new do - GenServer.call(__MODULE__, :new) - end - - @doc """ - Ask the configured captcha service to validate the captcha - """ - def validate(token, captcha, answer_data) do - GenServer.call(__MODULE__, {:validate, token, captcha, answer_data}) - end - - @doc false - def handle_call(:new, _from, state) do - enabled = Pleroma.Config.get([__MODULE__, :enabled]) - - if !enabled do - {:reply, %{type: :none}, state} + if not enabled?() do + %{type: :none} else new_captcha = method().new() - secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) - # This make salt a little different for two keys - token = new_captcha[:token] - secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt") - sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign") - # Basicallty copy what Phoenix.Token does here, add the time to + {secret, sign_secret} = secret_pair(new_captcha[:token]) + + # Basically copy what Phoenix.Token does here, add the time to # the actual data and make it a binary to then encrypt it encrypted_captcha_answer = %{ @@ -60,54 +29,73 @@ defmodule Pleroma.Captcha do |> :erlang.term_to_binary() |> MessageEncryptor.encrypt(secret, sign_secret) - { - :reply, - # Repalce the answer with the encrypted answer - %{new_captcha | answer_data: encrypted_captcha_answer}, - state - } + # Replace the answer with the encrypted answer + %{new_captcha | answer_data: encrypted_captcha_answer} end end - @doc false - def handle_call({:validate, token, captcha, answer_data}, _from, state) do + @doc """ + Ask the configured captcha service to validate the captcha + """ + def validate(token, captcha, answer_data) do + with {:ok, %{at: at, answer_data: answer_md5}} <- validate_answer_data(token, answer_data), + :ok <- validate_expiration(at), + :ok <- validate_usage(token), + :ok <- method().validate(token, captcha, answer_md5), + {:ok, _} <- mark_captcha_as_used(token) do + :ok + end + end + + def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled], false) + + defp seconds_valid, do: Pleroma.Config.get!([__MODULE__, :seconds_valid]) + + defp secret_pair(token) do secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt") sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign") + {secret, sign_secret} + end + + defp validate_answer_data(token, answer_data) do + {secret, sign_secret} = secret_pair(token) + + with false <- is_nil(answer_data), + {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), + %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do + {:ok, %{at: at, answer_data: answer_md5}} + else + _ -> {:error, :invalid_answer_data} + end + end + + defp validate_expiration(created_at) do # If the time found is less than (current_time-seconds_valid) then the time has already passed # Later we check that the time found is more than the presumed invalidatation time, that means # that the data is still valid and the captcha can be checked - seconds_valid = Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid]) - valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid) - - result = - with {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), - %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do - try do - if DateTime.before?(at, valid_if_after), - do: throw({:error, dgettext("errors", "CAPTCHA expired")}) - - if not is_nil(Cachex.get!(:used_captcha_cache, token)), - do: throw({:error, dgettext("errors", "CAPTCHA already used")}) - - res = method().validate(token, captcha, answer_md5) - # Throw if an error occurs - if res != :ok, do: throw(res) - - # Mark this captcha as used - {:ok, _} = - Cachex.put(:used_captcha_cache, token, true, ttl: :timer.seconds(seconds_valid)) - - :ok - catch - :throw, e -> e - end - else - _ -> {:error, dgettext("errors", "Invalid answer data")} - end - - {:reply, result, state} + + valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid()) + + if DateTime.before?(created_at, valid_if_after) do + {:error, :expired} + else + :ok + end + end + + defp validate_usage(token) do + if is_nil(Cachex.get!(:used_captcha_cache, token)) do + :ok + else + {:error, :already_used} + end + end + + defp mark_captcha_as_used(token) do + ttl = seconds_valid() |> :timer.seconds() + Cachex.put(:used_captcha_cache, token, true, ttl: ttl) end defp method, do: Pleroma.Config.get!([__MODULE__, :method]) diff --git a/lib/pleroma/captcha/captcha_service.ex b/lib/pleroma/captcha/captcha_service.ex index 8d27c04f1..959038cef 100644 --- a/lib/pleroma/captcha/captcha_service.ex +++ b/lib/pleroma/captcha/captcha_service.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha.Service do diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index 4e1a07c59..6bc2fa158 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -1,9 +1,8 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha.Kocaptcha do - import Pleroma.Web.Gettext alias Pleroma.Captcha.Service @behaviour Service @@ -13,7 +12,7 @@ defmodule Pleroma.Captcha.Kocaptcha do case Tesla.get(endpoint <> "/new") do {:error, _} -> - %{error: dgettext("errors", "Kocaptcha service unavailable")} + %{error: :kocaptcha_service_unavailable} {:ok, res} -> json_resp = Jason.decode!(res.body) @@ -33,6 +32,6 @@ defmodule Pleroma.Captcha.Kocaptcha do if not is_nil(captcha) and :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_data), do: :ok, - else: {:error, dgettext("errors", "Invalid CAPTCHA")} + else: {:error, :invalid} end end diff --git a/lib/pleroma/captcha/native.ex b/lib/pleroma/captcha/native.ex new file mode 100644 index 000000000..a90631d61 --- /dev/null +++ b/lib/pleroma/captcha/native.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Captcha.Native do + alias Pleroma.Captcha.Service + @behaviour Service + + @impl Service + def new do + case Captcha.get() do + :error -> + %{error: :captcha_error} + + {:ok, answer_data, img_binary} -> + %{ + type: :native, + token: token(), + url: "data:image/png;base64," <> Base.encode64(img_binary), + answer_data: answer_data + } + end + end + + @impl Service + def validate(_token, captcha, captcha) when not is_nil(captcha), do: :ok + def validate(_token, _captcha, _answer), do: {:error, :invalid} + + defp token do + 10 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/pleroma/clippy.ex b/lib/pleroma/clippy.ex index bd20952a6..ae96e6ad1 100644 --- a/lib/pleroma/clippy.ex +++ b/lib/pleroma/clippy.ex @@ -1,9 +1,10 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Clippy do @moduledoc false + # No software is complete until they have a Clippy implementation. # A ballmer peak _may_ be required to change this module. diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index fcc039710..cc80deff5 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config do @@ -65,4 +65,16 @@ defmodule Pleroma.Config do def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) def oauth_consumer_enabled?, do: oauth_consumer_strategies() != [] + + def enforce_oauth_admin_scope_usage?, do: !!get([:auth, :enforce_oauth_admin_scope_usage]) + + def oauth_admin_scopes(scopes) when is_list(scopes) do + Enum.flat_map( + scopes, + fn scope -> + ["admin:#{scope}"] ++ + if enforce_oauth_admin_scope_usage?(), do: [], else: [scope] + end + ) + end end diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex new file mode 100644 index 000000000..2b43d4c36 --- /dev/null +++ b/lib/pleroma/config/config_db.ex @@ -0,0 +1,414 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ConfigDB do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + import Pleroma.Web.Gettext + + alias __MODULE__ + alias Pleroma.Repo + + @type t :: %__MODULE__{} + + @full_key_update [ + {:pleroma, :ecto_repos}, + {:quack, :meta}, + {:mime, :types}, + {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:auto_linker, :opts}, + {:swarm, :node_blacklist}, + {:logger, :backends} + ] + + @full_subkey_update [ + {:pleroma, :assets, :mascots}, + {:pleroma, :emoji, :groups}, + {:pleroma, :workers, :retries}, + {:pleroma, :mrf_subchain, :match_actor}, + {:pleroma, :mrf_keyword, :replace} + ] + + @regex ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u + + @delimiters ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] + + schema "config" do + field(:key, :string) + field(:group, :string) + field(:value, :binary) + field(:db, {:array, :string}, virtual: true, default: []) + + timestamps() + end + + @spec get_all_as_keyword() :: keyword() + def get_all_as_keyword do + ConfigDB + |> select([c], {c.group, c.key, c.value}) + |> Repo.all() + |> Enum.reduce([], fn {group, key, value}, acc -> + group = ConfigDB.from_string(group) + key = ConfigDB.from_string(key) + value = from_binary(value) + + Keyword.update(acc, group, [{key, value}], &Keyword.merge(&1, [{key, value}])) + end) + end + + @spec get_by_params(map()) :: ConfigDB.t() | nil + def get_by_params(params), do: Repo.get_by(ConfigDB, params) + + @spec changeset(ConfigDB.t(), map()) :: Changeset.t() + def changeset(config, params \\ %{}) do + params = Map.put(params, :value, transform(params[:value])) + + config + |> cast(params, [:key, :group, :value]) + |> validate_required([:key, :group, :value]) + |> unique_constraint(:key, name: :config_group_key_index) + end + + @spec create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def create(params) do + %ConfigDB{} + |> changeset(params) + |> Repo.insert() + end + + @spec update(ConfigDB.t(), map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def update(%ConfigDB{} = config, %{value: value}) do + config + |> changeset(%{value: value}) + |> Repo.update() + end + + @spec get_db_keys(ConfigDB.t()) :: [String.t()] + def get_db_keys(%ConfigDB{} = config) do + config.value + |> ConfigDB.from_binary() + |> get_db_keys(config.key) + end + + @spec get_db_keys(keyword(), any()) :: [String.t()] + def get_db_keys(value, key) do + if Keyword.keyword?(value) do + value |> Keyword.keys() |> Enum.map(&convert(&1)) + else + [convert(key)] + end + end + + @spec merge_group(atom(), atom(), keyword(), keyword()) :: keyword() + def merge_group(group, key, old_value, new_value) do + new_keys = to_map_set(new_value) + + intersect_keys = + old_value |> to_map_set() |> MapSet.intersection(new_keys) |> MapSet.to_list() + + merged_value = ConfigDB.merge(old_value, new_value) + + @full_subkey_update + |> Enum.map(fn + {g, k, subkey} when g == group and k == key -> + if subkey in intersect_keys, do: subkey, else: [] + + _ -> + [] + end) + |> List.flatten() + |> Enum.reduce(merged_value, fn subkey, acc -> + Keyword.put(acc, subkey, new_value[subkey]) + end) + end + + defp to_map_set(keyword) do + keyword + |> Keyword.keys() + |> MapSet.new() + end + + @spec sub_key_full_update?(atom(), atom(), [Keyword.key()]) :: boolean() + def sub_key_full_update?(group, key, subkeys) do + Enum.any?(@full_subkey_update, fn {g, k, subkey} -> + g == group and k == key and subkey in subkeys + end) + end + + @spec merge(keyword(), keyword()) :: keyword() + def merge(config1, config2) when is_list(config1) and is_list(config2) do + Keyword.merge(config1, config2, fn _, app1, app2 -> + if Keyword.keyword?(app1) and Keyword.keyword?(app2) do + Keyword.merge(app1, app2, &deep_merge/3) + else + app2 + end + end) + end + + defp deep_merge(_key, value1, value2) do + if Keyword.keyword?(value1) and Keyword.keyword?(value2) do + Keyword.merge(value1, value2, &deep_merge/3) + else + value2 + end + end + + @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def update_or_create(params) do + search_opts = Map.take(params, [:group, :key]) + + with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), + {:partial_update, true, config} <- + {:partial_update, can_be_partially_updated?(config), config}, + old_value <- from_binary(config.value), + transformed_value <- do_transform(params[:value]), + {:can_be_merged, true, config} <- {:can_be_merged, is_list(transformed_value), config}, + new_value <- + merge_group( + ConfigDB.from_string(config.group), + ConfigDB.from_string(config.key), + old_value, + transformed_value + ) do + ConfigDB.update(config, %{value: new_value}) + else + {reason, false, config} when reason in [:partial_update, :can_be_merged] -> + ConfigDB.update(config, params) + + nil -> + ConfigDB.create(params) + end + end + + defp can_be_partially_updated?(%ConfigDB{} = config), do: not only_full_update?(config) + + defp only_full_update?(%ConfigDB{} = config) do + config_group = ConfigDB.from_string(config.group) + config_key = ConfigDB.from_string(config.key) + + Enum.any?(@full_key_update, fn + {group, key} when is_list(key) -> + config_group == group and config_key in key + + {group, key} -> + config_group == group and config_key == key + end) + end + + @spec delete(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def delete(params) do + search_opts = Map.delete(params, :subkeys) + + with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), + {config, sub_keys} when is_list(sub_keys) <- {config, params[:subkeys]}, + old_value <- from_binary(config.value), + keys <- Enum.map(sub_keys, &do_transform_string(&1)), + {:partial_remove, config, new_value} when new_value != [] <- + {:partial_remove, config, Keyword.drop(old_value, keys)} do + ConfigDB.update(config, %{value: new_value}) + else + {:partial_remove, config, []} -> + Repo.delete(config) + + {config, nil} -> + Repo.delete(config) + + nil -> + err = + dgettext("errors", "Config with params %{params} not found", params: inspect(params)) + + {:error, err} + end + end + + @spec from_binary(binary()) :: term() + def from_binary(binary), do: :erlang.binary_to_term(binary) + + @spec from_binary_with_convert(binary()) :: any() + def from_binary_with_convert(binary) do + binary + |> from_binary() + |> do_convert() + end + + @spec from_string(String.t()) :: atom() | no_return() + def from_string(string), do: do_transform_string(string) + + @spec convert(any()) :: any() + def convert(entity), do: do_convert(entity) + + defp do_convert(entity) when is_list(entity) do + for v <- entity, into: [], do: do_convert(v) + end + + defp do_convert(%Regex{} = entity), do: inspect(entity) + + defp do_convert(entity) when is_map(entity) do + for {k, v} <- entity, into: %{}, do: {do_convert(k), do_convert(v)} + end + + defp do_convert({:proxy_url, {type, :localhost, port}}) do + %{"tuple" => [":proxy_url", %{"tuple" => [do_convert(type), "localhost", port]}]} + end + + defp do_convert({:proxy_url, {type, host, port}}) when is_tuple(host) do + ip = + host + |> :inet_parse.ntoa() + |> to_string() + + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [do_convert(type), ip, port]} + ] + } + end + + defp do_convert({:proxy_url, {type, host, port}}) do + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [do_convert(type), to_string(host), port]} + ] + } + end + + defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]} + + defp do_convert(entity) when is_tuple(entity) do + value = + entity + |> Tuple.to_list() + |> do_convert() + + %{"tuple" => value} + end + + defp do_convert(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity) do + entity + end + + defp do_convert(entity) + when is_atom(entity) and entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do + ":#{entity}" + end + + defp do_convert(entity) when is_atom(entity), do: inspect(entity) + + defp do_convert(entity) when is_binary(entity), do: entity + + @spec transform(any()) :: binary() | no_return() + def transform(entity) when is_binary(entity) or is_map(entity) or is_list(entity) do + entity + |> do_transform() + |> to_binary() + end + + def transform(entity), do: to_binary(entity) + + @spec transform_with_out_binary(any()) :: any() + def transform_with_out_binary(entity), do: do_transform(entity) + + @spec to_binary(any()) :: binary() + def to_binary(entity), do: :erlang.term_to_binary(entity) + + defp do_transform(%Regex{} = entity), do: entity + + defp do_transform(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do + {:proxy_url, {do_transform_string(type), parse_host(host), port}} + end + + defp do_transform(%{"tuple" => [":partial_chain", entity]}) do + {partial_chain, []} = + entity + |> String.replace(~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") + |> Code.eval_string() + + {:partial_chain, partial_chain} + end + + defp do_transform(%{"tuple" => entity}) do + Enum.reduce(entity, {}, fn val, acc -> Tuple.append(acc, do_transform(val)) end) + end + + defp do_transform(entity) when is_map(entity) do + for {k, v} <- entity, into: %{}, do: {do_transform(k), do_transform(v)} + end + + defp do_transform(entity) when is_list(entity) do + for v <- entity, into: [], do: do_transform(v) + end + + defp do_transform(entity) when is_binary(entity) do + entity + |> String.trim() + |> do_transform_string() + end + + defp do_transform(entity), do: entity + + defp parse_host("localhost"), do: :localhost + + defp parse_host(host) do + charlist = to_charlist(host) + + case :inet.parse_address(charlist) do + {:error, :einval} -> + charlist + + {:ok, ip} -> + ip + end + end + + defp find_valid_delimiter([], _string, _) do + raise(ArgumentError, message: "valid delimiter for Regex expression not found") + end + + defp find_valid_delimiter([{leading, closing} = delimiter | others], pattern, regex_delimiter) + when is_tuple(delimiter) do + if String.contains?(pattern, closing) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {leading, closing}} + end + end + + defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do + if String.contains?(pattern, delimiter) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {delimiter, delimiter}} + end + end + + defp do_transform_string("~r" <> _pattern = regex) do + with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- + Regex.named_captures(@regex, regex), + {:ok, {leading, closing}} <- find_valid_delimiter(@delimiters, pattern, regex_delimiter), + {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do + result + end + end + + defp do_transform_string(":" <> atom), do: String.to_atom(atom) + + defp do_transform_string(value) do + if is_module_name?(value) do + String.to_existing_atom("Elixir." <> value) + else + value + end + end + + @spec is_module_name?(String.t()) :: boolean() + def is_module_name?(string) do + Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack|Ueberauth|Swoosh)\./, string) or + string in ["Oban", "Ueberauth", "ExSyslogger"] + end +end diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 240fb1c37..c39a8984b 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.DeprecationWarnings do diff --git a/lib/pleroma/config/holder.ex b/lib/pleroma/config/holder.ex new file mode 100644 index 000000000..f037d5d48 --- /dev/null +++ b/lib/pleroma/config/holder.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Holder do + @config Pleroma.Config.Loader.default_config() + + @spec save_default() :: :ok + def save_default do + default_config = + if System.get_env("RELEASE_NAME") do + release_config = + [:code.root_dir(), "releases", System.get_env("RELEASE_VSN"), "releases.exs"] + |> Path.join() + |> Pleroma.Config.Loader.read() + + Pleroma.Config.Loader.merge(@config, release_config) + else + @config + end + + Pleroma.Config.put(:default_config, default_config) + end + + @spec default_config() :: keyword() + def default_config, do: get_default() + + @spec default_config(atom()) :: keyword() + def default_config(group), do: Keyword.get(get_default(), group) + + @spec default_config(atom(), atom()) :: keyword() + def default_config(group, key), do: get_in(get_default(), [group, key]) + + defp get_default, do: Pleroma.Config.get(:default_config) +end diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex new file mode 100644 index 000000000..0f3ecf1ed --- /dev/null +++ b/lib/pleroma/config/loader.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Loader do + @reject_keys [ + Pleroma.Repo, + Pleroma.Web.Endpoint, + :env, + :configurable_from_database, + :database, + :swarm + ] + + if Code.ensure_loaded?(Config.Reader) do + @reader Config.Reader + + def read(path), do: @reader.read!(path) + else + # support for Elixir less than 1.9 + @reader Mix.Config + def read(path) do + path + |> @reader.eval!() + |> elem(0) + end + end + + @spec read(Path.t()) :: keyword() + + @spec merge(keyword(), keyword()) :: keyword() + def merge(c1, c2), do: @reader.merge(c1, c2) + + @spec default_config() :: keyword() + def default_config do + "config/config.exs" + |> read() + |> filter() + end + + defp filter(configs) do + configs + |> Keyword.keys() + |> Enum.reduce([], &Keyword.put(&2, &1, filter_group(&1, configs))) + end + + @spec filter_group(atom(), keyword()) :: keyword() + def filter_group(group, configs) do + Enum.reject(configs[group], fn {key, _v} -> + key in @reject_keys or (group == :phoenix and key == :serve_endpoints) or group == :postgrex + end) + end +end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 3214c9951..c02b70e96 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -1,59 +1,209 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.TransferTask do use Task - alias Pleroma.Web.AdminAPI.Config + + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.Repo + + require Logger + + @type env() :: :test | :benchmark | :dev | :prod + + @reboot_time_keys [ + {:pleroma, :hackney_pools}, + {:pleroma, :chat}, + {:pleroma, Oban}, + {:pleroma, :rate_limit}, + {:pleroma, :markup}, + {:pleroma, :streamer}, + {:pleroma, :pools}, + {:pleroma, :connections_pool} + ] + + @reboot_time_subkeys [ + {:pleroma, Pleroma.Captcha, [:seconds_valid]}, + {:pleroma, Pleroma.Upload, [:proxy_remote]}, + {:pleroma, :instance, [:upload_limit]}, + {:pleroma, :email_notifications, [:digest]}, + {:pleroma, :oauth2, [:clean_expired_tokens]}, + {:pleroma, Pleroma.ActivityExpiration, [:enabled]}, + {:pleroma, Pleroma.ScheduledActivity, [:enabled]}, + {:pleroma, :gopher, [:enabled]} + ] def start_link(_) do load_and_update_env() - if Pleroma.Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Pleroma.Repo) + if Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) :ignore end - def load_and_update_env do - if Pleroma.Config.get([:instance, :dynamic_configuration]) and - Ecto.Adapters.SQL.table_exists?(Pleroma.Repo, "config") do - for_restart = - Pleroma.Repo.all(Config) - |> Enum.map(&update_env(&1)) - + @spec load_and_update_env([ConfigDB.t()], boolean()) :: :ok + def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do + with {_, true} <- {:configurable, Config.get(:configurable_from_database)} do # We need to restart applications for loaded settings take effect - for_restart - |> Enum.reject(&(&1 in [:pleroma, :ok])) - |> Enum.each(fn app -> - Application.stop(app) - :ok = Application.start(app) - end) - end - end - defp update_env(setting) do - try do - key = - if String.starts_with?(setting.key, "Pleroma.") do - "Elixir." <> setting.key + {logger, other} = + (Repo.all(ConfigDB) ++ deleted_settings) + |> Enum.map(&transform_and_merge/1) + |> Enum.split_with(fn {group, _, _, _} -> group in [:logger, :quack] end) + + logger + |> Enum.sort() + |> Enum.each(&configure/1) + + started_applications = Application.started_applications() + + # TODO: some problem with prometheus after restart! + reject = [nil, :prometheus, :postgrex] + + reject = + if restart_pleroma? do + reject else - String.trim_leading(setting.key, ":") + [:pleroma | reject] end - group = String.to_existing_atom(setting.group) + other + |> Enum.map(&update/1) + |> Enum.uniq() + |> Enum.reject(&(&1 in reject)) + |> maybe_set_pleroma_last() + |> Enum.each(&restart(started_applications, &1, Config.get(:env))) + + :ok + else + {:configurable, false} -> Restarter.Pleroma.rebooted() + end + end + + defp maybe_set_pleroma_last(apps) do + # to be ensured that pleroma will be restarted last + if :pleroma in apps do + apps + |> List.delete(:pleroma) + |> List.insert_at(-1, :pleroma) + else + Restarter.Pleroma.rebooted() + apps + end + end + + defp transform_and_merge(%{group: group, key: key, value: value} = setting) do + group = ConfigDB.from_string(group) + key = ConfigDB.from_string(key) + value = ConfigDB.from_binary(value) + + default = Config.Holder.default_config(group, key) + + merged = + cond do + Ecto.get_meta(setting, :state) == :deleted -> default + can_be_merged?(default, value) -> ConfigDB.merge_group(group, key, default, value) + true -> value + end + + {group, key, value, merged} + end + + # change logger configuration in runtime, without restart + defp configure({:quack, key, _, merged}) do + Logger.configure_backend(Quack.Logger, [{key, merged}]) + :ok = update_env(:quack, key, merged) + end + + defp configure({_, :backends, _, merged}) do + # removing current backends + Enum.each(Application.get_env(:logger, :backends), &Logger.remove_backend/1) - Application.put_env( - group, - String.to_existing_atom(key), - Config.from_binary(setting.value) - ) + Enum.each(merged, &Logger.add_backend/1) - group + :ok = update_env(:logger, :backends, merged) + end + + defp configure({_, key, _, merged}) when key in [:console, :ex_syslogger] do + merged = + if key == :console do + put_in(merged[:format], merged[:format] <> "\n") + else + merged + end + + backend = + if key == :ex_syslogger, + do: {ExSyslogger, :ex_syslogger}, + else: key + + Logger.configure_backend(backend, merged) + :ok = update_env(:logger, key, merged) + end + + defp configure({_, key, _, merged}) do + Logger.configure([{key, merged}]) + :ok = update_env(:logger, key, merged) + end + + defp update({group, key, value, merged}) do + try do + :ok = update_env(group, key, merged) + + if group != :pleroma or pleroma_need_restart?(group, key, value), do: group rescue - e -> - require Logger + error -> + error_msg = + "updating env causes error, group: #{inspect(group)}, key: #{inspect(key)}, value: #{ + inspect(value) + } error: #{inspect(error)}" + + Logger.warn(error_msg) - Logger.warn( - "updating env causes error, key: #{inspect(setting.key)}, error: #{inspect(e)}" - ) + nil end end + + defp update_env(group, key, nil), do: Application.delete_env(group, key) + defp update_env(group, key, value), do: Application.put_env(group, key, value) + + @spec pleroma_need_restart?(atom(), atom(), any()) :: boolean() + def pleroma_need_restart?(group, key, value) do + group_and_key_need_reboot?(group, key) or group_and_subkey_need_reboot?(group, key, value) + end + + defp group_and_key_need_reboot?(group, key) do + Enum.any?(@reboot_time_keys, fn {g, k} -> g == group and k == key end) + end + + defp group_and_subkey_need_reboot?(group, key, value) do + Keyword.keyword?(value) and + Enum.any?(@reboot_time_subkeys, fn {g, k, subkeys} -> + g == group and k == key and + Enum.any?(Keyword.keys(value), &(&1 in subkeys)) + end) + end + + defp restart(_, :pleroma, env), do: Restarter.Pleroma.restart_after_boot(env) + + defp restart(started_applications, app, _) do + with {^app, _, _} <- List.keyfind(started_applications, app, 0), + :ok <- Application.stop(app) do + :ok = Application.start(app) + else + nil -> + Logger.warn("#{app} is not started.") + + error -> + error + |> inspect() + |> Logger.warn() + end + end + + defp can_be_merged?(val1, val2) when is_list(val1) and is_list(val2) do + Keyword.keyword?(val1) and Keyword.keyword?(val2) + end + + defp can_be_merged?(_val1, _val2), do: false end diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 0bf20cdd0..06174f624 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Constants do @@ -9,13 +9,21 @@ defmodule Pleroma.Constants do const(object_internal_fields, do: [ + "reactions", + "reaction_count", "likes", "like_count", "announcements", "announcement_count", "emoji", "context_id", - "deleted_activity_id" + "deleted_activity_id", + "pleroma_internal" ] ) + + const(static_only_files, + do: + ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc) + ) end diff --git a/lib/pleroma/conversation.ex b/lib/pleroma/conversation.ex index 098016af2..37d455cfc 100644 --- a/lib/pleroma/conversation.ex +++ b/lib/pleroma/conversation.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Conversation do @@ -67,7 +67,13 @@ defmodule Pleroma.Conversation do participations = Enum.map(users, fn user -> - User.increment_unread_conversation_count(conversation, user) + invisible_conversation = Enum.any?(users, &User.blocks?(user, &1)) + + unless invisible_conversation do + User.increment_unread_conversation_count(conversation, user) + end + + opts = Keyword.put(opts, :invisible_conversation, invisible_conversation) {:ok, participation} = Participation.create_for_user_and_conversation(user, conversation, opts) diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index 41918fa78..51bb1bda9 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Conversation.Participation do @@ -32,11 +32,20 @@ defmodule Pleroma.Conversation.Participation do def create_for_user_and_conversation(user, conversation, opts \\ []) do read = !!opts[:read] + invisible_conversation = !!opts[:invisible_conversation] + + update_on_conflict = + if(invisible_conversation, do: [], else: [read: read]) + |> Keyword.put(:updated_at, NaiveDateTime.utc_now()) %__MODULE__{} - |> creation_cng(%{user_id: user.id, conversation_id: conversation.id, read: read}) + |> creation_cng(%{ + user_id: user.id, + conversation_id: conversation.id, + read: invisible_conversation || read + }) |> Repo.insert( - on_conflict: [set: [read: read, updated_at: NaiveDateTime.utc_now()]], + on_conflict: [set: update_on_conflict], returning: true, conflict_target: [:user_id, :conversation_id] ) @@ -55,11 +64,13 @@ defmodule Pleroma.Conversation.Participation do end def mark_as_read(participation) do - participation - |> read_cng(%{read: true}) - |> Repo.update() + __MODULE__ + |> where(id: ^participation.id) + |> update(set: [read: true]) + |> select([p], p) + |> Repo.update_all([]) |> case do - {:ok, participation} -> + {1, [participation]} -> participation = Repo.preload(participation, :user) User.set_unread_conversation_count(participation.user) {:ok, participation} @@ -69,7 +80,26 @@ defmodule Pleroma.Conversation.Participation do end end - def mark_all_as_read(user) do + def mark_all_as_read(%User{local: true} = user, %User{} = target_user) do + target_conversation_ids = + __MODULE__ + |> where([p], p.user_id == ^target_user.id) + |> select([p], p.conversation_id) + |> Repo.all() + + __MODULE__ + |> where([p], p.user_id == ^user.id) + |> where([p], p.conversation_id in ^target_conversation_ids) + |> update([p], set: [read: true]) + |> Repo.update_all([]) + + {:ok, user} = User.set_unread_conversation_count(user) + {:ok, user, []} + end + + def mark_all_as_read(%User{} = user, %User{}), do: {:ok, user, []} + + def mark_all_as_read(%User{} = user) do {_, participations} = __MODULE__ |> where([p], p.user_id == ^user.id) @@ -78,8 +108,8 @@ defmodule Pleroma.Conversation.Participation do |> select([p], p) |> Repo.update_all([]) - User.set_unread_conversation_count(user) - {:ok, participations} + {:ok, user} = User.set_unread_conversation_count(user) + {:ok, user, participations} end def mark_as_unread(participation) do @@ -94,9 +124,32 @@ defmodule Pleroma.Conversation.Participation do order_by: [desc: p.updated_at], preload: [conversation: [:users]] ) + |> restrict_recipients(user, params) |> Pleroma.Pagination.fetch_paginated(params) end + def restrict_recipients(query, user, %{recipients: user_ids}) do + user_binary_ids = + [user.id | user_ids] + |> Enum.uniq() + |> User.binary_id() + + conversation_subquery = + __MODULE__ + |> group_by([p], p.conversation_id) + |> having( + [p], + count(p.user_id) == ^length(user_binary_ids) and + fragment("array_agg(?) @> ?", p.user_id, ^user_binary_ids) + ) + |> select([p], %{id: p.conversation_id}) + + query + |> join(:inner, [p], c in subquery(conversation_subquery), on: p.conversation_id == c.id) + end + + def restrict_recipients(query, _, _), do: query + def for_user_and_conversation(user, conversation) do from(p in __MODULE__, where: p.user_id == ^user.id, @@ -119,7 +172,7 @@ defmodule Pleroma.Conversation.Participation do | last_activity_id: activity_id } end) - |> Enum.filter(& &1.last_activity_id) + |> Enum.reject(&is_nil(&1.last_activity_id)) end def get(_, _ \\ []) diff --git a/lib/pleroma/conversation/participation_recipient_ship.ex b/lib/pleroma/conversation/participation_recipient_ship.ex index e3d158cbc..de40bacac 100644 --- a/lib/pleroma/conversation/participation_recipient_ship.ex +++ b/lib/pleroma/conversation/participation_recipient_ship.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Conversation.Participation.RecipientShip do diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex new file mode 100644 index 000000000..4d348a413 --- /dev/null +++ b/lib/pleroma/counter_cache.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.CounterCache do + alias Pleroma.CounterCache + alias Pleroma.Repo + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + schema "counter_cache" do + field(:name, :string) + field(:count, :integer) + end + + def changeset(struct, params) do + struct + |> cast(params, [:name, :count]) + |> validate_required([:name]) + |> unique_constraint(:name) + end + + def get_as_map(names) when is_list(names) do + CounterCache + |> where([cc], cc.name in ^names) + |> Repo.all() + |> Enum.group_by(& &1.name, & &1.count) + |> Map.new(fn {k, v} -> {k, hd(v)} end) + end + + def set(name, count) do + %CounterCache{} + |> changeset(%{"name" => name, "count" => count}) + |> Repo.insert( + on_conflict: [set: [count: count]], + returning: true, + conflict_target: :name + ) + end +end diff --git a/lib/pleroma/daemons/activity_expiration_daemon.ex b/lib/pleroma/daemons/activity_expiration_daemon.ex deleted file mode 100644 index cab7628c4..000000000 --- a/lib/pleroma/daemons/activity_expiration_daemon.ex +++ /dev/null @@ -1,66 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Daemons.ActivityExpirationDaemon do - alias Pleroma.Activity - alias Pleroma.ActivityExpiration - alias Pleroma.Config - alias Pleroma.Repo - alias Pleroma.User - alias Pleroma.Web.CommonAPI - - require Logger - use GenServer - import Ecto.Query - - @schedule_interval :timer.minutes(1) - - def start_link(_) do - GenServer.start_link(__MODULE__, nil) - end - - @impl true - def init(_) do - if Config.get([ActivityExpiration, :enabled]) do - schedule_next() - {:ok, nil} - else - :ignore - end - end - - def perform(:execute, expiration_id) do - try do - expiration = - ActivityExpiration - |> where([e], e.id == ^expiration_id) - |> Repo.one!() - - activity = Activity.get_by_id_with_object(expiration.activity_id) - user = User.get_by_ap_id(activity.object.data["actor"]) - CommonAPI.delete(activity.id, user) - rescue - error -> - Logger.error("#{__MODULE__} Couldn't delete expired activity: #{inspect(error)}") - end - end - - @impl true - def handle_info(:perform, state) do - ActivityExpiration.due_expirations(@schedule_interval) - |> Enum.each(fn expiration -> - Pleroma.Workers.ActivityExpirationWorker.enqueue( - "activity_expiration", - %{"activity_expiration_id" => expiration.id} - ) - end) - - schedule_next() - {:noreply, state} - end - - defp schedule_next do - Process.send_after(self(), :perform, @schedule_interval) - end -end diff --git a/lib/pleroma/daemons/digest_email_daemon.ex b/lib/pleroma/daemons/digest_email_daemon.ex deleted file mode 100644 index 462ad2c55..000000000 --- a/lib/pleroma/daemons/digest_email_daemon.ex +++ /dev/null @@ -1,42 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Daemons.DigestEmailDaemon do - alias Pleroma.Repo - alias Pleroma.Workers.DigestEmailsWorker - - import Ecto.Query - - def perform do - config = Pleroma.Config.get([:email_notifications, :digest]) - negative_interval = -Map.fetch!(config, :interval) - inactivity_threshold = Map.fetch!(config, :inactivity_threshold) - inactive_users_query = Pleroma.User.list_inactive_users_query(inactivity_threshold) - - now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) - - from(u in inactive_users_query, - where: fragment(~s(? #> '{"email_notifications","digest"}' @> 'true'), u.info), - where: u.last_digest_emailed_at < datetime_add(^now, ^negative_interval, "day"), - select: u - ) - |> Repo.all() - |> Enum.each(fn user -> - DigestEmailsWorker.enqueue("digest_email", %{"user_id" => user.id}) - end) - end - - @doc """ - Send digest email to the given user. - Updates `last_digest_emailed_at` field for the user and returns the updated user. - """ - @spec perform(Pleroma.User.t()) :: Pleroma.User.t() - def perform(user) do - with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(user) do - Pleroma.Emails.Mailer.deliver_async(email) - end - - Pleroma.User.touch_last_digest_emailed_at(user) - end -end diff --git a/lib/pleroma/daemons/scheduled_activity_daemon.ex b/lib/pleroma/daemons/scheduled_activity_daemon.ex deleted file mode 100644 index aee5f723a..000000000 --- a/lib/pleroma/daemons/scheduled_activity_daemon.ex +++ /dev/null @@ -1,62 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Daemons.ScheduledActivityDaemon do - @moduledoc """ - Sends scheduled activities to the job queue. - """ - - alias Pleroma.Config - alias Pleroma.ScheduledActivity - alias Pleroma.User - alias Pleroma.Web.CommonAPI - - use GenServer - require Logger - - @schedule_interval :timer.minutes(1) - - def start_link(_) do - GenServer.start_link(__MODULE__, nil) - end - - def init(_) do - if Config.get([ScheduledActivity, :enabled]) do - schedule_next() - {:ok, nil} - else - :ignore - end - end - - def perform(:execute, scheduled_activity_id) do - try do - {:ok, scheduled_activity} = ScheduledActivity.delete(scheduled_activity_id) - %User{} = user = User.get_cached_by_id(scheduled_activity.user_id) - {:ok, _result} = CommonAPI.post(user, scheduled_activity.params) - rescue - error -> - Logger.error( - "#{__MODULE__} Couldn't create a status from the scheduled activity: #{inspect(error)}" - ) - end - end - - def handle_info(:perform, state) do - ScheduledActivity.due_activities(@schedule_interval) - |> Enum.each(fn scheduled_activity -> - Pleroma.Workers.ScheduledActivityWorker.enqueue( - "execute", - %{"activity_id" => scheduled_activity.id} - ) - end) - - schedule_next() - {:noreply, state} - end - - defp schedule_next do - Process.send_after(self(), :perform, @schedule_interval) - end -end diff --git a/lib/pleroma/delivery.ex b/lib/pleroma/delivery.ex index 1d586a252..0ded2855c 100644 --- a/lib/pleroma/delivery.ex +++ b/lib/pleroma/delivery.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Delivery do diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex index aa578eee2..e0fc8cd02 100644 --- a/lib/pleroma/docs/generator.ex +++ b/lib/pleroma/docs/generator.ex @@ -6,68 +6,116 @@ defmodule Pleroma.Docs.Generator do implementation.process(descriptions) end - @spec uploaders_list() :: [module()] - def uploaders_list do - {:ok, modules} = :application.get_key(:pleroma, :modules) + @spec list_modules_in_dir(String.t(), String.t()) :: [module()] + def list_modules_in_dir(dir, start) do + with {:ok, files} <- File.ls(dir) do + files + |> Enum.filter(&String.ends_with?(&1, ".ex")) + |> Enum.map(fn filename -> + module = filename |> String.trim_trailing(".ex") |> Macro.camelize() + String.to_atom(start <> module) + end) + end + end + + @doc """ + Converts: + - atoms to strings with leading `:` + - module names to strings, without leading `Elixir.` + - add humanized labels to `keys` if label is not defined, e.g. `:instance` -> `Instance` + """ + @spec convert_to_strings([map()]) :: [map()] + def convert_to_strings(descriptions) do + Enum.map(descriptions, &format_entity(&1)) + end - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) + defp format_entity(entity) do + entity + |> format_key() + |> Map.put(:group, atom_to_string(entity[:group])) + |> format_children() + end - List.starts_with?(name_as_list, ["Pleroma", "Uploaders"]) and - List.last(name_as_list) != "Uploader" - end) + defp format_key(%{key: key} = entity) do + entity + |> Map.put(:key, atom_to_string(key)) + |> Map.put(:label, entity[:label] || humanize(key)) end - @spec filters_list() :: [module()] - def filters_list do - {:ok, modules} = :application.get_key(:pleroma, :modules) + defp format_key(%{group: group} = entity) do + Map.put(entity, :label, entity[:label] || humanize(group)) + end - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) + defp format_key(entity), do: entity - List.starts_with?(name_as_list, ["Pleroma", "Upload", "Filter"]) - end) + defp format_children(%{children: children} = entity) do + Map.put(entity, :children, Enum.map(children, &format_child(&1))) end - @spec mrf_list() :: [module()] - def mrf_list do - {:ok, modules} = :application.get_key(:pleroma, :modules) + defp format_children(entity), do: entity + + defp format_child(%{suggestions: suggestions} = entity) do + entity + |> Map.put(:suggestions, format_suggestions(suggestions)) + |> format_key() + |> format_group() + |> format_children() + end - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) + defp format_child(entity) do + entity + |> format_key() + |> format_group() + |> format_children() + end - List.starts_with?(name_as_list, ["Pleroma", "Web", "ActivityPub", "MRF"]) and - length(name_as_list) > 4 - end) + defp format_group(%{group: group} = entity) do + Map.put(entity, :group, format_suggestion(group)) end - @spec richmedia_parsers() :: [module()] - def richmedia_parsers do - {:ok, modules} = :application.get_key(:pleroma, :modules) + defp format_group(entity), do: entity + + defp atom_to_string(entity) when is_binary(entity), do: entity - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) + defp atom_to_string(entity) when is_atom(entity), do: inspect(entity) - List.starts_with?(name_as_list, ["Pleroma", "Web", "RichMedia", "Parsers"]) and - length(name_as_list) == 5 - end) + defp humanize(entity) do + string = inspect(entity) + + if String.starts_with?(string, ":"), + do: Phoenix.Naming.humanize(entity), + else: string end + + defp format_suggestions([]), do: [] + + defp format_suggestions([suggestion | tail]) do + [format_suggestion(suggestion) | format_suggestions(tail)] + end + + defp format_suggestion(entity) when is_atom(entity) do + atom_to_string(entity) + end + + defp format_suggestion([head | tail] = entity) when is_list(entity) do + [format_suggestion(head) | format_suggestions(tail)] + end + + defp format_suggestion(entity) when is_tuple(entity) do + format_suggestions(Tuple.to_list(entity)) |> List.to_tuple() + end + + defp format_suggestion(entity), do: entity end defimpl Jason.Encoder, for: Tuple do - def encode(tuple, opts) do - Jason.Encode.list(Tuple.to_list(tuple), opts) - end + def encode(tuple, opts), do: Jason.Encode.list(Tuple.to_list(tuple), opts) end defimpl Jason.Encoder, for: [Regex, Function] do - def encode(term, opts) do - Jason.Encode.string(inspect(term), opts) - end + def encode(term, opts), do: Jason.Encode.string(inspect(term), opts) end defimpl String.Chars, for: Regex do - def to_string(term) do - inspect(term) - end + def to_string(term), do: inspect(term) end diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex index 18ba01d58..d1cf1f487 100644 --- a/lib/pleroma/docs/json.ex +++ b/lib/pleroma/docs/json.ex @@ -3,18 +3,21 @@ defmodule Pleroma.Docs.JSON do @spec process(keyword()) :: {:ok, String.t()} def process(descriptions) do - config_path = "docs/generate_config.json" - - with {:ok, file} <- File.open(config_path, [:write]), - json <- generate_json(descriptions), + with path <- "docs/generated_config.json", + {:ok, file} <- File.open(path, [:write, :utf8]), + formatted_descriptions <- + Pleroma.Docs.Generator.convert_to_strings(descriptions), + json <- Jason.encode!(formatted_descriptions), :ok <- IO.write(file, json), :ok <- File.close(file) do - {:ok, config_path} + {:ok, path} end end - @spec generate_json([keyword()]) :: String.t() - def generate_json(descriptions) do - Jason.encode!(descriptions) + def compile do + with config <- Pleroma.Config.Loader.read("config/description.exs") do + config[:pleroma][:config_description] + |> Pleroma.Docs.Generator.convert_to_strings() + end end end diff --git a/lib/pleroma/earmark_renderer.ex b/lib/pleroma/earmark_renderer.ex new file mode 100644 index 000000000..6211a3b4a --- /dev/null +++ b/lib/pleroma/earmark_renderer.ex @@ -0,0 +1,256 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only +# +# This file is derived from Earmark, under the following copyright: +# Copyright © 2014 Dave Thomas, The Pragmatic Programmers +# SPDX-License-Identifier: Apache-2.0 +# Upstream: https://github.com/pragdave/earmark/blob/master/lib/earmark/html_renderer.ex +defmodule Pleroma.EarmarkRenderer do + @moduledoc false + + alias Earmark.Block + alias Earmark.Context + alias Earmark.HtmlRenderer + alias Earmark.Options + + import Earmark.Inline, only: [convert: 3] + import Earmark.Helpers.HtmlHelpers + import Earmark.Message, only: [add_messages_from: 2, get_messages: 1, set_messages: 2] + import Earmark.Context, only: [append: 2, set_value: 2] + import Earmark.Options, only: [get_mapper: 1] + + @doc false + def render(blocks, %Context{options: %Options{}} = context) do + messages = get_messages(context) + + {contexts, html} = + get_mapper(context.options).( + blocks, + &render_block(&1, put_in(context.options.messages, [])) + ) + |> Enum.unzip() + + all_messages = + contexts + |> Enum.reduce(messages, fn ctx, messages1 -> messages1 ++ get_messages(ctx) end) + + {put_in(context.options.messages, all_messages), html |> IO.iodata_to_binary()} + end + + ############# + # Paragraph # + ############# + defp render_block(%Block.Para{lnb: lnb, lines: lines, attrs: attrs}, context) do + lines = convert(lines, lnb, context) + add_attrs(lines, "<p>#{lines.value}</p>", attrs, [], lnb) + end + + ######## + # Html # + ######## + defp render_block(%Block.Html{html: html}, context) do + {context, html} + end + + defp render_block(%Block.HtmlComment{lines: lines}, context) do + {context, lines} + end + + defp render_block(%Block.HtmlOneline{html: html}, context) do + {context, html} + end + + ######### + # Ruler # + ######### + defp render_block(%Block.Ruler{lnb: lnb, attrs: attrs}, context) do + add_attrs(context, "<hr />", attrs, [], lnb) + end + + ########### + # Heading # + ########### + defp render_block( + %Block.Heading{lnb: lnb, level: level, content: content, attrs: attrs}, + context + ) do + converted = convert(content, lnb, context) + html = "<h#{level}>#{converted.value}</h#{level}>" + add_attrs(converted, html, attrs, [], lnb) + end + + ############## + # Blockquote # + ############## + + defp render_block(%Block.BlockQuote{lnb: lnb, blocks: blocks, attrs: attrs}, context) do + {context1, body} = render(blocks, context) + html = "<blockquote>#{body}</blockquote>" + add_attrs(context1, html, attrs, [], lnb) + end + + ######### + # Table # + ######### + + defp render_block( + %Block.Table{lnb: lnb, header: header, rows: rows, alignments: aligns, attrs: attrs}, + context + ) do + {context1, html} = add_attrs(context, "<table>", attrs, [], lnb) + context2 = set_value(context1, html) + + context3 = + if header do + append(add_trs(append(context2, "<thead>"), [header], "th", aligns, lnb), "</thead>") + else + # Maybe an error, needed append(context, html) + context2 + end + + context4 = append(add_trs(append(context3, "<tbody>"), rows, "td", aligns, lnb), "</tbody>") + + {context4, [context4.value, "</table>"]} + end + + ######## + # Code # + ######## + + defp render_block( + %Block.Code{lnb: lnb, language: language, attrs: attrs} = block, + %Context{options: options} = context + ) do + class = + if language, do: ~s{ class="#{code_classes(language, options.code_class_prefix)}"}, else: "" + + tag = ~s[<pre><code#{class}>] + lines = options.render_code.(block) + html = ~s[#{tag}#{lines}</code></pre>] + add_attrs(context, html, attrs, [], lnb) + end + + ######### + # Lists # + ######### + + defp render_block( + %Block.List{lnb: lnb, type: type, blocks: items, attrs: attrs, start: start}, + context + ) do + {context1, content} = render(items, context) + html = "<#{type}#{start}>#{content}</#{type}>" + add_attrs(context1, html, attrs, [], lnb) + end + + # format a single paragraph list item, and remove the para tags + defp render_block( + %Block.ListItem{lnb: lnb, blocks: blocks, spaced: false, attrs: attrs}, + context + ) + when length(blocks) == 1 do + {context1, content} = render(blocks, context) + content = Regex.replace(~r{</?p>}, content, "") + html = "<li>#{content}</li>" + add_attrs(context1, html, attrs, [], lnb) + end + + # format a spaced list item + defp render_block(%Block.ListItem{lnb: lnb, blocks: blocks, attrs: attrs}, context) do + {context1, content} = render(blocks, context) + html = "<li>#{content}</li>" + add_attrs(context1, html, attrs, [], lnb) + end + + ################## + # Footnote Block # + ################## + + defp render_block(%Block.FnList{blocks: footnotes}, context) do + items = + Enum.map(footnotes, fn note -> + blocks = append_footnote_link(note) + %Block.ListItem{attrs: "#fn:#{note.number}", type: :ol, blocks: blocks} + end) + + {context1, html} = render_block(%Block.List{type: :ol, blocks: items}, context) + {context1, Enum.join([~s[<div class="footnotes">], "<hr />", html, "</div>"])} + end + + ####################################### + # Isolated IALs are rendered as paras # + ####################################### + + defp render_block(%Block.Ial{verbatim: verbatim}, context) do + {context, "<p>{:#{verbatim}}</p>"} + end + + #################### + # IDDef is ignored # + #################### + + defp render_block(%Block.IdDef{}, context), do: {context, ""} + + ##################################### + # And here are the inline renderers # + ##################################### + + defdelegate br, to: HtmlRenderer + defdelegate codespan(text), to: HtmlRenderer + defdelegate em(text), to: HtmlRenderer + defdelegate strong(text), to: HtmlRenderer + defdelegate strikethrough(text), to: HtmlRenderer + + defdelegate link(url, text), to: HtmlRenderer + defdelegate link(url, text, title), to: HtmlRenderer + + defdelegate image(path, alt, title), to: HtmlRenderer + + defdelegate footnote_link(ref, backref, number), to: HtmlRenderer + + # Table rows + defp add_trs(context, rows, tag, aligns, lnb) do + numbered_rows = + rows + |> Enum.zip(Stream.iterate(lnb, &(&1 + 1))) + + numbered_rows + |> Enum.reduce(context, fn {row, lnb}, ctx -> + append(add_tds(append(ctx, "<tr>"), row, tag, aligns, lnb), "</tr>") + end) + end + + defp add_tds(context, row, tag, aligns, lnb) do + Enum.reduce(1..length(row), context, add_td_fn(row, tag, aligns, lnb)) + end + + defp add_td_fn(row, tag, aligns, lnb) do + fn n, ctx -> + style = + case Enum.at(aligns, n - 1, :default) do + :default -> "" + align -> " style=\"text-align: #{align}\"" + end + + col = Enum.at(row, n - 1) + converted = convert(col, lnb, set_messages(ctx, [])) + append(add_messages_from(ctx, converted), "<#{tag}#{style}>#{converted.value}</#{tag}>") + end + end + + ############################### + # Append Footnote Return Link # + ############################### + + defdelegate append_footnote_link(note), to: HtmlRenderer + defdelegate append_footnote_link(note, fnlink), to: HtmlRenderer + + defdelegate render_code(lines), to: HtmlRenderer + + defp code_classes(language, prefix) do + ["" | String.split(prefix || "")] + |> Enum.map(fn pfx -> "#{pfx}#{language}" end) + |> Enum.join(" ") + end +end diff --git a/lib/pleroma/ecto_enums.ex b/lib/pleroma/ecto_enums.ex new file mode 100644 index 000000000..6fc47620c --- /dev/null +++ b/lib/pleroma/ecto_enums.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 + +import EctoEnum + +defenum(Pleroma.UserRelationship.Type, + block: 1, + mute: 2, + reblog_mute: 3, + notification_mute: 4, + inverse_subscription: 5 +) + +defenum(Pleroma.FollowingRelationship.State, + follow_pending: 1, + follow_accept: 2, + follow_reject: 3 +) diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index b15e4041b..55f61024e 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emails.AdminEmail do @@ -7,6 +7,7 @@ defmodule Pleroma.Emails.AdminEmail do import Swoosh.Email + alias Pleroma.Config alias Pleroma.Web.Router.Helpers defp instance_config, do: Pleroma.Config.get(:instance) @@ -17,7 +18,20 @@ defmodule Pleroma.Emails.AdminEmail do end defp user_url(user) do - Helpers.feed_url(Pleroma.Web.Endpoint, :feed_redirect, user.id) + Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, user.id) + end + + def test_email(mail_to \\ nil) do + html_body = """ + <h3>Instance Test Email</h3> + <p>A test email was requested. Hello. :)</p> + """ + + new() + |> to(mail_to || Config.get([:instance, :email])) + |> from({instance_name(), instance_notify_email()}) + |> subject("Instance Test Email") + |> html_body(html_body) end def report(to, reporter, account, statuses, comment) do diff --git a/lib/pleroma/emails/mailer.ex b/lib/pleroma/emails/mailer.ex index eb96f2e8b..8b1bdef75 100644 --- a/lib/pleroma/emails/mailer.ex +++ b/lib/pleroma/emails/mailer.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emails.Mailer do diff --git a/lib/pleroma/emails/new_users_digest_email.ex b/lib/pleroma/emails/new_users_digest_email.ex new file mode 100644 index 000000000..7d16b807f --- /dev/null +++ b/lib/pleroma/emails/new_users_digest_email.ex @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emails.NewUsersDigestEmail do + use Phoenix.Swoosh, view: Pleroma.Web.EmailView, layout: {Pleroma.Web.LayoutView, :email_styled} + + defp instance_notify_email do + Pleroma.Config.get([:instance, :notify_email]) || Pleroma.Config.get([:instance, :email]) + end + + def new_users(to, users_and_statuses) do + instance_name = Pleroma.Config.get([:instance, :name]) + styling = Pleroma.Config.get([Pleroma.Emails.UserEmail, :styling]) + + logo_url = + Pleroma.Web.Endpoint.url() <> + Pleroma.Config.get([:frontend_configurations, :pleroma_fe, :logo]) + + new() + |> to({to.name, to.email}) + |> from({instance_name, instance_notify_email()}) + |> subject("#{instance_name} New Users") + |> render_body("new_users_digest.html", %{ + title: "New Users", + users_and_statuses: users_and_statuses, + instance: instance_name, + styling: styling, + logo_url: logo_url + }) + end +end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 40b67ff56..dfadc10b3 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emails.UserEmail do @@ -72,7 +72,7 @@ defmodule Pleroma.Emails.UserEmail do Endpoint, :confirm_email, user.id, - to_string(user.info.confirmation_token) + to_string(user.confirmation_token) ) html_body = """ diff --git a/lib/pleroma/emoji-data.txt b/lib/pleroma/emoji-data.txt new file mode 100644 index 000000000..2fb5c3ff6 --- /dev/null +++ b/lib/pleroma/emoji-data.txt @@ -0,0 +1,769 @@ +# emoji-data.txt +# Date: 2019-01-15, 12:10:05 GMT +# © 2019 Unicode®, Inc. +# Unicode and the Unicode Logo are registered trademarks of Unicode, Inc. in the U.S. and other countries. +# For terms of use, see http://www.unicode.org/terms_of_use.html +# +# Emoji Data for UTS #51 +# Version: 12.0 +# +# For documentation and usage, see http://www.unicode.org/reports/tr51 +# +# Format: +# <codepoint(s)> ; <property> # <comments> +# Note: there is no guarantee as to the structure of whitespace or comments +# +# Characters and sequences are listed in code point order. Users should be shown a more natural order. +# See the CLDR collation order for Emoji. + + +# ================================================ + +# All omitted code points have Emoji=No +# @missing: 0000..10FFFF ; Emoji ; No + +0023 ; Emoji # 1.1 [1] (#️) number sign +002A ; Emoji # 1.1 [1] (*️) asterisk +0030..0039 ; Emoji # 1.1 [10] (0️..9️) digit zero..digit nine +00A9 ; Emoji # 1.1 [1] (©️) copyright +00AE ; Emoji # 1.1 [1] (®️) registered +203C ; Emoji # 1.1 [1] (‼️) double exclamation mark +2049 ; Emoji # 3.0 [1] (⁉️) exclamation question mark +2122 ; Emoji # 1.1 [1] (™️) trade mark +2139 ; Emoji # 3.0 [1] (ℹ️) information +2194..2199 ; Emoji # 1.1 [6] (↔️..↙️) left-right arrow..down-left arrow +21A9..21AA ; Emoji # 1.1 [2] (↩️..↪️) right arrow curving left..left arrow curving right +231A..231B ; Emoji # 1.1 [2] (⌚..⌛) watch..hourglass done +2328 ; Emoji # 1.1 [1] (⌨️) keyboard +23CF ; Emoji # 4.0 [1] (⏏️) eject button +23E9..23F3 ; Emoji # 6.0 [11] (⏩..⏳) fast-forward button..hourglass not done +23F8..23FA ; Emoji # 7.0 [3] (⏸️..⏺️) pause button..record button +24C2 ; Emoji # 1.1 [1] (Ⓜ️) circled M +25AA..25AB ; Emoji # 1.1 [2] (▪️..▫️) black small square..white small square +25B6 ; Emoji # 1.1 [1] (▶️) play button +25C0 ; Emoji # 1.1 [1] (◀️) reverse button +25FB..25FE ; Emoji # 3.2 [4] (◻️..◾) white medium square..black medium-small square +2600..2604 ; Emoji # 1.1 [5] (☀️..☄️) sun..comet +260E ; Emoji # 1.1 [1] (☎️) telephone +2611 ; Emoji # 1.1 [1] (☑️) check box with check +2614..2615 ; Emoji # 4.0 [2] (☔..☕) umbrella with rain drops..hot beverage +2618 ; Emoji # 4.1 [1] (☘️) shamrock +261D ; Emoji # 1.1 [1] (☝️) index pointing up +2620 ; Emoji # 1.1 [1] (☠️) skull and crossbones +2622..2623 ; Emoji # 1.1 [2] (☢️..☣️) radioactive..biohazard +2626 ; Emoji # 1.1 [1] (☦️) orthodox cross +262A ; Emoji # 1.1 [1] (☪️) star and crescent +262E..262F ; Emoji # 1.1 [2] (☮️..☯️) peace symbol..yin yang +2638..263A ; Emoji # 1.1 [3] (☸️..☺️) wheel of dharma..smiling face +2640 ; Emoji # 1.1 [1] (♀️) female sign +2642 ; Emoji # 1.1 [1] (♂️) male sign +2648..2653 ; Emoji # 1.1 [12] (♈..♓) Aries..Pisces +265F..2660 ; Emoji # 1.1 [2] (♟️..♠️) chess pawn..spade suit +2663 ; Emoji # 1.1 [1] (♣️) club suit +2665..2666 ; Emoji # 1.1 [2] (♥️..♦️) heart suit..diamond suit +2668 ; Emoji # 1.1 [1] (♨️) hot springs +267B ; Emoji # 3.2 [1] (♻️) recycling symbol +267E..267F ; Emoji # 4.1 [2] (♾️..♿) infinity..wheelchair symbol +2692..2697 ; Emoji # 4.1 [6] (⚒️..⚗️) hammer and pick..alembic +2699 ; Emoji # 4.1 [1] (⚙️) gear +269B..269C ; Emoji # 4.1 [2] (⚛️..⚜️) atom symbol..fleur-de-lis +26A0..26A1 ; Emoji # 4.0 [2] (⚠️..⚡) warning..high voltage +26AA..26AB ; Emoji # 4.1 [2] (⚪..⚫) white circle..black circle +26B0..26B1 ; Emoji # 4.1 [2] (⚰️..⚱️) coffin..funeral urn +26BD..26BE ; Emoji # 5.2 [2] (⚽..⚾) soccer ball..baseball +26C4..26C5 ; Emoji # 5.2 [2] (⛄..⛅) snowman without snow..sun behind cloud +26C8 ; Emoji # 5.2 [1] (⛈️) cloud with lightning and rain +26CE ; Emoji # 6.0 [1] (⛎) Ophiuchus +26CF ; Emoji # 5.2 [1] (⛏️) pick +26D1 ; Emoji # 5.2 [1] (⛑️) rescue worker’s helmet +26D3..26D4 ; Emoji # 5.2 [2] (⛓️..⛔) chains..no entry +26E9..26EA ; Emoji # 5.2 [2] (⛩️..⛪) shinto shrine..church +26F0..26F5 ; Emoji # 5.2 [6] (⛰️..⛵) mountain..sailboat +26F7..26FA ; Emoji # 5.2 [4] (⛷️..⛺) skier..tent +26FD ; Emoji # 5.2 [1] (⛽) fuel pump +2702 ; Emoji # 1.1 [1] (✂️) scissors +2705 ; Emoji # 6.0 [1] (✅) check mark button +2708..2709 ; Emoji # 1.1 [2] (✈️..✉️) airplane..envelope +270A..270B ; Emoji # 6.0 [2] (✊..✋) raised fist..raised hand +270C..270D ; Emoji # 1.1 [2] (✌️..✍️) victory hand..writing hand +270F ; Emoji # 1.1 [1] (✏️) pencil +2712 ; Emoji # 1.1 [1] (✒️) black nib +2714 ; Emoji # 1.1 [1] (✔️) check mark +2716 ; Emoji # 1.1 [1] (✖️) multiplication sign +271D ; Emoji # 1.1 [1] (✝️) latin cross +2721 ; Emoji # 1.1 [1] (✡️) star of David +2728 ; Emoji # 6.0 [1] (✨) sparkles +2733..2734 ; Emoji # 1.1 [2] (✳️..✴️) eight-spoked asterisk..eight-pointed star +2744 ; Emoji # 1.1 [1] (❄️) snowflake +2747 ; Emoji # 1.1 [1] (❇️) sparkle +274C ; Emoji # 6.0 [1] (❌) cross mark +274E ; Emoji # 6.0 [1] (❎) cross mark button +2753..2755 ; Emoji # 6.0 [3] (❓..❕) question mark..white exclamation mark +2757 ; Emoji # 5.2 [1] (❗) exclamation mark +2763..2764 ; Emoji # 1.1 [2] (❣️..❤️) heart exclamation..red heart +2795..2797 ; Emoji # 6.0 [3] (➕..➗) plus sign..division sign +27A1 ; Emoji # 1.1 [1] (➡️) right arrow +27B0 ; Emoji # 6.0 [1] (➰) curly loop +27BF ; Emoji # 6.0 [1] (➿) double curly loop +2934..2935 ; Emoji # 3.2 [2] (⤴️..⤵️) right arrow curving up..right arrow curving down +2B05..2B07 ; Emoji # 4.0 [3] (⬅️..⬇️) left arrow..down arrow +2B1B..2B1C ; Emoji # 5.1 [2] (⬛..⬜) black large square..white large square +2B50 ; Emoji # 5.1 [1] (⭐) star +2B55 ; Emoji # 5.2 [1] (⭕) hollow red circle +3030 ; Emoji # 1.1 [1] (〰️) wavy dash +303D ; Emoji # 3.2 [1] (〽️) part alternation mark +3297 ; Emoji # 1.1 [1] (㊗️) Japanese “congratulations” button +3299 ; Emoji # 1.1 [1] (㊙️) Japanese “secret” button +1F004 ; Emoji # 5.1 [1] (🀄) mahjong red dragon +1F0CF ; Emoji # 6.0 [1] (🃏) joker +1F170..1F171 ; Emoji # 6.0 [2] (🅰️..🅱️) A button (blood type)..B button (blood type) +1F17E ; Emoji # 6.0 [1] (🅾️) O button (blood type) +1F17F ; Emoji # 5.2 [1] (🅿️) P button +1F18E ; Emoji # 6.0 [1] (🆎) AB button (blood type) +1F191..1F19A ; Emoji # 6.0 [10] (🆑..🆚) CL button..VS button +1F1E6..1F1FF ; Emoji # 6.0 [26] (🇦..🇿) regional indicator symbol letter a..regional indicator symbol letter z +1F201..1F202 ; Emoji # 6.0 [2] (🈁..🈂️) Japanese “here” button..Japanese “service charge” button +1F21A ; Emoji # 5.2 [1] (🈚) Japanese “free of charge” button +1F22F ; Emoji # 5.2 [1] (🈯) Japanese “reserved” button +1F232..1F23A ; Emoji # 6.0 [9] (🈲..🈺) Japanese “prohibited” button..Japanese “open for business” button +1F250..1F251 ; Emoji # 6.0 [2] (🉐..🉑) Japanese “bargain” button..Japanese “acceptable” button +1F300..1F320 ; Emoji # 6.0 [33] (🌀..🌠) cyclone..shooting star +1F321 ; Emoji # 7.0 [1] (🌡️) thermometer +1F324..1F32C ; Emoji # 7.0 [9] (🌤️..🌬️) sun behind small cloud..wind face +1F32D..1F32F ; Emoji # 8.0 [3] (🌭..🌯) hot dog..burrito +1F330..1F335 ; Emoji # 6.0 [6] (🌰..🌵) chestnut..cactus +1F336 ; Emoji # 7.0 [1] (🌶️) hot pepper +1F337..1F37C ; Emoji # 6.0 [70] (🌷..🍼) tulip..baby bottle +1F37D ; Emoji # 7.0 [1] (🍽️) fork and knife with plate +1F37E..1F37F ; Emoji # 8.0 [2] (🍾..🍿) bottle with popping cork..popcorn +1F380..1F393 ; Emoji # 6.0 [20] (🎀..🎓) ribbon..graduation cap +1F396..1F397 ; Emoji # 7.0 [2] (🎖️..🎗️) military medal..reminder ribbon +1F399..1F39B ; Emoji # 7.0 [3] (🎙️..🎛️) studio microphone..control knobs +1F39E..1F39F ; Emoji # 7.0 [2] (🎞️..🎟️) film frames..admission tickets +1F3A0..1F3C4 ; Emoji # 6.0 [37] (🎠..🏄) carousel horse..person surfing +1F3C5 ; Emoji # 7.0 [1] (🏅) sports medal +1F3C6..1F3CA ; Emoji # 6.0 [5] (🏆..🏊) trophy..person swimming +1F3CB..1F3CE ; Emoji # 7.0 [4] (🏋️..🏎️) person lifting weights..racing car +1F3CF..1F3D3 ; Emoji # 8.0 [5] (🏏..🏓) cricket game..ping pong +1F3D4..1F3DF ; Emoji # 7.0 [12] (🏔️..🏟️) snow-capped mountain..stadium +1F3E0..1F3F0 ; Emoji # 6.0 [17] (🏠..🏰) house..castle +1F3F3..1F3F5 ; Emoji # 7.0 [3] (🏳️..🏵️) white flag..rosette +1F3F7 ; Emoji # 7.0 [1] (🏷️) label +1F3F8..1F3FF ; Emoji # 8.0 [8] (🏸..🏿) badminton..dark skin tone +1F400..1F43E ; Emoji # 6.0 [63] (🐀..🐾) rat..paw prints +1F43F ; Emoji # 7.0 [1] (🐿️) chipmunk +1F440 ; Emoji # 6.0 [1] (👀) eyes +1F441 ; Emoji # 7.0 [1] (👁️) eye +1F442..1F4F7 ; Emoji # 6.0[182] (👂..📷) ear..camera +1F4F8 ; Emoji # 7.0 [1] (📸) camera with flash +1F4F9..1F4FC ; Emoji # 6.0 [4] (📹..📼) video camera..videocassette +1F4FD ; Emoji # 7.0 [1] (📽️) film projector +1F4FF ; Emoji # 8.0 [1] (📿) prayer beads +1F500..1F53D ; Emoji # 6.0 [62] (🔀..🔽) shuffle tracks button..downwards button +1F549..1F54A ; Emoji # 7.0 [2] (🕉️..🕊️) om..dove +1F54B..1F54E ; Emoji # 8.0 [4] (🕋..🕎) kaaba..menorah +1F550..1F567 ; Emoji # 6.0 [24] (🕐..🕧) one o’clock..twelve-thirty +1F56F..1F570 ; Emoji # 7.0 [2] (🕯️..🕰️) candle..mantelpiece clock +1F573..1F579 ; Emoji # 7.0 [7] (🕳️..🕹️) hole..joystick +1F57A ; Emoji # 9.0 [1] (🕺) man dancing +1F587 ; Emoji # 7.0 [1] (🖇️) linked paperclips +1F58A..1F58D ; Emoji # 7.0 [4] (🖊️..🖍️) pen..crayon +1F590 ; Emoji # 7.0 [1] (🖐️) hand with fingers splayed +1F595..1F596 ; Emoji # 7.0 [2] (🖕..🖖) middle finger..vulcan salute +1F5A4 ; Emoji # 9.0 [1] (🖤) black heart +1F5A5 ; Emoji # 7.0 [1] (🖥️) desktop computer +1F5A8 ; Emoji # 7.0 [1] (🖨️) printer +1F5B1..1F5B2 ; Emoji # 7.0 [2] (🖱️..🖲️) computer mouse..trackball +1F5BC ; Emoji # 7.0 [1] (🖼️) framed picture +1F5C2..1F5C4 ; Emoji # 7.0 [3] (🗂️..🗄️) card index dividers..file cabinet +1F5D1..1F5D3 ; Emoji # 7.0 [3] (🗑️..🗓️) wastebasket..spiral calendar +1F5DC..1F5DE ; Emoji # 7.0 [3] (🗜️..🗞️) clamp..rolled-up newspaper +1F5E1 ; Emoji # 7.0 [1] (🗡️) dagger +1F5E3 ; Emoji # 7.0 [1] (🗣️) speaking head +1F5E8 ; Emoji # 7.0 [1] (🗨️) left speech bubble +1F5EF ; Emoji # 7.0 [1] (🗯️) right anger bubble +1F5F3 ; Emoji # 7.0 [1] (🗳️) ballot box with ballot +1F5FA ; Emoji # 7.0 [1] (🗺️) world map +1F5FB..1F5FF ; Emoji # 6.0 [5] (🗻..🗿) mount fuji..moai +1F600 ; Emoji # 6.1 [1] (😀) grinning face +1F601..1F610 ; Emoji # 6.0 [16] (😁..😐) beaming face with smiling eyes..neutral face +1F611 ; Emoji # 6.1 [1] (😑) expressionless face +1F612..1F614 ; Emoji # 6.0 [3] (😒..😔) unamused face..pensive face +1F615 ; Emoji # 6.1 [1] (😕) confused face +1F616 ; Emoji # 6.0 [1] (😖) confounded face +1F617 ; Emoji # 6.1 [1] (😗) kissing face +1F618 ; Emoji # 6.0 [1] (😘) face blowing a kiss +1F619 ; Emoji # 6.1 [1] (😙) kissing face with smiling eyes +1F61A ; Emoji # 6.0 [1] (😚) kissing face with closed eyes +1F61B ; Emoji # 6.1 [1] (😛) face with tongue +1F61C..1F61E ; Emoji # 6.0 [3] (😜..😞) winking face with tongue..disappointed face +1F61F ; Emoji # 6.1 [1] (😟) worried face +1F620..1F625 ; Emoji # 6.0 [6] (😠..😥) angry face..sad but relieved face +1F626..1F627 ; Emoji # 6.1 [2] (😦..😧) frowning face with open mouth..anguished face +1F628..1F62B ; Emoji # 6.0 [4] (😨..😫) fearful face..tired face +1F62C ; Emoji # 6.1 [1] (😬) grimacing face +1F62D ; Emoji # 6.0 [1] (😭) loudly crying face +1F62E..1F62F ; Emoji # 6.1 [2] (😮..😯) face with open mouth..hushed face +1F630..1F633 ; Emoji # 6.0 [4] (😰..😳) anxious face with sweat..flushed face +1F634 ; Emoji # 6.1 [1] (😴) sleeping face +1F635..1F640 ; Emoji # 6.0 [12] (😵..🙀) dizzy face..weary cat +1F641..1F642 ; Emoji # 7.0 [2] (🙁..🙂) slightly frowning face..slightly smiling face +1F643..1F644 ; Emoji # 8.0 [2] (🙃..🙄) upside-down face..face with rolling eyes +1F645..1F64F ; Emoji # 6.0 [11] (🙅..🙏) person gesturing NO..folded hands +1F680..1F6C5 ; Emoji # 6.0 [70] (🚀..🛅) rocket..left luggage +1F6CB..1F6CF ; Emoji # 7.0 [5] (🛋️..🛏️) couch and lamp..bed +1F6D0 ; Emoji # 8.0 [1] (🛐) place of worship +1F6D1..1F6D2 ; Emoji # 9.0 [2] (🛑..🛒) stop sign..shopping cart +1F6D5 ; Emoji # 12.0 [1] (🛕) hindu temple +1F6E0..1F6E5 ; Emoji # 7.0 [6] (🛠️..🛥️) hammer and wrench..motor boat +1F6E9 ; Emoji # 7.0 [1] (🛩️) small airplane +1F6EB..1F6EC ; Emoji # 7.0 [2] (🛫..🛬) airplane departure..airplane arrival +1F6F0 ; Emoji # 7.0 [1] (🛰️) satellite +1F6F3 ; Emoji # 7.0 [1] (🛳️) passenger ship +1F6F4..1F6F6 ; Emoji # 9.0 [3] (🛴..🛶) kick scooter..canoe +1F6F7..1F6F8 ; Emoji # 10.0 [2] (🛷..🛸) sled..flying saucer +1F6F9 ; Emoji # 11.0 [1] (🛹) skateboard +1F6FA ; Emoji # 12.0 [1] (🛺) auto rickshaw +1F7E0..1F7EB ; Emoji # 12.0 [12] (🟠..🟫) orange circle..brown square +1F90D..1F90F ; Emoji # 12.0 [3] (🤍..🤏) white heart..pinching hand +1F910..1F918 ; Emoji # 8.0 [9] (🤐..🤘) zipper-mouth face..sign of the horns +1F919..1F91E ; Emoji # 9.0 [6] (🤙..🤞) call me hand..crossed fingers +1F91F ; Emoji # 10.0 [1] (🤟) love-you gesture +1F920..1F927 ; Emoji # 9.0 [8] (🤠..🤧) cowboy hat face..sneezing face +1F928..1F92F ; Emoji # 10.0 [8] (🤨..🤯) face with raised eyebrow..exploding head +1F930 ; Emoji # 9.0 [1] (🤰) pregnant woman +1F931..1F932 ; Emoji # 10.0 [2] (🤱..🤲) breast-feeding..palms up together +1F933..1F93A ; Emoji # 9.0 [8] (🤳..🤺) selfie..person fencing +1F93C..1F93E ; Emoji # 9.0 [3] (🤼..🤾) people wrestling..person playing handball +1F93F ; Emoji # 12.0 [1] (🤿) diving mask +1F940..1F945 ; Emoji # 9.0 [6] (🥀..🥅) wilted flower..goal net +1F947..1F94B ; Emoji # 9.0 [5] (🥇..🥋) 1st place medal..martial arts uniform +1F94C ; Emoji # 10.0 [1] (🥌) curling stone +1F94D..1F94F ; Emoji # 11.0 [3] (🥍..🥏) lacrosse..flying disc +1F950..1F95E ; Emoji # 9.0 [15] (🥐..🥞) croissant..pancakes +1F95F..1F96B ; Emoji # 10.0 [13] (🥟..🥫) dumpling..canned food +1F96C..1F970 ; Emoji # 11.0 [5] (🥬..🥰) leafy green..smiling face with hearts +1F971 ; Emoji # 12.0 [1] (🥱) yawning face +1F973..1F976 ; Emoji # 11.0 [4] (🥳..🥶) partying face..cold face +1F97A ; Emoji # 11.0 [1] (🥺) pleading face +1F97B ; Emoji # 12.0 [1] (🥻) sari +1F97C..1F97F ; Emoji # 11.0 [4] (🥼..🥿) lab coat..flat shoe +1F980..1F984 ; Emoji # 8.0 [5] (🦀..🦄) crab..unicorn +1F985..1F991 ; Emoji # 9.0 [13] (🦅..🦑) eagle..squid +1F992..1F997 ; Emoji # 10.0 [6] (🦒..🦗) giraffe..cricket +1F998..1F9A2 ; Emoji # 11.0 [11] (🦘..🦢) kangaroo..swan +1F9A5..1F9AA ; Emoji # 12.0 [6] (🦥..🦪) sloth..oyster +1F9AE..1F9AF ; Emoji # 12.0 [2] (🦮..🦯) guide dog..probing cane +1F9B0..1F9B9 ; Emoji # 11.0 [10] (🦰..🦹) red hair..supervillain +1F9BA..1F9BF ; Emoji # 12.0 [6] (🦺..🦿) safety vest..mechanical leg +1F9C0 ; Emoji # 8.0 [1] (🧀) cheese wedge +1F9C1..1F9C2 ; Emoji # 11.0 [2] (🧁..🧂) cupcake..salt +1F9C3..1F9CA ; Emoji # 12.0 [8] (🧃..🧊) beverage box..ice cube +1F9CD..1F9CF ; Emoji # 12.0 [3] (🧍..🧏) person standing..deaf person +1F9D0..1F9E6 ; Emoji # 10.0 [23] (🧐..🧦) face with monocle..socks +1F9E7..1F9FF ; Emoji # 11.0 [25] (🧧..🧿) red envelope..nazar amulet +1FA70..1FA73 ; Emoji # 12.0 [4] (🩰..🩳) ballet shoes..shorts +1FA78..1FA7A ; Emoji # 12.0 [3] (🩸..🩺) drop of blood..stethoscope +1FA80..1FA82 ; Emoji # 12.0 [3] (🪀..🪂) yo-yo..parachute +1FA90..1FA95 ; Emoji # 12.0 [6] (🪐..🪕) ringed planet..banjo + +# Total elements: 1311 + +# ================================================ + +# All omitted code points have Emoji_Presentation=No +# @missing: 0000..10FFFF ; Emoji_Presentation ; No + +231A..231B ; Emoji_Presentation # 1.1 [2] (⌚..⌛) watch..hourglass done +23E9..23EC ; Emoji_Presentation # 6.0 [4] (⏩..⏬) fast-forward button..fast down button +23F0 ; Emoji_Presentation # 6.0 [1] (⏰) alarm clock +23F3 ; Emoji_Presentation # 6.0 [1] (⏳) hourglass not done +25FD..25FE ; Emoji_Presentation # 3.2 [2] (◽..◾) white medium-small square..black medium-small square +2614..2615 ; Emoji_Presentation # 4.0 [2] (☔..☕) umbrella with rain drops..hot beverage +2648..2653 ; Emoji_Presentation # 1.1 [12] (♈..♓) Aries..Pisces +267F ; Emoji_Presentation # 4.1 [1] (♿) wheelchair symbol +2693 ; Emoji_Presentation # 4.1 [1] (⚓) anchor +26A1 ; Emoji_Presentation # 4.0 [1] (⚡) high voltage +26AA..26AB ; Emoji_Presentation # 4.1 [2] (⚪..⚫) white circle..black circle +26BD..26BE ; Emoji_Presentation # 5.2 [2] (⚽..⚾) soccer ball..baseball +26C4..26C5 ; Emoji_Presentation # 5.2 [2] (⛄..⛅) snowman without snow..sun behind cloud +26CE ; Emoji_Presentation # 6.0 [1] (⛎) Ophiuchus +26D4 ; Emoji_Presentation # 5.2 [1] (⛔) no entry +26EA ; Emoji_Presentation # 5.2 [1] (⛪) church +26F2..26F3 ; Emoji_Presentation # 5.2 [2] (⛲..⛳) fountain..flag in hole +26F5 ; Emoji_Presentation # 5.2 [1] (⛵) sailboat +26FA ; Emoji_Presentation # 5.2 [1] (⛺) tent +26FD ; Emoji_Presentation # 5.2 [1] (⛽) fuel pump +2705 ; Emoji_Presentation # 6.0 [1] (✅) check mark button +270A..270B ; Emoji_Presentation # 6.0 [2] (✊..✋) raised fist..raised hand +2728 ; Emoji_Presentation # 6.0 [1] (✨) sparkles +274C ; Emoji_Presentation # 6.0 [1] (❌) cross mark +274E ; Emoji_Presentation # 6.0 [1] (❎) cross mark button +2753..2755 ; Emoji_Presentation # 6.0 [3] (❓..❕) question mark..white exclamation mark +2757 ; Emoji_Presentation # 5.2 [1] (❗) exclamation mark +2795..2797 ; Emoji_Presentation # 6.0 [3] (➕..➗) plus sign..division sign +27B0 ; Emoji_Presentation # 6.0 [1] (➰) curly loop +27BF ; Emoji_Presentation # 6.0 [1] (➿) double curly loop +2B1B..2B1C ; Emoji_Presentation # 5.1 [2] (⬛..⬜) black large square..white large square +2B50 ; Emoji_Presentation # 5.1 [1] (⭐) star +2B55 ; Emoji_Presentation # 5.2 [1] (⭕) hollow red circle +1F004 ; Emoji_Presentation # 5.1 [1] (🀄) mahjong red dragon +1F0CF ; Emoji_Presentation # 6.0 [1] (🃏) joker +1F18E ; Emoji_Presentation # 6.0 [1] (🆎) AB button (blood type) +1F191..1F19A ; Emoji_Presentation # 6.0 [10] (🆑..🆚) CL button..VS button +1F1E6..1F1FF ; Emoji_Presentation # 6.0 [26] (🇦..🇿) regional indicator symbol letter a..regional indicator symbol letter z +1F201 ; Emoji_Presentation # 6.0 [1] (🈁) Japanese “here” button +1F21A ; Emoji_Presentation # 5.2 [1] (🈚) Japanese “free of charge” button +1F22F ; Emoji_Presentation # 5.2 [1] (🈯) Japanese “reserved” button +1F232..1F236 ; Emoji_Presentation # 6.0 [5] (🈲..🈶) Japanese “prohibited” button..Japanese “not free of charge” button +1F238..1F23A ; Emoji_Presentation # 6.0 [3] (🈸..🈺) Japanese “application” button..Japanese “open for business” button +1F250..1F251 ; Emoji_Presentation # 6.0 [2] (🉐..🉑) Japanese “bargain” button..Japanese “acceptable” button +1F300..1F320 ; Emoji_Presentation # 6.0 [33] (🌀..🌠) cyclone..shooting star +1F32D..1F32F ; Emoji_Presentation # 8.0 [3] (🌭..🌯) hot dog..burrito +1F330..1F335 ; Emoji_Presentation # 6.0 [6] (🌰..🌵) chestnut..cactus +1F337..1F37C ; Emoji_Presentation # 6.0 [70] (🌷..🍼) tulip..baby bottle +1F37E..1F37F ; Emoji_Presentation # 8.0 [2] (🍾..🍿) bottle with popping cork..popcorn +1F380..1F393 ; Emoji_Presentation # 6.0 [20] (🎀..🎓) ribbon..graduation cap +1F3A0..1F3C4 ; Emoji_Presentation # 6.0 [37] (🎠..🏄) carousel horse..person surfing +1F3C5 ; Emoji_Presentation # 7.0 [1] (🏅) sports medal +1F3C6..1F3CA ; Emoji_Presentation # 6.0 [5] (🏆..🏊) trophy..person swimming +1F3CF..1F3D3 ; Emoji_Presentation # 8.0 [5] (🏏..🏓) cricket game..ping pong +1F3E0..1F3F0 ; Emoji_Presentation # 6.0 [17] (🏠..🏰) house..castle +1F3F4 ; Emoji_Presentation # 7.0 [1] (🏴) black flag +1F3F8..1F3FF ; Emoji_Presentation # 8.0 [8] (🏸..🏿) badminton..dark skin tone +1F400..1F43E ; Emoji_Presentation # 6.0 [63] (🐀..🐾) rat..paw prints +1F440 ; Emoji_Presentation # 6.0 [1] (👀) eyes +1F442..1F4F7 ; Emoji_Presentation # 6.0[182] (👂..📷) ear..camera +1F4F8 ; Emoji_Presentation # 7.0 [1] (📸) camera with flash +1F4F9..1F4FC ; Emoji_Presentation # 6.0 [4] (📹..📼) video camera..videocassette +1F4FF ; Emoji_Presentation # 8.0 [1] (📿) prayer beads +1F500..1F53D ; Emoji_Presentation # 6.0 [62] (🔀..🔽) shuffle tracks button..downwards button +1F54B..1F54E ; Emoji_Presentation # 8.0 [4] (🕋..🕎) kaaba..menorah +1F550..1F567 ; Emoji_Presentation # 6.0 [24] (🕐..🕧) one o’clock..twelve-thirty +1F57A ; Emoji_Presentation # 9.0 [1] (🕺) man dancing +1F595..1F596 ; Emoji_Presentation # 7.0 [2] (🖕..🖖) middle finger..vulcan salute +1F5A4 ; Emoji_Presentation # 9.0 [1] (🖤) black heart +1F5FB..1F5FF ; Emoji_Presentation # 6.0 [5] (🗻..🗿) mount fuji..moai +1F600 ; Emoji_Presentation # 6.1 [1] (😀) grinning face +1F601..1F610 ; Emoji_Presentation # 6.0 [16] (😁..😐) beaming face with smiling eyes..neutral face +1F611 ; Emoji_Presentation # 6.1 [1] (😑) expressionless face +1F612..1F614 ; Emoji_Presentation # 6.0 [3] (😒..😔) unamused face..pensive face +1F615 ; Emoji_Presentation # 6.1 [1] (😕) confused face +1F616 ; Emoji_Presentation # 6.0 [1] (😖) confounded face +1F617 ; Emoji_Presentation # 6.1 [1] (😗) kissing face +1F618 ; Emoji_Presentation # 6.0 [1] (😘) face blowing a kiss +1F619 ; Emoji_Presentation # 6.1 [1] (😙) kissing face with smiling eyes +1F61A ; Emoji_Presentation # 6.0 [1] (😚) kissing face with closed eyes +1F61B ; Emoji_Presentation # 6.1 [1] (😛) face with tongue +1F61C..1F61E ; Emoji_Presentation # 6.0 [3] (😜..😞) winking face with tongue..disappointed face +1F61F ; Emoji_Presentation # 6.1 [1] (😟) worried face +1F620..1F625 ; Emoji_Presentation # 6.0 [6] (😠..😥) angry face..sad but relieved face +1F626..1F627 ; Emoji_Presentation # 6.1 [2] (😦..😧) frowning face with open mouth..anguished face +1F628..1F62B ; Emoji_Presentation # 6.0 [4] (😨..😫) fearful face..tired face +1F62C ; Emoji_Presentation # 6.1 [1] (😬) grimacing face +1F62D ; Emoji_Presentation # 6.0 [1] (😭) loudly crying face +1F62E..1F62F ; Emoji_Presentation # 6.1 [2] (😮..😯) face with open mouth..hushed face +1F630..1F633 ; Emoji_Presentation # 6.0 [4] (😰..😳) anxious face with sweat..flushed face +1F634 ; Emoji_Presentation # 6.1 [1] (😴) sleeping face +1F635..1F640 ; Emoji_Presentation # 6.0 [12] (😵..🙀) dizzy face..weary cat +1F641..1F642 ; Emoji_Presentation # 7.0 [2] (🙁..🙂) slightly frowning face..slightly smiling face +1F643..1F644 ; Emoji_Presentation # 8.0 [2] (🙃..🙄) upside-down face..face with rolling eyes +1F645..1F64F ; Emoji_Presentation # 6.0 [11] (🙅..🙏) person gesturing NO..folded hands +1F680..1F6C5 ; Emoji_Presentation # 6.0 [70] (🚀..🛅) rocket..left luggage +1F6CC ; Emoji_Presentation # 7.0 [1] (🛌) person in bed +1F6D0 ; Emoji_Presentation # 8.0 [1] (🛐) place of worship +1F6D1..1F6D2 ; Emoji_Presentation # 9.0 [2] (🛑..🛒) stop sign..shopping cart +1F6D5 ; Emoji_Presentation # 12.0 [1] (🛕) hindu temple +1F6EB..1F6EC ; Emoji_Presentation # 7.0 [2] (🛫..🛬) airplane departure..airplane arrival +1F6F4..1F6F6 ; Emoji_Presentation # 9.0 [3] (🛴..🛶) kick scooter..canoe +1F6F7..1F6F8 ; Emoji_Presentation # 10.0 [2] (🛷..🛸) sled..flying saucer +1F6F9 ; Emoji_Presentation # 11.0 [1] (🛹) skateboard +1F6FA ; Emoji_Presentation # 12.0 [1] (🛺) auto rickshaw +1F7E0..1F7EB ; Emoji_Presentation # 12.0 [12] (🟠..🟫) orange circle..brown square +1F90D..1F90F ; Emoji_Presentation # 12.0 [3] (🤍..🤏) white heart..pinching hand +1F910..1F918 ; Emoji_Presentation # 8.0 [9] (🤐..🤘) zipper-mouth face..sign of the horns +1F919..1F91E ; Emoji_Presentation # 9.0 [6] (🤙..🤞) call me hand..crossed fingers +1F91F ; Emoji_Presentation # 10.0 [1] (🤟) love-you gesture +1F920..1F927 ; Emoji_Presentation # 9.0 [8] (🤠..🤧) cowboy hat face..sneezing face +1F928..1F92F ; Emoji_Presentation # 10.0 [8] (🤨..🤯) face with raised eyebrow..exploding head +1F930 ; Emoji_Presentation # 9.0 [1] (🤰) pregnant woman +1F931..1F932 ; Emoji_Presentation # 10.0 [2] (🤱..🤲) breast-feeding..palms up together +1F933..1F93A ; Emoji_Presentation # 9.0 [8] (🤳..🤺) selfie..person fencing +1F93C..1F93E ; Emoji_Presentation # 9.0 [3] (🤼..🤾) people wrestling..person playing handball +1F93F ; Emoji_Presentation # 12.0 [1] (🤿) diving mask +1F940..1F945 ; Emoji_Presentation # 9.0 [6] (🥀..🥅) wilted flower..goal net +1F947..1F94B ; Emoji_Presentation # 9.0 [5] (🥇..🥋) 1st place medal..martial arts uniform +1F94C ; Emoji_Presentation # 10.0 [1] (🥌) curling stone +1F94D..1F94F ; Emoji_Presentation # 11.0 [3] (🥍..🥏) lacrosse..flying disc +1F950..1F95E ; Emoji_Presentation # 9.0 [15] (🥐..🥞) croissant..pancakes +1F95F..1F96B ; Emoji_Presentation # 10.0 [13] (🥟..🥫) dumpling..canned food +1F96C..1F970 ; Emoji_Presentation # 11.0 [5] (🥬..🥰) leafy green..smiling face with hearts +1F971 ; Emoji_Presentation # 12.0 [1] (🥱) yawning face +1F973..1F976 ; Emoji_Presentation # 11.0 [4] (🥳..🥶) partying face..cold face +1F97A ; Emoji_Presentation # 11.0 [1] (🥺) pleading face +1F97B ; Emoji_Presentation # 12.0 [1] (🥻) sari +1F97C..1F97F ; Emoji_Presentation # 11.0 [4] (🥼..🥿) lab coat..flat shoe +1F980..1F984 ; Emoji_Presentation # 8.0 [5] (🦀..🦄) crab..unicorn +1F985..1F991 ; Emoji_Presentation # 9.0 [13] (🦅..🦑) eagle..squid +1F992..1F997 ; Emoji_Presentation # 10.0 [6] (🦒..🦗) giraffe..cricket +1F998..1F9A2 ; Emoji_Presentation # 11.0 [11] (🦘..🦢) kangaroo..swan +1F9A5..1F9AA ; Emoji_Presentation # 12.0 [6] (🦥..🦪) sloth..oyster +1F9AE..1F9AF ; Emoji_Presentation # 12.0 [2] (🦮..🦯) guide dog..probing cane +1F9B0..1F9B9 ; Emoji_Presentation # 11.0 [10] (🦰..🦹) red hair..supervillain +1F9BA..1F9BF ; Emoji_Presentation # 12.0 [6] (🦺..🦿) safety vest..mechanical leg +1F9C0 ; Emoji_Presentation # 8.0 [1] (🧀) cheese wedge +1F9C1..1F9C2 ; Emoji_Presentation # 11.0 [2] (🧁..🧂) cupcake..salt +1F9C3..1F9CA ; Emoji_Presentation # 12.0 [8] (🧃..🧊) beverage box..ice cube +1F9CD..1F9CF ; Emoji_Presentation # 12.0 [3] (🧍..🧏) person standing..deaf person +1F9D0..1F9E6 ; Emoji_Presentation # 10.0 [23] (🧐..🧦) face with monocle..socks +1F9E7..1F9FF ; Emoji_Presentation # 11.0 [25] (🧧..🧿) red envelope..nazar amulet +1FA70..1FA73 ; Emoji_Presentation # 12.0 [4] (🩰..🩳) ballet shoes..shorts +1FA78..1FA7A ; Emoji_Presentation # 12.0 [3] (🩸..🩺) drop of blood..stethoscope +1FA80..1FA82 ; Emoji_Presentation # 12.0 [3] (🪀..🪂) yo-yo..parachute +1FA90..1FA95 ; Emoji_Presentation # 12.0 [6] (🪐..🪕) ringed planet..banjo + +# Total elements: 1093 + +# ================================================ + +# All omitted code points have Emoji_Modifier=No +# @missing: 0000..10FFFF ; Emoji_Modifier ; No + +1F3FB..1F3FF ; Emoji_Modifier # 8.0 [5] (🏻..🏿) light skin tone..dark skin tone + +# Total elements: 5 + +# ================================================ + +# All omitted code points have Emoji_Modifier_Base=No +# @missing: 0000..10FFFF ; Emoji_Modifier_Base ; No + +261D ; Emoji_Modifier_Base # 1.1 [1] (☝️) index pointing up +26F9 ; Emoji_Modifier_Base # 5.2 [1] (⛹️) person bouncing ball +270A..270B ; Emoji_Modifier_Base # 6.0 [2] (✊..✋) raised fist..raised hand +270C..270D ; Emoji_Modifier_Base # 1.1 [2] (✌️..✍️) victory hand..writing hand +1F385 ; Emoji_Modifier_Base # 6.0 [1] (🎅) Santa Claus +1F3C2..1F3C4 ; Emoji_Modifier_Base # 6.0 [3] (🏂..🏄) snowboarder..person surfing +1F3C7 ; Emoji_Modifier_Base # 6.0 [1] (🏇) horse racing +1F3CA ; Emoji_Modifier_Base # 6.0 [1] (🏊) person swimming +1F3CB..1F3CC ; Emoji_Modifier_Base # 7.0 [2] (🏋️..🏌️) person lifting weights..person golfing +1F442..1F443 ; Emoji_Modifier_Base # 6.0 [2] (👂..👃) ear..nose +1F446..1F450 ; Emoji_Modifier_Base # 6.0 [11] (👆..👐) backhand index pointing up..open hands +1F466..1F478 ; Emoji_Modifier_Base # 6.0 [19] (👦..👸) boy..princess +1F47C ; Emoji_Modifier_Base # 6.0 [1] (👼) baby angel +1F481..1F483 ; Emoji_Modifier_Base # 6.0 [3] (💁..💃) person tipping hand..woman dancing +1F485..1F487 ; Emoji_Modifier_Base # 6.0 [3] (💅..💇) nail polish..person getting haircut +1F48F ; Emoji_Modifier_Base # 6.0 [1] (💏) kiss +1F491 ; Emoji_Modifier_Base # 6.0 [1] (💑) couple with heart +1F4AA ; Emoji_Modifier_Base # 6.0 [1] (💪) flexed biceps +1F574..1F575 ; Emoji_Modifier_Base # 7.0 [2] (🕴️..🕵️) man in suit levitating..detective +1F57A ; Emoji_Modifier_Base # 9.0 [1] (🕺) man dancing +1F590 ; Emoji_Modifier_Base # 7.0 [1] (🖐️) hand with fingers splayed +1F595..1F596 ; Emoji_Modifier_Base # 7.0 [2] (🖕..🖖) middle finger..vulcan salute +1F645..1F647 ; Emoji_Modifier_Base # 6.0 [3] (🙅..🙇) person gesturing NO..person bowing +1F64B..1F64F ; Emoji_Modifier_Base # 6.0 [5] (🙋..🙏) person raising hand..folded hands +1F6A3 ; Emoji_Modifier_Base # 6.0 [1] (🚣) person rowing boat +1F6B4..1F6B6 ; Emoji_Modifier_Base # 6.0 [3] (🚴..🚶) person biking..person walking +1F6C0 ; Emoji_Modifier_Base # 6.0 [1] (🛀) person taking bath +1F6CC ; Emoji_Modifier_Base # 7.0 [1] (🛌) person in bed +1F90F ; Emoji_Modifier_Base # 12.0 [1] (🤏) pinching hand +1F918 ; Emoji_Modifier_Base # 8.0 [1] (🤘) sign of the horns +1F919..1F91E ; Emoji_Modifier_Base # 9.0 [6] (🤙..🤞) call me hand..crossed fingers +1F91F ; Emoji_Modifier_Base # 10.0 [1] (🤟) love-you gesture +1F926 ; Emoji_Modifier_Base # 9.0 [1] (🤦) person facepalming +1F930 ; Emoji_Modifier_Base # 9.0 [1] (🤰) pregnant woman +1F931..1F932 ; Emoji_Modifier_Base # 10.0 [2] (🤱..🤲) breast-feeding..palms up together +1F933..1F939 ; Emoji_Modifier_Base # 9.0 [7] (🤳..🤹) selfie..person juggling +1F93C..1F93E ; Emoji_Modifier_Base # 9.0 [3] (🤼..🤾) people wrestling..person playing handball +1F9B5..1F9B6 ; Emoji_Modifier_Base # 11.0 [2] (🦵..🦶) leg..foot +1F9B8..1F9B9 ; Emoji_Modifier_Base # 11.0 [2] (🦸..🦹) superhero..supervillain +1F9BB ; Emoji_Modifier_Base # 12.0 [1] (🦻) ear with hearing aid +1F9CD..1F9CF ; Emoji_Modifier_Base # 12.0 [3] (🧍..🧏) person standing..deaf person +1F9D1..1F9DD ; Emoji_Modifier_Base # 10.0 [13] (🧑..🧝) person..elf + +# Total elements: 120 + +# ================================================ + +# All omitted code points have Emoji_Component=No +# @missing: 0000..10FFFF ; Emoji_Component ; No + +0023 ; Emoji_Component # 1.1 [1] (#️) number sign +002A ; Emoji_Component # 1.1 [1] (*️) asterisk +0030..0039 ; Emoji_Component # 1.1 [10] (0️..9️) digit zero..digit nine +200D ; Emoji_Component # 1.1 [1] () zero width joiner +20E3 ; Emoji_Component # 3.0 [1] (⃣) combining enclosing keycap +FE0F ; Emoji_Component # 3.2 [1] () VARIATION SELECTOR-16 +1F1E6..1F1FF ; Emoji_Component # 6.0 [26] (🇦..🇿) regional indicator symbol letter a..regional indicator symbol letter z +1F3FB..1F3FF ; Emoji_Component # 8.0 [5] (🏻..🏿) light skin tone..dark skin tone +1F9B0..1F9B3 ; Emoji_Component # 11.0 [4] (🦰..🦳) red hair..white hair +E0020..E007F ; Emoji_Component # 3.1 [96] (..) tag space..cancel tag + +# Total elements: 146 + +# ================================================ + +# All omitted code points have Extended_Pictographic=No +# @missing: 0000..10FFFF ; Extended_Pictographic ; No + +00A9 ; Extended_Pictographic# 1.1 [1] (©️) copyright +00AE ; Extended_Pictographic# 1.1 [1] (®️) registered +203C ; Extended_Pictographic# 1.1 [1] (‼️) double exclamation mark +2049 ; Extended_Pictographic# 3.0 [1] (⁉️) exclamation question mark +2122 ; Extended_Pictographic# 1.1 [1] (™️) trade mark +2139 ; Extended_Pictographic# 3.0 [1] (ℹ️) information +2194..2199 ; Extended_Pictographic# 1.1 [6] (↔️..↙️) left-right arrow..down-left arrow +21A9..21AA ; Extended_Pictographic# 1.1 [2] (↩️..↪️) right arrow curving left..left arrow curving right +231A..231B ; Extended_Pictographic# 1.1 [2] (⌚..⌛) watch..hourglass done +2328 ; Extended_Pictographic# 1.1 [1] (⌨️) keyboard +2388 ; Extended_Pictographic# 3.0 [1] (⎈) HELM SYMBOL +23CF ; Extended_Pictographic# 4.0 [1] (⏏️) eject button +23E9..23F3 ; Extended_Pictographic# 6.0 [11] (⏩..⏳) fast-forward button..hourglass not done +23F8..23FA ; Extended_Pictographic# 7.0 [3] (⏸️..⏺️) pause button..record button +24C2 ; Extended_Pictographic# 1.1 [1] (Ⓜ️) circled M +25AA..25AB ; Extended_Pictographic# 1.1 [2] (▪️..▫️) black small square..white small square +25B6 ; Extended_Pictographic# 1.1 [1] (▶️) play button +25C0 ; Extended_Pictographic# 1.1 [1] (◀️) reverse button +25FB..25FE ; Extended_Pictographic# 3.2 [4] (◻️..◾) white medium square..black medium-small square +2600..2605 ; Extended_Pictographic# 1.1 [6] (☀️..★) sun..BLACK STAR +2607..2612 ; Extended_Pictographic# 1.1 [12] (☇..☒) LIGHTNING..BALLOT BOX WITH X +2614..2615 ; Extended_Pictographic# 4.0 [2] (☔..☕) umbrella with rain drops..hot beverage +2616..2617 ; Extended_Pictographic# 3.2 [2] (☖..☗) WHITE SHOGI PIECE..BLACK SHOGI PIECE +2618 ; Extended_Pictographic# 4.1 [1] (☘️) shamrock +2619 ; Extended_Pictographic# 3.0 [1] (☙) REVERSED ROTATED FLORAL HEART BULLET +261A..266F ; Extended_Pictographic# 1.1 [86] (☚..♯) BLACK LEFT POINTING INDEX..MUSIC SHARP SIGN +2670..2671 ; Extended_Pictographic# 3.0 [2] (♰..♱) WEST SYRIAC CROSS..EAST SYRIAC CROSS +2672..267D ; Extended_Pictographic# 3.2 [12] (♲..♽) UNIVERSAL RECYCLING SYMBOL..PARTIALLY-RECYCLED PAPER SYMBOL +267E..267F ; Extended_Pictographic# 4.1 [2] (♾️..♿) infinity..wheelchair symbol +2680..2685 ; Extended_Pictographic# 3.2 [6] (⚀..⚅) DIE FACE-1..DIE FACE-6 +2690..2691 ; Extended_Pictographic# 4.0 [2] (⚐..⚑) WHITE FLAG..BLACK FLAG +2692..269C ; Extended_Pictographic# 4.1 [11] (⚒️..⚜️) hammer and pick..fleur-de-lis +269D ; Extended_Pictographic# 5.1 [1] (⚝) OUTLINED WHITE STAR +269E..269F ; Extended_Pictographic# 5.2 [2] (⚞..⚟) THREE LINES CONVERGING RIGHT..THREE LINES CONVERGING LEFT +26A0..26A1 ; Extended_Pictographic# 4.0 [2] (⚠️..⚡) warning..high voltage +26A2..26B1 ; Extended_Pictographic# 4.1 [16] (⚢..⚱️) DOUBLED FEMALE SIGN..funeral urn +26B2 ; Extended_Pictographic# 5.0 [1] (⚲) NEUTER +26B3..26BC ; Extended_Pictographic# 5.1 [10] (⚳..⚼) CERES..SESQUIQUADRATE +26BD..26BF ; Extended_Pictographic# 5.2 [3] (⚽..⚿) soccer ball..SQUARED KEY +26C0..26C3 ; Extended_Pictographic# 5.1 [4] (⛀..⛃) WHITE DRAUGHTS MAN..BLACK DRAUGHTS KING +26C4..26CD ; Extended_Pictographic# 5.2 [10] (⛄..⛍) snowman without snow..DISABLED CAR +26CE ; Extended_Pictographic# 6.0 [1] (⛎) Ophiuchus +26CF..26E1 ; Extended_Pictographic# 5.2 [19] (⛏️..⛡) pick..RESTRICTED LEFT ENTRY-2 +26E2 ; Extended_Pictographic# 6.0 [1] (⛢) ASTRONOMICAL SYMBOL FOR URANUS +26E3 ; Extended_Pictographic# 5.2 [1] (⛣) HEAVY CIRCLE WITH STROKE AND TWO DOTS ABOVE +26E4..26E7 ; Extended_Pictographic# 6.0 [4] (⛤..⛧) PENTAGRAM..INVERTED PENTAGRAM +26E8..26FF ; Extended_Pictographic# 5.2 [24] (⛨..⛿) BLACK CROSS ON SHIELD..WHITE FLAG WITH HORIZONTAL MIDDLE BLACK STRIPE +2700 ; Extended_Pictographic# 7.0 [1] (✀) BLACK SAFETY SCISSORS +2701..2704 ; Extended_Pictographic# 1.1 [4] (✁..✄) UPPER BLADE SCISSORS..WHITE SCISSORS +2705 ; Extended_Pictographic# 6.0 [1] (✅) check mark button +2708..2709 ; Extended_Pictographic# 1.1 [2] (✈️..✉️) airplane..envelope +270A..270B ; Extended_Pictographic# 6.0 [2] (✊..✋) raised fist..raised hand +270C..2712 ; Extended_Pictographic# 1.1 [7] (✌️..✒️) victory hand..black nib +2714 ; Extended_Pictographic# 1.1 [1] (✔️) check mark +2716 ; Extended_Pictographic# 1.1 [1] (✖️) multiplication sign +271D ; Extended_Pictographic# 1.1 [1] (✝️) latin cross +2721 ; Extended_Pictographic# 1.1 [1] (✡️) star of David +2728 ; Extended_Pictographic# 6.0 [1] (✨) sparkles +2733..2734 ; Extended_Pictographic# 1.1 [2] (✳️..✴️) eight-spoked asterisk..eight-pointed star +2744 ; Extended_Pictographic# 1.1 [1] (❄️) snowflake +2747 ; Extended_Pictographic# 1.1 [1] (❇️) sparkle +274C ; Extended_Pictographic# 6.0 [1] (❌) cross mark +274E ; Extended_Pictographic# 6.0 [1] (❎) cross mark button +2753..2755 ; Extended_Pictographic# 6.0 [3] (❓..❕) question mark..white exclamation mark +2757 ; Extended_Pictographic# 5.2 [1] (❗) exclamation mark +2763..2767 ; Extended_Pictographic# 1.1 [5] (❣️..❧) heart exclamation..ROTATED FLORAL HEART BULLET +2795..2797 ; Extended_Pictographic# 6.0 [3] (➕..➗) plus sign..division sign +27A1 ; Extended_Pictographic# 1.1 [1] (➡️) right arrow +27B0 ; Extended_Pictographic# 6.0 [1] (➰) curly loop +27BF ; Extended_Pictographic# 6.0 [1] (➿) double curly loop +2934..2935 ; Extended_Pictographic# 3.2 [2] (⤴️..⤵️) right arrow curving up..right arrow curving down +2B05..2B07 ; Extended_Pictographic# 4.0 [3] (⬅️..⬇️) left arrow..down arrow +2B1B..2B1C ; Extended_Pictographic# 5.1 [2] (⬛..⬜) black large square..white large square +2B50 ; Extended_Pictographic# 5.1 [1] (⭐) star +2B55 ; Extended_Pictographic# 5.2 [1] (⭕) hollow red circle +3030 ; Extended_Pictographic# 1.1 [1] (〰️) wavy dash +303D ; Extended_Pictographic# 3.2 [1] (〽️) part alternation mark +3297 ; Extended_Pictographic# 1.1 [1] (㊗️) Japanese “congratulations” button +3299 ; Extended_Pictographic# 1.1 [1] (㊙️) Japanese “secret” button +1F000..1F02B ; Extended_Pictographic# 5.1 [44] (🀀..🀫) MAHJONG TILE EAST WIND..MAHJONG TILE BACK +1F02C..1F02F ; Extended_Pictographic# NA [4] (..) <reserved-1F02C>..<reserved-1F02F> +1F030..1F093 ; Extended_Pictographic# 5.1[100] (🀰..🂓) DOMINO TILE HORIZONTAL BACK..DOMINO TILE VERTICAL-06-06 +1F094..1F09F ; Extended_Pictographic# NA [12] (..) <reserved-1F094>..<reserved-1F09F> +1F0A0..1F0AE ; Extended_Pictographic# 6.0 [15] (🂠..🂮) PLAYING CARD BACK..PLAYING CARD KING OF SPADES +1F0AF..1F0B0 ; Extended_Pictographic# NA [2] (..) <reserved-1F0AF>..<reserved-1F0B0> +1F0B1..1F0BE ; Extended_Pictographic# 6.0 [14] (🂱..🂾) PLAYING CARD ACE OF HEARTS..PLAYING CARD KING OF HEARTS +1F0BF ; Extended_Pictographic# 7.0 [1] (🂿) PLAYING CARD RED JOKER +1F0C0 ; Extended_Pictographic# NA [1] () <reserved-1F0C0> +1F0C1..1F0CF ; Extended_Pictographic# 6.0 [15] (🃁..🃏) PLAYING CARD ACE OF DIAMONDS..joker +1F0D0 ; Extended_Pictographic# NA [1] () <reserved-1F0D0> +1F0D1..1F0DF ; Extended_Pictographic# 6.0 [15] (🃑..🃟) PLAYING CARD ACE OF CLUBS..PLAYING CARD WHITE JOKER +1F0E0..1F0F5 ; Extended_Pictographic# 7.0 [22] (🃠..🃵) PLAYING CARD FOOL..PLAYING CARD TRUMP-21 +1F0F6..1F0FF ; Extended_Pictographic# NA [10] (..) <reserved-1F0F6>..<reserved-1F0FF> +1F10D..1F10F ; Extended_Pictographic# NA [3] (🄍..🄏) <reserved-1F10D>..<reserved-1F10F> +1F12F ; Extended_Pictographic# 11.0 [1] (🄯) COPYLEFT SYMBOL +1F16C ; Extended_Pictographic# 12.0 [1] (🅬) RAISED MR SIGN +1F16D..1F16F ; Extended_Pictographic# NA [3] (🅭..🅯) <reserved-1F16D>..<reserved-1F16F> +1F170..1F171 ; Extended_Pictographic# 6.0 [2] (🅰️..🅱️) A button (blood type)..B button (blood type) +1F17E ; Extended_Pictographic# 6.0 [1] (🅾️) O button (blood type) +1F17F ; Extended_Pictographic# 5.2 [1] (🅿️) P button +1F18E ; Extended_Pictographic# 6.0 [1] (🆎) AB button (blood type) +1F191..1F19A ; Extended_Pictographic# 6.0 [10] (🆑..🆚) CL button..VS button +1F1AD..1F1E5 ; Extended_Pictographic# NA [57] (🆭..) <reserved-1F1AD>..<reserved-1F1E5> +1F201..1F202 ; Extended_Pictographic# 6.0 [2] (🈁..🈂️) Japanese “here” button..Japanese “service charge” button +1F203..1F20F ; Extended_Pictographic# NA [13] (..) <reserved-1F203>..<reserved-1F20F> +1F21A ; Extended_Pictographic# 5.2 [1] (🈚) Japanese “free of charge” button +1F22F ; Extended_Pictographic# 5.2 [1] (🈯) Japanese “reserved” button +1F232..1F23A ; Extended_Pictographic# 6.0 [9] (🈲..🈺) Japanese “prohibited” button..Japanese “open for business” button +1F23C..1F23F ; Extended_Pictographic# NA [4] (..) <reserved-1F23C>..<reserved-1F23F> +1F249..1F24F ; Extended_Pictographic# NA [7] (..) <reserved-1F249>..<reserved-1F24F> +1F250..1F251 ; Extended_Pictographic# 6.0 [2] (🉐..🉑) Japanese “bargain” button..Japanese “acceptable” button +1F252..1F25F ; Extended_Pictographic# NA [14] (..) <reserved-1F252>..<reserved-1F25F> +1F260..1F265 ; Extended_Pictographic# 10.0 [6] (🉠..🉥) ROUNDED SYMBOL FOR FU..ROUNDED SYMBOL FOR CAI +1F266..1F2FF ; Extended_Pictographic# NA[154] (..) <reserved-1F266>..<reserved-1F2FF> +1F300..1F320 ; Extended_Pictographic# 6.0 [33] (🌀..🌠) cyclone..shooting star +1F321..1F32C ; Extended_Pictographic# 7.0 [12] (🌡️..🌬️) thermometer..wind face +1F32D..1F32F ; Extended_Pictographic# 8.0 [3] (🌭..🌯) hot dog..burrito +1F330..1F335 ; Extended_Pictographic# 6.0 [6] (🌰..🌵) chestnut..cactus +1F336 ; Extended_Pictographic# 7.0 [1] (🌶️) hot pepper +1F337..1F37C ; Extended_Pictographic# 6.0 [70] (🌷..🍼) tulip..baby bottle +1F37D ; Extended_Pictographic# 7.0 [1] (🍽️) fork and knife with plate +1F37E..1F37F ; Extended_Pictographic# 8.0 [2] (🍾..🍿) bottle with popping cork..popcorn +1F380..1F393 ; Extended_Pictographic# 6.0 [20] (🎀..🎓) ribbon..graduation cap +1F394..1F39F ; Extended_Pictographic# 7.0 [12] (🎔..🎟️) HEART WITH TIP ON THE LEFT..admission tickets +1F3A0..1F3C4 ; Extended_Pictographic# 6.0 [37] (🎠..🏄) carousel horse..person surfing +1F3C5 ; Extended_Pictographic# 7.0 [1] (🏅) sports medal +1F3C6..1F3CA ; Extended_Pictographic# 6.0 [5] (🏆..🏊) trophy..person swimming +1F3CB..1F3CE ; Extended_Pictographic# 7.0 [4] (🏋️..🏎️) person lifting weights..racing car +1F3CF..1F3D3 ; Extended_Pictographic# 8.0 [5] (🏏..🏓) cricket game..ping pong +1F3D4..1F3DF ; Extended_Pictographic# 7.0 [12] (🏔️..🏟️) snow-capped mountain..stadium +1F3E0..1F3F0 ; Extended_Pictographic# 6.0 [17] (🏠..🏰) house..castle +1F3F1..1F3F7 ; Extended_Pictographic# 7.0 [7] (🏱..🏷️) WHITE PENNANT..label +1F3F8..1F3FA ; Extended_Pictographic# 8.0 [3] (🏸..🏺) badminton..amphora +1F400..1F43E ; Extended_Pictographic# 6.0 [63] (🐀..🐾) rat..paw prints +1F43F ; Extended_Pictographic# 7.0 [1] (🐿️) chipmunk +1F440 ; Extended_Pictographic# 6.0 [1] (👀) eyes +1F441 ; Extended_Pictographic# 7.0 [1] (👁️) eye +1F442..1F4F7 ; Extended_Pictographic# 6.0[182] (👂..📷) ear..camera +1F4F8 ; Extended_Pictographic# 7.0 [1] (📸) camera with flash +1F4F9..1F4FC ; Extended_Pictographic# 6.0 [4] (📹..📼) video camera..videocassette +1F4FD..1F4FE ; Extended_Pictographic# 7.0 [2] (📽️..📾) film projector..PORTABLE STEREO +1F4FF ; Extended_Pictographic# 8.0 [1] (📿) prayer beads +1F500..1F53D ; Extended_Pictographic# 6.0 [62] (🔀..🔽) shuffle tracks button..downwards button +1F546..1F54A ; Extended_Pictographic# 7.0 [5] (🕆..🕊️) WHITE LATIN CROSS..dove +1F54B..1F54F ; Extended_Pictographic# 8.0 [5] (🕋..🕏) kaaba..BOWL OF HYGIEIA +1F550..1F567 ; Extended_Pictographic# 6.0 [24] (🕐..🕧) one o’clock..twelve-thirty +1F568..1F579 ; Extended_Pictographic# 7.0 [18] (🕨..🕹️) RIGHT SPEAKER..joystick +1F57A ; Extended_Pictographic# 9.0 [1] (🕺) man dancing +1F57B..1F5A3 ; Extended_Pictographic# 7.0 [41] (🕻..🖣) LEFT HAND TELEPHONE RECEIVER..BLACK DOWN POINTING BACKHAND INDEX +1F5A4 ; Extended_Pictographic# 9.0 [1] (🖤) black heart +1F5A5..1F5FA ; Extended_Pictographic# 7.0 [86] (🖥️..🗺️) desktop computer..world map +1F5FB..1F5FF ; Extended_Pictographic# 6.0 [5] (🗻..🗿) mount fuji..moai +1F600 ; Extended_Pictographic# 6.1 [1] (😀) grinning face +1F601..1F610 ; Extended_Pictographic# 6.0 [16] (😁..😐) beaming face with smiling eyes..neutral face +1F611 ; Extended_Pictographic# 6.1 [1] (😑) expressionless face +1F612..1F614 ; Extended_Pictographic# 6.0 [3] (😒..😔) unamused face..pensive face +1F615 ; Extended_Pictographic# 6.1 [1] (😕) confused face +1F616 ; Extended_Pictographic# 6.0 [1] (😖) confounded face +1F617 ; Extended_Pictographic# 6.1 [1] (😗) kissing face +1F618 ; Extended_Pictographic# 6.0 [1] (😘) face blowing a kiss +1F619 ; Extended_Pictographic# 6.1 [1] (😙) kissing face with smiling eyes +1F61A ; Extended_Pictographic# 6.0 [1] (😚) kissing face with closed eyes +1F61B ; Extended_Pictographic# 6.1 [1] (😛) face with tongue +1F61C..1F61E ; Extended_Pictographic# 6.0 [3] (😜..😞) winking face with tongue..disappointed face +1F61F ; Extended_Pictographic# 6.1 [1] (😟) worried face +1F620..1F625 ; Extended_Pictographic# 6.0 [6] (😠..😥) angry face..sad but relieved face +1F626..1F627 ; Extended_Pictographic# 6.1 [2] (😦..😧) frowning face with open mouth..anguished face +1F628..1F62B ; Extended_Pictographic# 6.0 [4] (😨..😫) fearful face..tired face +1F62C ; Extended_Pictographic# 6.1 [1] (😬) grimacing face +1F62D ; Extended_Pictographic# 6.0 [1] (😭) loudly crying face +1F62E..1F62F ; Extended_Pictographic# 6.1 [2] (😮..😯) face with open mouth..hushed face +1F630..1F633 ; Extended_Pictographic# 6.0 [4] (😰..😳) anxious face with sweat..flushed face +1F634 ; Extended_Pictographic# 6.1 [1] (😴) sleeping face +1F635..1F640 ; Extended_Pictographic# 6.0 [12] (😵..🙀) dizzy face..weary cat +1F641..1F642 ; Extended_Pictographic# 7.0 [2] (🙁..🙂) slightly frowning face..slightly smiling face +1F643..1F644 ; Extended_Pictographic# 8.0 [2] (🙃..🙄) upside-down face..face with rolling eyes +1F645..1F64F ; Extended_Pictographic# 6.0 [11] (🙅..🙏) person gesturing NO..folded hands +1F680..1F6C5 ; Extended_Pictographic# 6.0 [70] (🚀..🛅) rocket..left luggage +1F6C6..1F6CF ; Extended_Pictographic# 7.0 [10] (🛆..🛏️) TRIANGLE WITH ROUNDED CORNERS..bed +1F6D0 ; Extended_Pictographic# 8.0 [1] (🛐) place of worship +1F6D1..1F6D2 ; Extended_Pictographic# 9.0 [2] (🛑..🛒) stop sign..shopping cart +1F6D3..1F6D4 ; Extended_Pictographic# 10.0 [2] (🛓..🛔) STUPA..PAGODA +1F6D5 ; Extended_Pictographic# 12.0 [1] (🛕) hindu temple +1F6D6..1F6DF ; Extended_Pictographic# NA [10] (🛖..🛟) <reserved-1F6D6>..<reserved-1F6DF> +1F6E0..1F6EC ; Extended_Pictographic# 7.0 [13] (🛠️..🛬) hammer and wrench..airplane arrival +1F6ED..1F6EF ; Extended_Pictographic# NA [3] (..) <reserved-1F6ED>..<reserved-1F6EF> +1F6F0..1F6F3 ; Extended_Pictographic# 7.0 [4] (🛰️..🛳️) satellite..passenger ship +1F6F4..1F6F6 ; Extended_Pictographic# 9.0 [3] (🛴..🛶) kick scooter..canoe +1F6F7..1F6F8 ; Extended_Pictographic# 10.0 [2] (🛷..🛸) sled..flying saucer +1F6F9 ; Extended_Pictographic# 11.0 [1] (🛹) skateboard +1F6FA ; Extended_Pictographic# 12.0 [1] (🛺) auto rickshaw +1F6FB..1F6FF ; Extended_Pictographic# NA [5] (🛻..) <reserved-1F6FB>..<reserved-1F6FF> +1F774..1F77F ; Extended_Pictographic# NA [12] (🝴..🝿) <reserved-1F774>..<reserved-1F77F> +1F7D5..1F7D8 ; Extended_Pictographic# 11.0 [4] (🟕..🟘) CIRCLED TRIANGLE..NEGATIVE CIRCLED SQUARE +1F7D9..1F7DF ; Extended_Pictographic# NA [7] (🟙..) <reserved-1F7D9>..<reserved-1F7DF> +1F7E0..1F7EB ; Extended_Pictographic# 12.0 [12] (🟠..🟫) orange circle..brown square +1F7EC..1F7FF ; Extended_Pictographic# NA [20] (..) <reserved-1F7EC>..<reserved-1F7FF> +1F80C..1F80F ; Extended_Pictographic# NA [4] (..) <reserved-1F80C>..<reserved-1F80F> +1F848..1F84F ; Extended_Pictographic# NA [8] (..) <reserved-1F848>..<reserved-1F84F> +1F85A..1F85F ; Extended_Pictographic# NA [6] (..) <reserved-1F85A>..<reserved-1F85F> +1F888..1F88F ; Extended_Pictographic# NA [8] (..) <reserved-1F888>..<reserved-1F88F> +1F8AE..1F8FF ; Extended_Pictographic# NA [82] (..) <reserved-1F8AE>..<reserved-1F8FF> +1F90C ; Extended_Pictographic# NA [1] (🤌) <reserved-1F90C> +1F90D..1F90F ; Extended_Pictographic# 12.0 [3] (🤍..🤏) white heart..pinching hand +1F910..1F918 ; Extended_Pictographic# 8.0 [9] (🤐..🤘) zipper-mouth face..sign of the horns +1F919..1F91E ; Extended_Pictographic# 9.0 [6] (🤙..🤞) call me hand..crossed fingers +1F91F ; Extended_Pictographic# 10.0 [1] (🤟) love-you gesture +1F920..1F927 ; Extended_Pictographic# 9.0 [8] (🤠..🤧) cowboy hat face..sneezing face +1F928..1F92F ; Extended_Pictographic# 10.0 [8] (🤨..🤯) face with raised eyebrow..exploding head +1F930 ; Extended_Pictographic# 9.0 [1] (🤰) pregnant woman +1F931..1F932 ; Extended_Pictographic# 10.0 [2] (🤱..🤲) breast-feeding..palms up together +1F933..1F93A ; Extended_Pictographic# 9.0 [8] (🤳..🤺) selfie..person fencing +1F93C..1F93E ; Extended_Pictographic# 9.0 [3] (🤼..🤾) people wrestling..person playing handball +1F93F ; Extended_Pictographic# 12.0 [1] (🤿) diving mask +1F940..1F945 ; Extended_Pictographic# 9.0 [6] (🥀..🥅) wilted flower..goal net +1F947..1F94B ; Extended_Pictographic# 9.0 [5] (🥇..🥋) 1st place medal..martial arts uniform +1F94C ; Extended_Pictographic# 10.0 [1] (🥌) curling stone +1F94D..1F94F ; Extended_Pictographic# 11.0 [3] (🥍..🥏) lacrosse..flying disc +1F950..1F95E ; Extended_Pictographic# 9.0 [15] (🥐..🥞) croissant..pancakes +1F95F..1F96B ; Extended_Pictographic# 10.0 [13] (🥟..🥫) dumpling..canned food +1F96C..1F970 ; Extended_Pictographic# 11.0 [5] (🥬..🥰) leafy green..smiling face with hearts +1F971 ; Extended_Pictographic# 12.0 [1] (🥱) yawning face +1F972 ; Extended_Pictographic# NA [1] (🥲) <reserved-1F972> +1F973..1F976 ; Extended_Pictographic# 11.0 [4] (🥳..🥶) partying face..cold face +1F977..1F979 ; Extended_Pictographic# NA [3] (🥷..🥹) <reserved-1F977>..<reserved-1F979> +1F97A ; Extended_Pictographic# 11.0 [1] (🥺) pleading face +1F97B ; Extended_Pictographic# 12.0 [1] (🥻) sari +1F97C..1F97F ; Extended_Pictographic# 11.0 [4] (🥼..🥿) lab coat..flat shoe +1F980..1F984 ; Extended_Pictographic# 8.0 [5] (🦀..🦄) crab..unicorn +1F985..1F991 ; Extended_Pictographic# 9.0 [13] (🦅..🦑) eagle..squid +1F992..1F997 ; Extended_Pictographic# 10.0 [6] (🦒..🦗) giraffe..cricket +1F998..1F9A2 ; Extended_Pictographic# 11.0 [11] (🦘..🦢) kangaroo..swan +1F9A3..1F9A4 ; Extended_Pictographic# NA [2] (🦣..🦤) <reserved-1F9A3>..<reserved-1F9A4> +1F9A5..1F9AA ; Extended_Pictographic# 12.0 [6] (🦥..🦪) sloth..oyster +1F9AB..1F9AD ; Extended_Pictographic# NA [3] (🦫..🦭) <reserved-1F9AB>..<reserved-1F9AD> +1F9AE..1F9AF ; Extended_Pictographic# 12.0 [2] (🦮..🦯) guide dog..probing cane +1F9B0..1F9B9 ; Extended_Pictographic# 11.0 [10] (🦰..🦹) red hair..supervillain +1F9BA..1F9BF ; Extended_Pictographic# 12.0 [6] (🦺..🦿) safety vest..mechanical leg +1F9C0 ; Extended_Pictographic# 8.0 [1] (🧀) cheese wedge +1F9C1..1F9C2 ; Extended_Pictographic# 11.0 [2] (🧁..🧂) cupcake..salt +1F9C3..1F9CA ; Extended_Pictographic# 12.0 [8] (🧃..🧊) beverage box..ice cube +1F9CB..1F9CC ; Extended_Pictographic# NA [2] (🧋..🧌) <reserved-1F9CB>..<reserved-1F9CC> +1F9CD..1F9CF ; Extended_Pictographic# 12.0 [3] (🧍..🧏) person standing..deaf person +1F9D0..1F9E6 ; Extended_Pictographic# 10.0 [23] (🧐..🧦) face with monocle..socks +1F9E7..1F9FF ; Extended_Pictographic# 11.0 [25] (🧧..🧿) red envelope..nazar amulet +1FA00..1FA53 ; Extended_Pictographic# 12.0 [84] (🨀..🩓) NEUTRAL CHESS KING..BLACK CHESS KNIGHT-BISHOP +1FA54..1FA5F ; Extended_Pictographic# NA [12] (..) <reserved-1FA54>..<reserved-1FA5F> +1FA60..1FA6D ; Extended_Pictographic# 11.0 [14] (🩠..🩭) XIANGQI RED GENERAL..XIANGQI BLACK SOLDIER +1FA6E..1FA6F ; Extended_Pictographic# NA [2] (..) <reserved-1FA6E>..<reserved-1FA6F> +1FA70..1FA73 ; Extended_Pictographic# 12.0 [4] (🩰..🩳) ballet shoes..shorts +1FA74..1FA77 ; Extended_Pictographic# NA [4] (🩴..🩷) <reserved-1FA74>..<reserved-1FA77> +1FA78..1FA7A ; Extended_Pictographic# 12.0 [3] (🩸..🩺) drop of blood..stethoscope +1FA7B..1FA7F ; Extended_Pictographic# NA [5] (🩻..) <reserved-1FA7B>..<reserved-1FA7F> +1FA80..1FA82 ; Extended_Pictographic# 12.0 [3] (🪀..🪂) yo-yo..parachute +1FA83..1FA8F ; Extended_Pictographic# NA [13] (🪃..) <reserved-1FA83>..<reserved-1FA8F> +1FA90..1FA95 ; Extended_Pictographic# 12.0 [6] (🪐..🪕) ringed planet..banjo +1FA96..1FFFD ; Extended_Pictographic# NA[1384] (🪖..) <reserved-1FA96>..<reserved-1FFFD> + +# Total elements: 3793 + +#EOF diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index bafad2ae9..f6016d73f 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emoji do @@ -98,4 +98,35 @@ defmodule Pleroma.Emoji do defp update_emojis(emojis) do :ets.insert(@ets, emojis) end + + @external_resource "lib/pleroma/emoji-data.txt" + + emojis = + @external_resource + |> File.read!() + |> String.split("\n") + |> Enum.filter(fn line -> line != "" and not String.starts_with?(line, "#") end) + |> Enum.map(fn line -> + line + |> String.split(";", parts: 2) + |> hd() + |> String.trim() + |> String.split("..") + |> case do + [number] -> + <<String.to_integer(number, 16)::utf8>> + + [first, last] -> + String.to_integer(first, 16)..String.to_integer(last, 16) + |> Enum.map(&<<&1::utf8>>) + end + end) + |> List.flatten() + |> Enum.uniq() + + for emoji <- emojis do + def is_unicode_emoji?(unquote(emoji)), do: true + end + + def is_unicode_emoji?(_), do: false end diff --git a/lib/pleroma/emoji/formatter.ex b/lib/pleroma/emoji/formatter.ex index 4869d073e..dc45b8a38 100644 --- a/lib/pleroma/emoji/formatter.ex +++ b/lib/pleroma/emoji/formatter.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emoji.Formatter do @@ -38,22 +38,14 @@ defmodule Pleroma.Emoji.Formatter do def demojify(text, nil), do: text - @doc "Outputs a list of the emoji-shortcodes in a text" - def get_emoji(text) when is_binary(text) do - Enum.filter(Emoji.get_all(), fn {emoji, %Emoji{}} -> - String.contains?(text, ":#{emoji}:") - end) - end - - def get_emoji(_), do: [] - @doc "Outputs a list of the emoji-Maps in a text" def get_emoji_map(text) when is_binary(text) do - get_emoji(text) + Emoji.get_all() + |> Enum.filter(fn {emoji, %Emoji{}} -> String.contains?(text, ":#{emoji}:") end) |> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc -> Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") end) end - def get_emoji_map(_), do: [] + def get_emoji_map(_), do: %{} end diff --git a/lib/pleroma/emoji/loader.ex b/lib/pleroma/emoji/loader.ex index 4f4ee51d1..3de2dc762 100644 --- a/lib/pleroma/emoji/loader.ex +++ b/lib/pleroma/emoji/loader.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emoji.Loader do diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex new file mode 100644 index 000000000..242344374 --- /dev/null +++ b/lib/pleroma/emoji/pack.ex @@ -0,0 +1,507 @@ +defmodule Pleroma.Emoji.Pack do + @derive {Jason.Encoder, only: [:files, :pack]} + defstruct files: %{}, + pack_file: nil, + path: nil, + pack: %{}, + name: nil + + @type t() :: %__MODULE__{ + files: %{String.t() => Path.t()}, + pack_file: Path.t(), + path: Path.t(), + pack: map(), + name: String.t() + } + + alias Pleroma.Emoji + + @spec emoji_path() :: Path.t() + def emoji_path do + static = Pleroma.Config.get!([:instance, :static_dir]) + Path.join(static, "emoji") + end + + @spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values} + def create(name) when byte_size(name) > 0 do + dir = Path.join(emoji_path(), name) + + with :ok <- File.mkdir(dir) do + %__MODULE__{ + pack_file: Path.join(dir, "pack.json") + } + |> save_pack() + end + end + + def create(_), do: {:error, :empty_values} + + @spec show(String.t()) :: {:ok, t()} | {:loaded, nil} | {:error, :empty_values} + def show(name) when byte_size(name) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, pack} <- validate_pack(pack) do + {:ok, pack} + end + end + + def show(_), do: {:error, :empty_values} + + @spec delete(String.t()) :: + {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} + def delete(name) when byte_size(name) > 0 do + emoji_path() + |> Path.join(name) + |> File.rm_rf() + end + + def delete(_), do: {:error, :empty_values} + + @spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def add_file(name, shortcode, filename, file) + when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(filename) > 0 do + with {_, nil} <- {:exists, Emoji.get(shortcode)}, + {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)} do + file_path = Path.join(pack.path, filename) + + create_subdirs(file_path) + + case 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 + + files = Map.put(pack.files, shortcode, filename) + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def add_file(_, _, _, _), do: {:error, :empty_values} + + defp create_subdirs(file_path) do + if String.contains?(file_path, "/") do + file_path + |> Path.dirname() + |> File.mkdir_p!() + end + end + + @spec delete_file(String.t(), String.t()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def delete_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, {filename, files}} when not is_nil(filename) <- + {:exists, Map.pop(pack.files, shortcode)}, + emoji <- Path.join(pack.path, filename), + {_, true} <- {:exists, File.exists?(emoji)} do + emoji_dir = Path.dirname(emoji) + + File.rm!(emoji) + + if String.contains?(filename, "/") and File.ls!(emoji_dir) == [] do + File.rmdir!(emoji_dir) + end + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def delete_file(_, _), do: {:error, :empty_values} + + @spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def update_file(name, shortcode, new_shortcode, new_filename, force) + when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(new_shortcode) > 0 and + byte_size(new_filename) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, {filename, files}} when not is_nil(filename) <- + {:exists, Map.pop(pack.files, shortcode)}, + {_, true} <- {:not_used, force or is_nil(Emoji.get(new_shortcode))} do + old_path = Path.join(pack.path, filename) + old_dir = Path.dirname(old_path) + new_path = Path.join(pack.path, new_filename) + + create_subdirs(new_path) + + :ok = File.rename(old_path, new_path) + + if String.contains?(filename, "/") and File.ls!(old_dir) == [] do + File.rmdir!(old_dir) + end + + files = Map.put(files, new_shortcode, new_filename) + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def update_file(_, _, _, _, _), do: {:error, :empty_values} + + @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, atom()} + def import_from_filesystem do + emoji_path = emoji_path() + + with {:ok, %{access: :read_write}} <- File.stat(emoji_path), + {:ok, results} <- File.ls(emoji_path) do + names = + results + |> Enum.map(&Path.join(emoji_path, &1)) + |> Enum.reject(fn path -> + File.dir?(path) and File.exists?(Path.join(path, "pack.json")) + end) + |> Enum.map(&write_pack_contents/1) + |> Enum.filter(& &1) + + {:ok, names} + else + {:ok, %{access: _}} -> {:error, :no_read_write} + e -> e + end + end + + defp write_pack_contents(path) do + pack = %__MODULE__{ + files: files_from_path(path), + path: path, + pack_file: Path.join(path, "pack.json") + } + + case save_pack(pack) do + :ok -> Path.basename(path) + _ -> nil + end + end + + defp files_from_path(path) do + txt_path = Path.join(path, "emoji.txt") + + if File.exists?(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 file + + # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 + + # Create a map of shortcodes to filenames from emoji.txt + File.read!(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 | _] -> + file_dir_name = Path.dirname(file) + + file = + if String.ends_with?(path, file_dir_name) do + Path.basename(file) + else + file + end + + {name, file} + + _ -> + nil + end + end) + |> Enum.filter(& &1) + |> 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]) + Emoji.Loader.make_shortcode_to_file_map(path, pack_extensions) + end + end + + @spec list_remote(String.t()) :: {:ok, map()} + def list_remote(url) do + uri = + url + |> String.trim() + |> URI.parse() + + with {_, true} <- {:shareable, shareable_packs_available?(uri)} do + packs = + uri + |> URI.merge("/api/pleroma/emoji/packs") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + + {:ok, packs} + end + end + + @spec list_local() :: {:ok, map()} + def list_local do + emoji_path = emoji_path() + + # 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_path)}, + {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do + packs = + results + |> Enum.map(&load_pack/1) + |> Enum.filter(& &1) + |> Enum.map(&validate_pack/1) + |> Map.new() + + {:ok, packs} + end + end + + defp validate_pack(pack) do + if downloadable?(pack) do + archive = fetch_archive(pack) + archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16() + + info = + pack.pack + |> Map.put("can-download", true) + |> Map.put("download-sha256", archive_sha) + + {pack.name, Map.put(pack, :pack, info)} + else + info = Map.put(pack.pack, "can-download", false) + {pack.name, Map.put(pack, :pack, info)} + end + end + + defp downloadable?(pack) 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 {_, file} -> + File.exists?(Path.join(pack.path, file)) + end) + end + + @spec get_archive(String.t()) :: {:ok, binary()} + def get_archive(name) do + with {_, %__MODULE__{} = pack} <- {:exists?, load_pack(name)}, + {_, true} <- {:can_download?, downloadable?(pack)} do + {:ok, fetch_archive(pack)} + end + end + + defp fetch_archive(pack) do + hash = :crypto.hash(:md5, File.read!(pack.pack_file)) + + case Cachex.get!(:emoji_packs_cache, pack.name) do + %{hash: ^hash, pack_data: archive} -> + archive + + _ -> + create_archive_and_cache(pack, hash) + end + end + + defp create_archive_and_cache(pack, hash) do + files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] + + {:ok, {_, result}} = + :zip.zip('#{pack.name}.zip', files, [:memory, cwd: to_charlist(pack.path)]) + + ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) + overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) + + Cachex.put!( + :emoji_packs_cache, + pack.name, + # if pack.json MD5 changes, the cache is not valid anymore + %{hash: hash, pack_data: result}, + # Add a minute to cache time for every file in the pack + ttl: overall_ttl + ) + + result + end + + @spec download(String.t(), String.t(), String.t()) :: :ok + def download(name, url, as) do + uri = + url + |> String.trim() + |> URI.parse() + + with {_, true} <- {:shareable, shareable_packs_available?(uri)} do + remote_pack = + uri + |> URI.merge("/api/pleroma/emoji/packs/#{name}") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + + result = + case remote_pack["pack"] do + %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> + {:ok, + %{ + sha: sha, + url: URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/archive") |> to_string() + }} + + %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> + {:ok, + %{ + sha: sha, + url: 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, url: url} = pinfo} <- result, + %{body: archive} <- Tesla.get!(url), + {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, archive)} do + local_name = as || name + + path = Path.join(emoji_path(), local_name) + + pack = %__MODULE__{ + name: local_name, + path: path, + files: remote_pack["files"], + pack_file: Path.join(path, "pack.json") + } + + File.mkdir_p!(pack.path) + + files = Enum.map(remote_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(archive, cwd: to_charlist(pack.path), 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 + save_pack(pack) + end + + :ok + end + end + end + + defp save_pack(pack), do: File.write(pack.pack_file, Jason.encode!(pack, pretty: true)) + + @spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()} + def save_metadata(metadata, %__MODULE__{} = pack) do + pack = Map.put(pack, :pack, metadata) + + with :ok <- save_pack(pack) do + {:ok, pack} + end + end + + @spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()} + def update_metadata(name, data) do + pack = load_pack(name) + + fb_sha_changed? = + not is_nil(data["fallback-src"]) and data["fallback-src"] != pack.pack["fallback-src"] + + with {_, true} <- {:update?, fb_sha_changed?}, + {:ok, %{body: zip}} <- Tesla.get(data["fallback-src"]), + {:ok, f_list} <- :zip.unzip(zip, [:memory]), + {_, true} <- {:has_all_files?, has_all_files?(pack.files, f_list)} do + fallback_sha = :crypto.hash(:sha256, zip) |> Base.encode16() + + data + |> Map.put("fallback-src-sha256", fallback_sha) + |> save_metadata(pack) + else + {:update?, _} -> save_metadata(data, pack) + e -> e + end + end + + # Check if all files from the pack.json are in the archive + defp has_all_files?(files, f_list) do + Enum.all?(files, fn {_, from_manifest} -> + List.keyfind(f_list, to_charlist(from_manifest), 0) + end) + end + + @spec load_pack(String.t()) :: t() | nil + def load_pack(name) do + pack_file = Path.join([emoji_path(), name, "pack.json"]) + + if File.exists?(pack_file) do + pack_file + |> File.read!() + |> from_json() + |> Map.put(:pack_file, pack_file) + |> Map.put(:path, Path.dirname(pack_file)) + |> Map.put(:name, name) + end + end + + defp from_json(json) do + map = Jason.decode!(json) + + struct(__MODULE__, %{files: map["files"], pack: map["pack"]}) + end + + defp shareable_packs_available?(uri) do + uri + |> URI.merge("/.well-known/nodeinfo") + |> to_string() + |> 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 +end diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index c87141582..4d61b3650 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Filter do @@ -89,11 +89,10 @@ defmodule Pleroma.Filter do |> Repo.delete() end - def update(%Pleroma.Filter{} = filter) do - destination = Map.from_struct(filter) - - Pleroma.Filter.get(filter.filter_id, %{id: filter.user_id}) - |> cast(destination, [:phrase, :context, :hide, :expires_at, :whole_word]) + def update(%Pleroma.Filter{} = filter, params) do + filter + |> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word]) + |> validate_required([:phrase, :context]) |> Repo.update() end end diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex new file mode 100644 index 000000000..3a3082e72 --- /dev/null +++ b/lib/pleroma/following_relationship.ex @@ -0,0 +1,256 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.FollowingRelationship do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Ecto.Changeset + alias FlakeId.Ecto.CompatType + alias Pleroma.FollowingRelationship.State + alias Pleroma.Repo + alias Pleroma.User + + schema "following_relationships" do + field(:state, State, default: :follow_pending) + + belongs_to(:follower, User, type: CompatType) + belongs_to(:following, User, type: CompatType) + + timestamps() + end + + @doc "Returns underlying integer code for state atom" + def state_int_code(state_atom), do: State.__enum_map__() |> Keyword.fetch!(state_atom) + + def accept_state_code, do: state_int_code(:follow_accept) + + def changeset(%__MODULE__{} = following_relationship, attrs) do + following_relationship + |> cast(attrs, [:state]) + |> put_assoc(:follower, attrs.follower) + |> put_assoc(:following, attrs.following) + |> validate_required([:state, :follower, :following]) + |> unique_constraint(:follower_id, + name: :following_relationships_follower_id_following_id_index + ) + |> validate_not_self_relationship() + end + + def state_to_enum(state) when state in ["pending", "accept", "reject"] do + String.to_existing_atom("follow_#{state}") + end + + def state_to_enum(state) do + raise "State is not convertible to Pleroma.FollowingRelationship.State: #{state}" + end + + def get(%User{} = follower, %User{} = following) do + __MODULE__ + |> where(follower_id: ^follower.id, following_id: ^following.id) + |> Repo.one() + end + + def update(follower, following, :follow_reject), do: unfollow(follower, following) + + def update(%User{} = follower, %User{} = following, state) do + case get(follower, following) do + nil -> + follow(follower, following, state) + + following_relationship -> + following_relationship + |> cast(%{state: state}, [:state]) + |> validate_required([:state]) + |> Repo.update() + end + end + + def follow(%User{} = follower, %User{} = following, state \\ :follow_accept) do + %__MODULE__{} + |> changeset(%{follower: follower, following: following, state: state}) + |> Repo.insert(on_conflict: :nothing) + end + + def unfollow(%User{} = follower, %User{} = following) do + case get(follower, following) do + %__MODULE__{} = following_relationship -> Repo.delete(following_relationship) + _ -> {:ok, nil} + end + end + + def follower_count(%User{} = user) do + %{followers: user, deactivated: false} + |> User.Query.build() + |> Repo.aggregate(:count, :id) + end + + def followers_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.follower_id == u.id) + |> where([r], r.following_id == ^user.id) + |> where([r], r.state == ^:follow_accept) + end + + def followers_ap_ids(%User{} = user, from_ap_ids \\ nil) do + query = + user + |> followers_query() + |> select([r, u], u.ap_id) + + query = + if from_ap_ids do + where(query, [r, u], u.ap_id in ^from_ap_ids) + else + query + end + + Repo.all(query) + end + + def following_count(%User{id: nil}), do: 0 + + def following_count(%User{} = user) do + %{friends: user, deactivated: false} + |> User.Query.build() + |> Repo.aggregate(:count, :id) + end + + def get_follow_requests(%User{id: id}) do + __MODULE__ + |> join(:inner, [r], f in assoc(r, :follower)) + |> where([r], r.state == ^:follow_pending) + |> where([r], r.following_id == ^id) + |> select([r, f], f) + |> Repo.all() + end + + def following?(%User{id: follower_id}, %User{id: followed_id}) do + __MODULE__ + |> where(follower_id: ^follower_id, following_id: ^followed_id, state: ^:follow_accept) + |> Repo.exists?() + end + + def following_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.following_id == u.id) + |> where([r], r.follower_id == ^user.id) + |> where([r], r.state == ^:follow_accept) + end + + def following(%User{} = user) do + following = + following_query(user) + |> select([r, u], u.follower_address) + |> Repo.all() + + if not user.local or user.invisible do + following + else + [user.follower_address | following] + end + end + + def move_following(origin, target) do + __MODULE__ + |> join(:inner, [r], f in assoc(r, :follower)) + |> where(following_id: ^origin.id) + |> where([r, f], f.allow_following_move == true) + |> limit(50) + |> preload([:follower]) + |> Repo.all() + |> Enum.map(fn following_relationship -> + Repo.delete(following_relationship) + Pleroma.Web.CommonAPI.follow(following_relationship.follower, target) + end) + |> case do + [] -> + User.update_follower_count(origin) + :ok + + _ -> + move_following(origin, target) + end + end + + def all_between_user_sets( + source_users, + target_users + ) + when is_list(source_users) and is_list(target_users) do + source_user_ids = User.binary_id(source_users) + target_user_ids = User.binary_id(target_users) + + __MODULE__ + |> where( + fragment( + "(follower_id = ANY(?) AND following_id = ANY(?)) OR \ + (follower_id = ANY(?) AND following_id = ANY(?))", + ^source_user_ids, + ^target_user_ids, + ^target_user_ids, + ^source_user_ids + ) + ) + |> Repo.all() + end + + def find(following_relationships, follower, following) do + Enum.find(following_relationships, fn + fr -> fr.follower_id == follower.id and fr.following_id == following.id + end) + end + + @doc """ + For a query with joined activity, + keeps rows where activity's actor is followed by user -or- is NOT domain-blocked by user. + """ + def keep_following_or_not_domain_blocked(query, user) do + where( + query, + [_, activity], + fragment( + # "(actor's domain NOT in domain_blocks) OR (actor IS in followed AP IDs)" + """ + NOT (substring(? from '.*://([^/]*)') = ANY(?)) OR + ? = ANY(SELECT ap_id FROM users AS u INNER JOIN following_relationships AS fr + ON u.id = fr.following_id WHERE fr.follower_id = ? AND fr.state = ?) + """, + activity.actor, + ^user.domain_blocks, + activity.actor, + ^User.binary_id(user.id), + ^accept_state_code() + ) + ) + end + + defp validate_not_self_relationship(%Changeset{} = changeset) do + changeset + |> validate_follower_id_following_id_inequality() + |> validate_following_id_follower_id_inequality() + end + + defp validate_follower_id_following_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :follower_id, fn _, follower_id -> + if follower_id == get_field(changeset, :following_id) do + [source_id: "can't be equal to following_id"] + else + [] + end + end) + end + + defp validate_following_id_follower_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :following_id, fn _, following_id -> + if following_id == get_field(changeset, :follower_id) do + [target_id: "can't be equal to follower_id"] + else + [] + end + end) + end +end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 931b9af2b..02a93a8dc 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Formatter do @@ -13,7 +13,8 @@ defmodule Pleroma.Formatter do @auto_linker_config hashtag: true, hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, mention: true, - mention_handler: &Pleroma.Formatter.mention_handler/4 + mention_handler: &Pleroma.Formatter.mention_handler/4, + scheme: true def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do case User.get_cached_by_nickname(nickname) do @@ -30,13 +31,23 @@ defmodule Pleroma.Formatter do def mention_handler("@" <> nickname, buffer, opts, acc) do case User.get_cached_by_nickname(nickname) do %User{id: id} = user -> - ap_id = get_ap_id(user) + user_url = user.uri || user.ap_id nickname_text = get_nickname_text(nickname, opts) link = - ~s(<span class="h-card"><a data-user="#{id}" class="u-url mention" href="#{ap_id}" rel="ugc">@<span>#{ - nickname_text - }</span></a></span>) + Phoenix.HTML.Tag.content_tag( + :span, + Phoenix.HTML.Tag.content_tag( + :a, + ["@", Phoenix.HTML.Tag.content_tag(:span, nickname_text)], + "data-user": id, + class: "u-url mention", + href: user_url, + rel: "ugc" + ), + class: "h-card" + ) + |> Phoenix.HTML.safe_to_string() {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}} @@ -48,7 +59,15 @@ defmodule Pleroma.Formatter do def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do tag = String.downcase(tag) url = "#{Pleroma.Web.base_url()}/tag/#{tag}" - link = ~s(<a class="hashtag" data-tag="#{tag}" href="#{url}" rel="tag ugc">#{tag_text}</a>) + + link = + Phoenix.HTML.Tag.content_tag(:a, tag_text, + class: "hashtag", + "data-tag": tag, + href: url, + rel: "tag ugc" + ) + |> Phoenix.HTML.safe_to_string() {link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}} end @@ -127,9 +146,6 @@ defmodule Pleroma.Formatter do end end - defp get_ap_id(%User{info: %{source_data: %{"url" => url}}}) when is_binary(url), do: url - defp get_ap_id(%User{ap_id: ap_id}), do: ap_id - defp get_nickname_text(nickname, %{mentions_format: :full}), do: User.full_nickname(nickname) defp get_nickname_text(nickname, _), do: User.local_nickname(nickname) end diff --git a/lib/pleroma/gopher/server.ex b/lib/pleroma/gopher/server.ex index d4e4f3e55..3d56d50a9 100644 --- a/lib/pleroma/gopher/server.ex +++ b/lib/pleroma/gopher/server.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Gopher.Server do diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex new file mode 100644 index 000000000..f51cd7db8 --- /dev/null +++ b/lib/pleroma/gun/api.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.Gun.API do + @behaviour Pleroma.Gun + + alias Pleroma.Gun + + @gun_keys [ + :connect_timeout, + :http_opts, + :http2_opts, + :protocols, + :retry, + :retry_timeout, + :trace, + :transport, + :tls_opts, + :tcp_opts, + :socks_opts, + :ws_opts + ] + + @impl Gun + def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun_keys)) + + @impl Gun + defdelegate info(pid), to: :gun + + @impl Gun + defdelegate close(pid), to: :gun + + @impl Gun + defdelegate await_up(pid, timeout \\ 5_000), to: :gun + + @impl Gun + defdelegate connect(pid, opts), to: :gun + + @impl Gun + defdelegate await(pid, ref), to: :gun + + @impl Gun + defdelegate set_owner(pid, owner), to: :gun +end diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex new file mode 100644 index 000000000..cd25a2e74 --- /dev/null +++ b/lib/pleroma/gun/conn.ex @@ -0,0 +1,198 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.Conn do + @moduledoc """ + Struct for gun connection data + """ + alias Pleroma.Gun + alias Pleroma.Pool.Connections + + require Logger + + @type gun_state :: :up | :down + @type conn_state :: :active | :idle + + @type t :: %__MODULE__{ + conn: pid(), + gun_state: gun_state(), + conn_state: conn_state(), + used_by: [pid()], + last_reference: pos_integer(), + crf: float(), + retries: pos_integer() + } + + defstruct conn: nil, + gun_state: :open, + conn_state: :init, + used_by: [], + last_reference: 0, + crf: 1, + retries: 0 + + @spec open(String.t() | URI.t(), atom(), keyword()) :: :ok | nil + def open(url, name, opts \\ []) + def open(url, name, opts) when is_binary(url), do: open(URI.parse(url), name, opts) + + def open(%URI{} = uri, name, opts) do + pool_opts = Pleroma.Config.get([:connections_pool], []) + + opts = + opts + |> Enum.into(%{}) + |> Map.put_new(:retry, pool_opts[:retry] || 1) + |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 1000) + |> Map.put_new(:await_up_timeout, pool_opts[:await_up_timeout] || 5_000) + |> maybe_add_tls_opts(uri) + + key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + + max_connections = pool_opts[:max_connections] || 250 + + conn_pid = + if Connections.count(name) < max_connections do + do_open(uri, opts) + else + close_least_used_and_do_open(name, uri, opts) + end + + if is_pid(conn_pid) do + conn = %Pleroma.Gun.Conn{ + conn: conn_pid, + gun_state: :up, + conn_state: :active, + last_reference: :os.system_time(:second) + } + + :ok = Gun.set_owner(conn_pid, Process.whereis(name)) + Connections.add_conn(name, key, conn) + end + end + + defp maybe_add_tls_opts(opts, %URI{scheme: "http"}), do: opts + + defp maybe_add_tls_opts(opts, %URI{scheme: "https", host: host}) do + tls_opts = [ + verify: :verify_peer, + cacertfile: CAStore.file_path(), + depth: 20, + reuse_sessions: false, + verify_fun: + {&:ssl_verify_hostname.verify_fun/3, + [check_hostname: Pleroma.HTTP.Connection.format_host(host)]} + ] + + tls_opts = + if Keyword.keyword?(opts[:tls_opts]) do + Keyword.merge(tls_opts, opts[:tls_opts]) + else + tls_opts + end + + Map.put(opts, :tls_opts, tls_opts) + end + + defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do + connect_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + + with open_opts <- Map.delete(opts, :tls_opts), + {:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts), + {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]), + stream <- Gun.connect(conn, connect_opts), + {:response, :fin, 200, _} <- Gun.await(conn, stream) do + conn + else + error -> + Logger.warn( + "Opening proxied connection to #{compose_uri_log(uri)} failed with error #{ + inspect(error) + }" + ) + + error + end + end + + defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do + version = + proxy_type + |> to_string() + |> String.last() + |> case do + "4" -> 4 + _ -> 5 + end + + socks_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + |> Map.put(:version, version) + + opts = + opts + |> Map.put(:protocols, [:socks]) + |> Map.put(:socks_opts, socks_opts) + + with {:ok, conn} <- Gun.open(proxy_host, proxy_port, opts), + {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]) do + conn + else + error -> + Logger.warn( + "Opening socks proxied connection to #{compose_uri_log(uri)} failed with error #{ + inspect(error) + }" + ) + + error + end + end + + defp do_open(%URI{host: host, port: port} = uri, opts) do + host = Pleroma.HTTP.Connection.parse_host(host) + + with {:ok, conn} <- Gun.open(host, port, opts), + {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]) do + conn + else + error -> + Logger.warn( + "Opening connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" + ) + + error + end + end + + defp destination_opts(%URI{host: host, port: port}) do + host = Pleroma.HTTP.Connection.parse_host(host) + %{host: host, port: port} + end + + defp add_http2_opts(opts, "https", tls_opts) do + Map.merge(opts, %{protocols: [:http2], transport: :tls, tls_opts: tls_opts}) + end + + defp add_http2_opts(opts, _, _), do: opts + + defp close_least_used_and_do_open(name, uri, opts) do + with [{key, conn} | _conns] <- Connections.get_unused_conns(name), + :ok <- Gun.close(conn.conn) do + Connections.remove_conn(name, key) + + do_open(uri, opts) + else + [] -> {:error, :pool_overflowed} + end + end + + def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do + "#{scheme}://#{host}#{path}" + end +end diff --git a/lib/pleroma/gun/gun.ex b/lib/pleroma/gun/gun.ex new file mode 100644 index 000000000..4043e4880 --- /dev/null +++ b/lib/pleroma/gun/gun.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.Gun do + @callback open(charlist(), pos_integer(), map()) :: {:ok, pid()} + @callback info(pid()) :: map() + @callback close(pid()) :: :ok + @callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()} + @callback connect(pid(), map()) :: reference() + @callback await(pid(), reference()) :: {:response, :fin, 200, []} + @callback set_owner(pid(), pid()) :: :ok + + @api Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) + + defp api, do: @api + + def open(host, port, opts), do: api().open(host, port, opts) + + def info(pid), do: api().info(pid) + + def close(pid), do: api().close(pid) + + def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout) + + def connect(pid, opts), do: api().connect(pid, opts) + + def await(pid, ref), do: api().await(pid, ref) + + def set_owner(pid, owner), do: api().set_owner(pid, owner) +end diff --git a/lib/pleroma/healthcheck.ex b/lib/pleroma/healthcheck.ex index fc2129815..92ce83cb7 100644 --- a/lib/pleroma/healthcheck.ex +++ b/lib/pleroma/healthcheck.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Healthcheck do @@ -29,7 +29,7 @@ defmodule Pleroma.Healthcheck do @spec system_info() :: t() def system_info do %Healthcheck{ - memory_used: Float.round(:erlang.memory(:total) / 1024 / 1024, 2) + memory_used: Float.round(:recon_alloc.memory(:allocated) / 1024 / 1024, 2) } |> assign_db_info() |> assign_job_queue_stats() diff --git a/lib/pleroma/helpers/uri_helper.ex b/lib/pleroma/helpers/uri_helper.ex index 8a79b44c4..256252ddb 100644 --- a/lib/pleroma/helpers/uri_helper.ex +++ b/lib/pleroma/helpers/uri_helper.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Helpers.UriHelper do diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 937bafed5..d78c5f202 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -1,9 +1,24 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTML do - alias HtmlSanitizeEx.Scrubber + # Scrubbers are compiled on boot so they can be configured in OTP releases + # @on_load :compile_scrubbers + + def compile_scrubbers do + dir = Path.join(:code.priv_dir(:pleroma), "scrubbers") + + dir + |> Pleroma.Utils.compile_dir() + |> case do + {:error, _errors, _warnings} -> + raise "Compiling scrubbers failed" + + {:ok, _modules, _warnings} -> + :ok + end + end defp get_scrubbers(scrubber) when is_atom(scrubber), do: [scrubber] defp get_scrubbers(scrubbers) when is_list(scrubbers), do: scrubbers @@ -24,9 +39,13 @@ defmodule Pleroma.HTML do end) end - def filter_tags(html, scrubber), do: Scrubber.scrub(html, scrubber) + def filter_tags(html, scrubber) do + {:ok, content} = FastSanitize.Sanitizer.scrub(html, scrubber) + content + end + def filter_tags(html), do: filter_tags(html, nil) - def strip_tags(html), do: Scrubber.scrub(html, Scrubber.StripTags) + def strip_tags(html), do: filter_tags(html, FastSanitize.Sanitizer.StripTags) def get_cached_scrubbed_html_for_activity( content, @@ -46,7 +65,7 @@ defmodule Pleroma.HTML do def get_cached_stripped_html_for_activity(content, activity, key) do get_cached_scrubbed_html_for_activity( content, - HtmlSanitizeEx.Scrubber.StripTags, + FastSanitize.Sanitizer.StripTags, activity, key, &HtmlEntities.decode/1 @@ -89,6 +108,7 @@ defmodule Pleroma.HTML do Cachex.fetch!(:scrubber_cache, key, fn _key -> result = content + |> Floki.parse_fragment!() |> Floki.filter_out("a.mention,a.hashtag,a[rel~=\"tag\"]") |> Floki.attribute("a", "href") |> Enum.at(0) @@ -97,218 +117,3 @@ defmodule Pleroma.HTML do end) end end - -defmodule Pleroma.HTML.Scrubber.TwitterText do - @moduledoc """ - An HTML scrubbing policy which limits to twitter-style text. Only - paragraphs, breaks and links are allowed through the filter. - """ - - @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) - - require HtmlSanitizeEx.Scrubber.Meta - alias HtmlSanitizeEx.Scrubber.Meta - - Meta.remove_cdata_sections_before_scrub() - Meta.strip_comments() - - # links - Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes) - - Meta.allow_tag_with_this_attribute_values("a", "class", [ - "hashtag", - "u-url", - "mention", - "u-url mention", - "mention u-url" - ]) - - Meta.allow_tag_with_this_attribute_values("a", "rel", [ - "tag", - "nofollow", - "noopener", - "noreferrer" - ]) - - Meta.allow_tag_with_these_attributes("a", ["name", "title"]) - - # paragraphs and linebreaks - Meta.allow_tag_with_these_attributes("br", []) - Meta.allow_tag_with_these_attributes("p", []) - - # microformats - Meta.allow_tag_with_this_attribute_values("span", "class", ["h-card"]) - Meta.allow_tag_with_these_attributes("span", []) - - # allow inline images for custom emoji - if Pleroma.Config.get([:markup, :allow_inline_images]) do - # restrict img tags to http/https only, because of MediaProxy. - Meta.allow_tag_with_uri_attributes("img", ["src"], ["http", "https"]) - - Meta.allow_tag_with_these_attributes("img", [ - "width", - "height", - "class", - "title", - "alt" - ]) - end - - Meta.strip_everything_not_covered() -end - -defmodule Pleroma.HTML.Scrubber.Default do - @doc "The default HTML scrubbing policy: no " - - require HtmlSanitizeEx.Scrubber.Meta - alias HtmlSanitizeEx.Scrubber.Meta - # credo:disable-for-previous-line - # No idea how to fix this one… - - @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) - - Meta.remove_cdata_sections_before_scrub() - Meta.strip_comments() - - Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes) - - Meta.allow_tag_with_this_attribute_values("a", "class", [ - "hashtag", - "u-url", - "mention", - "u-url mention", - "mention u-url" - ]) - - Meta.allow_tag_with_this_attribute_values("a", "rel", [ - "tag", - "nofollow", - "noopener", - "noreferrer", - "ugc" - ]) - - Meta.allow_tag_with_these_attributes("a", ["name", "title"]) - - Meta.allow_tag_with_these_attributes("abbr", ["title"]) - - Meta.allow_tag_with_these_attributes("b", []) - Meta.allow_tag_with_these_attributes("blockquote", []) - Meta.allow_tag_with_these_attributes("br", []) - Meta.allow_tag_with_these_attributes("code", []) - Meta.allow_tag_with_these_attributes("del", []) - Meta.allow_tag_with_these_attributes("em", []) - Meta.allow_tag_with_these_attributes("i", []) - Meta.allow_tag_with_these_attributes("li", []) - Meta.allow_tag_with_these_attributes("ol", []) - Meta.allow_tag_with_these_attributes("p", []) - Meta.allow_tag_with_these_attributes("pre", []) - Meta.allow_tag_with_these_attributes("strong", []) - Meta.allow_tag_with_these_attributes("sub", []) - Meta.allow_tag_with_these_attributes("sup", []) - Meta.allow_tag_with_these_attributes("u", []) - Meta.allow_tag_with_these_attributes("ul", []) - - Meta.allow_tag_with_this_attribute_values("span", "class", ["h-card"]) - Meta.allow_tag_with_these_attributes("span", []) - - @allow_inline_images Pleroma.Config.get([:markup, :allow_inline_images]) - - if @allow_inline_images do - # restrict img tags to http/https only, because of MediaProxy. - Meta.allow_tag_with_uri_attributes("img", ["src"], ["http", "https"]) - - Meta.allow_tag_with_these_attributes("img", [ - "width", - "height", - "class", - "title", - "alt" - ]) - end - - if Pleroma.Config.get([:markup, :allow_tables]) do - Meta.allow_tag_with_these_attributes("table", []) - Meta.allow_tag_with_these_attributes("tbody", []) - Meta.allow_tag_with_these_attributes("td", []) - Meta.allow_tag_with_these_attributes("th", []) - Meta.allow_tag_with_these_attributes("thead", []) - Meta.allow_tag_with_these_attributes("tr", []) - end - - if Pleroma.Config.get([:markup, :allow_headings]) do - Meta.allow_tag_with_these_attributes("h1", []) - Meta.allow_tag_with_these_attributes("h2", []) - Meta.allow_tag_with_these_attributes("h3", []) - Meta.allow_tag_with_these_attributes("h4", []) - Meta.allow_tag_with_these_attributes("h5", []) - end - - if Pleroma.Config.get([:markup, :allow_fonts]) do - Meta.allow_tag_with_these_attributes("font", ["face"]) - end - - Meta.strip_everything_not_covered() -end - -defmodule Pleroma.HTML.Transform.MediaProxy do - @moduledoc "Transforms inline image URIs to use MediaProxy." - - alias Pleroma.Web.MediaProxy - - def before_scrub(html), do: html - - def scrub_attribute("img", {"src", "http" <> target}) do - media_url = - ("http" <> target) - |> MediaProxy.url() - - {"src", media_url} - end - - def scrub_attribute(_tag, attribute), do: attribute - - def scrub({"img", attributes, children}) do - attributes = - attributes - |> Enum.map(fn attr -> scrub_attribute("img", attr) end) - |> Enum.reject(&is_nil(&1)) - - {"img", attributes, children} - end - - def scrub({:comment, _children}), do: "" - - def scrub({tag, attributes, children}), do: {tag, attributes, children} - def scrub({_tag, children}), do: children - def scrub(text), do: text -end - -defmodule Pleroma.HTML.Scrubber.LinksOnly do - @moduledoc """ - An HTML scrubbing policy which limits to links only. - """ - - @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) - - require HtmlSanitizeEx.Scrubber.Meta - alias HtmlSanitizeEx.Scrubber.Meta - - Meta.remove_cdata_sections_before_scrub() - Meta.strip_comments() - - # links - Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes) - - Meta.allow_tag_with_this_attribute_values("a", "rel", [ - "tag", - "nofollow", - "noopener", - "noreferrer", - "me", - "ugc" - ]) - - Meta.allow_tag_with_these_attributes("a", ["name", "title"]) - Meta.strip_everything_not_covered() -end diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex new file mode 100644 index 000000000..510722ff9 --- /dev/null +++ b/lib/pleroma/http/adapter_helper.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.HTTP.AdapterHelper do + alias Pleroma.HTTP.Connection + + @type proxy :: + {Connection.host(), pos_integer()} + | {Connection.proxy_type(), Connection.host(), pos_integer()} + + @callback options(keyword(), URI.t()) :: keyword() + @callback after_request(keyword()) :: :ok + + @spec options(keyword(), URI.t()) :: keyword() + def options(opts, _uri) do + proxy = Pleroma.Config.get([:http, :proxy_url], nil) + maybe_add_proxy(opts, format_proxy(proxy)) + end + + @spec maybe_get_conn(URI.t(), keyword()) :: keyword() + def maybe_get_conn(_uri, opts), do: opts + + @spec after_request(keyword()) :: :ok + def after_request(_opts), do: :ok + + @spec format_proxy(String.t() | tuple() | nil) :: proxy() | nil + def format_proxy(nil), do: nil + + def format_proxy(proxy_url) do + case Connection.parse_proxy(proxy_url) do + {:ok, host, port} -> {host, port} + {:ok, type, host, port} -> {type, host, port} + _ -> nil + end + end + + @spec maybe_add_proxy(keyword(), proxy() | nil) :: keyword() + def maybe_add_proxy(opts, nil), do: opts + def maybe_add_proxy(opts, proxy), do: Keyword.put_new(opts, :proxy, proxy) +end diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex new file mode 100644 index 000000000..ead7cdc6b --- /dev/null +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper.Gun do + @behaviour Pleroma.HTTP.AdapterHelper + + alias Pleroma.HTTP.AdapterHelper + alias Pleroma.Pool.Connections + + require Logger + + @defaults [ + connect_timeout: 5_000, + domain_lookup_timeout: 5_000, + tls_handshake_timeout: 5_000, + retry: 1, + retry_timeout: 1000, + await_up_timeout: 5_000 + ] + + @spec options(keyword(), URI.t()) :: keyword() + def options(incoming_opts \\ [], %URI{} = uri) do + proxy = + Pleroma.Config.get([:http, :proxy_url]) + |> AdapterHelper.format_proxy() + + config_opts = Pleroma.Config.get([:http, :adapter], []) + + @defaults + |> Keyword.merge(config_opts) + |> add_scheme_opts(uri) + |> AdapterHelper.maybe_add_proxy(proxy) + |> maybe_get_conn(uri, incoming_opts) + end + + @spec after_request(keyword()) :: :ok + def after_request(opts) do + if opts[:conn] && opts[:body_as] != :chunks do + Connections.checkout(opts[:conn], self(), :gun_connections) + end + + :ok + end + + defp add_scheme_opts(opts, %{scheme: "http"}), do: opts + + defp add_scheme_opts(opts, %{scheme: "https"}) do + opts + |> Keyword.put(:certificates_verification, true) + |> Keyword.put(:tls_opts, log_level: :warning) + end + + defp maybe_get_conn(adapter_opts, uri, incoming_opts) do + {receive_conn?, opts} = + adapter_opts + |> Keyword.merge(incoming_opts) + |> Keyword.pop(:receive_conn, true) + + if Connections.alive?(:gun_connections) and receive_conn? do + checkin_conn(uri, opts) + else + opts + end + end + + defp checkin_conn(uri, opts) do + case Connections.checkin(uri, :gun_connections) do + nil -> + Task.start(Pleroma.Gun.Conn, :open, [uri, :gun_connections, opts]) + opts + + conn when is_pid(conn) -> + Keyword.merge(opts, conn: conn, close_conn: false) + end + end +end diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex new file mode 100644 index 000000000..dcb4cac71 --- /dev/null +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -0,0 +1,43 @@ +defmodule Pleroma.HTTP.AdapterHelper.Hackney do + @behaviour Pleroma.HTTP.AdapterHelper + + @defaults [ + connect_timeout: 10_000, + recv_timeout: 20_000, + follow_redirect: true, + force_redirect: true, + pool: :federation + ] + + @spec options(keyword(), URI.t()) :: keyword() + def options(connection_opts \\ [], %URI{} = uri) do + proxy = Pleroma.Config.get([:http, :proxy_url]) + + config_opts = Pleroma.Config.get([:http, :adapter], []) + + @defaults + |> Keyword.merge(config_opts) + |> Keyword.merge(connection_opts) + |> add_scheme_opts(uri) + |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy) + end + + defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts + + defp add_scheme_opts(opts, %URI{scheme: "https", host: host}) do + ssl_opts = [ + ssl_options: [ + # Workaround for remote server certificate chain issues + partial_chain: &:hackney_connect.partial_chain/1, + + # We don't support TLS v1.3 yet + versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"], + server_name_indication: to_charlist(host) + ] + ] + + Keyword.merge(opts, ssl_opts) + end + + def after_request(_), do: :ok +end diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index 7e2c6f5e8..ebacf7902 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -1,43 +1,124 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.Connection do @moduledoc """ - Connection for http-requests. + Configure Tesla.Client with default and customized adapter options. """ - @hackney_options [ - connect_timeout: 10_000, - recv_timeout: 20_000, - follow_redirect: true, - force_redirect: true, - pool: :federation - ] - @adapter Application.get_env(:tesla, :adapter) + alias Pleroma.Config + alias Pleroma.HTTP.AdapterHelper - @doc """ - Configure a client connection + require Logger + + @defaults [pool: :federation] - # Returns + @type ip_address :: ipv4_address() | ipv6_address() + @type ipv4_address :: {0..255, 0..255, 0..255, 0..255} + @type ipv6_address :: + {0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535} + @type proxy_type() :: :socks4 | :socks5 + @type host() :: charlist() | ip_address() - Tesla.Env.client + @doc """ + Merge default connection & adapter options with received ones. """ - @spec new(Keyword.t()) :: Tesla.Env.client() - def new(opts \\ []) do - Tesla.client([], {@adapter, hackney_options(opts)}) + + @spec options(URI.t(), keyword()) :: keyword() + def options(%URI{} = uri, opts \\ []) do + @defaults + |> pool_timeout() + |> Keyword.merge(opts) + |> adapter_helper().options(uri) + end + + defp pool_timeout(opts) do + {config_key, default} = + if adapter() == Tesla.Adapter.Gun do + {:pools, Config.get([:pools, :default, :timeout])} + else + {:hackney_pools, 10_000} + end + + timeout = Config.get([config_key, opts[:pool], :timeout], default) + + Keyword.merge(opts, timeout: timeout) + end + + @spec after_request(keyword()) :: :ok + def after_request(opts), do: adapter_helper().after_request(opts) + + defp adapter, do: Application.get_env(:tesla, :adapter) + + defp adapter_helper do + case adapter() do + Tesla.Adapter.Gun -> AdapterHelper.Gun + Tesla.Adapter.Hackney -> AdapterHelper.Hackney + _ -> AdapterHelper + end + end + + @spec parse_proxy(String.t() | tuple() | nil) :: + {:ok, host(), pos_integer()} + | {:ok, proxy_type(), host(), pos_integer()} + | {:error, atom()} + | nil + + def parse_proxy(nil), do: nil + + def parse_proxy(proxy) when is_binary(proxy) do + with [host, port] <- String.split(proxy, ":"), + {port, ""} <- Integer.parse(port) do + {:ok, parse_host(host), port} + else + {_, _} -> + Logger.warn("Parsing port failed #{inspect(proxy)}") + {:error, :invalid_proxy_port} + + :error -> + Logger.warn("Parsing port failed #{inspect(proxy)}") + {:error, :invalid_proxy_port} + + _ -> + Logger.warn("Parsing proxy failed #{inspect(proxy)}") + {:error, :invalid_proxy} + end + end + + def parse_proxy(proxy) when is_tuple(proxy) do + with {type, host, port} <- proxy do + {:ok, type, parse_host(host), port} + else + _ -> + Logger.warn("Parsing proxy failed #{inspect(proxy)}") + {:error, :invalid_proxy} + end end - # fetch Hackney options - # - def hackney_options(opts) do - options = Keyword.get(opts, :adapter, []) - adapter_options = Pleroma.Config.get([:http, :adapter], []) - proxy_url = Pleroma.Config.get([:http, :proxy_url], nil) - - @hackney_options - |> Keyword.merge(adapter_options) - |> Keyword.merge(options) - |> Keyword.merge(proxy: proxy_url) + @spec parse_host(String.t() | atom() | charlist()) :: charlist() | ip_address() + def parse_host(host) when is_list(host), do: host + def parse_host(host) when is_atom(host), do: to_charlist(host) + + def parse_host(host) when is_binary(host) do + host = to_charlist(host) + + case :inet.parse_address(host) do + {:error, :einval} -> host + {:ok, ip} -> ip + end + end + + @spec format_host(String.t()) :: charlist() + def format_host(host) do + host_charlist = to_charlist(host) + + case :inet.parse_address(host_charlist) do + {:error, :einval} -> + :idna.encode(host_charlist) + + {:ok, _ip} -> + host_charlist + end end end diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index dec24458a..583b56484 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -1,24 +1,50 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP do @moduledoc """ - + Wrapper for `Tesla.request/2`. """ alias Pleroma.HTTP.Connection + alias Pleroma.HTTP.Request alias Pleroma.HTTP.RequestBuilder, as: Builder + alias Tesla.Client + alias Tesla.Env + + require Logger @type t :: __MODULE__ @doc """ - Builds and perform http request. + Performs GET request. + + See `Pleroma.HTTP.request/5` + """ + @spec get(Request.url() | nil, Request.headers(), keyword()) :: + nil | {:ok, Env.t()} | {:error, any()} + def get(url, headers \\ [], options \\ []) + def get(nil, _, _), do: nil + def get(url, headers, options), do: request(:get, url, "", headers, options) + + @doc """ + Performs POST request. + + See `Pleroma.HTTP.request/5` + """ + @spec post(Request.url(), String.t(), Request.headers(), keyword()) :: + {:ok, Env.t()} | {:error, any()} + def post(url, body, headers \\ [], options \\ []), + do: request(:post, url, body, headers, options) + + @doc """ + Builds and performs http request. # Arguments: `method` - :get, :post, :put, :delete - `url` - `body` + `url` - full url + `body` - request body `headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]` `options` - custom, per-request middleware or adapter options @@ -26,61 +52,66 @@ defmodule Pleroma.HTTP do `{:ok, %Tesla.Env{}}` or `{:error, error}` """ - def request(method, url, body \\ "", headers \\ [], options \\ []) do - try do - options = - process_request_options(options) - |> process_sni_options(url) - - params = Keyword.get(options, :params, []) - - %{} - |> Builder.method(method) - |> Builder.headers(headers) - |> Builder.opts(options) - |> Builder.url(url) - |> Builder.add_param(:body, :body, body) - |> Builder.add_param(:query, :query, params) - |> Enum.into([]) - |> (&Tesla.request(Connection.new(options), &1)).() - rescue - e -> - {:error, e} - catch - :exit, e -> - {:error, e} - end - end + @spec request(atom(), Request.url(), String.t(), Request.headers(), keyword()) :: + {:ok, Env.t()} | {:error, any()} + def request(method, url, body, headers, options) when is_binary(url) do + uri = URI.parse(url) + adapter_opts = Connection.options(uri, options[:adapter] || []) + options = put_in(options[:adapter], adapter_opts) + params = options[:params] || [] + request = build_request(method, headers, options, url, body, params) - defp process_sni_options(options, nil), do: options + adapter = Application.get_env(:tesla, :adapter) + client = Tesla.client([Tesla.Middleware.FollowRedirects], adapter) - defp process_sni_options(options, url) do - uri = URI.parse(url) - host = uri.host |> to_charlist() + pid = Process.whereis(adapter_opts[:pool]) - case uri.scheme do - "https" -> options ++ [ssl: [server_name_indication: host]] - _ -> options - end - end + pool_alive? = + if adapter == Tesla.Adapter.Gun && pid do + Process.alive?(pid) + else + false + end + + request_opts = + adapter_opts + |> Enum.into(%{}) + |> Map.put(:env, Pleroma.Config.get([:env])) + |> Map.put(:pool_alive?, pool_alive?) + + response = request(client, request, request_opts) + + Connection.after_request(adapter_opts) - def process_request_options(options) do - Keyword.merge(Pleroma.HTTP.Connection.hackney_options([]), options) + response end - @doc """ - Performs GET request. + @spec request(Client.t(), keyword(), map()) :: {:ok, Env.t()} | {:error, any()} + def request(%Client{} = client, request, %{env: :test}), do: request(client, request) - See `Pleroma.HTTP.request/5` - """ - def get(url, headers \\ [], options \\ []), - do: request(:get, url, "", headers, options) + def request(%Client{} = client, request, %{body_as: :chunks}), do: request(client, request) - @doc """ - Performs POST request. + def request(%Client{} = client, request, %{pool_alive?: false}), do: request(client, request) - See `Pleroma.HTTP.request/5` - """ - def post(url, body, headers \\ [], options \\ []), - do: request(:post, url, body, headers, options) + def request(%Client{} = client, request, %{pool: pool, timeout: timeout}) do + :poolboy.transaction( + pool, + &Pleroma.Pool.Request.execute(&1, client, request, timeout), + timeout + ) + end + + @spec request(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} + def request(client, request), do: Tesla.request(client, request) + + defp build_request(method, headers, options, url, body, params) do + Builder.new() + |> Builder.method(method) + |> Builder.headers(headers) + |> Builder.opts(options) + |> Builder.url(url) + |> Builder.add_param(:body, :body, body) + |> Builder.add_param(:query, :query, params) + |> Builder.convert_to_keyword() + end end diff --git a/lib/pleroma/http/request.ex b/lib/pleroma/http/request.ex new file mode 100644 index 000000000..761bd6ccf --- /dev/null +++ b/lib/pleroma/http/request.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.HTTP.Request do + @moduledoc """ + Request struct. + """ + defstruct method: :get, url: "", query: [], headers: [], body: "", opts: [] + + @type method :: :head | :get | :delete | :trace | :options | :post | :put | :patch + @type url :: String.t() + @type headers :: [{String.t(), String.t()}] + + @type t :: %__MODULE__{ + method: method(), + url: url(), + query: keyword(), + headers: headers(), + body: String.t(), + opts: keyword() + } +end diff --git a/lib/pleroma/http/request_builder.ex b/lib/pleroma/http/request_builder.ex index e23457999..2fc876d92 100644 --- a/lib/pleroma/http/request_builder.ex +++ b/lib/pleroma/http/request_builder.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.RequestBuilder do @@ -7,136 +7,87 @@ defmodule Pleroma.HTTP.RequestBuilder do Helper functions for building Tesla requests """ - @doc """ - Specify the request method when building a request - - ## Parameters - - - request (Map) - Collected request options - - m (atom) - Request method - - ## Returns + alias Pleroma.HTTP.Request + alias Tesla.Multipart - Map + @doc """ + Creates new request """ - @spec method(map(), atom) :: map() - def method(request, m) do - Map.put_new(request, :method, m) - end + @spec new(Request.t()) :: Request.t() + def new(%Request{} = request \\ %Request{}), do: request @doc """ Specify the request method when building a request + """ + @spec method(Request.t(), Request.method()) :: Request.t() + def method(request, m), do: %{request | method: m} - ## Parameters - - - request (Map) - Collected request options - - u (String) - Request URL - - ## Returns - - Map + @doc """ + Specify the request method when building a request """ - @spec url(map(), String.t()) :: map() - def url(request, u) do - Map.put_new(request, :url, u) - end + @spec url(Request.t(), Request.url()) :: Request.t() + def url(request, u), do: %{request | url: u} @doc """ Add headers to the request """ - @spec headers(map(), list(tuple)) :: map() - def headers(request, header_list) do - header_list = + @spec headers(Request.t(), Request.headers()) :: Request.t() + def headers(request, headers) do + headers_list = if Pleroma.Config.get([:http, :send_user_agent]) do - header_list ++ [{"User-Agent", Pleroma.Application.user_agent()}] + [{"user-agent", Pleroma.Application.user_agent()} | headers] else - header_list + headers end - Map.put_new(request, :headers, header_list) + %{request | headers: headers_list} end @doc """ Add custom, per-request middleware or adapter options to the request """ - @spec opts(map(), Keyword.t()) :: map() - def opts(request, options) do - Map.put_new(request, :opts, options) - end - - @doc """ - Add optional parameters to the request - - ## Parameters - - - request (Map) - Collected request options - - definitions (Map) - Map of parameter name to parameter location. - - options (KeywordList) - The provided optional parameters - - ## Returns - - Map - """ - @spec add_optional_params(map(), %{optional(atom) => atom}, keyword()) :: map() - def add_optional_params(request, _, []), do: request - - def add_optional_params(request, definitions, [{key, value} | tail]) do - case definitions do - %{^key => location} -> - request - |> add_param(location, key, value) - |> add_optional_params(definitions, tail) - - _ -> - add_optional_params(request, definitions, tail) - end - end + @spec opts(Request.t(), keyword()) :: Request.t() + def opts(request, options), do: %{request | opts: options} @doc """ Add optional parameters to the request - - ## Parameters - - - request (Map) - Collected request options - - location (atom) - Where to put the parameter - - key (atom) - The name of the parameter - - value (any) - The value of the parameter - - ## Returns - - Map """ - @spec add_param(map(), atom, atom, any()) :: map() - def add_param(request, :query, :query, values), do: Map.put(request, :query, values) + @spec add_param(Request.t(), atom(), atom(), any()) :: Request.t() + def add_param(request, :query, :query, values), do: %{request | query: values} - def add_param(request, :body, :body, value), do: Map.put(request, :body, value) + def add_param(request, :body, :body, value), do: %{request | body: value} def add_param(request, :body, key, value) do request - |> Map.put_new_lazy(:body, &Tesla.Multipart.new/0) + |> Map.put(:body, Multipart.new()) |> Map.update!( :body, - &Tesla.Multipart.add_field( + &Multipart.add_field( &1, key, Jason.encode!(value), - headers: [{:"Content-Type", "application/json"}] + headers: [{"content-type", "application/json"}] ) ) end def add_param(request, :file, name, path) do request - |> Map.put_new_lazy(:body, &Tesla.Multipart.new/0) - |> Map.update!(:body, &Tesla.Multipart.add_file(&1, path, name: name)) + |> Map.put(:body, Multipart.new()) + |> Map.update!(:body, &Multipart.add_file(&1, path, name: name)) end def add_param(request, :form, name, value) do - request - |> Map.update(:body, %{name => value}, &Map.put(&1, name, value)) + Map.update(request, :body, %{name => value}, &Map.put(&1, name, value)) end def add_param(request, location, key, value) do Map.update(request, location, [{key, value}], &(&1 ++ [{key, value}])) end + + def convert_to_keyword(request) do + request + |> Map.from_struct() + |> Enum.into([]) + end end diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex index 1b05d573c..557e8decf 100644 --- a/lib/pleroma/instances.ex +++ b/lib/pleroma/instances.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Instances do diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 544c4b687..74458c09a 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Instances.Instance do diff --git a/lib/pleroma/job_queue_monitor.ex b/lib/pleroma/job_queue_monitor.ex index 3feea8381..2ecf261f3 100644 --- a/lib/pleroma/job_queue_monitor.ex +++ b/lib/pleroma/job_queue_monitor.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.JobQueueMonitor do diff --git a/lib/pleroma/keys.ex b/lib/pleroma/keys.ex index 6dd31d3bd..c9af79f00 100644 --- a/lib/pleroma/keys.ex +++ b/lib/pleroma/keys.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Keys do diff --git a/lib/pleroma/list.ex b/lib/pleroma/list.ex index 08a94c62c..89aa7b5d4 100644 --- a/lib/pleroma/list.ex +++ b/lib/pleroma/list.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.List do diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 7f87c86c3..4d82860f5 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Marker do @@ -9,24 +9,34 @@ defmodule Pleroma.Marker do import Ecto.Query alias Ecto.Multi + alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.User + alias __MODULE__ @timelines ["notifications"] + @type t :: %__MODULE__{} schema "markers" do field(:last_read_id, :string, default: "") field(:timeline, :string, default: "") field(:lock_version, :integer, default: 0) + field(:unread_count, :integer, default: 0, virtual: true) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() end + @doc "Gets markers by user and timeline." + @spec get_markers(User.t(), list(String)) :: list(t()) def get_markers(user, timelines \\ []) do - Repo.all(get_query(user, timelines)) + user + |> get_query(timelines) + |> unread_count_query() + |> Repo.all() end + @spec upsert(User.t(), map()) :: {:ok | :error, any()} def upsert(%User{} = user, attrs) do attrs |> Map.take(@timelines) @@ -45,6 +55,27 @@ defmodule Pleroma.Marker do |> Repo.transaction() end + @spec multi_set_last_read_id(Multi.t(), User.t(), String.t()) :: Multi.t() + def multi_set_last_read_id(multi, %User{} = user, "notifications") do + multi + |> Multi.run(:counters, fn _repo, _changes -> + {:ok, %{last_read_id: Repo.one(Notification.last_read_query(user))}} + end) + |> Multi.insert( + :marker, + fn %{counters: attrs} -> + %Marker{timeline: "notifications", user_id: user.id} + |> struct(attrs) + |> Ecto.Changeset.change() + end, + returning: true, + on_conflict: {:replace, [:last_read_id]}, + conflict_target: [:user_id, :timeline] + ) + end + + def multi_set_last_read_id(multi, _, _), do: multi + defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do {:ok, marker} -> %__MODULE__{marker | user: user} @@ -71,4 +102,16 @@ defmodule Pleroma.Marker do |> by_user_id(user.id) |> by_timeline(timelines) end + + defp unread_count_query(query) do + from( + q in query, + left_join: n in "notifications", + on: n.user_id == q.user_id and n.seen == false, + group_by: [:id], + select_merge: %{ + unread_count: fragment("count(?)", n.id) + } + ) + end end diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex new file mode 100644 index 000000000..2b77f5426 --- /dev/null +++ b/lib/pleroma/mfa.ex @@ -0,0 +1,155 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA do + @moduledoc """ + The MFA context. + """ + + alias Pleroma.User + + alias Pleroma.MFA.BackupCodes + alias Pleroma.MFA.Changeset + alias Pleroma.MFA.Settings + alias Pleroma.MFA.TOTP + + @doc """ + Returns MFA methods the user has enabled. + + ## Examples + + iex> Pleroma.MFA.supported_method(User) + "totp, u2f" + """ + @spec supported_methods(User.t()) :: String.t() + def supported_methods(user) do + settings = fetch_settings(user) + + Settings.mfa_methods() + |> Enum.reduce([], fn m, acc -> + if method_enabled?(m, settings) do + acc ++ [m] + else + acc + end + end) + |> Enum.join(",") + end + + @doc "Checks that user enabled MFA" + def require?(user) do + fetch_settings(user).enabled + end + + @doc """ + Display MFA settings of user + """ + def mfa_settings(user) do + settings = fetch_settings(user) + + Settings.mfa_methods() + |> Enum.map(fn m -> [m, method_enabled?(m, settings)] end) + |> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end) + end + + @doc false + def fetch_settings(%User{} = user) do + user.multi_factor_authentication_settings || %Settings{} + end + + @doc "clears backup codes" + def invalidate_backup_code(%User{} = user, hash_code) do + %{backup_codes: codes} = fetch_settings(user) + + user + |> Changeset.cast_backup_codes(codes -- [hash_code]) + |> User.update_and_set_cache() + end + + @doc "generates backup codes" + @spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()} + def generate_backup_codes(%User{} = user) do + with codes <- BackupCodes.generate(), + hashed_codes <- Enum.map(codes, &Pbkdf2.hash_pwd_salt/1), + changeset <- Changeset.cast_backup_codes(user, hashed_codes), + {:ok, _} <- User.update_and_set_cache(changeset) do + {:ok, codes} + else + {:error, msg} -> + %{error: msg} + end + end + + @doc """ + Generates secret key and set delivery_type to 'app' for TOTP method. + """ + @spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def setup_totp(user) do + user + |> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"}) + |> User.update_and_set_cache() + end + + @doc """ + Confirms the TOTP method for user. + + `attrs`: + `password` - current user password + `code` - TOTP token + """ + @spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()} + def confirm_totp(%User{} = user, attrs) do + with settings <- user.multi_factor_authentication_settings.totp, + {:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do + user + |> Changeset.confirm_totp() + |> User.update_and_set_cache() + end + end + + @doc """ + Disables the TOTP method for user. + + `attrs`: + `password` - current user password + """ + @spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def disable_totp(%User{} = user) do + user + |> Changeset.disable_totp() + |> Changeset.disable() + |> User.update_and_set_cache() + end + + @doc """ + Force disables all MFA methods for user. + """ + @spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def disable(%User{} = user) do + user + |> Changeset.disable_totp() + |> Changeset.disable(true) + |> User.update_and_set_cache() + end + + @doc """ + Checks if the user has MFA method enabled. + """ + def method_enabled?(method, settings) do + with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do + true + else + _ -> false + end + end + + @doc """ + Checks if the user has enabled at least one MFA method. + """ + def enabled?(settings) do + Settings.mfa_methods() + |> Enum.map(fn m -> method_enabled?(m, settings) end) + |> Enum.any?() + end +end diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex new file mode 100644 index 000000000..2b5ec34f8 --- /dev/null +++ b/lib/pleroma/mfa/backup_codes.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.BackupCodes do + @moduledoc """ + This module contains functions for generating backup codes. + """ + alias Pleroma.Config + + @config_ns [:instance, :multi_factor_authentication, :backup_codes] + + @doc """ + Generates backup codes. + """ + @spec generate(Keyword.t()) :: list(String.t()) + def generate(opts \\ []) do + number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number()) + code_length = Keyword.get(opts, :length, default_backup_codes_code_length()) + + Enum.map(1..number_of_codes, fn _ -> + :crypto.strong_rand_bytes(div(code_length, 2)) + |> Base.encode16(case: :lower) + end) + end + + defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5) + + defp default_backup_codes_code_length, + do: Config.get(@config_ns ++ [:length], 16) +end diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex new file mode 100644 index 000000000..9b020aa8e --- /dev/null +++ b/lib/pleroma/mfa/changeset.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Changeset do + alias Pleroma.MFA + alias Pleroma.MFA.Settings + alias Pleroma.User + + def disable(%Ecto.Changeset{} = changeset, force \\ false) do + settings = + changeset + |> Ecto.Changeset.apply_changes() + |> MFA.fetch_settings() + + if force || not MFA.enabled?(settings) do + put_change(changeset, %Settings{settings | enabled: false}) + else + changeset + end + end + + def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do + user + |> put_change(%Settings{settings | totp: %Settings.TOTP{}}) + end + + def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do + totp_settings = %Settings.TOTP{settings.totp | confirmed: true} + + user + |> put_change(%Settings{settings | totp: totp_settings, enabled: true}) + end + + def setup_totp(%User{} = user, attrs) do + mfa_settings = MFA.fetch_settings(user) + + totp_settings = + %Settings.TOTP{} + |> Ecto.Changeset.cast(attrs, [:secret, :delivery_type]) + + user + |> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)}) + end + + def cast_backup_codes(%User{} = user, codes) do + user + |> put_change(%Settings{ + user.multi_factor_authentication_settings + | backup_codes: codes + }) + end + + defp put_change(%User{} = user, settings) do + user + |> Ecto.Changeset.change() + |> put_change(settings) + end + + defp put_change(%Ecto.Changeset{} = changeset, settings) do + changeset + |> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings) + end +end diff --git a/lib/pleroma/mfa/settings.ex b/lib/pleroma/mfa/settings.ex new file mode 100644 index 000000000..2764b889c --- /dev/null +++ b/lib/pleroma/mfa/settings.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Settings do + use Ecto.Schema + + @primary_key false + + @mfa_methods [:totp] + embedded_schema do + field(:enabled, :boolean, default: false) + field(:backup_codes, {:array, :string}, default: []) + + embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do + field(:secret, :string) + # app | sms + field(:delivery_type, :string, default: "app") + field(:confirmed, :boolean, default: false) + end + end + + def mfa_methods, do: @mfa_methods +end diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex new file mode 100644 index 000000000..25ff7fb29 --- /dev/null +++ b/lib/pleroma/mfa/token.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Token do + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token, as: OAuthToken + + @expires 300 + + schema "mfa_tokens" do + field(:token, :string) + field(:valid_until, :naive_datetime_usec) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:authorization, Authorization) + + timestamps() + end + + def get_by_token(token) do + from( + t in __MODULE__, + where: t.token == ^token, + preload: [:user, :authorization] + ) + |> Repo.find_resource() + end + + def validate(token) do + with {:fetch_token, {:ok, token}} <- {:fetch_token, get_by_token(token)}, + {:expired, false} <- {:expired, is_expired?(token)} do + {:ok, token} + else + {:expired, _} -> {:error, :expired_token} + {:fetch_token, _} -> {:error, :not_found} + error -> {:error, error} + end + end + + def create_token(%User{} = user) do + %__MODULE__{} + |> change + |> assign_user(user) + |> put_token + |> put_valid_until + |> Repo.insert() + end + + def create_token(user, authorization) do + %__MODULE__{} + |> change + |> assign_user(user) + |> assign_authorization(authorization) + |> put_token + |> put_valid_until + |> Repo.insert() + end + + defp assign_user(changeset, user) do + changeset + |> put_assoc(:user, user) + |> validate_required([:user]) + end + + defp assign_authorization(changeset, authorization) do + changeset + |> put_assoc(:authorization, authorization) + |> validate_required([:authorization]) + end + + defp put_token(changeset) do + changeset + |> change(%{token: OAuthToken.Utils.generate_token()}) + |> validate_required([:token]) + |> unique_constraint(:token) + end + + defp put_valid_until(changeset) do + expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires) + + changeset + |> change(%{valid_until: expires_in}) + |> validate_required([:valid_until]) + end + + def is_expired?(%__MODULE__{valid_until: valid_until}) do + NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0 + end + + def is_expired?(_), do: false + + def delete_expired_tokens do + from( + q in __MODULE__, + where: fragment("?", q.valid_until) < ^Timex.now() + ) + |> Repo.delete_all() + end +end diff --git a/lib/pleroma/mfa/totp.ex b/lib/pleroma/mfa/totp.ex new file mode 100644 index 000000000..1407afc57 --- /dev/null +++ b/lib/pleroma/mfa/totp.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.TOTP do + @moduledoc """ + This module represents functions to create secrets for + TOTP Application as well as validate them with a time based token. + """ + alias Pleroma.Config + + @config_ns [:instance, :multi_factor_authentication, :totp] + + @doc """ + https://github.com/google/google-authenticator/wiki/Key-Uri-Format + """ + def provisioning_uri(secret, label, opts \\ []) do + query = + %{ + secret: secret, + issuer: Keyword.get(opts, :issuer, default_issuer()), + digits: Keyword.get(opts, :digits, default_digits()), + period: Keyword.get(opts, :period, default_period()) + } + |> Enum.filter(fn {_, v} -> not is_nil(v) end) + |> Enum.into(%{}) + |> URI.encode_query() + + %URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query} + |> URI.to_string() + end + + defp default_period, do: Config.get(@config_ns ++ [:period]) + defp default_digits, do: Config.get(@config_ns ++ [:digits]) + + defp default_issuer, + do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name])) + + @doc "Creates a random Base 32 encoded string" + def generate_secret do + Base.encode32(:crypto.strong_rand_bytes(10)) + end + + @doc "Generates a valid token based on a secret" + def generate_token(secret) do + :pot.totp(secret) + end + + @doc """ + Validates a given token based on a secret. + + optional parameters: + `token_length` default `6` + `interval_length` default `30` + `window` default 0 + + Returns {:ok, :pass} if the token is valid and + {:error, :invalid_token} if it is not. + """ + @spec validate_token(String.t(), String.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def validate_token(secret, token) + when is_binary(secret) and is_binary(token) do + opts = [ + token_length: default_digits(), + interval_length: default_period() + ] + + validate_token(secret, token, opts) + end + + def validate_token(_, _), do: {:error, :invalid_secret_and_token} + + @doc "See `validate_token/2`" + @spec validate_token(String.t(), String.t(), Keyword.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def validate_token(secret, token, options) + when is_binary(secret) and is_binary(token) do + case :pot.valid_totp(token, secret, options) do + true -> {:ok, :pass} + false -> {:error, :invalid_token} + end + end + + def validate_token(_, _, _), do: {:error, :invalid_secret_and_token} +end diff --git a/lib/pleroma/mime.ex b/lib/pleroma/mime.ex index 36771533f..6ee055f50 100644 --- a/lib/pleroma/mime.ex +++ b/lib/pleroma/mime.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MIME do @@ -9,7 +9,7 @@ defmodule Pleroma.MIME do @default "application/octet-stream" @read_bytes 35 - @spec file_mime_type(String.t()) :: + @spec file_mime_type(String.t(), String.t()) :: {:ok, content_type :: String.t(), filename :: String.t()} | {:error, any()} | :error def file_mime_type(path, filename) do with {:ok, content_type} <- file_mime_type(path), diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index e8884e6e8..7aacd9d80 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -128,17 +128,35 @@ defmodule Pleroma.ModerationLog do {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, - action: "report_response", + action: "report_note", subject: %Activity{} = subject, text: text }) do %ModerationLog{ data: %{ "actor" => user_to_map(actor), - "action" => "report_response", + "action" => "report_note", "subject" => report_to_map(subject), - "text" => text, - "message" => "" + "text" => text + } + } + |> insert_log_entry_with_message() + end + + @spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) :: + {:ok, ModerationLog} | {:error, any} + def insert_log(%{ + actor: %User{} = actor, + action: "report_note_delete", + subject: %Activity{} = subject, + text: text + }) do + %ModerationLog{ + data: %{ + "actor" => user_to_map(actor), + "action" => "report_note_delete", + "subject" => report_to_map(subject), + "text" => text } } |> insert_log_entry_with_message() @@ -480,12 +498,24 @@ defmodule Pleroma.ModerationLog do def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, - "action" => "report_response", + "action" => "report_note", + "subject" => %{"id" => subject_id, "type" => "report"}, + "text" => text + } + }) do + "@#{actor_nickname} added note '#{text}' to report ##{subject_id}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_note_delete", "subject" => %{"id" => subject_id, "type" => "report"}, "text" => text } }) do - "@#{actor_nickname} responded with '#{text}' to report ##{subject_id}" + "@#{actor_nickname} deleted note '#{text}' from report ##{subject_id}" end @spec get_log_entry_message(ModerationLog) :: String.t() @@ -540,6 +570,52 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} deleted status ##{subject_id}" end + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "force_password_reset", + "subject" => subjects + } + }) do + "@#{actor_nickname} forced password reset for users: #{users_to_nicknames_string(subjects)}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "confirm_email", + "subject" => subjects + } + }) do + "@#{actor_nickname} confirmed email for users: #{users_to_nicknames_string(subjects)}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "resend_confirmation_email", + "subject" => subjects + } + }) do + "@#{actor_nickname} re-sent confirmation email for users: #{ + users_to_nicknames_string(subjects) + }" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "updated_users", + "subject" => subjects + } + }) do + "@#{actor_nickname} updated users: #{users_to_nicknames_string(subjects)}" + end + defp nicknames_to_string(nicknames) do nicknames |> Enum.map(&"@#{&1}") diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index e5da1492b..8aa9ed2d4 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -1,15 +1,19 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Notification do use Ecto.Schema + alias Ecto.Multi alias Pleroma.Activity + alias Pleroma.FollowingRelationship + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Repo + alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.Push @@ -17,10 +21,13 @@ defmodule Pleroma.Notification do import Ecto.Query import Ecto.Changeset + require Logger @type t :: %__MODULE__{} + @include_muted_option :with_muted + schema "notifications" do field(:seen, :boolean, default: false) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) @@ -29,18 +36,55 @@ defmodule Pleroma.Notification do timestamps() end + @spec unread_notifications_count(User.t()) :: integer() + def unread_notifications_count(%User{id: user_id}) do + from(q in __MODULE__, + where: q.user_id == ^user_id and q.seen == false + ) + |> Repo.aggregate(:count, :id) + end + def changeset(%Notification{} = notification, attrs) do notification |> cast(attrs, [:seen]) end - def for_user_query(user, opts \\ []) do + @spec last_read_query(User.t()) :: Ecto.Queryable.t() + def last_read_query(user) do + from(q in Pleroma.Notification, + where: q.user_id == ^user.id, + where: q.seen == true, + select: type(q.id, :string), + limit: 1, + order_by: [desc: :id] + ) + end + + defp for_user_query_ap_id_opts(user, opts) do + ap_id_relationships = + [:block] ++ + if opts[@include_muted_option], do: [], else: [:notification_mute] + + preloaded_ap_ids = User.outgoing_relationships_ap_ids(user, ap_id_relationships) + + exclude_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts) + + exclude_notification_muted_opts = + Map.merge(%{notification_muted_users_ap_ids: preloaded_ap_ids[:notification_mute]}, opts) + + {exclude_blocked_opts, exclude_notification_muted_opts} + end + + def for_user_query(user, opts \\ %{}) do + {exclude_blocked_opts, exclude_notification_muted_opts} = + for_user_query_ap_id_opts(user, opts) + Notification |> where(user_id: ^user.id) |> where( [n, a], fragment( - "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", + "? not in (SELECT ap_id FROM users WHERE deactivated = 'true')", a.actor ) ) @@ -54,28 +98,31 @@ defmodule Pleroma.Notification do ) ) |> preload([n, a, o], activity: {a, object: o}) - |> exclude_muted(user, opts) - |> exclude_blocked(user) + |> exclude_notification_muted(user, exclude_notification_muted_opts) + |> exclude_blocked(user, exclude_blocked_opts) |> exclude_visibility(opts) end - defp exclude_blocked(query, user) do + # Excludes blocked users and non-followed domain-blocked users + defp exclude_blocked(query, user, opts) do + blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user) + query - |> where([n, a], a.actor not in ^user.info.blocks) - |> where( - [n, a], - fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks - ) + |> where([n, a], a.actor not in ^blocked_ap_ids) + |> FollowingRelationship.keep_following_or_not_domain_blocked(user) end - defp exclude_muted(query, _, %{with_muted: true}) do + defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do query end - defp exclude_muted(query, user, _opts) do + defp exclude_notification_muted(query, user, opts) do + notification_muted_ap_ids = + opts[:notification_muted_users_ap_ids] || User.notification_muted_users_ap_ids(user) + query - |> where([n, a], a.actor not in ^user.info.muted_notifications) - |> join(:left, [n, a], tm in Pleroma.ThreadMute, + |> where([n, a], a.actor not in ^notification_muted_ap_ids) + |> join(:left, [n, a], tm in ThreadMute, on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data) ) |> where([n, a, o, tm], is_nil(tm.user_id)) @@ -87,10 +134,28 @@ defmodule Pleroma.Notification do when is_list(visibility) do if Enum.all?(visibility, &(&1 in @valid_visibilities)) do query + |> join(:left, [n, a], mutated_activity in Pleroma.Activity, + on: + fragment("?->>'context'", a.data) == + fragment("?->>'context'", mutated_activity.data) and + fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and + fragment("?->>'type'", mutated_activity.data) == "Create", + as: :mutated_activity + ) |> where( - [n, a], + [n, a, mutated_activity: mutated_activity], not fragment( - "activity_visibility(?, ?, ?) = ANY (?)", + """ + CASE WHEN (?->>'type') = 'Like' or (?->>'type') = 'Announce' + THEN (activity_visibility(?, ?, ?) = ANY (?)) + ELSE (activity_visibility(?, ?, ?) = ANY (?)) END + """, + a.data, + a.data, + mutated_activity.actor, + mutated_activity.recipients, + mutated_activity.data, + ^visibility, a.actor, a.recipients, a.data, @@ -105,17 +170,7 @@ defmodule Pleroma.Notification do defp exclude_visibility(query, %{exclude_visibilities: visibility}) when visibility in @valid_visibilities do - query - |> where( - [n, a], - not fragment( - "activity_visibility(?, ?, ?) = (?)", - a.actor, - a.recipients, - a.data, - ^visibility - ) - ) + exclude_visibility(query, [visibility]) end defp exclude_visibility(query, %{exclude_visibilities: visibility}) @@ -151,25 +206,23 @@ defmodule Pleroma.Notification do |> Repo.all() end - def set_read_up_to(%{id: user_id} = _user, id) do + def set_read_up_to(%{id: user_id} = user, id) do query = from( n in Notification, where: n.user_id == ^user_id, where: n.id <= ^id, where: n.seen == false, - update: [ - set: [ - seen: true, - updated_at: ^NaiveDateTime.utc_now() - ] - ], # Ideally we would preload object and activities here # but Ecto does not support preloads in update_all select: n.id ) - {_, notification_ids} = Repo.update_all(query, []) + {:ok, %{ids: {_, notification_ids}}} = + Multi.new() + |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() Notification |> where([n], n.id in ^notification_ids) @@ -186,11 +239,18 @@ defmodule Pleroma.Notification do |> Repo.all() end + @spec read_one(User.t(), String.t()) :: + {:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil def read_one(%User{} = user, notification_id) do with {:ok, %Notification{} = notification} <- get(user, notification_id) do - notification - |> changeset(%{seen: true}) - |> Repo.update() + Multi.new() + |> Multi.update(:update, changeset(notification, %{seen: true})) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() + |> case do + {:ok, %{update: notification}} -> {:ok, notification} + {:error, :update, changeset, _} -> {:error, changeset} + end end end @@ -227,6 +287,16 @@ defmodule Pleroma.Notification do |> Repo.delete_all() end + def dismiss(%Pleroma.Activity{} = activity) do + Notification + |> where([n], n.activity_id == ^activity.id) + |> Repo.delete_all() + |> case do + {_, notifications} -> {:ok, notifications} + _ -> {:error, "Cannot dismiss notification"} + end + end + def dismiss(%{id: user_id} = _user, id) do notification = Repo.get(Notification, id) @@ -242,59 +312,159 @@ defmodule Pleroma.Notification do def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do object = Object.normalize(activity) - unless object && object.data["type"] == "Answer" do - users = get_notified_from_activity(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) - {:ok, notifications} - else + if object && object.data["type"] == "Answer" do {:ok, []} + else + do_create_notifications(activity) end end - def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) - when type in ["Like", "Announce", "Follow"] do - users = get_notified_from_activity(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) - {:ok, notifications} + def create_notifications(%Activity{data: %{"type" => type}} = activity) + when type in ["Follow", "Like", "Announce", "Move", "EmojiReact"] do + do_create_notifications(activity) end def create_notifications(_), do: {:ok, []} + defp do_create_notifications(%Activity{} = activity) do + {enabled_receivers, disabled_receivers} = get_notified_from_activity(activity) + potential_receivers = enabled_receivers ++ disabled_receivers + + notifications = + Enum.map(potential_receivers, fn user -> + do_send = user in enabled_receivers + create_notification(activity, user, do_send) + end) + + {:ok, notifications} + end + # TODO move to sql, too. - def create_notification(%Activity{} = activity, %User{} = user) do + def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do unless skip?(activity, user) do - notification = %Notification{user_id: user.id, activity: activity} - {:ok, notification} = Repo.insert(notification) + {:ok, %{notification: notification}} = + Multi.new() + |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() - ["user", "user:notification"] - |> Streamer.stream(notification) + if do_send do + Streamer.stream(["user", "user:notification"], notification) + Push.send(notification) + end - Push.send(notification) notification end end + @doc """ + Returns a tuple with 2 elements: + {notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)} + + NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1 + """ + @spec get_notified_from_activity(Activity.t(), boolean()) :: {list(User.t()), list(User.t())} def get_notified_from_activity(activity, local_only \\ true) - def get_notified_from_activity( - %Activity{data: %{"to" => _, "type" => type} = _data} = activity, - local_only - ) - when type in ["Create", "Like", "Announce", "Follow"] do - recipients = - [] - |> Utils.maybe_notify_to_recipients(activity) - |> Utils.maybe_notify_mentioned_recipients(activity) - |> Utils.maybe_notify_subscribers(activity) - |> Enum.uniq() + def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) + when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact"] do + potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity) + + potential_receivers = User.get_users_from_set(potential_receiver_ap_ids, local_only) + + notification_enabled_ap_ids = + potential_receiver_ap_ids + |> exclude_domain_blocker_ap_ids(activity, potential_receivers) + |> exclude_relationship_restricted_ap_ids(activity) + |> exclude_thread_muter_ap_ids(activity) + + notification_enabled_users = + Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) + + {notification_enabled_users, potential_receivers -- notification_enabled_users} + end + + def get_notified_from_activity(_, _local_only), do: {[], []} + + # For some activities, only notify the author of the object + def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}}) + when type in ~w{Like Announce EmojiReact} do + case Object.get_cached_by_ap_id(object_id) do + %Object{data: %{"actor" => actor}} -> + [actor] + + _ -> + [] + end + end + + def get_potential_receiver_ap_ids(activity) do + [] + |> Utils.maybe_notify_to_recipients(activity) + |> Utils.maybe_notify_mentioned_recipients(activity) + |> Utils.maybe_notify_subscribers(activity) + |> Utils.maybe_notify_followers(activity) + |> Enum.uniq() + end + + @doc "Filters out AP IDs domain-blocking and not following the activity's actor" + def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ []) + + def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: [] - User.get_users_from_set(recipients, local_only) + def exclude_domain_blocker_ap_ids(ap_ids, %Activity{} = activity, preloaded_users) do + activity_actor_domain = activity.actor && URI.parse(activity.actor).host + + users = + ap_ids + |> Enum.map(fn ap_id -> + Enum.find(preloaded_users, &(&1.ap_id == ap_id)) || + User.get_cached_by_ap_id(ap_id) + end) + |> Enum.filter(& &1) + + domain_blocker_ap_ids = for u <- users, activity_actor_domain in u.domain_blocks, do: u.ap_id + + domain_blocker_follower_ap_ids = + if Enum.any?(domain_blocker_ap_ids) do + activity + |> Activity.user_actor() + |> FollowingRelationship.followers_ap_ids(domain_blocker_ap_ids) + else + [] + end + + ap_ids + |> Kernel.--(domain_blocker_ap_ids) + |> Kernel.++(domain_blocker_follower_ap_ids) end - def get_notified_from_activity(_, _local_only), do: [] + @doc "Filters out AP IDs of users basing on their relationships with activity actor user" + def exclude_relationship_restricted_ap_ids([], _activity), do: [] + + def exclude_relationship_restricted_ap_ids(ap_ids, %Activity{} = activity) do + relationship_restricted_ap_ids = + activity + |> Activity.user_actor() + |> User.incoming_relationships_ungrouped_ap_ids([ + :block, + :notification_mute + ]) + + Enum.uniq(ap_ids) -- relationship_restricted_ap_ids + end + + @doc "Filters out AP IDs of users who mute activity thread" + def exclude_thread_muter_ap_ids([], _activity), do: [] + + def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do + thread_muter_ap_ids = ThreadMute.muter_ap_ids(activity.data["context"]) + + Enum.uniq(ap_ids) -- thread_muter_ap_ids + end @spec skip?(Activity.t(), User.t()) :: boolean() - def skip?(activity, user) do + def skip?(%Activity{} = activity, %User{} = user) do [ :self, :followers, @@ -303,18 +473,20 @@ defmodule Pleroma.Notification do :non_follows, :recently_followed ] - |> Enum.any?(&skip?(&1, activity, user)) + |> Enum.find(&skip?(&1, activity, user)) end + def skip?(_, _), do: false + @spec skip?(atom(), Activity.t(), User.t()) :: boolean() - def skip?(:self, activity, user) do + def skip?(:self, %Activity{} = activity, %User{} = user) do activity.data["actor"] == user.ap_id end def skip?( :followers, - activity, - %{info: %{notification_settings: %{"followers" => false}}} = user + %Activity{} = activity, + %User{notification_settings: %{followers: false}} = user ) do actor = activity.data["actor"] follower = User.get_cached_by_ap_id(actor) @@ -323,15 +495,19 @@ defmodule Pleroma.Notification do def skip?( :non_followers, - activity, - %{info: %{notification_settings: %{"non_followers" => false}}} = user + %Activity{} = activity, + %User{notification_settings: %{non_followers: false}} = user ) do actor = activity.data["actor"] follower = User.get_cached_by_ap_id(actor) !User.following?(follower, user) end - def skip?(:follows, activity, %{info: %{notification_settings: %{"follows" => false}}} = user) do + def skip?( + :follows, + %Activity{} = activity, + %User{notification_settings: %{follows: false}} = user + ) do actor = activity.data["actor"] followed = User.get_cached_by_ap_id(actor) User.following?(user, followed) @@ -339,15 +515,16 @@ defmodule Pleroma.Notification do def skip?( :non_follows, - activity, - %{info: %{notification_settings: %{"non_follows" => false}}} = user + %Activity{} = activity, + %User{notification_settings: %{non_follows: false}} = user ) do actor = activity.data["actor"] followed = User.get_cached_by_ap_id(actor) !User.following?(user, followed) end - def skip?(:recently_followed, %{data: %{"type" => "Follow"}} = activity, user) do + # To do: consider defining recency in hours and checking FollowingRelationship with a single SQL + def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, %User{} = user) do actor = activity.data["actor"] Notification.for_user(user) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index d9b41d710..ab16bf2db 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -1,10 +1,13 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Object do use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + alias Pleroma.Activity alias Pleroma.Object alias Pleroma.Object.Fetcher @@ -12,17 +15,35 @@ defmodule Pleroma.Object do alias Pleroma.Repo alias Pleroma.User - import Ecto.Query - import Ecto.Changeset - require Logger + @type t() :: %__MODULE__{} + + @derive {Jason.Encoder, only: [:data]} + schema "objects" do field(:data, :map) timestamps() end + def with_joined_activity(query, activity_type \\ "Create", join_type \\ :inner) do + object_position = Map.get(query.aliases, :object, 0) + + join(query, join_type, [{object, object_position}], a in Activity, + on: + fragment( + "COALESCE(?->'object'->>'id', ?->>'object') = (? ->> 'id') AND (?->>'type' = ?) ", + a.data, + a.data, + object.data, + a.data, + ^activity_type + ), + as: :object_activity + ) + end + def create(data) do Object.change(%Object{}, %{data: data}) |> Repo.insert() @@ -62,8 +83,22 @@ defmodule Pleroma.Object do Repo.one(from(object in Object, where: fragment("(?)->>'id' = ?", object.data, ^ap_id))) end + @doc """ + Get a single attachment by it's name and href + """ + @spec get_attachment_by_name_and_href(String.t(), String.t()) :: Object.t() | nil + def get_attachment_by_name_and_href(name, href) do + query = + from(o in Object, + where: fragment("(?)->>'name' = ?", o.data, ^name), + where: fragment("(?)->>'href' = ?", o.data, ^href) + ) + + Repo.one(query) + end + defp warn_on_no_object_preloaded(ap_id) do - "Object.normalize() called without preloaded object (#{ap_id}). Consider preloading the object" + "Object.normalize() called without preloaded object (#{inspect(ap_id)}). Consider preloading the object" |> Logger.debug() Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}") @@ -103,25 +138,30 @@ defmodule Pleroma.Object do def normalize(_, _, _), do: nil - # Owned objects can only be mutated by their owner - def authorize_mutation(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}), - do: actor == ap_id + # Owned objects can only be accessed by their owner + def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do + if actor == ap_id do + :ok + else + {:error, :forbidden} + end + end - # Legacy objects can be mutated by anybody - def authorize_mutation(%Object{}, %User{}), do: true + # Legacy objects can be accessed by anybody + def authorize_access(%Object{}, %User{}), do: :ok + @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil def get_cached_by_ap_id(ap_id) do key = "object:#{ap_id}" - Cachex.fetch!(:object_cache, key, fn _ -> - object = get_by_ap_id(ap_id) - - if object do - {:commit, object} - else - {:ignore, object} - end - end) + with {:ok, nil} <- Cachex.get(:object_cache, key), + object when not is_nil(object) <- get_by_ap_id(ap_id), + {:ok, true} <- Cachex.put(:object_cache, key, object) do + object + else + {:ok, object} -> object + nil -> nil + end end def context_mapping(context) do @@ -147,9 +187,16 @@ defmodule Pleroma.Object do def delete(%Object{data: %{"id" => id}} = object) do with {:ok, _obj} = swap_object_with_tombstone(object), - deleted_activity = Activity.delete_by_ap_id(id), + deleted_activity = Activity.delete_all_by_object_ap_id(id), {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"), {:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do + with true <- Pleroma.Config.get([:instance, :cleanup_attachments]) do + {:ok, _} = + Pleroma.Workers.AttachmentsCleanupWorker.enqueue("cleanup_attachments", %{ + "object" => object + }) + end + {:ok, object, deleted_activity} end end @@ -219,7 +266,7 @@ defmodule Pleroma.Object do end end - def increase_vote_count(ap_id, name) do + def increase_vote_count(ap_id, name, actor) do with %Object{} = object <- Object.normalize(ap_id), "Question" <- object.data["type"] do multiple = Map.has_key?(object.data, "anyOf") @@ -234,12 +281,15 @@ defmodule Pleroma.Object do option end) + voters = [actor | object.data["voters"] || []] |> Enum.uniq() + data = if multiple do Map.put(object.data, "anyOf", options) else Map.put(object.data, "oneOf", options) end + |> Map.put("voters", voters) object |> Object.change(%{data: data}) @@ -255,4 +305,30 @@ defmodule Pleroma.Object do |> Object.change(%{data: Map.merge(data || %{}, attrs)}) |> Repo.update() end + + def local?(%Object{data: %{"id" => id}}) do + String.starts_with?(id, Pleroma.Web.base_url() <> "/") + end + + def replies(object, opts \\ []) do + object = Object.normalize(object) + + query = + Object + |> where( + [o], + fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"]) + ) + |> order_by([o], asc: o.id) + + if opts[:self_only] do + actor = object.data["actor"] + where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor)) + else + query + end + end + + def self_replies(object, opts \\ []), + do: replies(object, Keyword.put(opts, :self_only, true)) end diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index 68535c09e..99608b8a5 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Object.Containment do @@ -32,6 +32,18 @@ defmodule Pleroma.Object.Containment do get_actor(%{"actor" => actor}) end + def get_object(%{"object" => id}) when is_binary(id) do + id + end + + def get_object(%{"object" => %{"id" => id}}) when is_binary(id) do + id + end + + def get_object(_) do + nil + end + # TODO: We explicitly allow 'tag' URIs through, due to references to legacy OStatus # objects being present in the test suite environment. Once these objects are # removed, please also remove this. @@ -39,15 +51,8 @@ defmodule Pleroma.Object.Containment do defp compare_uris(_, %URI{scheme: "tag"}), do: :ok end - defp compare_uris(%URI{} = id_uri, %URI{} = other_uri) do - if id_uri.host == other_uri.host do - :ok - else - :error - end - end - - defp compare_uris(_, _), do: :error + defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok + defp compare_uris(_id_uri, _other_uri), do: :error @doc """ Checks that an imported AP object's actor matches the domain it came from. @@ -64,15 +69,17 @@ defmodule Pleroma.Object.Containment do def contain_origin(id, %{"attributedTo" => actor} = params), do: contain_origin(id, Map.put(params, "actor", actor)) - def contain_origin_from_id(_id, %{"id" => nil}), do: :error + def contain_origin(_id, _data), do: :error - def contain_origin_from_id(id, %{"id" => other_id} = _params) do + def contain_origin_from_id(id, %{"id" => other_id} = _params) when is_binary(other_id) do id_uri = URI.parse(id) other_uri = URI.parse(other_id) compare_uris(id_uri, other_uri) end + def contain_origin_from_id(_id, _data), do: :error + def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}), do: contain_origin(id, object) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 7758cb90b..263ded5dd 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Object.Fetcher do @@ -10,6 +10,7 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.Signature alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.Federator require Logger require Pleroma.Constants @@ -38,7 +39,8 @@ defmodule Pleroma.Object.Fetcher do data <- maybe_reinject_internal_fields(data, struct), changeset <- Object.change(struct, %{data: data}), changeset <- touch_changeset(changeset), - {:ok, object} <- Repo.insert_or_update(changeset) do + {:ok, object} <- Repo.insert_or_update(changeset), + {:ok, object} <- Object.set_cache(object) do {:ok, object} else e -> @@ -48,30 +50,33 @@ defmodule Pleroma.Object.Fetcher do end def refetch_object(%Object{data: %{"id" => id}} = object) do - with {:local, false} <- {:local, String.starts_with?(id, Pleroma.Web.base_url() <> "/")}, + with {:local, false} <- {:local, Object.local?(object)}, {:ok, data} <- fetch_and_contain_remote_object_from_id(id), {:ok, object} <- reinject_object(object, data) do {:ok, object} else - {:local, true} -> object + {:local, true} -> {:ok, object} e -> {:error, e} end end - # TODO: - # This will create a Create activity, which we need internally at the moment. + # Note: will create a Create activity, which we need internally at the moment. def fetch_object_from_id(id, options \\ []) do - with {:fetch_object, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, - {:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, - {:normalize, nil} <- {:normalize, Object.normalize(data, false)}, + with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, + {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])}, + {_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, + {_, nil} <- {:normalize, Object.normalize(data, false)}, params <- prepare_activity_params(data), - {:containment, :ok} <- {:containment, Containment.contain_origin(id, params)}, - {:transmogrifier, {:ok, activity}} <- + {_, :ok} <- {:containment, Containment.contain_origin(id, params)}, + {_, {:ok, activity}} <- {:transmogrifier, Transmogrifier.handle_incoming(params, options)}, - {:object, _data, %Object{} = object} <- + {_, _data, %Object{} = object} <- {:object, data, Object.normalize(activity, false)} do {:ok, object} else + {:allowed_depth, false} -> + {:error, "Max thread distance exceeded."} + {:containment, _} -> {:error, "Object containment failed."} @@ -90,6 +95,9 @@ defmodule Pleroma.Object.Fetcher do {:fetch_object, %Object{} = object} -> {:ok, object} + {:fetch, {:error, error}} -> + {:error, error} + e -> e end @@ -110,6 +118,12 @@ defmodule Pleroma.Object.Fetcher do with {:ok, object} <- fetch_object_from_id(id, options) do object else + {:error, %Tesla.Mock.Error{}} -> + nil + + {:error, "Object has been deleted"} -> + nil + e -> Logger.error("Error while fetching #{id}: #{inspect(e)}") nil @@ -127,7 +141,7 @@ defmodule Pleroma.Object.Fetcher do date: date }) - [{:Signature, signature}] + [{"signature", signature}] end defp sign_fetch(headers, id, date) do @@ -140,19 +154,19 @@ defmodule Pleroma.Object.Fetcher do defp maybe_date_fetch(headers, date) do if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do - headers ++ [{:Date, date}] + headers ++ [{"date", date}] else headers end end def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do - Logger.info("Fetching object #{id} via AP") + Logger.debug("Fetching object #{id} via AP") date = Pleroma.Signature.signed_date() headers = - [{:Accept, "application/activity+json"}] + [{"accept", "application/activity+json"}] |> maybe_date_fetch(date) |> sign_fetch(id, date) @@ -170,6 +184,9 @@ defmodule Pleroma.Object.Fetcher do {:scheme, _} -> {:error, "Unsupported URI scheme"} + {:error, e} -> + {:error, e} + e -> {:error, e} end diff --git a/lib/pleroma/object_tombstone.ex b/lib/pleroma/object_tombstone.ex index fe947ffd3..e26f44057 100644 --- a/lib/pleroma/object_tombstone.ex +++ b/lib/pleroma/object_tombstone.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ObjectTombstone do diff --git a/lib/pleroma/otp_version.ex b/lib/pleroma/otp_version.ex new file mode 100644 index 000000000..114d0054f --- /dev/null +++ b/lib/pleroma/otp_version.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.OTPVersion do + @spec version() :: String.t() | nil + def version do + # OTP Version https://erlang.org/doc/system_principles/versions.html#otp-version + [ + Path.join(:code.root_dir(), "OTP_VERSION"), + Path.join([:code.root_dir(), "releases", :erlang.system_info(:otp_release), "OTP_VERSION"]) + ] + |> get_version_from_files() + end + + @spec get_version_from_files([Path.t()]) :: String.t() | nil + def get_version_from_files([]), do: nil + + def get_version_from_files([path | paths]) do + if File.exists?(path) do + path + |> File.read!() + |> String.replace(~r/\r|\n|\s/, "") + else + get_version_from_files(paths) + end + end +end diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index 9d279fba7..d43a96cd2 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Pagination do @@ -12,61 +12,72 @@ defmodule Pleroma.Pagination do alias Pleroma.Repo + @type type :: :keyset | :offset + @default_limit 20 + @max_limit 40 + @page_keys ["max_id", "min_id", "limit", "since_id", "order"] + + def page_keys, do: @page_keys - def fetch_paginated(query, params, type \\ :keyset) + @spec fetch_paginated(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] + def fetch_paginated(query, params, type \\ :keyset, table_binding \\ nil) - def fetch_paginated(query, %{"total" => true} = params, :keyset) do + def fetch_paginated(query, %{"total" => true} = params, :keyset, table_binding) do total = Repo.aggregate(query, :count, :id) %{ total: total, - items: fetch_paginated(query, Map.drop(params, ["total"]), :keyset) + items: fetch_paginated(query, Map.drop(params, ["total"]), :keyset, table_binding) } end - def fetch_paginated(query, params, :keyset) do + def fetch_paginated(query, params, :keyset, table_binding) do options = cast_params(params) query - |> paginate(options, :keyset) + |> paginate(options, :keyset, table_binding) |> Repo.all() |> enforce_order(options) end - def fetch_paginated(query, %{"total" => true} = params, :offset) do - total = Repo.aggregate(query, :count, :id) + def fetch_paginated(query, %{"total" => true} = params, :offset, table_binding) do + total = + query + |> Ecto.Query.exclude(:left_join) + |> Repo.aggregate(:count, :id) %{ total: total, - items: fetch_paginated(query, Map.drop(params, ["total"]), :offset) + items: fetch_paginated(query, Map.drop(params, ["total"]), :offset, table_binding) } end - def fetch_paginated(query, params, :offset) do + def fetch_paginated(query, params, :offset, table_binding) do options = cast_params(params) query - |> paginate(options, :offset) + |> paginate(options, :offset, table_binding) |> Repo.all() end - def paginate(query, options, method \\ :keyset) + @spec paginate(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] + def paginate(query, options, method \\ :keyset, table_binding \\ nil) - def paginate(query, options, :keyset) do + def paginate(query, options, :keyset, table_binding) do query - |> restrict(:min_id, options) - |> restrict(:since_id, options) - |> restrict(:max_id, options) - |> restrict(:order, options) - |> restrict(:limit, options) + |> restrict(:min_id, options, table_binding) + |> restrict(:since_id, options, table_binding) + |> restrict(:max_id, options, table_binding) + |> restrict(:order, options, table_binding) + |> restrict(:limit, options, table_binding) end - def paginate(query, options, :offset) do + def paginate(query, options, :offset, table_binding) do query - |> restrict(:order, options) - |> restrict(:offset, options) - |> restrict(:limit, options) + |> restrict(:order, options, table_binding) + |> restrict(:offset, options, table_binding) + |> restrict(:limit, options, table_binding) end defp cast_params(params) do @@ -75,7 +86,8 @@ defmodule Pleroma.Pagination do since_id: :string, max_id: :string, offset: :integer, - limit: :integer + limit: :integer, + skip_order: :boolean } params = @@ -88,38 +100,52 @@ defmodule Pleroma.Pagination do changeset.changes end - defp restrict(query, :min_id, %{min_id: min_id}) do - where(query, [q], q.id > ^min_id) + defp restrict(query, :min_id, %{min_id: min_id}, table_binding) do + where(query, [{q, table_position(query, table_binding)}], q.id > ^min_id) end - defp restrict(query, :since_id, %{since_id: since_id}) do - where(query, [q], q.id > ^since_id) + defp restrict(query, :since_id, %{since_id: since_id}, table_binding) do + where(query, [{q, table_position(query, table_binding)}], q.id > ^since_id) end - defp restrict(query, :max_id, %{max_id: max_id}) do - where(query, [q], q.id < ^max_id) + defp restrict(query, :max_id, %{max_id: max_id}, table_binding) do + where(query, [{q, table_position(query, table_binding)}], q.id < ^max_id) end - defp restrict(query, :order, %{min_id: _}) do - order_by(query, [u], fragment("? asc nulls last", u.id)) + defp restrict(query, :order, %{skip_order: true}, _), do: query + + defp restrict(query, :order, %{min_id: _}, table_binding) do + order_by( + query, + [{u, table_position(query, table_binding)}], + fragment("? asc nulls last", u.id) + ) end - defp restrict(query, :order, _options) do - order_by(query, [u], fragment("? desc nulls last", u.id)) + defp restrict(query, :order, _options, table_binding) do + order_by( + query, + [{u, table_position(query, table_binding)}], + fragment("? desc nulls last", u.id) + ) end - defp restrict(query, :offset, %{offset: offset}) do + defp restrict(query, :offset, %{offset: offset}, _table_binding) do offset(query, ^offset) end - defp restrict(query, :limit, options) do - limit = Map.get(options, :limit, @default_limit) + defp restrict(query, :limit, options, _table_binding) do + limit = + case Map.get(options, :limit, @default_limit) do + limit when limit < @max_limit -> limit + _ -> @max_limit + end query |> limit(^limit) end - defp restrict(query, _, _), do: query + defp restrict(query, _, _, _), do: query defp enforce_order(result, %{min_id: _}) do result @@ -127,4 +153,10 @@ defmodule Pleroma.Pagination do end defp enforce_order(result, _), do: result + + defp table_position(%Ecto.Query{} = query, binding_name) do + Map.get(query.aliases, binding_name, 0) + end + + defp table_position(_, _), do: 0 end diff --git a/lib/pleroma/password_reset_token.ex b/lib/pleroma/password_reset_token.ex index db398b1fc..787bd4781 100644 --- a/lib/pleroma/password_reset_token.ex +++ b/lib/pleroma/password_reset_token.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.PasswordResetToken do diff --git a/lib/pleroma/plugs/admin_secret_authentication_plug.ex b/lib/pleroma/plugs/admin_secret_authentication_plug.ex index 5baf8a691..b4b47a31f 100644 --- a/lib/pleroma/plugs/admin_secret_authentication_plug.ex +++ b/lib/pleroma/plugs/admin_secret_authentication_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.AdminSecretAuthenticationPlug do @@ -16,14 +16,28 @@ defmodule Pleroma.Plugs.AdminSecretAuthenticationPlug do def call(%{assigns: %{user: %User{}}} = conn, _), do: conn - def call(%{params: %{"admin_token" => admin_token}} = conn, _) do - if secret_token() && admin_token == secret_token() do + def call(conn, _) do + if secret_token() do + authenticate(conn) + else conn - |> assign(:user, %User{info: %{is_admin: true}}) + end + end + + def authenticate(%{params: %{"admin_token" => admin_token}} = conn) do + if admin_token == secret_token() do + assign(conn, :user, %User{is_admin: true}) else conn end end - def call(conn, _), do: conn + def authenticate(conn) do + token = secret_token() + + case get_req_header(conn, "x-admin-token") do + [^token] -> assign(conn, :user, %User{is_admin: true}) + _ -> conn + end + end end diff --git a/lib/pleroma/plugs/authentication_plug.ex b/lib/pleroma/plugs/authentication_plug.ex index 567674a0b..057ea42f1 100644 --- a/lib/pleroma/plugs/authentication_plug.ex +++ b/lib/pleroma/plugs/authentication_plug.ex @@ -1,11 +1,13 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.AuthenticationPlug do - alias Comeonin.Pbkdf2 - import Plug.Conn + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User + + import Plug.Conn + require Logger def init(options), do: options @@ -14,8 +16,13 @@ defmodule Pleroma.Plugs.AuthenticationPlug do :crypt.crypt(password, password_hash) == password_hash end + def checkpw(password, "$2" <> _ = password_hash) do + # Handle bcrypt passwords for Mastodon migration + Bcrypt.verify_pass(password, password_hash) + end + def checkpw(password, "$pbkdf2" <> _ = password_hash) do - Pbkdf2.checkpw(password, password_hash) + Pbkdf2.verify_pass(password, password_hash) end def checkpw(_password, _password_hash) do @@ -23,6 +30,25 @@ defmodule Pleroma.Plugs.AuthenticationPlug do false end + def maybe_update_password(%User{password_hash: "$2" <> _} = user, password) do + do_update_password(user, password) + end + + def maybe_update_password(%User{password_hash: "$6" <> _} = user, password) do + do_update_password(user, password) + end + + def maybe_update_password(user, _), do: {:ok, user} + + defp do_update_password(user, password) do + user + |> User.password_update_changeset(%{ + "password" => password, + "password_confirmation" => password + }) + |> Pleroma.Repo.update() + end + def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call( @@ -34,16 +60,19 @@ defmodule Pleroma.Plugs.AuthenticationPlug do } = conn, _ ) do - if Pbkdf2.checkpw(password, password_hash) do + if checkpw(password, password_hash) do + {:ok, auth_user} = maybe_update_password(auth_user, password) + conn |> assign(:user, auth_user) + |> OAuthScopesPlug.skip_plug() else conn end end def call(%{assigns: %{auth_credentials: %{password: _}}} = conn, _) do - Pbkdf2.dummy_checkpw() + Pbkdf2.no_user_verify() conn end diff --git a/lib/pleroma/plugs/basic_auth_decoder_plug.ex b/lib/pleroma/plugs/basic_auth_decoder_plug.ex index 7eeeb1e5d..af7ecb0d8 100644 --- a/lib/pleroma/plugs/basic_auth_decoder_plug.ex +++ b/lib/pleroma/plugs/basic_auth_decoder_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.BasicAuthDecoderPlug do diff --git a/lib/pleroma/plugs/cache.ex b/lib/pleroma/plugs/cache.ex index 50b534e7b..f65c2a189 100644 --- a/lib/pleroma/plugs/cache.ex +++ b/lib/pleroma/plugs/cache.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.Cache do diff --git a/lib/pleroma/plugs/digest.ex b/lib/pleroma/plugs/digest.ex index 0ba00845a..b521b3073 100644 --- a/lib/pleroma/plugs/digest.ex +++ b/lib/pleroma/plugs/digest.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.DigestPlug do diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex index 27cd41aec..3fe550806 100644 --- a/lib/pleroma/plugs/ensure_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex @@ -1,23 +1,41 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.EnsureAuthenticatedPlug do import Plug.Conn import Pleroma.Web.TranslationHelpers + alias Pleroma.User + use Pleroma.Web, :plug + def init(options) do options end - def call(%{assigns: %{user: %User{}}} = conn, _) do + @impl true + def perform( + %{ + assigns: %{ + auth_credentials: %{password: _}, + user: %User{multi_factor_authentication_settings: %{enabled: true}} + } + } = conn, + _ + ) do + conn + |> render_error(:forbidden, "Two-factor authentication enabled, you must use a access token.") + |> halt() + end + + def perform(%{assigns: %{user: %User{}}} = conn, _) do conn end - def call(conn, _) do + def perform(conn, _) do conn |> render_error(:forbidden, "Invalid credentials.") - |> halt + |> halt() end end diff --git a/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex b/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex index a16f61435..7265bb87a 100644 --- a/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex @@ -1,18 +1,22 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug do import Pleroma.Web.TranslationHelpers import Plug.Conn + alias Pleroma.Config alias Pleroma.User + use Pleroma.Web, :plug + def init(options) do options end - def call(conn, _) do + @impl true + def perform(conn, _) do public? = Config.get!([:instance, :public]) case {public?, conn} do diff --git a/lib/pleroma/plugs/ensure_user_key_plug.ex b/lib/pleroma/plugs/ensure_user_key_plug.ex index c88ebfb3f..9795cdbde 100644 --- a/lib/pleroma/plugs/ensure_user_key_plug.ex +++ b/lib/pleroma/plugs/ensure_user_key_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.EnsureUserKeyPlug do diff --git a/lib/pleroma/plugs/expect_authenticated_check_plug.ex b/lib/pleroma/plugs/expect_authenticated_check_plug.ex new file mode 100644 index 000000000..66b8d5de5 --- /dev/null +++ b/lib/pleroma/plugs/expect_authenticated_check_plug.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.ExpectAuthenticatedCheckPlug do + @moduledoc """ + Marks `Pleroma.Plugs.EnsureAuthenticatedPlug` as expected to be executed later in plug chain. + + No-op plug which affects `Pleroma.Web` operation (is checked with `PlugHelper.plug_called?/2`). + """ + + use Pleroma.Web, :plug + + def init(options), do: options + + @impl true + def perform(conn, _) do + conn + end +end diff --git a/lib/pleroma/plugs/expect_public_or_authenticated_check_plug.ex b/lib/pleroma/plugs/expect_public_or_authenticated_check_plug.ex new file mode 100644 index 000000000..ba0ef76bd --- /dev/null +++ b/lib/pleroma/plugs/expect_public_or_authenticated_check_plug.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug do + @moduledoc """ + Marks `Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug` as expected to be executed later in plug + chain. + + No-op plug which affects `Pleroma.Web` operation (is checked with `PlugHelper.plug_called?/2`). + """ + + use Pleroma.Web, :plug + + def init(options), do: options + + @impl true + def perform(conn, _) do + conn + end +end diff --git a/lib/pleroma/plugs/federating_plug.ex b/lib/pleroma/plugs/federating_plug.ex index 4dc4e9279..09038f3c6 100644 --- a/lib/pleroma/plugs/federating_plug.ex +++ b/lib/pleroma/plugs/federating_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.FederatingPlug do @@ -10,14 +10,23 @@ defmodule Pleroma.Web.FederatingPlug do end def call(conn, _opts) do - if Pleroma.Config.get([:instance, :federating]) do + if federating?() do conn else - conn - |> put_status(404) - |> Phoenix.Controller.put_view(Pleroma.Web.ErrorView) - |> Phoenix.Controller.render("404.json") - |> halt() + fail(conn) end end + + def federating?, do: Pleroma.Config.get([:instance, :federating]) + + # Definition for the use in :if_func / :unless_func plug options + def federating?(_conn), do: federating?() + + defp fail(conn) do + conn + |> put_status(404) + |> Phoenix.Controller.put_view(Pleroma.Web.ErrorView) + |> Phoenix.Controller.render("404.json") + |> halt() + end end diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index a7cc22831..6462797b6 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -1,11 +1,13 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.HTTPSecurityPlug do alias Pleroma.Config import Plug.Conn + require Logger + def init(opts), do: opts def call(conn, _options) do @@ -73,7 +75,7 @@ defmodule Pleroma.Plugs.HTTPSecurityPlug do "default-src 'none'", "base-uri 'self'", "frame-ancestors 'none'", - "img-src 'self' data: https:", + "img-src 'self' data: blob: https:", "media-src 'self' https:", "style-src 'self' 'unsafe-inline'", "font-src 'self'", @@ -90,6 +92,51 @@ defmodule Pleroma.Plugs.HTTPSecurityPlug do |> Enum.join("; ") end + def warn_if_disabled do + unless Config.get([:http_security, :enabled]) do + Logger.warn(" + .i;;;;i. + iYcviii;vXY: + .YXi .i1c. + .YC. . in7. + .vc. ...... ;1c. + i7, .. .;1; + i7, .. ... .Y1i + ,7v .6MMM@; .YX, + .7;. ..IMMMMMM1 :t7. + .;Y. ;$MMMMMM9. :tc. + vY. .. .nMMM@MMU. ;1v. + i7i ... .#MM@M@C. .....:71i + it: .... $MMM@9;.,i;;;i,;tti + :t7. ..... 0MMMWv.,iii:::,,;St. + .nC. ..... IMMMQ..,::::::,.,czX. + .ct: ....... .ZMMMI..,:::::::,,:76Y. + c2: ......,i..Y$M@t..:::::::,,..inZY + vov ......:ii..c$MBc..,,,,,,,,,,..iI9i + i9Y ......iii:..7@MA,..,,,,,,,,,....;AA: + iIS. ......:ii::..;@MI....,............;Ez. + .I9. ......:i::::...8M1..................C0z. + .z9; ......:i::::,.. .i:...................zWX. + vbv ......,i::::,,. ................. :AQY + c6Y. .,...,::::,,..:t0@@QY. ................ :8bi + :6S. ..,,...,:::,,,..EMMMMMMI. ............... .;bZ, + :6o, .,,,,..:::,,,..i#MMMMMM#v................. YW2. + .n8i ..,,,,,,,::,,,,.. tMMMMM@C:.................. .1Wn + 7Uc. .:::,,,,,::,,,,.. i1t;,..................... .UEi + 7C...::::::::::::,,,,.. .................... vSi. + ;1;...,,::::::,......... .................. Yz: + v97,......... .voC. + izAotX7777777777777777777777777777777777777777Y7n92: + .;CoIIIIIUAA666666699999ZZZZZZZZZZZZZZZZZZZZ6ov. + +HTTP Security is disabled. Please re-enable it to prevent users from attacking +your instance and your users via malicious posts: + + config :pleroma, :http_security, enabled: true + ") + end + end + defp maybe_send_sts_header(conn, true) do max_age_sts = Config.get([:http_security, :sts_max_age]) max_age_ct = Config.get([:http_security, :ct_max_age]) diff --git a/lib/pleroma/plugs/http_signature.ex b/lib/pleroma/plugs/http_signature.ex index 23d22a712..036e2a773 100644 --- a/lib/pleroma/plugs/http_signature.ex +++ b/lib/pleroma/plugs/http_signature.ex @@ -1,9 +1,10 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do import Plug.Conn + import Phoenix.Controller, only: [get_format: 1, text: 2] require Logger def init(options) do @@ -15,25 +16,27 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do end def call(conn, _opts) do - headers = get_req_header(conn, "signature") - signature = Enum.at(headers, 0) + if get_format(conn) == "activity+json" do + conn + |> maybe_assign_valid_signature() + |> maybe_require_signature() + else + conn + end + end - if signature do + defp maybe_assign_valid_signature(conn) do + if has_signature_header?(conn) do # set (request-target) header to the appropriate value # we also replace the digest header with the one we computed - conn = - conn - |> put_req_header( - "(request-target)", - String.downcase("#{conn.method}") <> " #{conn.request_path}" - ) + request_target = String.downcase("#{conn.method}") <> " #{conn.request_path}" conn = - if conn.assigns[:digest] do - conn - |> put_req_header("digest", conn.assigns[:digest]) - else - conn + conn + |> put_req_header("(request-target)", request_target) + |> case do + %{assigns: %{digest: digest}} = conn -> put_req_header(conn, "digest", digest) + conn -> conn end assign(conn, :valid_signature, HTTPSignatures.validate_conn(conn)) @@ -42,4 +45,21 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do conn end end + + defp has_signature_header?(conn) do + conn |> get_req_header("signature") |> Enum.at(0, false) + end + + defp maybe_require_signature(%{assigns: %{valid_signature: true}} = conn), do: conn + + defp maybe_require_signature(conn) do + if Pleroma.Config.get([:activitypub, :authorized_fetch_mode], false) do + conn + |> put_status(:unauthorized) + |> text("Request not signed") + |> halt() + else + conn + end + end end diff --git a/lib/pleroma/plugs/idempotency_plug.ex b/lib/pleroma/plugs/idempotency_plug.ex index e99c5d279..f41397075 100644 --- a/lib/pleroma/plugs/idempotency_plug.ex +++ b/lib/pleroma/plugs/idempotency_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.IdempotencyPlug do diff --git a/lib/pleroma/plugs/instance_static.ex b/lib/pleroma/plugs/instance_static.ex index a64f1ea80..7516f75c3 100644 --- a/lib/pleroma/plugs/instance_static.ex +++ b/lib/pleroma/plugs/instance_static.ex @@ -1,8 +1,10 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.InstanceStatic do + require Pleroma.Constants + @moduledoc """ This is a shim to call `Plug.Static` but with runtime `from` configuration. @@ -21,9 +23,6 @@ defmodule Pleroma.Plugs.InstanceStatic do end end - @only ~w(index.html robots.txt static emoji packs sounds images instance favicon.png sw.js - sw-pleroma.js) - def init(opts) do opts |> Keyword.put(:from, "__unconfigured_instance_static_plug") @@ -31,7 +30,7 @@ defmodule Pleroma.Plugs.InstanceStatic do |> Plug.Static.init() end - for only <- @only do + for only <- Pleroma.Constants.static_only_files() do at = Plug.Router.Utils.split("/") def call(%{request_path: "/" <> unquote(only) <> _} = conn, opts) do diff --git a/lib/pleroma/plugs/legacy_authentication_plug.ex b/lib/pleroma/plugs/legacy_authentication_plug.ex index 78b7e388f..d346e01a6 100644 --- a/lib/pleroma/plugs/legacy_authentication_plug.ex +++ b/lib/pleroma/plugs/legacy_authentication_plug.ex @@ -1,9 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.LegacyAuthenticationPlug do import Plug.Conn + + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User def init(options) do @@ -27,6 +29,7 @@ defmodule Pleroma.Plugs.LegacyAuthenticationPlug do conn |> assign(:auth_user, user) |> assign(:user, user) + |> OAuthScopesPlug.skip_plug() else _ -> conn diff --git a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex index ce8494b9d..f44d4dee5 100644 --- a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex +++ b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do @@ -13,8 +13,9 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do def init(options), do: options defp key_id_from_conn(conn) do - with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn) do - Signature.key_id_to_actor_id(key_id) + with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn), + {:ok, ap_id} <- Signature.key_id_to_actor_id(key_id) do + ap_id else _ -> nil @@ -42,13 +43,13 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do else {:user_match, false} -> Logger.debug("Failed to map identity from signature (payload actor mismatch)") - Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{inspect(actor)}") assign(conn, :valid_signature, false) # remove me once testsuite uses mapped capabilities instead of what we do now {:user, nil} -> Logger.debug("Failed to map identity from signature (lookup failure)") - Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{actor}") conn end end @@ -60,7 +61,7 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do else _ -> Logger.debug("Failed to map identity from signature (no payload actor mismatch)") - Logger.debug("key_id=#{key_id_from_conn(conn)}") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}") assign(conn, :valid_signature, false) end end diff --git a/lib/pleroma/plugs/oauth_plug.ex b/lib/pleroma/plugs/oauth_plug.ex index 86bc4aa3a..6fa71ef47 100644 --- a/lib/pleroma/plugs/oauth_plug.ex +++ b/lib/pleroma/plugs/oauth_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.OAuthPlug do @@ -71,7 +71,7 @@ defmodule Pleroma.Plugs.OAuthPlug do ) # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength - with %Token{user: %{info: %{deactivated: false} = _} = user} = token_record <- Repo.one(query) do + with %Token{user: user} = token_record <- Repo.one(query) do {:ok, user, token_record} end end diff --git a/lib/pleroma/plugs/oauth_scopes_plug.ex b/lib/pleroma/plugs/oauth_scopes_plug.ex index a3278dbef..efc25b79f 100644 --- a/lib/pleroma/plugs/oauth_scopes_plug.ex +++ b/lib/pleroma/plugs/oauth_scopes_plug.ex @@ -1,37 +1,34 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.OAuthScopesPlug do import Plug.Conn import Pleroma.Web.Gettext - alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + alias Pleroma.Config - @behaviour Plug + use Pleroma.Web, :plug def init(%{scopes: _} = options), do: options - def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do + @impl true + def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do op = options[:op] || :| token = assigns[:token] - matched_scopes = token && filter_descendants(scopes, token.scopes) - cond do - is_nil(token) -> - maybe_perform_instance_privacy_check(conn, options) + scopes = transform_scopes(scopes, options) + matched_scopes = (token && filter_descendants(scopes, token.scopes)) || [] - op == :| && Enum.any?(matched_scopes) -> + cond do + token && op == :| && Enum.any?(matched_scopes) -> conn - op == :& && matched_scopes == scopes -> + token && op == :& && matched_scopes == scopes -> conn options[:fallback] == :proceed_unauthenticated -> - conn - |> assign(:user, nil) - |> assign(:token, nil) - |> maybe_perform_instance_privacy_check(options) + drop_auth_info(conn) true -> missing_scopes = scopes -- matched_scopes @@ -47,6 +44,15 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do end end + @doc "Drops authentication info from connection" + def drop_auth_info(conn) do + # To simplify debugging, setting a private variable on `conn` if auth info is dropped + conn + |> put_private(:authentication_ignored, true) + |> assign(:user, nil) + |> assign(:token, nil) + end + @doc "Filters descendants of supported scopes" def filter_descendants(scopes, supported_scopes) do Enum.filter( @@ -60,11 +66,12 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do ) end - defp maybe_perform_instance_privacy_check(%Plug.Conn{} = conn, options) do - if options[:skip_instance_privacy_check] do - conn + @doc "Transforms scopes by applying supported options (e.g. :admin)" + def transform_scopes(scopes, options) do + if options[:admin] do + Config.oauth_admin_scopes(scopes) else - EnsurePublicOrAuthenticatedPlug.call(conn, []) + scopes end end end diff --git a/lib/pleroma/plugs/plug_helper.ex b/lib/pleroma/plugs/plug_helper.ex new file mode 100644 index 000000000..9c67be8ef --- /dev/null +++ b/lib/pleroma/plugs/plug_helper.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.Plugs.PlugHelper do + @moduledoc "Pleroma Plug helper" + + @called_plugs_list_id :called_plugs + def called_plugs_list_id, do: @called_plugs_list_id + + @skipped_plugs_list_id :skipped_plugs + def skipped_plugs_list_id, do: @skipped_plugs_list_id + + @doc "Returns `true` if specified plug was called." + def plug_called?(conn, plug_module) do + contained_in_private_list?(conn, @called_plugs_list_id, plug_module) + end + + @doc "Returns `true` if specified plug was explicitly marked as skipped." + def plug_skipped?(conn, plug_module) do + contained_in_private_list?(conn, @skipped_plugs_list_id, plug_module) + end + + @doc "Returns `true` if specified plug was either called or explicitly marked as skipped." + def plug_called_or_skipped?(conn, plug_module) do + plug_called?(conn, plug_module) || plug_skipped?(conn, plug_module) + end + + # Appends plug to known list (skipped, called). Intended to be used from within plug code only. + def append_to_private_list(conn, list_id, value) do + list = conn.private[list_id] || [] + modified_list = Enum.uniq(list ++ [value]) + Plug.Conn.put_private(conn, list_id, modified_list) + end + + defp contained_in_private_list?(conn, private_variable, value) do + list = conn.private[private_variable] || [] + value in list + end +end diff --git a/lib/pleroma/plugs/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter.ex deleted file mode 100644 index 31388f574..000000000 --- a/lib/pleroma/plugs/rate_limiter.ex +++ /dev/null @@ -1,131 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Plugs.RateLimiter do - @moduledoc """ - - ## Configuration - - A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration. The basic configuration is a tuple where: - - * The first element: `scale` (Integer). The time scale in milliseconds. - * The second element: `limit` (Integer). How many requests to limit in the time scale provided. - - It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated. - - To disable a limiter set its value to `nil`. - - ### Example - - config :pleroma, :rate_limit, - one: {1000, 10}, - two: [{10_000, 10}, {10_000, 50}], - foobar: nil - - Here we have three limiters: - - * `one` which is not over 10req/1s - * `two` which has two limits: 10req/10s for unauthenticated users and 50req/10s for authenticated users - * `foobar` which is disabled - - ## Usage - - AllowedSyntax: - - plug(Pleroma.Plugs.RateLimiter, :limiter_name) - plug(Pleroma.Plugs.RateLimiter, {:limiter_name, options}) - - Allowed options: - - * `bucket_name` overrides bucket name (e.g. to have a separate limit for a set of actions) - * `params` appends values of specified request params (e.g. ["id"]) to bucket name - - Inside a controller: - - plug(Pleroma.Plugs.RateLimiter, :one when action == :one) - plug(Pleroma.Plugs.RateLimiter, :two when action in [:two, :three]) - - plug( - Pleroma.Plugs.RateLimiter, - {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]} - when action in ~w(fav_status unfav_status)a - ) - - or inside a router pipeline: - - pipeline :api do - ... - plug(Pleroma.Plugs.RateLimiter, :one) - ... - end - """ - import Pleroma.Web.TranslationHelpers - import Plug.Conn - - alias Pleroma.User - - def init(limiter_name) when is_atom(limiter_name) do - init({limiter_name, []}) - end - - def init({limiter_name, opts}) do - case Pleroma.Config.get([:rate_limit, limiter_name]) do - nil -> nil - config -> {limiter_name, config, opts} - end - end - - # Do not limit if there is no limiter configuration - def call(conn, nil), do: conn - - def call(conn, settings) do - case check_rate(conn, settings) do - {:ok, _count} -> - conn - - {:error, _count} -> - render_throttled_error(conn) - end - end - - defp bucket_name(conn, limiter_name, opts) do - bucket_name = opts[:bucket_name] || limiter_name - - if params_names = opts[:params] do - params_values = for p <- Enum.sort(params_names), do: conn.params[p] - Enum.join([bucket_name] ++ params_values, ":") - else - bucket_name - end - end - - defp check_rate( - %{assigns: %{user: %User{id: user_id}}} = conn, - {limiter_name, [_, {scale, limit}], opts} - ) do - bucket_name = bucket_name(conn, limiter_name, opts) - ExRated.check_rate("#{bucket_name}:#{user_id}", scale, limit) - end - - defp check_rate(conn, {limiter_name, [{scale, limit} | _], opts}) do - bucket_name = bucket_name(conn, limiter_name, opts) - ExRated.check_rate("#{bucket_name}:#{ip(conn)}", scale, limit) - end - - defp check_rate(conn, {limiter_name, {scale, limit}, opts}) do - check_rate(conn, {limiter_name, [{scale, limit}, {scale, limit}], opts}) - end - - def ip(%{remote_ip: remote_ip}) do - remote_ip - |> Tuple.to_list() - |> Enum.join(".") - end - - defp render_throttled_error(conn) do - conn - |> render_error(:too_many_requests, "Throttled") - |> halt() - end -end diff --git a/lib/pleroma/plugs/rate_limiter/limiter_supervisor.ex b/lib/pleroma/plugs/rate_limiter/limiter_supervisor.ex new file mode 100644 index 000000000..884268d96 --- /dev/null +++ b/lib/pleroma/plugs/rate_limiter/limiter_supervisor.ex @@ -0,0 +1,50 @@ +defmodule Pleroma.Plugs.RateLimiter.LimiterSupervisor do + use DynamicSupervisor + + import Cachex.Spec + + def start_link(init_arg) do + DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + def add_or_return_limiter(limiter_name, expiration) do + result = + DynamicSupervisor.start_child( + __MODULE__, + %{ + id: String.to_atom("rl_#{limiter_name}"), + start: + {Cachex, :start_link, + [ + limiter_name, + [ + expiration: + expiration( + default: expiration, + interval: check_interval(expiration), + lazy: true + ) + ] + ]} + } + ) + + case result do + {:ok, _pid} = result -> result + {:error, {:already_started, pid}} -> {:ok, pid} + _ -> result + end + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init(strategy: :one_for_one) + end + + defp check_interval(exp) do + (exp / 2) + |> Kernel.trunc() + |> Kernel.min(5000) + |> Kernel.max(1) + end +end diff --git a/lib/pleroma/plugs/rate_limiter/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter/rate_limiter.ex new file mode 100644 index 000000000..c51e2c634 --- /dev/null +++ b/lib/pleroma/plugs/rate_limiter/rate_limiter.ex @@ -0,0 +1,267 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.RateLimiter do + @moduledoc """ + + ## Configuration + + A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration. + The basic configuration is a tuple where: + + * The first element: `scale` (Integer). The time scale in milliseconds. + * The second element: `limit` (Integer). How many requests to limit in the time scale provided. + + It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a + list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated. + + To disable a limiter set its value to `nil`. + + ### Example + + config :pleroma, :rate_limit, + one: {1000, 10}, + two: [{10_000, 10}, {10_000, 50}], + foobar: nil + + Here we have three limiters: + + * `one` which is not over 10req/1s + * `two` which has two limits: 10req/10s for unauthenticated users and 50req/10s for authenticated users + * `foobar` which is disabled + + ## Usage + + AllowedSyntax: + + plug(Pleroma.Plugs.RateLimiter, name: :limiter_name) + plug(Pleroma.Plugs.RateLimiter, options) # :name is a required option + + Allowed options: + + * `name` required, always used to fetch the limit values from the config + * `bucket_name` overrides name for counting purposes (e.g. to have a separate limit for a set of actions) + * `params` appends values of specified request params (e.g. ["id"]) to bucket name + + Inside a controller: + + plug(Pleroma.Plugs.RateLimiter, [name: :one] when action == :one) + plug(Pleroma.Plugs.RateLimiter, [name: :two] when action in [:two, :three]) + + plug( + Pleroma.Plugs.RateLimiter, + [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]] + when action in ~w(fav_status unfav_status)a + ) + + or inside a router pipeline: + + pipeline :api do + ... + plug(Pleroma.Plugs.RateLimiter, name: :one) + ... + end + """ + import Pleroma.Web.TranslationHelpers + import Plug.Conn + + alias Pleroma.Config + alias Pleroma.Plugs.RateLimiter.LimiterSupervisor + alias Pleroma.User + + require Logger + + @doc false + def init(plug_opts) do + plug_opts + end + + def call(conn, plug_opts) do + if disabled?(conn) do + handle_disabled(conn) + else + action_settings = action_settings(plug_opts) + handle(conn, action_settings) + end + end + + defp handle_disabled(conn) do + Logger.warn( + "Rate limiter disabled due to forwarded IP not being found. Please ensure your reverse proxy is providing the X-Forwarded-For header or disable the RemoteIP plug/rate limiter." + ) + + conn + end + + defp handle(conn, nil), do: conn + + defp handle(conn, action_settings) do + action_settings + |> incorporate_conn_info(conn) + |> check_rate() + |> case do + {:ok, _count} -> + conn + + {:error, _count} -> + render_throttled_error(conn) + end + end + + def disabled?(conn) do + if Map.has_key?(conn.assigns, :remote_ip_found), + do: !conn.assigns.remote_ip_found, + else: false + end + + @inspect_bucket_not_found {:error, :not_found} + + def inspect_bucket(conn, bucket_name_root, plug_opts) do + with %{name: _} = action_settings <- action_settings(plug_opts) do + action_settings = incorporate_conn_info(action_settings, conn) + bucket_name = make_bucket_name(%{action_settings | name: bucket_name_root}) + key_name = make_key_name(action_settings) + limit = get_limits(action_settings) + + case Cachex.get(bucket_name, key_name) do + {:error, :no_cache} -> + @inspect_bucket_not_found + + {:ok, nil} -> + {0, limit} + + {:ok, value} -> + {value, limit - value} + end + else + _ -> @inspect_bucket_not_found + end + end + + def action_settings(plug_opts) do + with limiter_name when is_atom(limiter_name) <- plug_opts[:name], + limits when not is_nil(limits) <- Config.get([:rate_limit, limiter_name]) do + bucket_name_root = Keyword.get(plug_opts, :bucket_name, limiter_name) + + %{ + name: bucket_name_root, + limits: limits, + opts: plug_opts + } + end + end + + defp check_rate(action_settings) do + bucket_name = make_bucket_name(action_settings) + key_name = make_key_name(action_settings) + limit = get_limits(action_settings) + + case Cachex.get_and_update(bucket_name, key_name, &increment_value(&1, limit)) do + {:commit, value} -> + {:ok, value} + + {:ignore, value} -> + {:error, value} + + {:error, :no_cache} -> + initialize_buckets!(action_settings) + check_rate(action_settings) + end + end + + defp increment_value(nil, _limit), do: {:commit, 1} + + defp increment_value(val, limit) when val >= limit, do: {:ignore, val} + + defp increment_value(val, _limit), do: {:commit, val + 1} + + defp incorporate_conn_info(action_settings, %{ + assigns: %{user: %User{id: user_id}}, + params: params + }) do + Map.merge(action_settings, %{ + mode: :user, + conn_params: params, + conn_info: "#{user_id}" + }) + end + + defp incorporate_conn_info(action_settings, %{params: params} = conn) do + Map.merge(action_settings, %{ + mode: :anon, + conn_params: params, + conn_info: "#{ip(conn)}" + }) + end + + defp ip(%{remote_ip: remote_ip}) do + remote_ip + |> Tuple.to_list() + |> Enum.join(".") + end + + defp render_throttled_error(conn) do + conn + |> render_error(:too_many_requests, "Throttled") + |> halt() + end + + defp make_key_name(action_settings) do + "" + |> attach_selected_params(action_settings) + |> attach_identity(action_settings) + end + + defp get_scale(_, {scale, _}), do: scale + + defp get_scale(:anon, [{scale, _}, {_, _}]), do: scale + + defp get_scale(:user, [{_, _}, {scale, _}]), do: scale + + defp get_limits(%{limits: {_scale, limit}}), do: limit + + defp get_limits(%{mode: :user, limits: [_, {_, limit}]}), do: limit + + defp get_limits(%{limits: [{_, limit}, _]}), do: limit + + defp make_bucket_name(%{mode: :user, name: bucket_name_root}), + do: user_bucket_name(bucket_name_root) + + defp make_bucket_name(%{mode: :anon, name: bucket_name_root}), + do: anon_bucket_name(bucket_name_root) + + defp attach_selected_params(input, %{conn_params: conn_params, opts: plug_opts}) do + params_string = + plug_opts + |> Keyword.get(:params, []) + |> Enum.sort() + |> Enum.map(&Map.get(conn_params, &1, "")) + |> Enum.join(":") + + [input, params_string] + |> Enum.join(":") + |> String.replace_leading(":", "") + end + + defp initialize_buckets!(%{name: _name, limits: nil}), do: :ok + + defp initialize_buckets!(%{name: name, limits: limits}) do + {:ok, _pid} = + LimiterSupervisor.add_or_return_limiter(anon_bucket_name(name), get_scale(:anon, limits)) + + {:ok, _pid} = + LimiterSupervisor.add_or_return_limiter(user_bucket_name(name), get_scale(:user, limits)) + + :ok + end + + defp attach_identity(base, %{mode: :user, conn_info: conn_info}), + do: "user:#{base}:#{conn_info}" + + defp attach_identity(base, %{mode: :anon, conn_info: conn_info}), + do: "ip:#{base}:#{conn_info}" + + defp user_bucket_name(bucket_name_root), do: "user:#{bucket_name_root}" |> String.to_atom() + defp anon_bucket_name(bucket_name_root), do: "anon:#{bucket_name_root}" |> String.to_atom() +end diff --git a/lib/pleroma/plugs/rate_limiter/supervisor.ex b/lib/pleroma/plugs/rate_limiter/supervisor.ex new file mode 100644 index 000000000..9672f7876 --- /dev/null +++ b/lib/pleroma/plugs/rate_limiter/supervisor.ex @@ -0,0 +1,16 @@ +defmodule Pleroma.Plugs.RateLimiter.Supervisor do + use Supervisor + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_args) do + children = [ + Pleroma.Plugs.RateLimiter.LimiterSupervisor + ] + + opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor] + Supervisor.init(children, opts) + end +end diff --git a/lib/pleroma/plugs/remote_ip.ex b/lib/pleroma/plugs/remote_ip.ex index fdedc27ee..2eca4f8f6 100644 --- a/lib/pleroma/plugs/remote_ip.ex +++ b/lib/pleroma/plugs/remote_ip.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.RemoteIp do @@ -10,10 +10,7 @@ defmodule Pleroma.Plugs.RemoteIp do @behaviour Plug @headers ~w[ - forwarded x-forwarded-for - x-client-ip - x-real-ip ] # https://en.wikipedia.org/wiki/Localhost diff --git a/lib/pleroma/plugs/session_authentication_plug.ex b/lib/pleroma/plugs/session_authentication_plug.ex index a08484b65..0f83a5e53 100644 --- a/lib/pleroma/plugs/session_authentication_plug.ex +++ b/lib/pleroma/plugs/session_authentication_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.SessionAuthenticationPlug do diff --git a/lib/pleroma/plugs/set_format_plug.ex b/lib/pleroma/plugs/set_format_plug.ex index 5ca741c64..c03fcb28d 100644 --- a/lib/pleroma/plugs/set_format_plug.ex +++ b/lib/pleroma/plugs/set_format_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.SetFormatPlug do diff --git a/lib/pleroma/plugs/set_locale_plug.ex b/lib/pleroma/plugs/set_locale_plug.ex index 8646cb30d..9a21d0a9d 100644 --- a/lib/pleroma/plugs/set_locale_plug.ex +++ b/lib/pleroma/plugs/set_locale_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only # NOTE: this module is based on https://github.com/smeevil/set_locale diff --git a/lib/pleroma/plugs/set_user_session_id_plug.ex b/lib/pleroma/plugs/set_user_session_id_plug.ex index 9265cc116..730c4ac74 100644 --- a/lib/pleroma/plugs/set_user_session_id_plug.ex +++ b/lib/pleroma/plugs/set_user_session_id_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.SetUserSessionIdPlug do diff --git a/lib/pleroma/plugs/static_fe_plug.ex b/lib/pleroma/plugs/static_fe_plug.ex new file mode 100644 index 000000000..156e6788e --- /dev/null +++ b/lib/pleroma/plugs/static_fe_plug.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.Plugs.StaticFEPlug do + import Plug.Conn + alias Pleroma.Web.StaticFE.StaticFEController + + def init(options), do: options + + def call(conn, _) do + if enabled?() and accepts_html?(conn) do + conn + |> StaticFEController.call(:show) + |> halt() + else + conn + end + end + + defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false) + + defp accepts_html?(conn) do + case get_req_header(conn, "accept") do + [accept | _] -> String.contains?(accept, "text/html") + _ -> false + end + end +end diff --git a/lib/pleroma/plugs/trailing_format_plug.ex b/lib/pleroma/plugs/trailing_format_plug.ex index ce366b218..8b4d5fc9f 100644 --- a/lib/pleroma/plugs/trailing_format_plug.ex +++ b/lib/pleroma/plugs/trailing_format_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.TrailingFormatPlug do @@ -24,7 +24,8 @@ defmodule Pleroma.Plugs.TrailingFormatPlug do "/api/help", "/api/externalprofile", "/notice", - "/api/pleroma/emoji" + "/api/pleroma/emoji", + "/api/oauth_tokens" ] def init(opts) do diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 69c1ab942..94147e0c4 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.UploadedMedia do @@ -14,9 +14,14 @@ defmodule Pleroma.Plugs.UploadedMedia do # no slashes @path "media" + @default_cache_control_header "public, max-age=1209600" + def init(_opts) do static_plug_opts = - [] + [ + headers: %{"cache-control" => @default_cache_control_header}, + cache_control_for_etags: @default_cache_control_header + ] |> Keyword.put(:from, "__unconfigured_media_plug") |> Keyword.put(:at, "/__unconfigured_media_plug") |> Plug.Static.init() @@ -36,6 +41,7 @@ defmodule Pleroma.Plugs.UploadedMedia do conn -> conn end + |> merge_resp_headers([{"content-security-policy", "sandbox"}]) config = Pleroma.Config.get(Pleroma.Upload) diff --git a/lib/pleroma/plugs/user_enabled_plug.ex b/lib/pleroma/plugs/user_enabled_plug.ex index da892c28b..23e800a74 100644 --- a/lib/pleroma/plugs/user_enabled_plug.ex +++ b/lib/pleroma/plugs/user_enabled_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.UserEnabledPlug do @@ -10,9 +10,11 @@ defmodule Pleroma.Plugs.UserEnabledPlug do options end - def call(%{assigns: %{user: %User{info: %{deactivated: true}}}} = conn, _) do - conn - |> assign(:user, nil) + def call(%{assigns: %{user: %User{} = user}} = conn, _) do + case User.account_status(user) do + :active -> conn + _ -> assign(conn, :user, nil) + end end def call(conn, _) do diff --git a/lib/pleroma/plugs/user_fetcher_plug.ex b/lib/pleroma/plugs/user_fetcher_plug.ex index 4089aa958..235c77d85 100644 --- a/lib/pleroma/plugs/user_fetcher_plug.ex +++ b/lib/pleroma/plugs/user_fetcher_plug.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.UserFetcherPlug do diff --git a/lib/pleroma/plugs/user_is_admin_plug.ex b/lib/pleroma/plugs/user_is_admin_plug.ex index 4c4b3d610..2748102df 100644 --- a/lib/pleroma/plugs/user_is_admin_plug.ex +++ b/lib/pleroma/plugs/user_is_admin_plug.ex @@ -1,23 +1,43 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.UserIsAdminPlug do import Pleroma.Web.TranslationHelpers import Plug.Conn + alias Pleroma.User + alias Pleroma.Web.OAuth def init(options) do options end - def call(%{assigns: %{user: %User{info: %{is_admin: true}}}} = conn, _) do - conn + def call(%{assigns: %{user: %User{is_admin: true}} = assigns} = conn, _) do + token = assigns[:token] + + cond do + not Pleroma.Config.enforce_oauth_admin_scope_usage?() -> + conn + + token && OAuth.Scopes.contains_admin_scopes?(token.scopes) -> + # Note: checking for _any_ admin scope presence, not necessarily fitting requested action. + # Thus, controller must explicitly invoke OAuthScopesPlug to verify scope requirements. + # Admin might opt out of admin scope for some apps to block any admin actions from them. + conn + + true -> + fail(conn) + end end def call(conn, _) do + fail(conn) + end + + defp fail(conn) do conn - |> render_error(:forbidden, "User is not admin.") - |> halt + |> render_error(:forbidden, "User is not an admin or OAuth admin scope is not granted.") + |> halt() end end diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex new file mode 100644 index 000000000..acafe1bea --- /dev/null +++ b/lib/pleroma/pool/connections.ex @@ -0,0 +1,283 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool.Connections do + use GenServer + + alias Pleroma.Config + alias Pleroma.Gun + + require Logger + + @type domain :: String.t() + @type conn :: Pleroma.Gun.Conn.t() + + @type t :: %__MODULE__{ + conns: %{domain() => conn()}, + opts: keyword() + } + + defstruct conns: %{}, opts: [] + + @spec start_link({atom(), keyword()}) :: {:ok, pid()} + def start_link({name, opts}) do + GenServer.start_link(__MODULE__, opts, name: name) + end + + @impl true + def init(opts), do: {:ok, %__MODULE__{conns: %{}, opts: opts}} + + @spec checkin(String.t() | URI.t(), atom()) :: pid() | nil + def checkin(url, name) + def checkin(url, name) when is_binary(url), do: checkin(URI.parse(url), name) + + def checkin(%URI{} = uri, name) do + timeout = Config.get([:connections_pool, :checkin_timeout], 250) + + GenServer.call(name, {:checkin, uri}, timeout) + end + + @spec alive?(atom()) :: boolean() + def alive?(name) do + if pid = Process.whereis(name) do + Process.alive?(pid) + else + false + end + end + + @spec get_state(atom()) :: t() + def get_state(name) do + GenServer.call(name, :state) + end + + @spec count(atom()) :: pos_integer() + def count(name) do + GenServer.call(name, :count) + end + + @spec get_unused_conns(atom()) :: [{domain(), conn()}] + def get_unused_conns(name) do + GenServer.call(name, :unused_conns) + end + + @spec checkout(pid(), pid(), atom()) :: :ok + def checkout(conn, pid, name) do + GenServer.cast(name, {:checkout, conn, pid}) + end + + @spec add_conn(atom(), String.t(), Pleroma.Gun.Conn.t()) :: :ok + def add_conn(name, key, conn) do + GenServer.cast(name, {:add_conn, key, conn}) + end + + @spec remove_conn(atom(), String.t()) :: :ok + def remove_conn(name, key) do + GenServer.cast(name, {:remove_conn, key}) + end + + @impl true + def handle_cast({:add_conn, key, conn}, state) do + state = put_in(state.conns[key], conn) + + Process.monitor(conn.conn) + {:noreply, state} + end + + @impl true + def handle_cast({:checkout, conn_pid, pid}, state) do + state = + with true <- Process.alive?(conn_pid), + {key, conn} <- find_conn(state.conns, conn_pid), + used_by <- List.keydelete(conn.used_by, pid, 0) do + conn_state = if used_by == [], do: :idle, else: conn.conn_state + + put_in(state.conns[key], %{conn | conn_state: conn_state, used_by: used_by}) + else + false -> + Logger.debug("checkout for closed conn #{inspect(conn_pid)}") + state + + nil -> + Logger.debug("checkout for alive conn #{inspect(conn_pid)}, but is not in state") + state + end + + {:noreply, state} + end + + @impl true + def handle_cast({:remove_conn, key}, state) do + state = put_in(state.conns, Map.delete(state.conns, key)) + {:noreply, state} + end + + @impl true + def handle_call({:checkin, uri}, from, state) do + key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + + case state.conns[key] do + %{conn: pid, gun_state: :up} = conn -> + time = :os.system_time(:second) + last_reference = time - conn.last_reference + crf = crf(last_reference, 100, conn.crf) + + state = + put_in(state.conns[key], %{ + conn + | last_reference: time, + crf: crf, + conn_state: :active, + used_by: [from | conn.used_by] + }) + + {:reply, pid, state} + + %{gun_state: :down} -> + {:reply, nil, state} + + nil -> + {:reply, nil, state} + end + end + + @impl true + def handle_call(:state, _from, state), do: {:reply, state, state} + + @impl true + def handle_call(:count, _from, state) do + {:reply, Enum.count(state.conns), state} + end + + @impl true + def handle_call(:unused_conns, _from, state) do + unused_conns = + state.conns + |> Enum.filter(&filter_conns/1) + |> Enum.sort(&sort_conns/2) + + {:reply, unused_conns, state} + end + + defp filter_conns({_, %{conn_state: :idle, used_by: []}}), do: true + defp filter_conns(_), do: false + + defp sort_conns({_, c1}, {_, c2}) do + c1.crf <= c2.crf and c1.last_reference <= c2.last_reference + end + + @impl true + def handle_info({:gun_up, conn_pid, _protocol}, state) do + %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(conn_pid) + + host = + case :inet.ntoa(host) do + {:error, :einval} -> host + ip -> ip + end + + key = "#{scheme}:#{host}:#{port}" + + state = + with {key, conn} <- find_conn(state.conns, conn_pid, key), + {true, key} <- {Process.alive?(conn_pid), key} do + put_in(state.conns[key], %{ + conn + | gun_state: :up, + conn_state: :active, + retries: 0 + }) + else + {false, key} -> + put_in( + state.conns, + Map.delete(state.conns, key) + ) + + nil -> + :ok = Gun.close(conn_pid) + + state + end + + {:noreply, state} + end + + @impl true + def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do + retries = Config.get([:connections_pool, :retry], 1) + # we can't get info on this pid, because pid is dead + state = + with {key, conn} <- find_conn(state.conns, conn_pid), + {true, key} <- {Process.alive?(conn_pid), key} do + if conn.retries == retries do + :ok = Gun.close(conn.conn) + + put_in( + state.conns, + Map.delete(state.conns, key) + ) + else + put_in(state.conns[key], %{ + conn + | gun_state: :down, + retries: conn.retries + 1 + }) + end + else + {false, key} -> + put_in( + state.conns, + Map.delete(state.conns, key) + ) + + nil -> + Logger.debug(":gun_down for conn which isn't found in state") + + state + end + + {:noreply, state} + end + + @impl true + def handle_info({:DOWN, _ref, :process, conn_pid, reason}, state) do + Logger.debug("received DOWN message for #{inspect(conn_pid)} reason -> #{inspect(reason)}") + + state = + with {key, conn} <- find_conn(state.conns, conn_pid) do + Enum.each(conn.used_by, fn {pid, _ref} -> + Process.exit(pid, reason) + end) + + put_in( + state.conns, + Map.delete(state.conns, key) + ) + else + nil -> + Logger.debug(":DOWN for conn which isn't found in state") + + state + end + + {:noreply, state} + end + + defp find_conn(conns, conn_pid) do + Enum.find(conns, fn {_key, conn} -> + conn.conn == conn_pid + end) + end + + defp find_conn(conns, conn_pid, conn_key) do + Enum.find(conns, fn {key, conn} -> + key == conn_key and conn.conn == conn_pid + end) + end + + def crf(current, steps, crf) do + 1 + :math.pow(0.5, current / steps) * crf + end +end diff --git a/lib/pleroma/pool/pool.ex b/lib/pleroma/pool/pool.ex new file mode 100644 index 000000000..21a6fbbc5 --- /dev/null +++ b/lib/pleroma/pool/pool.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool do + def child_spec(opts) do + poolboy_opts = + opts + |> Keyword.put(:worker_module, Pleroma.Pool.Request) + |> Keyword.put(:name, {:local, opts[:name]}) + |> Keyword.put(:size, opts[:size]) + |> Keyword.put(:max_overflow, opts[:max_overflow]) + + %{ + id: opts[:id] || {__MODULE__, make_ref()}, + start: {:poolboy, :start_link, [poolboy_opts, [name: opts[:name]]]}, + restart: :permanent, + shutdown: 5000, + type: :worker + } + end +end diff --git a/lib/pleroma/pool/request.ex b/lib/pleroma/pool/request.ex new file mode 100644 index 000000000..3fb930db7 --- /dev/null +++ b/lib/pleroma/pool/request.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.Pool.Request do + use GenServer + + require Logger + + def start_link(args) do + GenServer.start_link(__MODULE__, args) + end + + @impl true + def init(_), do: {:ok, []} + + @spec execute(pid() | atom(), Tesla.Client.t(), keyword(), pos_integer()) :: + {:ok, Tesla.Env.t()} | {:error, any()} + def execute(pid, client, request, timeout) do + GenServer.call(pid, {:execute, client, request}, timeout) + end + + @impl true + def handle_call({:execute, client, request}, _from, state) do + response = Pleroma.HTTP.request(client, request) + + {:reply, response, state} + end + + @impl true + def handle_info({:gun_data, _conn, _stream, _, _}, state) do + {:noreply, state} + end + + @impl true + def handle_info({:gun_up, _conn, _protocol}, state) do + {:noreply, state} + end + + @impl true + def handle_info({:gun_down, _conn, _protocol, _reason, _killed}, state) do + {:noreply, state} + end + + @impl true + def handle_info({:gun_error, _conn, _stream, _error}, state) do + {:noreply, state} + end + + @impl true + def handle_info({:gun_push, _conn, _stream, _new_stream, _method, _uri, _headers}, state) do + {:noreply, state} + end + + @impl true + def handle_info({:gun_response, _conn, _stream, _, _status, _headers}, state) do + {:noreply, state} + end + + @impl true + def handle_info(msg, state) do + Logger.warn("Received unexpected message #{inspect(__MODULE__)} #{inspect(msg)}") + {:noreply, state} + end +end diff --git a/lib/pleroma/pool/supervisor.ex b/lib/pleroma/pool/supervisor.ex new file mode 100644 index 000000000..faf646cb2 --- /dev/null +++ b/lib/pleroma/pool/supervisor.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool.Supervisor do + use Supervisor + + alias Pleroma.Config + alias Pleroma.Pool + + def start_link(args) do + Supervisor.start_link(__MODULE__, args, name: __MODULE__) + end + + def init(_) do + conns_child = %{ + id: Pool.Connections, + start: + {Pool.Connections, :start_link, [{:gun_connections, Config.get([:connections_pool])}]} + } + + Supervisor.init([conns_child | pools()], strategy: :one_for_one) + end + + defp pools do + pools = Config.get(:pools) + + pools = + if Config.get([Pleroma.Upload, :proxy_remote]) == false do + Keyword.delete(pools, :upload) + else + pools + end + + for {pool_name, pool_opts} <- pools do + pool_opts + |> Keyword.put(:id, {Pool, pool_name}) + |> Keyword.put(:name, pool_name) + |> Pool.child_spec() + end + end +end diff --git a/lib/pleroma/registration.ex b/lib/pleroma/registration.ex index 8544461db..9163040b4 100644 --- a/lib/pleroma/registration.ex +++ b/lib/pleroma/registration.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Registration do diff --git a/lib/pleroma/release_tasks.ex b/lib/pleroma/release_tasks.ex index 8afabf463..02dd6c325 100644 --- a/lib/pleroma/release_tasks.ex +++ b/lib/pleroma/release_tasks.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReleaseTasks do diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index f57e088bc..f62138466 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Repo do @@ -8,6 +8,8 @@ defmodule Pleroma.Repo do adapter: Ecto.Adapters.Postgres, migration_timestamps: [type: :naive_datetime_usec] + require Logger + defmodule Instrumenter do use Prometheus.EctoInstrumenter end @@ -47,4 +49,37 @@ defmodule Pleroma.Repo do _ -> {:error, :not_found} end end + + def check_migrations_applied!() do + unless Pleroma.Config.get( + [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], + false + ) do + Ecto.Migrator.with_repo(__MODULE__, fn repo -> + down_migrations = + Ecto.Migrator.migrations(repo) + |> Enum.reject(fn + {:up, _, _} -> true + {:down, _, _} -> false + end) + + if length(down_migrations) > 0 do + down_migrations_text = + Enum.map(down_migrations, fn {:down, id, name} -> "- #{name} (#{id})\n" end) + + Logger.error( + "The following migrations were not applied:\n#{down_migrations_text}If you want to start Pleroma anyway, set\nconfig :pleroma, :i_am_aware_this_may_cause_data_loss, disable_migration_check: true" + ) + + raise Pleroma.Repo.UnappliedMigrationsError + end + end) + else + :ok + end + end +end + +defmodule Pleroma.Repo.UnappliedMigrationsError do + defexception message: "Unapplied Migrations detected" end diff --git a/lib/pleroma/repo_streamer.ex b/lib/pleroma/repo_streamer.ex index a4b71a1bb..cb4d7bb7a 100644 --- a/lib/pleroma/repo_streamer.ex +++ b/lib/pleroma/repo_streamer.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.RepoStreamer do diff --git a/lib/pleroma/report_note.ex b/lib/pleroma/report_note.ex new file mode 100644 index 000000000..a239bd361 --- /dev/null +++ b/lib/pleroma/report_note.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.ReportNote do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Activity + alias Pleroma.Repo + alias Pleroma.ReportNote + alias Pleroma.User + + @type t :: %__MODULE__{} + + schema "report_notes" do + field(:content, :string) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + @spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t(), String.t()) :: + {:ok, ReportNote.t()} | {:error, Changeset.t()} + def create(user_id, activity_id, content) do + attrs = %{ + user_id: user_id, + activity_id: activity_id, + content: content + } + + %ReportNote{} + |> cast(attrs, [:user_id, :activity_id, :content]) + |> validate_required([:user_id, :activity_id, :content]) + |> Repo.insert() + end + + @spec destroy(FlakeId.Ecto.CompatType.t()) :: + {:ok, ReportNote.t()} | {:error, Changeset.t()} + def destroy(id) do + from(r in ReportNote, where: r.id == ^id) + |> Repo.one() + |> Repo.delete() + end +end diff --git a/lib/pleroma/reverse_proxy/client.ex b/lib/pleroma/reverse_proxy/client.ex index 776c4794c..0d13ff174 100644 --- a/lib/pleroma/reverse_proxy/client.ex +++ b/lib/pleroma/reverse_proxy/client.ex @@ -1,21 +1,25 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy.Client do - @callback request(atom(), String.t(), [tuple()], String.t(), list()) :: - {:ok, pos_integer(), [tuple()], reference() | map()} - | {:ok, pos_integer(), [tuple()]} + @type status :: pos_integer() + @type header_name :: String.t() + @type header_value :: String.t() + @type headers :: [{header_name(), header_value()}] + + @callback request(atom(), String.t(), headers(), String.t(), list()) :: + {:ok, status(), headers(), reference() | map()} + | {:ok, status(), headers()} | {:ok, reference()} | {:error, term()} - @callback stream_body(reference() | pid() | map()) :: - {:ok, binary()} | :done | {:error, String.t()} + @callback stream_body(map()) :: {:ok, binary(), map()} | :done | {:error, atom() | String.t()} @callback close(reference() | pid() | map()) :: :ok - def request(method, url, headers, "", opts \\ []) do - client().request(method, url, headers, "", opts) + def request(method, url, headers, body \\ "", opts \\ []) do + client().request(method, url, headers, body, opts) end def stream_body(ref), do: client().stream_body(ref) @@ -23,6 +27,12 @@ defmodule Pleroma.ReverseProxy.Client do def close(ref), do: client().close(ref) defp client do - Pleroma.Config.get([Pleroma.ReverseProxy.Client], :hackney) + :tesla + |> Application.get_env(:adapter) + |> client() end + + defp client(Tesla.Adapter.Hackney), do: Pleroma.ReverseProxy.Client.Hackney + defp client(Tesla.Adapter.Gun), do: Pleroma.ReverseProxy.Client.Tesla + defp client(_), do: Pleroma.Config.get!(Pleroma.ReverseProxy.Client) end diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex new file mode 100644 index 000000000..e84118a90 --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.Hackney do + @behaviour Pleroma.ReverseProxy.Client + + @impl true + def request(method, url, headers, body, opts \\ []) do + :hackney.request(method, url, headers, body, opts) + end + + @impl true + def stream_body(ref) do + case :hackney.stream_body(ref) do + :done -> :done + {:ok, data} -> {:ok, data, ref} + {:error, error} -> {:error, error} + end + end + + @impl true + def close(ref), do: :hackney.close(ref) +end diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex new file mode 100644 index 000000000..e81ea8bde --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -0,0 +1,90 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.Tesla do + @behaviour Pleroma.ReverseProxy.Client + + @type headers() :: [{String.t(), String.t()}] + @type status() :: pos_integer() + + @spec request(atom(), String.t(), headers(), String.t(), keyword()) :: + {:ok, status(), headers} + | {:ok, status(), headers, map()} + | {:error, atom() | String.t()} + | no_return() + + @impl true + def request(method, url, headers, body, opts \\ []) do + check_adapter() + + opts = Keyword.put(opts, :body_as, :chunks) + + with {:ok, response} <- + Pleroma.HTTP.request( + method, + url, + body, + headers, + Keyword.put(opts, :adapter, opts) + ) do + if is_map(response.body) and method != :head do + {:ok, response.status, response.headers, response.body} + else + {:ok, response.status, response.headers} + end + else + {:error, error} -> {:error, error} + end + end + + @impl true + @spec stream_body(map()) :: + {:ok, binary(), map()} | {:error, atom() | String.t()} | :done | no_return() + def stream_body(%{pid: pid, opts: opts, fin: true}) do + # if connection was reused, but in tesla were redirects, + # tesla returns new opened connection, which must be closed manually + if opts[:old_conn], do: Tesla.Adapter.Gun.close(pid) + # if there were redirects we need to checkout old conn + conn = opts[:old_conn] || opts[:conn] + + if conn, do: :ok = Pleroma.Pool.Connections.checkout(conn, self(), :gun_connections) + + :done + end + + def stream_body(client) do + case read_chunk!(client) do + {:fin, body} -> + {:ok, body, Map.put(client, :fin, true)} + + {:nofin, part} -> + {:ok, part, client} + + {:error, error} -> + {:error, error} + end + end + + defp read_chunk!(%{pid: pid, stream: stream, opts: opts}) do + adapter = check_adapter() + adapter.read_chunk(pid, stream, opts) + end + + @impl true + @spec close(map) :: :ok | no_return() + def close(%{pid: pid}) do + adapter = check_adapter() + adapter.close(pid) + end + + defp check_adapter do + adapter = Application.get_env(:tesla, :adapter) + + unless adapter == Tesla.Adapter.Gun do + raise "#{adapter} doesn't support reading body in chunks" + end + + adapter + end +end diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 2ed719315..4bbeb493c 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -1,13 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy do - alias Pleroma.HTTP - @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++ ~w(if-unmodified-since if-none-match if-range range) - @resp_cache_headers ~w(etag date last-modified cache-control) + @resp_cache_headers ~w(etag date last-modified) @keep_resp_headers @resp_cache_headers ++ ~w(content-type content-disposition content-encoding content-range) ++ ~w(accept-ranges vary) @@ -34,9 +32,6 @@ defmodule Pleroma.ReverseProxy do * request: `#{inspect(@keep_req_headers)}` * response: `#{inspect(@keep_resp_headers)}` - If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be - set to `#{inspect(@default_cache_control_header)}`. - Options: * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP @@ -61,10 +56,10 @@ defmodule Pleroma.ReverseProxy do * `req_headers`, `resp_headers` additional headers. - * `http`: options for [hackney](https://github.com/benoitc/hackney). + * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun). """ - @default_hackney_options [pool: :media] + @default_options [pool: :media] @inline_content_types [ "image/gif", @@ -97,11 +92,7 @@ defmodule Pleroma.ReverseProxy do def call(_conn, _url, _opts \\ []) def call(conn = %{method: method}, url, opts) when method in @methods do - hackney_opts = - Pleroma.HTTP.Connection.hackney_options([]) - |> Keyword.merge(@default_hackney_options) - |> Keyword.merge(Keyword.get(opts, :http, [])) - |> HTTP.process_request_options() + client_opts = Keyword.merge(@default_options, Keyword.get(opts, :http, [])) req_headers = build_req_headers(conn.req_headers, opts) @@ -113,7 +104,7 @@ defmodule Pleroma.ReverseProxy do end with {:ok, nil} <- Cachex.get(:failed_proxy_url_cache, url), - {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), + {:ok, code, headers, client} <- request(method, url, req_headers, client_opts), :ok <- header_length_constraint( headers, @@ -159,11 +150,11 @@ defmodule Pleroma.ReverseProxy do |> halt() end - defp request(method, url, headers, hackney_opts) do + defp request(method, url, headers, opts) do Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") method = method |> String.downcase() |> String.to_existing_atom() - case client().request(method, url, headers, "", hackney_opts) do + case client().request(method, url, headers, "", opts) do {:ok, code, headers, client} when code in @valid_resp_codes -> {:ok, code, downcase_headers(headers), client} @@ -213,7 +204,7 @@ defmodule Pleroma.ReverseProxy do duration, Keyword.get(opts, :max_read_duration, @max_read_duration) ), - {:ok, data} <- client().stream_body(client), + {:ok, data, client} <- client().stream_body(client), {:ok, duration} <- increase_read_duration(duration), sent_so_far = sent_so_far + byte_size(data), :ok <- @@ -297,16 +288,17 @@ defmodule Pleroma.ReverseProxy do defp build_resp_cache_headers(headers, _opts) do has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end) - has_cache_control? = List.keymember?(headers, "cache-control", 0) cond do - has_cache? && has_cache_control? -> - headers - has_cache? -> - # There's caching header present but no cache-control -- we need to explicitely override it - # to public as Plug defaults to "max-age=0, private, must-revalidate" - List.keystore(headers, "cache-control", 0, {"cache-control", "public"}) + # There's caching header present but no cache-control -- we need to set our own + # as Plug defaults to "max-age=0, private, must-revalidate" + List.keystore( + headers, + "cache-control", + 0, + {"cache-control", @default_cache_control_header} + ) true -> List.keystore( diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex index fea2cf3ff..0937cb7db 100644 --- a/lib/pleroma/scheduled_activity.ex +++ b/lib/pleroma/scheduled_activity.ex @@ -1,19 +1,23 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ScheduledActivity do use Ecto.Schema + alias Ecto.Multi alias Pleroma.Config alias Pleroma.Repo alias Pleroma.ScheduledActivity alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Workers.ScheduledActivityWorker import Ecto.Query import Ecto.Changeset + @type t :: %__MODULE__{} + @min_offset :timer.minutes(5) schema "scheduled_activities" do @@ -36,7 +40,7 @@ defmodule Pleroma.ScheduledActivity do %{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset ) when is_list(media_ids) do - media_attachments = Utils.attachments_from_ids(%{"media_ids" => media_ids}) + media_attachments = Utils.attachments_from_ids(%{media_ids: media_ids}) params = params @@ -105,16 +109,32 @@ defmodule Pleroma.ScheduledActivity do end def new(%User{} = user, attrs) do - %ScheduledActivity{user_id: user.id} - |> changeset(attrs) + changeset(%ScheduledActivity{user_id: user.id}, attrs) end + @doc """ + Creates ScheduledActivity and add to queue to perform at scheduled_at date + """ + @spec create(User.t(), map()) :: {:ok, ScheduledActivity.t()} | {:error, Ecto.Changeset.t()} def create(%User{} = user, attrs) do - user - |> new(attrs) - |> Repo.insert() + Multi.new() + |> Multi.insert(:scheduled_activity, new(user, attrs)) + |> maybe_add_jobs(Config.get([ScheduledActivity, :enabled])) + |> Repo.transaction() + |> transaction_response + end + + defp maybe_add_jobs(multi, true) do + multi + |> Multi.run(:scheduled_activity_job, fn _repo, %{scheduled_activity: activity} -> + %{activity_id: activity.id} + |> ScheduledActivityWorker.new(scheduled_at: activity.scheduled_at) + |> Oban.insert() + end) end + defp maybe_add_jobs(multi, _), do: multi + def get(%User{} = user, scheduled_activity_id) do ScheduledActivity |> where(user_id: ^user.id) @@ -122,25 +142,43 @@ defmodule Pleroma.ScheduledActivity do |> Repo.one() end - def update(%ScheduledActivity{} = scheduled_activity, attrs) do - scheduled_activity - |> update_changeset(attrs) - |> Repo.update() + @spec update(ScheduledActivity.t(), map()) :: + {:ok, ScheduledActivity.t()} | {:error, Ecto.Changeset.t()} + def update(%ScheduledActivity{id: id} = scheduled_activity, attrs) do + with {:error, %Ecto.Changeset{valid?: true} = changeset} <- + {:error, update_changeset(scheduled_activity, attrs)} do + Multi.new() + |> Multi.update(:scheduled_activity, changeset) + |> Multi.update_all(:scheduled_job, job_query(id), + set: [scheduled_at: get_field(changeset, :scheduled_at)] + ) + |> Repo.transaction() + |> transaction_response + end end - def delete(%ScheduledActivity{} = scheduled_activity) do - scheduled_activity - |> Repo.delete() + @doc "Deletes a ScheduledActivity and linked jobs." + @spec delete(ScheduledActivity.t() | binary() | integer) :: + {:ok, ScheduledActivity.t()} | {:error, Ecto.Changeset.t()} + def delete(%ScheduledActivity{id: id} = scheduled_activity) do + Multi.new() + |> Multi.delete(:scheduled_activity, scheduled_activity, stale_error_field: :id) + |> Multi.delete_all(:jobs, job_query(id)) + |> Repo.transaction() + |> transaction_response end def delete(id) when is_binary(id) or is_integer(id) do - ScheduledActivity - |> where(id: ^id) - |> select([sa], sa) - |> Repo.delete_all() - |> case do - {1, [scheduled_activity]} -> {:ok, scheduled_activity} - _ -> :error + delete(%__MODULE__{id: id}) + end + + defp transaction_response(result) do + case result do + {:ok, %{scheduled_activity: scheduled_activity}} -> + {:ok, scheduled_activity} + + {:error, _, changeset, _} -> + {:error, changeset} end end @@ -158,4 +196,11 @@ defmodule Pleroma.ScheduledActivity do |> where([sa], sa.scheduled_at < ^naive_datetime) |> Repo.all() end + + def job_query(scheduled_activity_id) do + from(j in Oban.Job, + where: j.queue == "scheduled_activities", + where: fragment("args ->> 'activity_id' = ?::text", ^to_string(scheduled_activity_id)) + ) + end end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 1e7c9ae86..d01728361 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Signature do @@ -8,6 +8,7 @@ defmodule Pleroma.Signature do alias Pleroma.Keys alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ObjectValidators.Types def key_id_to_actor_id(key_id) do uri = @@ -21,12 +22,23 @@ defmodule Pleroma.Signature do uri end - URI.to_string(uri) + maybe_ap_id = URI.to_string(uri) + + case Types.ObjectID.cast(maybe_ap_id) do + {:ok, ap_id} -> + {:ok, ap_id} + + _ -> + case Pleroma.Web.WebFinger.finger(maybe_ap_id) do + %{"ap_id" => ap_id} -> {:ok, ap_id} + _ -> {:error, maybe_ap_id} + end + end end def fetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), - actor_id <- key_id_to_actor_id(kid), + {:ok, actor_id} <- key_id_to_actor_id(kid), {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} else @@ -37,7 +49,7 @@ defmodule Pleroma.Signature do def refetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), - actor_id <- key_id_to_actor_id(kid), + {:ok, actor_id} <- key_id_to_actor_id(kid), {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id), {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index df80fbaa4..6b3a8a41f 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -1,43 +1,55 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Stats do import Ecto.Query + alias Pleroma.CounterCache alias Pleroma.Repo alias Pleroma.User use GenServer - @interval 1000 * 60 * 60 - def start_link(_) do - GenServer.start_link(__MODULE__, initial_data(), name: __MODULE__) + GenServer.start_link( + __MODULE__, + nil, + name: __MODULE__ + ) end + @doc "Performs update stats" def force_update do GenServer.call(__MODULE__, :force_update) end + @doc "Performs collect stats" + def do_collect do + GenServer.cast(__MODULE__, :run_update) + end + + @doc "Returns stats data" + @spec get_stats() :: %{domain_count: integer(), status_count: integer(), user_count: integer()} def get_stats do %{stats: stats} = GenServer.call(__MODULE__, :get_state) stats end + @doc "Returns list peers" + @spec get_peers() :: list(String.t()) def get_peers do %{peers: peers} = GenServer.call(__MODULE__, :get_state) peers end - def init(args) do - Process.send(self(), :run_update, []) - {:ok, args} + def init(_args) do + {:ok, calculate_stat_data()} end def handle_call(:force_update, _from, _state) do - new_stats = get_stat_data() + new_stats = calculate_stat_data() {:reply, new_stats, new_stats} end @@ -45,18 +57,13 @@ defmodule Pleroma.Stats do {:reply, state, state} end - def handle_info(:run_update, _state) do - new_stats = get_stat_data() + def handle_cast(:run_update, _state) do + new_stats = calculate_stat_data() - Process.send_after(self(), :run_update, @interval) {:noreply, new_stats} end - defp initial_data do - %{peers: [], stats: %{}} - end - - defp get_stat_data do + def calculate_stat_data do peers = from( u in User, @@ -68,18 +75,42 @@ defmodule Pleroma.Stats do domain_count = Enum.count(peers) - status_query = - from(u in User.Query.build(%{local: true}), - select: fragment("sum((?->>'note_count')::int)", u.info) - ) + status_count = Repo.aggregate(User.Query.build(%{local: true}), :sum, :note_count) - status_count = Repo.one(status_query) + users_query = + from(u in User, + where: u.deactivated != true, + where: u.local == true, + where: not is_nil(u.nickname), + where: not u.invisible + ) - user_count = Repo.aggregate(User.Query.build(%{local: true, active: true}), :count, :id) + user_count = Repo.aggregate(users_query, :count, :id) %{ peers: peers, - stats: %{domain_count: domain_count, status_count: status_count, user_count: user_count} + stats: %{ + domain_count: domain_count, + status_count: status_count || 0, + user_count: user_count + } + } + end + + def get_status_visibility_count do + counter_cache = + CounterCache.get_as_map([ + "status_visibility_public", + "status_visibility_private", + "status_visibility_unlisted", + "status_visibility_direct" + ]) + + %{ + public: counter_cache["status_visibility_public"] || 0, + unlisted: counter_cache["status_visibility_unlisted"] || 0, + private: counter_cache["status_visibility_private"] || 0, + direct: counter_cache["status_visibility_direct"] || 0 } end end diff --git a/lib/pleroma/tests/auth_test_controller.ex b/lib/pleroma/tests/auth_test_controller.ex new file mode 100644 index 000000000..fb04411d9 --- /dev/null +++ b/lib/pleroma/tests/auth_test_controller.ex @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +# A test controller reachable only in :test env. +defmodule Pleroma.Tests.AuthTestController do + @moduledoc false + + use Pleroma.Web, :controller + + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + + # Serves only with proper OAuth token (:api and :authenticated_api) + # Skipping EnsurePublicOrAuthenticatedPlug has no effect in this case + # + # Suggested use case: all :authenticated_api endpoints (makes no sense for :api endpoints) + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :do_oauth_check) + + # Via :api, keeps :user if token has requested scopes (if :user is dropped, serves if public) + # Via :authenticated_api, serves if token is present and has requested scopes + # + # Suggested use case: vast majority of :api endpoints (no sense for :authenticated_api ones) + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} + when action == :fallback_oauth_check + ) + + # Keeps :user if present, executes regardless of token / token scopes + # Fails with no :user for :authenticated_api / no user for :api on private instance + # Note: EnsurePublicOrAuthenticatedPlug is not skipped (private instance fails on no :user) + # Note: Basic Auth processing results in :skip_plug call for OAuthScopesPlug + # + # Suggested use: suppressing OAuth checks for other auth mechanisms (like Basic Auth) + # For controller-level use, see :skip_oauth_skip_publicity_check instead + plug( + :skip_plug, + OAuthScopesPlug when action == :skip_oauth_check + ) + + # (Shouldn't be executed since the plug is skipped) + plug(OAuthScopesPlug, %{scopes: ["admin"]} when action == :skip_oauth_check) + + # Via :api, keeps :user if token has requested scopes, and continues with nil :user otherwise + # Via :authenticated_api, serves if token is present and has requested scopes + # + # Suggested use: as :fallback_oauth_check but open with nil :user for :api on private instances + plug( + :skip_plug, + EnsurePublicOrAuthenticatedPlug when action == :fallback_oauth_skip_publicity_check + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} + when action == :fallback_oauth_skip_publicity_check + ) + + # Via :api, keeps :user if present, serves regardless of token presence / scopes / :user presence + # Via :authenticated_api, serves if :user is set (regardless of token presence and its scopes) + # + # Suggested use: making an :api endpoint always accessible (e.g. email confirmation endpoint) + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] + when action == :skip_oauth_skip_publicity_check + ) + + # Via :authenticated_api, always fails with 403 (endpoint is insecure) + # Via :api, drops :user if present and serves if public (private instance rejects on no user) + # + # Suggested use: none; please define OAuth rules for all :api / :authenticated_api endpoints + plug(:skip_plug, [] when action == :missing_oauth_check_definition) + + def do_oauth_check(conn, _params), do: conn_state(conn) + + def fallback_oauth_check(conn, _params), do: conn_state(conn) + + def skip_oauth_check(conn, _params), do: conn_state(conn) + + def fallback_oauth_skip_publicity_check(conn, _params), do: conn_state(conn) + + def skip_oauth_skip_publicity_check(conn, _params), do: conn_state(conn) + + def missing_oauth_check_definition(conn, _params), do: conn_state(conn) + + defp conn_state(%{assigns: %{user: %User{} = user}} = conn), + do: json(conn, %{user_id: user.id}) + + defp conn_state(conn), do: json(conn, %{user_id: nil}) +end diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex index 65cbbede3..be01d541d 100644 --- a/lib/pleroma/thread_mute.ex +++ b/lib/pleroma/thread_mute.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ThreadMute do @@ -9,7 +9,8 @@ defmodule Pleroma.ThreadMute do alias Pleroma.ThreadMute alias Pleroma.User - require Ecto.Query + import Ecto.Changeset + import Ecto.Query schema "thread_mutes" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) @@ -18,19 +19,44 @@ defmodule Pleroma.ThreadMute do def changeset(mute, params \\ %{}) do mute - |> Ecto.Changeset.cast(params, [:user_id, :context]) - |> Ecto.Changeset.foreign_key_constraint(:user_id) - |> Ecto.Changeset.unique_constraint(:user_id, name: :unique_index) + |> cast(params, [:user_id, :context]) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id, name: :unique_index) end def query(user_id, context) do - {:ok, user_id} = FlakeId.Ecto.CompatType.dump(user_id) + user_binary_id = User.binary_id(user_id) ThreadMute - |> Ecto.Query.where(user_id: ^user_id) - |> Ecto.Query.where(context: ^context) + |> where(user_id: ^user_binary_id) + |> where(context: ^context) end + def muters_query(context) do + ThreadMute + |> join(:inner, [tm], u in assoc(tm, :user)) + |> where([tm], tm.context == ^context) + |> select([tm, u], u.ap_id) + end + + def muter_ap_ids(context, ap_ids \\ nil) + + # Note: applies to fake activities (ActivityPub.Utils.get_notified_from_object/1 etc.) + def muter_ap_ids(context, _ap_ids) when is_nil(context), do: [] + + def muter_ap_ids(context, ap_ids) do + context + |> muters_query() + |> maybe_filter_on_ap_id(ap_ids) + |> Repo.all() + end + + defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do + where(query, [tm, u], u.ap_id in ^ap_ids) + end + + defp maybe_filter_on_ap_id(query, _ap_ids), do: query + def add_mute(user_id, context) do %ThreadMute{} |> changeset(%{user_id: user_id, context: context}) @@ -42,8 +68,8 @@ defmodule Pleroma.ThreadMute do |> Repo.delete_all() end - def check_muted(user_id, context) do + def exists?(user_id, context) do query(user_id, context) - |> Repo.all() + |> Repo.exists?() end end diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 2e0986197..1be1a3a5b 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload do @@ -37,6 +37,7 @@ defmodule Pleroma.Upload do Plug.Upload.t() | (data_uri_string :: String.t()) | {:from_local, name :: String.t(), id :: String.t(), path :: String.t()} + | map() @type option :: {:type, :avatar | :banner | :background} @@ -133,7 +134,7 @@ defmodule Pleroma.Upload do end end - defp prepare_upload(%{"img" => "data:image/" <> image_data}, opts) do + defp prepare_upload(%{img: "data:image/" <> image_data}, opts) do parsed = Regex.named_captures(~r/(?<filetype>jpeg|png|gif);base64,(?<data>.*)/, image_data) data = Base.decode64!(parsed["data"], ignore: :whitespace) hash = String.downcase(Base.encode16(:crypto.hash(:sha256, data))) diff --git a/lib/pleroma/upload/filter.ex b/lib/pleroma/upload/filter.ex index fa02a55de..dbdadc97f 100644 --- a/lib/pleroma/upload/filter.ex +++ b/lib/pleroma/upload/filter.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter do diff --git a/lib/pleroma/upload/filter/anonymize_filename.ex b/lib/pleroma/upload/filter/anonymize_filename.ex index a8516811c..07ead8203 100644 --- a/lib/pleroma/upload/filter/anonymize_filename.ex +++ b/lib/pleroma/upload/filter/anonymize_filename.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter.AnonymizeFilename do diff --git a/lib/pleroma/upload/filter/dedupe.ex b/lib/pleroma/upload/filter/dedupe.ex index 14928c355..41218a918 100644 --- a/lib/pleroma/upload/filter/dedupe.ex +++ b/lib/pleroma/upload/filter/dedupe.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter.Dedupe do diff --git a/lib/pleroma/upload/filter/mogrifun.ex b/lib/pleroma/upload/filter/mogrifun.ex index fee49fb51..7d95577a4 100644 --- a/lib/pleroma/upload/filter/mogrifun.ex +++ b/lib/pleroma/upload/filter/mogrifun.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter.Mogrifun do diff --git a/lib/pleroma/upload/filter/mogrify.ex b/lib/pleroma/upload/filter/mogrify.ex index 91bfdd4f5..2eb758006 100644 --- a/lib/pleroma/upload/filter/mogrify.ex +++ b/lib/pleroma/upload/filter/mogrify.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter.Mogrify do diff --git a/lib/pleroma/uploaders/local.ex b/lib/pleroma/uploaders/local.ex index 36b3c35ec..10b3069f4 100644 --- a/lib/pleroma/uploaders/local.ex +++ b/lib/pleroma/uploaders/local.ex @@ -1,14 +1,16 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Uploaders.Local do @behaviour Pleroma.Uploaders.Uploader + @impl true def get_file(_) do {:ok, {:static_dir, upload_path()}} end + @impl true def put_file(upload) do {local_path, file} = case Enum.reverse(Path.split(upload.path)) do @@ -33,4 +35,15 @@ defmodule Pleroma.Uploaders.Local do def upload_path do Pleroma.Config.get!([__MODULE__, :uploads]) end + + @impl true + def delete_file(path) do + upload_path() + |> Path.join(path) + |> File.rm() + |> case do + :ok -> :ok + {:error, posix_error} -> {:error, to_string(posix_error)} + end + end end diff --git a/lib/pleroma/uploaders/mdii.ex b/lib/pleroma/uploaders/mdii.ex deleted file mode 100644 index c36f3d61d..000000000 --- a/lib/pleroma/uploaders/mdii.ex +++ /dev/null @@ -1,37 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Uploaders.MDII do - @moduledoc "Represents uploader for https://github.com/hakaba-hitoyo/minimal-digital-image-infrastructure" - - alias Pleroma.Config - alias Pleroma.HTTP - - @behaviour Pleroma.Uploaders.Uploader - - # MDII-hosted images are never passed through the MediaPlug; only local media. - # Delegate to Pleroma.Uploaders.Local - def get_file(file) do - Pleroma.Uploaders.Local.get_file(file) - end - - def put_file(upload) do - cgi = Config.get([Pleroma.Uploaders.MDII, :cgi]) - files = Config.get([Pleroma.Uploaders.MDII, :files]) - - {:ok, file_data} = File.read(upload.tempfile) - - extension = String.split(upload.name, ".") |> List.last() - query = "#{cgi}?#{extension}" - - with {:ok, %{status: 200, body: body}} <- - HTTP.post(query, file_data, [], adapter: [pool: :default]) do - remote_file_name = String.split(body) |> List.first() - public_url = "#{files}/#{remote_file_name}.#{extension}" - {:ok, {:url, public_url}} - else - _ -> Pleroma.Uploaders.Local.put_file(upload) - end - end -end diff --git a/lib/pleroma/uploaders/s3.ex b/lib/pleroma/uploaders/s3.ex index 9876b6398..a13ff23b6 100644 --- a/lib/pleroma/uploaders/s3.ex +++ b/lib/pleroma/uploaders/s3.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Uploaders.S3 do @@ -10,6 +10,7 @@ defmodule Pleroma.Uploaders.S3 do # The file name is re-encoded with S3's constraints here to comply with previous # links with less strict filenames + @impl true def get_file(file) do config = Config.get([__MODULE__]) bucket = Keyword.fetch!(config, :bucket) @@ -35,6 +36,7 @@ defmodule Pleroma.Uploaders.S3 do ])}} end + @impl true def put_file(%Pleroma.Upload{} = upload) do config = Config.get([__MODULE__]) bucket = Keyword.get(config, :bucket) @@ -69,6 +71,18 @@ defmodule Pleroma.Uploaders.S3 do end end + @impl true + def delete_file(file) do + [__MODULE__, :bucket] + |> Config.get() + |> ExAws.S3.delete_object(file) + |> ExAws.request() + |> case do + {:ok, %{status_code: 204}} -> :ok + error -> {:error, inspect(error)} + end + end + @regex Regex.compile!("[^0-9a-zA-Z!.*/'()_-]") def strict_encode(name) do String.replace(name, @regex, "-") diff --git a/lib/pleroma/uploaders/uploader.ex b/lib/pleroma/uploaders/uploader.ex index c0b22c28a..9a94534e9 100644 --- a/lib/pleroma/uploaders/uploader.ex +++ b/lib/pleroma/uploaders/uploader.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Uploaders.Uploader do @@ -36,6 +36,8 @@ defmodule Pleroma.Uploaders.Uploader do @callback put_file(Pleroma.Upload.t()) :: :ok | {:ok, file_spec()} | {:error, String.t()} | :wait_callback + @callback delete_file(file :: String.t()) :: :ok | {:error, String.t()} + @callback http_callback(Plug.Conn.t(), Map.t()) :: {:ok, Plug.Conn.t()} | {:ok, Plug.Conn.t(), file_spec()} @@ -43,7 +45,6 @@ defmodule Pleroma.Uploaders.Uploader do @optional_callbacks http_callback: 2 @spec put_file(module(), Pleroma.Upload.t()) :: {:ok, file_spec()} | {:error, String.t()} - def put_file(uploader, upload) do case uploader.put_file(upload) do :ok -> {:ok, {:file, upload.path}} diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 2bbfaa55b..cba391072 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User do @@ -7,21 +7,31 @@ defmodule Pleroma.User do import Ecto.Changeset import Ecto.Query + import Ecto, only: [assoc: 2] - alias Comeonin.Pbkdf2 alias Ecto.Multi alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Delivery + alias Pleroma.Emoji + alias Pleroma.FollowingRelationship + alias Pleroma.Formatter + alias Pleroma.HTML alias Pleroma.Keys + alias Pleroma.MFA alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.RepoStreamer alias Pleroma.User + alias Pleroma.UserRelationship alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils @@ -32,7 +42,7 @@ defmodule Pleroma.User do require Logger @type t :: %__MODULE__{} - + @type account_status :: :active | :deactivated | :password_reset_pending | :confirmation_pending @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength @@ -41,6 +51,32 @@ defmodule Pleroma.User do @strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/ @extended_local_nickname_regex ~r/^[a-zA-Z\d_-]+$/ + # AP ID user relationships (blocks, mutes etc.) + # Format: [rel_type: [outgoing_rel: :outgoing_rel_target, incoming_rel: :incoming_rel_source]] + @user_relationships_config [ + block: [ + blocker_blocks: :blocked_users, + blockee_blocks: :blocker_users + ], + mute: [ + muter_mutes: :muted_users, + mutee_mutes: :muter_users + ], + reblog_mute: [ + reblog_muter_mutes: :reblog_muted_users, + reblog_mutee_mutes: :reblog_muter_users + ], + notification_mute: [ + notification_muter_mutes: :notification_muted_users, + notification_mutee_mutes: :notification_muter_users + ], + # Note: `inverse_subscription` relationship is inverse: subscriber acts as relationship target + inverse_subscription: [ + subscribee_subscriptions: :subscriber_users, + subscriber_subscriptions: :subscribee_users + ] + ] + schema "users" do field(:bio, :string) field(:email, :string) @@ -50,7 +86,7 @@ defmodule Pleroma.User do field(:password, :string, virtual: true) field(:password_confirmation, :string, virtual: true) field(:keys, :string) - field(:following, {:array, :string}, default: []) + field(:public_key, :string) field(:ap_id, :string) field(:avatar, :map) field(:local, :boolean, default: true) @@ -61,34 +97,210 @@ defmodule Pleroma.User do field(:tags, {:array, :string}, default: []) field(:last_refreshed_at, :naive_datetime_usec) field(:last_digest_emailed_at, :naive_datetime) + field(:banner, :map, default: %{}) + field(:background, :map, default: %{}) + field(:note_count, :integer, default: 0) + field(:follower_count, :integer, default: 0) + field(:following_count, :integer, default: 0) + field(:locked, :boolean, default: false) + field(:confirmation_pending, :boolean, default: false) + field(:password_reset_pending, :boolean, default: false) + field(:confirmation_token, :string, default: nil) + field(:default_scope, :string, default: "public") + field(:domain_blocks, {:array, :string}, default: []) + field(:deactivated, :boolean, default: false) + field(:no_rich_text, :boolean, default: false) + field(:ap_enabled, :boolean, default: false) + field(:is_moderator, :boolean, default: false) + field(:is_admin, :boolean, default: false) + field(:show_role, :boolean, default: true) + field(:settings, :map, default: nil) + field(:uri, Types.Uri, default: nil) + field(:hide_followers_count, :boolean, default: false) + field(:hide_follows_count, :boolean, default: false) + field(:hide_followers, :boolean, default: false) + field(:hide_follows, :boolean, default: false) + field(:hide_favorites, :boolean, default: true) + field(:unread_conversation_count, :integer, default: 0) + field(:pinned_activities, {:array, :string}, default: []) + field(:email_notifications, :map, default: %{"digest" => false}) + field(:mascot, :map, default: nil) + field(:emoji, :map, default: %{}) + field(:pleroma_settings_store, :map, default: %{}) + field(:fields, {:array, :map}, default: []) + field(:raw_fields, {:array, :map}, default: []) + field(:discoverable, :boolean, default: false) + field(:invisible, :boolean, default: false) + field(:allow_following_move, :boolean, default: true) + field(:skip_thread_containment, :boolean, default: false) + field(:actor_type, :string, default: "Person") + field(:also_known_as, {:array, :string}, default: []) + field(:inbox, :string) + field(:shared_inbox, :string) + + embeds_one( + :notification_settings, + Pleroma.User.NotificationSetting, + on_replace: :update + ) + has_many(:notifications, Notification) has_many(:registrations, Registration) has_many(:deliveries, Delivery) - embeds_one(:info, User.Info) + + has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id) + has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id) + + for {relationship_type, + [ + {outgoing_relation, outgoing_relation_target}, + {incoming_relation, incoming_relation_source} + ]} <- @user_relationships_config do + # Definitions of `has_many` relations: :blocker_blocks, :muter_mutes, :reblog_muter_mutes, + # :notification_muter_mutes, :subscribee_subscriptions + has_many(outgoing_relation, UserRelationship, + foreign_key: :source_id, + where: [relationship_type: relationship_type] + ) + + # Definitions of `has_many` relations: :blockee_blocks, :mutee_mutes, :reblog_mutee_mutes, + # :notification_mutee_mutes, :subscriber_subscriptions + has_many(incoming_relation, UserRelationship, + foreign_key: :target_id, + where: [relationship_type: relationship_type] + ) + + # Definitions of `has_many` relations: :blocked_users, :muted_users, :reblog_muted_users, + # :notification_muted_users, :subscriber_users + has_many(outgoing_relation_target, through: [outgoing_relation, :target]) + + # Definitions of `has_many` relations: :blocker_users, :muter_users, :reblog_muter_users, + # :notification_muter_users, :subscribee_users + has_many(incoming_relation_source, through: [incoming_relation, :source]) + end + + # `:blocks` is deprecated (replaced with `blocked_users` relation) + field(:blocks, {:array, :string}, default: []) + # `:mutes` is deprecated (replaced with `muted_users` relation) + field(:mutes, {:array, :string}, default: []) + # `:muted_reblogs` is deprecated (replaced with `reblog_muted_users` relation) + field(:muted_reblogs, {:array, :string}, default: []) + # `:muted_notifications` is deprecated (replaced with `notification_muted_users` relation) + field(:muted_notifications, {:array, :string}, default: []) + # `:subscribers` is deprecated (replaced with `subscriber_users` relation) + field(:subscribers, {:array, :string}, default: []) + + embeds_one( + :multi_factor_authentication_settings, + MFA.Settings, + on_replace: :delete + ) timestamps() end - def auth_active?(%User{info: %User.Info{confirmation_pending: true}}), - do: !Pleroma.Config.get([:instance, :account_activation_required]) + for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <- + @user_relationships_config do + # `def blocked_users_relation/2`, `def muted_users_relation/2`, + # `def reblog_muted_users_relation/2`, `def notification_muted_users/2`, + # `def subscriber_users/2` + def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do + target_users_query = assoc(user, unquote(outgoing_relation_target)) - def auth_active?(%User{}), do: true + if restrict_deactivated? do + restrict_deactivated(target_users_query) + else + target_users_query + end + end + # `def blocked_users/2`, `def muted_users/2`, `def reblog_muted_users/2`, + # `def notification_muted_users/2`, `def subscriber_users/2` + def unquote(outgoing_relation_target)(user, restrict_deactivated? \\ false) do + __MODULE__ + |> apply(unquote(:"#{outgoing_relation_target}_relation"), [ + user, + restrict_deactivated? + ]) + |> Repo.all() + end + + # `def blocked_users_ap_ids/2`, `def muted_users_ap_ids/2`, `def reblog_muted_users_ap_ids/2`, + # `def notification_muted_users_ap_ids/2`, `def subscriber_users_ap_ids/2` + def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \\ false) do + __MODULE__ + |> apply(unquote(:"#{outgoing_relation_target}_relation"), [ + user, + restrict_deactivated? + ]) + |> select([u], u.ap_id) + |> Repo.all() + end + end + + @doc """ + Dumps Flake Id to SQL-compatible format (16-byte UUID). + E.g. "9pQtDGXuq4p3VlcJEm" -> <<0, 0, 1, 110, 179, 218, 42, 92, 213, 41, 44, 227, 95, 213, 0, 0>> + """ + def binary_id(source_id) when is_binary(source_id) do + with {:ok, dumped_id} <- FlakeId.Ecto.CompatType.dump(source_id) do + dumped_id + else + _ -> source_id + end + end + + def binary_id(source_ids) when is_list(source_ids) do + Enum.map(source_ids, &binary_id/1) + end + + def binary_id(%User{} = user), do: binary_id(user.id) + + @doc "Returns status account" + @spec account_status(User.t()) :: account_status() + def account_status(%User{deactivated: true}), do: :deactivated + def account_status(%User{password_reset_pending: true}), do: :password_reset_pending + + def account_status(%User{confirmation_pending: true}) do + case Config.get([:instance, :account_activation_required]) do + true -> :confirmation_pending + _ -> :active + end + end + + def account_status(%User{}), do: :active + + @spec visible_for?(User.t(), User.t() | nil) :: boolean() def visible_for?(user, for_user \\ nil) - def visible_for?(%User{id: user_id}, %User{id: for_id}) when user_id == for_id, do: true + def visible_for?(%User{invisible: true}, _), do: false + + def visible_for?(%User{id: user_id}, %User{id: user_id}), do: true + + def visible_for?(%User{local: local} = user, nil) do + cfg_key = + if local, + do: :local, + else: :remote + + if Config.get([:restrict_unauthenticated, :profiles, cfg_key]), + do: false, + else: account_status(user) == :active + end def visible_for?(%User{} = user, for_user) do - auth_active?(user) || superuser?(for_user) + account_status(user) == :active || superuser?(for_user) end def visible_for?(_, _), do: false - def superuser?(%User{local: true, info: %User.Info{is_admin: true}}), do: true - def superuser?(%User{local: true, info: %User.Info{is_moderator: true}}), do: true + @spec superuser?(User.t()) :: boolean() + def superuser?(%User{local: true, is_admin: true}), do: true + def superuser?(%User{local: true, is_moderator: true}), do: true def superuser?(_), do: false - def invisible?(%User{info: %User.Info{invisible: true}}), do: true + @spec invisible?(User.t()) :: boolean() + def invisible?(%User{invisible: true}), do: true def invisible?(_), do: false def avatar_url(user, options \\ []) do @@ -99,76 +311,35 @@ defmodule Pleroma.User do end def banner_url(user, options \\ []) do - case user.info.banner do + case user.banner do %{"url" => [%{"href" => href} | _]} -> href _ -> !options[:no_default] && "#{Web.base_url()}/images/banner.png" end end - def profile_url(%User{info: %{source_data: %{"url" => url}}}), do: url - def profile_url(%User{ap_id: ap_id}), do: ap_id - def profile_url(_), do: nil - + # Should probably be renamed or removed def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" - @spec ap_following(User.t()) :: Sring.t() + @spec ap_following(User.t()) :: String.t() def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa def ap_following(%User{} = user), do: "#{ap_id(user)}/following" - def user_info(%User{} = user, args \\ %{}) do - following_count = - Map.get(args, :following_count, user.info.following_count || following_count(user)) - - follower_count = Map.get(args, :follower_count, user.info.follower_count) - - %{ - note_count: user.info.note_count, - locked: user.info.locked, - confirmation_pending: user.info.confirmation_pending, - default_scope: user.info.default_scope - } - |> Map.put(:following_count, following_count) - |> Map.put(:follower_count, follower_count) - end - - def follow_state(%User{} = user, %User{} = target) do - case Utils.fetch_latest_follow(user, target) do - %{data: %{"state" => state}} -> state - # Ideally this would be nil, but then Cachex does not commit the value - _ -> false - end - end - - def get_cached_follow_state(user, target) do - key = "follow_state:#{user.ap_id}|#{target.ap_id}" - Cachex.fetch!(:user_cache, key, fn _ -> {:commit, follow_state(user, target)} end) - end - - @spec set_follow_state_cache(String.t(), String.t(), String.t()) :: {:ok | :error, boolean()} - def set_follow_state_cache(user_ap_id, target_ap_id, state) do - Cachex.put(:user_cache, "follow_state:#{user_ap_id}|#{target_ap_id}", state) - end - - def set_info_cache(user, args) do - Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user, args)) - end - @spec restrict_deactivated(Ecto.Query.t()) :: Ecto.Query.t() def restrict_deactivated(query) do - from(u in query, - where: not fragment("? \\? 'deactivated' AND ?->'deactivated' @> 'true'", u.info, u.info) - ) + from(u in query, where: u.deactivated != ^true) end - def following_count(%User{following: []}), do: 0 + defdelegate following_count(user), to: FollowingRelationship - def following_count(%User{} = user) do - user - |> get_friends_query() - |> Repo.aggregate(:count, :id) + defp truncate_fields_param(params) do + if Map.has_key?(params, :fields) do + Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) + else + params + end end defp truncate_if_exists(params, key, max_length) do @@ -180,36 +351,71 @@ defmodule Pleroma.User do end end - def remote_user_creation(params) do + defp fix_follower_address(%{follower_address: _, following_address: _} = params), do: params + + defp fix_follower_address(%{nickname: nickname} = params), + do: Map.put(params, :follower_address, ap_followers(%User{nickname: nickname})) + + defp fix_follower_address(params), do: params + + def remote_user_changeset(struct \\ %User{local: false}, params) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + name = + case params[:name] do + name when is_binary(name) and byte_size(name) > 0 -> name + _ -> params[:nickname] + end + params = params - |> Map.put(:info, params[:info] || %{}) + |> Map.put(:name, name) + |> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now()) |> truncate_if_exists(:name, name_limit) |> truncate_if_exists(:bio, bio_limit) + |> truncate_fields_param() + |> fix_follower_address() - changeset = - %User{local: false} - |> cast(params, [:bio, :name, :ap_id, :nickname, :avatar]) - |> validate_required([:name, :ap_id]) - |> unique_constraint(:nickname) - |> validate_format(:nickname, @email_regex) - |> validate_length(:bio, max: bio_limit) - |> validate_length(:name, max: name_limit) - |> change_info(&User.Info.remote_user_creation(&1, params[:info])) - - case params[:info][:source_data] do - %{"followers" => followers, "following" => following} -> - changeset - |> put_change(:follower_address, followers) - |> put_change(:following_address, following) - - _ -> - followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) - put_change(changeset, :follower_address, followers) - end + struct + |> cast( + params, + [ + :bio, + :name, + :emoji, + :ap_id, + :inbox, + :shared_inbox, + :nickname, + :public_key, + :avatar, + :ap_enabled, + :banner, + :locked, + :last_refreshed_at, + :uri, + :follower_address, + :following_address, + :hide_followers, + :hide_follows, + :hide_followers_count, + :hide_follows_count, + :follower_count, + :fields, + :following_count, + :discoverable, + :invisible, + :actor_type, + :also_known_as + ] + ) + |> validate_required([:name, :ap_id]) + |> unique_constraint(:nickname) + |> validate_format(:nickname, @email_regex) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, max: name_limit) + |> validate_fields(true) end def update_changeset(struct, params \\ %{}) do @@ -217,33 +423,128 @@ defmodule Pleroma.User do name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) struct - |> cast(params, [:bio, :name, :avatar, :following]) + |> cast( + params, + [ + :bio, + :name, + :emoji, + :avatar, + :public_key, + :inbox, + :shared_inbox, + :locked, + :no_rich_text, + :default_scope, + :banner, + :hide_follows, + :hide_followers, + :hide_followers_count, + :hide_follows_count, + :hide_favorites, + :allow_following_move, + :background, + :show_role, + :skip_thread_containment, + :fields, + :raw_fields, + :pleroma_settings_store, + :discoverable, + :actor_type, + :also_known_as + ] + ) |> unique_constraint(:nickname) |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) + |> put_fields() + |> put_emoji() + |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) + |> put_change_if_present(:avatar, &put_upload(&1, :avatar)) + |> put_change_if_present(:banner, &put_upload(&1, :banner)) + |> put_change_if_present(:background, &put_upload(&1, :background)) + |> put_change_if_present( + :pleroma_settings_store, + &{:ok, Map.merge(struct.pleroma_settings_store, &1)} + ) + |> validate_fields(false) end - def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do - bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) - name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + defp put_fields(changeset) do + if raw_fields = get_change(changeset, :raw_fields) do + raw_fields = + raw_fields + |> Enum.filter(fn %{"name" => n} -> n != "" end) - params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now()) + fields = + raw_fields + |> Enum.map(fn f -> Map.update!(f, "value", &parse_fields(&1)) end) + changeset + |> put_change(:raw_fields, raw_fields) + |> put_change(:fields, fields) + else + changeset + end + end + + defp parse_fields(value) do + value + |> Formatter.linkify(mentions_format: :full) + |> elem(0) + end + + defp put_emoji(changeset) do + bio = get_change(changeset, :bio) + name = get_change(changeset, :name) + + if bio || name do + emoji = Map.merge(Emoji.Formatter.get_emoji_map(bio), Emoji.Formatter.get_emoji_map(name)) + put_change(changeset, :emoji, emoji) + else + changeset + end + end + + defp put_change_if_present(changeset, map_field, value_function) do + if value = get_change(changeset, map_field) do + with {:ok, new_value} <- value_function.(value) do + put_change(changeset, map_field, new_value) + else + _ -> changeset + end + else + changeset + end + end + + defp put_upload(value, type) do + with %Plug.Upload{} <- value, + {:ok, object} <- ActivityPub.upload(value, type: type) do + {:ok, object.data} + end + end + + def update_as_admin_changeset(struct, params) do struct - |> cast(params, [ - :bio, - :name, - :follower_address, - :following_address, - :avatar, - :last_refreshed_at - ]) - |> unique_constraint(:nickname) - |> validate_format(:nickname, local_nickname_regex()) - |> validate_length(:bio, max: bio_limit) - |> validate_length(:name, max: name_limit) - |> change_info(&User.Info.user_upgrade(&1, params[:info], remote?)) + |> update_changeset(params) + |> cast(params, [:email]) + |> delete_change(:also_known_as) + |> unique_constraint(:email) + |> validate_format(:email, @email_regex) + end + + @spec update_as_admin(%User{}, map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_as_admin(user, params) do + params = Map.put(params, "password_confirmation", params["password"]) + changeset = update_as_admin_changeset(user, params) + + if params["password"] do + reset_password(user, changeset, params) + else + User.update_and_set_cache(changeset) + end end def password_update_changeset(struct, params) do @@ -251,15 +552,19 @@ defmodule Pleroma.User do |> cast(params, [:password, :password_confirmation]) |> validate_required([:password, :password_confirmation]) |> validate_confirmation(:password) - |> put_password_hash - |> put_embed(:info, User.Info.set_password_reset_pending(struct.info, false)) + |> put_password_hash() + |> put_change(:password_reset_pending, false) end @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} - def reset_password(%User{id: user_id} = user, data) do + def reset_password(%User{} = user, params) do + reset_password(user, user, params) + end + + def reset_password(%User{id: user_id} = user, struct, params) do multi = Multi.new() - |> Multi.update(:user, password_update_changeset(user, data)) + |> Multi.update(:user, password_update_changeset(struct, params)) |> Multi.delete_all(:tokens, OAuth.Token.Query.get_by_user(user_id)) |> Multi.delete_all(:auth, OAuth.Authorization.delete_by_user_query(user)) @@ -269,19 +574,19 @@ defmodule Pleroma.User do end end + def update_password_reset_pending(user, value) do + user + |> change() + |> put_change(:password_reset_pending, value) + |> update_and_set_cache() + end + def force_password_reset_async(user) do BackgroundWorker.enqueue("force_password_reset", %{"user_id" => user.id}) end @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} - def force_password_reset(user) do - info_cng = User.Info.set_password_reset_pending(user.info, true) - - user - |> change() - |> put_embed(:info, info_cng) - |> update_and_set_cache() - end + def force_password_reset(user), do: update_password_reset_pending(user, true) def register_changeset(struct, params \\ %{}, opts \\ []) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) @@ -295,7 +600,8 @@ defmodule Pleroma.User do end struct - |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) + |> confirmation_changeset(need_confirmation: need_confirmation?) + |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation, :emoji]) |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) |> unique_constraint(:email) @@ -305,7 +611,6 @@ defmodule Pleroma.User do |> validate_format(:email, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) - |> change_info(&User.Info.confirmation_changeset(&1, need_confirmation: need_confirmation?)) |> maybe_validate_required_email(opts[:external]) |> put_password_hash |> put_ap_id() @@ -314,7 +619,14 @@ defmodule Pleroma.User do end def maybe_validate_required_email(changeset, true), do: changeset - def maybe_validate_required_email(changeset, _), do: validate_required(changeset, [:email]) + + def maybe_validate_required_email(changeset, _) do + if Pleroma.Config.get([:instance, :account_activation_required]) do + validate_required(changeset, [:email]) + else + changeset + end + end defp put_ap_id(changeset) do ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)}) @@ -325,7 +637,6 @@ defmodule Pleroma.User do followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) changeset - |> put_change(:following, [followers]) |> put_change(:follower_address, followers) end @@ -356,7 +667,7 @@ defmodule Pleroma.User do end def try_send_confirmation_email(%User{} = user) do - if user.info.confirmation_pending && + if user.confirmation_pending && Pleroma.Config.get([:instance, :account_activation_required]) do user |> Pleroma.Emails.UserEmail.account_confirmation_email() @@ -368,6 +679,10 @@ defmodule Pleroma.User do end end + def try_send_confirmation_email(users) do + Enum.each(users, &try_send_confirmation_email/1) + end + def needs_update?(%User{local: true}), do: false def needs_update?(%User{local: false, last_refreshed_at: nil}), do: true @@ -379,8 +694,10 @@ defmodule Pleroma.User do def needs_update?(_), do: true @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()} - def maybe_direct_follow(%User{} = follower, %User{local: true, info: %{locked: true}}) do - {:ok, follower} + + # "Locked" (self-locked) users demand explicit authorization of follow requests + def maybe_direct_follow(%User{} = follower, %User{local: true, locked: true} = followed) do + follow(follower, followed, :follow_pending) end def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do @@ -398,95 +715,91 @@ defmodule Pleroma.User do @doc "A mass follow for local users. Respects blocks in both directions but does not create activities." @spec follow_all(User.t(), list(User.t())) :: {atom(), User.t()} def follow_all(follower, followeds) do - followed_addresses = - followeds - |> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end) - |> Enum.map(fn %{follower_address: fa} -> fa end) - - q = - from(u in User, - where: u.id == ^follower.id, - update: [ - set: [ - following: - fragment( - "array(select distinct unnest (array_cat(?, ?)))", - u.following, - ^followed_addresses - ) - ] - ], - select: u - ) - - {1, [follower]} = Repo.update_all(q, []) - - Enum.each(followeds, &update_follower_count/1) + followeds + |> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end) + |> Enum.each(&follow(follower, &1, :follow_accept)) set_cache(follower) end - def follow(%User{} = follower, %User{info: info} = followed) do + defdelegate following(user), to: FollowingRelationship + + def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) - ap_followers = followed.follower_address cond do - info.deactivated -> - {:error, "Could not follow user: You are deactivated."} + followed.deactivated -> + {:error, "Could not follow user: #{followed.nickname} is deactivated."} deny_follow_blocked and blocks?(followed, follower) -> {:error, "Could not follow user: #{followed.nickname} blocked you."} true -> - q = - from(u in User, - where: u.id == ^follower.id, - update: [push: [following: ^ap_followers]], - select: u - ) - - {1, [follower]} = Repo.update_all(q, []) - - follower = maybe_update_following_count(follower) + FollowingRelationship.follow(follower, followed, state) {:ok, _} = update_follower_count(followed) - set_cache(follower) + follower + |> update_following_count() + |> set_cache() end end + def unfollow(%User{ap_id: ap_id}, %User{ap_id: ap_id}) do + {:error, "Not subscribed!"} + end + def unfollow(%User{} = follower, %User{} = followed) do - ap_followers = followed.follower_address - - if following?(follower, followed) and follower.ap_id != followed.ap_id do - q = - from(u in User, - where: u.id == ^follower.id, - update: [pull: [following: ^ap_followers]], - select: u - ) + case get_follow_state(follower, followed) do + state when state in [:follow_pending, :follow_accept] -> + FollowingRelationship.unfollow(follower, followed) + {:ok, followed} = update_follower_count(followed) - {1, [follower]} = Repo.update_all(q, []) + {:ok, follower} = + follower + |> update_following_count() + |> set_cache() - follower = maybe_update_following_count(follower) + {:ok, follower, Utils.fetch_latest_follow(follower, followed)} - {:ok, followed} = update_follower_count(followed) + nil -> + {:error, "Not subscribed!"} + end + end - set_cache(follower) + defdelegate following?(follower, followed), to: FollowingRelationship - {:ok, follower, Utils.fetch_latest_follow(follower, followed)} - else - {:error, "Not subscribed!"} - end + @doc "Returns follow state as Pleroma.FollowingRelationship.State value" + def get_follow_state(%User{} = follower, %User{} = following) do + following_relationship = FollowingRelationship.get(follower, following) + get_follow_state(follower, following, following_relationship) end - @spec following?(User.t(), User.t()) :: boolean - def following?(%User{} = follower, %User{} = followed) do - Enum.member?(follower.following, followed.follower_address) + def get_follow_state( + %User{} = follower, + %User{} = following, + following_relationship + ) do + case {following_relationship, following.local} do + {nil, false} -> + case Utils.fetch_latest_follow(follower, following) do + %Activity{data: %{"state" => state}} when state in ["pending", "accept"] -> + FollowingRelationship.state_to_enum(state) + + _ -> + nil + end + + {%{state: state}, _} -> + state + + {nil, _} -> + nil + end end def locked?(%User{} = user) do - user.info.locked || false + user.locked || false end def get_by_id(id) do @@ -525,25 +838,52 @@ defmodule Pleroma.User do def set_cache(%User{} = user) do Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) Cachex.put(:user_cache, "nickname:#{user.nickname}", user) - Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user)) + Cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user)) {:ok, user} end + def update_and_set_cache(struct, params) do + struct + |> update_changeset(params) + |> update_and_set_cache() + end + def update_and_set_cache(changeset) do with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do set_cache(user) end end + def get_user_friends_ap_ids(user) do + from(u in User.get_friends_query(user), select: u.ap_id) + |> Repo.all() + end + + @spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()] + def get_cached_user_friends_ap_ids(user) do + Cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ -> + get_user_friends_ap_ids(user) + end) + end + def invalidate_cache(user) do Cachex.del(:user_cache, "ap_id:#{user.ap_id}") Cachex.del(:user_cache, "nickname:#{user.nickname}") - Cachex.del(:user_cache, "user_info:#{user.id}") + Cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}") end + @spec get_cached_by_ap_id(String.t()) :: User.t() | nil def get_cached_by_ap_id(ap_id) do key = "ap_id:#{ap_id}" - Cachex.fetch!(:user_cache, key, fn _ -> get_by_ap_id(ap_id) end) + + with {:ok, nil} <- Cachex.get(:user_cache, key), + user when not is_nil(user) <- get_by_ap_id(ap_id), + {:ok, true} <- Cachex.put(:user_cache, key, user) do + user + else + {:ok, user} -> user + nil -> nil + end end def get_cached_by_id(id) do @@ -593,6 +933,7 @@ defmodule Pleroma.User do end end + @spec get_by_nickname(String.t()) :: User.t() | nil def get_by_nickname(nickname) do Repo.get_by(User, nickname: nickname) || if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do @@ -606,11 +947,6 @@ defmodule Pleroma.User do get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email) end - def get_cached_user_info(user) do - key = "user_info:#{user.id}" - Cachex.fetch!(:user_cache, key, fn -> user_info(user) end) - end - def fetch_by_nickname(nickname), do: ActivityPub.make_user_from_nickname(nickname) def get_or_fetch_by_nickname(nickname) do @@ -620,10 +956,6 @@ defmodule Pleroma.User do _e -> with [_nick, _domain] <- String.split(nickname, "@"), {:ok, user} <- fetch_by_nickname(nickname) do - if Pleroma.Config.get([:fetch_initial_posts, :enabled]) do - fetch_initial_posts(user) - end - {:ok, user} else _e -> {:error, "not found " <> nickname} @@ -631,11 +963,6 @@ defmodule Pleroma.User do end end - @doc "Fetch some posts when the user has just been federated with" - def fetch_initial_posts(user) do - BackgroundWorker.enqueue("fetch_initial_posts", %{"user_id" => user.id}) - end - @spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() def get_followers_query(%User{} = user, nil) do User.Query.build(%{followers: user, deactivated: false}) @@ -650,14 +977,14 @@ defmodule Pleroma.User do @spec get_followers_query(User.t()) :: Ecto.Query.t() def get_followers_query(user), do: get_followers_query(user, nil) - @spec get_followers(User.t(), pos_integer()) :: {:ok, list(User.t())} + @spec get_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())} def get_followers(user, page \\ nil) do user |> get_followers_query(page) |> Repo.all() end - @spec get_external_followers(User.t(), pos_integer()) :: {:ok, list(User.t())} + @spec get_external_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())} def get_external_followers(user, page \\ nil) do user |> get_followers_query(page) @@ -692,37 +1019,26 @@ defmodule Pleroma.User do |> Repo.all() end - def get_friends_ids(user, page \\ nil) do + def get_friends_ap_ids(user) do user - |> get_friends_query(page) - |> select([u], u.id) + |> get_friends_query(nil) + |> select([u], u.ap_id) |> Repo.all() end - @spec get_follow_requests(User.t()) :: {:ok, [User.t()]} - def get_follow_requests(%User{} = user) do + def get_friends_ids(user, page \\ nil) do user - |> Activity.follow_requests_for_actor() - |> join(:inner, [a], u in User, on: a.actor == u.ap_id) - |> where([a, u], not fragment("? @> ?", u.following, ^[user.follower_address])) - |> group_by([a, u], u.id) - |> select([a, u], u) + |> get_friends_query(page) + |> select([u], u.id) |> Repo.all() end + defdelegate get_follow_requests(user), to: FollowingRelationship + def increase_note_count(%User{} = user) do User |> where(id: ^user.id) - |> update([u], - set: [ - info: - fragment( - "safe_jsonb_set(?, '{note_count}', ((?->>'note_count')::int + 1)::varchar::jsonb, true)", - u.info, - u.info - ) - ] - ) + |> update([u], inc: [note_count: 1]) |> select([u], u) |> Repo.update_all([]) |> case do @@ -736,12 +1052,7 @@ defmodule Pleroma.User do |> where(id: ^user.id) |> update([u], set: [ - info: - fragment( - "safe_jsonb_set(?, '{note_count}', (greatest(0, (?->>'note_count')::int - 1))::varchar::jsonb, true)", - u.info, - u.info - ) + note_count: fragment("greatest(0, note_count - 1)") ] ) |> select([u], u) @@ -752,28 +1063,18 @@ defmodule Pleroma.User do end end - def update_note_count(%User{} = user) do + def update_note_count(%User{} = user, note_count \\ nil) do note_count = - from( - a in Object, - where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data), - select: count(a.id) - ) - |> Repo.one() - - update_info(user, &User.Info.set_note_count(&1, note_count)) - end - - def update_mascot(user, url) do - info_changeset = - User.Info.mascot_update( - user.info, - url - ) + note_count || + from( + a in Object, + where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data), + select: count(a.id) + ) + |> Repo.one() user - |> change() - |> put_embed(:info, info_changeset) + |> cast(%{note_count: note_count}, [:note_count]) |> update_and_set_cache() end @@ -791,10 +1092,24 @@ defmodule Pleroma.User do def fetch_follow_information(user) do with {:ok, info} <- ActivityPub.fetch_follow_information_for_user(user) do - update_info(user, &User.Info.follow_information_update(&1, info)) + user + |> follow_information_changeset(info) + |> update_and_set_cache() end end + defp follow_information_changeset(user, params) do + user + |> cast(params, [ + :hide_followers, + :hide_follows, + :follower_count, + :following_count, + :hide_followers_count, + :hide_follows_count + ]) + end + def update_follower_count(%User{} = user) do if user.local or !Pleroma.Config.get([:instance, :external_user_synchronization]) do follower_count_query = @@ -805,14 +1120,7 @@ defmodule Pleroma.User do |> where(id: ^user.id) |> join(:inner, [u], s in subquery(follower_count_query)) |> update([u, s], - set: [ - info: - fragment( - "safe_jsonb_set(?, '{follower_count}', ?::varchar::jsonb, true)", - u.info, - s.count - ) - ] + set: [follower_count: s.count] ) |> select([u], u) |> Repo.update_all([]) @@ -825,8 +1133,8 @@ defmodule Pleroma.User do end end - @spec maybe_update_following_count(User.t()) :: User.t() - def maybe_update_following_count(%User{local: false} = user) do + @spec update_following_count(User.t()) :: User.t() + def update_following_count(%User{local: false} = user) do if Pleroma.Config.get([:instance, :external_user_synchronization]) do maybe_fetch_follow_information(user) else @@ -834,7 +1142,13 @@ defmodule Pleroma.User do end end - def maybe_update_following_count(user), do: user + def update_following_count(%User{local: true} = user) do + following_count = FollowingRelationship.following_count(user) + + user + |> follow_information_changeset(%{following_count: following_count}) + |> Repo.update!() + end def set_unread_conversation_count(%User{local: true} = user) do unread_query = Participation.unread_conversation_count_for_user(user) @@ -842,14 +1156,7 @@ defmodule Pleroma.User do User |> join(:inner, [u], p in subquery(unread_query)) |> update([u, p], - set: [ - info: - fragment( - "jsonb_set(?, '{unread_conversation_count}', ?::varchar::jsonb, true)", - u.info, - p.count - ) - ] + set: [unread_conversation_count: p.count] ) |> where([u], u.id == ^user.id) |> select([u], u) @@ -860,7 +1167,7 @@ defmodule Pleroma.User do end end - def set_unread_conversation_count(_), do: :noop + def set_unread_conversation_count(user), do: {:ok, user} def increment_unread_conversation_count(conversation, %User{local: true} = user) do unread_query = @@ -870,14 +1177,7 @@ defmodule Pleroma.User do User |> join(:inner, [u], p in subquery(unread_query)) |> update([u, p], - set: [ - info: - fragment( - "jsonb_set(?, '{unread_conversation_count}', (coalesce((?->>'unread_conversation_count')::int, 0) + 1)::varchar::jsonb, true)", - u.info, - u.info - ) - ] + inc: [unread_conversation_count: 1] ) |> where([u], u.id == ^user.id) |> where([u, p], p.count == 0) @@ -889,19 +1189,7 @@ defmodule Pleroma.User do end end - def increment_unread_conversation_count(_, _), do: :noop - - def remove_duplicated_following(%User{following: following} = user) do - uniq_following = Enum.uniq(following) - - if length(following) == length(uniq_following) do - {:ok, user} - else - user - |> update_changeset(%{following: uniq_following}) - |> update_and_set_cache() - end - end + def increment_unread_conversation_count(_, user), do: {:ok, user} @spec get_users_from_set([String.t()], boolean()) :: [User.t()] def get_users_from_set(ap_ids, local_only \\ true) do @@ -913,39 +1201,52 @@ defmodule Pleroma.User do end @spec get_recipients_from_activity(Activity.t()) :: [User.t()] - def get_recipients_from_activity(%Activity{recipients: to}) do + def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do + to = [actor | to] + User.Query.build(%{recipients_from_activity: to, local: true, deactivated: false}) |> Repo.all() end - @spec mute(User.t(), User.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()} - def mute(muter, %User{ap_id: ap_id}, notifications? \\ true) do - update_info(muter, &User.Info.add_to_mutes(&1, ap_id, notifications?)) + @spec mute(User.t(), User.t(), boolean()) :: + {:ok, list(UserRelationship.t())} | {:error, String.t()} + def mute(%User{} = muter, %User{} = mutee, notifications? \\ true) do + add_to_mutes(muter, mutee, notifications?) end - def unmute(muter, %{ap_id: ap_id}) do - update_info(muter, &User.Info.remove_from_mutes(&1, ap_id)) + def unmute(%User{} = muter, %User{} = mutee) do + remove_from_mutes(muter, mutee) end - def subscribe(subscriber, %{ap_id: ap_id}) do - with %User{} = subscribed <- get_cached_by_ap_id(ap_id) do - deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) + def subscribe(%User{} = subscriber, %User{} = target) do + deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) - if blocks?(subscribed, subscriber) and deny_follow_blocked do - {:error, "Could not subscribe: #{subscribed.nickname} is blocking you"} - else - update_info(subscribed, &User.Info.add_to_subscribers(&1, subscriber.ap_id)) - end + if blocks?(target, subscriber) and deny_follow_blocked do + {:error, "Could not subscribe: #{target.nickname} is blocking you"} + else + # Note: the relationship is inverse: subscriber acts as relationship target + UserRelationship.create_inverse_subscription(target, subscriber) end end - def unsubscribe(unsubscriber, %{ap_id: ap_id}) do + def subscribe(%User{} = subscriber, %{ap_id: ap_id}) do + with %User{} = subscribee <- get_cached_by_ap_id(ap_id) do + subscribe(subscriber, subscribee) + end + end + + def unsubscribe(%User{} = unsubscriber, %User{} = target) do + # Note: the relationship is inverse: subscriber acts as relationship target + UserRelationship.delete_inverse_subscription(target, unsubscriber) + end + + def unsubscribe(%User{} = unsubscriber, %{ap_id: ap_id}) do with %User{} = user <- get_cached_by_ap_id(ap_id) do - update_info(user, &User.Info.remove_from_subscribers(&1, unsubscriber.ap_id)) + unsubscribe(unsubscriber, user) end end - def block(blocker, %User{ap_id: ap_id} = blocked) do + def block(%User{} = blocker, %User{} = blocked) do # sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213) blocker = if following?(blocker, blocked) do @@ -962,90 +1263,125 @@ defmodule Pleroma.User do nil -> blocked end - blocker = - if subscribed_to?(blocked, blocker) do - {:ok, blocker} = unsubscribe(blocked, blocker) - blocker - else - blocker - end + unsubscribe(blocked, blocker) if following?(blocked, blocker), do: unfollow(blocked, blocker) {:ok, blocker} = update_follower_count(blocker) - - update_info(blocker, &User.Info.add_to_block(&1, ap_id)) + {:ok, blocker, _} = Participation.mark_all_as_read(blocker, blocked) + add_to_block(blocker, blocked) end # helper to handle the block given only an actor's AP id - def block(blocker, %{ap_id: ap_id}) do + def block(%User{} = blocker, %{ap_id: ap_id}) do block(blocker, get_cached_by_ap_id(ap_id)) end - def unblock(blocker, %{ap_id: ap_id}) do - update_info(blocker, &User.Info.remove_from_block(&1, ap_id)) + def unblock(%User{} = blocker, %User{} = blocked) do + remove_from_block(blocker, blocked) + end + + # helper to handle the block given only an actor's AP id + def unblock(%User{} = blocker, %{ap_id: ap_id}) do + unblock(blocker, get_cached_by_ap_id(ap_id)) end def mutes?(nil, _), do: false - def mutes?(user, %{ap_id: ap_id}), do: Enum.member?(user.info.mutes, ap_id) + def mutes?(%User{} = user, %User{} = target), do: mutes_user?(user, target) + + def mutes_user?(%User{} = user, %User{} = target) do + UserRelationship.mute_exists?(user, target) + end @spec muted_notifications?(User.t() | nil, User.t() | map()) :: boolean() def muted_notifications?(nil, _), do: false - def muted_notifications?(user, %{ap_id: ap_id}), - do: Enum.member?(user.info.muted_notifications, ap_id) + def muted_notifications?(%User{} = user, %User{} = target), + do: UserRelationship.notification_mute_exists?(user, target) + + def blocks?(nil, _), do: false def blocks?(%User{} = user, %User{} = target) do - blocks_ap_id?(user, target) || blocks_domain?(user, target) + blocks_user?(user, target) || + (blocks_domain?(user, target) and not User.following?(user, target)) end - def blocks?(nil, _), do: false - - def blocks_ap_id?(%User{} = user, %User{} = target) do - Enum.member?(user.info.blocks, target.ap_id) + def blocks_user?(%User{} = user, %User{} = target) do + UserRelationship.block_exists?(user, target) end - def blocks_ap_id?(_, _), do: false + def blocks_user?(_, _), do: false def blocks_domain?(%User{} = user, %User{} = target) do - domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.info.domain_blocks) + domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) %{host: host} = URI.parse(target.ap_id) Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host) end def blocks_domain?(_, _), do: false - def subscribed_to?(user, %{ap_id: ap_id}) do + def subscribed_to?(%User{} = user, %User{} = target) do + # Note: the relationship is inverse: subscriber acts as relationship target + UserRelationship.inverse_subscription_exists?(target, user) + end + + def subscribed_to?(%User{} = user, %{ap_id: ap_id}) do with %User{} = target <- get_cached_by_ap_id(ap_id) do - Enum.member?(target.info.subscribers, user.ap_id) + subscribed_to?(user, target) end end - @spec muted_users(User.t()) :: [User.t()] - def muted_users(user) do - User.Query.build(%{ap_id: user.info.mutes, deactivated: false}) - |> Repo.all() - end + @doc """ + Returns map of outgoing (blocked, muted etc.) relationships' user AP IDs by relation type. + E.g. `outgoing_relationships_ap_ids(user, [:block])` -> `%{block: ["https://some.site/users/userapid"]}` + """ + @spec outgoing_relationships_ap_ids(User.t(), list(atom())) :: %{atom() => list(String.t())} + def outgoing_relationships_ap_ids(_user, []), do: %{} - @spec blocked_users(User.t()) :: [User.t()] - def blocked_users(user) do - User.Query.build(%{ap_id: user.info.blocks, deactivated: false}) - |> Repo.all() + def outgoing_relationships_ap_ids(nil, _relationship_types), do: %{} + + def outgoing_relationships_ap_ids(%User{} = user, relationship_types) + when is_list(relationship_types) do + db_result = + user + |> assoc(:outgoing_relationships) + |> join(:inner, [user_rel], u in assoc(user_rel, :target)) + |> where([user_rel, u], user_rel.relationship_type in ^relationship_types) + |> select([user_rel, u], [user_rel.relationship_type, fragment("array_agg(?)", u.ap_id)]) + |> group_by([user_rel, u], user_rel.relationship_type) + |> Repo.all() + |> Enum.into(%{}, fn [k, v] -> {k, v} end) + + Enum.into( + relationship_types, + %{}, + fn rel_type -> {rel_type, db_result[rel_type] || []} end + ) end - @spec subscribers(User.t()) :: [User.t()] - def subscribers(user) do - User.Query.build(%{ap_id: user.info.subscribers, deactivated: false}) + def incoming_relationships_ungrouped_ap_ids(user, relationship_types, ap_ids \\ nil) + + def incoming_relationships_ungrouped_ap_ids(_user, [], _ap_ids), do: [] + + def incoming_relationships_ungrouped_ap_ids(nil, _relationship_types, _ap_ids), do: [] + + def incoming_relationships_ungrouped_ap_ids(%User{} = user, relationship_types, ap_ids) + when is_list(relationship_types) do + user + |> assoc(:incoming_relationships) + |> join(:inner, [user_rel], u in assoc(user_rel, :source)) + |> where([user_rel, u], user_rel.relationship_type in ^relationship_types) + |> maybe_filter_on_ap_id(ap_ids) + |> select([user_rel, u], u.ap_id) + |> distinct(true) |> Repo.all() end - def block_domain(user, domain) do - update_info(user, &User.Info.add_to_domain_block(&1, domain)) + defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do + where(query, [user_rel, u], u.ap_id in ^ap_ids) end - def unblock_domain(user, domain) do - update_info(user, &User.Info.remove_from_domain_block(&1, domain)) - end + defp maybe_filter_on_ap_id(query, _ap_ids), do: query def deactivate_async(user, status \\ true) do BackgroundWorker.enqueue("deactivate_user", %{"user_id" => user.id, "status" => status}) @@ -1060,16 +1396,30 @@ defmodule Pleroma.User do end def deactivate(%User{} = user, status) do - with {:ok, user} <- update_info(user, &User.Info.set_activation_status(&1, status)) do - Enum.each(get_followers(user), &invalidate_cache/1) - Enum.each(get_friends(user), &update_follower_count/1) + with {:ok, user} <- set_activation_status(user, status) do + user + |> get_followers() + |> Enum.filter(& &1.local) + |> Enum.each(fn follower -> + follower |> update_following_count() |> set_cache() + end) + + # Only update local user counts, remote will be update during the next pull. + user + |> get_friends() + |> Enum.filter(& &1.local) + |> Enum.each(&update_follower_count/1) {:ok, user} end end - def update_notification_settings(%User{} = user, settings \\ %{}) do - update_info(user, &User.Info.update_notification_settings(&1, settings)) + def update_notification_settings(%User{} = user, settings) do + user + |> cast(%{notification_settings: settings}, []) + |> cast_embed(:notification_settings) + |> validate_required([:notification_settings]) + |> update_and_set_cache() end def delete(users) when is_list(users) do @@ -1084,8 +1434,6 @@ defmodule Pleroma.User do @spec perform(atom(), User.t()) :: {:ok, User.t()} def perform(:delete, %User{} = user) do - {:ok, _user} = ActivityPub.delete(user) - # Remove all relationships user |> get_followers() @@ -1102,19 +1450,15 @@ defmodule Pleroma.User do end) delete_user_activities(user) - invalidate_cache(user) - Repo.delete(user) - end - - @spec perform(atom(), User.t()) :: {:ok, User.t()} - def perform(:fetch_initial_posts, %User{} = user) do - pages = Pleroma.Config.get!([:fetch_initial_posts, :pages]) - # Insert all the posts in reverse order, so they're in the right order on the timeline - user.info.source_data["outbox"] - |> Utils.fetch_ordered_collection(pages) - |> Enum.reverse() - |> Enum.each(&Pleroma.Web.Federator.incoming_ap_doc/1) + if user.local do + user + |> change(%{deactivated: true, email: nil}) + |> update_and_set_cache() + else + invalidate_cache(user) + Repo.delete(user) + end end def perform(:deactivate_async, user, status), do: deactivate(user, status) @@ -1126,7 +1470,7 @@ defmodule Pleroma.User do blocked_identifiers, fn blocked_identifier -> with {:ok, %User{} = blocked} <- get_or_fetch(blocked_identifier), - {:ok, blocker} <- block(blocker, blocked), + {:ok, _user_block} <- block(blocker, blocked), {:ok, _} <- ActivityPub.block(blocker, blocked) do blocked else @@ -1138,7 +1482,6 @@ defmodule Pleroma.User do ) end - @spec perform(atom(), User.t(), list()) :: list() | {:error, any()} def perform(:follow_import, %User{} = follower, followed_identifiers) when is_list(followed_identifiers) do Enum.map( @@ -1170,7 +1513,7 @@ defmodule Pleroma.User do def external_users(opts \\ []) do query = external_users_query() - |> select([u], struct(u, [:id, :ap_id, :info])) + |> select([u], struct(u, [:id, :ap_id])) query = if opts[:max_id], @@ -1200,39 +1543,44 @@ defmodule Pleroma.User do }) end - def delete_user_activities(%User{ap_id: ap_id}) do + def delete_user_activities(%User{ap_id: ap_id} = user) do ap_id |> Activity.Queries.by_actor() |> RepoStreamer.chunk_stream(50) - |> Stream.each(fn activities -> Enum.each(activities, &delete_activity/1) end) + |> Stream.each(fn activities -> + Enum.each(activities, fn activity -> delete_activity(activity, user) end) + end) |> Stream.run() end - defp delete_activity(%{data: %{"type" => "Create"}} = activity) do - activity - |> Object.normalize() - |> ActivityPub.delete() - end - - defp delete_activity(%{data: %{"type" => "Like"}} = activity) do - object = Object.normalize(activity) + defp delete_activity(%{data: %{"type" => "Create", "object" => object}} = activity, user) do + with {_, %Object{}} <- {:find_object, Object.get_by_ap_id(object)}, + {:ok, delete_data, _} <- Builder.delete(user, object) do + Pipeline.common_pipeline(delete_data, local: user.local) + else + {:find_object, nil} -> + # 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(user.ap_id, object), + {:ok, _tombstone} <- Object.create(tombstone_data) do + delete_activity(activity, user) + end - activity.actor - |> get_cached_by_ap_id() - |> ActivityPub.unlike(object) + e -> + Logger.error("Could not delete #{object} created by #{activity.data["ap_id"]}") + Logger.error("Error: #{inspect(e)}") + end end - defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do - object = Object.normalize(activity) - - activity.actor - |> get_cached_by_ap_id() - |> ActivityPub.unannounce(object) + defp delete_activity(%{data: %{"type" => type}} = activity, user) + when type in ["Like", "Announce"] do + {:ok, undo, _} = Builder.undo(user, activity) + Pipeline.common_pipeline(undo, local: user.local) end - defp delete_activity(_activity), do: "Doing nothing" + defp delete_activity(_activity, _user), do: "Doing nothing" - def html_filter_policy(%User{info: %{no_rich_text: true}}) do + def html_filter_policy(%User{no_rich_text: true}) do Pleroma.HTML.Scrubber.TwitterText end @@ -1246,44 +1594,58 @@ defmodule Pleroma.User do if !is_nil(user) and !needs_update?(user) do {:ok, user} else - # Whether to fetch initial posts for the user (if it's a new user & the fetching is enabled) - should_fetch_initial = is_nil(user) and Pleroma.Config.get([:fetch_initial_posts, :enabled]) + fetch_by_ap_id(ap_id) + end + end - resp = fetch_by_ap_id(ap_id) + @doc """ + Creates an internal service actor by URI if missing. + Optionally takes nickname for addressing. + """ + @spec get_or_create_service_actor_by_ap_id(String.t(), String.t()) :: User.t() | nil + def get_or_create_service_actor_by_ap_id(uri, nickname) do + {_, user} = + case get_cached_by_ap_id(uri) do + nil -> + with {:error, %{errors: errors}} <- create_service_actor(uri, nickname) do + Logger.error("Cannot create service actor: #{uri}/.\n#{inspect(errors)}") + {:error, nil} + end - if should_fetch_initial do - with {:ok, %User{} = user} <- resp do - fetch_initial_posts(user) - end + %User{invisible: false} = user -> + set_invisible(user) + + user -> + {:ok, user} end - resp - end + user end - @doc "Creates an internal service actor by URI if missing. Optionally takes nickname for addressing." - def get_or_create_service_actor_by_ap_id(uri, nickname \\ nil) do - with %User{} = user <- get_cached_by_ap_id(uri) do - user - else - _ -> - {:ok, user} = - %User{info: %User.Info{}} - |> cast(%{}, [:ap_id, :nickname, :local]) - |> put_change(:ap_id, uri) - |> put_change(:nickname, nickname) - |> put_change(:local, true) - |> put_change(:follower_address, uri <> "/followers") - |> Repo.insert() + @spec set_invisible(User.t()) :: {:ok, User.t()} + defp set_invisible(user) do + user + |> change(%{invisible: true}) + |> update_and_set_cache() + end - user - end + @spec create_service_actor(String.t(), String.t()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + defp create_service_actor(uri, nickname) do + %User{ + invisible: true, + local: true, + ap_id: uri, + nickname: nickname, + follower_address: uri <> "/followers" + } + |> change + |> unique_constraint(:nickname) + |> Repo.insert() + |> set_cache() end - # AP style - def public_key_from_info(%{ - source_data: %{"publicKey" => %{"publicKeyPem" => public_key_pem}} - }) do + def public_key(%{public_key: public_key_pem}) when is_binary(public_key_pem) do key = public_key_pem |> :public_key.pem_decode() @@ -1293,30 +1655,19 @@ defmodule Pleroma.User do {:ok, key} end - def public_key_from_info(_), do: {:error, "not found key"} + def public_key(_), do: {:error, "key not found"} def get_public_key_for_ap_id(ap_id) do with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), - {:ok, public_key} <- public_key_from_info(user.info) do + {:ok, public_key} <- public_key(user) do {:ok, public_key} else _ -> :error end end - defp blank?(""), do: nil - defp blank?(n), do: n - - def insert_or_update_user(data) do - data - |> Map.put(:name, blank?(data[:name]) || data[:nickname]) - |> remote_user_creation() - |> Repo.insert(on_conflict: :replace_all_except_primary_key, conflict_target: :nickname) - |> set_cache() - end - def ap_enabled?(%User{local: true}), do: true - def ap_enabled?(%User{info: info}), do: info.ap_enabled + def ap_enabled?(%User{ap_enabled: ap_enabled}), do: ap_enabled def ap_enabled?(_), do: false @doc "Gets or fetch a user by uri or nickname." @@ -1429,7 +1780,6 @@ defmodule Pleroma.User do %User{ name: ap_id, ap_id: ap_id, - info: %User.Info{}, nickname: "erroruser@example.com", inserted_at: NaiveDateTime.utc_now() } @@ -1441,8 +1791,12 @@ defmodule Pleroma.User do |> Repo.all() end + def muting_reblogs?(%User{} = user, %User{} = target) do + UserRelationship.reblog_mute_exists?(user, target) + end + def showing_reblogs?(%User{} = user, %User{} = target) do - target.ap_id not in user.info.muted_reblogs + not muting_reblogs?(user, target) end @doc """ @@ -1474,7 +1828,7 @@ defmodule Pleroma.User do left_join: a in Pleroma.Activity, on: u.ap_id == a.actor, where: not is_nil(u.nickname), - where: fragment("not (?->'deactivated' @> 'true')", u.info), + where: u.deactivated != ^true, where: u.id not in ^has_read_notifications, group_by: u.id, having: @@ -1488,16 +1842,16 @@ defmodule Pleroma.User do ## Examples - iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => false}}}, "digest", true) - Pleroma.User{info: %{email_notifications: %{"digest" => true}}} + iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => false}}, "digest", true) + Pleroma.User{email_notifications: %{"digest" => true}} - iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => true}}}, "digest", false) - Pleroma.User{info: %{email_notifications: %{"digest" => false}}} + iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => true}}, "digest", false) + Pleroma.User{email_notifications: %{"digest" => false}} """ @spec switch_email_notifications(t(), String.t(), boolean()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def switch_email_notifications(user, type, status) do - update_info(user, &User.Info.update_email_notifications(&1, %{type => status})) + User.update_email_notifications(user, %{type => status}) end @doc """ @@ -1517,17 +1871,21 @@ defmodule Pleroma.User do @spec toggle_confirmation(User.t()) :: {:ok, User.t()} | {:error, Changeset.t()} def toggle_confirmation(%User{} = user) do - need_confirmation? = !user.info.confirmation_pending - user - |> update_info(&User.Info.confirmation_changeset(&1, need_confirmation: need_confirmation?)) + |> confirmation_changeset(need_confirmation: !user.confirmation_pending) + |> update_and_set_cache() + end + + @spec toggle_confirmation([User.t()]) :: [{:ok, User.t()} | {:error, Changeset.t()}] + def toggle_confirmation(users) do + Enum.map(users, &toggle_confirmation/1) end - def get_mascot(%{info: %{mascot: %{} = mascot}}) when not is_nil(mascot) do + def get_mascot(%{mascot: %{} = mascot}) when not is_nil(mascot) do mascot end - def get_mascot(%{info: %{mascot: mascot}}) when is_nil(mascot) do + def get_mascot(%{mascot: mascot}) when is_nil(mascot) do # use instance-default config = Pleroma.Config.get([:assets, :mascots]) default_mascot = Pleroma.Config.get([:assets, :default_mascot]) @@ -1567,7 +1925,7 @@ defmodule Pleroma.User do defp put_password_hash( %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset ) do - change(changeset, password_hash: Pbkdf2.hashpwsalt(password)) + change(changeset, password_hash: Pbkdf2.hash_pwd_salt(password)) end defp put_password_hash(changeset), do: changeset @@ -1597,31 +1955,228 @@ defmodule Pleroma.User do |> update_and_set_cache() end - @doc """ - Changes `user.info` and returns the user changeset. + # Internal function; public one is `deactivate/2` + defp set_activation_status(user, deactivated) do + user + |> cast(%{deactivated: deactivated}, [:deactivated]) + |> update_and_set_cache() + end - `fun` is called with the `user.info`. - """ - def change_info(user, fun) do - changeset = change(user) - info = get_field(changeset, :info) || %User.Info{} - put_embed(changeset, :info, fun.(info)) + def update_banner(user, banner) do + user + |> cast(%{banner: banner}, [:banner]) + |> update_and_set_cache() end - @doc """ - Updates `user.info` and sets cache. + def update_background(user, background) do + user + |> cast(%{background: background}, [:background]) + |> update_and_set_cache() + end - `fun` is called with the `user.info`. - """ - def update_info(users, fun) when is_list(users) do - Repo.transaction(fn -> - for user <- users, do: update_info(user, fun) + def roles(%{is_moderator: is_moderator, is_admin: is_admin}) do + %{ + admin: is_admin, + moderator: is_moderator + } + end + + def validate_fields(changeset, remote? \\ false) do + limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields + limit = Pleroma.Config.get([:instance, limit_name], 0) + + changeset + |> validate_length(:fields, max: limit) + |> validate_change(:fields, fn :fields, fields -> + if Enum.all?(fields, &valid_field?/1) do + [] + else + [fields: "invalid"] + end end) end - def update_info(user, fun) do + defp valid_field?(%{"name" => name, "value" => value}) do + name_limit = Pleroma.Config.get([:instance, :account_field_name_length], 255) + value_limit = Pleroma.Config.get([:instance, :account_field_value_length], 255) + + is_binary(name) && is_binary(value) && String.length(name) <= name_limit && + String.length(value) <= value_limit + end + + defp valid_field?(_), do: false + + defp truncate_field(%{"name" => name, "value" => value}) do + {name, _chopped} = + String.split_at(name, Pleroma.Config.get([:instance, :account_field_name_length], 255)) + + {value, _chopped} = + String.split_at(value, Pleroma.Config.get([:instance, :account_field_value_length], 255)) + + %{"name" => name, "value" => value} + end + + def admin_api_update(user, params) do user - |> change_info(fun) + |> cast(params, [ + :is_moderator, + :is_admin, + :show_role + ]) |> update_and_set_cache() end + + @doc "Signs user out of all applications" + def global_sign_out(user) do + OAuth.Authorization.delete_user_authorizations(user) + OAuth.Token.delete_user_tokens(user) + end + + def mascot_update(user, url) do + user + |> cast(%{mascot: url}, [:mascot]) + |> validate_required([:mascot]) + |> update_and_set_cache() + end + + def mastodon_settings_update(user, settings) do + user + |> cast(%{settings: settings}, [:settings]) + |> validate_required([:settings]) + |> update_and_set_cache() + end + + @spec confirmation_changeset(User.t(), keyword()) :: Changeset.t() + def confirmation_changeset(user, need_confirmation: need_confirmation?) do + params = + if need_confirmation? do + %{ + confirmation_pending: true, + confirmation_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64() + } + else + %{ + confirmation_pending: false, + confirmation_token: nil + } + end + + cast(user, params, [:confirmation_pending, :confirmation_token]) + end + + def add_pinnned_activity(user, %Pleroma.Activity{id: id}) do + if id not in user.pinned_activities do + max_pinned_statuses = Pleroma.Config.get([:instance, :max_pinned_statuses], 0) + params = %{pinned_activities: user.pinned_activities ++ [id]} + + user + |> cast(params, [:pinned_activities]) + |> validate_length(:pinned_activities, + max: max_pinned_statuses, + message: "You have already pinned the maximum number of statuses" + ) + else + change(user) + end + |> update_and_set_cache() + end + + def remove_pinnned_activity(user, %Pleroma.Activity{id: id}) do + params = %{pinned_activities: List.delete(user.pinned_activities, id)} + + user + |> cast(params, [:pinned_activities]) + |> update_and_set_cache() + end + + def update_email_notifications(user, settings) do + email_notifications = + user.email_notifications + |> Map.merge(settings) + |> Map.take(["digest"]) + + params = %{email_notifications: email_notifications} + fields = [:email_notifications] + + user + |> cast(params, fields) + |> validate_required(fields) + |> update_and_set_cache() + end + + defp set_domain_blocks(user, domain_blocks) do + params = %{domain_blocks: domain_blocks} + + user + |> cast(params, [:domain_blocks]) + |> validate_required([:domain_blocks]) + |> update_and_set_cache() + end + + def block_domain(user, domain_blocked) do + set_domain_blocks(user, Enum.uniq([domain_blocked | user.domain_blocks])) + end + + def unblock_domain(user, domain_blocked) do + set_domain_blocks(user, List.delete(user.domain_blocks, domain_blocked)) + end + + @spec add_to_block(User.t(), User.t()) :: + {:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()} + defp add_to_block(%User{} = user, %User{} = blocked) do + UserRelationship.create_block(user, blocked) + end + + @spec add_to_block(User.t(), User.t()) :: + {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()} + defp remove_from_block(%User{} = user, %User{} = blocked) do + UserRelationship.delete_block(user, blocked) + end + + defp add_to_mutes(%User{} = user, %User{} = muted_user, notifications?) do + with {:ok, user_mute} <- UserRelationship.create_mute(user, muted_user), + {:ok, user_notification_mute} <- + (notifications? && UserRelationship.create_notification_mute(user, muted_user)) || + {:ok, nil} do + {:ok, Enum.filter([user_mute, user_notification_mute], & &1)} + end + end + + defp remove_from_mutes(user, %User{} = muted_user) do + with {:ok, user_mute} <- UserRelationship.delete_mute(user, muted_user), + {:ok, user_notification_mute} <- + UserRelationship.delete_notification_mute(user, muted_user) do + {:ok, [user_mute, user_notification_mute]} + end + end + + def set_invisible(user, invisible) do + params = %{invisible: invisible} + + user + |> cast(params, [:invisible]) + |> validate_required([:invisible]) + |> update_and_set_cache() + end + + def sanitize_html(%User{} = user) do + sanitize_html(user, nil) + end + + # User data that mastodon isn't filtering (treated as plaintext): + # - field name + # - display name + def sanitize_html(%User{} = user, filter) do + fields = + Enum.map(user.fields, fn %{"name" => name, "value" => value} -> + %{ + "name" => name, + "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) + } + end) + + user + |> Map.put(:bio, HTML.filter_tags(user.bio, filter)) + |> Map.put(:fields, fields) + end end diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex deleted file mode 100644 index 982fb61c6..000000000 --- a/lib/pleroma/user/info.ex +++ /dev/null @@ -1,482 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.User.Info do - use Ecto.Schema - import Ecto.Changeset - - alias Pleroma.User.Info - - @type t :: %__MODULE__{} - - embedded_schema do - field(:banner, :map, default: %{}) - field(:background, :map, default: %{}) - field(:source_data, :map, default: %{}) - field(:note_count, :integer, default: 0) - field(:follower_count, :integer, default: 0) - # Should be filled in only for remote users - field(:following_count, :integer, default: nil) - field(:locked, :boolean, default: false) - field(:confirmation_pending, :boolean, default: false) - field(:password_reset_pending, :boolean, default: false) - field(:confirmation_token, :string, default: nil) - field(:default_scope, :string, default: "public") - field(:blocks, {:array, :string}, default: []) - field(:domain_blocks, {:array, :string}, default: []) - field(:mutes, {:array, :string}, default: []) - field(:muted_reblogs, {:array, :string}, default: []) - field(:muted_notifications, {:array, :string}, default: []) - field(:subscribers, {:array, :string}, default: []) - field(:deactivated, :boolean, default: false) - field(:no_rich_text, :boolean, default: false) - field(:ap_enabled, :boolean, default: false) - field(:is_moderator, :boolean, default: false) - field(:is_admin, :boolean, default: false) - field(:show_role, :boolean, default: true) - field(:keys, :string, default: nil) - field(:settings, :map, default: nil) - field(:magic_key, :string, default: nil) - field(:uri, :string, default: nil) - field(:hide_followers_count, :boolean, default: false) - field(:hide_follows_count, :boolean, default: false) - field(:hide_followers, :boolean, default: false) - field(:hide_follows, :boolean, default: false) - field(:hide_favorites, :boolean, default: true) - field(:unread_conversation_count, :integer, default: 0) - field(:pinned_activities, {:array, :string}, default: []) - field(:email_notifications, :map, default: %{"digest" => false}) - field(:mascot, :map, default: nil) - field(:emoji, {:array, :map}, default: []) - field(:pleroma_settings_store, :map, default: %{}) - field(:fields, {:array, :map}, default: nil) - field(:raw_fields, {:array, :map}, default: []) - field(:discoverable, :boolean, default: false) - field(:invisible, :boolean, default: false) - - field(:notification_settings, :map, - default: %{ - "followers" => true, - "follows" => true, - "non_follows" => true, - "non_followers" => true - } - ) - - field(:skip_thread_containment, :boolean, default: false) - - # Found in the wild - # ap_id -> Where is this used? - # bio -> Where is this used? - # avatar -> Where is this used? - # fqn -> Where is this used? - # host -> Where is this used? - # subject _> Where is this used? - end - - def set_activation_status(info, deactivated) do - params = %{deactivated: deactivated} - - info - |> cast(params, [:deactivated]) - |> validate_required([:deactivated]) - end - - def set_password_reset_pending(info, pending) do - params = %{password_reset_pending: pending} - - info - |> cast(params, [:password_reset_pending]) - |> validate_required([:password_reset_pending]) - end - - def update_notification_settings(info, settings) do - settings = - settings - |> Enum.map(fn {k, v} -> {k, v in [true, "true", "True", "1"]} end) - |> Map.new() - - notification_settings = - info.notification_settings - |> Map.merge(settings) - |> Map.take(["followers", "follows", "non_follows", "non_followers"]) - - params = %{notification_settings: notification_settings} - - info - |> cast(params, [:notification_settings]) - |> validate_required([:notification_settings]) - end - - @doc """ - Update email notifications in the given User.Info struct. - - Examples: - - iex> update_email_notifications(%Pleroma.User.Info{email_notifications: %{"digest" => false}}, %{"digest" => true}) - %Pleroma.User.Info{email_notifications: %{"digest" => true}} - - """ - @spec update_email_notifications(t(), map()) :: Ecto.Changeset.t() - def update_email_notifications(info, settings) do - email_notifications = - info.email_notifications - |> Map.merge(settings) - |> Map.take(["digest"]) - - params = %{email_notifications: email_notifications} - fields = [:email_notifications] - - info - |> cast(params, fields) - |> validate_required(fields) - end - - def add_to_note_count(info, number) do - set_note_count(info, info.note_count + number) - end - - def set_note_count(info, number) do - params = %{note_count: Enum.max([0, number])} - - info - |> cast(params, [:note_count]) - |> validate_required([:note_count]) - end - - def set_follower_count(info, number) do - params = %{follower_count: Enum.max([0, number])} - - info - |> cast(params, [:follower_count]) - |> validate_required([:follower_count]) - end - - def set_mutes(info, mutes) do - params = %{mutes: mutes} - - info - |> cast(params, [:mutes]) - |> validate_required([:mutes]) - end - - @spec set_notification_mutes(Changeset.t(), [String.t()], boolean()) :: Changeset.t() - def set_notification_mutes(changeset, muted_notifications, notifications?) do - if notifications? do - put_change(changeset, :muted_notifications, muted_notifications) - |> validate_required([:muted_notifications]) - else - changeset - end - end - - def set_blocks(info, blocks) do - params = %{blocks: blocks} - - info - |> cast(params, [:blocks]) - |> validate_required([:blocks]) - end - - def set_subscribers(info, subscribers) do - params = %{subscribers: subscribers} - - info - |> cast(params, [:subscribers]) - |> validate_required([:subscribers]) - end - - @spec add_to_mutes(Info.t(), String.t(), boolean()) :: Changeset.t() - def add_to_mutes(info, muted, notifications?) do - info - |> set_mutes(Enum.uniq([muted | info.mutes])) - |> set_notification_mutes( - Enum.uniq([muted | info.muted_notifications]), - notifications? - ) - end - - @spec remove_from_mutes(Info.t(), String.t()) :: Changeset.t() - def remove_from_mutes(info, muted) do - info - |> set_mutes(List.delete(info.mutes, muted)) - |> set_notification_mutes(List.delete(info.muted_notifications, muted), true) - end - - def add_to_block(info, blocked) do - set_blocks(info, Enum.uniq([blocked | info.blocks])) - end - - def remove_from_block(info, blocked) do - set_blocks(info, List.delete(info.blocks, blocked)) - end - - def add_to_subscribers(info, subscribed) do - set_subscribers(info, Enum.uniq([subscribed | info.subscribers])) - end - - def remove_from_subscribers(info, subscribed) do - set_subscribers(info, List.delete(info.subscribers, subscribed)) - end - - def set_domain_blocks(info, domain_blocks) do - params = %{domain_blocks: domain_blocks} - - info - |> cast(params, [:domain_blocks]) - |> validate_required([:domain_blocks]) - end - - def add_to_domain_block(info, domain_blocked) do - set_domain_blocks(info, Enum.uniq([domain_blocked | info.domain_blocks])) - end - - def remove_from_domain_block(info, domain_blocked) do - set_domain_blocks(info, List.delete(info.domain_blocks, domain_blocked)) - end - - def set_keys(info, keys) do - params = %{keys: keys} - - info - |> cast(params, [:keys]) - |> validate_required([:keys]) - end - - def remote_user_creation(info, params) do - params = - if Map.has_key?(params, :fields) do - Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) - else - params - end - - info - |> cast(params, [ - :ap_enabled, - :source_data, - :banner, - :locked, - :magic_key, - :uri, - :hide_followers, - :hide_follows, - :hide_followers_count, - :hide_follows_count, - :follower_count, - :fields, - :following_count, - :discoverable, - :invisible - ]) - |> validate_fields(true) - end - - def user_upgrade(info, params, remote? \\ false) do - info - |> cast(params, [ - :ap_enabled, - :source_data, - :banner, - :locked, - :magic_key, - :follower_count, - :following_count, - :hide_follows, - :fields, - :hide_followers, - :discoverable, - :hide_followers_count, - :hide_follows_count - ]) - |> validate_fields(remote?) - end - - def profile_update(info, params) do - info - |> cast(params, [ - :locked, - :no_rich_text, - :default_scope, - :banner, - :hide_follows, - :hide_followers, - :hide_followers_count, - :hide_follows_count, - :hide_favorites, - :background, - :show_role, - :skip_thread_containment, - :fields, - :raw_fields, - :pleroma_settings_store, - :discoverable - ]) - |> validate_fields() - end - - def validate_fields(changeset, remote? \\ false) do - limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields - limit = Pleroma.Config.get([:instance, limit_name], 0) - - changeset - |> validate_length(:fields, max: limit) - |> validate_change(:fields, fn :fields, fields -> - if Enum.all?(fields, &valid_field?/1) do - [] - else - [fields: "invalid"] - end - end) - end - - defp valid_field?(%{"name" => name, "value" => value}) do - name_limit = Pleroma.Config.get([:instance, :account_field_name_length], 255) - value_limit = Pleroma.Config.get([:instance, :account_field_value_length], 255) - - is_binary(name) && is_binary(value) && String.length(name) <= name_limit && - String.length(value) <= value_limit - end - - defp valid_field?(_), do: false - - defp truncate_field(%{"name" => name, "value" => value}) do - {name, _chopped} = - String.split_at(name, Pleroma.Config.get([:instance, :account_field_name_length], 255)) - - {value, _chopped} = - String.split_at(value, Pleroma.Config.get([:instance, :account_field_value_length], 255)) - - %{"name" => name, "value" => value} - end - - @spec confirmation_changeset(Info.t(), keyword()) :: Changeset.t() - def confirmation_changeset(info, opts) do - need_confirmation? = Keyword.get(opts, :need_confirmation) - - params = - if need_confirmation? do - %{ - confirmation_pending: true, - confirmation_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64() - } - else - %{ - confirmation_pending: false, - confirmation_token: nil - } - end - - cast(info, params, [:confirmation_pending, :confirmation_token]) - end - - def mastodon_settings_update(info, settings) do - params = %{settings: settings} - - info - |> cast(params, [:settings]) - |> validate_required([:settings]) - end - - def mascot_update(info, url) do - params = %{mascot: url} - - info - |> cast(params, [:mascot]) - |> validate_required([:mascot]) - end - - def set_source_data(info, source_data) do - params = %{source_data: source_data} - - info - |> cast(params, [:source_data]) - |> validate_required([:source_data]) - end - - def set_invisible(info, invisible) do - params = %{invisible: invisible} - - info - |> cast(params, [:invisible]) - |> validate_required([:invisible]) - end - - def admin_api_update(info, params) do - info - |> cast(params, [ - :is_moderator, - :is_admin, - :show_role - ]) - end - - def add_pinnned_activity(info, %Pleroma.Activity{id: id}) do - if id not in info.pinned_activities do - max_pinned_statuses = Pleroma.Config.get([:instance, :max_pinned_statuses], 0) - params = %{pinned_activities: info.pinned_activities ++ [id]} - - info - |> cast(params, [:pinned_activities]) - |> validate_length(:pinned_activities, - max: max_pinned_statuses, - message: "You have already pinned the maximum number of statuses" - ) - else - change(info) - end - end - - def remove_pinnned_activity(info, %Pleroma.Activity{id: id}) do - params = %{pinned_activities: List.delete(info.pinned_activities, id)} - - cast(info, params, [:pinned_activities]) - end - - def roles(%Info{is_moderator: is_moderator, is_admin: is_admin}) do - %{ - admin: is_admin, - moderator: is_moderator - } - end - - def add_reblog_mute(info, ap_id) do - params = %{muted_reblogs: info.muted_reblogs ++ [ap_id]} - - cast(info, params, [:muted_reblogs]) - end - - def remove_reblog_mute(info, ap_id) do - params = %{muted_reblogs: List.delete(info.muted_reblogs, ap_id)} - - cast(info, params, [:muted_reblogs]) - end - - # ``fields`` is an array of mastodon profile field, containing ``{"name": "…", "value": "…"}``. - # For example: [{"name": "Pronoun", "value": "she/her"}, …] - def fields(%{fields: nil, source_data: %{"attachment" => attachment}}) do - limit = Pleroma.Config.get([:instance, :max_remote_account_fields], 0) - - attachment - |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) - |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) - |> Enum.take(limit) - end - - def fields(%{fields: nil}), do: [] - - def fields(%{fields: fields}), do: fields - - def follow_information_update(info, params) do - info - |> cast(params, [ - :hide_followers, - :hide_follows, - :follower_count, - :following_count, - :hide_followers_count, - :hide_follows_count - ]) - end -end diff --git a/lib/pleroma/user/notification_setting.ex b/lib/pleroma/user/notification_setting.ex new file mode 100644 index 000000000..4bd55e139 --- /dev/null +++ b/lib/pleroma/user/notification_setting.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.User.NotificationSetting do + use Ecto.Schema + import Ecto.Changeset + + @derive Jason.Encoder + @primary_key false + + embedded_schema do + field(:followers, :boolean, default: true) + field(:follows, :boolean, default: true) + field(:non_follows, :boolean, default: true) + field(:non_followers, :boolean, default: true) + field(:privacy_option, :boolean, default: false) + end + + def changeset(schema, params) do + schema + |> cast(prepare_attrs(params), [ + :followers, + :follows, + :non_follows, + :non_followers, + :privacy_option + ]) + end + + defp prepare_attrs(params) do + Enum.reduce(params, %{}, fn + {k, v}, acc when is_binary(v) -> + Map.put(acc, k, String.downcase(v)) + + {k, v}, acc -> + Map.put(acc, k, v) + end) + end +end diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 2baf016cf..3a3b04793 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.Query do @@ -28,6 +28,8 @@ defmodule Pleroma.User.Query do """ import Ecto.Query import Pleroma.Web.AdminAPI.Search, only: [not_empty_string: 1] + + alias Pleroma.FollowingRelationship alias Pleroma.User @type criteria :: @@ -43,23 +45,23 @@ defmodule Pleroma.User.Query do is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), + exclude_service_users: boolean(), followers: User.t(), friends: User.t(), recipients_from_activity: [String.t()], - nickname: [String.t()], + nickname: [String.t()] | String.t(), ap_id: [String.t()], order_by: term(), select: term(), limit: pos_integer() } - | %{} + | map() @ilike_criteria [:nickname, :name, :query] @equal_criteria [:email] - @role_criteria [:is_admin, :is_moderator] @contains_criteria [:ap_id, :nickname] - @spec build(criteria()) :: Query.t() + @spec build(Query.t(), criteria()) :: Query.t() def build(query \\ base_query(), criteria) do prepare_query(query, criteria) end @@ -87,6 +89,10 @@ defmodule Pleroma.User.Query do where(query, [u], ilike(field(u, ^key), ^"%#{value}%")) end + defp compose_query({:exclude_service_users, _}, query) do + where(query, [u], not like(u.ap_id, "%/relay") and not like(u.ap_id, "%/internal/fetch")) + end + defp compose_query({key, value}, query) when key in @equal_criteria and not_empty_string(value) do where(query, [u], ^[{key, value}]) @@ -97,18 +103,22 @@ defmodule Pleroma.User.Query do end defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do - Enum.reduce(tags, query, &prepare_tag_criteria/2) + where(query, [u], fragment("? && ?", u.tags, ^tags)) + end + + defp compose_query({:is_admin, _}, query) do + where(query, [u], u.is_admin) end - defp compose_query({key, _}, query) when key in @role_criteria do - where(query, [u], fragment("(?->? @> 'true')", u.info, ^to_string(key))) + defp compose_query({:is_moderator, _}, query) do + where(query, [u], u.is_moderator) end defp compose_query({:super_users, _}, query) do where( query, [u], - fragment("?->'is_admin' @> 'true' OR ?->'is_moderator' @> 'true'", u.info, u.info) + u.is_admin or u.is_moderator ) end @@ -117,7 +127,13 @@ defmodule Pleroma.User.Query do defp compose_query({:external, _}, query), do: location_query(query, false) defp compose_query({:active, _}, query) do - where(query, [u], fragment("not (?->'deactivated' @> 'true')", u.info)) + User.restrict_deactivated(query) + |> where([u], not is_nil(u.nickname)) + end + + defp compose_query({:legacy_active, _}, query) do + query + |> where([u], fragment("not (?->'deactivated' @> 'true')", u.info)) |> where([u], not is_nil(u.nickname)) end @@ -126,22 +142,45 @@ defmodule Pleroma.User.Query do end defp compose_query({:deactivated, true}, query) do - where(query, [u], fragment("?->'deactivated' @> 'true'", u.info)) + where(query, [u], u.deactivated == ^true) |> where([u], not is_nil(u.nickname)) end - defp compose_query({:followers, %User{id: id, follower_address: follower_address}}, query) do - where(query, [u], fragment("? <@ ?", ^[follower_address], u.following)) + defp compose_query({:followers, %User{id: id}}, query) do + query |> where([u], u.id != ^id) + |> join(:inner, [u], r in FollowingRelationship, + as: :relationships, + on: r.following_id == ^id and r.follower_id == u.id + ) + |> where([relationships: r], r.state == ^:follow_accept) end - defp compose_query({:friends, %User{id: id, following: following}}, query) do - where(query, [u], u.follower_address in ^following) + defp compose_query({:friends, %User{id: id}}, query) do + query |> where([u], u.id != ^id) + |> join(:inner, [u], r in FollowingRelationship, + as: :relationships, + on: r.following_id == u.id and r.follower_id == ^id + ) + |> where([relationships: r], r.state == ^:follow_accept) end defp compose_query({:recipients_from_activity, to}, query) do - where(query, [u], u.ap_id in ^to or fragment("? && ?", u.following, ^to)) + query + |> join(:left, [u], r in FollowingRelationship, + as: :relationships, + on: r.follower_id == u.id + ) + |> join(:left, [relationships: r], f in User, + as: :following, + on: f.id == r.following_id + ) + |> where( + [u, following: f, relationships: r], + u.ap_id in ^to or (f.follower_address in ^to and r.state == ^:follow_accept) + ) + |> distinct(true) end defp compose_query({:order_by, key}, query) do @@ -158,10 +197,6 @@ defmodule Pleroma.User.Query do defp compose_query(_unsupported_param, query), do: query - defp prepare_tag_criteria(tag, query) do - or_where(query, [u], fragment("? = any(?)", ^tag, u.tags)) - end - defp location_query(query, local) do where(query, [u], u.local == ^local) |> where([u], not is_nil(u.nickname)) diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 0d697fe3d..cec59c372 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.Search do @@ -33,9 +33,15 @@ defmodule Pleroma.User.Search do # Strip the beginning @ off if there is a query query_string = String.trim_leading(query_string, "@") - with [name, domain] <- String.split(query_string, "@"), - formatted_domain <- String.replace(domain, ~r/[!-\-|@|[-`|{-~|\/|:|\s]+/, "") do - name <> "@" <> to_string(:idna.encode(formatted_domain)) + with [name, domain] <- String.split(query_string, "@") do + encoded_domain = + domain + |> String.replace(~r/[!-\-|@|[-`|{-~|\/|:|\s]+/, "") + |> String.to_charlist() + |> :idna.encode() + |> to_string() + + name <> "@" <> encoded_domain else _ -> query_string end @@ -45,6 +51,7 @@ defmodule Pleroma.User.Search do for_user |> base_query(following) |> filter_blocked_user(for_user) + |> filter_invisible_users() |> filter_blocked_domains(for_user) |> fts_search(query_string) |> trigram_rank(query_string) @@ -54,15 +61,7 @@ defmodule Pleroma.User.Search do |> maybe_restrict_local(for_user) end - @nickname_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~\-@]+$/ defp fts_search(query, query_string) do - {nickname_weight, name_weight} = - if String.match?(query_string, @nickname_regex) do - {"A", "B"} - else - {"B", "A"} - end - query_string = to_tsquery(query_string) from( @@ -70,12 +69,10 @@ defmodule Pleroma.User.Search do where: fragment( """ - (setweight(to_tsvector('simple', ?), ?) || setweight(to_tsvector('simple', ?), ?)) @@ to_tsquery('simple', ?) + (to_tsvector('simple', ?) || to_tsvector('simple', ?)) @@ to_tsquery('simple', ?) """, u.name, - ^name_weight, u.nickname, - ^nickname_weight, ^query_string ) ) @@ -108,14 +105,22 @@ defmodule Pleroma.User.Search do defp base_query(_user, false), do: User defp base_query(user, true), do: User.get_followers_query(user) - defp filter_blocked_user(query, %User{info: %{blocks: blocks}}) - when length(blocks) > 0 do - from(q in query, where: not (q.ap_id in ^blocks)) + defp filter_invisible_users(query) do + from(q in query, where: q.invisible == false) + end + + defp filter_blocked_user(query, %User{} = blocker) do + query + |> join(:left, [u], b in Pleroma.UserRelationship, + as: :blocks, + on: b.relationship_type == ^:block and b.source_id == ^blocker.id and u.id == b.target_id + ) + |> where([blocks: b], is_nil(b.target_id)) end defp filter_blocked_user(query, _), do: query - defp filter_blocked_domains(query, %User{info: %{domain_blocks: domain_blocks}}) + defp filter_blocked_domains(query, %User{domain_blocks: domain_blocks}) when length(domain_blocks) > 0 do domains = Enum.join(domain_blocks, ",") diff --git a/lib/pleroma/user/welcome_message.ex b/lib/pleroma/user/welcome_message.ex index 99fba729e..f8f520285 100644 --- a/lib/pleroma/user/welcome_message.ex +++ b/lib/pleroma/user/welcome_message.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.WelcomeMessage do @@ -10,8 +10,8 @@ defmodule Pleroma.User.WelcomeMessage do with %User{} = sender_user <- welcome_user(), message when is_binary(message) <- welcome_message() do CommonAPI.post(sender_user, %{ - "visibility" => "direct", - "status" => "@#{user.nickname}\n#{message}" + visibility: "direct", + status: "@#{user.nickname}\n#{message}" }) else _ -> {:ok, nil} diff --git a/lib/pleroma/user_invite_token.ex b/lib/pleroma/user_invite_token.ex index b9e80acdd..a08ba99f1 100644 --- a/lib/pleroma/user_invite_token.ex +++ b/lib/pleroma/user_invite_token.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.UserInviteToken do diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex new file mode 100644 index 000000000..6dfdd2860 --- /dev/null +++ b/lib/pleroma/user_relationship.ex @@ -0,0 +1,217 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.UserRelationship do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Ecto.Changeset + alias Pleroma.FollowingRelationship + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.UserRelationship + + schema "user_relationships" do + belongs_to(:source, User, type: FlakeId.Ecto.CompatType) + belongs_to(:target, User, type: FlakeId.Ecto.CompatType) + field(:relationship_type, Pleroma.UserRelationship.Type) + + timestamps(updated_at: false) + end + + for relationship_type <- Keyword.keys(Pleroma.UserRelationship.Type.__enum_map__()) do + # `def create_block/2`, `def create_mute/2`, `def create_reblog_mute/2`, + # `def create_notification_mute/2`, `def create_inverse_subscription/2` + def unquote(:"create_#{relationship_type}")(source, target), + do: create(unquote(relationship_type), source, target) + + # `def delete_block/2`, `def delete_mute/2`, `def delete_reblog_mute/2`, + # `def delete_notification_mute/2`, `def delete_inverse_subscription/2` + def unquote(:"delete_#{relationship_type}")(source, target), + do: delete(unquote(relationship_type), source, target) + + # `def block_exists?/2`, `def mute_exists?/2`, `def reblog_mute_exists?/2`, + # `def notification_mute_exists?/2`, `def inverse_subscription_exists?/2` + def unquote(:"#{relationship_type}_exists?")(source, target), + do: exists?(unquote(relationship_type), source, target) + end + + def user_relationship_types, do: Keyword.keys(user_relationship_mappings()) + + def user_relationship_mappings, do: Pleroma.UserRelationship.Type.__enum_map__() + + def changeset(%UserRelationship{} = user_relationship, params \\ %{}) do + user_relationship + |> cast(params, [:relationship_type, :source_id, :target_id]) + |> validate_required([:relationship_type, :source_id, :target_id]) + |> unique_constraint(:relationship_type, + name: :user_relationships_source_id_relationship_type_target_id_index + ) + |> validate_not_self_relationship() + end + + def exists?(relationship_type, %User{} = source, %User{} = target) do + UserRelationship + |> where(relationship_type: ^relationship_type, source_id: ^source.id, target_id: ^target.id) + |> Repo.exists?() + end + + def create(relationship_type, %User{} = source, %User{} = target) do + %UserRelationship{} + |> changeset(%{ + relationship_type: relationship_type, + source_id: source.id, + target_id: target.id + }) + |> Repo.insert( + on_conflict: {:replace_all_except, [:id]}, + conflict_target: [:source_id, :relationship_type, :target_id] + ) + end + + def delete(relationship_type, %User{} = source, %User{} = target) do + attrs = %{relationship_type: relationship_type, source_id: source.id, target_id: target.id} + + case Repo.get_by(UserRelationship, attrs) do + %UserRelationship{} = existing_record -> Repo.delete(existing_record) + nil -> {:ok, nil} + end + end + + def dictionary( + source_users, + target_users, + source_to_target_rel_types \\ nil, + target_to_source_rel_types \\ nil + ) + + def dictionary( + _source_users, + _target_users, + [] = _source_to_target_rel_types, + [] = _target_to_source_rel_types + ) do + [] + end + + def dictionary( + source_users, + target_users, + source_to_target_rel_types, + target_to_source_rel_types + ) + when is_list(source_users) and is_list(target_users) do + source_user_ids = User.binary_id(source_users) + target_user_ids = User.binary_id(target_users) + + get_rel_type_codes = fn rel_type -> user_relationship_mappings()[rel_type] end + + source_to_target_rel_types = + Enum.map(source_to_target_rel_types || user_relationship_types(), &get_rel_type_codes.(&1)) + + target_to_source_rel_types = + Enum.map(target_to_source_rel_types || user_relationship_types(), &get_rel_type_codes.(&1)) + + __MODULE__ + |> where( + fragment( + "(source_id = ANY(?) AND target_id = ANY(?) AND relationship_type = ANY(?)) OR \ + (source_id = ANY(?) AND target_id = ANY(?) AND relationship_type = ANY(?))", + ^source_user_ids, + ^target_user_ids, + ^source_to_target_rel_types, + ^target_user_ids, + ^source_user_ids, + ^target_to_source_rel_types + ) + ) + |> select([ur], [ur.relationship_type, ur.source_id, ur.target_id]) + |> Repo.all() + end + + def exists?(dictionary, rel_type, source, target, func) do + cond do + is_nil(source) or is_nil(target) -> + false + + dictionary -> + [rel_type, source.id, target.id] in dictionary + + true -> + func.(source, target) + end + end + + @doc ":relationships option for StatusView / AccountView / NotificationView" + def view_relationships_option(reading_user, actors, opts \\ []) + + def view_relationships_option(nil = _reading_user, _actors, _opts) do + %{user_relationships: [], following_relationships: []} + end + + def view_relationships_option(%User{} = reading_user, actors, opts) do + {source_to_target_rel_types, target_to_source_rel_types} = + case opts[:subset] do + :source_mutes -> + # Used for statuses rendering (FE needs `muted` flag for each status when statuses load) + {[:mute], []} + + nil -> + {[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]} + + unknown -> + raise "Unsupported :subset option value: #{inspect(unknown)}" + end + + user_relationships = + UserRelationship.dictionary( + [reading_user], + actors, + source_to_target_rel_types, + target_to_source_rel_types + ) + + following_relationships = + case opts[:subset] do + :source_mutes -> + [] + + nil -> + FollowingRelationship.all_between_user_sets([reading_user], actors) + + unknown -> + raise "Unsupported :subset option value: #{inspect(unknown)}" + end + + %{user_relationships: user_relationships, following_relationships: following_relationships} + end + + defp validate_not_self_relationship(%Changeset{} = changeset) do + changeset + |> validate_source_id_target_id_inequality() + |> validate_target_id_source_id_inequality() + end + + defp validate_source_id_target_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :source_id, fn _, source_id -> + if source_id == get_field(changeset, :target_id) do + [source_id: "can't be equal to target_id"] + else + [] + end + end) + end + + defp validate_target_id_source_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :target_id, fn _, target_id -> + if target_id == get_field(changeset, :source_id) do + [target_id: "can't be equal to source_id"] + else + [] + end + end) + end +end diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex new file mode 100644 index 000000000..6b8e3accf --- /dev/null +++ b/lib/pleroma/utils.ex @@ -0,0 +1,12 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Utils do + def compile_dir(dir) when is_binary(dir) do + dir + |> File.ls!() + |> Enum.map(&Path.join(dir, &1)) + |> Kernel.ParallelCompiler.compile() + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 9a0a3522a..d752f4f04 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1,11 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Activity alias Pleroma.Activity.Ir.Topics alias Pleroma.Config + alias Pleroma.Constants alias Pleroma.Conversation alias Pleroma.Conversation.Participation alias Pleroma.Notification @@ -69,7 +70,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp check_actor_is_active(actor) do if not is_nil(actor) do with user <- User.get_cached_by_ap_id(actor), - false <- user.info.deactivated do + false <- user.deactivated do true else _e -> false @@ -117,13 +118,30 @@ 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), map <- lazy_put_activity_defaults(map, fake), @@ -152,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 -> @@ -180,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), @@ -231,12 +252,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do :noop end - def create(%{to: to, actor: actor, context: context, object: object} = params, fake \\ false) do + @spec create(map(), boolean()) :: {:ok, Activity.t()} | {:error, any()} + def create(params, fake \\ false) do + with {:ok, result} <- Repo.transaction(fn -> do_create(params, fake) end) do + result + end + end + + defp do_create(%{to: to, actor: actor, context: context, object: object} = params, fake) do additional = params[:additional] || %{} # only accept false as false value local = !(params[:local] == false) published = params[:published] - quick_insert? = Pleroma.Config.get([:env]) == :benchmark + quick_insert? = Config.get([:env]) == :benchmark with create_data <- make_create_data( @@ -248,9 +276,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do _ <- increase_replies_count_if_reply(create_data), _ <- increase_poll_votes_if_vote(create_data), {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, - # Changing note count prior to enqueuing federation task in order to avoid - # race conditions on updating user.info {:ok, _actor} <- increase_note_count_if_public(actor, activity), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -261,10 +288,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do {:ok, activity} {:error, message} -> - {:error, message} + Repo.rollback(message) end end + @spec listen(map()) :: {:ok, Activity.t()} | {:error, any()} def listen(%{to: to, actor: actor, context: context, object: object} = params) do additional = params[:additional] || %{} # only accept false as false value @@ -277,22 +305,23 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do additional ), {:ok, activity} <- insert(listen_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} - else - {:error, message} -> - {:error, message} end end + @spec accept(map()) :: {:ok, Activity.t()} | {:error, any()} def accept(params) do accept_or_reject("Accept", params) end + @spec reject(map()) :: {:ok, Activity.t()} | {:error, any()} def reject(params) do accept_or_reject("Reject", params) end + @spec accept_or_reject(String.t(), map()) :: {:ok, Activity.t()} | {:error, any()} def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do local = Map.get(params, :local, true) activity_id = Map.get(params, :activity_id, nil) @@ -301,11 +330,13 @@ 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 end + @spec update(map()) :: {:ok, Activity.t()} | {:error, any()} def update(%{to: to, cc: cc, actor: actor, object: object} = params) do local = !(params[:local] == false) activity_id = params[:activity_id] @@ -319,43 +350,14 @@ 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 - # TODO: This is weird, maybe we shouldn't check here if we can make the activity. - def like( - %User{ap_id: ap_id} = user, - %Object{data: %{"id" => _}} = object, - activity_id \\ nil, - local \\ true - ) 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, error} - end - end - - def unlike(%User{} = actor, %Object{} = object, activity_id \\ nil, local \\ true) 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 - _e -> {:ok, object} - 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, @@ -363,99 +365,80 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do 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), + object <- Object.get_by_id(object.id), announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity, object} else - error -> {:error, error} + false -> {:error, false} + {:error, error} -> Repo.rollback(error) end end - def unannounce( - %User{} = actor, - %Object{} = object, - activity_id \\ nil, - local \\ true - ) 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 - _e -> {:ok, object} + @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 + with {:ok, result} <- + Repo.transaction(fn -> do_follow(follower, followed, activity_id, local) end) do + result end end - def follow(follower, followed, activity_id \\ nil, local \\ true) 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) end end + @spec unfollow(User.t(), User.t(), String.t() | nil, boolean()) :: + {:ok, Activity.t()} | nil | {:error, any()} def unfollow(follower, followed, activity_id \\ nil, local \\ true) do + with {:ok, result} <- + Repo.transaction(fn -> do_unfollow(follower, followed, activity_id, local) end) do + result + end + end + + defp do_unfollow(follower, followed, activity_id, local) do with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed), {: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 + nil -> nil + {:error, error} -> Repo.rollback(error) end end - def 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 - - def 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 {:ok, object, activity} <- Object.delete(object), - data <- - %{ - "type" => "Delete", - "actor" => actor, - "object" => id, - "to" => to, - "deleted_activity_id" => activity && activity.id - } - |> maybe_put("id", activity_id), - {:ok, activity} <- insert(data, local, false), - stream_out_participations(object, user), - _ <- decrease_replies_count_if_reply(object), - # Changing note count prior to enqueuing federation task in order to avoid - # race conditions on updating user.info - {:ok, _actor} <- decrease_note_count_if_public(user, object), - :ok <- maybe_federate(activity) do - {:ok, activity} + @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 + with {:ok, result} <- + Repo.transaction(fn -> do_block(blocker, blocked, activity_id, local) end) do + result end end - @spec block(User.t(), User.t(), String.t() | nil, boolean) :: {:ok, Activity.t() | nil} - def block(blocker, blocked, activity_id \\ nil, local \\ true) do - outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) + defp do_block(blocker, blocked, activity_id, local) do unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) if unfollow_blocked do @@ -463,26 +446,17 @@ 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 - _e -> {:ok, nil} - end - end - - def unblock(blocker, blocked, activity_id \\ nil, local \\ true) 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} + {:error, error} -> Repo.rollback(error) end end - @spec flag(map()) :: {:ok, Activity.t()} | any + @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} def flag( %{ actor: actor, @@ -507,8 +481,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do with flag_data <- make_flag_data(params, additional), {:ok, activity} <- insert(flag_data, local), - :ok <- maybe_federate(activity) do - Enum.each(User.all_superusers(), fn superuser -> + {: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) + |> Enum.each(fn superuser -> superuser |> Pleroma.Emails.AdminEmail.report(actor, account, statuses, content) |> Pleroma.Emails.Mailer.deliver_async() @@ -518,11 +496,39 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - defp fetch_activities_for_context_query(context, opts) do - public = [Pleroma.Constants.as_public()] + @spec move(User.t(), User.t(), boolean()) :: {:ok, Activity.t()} | {:error, any()} + def move(%User{} = origin, %User{} = target, local \\ true) do + params = %{ + "type" => "Move", + "actor" => origin.ap_id, + "object" => origin.ap_id, + "target" => target.ap_id + } + + with true <- origin.ap_id in target.also_known_as, + {:ok, activity} <- insert(params, local), + _ <- notify_and_stream(activity) do + maybe_federate(activity) + + BackgroundWorker.enqueue("move_following", %{ + "origin_id" => origin.id, + "target_id" => target.id + }) + + {:ok, activity} + else + false -> {:error, "Target account must have the origin in `alsoKnownAs`"} + err -> err + end + end + + def fetch_activities_for_context_query(context, opts) do + public = [Constants.as_public()] recipients = - if opts["user"], do: [opts["user"].ap_id | opts["user"].following] ++ public, else: public + if opts["user"], + do: [opts["user"].ap_id | User.following(opts["user"])] ++ public, + else: public from(activity in Activity) |> maybe_preload_objects(opts) @@ -562,14 +568,14 @@ 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 opts = Map.drop(opts, ["user"]) - [Pleroma.Constants.as_public()] + [Constants.as_public()] |> fetch_activities_query(opts) |> restrict_unlisted() |> Pagination.fetch_paginated(opts, pagination) - |> Enum.reverse() end @valid_visibilities ~w[direct unlisted public private] @@ -648,7 +654,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 @@ -660,7 +666,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_thread_visibility( query, - %{"user" => %User{info: %{skip_thread_containment: true}}}, + %{"user" => %User{skip_thread_containment: true}}, _ ), do: query @@ -679,7 +685,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do params |> Map.put("user", reading_user) |> Map.put("actor_id", user.ap_id) - |> Map.put("whole_db", true) recipients = user_activities_recipients(%{ @@ -697,8 +702,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Map.put("type", ["Create", "Announce"]) |> Map.put("user", reading_user) |> Map.put("actor_id", user.ap_id) - |> Map.put("whole_db", true) - |> Map.put("pinned_activity_ids", user.info.pinned_activities) + |> Map.put("pinned_activity_ids", user.pinned_activities) + + params = + if User.blocks?(reading_user, user) do + params + else + params + |> Map.put("blocking_user", reading_user) + |> Map.put("muting_user", reading_user) + end recipients = user_activities_recipients(%{ @@ -710,15 +723,30 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Enum.reverse() end + def fetch_statuses(reading_user, params) do + params = + params + |> Map.put("type", ["Create", "Announce"]) + + recipients = + user_activities_recipients(%{ + "godmode" => params["godmode"], + "reading_user" => reading_user + }) + + fetch_activities(recipients, params, :offset) + |> Enum.reverse() + end + defp user_activities_recipients(%{"godmode" => true}) do [] end defp user_activities_recipients(%{"reading_user" => reading_user}) do if reading_user do - [Pleroma.Constants.as_public()] ++ [reading_user.ap_id | reading_user.following] + [Constants.as_public()] ++ [reading_user.ap_id | User.following(reading_user)] else - [Pleroma.Constants.as_public()] + [Constants.as_public()] end end @@ -833,7 +861,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, ^[]) @@ -842,16 +870,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 @@ -859,8 +922,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_muted(query, %{"with_muted" => val}) when val in [true, "true", "1"], do: query - defp restrict_muted(query, %{"muting_user" => %User{info: info}} = opts) do - mutes = info.mutes + defp restrict_muted(query, %{"muting_user" => %User{} = user} = opts) do + mutes = opts["muted_users_ap_ids"] || User.muted_users_ap_ids(user) query = from([activity] in query, @@ -877,26 +940,42 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_muted(query, _), do: query - defp restrict_blocked(query, %{"blocking_user" => %User{info: info}}) do - blocks = info.blocks || [] - domain_blocks = info.domain_blocks || [] + defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do + blocked_ap_ids = opts["blocked_users_ap_ids"] || User.blocked_users_ap_ids(user) + domain_blocks = user.domain_blocks || [] + + following_ap_ids = User.get_friends_ap_ids(user) query = if has_named_binding?(query, :object), do: query, else: Activity.with_joined_object(query) from( [activity, object: o] in query, - where: fragment("not (? = ANY(?))", activity.actor, ^blocks), - where: fragment("not (? && ?)", activity.recipients, ^blocks), + where: fragment("not (? = ANY(?))", activity.actor, ^blocked_ap_ids), + where: fragment("not (? && ?)", activity.recipients, ^blocked_ap_ids), where: fragment( "not (?->>'type' = 'Announce' and ?->'to' \\?| ?)", activity.data, activity.data, - ^blocks + ^blocked_ap_ids ), - where: fragment("not (split_part(?, '/', 3) = ANY(?))", activity.actor, ^domain_blocks), - where: fragment("not (split_part(?->>'actor', '/', 3) = ANY(?))", o.data, ^domain_blocks) + where: + fragment( + "(not (split_part(?, '/', 3) = ANY(?))) or ? = ANY(?)", + activity.actor, + ^domain_blocks, + activity.actor, + ^following_ap_ids + ), + where: + fragment( + "(not (split_part(?->>'actor', '/', 3) = ANY(?))) or (?->>'actor') = ANY(?)", + o.data, + ^domain_blocks, + o.data, + ^following_ap_ids + ) ) end @@ -909,19 +988,24 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do fragment( "not (coalesce(?->'cc', '{}'::jsonb) \\?| ?)", activity.data, - ^[Pleroma.Constants.as_public()] + ^[Constants.as_public()] ) ) 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 defp restrict_pinned(query, _), do: query - defp restrict_muted_reblogs(query, %{"muting_user" => %User{info: info}}) do - muted_reblogs = info.muted_reblogs || [] + defp restrict_muted_reblogs(query, %{"muting_user" => %User{} = user} = opts) do + muted_reblogs = opts["reblog_muted_users_ap_ids"] || User.reblog_muted_users_ap_ids(user) from( activity in query, @@ -937,6 +1021,20 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_muted_reblogs(query, _), do: query + defp restrict_instance(query, %{"instance" => instance}) do + users = + from( + u in User, + select: u.ap_id, + where: fragment("? LIKE ?", u.nickname, ^"%@#{instance}") + ) + |> Repo.all() + + from(activity in query, where: activity.actor in ^users) + end + + defp restrict_instance(query, _), do: query + defp exclude_poll_votes(query, %{"include_poll_votes" => true}), do: query defp exclude_poll_votes(query, _) do @@ -969,6 +1067,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Activity.with_preloaded_bookmark(opts["user"]) end + defp maybe_preload_report_notes(query, %{"preload_report_notes" => true}) do + query + |> Activity.with_preloaded_report_notes() + end + + defp maybe_preload_report_notes(query, _), do: query + defp maybe_set_thread_muted_field(query, %{"skip_preload" => true}), do: query defp maybe_set_thread_muted_field(query, opts) do @@ -988,7 +1093,33 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp maybe_order(query, _), do: query + defp fetch_activities_query_ap_ids_ops(opts) do + source_user = opts["muting_user"] + ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: [] + + ap_id_relationships = + ap_id_relationships ++ + if opts["blocking_user"] && opts["blocking_user"] == source_user do + [:block] + else + [] + end + + 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) + + restrict_muted_reblogs_opts = + Map.merge(%{"reblog_muted_users_ap_ids" => preloaded_ap_ids[:reblog_mute]}, opts) + + {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} + end + def fetch_activities_query(recipients, opts \\ %{}) do + {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} = + fetch_activities_query_ap_ids_ops(opts) + config = %{ skip_thread_containment: Config.get([:instance, :skip_thread_containment]) } @@ -996,9 +1127,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do Activity |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) + |> maybe_preload_report_notes(opts) |> 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) @@ -1008,15 +1141,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> restrict_type(opts) |> restrict_state(opts) |> restrict_favorited_by(opts) - |> restrict_blocked(opts) - |> restrict_muted(opts) + |> restrict_blocked(restrict_blocked_opts) + |> restrict_muted(restrict_muted_opts) |> restrict_media(opts) |> restrict_visibility(opts) |> restrict_thread_visibility(opts, config) - |> restrict_replies(opts) |> restrict_reblogs(opts) |> restrict_pinned(opts) - |> restrict_muted_reblogs(opts) + |> restrict_muted_reblogs(restrict_muted_reblogs_opts) + |> restrict_instance(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) |> exclude_visibility(opts) @@ -1031,6 +1164,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> maybe_update_cc(list_memberships, opts["user"]) end + @doc """ + Fetch favorites activities of user with order by sort adds to favorites + """ + @spec fetch_favourites(User.t(), map(), Pagination.type()) :: list(Activity.t()) + def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do + user.ap_id + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Like") + |> Activity.with_joined_object() + |> Object.with_joined_activity() + |> select([_like, object, activity], %{activity | object: object}) + |> order_by([like, _, _], desc: like.id) + |> Pagination.fetch_paginated( + Map.merge(params, %{"skip_order" => true}), + pagination, + :object_activity + ) + end + defp maybe_update_cc(activities, list_memberships, %User{ap_id: user_ap_id}) when is_list(list_memberships) and length(list_memberships) > 0 do Enum.map(activities, fn @@ -1053,7 +1205,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do where: fragment("? && ?", activity.recipients, ^recipients) or (fragment("? && ?", activity.recipients, ^recipients_with_public) and - ^Pleroma.Constants.as_public() in activity.recipients) + ^Constants.as_public() in activity.recipients) ) end @@ -1069,6 +1221,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Enum.reverse() end + @spec upload(Upload.source(), keyword()) :: {:ok, Object.t()} | {:error, any()} def upload(file, opts \\ []) do with {:ok, data} <- Upload.store(file, opts) do obj_data = @@ -1082,6 +1235,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"] && @@ -1103,27 +1268,57 @@ 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"], - info: %{ - ap_enabled: true, - source_data: data, - banner: banner, - fields: fields, - locked: locked, - discoverable: discoverable, - invisible: invisible - }, + uri: get_actor_url(data["url"]), + ap_enabled: true, + banner: banner, + fields: fields, + emoji: emojis, + locked: locked, + discoverable: discoverable, + invisible: invisible, avatar: avatar, name: data["name"], follower_address: data["followers"], following_address: data["following"], - bio: data["summary"] + bio: data["summary"], + actor_type: actor_type, + 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 @@ -1144,68 +1339,74 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def fetch_follow_information_for_user(user) do with {:ok, following_data} <- Fetcher.fetch_and_contain_remote_object_from_id(user.following_address), - following_count when is_integer(following_count) <- following_data["totalItems"], {:ok, hide_follows} <- collection_private(following_data), {:ok, followers_data} <- Fetcher.fetch_and_contain_remote_object_from_id(user.follower_address), - followers_count when is_integer(followers_count) <- followers_data["totalItems"], {:ok, hide_followers} <- collection_private(followers_data) do {:ok, %{ hide_follows: hide_follows, - follower_count: followers_count, - following_count: following_count, + follower_count: normalize_counter(followers_data["totalItems"]), + following_count: normalize_counter(following_data["totalItems"]), hide_followers: hide_followers }} else - {:error, _} = e -> - e - - e -> - {:error, e} + {:error, _} = e -> e + e -> {:error, e} end end - defp maybe_update_follow_information(data) do - with {:enabled, true} <- - {:enabled, Pleroma.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) + defp normalize_counter(counter) when is_integer(counter), do: counter + defp normalize_counter(_), do: 0 + + def maybe_update_follow_information(user_data) do + with {:enabled, true} <- {:enabled, Config.get([:instance, :external_user_synchronization])}, + {_, 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 - defp collection_private(data) do - if is_map(data["first"]) and - data["first"]["type"] in ["CollectionPage", "OrderedCollectionPage"] do + defp collection_private(%{"first" => %{"type" => type}}) + when type in ["CollectionPage", "OrderedCollectionPage"], + do: {:ok, false} + + defp collection_private(%{"first" => first}) do + with {:ok, %{"type" => type}} when type in ["CollectionPage", "OrderedCollectionPage"] <- + Fetcher.fetch_and_contain_remote_object_from_id(first) do {:ok, false} else - with {:ok, %{"type" => type}} when type in ["CollectionPage", "OrderedCollectionPage"] <- - Fetcher.fetch_and_contain_remote_object_from_id(data["first"]) do - {:ok, false} - else - {:error, {:ok, %{status: code}}} when code in [401, 403] -> - {:ok, true} - - {:error, _} = e -> - e - - e -> - {:error, e} - end + {:error, {:ok, %{status: code}}} when code in [401, 403] -> {:ok, true} + {:error, _} = e -> e + e -> {:error, e} end end + defp collection_private(_data), do: {:ok, true} + def user_data_from_user_object(data) do with {:ok, data} <- MRF.filter(data), {:ok, data} <- object_to_user_data(data) do @@ -1221,6 +1422,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do data <- maybe_update_follow_information(data) do {:ok, data} else + {:error, "Object has been deleted"} = e -> + Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") + {:error, e} + e -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} @@ -1228,11 +1433,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 080030eb5..62ad15d85 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ActivityPubController do @@ -9,32 +9,50 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Delivery alias Pleroma.Object alias Pleroma.Object.Fetcher + 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.FederatingPlug alias Pleroma.Web.Federator require Logger action_fallback(:errors) + @federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers] + + plug(FederatingPlug when action in @federating_only_actions) + + plug( + EnsureAuthenticatedPlug, + [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] + ) + plug( Pleroma.Plugs.Cache, [query_params: false, tracking_fun: &__MODULE__.track_object_fetch/2] when action in [:activity, :object] ) - plug(Pleroma.Web.FederatingPlug when action in [:inbox, :relay]) plug(:set_requester_reachable when action in [:inbox]) plug(:relay_active? when action in [:relay]) - def relay_active?(conn, _) do + defp relay_active?(conn, _) do if Pleroma.Config.get([:instance, :allow_relay]) do conn else @@ -45,7 +63,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end def user(conn, %{"nickname" => nickname}) do - with %User{} = user <- User.get_cached_by_nickname(nickname), + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") @@ -53,6 +71,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> render("user.json", %{user: user}) else nil -> {:error, :not_found} + %{local: false} -> {:error, :not_found} end end @@ -126,18 +145,20 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end # GET /relay/following - def following(%{assigns: %{relay: true}} = conn, _params) do - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("following.json", %{user: Relay.get_actor()}) + def relay_following(conn, _params) do + with %{halted: false} = conn <- FederatingPlug.call(conn, []) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("following.json", %{user: Relay.get_actor()}) + end end def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do with %User{} = user <- User.get_cached_by_nickname(nickname), {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user), {:show_follows, true} <- - {:show_follows, (for_user && for_user == user) || !user.info.hide_follows} do + {:show_follows, (for_user && for_user == user) || !user.hide_follows} do {page, _} = Integer.parse(page) conn @@ -163,18 +184,20 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end # GET /relay/followers - def followers(%{assigns: %{relay: true}} = conn, _params) do - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("followers.json", %{user: Relay.get_actor()}) + def relay_followers(conn, _params) do + with %{halted: false} = conn <- FederatingPlug.call(conn, []) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("followers.json", %{user: Relay.get_actor()}) + end end def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do with %User{} = user <- User.get_cached_by_nickname(nickname), {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user), {:show_followers, true} <- - {:show_followers, (for_user && for_user == user) || !user.info.hide_followers} do + {:show_followers, (for_user && for_user == user) || !user.hide_followers} do {page, _} = Integer.parse(page) conn @@ -199,13 +222,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - def outbox(conn, %{"nickname" => nickname, "page" => page?} = params) + def outbox( + %{assigns: %{user: for_user}} = conn, + %{"nickname" => nickname, "page" => page?} = params + ) when page? in [true, "true"] do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do activities = if params["max_id"] do - ActivityPub.fetch_user_activities(user, nil, %{ + ActivityPub.fetch_user_activities(user, for_user, %{ "max_id" => params["max_id"], # This is a hack because postgres generates inefficient queries when filtering by # 'Answer', poll votes will be hidden by the visibility filter in this case anyway @@ -213,7 +239,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do "limit" => 10 }) else - ActivityPub.fetch_user_activities(user, nil, %{ + ActivityPub.fetch_user_activities(user, for_user, %{ "limit" => 10, "include_poll_votes" => true }) @@ -254,9 +280,17 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do json(conn, "ok") end - # only accept relayed Creates - def inbox(conn, %{"type" => "Create"} = params) do - Logger.info( + # POST /relay/inbox -or- POST /internal/fetch/inbox + def inbox(conn, params) do + if params["type"] == "Create" && FederatingPlug.federating?() do + post_inbox_relayed_create(conn, params) + else + post_inbox_fallback(conn, params) + end + end + + defp post_inbox_relayed_create(conn, params) do + Logger.debug( "Signature missing or not from author, relayed Create message, fetching object from source" ) @@ -265,18 +299,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do json(conn, "ok") end - def inbox(conn, params) do + defp post_inbox_fallback(conn, params) do headers = Enum.into(conn.req_headers, %{}) - if String.contains?(headers["signature"], params["actor"]) do - Logger.info( + if headers["signature"] && params["actor"] && + String.contains?(headers["signature"], params["actor"]) do + Logger.debug( "Signature validation error for: #{params["actor"]}, make sure you are forwarding the HTTP Host header!" ) - Logger.info(inspect(conn.req_headers)) + Logger.debug(inspect(conn.req_headers)) end - json(conn, dgettext("errors", "error")) + conn + |> put_status(:bad_request) + |> json(dgettext("errors", "error")) end defp represent_service_actor(%User{} = user, conn) do @@ -310,21 +347,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> render("user.json", %{user: user}) end - def whoami(_conn, _params), do: {:error, :not_found} - def read_inbox( - %{assigns: %{user: %{nickname: nickname} = user}} = conn, + %{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{"nickname" => nickname, "page" => page?} = params ) when page? in [true, "true"] do activities = if params["max_id"] do - ActivityPub.fetch_activities([user.ap_id | user.following], %{ + ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{ "max_id" => params["max_id"], "limit" => 10 }) else - ActivityPub.fetch_activities([user.ap_id | user.following], %{"limit" => 10}) + ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{"limit" => 10}) end conn @@ -336,7 +371,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do }) end - def read_inbox(%{assigns: %{user: %{nickname: nickname} = user}} = conn, %{ + def read_inbox(%{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{ "nickname" => nickname }) do with {:ok, user} <- User.ensure_keys_present(user) do @@ -347,15 +382,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - def read_inbox(%{assigns: %{user: nil}} = conn, %{"nickname" => nickname}) do - err = dgettext("errors", "can't read inbox of %{nickname}", nickname: nickname) - - conn - |> put_status(:forbidden) - |> json(err) - end - - def read_inbox(%{assigns: %{user: %{nickname: as_nickname}}} = conn, %{ + def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ "nickname" => nickname }) do err = @@ -369,7 +396,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json(err) end - def handle_user_activity(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"])) @@ -385,26 +415,30 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do }) end - def handle_user_activity(user, %{"type" => "Delete"} = params) do + defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do with %Object{} = object <- Object.normalize(params["object"]), - true <- user.info.is_moderator || user.ap_id == object.data["actor"], - {:ok, delete} <- ActivityPub.delete(object) do + true <- user.is_moderator || user.ap_id == object.data["actor"], + {: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")} end end - def handle_user_activity(user, %{"type" => "Like"} = params) 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")} end end - def handle_user_activity(_, _) do + defp handle_user_activity(_, _) do {:error, dgettext("errors", "Unhandled activity type")} end @@ -433,7 +467,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - def update_outbox(%{assigns: %{user: user}} = conn, %{"nickname" => nickname} = _) do + def update_outbox(%{assigns: %{user: %User{} = user}} = conn, %{"nickname" => nickname}) do err = dgettext("errors", "can't update outbox of %{nickname} as %{as_nickname}", nickname: nickname, @@ -445,13 +479,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json(err) end - def errors(conn, {:error, :not_found}) do + defp errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> json(dgettext("errors", "Not found")) end - def errors(conn, _e) do + defp errors(conn, _e) do conn |> put_status(:internal_server_error) |> json(dgettext("errors", "error")) @@ -491,7 +525,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do - HTTP Code: 201 Created - HTTP Body: ActivityPub object to be inserted into another's `attachment` field """ - def upload_media(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do with {:ok, object} <- ActivityPub.upload( file, diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex new file mode 100644 index 000000000..4a247ad0c --- /dev/null +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -0,0 +1,115 @@ +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.Utils + alias Pleroma.Web.ActivityPub.Visibility + + @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 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/internal_fetch_actor.ex b/lib/pleroma/web/activity_pub/internal_fetch_actor.ex index 9213ddde7..c80272b8f 100644 --- a/lib/pleroma/web/activity_pub/internal_fetch_actor.ex +++ b/lib/pleroma/web/activity_pub/internal_fetch_actor.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.InternalFetchActor do diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 263ed11af..a0b3af432 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF do 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/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index b90193ca0..9e7800997 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_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.AntiLinkSpamPolicy do @@ -11,12 +11,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do # has the user successfully posted before? defp old_user?(%User{} = u) do - u.info.note_count > 0 || u.info.follower_count > 0 + u.note_count > 0 || u.follower_count > 0 end # does the post contain links? defp contains_links?(%{"content" => content} = _object) do content + |> Floki.parse_fragment!() |> Floki.filter_out("a.mention,a.hashtag,a[rel~=\"tag\"],a.zrl") |> Floki.attribute("a", "href") |> length() > 0 diff --git a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex index f7831bc3e..5ab9844ff 100644 --- a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.DropPolicy do @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.DropPolicy do @impl true def filter(object) do - Logger.info("REJECTING #{inspect(object)}") + Logger.debug("REJECTING #{inspect(object)}") {:reject, object} end diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex index 3a3e72910..2627a0007 100644 --- a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex +++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index b3c742954..1764bc789 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index d6d1396bc..88b0d2b39 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index 26b8539fe..dfab105a3 100644 --- a/lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_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.MediaProxyWarmingPolicy do @@ -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.info("Prefetching #{inspect(url)}") + 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/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex index ce8bc4580..06f003921 100644 --- a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/noop_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex index 878c57925..cc2ac9d08 100644 --- a/lib/pleroma/web/activity_pub/mrf/noop_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NoOpPolicy 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/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex index daa4c88ad..7abae37ae 100644 --- a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex +++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup 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 new file mode 100644 index 000000000..b0ccb63c8 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.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.ActivityPub.MRF.ObjectAgePolicy do + alias Pleroma.Config + alias Pleroma.User + + require Pleroma.Constants + + @moduledoc "Filter activities depending on their age" + @behaviour Pleroma.Web.ActivityPub.MRF + + 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]), + {:ttl, false} <- {:ttl, DateTime.diff(now, then) > max_ttl} do + {:ok, message} + else + {:ttl, true} -> + {:reject, nil} + + e -> + {:error, e} + end + end + + defp check_reject(message, actions) do + if :reject in actions do + {:reject, nil} + else + {:ok, message} + end + end + + defp check_delist(message, actions) do + if :delist in actions do + with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do + to = List.delete(message["to"], Pleroma.Constants.as_public()) ++ [user.follower_address] + cc = List.delete(message["cc"], user.follower_address) ++ [Pleroma.Constants.as_public()] + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + + {:ok, message} + else + # Unhandleable error: somebody is messing around, just drop the message. + _e -> + {:reject, nil} + end + else + {:ok, message} + end + end + + defp check_strip_followers(message, actions) do + if :strip_followers in actions do + with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do + to = List.delete(message["to"], user.follower_address) + cc = List.delete(message["cc"], user.follower_address) + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + + {:ok, message} + else + # Unhandleable error: somebody is messing around, just drop the message. + _e -> + {:reject, nil} + end + else + {:ok, message} + end + end + + @impl true + def filter(%{"type" => "Create", "published" => _} = message) do + with actions <- Config.get([:mrf_object_age, :actions]), + {:reject, _} <- check_date(message), + {:ok, message} <- check_reject(message, actions), + {:ok, message} <- check_delist(message, actions), + {:ok, message} <- check_strip_followers(message, actions) do + {:ok, message} + else + # check_date() is allowed to short-circuit the pipeline + e -> e + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + 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/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex index 5a809a321..3092f3272 100644 --- a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex +++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublic do diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 8e53296e7..b7dcb1b86 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -1,12 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @moduledoc "Filter activities depending on their origin instance" - @behaviour MRF + @behaviour Pleroma.Web.ActivityPub.MRF require Pleroma.Constants @@ -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/subchain_policy.ex b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex index 566c1e191..c9f20571f 100644 --- a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicy do @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicy do require Logger - @behaviour MRF + @behaviour Pleroma.Web.ActivityPub.MRF defp lookup_subchain(actor) do with matches <- Config.get([:mrf_subchain, :match_actor]), diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex index c1801d2ec..c310462cb 100644 --- a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.TagPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/user_allowlist_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index 7389d6a96..a927a4ed8 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allowlist_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex index 4eaea00d8..6167a74e2 100644 --- a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do with accepted_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :accept]), rejected_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :reject]), true <- - length(accepted_vocabulary) == 0 || Enum.member?(accepted_vocabulary, message_type), + Enum.empty?(accepted_vocabulary) || Enum.member?(accepted_vocabulary, message_type), false <- length(rejected_vocabulary) > 0 && Enum.member?(rejected_vocabulary, message_type), {:ok, _} <- filter(message["object"]) do 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..549e5e761 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validator.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.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.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 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"]) + :ok + 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..657cdfdb1 --- /dev/null +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -0,0 +1,59 @@ +# 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.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] + + 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 2aac4e8b9..b70cbd043 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Publisher do @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do alias Pleroma.HTTP alias Pleroma.Instances alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier @@ -47,7 +48,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do * `id`: the ActivityStreams URI of the message """ def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = params) do - Logger.info("Federating #{id} to #{inbox}") + Logger.debug("Federating #{id} to #{inbox}") %{host: host, path: path} = URI.parse(inbox) digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) @@ -140,8 +141,8 @@ defmodule Pleroma.Web.ActivityPub.Publisher do |> Enum.map(& &1.ap_id) end - defp maybe_use_sharedinbox(%User{info: %{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 @@ -156,7 +157,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do """ def determine_inbox( %Activity{data: activity_data}, - %User{info: %{source_data: data}} = user + %User{inbox: inbox} = user ) do to = activity_data["to"] || [] cc = activity_data["cc"] || [] @@ -173,7 +174,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do maybe_use_sharedinbox(user) true -> - data["inbox"] + inbox end end @@ -188,31 +189,34 @@ defmodule Pleroma.Web.ActivityPub.Publisher do recipients = recipients(actor, activity) - recipients - |> Enum.filter(&User.ap_enabled?/1) - |> Enum.map(fn %{info: %{source_data: data}} -> data["inbox"] end) - |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) - |> Instances.filter_reachable() - |> Enum.each(fn {inbox, unreachable_since} -> - %User{ap_id: ap_id} = - Enum.find(recipients, fn %{info: %{source_data: data}} -> data["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. - cc = get_cc_ap_ids(ap_id, recipients) - - json = - data - |> Map.put("cc", cc) - |> Jason.encode!() - - Pleroma.Web.Federator.Publisher.enqueue_one(__MODULE__, %{ - inbox: inbox, - json: json, - actor_id: actor.id, - id: activity.data["id"], - unreachable_since: unreachable_since - }) + inboxes = + recipients + |> Enum.filter(&User.ap_enabled?/1) + |> 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 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. + cc = get_cc_ap_ids(ap_id, recipients) + + json = + data + |> Map.put("cc", cc) + |> Jason.encode!() + + Pleroma.Web.Federator.Publisher.enqueue_one(__MODULE__, %{ + inbox: inbox, + json: json, + actor_id: actor.id, + id: activity.data["id"], + unreachable_since: unreachable_since + }) + end) end) end @@ -223,7 +227,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do public = is_public?(activity) if public && Config.get([:instance, :allow_relay]) do - Logger.info(fn -> "Relaying #{activity.data["id"]} out" end) + Logger.debug(fn -> "Relaying #{activity.data["id"]} out" end) Relay.publish(activity) end @@ -259,6 +263,10 @@ defmodule Pleroma.Web.ActivityPub.Publisher do "rel" => "self", "type" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "href" => user.ap_id + }, + %{ + "rel" => "http://ostatus.org/schema/1.0/subscribe", + "template" => "#{Pleroma.Web.base_url()}/ostatus_subscribe?acct={uri}" } ] end diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index de80612f1..729c23af7 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Relay do @@ -9,15 +9,20 @@ defmodule Pleroma.Web.ActivityPub.Relay do alias Pleroma.Web.ActivityPub.ActivityPub require Logger + @relay_nickname "relay" + def get_actor do actor = - "#{Pleroma.Web.Endpoint.url()}/relay" - |> User.get_or_create_service_actor_by_ap_id() + relay_ap_id() + |> User.get_or_create_service_actor_by_ap_id(@relay_nickname) - {:ok, actor} = User.update_info(actor, &User.Info.set_invisible(&1, true)) actor end + def relay_ap_id do + "#{Pleroma.Web.Endpoint.url()}/relay" + end + @spec follow(String.t()) :: {:ok, Activity.t()} | {:error, any()} def follow(target_instance) do with %User{} = local_user <- get_actor(), @@ -55,14 +60,28 @@ defmodule Pleroma.Web.ActivityPub.Relay do def publish(_), do: {:error, "Not implemented"} - @spec list() :: {:ok, [String.t()]} | {:error, any()} - def list do - with %User{following: following} = _user <- get_actor() do - list = - following + @spec list(boolean()) :: {:ok, [String.t()]} | {:error, any()} + def list(with_not_accepted \\ false) do + with %User{} = user <- get_actor() do + accepted = + user + |> User.following() |> Enum.map(fn entry -> URI.parse(entry).host end) |> Enum.uniq() + list = + if with_not_accepted do + without_accept = + user + |> Pleroma.Activity.following_requests_for_actor() + |> Enum.map(fn a -> URI.parse(a.data["object"]).host <> " (no Accept received)" end) + |> Enum.uniq() + + accepted ++ without_accept + else + accepted + end + {:ok, list} else error -> format_error(error) 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..bfc2ab845 --- /dev/null +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -0,0 +1,133 @@ +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 + + 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 4a250d131..80701bb63 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier do @@ -7,11 +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 @@ -39,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 @@ -155,10 +162,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do when not is_nil(in_reply_to) do in_reply_to_id = prepare_in_reply_to(in_reply_to) object = Map.put(object, "inReplyToAtomUri", in_reply_to_id) + depth = (options[:depth] || 0) + 1 - if Federator.allowed_incoming_reply_depth?(options[:depth]) do + if Federator.allowed_thread_distance?(depth) do with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options), - %Activity{} = _ <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do + %Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do object |> Map.put("inReplyTo", replied_object.data["id"]) |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) @@ -200,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}] - - data - |> Map.put("mediaType", media_type) - |> Map.put("url", url) + url = + cond do + is_list(data["url"]) -> List.first(data["url"]) + is_map(data["url"]) -> data["url"] + true -> nil + end + + 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) @@ -227,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) @@ -311,7 +350,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) when is_binary(reply_id) do - with true <- Federator.allowed_incoming_reply_depth?(options[:depth]), + with true <- Federator.allowed_thread_distance?(options[:depth]), {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do Map.put(object, "type", "Answer") else @@ -321,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), @@ -386,7 +437,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def handle_incoming(%{"id" => nil}, _options), do: :error def handle_incoming(%{"id" => ""}, _options), do: :error # length of https:// = 8, should validate better, but good enough for now. - def handle_incoming(%{"id" => id}, _options) when not (is_binary(id) and length(id) > 8), + def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id) < 8, do: :error # TODO: validate those with a Ecto scheme @@ -396,7 +447,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, options ) - when objtype in ["Article", "Note", "Video", "Page", "Question", "Answer"] do + when objtype in ["Article", "Event", "Note", "Video", "Page", "Question", "Answer", "Audio"] do actor = Containment.get_actor(data) data = @@ -405,8 +456,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do with nil <- Activity.get_create_by_object_ap_id(object["id"]), {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do - options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) - object = fix_object(data["object"], options) + object = fix_object(object, options) params = %{ to: data["to"], @@ -423,7 +473,20 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do ]) } - ActivityPub.create(params) + with {:ok, created_activity} <- ActivityPub.create(params) do + reply_depth = (options[:depth] || 0) + 1 + + if Federator.allowed_thread_distance?(reply_depth) do + for reply_id <- replies(object) do + Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{ + "id" => reply_id, + "depth" => reply_depth + }) + end + end + + {:ok, created_activity} + end else %Activity{} = activity -> {:ok, activity} _e -> :error @@ -441,7 +504,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> fix_addressing with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do - options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) + reply_depth = (options[:depth] || 0) + 1 + options = Keyword.put(options, :depth, reply_depth) object = fix_object(object, options) params = %{ @@ -474,7 +538,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {_, false} <- {:user_locked, User.locked?(followed)}, {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, {_, {:ok, _}} <- - {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")} do + {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, + {:ok, _relationship} <- + FollowingRelationship.update(follower, followed, :follow_accept) do ActivityPub.accept(%{ to: [follower.ap_id], actor: followed, @@ -484,6 +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, :follow_reject) ActivityPub.reject(%{ to: [follower.ap_id], @@ -494,6 +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, :follow_reject) ActivityPub.reject(%{ to: [follower.ap_id], @@ -503,6 +571,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do }) {:user_locked, true} -> + {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_pending) :noop end @@ -522,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, _follower} = User.follow(follower, followed) 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", @@ -532,7 +604,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do activity_id: id }) else - _e -> :error + _e -> + :error end end @@ -545,6 +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, :follow_reject), {:ok, activity} <- ActivityPub.reject(%{ to: follow_activity.data["to"], @@ -554,25 +628,47 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do local: false, activity_id: id }) do - User.unfollow(follower, followed) - {:ok, activity} else _e -> :error end end + @misskey_reactions %{ + "like" => "👍", + "love" => "❤️", + "laugh" => "😆", + "hmm" => "🤔", + "surprise" => "😮", + "congrats" => "🎉", + "angry" => "💢", + "confused" => "😥", + "rip" => "😇", + "pudding" => "🍮", + "star" => "⭐" + } + + @doc "Rewrite misskey likes into EmojiReacts" def handle_incoming( - %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data, - _options + %{ + "type" => "Like", + "_misskey_reaction" => reaction + } = 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 + data + |> Map.put("type", "EmojiReact") + |> Map.put("content", @misskey_reactions[reaction] || reaction) + |> handle_incoming(options) + end + + def handle_incoming(%{"type" => type} = data, _options) when type in ["Like", "EmojiReact"] 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 @@ -605,23 +701,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object) - banner = new_user_data[:info][:banner] - locked = new_user_data[:info][:locked] || false - attachment = get_in(new_user_data, [:info, :source_data, "attachment"]) || [] - invisible = new_user_data[:info][:invisible] || false - - fields = - attachment - |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) - |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) - - update_data = - new_user_data - |> Map.take([:name, :bio, :avatar]) - |> Map.put(:info, %{banner: banner, locked: locked, fields: fields, invisible: invisible}) - actor - |> User.upgrade_changeset(update_data, true) + |> User.remote_user_changeset(new_user_data) |> User.update_and_set_cache() ActivityPub.update(%{ @@ -639,55 +720,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 @@ -713,17 +764,29 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def handle_incoming( %{ "type" => "Undo", - "object" => %{"type" => "Block", "object" => blocked}, - "actor" => blocker, - "id" => id - } = _data, + "object" => %{"type" => type} + } = 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) + ) + when type in ["Like", "EmojiReact", "Announce", "Block"] do + with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} + 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 @@ -746,36 +809,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def handle_incoming( %{ - "type" => "Undo", - "object" => %{"type" => "Like", "object" => object_id}, - "actor" => _actor, - "id" => id - } = data, + "type" => "Move", + "actor" => origin_actor, + "object" => origin_actor, + "target" => target_actor + }, _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) + with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor), + {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor), + true <- origin_actor in target_user.also_known_as do + ActivityPub.move(origin_user, target_user, false) else _e -> :error end @@ -825,6 +869,50 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def set_reply_to_uri(obj), do: obj + @doc """ + Serialized Mastodon-compatible `replies` collection containing _self-replies_. + Based on Mastodon's ActivityPub::NoteSerializer#replies. + """ + def set_replies(obj_data) do + replies_uris = + with limit when limit > 0 <- + Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0), + %Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do + object + |> Object.self_replies() + |> select([o], fragment("?->>'id'", o.data)) + |> limit(^limit) + |> Repo.all() + else + _ -> [] + end + + set_replies(obj_data, replies_uris) + end + + defp set_replies(obj, []) do + obj + end + + defp set_replies(obj, replies_uris) do + replies_collection = %{ + "type" => "Collection", + "items" => replies_uris + } + + Map.merge(obj, %{"replies" => replies_collection}) + end + + def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do + items + end + + def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do + items + end + + def replies(_), do: [] + # Prepares the object of an outgoing create activity. def prepare_object(object) do object @@ -836,6 +924,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> prepare_attachments |> set_conversation |> set_reply_to_uri + |> set_replies |> strip_internal_fields |> strip_internal_tags |> set_type @@ -971,13 +1060,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end def add_mention_tags(object) do - mentions = - object - |> Utils.get_notified_from_object() - |> Enum.map(&build_mention_tag/1) + {enabled_receivers, disabled_receivers} = Utils.get_notified_from_object(object) + potential_receivers = enabled_receivers ++ disabled_receivers + mentions = Enum.map(potential_receivers, &build_mention_tag/1) tags = object["tag"] || [] - Map.put(object, "tag", tags ++ mentions) end @@ -985,9 +1072,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"} end - def take_emoji_tags(%User{info: %{emoji: emoji} = _user_info} = _user) 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 @@ -1016,6 +1103,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) @@ -1034,18 +1125,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 - defp strip_internal_fields(object) do - object - |> Map.drop(Pleroma.Constants.object_internal_fields()) + def strip_internal_fields(object) do + Map.drop(object, Pleroma.Constants.object_internal_fields()) end defp strip_internal_tags(%{"tag" => tags} = object) do @@ -1060,54 +1157,29 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do # we pass a fake user so that the followers collection is stripped away old_follower_address = User.ap_followers(%User{nickname: user.nickname}) - q = - from( - u in User, - where: ^old_follower_address in u.following, - update: [ - set: [ - following: - fragment( - "array_replace(?,?,?)", - u.following, - ^old_follower_address, - ^user.follower_address - ) - ] - ] - ) - - Repo.update_all(q, []) - - q = - from( - a in Activity, - where: ^old_follower_address in a.recipients, - update: [ - set: [ - recipients: - fragment( - "array_replace(?,?,?)", - a.recipients, - ^old_follower_address, - ^user.follower_address - ) - ] + from( + a in Activity, + where: ^old_follower_address in a.recipients, + update: [ + set: [ + recipients: + fragment( + "array_replace(?,?,?)", + a.recipients, + ^old_follower_address, + ^user.follower_address + ) ] - ) - - Repo.update_all(q, []) + ] + ) + |> Repo.update_all([]) end 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} @@ -1115,9 +1187,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 6b28df92c..f2375bcc4 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -1,17 +1,20 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Utils do alias Ecto.Changeset alias Ecto.UUID alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.Endpoint alias Pleroma.Web.Router.Helpers @@ -20,7 +23,17 @@ defmodule Pleroma.Web.ActivityPub.Utils do require Logger require Pleroma.Constants - @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer", "Audio"] + @supported_object_types [ + "Article", + "Note", + "Event", + "Video", + "Page", + "Question", + "Answer", + "Audio" + ] + @strip_status_report_states ~w(closed resolved) @supported_report_states ~w(open closed resolved) @valid_visibilities ~w(public unlisted private direct) @@ -33,8 +46,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do Map.put(params, "actor", get_ap_id(params["actor"])) end - @spec determine_explicit_mentions(map()) :: map() - def determine_explicit_mentions(%{"tag" => tag} = _) when is_list(tag) do + @spec determine_explicit_mentions(map()) :: [any] + def determine_explicit_mentions(%{"tag" => tag}) when is_list(tag) do Enum.flat_map(tag, fn %{"type" => "Mention", "href" => href} -> [href] _ -> [] @@ -49,26 +62,28 @@ defmodule Pleroma.Web.ActivityPub.Utils do def determine_explicit_mentions(_), do: [] - @spec recipient_in_collection(any(), any()) :: boolean() - defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll - defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll - defp recipient_in_collection(_, _), do: false + @spec label_in_collection?(any(), any()) :: boolean() + defp label_in_collection?(ap_id, coll) when is_binary(coll), do: ap_id == coll + defp label_in_collection?(ap_id, coll) when is_list(coll), do: ap_id in coll + defp label_in_collection?(_, _), do: false + + @spec label_in_message?(String.t(), map()) :: boolean() + def label_in_message?(label, params), + do: + [params["to"], params["cc"], params["bto"], params["bcc"]] + |> Enum.any?(&label_in_collection?(label, &1)) + + @spec unaddressed_message?(map()) :: boolean() + def unaddressed_message?(params), + do: + [params["to"], params["cc"], params["bto"], params["bcc"]] + |> Enum.all?(&is_nil(&1)) @spec recipient_in_message(User.t(), User.t(), map()) :: boolean() - def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do - addresses = [params["to"], params["cc"], params["bto"], params["bcc"]] - - cond do - Enum.any?(addresses, &recipient_in_collection(ap_id, &1)) -> true - # if the message is unaddressed at all, then assume it is directly addressed - # to the recipient - Enum.all?(addresses, &is_nil(&1)) -> true - # if the message is sent from somebody the user is following, then assume it - # is addressed to the recipient - User.following?(recipient, actor) -> true - true -> false - end - end + def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params), + do: + label_in_message?(ap_id, params) || unaddressed_message?(params) || + User.following?(recipient, actor) defp extract_list(target) when is_binary(target), do: [target] defp extract_list(lst) when is_list(lst), do: lst @@ -76,8 +91,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do def maybe_splice_recipient(ap_id, params) do need_splice? = - !recipient_in_collection(ap_id, params["to"]) && - !recipient_in_collection(ap_id, params["cc"]) + !label_in_collection?(ap_id, params["to"]) && + !label_in_collection?(ap_id, params["cc"]) if need_splice? do cc_list = extract_list(params["cc"]) @@ -155,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 @@ -251,6 +269,16 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> Repo.one() end + @doc """ + Returns like activities targeting an object + """ + def get_object_likes(%{data: %{"id" => id}}) do + id + |> Activity.Queries.by_object_id() + |> Activity.Queries.by_type("Like") + |> Repo.all() + end + @spec make_like_data(User.t(), map(), String.t()) :: map() def make_like_data( %User{ap_id: ap_id} = actor, @@ -282,13 +310,23 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> maybe_put("id", activity_id) end - @spec update_element_in_object(String.t(), list(any), Object.t()) :: + def make_emoji_reaction_data(user, object, emoji, activity_id) do + make_like_data(user, object, activity_id) + |> Map.put("type", "EmojiReact") + |> Map.put("content", emoji) + end + + @spec update_element_in_object(String.t(), list(any), Object.t(), integer() | nil) :: {:ok, Object.t()} | {:error, Ecto.Changeset.t()} - def update_element_in_object(property, element, object) do + def update_element_in_object(property, element, object, count \\ nil) do + length = + count || + length(element) + data = Map.merge( object.data, - %{"#{property}_count" => length(element), "#{property}s" => element} + %{"#{property}_count" => length, "#{property}s" => element} ) object @@ -296,6 +334,69 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> Object.update_and_set_cache() end + @spec add_emoji_reaction_to_object(Activity.t(), Object.t()) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} + + def add_emoji_reaction_to_object( + %Activity{data: %{"content" => emoji, "actor" => actor}}, + object + ) do + reactions = get_cached_emoji_reactions(object) + + new_reactions = + case Enum.find_index(reactions, fn [candidate, _] -> emoji == candidate end) do + nil -> + reactions ++ [[emoji, [actor]]] + + index -> + List.update_at( + reactions, + index, + fn [emoji, users] -> [emoji, Enum.uniq([actor | users])] end + ) + end + + count = emoji_count(new_reactions) + + update_element_in_object("reaction", new_reactions, object, count) + end + + def emoji_count(reactions_list) do + Enum.reduce(reactions_list, 0, fn [_, users], acc -> acc + length(users) end) + end + + def remove_emoji_reaction_from_object( + %Activity{data: %{"content" => emoji, "actor" => actor}}, + object + ) do + reactions = get_cached_emoji_reactions(object) + + new_reactions = + case Enum.find_index(reactions, fn [candidate, _] -> emoji == candidate end) do + nil -> + reactions + + index -> + List.update_at( + reactions, + index, + fn [emoji, users] -> [emoji, List.delete(users, actor)] end + ) + |> Enum.reject(fn [_, users] -> Enum.empty?(users) end) + end + + count = emoji_count(new_reactions) + update_element_in_object("reaction", new_reactions, object, count) + end + + def get_cached_emoji_reactions(object) do + if is_list(object.data["reactions"]) do + object.data["reactions"] + else + [] + end + end + @spec add_like_to_object(Activity.t(), Object.t()) :: {:ok, Object.t()} | {:error, Ecto.Changeset.t()} def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do @@ -330,7 +431,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do @doc """ Updates a follow activity's state (for locked accounts). """ - @spec update_follow_state_for_all(Activity.t(), String.t()) :: {:ok, Activity} | {:error, any()} + @spec update_follow_state_for_all(Activity.t(), String.t()) :: {:ok, Activity | nil} def update_follow_state_for_all( %Activity{data: %{"actor" => actor, "object" => object}} = activity, state @@ -343,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 @@ -393,10 +491,32 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> Repo.one() end + def fetch_latest_undo(%User{ap_id: ap_id}) do + "Undo" + |> Activity.Queries.by_type() + |> where(actor: ^ap_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() + end + + def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do + %{data: %{"object" => object_ap_id}} = Activity.get_by_id(internal_activity_id) + + "EmojiReact" + |> Activity.Queries.by_type() + |> where(actor: ^ap_id) + |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji)) + |> Activity.Queries.by_object_id(object_ap_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() + end + #### 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 @@ -446,39 +566,19 @@ 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 + def make_undo_data( + %User{ap_id: actor, follower_address: follower_address}, + %Activity{ + data: %{"id" => undone_activity_id, "context" => context}, + actor: undone_activity_actor + }, + activity_id \\ nil ) 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"]], + "actor" => actor, + "object" => undone_activity_id, + "to" => [follower_address, undone_activity_actor], "cc" => [Pleroma.Constants.as_public()], "context" => context } @@ -553,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 @@ -611,56 +701,68 @@ defmodule Pleroma.Web.ActivityPub.Utils do def make_flag_data(_, _), do: %{} defp build_flag_object(%{account: account, statuses: statuses} = _) do - [account.ap_id] ++ - Enum.map(statuses || [], fn + [account.ap_id] ++ build_flag_object(%{statuses: statuses}) + end + + defp build_flag_object(%{statuses: statuses}) do + Enum.map(statuses || [], &build_flag_object/1) + end + + defp build_flag_object(act) when is_map(act) or is_binary(act) do + id = + case act do %Activity{} = act -> act.data["id"] act when is_map(act) -> act["id"] act when is_binary(act) -> act - end) - end + end - defp build_flag_object(_), do: [] + case Activity.get_by_ap_id_with_object(id) do + %Activity{} = activity -> + %{ + "type" => "Note", + "id" => activity.data["id"], + "content" => activity.object.data["content"], + "published" => activity.object.data["published"], + "actor" => + AccountView.render("show.json", %{ + user: User.get_by_ap_id(activity.object.data["actor"]) + }) + } - @doc """ - Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after - the first one to `pages_left` pages. - If the amount of pages is higher than the collection has, it returns whatever was there. - """ - def fetch_ordered_collection(from, pages_left, acc \\ []) do - with {:ok, response} <- Tesla.get(from), - {:ok, collection} <- Jason.decode(response.body) do - case collection["type"] do - "OrderedCollection" -> - # If we've encountered the OrderedCollection and not the page, - # just call the same function on the page address - fetch_ordered_collection(collection["first"], pages_left) - - "OrderedCollectionPage" -> - if pages_left > 0 do - # There are still more pages - if Map.has_key?(collection, "next") do - # There are still more pages, go deeper saving what we have into the accumulator - fetch_ordered_collection( - collection["next"], - pages_left - 1, - acc ++ collection["orderedItems"] - ) - else - # No more pages left, just return whatever we already have - acc ++ collection["orderedItems"] - end - else - # Got the amount of pages needed, add them all to the accumulator - acc ++ collection["orderedItems"] - end - - _ -> - {:error, "Not an OrderedCollection or OrderedCollectionPage"} - end + _ -> + %{"id" => id, "deleted" => true} end end + defp build_flag_object(_), do: [] + #### Report-related helpers + def get_reports(params, page, page_size) do + params = + params + |> Map.put("type", "Flag") + |> Map.put("skip_preload", true) + |> Map.put("preload_report_notes", true) + |> Map.put("total", true) + |> Map.put("limit", page_size) + |> Map.put("offset", (page - 1) * page_size) + + ActivityPub.fetch_activities([], params, :offset) + end + + def update_report_state(%Activity{} = activity, state) + when state in @strip_status_report_states do + {:ok, stripped_activity} = strip_report_status_data(activity) + + new_data = + activity.data + |> Map.put("state", state) + |> Map.put("object", stripped_activity.data["object"]) + + activity + |> Changeset.change(data: new_data) + |> Repo.update() + end def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do new_data = Map.put(activity.data, "state", state) @@ -670,8 +772,34 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> Repo.update() end + def update_report_state(activity_ids, state) when state in @supported_report_states do + activities_num = length(activity_ids) + + from(a in Activity, where: a.id in ^activity_ids) + |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)]) + |> Repo.update_all([]) + |> case do + {^activities_num, _} -> :ok + _ -> {:error, activity_ids} + end + end + def update_report_state(_, _), do: {:error, "Unsupported state"} + def strip_report_status_data(activity) do + [actor | reported_activities] = activity.data["object"] + + stripped_activities = + Enum.map(reported_activities, fn + act when is_map(act) -> act["id"] + act when is_binary(act) -> act + end) + + new_data = put_in(activity.data, ["object"], [actor | stripped_activities]) + + {:ok, %{activity | data: new_data}} + end + def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do [to, cc, recipients] = activity diff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex index d8a3ec288..e555e9999 100644 --- a/lib/pleroma/web/activity_pub/views/object_view.ex +++ b/lib/pleroma/web/activity_pub/views/object_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectView do diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 8c5b4460b..34590b16d 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.UserView do @@ -73,25 +73,17 @@ defmodule Pleroma.Web.ActivityPub.UserView do {:ok, _, public_key} = Keys.keys_from_pem(user.keys) public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) public_key = :public_key.pem_encode([public_key]) + user = User.sanitize_html(user) endpoints = render("endpoints.json", %{user: user}) emoji_tags = Transmogrifier.take_emoji_tags(user) - fields = - user.info - |> User.Info.fields() - |> Enum.map(fn %{"name" => name, "value" => value} -> - %{ - "name" => Pleroma.HTML.strip_tags(name), - "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) - } - end) - |> Enum.map(&Map.put(&1, "type", "PropertyValue")) + fields = Enum.map(user.fields, &Map.put(&1, "type", "PropertyValue")) %{ "id" => user.ap_id, - "type" => "Person", + "type" => user.actor_type, "following" => "#{user.ap_id}/following", "followers" => "#{user.ap_id}/followers", "inbox" => "#{user.ap_id}/inbox", @@ -100,7 +92,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "name" => user.name, "summary" => user.bio, "url" => user.ap_id, - "manuallyApprovesFollowers" => user.info.locked, + "manuallyApprovesFollowers" => user.locked, "publicKey" => %{ "id" => "#{user.ap_id}#main-key", "owner" => user.ap_id, @@ -108,8 +100,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do }, "endpoints" => endpoints, "attachment" => fields, - "tag" => (user.info.source_data["tag"] || []) ++ emoji_tags, - "discoverable" => user.info.discoverable + "tag" => emoji_tags, + "discoverable" => user.discoverable } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) @@ -117,8 +109,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do end def render("following.json", %{user: user, page: page} = opts) do - showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_follows - showing_count = showing_items || !user.info.hide_follows_count + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_follows + showing_count = showing_items || !user.hide_follows_count query = User.get_friends_query(user) query = from(user in query, select: [:ap_id]) @@ -136,8 +128,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do end def render("following.json", %{user: user} = opts) do - showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_follows - showing_count = showing_items || !user.info.hide_follows_count + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_follows + showing_count = showing_items || !user.hide_follows_count query = User.get_friends_query(user) query = from(user in query, select: [:ap_id]) @@ -156,7 +148,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "totalItems" => total, "first" => if showing_items do - collection(following, "#{user.ap_id}/following", 1, !user.info.hide_follows) + collection(following, "#{user.ap_id}/following", 1, !user.hide_follows) else "#{user.ap_id}/following?page=1" end @@ -165,8 +157,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do end def render("followers.json", %{user: user, page: page} = opts) do - showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_followers - showing_count = showing_items || !user.info.hide_followers_count + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_followers + showing_count = showing_items || !user.hide_followers_count query = User.get_followers_query(user) query = from(user in query, select: [:ap_id]) @@ -184,8 +176,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do end def render("followers.json", %{user: user} = opts) do - showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_followers - showing_count = showing_items || !user.info.hide_followers_count + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_followers + showing_count = showing_items || !user.hide_followers_count query = User.get_followers_query(user) query = from(user in query, select: [:ap_id]) @@ -201,7 +193,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do %{ "id" => "#{user.ap_id}/followers", "type" => "OrderedCollection", - "totalItems" => total, "first" => if showing_items do collection(followers, "#{user.ap_id}/followers", 1, showing_items, total) @@ -209,6 +200,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "#{user.ap_id}/followers?page=1" end } + |> maybe_put_total_items(showing_count, total) |> Map.merge(Utils.make_json_ld_header()) end @@ -251,6 +243,12 @@ defmodule Pleroma.Web.ActivityPub.UserView do |> Map.merge(Utils.make_json_ld_header()) end + defp maybe_put_total_items(map, false, _total), do: map + + defp maybe_put_total_items(map, true, total) do + Map.put(map, "totalItems", total) + end + def collection(collection, iri, page, show_items \\ true, total \\ nil) do offset = (page - 1) * 10 items = Enum.slice(collection, offset, 10) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 270d0fa02..453a6842e 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Visibility do @@ -7,15 +7,17 @@ defmodule Pleroma.Web.ActivityPub.Visibility do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.ActivityPub.Utils require Pleroma.Constants @spec is_public?(Object.t() | Activity.t() | map()) :: boolean() def is_public?(%Object{data: %{"type" => "Tombstone"}}), do: false def is_public?(%Object{data: data}), do: is_public?(data) + def is_public?(%Activity{data: %{"type" => "Move"}}), do: true def is_public?(%Activity{data: data}), do: is_public?(data) def is_public?(%{"directMessage" => true}), do: false - def is_public?(data), do: Pleroma.Constants.as_public() in (data["to"] ++ (data["cc"] || [])) + def is_public?(data), do: Utils.label_in_message?(Pleroma.Constants.as_public(), data) def is_private?(activity) do with false <- is_public?(activity), @@ -42,6 +44,7 @@ defmodule Pleroma.Web.ActivityPub.Visibility do def is_list?(%{data: %{"listMessage" => _}}), do: true def is_list?(_), do: false + @spec visible_for_user?(Activity.t(), User.t() | nil) :: boolean() def visible_for_user?(%{actor: ap_id}, %User{ap_id: ap_id}), do: true def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{} = user) do @@ -53,14 +56,21 @@ defmodule Pleroma.Web.ActivityPub.Visibility do def visible_for_user?(%{data: %{"listMessage" => _}}, nil), do: false - def visible_for_user?(activity, nil) do - is_public?(activity) + def visible_for_user?(%{local: local} = activity, nil) do + cfg_key = + if local, + do: :local, + else: :remote + + if Pleroma.Config.get([:restrict_unauthenticated, :activities, cfg_key]), + do: false, + else: is_public?(activity) end def visible_for_user?(activity, user) do - x = [user.ap_id | user.following] + x = [user.ap_id | User.following(user)] y = [activity.actor] ++ activity.data["to"] ++ (activity.data["cc"] || []) - visible_for_user?(activity, nil) || Enum.any?(x, &(&1 in y)) + is_public?(activity) || Enum.any?(x, &(&1 in y)) end def entire_thread_visible_for_user?(%Activity{} = activity, %User{} = user) do diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index b6d3f79c8..647ceb3ba 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -1,18 +1,29 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.AdminAPIController do use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.ReportNote + alias Pleroma.Stats 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.Config alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView alias Pleroma.Web.AdminAPI.Report @@ -20,29 +31,28 @@ 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 - import Pleroma.Web.ControllerHelper, only: [json_response: 3] - require Logger + @descriptions Pleroma.Docs.JSON.compile() + @users_page_size 50 + plug( OAuthScopesPlug, - %{scopes: ["read:accounts"]} - when action in [:list_users, :user_show, :right_get, :invites] + %{scopes: ["read:accounts"], admin: true} + when action in [:list_users, :user_show, :right_get, :show_user_credentials] ) plug( OAuthScopesPlug, - %{scopes: ["write:accounts"]} + %{scopes: ["write:accounts"], admin: true} when action in [ - :get_invite_token, - :revoke_invite, - :email_invite, :get_password_reset, - :user_follow, - :user_unfollow, + :force_password_reset, :user_delete, :users_create, :user_toggle_activation, @@ -51,66 +61,97 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :tag_users, :untag_users, :right_add, + :right_add_multiple, :right_delete, - :set_activation_status + :disable_mfa, + :right_delete_multiple, + :update_user_credentials ] ) + plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :invites) + plug( OAuthScopesPlug, - %{scopes: ["read:reports"]} when action in [:list_reports, :report_show] + %{scopes: ["write:invites"], admin: true} + when action in [:create_invite_token, :revoke_invite, :email_invite] ) plug( OAuthScopesPlug, - %{scopes: ["write:reports"]} - when action in [:report_update_state, :report_respond] + %{scopes: ["write:follows"], admin: true} + when action in [:user_follow, :user_unfollow, :relay_follow, :relay_unfollow] ) plug( OAuthScopesPlug, - %{scopes: ["read:statuses"]} when action == :list_user_statuses + %{scopes: ["read:reports"], admin: true} + when action in [:list_reports, :report_show] ) plug( OAuthScopesPlug, - %{scopes: ["write:statuses"]} - when action in [:status_update, :status_delete] + %{scopes: ["write:reports"], admin: true} + when action in [:reports_update, :report_notes_create, :report_notes_delete] ) plug( OAuthScopesPlug, - %{scopes: ["read"]} - when action in [:config_show, :migrate_to_db, :migrate_from_db, :list_log] + %{scopes: ["read:statuses"], admin: true} + when action in [:list_statuses, :list_user_statuses, :list_instance_statuses, :status_show] ) plug( OAuthScopesPlug, - %{scopes: ["write"]} - when action in [:relay_follow, :relay_unfollow, :config_update] + %{scopes: ["write:statuses"], admin: true} + when action in [:status_update, :status_delete] ) - @users_page_size 50 - - action_fallback(:errors) + plug( + OAuthScopesPlug, + %{scopes: ["read"], admin: true} + when action in [ + :config_show, + :list_log, + :stats, + :relay_list, + :config_descriptions, + :need_reboot + ] + ) - def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do - user = User.get_cached_by_nickname(nickname) - User.delete(user) + plug( + OAuthScopesPlug, + %{scopes: ["write"], admin: true} + 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 + ] + ) - ModerationLog.insert_log(%{ - actor: admin, - subject: [user], - action: "delete" - }) + action_fallback(:errors) - 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, @@ -227,7 +268,25 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end + def list_instance_statuses(conn, %{"instance" => instance} = params) do + with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true + {page, page_size} = page_params(params) + + activities = + ActivityPub.fetch_statuses(nil, %{ + "instance" => instance, + "limit" => page_size, + "offset" => (page - 1) * page_size, + "exclude_reblogs" => !with_reblogs && "true" + }) + + conn + |> put_view(AdminAPI.StatusView) + |> render("index.json", %{activities: activities, as: :activity}) + end + def list_user_statuses(conn, %{"nickname" => nickname} = params) do + with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true godmode = params["godmode"] == "true" || params["godmode"] == true with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do @@ -236,11 +295,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do activities = ActivityPub.fetch_user_activities(user, nil, %{ "limit" => page_size, - "godmode" => godmode + "godmode" => godmode, + "exclude_reblogs" => !with_reblogs && "true" }) conn - |> put_view(StatusView) + |> put_view(MastodonAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} @@ -250,9 +310,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do user = User.get_cached_by_nickname(nickname) - {:ok, updated_user} = User.deactivate(user, !user.info.deactivated) + {:ok, updated_user} = User.deactivate(user, !user.deactivated) - action = if user.info.deactivated, do: "activate", else: "deactivate" + action = if user.deactivated, do: "activate", else: "deactivate" ModerationLog.insert_log(%{ actor: admin, @@ -334,16 +394,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do email: params["email"] } - with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)), - do: - conn - |> json( - AccountView.render("index.json", - users: users, - count: count, - page_size: page_size - ) - ) + 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) @@ -364,11 +420,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do "nicknames" => nicknames }) when permission_group in ["moderator", "admin"] do - info = Map.put(%{}, "is_" <> permission_group, true) + update = %{:"is_#{permission_group}" => true} users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - User.update_info(users, &User.Info.admin_api_update(&1, info)) + for u <- users, do: User.admin_api_update(u, update) ModerationLog.insert_log(%{ action: "grant", @@ -377,7 +433,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do permission: permission_group }) - json(conn, info) + json(conn, update) end def right_add_multiple(conn, _) do @@ -389,12 +445,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do "nickname" => nickname }) when permission_group in ["moderator", "admin"] do - info = Map.put(%{}, "is_" <> permission_group, true) + fields = %{:"is_#{permission_group}" => true} {:ok, user} = nickname |> User.get_cached_by_nickname() - |> User.update_info(&User.Info.admin_api_update(&1, info)) + |> User.admin_api_update(fields) ModerationLog.insert_log(%{ action: "grant", @@ -403,7 +459,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do permission: permission_group }) - json(conn, info) + json(conn, fields) end def right_add(conn, _) do @@ -415,8 +471,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do conn |> json(%{ - is_moderator: user.info.is_moderator, - is_admin: user.info.is_admin + is_moderator: user.is_moderator, + is_admin: user.is_admin }) end @@ -429,11 +485,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do ) when permission_group in ["moderator", "admin"] do with false <- Enum.member?(nicknames, admin_nickname) do - info = Map.put(%{}, "is_" <> permission_group, false) + update = %{:"is_#{permission_group}" => false} users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - User.update_info(users, &User.Info.admin_api_update(&1, info)) + for u <- users, do: User.admin_api_update(u, update) ModerationLog.insert_log(%{ action: "revoke", @@ -442,7 +498,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do permission: permission_group }) - json(conn, info) + json(conn, update) else _ -> render_error(conn, :forbidden, "You can't revoke your own admin/moderator status.") end @@ -460,12 +516,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do } ) when permission_group in ["moderator", "admin"] do - info = Map.put(%{}, "is_" <> permission_group, false) + fields = %{:"is_#{permission_group}" => false} {:ok, user} = nickname |> User.get_cached_by_nickname() - |> User.update_info(&User.Info.admin_api_update(&1, info)) + |> User.admin_api_update(fields) ModerationLog.insert_log(%{ action: "revoke", @@ -474,7 +530,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do permission: permission_group }) - json(conn, info) + json(conn, fields) end def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do @@ -527,9 +583,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 <- - Pleroma.Config.get([:instance, :invites_enabled]) && - !Pleroma.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( @@ -540,6 +595,18 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do ), {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + errors( + conn, + {:error, "To send invites you need to set the `registrations_open` option to false."} + ) + + {:invites_enabled, _} -> + errors( + conn, + {:error, "To send invites you need to set the `invites_enabled` option to true."} + ) end end @@ -596,26 +663,82 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end @doc "Force password reset for a given user" - def force_password_reset(conn, %{"nickname" => nickname}) do - (%User{local: true} = user) = User.get_cached_by_nickname(nickname) + def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - User.force_password_reset_async(user) + Enum.each(users, &User.force_password_reset_async/1) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "force_password_reset" + }) 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.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} -> + {_, {error, _}} = Enum.at(changeset.errors, 0) + json(conn, %{error: "New password #{error}."}) + + _ -> + json(conn, %{error: "Unable to change password."}) + end + end + def list_reports(conn, params) do {page, page_size} = page_params(params) - params = - params - |> Map.put("type", "Flag") - |> Map.put("skip_preload", true) - |> Map.put("total", true) - |> Map.put("limit", page_size) - |> Map.put("offset", (page - 1) * page_size) - - reports = ActivityPub.fetch_activities([], params, :offset) + reports = Utils.get_reports(params, page, page_size) conn |> put_view(ReportView) @@ -632,63 +755,114 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def report_update_state(%{assigns: %{user: admin}} = conn, %{"id" => id, "state" => state}) do - with {:ok, report} <- CommonAPI.update_report_state(id, state) do - ModerationLog.insert_log(%{ - action: "report_update", - actor: admin, - subject: report - }) + def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do + result = + reports + |> Enum.map(fn report -> + with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity + }) + + activity + else + {:error, message} -> %{id: report["id"], error: message} + end + end) - conn - |> put_view(ReportView) - |> render("show.json", Report.extract_report_info(report)) + case Enum.any?(result, &Map.has_key?(&1, :error)) do + true -> json_response(conn, :bad_request, result) + false -> json_response(conn, :no_content, "") end end - def report_respond(%{assigns: %{user: user}} = conn, %{"id" => id} = params) do - with false <- is_nil(params["status"]), - %Activity{} <- Activity.get_by_id(id) do - params = - params - |> Map.put("in_reply_to_status_id", id) - |> Map.put("visibility", "direct") + def report_notes_create(%{assigns: %{user: user}} = conn, %{ + "id" => report_id, + "content" => content + }) do + with {:ok, _} <- ReportNote.create(user.id, report_id, content) do + ModerationLog.insert_log(%{ + action: "report_note", + actor: user, + subject: Activity.get_by_id(report_id), + text: content + }) - {:ok, activity} = CommonAPI.post(user, params) + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + def report_notes_delete(%{assigns: %{user: user}} = conn, %{ + "id" => note_id, + "report_id" => report_id + }) do + with {:ok, note} <- ReportNote.destroy(note_id) do ModerationLog.insert_log(%{ - action: "report_response", + action: "report_note_delete", actor: user, - subject: activity, - text: params["status"] + subject: Activity.get_by_id(report_id), + text: note.content }) + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + 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(AdminAPI.StatusView) + |> render("index.json", %{activities: activities, as: :activity}) + end + + def status_show(conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do conn - |> put_view(StatusView) + |> put_view(MastodonAPI.StatusView) |> render("show.json", %{activity: activity}) else - true -> - {:param_cast, nil} - - nil -> - {:error, :not_found} + _ -> errors(conn, {:error, :not_found}) end end def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do + params = + params + |> Map.take(["sensitive", "visibility"]) + |> Map.new(fn {key, value} -> {String.to_existing_atom(key), value} end) + with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do - {:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"]) + {:ok, sensitive} = Ecto.Type.cast(:boolean, params[:sensitive]) ModerationLog.insert_log(%{ action: "status_update", actor: admin, subject: activity, sensitive: sensitive, - visibility: params["visibility"] + visibility: params[:visibility] }) conn - |> put_view(StatusView) + |> put_view(MastodonAPI.StatusView) |> render("show.json", %{activity: activity}) end end @@ -723,49 +897,148 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do |> render("index.json", %{log: log}) end - def migrate_to_db(conn, _params) do - Mix.Tasks.Pleroma.Config.run(["migrate_to_db"]) - json(conn, %{}) + def config_descriptions(conn, _params) do + descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) + + json(conn, descriptions) end - def migrate_from_db(conn, _params) do - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", Pleroma.Config.get(:env), "true"]) - json(conn, %{}) + def config_show(conn, %{"only_db" => true}) do + with :ok <- configurable_from_database(conn) do + configs = Pleroma.Repo.all(ConfigDB) + + conn + |> put_view(ConfigView) + |> render("index.json", %{configs: configs}) + end end def config_show(conn, _params) do - configs = Pleroma.Repo.all(Config) + with :ok <- configurable_from_database(conn) do + configs = ConfigDB.get_all_as_keyword() + + merged = + Config.Holder.default_config() + |> ConfigDB.merge(configs) + |> Enum.map(fn {group, values} -> + Enum.map(values, fn {key, value} -> + db = + if configs[group][key] do + ConfigDB.get_db_keys(configs[group][key], key) + end + + db_value = configs[group][key] + + merged_value = + if !is_nil(db_value) and Keyword.keyword?(db_value) and + ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do + ConfigDB.merge_group(group, key, value, db_value) + else + value + end + + setting = %{ + group: ConfigDB.convert(group), + key: ConfigDB.convert(key), + value: ConfigDB.convert(merged_value) + } + + if db, do: Map.put(setting, :db, db), else: setting + end) + end) + |> List.flatten() - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: configs}) + json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) + end end def config_update(conn, %{"configs" => configs}) do - updated = - if Pleroma.Config.get([:instance, :dynamic_configuration]) do - updated = - Enum.map(configs, fn - %{"group" => group, "key" => key, "delete" => "true"} = params -> - {:ok, config} = Config.delete(%{group: group, key: key, subkeys: params["subkeys"]}) - config - - %{"group" => group, "key" => key, "value" => value} -> - {:ok, config} = Config.update_or_create(%{group: group, key: key, value: value}) - config + with :ok <- configurable_from_database(conn) do + {_errors, results} = + 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"]}) + + %{"group" => group, "key" => key, "value" => value} -> + ConfigDB.update_or_create(%{group: group, key: key, value: value}) + end) + |> Enum.split_with(fn result -> elem(result, 0) == :error end) + + {deleted, updated} = + results + |> Enum.map(fn {:ok, config} -> + Map.put(config, :db, ConfigDB.get_db_keys(config)) + end) + |> Enum.split_with(fn config -> + Ecto.get_meta(config, :state) == :deleted + end) + + Config.TransferTask.load_and_update_env(deleted, false) + + 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) - |> Enum.reject(&is_nil(&1)) - Pleroma.Config.TransferTask.load_and_update_env() - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", Pleroma.Config.get(:env), "false"]) - updated - else - [] + if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() end - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: updated}) + conn + |> put_view(ConfigView) + |> 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 + Restarter.Pleroma.restart(Config.get(:env), 50) + + json(conn, %{}) + end + end + + def need_reboot(conn, _params) do + json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()}) + end + + defp configurable_from_database(conn) do + if Config.get(:configurable_from_database) do + :ok + else + errors( + conn, + {: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 @@ -774,25 +1047,137 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do conn |> json("ok") end - def errors(conn, {:error, :not_found}) do + def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + User.toggle_confirmation(users) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "confirm_email" + }) + + conn |> json("") + end + + def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + User.try_send_confirmation_email(users) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "resend_confirmation_email" + }) + + conn |> json("") + end + + def oauth_app_create(conn, params) do + params = + if params["name"] do + Map.put(params, "client_name", params["name"]) + else + params + end + + 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 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 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 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 stats(conn, _) do + count = Stats.get_status_visibility_count() + + conn + |> json(%{"status_visibility" => count}) + end + + defp errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> json(dgettext("errors", "Not found")) end - def errors(conn, {:error, reason}) do + defp errors(conn, {:error, reason}) do conn |> put_status(:bad_request) |> json(reason) end - def errors(conn, {:param_cast, _}) do + defp errors(conn, {:param_cast, _}) do conn |> put_status(:bad_request) |> json(dgettext("errors", "Invalid parameters")) end - def errors(conn, _) do + defp errors(conn, _) do conn |> put_status(:internal_server_error) |> json(dgettext("errors", "Something went wrong")) diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex deleted file mode 100644 index 1917a5580..000000000 --- a/lib/pleroma/web/admin_api/config.ex +++ /dev/null @@ -1,182 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.AdminAPI.Config do - use Ecto.Schema - import Ecto.Changeset - import Pleroma.Web.Gettext - alias __MODULE__ - alias Pleroma.Repo - - @type t :: %__MODULE__{} - - schema "config" do - field(:key, :string) - field(:group, :string) - field(:value, :binary) - - timestamps() - end - - @spec get_by_params(map()) :: Config.t() | nil - def get_by_params(params), do: Repo.get_by(Config, params) - - @spec changeset(Config.t(), map()) :: Changeset.t() - def changeset(config, params \\ %{}) do - config - |> cast(params, [:key, :group, :value]) - |> validate_required([:key, :group, :value]) - |> unique_constraint(:key, name: :config_group_key_index) - end - - @spec create(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} - def create(params) do - %Config{} - |> changeset(Map.put(params, :value, transform(params[:value]))) - |> Repo.insert() - end - - @spec update(Config.t(), map()) :: {:ok, Config} | {:error, Changeset.t()} - def update(%Config{} = config, %{value: value}) do - config - |> change(value: transform(value)) - |> Repo.update() - end - - @spec update_or_create(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} - def update_or_create(params) do - with %Config{} = config <- Config.get_by_params(Map.take(params, [:group, :key])) do - Config.update(config, params) - else - nil -> Config.create(params) - end - end - - @spec delete(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} - def delete(params) do - with %Config{} = config <- Config.get_by_params(Map.delete(params, :subkeys)) do - if params[:subkeys] do - updated_value = - Keyword.drop( - :erlang.binary_to_term(config.value), - Enum.map(params[:subkeys], &do_transform_string(&1)) - ) - - Config.update(config, %{value: updated_value}) - else - Repo.delete(config) - {:ok, nil} - end - else - nil -> - err = - dgettext("errors", "Config with params %{params} not found", params: inspect(params)) - - {:error, err} - end - end - - @spec from_binary(binary()) :: term() - def from_binary(binary), do: :erlang.binary_to_term(binary) - - @spec from_binary_with_convert(binary()) :: any() - def from_binary_with_convert(binary) do - from_binary(binary) - |> do_convert() - end - - defp do_convert(entity) when is_list(entity) do - for v <- entity, into: [], do: do_convert(v) - end - - defp do_convert(%Regex{} = entity), do: inspect(entity) - - defp do_convert(entity) when is_map(entity) do - for {k, v} <- entity, into: %{}, do: {do_convert(k), do_convert(v)} - end - - defp do_convert({:dispatch, [entity]}), do: %{"tuple" => [":dispatch", [inspect(entity)]]} - defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]} - - defp do_convert(entity) when is_tuple(entity), - do: %{"tuple" => do_convert(Tuple.to_list(entity))} - - defp do_convert(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity), - do: entity - - defp do_convert(entity) when is_atom(entity) do - string = to_string(entity) - - if String.starts_with?(string, "Elixir."), - do: do_convert(string), - else: ":" <> string - end - - defp do_convert("Elixir." <> module_name), do: module_name - - defp do_convert(entity) when is_binary(entity), do: entity - - @spec transform(any()) :: binary() - def transform(entity) when is_binary(entity) or is_map(entity) or is_list(entity) do - :erlang.term_to_binary(do_transform(entity)) - end - - def transform(entity), do: :erlang.term_to_binary(entity) - - defp do_transform(%Regex{} = entity), do: entity - - defp do_transform(%{"tuple" => [":dispatch", [entity]]}) do - {dispatch_settings, []} = do_eval(entity) - {:dispatch, [dispatch_settings]} - end - - defp do_transform(%{"tuple" => [":partial_chain", entity]}) do - {partial_chain, []} = do_eval(entity) - {:partial_chain, partial_chain} - end - - defp do_transform(%{"tuple" => entity}) do - Enum.reduce(entity, {}, fn val, acc -> Tuple.append(acc, do_transform(val)) end) - end - - defp do_transform(entity) when is_map(entity) do - for {k, v} <- entity, into: %{}, do: {do_transform(k), do_transform(v)} - end - - defp do_transform(entity) when is_list(entity) do - for v <- entity, into: [], do: do_transform(v) - end - - defp do_transform(entity) when is_binary(entity) do - String.trim(entity) - |> do_transform_string() - end - - defp do_transform(entity), do: entity - - defp do_transform_string("~r/" <> pattern) do - modificator = String.split(pattern, "/") |> List.last() - pattern = String.trim_trailing(pattern, "/" <> modificator) - - case modificator do - "" -> ~r/#{pattern}/ - "i" -> ~r/#{pattern}/i - "u" -> ~r/#{pattern}/u - "s" -> ~r/#{pattern}/s - end - end - - defp do_transform_string(":" <> atom), do: String.to_atom(atom) - - defp do_transform_string(value) do - if String.starts_with?(value, "Pleroma") or String.starts_with?(value, "Phoenix"), - do: String.to_existing_atom("Elixir." <> value), - else: value - end - - defp do_eval(entity) do - cleaned_string = String.replace(entity, ~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") - Code.eval_string(cleaned_string, [], requires: [], macros: []) - end -end diff --git a/lib/pleroma/web/admin_api/report.ex b/lib/pleroma/web/admin_api/report.ex index c751dc2be..8660d6520 100644 --- a/lib/pleroma/web/admin_api/report.ex +++ b/lib/pleroma/web/admin_api/report.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.Report do @@ -13,8 +13,9 @@ defmodule Pleroma.Web.AdminAPI.Report do account = User.get_cached_by_ap_id(account_ap_id) statuses = - Enum.map(status_ap_ids, fn ap_id -> - Activity.get_by_ap_id_with_object(ap_id) + Enum.map(status_ap_ids, fn + act when is_map(act) -> Activity.get_by_ap_id_with_object(act["id"]) + act when is_binary(act) -> Activity.get_by_ap_id_with_object(act) end) %{report: report, user: user, account: account, statuses: statuses} diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex index ed919833e..c28efadd5 100644 --- a/lib/pleroma/web/admin_api/search.ex +++ b/lib/pleroma/web/admin_api/search.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.Search do @@ -18,7 +18,12 @@ defmodule Pleroma.Web.AdminAPI.Search do @spec user(map()) :: {:ok, [User.t()], pos_integer()} def user(params \\ %{}) do - query = User.Query.build(params) |> order_by([u], u.nickname) + query = + params + |> Map.drop([:page, :page_size]) + |> Map.put(:exclude_service_users, true) + |> User.Query.build() + |> order_by([u], u.nickname) paginated_query = User.Query.paginate(query, params[:page] || 1, params[:page_size] || @page_size) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 441269162..46dadb5ee 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -1,14 +1,14 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.AccountView do use Pleroma.Web, :view - alias Pleroma.HTML alias Pleroma.User - alias Pleroma.User.Info + 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 @@ -25,19 +25,58 @@ 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 = HTML.strip_tags(user.name || user.nickname) + display_name = Pleroma.HTML.strip_tags(user.name || user.nickname) + user = User.sanitize_html(user, FastSanitize.Sanitizer.StripTags) %{ "id" => user.id, "avatar" => avatar, "nickname" => user.nickname, "display_name" => display_name, - "deactivated" => user.info.deactivated, + "deactivated" => user.deactivated, "local" => user.local, - "roles" => Info.roles(user.info), - "tags" => user.tags || [] + "roles" => User.roles(user), + "tags" => user.tags || [], + "confirmation_pending" => user.confirmation_pending } end @@ -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/config_view.ex b/lib/pleroma/web/admin_api/views/config_view.ex index 49add0b6e..587ef760e 100644 --- a/lib/pleroma/web/admin_api/views/config_view.ex +++ b/lib/pleroma/web/admin_api/views/config_view.ex @@ -1,21 +1,33 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.ConfigView do use Pleroma.Web, :view - def render("index.json", %{configs: configs}) do - %{ + def render("index.json", %{configs: configs} = params) do + map = %{ configs: render_many(configs, __MODULE__, "show.json", as: :config) } + + if params[:need_reboot] do + Map.put(map, :need_reboot, true) + else + map + end end def render("show.json", %{config: config}) do - %{ + map = %{ key: config.key, group: config.group, - value: Pleroma.Web.AdminAPI.Config.from_binary_with_convert(config.value) + value: Pleroma.ConfigDB.from_binary_with_convert(config.value) } + + if config.db != [] do + Map.put(map, :db, config.db) + else + map + end end end diff --git a/lib/pleroma/web/admin_api/views/moderation_log_view.ex b/lib/pleroma/web/admin_api/views/moderation_log_view.ex index e7752d1f3..112f9e0e1 100644 --- a/lib/pleroma/web/admin_api/views/moderation_log_view.ex +++ b/lib/pleroma/web/admin_api/views/moderation_log_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.ModerationLogView do diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index 101a74c63..f432b8c2c 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -1,15 +1,19 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.ReportView do use Pleroma.Web, :view + 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: @@ -37,15 +41,35 @@ 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}), - state: report.data["state"] + statuses: + StatusView.render("index.json", %{ + activities: statuses, + as: :activity + }), + state: report.data["state"], + notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}) } 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})) + def render("index_notes.json", %{notes: notes}) when is_list(notes) do + Enum.map(notes, &render(__MODULE__, "show_note.json", &1)) end - defp merge_account_views(_), do: %{} + def render("index_notes.json", _), do: [] + + def render("show_note.json", %{ + id: id, + content: content, + user_id: user_id, + inserted_at: inserted_at + }) do + user = User.get_by_id(user_id) + + %{ + id: id, + content: content, + user: merge_account_views(user), + created_at: Utils.to_masto_date(inserted_at) + } + end end diff --git a/lib/pleroma/web/admin_api/views/status_view.ex b/lib/pleroma/web/admin_api/views/status_view.ex new file mode 100644 index 000000000..500800be2 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/status_view.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.AdminAPI.StatusView do + use Pleroma.Web, :view + + require Pleroma.Constants + + 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 = MastodonAPI.StatusView.get_user(activity.data["actor"]) + + MastodonAPI.StatusView.render("show.json", opts) + |> Map.merge(%{account: merge_account_views(user)}) + 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..934f6038e --- /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{ + type: :boolean, + 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{ + type: :boolean, + 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{ + type: :boolean, + 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{ + type: :boolean, + nullable: true, + description: "html tags are stripped from all statuses requested from the API" + }, + hide_followers: %Schema{ + type: :boolean, + nullable: true, + description: "user's followers will be hidden" + }, + hide_follows: %Schema{ + type: :boolean, + nullable: true, + description: "user's follows will be hidden" + }, + hide_followers_count: %Schema{ + type: :boolean, + nullable: true, + description: "user's follower count will be hidden" + }, + hide_follows_count: %Schema{ + type: :boolean, + nullable: true, + description: "user's follow count will be hidden" + }, + hide_favorites: %Schema{ + type: :boolean, + nullable: true, + description: "user's favorites timeline will be hidden" + }, + show_role: %Schema{ + type: :boolean, + 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{ + type: :boolean, + nullable: true, + description: "Skip filtering out broken threads" + }, + allow_following_move: %Schema{ + type: :boolean, + 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{ + type: :boolean, + 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{ + type: :boolean, + 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/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/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex new file mode 100644 index 000000000..7310c1c4d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -0,0 +1,229 @@ +# 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 + + 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{ + type: :bolean, + 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{ + type: :boolean, + nullable: true, + description: + "Should the server irreversibly drop matching entities from home and notifications?" + }, + whole_word: %Schema{ + type: :boolean, + 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..880bd3f1b --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -0,0 +1,169 @@ +# 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"} + }, + example: %{ + "avatar_upload_limit" => 2_000_000, + "background_upload_limit" => 4_000_000, + "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..64adc5319 --- /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 + + defp 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/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..882177c96 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/report_operation.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.ApiSpec.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + 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{ + type: :boolean, + 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..4b284c537 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -0,0 +1,517 @@ +# 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.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{ + type: :boolean, + nullable: true, + description: "Allow multiple choices?" + }, + hide_totals: %Schema{ + type: :boolean, + 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{ + type: :boolean, + 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{ + type: :boolean, + 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 + + defp 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..cf6dcb068 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -0,0 +1,226 @@ +# 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.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{ + type: :boolean, + nullable: true, + description: "Receive follow notifications?" + }, + favourite: %Schema{ + type: :boolean, + nullable: true, + description: "Receive favourite notifications?" + }, + reblog: %Schema{ + type: :boolean, + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + type: :boolean, + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + type: :boolean, + 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{ + type: :boolean, + nullable: true, + description: "Receive follow notifications?" + }, + favourite: %Schema{ + type: :boolean, + nullable: true, + description: "Receive favourite notifications?" + }, + reblog: %Schema{ + type: :boolean, + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + type: :boolean, + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + type: :boolean, + 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..cb9d75841 --- /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: 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/authenticator.ex b/lib/pleroma/web/auth/authenticator.ex index dd49987f7..b4db312fb 100644 --- a/lib/pleroma/web/auth/authenticator.ex +++ b/lib/pleroma/web/auth/authenticator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.Authenticator do diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 177c05636..f63a66c03 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.LDAPAuthenticator do diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index f4234b743..200ca03dc 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.PleromaAuthenticator do @@ -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..ce8a76219 --- /dev/null +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 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/channels/user_socket.ex b/lib/pleroma/web/channels/user_socket.ex index 8e2759e3b..306ef1916 100644 --- a/lib/pleroma/web/channels/user_socket.ex +++ b/lib/pleroma/web/channels/user_socket.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.UserSocket do diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index 08841a3e8..bce27897f 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ChatChannel do @@ -20,9 +20,10 @@ defmodule Pleroma.Web.ChatChannel do def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} = socket) do text = String.trim(text) - if String.length(text) > 0 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 f7da81b34..3f1a50b96 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.CommonAPI.ActivityDraft do @@ -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 386408d51..7c94f16b6 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -1,15 +1,20 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.CommonAPI do alias Pleroma.Activity 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 @@ -17,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]) @@ -31,15 +56,16 @@ defmodule Pleroma.Web.CommonAPI do def unfollow(follower, unfollowed) do with {:ok, follower, _follow_activity} <- User.unfollow(follower, unfollowed), {:ok, _activity} <- ActivityPub.unfollow(follower, unfollowed), - {:ok, _unfollowed} <- User.unsubscribe(follower, unfollowed) do + {:ok, _subscription} <- User.unsubscribe(follower, unfollowed) do {:ok, follower} end 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, :follow_accept), {:ok, _activity} <- ActivityPub.accept(%{ to: [follower.ap_id], @@ -54,6 +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, :follow_reject), + {:ok, _notifications} <- Notification.dismiss(follow_activity), {:ok, _activity} <- ActivityPub.reject(%{ to: [follower.ap_id], @@ -66,57 +94,153 @@ defmodule Pleroma.Web.CommonAPI do end def delete(activity_id, user) do - with %Activity{data: %{"object" => _}} = 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 - _ -> {: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 <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity), - nil <- Utils.get_existing_announce(user.ap_id, object), - public <- public_announce?(object, params) do - ActivityPub.announce(user, object, nil, true, public) + def repeat(id, user, params \\ %{}) do + with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id) do + object = Object.normalize(activity) + announce_activity = Utils.get_existing_announce(user.ap_id, object) + public = public_announce?(object, params) + + if announce_activity do + {:ok, announce_activity, object} + else + ActivityPub.announce(user, object, nil, true, public) + end else - _ -> {:error, dgettext("errors", "Could not repeat")} + _ -> {:error, :not_found} end end - def unrepeat(id_or_ap_id, user) do - with %Activity{} = 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 <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity), - nil <- Utils.get_existing_like(user.ap_id, object) do - ActivityPub.like(user, object) + @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 - _ -> {: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 <- 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")} end end + def react_with_emoji(id, user, emoji) do + with %Activity{} = activity <- Activity.get_by_id(id), + 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")} + end + end + + def unreact_with_emoji(id, user, emoji) do + 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")} + end + end + def vote(user, %{data: %{"type" => "Question"}} = object, choices) do with :ok <- validate_not_author(object, user), :ok <- validate_existing_votes(user, object), @@ -172,7 +296,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) @@ -182,11 +306,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 @@ -244,7 +368,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?) @@ -260,47 +384,28 @@ 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 = user.info |> Map.get(:source_data, %{}) |> Map.put("tag", emoji) - - user = - case User.update_info(user, &User.Info.set_source_data(&1, 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" => "Note"}} - } = activity <- get_by_id_or_ap_id(id_or_ap_id), + object: %Object{data: %{"type" => object_type}} + } = activity <- Activity.get_by_id_with_object(id), + true <- object_type in ["Note", "Article", "Question"], true <- Visibility.is_public?(activity), - {:ok, _user} <- User.update_info(user, &User.Info.add_pinnned_activity(&1, activity)) do + {:ok, _user} <- User.add_pinnned_activity(user, activity) do {:ok, activity} else - {:error, %{changes: %{info: %{errors: [pinned_activities: {err, _}]}}}} -> {:error, err} + {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} _ -> {:error, dgettext("errors", "Could not pin")} end end - def unpin(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - {:ok, _user} <- User.update_info(user, &User.Info.remove_pinnned_activity(&1, activity)) do + 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 - %{errors: [pinned_activities: {err, _}]} -> {:error, err} + {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} _ -> {:error, dgettext("errors", "Could not unpin")} end end @@ -321,12 +426,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(), @@ -334,13 +439,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} @@ -348,6 +451,13 @@ defmodule Pleroma.Web.CommonAPI do end end + def update_report_state(activity_ids, state) when is_list(activity_ids) do + case Utils.update_report_state(activity_ids, state) do + :ok -> {:ok, activity_ids} + _ -> {:error, dgettext("errors", "Could not update state")} + end + end + def update_report_state(activity_id, state) do with %Activity{} = activity <- Activity.get_by_id(activity_id) do Utils.update_report_state(activity, state) @@ -367,11 +477,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) @@ -385,21 +495,17 @@ 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 defp set_visibility(activity, _), do: {:ok, activity} - def hide_reblogs(user, %{ap_id: ap_id} = _muted) do - if ap_id not in user.info.muted_reblogs do - User.update_info(user, &User.Info.add_reblog_mute(&1, ap_id)) - end + def hide_reblogs(%User{} = user, %User{} = target) do + UserRelationship.create_reblog_mute(user, target) end - def show_reblogs(user, %{ap_id: ap_id} = _muted) do - if ap_id in user.info.muted_reblogs do - User.update_info(user, &User.Info.remove_reblog_mute(&1, ap_id)) - end + def show_reblogs(%User{} = user, %User{} = target) do + UserRelationship.delete_reblog_mute(user, target) end end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 88a5f434a..e8deee223 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.CommonAPI.Utils do @@ -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( @@ -160,7 +140,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,15 +155,15 @@ 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 = - NaiveDateTime.utc_now() - |> NaiveDateTime.add(expires_in) - |> NaiveDateTime.to_iso8601() + DateTime.utc_now() + |> 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}} @@ -228,12 +208,12 @@ defmodule Pleroma.Web.CommonAPI.Utils do data, visibility ) do - no_attachment_links = + attachment_links = data - |> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links])) + |> 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 @@ -244,7 +224,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do status |> format_input(content_type, options) - |> maybe_add_attachments(attachments, no_attachment_links) + |> maybe_add_attachments(attachments, attachment_links) |> maybe_add_nsfw_tag(data) end @@ -270,7 +250,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do def make_context(%Activity{data: %{"context" => context}}, _), do: context def make_context(_, _), do: Utils.generate_context_id() - def maybe_add_attachments(parsed, _attachments, true = _no_links), do: parsed + def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do text = add_attachments(text, attachments) @@ -331,7 +311,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do def format_input(text, "text/markdown", options) do text |> Formatter.mentions_escape(options) - |> Earmark.as_html!() + |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer}) |> Formatter.linkify(options) |> Formatter.html_escape("text/html") end @@ -422,6 +402,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do 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 +412,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 @@ -451,6 +419,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do recipients ++ to end + def maybe_notify_to_recipients(recipients, _), do: recipients + def maybe_notify_mentioned_recipients( recipients, %Activity{data: %{"to" => _to, "type" => type} = data} = activity @@ -492,7 +462,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do with %User{} = user <- User.get_cached_by_ap_id(actor) do subscriber_ids = user - |> User.subscribers() + |> User.subscriber_users() |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) |> Enum.map(& &1.ap_id) @@ -502,6 +472,17 @@ defmodule Pleroma.Web.CommonAPI.Utils do def maybe_notify_subscribers(recipients, _), do: recipients + def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do + with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do + user + |> User.get_followers() + |> Enum.map(& &1.ap_id) + |> Enum.concat(recipients) + end + end + + def maybe_notify_followers(recipients, _), do: recipients + def maybe_extract_mentions(%{"tag" => tag}) do tag |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end) @@ -524,7 +505,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 @@ -578,7 +560,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do limit = Pleroma.Config.get([:instance, :limit]) length = String.length(full_payload) - if length < limit do + if length <= limit do :ok else {:error, dgettext("errors", "The status is over the character limit")} diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 9a4e322c9..5a1316a5f 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -1,14 +1,20 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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,15 +80,15 @@ 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 end - def try_render(conn, target, params) - when is_binary(target) do + def try_render(conn, target, params) when is_binary(target) do case render(conn, target, params) do nil -> render_error(conn, :not_implemented, "Can't display this activity") res -> res @@ -87,4 +98,22 @@ defmodule Pleroma.Web.ControllerHelper do def try_render(conn, _, _) do render_error(conn, :not_implemented, "Can't display this activity") end + + @spec put_if_exist(map(), atom() | String.t(), any) :: map() + 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 2212e93f4..226d42c2c 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -1,10 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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), + only: Pleroma.Constants.static_only_files(), # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength gzip: true, cache_control_for_etags: @static_cache_control, @@ -59,11 +60,14 @@ defmodule Pleroma.Web.Endpoint do plug(Pleroma.Plugs.TrailingFormatPlug) plug(Plug.RequestId) - plug(Plug.Logger) - - plug( - Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], + plug(Plug.Logger, log: :debug) + + plug(Plug.Parsers, + parsers: [ + :urlencoded, + {:multipart, length: {Pleroma.Config, :get, [[:instance, :upload_limit]]}}, + :json + ], pass: ["*/*"], json_decoder: Jason, length: Pleroma.Config.get([:instance, :upload_limit]), diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 5fbf3695f..0d9d578fc 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -1,10 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule 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 e8a56ebd7..f5803578d 100644 --- a/lib/pleroma/web/federator/federator.ex +++ b/lib/pleroma/web/federator/federator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Federator do @@ -15,13 +15,19 @@ defmodule Pleroma.Web.Federator do require Logger - @doc "Addresses [memory leaks on recursive replies fetching](https://git.pleroma.social/pleroma/pleroma/issues/161)" + @doc """ + Returns `true` if the distance to target object does not exceed max configured value. + Serves to prevent fetching of very long threads, especially useful on smaller instances. + Addresses [memory leaks on recursive replies fetching](https://git.pleroma.social/pleroma/pleroma/issues/161). + Applies to fetching of both ancestor (reply-to) and child (reply) objects. + """ # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength - def allowed_incoming_reply_depth?(depth) do - max_replies_depth = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth]) + def allowed_thread_distance?(distance) do + max_distance = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth]) - if max_replies_depth do - (depth || 1) <= max_replies_depth + if max_distance && max_distance >= 0 do + # Default depth is 0 (an object has zero distance from itself in its thread) + (distance || 0) <= max_distance else true end @@ -58,7 +64,7 @@ defmodule Pleroma.Web.Federator do end def perform(:incoming_ap_doc, params) do - Logger.info("Handling incoming AP activity") + Logger.debug("Handling incoming AP activity") params = Utils.normalize_params(params) @@ -66,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.info("Already had #{params["id"]}") - :error + Logger.debug("Already had #{params["id"]}") + {:error, :already_present} - _e -> + e -> # Just drop those for now - Logger.info("Unhandled activity") - Logger.info(Jason.encode!(params, pretty: true)) - :error + Logger.debug("Unhandled activity") + Logger.debug(Jason.encode!(params, pretty: true)) + {:error, e} end end diff --git a/lib/pleroma/web/federator/publisher.ex b/lib/pleroma/web/federator/publisher.ex index fb9b26649..ad0201361 100644 --- a/lib/pleroma/web/federator/publisher.ex +++ b/lib/pleroma/web/federator/publisher.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Federator.Publisher do @@ -47,7 +47,7 @@ defmodule Pleroma.Web.Federator.Publisher do Config.get([:instance, :federation_publisher_modules]) |> Enum.each(fn module -> if module.is_representable?(activity) do - Logger.info("Publishing #{activity.data["id"]} using #{inspect(module)}") + Logger.debug("Publishing #{activity.data["id"]} using #{inspect(module)}") module.publish(user, activity) end end) diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 5eef1e757..1ae03e7e2 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -1,53 +1,89 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Feed.FeedView do use Phoenix.HTML use Pleroma.Web, :view + alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.MediaProxy require Pleroma.Constants + @spec pub_date(String.t() | DateTime.t()) :: String.t() + def pub_date(date) when is_binary(date) do + date + |> Timex.parse!("{ISO:Extended}") + |> pub_date + end + + def pub_date(%DateTime{} = date), do: Timex.format!(date, "{RFC822}") + + def prepare_activity(activity, opts \\ []) do + object = Object.normalize(activity) + + actor = + if opts[:actor] do + Pleroma.User.get_cached_by_ap_id(activity.actor) + end + + %{ + activity: activity, + data: Map.get(object, :data), + actor: actor + } + end + + def most_recent_update(activities) do + with %{updated_at: updated_at} <- List.first(activities) do + NaiveDateTime.to_iso8601(updated_at) + end + end + def most_recent_update(activities, user) do (List.first(activities) || user).updated_at |> NaiveDateTime.to_iso8601() end + def feed_logo do + case Pleroma.Config.get([:feed, :logo]) do + nil -> + "#{Pleroma.Web.base_url()}/static/logo.png" + + logo -> + "#{Pleroma.Web.base_url()}#{logo}" + end + |> MediaProxy.url() + end + def logo(user) do user |> User.avatar_url() |> MediaProxy.url() end - def last_activity(activities) do - List.last(activities) - end - - def activity_object(activity) do - Object.normalize(activity) - end + def last_activity(activities), do: List.last(activities) - def activity_object_data(activity) do - activity - |> activity_object() - |> Map.get(:data) + def activity_title(%{"content" => content}, opts \\ %{}) do + content + |> Pleroma.Web.Metadata.Utils.scrub_html() + |> Pleroma.Emoji.Formatter.demojify() + |> Formatter.truncate(opts[:max_length], opts[:omission]) + |> escape() end - def activity_content(activity) do - content = activity_object_data(activity)["content"] - + def activity_content(%{"content" => content}) do content |> String.replace(~r/[\n\r]/, "") |> escape() end - def activity_context(activity) do - activity.data["context"] - end + def activity_content(_), do: "" + + def activity_context(activity), do: activity.data["context"] def attachment_href(attachment) do attachment["url"] diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex new file mode 100644 index 000000000..8133f8480 --- /dev/null +++ b/lib/pleroma/web/feed/tag_controller.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.Feed.TagController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.Feed.FeedView + + import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] + + def feed(conn, %{"tag" => raw_tag} = params) do + {format, tag} = parse_tag(raw_tag) + + activities = + %{"type" => ["Create"], "tag" => tag} + |> put_if_exist("max_id", params["max_id"]) + |> ActivityPub.fetch_public_activities() + + conn + |> put_resp_content_type("application/#{format}+xml") + |> put_view(FeedView) + |> render("tag.#{format}", + activities: activities, + tag: tag, + feed_config: Config.get([:feed]) + ) + end + + @spec parse_tag(binary() | any()) :: {format :: String.t(), tag :: String.t()} + defp parse_tag(raw_tag) when is_binary(raw_tag) do + case Enum.reverse(String.split(raw_tag, ".")) do + [format | tag] when format in ["atom", "rss"] -> {format, Enum.join(tag, ".")} + _ -> {"rss", raw_tag} + end + end + + defp parse_tag(raw_tag), do: {"rss", raw_tag} +end diff --git a/lib/pleroma/web/feed/feed_controller.ex b/lib/pleroma/web/feed/user_controller.ex index d91ecef9c..1b72e23dc 100644 --- a/lib/pleroma/web/feed/feed_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -1,14 +1,17 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.Feed.FeedController do +defmodule Pleroma.Web.Feed.UserController do use Pleroma.Web, :controller alias Fallback.RedirectController alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPubController + alias Pleroma.Web.Feed.FeedView + + import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] plug(Pleroma.Plugs.SetFormatPlug when action in [:feed_redirect]) @@ -22,32 +25,47 @@ defmodule Pleroma.Web.Feed.FeedController do def feed_redirect(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do - ActivityPubController.call(conn, :user) + with %{halted: false} = conn <- + Pleroma.Plugs.EnsureAuthenticatedPlug.call(conn, + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 + ) do + ActivityPubController.call(conn, :user) + end end def feed_redirect(conn, %{"nickname" => nickname}) do with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - redirect(conn, external: "#{feed_url(conn, :feed, user.nickname)}.atom") + redirect(conn, external: "#{user_feed_url(conn, :feed, user.nickname)}.atom") end end def feed(conn, %{"nickname" => nickname} = params) do - with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - query_params = - params - |> Map.take(["max_id"]) - |> Map.put("type", ["Create"]) - |> Map.put("whole_db", true) - |> Map.put("actor_id", user.ap_id) + format = get_format(conn) + format = + if format in ["rss", "atom"] do + format + else + "atom" + end + + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do activities = - query_params + %{ + "type" => ["Create"], + "actor_id" => user.ap_id + } + |> put_if_exist("max_id", params["max_id"]) |> ActivityPub.fetch_public_activities() - |> Enum.reverse() conn - |> put_resp_content_type("application/atom+xml") - |> render("feed.xml", user: user, activities: activities) + |> put_resp_content_type("application/#{format}+xml") + |> put_view(FeedView) + |> render("user.#{format}", + user: user, + activities: activities, + feed_config: Pleroma.Config.get([:feed]) + ) end end diff --git a/lib/pleroma/web/gettext.ex b/lib/pleroma/web/gettext.ex index 1328b46cc..0adf428ec 100644 --- a/lib/pleroma/web/gettext.ex +++ b/lib/pleroma/web/gettext.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Gettext do diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 93b38e8f4..d0d8bc8eb 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -1,37 +1,46 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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}} = conn, _params) do - token = get_session(conn, :oauth_token) + def index(%{assigns: %{user: user, token: token}} = conn, _params) + when not is_nil(user) and not is_nil(token) do + conn + |> put_layout(false) + |> render("index.html", + token: token.token, + user: user, + custom_emojis: Pleroma.Emoji.get_all() + ) + end - if user && token do - conn - |> put_layout(false) - |> render("index.html", token: token, user: user, custom_emojis: Pleroma.Emoji.get_all()) - else - conn - |> put_session(:return_to, conn.request_path) - |> redirect(to: "/web/login") - end + def index(conn, _params) do + conn + |> put_session(:return_to, conn.request_path) + |> redirect(to: "/web/login") end @doc "GET /web/manifest.json" @@ -42,7 +51,7 @@ defmodule Pleroma.Web.MastoFEController do @doc "PUT /api/web/settings" def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do - with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do + with {:ok, _} <- User.mastodon_settings_update(user, settings) do json(conn, %{}) else e -> diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 9ef7fd48d..ef41f9e96 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -1,14 +1,20 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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,36 @@ 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 != :create + 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, {:relations_id_action, params: ["id", "uri"]} when action in @relations) - plug(RateLimiter, :relations_actions when action in @relations) - plug(RateLimiter, :app_account_creation when action == :create) + plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions) + plug(RateLimiter, [name: :app_account_creation] when action == :create) plug(:assign_account_by_id when action in @needs_account) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation + @doc "POST /api/v1/accounts" - def create( - %{assigns: %{app: app}} = conn, - %{"username" => nickname, "email" => _, "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"]) - - with {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), + 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, %{ token_type: "Bearer", @@ -102,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 @@ -114,6 +117,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do render_error(conn, :forbidden, "Invalid credentials") end + defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok + + defp validate_email_param(_) do + case Pleroma.Config.get([:instance, :account_activation_required]) do + true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")} + _ -> :ok + end + end + @doc "GET /api/v1/accounts/verify_credentials" def verify_credentials(%{assigns: %{user: user}} = conn, _) do chat_token = Phoenix.Token.sign(conn, "user socket", user.id) @@ -127,40 +139,15 @@ 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 - user_params = - %{} - |> 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) - - emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") - - user_info_emojis = - user.info - |> Map.get(:emoji, []) - |> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text)) - |> Enum.dedup() - 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(%{}) - info_params = + user_params = [ :no_rich_text, :locked, @@ -171,43 +158,30 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :hide_favorites, :show_role, :skip_thread_containment, + :allow_following_move, :discoverable ] |> Enum.reduce(%{}, fn key, acc -> - add_if_present(acc, params, to_string(key), key, &{:ok, truthy_param?(&1)}) - end) - |> add_if_present(params, "default_scope", :default_scope) - |> 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.info.pleroma_settings_store, value)} - 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 - 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 + add_if_present(acc, params, key, key, &{:ok, truthy_param?(&1)}) end) - |> Map.put(:emoji, user_info_emojis) - - changeset = - user - |> User.update_changeset(user_params) - |> User.change_info(&User.Info.profile_update(&1, info_params)) + |> 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, :actor_type, :actor_type) + + 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") @@ -216,7 +190,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do 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 + {:ok, new_value} <- value_function.(Map.get(params, params_field)) do Map.put(map, map_field, new_value) else _ -> map @@ -227,12 +201,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) @@ -242,9 +219,9 @@ 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.auth_active?(user) || user.id == for_user.id || User.superuser?(for_user) do + true <- User.visible_for?(user, for_user) do render(conn, "show.json", user: user, for: for_user) else _e -> render_error(conn, :not_found, "Can't find user") @@ -253,43 +230,78 @@ 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) do - params = Map.put(params, "tag", params["tagged"]) + 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.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 end @doc "GET /api/v1/accounts/:id/followers" def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do + params = + params + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + followers = cond do for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params) - user.info.hide_followers -> [] + user.hide_followers -> [] true -> MastodonAPI.get_followers(user, params) end 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) - user.info.hide_follows -> [] + user.hide_follows -> [] true -> MastodonAPI.get_friends(user, params) end 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" @@ -303,11 +315,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}) @@ -316,7 +328,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 @@ -326,10 +338,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, muter} <- 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}) @@ -338,7 +348,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts/:id/unmute" def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do - with {:ok, muter} <- User.unmute(muter, muted) do + with {:ok, _user_relationships} <- User.unmute(muter, muted) do render(conn, "relationship.json", user: muter, target: muted) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) @@ -347,7 +357,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts/:id/block" def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do - with {:ok, blocker} <- User.block(blocker, blocked), + with {:ok, _user_block} <- User.block(blocker, blocked), {:ok, _activity} <- ActivityPub.block(blocker, blocked) do render(conn, "relationship.json", user: blocker, target: blocked) else @@ -357,8 +367,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, blocker} <- 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}) @@ -366,28 +375,33 @@ 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 @doc "GET /api/v1/mutes" def mutes(%{assigns: %{user: user}} = conn, _) do - render(conn, "index.json", users: User.muted_users(user), for: user, as: :user) + users = User.muted_users(user, _restrict_deactivated = true) + render(conn, "index.json", users: users, for: user, as: :user) end @doc "GET /api/v1/blocks" def blocks(%{assigns: %{user: user}} = conn, _) do - render(conn, "index.json", users: User.blocked_users(user), for: user, as: :user) + users = User.blocked_users(user, _restrict_deactivated = true) + render(conn, "index.json", users: users, for: user, as: :user) 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 13a30a34d..a516b6c20 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -1,10 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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 bfd5120ba..753b3db3e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.AuthController do @@ -13,9 +13,9 @@ 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) - plug(Pleroma.Plugs.RateLimiter, :password_reset when action == :password_reset) + @local_mastodon_name "Mastodon-Local" @doc "GET /web/login" def login(%{assigns: %{user: %User{}}} = conn, _params) do @@ -86,6 +86,6 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} defp get_or_make_app do %{client_name: @local_mastodon_name, redirect_uris: "."} - |> App.get_or_make(["read", "write", "follow", "push"]) + |> App.get_or_make(["read", "write", "follow", "push", "admin"]) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index 6c0584c54..f35ec3596 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ConversationController do @@ -13,10 +13,11 @@ 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 @@ -28,7 +29,7 @@ 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 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 391c0648b..c5f47c5df 100644 --- a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -1,10 +1,20 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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 c7606246b..825b231ab 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.DomainBlockController do @@ -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: %{info: info}}} = conn, _) do - json(conn, Map.get(info, :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 41243d5e7..8af557b61 100644 --- a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.FallbackController do @@ -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 cadef72e1..abbf0ce02 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.FilterController do @@ -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 3ccbdf1c6..748b6b475 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.FollowRequestController do @@ -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 a55f60fec..d8859731d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -1,10 +1,20 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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 e0ffdba21..acdc76fd2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ListController do @@ -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 ce025624d..85310edfa 100644 --- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -1,11 +1,13 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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 7d839a8cf..e7767de4e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -1,23 +1,35 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do + @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 ed4c08d99..513de279f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MediaController do @@ -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 16759be6a..bcd12c73f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.NotificationController do @@ -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,19 +22,40 @@ 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 + case Pleroma.User.get_cached_by_id(account_id) do + %{ap_id: account_ap_id} -> + params = + params + |> Map.delete(:account_id) + |> Map.put(:account_ap_id, account_ap_id) + + index(conn, params) + + _ -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Account is not found"}) + end + end + 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 @@ -49,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 @@ -61,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 d129f8672..db46ffcfc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.PollController do @@ -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 263c2180f..405167108 100644 --- a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ReportController do @@ -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 ff9276541..1719c67ea 100644 --- a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do @@ -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 6cfd68a84..77e2224e4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.SearchController do @@ -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]) - plug(RateLimiter, :search when action in [:search, :search2, :account_search]) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation - def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do + 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"] == nil or params["type"] == 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 e5d016f63..9dbf4f33c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -1,11 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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,32 +80,32 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a plug( RateLimiter, - {: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, - {: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 ) - plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions) + plug(RateLimiter, [name: :statuses_actions] when action in @rate_limited_status_actions) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.StatusOperation + @doc """ GET `/api/v1/statuses?ids[]=1&ids[]=2` `ids` query param is required """ - def index(%{assigns: %{user: user}} = conn, %{"ids" => ids}) 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,32 @@ 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"]) - - if ScheduledActivity.far_enough?(scheduled_at) do - with {:ok, scheduled_activity} <- - ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do - conn - |> put_view(ScheduledActivityView) - |> render("show.json", scheduled_activity: scheduled_activity) - end + %{ + 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)}, + {:ok, scheduled_activity} <- ScheduledActivity.create(user, attrs) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", scheduled_activity: scheduled_activity) else - create(conn, Map.drop(params, ["scheduled_at"])) + {:far_enough, _} -> + params = Map.drop(params, [:scheduled_at]) + create(%Plug.Conn{conn | body_params: params}, %{}) + + error -> + error end end @@ -141,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", @@ -159,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", @@ -172,20 +193,23 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do for: user, with_direct_conversation_id: true ) + else + _ -> {:error, :not_found} end 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 + {:error, :not_found} = e -> e _e -> render_error(conn, :forbidden, "Can't delete this post") end end @doc "POST /api/v1/statuses/:id/reblog" - def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do + def reblog(%{assigns: %{user: user}, body_params: params} = conn, %{id: ap_id_or_id}) do with {:ok, announce, _activity} <- 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}) @@ -193,45 +217,45 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do 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), @@ -241,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), @@ -251,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) @@ -259,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) @@ -268,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) @@ -279,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 @@ -299,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}} <- @@ -331,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"], %{ @@ -345,20 +369,21 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "GET /api/v1/favourites" - def favourites(%{assigns: %{user: user}} = conn, params) do + def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do params = params - |> Map.put("type", "Create") - |> Map.put("favorited_by", user.ap_id) - |> Map.put("blocking_user", user) + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.take(Pleroma.Pagination.page_keys()) - activities = - ActivityPub.fetch_activities([], params) - |> Enum.reverse() + 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" @@ -376,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 fc7d52824..34eac97c5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.SubscriptionController do @@ -8,45 +8,40 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do alias Pleroma.Web.Push alias Pleroma.Web.Push.Subscription - alias Pleroma.Web.MastodonAPI.PushSubscriptionView, as: View 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 fe71c36af..f91df9ab7 100644 --- a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.SuggestionController do @@ -7,62 +7,26 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionController do require Logger - alias Pleroma.Config - alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.User - alias Pleroma.Web.MediaProxy + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action == :index) - action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - - plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :index) - - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - - @doc "GET /api/v1/suggestions" - def index(%{assigns: %{user: user}} = conn, _) do - if Config.get([:suggestions, :enabled], false) do - with {:ok, data} <- fetch_suggestions(user) do - limit = Config.get([:suggestions, :limit], 23) - - data = - data - |> Enum.slice(0, limit) - |> Enum.map(fn x -> - x - |> Map.put("id", fetch_suggestion_id(x)) - |> Map.put("avatar", MediaProxy.url(x["avatar"])) - |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"])) - end) - - json(conn, data) - end - else - json(conn, []) - end + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) end - defp fetch_suggestions(user) do - api = Config.get([:suggestions, :third_party_engine], "") - timeout = Config.get([:suggestions, :timeout], 5000) - host = Config.get([Pleroma.Web.Endpoint, :url, :host]) - - url = - api - |> String.replace("{{host}}", host) - |> String.replace("{{user}}", user.nickname) - - with {:ok, %{status: 200, body: body}} <- - Pleroma.HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]) do - Jason.decode(body) - else - e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}") - 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 - defp fetch_suggestion_id(attrs) do - case User.get_or_fetch(attrs["acct"]) do - {:ok, %User{id: id}} -> id - _ -> 0 - end - 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 9f086a8c2..958567510 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -1,34 +1,57 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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 + 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 + + plug(RateLimiter, [name: :timeline, bucket_name: :direct_timeline] when action == :direct) + plug(RateLimiter, [name: :timeline, bucket_name: :public_timeline] when action == :public) + plug(RateLimiter, [name: :timeline, bucket_name: :home_timeline] when action == :home) + plug(RateLimiter, [name: :timeline, bucket_name: :hashtag_timeline] when action == :hashtag) + plug(RateLimiter, [name: :timeline, bucket_name: :list_timeline] when action == :list) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + 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] + recipients = [user.ap_id | User.following(user)] activities = recipients @@ -37,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) @@ -56,31 +84,51 @@ 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) - activities = - params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> ActivityPub.fetch_public_activities() - |> Enum.reverse() + local_only = params["local"] - conn - |> add_link_headers(activities, %{"local" => local_only}) - |> render("index.json", activities: activities, for: user, as: :activity) - end + cfg_key = + if local_only do + :local + else + :federated + end - # GET /api/v1/timelines/tag/:tag - def hashtag(%{assigns: %{user: user}} = conn, params) do - local_only = truthy_param?(params["local"]) + restrict? = Pleroma.Config.get([:restrict_unauthenticated, :timelines, cfg_key]) + 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 + ) + end + end + + defp hashtag_fetching(params, user, local_only) do tags = [params["tag"], params["any"]] |> List.flatten() @@ -98,7 +146,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do |> Map.get("none", []) |> Enum.map(&String.downcase(&1)) - activities = + _activities = params |> Map.put("type", "Create") |> Map.put("local_only", local_only) @@ -109,18 +157,29 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do |> Map.put("tag_all", tag_all) |> Map.put("tag_reject", tag_reject) |> ActivityPub.fetch_public_activities() - |> Enum.reverse() + end + + # GET /api/v1/timelines/tag/:tag + def hashtag(%{assigns: %{user: user}} = conn, params) do + 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) @@ -128,13 +187,20 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do # we must filter the following list for the user to avoid leaking statuses the user # does not actually have permission to see (for more info, peruse security issue #270). + + user_following = User.following(user) + activities = following - |> Enum.filter(fn x -> x in user.following end) + |> Enum.filter(fn x -> x in user_following end) |> 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/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index d875a5788..70da64a7a 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MastodonAPI do @@ -24,19 +24,16 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do with {:ok, follower, _followed, _} <- result do options = cast_params(params) - - case reblogs_visibility(options[:reblogs], result) do - {:ok, follower} -> {:ok, follower} - _ -> {:ok, follower} - end + set_reblogs_visibility(options[:reblogs], result) + {:ok, follower} end end - defp reblogs_visibility(false, {:ok, follower, followed, _}) do + defp set_reblogs_visibility(false, {:ok, follower, followed, _}) do CommonAPI.hide_reblogs(follower, followed) end - defp reblogs_visibility(_, {:ok, follower, followed, _}) do + defp set_reblogs_visibility(_, {:ok, follower, followed, _}) do CommonAPI.show_reblogs(follower, followed) end @@ -58,7 +55,9 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do user |> Notification.for_user_query(options) + |> restrict(:include_types, options) |> restrict(:exclude_types, options) + |> restrict(:account_ap_id, options) |> Pagination.fetch_paginated(params) end @@ -71,24 +70,38 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do defp cast_params(params) do param_types = %{ exclude_types: {:array, :string}, + include_types: {:array, :string}, exclude_visibilities: {:array, :string}, reblogs: :boolean, - with_muted: :boolean + with_muted: :boolean, + account_ap_id: :string } changeset = cast({%{}, param_types}, params, Map.keys(param_types)) changeset.changes end + defp restrict(query, :include_types, %{include_types: mastodon_types = [_ | _]}) do + ap_types = convert_and_filter_mastodon_types(mastodon_types) + + where(query, [q, a], fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + end + defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do - ap_types = - mastodon_types - |> Enum.map(&Activity.from_mastodon_notification_type/1) - |> Enum.filter(& &1) + ap_types = convert_and_filter_mastodon_types(mastodon_types) - query - |> where([q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + where(query, [q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + end + + defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do + where(query, [n, a], a.actor == ^account_ap_id) end defp restrict(query, _, _), do: query + + defp convert_and_filter_mastodon_types(types) do + types + |> Enum.map(&Activity.from_mastodon_notification_type/1) + |> Enum.filter(& &1) + end end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 2d4976891..45fffaad2 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -1,26 +1,45 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.AccountView do use Pleroma.Web, :view - alias Pleroma.HTML + 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 @@ -28,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 @@ -36,140 +55,219 @@ 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_ap_id?(user, target), - blocked_by: User.blocks_ap_id?(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 - display_name = HTML.strip_tags(user.name || user.nickname) + user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) + display_name = user.name || user.nickname image = User.avatar_url(user) |> MediaProxy.url() header = User.banner_url(user) |> MediaProxy.url() - user_info = User.get_cached_user_info(user) following_count = - if !user.info.hide_follows_count or !user.info.hide_follows or opts[:for] == user do - user_info.following_count + if !user.hide_follows_count or !user.hide_follows or opts[:for] == user do + user.following_count || 0 else 0 end followers_count = - if !user.info.hide_followers_count or !user.info.hide_followers or opts[:for] == user do - user_info.follower_count + if !user.hide_followers_count or !user.hide_followers or opts[:for] == user do + user.follower_count || 0 else 0 end - bot = (user.info.source_data["type"] || "Person") in ["Application", "Service"] + bot = user.actor_type in ["Application", "Service"] emojis = - (user.info.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, url} -> %{ - "shortcode" => String.trim(name, ":"), - "url" => MediaProxy.url(url), - "static_url" => MediaProxy.url(url), + "shortcode" => shortcode, + "url" => url, + "static_url" => url, "visible_in_picker" => false } end) - fields = - user.info - |> User.Info.fields() - |> Enum.map(fn %{"name" => name, "value" => value} -> - %{ - "name" => Pleroma.HTML.strip_tags(name), - "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) - } - end) - - raw_fields = Map.get(user.info, :raw_fields, []) - - bio = HTML.filter_tags(user.bio, User.html_filter_policy(opts[:for])) - relationship = render("relationship.json", %{user: opts[:for], target: user}) - - discoverable = user.info.discoverable + relationship = + if opts[:embed_relationships] do + render("relationship.json", %{ + user: opts[:for], + target: user, + relationships: opts[:relationships] + }) + else + %{} + end %{ id: to_string(user.id), username: username_from_nickname(user.nickname), acct: user.nickname, display_name: display_name, - locked: user_info.locked, + locked: user.locked, created_at: Utils.to_masto_date(user.inserted_at), followers_count: followers_count, following_count: following_count, - statuses_count: user_info.note_count, - note: bio || "", - url: User.profile_url(user), + statuses_count: user.note_count, + note: user.bio || "", + url: user.uri || user.ap_id, avatar: image, avatar_static: image, header: header, header_static: header, emojis: emojis, - fields: fields, + fields: user.fields, bot: bot, source: %{ - note: HTML.strip_tags((user.bio || "") |> String.replace("<br>", "\n")), + note: prepare_user_bio(user), sensitive: false, - fields: raw_fields, + fields: user.raw_fields, pleroma: %{ - discoverable: discoverable + discoverable: user.discoverable, + actor_type: user.actor_type } }, # Pleroma extension pleroma: %{ - confirmation_pending: user_info.confirmation_pending, + confirmation_pending: user.confirmation_pending, tags: user.tags, - hide_followers_count: user.info.hide_followers_count, - hide_follows_count: user.info.hide_follows_count, - hide_followers: user.info.hide_followers, - hide_follows: user.info.hide_follows, - hide_favorites: user.info.hide_favorites, + hide_followers_count: user.hide_followers_count, + hide_follows_count: user.hide_follows_count, + hide_followers: user.hide_followers, + hide_follows: user.hide_follows, + hide_favorites: user.hide_favorites, relationship: relationship, - skip_thread_containment: user.info.skip_thread_containment, - background_image: image_url(user.info.background) |> MediaProxy.url() + skip_thread_containment: user.skip_thread_containment, + background_image: image_url(user.background) |> MediaProxy.url() } } |> maybe_put_role(user, opts[:for]) - |> maybe_put_settings(user, opts[:for], user_info) + |> maybe_put_settings(user, opts[:for], opts) |> maybe_put_notification_settings(user, opts[:for]) |> maybe_put_settings_store(user, opts[:for], opts) |> maybe_put_chat_token(user, opts[:for], opts) |> maybe_put_activation_status(user, opts[:for]) |> maybe_put_follow_requests_count(user, opts[:for]) + |> maybe_put_allow_following_move(user, opts[:for]) |> maybe_put_unread_conversation_count(user, opts[:for]) + |> maybe_put_unread_notification_count(user, opts[:for]) 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 @@ -195,21 +293,21 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do data, %User{id: user_id} = user, %User{id: user_id}, - user_info + _opts ) do data - |> Kernel.put_in([:source, :privacy], user_info.default_scope) - |> Kernel.put_in([:source, :pleroma, :show_role], user.info.show_role) - |> Kernel.put_in([:source, :pleroma, :no_rich_text], user.info.no_rich_text) + |> Kernel.put_in([:source, :privacy], user.default_scope) + |> Kernel.put_in([:source, :pleroma, :show_role], user.show_role) + |> Kernel.put_in([:source, :pleroma, :no_rich_text], user.no_rich_text) end defp maybe_put_settings(data, _, _, _), do: data - defp maybe_put_settings_store(data, %User{info: info, id: id}, %User{id: id}, %{ + defp maybe_put_settings_store(data, %User{} = user, %User{}, %{ with_pleroma_settings: true }) do data - |> Kernel.put_in([:pleroma, :settings_store], info.pleroma_settings_store) + |> Kernel.put_in([:pleroma, :settings_store], user.pleroma_settings_store) end defp maybe_put_settings_store(data, _, _, _), do: data @@ -223,28 +321,38 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do defp maybe_put_chat_token(data, _, _, _), do: data - defp maybe_put_role(data, %User{info: %{show_role: true}} = user, _) do + defp maybe_put_role(data, %User{show_role: true} = user, _) do data - |> Kernel.put_in([:pleroma, :is_admin], user.info.is_admin) - |> Kernel.put_in([:pleroma, :is_moderator], user.info.is_moderator) + |> Kernel.put_in([:pleroma, :is_admin], user.is_admin) + |> Kernel.put_in([:pleroma, :is_moderator], user.is_moderator) end defp maybe_put_role(data, %User{id: user_id} = user, %User{id: user_id}) do data - |> Kernel.put_in([:pleroma, :is_admin], user.info.is_admin) - |> Kernel.put_in([:pleroma, :is_moderator], user.info.is_moderator) + |> Kernel.put_in([:pleroma, :is_admin], user.is_admin) + |> Kernel.put_in([:pleroma, :is_moderator], user.is_moderator) end 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.info.notification_settings) + Kernel.put_in( + data, + [:pleroma, :notification_settings], + Map.from_struct(user.notification_settings) + ) end defp maybe_put_notification_settings(data, _, _), do: data - defp maybe_put_activation_status(data, user, %User{info: %{is_admin: true}}) do - Kernel.put_in(data, [:pleroma, :deactivated], user.info.deactivated) + defp maybe_put_allow_following_move(data, %User{id: user_id} = user, %User{id: user_id}) do + Kernel.put_in(data, [:pleroma, :allow_following_move], user.allow_following_move) + end + + defp maybe_put_allow_following_move(data, _, _), do: data + + defp maybe_put_activation_status(data, user, %User{is_admin: true}) do + Kernel.put_in(data, [:pleroma, :deactivated], user.deactivated) end defp maybe_put_activation_status(data, _, _), do: data @@ -253,12 +361,22 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do data |> Kernel.put_in( [:pleroma, :unread_conversation_count], - user.info.unread_conversation_count + user.unread_conversation_count ) end defp maybe_put_unread_conversation_count(data, _, _), do: data + defp maybe_put_unread_notification_count(data, %User{id: user_id}, %User{id: user_id} = user) do + Kernel.put_in( + data, + [:pleroma, :unread_notifications_count], + Pleroma.Notification.unread_notifications_count(user) + ) + end + + defp maybe_put_unread_notification_count(data, _, _), do: data + defp 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 f52b693a6..36071cd25 100644 --- a/lib/pleroma/web/mastodon_api/views/app_view.ex +++ b/lib/pleroma/web/mastodon_api/views/app_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.AppView do @@ -7,9 +7,20 @@ defmodule Pleroma.Web.MastodonAPI.AppView do alias Pleroma.Web.OAuth.App - @vapid_key :web_push_encryption - |> Application.get_env(:vapid_details, []) - |> Keyword.get(:public_key) + 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 %{ @@ -32,8 +43,10 @@ defmodule Pleroma.Web.MastodonAPI.AppView do end defp with_vapid_key(data) do - if @vapid_key do - Map.put(data, "vapid_key", @vapid_key) + vapid_key = Application.get_env(:web_push_encryption, :vapid_details, [])[:public_key] + + if vapid_key do + Map.put(data, "vapid_key", vapid_key) else data end diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index e9d2735b3..2b6f84c72 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ConversationView do @@ -12,7 +12,10 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do alias Pleroma.Web.MastodonAPI.StatusView def render("participations.json", %{participations: participations, for: user}) do - render_many(participations, __MODULE__, "participation.json", as: :participation, for: user) + safe_render_many(participations, __MODULE__, "participation.json", %{ + as: :participation, + for: user + }) end def render("participation.json", %{participation: participation, for: user}) do @@ -34,7 +37,11 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do id: participation.id |> to_string(), accounts: render(AccountView, "index.json", users: users, as: :user), unread: !participation.read, - last_status: render(StatusView, "show.json", activity: activity, for: user) + last_status: + render(StatusView, "show.json", + activity: activity, + direct_conversation_id: participation.id + ) } end end diff --git a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex index cb8688941..47a242b8e 100644 --- a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex +++ b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.CustomEmojiView do diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex index a685bc7b6..aeff646f5 100644 --- a/lib/pleroma/web/mastodon_api/views/filter_view.ex +++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.FilterView do @@ -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 c4866e510..a329ffc28 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -1,14 +1,17 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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(), @@ -29,7 +32,58 @@ 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), + 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 end diff --git a/lib/pleroma/web/mastodon_api/views/list_view.ex b/lib/pleroma/web/mastodon_api/views/list_view.ex index bfda6f5b3..580596b64 100644 --- a/lib/pleroma/web/mastodon_api/views/list_view.ex +++ b/lib/pleroma/web/mastodon_api/views/list_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ListView do diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 38fbeed5f..21d535d54 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -1,17 +1,21 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.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 5e3dbe728..c46ddcf55 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.NotificationView do @@ -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,24 +101,23 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do case mastodon_type do "mention" -> - response - |> Map.merge(%{ - status: StatusView.render("show.json", %{activity: activity, for: user}) - }) + put_status(response, activity, reading_user, status_render_opts) "favourite" -> - response - |> Map.merge(%{ - status: StatusView.render("show.json", %{activity: parent_activity, for: user}) - }) + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "reblog" -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + + "move" -> + put_target(response, activity, reading_user, %{}) + + "pleroma:emoji_reaction" -> response - |> Map.merge(%{ - status: StatusView.render("show.json", %{activity: parent_activity, for: user}) - }) + |> put_status(parent_activity_fn.(), reading_user, status_render_opts) + |> put_emoji(activity) - "follow" -> + type when type in ["follow", "follow_request"] -> response _ -> @@ -65,4 +127,23 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do _ -> nil end end + + defp put_emoji(response, activity) do + Map.put(response, :emoji, activity.data["content"]) + end + + defp put_status(response, activity, reading_user, opts) do + status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user}) + status_render = StatusView.render("show.json", status_render_opts) + + Map.put(response, :status, status_render) + end + + defp put_target(response, activity, reading_user, opts) do + target_user = User.get_cached_by_ap_id(activity.data["target"]) + target_render_opts = Map.merge(opts, %{user: target_user, for: reading_user}) + target_render = AccountView.render("show.json", target_render_opts) + + Map.put(response, :target, target_render) + end end diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 753039da3..59a5deb28 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -1,11 +1,10 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.PollView do use Pleroma.Web, :view - alias Pleroma.HTML alias Pleroma.Web.CommonAPI.Utils def render("show.json", %{object: object, multiple: multiple, options: options} = params) do @@ -20,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"]) @@ -57,12 +57,18 @@ defmodule Pleroma.Web.MastodonAPI.PollView do current_count = option["replies"]["totalItems"] || 0 {%{ - title: HTML.strip_tags(name), + title: name, votes_count: current_count }, current_count + count} end) end + defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do + length(voters) + end + + defp voters_count(_), do: 0 + defp voted?(%{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/report_view.ex b/lib/pleroma/web/mastodon_api/views/report_view.ex index 9da2dd740..98cb581ef 100644 --- a/lib/pleroma/web/mastodon_api/views/report_view.ex +++ b/lib/pleroma/web/mastodon_api/views/report_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ReportView do diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex index fc042a276..458f6bc78 100644 --- a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex +++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 9b8dd3086..05a26017a 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.StatusView do @@ -9,12 +9,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Activity alias Pleroma.ActivityExpiration - alias Pleroma.Conversation - alias Pleroma.Conversation.Participation alias Pleroma.HTML 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 @@ -46,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 @@ -54,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 @@ -73,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] + + 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) - safe_render_many(opts.activities, StatusView, "show.json", opts) + 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( @@ -87,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 @@ -109,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, @@ -118,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, @@ -177,15 +229,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do expires_at = with true <- client_posted_this_activity, - expiration when not is_nil(expiration) <- + %ActivityExpiration{scheduled_at: scheduled_at} <- ActivityExpiration.get_by_activity_id(activity.id) do - expiration.scheduled_at + scheduled_at + else + _ -> nil 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"] || [] @@ -218,21 +273,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do summary = object.data["summary"] || "" - summary_html = - summary - |> HTML.get_cached_scrubbed_html_for_activity( - User.html_filter_policy(opts[:for]), - activity, - "mastoapi:summary" - ) - - summary_plaintext = - summary - |> HTML.get_cached_stripped_html_for_activity( - activity, - "mastoapi:summary" - ) - card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)) url = @@ -243,23 +283,51 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end direct_conversation_id = - with {_, true} <- {:include_id, opts[:with_direct_conversation_id]}, - {_, %User{} = for_user} <- {:for_user, opts[:for]}, - %{data: %{"context" => context}} when is_binary(context) <- activity, - %Conversation{} = conversation <- Conversation.get_for_ap_id(context), - %Participation{id: participation_id} <- - Participation.for_user_and_conversation(for_user, conversation) do - participation_id + with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]}, + {_, true} <- {:include_id, opts[:with_direct_conversation_id]}, + {_, %User{} = for_user} <- {:for_user, opts[:for]} do + Activity.direct_conversation_id(activity, for_user) else + {:direct_conversation_id, participation_id} when is_integer(participation_id) -> + participation_id + _e -> nil end + emoji_reactions = + with %{data: %{"reactions" => emoji_reactions}} <- object do + Enum.map(emoji_reactions, fn [emoji, users] -> + %{ + name: emoji, + count: length(users), + me: !!(opts[:for] && opts[:for].ap_id in users) + } + end) + else + _ -> [] + 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, @@ -272,10 +340,10 @@ 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_html, + spoiler_text: summary, visibility: get_visibility(object), media_attachments: attachments, poll: render(PollView, "show.json", object: object, for: opts[:for]), @@ -292,10 +360,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do conversation_id: get_context_id(activity), in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, content: %{"text/plain" => content_plaintext}, - spoiler_text: %{"text/plain" => summary_plaintext}, + spoiler_text: %{"text/plain" => summary}, expires_at: expires_at, direct_conversation_id: direct_conversation_id, - thread_muted: thread_muted? + thread_muted: thread_muted?, + emoji_reactions: emoji_reactions } } end @@ -324,11 +393,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do nil end - site_name = rich_media[:site_name] || page_url_data.host - %{ type: "link", - provider_name: site_name, + provider_name: page_url_data.host, provider_url: page_url_data.scheme <> "://" <> page_url_data.host, url: page_url, image: image_url |> MediaProxy.url(), @@ -423,7 +490,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end end - def render_content(%{data: %{"type" => "Video"}} = object) do + def render_content(%{data: %{"type" => object_type}} = object) + 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 @@ -455,11 +523,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: [] @@ -498,6 +564,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do defp present?(false), do: false defp present?(_), do: true - defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}), + defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}), do: id in pinned_activities end diff --git a/lib/pleroma/web/mastodon_api/views/push_subscription_view.ex b/lib/pleroma/web/mastodon_api/views/subscription_view.ex index 021489711..7c67cc924 100644 --- a/lib/pleroma/web/mastodon_api/views/push_subscription_view.ex +++ b/lib/pleroma/web/mastodon_api/views/subscription_view.ex @@ -1,12 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.MastodonAPI.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 a400d1c8d..94e4595d8 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do @@ -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/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 1725ab071..b2b524524 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MediaProxy do diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 8403850ff..4657a4383 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -1,9 +1,10 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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 8761260f2..a9f70c43e 100644 --- a/lib/pleroma/web/metadata.ex +++ b/lib/pleroma/web/metadata.ex @@ -1,12 +1,17 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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/feed.ex b/lib/pleroma/web/metadata/feed.ex index 8043e6c54..bd1459a17 100644 --- a/lib/pleroma/web/metadata/feed.ex +++ b/lib/pleroma/web/metadata/feed.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.Feed do @@ -16,7 +16,7 @@ defmodule Pleroma.Web.Metadata.Providers.Feed do [ rel: "alternate", type: "application/atom+xml", - href: Helpers.feed_path(Endpoint, :feed, user.nickname) <> ".atom" + href: Helpers.user_feed_path(Endpoint, :feed, user.nickname) <> ".atom" ], []} ] end diff --git a/lib/pleroma/web/metadata/opengraph.ex b/lib/pleroma/web/metadata/opengraph.ex index e7fa7f408..68c871e71 100644 --- a/lib/pleroma/web/metadata/opengraph.ex +++ b/lib/pleroma/web/metadata/opengraph.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.OpenGraph do @@ -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/player_view.ex b/lib/pleroma/web/metadata/player_view.ex index 4289ebdbd..5a918532a 100644 --- a/lib/pleroma/web/metadata/player_view.ex +++ b/lib/pleroma/web/metadata/player_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.PlayerView do diff --git a/lib/pleroma/web/metadata/provider.ex b/lib/pleroma/web/metadata/provider.ex index 197fb2a77..767288f9c 100644 --- a/lib/pleroma/web/metadata/provider.ex +++ b/lib/pleroma/web/metadata/provider.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.Provider do diff --git a/lib/pleroma/web/metadata/rel_me.ex b/lib/pleroma/web/metadata/rel_me.ex index f87fc1973..8905c9c72 100644 --- a/lib/pleroma/web/metadata/rel_me.ex +++ b/lib/pleroma/web/metadata/rel_me.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.RelMe do @@ -8,8 +8,10 @@ defmodule Pleroma.Web.Metadata.Providers.RelMe do @impl Provider def build_tags(%{user: user}) do - (Floki.attribute(user.bio, "link[rel~=me]", "href") ++ - Floki.attribute(user.bio, "a[rel~=me]", "href")) + bio_tree = Floki.parse_fragment!(user.bio) + + (Floki.attribute(bio_tree, "link[rel~=me]", "href") ++ + Floki.attribute(bio_tree, "a[rel~=me]", "href")) |> Enum.map(fn link -> {:link, [rel: "me", href: link], []} end) 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/metadata/twitter_card.ex b/lib/pleroma/web/metadata/twitter_card.ex index d6a6049b3..5d08ce422 100644 --- a/lib/pleroma/web/metadata/twitter_card.ex +++ b/lib/pleroma/web/metadata/twitter_card.ex @@ -1,6 +1,6 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.TwitterCard do @@ -31,7 +31,7 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do if attachments == [] or Metadata.activity_nsfw?(object) do [ image_tag(user), - {:meta, [property: "twitter:card", content: "summary_large_image"], []} + {:meta, [property: "twitter:card", content: "summary"], []} ] else attachments diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 382ecf426..2f0dfb474 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Utils do @@ -15,19 +15,28 @@ defmodule Pleroma.Web.Metadata.Utils do |> String.replace(~r/<br\s?\/?>/, " ") |> HTML.get_cached_stripped_html_for_activity(object, "metadata") |> Emoji.Formatter.demojify() + |> HtmlEntities.decode() |> Formatter.truncate() end def scrub_html_and_truncate(content, max_length \\ 200) when is_binary(content) do content + |> scrub_html + |> Emoji.Formatter.demojify() + |> HtmlEntities.decode() + |> Formatter.truncate(max_length) + end + + def scrub_html(content) when is_binary(content) do + content # html content comes from DB already encoded, decode first and scrub after |> HtmlEntities.decode() |> String.replace(~r/<br\s?\/?>/, " ") |> HTML.strip_tags() - |> Emoji.Formatter.demojify() - |> Formatter.truncate(max_length) end + def scrub_html(content), do: content + def attachment_url(url) do MediaProxy.url(url) end diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index 6ed181cff..6cbbe8fd8 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -1,20 +1,20 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MongooseIM.MongooseIMController do use Pleroma.Web, :controller - alias Comeonin.Pbkdf2 + alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.User - plug(RateLimiter, :authentication when action in [:user_exists, :check_password]) - plug(RateLimiter, {:authentication, params: ["user"]} when action == :check_password) + plug(RateLimiter, [name: :authentication] when action in [:user_exists, :check_password]) + 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 192984242..721b599d4 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Nodeinfo.NodeinfoController do @@ -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 - - features = - [ - "pleroma_api", - "mastodon_api", - "mastodon_api_streaming", - "polls", - "pleroma_explicit_addressing", - "shareable_emoji_packs", - 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([:suggestions, :enabled]) do - "suggestions" - 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", @@ -93,23 +55,19 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do openRegistrations: Config.get([:instance, :registrations_open]), usage: %{ users: %{ - total: stats.user_count || 0 + total: Map.get(stats, :user_count, 0) }, - localPosts: stats.status_count || 0 + localPosts: Map.get(stats, :status_count, 0) }, metadata: %{ nodeName: Config.get([:instance, :name]), nodeDescription: Config.get([:instance, :description]), private: !Config.get([:instance, :public], true), suggestions: %{ - enabled: Config.get([:suggestions, :enabled], false), - thirdPartyEngine: Config.get([:suggestions, :third_party_engine], ""), - timeout: Config.get([:suggestions, :timeout], 5000), - limit: Config.get([:suggestions, :limit], 23), - web: Config.get([:suggestions, :web], "") + enabled: false }, staffAccounts: staff_accounts, - federation: federation_response, + federation: federation, pollLimits: Config.get([:instance, :poll_limits]), postFormats: Config.get([:instance, :allowed_post_formats]), uploadLimits: %{ @@ -118,6 +76,12 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do banner: Config.get([:instance, :banner_upload_limit]), background: Config.get([:instance, :background_upload_limit]) }, + fieldsLimits: %{ + maxFields: Config.get([:instance, :max_account_fields]), + maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), + nameLength: Config.get([:instance, :account_field_name_length]), + valueLength: Config.get([:instance, :account_field_value_length]) + }, accountActivationRequired: Config.get([:instance, :account_activation_required], false), invitesEnabled: Config.get([:instance, :invites_enabled], false), mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), diff --git a/lib/pleroma/web/oauth.ex b/lib/pleroma/web/oauth.ex index 280cf28c0..2f1b8708d 100644 --- a/lib/pleroma/web/oauth.ex +++ b/lib/pleroma/web/oauth.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth do diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index cc3fb1ce5..6a6d5f2e2 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -1,10 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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/authorization.ex b/lib/pleroma/web/oauth/authorization.ex index ed42a34f3..268ee5b63 100644 --- a/lib/pleroma/web/oauth/authorization.ex +++ b/lib/pleroma/web/oauth/authorization.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Authorization do diff --git a/lib/pleroma/web/oauth/fallback_controller.ex b/lib/pleroma/web/oauth/fallback_controller.ex index dd7f08bf1..a89ced886 100644 --- a/lib/pleroma/web/oauth/fallback_controller.ex +++ b/lib/pleroma/web/oauth/fallback_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.FallbackController do diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex new file mode 100644 index 000000000..e52cccd85 --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_controller.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 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/scheduler.ex b/lib/pleroma/web/oauth/mfa_view.ex index d84cd99ad..e88e7066b 100644 --- a/lib/pleroma/scheduler.ex +++ b/lib/pleroma/web/oauth/mfa_view.ex @@ -2,6 +2,7 @@ # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Scheduler do - use Quantum.Scheduler, otp_app: :pleroma +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 03c9a5027..7c804233c 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -1,11 +1,13 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller alias Pleroma.Helpers.UriHelper + alias Pleroma.MFA + alias Pleroma.Plugs.RateLimiter alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.User @@ -13,10 +15,11 @@ 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 alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken - alias Pleroma.Web.OAuth.Scopes require Logger @@ -24,7 +27,10 @@ defmodule Pleroma.Web.OAuth.OAuthController do plug(:fetch_session) plug(:fetch_flash) - plug(Pleroma.Plugs.RateLimiter, :authentication when action == :create_authorization) + + plug(:skip_plug, [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]) + + plug(RateLimiter, [name: :authentication] when action == :create_authorization) action_fallback(Pleroma.Web.OAuth.FallbackController) @@ -36,7 +42,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do authorize(conn, Map.merge(params, auth_attrs)) end - def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, params) do + def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, %{"force_login" => _} = params) do if ControllerHelper.truthy_param?(params["force_login"]) do do_authorize(conn, params) else @@ -44,6 +50,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do end end + # Note: the token is set in oauth_plug, but the token and client do not always go together. + # For example, MastodonFE's token is set if user requests with another client, + # after user already authorized to MastodonFE. + # So we have to check client and token. + def authorize( + %Plug.Conn{assigns: %{token: %Token{} = token}} = conn, + %{"client_id" => client_id} = params + ) do + with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app), + ^client_id <- t.app.client_id do + handle_existing_authorization(conn, params) + else + _ -> do_authorize(conn, params) + end + end + def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params) defp do_authorize(%Plug.Conn{} = conn, params) do @@ -101,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 -> @@ -150,17 +173,53 @@ defmodule Pleroma.Web.OAuth.OAuthController do defp handle_create_authorization_error( %Plug.Conn{} = conn, - {:auth_active, false}, + {:account_status, :confirmation_pending}, %{"authorization" => _} = params ) do - # Per https://github.com/tootsuite/mastodon/blob/ - # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76 conn |> put_flash(:error, dgettext("errors", "Your login is missing a confirmed e-mail address")) |> put_status(:forbidden) |> authorize(params) end + 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 + conn + |> put_flash(:error, dgettext("errors", "Password reset is required")) + |> put_status(:forbidden) + |> authorize(params) + end + + defp handle_create_authorization_error( + %Plug.Conn{} = conn, + {:account_status, :deactivated}, + %{"authorization" => _} = params + ) do + conn + |> put_flash(:error, dgettext("errors", "Your account is currently disabled")) + |> put_status(:forbidden) + |> authorize(params) + end + defp handle_create_authorization_error(%Plug.Conn{} = conn, error, %{"authorization" => _}) do Authenticator.handle_error(conn, error) end @@ -191,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 @@ -201,46 +261,15 @@ defmodule Pleroma.Web.OAuth.OAuthController do ) do with {:ok, %User{} = user} <- Authenticator.get_user(conn), {:ok, app} <- Token.Utils.fetch_app(conn), - {:auth_active, true} <- {:auth_active, User.auth_active?(user)}, - {:user_active, true} <- {:user_active, !user.info.deactivated}, - {:password_reset_pending, false} <- - {:password_reset_pending, user.info.password_reset_pending}, + {: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 - {:auth_active, false} -> - # Per https://github.com/tootsuite/mastodon/blob/ - # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76 - render_error( - conn, - :forbidden, - "Your login is missing a confirmed e-mail address", - %{}, - "missing_confirmed_email" - ) - - {:user_active, false} -> - render_error( - conn, - :forbidden, - "Your account is currently disabled", - %{}, - "account_is_disabled" - ) - - {:password_reset_pending, true} -> - render_error( - conn, - :forbidden, - "Password reset is required", - %{}, - "password_reset_required" - ) - - _error -> - render_invalid_credentials_error(conn) + error -> + handle_token_exchange_error(conn, error) end end @@ -262,13 +291,57 @@ 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, + :forbidden, + "Your account is currently disabled", + %{}, + "account_is_disabled" + ) + end + + defp handle_token_exchange_error( + %Plug.Conn{} = conn, + {:account_status, :password_reset_pending} + ) do + render_error( + conn, + :forbidden, + "Password reset is required", + %{}, + "password_reset_required" + ) + end + + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :confirmation_pending}) do + render_error( + conn, + :forbidden, + "Your login is missing a confirmed e-mail address", + %{}, + "missing_confirmed_email" + ) + end + + defp handle_token_exchange_error(%Plug.Conn{} = conn, _error) do + render_invalid_credentials_error(conn) + end + def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do with {:ok, app} <- Token.Utils.fetch_app(conn), {:ok, _token} <- RevokeToken.revoke(app, params) do @@ -389,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 @@ -455,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), - {:auth_active, true} <- {:auth_active, User.auth_active?(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 @@ -470,9 +545,15 @@ 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, params) do + defp validate_scopes(%App{} = app, params) do params |> Scopes.fetch_scopes(app.scopes) |> Scopes.validate(app.scopes) diff --git a/lib/pleroma/web/oauth/oauth_view.ex b/lib/pleroma/web/oauth/oauth_view.ex index 9b37a91c5..94ddaf913 100644 --- a/lib/pleroma/web/oauth/oauth_view.ex +++ b/lib/pleroma/web/oauth/oauth_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.OAuthView do diff --git a/lib/pleroma/web/oauth/scopes.ex b/lib/pleroma/web/oauth/scopes.ex index 48bd14407..6f06f1431 100644 --- a/lib/pleroma/web/oauth/scopes.ex +++ b/lib/pleroma/web/oauth/scopes.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Scopes do @@ -7,15 +7,18 @@ defmodule Pleroma.Web.OAuth.Scopes do Functions for dealing with scopes. """ + alias Pleroma.Plugs.OAuthScopesPlug + @doc """ Fetch scopes from request params. 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 @@ -55,13 +58,19 @@ defmodule Pleroma.Web.OAuth.Scopes do """ @spec validate(list() | nil, list()) :: {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} - def validate([], _app_scopes), do: {:error, :missing_scopes} - def validate(nil, _app_scopes), do: {:error, :missing_scopes} + def validate(blank_scopes, _app_scopes) when blank_scopes in [nil, []], + do: {:error, :missing_scopes} def validate(scopes, app_scopes) do - case Pleroma.Plugs.OAuthScopesPlug.filter_descendants(scopes, app_scopes) do + case OAuthScopesPlug.filter_descendants(scopes, app_scopes) do ^scopes -> {:ok, scopes} _ -> {:error, :unsupported_scopes} end end + + def contains_admin_scopes?(scopes) do + scopes + |> OAuthScopesPlug.filter_descendants(["admin"]) + |> Enum.any?() + end end diff --git a/lib/pleroma/web/oauth/token.ex b/lib/pleroma/web/oauth/token.ex index 8ea373805..08bb7326d 100644 --- a/lib/pleroma/web/oauth/token.ex +++ b/lib/pleroma/web/oauth/token.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token do diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex index f639f9c6f..2c3bb9ded 100644 --- a/lib/pleroma/web/oauth/token/clean_worker.ex +++ b/lib/pleroma/web/oauth/token/clean_worker.ex @@ -4,19 +4,15 @@ defmodule Pleroma.Web.OAuth.Token.CleanWorker do @moduledoc """ - The module represents functions to clean an expired oauth tokens. + The module represents functions to clean an expired OAuth and MFA tokens. """ use GenServer @ten_seconds 10_000 @one_day 86_400_000 - @interval Pleroma.Config.get( - [:oauth2, :clean_expired_tokens_interval], - @one_day - ) - - alias Pleroma.Web.OAuth.Token + alias Pleroma.MFA + alias Pleroma.Web.OAuth alias Pleroma.Workers.BackgroundWorker def start_link(_), do: GenServer.start_link(__MODULE__, %{}) @@ -29,10 +25,14 @@ defmodule Pleroma.Web.OAuth.Token.CleanWorker do @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) + Process.send_after(self(), :perform, interval) {:noreply, state} end - def perform(:clean), do: Token.delete_expired_tokens() + def perform(:clean) do + OAuth.Token.delete_expired_tokens() + MFA.Token.delete_expired_tokens() + end end diff --git a/lib/pleroma/web/oauth/token/query.ex b/lib/pleroma/web/oauth/token/query.ex index 9642103e6..93d6e26ed 100644 --- a/lib/pleroma/web/oauth/token/query.ex +++ b/lib/pleroma/web/oauth/token/query.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.Query do diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex index 266110814..0e72c31e9 100644 --- a/lib/pleroma/web/oauth/token/response.ex +++ b/lib/pleroma/web/oauth/token/response.ex @@ -1,10 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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/oauth/token/strategy/refresh_token.ex b/lib/pleroma/web/oauth/token/strategy/refresh_token.ex index c620050c8..debc29b0b 100644 --- a/lib/pleroma/web/oauth/token/strategy/refresh_token.ex +++ b/lib/pleroma/web/oauth/token/strategy/refresh_token.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.Strategy.RefreshToken do diff --git a/lib/pleroma/web/oauth/token/strategy/revoke.ex b/lib/pleroma/web/oauth/token/strategy/revoke.ex index 983f095b4..069c1ee21 100644 --- a/lib/pleroma/web/oauth/token/strategy/revoke.ex +++ b/lib/pleroma/web/oauth/token/strategy/revoke.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.Strategy.Revoke do diff --git a/lib/pleroma/web/oauth/token/utils.ex b/lib/pleroma/web/oauth/token/utils.ex index 1e8765e93..43aeab6b0 100644 --- a/lib/pleroma/web/oauth/token/utils.ex +++ b/lib/pleroma/web/oauth/token/utils.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.Utils do diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 6958519de..6971cd9f8 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OStatus.OStatusController do @@ -8,17 +8,21 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Fallback.RedirectController alias Pleroma.Activity alias Pleroma.Object + alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPubController - alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Endpoint alias Pleroma.Web.Metadata.PlayerView alias Pleroma.Web.Router + plug(Pleroma.Plugs.EnsureAuthenticatedPlug, + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 + ) + plug( - Pleroma.Plugs.RateLimiter, - {:ap_routes, params: ["uuid"]} when action in [:object, :activity] + RateLimiter, + [name: :ap_routes, params: ["uuid"]] when action in [:object, :activity] ) plug( @@ -37,11 +41,9 @@ defmodule Pleroma.Web.OStatus.OStatusController do with id <- o_status_url(conn, :object, uuid), {_, %Activity{} = activity} <- {:activity, Activity.get_create_by_object_ap_id_with_object(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)}, - %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do + {_, true} <- {:public?, Visibility.is_public?(activity)} do case format do - "html" -> redirect(conn, to: "/notice/#{activity.id}") - _ -> represent_activity(conn, nil, activity, user) + _ -> redirect(conn, to: "/notice/#{activity.id}") end else reason when reason in [{:public?, false}, {:activity, nil}] -> @@ -60,11 +62,9 @@ defmodule Pleroma.Web.OStatus.OStatusController do def activity(%{assigns: %{format: format}} = conn, %{"uuid" => uuid}) do with id <- o_status_url(conn, :activity, uuid), {_, %Activity{} = activity} <- {:activity, Activity.normalize(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)}, - %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do + {_, true} <- {:public?, Visibility.is_public?(activity)} do case format do - "html" -> redirect(conn, to: "/notice/#{activity.id}") - _ -> represent_activity(conn, format, activity, user) + _ -> redirect(conn, to: "/notice/#{activity.id}") end else reason when reason in [{:public?, false}, {:activity, nil}] -> @@ -80,7 +80,15 @@ defmodule Pleroma.Web.OStatus.OStatusController do {_, true} <- {:public?, Visibility.is_public?(activity)}, %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do cond do - format == "html" && activity.data["type"] == "Create" -> + format in ["json", "activity+json"] -> + if activity.local do + %{data: %{"id" => redirect_url}} = Object.normalize(activity) + redirect(conn, external: redirect_url) + else + {:error, :not_found} + end + + activity.data["type"] == "Create" -> %Object{} = object = Object.normalize(activity) RedirectController.redirector_with_meta( @@ -93,11 +101,8 @@ defmodule Pleroma.Web.OStatus.OStatusController do } ) - format == "html" -> - RedirectController.redirector(conn, nil) - true -> - represent_activity(conn, format, activity, user) + RedirectController.redirector(conn, nil) end else reason when reason in [{:public?, false}, {:activity, nil}] -> @@ -134,31 +139,13 @@ defmodule Pleroma.Web.OStatus.OStatusController do end end - defp represent_activity( - conn, - "activity+json", - %Activity{data: %{"type" => "Create"}} = activity, - _user - ) do - object = Object.normalize(activity) - - conn - |> put_resp_header("content-type", "application/activity+json") - |> put_view(ObjectView) - |> render("object.json", %{object: object}) - end - - defp represent_activity(_conn, _, _, _) do - {:error, :not_found} - end - - def errors(conn, {:error, :not_found}) do + defp errors(conn, {:error, :not_found}) do render_error(conn, :not_found, "Not found") end - def errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) + defp errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) - def errors(conn, _) do + defp errors(conn, _) do render_error(conn, :internal_server_error, "Something went wrong") end end diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 9012e2175..0a3f45620 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.AccountController do @@ -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, :account_confirmation_resend when action == :confirmation_resend) + 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,42 +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 - new_info = %{"banner" => %{}} - - with {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) 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), - new_info <- %{"banner" => object.data}, - {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) 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}) @@ -100,18 +103,15 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do end @doc "PATCH /api/v1/pleroma/accounts/update_background" - def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do - new_info = %{"background" => %{}} - - with {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) 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), - new_info <- %{"background" => object.data}, - {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do + {:ok, _user} <- User.update_background(user, object.data) do %{"url" => [%{"href" => href} | _]} = object.data json(conn, %{url: href}) @@ -119,20 +119,21 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do end @doc "GET /api/v1/pleroma/accounts/:id/favourites" - def favourites(%{assigns: %{account: %{info: %{hide_favorites: true}}}} = conn, _params) do + def favourites(%{assigns: %{account: %{hide_favorites: true}}} = conn, _params) do render_error(conn, :forbidden, "Can't get favorites") end 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) recipients = if for_user do - [Pleroma.Constants.as_public()] ++ [for_user.ap_id | for_user.following] + [Pleroma.Constants.as_public()] ++ [for_user.ap_id | User.following(for_user)] else [Pleroma.Constants.as_public()] end @@ -145,12 +146,16 @@ 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" def subscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do - with {:ok, subscription_target} <- User.subscribe(user, subscription_target) do + with {:ok, _subscription} <- User.subscribe(user, subscription_target) do render(conn, "relationship.json", user: user, target: subscription_target) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) @@ -159,7 +164,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do @doc "POST /api/v1/pleroma/accounts/:id/unsubscribe" def unsubscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do - with {:ok, subscription_target} <- User.unsubscribe(user, subscription_target) do + with {:ok, _subscription} <- User.unsubscribe(user, subscription_target) do render(conn, "relationship.json", user: user, target: subscription_target) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index a474d41d4..d276b96a4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -1,191 +1,93 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do use Pleroma.Web, :controller - alias Pleroma.Plugs.OAuthScopesPlug - - require Logger + alias Pleroma.Emoji.Pack plug( - OAuthScopesPlug, - %{scopes: ["write"]} + Pleroma.Plugs.OAuthScopesPlug, + %{scopes: ["write"], admin: true} when action in [ + :import_from_filesystem, + :remote, + :download, :create, + :update, :delete, - :download_from, - :list_from, - :import_from_fs, + :add_file, :update_file, - :update_metadata + :delete_file ] ) - 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!() + plug( + :skip_plug, + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] + when action in [:archive, :show, :list] + ) - json(conn, list_resp) + def remote(conn, %{"url" => url}) do + with {:ok, packs} <- Pack.list_remote(url) do + json(conn, packs) else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) + {:shareable, _} -> + 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 authentification. 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) + def list(conn, _params) do + emoji_path = + Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + + with {:ok, packs} <- Pack.list_local() do + json(conn, packs) 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}"}) + |> json(%{error: "Failed to create the emoji pack directory at #{emoji_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}" + error: "Failed to get the contents of the emoji pack directory at #{emoji_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") + def show(conn, %{"name" => name}) do + name = String.trim(name) - {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} + with {:ok, pack} <- Pack.show(name) do + json(conn, 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 + {:loaded, _} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) - _ -> - create_archive_and_cache(name, pack, pack_dir, pack_file_md5) + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) 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") + def archive(conn, %{"name" => name}) do + with {:ok, archive} <- Pack.get_archive(name) do + send_download(conn, {:binary, archive}, 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" + error: + "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" }) {:exists?, _} -> @@ -195,396 +97,201 @@ keeping it in cache for #{div(cache_ms, 1000)}s") 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 + def download(conn, %{"url" => url, "name" => name} = params) do + with :ok <- Pack.download(name, url, params["as"]) do + json(conn, "ok") else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) + {:shareable, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + + {: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 - @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) + name = String.trim(name) - if not File.exists?(pack_dir) do - File.mkdir_p!(pack_dir) - - pack_file_p = Path.join(pack_dir, "pack.json") + with :ok <- Pack.create(name) do + json(conn, "ok") + else + {:error, :eexist} -> + conn + |> put_status(:conflict) + |> json(%{error: "A pack named \"#{name}\" already exists"}) - File.write!( - pack_file_p, - Jason.encode!(%{pack: %{}, files: %{}}, pretty: true) - ) + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) - conn |> json("ok") - else - conn - |> put_status(:conflict) - |> json(%{error: "A pack named \"#{name}\" already exists"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while creating pack." + ) 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) + name = String.trim(name) - case File.rm_rf(pack_dir) do - {:ok, _} -> - conn |> json("ok") + 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, _} -> + {: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 - @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) + def update(conn, %{"name" => name, "metadata" => metadata}) do + with {:ok, pack} <- Pack.update_metadata(name, metadata) do + json(conn, pack.pack) 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) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating pack metadata." + ) 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)) + def add_file(conn, %{"name" => name} = params) do + filename = params["filename"] || get_filename(params["file"]) + shortcode = params["shortcode"] || Path.basename(filename, Path.extname(filename)) - # 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) + with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params["file"]) do + json(conn, pack.files) else - {:has_shortcode, _} -> + {:exists, _} -> conn |> put_status(:conflict) |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) - true -> + {:loaded, _} -> conn |> put_status(:bad_request) - |> json(%{error: "shortcode or filename cannot be empty"}) - end - end + |> json(%{error: "pack \"#{name}\" is not found"}) - # 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") + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name, shortcode or filename cannot be empty"}) - full_pack = Jason.decode!(File.read!(pack_file_p)) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while adding file to pack." + ) + end + end - if Map.has_key?(full_pack["files"], shortcode) do - {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) + def update_file(conn, %{"name" => name, "shortcode" => shortcode} = params) do + new_shortcode = params["new_shortcode"] + new_filename = params["new_filename"] + force = params["force"] == true - emoji_file_path = Path.join(pack_dir, emoji_file_path) + with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do + json(conn, pack.files) + else + {:exists, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - # Delete the emoji file - File.rm!(emoji_file_path) + {:not_used, _} -> + conn + |> put_status(:conflict) + |> json(%{ + error: + "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" + }) - # If the old directory has no more files, remove it - if String.contains?(emoji_file_path, "/") do - dir = Path.dirname(emoji_file_path) + {:loaded, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) - if Enum.empty?(File.ls!(dir)) do - File.rmdir!(dir) - end - end + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) - update_file_and_send(conn, updated_full_pack, pack_file_p) - else - conn - |> put_status(:bad_request) - |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating file in pack." + ) 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) + def delete_file(conn, %{"name" => name, "shortcode" => shortcode}) do + with {:ok, pack} <- Pack.delete_file(name, shortcode) do + json(conn, pack.files) else - {:has_shortcode, _} -> + {:exists, _} -> conn |> put_status(:bad_request) |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - true -> + {:loaded, _} -> conn |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + |> json(%{error: "pack \"#{name}\" is not found"}) - _ -> + {:error, :empty_values} -> conn |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_file were not specified"}) - end - end + |> json(%{error: "pack name or shortcode cannot be empty"}) - def update_file(conn, %{"action" => action}) do - conn - |> put_status(:bad_request) - |> json(%{error: "Unknown action: #{action}"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while removing file from pack." + ) + end 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 - with {:ok, results} <- File.ls(emoji_dir_path()) do - imported_pack_names = - results - |> Enum.filter(fn file -> - dir_path = Path.join(emoji_dir_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) + 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) @@ -592,44 +299,6 @@ keeping it in cache for #{div(cache_ms, 1000)}s") 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 + 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/mascot_controller.ex b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex index d71d72dd5..d4e0d8b7c 100644 --- a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.MascotController do @@ -12,8 +12,6 @@ defmodule Pleroma.Web.PleromaAPI.MascotController do plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :show) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action != :show) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "GET /api/v1/pleroma/mascot" def show(%{assigns: %{user: user}} = conn, _params) do json(conn, User.get_mascot(user)) @@ -24,9 +22,7 @@ defmodule Pleroma.Web.PleromaAPI.MascotController do with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)), # Reject if not an image %{type: "image"} = attachment <- render_attachment(object) do - # Sure! - # Save to the user's info - {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, attachment)) + {:ok, _user} = User.mascot_update(user, attachment) json(conn, attachment) else diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index fc39abf05..e834133b2 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do @@ -7,27 +7,110 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do 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] + %{scopes: ["read:statuses"]} + when action in [:conversation, :conversation_statuses] ) plug( OAuthScopesPlug, - %{scopes: ["write:conversations"]} when action == :update_conversation + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} + when action == :emoji_reactions_by ) - plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification) + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"]} + when action in [:react_with_emoji, :unreact_with_emoji] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:conversations"]} + when action in [:update_conversation, :mark_conversations_as_read] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read + ) + + 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(fn + %{deactivated: false} -> true + _ -> false + end) + + %{ + 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} <- 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 - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{ + "id" => activity_id, + "emoji" => emoji + }) do + with {:ok, _activity} <- + 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), @@ -35,16 +118,20 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController 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, + %{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => participation_id} = params ) do - participation = Participation.get(participation_id, preload: [:conversation]) - - if user.id == participation.user_id do + with %Participation{user_id: ^user_id} = participation <- + Participation.get(participation_id, preload: [:conversation]) do params = params |> Map.put("blocking_user", user) @@ -53,13 +140,23 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do activities = participation.conversation.ap_id - |> ActivityPub.fetch_activities_for_context(params) + |> 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}) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) + else + _error -> + conn + |> put_status(404) + |> json(%{"error" => "Unknown conversation id"}) end end @@ -67,20 +164,27 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do %{assigns: %{user: user}} = conn, %{"id" => participation_id, "recipients" => recipients} ) do - participation = - participation_id - |> Participation.get() - - with true <- user.id == participation.user_id, + 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 + def mark_conversations_as_read(%{assigns: %{user: user}} = conn, _params) do + with {:ok, _, participations} <- Participation.mark_all_as_read(user) do conn |> add_link_headers(participations) |> put_view(ConversationView) @@ -88,7 +192,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do end end - def read_notification(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do + def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do with {:ok, notification} <- Notification.read_one(user, notification_id) do conn |> put_view(NotificationView) @@ -101,13 +205,16 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do end end - def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) do + def mark_notifications_as_read(%{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}) + |> 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 b74b3debc..22da6c0ad 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.ScrobbleController do @@ -13,10 +13,12 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do 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( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :user_scrobbles + ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do params = 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..eb9989cdf --- /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-2019 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/push/impl.ex b/lib/pleroma/web/push/impl.ex index dd445e8bf..691725702 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Push.Impl do @@ -16,46 +16,51 @@ defmodule Pleroma.Web.Push.Impl do require Logger import Ecto.Query - @types ["Create", "Follow", "Announce", "Like"] + defdelegate mastodon_notification_type(activity), to: Activity + + @types ["Create", "Follow", "Announce", "Like", "Move"] @doc "Performs sending notifications for user subscriptions" @spec perform(Notification.t()) :: list(any) | :error def perform( %{ - activity: %{data: %{"type" => activity_type}, id: activity_id} = activity, - user_id: user_id - } = notif + activity: %{data: %{"type" => activity_type}} = activity, + user: %User{id: user_id} + } = 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 %{ - title: format_title(notif), access_token: subscription.token.token, - body: format_body(notif, actor, object), - notification_id: notif.id, - notification_type: type, + notification_id: notification.id, + notification_type: mastodon_type, icon: avatar_url, preferred_locale: "en", pleroma: %{ - activity_id: activity_id + activity_id: notification.activity.id, + direct_conversation_id: direct_conversation_id } } + |> 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" @@ -80,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) @@ -97,10 +102,35 @@ defmodule Pleroma.Web.Push.Impl do } end + def build_content(notification, actor, object, mastodon_type \\ nil) + + def build_content( + %{ + user: %{notification_settings: %{privacy_option: true}} + } = notification, + _actor, + _object, + mastodon_type + ) do + %{body: format_title(notification, mastodon_type)} + end + + def build_content(notification, actor, object, mastodon_type) do + mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + + %{ + 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 @@ -108,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/push.ex b/lib/pleroma/web/push/push.ex index 7ef1532ac..b80a6438d 100644 --- a/lib/pleroma/web/push/push.ex +++ b/lib/pleroma/web/push/push.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Push do diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 988fabaeb..3e401a490 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Push.Subscription do @@ -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 d376e2069..8e2b51508 100644 --- a/lib/pleroma/web/rel_me.ex +++ b/lib/pleroma/web/rel_me.ex @@ -1,13 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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,13 +23,24 @@ defmodule Pleroma.Web.RelMe do def parse(_), do: {:error, "No URL provided"} defp parse_url(url) do - {:ok, %Tesla.Env{body: html}} = Pleroma.HTTP.get(url, [], adapter: @hackney_options) - - data = - Floki.attribute(html, "link[rel~=me]", "href") ++ - Floki.attribute(html, "a[rel~=me]", "href") - - {:ok, data} + 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: opts), + {:ok, html_tree} <- Floki.parse_document(html), + data <- + Floki.attribute(html_tree, "link[rel~=me]", "href") ++ + Floki.attribute(html_tree, "a[rel~=me]", "href") do + {:ok, data} + end rescue e -> {:error, "Parsing error: #{inspect(e)}"} end diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 6506de46c..9d3d7f978 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright _ 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright _ 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Helpers do @@ -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 c06b0a0f2..40980def8 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -1,13 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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,22 +75,32 @@ 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 + |> parse_html() |> maybe_parse() |> Map.put(:url, url) |> clean_parsed_data() |> check_parsed_data() rescue e -> - {:error, "Parsing error: #{inspect(e)}"} + {:error, "Parsing error: #{inspect(e)} #{inspect(__STACKTRACE__)}"} end end - defp parse_html(html), do: Floki.parse(html) + defp parse_html(html), do: Floki.parse_document!(html) defp maybe_parse(html) do Enum.reduce_while(parsers(), %{}, fn parser, acc -> diff --git a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex index 913975616..ae0f36702 100644 --- a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.MetaTagsParser do @@ -48,6 +48,6 @@ defmodule Pleroma.Web.RichMedia.Parsers.MetaTagsParser do defp maybe_put_title(meta, _), do: meta defp get_page_title(html) do - Floki.find(html, "title") |> Floki.text() + Floki.find(html, "html head title") |> List.first() |> Floki.text() end end diff --git a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex index 875637c4d..8f32bf91b 100644 --- a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.OEmbed do diff --git a/lib/pleroma/web/rich_media/parsers/ogp.ex b/lib/pleroma/web/rich_media/parsers/ogp.ex index d40fa009f..3e9012588 100644 --- a/lib/pleroma/web/rich_media/parsers/ogp.ex +++ b/lib/pleroma/web/rich_media/parsers/ogp.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.OGP do diff --git a/lib/pleroma/web/rich_media/parsers/twitter_card.ex b/lib/pleroma/web/rich_media/parsers/twitter_card.ex index afaa98f3d..09d4b526e 100644 --- a/lib/pleroma/web/rich_media/parsers/twitter_card.ex +++ b/lib/pleroma/web/rich_media/parsers/twitter_card.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.TwitterCard do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index f69c5c2bc..d77a61361 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Router do @@ -13,77 +13,73 @@ defmodule Pleroma.Web.Router do pipeline :oauth do plug(:fetch_session) plug(Pleroma.Plugs.OAuthPlug) + 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 @@ -93,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 @@ -134,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) @@ -171,49 +170,70 @@ defmodule Pleroma.Web.Router do post("/users/email_invite", AdminAPIController, :email_invite) get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) - patch("/users/:nickname/force_password_reset", AdminAPIController, :force_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) get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses) + get("/instances/:instance/statuses", AdminAPIController, :list_instance_statuses) + + patch("/users/confirm_email", AdminAPIController, :confirm_email) + patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email) + get("/reports", AdminAPIController, :list_reports) get("/reports/:id", AdminAPIController, :report_show) - put("/reports/:id", AdminAPIController, :report_update_state) - post("/reports/:id/respond", AdminAPIController, :report_respond) + patch("/reports", AdminAPIController, :reports_update) + post("/reports/:id/notes", AdminAPIController, :report_notes_create) + delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete) + get("/statuses/:id", AdminAPIController, :status_show) put("/statuses/:id", AdminAPIController, :status_update) delete("/statuses/:id", AdminAPIController, :status_delete) + get("/statuses", AdminAPIController, :list_statuses) get("/config", AdminAPIController, :config_show) post("/config", AdminAPIController, :config_update) - get("/config/migrate_to_db", AdminAPIController, :migrate_to_db) - get("/config/migrate_from_db", AdminAPIController, :migrate_from_db) + 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", EmojiAPIController, :import_from_filesystem) + get("/remote", EmojiAPIController, :remote) + post("/download", EmojiAPIController, :download) - post("/:pack_name/update_file", EmojiAPIController, :update_file) - post("/:pack_name/update_metadata", EmojiAPIController, :update_metadata) - put("/:name", EmojiAPIController, :create) + post("/:name", EmojiAPIController, :create) + patch("/:name", EmojiAPIController, :update) delete("/:name", EmojiAPIController, :delete) - post("/download_from", EmojiAPIController, :download_from) - post("/list_from", EmojiAPIController, :list_from) + + post("/:name/files", EmojiAPIController, :add_file) + patch("/:name/files", EmojiAPIController, :update_file) + delete("/:name/files", EmojiAPIController, :delete_file) end + # Pack info / downloading scope "/packs" do - # Pack info / downloading - get("/", EmojiAPIController, :list_packs) - get("/:name/download_shared/", EmojiAPIController, :download_shared) + get("/", EmojiAPIController, :list) + get("/:name", EmojiAPIController, :show) + get("/:name/archive", EmojiAPIController, :archive) end end @@ -221,9 +241,9 @@ defmodule Pleroma.Web.Router do pipe_through(:pleroma_html) post("/main/ostatus", UtilController, :remote_subscribe) - get("/ostatus_subscribe", UtilController, :remote_follow) + get("/ostatus_subscribe", RemoteFollowController, :follow) - post("/ostatus_subscribe", UtilController, :do_remote_follow) + post("/ostatus_subscribe", RemoteFollowController, :do_follow) end scope "/api/pleroma", Pleroma.Web.TwitterAPI do @@ -239,6 +259,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) @@ -250,6 +280,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) @@ -261,19 +295,28 @@ defmodule Pleroma.Web.Router do end 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) + 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) + post("/conversations/read", PleromaAPIController, :mark_conversations_as_read) end scope [] do pipe_through(:authenticated_api) patch("/conversations/:id", PleromaAPIController, :update_conversation) - post("/notifications/read", PleromaAPIController, :read_notification) + put("/statuses/:id/reactions/:emoji", PleromaAPIController, :react_with_emoji) + delete("/statuses/:id/reactions/:emoji", PleromaAPIController, :unreact_with_emoji) + post("/notifications/read", PleromaAPIController, :mark_notifications_as_read) patch("/accounts/update_avatar", AccountController, :update_avatar) patch("/accounts/update_banner", AccountController, :update_banner) @@ -309,51 +352,85 @@ defmodule Pleroma.Web.Router 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) @@ -365,49 +442,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 @@ -419,15 +463,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) @@ -438,25 +491,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 @@ -480,21 +523,28 @@ 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 - plug(:accepts, ["html", "xml", "atom", "activity+json", "json"]) + plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) + plug(Pleroma.Plugs.StaticFEPlug) end pipeline :oembed do @@ -502,24 +552,22 @@ 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) - get("/users/:nickname/feed", Feed.FeedController, :feed) - get("/users/:nickname", Feed.FeedController, :feed_redirect) + get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) + get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) - get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) + get("/tags/:tag", Feed.TagController, :feed, as: :tag_feed) end - pipeline :activitypub do - plug(:accepts, ["activity+json", "json"]) - plug(Pleroma.Web.Plugs.HTTPSignaturePlug) - plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) + scope "/", Pleroma.Web do + pipe_through(:browser) + get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) end scope "/", Pleroma.Web.ActivityPub do @@ -529,18 +577,22 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/outbox", ActivityPubController, :outbox) end - pipeline :activitypub_client do + 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(: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 @@ -552,6 +604,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 @@ -572,8 +625,8 @@ defmodule Pleroma.Web.Router do post("/inbox", ActivityPubController, :inbox) end - get("/following", ActivityPubController, :following, assigns: %{relay: true}) - get("/followers", ActivityPubController, :followers, assigns: %{relay: true}) + get("/following", ActivityPubController, :relay_following) + get("/followers", ActivityPubController, :relay_followers) end scope "/internal/fetch", Pleroma.Web.ActivityPub do @@ -612,12 +665,7 @@ defmodule Pleroma.Web.Router do get("/web/*path", MastoFEController, :index) 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 @@ -630,6 +678,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 new file mode 100644 index 000000000..c3efb6651 --- /dev/null +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -0,0 +1,176 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.StaticFE.StaticFEController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Metadata + alias Pleroma.Web.Router.Helpers + + plug(:put_layout, :static_fe) + plug(:put_view, Pleroma.Web.StaticFE.StaticFEView) + plug(:assign_id) + + plug(Pleroma.Plugs.EnsureAuthenticatedPlug, + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 + ) + + @page_keys ["max_id", "min_id", "limit", "since_id", "order"] + + defp get_title(%Object{data: %{"name" => name}}) when is_binary(name), + do: name + + defp get_title(%Object{data: %{"summary" => summary}}) when is_binary(summary), + do: summary + + defp get_title(_), do: nil + + defp not_found(conn, message) do + conn + |> put_status(404) + |> render("error.html", %{message: message, meta: ""}) + end + + defp get_counts(%Activity{} = activity) do + %Object{data: data} = Object.normalize(activity) + + %{ + likes: data["like_count"] || 0, + replies: data["repliesCount"] || 0, + announces: data["announcement_count"] || 0 + } + end + + defp represent(%Activity{} = activity), do: represent(activity, false) + + defp represent(%Activity{object: %Object{data: data}} = activity, selected) do + {:ok, user} = User.get_or_fetch(activity.object.data["actor"]) + + link = + case user.local do + true -> Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + _ -> data["url"] || data["external_url"] || data["id"] + end + + content = + if data["content"] do + data["content"] + |> Pleroma.HTML.filter_tags() + |> Pleroma.Emoji.Formatter.emojify(Map.get(data, "emoji", %{})) + else + nil + end + + %{ + user: User.sanitize_html(user), + title: get_title(activity.object), + content: content, + attachment: data["attachment"], + link: link, + published: data["published"], + sensitive: data["sensitive"], + selected: selected, + counts: get_counts(activity), + id: activity.id + } + end + + def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do + with %Activity{local: true} = activity <- + Activity.get_by_id_with_object(notice_id), + true <- Visibility.is_public?(activity.object), + %User{} = user <- User.get_by_ap_id(activity.object.data["actor"]) do + meta = Metadata.build_tags(%{activity_id: notice_id, object: activity.object, user: user}) + + timeline = + activity.object.data["context"] + |> ActivityPub.fetch_activities_for_context(%{}) + |> Enum.reverse() + |> Enum.map(&represent(&1, &1.object.id == activity.object.id)) + + render(conn, "conversation.html", %{activities: timeline, meta: meta}) + else + %Activity{object: %Object{data: data}} -> + conn + |> put_status(:found) + |> redirect(external: data["url"] || data["external_url"] || data["id"]) + + _ -> + not_found(conn, "Post not found.") + end + end + + def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do + case User.get_cached_by_nickname_or_id(username_or_id) do + %User{} = user -> + meta = Metadata.build_tags(%{user: user}) + + timeline = + ActivityPub.fetch_user_activities(user, nil, Map.take(params, @page_keys)) + |> Enum.map(&represent/1) + + prev_page_id = + (params["min_id"] || params["max_id"]) && + List.first(timeline) && List.first(timeline).id + + next_page_id = List.last(timeline) && List.last(timeline).id + + render(conn, "profile.html", %{ + user: User.sanitize_html(user), + timeline: timeline, + prev_page_id: prev_page_id, + next_page_id: next_page_id, + meta: meta + }) + + _ -> + not_found(conn, "User not found.") + end + end + + def show(%{assigns: %{object_id: _}} = conn, _params) do + url = Helpers.url(conn) <> conn.request_path + + case Activity.get_create_by_object_ap_id_with_object(url) do + %Activity{} = activity -> + to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) + redirect(conn, to: to) + + _ -> + not_found(conn, "Post not found.") + end + end + + def show(%{assigns: %{activity_id: _}} = conn, _params) do + url = Helpers.url(conn) <> conn.request_path + + case Activity.get_by_ap_id(url) do + %Activity{} = activity -> + to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) + redirect(conn, to: to) + + _ -> + not_found(conn, "Post not found.") + end + end + + defp assign_id(%{path_info: ["notice", notice_id]} = conn, _opts), + do: assign(conn, :notice_id, notice_id) + + defp assign_id(%{path_info: ["users", user_id]} = conn, _opts), + do: assign(conn, :username_or_id, user_id) + + defp assign_id(%{path_info: ["objects", object_id]} = conn, _opts), + do: assign(conn, :object_id, object_id) + + defp assign_id(%{path_info: ["activities", activity_id]} = conn, _opts), + do: assign(conn, :activity_id, activity_id) + + defp assign_id(conn, _opts), do: conn +end diff --git a/lib/pleroma/web/static_fe/static_fe_view.ex b/lib/pleroma/web/static_fe/static_fe_view.ex new file mode 100644 index 000000000..b3d1d1ec8 --- /dev/null +++ b/lib/pleroma/web/static_fe/static_fe_view.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.StaticFE.StaticFEView do + use Pleroma.Web, :view + + alias Calendar.Strftime + alias Pleroma.Emoji.Formatter + alias Pleroma.User + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Gettext + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Metadata.Utils + alias Pleroma.Web.Router.Helpers + + use Phoenix.HTML + + @media_types ["image", "audio", "video"] + + def fetch_media_type(%{"mediaType" => mediaType}) do + Utils.fetch_media_type(@media_types, mediaType) + end + + def format_date(date) do + {:ok, date, _} = DateTime.from_iso8601(date) + Strftime.strftime!(date, "%Y/%m/%d %l:%M:%S %p UTC") + end + + def instance_name, do: Pleroma.Config.get([:instance, :name], "Pleroma") + + def open_content? do + Pleroma.Config.get( + [:frontend_configurations, :collapse_message_with_subjects], + true + ) + end +end diff --git a/lib/pleroma/web/streamer/ping.ex b/lib/pleroma/web/streamer/ping.ex deleted file mode 100644 index db3e68abe..000000000 --- a/lib/pleroma/web/streamer/ping.ex +++ /dev/null @@ -1,37 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 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 5ce3ebb8a..000000000 --- a/lib/pleroma/web/streamer/state.ex +++ /dev/null @@ -1,82 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 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 2fc7ac8cf..49a400df7 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -1,55 +1,290 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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, item) 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 cf0fa3077..000000000 --- a/lib/pleroma/web/streamer/streamer_socket.ex +++ /dev/null @@ -1,35 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 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 ec5985085..000000000 --- a/lib/pleroma/web/streamer/supervisor.ex +++ /dev/null @@ -1,37 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 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 0ea224874..000000000 --- a/lib/pleroma/web/streamer/worker.ex +++ /dev/null @@ -1,224 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 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 - blocks = user.info.blocks || [] - mutes = user.info.mutes || [] - reblog_mutes = user.info.muted_reblogs || [] - recipient_blocks = MapSet.new(blocks ++ mutes) - recipients = MapSet.new(item.recipients) - domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.info.domain_blocks) - - with parent when not is_nil(parent) <- Object.normalize(item), - true <- Enum.all?([blocks, mutes, reblog_mutes], &(item.actor not in &1)), - true <- Enum.all?([blocks, mutes], &(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{info: %{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/email/new_users_digest.html.eex b/lib/pleroma/web/templates/email/new_users_digest.html.eex new file mode 100644 index 000000000..40d9b8381 --- /dev/null +++ b/lib/pleroma/web/templates/email/new_users_digest.html.eex @@ -0,0 +1,158 @@ +<%= for {user, total_statuses, latest_status} <- @users_and_statuses do %> + <%# user card START %> + <div style="background-color:transparent;"> + <div class="block-grid mixed-two-up no-stack" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="147" style="background-color:<%= @styling.content_background_color%>;width:76px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 20px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num3" + style="display: table-cell; vertical-align: top; max-width: 320px; min-width: 76px; width: 76px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 20px;"> + <!--<![endif]--> + <div align="left" class="img-container left " + style="padding-right: 0px;padding-left: 0px;"> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr style="line-height:0px"><td style="padding-right: 0px;padding-left: 0px;" align="left"><![endif]--><img + alt="<%= user.name %>" border="0" class="left " src="<%= avatar_url(user) %>" + style="text-decoration: none; -ms-interpolation-mode: bicubic; border: 0; height: auto; width: 100%; max-width: 76px; display: block;" + title="<%= user.name %>" width="76" /> + <!--[if mso]></td></tr></table><![endif]--> + </div> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td><td align="center" width="442" style="background-color:<%= @styling.content_background_color%>;width:442px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num9" + style="display: table-cell; vertical-align: top; min-width: 320px; max-width: 441px; width: 442px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_color %>;"> + <p style="font-size: 14px; line-height: 19px; margin: 0;"><span + style="font-size: 16px; color: <%= @styling.text_color %>;"><%= user.name %></span></p> + <p style="font-size: 14px; line-height: 19px; margin: 0;"><span + style="font-size: 16px;"><%= link "@" <> user.nickname, style: "color: #{@styling.link_color};text-decoration: none;", to: admin_user_url(user) %></span></p> + <p style="font-size: 14px; line-height: 19px; margin: 0;"><span + style="font-size: 16px;">Total: <%= total_statuses %></span></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <%# user card END %> + + <%= if latest_status do %> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 15px; padding-left: 15px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 15px; padding-left: 15px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_color %>;"> + <span style="font-size: 16px; line-height: 19px;"><%= raw latest_status.object.data["content"] %></span></div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 15px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_muted_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:15px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_muted_color %>;"> + <p style="font-size: 14px; line-height: 16px; margin: 0;"><%= format_date latest_status.object.data["published"] %></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <% end %> + <%# divider start %> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <table border="0" cellpadding="0" cellspacing="0" class="divider" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td class="divider_inner" + style="word-break: break-word; vertical-align: top; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; padding-top: 10px; padding-right: 10px; padding-bottom: 10px; padding-left: 10px;" + valign="top"> + <table align="center" border="0" cellpadding="0" cellspacing="0" class="divider_content" + height="0" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; border-top: 1px solid <%= @styling.text_color %>; height: 0px;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td height="0" + style="word-break: break-word; vertical-align: top; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top"><span></span></td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + + <%# divider end %> + <%# user card END %> +<% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index d1f5e903c..78350f2aa 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -2,12 +2,14 @@ <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><%= "New note by #{@user.nickname}" %></title> - <content type="html"><%= activity_content(@activity) %></content> - <published><%= @data["published"] %></published> - <updated><%= @data["published"] %></updated> - <ostatus:conversation ref="<%= activity_context(@activity) %>"><%= activity_context(@activity) %></ostatus:conversation> - <link ref="<%= activity_context(@activity) %>" rel="ostatus:conversation"/> + <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> + <link href="<%= activity_context(@activity) %>" rel="ostatus:conversation"/> <%= if @data["summary"] do %> <summary><%= @data["summary"] %></summary> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex new file mode 100644 index 000000000..a304a16af --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -0,0 +1,49 @@ +<item> + <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(@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> + <link rel="ostatus:conversation"><%= activity_context(@activity) %></link> + + <%= if @data["summary"] do %> + <description><%= @data["summary"] %></description> + <% end %> + + <%= if @activity.local do %> + <link><%= @data["id"] %></link> + <% else %> + <link><%= @data["external_url"] %></link> + <% end %> + + <%= for tag <- @data["tag"] || [] do %> + <category term="<%= tag %>"></category> + <% end %> + + <%= for attachment <- @data["attachment"] || [] do %> + <link type="<%= attachment_type(attachment) %>"><%= attachment_href(attachment) %></link> + <% end %> + + <%= if @data["inReplyTo"] do %> + <thr:in-reply-to ref='<%= @data["inReplyTo"] %>' href='<%= get_href(@data["inReplyTo"]) %>'/> + <% end %> + + <%= for id <- @activity.recipients do %> + <%= if id == Pleroma.Constants.as_public() do %> + <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection">http://activityschema.org/collection/public</link> + <% else %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person"><%= id %></link> + <% end %> + <% end %> + <% end %> + + <%= for {emoji, file} <- @data["emoji"] || %{} do %> + <link name="<%= emoji %>" rel="emoji"><%= file %></link> + <% end %> +</item> diff --git a/lib/pleroma/web/templates/feed/feed/_author.xml.eex b/lib/pleroma/web/templates/feed/feed/_author.atom.eex index 25cbffada..25cbffada 100644 --- a/lib/pleroma/web/templates/feed/feed/_author.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/_author.atom.eex diff --git a/lib/pleroma/web/templates/feed/feed/_author.rss.eex b/lib/pleroma/web/templates/feed/feed/_author.rss.eex new file mode 100644 index 000000000..526aeddcf --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_author.rss.eex @@ -0,0 +1,17 @@ +<managingEditor> + <guid><%= @user.ap_id %></guid> + <activity:object>http://activitystrea.ms/schema/1.0/person</activity:object> + <uri><%= @user.ap_id %></uri> + <poco:preferredUsername><%= @user.nickname %></poco:preferredUsername> + <poco:displayName><%= @user.name %></poco:displayName> + <poco:note><%= escape(@user.bio) %></poco:note> + <description><%= escape(@user.bio) %></description> + <name><%= @user.nickname %></name> + <link rel="avatar"><%= User.avatar_url(@user) %></link> + <%= if User.banner_url(@user) do %> + <link rel="header"><%= User.banner_url(@user) %></link> + <% end %> + <%= if @user.local do %> + <ap_enabled>true</ap_enabled> + <% end %> +</managingEditor> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex new file mode 100644 index 000000000..cf5874a91 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -0,0 +1,51 @@ +<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(@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"/> + <link type="text/html" href='<%= @data["id"] %>' rel="alternate"/> + <% else %> + <link type="text/html" href='<%= @data["external_url"] %>' rel="alternate"/> + <% end %> + + <published><%= @activity.data["published"] %></published> + <updated><%= @activity.data["published"] %></updated> + + <ostatus:conversation ref="<%= activity_context(@activity) %>"> + <%= activity_context(@activity) %> + </ostatus:conversation> + <link href="<%= activity_context(@activity) %>" rel="ostatus:conversation"/> + + <%= if @data["summary"] do %> + <summary><%= @data["summary"] %></summary> + <% end %> + + <%= for id <- @activity.recipients do %> + <%= if id == Pleroma.Constants.as_public() do %> + <link rel="mentioned" + ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" + href="http://activityschema.org/collection/public"/> + <% else %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + <link rel="mentioned" + ostatus:object-type="http://activitystrea.ms/schema/1.0/person" + href="<%= id %>" /> + <% end %> + <% end %> + <% end %> + + <%= for tag <- @data["tag"] || [] do %> + <category term="<%= tag %>"></category> + <% end %> + + <%= for {emoji, file} <- @data["emoji"] || %{} do %> + <link name="<%= emoji %>" rel="emoji" href="<%= file %>"/> + <% end %> +</entry> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex new file mode 100644 index 000000000..2334e24a2 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex @@ -0,0 +1,14 @@ +<item> + <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(@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> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_author.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_author.atom.eex new file mode 100644 index 000000000..997c4936e --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_tag_author.atom.eex @@ -0,0 +1,18 @@ +<author> + <activity:object-type>http://activitystrea.ms/schema/1.0/person</activity:object-type> + <id><%= @actor.ap_id %></id> + <uri><%= @actor.ap_id %></uri> + <name><%= @actor.nickname %></name> + <summary><%= escape(@actor.bio) %></summary> + <link rel="avatar" href="<%= User.avatar_url(@actor) %>"/> + <%= if User.banner_url(@actor) do %> + <link rel="header" href="<%= User.banner_url(@actor) %>"/> + <% end %> + <%= if @actor.local do %> + <ap_enabled>true</ap_enabled> + <% end %> + + <poco:preferredUsername><%= @actor.nickname %></poco:preferredUsername> + <poco:displayName><%= @actor.name %></poco:displayName> + <poco:note><%= escape(@actor.bio) %></poco:note> +</author> diff --git a/lib/pleroma/web/templates/feed/feed/tag.atom.eex b/lib/pleroma/web/templates/feed/feed/tag.atom.eex new file mode 100644 index 000000000..a288539ed --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/tag.atom.eex @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<feed xml:lang="en-US" xmlns="http://www.w3.org/2005/Atom" + xmlns:thr="http://purl.org/syndication/thread/1.0" + xmlns:georss="http://www.georss.org/georss" + xmlns:activity="http://activitystrea.ms/spec/1.0/" + xmlns:media="http://purl.org/syndication/atommedia" + xmlns:poco="http://portablecontacts.net/spec/1.0" + xmlns:ostatus="http://ostatus.org/schema/1.0" + xmlns:statusnet="http://status.net/schema/api/1/"> + + <id><%= '#{tag_feed_url(@conn, :feed, @tag)}.rss' %></id> + <title>#<%= @tag %></title> + + <subtitle>These are public toots tagged with #<%= @tag %>. You can interact with them if you have an account anywhere in the fediverse.</subtitle> + <logo><%= feed_logo() %></logo> + <updated><%= most_recent_update(@activities) %></updated> + <link rel="self" href="<%= '#{tag_feed_url(@conn, :feed, @tag)}.atom' %>" type="application/atom+xml"/> + <%= for activity <- @activities do %> + <%= render @view_module, "_tag_activity.atom", Map.merge(assigns, prepare_activity(activity, actor: true)) %> + <% end %> +</feed> diff --git a/lib/pleroma/web/templates/feed/feed/tag.rss.eex b/lib/pleroma/web/templates/feed/feed/tag.rss.eex new file mode 100644 index 000000000..eeda01a04 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/tag.rss.eex @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:webfeeds="http://webfeeds.org/rss/1.0"> + <channel> + + + <title>#<%= @tag %></title> + <description>These are public toots tagged with #<%= @tag %>. You can interact with them if you have an account anywhere in the fediverse.</description> + <link><%= '#{tag_feed_url(@conn, :feed, @tag)}.rss' %></link> + <webfeeds:logo><%= feed_logo() %></webfeeds:logo> + <webfeeds:accentColor>2b90d9</webfeeds:accentColor> + <%= for activity <- @activities do %> + <%= render @view_module, "_tag_activity.xml", Map.merge(assigns, prepare_activity(activity)) %> + <% end %> + </channel> +</rss> diff --git a/lib/pleroma/web/templates/feed/feed/feed.xml.eex b/lib/pleroma/web/templates/feed/feed/user.atom.eex index 45df9dc09..c6acd848f 100644 --- a/lib/pleroma/web/templates/feed/feed/feed.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/user.atom.eex @@ -6,19 +6,19 @@ xmlns:poco="http://portablecontacts.net/spec/1.0" xmlns:ostatus="http://ostatus.org/schema/1.0"> - <id><%= feed_url(@conn, :feed, @user.nickname) <> ".atom" %></id> + <id><%= user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %></id> <title><%= @user.nickname <> "'s timeline" %></title> <updated><%= most_recent_update(@activities, @user) %></updated> <logo><%= logo(@user) %></logo> - <link rel="self" href="<%= '#{feed_url(@conn, :feed, @user.nickname)}.atom' %>" type="application/atom+xml"/> + <link rel="self" href="<%= '#{user_feed_url(@conn, :feed, @user.nickname)}.atom' %>" type="application/atom+xml"/> - <%= render @view_module, "_author.xml", assigns %> + <%= render @view_module, "_author.atom", assigns %> <%= if last_activity(@activities) do %> - <link rel="next" href="<%= '#{feed_url(@conn, :feed, @user.nickname)}.atom?max_id=#{last_activity(@activities).id}' %>" type="application/atom+xml"/> + <link rel="next" href="<%= '#{user_feed_url(@conn, :feed, @user.nickname)}.atom?max_id=#{last_activity(@activities).id}' %>" type="application/atom+xml"/> <% end %> <%= for activity <- @activities do %> - <%= render @view_module, "_activity.xml", Map.merge(assigns, %{activity: activity, data: activity_object_data(activity)}) %> + <%= render @view_module, "_activity.atom", Map.merge(assigns, prepare_activity(activity)) %> <% end %> </feed> diff --git a/lib/pleroma/web/templates/feed/feed/user.rss.eex b/lib/pleroma/web/templates/feed/feed/user.rss.eex new file mode 100644 index 000000000..d69120480 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/user.rss.eex @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<rss version="2.0"> + <channel> + <guid><%= user_feed_url(@conn, :feed, @user.nickname) <> ".rss" %></guid> + <title><%= @user.nickname <> "'s timeline" %></title> + <updated><%= most_recent_update(@activities, @user) %></updated> + <image><%= logo(@user) %></image> + <link><%= '#{user_feed_url(@conn, :feed, @user.nickname)}.rss' %></link> + + <%= render @view_module, "_author.rss", assigns %> + + <%= if last_activity(@activities) do %> + <link rel="next"><%= '#{user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}' %></link> + <% end %> + + <%= for activity <- @activities do %> + <%= render @view_module, "_activity.rss", Map.merge(assigns, prepare_activity(activity)) %> + <% end %> + </channel> +</rss> diff --git a/lib/pleroma/web/templates/layout/email_styled.html.eex b/lib/pleroma/web/templates/layout/email_styled.html.eex new file mode 100644 index 000000000..ca2caaf4d --- /dev/null +++ b/lib/pleroma/web/templates/layout/email_styled.html.eex @@ -0,0 +1,193 @@ +<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> + +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" + xmlns:v="urn:schemas-microsoft-com:vml"> + +<head> + <!--[if gte mso 9]><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]--> + <meta content="text/html; charset=utf-8" http-equiv="Content-Type" /> + <meta content="width=device-width" name="viewport" /> + <!--[if !mso]><!--> + <meta content="IE=edge" http-equiv="X-UA-Compatible" /> + <!--<![endif]--> + <title><%= @email.subject %></title> + <!--[if !mso]><!--> + <!--<![endif]--> + <style type="text/css"> + body { + margin: 0; + padding: 0; + } + + a { + + color: <%= @styling.link_color %>; + text-decoration: none; + } + + table, + td, + tr { + vertical-align: top; + border-collapse: collapse; + } + + * { + line-height: inherit; + } + + a[x-apple-data-detectors=true] { + color: inherit !important; + text-decoration: none !important; + } + </style> + <style id="media-query" type="text/css"> + @media (max-width: 610px) { + + .block-grid, + .col { + min-width: 320px !important; + max-width: 100% !important; + display: block !important; + } + + .block-grid { + width: 100% !important; + } + + .col { + width: 100% !important; + } + + .col>div { + margin: 0 auto; + } + + .no-stack .col { + min-width: 0 !important; + display: table-cell !important; + } + + .no-stack.two-up .col { + width: 50% !important; + } + + .no-stack .col.num4 { + width: 33% !important; + } + + .no-stack .col.num8 { + width: 66% !important; + } + + .no-stack .col.num4 { + width: 33% !important; + } + + .no-stack .col.num3 { + width: 25% !important; + } + + .no-stack .col.num6 { + width: 50% !important; + } + + .no-stack .col.num9 { + width: 75% !important; + } + + } + </style> +</head> + +<body class="clean-body" style="margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: <%= @styling.background_color %>;"> + <!--[if IE]><div class="ie-browser"><![endif]--> + <table bgcolor="<%= @styling.background_color %>" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" + style="table-layout: fixed; vertical-align: top; min-width: 320px; Margin: 0 auto; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: <%= @styling.background_color %>; width: 100%;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td style="word-break: break-word; vertical-align: top;" valign="top"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color:<%= @styling.background_color %>"><![endif]--> + + <%# header %> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <div align="center" class="img-container center" + style="padding-right: 0px;padding-left: 0px;"> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr style="line-height:0px"><td style="padding-right: 0px;padding-left: 0px;" align="center"><![endif]--><img + align="center" alt="Image" border="0" class="center" src="<%= @logo_url %>" + style="text-decoration: none; -ms-interpolation-mode: bicubic; border: 0; height: 80px; width: auto; max-height: 80px; display: block;" + title="Image" height="80" /> + <!--[if mso]></td></tr></table><![endif]--> + </div> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + + + <%# title %> + <%= if @title do %> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height: 14px; color: <%= @styling.header_color %>;"> + <p style="line-height: 36px; text-align: center; margin: 0;"><span + style="font-size: 30px; color: <%= @styling.header_color %>;"><%= @title %></span></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <% end %> + <%= render @view_module, @view_template, assigns %> + + </td> + </tr> + </tbody> + </table> + <!--[if (IE)]></div><![endif]--> +</body> + +</html> diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex new file mode 100644 index 000000000..dc0ee2a5c --- /dev/null +++ b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <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-fe/static-fe.css"> + </head> + <body> + <div class="container"> + <%= render @view_module, @view_template, assigns %> + </div> + </body> +</html> 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 new file mode 100644 index 000000000..4853e7f4b --- /dev/null +++ b/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex @@ -0,0 +1,8 @@ +<%= case @mediaType do %> +<% "audio" -> %> +<audio class="u-audio" src="<%= @url %>" controls="controls"></audio> +<% "video" -> %> +<video class="u-video" src="<%= @url %>" controls="controls"></video> +<% _ -> %> +<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 new file mode 100644 index 000000000..df0244795 --- /dev/null +++ b/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex @@ -0,0 +1,41 @@ +<div class="activity h-entry" <%= if @selected do %> id="selected" <% end %>> + <p class="pull-right"> + <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 class="p-name"><%= raw @title %></summary> + <div class="e-content"><%= raw @content %></div> + </details> + <% else %> + <div class="e-content"><%= raw @content %></div> + <% end %> + <%= for %{"name" => name, "url" => [url | _]} <- @attachment do %> + <%= if @sensitive do %> + <details class="nsfw"> + <summary><%= Gettext.gettext("sensitive media") %></summary> + <div> + <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> + </div> + </details> + <% else %> + <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> + <% end %> + <% end %> + </div> + <%= if @selected do %> + <dl class="counts"> + <dt><%= Gettext.gettext("replies") %></dt><dd><%= @counts.replies %></dd> + <dt><%= Gettext.gettext("announces") %></dt><dd><%= @counts.announces %></dd> + <dt><%= Gettext.gettext("likes") %></dt><dd><%= @counts.likes %></dd> + </dl> + <% end %> +</div> 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 new file mode 100644 index 000000000..977b894d3 --- /dev/null +++ b/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex @@ -0,0 +1,11 @@ +<div class="p-author h-card"> + <a class="u-url" rel="author noopener" href="<%= (@user.uri || @user.ap_id) %>"> + <div class="avatar"> + <img class="u-photo" src="<%= User.avatar_url(@user) |> MediaProxy.url %>" width="48" height="48" alt=""> + </div> + <span class="display-name"> + <bdi class="p-name"><%= raw Formatter.emojify(@user.name, @user.emoji) %></bdi> + <span class="nickname"><%= @user.nickname %></span> + </span> + </a> +</div> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex new file mode 100644 index 000000000..2acd84828 --- /dev/null +++ b/lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex @@ -0,0 +1,11 @@ +<header> + <h1><%= link instance_name(), to: "/" %></h1> +</header> + +<main> + <div class="conversation"> + <%= for activity <- @activities do %> + <%= render("_notice.html", activity) %> + <% end %> + </div> +</main> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/error.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/error.html.eex new file mode 100644 index 000000000..d98a1eba7 --- /dev/null +++ b/lib/pleroma/web/templates/static_fe/static_fe/error.html.eex @@ -0,0 +1,7 @@ +<header> + <h1><%= gettext("Oops") %></h1> +</header> + +<main> + <p><%= @message %></p> +</main> 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 new file mode 100644 index 000000000..3191bf450 --- /dev/null +++ b/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex @@ -0,0 +1,31 @@ +<header> + <h1><%= link instance_name(), to: "/" %></h1> + + <h3> + <form class="pull-right collapse" method="POST" action="<%= Helpers.util_path(@conn, :remote_subscribe) %>"> + <input type="hidden" name="nickname" value="<%= @user.nickname %>"> + <input type="hidden" name="profile" value=""> + <button type="submit" class="collapse">Remote follow</button> + </form> + <%= 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> + +<main> + <div class="activity-stream"> + <%= for activity <- @timeline do %> + <%= render("_notice.html", Map.put(activity, :selected, false)) %> + <% end %> + <p id="pagination"> + <%= if @prev_page_id do %> + <%= link "«", to: "?min_id=" <> @prev_page_id %> + <% end %> + <%= if @prev_page_id && @next_page_id, do: " | " %> + <%= if @next_page_id do %> + <%= link "»", to: "?max_id=" <> @next_page_id %> + <% end %> + </p> + </div> +</main> diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex new file mode 100644 index 000000000..5ba192cd7 --- /dev/null +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex @@ -0,0 +1,11 @@ +<%= if @error == :error do %> + <h2>Error fetching user</h2> +<% else %> + <h2>Remote follow</h2> + <img height="128" width="128" src="<%= avatar_url(@followee) %>"> + <p><%= @followee.nickname %></p> + <%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "user"], fn f -> %> + <%= hidden_input f, :id, value: @followee.id %> + <%= submit "Authorize" %> + <% end %> +<% end %> diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex new file mode 100644 index 000000000..df44988ee --- /dev/null +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex @@ -0,0 +1,14 @@ +<%= if @error do %> +<h2><%= @error %></h2> +<% end %> +<h2>Log in to follow</h2> +<p><%= @followee.nickname %></p> +<img height="128" width="128" src="<%= avatar_url(@followee) %>"> +<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "authorization"], fn f -> %> +<%= text_input f, :name, placeholder: "Username", required: true %> +<br> +<%= password_input f, :password, placeholder: "Password", required: true %> +<br> +<%= hidden_input f, :id, value: @followee.id %> +<%= submit "Authorize" %> +<% end %> 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/templates/twitter_api/util/followed.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/followed.html.eex index da473d502..da473d502 100644 --- a/lib/pleroma/web/templates/twitter_api/util/followed.html.eex +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/followed.html.eex diff --git a/lib/pleroma/web/templates/twitter_api/util/follow.html.eex b/lib/pleroma/web/templates/twitter_api/util/follow.html.eex deleted file mode 100644 index 06359fa6c..000000000 --- a/lib/pleroma/web/templates/twitter_api/util/follow.html.eex +++ /dev/null @@ -1,11 +0,0 @@ -<%= if @error == :error do %> - <h2>Error fetching user</h2> -<% else %> - <h2>Remote follow</h2> - <img width="128" height="128" src="<%= @avatar %>"> - <p><%= @name %></p> - <%= form_for @conn, util_path(@conn, :do_remote_follow), [as: "user"], fn f -> %> - <%= hidden_input f, :id, value: @id %> - <%= submit "Authorize" %> - <% end %> -<% end %> diff --git a/lib/pleroma/web/templates/twitter_api/util/follow_login.html.eex b/lib/pleroma/web/templates/twitter_api/util/follow_login.html.eex deleted file mode 100644 index 4e3a2be67..000000000 --- a/lib/pleroma/web/templates/twitter_api/util/follow_login.html.eex +++ /dev/null @@ -1,14 +0,0 @@ -<%= if @error do %> - <h2><%= @error %></h2> -<% end %> -<h2>Log in to follow</h2> -<p><%= @name %></p> -<img height="128" width="128" src="<%= @avatar %>"> -<%= form_for @conn, util_path(@conn, :do_remote_follow), [as: "authorization"], fn f -> %> -<%= text_input f, :name, placeholder: "Username" %> -<br> -<%= password_input f, :password, placeholder: "Password" %> -<br> -<%= hidden_input f, :id, value: @id %> -<%= submit "Authorize" %> -<% end %> diff --git a/lib/pleroma/web/translation_helpers.ex b/lib/pleroma/web/translation_helpers.ex index a104ea6b8..7f78ce1b9 100644 --- a/lib/pleroma/web/translation_helpers.ex +++ b/lib/pleroma/web/translation_helpers.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TranslationHelpers do diff --git a/lib/pleroma/web/twitter_api/controllers/password_controller.ex b/lib/pleroma/web/twitter_api/controllers/password_controller.ex index 1941e6143..800ab8954 100644 --- a/lib/pleroma/web/twitter_api/controllers/password_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/password_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.PasswordController do diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex new file mode 100644 index 000000000..521dc9322 --- /dev/null +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -0,0 +1,159 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do + use Pleroma.Web, :controller + + 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"] + + plug(Pleroma.Web.FederatingPlug) + + # 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 in [:do_follow] + ) + + # GET /ostatus_subscribe + # + def follow(%{assigns: %{user: user}} = conn, %{"acct" => acct}) do + case is_status?(acct) do + true -> follow_status(conn, user, acct) + _ -> follow_account(conn, user, acct) + end + end + + defp follow_status(conn, _user, acct) do + with {:ok, object} <- Fetcher.fetch_object_from_id(acct), + %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(object.data["id"]) do + redirect(conn, to: o_status_path(conn, :notice, activity_id)) + else + error -> + handle_follow_error(conn, error) + end + end + + defp follow_account(conn, user, acct) do + with {:ok, followee} <- User.get_or_fetch(acct) do + render(conn, follow_template(user), %{error: false, followee: followee, acct: acct}) + else + {:error, _reason} -> + render(conn, follow_template(user), %{error: :error}) + end + end + + defp follow_template(%User{} = _user), do: "follow.html" + defp follow_template(_), do: "follow_login.html" + + defp is_status?(acct) do + case Fetcher.fetch_and_contain_remote_object_from_id(acct) do + {:ok, %{"type" => type}} when type in @status_types -> + true + + _ -> + false + end + end + + # 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 + redirect(conn, to: "/users/#{followee.id}") + else + error -> + handle_follow_error(conn, error) + 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 {_, %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 + error -> + handle_follow_error(conn, error) + end + end + + def do_follow(%{assigns: %{user: nil}} = conn, _) do + Logger.debug("Insufficient permissions: follow | write:follows.") + 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 + + defp handle_follow_error(conn, {:fetch_user, error} = _) do + Logger.debug("Remote follow failed with error #{inspect(error)}") + render(conn, "followed.html", %{error: "Could not find user"}) + end + + defp handle_follow_error(conn, {:error, "Could not follow user:" <> _} = _) do + render(conn, "followed.html", %{error: "Error following account"}) + end + + defp handle_follow_error(conn, error) do + Logger.debug("Remote follow failed with error #{inspect(error)}") + render(conn, "followed.html", %{error: "Something went wrong."}) + end +end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 2305bb413..fd2aee175 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.UtilController do @@ -7,22 +7,22 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do require Logger - alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Emoji alias Pleroma.Healthcheck alias Pleroma.Notification - alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.CommonAPI alias Pleroma.Web.WebFinger + plug(Pleroma.Web.FederatingPlug when action == :remote_subscribe) + plug( OAuthScopesPlug, %{scopes: ["follow", "write:follows"]} - when action in [:do_remote_follow, :follow_import] + when action == :follow_import ) plug(OAuthScopesPlug, %{scopes: ["follow", "write:blocks"]} when action == :blocks_import) @@ -77,94 +77,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do end end - def remote_follow(%{assigns: %{user: user}} = conn, %{"acct" => acct}) do - if is_status?(acct) do - {:ok, object} = Pleroma.Object.Fetcher.fetch_object_from_id(acct) - %Activity{id: activity_id} = Activity.get_create_by_object_ap_id(object.data["id"]) - redirect(conn, to: "/notice/#{activity_id}") - else - with {:ok, followee} <- User.get_or_fetch(acct) do - conn - |> render(follow_template(user), %{ - error: false, - acct: acct, - avatar: User.avatar_url(followee), - name: followee.nickname, - id: followee.id - }) - else - {:error, _reason} -> - render(conn, follow_template(user), %{error: :error}) - end - end - end - - defp follow_template(%User{} = _user), do: "follow.html" - defp follow_template(_), do: "follow_login.html" - - defp is_status?(acct) do - case Pleroma.Object.Fetcher.fetch_and_contain_remote_object_from_id(acct) do - {:ok, %{"type" => type}} when type in ["Article", "Note", "Video", "Page", "Question"] -> - true - - _ -> - false - end - end - - def do_remote_follow(conn, %{ - "authorization" => %{"name" => username, "password" => password, "id" => id} - }) do - with %User{} = followee <- User.get_cached_by_id(id), - {_, %User{} = user, _} <- {:auth, User.get_cached_by_nickname(username), followee}, - {_, true, _} <- { - :auth, - AuthenticationPlug.checkpw(password, user.password_hash), - followee - }, - {:ok, _follower, _followee, _activity} <- CommonAPI.follow(user, followee) do - conn - |> render("followed.html", %{error: false}) - else - # Was already following user - {:error, "Could not follow user:" <> _rest} -> - render(conn, "followed.html", %{error: "Error following account"}) - - {:auth, _, followee} -> - conn - |> render("follow_login.html", %{ - error: "Wrong username or password", - id: id, - name: followee.nickname, - avatar: User.avatar_url(followee) - }) - - e -> - Logger.debug("Remote follow failed with error #{inspect(e)}") - render(conn, "followed.html", %{error: "Something went wrong."}) - end - end - - def do_remote_follow(%{assigns: %{user: user}} = conn, %{"user" => %{"id" => id}}) do - with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, - {:ok, _follower, _followee, _activity} <- CommonAPI.follow(user, followee) do - conn - |> render("followed.html", %{error: false}) - else - # Was already following user - {:error, "Could not follow user:" <> _rest} -> - render(conn, "followed.html", %{error: "Error following account"}) - - {:fetch_user, error} -> - Logger.debug("Remote follow failed with error #{inspect(error)}") - render(conn, "followed.html", %{error: "Could not find user"}) - - e -> - Logger.debug("Remote follow failed with error #{inspect(e)}") - render(conn, "followed.html", %{error: "Something went wrong."}) - end - end - def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do with {:ok, _} <- Notification.read_one(user, notification_id) do json(conn, %{status: "success"}) @@ -280,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 @@ -296,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 @@ -345,7 +257,9 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do end def delete_account(%{assigns: %{user: user}} = conn, params) do - case CommonAPI.Utils.confirm_current_password(user, params["password"]) do + password = params["password"] || "" + + case CommonAPI.Utils.confirm_current_password(user, password) do {:ok, user} -> User.delete(user) json(conn, %{status: "success"}) diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index bfd838902..5cfb385ac 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -1,83 +1,41 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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,18 @@ 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} = user <- User.get_by_nickname_or_email(nickname_or_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 |> UserEmail.password_reset_email(token_record.token) @@ -110,6 +70,9 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do false -> {:error, "bad user identifier"} + %User{local: true, email: nil} -> + {:ok, :noop} + %User{local: false} -> {:error, "remote user"} @@ -117,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 bf5a6ae42..c2de26b0b 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -1,11 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.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,18 +14,27 @@ 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) def confirm_email(conn, %{"user_id" => uid, "token" => token}) do - new_info = [need_confirmation: false] - - with %User{info: info} = user <- User.get_cached_by_id(uid), - true <- user.local and info.confirmation_pending and info.confirmation_token == token, - {:ok, _} <- User.update_info(user, &User.Info.confirmation_changeset(&1, new_info)) do + with %User{} = user <- User.get_cached_by_id(uid), + true <- user.local and user.confirmation_pending and user.confirmation_token == token, + {:ok, _} <- + user + |> User.confirmation_changeset(need_confirmation: false) + |> User.update_and_set_cache() do redirect(conn, to: "/") end end @@ -43,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") @@ -61,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) @@ -72,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/twitter_api/views/password_view.ex b/lib/pleroma/web/twitter_api/views/password_view.ex index b166b925d..41462e4af 100644 --- a/lib/pleroma/web/twitter_api/views/password_view.ex +++ b/lib/pleroma/web/twitter_api/views/password_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.PasswordView do diff --git a/lib/pleroma/web/twitter_api/views/remote_follow_view.ex b/lib/pleroma/web/twitter_api/views/remote_follow_view.ex new file mode 100644 index 000000000..c05c7821c --- /dev/null +++ b/lib/pleroma/web/twitter_api/views/remote_follow_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.TwitterAPI.RemoteFollowView do + use Pleroma.Web, :view + import Phoenix.HTML.Form + + defdelegate avatar_url(user), to: Pleroma.User +end diff --git a/lib/pleroma/web/twitter_api/views/token_view.ex b/lib/pleroma/web/twitter_api/views/token_view.ex index 3ff314913..c36303625 100644 --- a/lib/pleroma/web/twitter_api/views/token_view.ex +++ b/lib/pleroma/web/twitter_api/views/token_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.TokenView do diff --git a/lib/pleroma/web/twitter_api/views/util_view.ex b/lib/pleroma/web/twitter_api/views/util_view.ex index f4050650e..52054e020 100644 --- a/lib/pleroma/web/twitter_api/views/util_view.ex +++ b/lib/pleroma/web/twitter_api/views/util_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.UtilView do diff --git a/lib/pleroma/web/uploader_controller.ex b/lib/pleroma/web/uploader_controller.ex index 0cc172698..6533f1c0e 100644 --- a/lib/pleroma/web/uploader_controller.ex +++ b/lib/pleroma/web/uploader_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.UploaderController do diff --git a/lib/pleroma/web/views/email_view.ex b/lib/pleroma/web/views/email_view.ex index b506a234b..6b0fbe61e 100644 --- a/lib/pleroma/web/views/email_view.ex +++ b/lib/pleroma/web/views/email_view.ex @@ -12,4 +12,8 @@ defmodule Pleroma.Web.EmailView do |> Timex.parse!("{ISO:Extended:Z}") |> Timex.format!("{Mshort} {D}, {YYYY} {h24}:{m}") end + + def admin_user_url(%{id: id}) do + Pleroma.Web.Endpoint.url() <> "/pleroma/admin/#/users/" <> id + end end diff --git a/lib/pleroma/web/views/error_helpers.ex b/lib/pleroma/web/views/error_helpers.ex index bc08e60e4..df657a343 100644 --- a/lib/pleroma/web/views/error_helpers.ex +++ b/lib/pleroma/web/views/error_helpers.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ErrorHelpers do diff --git a/lib/pleroma/web/views/error_view.ex b/lib/pleroma/web/views/error_view.ex index 5cb8669fe..e68d55e08 100644 --- a/lib/pleroma/web/views/error_view.ex +++ b/lib/pleroma/web/views/error_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ErrorView do diff --git a/lib/pleroma/web/views/layout_view.ex b/lib/pleroma/web/views/layout_view.ex index e5183701d..3e49c6549 100644 --- a/lib/pleroma/web/views/layout_view.ex +++ b/lib/pleroma/web/views/layout_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.LayoutView do diff --git a/lib/pleroma/web/views/masto_fe_view.ex b/lib/pleroma/web/views/masto_fe_view.ex index 85b164b59..c3096006e 100644 --- a/lib/pleroma/web/views/masto_fe_view.ex +++ b/lib/pleroma/web/views/masto_fe_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastoFEView do @@ -61,12 +61,12 @@ defmodule Pleroma.Web.MastoFEView do }, poll_limits: Config.get([:instance, :poll_limits]), rights: %{ - delete_others_notice: present?(user.info.is_moderator), - admin: present?(user.info.is_admin) + delete_others_notice: present?(user.is_moderator), + admin: present?(user.is_admin) }, compose: %{ me: "#{user.id}", - default_privacy: user.info.default_scope, + default_privacy: user.default_scope, default_sensitive: false, allow_content_types: Config.get([:instance, :allowed_post_formats]) }, @@ -86,7 +86,7 @@ defmodule Pleroma.Web.MastoFEView do "video\/mp4" ] }, - settings: user.info.settings || @default_settings, + settings: user.settings || @default_settings, push_subscription: nil, accounts: %{user.id => render(AccountView, "show.json", user: user, for: user)}, custom_emojis: render(CustomEmojiView, "index.json", custom_emojis: custom_emojis), diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index a9f14d09a..237b29ded 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.StreamerView do @@ -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 687346554..4f9281851 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -1,7 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.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 b4cc80179..71ccf251a 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.WebFinger do @@ -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) diff --git a/lib/pleroma/web/web_finger/web_finger_controller.ex b/lib/pleroma/web/web_finger/web_finger_controller.ex index 896eb15f9..7077b20d2 100644 --- a/lib/pleroma/web/web_finger/web_finger_controller.ex +++ b/lib/pleroma/web/web_finger/web_finger_controller.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.WebFinger.WebFingerController do diff --git a/lib/pleroma/web/xml/xml.ex b/lib/pleroma/web/xml/xml.ex index df50aac9c..c69a86a1e 100644 --- a/lib/pleroma/web/xml/xml.ex +++ b/lib/pleroma/web/xml/xml.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.XML do diff --git a/lib/pleroma/workers/activity_expiration_worker.ex b/lib/pleroma/workers/activity_expiration_worker.ex deleted file mode 100644 index 4e3e4195f..000000000 --- a/lib/pleroma/workers/activity_expiration_worker.ex +++ /dev/null @@ -1,18 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Workers.ActivityExpirationWorker do - use Pleroma.Workers.WorkerHelper, queue: "activity_expiration" - - @impl Oban.Worker - def perform( - %{ - "op" => "activity_expiration", - "activity_expiration_id" => activity_expiration_id - }, - _job - ) do - Pleroma.Daemons.ActivityExpirationDaemon.perform(:execute, activity_expiration_id) - end -end diff --git a/lib/pleroma/workers/attachments_cleanup_worker.ex b/lib/pleroma/workers/attachments_cleanup_worker.ex new file mode 100644 index 000000000..3c5820a86 --- /dev/null +++ b/lib/pleroma/workers/attachments_cleanup_worker.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.Workers.AttachmentsCleanupWorker do + import Ecto.Query + + alias Pleroma.Object + alias Pleroma.Repo + + use Pleroma.Workers.WorkerHelper, queue: "attachments_cleanup" + + @impl Oban.Worker + def perform( + %{ + "op" => "cleanup_attachments", + "object" => %{"data" => %{"attachment" => [_ | _] = attachments, "actor" => actor}} + }, + _job + ) do + hrefs = + Enum.flat_map(attachments, fn attachment -> + Enum.map(attachment["url"], & &1["href"]) + end) + + names = Enum.map(attachments, & &1["name"]) + + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + + # find all objects for copies of the attachments, name and actor doesn't matter here + delete_ids = + from(o in Object, + where: + fragment( + "to_jsonb(array(select jsonb_array_elements((?)#>'{url}') ->> 'href' where jsonb_typeof((?)#>'{url}') = 'array'))::jsonb \\?| (?)", + o.data, + o.data, + ^hrefs + ) + ) + # The query above can be time consumptive on large instances until we + # refactor how uploads are stored + |> Repo.all(timeout: :infinity) + # we should delete 1 object for any given attachment, but don't delete + # files if there are more than 1 object for it + |> Enum.reduce(%{}, fn %{ + id: id, + data: %{ + "url" => [%{"href" => href}], + "actor" => obj_actor, + "name" => name + } + }, + acc -> + Map.update(acc, href, %{id: id, count: 1}, fn val -> + case obj_actor == actor and name in names do + true -> + # set id of the actor's object that will be deleted + %{val | id: id, count: val.count + 1} + + false -> + # another actor's object, just increase count to not delete file + %{val | count: val.count + 1} + end + end) + end) + |> Enum.map(fn {href, %{id: id, count: count}} -> + # only delete files that have single instance + with 1 <- count do + prefix = + case Pleroma.Config.get([Pleroma.Upload, :base_url]) do + nil -> "media" + _ -> "" + end + + base_url = + String.trim_trailing( + Pleroma.Config.get([Pleroma.Upload, :base_url], Pleroma.Web.base_url()), + "/" + ) + + file_path = String.trim_leading(href, "#{base_url}/#{prefix}") + + uploader.delete_file(file_path) + end + + id + end) + + from(o in Object, where: o.id in ^delete_ids) + |> Repo.delete_all() + end + + def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: :ok +end diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex index 7ffc8eabe..57c3a9c3a 100644 --- a/lib/pleroma/workers/background_worker.ex +++ b/lib/pleroma/workers/background_worker.ex @@ -1,20 +1,15 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.BackgroundWorker do alias Pleroma.Activity alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy - alias Pleroma.Web.OAuth.Token.CleanWorker use Pleroma.Workers.WorkerHelper, queue: "background" @impl Oban.Worker - def perform(%{"op" => "fetch_initial_posts", "user_id" => user_id}, _job) do - user = User.get_cached_by_id(user_id) - User.perform(:fetch_initial_posts, user) - end def perform(%{"op" => "deactivate_user", "user_id" => user_id, "status" => status}, _job) do user = User.get_cached_by_id(user_id) @@ -40,7 +35,7 @@ defmodule Pleroma.Workers.BackgroundWorker do _job ) do blocker = User.get_cached_by_id(blocker_id) - User.perform(:blocks_import, blocker, blocked_identifiers) + {:ok, User.perform(:blocks_import, blocker, blocked_identifiers)} end def perform( @@ -52,11 +47,7 @@ defmodule Pleroma.Workers.BackgroundWorker do _job ) do follower = User.get_cached_by_id(follower_id) - User.perform(:follow_import, follower, followed_identifiers) - end - - def perform(%{"op" => "clean_expired_tokens"}, _job) do - CleanWorker.perform(:clean) + {:ok, User.perform(:follow_import, follower, followed_identifiers)} end def perform(%{"op" => "media_proxy_preload", "message" => message}, _job) do @@ -71,4 +62,11 @@ defmodule Pleroma.Workers.BackgroundWorker do activity = Activity.get_by_id(activity_id) Pleroma.Web.RichMedia.Helpers.perform(:fetch, activity) end + + def perform(%{"op" => "move_following", "origin_id" => origin_id, "target_id" => target_id}, _) do + origin = User.get_cached_by_id(origin_id) + target = User.get_cached_by_id(target_id) + + Pleroma.FollowingRelationship.move_following(origin, target) + end end diff --git a/lib/pleroma/workers/cron/clear_oauth_token_worker.ex b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex new file mode 100644 index 000000000..341eff054 --- /dev/null +++ b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.ClearOauthTokenWorker do + @moduledoc """ + The worker to cleanup expired oAuth tokens. + """ + + use Oban.Worker, queue: "background" + + alias Pleroma.Config + alias Pleroma.Web.OAuth.Token + + @impl Oban.Worker + def perform(_opts, _job) do + if Config.get([:oauth2, :clean_expired_tokens], false) do + Token.delete_expired_tokens() + end + end +end diff --git a/lib/pleroma/workers/cron/digest_emails_worker.ex b/lib/pleroma/workers/cron/digest_emails_worker.ex new file mode 100644 index 000000000..dd13c3b17 --- /dev/null +++ b/lib/pleroma/workers/cron/digest_emails_worker.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.DigestEmailsWorker do + @moduledoc """ + The worker to send digest emails. + """ + + use Oban.Worker, queue: "digest_emails" + + alias Pleroma.Config + alias Pleroma.Emails + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Query + + require Logger + + @impl Oban.Worker + def perform(_opts, _job) do + config = Config.get([:email_notifications, :digest]) + + if config[:active] do + negative_interval = -Map.fetch!(config, :interval) + inactivity_threshold = Map.fetch!(config, :inactivity_threshold) + inactive_users_query = User.list_inactive_users_query(inactivity_threshold) + + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + + from(u in inactive_users_query, + where: fragment(~s(? ->'digest' @> 'true'), u.email_notifications), + where: not is_nil(u.email), + where: u.last_digest_emailed_at < datetime_add(^now, ^negative_interval, "day"), + select: u + ) + |> Repo.all() + |> send_emails + end + end + + def send_emails(users) do + Enum.each(users, &send_email/1) + end + + @doc """ + Send digest email to the given user. + Updates `last_digest_emailed_at` field for the user and returns the updated user. + """ + @spec send_email(User.t()) :: User.t() + def send_email(user) do + with %Swoosh.Email{} = email <- Emails.UserEmail.digest_email(user) do + Emails.Mailer.deliver_async(email) + end + + User.touch_last_digest_emailed_at(user) + end +end diff --git a/lib/pleroma/workers/cron/new_users_digest_worker.ex b/lib/pleroma/workers/cron/new_users_digest_worker.ex new file mode 100644 index 000000000..9bd0a5621 --- /dev/null +++ b/lib/pleroma/workers/cron/new_users_digest_worker.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.Workers.Cron.NewUsersDigestWorker do + alias Pleroma.Activity + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Query + + use Pleroma.Workers.WorkerHelper, queue: "new_users_digest" + + @impl Oban.Worker + def perform(_args, _job) do + if Pleroma.Config.get([Pleroma.Emails.NewUsersDigestEmail, :enabled]) do + today = NaiveDateTime.utc_now() |> Timex.beginning_of_day() + + a_day_ago = + today + |> Timex.shift(days: -1) + |> Timex.beginning_of_day() + + users_and_statuses = + %{ + local: true, + order_by: :inserted_at + } + |> User.Query.build() + |> where([u], u.inserted_at >= ^a_day_ago and u.inserted_at < ^today) + |> Repo.all() + |> Enum.map(fn user -> + latest_status = + Activity + |> Activity.Queries.by_actor(user.ap_id) + |> Activity.Queries.by_type("Create") + |> Activity.with_preloaded_object() + |> order_by(desc: :inserted_at) + |> limit(1) + |> Repo.one() + + total_statuses = + Activity + |> Activity.Queries.by_actor(user.ap_id) + |> Activity.Queries.by_type("Create") + |> Repo.aggregate(:count, :id) + + {user, total_statuses, latest_status} + end) + + if users_and_statuses != [] do + %{is_admin: true} + |> User.Query.build() + |> where([u], not is_nil(u.email)) + |> Repo.all() + |> Enum.map(&Pleroma.Emails.NewUsersDigestEmail.new_users(&1, users_and_statuses)) + |> Enum.each(&Pleroma.Emails.Mailer.deliver/1) + end + end + end +end diff --git a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex new file mode 100644 index 000000000..b8953dd7f --- /dev/null +++ b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker do + @moduledoc """ + The worker to purge expired activities. + """ + + use Oban.Worker, queue: "activity_expiration" + + alias Pleroma.Activity + alias Pleroma.ActivityExpiration + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + require Logger + + @interval :timer.minutes(1) + + @impl Oban.Worker + def perform(_opts, _job) do + if Config.get([ActivityExpiration, :enabled]) do + Enum.each(ActivityExpiration.due_expirations(@interval), &delete_activity/1) + end + end + + def delete_activity(%ActivityExpiration{activity_id: activity_id}) do + with {:activity, %Activity{} = activity} <- + {:activity, Activity.get_by_id_with_object(activity_id)}, + {:user, %User{} = user} <- {:user, User.get_by_ap_id(activity.object.data["actor"])} do + CommonAPI.delete(activity.id, user) + else + {:activity, _} -> + Logger.error( + "#{__MODULE__} Couldn't delete expired activity: not found activity ##{activity_id}" + ) + + {:user, _} -> + Logger.error( + "#{__MODULE__} Couldn't delete expired activity: not found actorof ##{activity_id}" + ) + end + end +end diff --git a/lib/pleroma/workers/cron/stats_worker.ex b/lib/pleroma/workers/cron/stats_worker.ex new file mode 100644 index 000000000..e9b8d59c4 --- /dev/null +++ b/lib/pleroma/workers/cron/stats_worker.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.StatsWorker do + @moduledoc """ + The worker to update peers statistics. + """ + + use Oban.Worker, queue: "background" + + @impl Oban.Worker + def perform(_opts, _job) do + Pleroma.Stats.do_collect() + end +end diff --git a/lib/pleroma/workers/digest_emails_worker.ex b/lib/pleroma/workers/digest_emails_worker.ex deleted file mode 100644 index 3e5a836d0..000000000 --- a/lib/pleroma/workers/digest_emails_worker.ex +++ /dev/null @@ -1,16 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Workers.DigestEmailsWorker do - alias Pleroma.User - - use Pleroma.Workers.WorkerHelper, queue: "digest_emails" - - @impl Oban.Worker - def perform(%{"op" => "digest_email", "user_id" => user_id}, _job) do - user_id - |> User.get_cached_by_id() - |> Pleroma.Daemons.DigestEmailDaemon.perform() - end -end diff --git a/lib/pleroma/workers/mailer_worker.ex b/lib/pleroma/workers/mailer_worker.ex index 1b7a0eb3e..6955338a5 100644 --- a/lib/pleroma/workers/mailer_worker.ex +++ b/lib/pleroma/workers/mailer_worker.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.MailerWorker do diff --git a/lib/pleroma/workers/publisher_worker.ex b/lib/pleroma/workers/publisher_worker.ex index 455f7fc7e..daf79efc0 100644 --- a/lib/pleroma/workers/publisher_worker.ex +++ b/lib/pleroma/workers/publisher_worker.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.PublisherWorker do diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 8ad756b62..f7a7124f3 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.ReceiverWorker do diff --git a/lib/pleroma/workers/remote_fetcher_worker.ex b/lib/pleroma/workers/remote_fetcher_worker.ex new file mode 100644 index 000000000..ec6534f21 --- /dev/null +++ b/lib/pleroma/workers/remote_fetcher_worker.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.RemoteFetcherWorker do + alias Pleroma.Object.Fetcher + + use Pleroma.Workers.WorkerHelper, queue: "remote_fetcher" + + @impl Oban.Worker + def perform( + %{ + "op" => "fetch_remote", + "id" => id + } = args, + _job + ) do + {:ok, _object} = Fetcher.fetch_object_from_id(id, depth: args["depth"]) + end +end diff --git a/lib/pleroma/workers/scheduled_activity_worker.ex b/lib/pleroma/workers/scheduled_activity_worker.ex index ca7d53af1..97d1efbfb 100644 --- a/lib/pleroma/workers/scheduled_activity_worker.ex +++ b/lib/pleroma/workers/scheduled_activity_worker.ex @@ -1,12 +1,46 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.ScheduledActivityWorker do + @moduledoc """ + The worker to post scheduled activity. + """ + use Pleroma.Workers.WorkerHelper, queue: "scheduled_activities" + alias Pleroma.Config + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + require Logger + @impl Oban.Worker - def perform(%{"op" => "execute", "activity_id" => activity_id}, _job) do - Pleroma.Daemons.ScheduledActivityDaemon.perform(:execute, activity_id) + def perform(%{"activity_id" => activity_id}, _job) do + if Config.get([ScheduledActivity, :enabled]) do + case Pleroma.Repo.get(ScheduledActivity, activity_id) do + %ScheduledActivity{} = scheduled_activity -> + post_activity(scheduled_activity) + + _ -> + Logger.error("#{__MODULE__} Couldn't find scheduled activity: #{activity_id}") + end + end + end + + defp post_activity(%ScheduledActivity{user_id: user_id, params: params} = scheduled_activity) do + params = Map.new(params, fn {key, value} -> {String.to_existing_atom(key), value} end) + + with {:delete, {:ok, _}} <- {:delete, ScheduledActivity.delete(scheduled_activity)}, + {:user, %User{} = user} <- {:user, User.get_cached_by_id(user_id)}, + {:post, {:ok, _}} <- {:post, CommonAPI.post(user, params)} do + :ok + else + error -> + Logger.error( + "#{__MODULE__} Couldn't create a status from the scheduled activity: #{inspect(error)}" + ) + end end end diff --git a/lib/pleroma/workers/transmogrifier_worker.ex b/lib/pleroma/workers/transmogrifier_worker.ex index b581a2f86..11239ca5e 100644 --- a/lib/pleroma/workers/transmogrifier_worker.ex +++ b/lib/pleroma/workers/transmogrifier_worker.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.TransmogrifierWorker do diff --git a/lib/pleroma/workers/web_pusher_worker.ex b/lib/pleroma/workers/web_pusher_worker.ex index 61b451e3e..58ad25e39 100644 --- a/lib/pleroma/workers/web_pusher_worker.ex +++ b/lib/pleroma/workers/web_pusher_worker.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.WebPusherWorker do @@ -13,7 +13,7 @@ defmodule Pleroma.Workers.WebPusherWorker do notification = Notification |> Repo.get(notification_id) - |> Repo.preload([:activity]) + |> Repo.preload([:activity, :user]) Pleroma.Web.Push.Impl.perform(notification) end diff --git a/lib/pleroma/workers/worker_helper.ex b/lib/pleroma/workers/worker_helper.ex index 358efa14a..d1f90c35b 100644 --- a/lib/pleroma/workers/worker_helper.ex +++ b/lib/pleroma/workers/worker_helper.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.WorkerHelper do |