diff options
81 files changed, 3930 insertions, 148 deletions
| diff --git a/CHANGELOG.md b/CHANGELOG.md index 839bf90ab..1cf2210f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).  - **Breaking:** removed `with_move` parameter from notifications timeline.  ### Added +- Chats: Added support for federated chats. For details, see the docs.  - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon.  - Instance: Add `background_image` to configuration and `/api/v1/instance`  - Instance: Extend `/api/v1/instance` with Pleroma-specific information. diff --git a/docs/API/chats.md b/docs/API/chats.md new file mode 100644 index 000000000..aa6119670 --- /dev/null +++ b/docs/API/chats.md @@ -0,0 +1,248 @@ +# Chats + +Chats are a way to represent an IM-style conversation between two actors. They are not the same as direct messages and they are not `Status`es, even though they have a lot in common. + +## Why Chats? + +There are no 'visibility levels' in ActivityPub, their definition is purely a Mastodon convention. Direct Messaging between users on the fediverse has mostly been modeled by using ActivityPub addressing following Mastodon conventions on normal `Note` objects. In this case, a 'direct message' would be a message that has no followers addressed and also does not address the special public actor, but just the recipients in the `to` field. It would still be a `Note` and is presented with other `Note`s as a `Status` in the API. + +This is an awkward setup for a few reasons: + +- As DMs generally still follow the usual `Status` conventions, it is easy to accidentally pull somebody into a DM thread by mentioning them. (e.g. "I hate @badguy so much") +- It is possible to go from a publicly addressed `Status` to a DM reply, back to public, then to a 'followers only' reply, and so on. This can be become very confusing, as it is unclear which user can see which part of the conversation. +- The standard `Status` format of implicit addressing also leads to rather ugly results if you try to display the messages as a chat, because all the recipients are always mentioned by name in the message. +- As direct messages are posted with the same api call (and usually same frontend component) as public messages, accidentally making a public message private or vice versa can happen easily. Client bugs can also lead to this, accidentally making private messages public. + +As a measure to improve this situation, the `Conversation` concept and related Pleroma extensions were introduced. While it made it possible to work around a few of the issues, many of the problems remained and it didn't see much adoption because it was too complicated to use correctly.  + +## Chats explained +For this reasons, Chats are a new and different entity, both in the API as well as in ActivityPub. A quick overview: + +- Chats are meant to represent an instant message conversation between two actors. For now these are only 1-on-1 conversations, but the other actor can be a group in the future. +- Chat messages have the ActivityPub type `ChatMessage`. They are not `Note`s. Servers that don't understand them will just drop them. +- The only addressing allowed in `ChatMessage`s is one single ActivityPub actor in the `to` field. +- There's always only one Chat between two actors. If you start chatting with someone and later start a 'new' Chat, the old Chat will be continued. +- `ChatMessage`s are posted with a different api, making it very hard to accidentally send a message to the wrong person. +- `ChatMessage`s don't show up in the existing timelines. +- Chats can never go from private to public. They are always private between the two actors. + +## Caveats + +- Chats are NOT E2E encrypted (yet). Security is still the same as email. + +## API + +In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. `Group`s will later be supported by making them a sub-type of `Account`. + +This is the overview of using the API. The API is also documented via OpenAPI, so you can view it and play with it by pointing SwaggerUI or a similar OpenAPI tool to `https://yourinstance.tld/api/openapi`. + +### Creating or getting a chat. + +To create or get an existing Chat for a certain recipient (identified by Account ID) +you can call: + +`POST /api/v1/pleroma/chats/by-account-id/:account_id` + +The account id is the normal FlakeId of the user +``` +POST /api/v1/pleroma/chats/by-account-id/someflakeid +``` + +If you already have the id of a chat, you can also use + +``` +GET /api/v1/pleroma/chats/:id +``` + +There will only ever be ONE Chat for you and a given recipient, so this call +will return the same Chat if you already have one with that user. + +Returned data: + +```json +{ +  "account": { +    "id": "someflakeid", +    "username": "somenick", +    ... +  }, +  "id" : "1", +  "unread" : 2, +  "last_message" : {...}, // The last message in that chat +  "updated_at": "2020-04-21T15:11:46.000Z" +} +``` + +### Marking a chat as read + +To mark a number of messages in a chat up to a certain message as read, you can use + +`POST /api/v1/pleroma/chats/:id/read` + + +Parameters: +- last_read_id: Given this id, all chat messages until this one will be marked as read. Required. + + +Returned data: + +```json +{ +  "account": { +    "id": "someflakeid", +    "username": "somenick", +    ... +  }, +  "id" : "1", +  "unread" : 0, +  "updated_at": "2020-04-21T15:11:46.000Z" +} +``` + +### Marking a single chat message as read + +To set the `unread` property of a message to `false` + +`POST /api/v1/pleroma/chats/:id/messages/:message_id/read` + +Returned data: + +The modified chat message + +### Getting a list of Chats + +`GET /api/v1/pleroma/chats` + +This will return a list of chats that you have been involved in, sorted by their +last update (so new chats will be at the top). + +Returned data: + +```json +[ +   { +      "account": { +        "id": "someflakeid", +        "username": "somenick", +        ... +      }, +      "id" : "1", +      "unread" : 2, +      "last_message" : {...}, // The last message in that chat +      "updated_at": "2020-04-21T15:11:46.000Z" +   } +] +``` + +The recipient of messages that are sent to this chat is given by their AP ID. +No pagination is implemented for now. + +### Getting the messages for a Chat + +For a given Chat id, you can get the associated messages with + +`GET /api/v1/pleroma/chats/:id/messages` + +This will return all messages, sorted by most recent to least recent. The usual +pagination options are implemented. + +Returned data: + +```json +[ +  { +    "account_id": "someflakeid", +    "chat_id": "1", +    "content": "Check this out :firefox:", +    "created_at": "2020-04-21T15:11:46.000Z", +    "emojis": [ +      { +        "shortcode": "firefox", +        "static_url": "https://dontbulling.me/emoji/Firefox.gif", +        "url": "https://dontbulling.me/emoji/Firefox.gif", +        "visible_in_picker": false +      } +    ], +    "id": "13", +    "unread": true +  }, +  { +    "account_id": "someflakeid", +    "chat_id": "1", +    "content": "Whats' up?", +    "created_at": "2020-04-21T15:06:45.000Z", +    "emojis": [], +    "id": "12", +    "unread": false +  } +] +``` + +### Posting a chat message + +Posting a chat message for given Chat id works like this: + +`POST /api/v1/pleroma/chats/:id/messages` + +Parameters: +- content: The text content of the message. Optional if media is attached. +- media_id: The id of an upload that will be attached to the message. + +Currently, no formatting beyond basic escaping and emoji is implemented. + +Returned data: + +```json +{ +  "account_id": "someflakeid", +  "chat_id": "1", +  "content": "Check this out :firefox:", +  "created_at": "2020-04-21T15:11:46.000Z", +  "emojis": [ +    { +      "shortcode": "firefox", +      "static_url": "https://dontbulling.me/emoji/Firefox.gif", +      "url": "https://dontbulling.me/emoji/Firefox.gif", +      "visible_in_picker": false +    } +  ], +  "id": "13", +  "unread": false +} +``` + +### Deleting a chat message + +Deleting a chat message for given Chat id works like this: + +`DELETE /api/v1/pleroma/chats/:chat_id/messages/:message_id` + +Returned data is the deleted message. + +### Notifications + +There's a new `pleroma:chat_mention` notification, which has this form. It is not given out in the notifications endpoint by default, you need to explicitly request it with `include_types[]=pleroma:chat_mention`: + +```json +{ +  "id": "someid", +  "type": "pleroma:chat_mention", +  "account": { ... } // User account of the sender, +  "chat_message": { +    "chat_id": "1", +    "id": "10", +    "content": "Hello", +    "account_id": "someflakeid", +    "unread": false +  }, +  "created_at": "somedate" +} +``` + +### Streaming + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. + +### Web Push + +If you want to receive push messages for this type, you'll need to add the `pleroma:chat_mention` type to your alerts in the push subscription. diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 434ade9a4..be3c802af 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -230,3 +230,7 @@ Has theses additional parameters (which are the same as in Pleroma-API):  Has these additional fields under the `pleroma` object:  - `unread_count`: contains number unread notifications + +## Streaming + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. diff --git a/docs/ap_extensions.md b/docs/ap_extensions.md new file mode 100644 index 000000000..c4550a1ac --- /dev/null +++ b/docs/ap_extensions.md @@ -0,0 +1,35 @@ +# ChatMessages + +ChatMessages are the messages sent in 1-on-1 chats. They are similar to +`Note`s, but the addresing is done by having a single AP actor in the `to` +field. Addressing multiple actors is not allowed. These messages are always +private, there is no public version of them. They are created with a `Create` +activity. + +Example: + +```json +{ +  "actor": "http://2hu.gensokyo/users/raymoo", +  "id": "http://2hu.gensokyo/objects/1", +  "object": { +    "attributedTo": "http://2hu.gensokyo/users/raymoo", +    "content": "You expected a cute girl? Too bad.", +    "id": "http://2hu.gensokyo/objects/2", +    "published": "2020-02-12T14:08:20Z", +    "to": [ +      "http://2hu.gensokyo/users/marisa" +    ], +    "type": "ChatMessage" +  }, +  "published": "2018-02-12T14:08:20Z", +  "to": [ +    "http://2hu.gensokyo/users/marisa" +  ], +  "type": "Create" +} +``` + +This setup does not prevent multi-user chats, but these will have to go through +a `Group`, which will be the recipient of the messages and then `Announce` them +to the users in the `Group`. 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/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 eb73c95fe..aeec4beae 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -112,7 +112,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), @@ -344,20 +351,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 @@ -1000,6 +1008,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 @@ -1115,6 +1135,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 543972ae9..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 @@ -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/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 dbb3d7ade..5a194910d 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 @@ -427,12 +476,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..46bcf4228 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,19 @@ 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 +      |> 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", diff --git a/priv/repo/migrations/20200309123730_create_chats.exs b/priv/repo/migrations/20200309123730_create_chats.exs new file mode 100644 index 000000000..715d798ea --- /dev/null +++ b/priv/repo/migrations/20200309123730_create_chats.exs @@ -0,0 +1,16 @@ +defmodule Pleroma.Repo.Migrations.CreateChats do +  use Ecto.Migration + +  def change do +    create table(:chats) do +      add(:user_id, references(:users, type: :uuid)) +      # Recipient is an ActivityPub id, to future-proof for group support. +      add(:recipient, :string) +      add(:unread, :integer, default: 0) +      timestamps() +    end + +    # There's only one chat between a user and a recipient. +    create(index(:chats, [:user_id, :recipient], unique: true)) +  end +end diff --git a/priv/repo/migrations/20200602094828_add_type_to_notifications.exs b/priv/repo/migrations/20200602094828_add_type_to_notifications.exs new file mode 100644 index 000000000..19c733628 --- /dev/null +++ b/priv/repo/migrations/20200602094828_add_type_to_notifications.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddTypeToNotifications do +  use Ecto.Migration + +  def change do +    alter table(:notifications) do +      add(:type, :string) +    end +  end +end diff --git a/priv/repo/migrations/20200602125218_backfill_notification_types.exs b/priv/repo/migrations/20200602125218_backfill_notification_types.exs new file mode 100644 index 000000000..996d721ee --- /dev/null +++ b/priv/repo/migrations/20200602125218_backfill_notification_types.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.BackfillNotificationTypes do +  use Ecto.Migration + +  def up do +    Pleroma.MigrationHelper.NotificationBackfill.fill_in_notification_types() +  end + +  def down do +  end +end diff --git a/priv/repo/migrations/20200602150528_create_chat_message_reference.exs b/priv/repo/migrations/20200602150528_create_chat_message_reference.exs new file mode 100644 index 000000000..6f9148b7c --- /dev/null +++ b/priv/repo/migrations/20200602150528_create_chat_message_reference.exs @@ -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.Repo.Migrations.CreateChatMessageReference do +  use Ecto.Migration + +  def change do +    create table(:chat_message_references, primary_key: false) do +      add(:id, :uuid, primary_key: true) +      add(:chat_id, references(:chats, on_delete: :delete_all), null: false) +      add(:object_id, references(:objects, on_delete: :delete_all), null: false) +      add(:seen, :boolean, default: false, null: false) + +      timestamps() +    end + +    create(index(:chat_message_references, [:chat_id, "id desc"])) +  end +end diff --git a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs new file mode 100644 index 000000000..fdf85132e --- /dev/null +++ b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs @@ -0,0 +1,7 @@ +defmodule Pleroma.Repo.Migrations.AddUniqueIndexToChatMessageReferences do +  use Ecto.Migration + +  def change do +    create(unique_index(:chat_message_references, [:object_id, :chat_id])) +  end +end diff --git a/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs b/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs new file mode 100644 index 000000000..6322137d5 --- /dev/null +++ b/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.RemoveUnreadFromChats do +  use Ecto.Migration + +  def change do +    alter table(:chats) do +      remove(:unread, :integer, default: 0) +    end +  end +end diff --git a/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs b/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs new file mode 100644 index 000000000..a5065d612 --- /dev/null +++ b/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs @@ -0,0 +1,12 @@ +defmodule Pleroma.Repo.Migrations.AddSeenIndexToChatMessageReferences do +  use Ecto.Migration + +  def change do +    create( +      index(:chat_message_references, [:chat_id], +        where: "seen = false", +        name: "unseen_messages_count_index" +      ) +    ) +  end +end diff --git a/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs b/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs new file mode 100644 index 000000000..fd6bc7bc7 --- /dev/null +++ b/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs @@ -0,0 +1,30 @@ +defmodule Pleroma.Repo.Migrations.MigrateSeenToUnreadInChatMessageReferences do +  use Ecto.Migration + +  def change do +    drop( +      index(:chat_message_references, [:chat_id], +        where: "seen = false", +        name: "unseen_messages_count_index" +      ) +    ) + +    alter table(:chat_message_references) do +      add(:unread, :boolean, default: true) +    end + +    execute("update chat_message_references set unread = not seen") + +    alter table(:chat_message_references) do +      modify(:unread, :boolean, default: true, null: false) +      remove(:seen, :boolean, default: false, null: false) +    end + +    create( +      index(:chat_message_references, [:chat_id], +        where: "unread = true", +        name: "unread_messages_count_index" +      ) +    ) +  end +end diff --git a/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs b/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs new file mode 100644 index 000000000..9ea34436b --- /dev/null +++ b/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs @@ -0,0 +1,36 @@ +defmodule Pleroma.Repo.Migrations.ChangeTypeToEnumForNotifications do +  use Ecto.Migration + +  def up do +    """ +    create type notification_type as enum ( +      'follow', +      'follow_request', +      'mention', +      'move', +      'pleroma:emoji_reaction', +      'pleroma:chat_mention', +      'reblog', +      'favourite' +    ) +    """ +    |> execute() + +    """ +    alter table notifications  +    alter column type type notification_type using (type::notification_type) +    """ +    |> execute() +  end + +  def down do +    alter table(:notifications) do +      modify(:type, :string) +    end + +    """ +    drop type notification_type +    """ +    |> execute() +  end +end diff --git a/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs b/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs new file mode 100644 index 000000000..f14e269ca --- /dev/null +++ b/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs @@ -0,0 +1,23 @@ +defmodule Pleroma.Repo.Migrations.ChangeChatIdToFlake do +  use Ecto.Migration + +  def up do +    execute(""" +    alter table chats +    drop constraint chats_pkey cascade, +    alter column id drop default, +    alter column id set data type uuid using cast( lpad( to_hex(id), 32, '0') as uuid), +    add primary key (id) +    """) + +    execute(""" +    alter table chat_message_references +    alter column chat_id set data type uuid using cast( lpad( to_hex(chat_id), 32, '0') as uuid), +    add constraint chat_message_references_chat_id_fkey foreign key (chat_id) references chats(id) on delete cascade +    """) +  end + +  def down do +    :ok +  end +end diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 278ad2f96..7cc3fee40 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -30,6 +30,7 @@                  "@type": "@id"              },              "EmojiReact": "litepub:EmojiReact", +            "ChatMessage": "litepub:ChatMessage",              "alsoKnownAs": {                  "@id": "as:alsoKnownAs",                  "@type": "@id" diff --git a/test/chat/message_reference_test.exs b/test/chat/message_reference_test.exs new file mode 100644 index 000000000..aaa7c1ad4 --- /dev/null +++ b/test/chat/message_reference_test.exs @@ -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.Chat.MessageReferenceTest do +  use Pleroma.DataCase, async: true + +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference +  alias Pleroma.Web.CommonAPI + +  import Pleroma.Factory + +  describe "messages" do +    test "it returns the last message in a chat" do +      user = insert(:user) +      recipient = insert(:user) + +      {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") +      {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") + +      {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + +      message = MessageReference.last_message_for_chat(chat) + +      assert message.object.data["content"] == "ho" +    end +  end +end diff --git a/test/chat_test.exs b/test/chat_test.exs new file mode 100644 index 000000000..332f2180a --- /dev/null +++ b/test/chat_test.exs @@ -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.ChatTest do +  use Pleroma.DataCase, async: true + +  alias Pleroma.Chat + +  import Pleroma.Factory + +  describe "creation and getting" do +    test "it only works if the recipient is a valid user (for now)" do +      user = insert(:user) + +      assert {:error, _chat} = Chat.bump_or_create(user.id, "http://some/nonexisting/account") +      assert {:error, _chat} = Chat.get_or_create(user.id, "http://some/nonexisting/account") +    end + +    test "it creates a chat for a user and recipient" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + +      assert chat.id +    end + +    test "it returns and bumps a chat for a user and recipient if it already exists" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) +      {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) + +      assert chat.id == chat_two.id +    end + +    test "it returns a chat for a user and recipient if it already exists" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) +      {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + +      assert chat.id == chat_two.id +    end + +    test "a returning chat will have an updated `update_at` field" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) +      :timer.sleep(1500) +      {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) + +      assert chat.id == chat_two.id +      assert chat.updated_at != chat_two.updated_at +    end +  end +end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json new file mode 100644 index 000000000..9c23a1c9b --- /dev/null +++ b/test/fixtures/create-chat-message.json @@ -0,0 +1,31 @@ +{ +  "actor": "http://2hu.gensokyo/users/raymoo", +  "id": "http://2hu.gensokyo/objects/1", +  "object": { +    "attributedTo": "http://2hu.gensokyo/users/raymoo", +    "content": "You expected a cute girl? Too bad. <script>alert('XSS')</script>", +    "id": "http://2hu.gensokyo/objects/2", +    "published": "2020-02-12T14:08:20Z", +    "to": [ +      "http://2hu.gensokyo/users/marisa" +    ], +    "tag": [ +      { +        "icon": { +          "type": "Image", +          "url": "http://2hu.gensokyo/emoji/Firefox.gif" +        }, +        "id": "http://2hu.gensokyo/emoji/Firefox.gif", +        "name": ":firefox:", +        "type": "Emoji", +        "updated": "1970-01-01T00:00:00Z" +      } +    ], +    "type": "ChatMessage" +  }, +  "published": "2018-02-12T14:08:20Z", +  "to": [ +    "http://2hu.gensokyo/users/marisa" +  ], +  "type": "Create" +} diff --git a/test/migration_helper/notification_backfill_test.exs b/test/migration_helper/notification_backfill_test.exs new file mode 100644 index 000000000..2a62a2b00 --- /dev/null +++ b/test/migration_helper/notification_backfill_test.exs @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelper.NotificationBackfillTest do +  use Pleroma.DataCase + +  alias Pleroma.Activity +  alias Pleroma.MigrationHelper.NotificationBackfill +  alias Pleroma.Notification +  alias Pleroma.Repo +  alias Pleroma.Web.CommonAPI + +  import Pleroma.Factory + +  describe "fill_in_notification_types" do +    test "it fills in missing notification types" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) +      {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") +      {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") +      {:ok, like} = CommonAPI.favorite(other_user, post.id) +      {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + +      data = +        react_2.data +        |> Map.put("type", "EmojiReaction") + +      {:ok, react_2} = +        react_2 +        |> Activity.change(%{data: data}) +        |> Repo.update() + +      assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) + +      NotificationBackfill.fill_in_notification_types() + +      assert %{type: "mention"} = +               Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) + +      assert %{type: "favourite"} = +               Repo.get_by(Notification, user_id: user.id, activity_id: like.id) + +      assert %{type: "pleroma:emoji_reaction"} = +               Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + +      assert %{type: "pleroma:emoji_reaction"} = +               Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) + +      assert %{type: "pleroma:chat_mention"} = +               Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) +    end +  end +end diff --git a/test/notification_test.exs b/test/notification_test.exs index 37c255fee..b9bbdceca 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -10,6 +10,7 @@ defmodule Pleroma.NotificationTest do    alias Pleroma.FollowingRelationship    alias Pleroma.Notification +  alias Pleroma.Repo    alias Pleroma.Tests.ObanHelpers    alias Pleroma.User    alias Pleroma.Web.ActivityPub.ActivityPub @@ -31,6 +32,7 @@ defmodule Pleroma.NotificationTest do        {:ok, [notification]} = Notification.create_notifications(activity)        assert notification.user_id == user.id +      assert notification.type == "pleroma:emoji_reaction"      end      test "notifies someone when they are directly addressed" do @@ -48,6 +50,7 @@ defmodule Pleroma.NotificationTest do        notified_ids = Enum.sort([notification.user_id, other_notification.user_id])        assert notified_ids == [other_user.id, third_user.id]        assert notification.activity_id == activity.id +      assert notification.type == "mention"        assert other_notification.activity_id == activity.id        assert [%Pleroma.Marker{unread_count: 2}] = @@ -335,9 +338,12 @@ defmodule Pleroma.NotificationTest do        # After request is accepted, the same notification is rendered with type "follow":        assert {:ok, _} = CommonAPI.accept_follow_request(user, followed_user) -      notification_id = notification.id -      assert [%{id: ^notification_id}] = Notification.for_user(followed_user) -      assert %{type: "follow"} = NotificationView.render("show.json", render_opts) +      notification = +        Repo.get(Notification, notification.id) +        |> Repo.preload(:activity) + +      assert %{type: "follow"} = +               NotificationView.render("show.json", notification: notification, for: followed_user)      end      test "it doesn't create a notification for follow-unfollow-follow chains" do diff --git a/test/upload_test.exs b/test/upload_test.exs index 060a940bb..2abf0edec 100644 --- a/test/upload_test.exs +++ b/test/upload_test.exs @@ -54,6 +54,7 @@ defmodule Pleroma.UploadTest do                  %{                    "name" => "image.jpg",                    "type" => "Document", +                  "mediaType" => "image/jpeg",                    "url" => [                      %{                        "href" => "http://localhost:4001/media/post-process-file.jpg", diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 7953eecf2..31224abe0 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -2,14 +2,264 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do    use Pleroma.DataCase    alias Pleroma.Object +  alias Pleroma.Web.ActivityPub.ActivityPub    alias Pleroma.Web.ActivityPub.Builder    alias Pleroma.Web.ActivityPub.ObjectValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator    alias Pleroma.Web.ActivityPub.Utils    alias Pleroma.Web.CommonAPI    import Pleroma.Factory +  describe "attachments" do +    test "works with honkerific attachments" do +      attachment = %{ +        "mediaType" => "", +        "name" => "", +        "summary" => "298p3RG7j27tfsZ9RQ.jpg", +        "type" => "Document", +        "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" +      } + +      assert {:ok, attachment} = +               AttachmentValidator.cast_and_validate(attachment) +               |> Ecto.Changeset.apply_action(:insert) + +      assert attachment.mediaType == "application/octet-stream" +    end + +    test "it turns mastodon attachments into our attachments" do +      attachment = %{ +        "url" => +          "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", +        "type" => "Document", +        "name" => nil, +        "mediaType" => "image/jpeg" +      } + +      {:ok, attachment} = +        AttachmentValidator.cast_and_validate(attachment) +        |> Ecto.Changeset.apply_action(:insert) + +      assert [ +               %{ +                 href: +                   "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", +                 type: "Link", +                 mediaType: "image/jpeg" +               } +             ] = attachment.url + +      assert attachment.mediaType == "image/jpeg" +    end + +    test "it handles our own uploads" do +      user = insert(:user) + +      file = %Plug.Upload{ +        content_type: "image/jpg", +        path: Path.absname("test/fixtures/image.jpg"), +        filename: "an_image.jpg" +      } + +      {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + +      {:ok, attachment} = +        attachment.data +        |> AttachmentValidator.cast_and_validate() +        |> Ecto.Changeset.apply_action(:insert) + +      assert attachment.mediaType == "image/jpeg" +    end +  end + +  describe "chat message create activities" do +    test "it is invalid if the object already exists" do +      user = insert(:user) +      recipient = insert(:user) +      {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey") +      object = Object.normalize(activity, false) + +      {:ok, create_data, _} = Builder.create(user, object.data, [recipient.ap_id]) + +      {:error, cng} = ObjectValidator.validate(create_data, []) + +      assert {:object, {"The object to create already exists", []}} in cng.errors +    end + +    test "it is invalid if the object data has a different `to` or `actor` field" do +      user = insert(:user) +      recipient = insert(:user) +      {:ok, object_data, _} = Builder.chat_message(recipient, user.ap_id, "Hey") + +      {:ok, create_data, _} = Builder.create(user, object_data, [recipient.ap_id]) + +      {:error, cng} = ObjectValidator.validate(create_data, []) + +      assert {:to, {"Recipients don't match with object recipients", []}} in cng.errors +      assert {:actor, {"Actor doesn't match with object actor", []}} in cng.errors +    end +  end + +  describe "chat messages" do +    setup do +      clear_config([:instance, :remote_limit]) +      user = insert(:user) +      recipient = insert(:user, local: false) + +      {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey :firefox:") + +      %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} +    end + +    test "let's through some basic html", %{user: user, recipient: recipient} do +      {:ok, valid_chat_message, _} = +        Builder.chat_message( +          user, +          recipient.ap_id, +          "hey <a href='https://example.org'>example</a> <script>alert('uguu')</script>" +        ) + +      assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + +      assert object["content"] == +               "hey <a href=\"https://example.org\">example</a> alert('uguu')" +    end + +    test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do +      assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + +      assert Map.put(valid_chat_message, "attachment", nil) == object +    end + +    test "validates for a basic object with an attachment", %{ +      valid_chat_message: valid_chat_message, +      user: user +    } do +      file = %Plug.Upload{ +        content_type: "image/jpg", +        path: Path.absname("test/fixtures/image.jpg"), +        filename: "an_image.jpg" +      } + +      {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + +      valid_chat_message = +        valid_chat_message +        |> Map.put("attachment", attachment.data) + +      assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + +      assert object["attachment"] +    end + +    test "validates for a basic object with an attachment in an array", %{ +      valid_chat_message: valid_chat_message, +      user: user +    } do +      file = %Plug.Upload{ +        content_type: "image/jpg", +        path: Path.absname("test/fixtures/image.jpg"), +        filename: "an_image.jpg" +      } + +      {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + +      valid_chat_message = +        valid_chat_message +        |> Map.put("attachment", [attachment.data]) + +      assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + +      assert object["attachment"] +    end + +    test "validates for a basic object with an attachment but without content", %{ +      valid_chat_message: valid_chat_message, +      user: user +    } do +      file = %Plug.Upload{ +        content_type: "image/jpg", +        path: Path.absname("test/fixtures/image.jpg"), +        filename: "an_image.jpg" +      } + +      {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + +      valid_chat_message = +        valid_chat_message +        |> Map.put("attachment", attachment.data) +        |> Map.delete("content") + +      assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + +      assert object["attachment"] +    end + +    test "does not validate if the message has no content", %{ +      valid_chat_message: valid_chat_message +    } do +      contentless = +        valid_chat_message +        |> Map.delete("content") + +      refute match?({:ok, _object, _meta}, ObjectValidator.validate(contentless, [])) +    end + +    test "does not validate if the message is longer than the remote_limit", %{ +      valid_chat_message: valid_chat_message +    } do +      Pleroma.Config.put([:instance, :remote_limit], 2) +      refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) +    end + +    test "does not validate if the recipient is blocking the actor", %{ +      valid_chat_message: valid_chat_message, +      user: user, +      recipient: recipient +    } do +      Pleroma.User.block(recipient, user) +      refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) +    end + +    test "does not validate if the actor or the recipient is not in our system", %{ +      valid_chat_message: valid_chat_message +    } do +      chat_message = +        valid_chat_message +        |> Map.put("actor", "https://raymoo.com/raymoo") + +      {:error, _} = ObjectValidator.validate(chat_message, []) + +      chat_message = +        valid_chat_message +        |> Map.put("to", ["https://raymoo.com/raymoo"]) + +      {:error, _} = ObjectValidator.validate(chat_message, []) +    end + +    test "does not validate for a message with multiple recipients", %{ +      valid_chat_message: valid_chat_message, +      user: user, +      recipient: recipient +    } do +      chat_message = +        valid_chat_message +        |> Map.put("to", [user.ap_id, recipient.ap_id]) + +      assert {:error, _} = ObjectValidator.validate(chat_message, []) +    end + +    test "does not validate if it doesn't concern local users" do +      user = insert(:user, local: false) +      recipient = insert(:user, local: false) + +      {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") +      assert {:error, _} = ObjectValidator.validate(valid_chat_message, []) +    end +  end +    describe "EmojiReacts" do      setup do        user = insert(:user) diff --git a/test/web/activity_pub/object_validators/types/object_id_test.exs b/test/web/activity_pub/object_validators/types/object_id_test.exs index 834213182..c8911948e 100644 --- a/test/web/activity_pub/object_validators/types/object_id_test.exs +++ b/test/web/activity_pub/object_validators/types/object_id_test.exs @@ -1,3 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only +  defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do    alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID    use Pleroma.DataCase diff --git a/test/web/activity_pub/object_validators/types/safe_text_test.exs b/test/web/activity_pub/object_validators/types/safe_text_test.exs new file mode 100644 index 000000000..d4a574554 --- /dev/null +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -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.Types.SafeTextTest do +  use Pleroma.DataCase + +  alias Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText + +  test "it lets normal text go through" do +    text = "hey how are you" +    assert {:ok, text} == SafeText.cast(text) +  end + +  test "it removes html tags from text" do +    text = "hey look xss <script>alert('foo')</script>" +    assert {:ok, "hey look xss alert('foo')"} == SafeText.cast(text) +  end + +  test "it keeps basic html tags" do +    text = "hey <a href='http://gensokyo.2hu'>look</a> xss <script>alert('foo')</script>" + +    assert {:ok, "hey <a href=\"http://gensokyo.2hu\">look</a> xss alert('foo')"} == +             SafeText.cast(text) +  end + +  test "errors for non-text" do +    assert :error == SafeText.cast(1) +  end +end diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs index 26557720b..8deb64501 100644 --- a/test/web/activity_pub/pipeline_test.exs +++ b/test/web/activity_pub/pipeline_test.exs @@ -33,7 +33,10 @@ defmodule Pleroma.Web.ActivityPub.PipelineTest do          {            Pleroma.Web.ActivityPub.SideEffects,            [], -          [handle: fn o, m -> {:ok, o, m} end] +          [ +            handle: fn o, m -> {:ok, o, m} end, +            handle_after_transaction: fn m -> m end +          ]          },          {            Pleroma.Web.Federator, @@ -71,7 +74,7 @@ defmodule Pleroma.Web.ActivityPub.PipelineTest do          {            Pleroma.Web.ActivityPub.SideEffects,            [], -          [handle: fn o, m -> {:ok, o, m} end] +          [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end]          },          {            Pleroma.Web.Federator, @@ -110,7 +113,7 @@ defmodule Pleroma.Web.ActivityPub.PipelineTest do          {            Pleroma.Web.ActivityPub.SideEffects,            [], -          [handle: fn o, m -> {:ok, o, m} end] +          [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end]          },          {            Pleroma.Web.Federator, diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index a80104ea7..6bbbaae87 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference    alias Pleroma.Notification    alias Pleroma.Object    alias Pleroma.Repo @@ -20,6 +22,48 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do    import Pleroma.Factory    import Mock +  describe "handle_after_transaction" do +    test "it streams out notifications and streams" do +      author = insert(:user, local: true) +      recipient = insert(:user, local: true) + +      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      {:ok, _create_activity, meta} = +        SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + +      assert [notification] = meta[:notifications] + +      with_mocks([ +        { +          Pleroma.Web.Streamer, +          [], +          [ +            stream: fn _, _ -> nil end +          ] +        }, +        { +          Pleroma.Web.Push, +          [], +          [ +            send: fn _ -> nil end +          ] +        } +      ]) do +        SideEffects.handle_after_transaction(meta) + +        assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) +        assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) +        assert called(Pleroma.Web.Push.send(notification)) +      end +    end +  end +    describe "delete objects" do      setup do        user = insert(:user) @@ -290,6 +334,147 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do      end    end +  describe "creation of ChatMessages" do +    test "notifies the recipient" do +      author = insert(:user, local: false) +      recipient = insert(:user, local: true) + +      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      {:ok, _create_activity, _meta} = +        SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + +      assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) +    end + +    test "it streams the created ChatMessage" do +      author = insert(:user, local: true) +      recipient = insert(:user, local: true) + +      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      {:ok, _create_activity, meta} = +        SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + +      assert [_, _] = meta[:streamables] +    end + +    test "it creates a Chat and MessageReferences for the local users and bumps the unread count, except for the author" do +      author = insert(:user, local: true) +      recipient = insert(:user, local: true) + +      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      with_mocks([ +        { +          Pleroma.Web.Streamer, +          [], +          [ +            stream: fn _, _ -> nil end +          ] +        }, +        { +          Pleroma.Web.Push, +          [], +          [ +            send: fn _ -> nil end +          ] +        } +      ]) do +        {:ok, _create_activity, meta} = +          SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + +        # The notification gets created +        assert [notification] = meta[:notifications] +        assert notification.activity_id == create_activity.id + +        # But it is not sent out +        refute called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) +        refute called(Pleroma.Web.Push.send(notification)) + +        # Same for the user chat stream +        assert [{topics, _}, _] = meta[:streamables] +        assert topics == ["user", "user:pleroma_chat"] +        refute called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) + +        chat = Chat.get(author.id, recipient.ap_id) + +        [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() + +        assert cm_ref.object.data["content"] == "hey" +        assert cm_ref.unread == false + +        chat = Chat.get(recipient.id, author.ap_id) + +        [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() + +        assert cm_ref.object.data["content"] == "hey" +        assert cm_ref.unread == true +      end +    end + +    test "it creates a Chat for the local users and bumps the unread count" do +      author = insert(:user, local: false) +      recipient = insert(:user, local: true) + +      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      {:ok, _create_activity, _meta} = +        SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + +      # An object is created +      assert Object.get_by_ap_id(chat_message_data["id"]) + +      # The remote user won't get a chat +      chat = Chat.get(author.id, recipient.ap_id) +      refute chat + +      # The local user will get a chat +      chat = Chat.get(recipient.id, author.ap_id) +      assert chat + +      author = insert(:user, local: true) +      recipient = insert(:user, local: true) + +      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      {:ok, _create_activity, _meta} = +        SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + +      # Both users are local and get the chat +      chat = Chat.get(author.id, recipient.ap_id) +      assert chat + +      chat = Chat.get(recipient.id, author.ap_id) +      assert chat +    end +  end +    describe "announce objects" do      setup do        poster = insert(:user) diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs new file mode 100644 index 000000000..d6736dc3e --- /dev/null +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -0,0 +1,153 @@ +# 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.Transmogrifier.ChatMessageTest do +  use Pleroma.DataCase + +  import Pleroma.Factory + +  alias Pleroma.Activity +  alias Pleroma.Chat +  alias Pleroma.Object +  alias Pleroma.Web.ActivityPub.Transmogrifier + +  describe "handle_incoming" do +    test "handles chonks with attachment" do +      data = %{ +        "@context" => "https://www.w3.org/ns/activitystreams", +        "actor" => "https://honk.tedunangst.com/u/tedu", +        "id" => "https://honk.tedunangst.com/u/tedu/honk/x6gt8X8PcyGkQcXxzg1T", +        "object" => %{ +          "attachment" => [ +            %{ +              "mediaType" => "image/jpeg", +              "name" => "298p3RG7j27tfsZ9RQ.jpg", +              "summary" => "298p3RG7j27tfsZ9RQ.jpg", +              "type" => "Document", +              "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" +            } +          ], +          "attributedTo" => "https://honk.tedunangst.com/u/tedu", +          "content" => "", +          "id" => "https://honk.tedunangst.com/u/tedu/chonk/26L4wl5yCbn4dr4y1b", +          "published" => "2020-05-18T01:13:03Z", +          "to" => [ +            "https://dontbulling.me/users/lain" +          ], +          "type" => "ChatMessage" +        }, +        "published" => "2020-05-18T01:13:03Z", +        "to" => [ +          "https://dontbulling.me/users/lain" +        ], +        "type" => "Create" +      } + +      _user = insert(:user, ap_id: data["actor"]) +      _user = insert(:user, ap_id: hd(data["to"])) + +      assert {:ok, _activity} = Transmogrifier.handle_incoming(data) +    end + +    test "it rejects messages that don't contain content" do +      data = +        File.read!("test/fixtures/create-chat-message.json") +        |> Poison.decode!() + +      object = +        data["object"] +        |> Map.delete("content") + +      data = +        data +        |> Map.put("object", object) + +      _author = +        insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + +      _recipient = +        insert(:user, +          ap_id: List.first(data["to"]), +          local: true, +          last_refreshed_at: DateTime.utc_now() +        ) + +      {:error, _} = Transmogrifier.handle_incoming(data) +    end + +    test "it rejects messages that don't concern local users" do +      data = +        File.read!("test/fixtures/create-chat-message.json") +        |> Poison.decode!() + +      _author = +        insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + +      _recipient = +        insert(:user, +          ap_id: List.first(data["to"]), +          local: false, +          last_refreshed_at: DateTime.utc_now() +        ) + +      {:error, _} = Transmogrifier.handle_incoming(data) +    end + +    test "it rejects messages where the `to` field of activity and object don't match" do +      data = +        File.read!("test/fixtures/create-chat-message.json") +        |> Poison.decode!() + +      author = insert(:user, ap_id: data["actor"]) +      _recipient = insert(:user, ap_id: List.first(data["to"])) + +      data = +        data +        |> Map.put("to", author.ap_id) + +      assert match?({:error, _}, Transmogrifier.handle_incoming(data)) +      refute Object.get_by_ap_id(data["object"]["id"]) +    end + +    test "it fetches the actor if they aren't in our system" do +      Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + +      data = +        File.read!("test/fixtures/create-chat-message.json") +        |> Poison.decode!() +        |> Map.put("actor", "http://mastodon.example.org/users/admin") +        |> put_in(["object", "actor"], "http://mastodon.example.org/users/admin") + +      _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + +      {:ok, %Activity{} = _activity} = Transmogrifier.handle_incoming(data) +    end + +    test "it inserts it and creates a chat" do +      data = +        File.read!("test/fixtures/create-chat-message.json") +        |> Poison.decode!() + +      author = +        insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + +      recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + +      {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) +      assert activity.local == false + +      assert activity.actor == author.ap_id +      assert activity.recipients == [recipient.ap_id, author.ap_id] + +      %Object{} = object = Object.get_by_ap_id(activity.data["object"]) + +      assert object +      assert object.data["content"] == "You expected a cute girl? Too bad. alert('XSS')" +      assert match?(%{"firefox" => _}, object.data["emoji"]) + +      refute Chat.get(author.id, recipient.ap_id) +      assert Chat.get(recipient.id, author.ap_id) +    end +  end +end diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index 967389fae..06c39eed6 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -5,6 +5,7 @@  defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Notification    alias Pleroma.Repo    alias Pleroma.User    alias Pleroma.Web.ActivityPub.Transmogrifier @@ -12,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do    import Pleroma.Factory    import Ecto.Query +  import Mock    setup_all do      Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -57,9 +59,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do        activity = Repo.get(Activity, activity.id)        assert activity.data["state"] == "accept"        assert User.following?(User.get_cached_by_ap_id(data["actor"]), user) + +      [notification] = Notification.for_user(user) +      assert notification.type == "follow"      end -    test "with locked accounts, it does not create a follow or an accept" do +    test "with locked accounts, it does create a Follow, but not an Accept" do        user = insert(:user, locked: true)        data = @@ -81,6 +86,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do          |> Repo.all()        assert Enum.empty?(accepts) + +      [notification] = Notification.for_user(user) +      assert notification.type == "follow_request"      end      test "it works for follow requests when you are already followed, creating a new accept activity" do @@ -144,6 +152,23 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do        assert activity.data["state"] == "reject"      end +    test "it rejects incoming follow requests if the following errors for some reason" do +      user = insert(:user) + +      data = +        File.read!("test/fixtures/mastodon-follow-activity.json") +        |> Poison.decode!() +        |> Map.put("object", user.ap_id) + +      with_mock Pleroma.User, [:passthrough], follow: fn _, _ -> {:error, :testing} end do +        {:ok, %Activity{data: %{"id" => id}}} = Transmogrifier.handle_incoming(data) + +        %Activity{} = activity = Activity.get_by_ap_id(id) + +        assert activity.data["state"] == "reject" +      end +    end +      test "it works for incoming follow requests from hubzilla" do        user = insert(:user) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 2291f76dd..6bd26050e 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -5,7 +5,9 @@  defmodule Pleroma.Web.CommonAPITest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Chat    alias Pleroma.Conversation.Participation +  alias Pleroma.Notification    alias Pleroma.Object    alias Pleroma.User    alias Pleroma.Web.ActivityPub.ActivityPub @@ -23,6 +25,150 @@ defmodule Pleroma.Web.CommonAPITest do    setup do: clear_config([:instance, :limit])    setup do: clear_config([:instance, :max_pinned_statuses]) +  describe "posting chat messages" do +    setup do: clear_config([:instance, :chat_limit]) + +    test "it posts a chat message without content but with an attachment" do +      author = insert(:user) +      recipient = insert(:user) + +      file = %Plug.Upload{ +        content_type: "image/jpg", +        path: Path.absname("test/fixtures/image.jpg"), +        filename: "an_image.jpg" +      } + +      {:ok, upload} = ActivityPub.upload(file, actor: author.ap_id) + +      with_mocks([ +        { +          Pleroma.Web.Streamer, +          [], +          [ +            stream: fn _, _ -> +              nil +            end +          ] +        }, +        { +          Pleroma.Web.Push, +          [], +          [ +            send: fn _ -> nil end +          ] +        } +      ]) do +        {:ok, activity} = +          CommonAPI.post_chat_message( +            author, +            recipient, +            nil, +            media_id: upload.id +          ) + +        notification = +          Notification.for_user_and_activity(recipient, activity) +          |> Repo.preload(:activity) + +        assert called(Pleroma.Web.Push.send(notification)) +        assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) +        assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) + +        assert activity +      end +    end + +    test "it adds html newlines" do +      author = insert(:user) +      recipient = insert(:user) + +      other_user = insert(:user) + +      {:ok, activity} = +        CommonAPI.post_chat_message( +          author, +          recipient, +          "uguu\nuguuu" +        ) + +      assert other_user.ap_id not in activity.recipients + +      object = Object.normalize(activity, false) + +      assert object.data["content"] == "uguu<br/>uguuu" +    end + +    test "it linkifies" do +      author = insert(:user) +      recipient = insert(:user) + +      other_user = insert(:user) + +      {:ok, activity} = +        CommonAPI.post_chat_message( +          author, +          recipient, +          "https://example.org is the site of @#{other_user.nickname} #2hu" +        ) + +      assert other_user.ap_id not in activity.recipients + +      object = Object.normalize(activity, false) + +      assert object.data["content"] == +               "<a href=\"https://example.org\" rel=\"ugc\">https://example.org</a> is the site of <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{ +                 other_user.id +               }\" href=\"#{other_user.ap_id}\" rel=\"ugc\">@<span>#{other_user.nickname}</span></a></span> <a class=\"hashtag\" data-tag=\"2hu\" href=\"http://localhost:4001/tag/2hu\">#2hu</a>" +    end + +    test "it posts a chat message" do +      author = insert(:user) +      recipient = insert(:user) + +      {:ok, activity} = +        CommonAPI.post_chat_message( +          author, +          recipient, +          "a test message <script>alert('uuu')</script> :firefox:" +        ) + +      assert activity.data["type"] == "Create" +      assert activity.local +      object = Object.normalize(activity) + +      assert object.data["type"] == "ChatMessage" +      assert object.data["to"] == [recipient.ap_id] + +      assert object.data["content"] == +               "a test message <script>alert('uuu')</script> :firefox:" + +      assert object.data["emoji"] == %{ +               "firefox" => "http://localhost:4001/emoji/Firefox.gif" +             } + +      assert Chat.get(author.id, recipient.ap_id) +      assert Chat.get(recipient.id, author.ap_id) + +      assert :ok == Pleroma.Web.Federator.perform(:publish, activity) +    end + +    test "it reject messages over the local limit" do +      Pleroma.Config.put([:instance, :chat_limit], 2) + +      author = insert(:user) +      recipient = insert(:user) + +      {:error, message} = +        CommonAPI.post_chat_message( +          author, +          recipient, +          "123" +        ) + +      assert message == :content_too_long +    end +  end +    describe "unblocking" do      test "it works even without an existing block activity" do        blocked = insert(:user) diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index e278d61f5..698c99711 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -54,6 +54,27 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do      assert response == expected_response    end +  test "by default, does not contain pleroma:chat_mention" do +    %{user: user, conn: conn} = oauth_access(["read:notifications"]) +    other_user = insert(:user) + +    {:ok, _activity} = CommonAPI.post_chat_message(other_user, user, "hey") + +    result = +      conn +      |> get("/api/v1/notifications") +      |> json_response_and_validate_schema(200) + +    assert [] == result + +    result = +      conn +      |> get("/api/v1/notifications?include_types[]=pleroma:chat_mention") +      |> json_response_and_validate_schema(200) + +    assert [_] = result +  end +    test "getting a single notification" do      %{user: user, conn: conn} = oauth_access(["read:notifications"])      other_user = insert(:user) diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 84d46895e..0e025adca 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -111,6 +111,15 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do                 %{"name" => "prone", "url" => "#{Web.base_url()}/tag/prone"},                 %{"name" => "AccidentProne", "url" => "#{Web.base_url()}/tag/AccidentProne"}               ] + +      results = +        conn +        |> get("/api/v2/search?#{URI.encode_query(%{q: "https://shpposter.club/users/shpuld"})}") +        |> json_response_and_validate_schema(200) + +      assert results["hashtags"] == [ +               %{"name" => "shpuld", "url" => "#{Web.base_url()}/tag/shpuld"} +             ]      end      test "excludes a blocked users from search results", %{conn: conn} do diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs index 4aa260663..d36bb1ae8 100644 --- a/test/web/mastodon_api/controllers/subscription_controller_test.exs +++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs @@ -58,7 +58,9 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do        result =          conn          |> post("/api/v1/push/subscription", %{ -          "data" => %{"alerts" => %{"mention" => true, "test" => true}}, +          "data" => %{ +            "alerts" => %{"mention" => true, "test" => true, "pleroma:chat_mention" => true} +          },            "subscription" => @sub          })          |> json_response_and_validate_schema(200) @@ -66,7 +68,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do        [subscription] = Pleroma.Repo.all(Subscription)        assert %{ -               "alerts" => %{"mention" => true}, +               "alerts" => %{"mention" => true, "pleroma:chat_mention" => true},                 "endpoint" => subscription.endpoint,                 "id" => to_string(subscription.id),                 "server_key" => @server_key diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index f91333e5c..044f088a4 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -72,6 +72,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do          fields: []        },        pleroma: %{ +        ap_id: user.ap_id,          background_image: "https://example.com/images/asuka_hospital.png",          confirmation_pending: false,          tags: [], @@ -148,6 +149,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do          fields: []        },        pleroma: %{ +        ap_id: user.ap_id,          background_image: nil,          confirmation_pending: false,          tags: [], diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index f15be1df1..b2fa5b302 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -6,7 +6,10 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do    use Pleroma.DataCase    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.CommonAPI @@ -14,6 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do    alias Pleroma.Web.MastodonAPI.AccountView    alias Pleroma.Web.MastodonAPI.NotificationView    alias Pleroma.Web.MastodonAPI.StatusView +  alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView    import Pleroma.Factory    defp test_notifications_rendering(notifications, user, expected_result) do @@ -31,6 +35,30 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do      assert expected_result == result    end +  test "ChatMessage notification" do +    user = insert(:user) +    recipient = insert(:user) +    {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "what's up my dude") + +    {:ok, [notification]} = Notification.create_notifications(activity) + +    object = Object.normalize(activity) +    chat = Chat.get(recipient.id, user.ap_id) + +    cm_ref = MessageReference.for_chat_and_object(chat, object) + +    expected = %{ +      id: to_string(notification.id), +      pleroma: %{is_seen: false}, +      type: "pleroma:chat_mention", +      account: AccountView.render("show.json", %{user: user, for: recipient}), +      chat_message: MessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), +      created_at: Utils.to_masto_date(notification.inserted_at) +    } + +    test_notifications_rendering([notification], recipient, [expected]) +  end +    test "Mention notification" do      user = insert(:user)      mentioned_user = insert(:user) diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 9bcc07b37..00925caad 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -145,7 +145,8 @@ defmodule Pleroma.Web.NodeInfoTest do        "shareable_emoji_packs",        "multifetch",        "pleroma_emoji_reactions", -      "pleroma:api/v1/notifications:include_types_filter" +      "pleroma:api/v1/notifications:include_types_filter", +      "pleroma_chat_messages"      ]      assert MapSet.subset?( diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs new file mode 100644 index 000000000..82e16741d --- /dev/null +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -0,0 +1,336 @@ +# 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.ChatControllerTest do +  use Pleroma.Web.ConnCase, async: true + +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference +  alias Pleroma.Object +  alias Pleroma.User +  alias Pleroma.Web.ActivityPub.ActivityPub +  alias Pleroma.Web.CommonAPI + +  import Pleroma.Factory + +  describe "POST /api/v1/pleroma/chats/:id/messages/:message_id/read" do +    setup do: oauth_access(["write:chats"]) + +    test "it marks one message as read", %{conn: conn, user: user} do +      other_user = insert(:user) + +      {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") +      {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) +      object = Object.normalize(create, false) +      cm_ref = MessageReference.for_chat_and_object(chat, object) + +      assert cm_ref.unread == true + +      result = +        conn +        |> post("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}/read") +        |> json_response_and_validate_schema(200) + +      assert result["unread"] == false + +      cm_ref = MessageReference.for_chat_and_object(chat, object) + +      assert cm_ref.unread == false +    end +  end + +  describe "POST /api/v1/pleroma/chats/:id/read" do +    setup do: oauth_access(["write:chats"]) + +    test "given a `last_read_id`, it marks everything until then as read", %{ +      conn: conn, +      user: user +    } do +      other_user = insert(:user) + +      {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") +      {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) +      object = Object.normalize(create, false) +      cm_ref = MessageReference.for_chat_and_object(chat, object) + +      assert cm_ref.unread == true + +      result = +        conn +        |> put_req_header("content-type", "application/json") +        |> post("/api/v1/pleroma/chats/#{chat.id}/read", %{"last_read_id" => cm_ref.id}) +        |> json_response_and_validate_schema(200) + +      assert result["unread"] == 1 + +      cm_ref = MessageReference.for_chat_and_object(chat, object) + +      assert cm_ref.unread == false +    end +  end + +  describe "POST /api/v1/pleroma/chats/:id/messages" do +    setup do: oauth_access(["write:chats"]) + +    test "it posts a message to the chat", %{conn: conn, user: user} do +      other_user = insert(:user) + +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + +      result = +        conn +        |> put_req_header("content-type", "application/json") +        |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) +        |> json_response_and_validate_schema(200) + +      assert result["content"] == "Hallo!!" +      assert result["chat_id"] == chat.id |> to_string() +    end + +    test "it fails if there is no content", %{conn: conn, user: user} do +      other_user = insert(:user) + +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + +      result = +        conn +        |> put_req_header("content-type", "application/json") +        |> post("/api/v1/pleroma/chats/#{chat.id}/messages") +        |> json_response_and_validate_schema(400) + +      assert result +    end + +    test "it works with an attachment", %{conn: conn, user: user} do +      file = %Plug.Upload{ +        content_type: "image/jpg", +        path: Path.absname("test/fixtures/image.jpg"), +        filename: "an_image.jpg" +      } + +      {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + +      other_user = insert(:user) + +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + +      result = +        conn +        |> put_req_header("content-type", "application/json") +        |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{ +          "media_id" => to_string(upload.id) +        }) +        |> json_response_and_validate_schema(200) + +      assert result["attachment"] +    end +  end + +  describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do +    setup do: oauth_access(["write:chats"]) + +    test "it deletes a message from the chat", %{conn: conn, user: user} do +      recipient = insert(:user) + +      {:ok, message} = +        CommonAPI.post_chat_message(user, recipient, "Hello darkness my old friend") + +      {:ok, other_message} = CommonAPI.post_chat_message(recipient, user, "nico nico ni") + +      object = Object.normalize(message, false) + +      chat = Chat.get(user.id, recipient.ap_id) + +      cm_ref = MessageReference.for_chat_and_object(chat, object) + +      # Deleting your own message removes the message and the reference +      result = +        conn +        |> put_req_header("content-type", "application/json") +        |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}") +        |> json_response_and_validate_schema(200) + +      assert result["id"] == cm_ref.id +      refute MessageReference.get_by_id(cm_ref.id) +      assert %{data: %{"type" => "Tombstone"}} = Object.get_by_id(object.id) + +      # Deleting other people's messages just removes the reference +      object = Object.normalize(other_message, false) +      cm_ref = MessageReference.for_chat_and_object(chat, object) + +      result = +        conn +        |> put_req_header("content-type", "application/json") +        |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}") +        |> json_response_and_validate_schema(200) + +      assert result["id"] == cm_ref.id +      refute MessageReference.get_by_id(cm_ref.id) +      assert Object.get_by_id(object.id) +    end +  end + +  describe "GET /api/v1/pleroma/chats/:id/messages" do +    setup do: oauth_access(["read:chats"]) + +    test "it paginates", %{conn: conn, user: user} do +      recipient = insert(:user) + +      Enum.each(1..30, fn _ -> +        {:ok, _} = CommonAPI.post_chat_message(user, recipient, "hey") +      end) + +      chat = Chat.get(user.id, recipient.ap_id) + +      result = +        conn +        |> get("/api/v1/pleroma/chats/#{chat.id}/messages") +        |> json_response_and_validate_schema(200) + +      assert length(result) == 20 + +      result = +        conn +        |> get("/api/v1/pleroma/chats/#{chat.id}/messages?max_id=#{List.last(result)["id"]}") +        |> json_response_and_validate_schema(200) + +      assert length(result) == 10 +    end + +    test "it returns the messages for a given chat", %{conn: conn, user: user} do +      other_user = insert(:user) +      third_user = insert(:user) + +      {:ok, _} = CommonAPI.post_chat_message(user, other_user, "hey") +      {:ok, _} = CommonAPI.post_chat_message(user, third_user, "hey") +      {:ok, _} = CommonAPI.post_chat_message(user, other_user, "how are you?") +      {:ok, _} = CommonAPI.post_chat_message(other_user, user, "fine, how about you?") + +      chat = Chat.get(user.id, other_user.ap_id) + +      result = +        conn +        |> get("/api/v1/pleroma/chats/#{chat.id}/messages") +        |> json_response_and_validate_schema(200) + +      result +      |> Enum.each(fn message -> +        assert message["chat_id"] == chat.id |> to_string() +      end) + +      assert length(result) == 3 + +      # Trying to get the chat of a different user +      result = +        conn +        |> assign(:user, other_user) +        |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + +      assert result |> json_response(404) +    end +  end + +  describe "POST /api/v1/pleroma/chats/by-account-id/:id" do +    setup do: oauth_access(["write:chats"]) + +    test "it creates or returns a chat", %{conn: conn} do +      other_user = insert(:user) + +      result = +        conn +        |> post("/api/v1/pleroma/chats/by-account-id/#{other_user.id}") +        |> json_response_and_validate_schema(200) + +      assert result["id"] +    end +  end + +  describe "GET /api/v1/pleroma/chats/:id" do +    setup do: oauth_access(["read:chats"]) + +    test "it returns a chat", %{conn: conn, user: user} do +      other_user = insert(:user) + +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + +      result = +        conn +        |> get("/api/v1/pleroma/chats/#{chat.id}") +        |> json_response_and_validate_schema(200) + +      assert result["id"] == to_string(chat.id) +    end +  end + +  describe "GET /api/v1/pleroma/chats" do +    setup do: oauth_access(["read:chats"]) + +    test "it does not return chats with users you blocked", %{conn: conn, user: user} do +      recipient = insert(:user) + +      {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) + +      result = +        conn +        |> get("/api/v1/pleroma/chats") +        |> json_response_and_validate_schema(200) + +      assert length(result) == 1 + +      User.block(user, recipient) + +      result = +        conn +        |> get("/api/v1/pleroma/chats") +        |> json_response_and_validate_schema(200) + +      assert length(result) == 0 +    end + +    test "it returns all chats", %{conn: conn, user: user} do +      Enum.each(1..30, fn _ -> +        recipient = insert(:user) +        {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) +      end) + +      result = +        conn +        |> get("/api/v1/pleroma/chats") +        |> json_response_and_validate_schema(200) + +      assert length(result) == 30 +    end + +    test "it return a list of chats the current user is participating in, in descending order of updates", +         %{conn: conn, user: user} do +      har = insert(:user) +      jafnhar = insert(:user) +      tridi = insert(:user) + +      {:ok, chat_1} = Chat.get_or_create(user.id, har.ap_id) +      :timer.sleep(1000) +      {:ok, _chat_2} = Chat.get_or_create(user.id, jafnhar.ap_id) +      :timer.sleep(1000) +      {:ok, chat_3} = Chat.get_or_create(user.id, tridi.ap_id) +      :timer.sleep(1000) + +      # bump the second one +      {:ok, chat_2} = Chat.bump_or_create(user.id, jafnhar.ap_id) + +      result = +        conn +        |> get("/api/v1/pleroma/chats") +        |> json_response_and_validate_schema(200) + +      ids = Enum.map(result, & &1["id"]) + +      assert ids == [ +               chat_2.id |> to_string(), +               chat_3.id |> to_string(), +               chat_1.id |> to_string() +             ] +    end +  end +end diff --git a/test/web/pleroma_api/views/chat/message_reference_view_test.exs b/test/web/pleroma_api/views/chat/message_reference_view_test.exs new file mode 100644 index 000000000..e5b165255 --- /dev/null +++ b/test/web/pleroma_api/views/chat/message_reference_view_test.exs @@ -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.PleromaAPI.Chat.MessageReferenceViewTest do +  use Pleroma.DataCase + +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference +  alias Pleroma.Object +  alias Pleroma.Web.ActivityPub.ActivityPub +  alias Pleroma.Web.CommonAPI +  alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView + +  import Pleroma.Factory + +  test "it displays a chat message" do +    user = insert(:user) +    recipient = insert(:user) + +    file = %Plug.Upload{ +      content_type: "image/jpg", +      path: Path.absname("test/fixtures/image.jpg"), +      filename: "an_image.jpg" +    } + +    {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) +    {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") + +    chat = Chat.get(user.id, recipient.ap_id) + +    object = Object.normalize(activity) + +    cm_ref = MessageReference.for_chat_and_object(chat, object) + +    chat_message = MessageReferenceView.render("show.json", chat_message_reference: cm_ref) + +    assert chat_message[:id] == cm_ref.id +    assert chat_message[:content] == "kippis :firefox:" +    assert chat_message[:account_id] == user.id +    assert chat_message[:chat_id] +    assert chat_message[:created_at] +    assert chat_message[:unread] == false +    assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) + +    {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) + +    object = Object.normalize(activity) + +    cm_ref = MessageReference.for_chat_and_object(chat, object) + +    chat_message_two = MessageReferenceView.render("show.json", chat_message_reference: cm_ref) + +    assert chat_message_two[:id] == cm_ref.id +    assert chat_message_two[:content] == "gkgkgk" +    assert chat_message_two[:account_id] == recipient.id +    assert chat_message_two[:chat_id] == chat_message[:chat_id] +    assert chat_message_two[:attachment] +    assert chat_message_two[:unread] == true +  end +end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs new file mode 100644 index 000000000..14eecb1bd --- /dev/null +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatViewTest do +  use Pleroma.DataCase + +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference +  alias Pleroma.Object +  alias Pleroma.Web.CommonAPI +  alias Pleroma.Web.CommonAPI.Utils +  alias Pleroma.Web.MastodonAPI.AccountView +  alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView +  alias Pleroma.Web.PleromaAPI.ChatView + +  import Pleroma.Factory + +  test "it represents a chat" do +    user = insert(:user) +    recipient = insert(:user) + +    {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + +    represented_chat = ChatView.render("show.json", chat: chat) + +    assert represented_chat == %{ +             id: "#{chat.id}", +             account: AccountView.render("show.json", user: recipient), +             unread: 0, +             last_message: nil, +             updated_at: Utils.to_masto_date(chat.updated_at) +           } + +    {:ok, chat_message_creation} = CommonAPI.post_chat_message(user, recipient, "hello") + +    chat_message = Object.normalize(chat_message_creation, false) + +    {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + +    represented_chat = ChatView.render("show.json", chat: chat) + +    cm_ref = MessageReference.for_chat_and_object(chat, chat_message) + +    assert represented_chat[:last_message] == +             MessageReferenceView.render("show.json", chat_message_reference: cm_ref) +  end +end diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index a826b24c9..b48952b29 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -5,8 +5,10 @@  defmodule Pleroma.Web.Push.ImplTest do    use Pleroma.DataCase +  alias Pleroma.Notification    alias Pleroma.Object    alias Pleroma.User +  alias Pleroma.Web.ActivityPub.ActivityPub    alias Pleroma.Web.CommonAPI    alias Pleroma.Web.Push.Impl    alias Pleroma.Web.Push.Subscription @@ -60,7 +62,8 @@ defmodule Pleroma.Web.Push.ImplTest do      notif =        insert(:notification,          user: user, -        activity: activity +        activity: activity, +        type: "mention"        )      assert Impl.perform(notif) == {:ok, [:ok, :ok]} @@ -126,7 +129,7 @@ defmodule Pleroma.Web.Push.ImplTest do             ) ==               "@Bob: Lorem ipsum dolor sit amet, consectetur  adipiscing elit. Fusce sagittis fini..." -    assert Impl.format_title(%{activity: activity}) == +    assert Impl.format_title(%{activity: activity, type: "mention"}) ==               "New Mention"    end @@ -136,9 +139,10 @@ defmodule Pleroma.Web.Push.ImplTest do      {:ok, _, _, activity} = CommonAPI.follow(user, other_user)      object = Object.normalize(activity, false) -    assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has followed you" +    assert Impl.format_body(%{activity: activity, type: "follow"}, user, object) == +             "@Bob has followed you" -    assert Impl.format_title(%{activity: activity}) == +    assert Impl.format_title(%{activity: activity, type: "follow"}) ==               "New Follower"    end @@ -157,7 +161,7 @@ defmodule Pleroma.Web.Push.ImplTest do      assert Impl.format_body(%{activity: announce_activity}, user, object) ==               "@#{user.nickname} repeated: Lorem ipsum dolor sit amet, consectetur  adipiscing elit. Fusce sagittis fini..." -    assert Impl.format_title(%{activity: announce_activity}) == +    assert Impl.format_title(%{activity: announce_activity, type: "reblog"}) ==               "New Repeat"    end @@ -173,9 +177,10 @@ defmodule Pleroma.Web.Push.ImplTest do      {:ok, activity} = CommonAPI.favorite(user, activity.id)      object = Object.normalize(activity) -    assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has favorited your post" +    assert Impl.format_body(%{activity: activity, type: "favourite"}, user, object) == +             "@Bob has favorited your post" -    assert Impl.format_title(%{activity: activity}) == +    assert Impl.format_title(%{activity: activity, type: "favourite"}) ==               "New Favorite"    end @@ -193,6 +198,46 @@ defmodule Pleroma.Web.Push.ImplTest do    end    describe "build_content/3" do +    test "builds content for chat messages" do +      user = insert(:user) +      recipient = insert(:user) + +      {:ok, chat} = CommonAPI.post_chat_message(user, recipient, "hey") +      object = Object.normalize(chat, false) +      [notification] = Notification.for_user(recipient) + +      res = Impl.build_content(notification, user, object) + +      assert res == %{ +               body: "@#{user.nickname}: hey", +               title: "New Chat Message" +             } +    end + +    test "builds content for chat messages with no content" do +      user = insert(:user) +      recipient = insert(:user) + +      file = %Plug.Upload{ +        content_type: "image/jpg", +        path: Path.absname("test/fixtures/image.jpg"), +        filename: "an_image.jpg" +      } + +      {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + +      {:ok, chat} = CommonAPI.post_chat_message(user, recipient, nil, media_id: upload.id) +      object = Object.normalize(chat, false) +      [notification] = Notification.for_user(recipient) + +      res = Impl.build_content(notification, user, object) + +      assert res == %{ +               body: "@#{user.nickname}: (Attachment)", +               title: "New Chat Message" +             } +    end +      test "hides details for notifications when privacy option enabled" do        user = insert(:user, nickname: "Bob")        user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true}) @@ -218,7 +263,7 @@ defmodule Pleroma.Web.Push.ImplTest do            status: "<Lorem ipsum dolor sit amet."          }) -      notif = insert(:notification, user: user2, activity: activity) +      notif = insert(:notification, user: user2, activity: activity, type: "mention")        actor = User.get_cached_by_ap_id(notif.activity.data["actor"])        object = Object.normalize(activity) @@ -229,7 +274,7 @@ defmodule Pleroma.Web.Push.ImplTest do        {:ok, activity} = CommonAPI.favorite(user, activity.id) -      notif = insert(:notification, user: user2, activity: activity) +      notif = insert(:notification, user: user2, activity: activity, type: "favourite")        actor = User.get_cached_by_ap_id(notif.activity.data["actor"])        object = Object.normalize(activity) @@ -268,7 +313,7 @@ defmodule Pleroma.Web.Push.ImplTest do              "<span>Lorem ipsum dolor sit amet</span>, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis."          }) -      notif = insert(:notification, user: user2, activity: activity) +      notif = insert(:notification, user: user2, activity: activity, type: "mention")        actor = User.get_cached_by_ap_id(notif.activity.data["actor"])        object = Object.normalize(activity) @@ -281,7 +326,7 @@ defmodule Pleroma.Web.Push.ImplTest do        {:ok, activity} = CommonAPI.favorite(user, activity.id) -      notif = insert(:notification, user: user2, activity: activity) +      notif = insert(:notification, user: user2, activity: activity, type: "favourite")        actor = User.get_cached_by_ap_id(notif.activity.data["actor"])        object = Object.normalize(activity) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 3f012259a..245f6e63f 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -7,11 +7,15 @@ defmodule Pleroma.Web.StreamerTest do    import Pleroma.Factory +  alias Pleroma.Chat +  alias Pleroma.Chat.MessageReference    alias Pleroma.Conversation.Participation    alias Pleroma.List +  alias Pleroma.Object    alias Pleroma.User    alias Pleroma.Web.CommonAPI    alias Pleroma.Web.Streamer +  alias Pleroma.Web.StreamerView    @moduletag needs_streamer: true, capture_log: true @@ -145,6 +149,57 @@ defmodule Pleroma.Web.StreamerTest do        refute Streamer.filtered_by_user?(user, notify)      end +    test "it sends chat messages to the 'user:pleroma_chat' stream", %{user: user} do +      other_user = insert(:user) + +      {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") +      object = Object.normalize(create_activity, false) +      chat = Chat.get(user.id, other_user.ap_id) +      cm_ref = MessageReference.for_chat_and_object(chat, object) +      cm_ref = %{cm_ref | chat: chat, object: object} + +      Streamer.get_topic_and_add_socket("user:pleroma_chat", user) +      Streamer.stream("user:pleroma_chat", {user, cm_ref}) + +      text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + +      assert text =~ "hey cirno" +      assert_receive {:text, ^text} +    end + +    test "it sends chat messages to the 'user' stream", %{user: user} do +      other_user = insert(:user) + +      {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") +      object = Object.normalize(create_activity, false) +      chat = Chat.get(user.id, other_user.ap_id) +      cm_ref = MessageReference.for_chat_and_object(chat, object) +      cm_ref = %{cm_ref | chat: chat, object: object} + +      Streamer.get_topic_and_add_socket("user", user) +      Streamer.stream("user", {user, cm_ref}) + +      text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + +      assert text =~ "hey cirno" +      assert_receive {:text, ^text} +    end + +    test "it sends chat message notifications to the 'user:notification' stream", %{user: user} do +      other_user = insert(:user) + +      {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + +      notify = +        Repo.get_by(Pleroma.Notification, user_id: user.id, activity_id: create_activity.id) +        |> Repo.preload(:activity) + +      Streamer.get_topic_and_add_socket("user:notification", user) +      Streamer.stream("user:notification", notify) +      assert_receive {:render_with_user, _, _, ^notify} +      refute Streamer.filtered_by_user?(user, notify) +    end +      test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{        user: user      } do | 
