diff options
Diffstat (limited to 'lib')
45 files changed, 1889 insertions, 131 deletions
diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 29a5fa99c..f4eaeac98 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -237,6 +237,12 @@ defmodule Mix.Tasks.Pleroma.Emoji do      end    end +  def run(["reload"]) do +    start_pleroma() +    Pleroma.Emoji.reload() +    IO.puts("Emoji packs have been reloaded.") +  end +    defp fetch_and_decode(from) do      with {:ok, json} <- fetch(from) do        Jason.decode!(json) diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 3635c02bc..bca7e87bf 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -144,6 +144,18 @@ defmodule Mix.Tasks.Pleroma.User do      end    end +  def run(["reset_mfa", nickname]) do +    start_pleroma() + +    with %User{local: true} = user <- User.get_cached_by_nickname(nickname), +         {:ok, _token} <- Pleroma.MFA.disable(user) do +      shell_info("Multi-Factor Authentication disabled for #{user.nickname}") +    else +      _ -> +        shell_error("No local user #{nickname}") +    end +  end +    def run(["deactivate", nickname]) do      start_pleroma() diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 6213d0eb7..da1be20b3 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -24,16 +24,6 @@ defmodule Pleroma.Activity do    @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} -  # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 -  @mastodon_notification_types %{ -    "Create" => "mention", -    "Follow" => ["follow", "follow_request"], -    "Announce" => "reblog", -    "Like" => "favourite", -    "Move" => "move", -    "EmojiReact" => "pleroma:emoji_reaction" -  } -    schema "activities" do      field(:data, :map)      field(:local, :boolean, default: true) @@ -300,32 +290,6 @@ defmodule Pleroma.Activity do    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 -    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 \\ [])    def all_by_actor_and_id(_actor, []), do: [] diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex new file mode 100644 index 000000000..24a86371e --- /dev/null +++ b/lib/pleroma/chat.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat do +  use Ecto.Schema + +  import Ecto.Changeset + +  alias Pleroma.Repo +  alias Pleroma.User + +  @moduledoc """ +  Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). + +  It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. +  """ + +  @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + +  schema "chats" do +    belongs_to(:user, User, type: FlakeId.Ecto.CompatType) +    field(:recipient, :string) + +    timestamps() +  end + +  def changeset(struct, params) do +    struct +    |> cast(params, [:user_id, :recipient]) +    |> validate_change(:recipient, fn +      :recipient, recipient -> +        case User.get_cached_by_ap_id(recipient) do +          nil -> [recipient: "must be an existing user"] +          _ -> [] +        end +    end) +    |> validate_required([:user_id, :recipient]) +    |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) +  end + +  def get_by_id(id) do +    __MODULE__ +    |> Repo.get(id) +  end + +  def get(user_id, recipient) do +    __MODULE__ +    |> Repo.get_by(user_id: user_id, recipient: recipient) +  end + +  def get_or_create(user_id, recipient) do +    %__MODULE__{} +    |> changeset(%{user_id: user_id, recipient: recipient}) +    |> Repo.insert( +      # Need to set something, otherwise we get nothing back at all +      on_conflict: [set: [recipient: recipient]], +      returning: true, +      conflict_target: [:user_id, :recipient] +    ) +  end + +  def bump_or_create(user_id, recipient) do +    %__MODULE__{} +    |> changeset(%{user_id: user_id, recipient: recipient}) +    |> Repo.insert( +      on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], +      returning: true, +      conflict_target: [:user_id, :recipient] +    ) +  end +end diff --git a/lib/pleroma/chat/message_reference.ex b/lib/pleroma/chat/message_reference.ex new file mode 100644 index 000000000..131ae0186 --- /dev/null +++ b/lib/pleroma/chat/message_reference.ex @@ -0,0 +1,117 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat.MessageReference do +  @moduledoc """ +  A reference that builds a relation between an AP chat message that a user can see and whether it has been seen +  by them, or should be displayed to them. Used to build the chat view that is presented to the user. +  """ + +  use Ecto.Schema + +  alias Pleroma.Chat +  alias Pleroma.Object +  alias Pleroma.Repo + +  import Ecto.Changeset +  import Ecto.Query + +  @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} + +  schema "chat_message_references" do +    belongs_to(:object, Object) +    belongs_to(:chat, Chat, type: FlakeId.Ecto.CompatType) + +    field(:unread, :boolean, default: true) + +    timestamps() +  end + +  def changeset(struct, params) do +    struct +    |> cast(params, [:object_id, :chat_id, :unread]) +    |> validate_required([:object_id, :chat_id, :unread]) +  end + +  def get_by_id(id) do +    __MODULE__ +    |> Repo.get(id) +    |> Repo.preload(:object) +  end + +  def delete(cm_ref) do +    cm_ref +    |> Repo.delete() +  end + +  def delete_for_object(%{id: object_id}) do +    from(cr in __MODULE__, +      where: cr.object_id == ^object_id +    ) +    |> Repo.delete_all() +  end + +  def for_chat_and_object(%{id: chat_id}, %{id: object_id}) do +    __MODULE__ +    |> Repo.get_by(chat_id: chat_id, object_id: object_id) +    |> Repo.preload(:object) +  end + +  def for_chat_query(chat) do +    from(cr in __MODULE__, +      where: cr.chat_id == ^chat.id, +      order_by: [desc: :id], +      preload: [:object] +    ) +  end + +  def last_message_for_chat(chat) do +    chat +    |> for_chat_query() +    |> limit(1) +    |> Repo.one() +  end + +  def create(chat, object, unread) do +    params = %{ +      chat_id: chat.id, +      object_id: object.id, +      unread: unread +    } + +    %__MODULE__{} +    |> changeset(params) +    |> Repo.insert() +  end + +  def unread_count_for_chat(chat) do +    chat +    |> for_chat_query() +    |> where([cmr], cmr.unread == true) +    |> Repo.aggregate(:count) +  end + +  def mark_as_read(cm_ref) do +    cm_ref +    |> changeset(%{unread: false}) +    |> Repo.update() +  end + +  def set_all_seen_for_chat(chat, last_read_id \\ nil) do +    query = +      chat +      |> for_chat_query() +      |> exclude(:order_by) +      |> exclude(:preload) +      |> where([cmr], cmr.unread == true) + +    if last_read_id do +      query +      |> where([cmr], cmr.id <= ^last_read_id) +    else +      query +    end +    |> Repo.update_all(set: [unread: false]) +  end +end diff --git a/lib/pleroma/migration_helper/notification_backfill.ex b/lib/pleroma/migration_helper/notification_backfill.ex new file mode 100644 index 000000000..09647d12a --- /dev/null +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelper.NotificationBackfill do +  alias Pleroma.Notification +  alias Pleroma.Object +  alias Pleroma.Repo +  alias Pleroma.User + +  import Ecto.Query + +  def fill_in_notification_types do +    query = +      from(n in Pleroma.Notification, +        where: is_nil(n.type), +        preload: :activity +      ) + +    query +    |> Repo.all() +    |> Enum.each(fn notification -> +      type = +        notification.activity +        |> type_from_activity() + +      notification +      |> Notification.changeset(%{type: type}) +      |> Repo.update() +    end) +  end + +  # This is copied over from Notifications to keep this stable. +  defp type_from_activity(%{data: %{"type" => type}} = activity) do +    case type do +      "Follow" -> +        accepted_function = fn activity -> +          with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), +               %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do +            Pleroma.FollowingRelationship.following?(follower, followed) +          end +        end + +        if accepted_function.(activity) do +          "follow" +        else +          "follow_request" +        end + +      "Announce" -> +        "reblog" + +      "Like" -> +        "favourite" + +      "Move" -> +        "move" + +      "EmojiReact" -> +        "pleroma:emoji_reaction" + +      # Compatibility with old reactions +      "EmojiReaction" -> +        "pleroma:emoji_reaction" + +      "Create" -> +        activity +        |> type_from_activity_object() + +      t -> +        raise "No notification type for activity type #{t}" +    end +  end + +  defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + +  defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do +    object = Object.get_by_ap_id(activity.data["object"]) + +    case object && object.data["type"] do +      "ChatMessage" -> "pleroma:chat_mention" +      _ -> "mention" +    end +  end +end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 7eca55ac9..3386a1933 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -30,12 +30,29 @@ defmodule Pleroma.Notification do    schema "notifications" do      field(:seen, :boolean, default: false) +    # This is an enum type in the database. If you add a new notification type, +    # remember to add a migration to add it to the `notifications_type` enum +    # as well. +    field(:type, :string)      belongs_to(:user, User, type: FlakeId.Ecto.CompatType)      belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)      timestamps()    end +  def update_notification_type(user, activity) do +    with %__MODULE__{} = notification <- +           Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do +      type = +        activity +        |> type_from_activity() + +      notification +      |> changeset(%{type: type}) +      |> Repo.update() +    end +  end +    @spec unread_notifications_count(User.t()) :: integer()    def unread_notifications_count(%User{id: user_id}) do      from(q in __MODULE__, @@ -44,9 +61,21 @@ defmodule Pleroma.Notification do      |> Repo.aggregate(:count, :id)    end +  @notification_types ~w{ +    favourite +    follow +    follow_request +    mention +    move +    pleroma:chat_mention +    pleroma:emoji_reaction +    reblog +  } +    def changeset(%Notification{} = notification, attrs) do      notification -    |> cast(attrs, [:seen]) +    |> cast(attrs, [:seen, :type]) +    |> validate_inclusion(:type, @notification_types)    end    @spec last_read_query(User.t()) :: Ecto.Queryable.t() @@ -300,42 +329,95 @@ defmodule Pleroma.Notification do      end    end -  def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do -    object = Object.normalize(activity) +  def create_notifications(activity, options \\ []) + +  def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity, options) do +    object = Object.normalize(activity, false)      if object && object.data["type"] == "Answer" do        {:ok, []}      else -      do_create_notifications(activity) +      do_create_notifications(activity, options)      end    end -  def create_notifications(%Activity{data: %{"type" => type}} = activity) +  def create_notifications(%Activity{data: %{"type" => type}} = activity, options)        when type in ["Follow", "Like", "Announce", "Move", "EmojiReact"] do -    do_create_notifications(activity) +    do_create_notifications(activity, options)    end -  def create_notifications(_), do: {:ok, []} +  def create_notifications(_, _), do: {:ok, []} + +  defp do_create_notifications(%Activity{} = activity, options) do +    do_send = Keyword.get(options, :do_send, true) -  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 +        do_send = do_send && user in enabled_receivers          create_notification(activity, user, do_send)        end)      {:ok, notifications}    end +  defp type_from_activity(%{data: %{"type" => type}} = activity) do +    case type do +      "Follow" -> +        if Activity.follow_accepted?(activity) do +          "follow" +        else +          "follow_request" +        end + +      "Announce" -> +        "reblog" + +      "Like" -> +        "favourite" + +      "Move" -> +        "move" + +      "EmojiReact" -> +        "pleroma:emoji_reaction" + +      # Compatibility with old reactions +      "EmojiReaction" -> +        "pleroma:emoji_reaction" + +      "Create" -> +        activity +        |> type_from_activity_object() + +      t -> +        raise "No notification type for activity type #{t}" +    end +  end + +  defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + +  defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do +    object = Object.get_by_ap_id(activity.data["object"]) + +    case object && object.data["type"] do +      "ChatMessage" -> "pleroma:chat_mention" +      _ -> "mention" +    end +  end +    # TODO move to sql, too.    def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do      unless skip?(activity, user) do        {:ok, %{notification: notification}} =          Multi.new() -        |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) +        |> Multi.insert(:notification, %Notification{ +          user_id: user.id, +          activity: activity, +          type: type_from_activity(activity) +        })          |> Marker.multi_set_last_read_id(user, "notifications")          |> Repo.transaction() @@ -527,4 +609,12 @@ defmodule Pleroma.Notification do    end    def skip?(_, _, _), do: false + +  def for_user_and_activity(user, activity) do +    from(n in __MODULE__, +      where: n.user_id == ^user.id, +      where: n.activity_id == ^activity.id +    ) +    |> Repo.one() +  end  end diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 6a339b32c..1420a9611 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -113,6 +113,10 @@ defmodule Pleroma.Plugs.HTTPSecurityPlug do          add_source(acc, host)        end) +    media_proxy_base_url = +      if Config.get([:media_proxy, :base_url]), +        do: URI.parse(Config.get([:media_proxy, :base_url])).host +      upload_base_url =        if Config.get([Pleroma.Upload, :base_url]),          do: URI.parse(Config.get([Pleroma.Upload, :base_url])).host @@ -122,6 +126,7 @@ defmodule Pleroma.Plugs.HTTPSecurityPlug do          do: URI.parse(Config.get([Pleroma.Uploaders.S3, :public_endpoint])).host      [] +    |> add_source(media_proxy_base_url)      |> add_source(upload_base_url)      |> add_source(s3_endpoint)      |> add_source(media_proxy_whitelist) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 1be1a3a5b..797555bff 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -67,6 +67,7 @@ defmodule Pleroma.Upload do        {:ok,         %{           "type" => opts.activity_type, +         "mediaType" => upload.content_type,           "url" => [             %{               "type" => "Link", diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5b519033e..a5f8ba40a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -113,7 +113,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do    defp increase_poll_votes_if_vote(_create_data), do: :noop +  @object_types ["ChatMessage"]    @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} +  def persist(%{"type" => type} = object, meta) when type in @object_types do +    with {:ok, object} <- Object.create(object) do +      {:ok, object, meta} +    end +  end +    def persist(object, meta) do      with local <- Keyword.fetch!(meta, :local),           {recipients, _, _} <- get_recipients(object), @@ -355,20 +362,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      end    end -  @spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: +  @spec follow(User.t(), User.t(), String.t() | nil, boolean(), keyword()) ::            {:ok, Activity.t()} | {:error, any()} -  def follow(follower, followed, activity_id \\ nil, local \\ true) do +  def follow(follower, followed, activity_id \\ nil, local \\ true, opts \\ []) do      with {:ok, result} <- -           Repo.transaction(fn -> do_follow(follower, followed, activity_id, local) end) do +           Repo.transaction(fn -> do_follow(follower, followed, activity_id, local, opts) end) do        result      end    end -  defp do_follow(follower, followed, activity_id, local) do +  defp do_follow(follower, followed, activity_id, local, opts) do +    skip_notify_and_stream = Keyword.get(opts, :skip_notify_and_stream, false)      data = make_follow_data(follower, followed, activity_id)      with {:ok, activity} <- insert(data, local), -         _ <- notify_and_stream(activity), +         _ <- skip_notify_and_stream || notify_and_stream(activity),           :ok <- maybe_federate(activity) do        {:ok, activity}      else @@ -1011,6 +1019,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      end    end +  defp exclude_chat_messages(query, %{include_chat_messages: true}), do: query + +  defp exclude_chat_messages(query, _) do +    if has_named_binding?(query, :object) do +      from([activity, object: o] in query, +        where: fragment("not(?->>'type' = ?)", o.data, "ChatMessage") +      ) +    else +      query +    end +  end +    defp exclude_invisible_actors(query, %{invisible_actors: true}), do: query    defp exclude_invisible_actors(query, _opts) do @@ -1126,6 +1146,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      |> restrict_instance(opts)      |> Activity.restrict_deactivated_users()      |> exclude_poll_votes(opts) +    |> exclude_chat_messages(opts)      |> exclude_invisible_actors(opts)      |> exclude_visibility(opts)    end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 51b74414a..1aac62c69 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do    This module encodes our addressing policies and general shape of our objects.    """ +  alias Pleroma.Emoji    alias Pleroma.Object    alias Pleroma.User    alias Pleroma.Web.ActivityPub.Relay @@ -65,6 +66,42 @@ defmodule Pleroma.Web.ActivityPub.Builder do       }, []}    end +  def create(actor, object, recipients) do +    {:ok, +     %{ +       "id" => Utils.generate_activity_id(), +       "actor" => actor.ap_id, +       "to" => recipients, +       "object" => object, +       "type" => "Create", +       "published" => DateTime.utc_now() |> DateTime.to_iso8601() +     }, []} +  end + +  def chat_message(actor, recipient, content, opts \\ []) do +    basic = %{ +      "id" => Utils.generate_object_id(), +      "actor" => actor.ap_id, +      "type" => "ChatMessage", +      "to" => [recipient], +      "content" => content, +      "published" => DateTime.utc_now() |> DateTime.to_iso8601(), +      "emoji" => Emoji.Formatter.get_emoji_map(content) +    } + +    case opts[:attachment] do +      %Object{data: attachment_data} -> +        { +          :ok, +          Map.put(basic, "attachment", attachment_data), +          [] +        } + +      _ -> +        {:ok, basic, []} +    end +  end +    @spec tombstone(String.t(), String.t()) :: {:ok, map(), keyword()}    def tombstone(actor, id) do      {:ok, diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 2599067a8..c01c5f780 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -12,6 +12,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do    alias Pleroma.Object    alias Pleroma.User    alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -43,8 +45,20 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do    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()) +           object +           |> LikeValidator.cast_and_validate() +           |> Ecto.Changeset.apply_action(:insert) do +      object = stringify_keys(object) +      {:ok, object, meta} +    end +  end + +  def validate(%{"type" => "ChatMessage"} = object, meta) do +    with {:ok, object} <- +           object +           |> ChatMessageValidator.cast_and_validate() +           |> Ecto.Changeset.apply_action(:insert) do +      object = stringify_keys(object)        {:ok, object, meta}      end    end @@ -59,6 +73,18 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do      end    end +  def validate(%{"type" => "Create", "object" => object} = create_activity, meta) do +    with {:ok, object_data} <- cast_and_apply(object), +         meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), +         {:ok, create_activity} <- +           create_activity +           |> CreateChatMessageValidator.cast_and_validate(meta) +           |> Ecto.Changeset.apply_action(:insert) do +      create_activity = stringify_keys(create_activity) +      {:ok, create_activity, meta} +    end +  end +    def validate(%{"type" => "Announce"} = object, meta) do      with {:ok, object} <-             object @@ -69,17 +95,30 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do      end    end +  def cast_and_apply(%{"type" => "ChatMessage"} = object) do +    ChatMessageValidator.cast_and_apply(object) +  end + +  def cast_and_apply(o), do: {:error, {:validator_not_set, o}} +    def stringify_keys(%{__struct__: _} = object) do      object      |> Map.from_struct()      |> stringify_keys    end -  def stringify_keys(object) do +  def stringify_keys(object) when is_map(object) do      object -    |> Map.new(fn {key, val} -> {to_string(key), val} end) +    |> Map.new(fn {key, val} -> {to_string(key), stringify_keys(val)} end)    end +  def stringify_keys(object) when is_list(object) do +    object +    |> Enum.map(&stringify_keys/1) +  end + +  def stringify_keys(object), do: object +    def fetch_actor(object) do      with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do        User.get_or_fetch_by_ap_id(actor) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex new file mode 100644 index 000000000..f53bb02be --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.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.AttachmentValidator do +  use Ecto.Schema + +  alias Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator + +  import Ecto.Changeset + +  @primary_key false +  embedded_schema do +    field(:type, :string) +    field(:mediaType, :string, default: "application/octet-stream") +    field(:name, :string) + +    embeds_many(:url, UrlObjectValidator) +  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 +    data = +      data +      |> fix_media_type() +      |> fix_url() + +    struct +    |> cast(data, [:type, :mediaType, :name]) +    |> cast_embed(:url, required: true) +  end + +  def fix_media_type(data) do +    data = +      data +      |> Map.put_new("mediaType", data["mimeType"]) + +    if MIME.valid?(data["mediaType"]) do +      data +    else +      data +      |> Map.put("mediaType", "application/octet-stream") +    end +  end + +  def fix_url(data) do +    case data["url"] do +      url when is_binary(url) -> +        data +        |> Map.put( +          "url", +          [ +            %{ +              "href" => url, +              "type" => "Link", +              "mediaType" => data["mediaType"] +            } +          ] +        ) + +      _ -> +        data +    end +  end + +  def validate_data(cng) do +    cng +    |> validate_required([:mediaType, :url, :type]) +  end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex new file mode 100644 index 000000000..138736f23 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -0,0 +1,123 @@ +# 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.ChatMessageValidator do +  use Ecto.Schema + +  alias Pleroma.User +  alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.Types + +  import Ecto.Changeset +  import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] + +  @primary_key false +  @derive Jason.Encoder + +  embedded_schema do +    field(:id, Types.ObjectID, primary_key: true) +    field(:to, Types.Recipients, default: []) +    field(:type, :string) +    field(:content, Types.SafeText) +    field(:actor, Types.ObjectID) +    field(:published, Types.DateTime) +    field(:emoji, :map, default: %{}) + +    embeds_one(:attachment, AttachmentValidator) +  end + +  def cast_and_apply(data) do +    data +    |> cast_data +    |> apply_action(:insert) +  end + +  def cast_and_validate(data) do +    data +    |> cast_data() +    |> validate_data() +  end + +  def cast_data(data) do +    %__MODULE__{} +    |> changeset(data) +  end + +  def fix(data) do +    data +    |> fix_emoji() +    |> fix_attachment() +    |> Map.put_new("actor", data["attributedTo"]) +  end + +  # Throws everything but the first one away +  def fix_attachment(%{"attachment" => [attachment | _]} = data) do +    data +    |> Map.put("attachment", attachment) +  end + +  def fix_attachment(data), do: data + +  def changeset(struct, data) do +    data = fix(data) + +    struct +    |> cast(data, List.delete(__schema__(:fields), :attachment)) +    |> cast_embed(:attachment) +  end + +  def validate_data(data_cng) do +    data_cng +    |> validate_inclusion(:type, ["ChatMessage"]) +    |> validate_required([:id, :actor, :to, :type, :published]) +    |> validate_content_or_attachment() +    |> validate_length(:to, is: 1) +    |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) +    |> validate_local_concern() +  end + +  def validate_content_or_attachment(cng) do +    attachment = get_field(cng, :attachment) + +    if attachment do +      cng +    else +      cng +      |> validate_required([:content]) +    end +  end + +  @doc """ +  Validates the following +  - If both users are in our system +  - If at least one of the users in this ChatMessage is a local user +  - If the recipient is not blocking the actor +  """ +  def validate_local_concern(cng) do +    with actor_ap <- get_field(cng, :actor), +         {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)}, +         {_, %User{} = recipient} <- +           {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())}, +         {_, false} <- {:blocking_actor?, User.blocks?(recipient, actor)}, +         {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do +      cng +    else +      {:blocking_actor?, true} -> +        cng +        |> add_error(:actor, "actor is blocked by recipient") + +      {:local?, false} -> +        cng +        |> add_error(:actor, "actor and recipient are both remote") + +      {:find_actor, _} -> +        cng +        |> add_error(:actor, "can't find user") + +      {:find_recipient, _} -> +        cng +        |> add_error(:to, "can't find user") +    end +  end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex new file mode 100644 index 000000000..fc582400b --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -0,0 +1,91 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +# NOTES +# - Can probably be a generic create validator +# - doesn't embed, will only get the object id +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator 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(:actor, Types.ObjectID) +    field(:type, :string) +    field(:to, Types.Recipients, default: []) +    field(:object, Types.ObjectID) +  end + +  def cast_and_apply(data) do +    data +    |> cast_data +    |> apply_action(:insert) +  end + +  def cast_data(data) do +    cast(%__MODULE__{}, data, __schema__(:fields)) +  end + +  def cast_and_validate(data, meta \\ []) do +    cast_data(data) +    |> validate_data(meta) +  end + +  def validate_data(cng, meta \\ []) do +    cng +    |> validate_required([:id, :actor, :to, :type, :object]) +    |> validate_inclusion(:type, ["Create"]) +    |> validate_actor_presence() +    |> validate_recipients_match(meta) +    |> validate_actors_match(meta) +    |> validate_object_nonexistence() +  end + +  def validate_object_nonexistence(cng) do +    cng +    |> validate_change(:object, fn :object, object_id -> +      if Object.get_cached_by_ap_id(object_id) do +        [{:object, "The object to create already exists"}] +      else +        [] +      end +    end) +  end + +  def validate_actors_match(cng, meta) do +    object_actor = meta[:object_data]["actor"] + +    cng +    |> validate_change(:actor, fn :actor, actor -> +      if actor == object_actor do +        [] +      else +        [{:actor, "Actor doesn't match with object actor"}] +      end +    end) +  end + +  def validate_recipients_match(cng, meta) do +    object_recipients = meta[:object_data]["to"] || [] + +    cng +    |> validate_change(:to, fn :to, recipients -> +      activity_set = MapSet.new(recipients) +      object_set = MapSet.new(object_recipients) + +      if MapSet.equal?(activity_set, object_set) do +        [] +      else +        [{:to, "Recipients don't match with object recipients"}] +      end +    end) +  end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex index 926804ce7..926804ce7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index f42c03510..e5d08eb5c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -46,12 +46,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do      Answer      Article      Audio +    ChatMessage      Event      Note      Page      Question -    Video      Tombstone +    Video    }    def validate_data(cng) do      cng diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex index 48fe61e1a..408e0f6ee 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -11,11 +11,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do    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]} +    |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> +      case ObjectID.cast(element) do +        {:ok, id} -> +          {:cont, {:ok, [id | list]}} + +        _ -> +          {:halt, :error}        end      end)    end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex new file mode 100644 index 000000000..95c948123 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.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.ActivityPub.ObjectValidators.Types.SafeText do +  use Ecto.Type + +  alias Pleroma.HTML + +  def type, do: :string + +  def cast(str) when is_binary(str) do +    {:ok, HTML.filter_tags(str)} +  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/url_object_validator.ex b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex new file mode 100644 index 000000000..47e231150 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator do +  use Ecto.Schema + +  alias Pleroma.Web.ActivityPub.ObjectValidators.Types + +  import Ecto.Changeset +  @primary_key false + +  embedded_schema do +    field(:type, :string) +    field(:href, Types.Uri) +    field(:mediaType, :string) +  end + +  def changeset(struct, data) do +    struct +    |> cast(data, __schema__(:fields)) +    |> validate_required([:type, :href, :mediaType]) +  end +end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 0c54c4b23..6875c47f6 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -17,6 +17,10 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do            {: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, {:ok, activity, meta}} -> +        SideEffects.handle_after_transaction(meta) +        {:ok, activity, meta} +        {:ok, value} ->          value diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index fb6275450..1a1cc675c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -6,12 +6,17 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do    collection, and so on.    """    alias Pleroma.Activity +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference    alias Pleroma.Notification    alias Pleroma.Object    alias Pleroma.Repo    alias Pleroma.User    alias Pleroma.Web.ActivityPub.ActivityPub +  alias Pleroma.Web.ActivityPub.Pipeline    alias Pleroma.Web.ActivityPub.Utils +  alias Pleroma.Web.Push +  alias Pleroma.Web.Streamer    def handle(object, meta \\ []) @@ -27,6 +32,24 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do      {:ok, object, meta}    end +  # Tasks this handles +  # - Actually create object +  # - Rollback if we couldn't create it +  # - Set up notifications +  def handle(%{data: %{"type" => "Create"}} = activity, meta) do +    with {:ok, _object, meta} <- handle_object_creation(meta[:object_data], meta) do +      {:ok, notifications} = Notification.create_notifications(activity, do_send: false) + +      meta = +        meta +        |> add_notifications(notifications) + +      {:ok, activity, meta} +    else +      e -> Repo.rollback(e) +    end +  end +    # Tasks this handles:    # - Add announce to object    # - Set up notification @@ -88,6 +111,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do                Object.decrease_replies_count(in_reply_to)              end +            MessageReference.delete_for_object(deleted_object) +              ActivityPub.stream_out(object)              ActivityPub.stream_out_participations(deleted_object, user)              :ok @@ -112,6 +137,39 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do      {:ok, object, meta}    end +  def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do +    with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do +      actor = User.get_cached_by_ap_id(object.data["actor"]) +      recipient = User.get_cached_by_ap_id(hd(object.data["to"])) + +      streamables = +        [[actor, recipient], [recipient, actor]] +        |> Enum.map(fn [user, other_user] -> +          if user.local do +            {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) +            {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) + +            { +              ["user", "user:pleroma_chat"], +              {user, %{cm_ref | chat: chat, object: object}} +            } +          end +        end) +        |> Enum.filter(& &1) + +      meta = +        meta +        |> add_streamables(streamables) + +      {:ok, object, meta} +    end +  end + +  # Nothing to do +  def handle_object_creation(object) do +    {:ok, object} +  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), @@ -148,4 +206,43 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do    end    def handle_undoing(object), do: {:error, ["don't know how to handle", object]} + +  defp send_notifications(meta) do +    Keyword.get(meta, :notifications, []) +    |> Enum.each(fn notification -> +      Streamer.stream(["user", "user:notification"], notification) +      Push.send(notification) +    end) + +    meta +  end + +  defp send_streamables(meta) do +    Keyword.get(meta, :streamables, []) +    |> Enum.each(fn {topics, items} -> +      Streamer.stream(topics, items) +    end) + +    meta +  end + +  defp add_streamables(meta, streamables) do +    existing = Keyword.get(meta, :streamables, []) + +    meta +    |> Keyword.put(:streamables, streamables ++ existing) +  end + +  defp add_notifications(meta, notifications) do +    existing = Keyword.get(meta, :notifications, []) + +    meta +    |> Keyword.put(:notifications, notifications ++ existing) +  end + +  def handle_after_transaction(meta) do +    meta +    |> send_notifications() +    |> send_streamables() +  end  end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index fda1c71df..985921aa0 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do    alias Pleroma.EarmarkRenderer    alias Pleroma.FollowingRelationship    alias Pleroma.Maps +  alias Pleroma.Notification    alias Pleroma.Object    alias Pleroma.Object.Containment    alias Pleroma.Repo @@ -221,9 +222,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do          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"] +            is_map(url) && MIME.valid?(url["mediaType"]) -> url["mediaType"] +            MIME.valid?(data["mediaType"]) -> data["mediaType"] +            MIME.valid?(data["mimeType"]) -> data["mimeType"]              true -> nil            end @@ -527,7 +528,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do             User.get_cached_by_ap_id(Containment.get_actor(%{"actor" => followed})),           {:ok, %User{} = follower} <-             User.get_or_fetch_by_ap_id(Containment.get_actor(%{"actor" => follower})), -         {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do +         {:ok, activity} <- +           ActivityPub.follow(follower, followed, id, false, skip_notify_and_stream: true) do        with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]),             {_, false} <- {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked},             {_, false} <- {:user_locked, User.locked?(followed)}, @@ -570,6 +572,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do            :noop        end +      ActivityPub.notify_and_stream(activity)        {:ok, activity}      else        _e -> @@ -590,6 +593,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do        User.update_follower_count(followed)        User.update_following_count(follower) +      Notification.update_notification_type(followed, follow_activity) +        ActivityPub.accept(%{          to: follow_activity.data["to"],          type: "Accept", @@ -657,6 +662,16 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do      |> handle_incoming(options)    end +  def handle_incoming( +        %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, +        _options +      ) do +    with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), +         {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do +      {:ok, activity} +    end +  end +    def handle_incoming(%{"type" => type} = data, _options)        when type in ["Like", "EmojiReact", "Announce"] do      with :ok <- ObjectValidator.fetch_actor_and_object(data), @@ -1108,6 +1123,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do      Map.put(object, "attributedTo", attributed_to)    end +  # TODO: Revisit this +  def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object +    def prepare_attachments(object) do      attachments =        object diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 120159527..e1e929632 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -76,7 +76,8 @@ defmodule Pleroma.Web.AdminAPI.AccountView do        "local" => user.local,        "roles" => User.roles(user),        "tags" => user.tags || [], -      "confirmation_pending" => user.confirmation_pending +      "confirmation_pending" => user.confirmation_pending, +      "url" => user.uri || user.ap_id      }    end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex new file mode 100644 index 000000000..cf299bfc2 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -0,0 +1,355 @@ +# 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.ChatOperation do +  alias OpenApiSpex.Operation +  alias OpenApiSpex.Schema +  alias Pleroma.Web.ApiSpec.Schemas.ApiError +  alias Pleroma.Web.ApiSpec.Schemas.Chat +  alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + +  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 + +  def mark_as_read_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Mark all messages in the chat as read", +      operationId: "ChatController.mark_as_read", +      parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")], +      requestBody: request_body("Parameters", mark_as_read()), +      responses: %{ +        200 => +          Operation.response( +            "The updated chat", +            "application/json", +            Chat +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["write:chats"] +        } +      ] +    } +  end + +  def mark_message_as_read_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Mark one message in the chat as read", +      operationId: "ChatController.mark_message_as_read", +      parameters: [ +        Operation.parameter(:id, :path, :string, "The ID of the Chat"), +        Operation.parameter(:message_id, :path, :string, "The ID of the message") +      ], +      responses: %{ +        200 => +          Operation.response( +            "The read ChatMessage", +            "application/json", +            ChatMessage +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["write:chats"] +        } +      ] +    } +  end + +  def show_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Create a chat", +      operationId: "ChatController.show", +      parameters: [ +        Operation.parameter( +          :id, +          :path, +          :string, +          "The id of the chat", +          required: true, +          example: "1234" +        ) +      ], +      responses: %{ +        200 => +          Operation.response( +            "The existing chat", +            "application/json", +            Chat +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["read"] +        } +      ] +    } +  end + +  def create_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Create a chat", +      operationId: "ChatController.create", +      parameters: [ +        Operation.parameter( +          :id, +          :path, +          :string, +          "The account id of the recipient of this chat", +          required: true, +          example: "someflakeid" +        ) +      ], +      responses: %{ +        200 => +          Operation.response( +            "The created or existing chat", +            "application/json", +            Chat +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["write:chats"] +        } +      ] +    } +  end + +  def index_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Get a list of chats that you participated in", +      operationId: "ChatController.index", +      parameters: pagination_params(), +      responses: %{ +        200 => Operation.response("The chats of the user", "application/json", chats_response()) +      }, +      security: [ +        %{ +          "oAuth" => ["read:chats"] +        } +      ] +    } +  end + +  def messages_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Get the most recent messages of the chat", +      operationId: "ChatController.messages", +      parameters: +        [Operation.parameter(:id, :path, :string, "The ID of the Chat")] ++ +          pagination_params(), +      responses: %{ +        200 => +          Operation.response( +            "The messages in the chat", +            "application/json", +            chat_messages_response() +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["read:chats"] +        } +      ] +    } +  end + +  def post_chat_message_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Post a message to the chat", +      operationId: "ChatController.post_chat_message", +      parameters: [ +        Operation.parameter(:id, :path, :string, "The ID of the Chat") +      ], +      requestBody: request_body("Parameters", chat_message_create()), +      responses: %{ +        200 => +          Operation.response( +            "The newly created ChatMessage", +            "application/json", +            ChatMessage +          ), +        400 => Operation.response("Bad Request", "application/json", ApiError) +      }, +      security: [ +        %{ +          "oAuth" => ["write:chats"] +        } +      ] +    } +  end + +  def delete_message_operation do +    %Operation{ +      tags: ["chat"], +      summary: "delete_message", +      operationId: "ChatController.delete_message", +      parameters: [ +        Operation.parameter(:id, :path, :string, "The ID of the Chat"), +        Operation.parameter(:message_id, :path, :string, "The ID of the message") +      ], +      responses: %{ +        200 => +          Operation.response( +            "The deleted ChatMessage", +            "application/json", +            ChatMessage +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["write:chats"] +        } +      ] +    } +  end + +  def chats_response do +    %Schema{ +      title: "ChatsResponse", +      description: "Response schema for multiple Chats", +      type: :array, +      items: Chat, +      example: [ +        %{ +          "account" => %{ +            "pleroma" => %{ +              "is_admin" => false, +              "confirmation_pending" => false, +              "hide_followers_count" => false, +              "is_moderator" => false, +              "hide_favorites" => true, +              "ap_id" => "https://dontbulling.me/users/lain", +              "hide_follows_count" => false, +              "hide_follows" => false, +              "background_image" => nil, +              "skip_thread_containment" => false, +              "hide_followers" => false, +              "relationship" => %{}, +              "tags" => [] +            }, +            "avatar" => +              "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", +            "following_count" => 0, +            "header_static" => "https://originalpatchou.li/images/banner.png", +            "source" => %{ +              "sensitive" => false, +              "note" => "lain", +              "pleroma" => %{ +                "discoverable" => false, +                "actor_type" => "Person" +              }, +              "fields" => [] +            }, +            "statuses_count" => 1, +            "locked" => false, +            "created_at" => "2020-04-16T13:40:15.000Z", +            "display_name" => "lain", +            "fields" => [], +            "acct" => "lain@dontbulling.me", +            "id" => "9u6Qw6TAZANpqokMkK", +            "emojis" => [], +            "avatar_static" => +              "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", +            "username" => "lain", +            "followers_count" => 0, +            "header" => "https://originalpatchou.li/images/banner.png", +            "bot" => false, +            "note" => "lain", +            "url" => "https://dontbulling.me/users/lain" +          }, +          "id" => "1", +          "unread" => 2 +        } +      ] +    } +  end + +  def chat_messages_response do +    %Schema{ +      title: "ChatMessagesResponse", +      description: "Response schema for multiple ChatMessages", +      type: :array, +      items: ChatMessage, +      example: [ +        %{ +          "emojis" => [ +            %{ +              "static_url" => "https://dontbulling.me/emoji/Firefox.gif", +              "visible_in_picker" => false, +              "shortcode" => "firefox", +              "url" => "https://dontbulling.me/emoji/Firefox.gif" +            } +          ], +          "created_at" => "2020-04-21T15:11:46.000Z", +          "content" => "Check this out :firefox:", +          "id" => "13", +          "chat_id" => "1", +          "actor_id" => "someflakeid", +          "unread" => false +        }, +        %{ +          "actor_id" => "someflakeid", +          "content" => "Whats' up?", +          "id" => "12", +          "chat_id" => "1", +          "emojis" => [], +          "created_at" => "2020-04-21T15:06:45.000Z", +          "unread" => false +        } +      ] +    } +  end + +  def chat_message_create do +    %Schema{ +      title: "ChatMessageCreateRequest", +      description: "POST body for creating an chat message", +      type: :object, +      properties: %{ +        content: %Schema{ +          type: :string, +          description: "The content of your message. Optional if media_id is present" +        }, +        media_id: %Schema{type: :string, description: "The id of an upload"} +      }, +      example: %{ +        "content" => "Hey wanna buy feet pics?", +        "media_id" => "134234" +      } +    } +  end + +  def mark_as_read do +    %Schema{ +      title: "MarkAsReadRequest", +      description: "POST body for marking a number of chat messages as read", +      type: :object, +      required: [:last_read_id], +      properties: %{ +        last_read_id: %Schema{ +          type: :string, +          description: "The content of your message." +        } +      }, +      example: %{ +        "last_read_id" => "abcdef12456" +      } +    } +  end +end diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 46e72f8bf..c966b553a 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -185,6 +185,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do          "mention",          "poll",          "pleroma:emoji_reaction", +        "pleroma:chat_mention",          "move",          "follow_request"        ], diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index c575a87e6..775dd795d 100644 --- a/lib/pleroma/web/api_spec/operations/subscription_operation.ex +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -141,6 +141,11 @@ defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do                    allOf: [BooleanLike],                    nullable: true,                    description: "Receive poll notifications?" +                }, +                "pleroma:chat_mention": %Schema{ +                  allOf: [BooleanLike], +                  nullable: true, +                  description: "Receive chat notifications?"                  }                }              } diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex new file mode 100644 index 000000000..b4986b734 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -0,0 +1,75 @@ +# 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.Chat do +  alias OpenApiSpex.Schema +  alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + +  require OpenApiSpex + +  OpenApiSpex.schema(%{ +    title: "Chat", +    description: "Response schema for a Chat", +    type: :object, +    properties: %{ +      id: %Schema{type: :string}, +      account: %Schema{type: :object}, +      unread: %Schema{type: :integer}, +      last_message: ChatMessage, +      updated_at: %Schema{type: :string, format: :"date-time"} +    }, +    example: %{ +      "account" => %{ +        "pleroma" => %{ +          "is_admin" => false, +          "confirmation_pending" => false, +          "hide_followers_count" => false, +          "is_moderator" => false, +          "hide_favorites" => true, +          "ap_id" => "https://dontbulling.me/users/lain", +          "hide_follows_count" => false, +          "hide_follows" => false, +          "background_image" => nil, +          "skip_thread_containment" => false, +          "hide_followers" => false, +          "relationship" => %{}, +          "tags" => [] +        }, +        "avatar" => +          "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", +        "following_count" => 0, +        "header_static" => "https://originalpatchou.li/images/banner.png", +        "source" => %{ +          "sensitive" => false, +          "note" => "lain", +          "pleroma" => %{ +            "discoverable" => false, +            "actor_type" => "Person" +          }, +          "fields" => [] +        }, +        "statuses_count" => 1, +        "locked" => false, +        "created_at" => "2020-04-16T13:40:15.000Z", +        "display_name" => "lain", +        "fields" => [], +        "acct" => "lain@dontbulling.me", +        "id" => "9u6Qw6TAZANpqokMkK", +        "emojis" => [], +        "avatar_static" => +          "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", +        "username" => "lain", +        "followers_count" => 0, +        "header" => "https://originalpatchou.li/images/banner.png", +        "bot" => false, +        "note" => "lain", +        "url" => "https://dontbulling.me/users/lain" +      }, +      "id" => "1", +      "unread" => 2, +      "last_message" => ChatMessage.schema().example(), +      "updated_at" => "2020-04-21T15:06:45.000Z" +    } +  }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex new file mode 100644 index 000000000..3ee85aa76 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message.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.ChatMessage do +  alias OpenApiSpex.Schema + +  require OpenApiSpex + +  OpenApiSpex.schema(%{ +    title: "ChatMessage", +    description: "Response schema for a ChatMessage", +    nullable: true, +    type: :object, +    properties: %{ +      id: %Schema{type: :string}, +      account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, +      chat_id: %Schema{type: :string}, +      content: %Schema{type: :string, nullable: true}, +      created_at: %Schema{type: :string, format: :"date-time"}, +      emojis: %Schema{type: :array}, +      attachment: %Schema{type: :object, nullable: true} +    }, +    example: %{ +      "account_id" => "someflakeid", +      "chat_id" => "1", +      "content" => "hey you again", +      "created_at" => "2020-04-21T15:06:45.000Z", +      "emojis" => [ +        %{ +          "static_url" => "https://dontbulling.me/emoji/Firefox.gif", +          "visible_in_picker" => false, +          "shortcode" => "firefox", +          "url" => "https://dontbulling.me/emoji/Firefox.gif" +        } +      ], +      "id" => "14", +      "attachment" => nil +    } +  }) +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index d452429c0..04e081a8e 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.CommonAPI do    alias Pleroma.ActivityExpiration    alias Pleroma.Conversation.Participation    alias Pleroma.FollowingRelationship +  alias Pleroma.Formatter    alias Pleroma.Notification    alias Pleroma.Object    alias Pleroma.ThreadMute @@ -24,6 +25,53 @@ defmodule Pleroma.Web.CommonAPI do    require Pleroma.Constants    require Logger +  def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do +    with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), +         :ok <- validate_chat_content_length(content, !!maybe_attachment), +         {_, {:ok, chat_message_data, _meta}} <- +           {:build_object, +            Builder.chat_message( +              user, +              recipient.ap_id, +              content |> format_chat_content, +              attachment: maybe_attachment +            )}, +         {_, {:ok, create_activity_data, _meta}} <- +           {:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])}, +         {_, {:ok, %Activity{} = activity, _meta}} <- +           {:common_pipeline, +            Pipeline.common_pipeline(create_activity_data, +              local: true +            )} do +      {:ok, activity} +    end +  end + +  defp format_chat_content(nil), do: nil + +  defp format_chat_content(content) do +    {text, _, _} = +      content +      |> Formatter.html_escape("text/plain") +      |> Formatter.linkify() +      |> (fn {text, mentions, tags} -> +            {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags} +          end).() + +    text +  end + +  defp validate_chat_content_length(_, true), do: :ok +  defp validate_chat_content_length(nil, false), do: {:error, :no_content} + +  defp validate_chat_content_length(content, _) do +    if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do +      :ok +    else +      {:error, :content_too_long} +    end +  end +    def unblock(blocker, blocked) do      with {_, %Activity{} = block} <- {:fetch_block, Utils.fetch_latest_block(blocker, blocked)},           {:ok, unblock_data, _} <- Builder.undo(blocker, block), @@ -73,6 +121,7 @@ defmodule Pleroma.Web.CommonAPI do               object: follow_activity.data["id"],               type: "Accept"             }) do +      Notification.update_notification_type(followed, follow_activity)        {:ok, follower}      end    end @@ -417,12 +466,13 @@ defmodule Pleroma.Web.CommonAPI do      {:ok, activity}    end -  def thread_muted?(%{id: nil} = _user, _activity), do: false - -  def thread_muted?(user, activity) do -    ThreadMute.exists?(user.id, activity.data["context"]) +  def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}}) +      when is_binary("context") do +    ThreadMute.exists?(user_id, context)    end +  def thread_muted?(_, _), do: false +    def report(user, data) do      with {:ok, account} <- get_reported_account(data.account_id),           {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 6ec489f9a..15594125f 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -429,7 +429,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do          %Activity{data: %{"to" => _to, "type" => type} = data} = activity        )        when type == "Create" do -    object = Object.normalize(activity) +    object = Object.normalize(activity, false)      object_data =        cond do diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index bcd12c73f..e25cef30b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -42,8 +42,20 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do      end    end +  @default_notification_types ~w{ +    mention +    follow +    follow_request +    reblog +    favourite +    move +    pleroma:emoji_reaction +  }    def index(%{assigns: %{user: user}} = conn, params) do -    params = Map.new(params, fn {k, v} -> {to_string(k), v} end) +    params = +      Map.new(params, fn {k, v} -> {to_string(k), v} end) +      |> Map.put_new("include_types", @default_notification_types) +      notifications = MastodonAPI.get_notifications(user, params)      conn diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 8840fc19c..3be0ca095 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -124,6 +124,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do    defp prepare_tags(query, add_joined_tag \\ true) do      tags =        query +      |> preprocess_uri_query()        |> String.split(~r/[^#\w]+/u, trim: true)        |> Enum.uniq_by(&String.downcase/1) @@ -147,6 +148,20 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do      end    end +  # If `query` is a URI, returns last component of its path, otherwise returns `query` +  defp preprocess_uri_query(query) do +    if query =~ ~r/https?:\/\// do +      query +      |> String.trim_trailing("/") +      |> URI.parse() +      |> Map.get(:path) +      |> String.split("/") +      |> Enum.at(-1) +    else +      query +    end +  end +    defp joined_tag(tags) do      tags      |> Enum.map(fn tag -> String.capitalize(tag) end) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index 70da64a7a..694bf5ca8 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do    import Ecto.Query    import Ecto.Changeset -  alias Pleroma.Activity    alias Pleroma.Notification    alias Pleroma.Pagination    alias Pleroma.ScheduledActivity @@ -82,15 +81,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do    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)) +    where(query, [n], n.type in ^mastodon_types)    end    defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do -    ap_types = convert_and_filter_mastodon_types(mastodon_types) - -    where(query, [q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) +    where(query, [n], n.type not in ^mastodon_types)    end    defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do @@ -98,10 +93,4 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do    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 04c419d2f..9fc06bf9d 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -235,6 +235,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do        # Pleroma extension        pleroma: %{ +        ap_id: user.ap_id,          confirmation_pending: user.confirmation_pending,          tags: user.tags,          hide_followers_count: user.hide_followers_count, diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 6a630eafa..c498fe632 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -69,7 +69,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do        if Config.get([:instance, :safe_dm_mentions]) do          "safe_dm_mentions"        end, -      "pleroma_emoji_reactions" +      "pleroma_emoji_reactions", +      "pleroma_chat_messages"      ]      |> Enum.filter(& &1)    end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index c46ddcf55..b11578623 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -6,26 +6,28 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do    use Pleroma.Web, :view    alias Pleroma.Activity +  alias Pleroma.Chat.MessageReference    alias Pleroma.Notification +  alias Pleroma.Object    alias Pleroma.User    alias Pleroma.UserRelationship    alias Pleroma.Web.CommonAPI    alias Pleroma.Web.MastodonAPI.AccountView    alias Pleroma.Web.MastodonAPI.NotificationView    alias Pleroma.Web.MastodonAPI.StatusView +  alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView + +  @parent_types ~w{Like Announce EmojiReact}    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.filter(fn +        %{data: %{"type" => type}} -> +          type in @parent_types +      end)        |> Enum.map(& &1.data["object"])        |> Activity.create_by_object_ap_id()        |> Activity.with_preloaded_object(:left) @@ -42,7 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do          true ->            move_activities_targets =              activities -            |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) +            |> Enum.filter(&(&1.data["type"] == "Move"))              |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"]))            actors = @@ -79,8 +81,6 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do        end      end -    mastodon_type = Activity.mastodon_notification_type(activity) -      # Note: :relationships contain user mutes (needed for :muted flag in :status)      status_render_opts = %{relationships: opts[:relationships]} @@ -91,7 +91,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do             ) do        response = %{          id: to_string(notification.id), -        type: mastodon_type, +        type: notification.type,          created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at),          account: account,          pleroma: %{ @@ -99,7 +99,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do          }        } -      case mastodon_type do +      case notification.type do          "mention" ->            put_status(response, activity, reading_user, status_render_opts) @@ -117,6 +117,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do            |> put_status(parent_activity_fn.(), reading_user, status_render_opts)            |> put_emoji(activity) +        "pleroma:chat_mention" -> +          put_chat_message(response, activity, reading_user, status_render_opts) +          type when type in ["follow", "follow_request"] ->            response @@ -132,6 +135,17 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do      Map.put(response, :emoji, activity.data["content"])    end +  defp put_chat_message(response, activity, reading_user, opts) do +    object = Object.normalize(activity) +    author = User.get_cached_by_ap_id(object.data["actor"]) +    chat = Pleroma.Chat.get(reading_user.id, author.ap_id) +    cm_ref = MessageReference.for_chat_and_object(chat, object) +    render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref}) +    chat_message_render = MessageReferenceView.render("show.json", render_opts) + +    Map.put(response, :chat_message, chat_message_render) +  end +    defp put_status(response, activity, reading_user, opts) do      status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user})      status_render = StatusView.render("show.json", status_render_opts) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex new file mode 100644 index 000000000..c8ef3d915 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -0,0 +1,174 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.PleromaAPI.ChatController do +  use Pleroma.Web, :controller + +  alias Pleroma.Activity +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference +  alias Pleroma.Object +  alias Pleroma.Pagination +  alias Pleroma.Plugs.OAuthScopesPlug +  alias Pleroma.Repo +  alias Pleroma.User +  alias Pleroma.Web.CommonAPI +  alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView +  alias Pleroma.Web.PleromaAPI.ChatView + +  import Ecto.Query + +  action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + +  plug( +    OAuthScopesPlug, +    %{scopes: ["write:chats"]} +    when action in [ +           :post_chat_message, +           :create, +           :mark_as_read, +           :mark_message_as_read, +           :delete_message +         ] +  ) + +  plug( +    OAuthScopesPlug, +    %{scopes: ["read:chats"]} when action in [:messages, :index, :show] +  ) + +  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + +  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation + +  def delete_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ +        message_id: message_id, +        id: chat_id +      }) do +    with %MessageReference{} = cm_ref <- +           MessageReference.get_by_id(message_id), +         ^chat_id <- cm_ref.chat_id |> to_string(), +         %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), +         {:ok, _} <- remove_or_delete(cm_ref, user) do +      conn +      |> put_view(MessageReferenceView) +      |> render("show.json", chat_message_reference: cm_ref) +    else +      _e -> +        {:error, :could_not_delete} +    end +  end + +  defp remove_or_delete( +         %{object: %{data: %{"actor" => actor, "id" => id}}}, +         %{ap_id: actor} = user +       ) do +    with %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do +      CommonAPI.delete(activity.id, user) +    end +  end + +  defp remove_or_delete(cm_ref, _) do +    cm_ref +    |> MessageReference.delete() +  end + +  def post_chat_message( +        %{body_params: params, assigns: %{user: %{id: user_id} = user}} = conn, +        %{ +          id: id +        } +      ) do +    with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), +         %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), +         {:ok, activity} <- +           CommonAPI.post_chat_message(user, recipient, params[:content], +             media_id: params[:media_id] +           ), +         message <- Object.normalize(activity, false), +         cm_ref <- MessageReference.for_chat_and_object(chat, message) do +      conn +      |> put_view(MessageReferenceView) +      |> render("show.json", for: user, chat_message_reference: cm_ref) +    end +  end + +  def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ +        id: chat_id, +        message_id: message_id +      }) do +    with %MessageReference{} = cm_ref <- +           MessageReference.get_by_id(message_id), +         ^chat_id <- cm_ref.chat_id |> to_string(), +         %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), +         {:ok, cm_ref} <- MessageReference.mark_as_read(cm_ref) do +      conn +      |> put_view(MessageReferenceView) +      |> render("show.json", for: user, chat_message_reference: cm_ref) +    end +  end + +  def mark_as_read( +        %{body_params: %{last_read_id: last_read_id}, assigns: %{user: %{id: user_id}}} = conn, +        %{id: id} +      ) do +    with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), +         {_n, _} <- +           MessageReference.set_all_seen_for_chat(chat, last_read_id) do +      conn +      |> put_view(ChatView) +      |> render("show.json", chat: chat) +    end +  end + +  def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do +    with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do +      cm_refs = +        chat +        |> MessageReference.for_chat_query() +        |> Pagination.fetch_paginated(params) + +      conn +      |> put_view(MessageReferenceView) +      |> render("index.json", for: user, chat_message_references: cm_refs) +    else +      _ -> +        conn +        |> put_status(:not_found) +        |> json(%{error: "not found"}) +    end +  end + +  def index(%{assigns: %{user: %{id: user_id} = user}} = conn, _params) do +    blocked_ap_ids = User.blocked_users_ap_ids(user) + +    chats = +      from(c in Chat, +        where: c.user_id == ^user_id, +        where: c.recipient not in ^blocked_ap_ids, +        order_by: [desc: c.updated_at] +      ) +      |> Repo.all() + +    conn +    |> put_view(ChatView) +    |> render("index.json", chats: chats) +  end + +  def create(%{assigns: %{user: user}} = conn, params) do +    with %User{ap_id: recipient} <- User.get_by_id(params[:id]), +         {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do +      conn +      |> put_view(ChatView) +      |> render("show.json", chat: chat) +    end +  end + +  def show(%{assigns: %{user: user}} = conn, params) do +    with %Chat{} = chat <- Repo.get_by(Chat, user_id: user.id, id: params[:id]) do +      conn +      |> put_view(ChatView) +      |> render("show.json", chat: chat) +    end +  end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex new file mode 100644 index 000000000..f2112a86e --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do +  use Pleroma.Web, :view + +  alias Pleroma.User +  alias Pleroma.Web.CommonAPI.Utils +  alias Pleroma.Web.MastodonAPI.StatusView + +  def render( +        "show.json", +        %{ +          chat_message_reference: %{ +            id: id, +            object: %{data: chat_message}, +            chat_id: chat_id, +            unread: unread +          } +        } +      ) do +    %{ +      id: id |> to_string(), +      content: chat_message["content"], +      chat_id: chat_id |> to_string(), +      account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, +      created_at: Utils.to_masto_date(chat_message["published"]), +      emojis: StatusView.build_emojis(chat_message["emoji"]), +      attachment: +        chat_message["attachment"] && +          StatusView.render("attachment.json", attachment: chat_message["attachment"]), +      unread: unread +    } +  end + +  def render("index.json", opts) do +    render_many( +      opts[:chat_message_references], +      __MODULE__, +      "show.json", +      Map.put(opts, :as, :chat_message_reference) +    ) +  end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex new file mode 100644 index 000000000..1c996da11 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatView do +  use Pleroma.Web, :view + +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference +  alias Pleroma.User +  alias Pleroma.Web.CommonAPI.Utils +  alias Pleroma.Web.MastodonAPI.AccountView +  alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView + +  def render("show.json", %{chat: %Chat{} = chat} = opts) do +    recipient = User.get_cached_by_ap_id(chat.recipient) +    last_message = opts[:last_message] || MessageReference.last_message_for_chat(chat) + +    %{ +      id: chat.id |> to_string(), +      account: AccountView.render("show.json", Map.put(opts, :user, recipient)), +      unread: MessageReference.unread_count_for_chat(chat), +      last_message: +        last_message && +          MessageReferenceView.render("show.json", chat_message_reference: last_message), +      updated_at: Utils.to_masto_date(chat.updated_at) +    } +  end + +  def render("index.json", %{chats: chats}) do +    render_many(chats, __MODULE__, "show.json") +  end +end diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 691725702..cdb827e76 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -16,8 +16,6 @@ defmodule Pleroma.Web.Push.Impl do    require Logger    import Ecto.Query -  defdelegate mastodon_notification_type(activity), to: Activity -    @types ["Create", "Follow", "Announce", "Like", "Move"]    @doc "Performs sending notifications for user subscriptions" @@ -31,10 +29,10 @@ defmodule Pleroma.Web.Push.Impl do        when activity_type in @types do      actor = User.get_cached_by_ap_id(notification.activity.data["actor"]) -    mastodon_type = mastodon_notification_type(notification.activity) +    mastodon_type = notification.type      gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key)      avatar_url = User.avatar_url(actor) -    object = Object.normalize(activity) +    object = Object.normalize(activity, false)      user = User.get_cached_by_id(user_id)      direct_conversation_id = Activity.direct_conversation_id(activity, user) @@ -116,7 +114,7 @@ defmodule Pleroma.Web.Push.Impl do    end    def build_content(notification, actor, object, mastodon_type) do -    mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) +    mastodon_type = mastodon_type || notification.type      %{        title: format_title(notification, mastodon_type), @@ -126,6 +124,13 @@ defmodule Pleroma.Web.Push.Impl do    def format_body(activity, actor, object, mastodon_type \\ nil) +  def format_body(_activity, actor, %{data: %{"type" => "ChatMessage", "content" => content}}, _) do +    case content do +      nil -> "@#{actor.nickname}: (Attachment)" +      content -> "@#{actor.nickname}: #{Utils.scrub_html_and_truncate(content, 80)}" +    end +  end +    def format_body(          %{activity: %{data: %{"type" => "Create"}}},          actor, @@ -151,7 +156,7 @@ defmodule Pleroma.Web.Push.Impl do          mastodon_type        )        when type in ["Follow", "Like"] do -    mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) +    mastodon_type = mastodon_type || notification.type      case mastodon_type do        "follow" -> "@#{actor.nickname} has followed you" @@ -166,15 +171,14 @@ defmodule Pleroma.Web.Push.Impl do      "New Direct Message"    end -  def format_title(%{activity: activity}, mastodon_type) do -    mastodon_type = mastodon_type || mastodon_notification_type(activity) - -    case mastodon_type do +  def format_title(%{type: type}, mastodon_type) do +    case mastodon_type || type do        "mention" -> "New Mention"        "follow" -> "New Follower"        "follow_request" -> "New Follow Request"        "reblog" -> "New Repeat"        "favourite" -> "New Favorite" +      "pleroma:chat_mention" -> "New Chat Message"        type -> "New #{String.capitalize(type || "event")}"      end    end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 3e401a490..5b5aa0d59 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Web.Push.Subscription do      timestamps()    end -  @supported_alert_types ~w[follow favourite mention reblog]a +  @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention]a    defp alerts(%{data: %{alerts: alerts}}) do      alerts = Map.take(alerts, @supported_alert_types) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index aa272540d..57570b672 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -306,6 +306,15 @@ defmodule Pleroma.Web.Router do      scope [] do        pipe_through(:authenticated_api) +      post("/chats/by-account-id/:id", ChatController, :create) +      get("/chats", ChatController, :index) +      get("/chats/:id", ChatController, :show) +      get("/chats/:id/messages", ChatController, :messages) +      post("/chats/:id/messages", ChatController, :post_chat_message) +      delete("/chats/:id/messages/:message_id", ChatController, :delete_message) +      post("/chats/:id/read", ChatController, :mark_as_read) +      post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read) +        get("/conversations/:id/statuses", ConversationController, :statuses)        get("/conversations/:id", ConversationController, :show)        post("/conversations/read", ConversationController, :mark_as_read) diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 0cf41189b..d1d2c9b9c 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.Streamer do    require Logger    alias Pleroma.Activity +  alias Pleroma.Chat.MessageReference    alias Pleroma.Config    alias Pleroma.Conversation.Participation    alias Pleroma.Notification @@ -22,7 +23,7 @@ defmodule Pleroma.Web.Streamer do    def registry, do: @registry    @public_streams ["public", "public:local", "public:media", "public:local:media"] -  @user_streams ["user", "user:notification", "direct"] +  @user_streams ["user", "user:notification", "direct", "user:pleroma_chat"]    @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) :: @@ -89,34 +90,20 @@ defmodule Pleroma.Web.Streamer do      if should_env_send?(), do: Registry.unregister(@registry, topic)    end -  def stream(topics, item) when is_list(topics) do +  def stream(topics, items) do      if should_env_send?() do -      Enum.each(topics, fn t -> -        spawn(fn -> do_stream(t, item) end) +      List.wrap(topics) +      |> Enum.each(fn topic -> +        List.wrap(items) +        |> Enum.each(fn item -> +          spawn(fn -> do_stream(topic, item) end) +        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 stream(topic, item) do -    if should_env_send?() do -      spawn(fn -> do_stream(topic, item) end) -    end - -    :ok -  end -    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]) @@ -200,6 +187,19 @@ defmodule Pleroma.Web.Streamer do      end)    end +  defp do_stream(topic, {user, %MessageReference{} = cm_ref}) +       when topic in ["user", "user:pleroma_chat"] do +    topic = "#{topic}:#{user.id}" + +    text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + +    Registry.dispatch(@registry, topic, fn list -> +      Enum.each(list, fn {pid, _auth} -> +        send(pid, {:text, text}) +      end) +    end) +  end +    defp do_stream("user", item) do      Logger.debug("Trying to push to users") diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 237b29ded..476a33245 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -51,6 +51,29 @@ defmodule Pleroma.Web.StreamerView do      |> Jason.encode!()    end +  def render("chat_update.json", %{chat_message_reference: cm_ref}) do +    # Explicitly giving the cmr for the object here, so we don't accidentally +    # send a later 'last_message' that was inserted between inserting this and +    # streaming it out +    # +    # It also contains the chat with a cache of the correct unread count +    Logger.debug("Trying to stream out #{inspect(cm_ref)}") + +    representation = +      Pleroma.Web.PleromaAPI.ChatView.render( +        "show.json", +        %{last_message: cm_ref, chat: cm_ref.chat} +      ) + +    %{ +      event: "pleroma:chat_update", +      payload: +        representation +        |> Jason.encode!() +    } +    |> Jason.encode!() +  end +    def render("conversation.json", %Participation{} = participation) do      %{        event: "conversation",  | 
