diff options
43 files changed, 2227 insertions, 12 deletions
| diff --git a/docs/API/chats.md b/docs/API/chats.md new file mode 100644 index 000000000..8d925989c --- /dev/null +++ b/docs/API/chats.md @@ -0,0 +1,204 @@ +# 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 usre + +``` +POST /api/v1/pleroma/chats/by-account-id/someflakeid +``` + +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 +} +``` + +### Marking a chat as read + +To set the `unread` count of a chat to 0, call + +`POST /api/v1/pleroma/chats/:id/read` + +Returned data: + +```json +{ +  "account": { +    "id": "someflakeid", +    "username": "somenick", +    ... +  }, +  "id" : "1", +  "unread" : 0 +} +``` + + +### 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 +   } +] +``` + +The recipient of messages that are sent to this chat is given by their AP ID. +The usual pagination options are implemented. + +### 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" +  }, +  { +    "account_id": "someflakeid", +    "chat_id": "1", +    "content": "Whats' up?", +    "created_at": "2020-04-21T15:06:45.000Z", +    "emojis": [], +    "id": "12" +  } +] +``` + +### 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 + +Currently, no formatting beyond basic escaping and emoji is implemented, as well as no +attachments. This will most probably change. + +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" +} +``` + +### Notifications + +There's a new `pleroma:chat_mention` notification, which has this form: + +```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" +  }, +  "created_at": "somedate" +} +``` 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/chat.ex b/lib/pleroma/chat.ex new file mode 100644 index 000000000..1a092b992 --- /dev/null +++ b/lib/pleroma/chat.ex @@ -0,0 +1,70 @@ +# 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. +  """ + +  schema "chats" do +    belongs_to(:user, User, type: FlakeId.Ecto.CompatType) +    field(:recipient, :string) +    field(:unread, :integer, default: 0, read_after_writes: true) + +    timestamps() +  end + +  def creation_cng(struct, params) do +    struct +    |> cast(params, [:user_id, :recipient, :unread]) +    |> 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(user_id, recipient) do +    __MODULE__ +    |> Repo.get_by(user_id: user_id, recipient: recipient) +  end + +  def get_or_create(user_id, recipient) do +    %__MODULE__{} +    |> creation_cng(%{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__{} +    |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1}) +    |> Repo.insert( +      on_conflict: [set: [updated_at: NaiveDateTime.utc_now()], inc: [unread: 1]], +      conflict_target: [:user_id, :recipient] +    ) +  end + +  def mark_as_read(chat) do +    chat +    |> change(%{unread: 0}) +    |> Repo.update() +  end +end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 98289af08..a2c870ded 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -284,7 +284,7 @@ defmodule Pleroma.Notification do    end    def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do -    object = Object.normalize(activity) +    object = Object.normalize(activity, false)      if object && object.data["type"] == "Answer" do        {:ok, []} diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 099df5879..697336019 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -126,7 +126,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do    def 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), @@ -1157,6 +1164,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_id(query, %{"exclude_id" => id}) when is_binary(id) do      from(activity in query, where: activity.id != ^id)    end @@ -1262,6 +1281,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      |> restrict_instance(opts)      |> Activity.restrict_deactivated_users()      |> exclude_poll_votes(opts) +    |> exclude_chat_messages(opts)      |> exclude_visibility(opts)    end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 1345a3a3e..6e3a375e7 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.Utils @@ -37,6 +38,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 like(User.t(), Object.t()) :: {:ok, map(), keyword()}    def like(actor, object) do      object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 479f922f5..cc5ca1d9e 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do    alias Pleroma.Object    alias Pleroma.User +  alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.Types @@ -30,23 +32,60 @@ 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 + +  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 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..16ed49051 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.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.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) +    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 +    |> Map.put_new("mediaType", data["mimeType"]) +  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..e40c80ab4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -0,0 +1,102 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.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() +    |> Map.put_new("actor", data["attributedTo"]) +  end + +  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, :content, :published]) +    |> validate_length(:to, is: 1) +    |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) +    |> validate_local_concern() +  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/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..822e8d2c1 --- /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.strip_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/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 7b53abeaf..8bdc433ff 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,10 +5,13 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do    liked object, a `Follow` activity will add the user to the follower    collection, and so on.    """ +  alias Pleroma.Chat    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    def handle(object, meta \\ []) @@ -25,6 +28,19 @@ 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 +      Notification.create_notifications(activity) +      {:ok, activity, meta} +    else +      e -> Repo.rollback(e) +    end +  end +    # Tasks this handles:    # - Delete and unpins the create activity    # - Replace object with Tombstone @@ -72,4 +88,25 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do    def handle(object, meta) 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"])) + +      [[actor, recipient], [recipient, actor]] +      |> Enum.each(fn [user, other_user] -> +        if user.local do +          Chat.bump_or_create(user.id, other_user.ap_id) +        end +      end) + +      {:ok, object, meta} +    end +  end + +  # Nothing to do +  def handle_object_creation(object) do +    {:ok, object} +  end  end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0e4e7261b..55e0df283 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -656,6 +656,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" => "Like"} = data, _options) do      with :ok <- ObjectValidator.fetch_actor_and_object(data),           {:ok, activity, _meta} <- 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..8b9dc2e44 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -0,0 +1,248 @@ +# 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.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")], +      responses: %{ +        200 => +          Operation.response( +            "The updated chat", +            "application/json", +            Chat +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["write"] +        } +      ] +    } +  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"] +        } +      ] +    } +  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"] +        } +      ] +    } +  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"] +        } +      ] +    } +  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(), required: true), +      responses: %{ +        200 => +          Operation.response( +            "The newly created ChatMessage", +            "application/json", +            ChatMessage +          ) +      }, +      security: [ +        %{ +          "oAuth" => ["write"] +        } +      ] +    } +  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" +        }, +        %{ +          "actor_id" => "someflakeid", +          "content" => "Whats' up?", +          "id" => "12", +          "chat_id" => "1", +          "emojis" => [], +          "created_at" => "2020-04-21T15:06:45.000Z" +        } +      ] +    } +  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"}, +        media_id: %Schema{type: :string, description: "The id of an upload"} +      }, +      required: [:content], +      example: %{ +        "content" => "Hey wanna buy feet pics?" +      } +    } +  end +end 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..4d385d6ab --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -0,0 +1,70 @@ +# 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 + +  require OpenApiSpex + +  OpenApiSpex.schema(%{ +    title: "Chat", +    description: "Response schema for a Chat", +    type: :object, +    properties: %{ +      id: %Schema{type: :string, nullable: false}, +      account: %Schema{type: :object, nullable: false}, +      unread: %Schema{type: :integer, nullable: false} +    }, +    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 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..89e062ddd --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do +  alias OpenApiSpex.Schema + +  require OpenApiSpex + +  OpenApiSpex.schema(%{ +    title: "ChatMessage", +    description: "Response schema for a ChatMessage", +    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}, +      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 986e8d3f8..d7d934683 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,36 @@ defmodule Pleroma.Web.CommonAPI do    require Pleroma.Constants    require Logger +  def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do +    with :ok <- validate_chat_content_length(content), +         maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), +         {_, {:ok, chat_message_data, _meta}} <- +           {:build_object, +            Builder.chat_message( +              user, +              recipient.ap_id, +              content |> Formatter.html_escape("text/plain"), +              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 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 follow(follower, followed) do      timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 6540fa5d1..b0b1bd559 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -425,7 +425,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/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index b4b61e74c..c46517e49 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -232,6 +232,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/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 4da1ab67f..2a9951831 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -7,12 +7,14 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do    alias Pleroma.Activity    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.ChatMessageView    def render("index.json", %{notifications: notifications, for: reading_user} = opts) do      activities = Enum.map(notifications, & &1.activity) @@ -81,7 +83,21 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do        end      end -    mastodon_type = Activity.mastodon_notification_type(activity) +    # This returns the notification type by activity, but both chats and statuses +    # are in "Create" activities. +    mastodon_type = +      case Activity.mastodon_notification_type(activity) do +        "mention" -> +          object = Object.normalize(activity) + +          case object do +            %{data: %{"type" => "ChatMessage"}} -> "pleroma:chat_mention" +            _ -> "mention" +          end + +        type -> +          type +      end      render_opts = %{        relationships: opts[:relationships], @@ -122,6 +138,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do            |> put_status(parent_activity_fn.(), reading_user, render_opts)            |> put_emoji(activity) +        "pleroma:chat_mention" -> +          put_chat_message(response, activity, reading_user, render_opts) +          type when type in ["follow", "follow_request"] ->            response @@ -137,6 +156,16 @@ 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) +    render_opts = Map.merge(opts, %{object: object, for: reading_user, chat: chat}) +    chat_message_render = ChatMessageView.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..450d85332 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -0,0 +1,120 @@ +# 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.Chat +  alias Pleroma.Object +  alias Pleroma.Pagination +  alias Pleroma.Plugs.OAuthScopesPlug +  alias Pleroma.Repo +  alias Pleroma.User +  alias Pleroma.Web.CommonAPI +  alias Pleroma.Web.PleromaAPI.ChatMessageView +  alias Pleroma.Web.PleromaAPI.ChatView + +  import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] + +  import Ecto.Query + +  # TODO +  # - Error handling + +  plug( +    OAuthScopesPlug, +    %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create, :mark_as_read] +  ) + +  plug( +    OAuthScopesPlug, +    %{scopes: ["read:statuses"]} when action in [:messages, :index] +  ) + +  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + +  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation + +  def post_chat_message( +        %{body_params: %{content: content} = 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, content, media_id: params[:media_id]), +         message <- Object.normalize(activity) do +      conn +      |> put_view(ChatMessageView) +      |> render("show.json", for: user, object: message, chat: chat) +    end +  end + +  def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do +    with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), +         {:ok, chat} <- Chat.mark_as_read(chat) 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 +      messages = +        from(o in Object, +          where: fragment("?->>'type' = ?", o.data, "ChatMessage"), +          where: +            fragment( +              """ +              (?->>'actor' = ? and ?->'to' = ?)  +              OR (?->>'actor' = ? and ?->'to' = ?)  +              """, +              o.data, +              ^user.ap_id, +              o.data, +              ^[chat.recipient], +              o.data, +              ^chat.recipient, +              o.data, +              ^[user.ap_id] +            ) +        ) +        |> Pagination.fetch_paginated(params |> stringify_keys()) + +      conn +      |> put_view(ChatMessageView) +      |> render("index.json", for: user, objects: messages, chat: chat) +    else +      _ -> +        conn +        |> put_status(:not_found) +        |> json(%{error: "not found"}) +    end +  end + +  def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do +    chats = +      from(c in Chat, +        where: c.user_id == ^user_id, +        order_by: [desc: c.updated_at] +      ) +      |> Pagination.fetch_paginated(params |> stringify_keys) + +    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 +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex new file mode 100644 index 000000000..b088a8734 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageView do +  use Pleroma.Web, :view + +  alias Pleroma.Chat +  alias Pleroma.User +  alias Pleroma.Web.CommonAPI.Utils +  alias Pleroma.Web.MastodonAPI.StatusView + +  def render( +        "show.json", +        %{ +          object: %{id: id, data: %{"type" => "ChatMessage"} = chat_message}, +          chat: %Chat{id: chat_id} +        } +      ) 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"]) +    } +  end + +  def render("index.json", opts) do +    render_many(opts[:objects], __MODULE__, "show.json", Map.put(opts, :as, :object)) +  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..bc3af5ef5 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatView do +  use Pleroma.Web, :view + +  alias Pleroma.Chat +  alias Pleroma.User +  alias Pleroma.Web.MastodonAPI.AccountView + +  def render("show.json", %{chat: %Chat{} = chat} = opts) do +    recipient = User.get_cached_by_ap_id(chat.recipient) + +    %{ +      id: chat.id |> to_string(), +      account: AccountView.render("show.json", Map.put(opts, :user, recipient)), +      unread: chat.unread +    } +  end + +  def render("index.json", %{chats: chats}) do +    render_many(chats, __MODULE__, "show.json") +  end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 281516bb8..6b16cfa5d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -290,6 +290,16 @@ 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/messages", ChatController, :messages) +      post("/chats/:id/messages", ChatController, :post_chat_message) +      post("/chats/:id/read", ChatController, :mark_as_read) +    end + +    scope [] do +      pipe_through(:authenticated_api) +        get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)        get("/conversations/:id", PleromaAPIController, :conversation)        post("/conversations/read", PleromaAPIController, :mark_conversations_as_read) 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/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_test.exs b/test/chat_test.exs new file mode 100644 index 000000000..943e48111 --- /dev/null +++ b/test/chat_test.exs @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.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 +      assert chat_two.unread == 2 +    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 and an incremented unread count" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) +      assert chat.unread == 1 +      :timer.sleep(1500) +      {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) +      assert chat_two.unread == 2 + +      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/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 744c46781..c9eace866 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -2,14 +2,160 @@ 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 "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 +    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 "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 "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 "deletes" 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..59ed0a1fe --- /dev/null +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.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 "errors for non-text" do +    assert :error == SafeText.cast(1) +  end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index a9598d7b3..a631e5c6b 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Chat    alias Pleroma.Notification    alias Pleroma.Object    alias Pleroma.Repo @@ -96,4 +97,69 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do        assert Repo.get_by(Notification, user_id: poster.id, activity_id: like.id)      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 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  end 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..85644d787 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -0,0 +1,116 @@ +# 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 "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/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 62a2665b6..ef7c479c0 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -5,6 +5,7 @@  defmodule Pleroma.Web.CommonAPITest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Chat    alias Pleroma.Conversation.Participation    alias Pleroma.Object    alias Pleroma.User @@ -23,6 +24,55 @@ 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" 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) +    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 "deletion" do      test "it allows users to delete their posts" do        user = insert(:user) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 06efdc901..a5c227991 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -51,6 +51,9 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do        {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"})        {:ok, _, _} = CommonAPI.repeat(activity.id, following) +      # This one should not show up in the TL +      {:ok, _activity} = CommonAPI.post_chat_message(third_user, user, ":gun:") +        ret_conn = get(conn, uri)        assert Enum.empty?(json_response(ret_conn, :ok)) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 85fa4f6a2..9ebb13549 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: [], @@ -141,6 +142,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do          fields: []        },        pleroma: %{ +        ap_id: user.ap_id,          background_image: nil,          confirmation_pending: false,          tags: [], @@ -339,6 +341,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 c3ec9dfec..a48c298f2 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -6,7 +6,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Chat    alias Pleroma.Notification +  alias Pleroma.Object    alias Pleroma.Repo    alias Pleroma.User    alias Pleroma.Web.CommonAPI @@ -14,6 +16,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.ChatMessageView    import Pleroma.Factory    defp test_notifications_rendering(notifications, user, expected_result) do @@ -31,6 +34,29 @@ 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) + +    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: +        ChatMessageView.render("show.json", %{object: object, for: recipient, chat: chat}), +      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/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs new file mode 100644 index 000000000..b4b73da90 --- /dev/null +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -0,0 +1,210 @@ +# 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.Web.ActivityPub.ActivityPub +  alias Pleroma.Web.CommonAPI + +  import Pleroma.Factory + +  describe "POST /api/v1/pleroma/chats/:id/read" do +    setup do: oauth_access(["write:statuses"]) + +    test "it marks all messages in a chat as read", %{conn: conn, user: user} do +      other_user = insert(:user) + +      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + +      assert chat.unread == 1 + +      result = +        conn +        |> post("/api/v1/pleroma/chats/#{chat.id}/read") +        |> json_response_and_validate_schema(200) + +      assert result["unread"] == 0 + +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + +      assert chat.unread == 0 +    end +  end + +  describe "POST /api/v1/pleroma/chats/:id/messages" do +    setup do: oauth_access(["write:statuses"]) + +    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 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", %{ +          "content" => "Hallo!!", +          "media_id" => to_string(upload.id) +        }) +        |> json_response_and_validate_schema(200) + +      assert result["content"] == "Hallo!!" +      assert result["chat_id"] == chat.id |> to_string() +    end +  end + +  describe "GET /api/v1/pleroma/chats/:id/messages" do +    setup do: oauth_access(["read:statuses"]) + +    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:statuses"]) + +    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" do +    setup do: oauth_access(["read:statuses"]) + +    test "it paginates", %{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) == 20 + +      result = +        conn +        |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}") +        |> json_response_and_validate_schema(200) + +      assert length(result) == 10 +    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_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs new file mode 100644 index 000000000..d7a2d10a5 --- /dev/null +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do +  use Pleroma.DataCase + +  alias Pleroma.Chat +  alias Pleroma.Object +  alias Pleroma.Web.ActivityPub.ActivityPub +  alias Pleroma.Web.CommonAPI +  alias Pleroma.Web.PleromaAPI.ChatMessageView + +  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) + +    chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + +    assert chat_message[:id] == object.id |> to_string() +    assert chat_message[:content] == "kippis :firefox:" +    assert chat_message[:account_id] == user.id +    assert chat_message[:chat_id] +    assert chat_message[:created_at] +    assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) + +    {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) + +    object = Object.normalize(activity) + +    chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + +    assert chat_message_two[:id] == object.id |> to_string() +    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] +  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..1ac3483d1 --- /dev/null +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatViewTest do +  use Pleroma.DataCase + +  alias Pleroma.Chat +  alias Pleroma.Web.MastodonAPI.AccountView +  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 +           } +  end +end | 
