Merge branch 'develop' of git.pleroma.social:pleroma/pleroma into remake-remodel-dms
authorlain <lain@soykaf.club>
Thu, 21 May 2020 13:35:13 +0000 (15:35 +0200)
committerlain <lain@soykaf.club>
Thu, 21 May 2020 13:35:13 +0000 (15:35 +0200)
47 files changed:
docs/API/chats.md [new file with mode: 0644]
docs/ap_extensions.md [new file with mode: 0644]
lib/pleroma/chat.ex [new file with mode: 0644]
lib/pleroma/notification.ex
lib/pleroma/upload.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/builder.ex
lib/pleroma/web/activity_pub/object_validator.ex
lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex [moved from lib/pleroma/web/activity_pub/object_validators/create_validator.ex with 100% similarity]
lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
lib/pleroma/web/activity_pub/object_validators/types/recipients.ex
lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/side_effects.ex
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/api_spec/operations/chat_operation.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/schemas/chat.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/schemas/chat_message.ex [new file with mode: 0644]
lib/pleroma/web/common_api/common_api.ex
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/mastodon_api/views/account_view.ex
lib/pleroma/web/mastodon_api/views/instance_view.ex
lib/pleroma/web/mastodon_api/views/notification_view.ex
lib/pleroma/web/pleroma_api/controllers/chat_controller.ex [new file with mode: 0644]
lib/pleroma/web/pleroma_api/views/chat_message_view.ex [new file with mode: 0644]
lib/pleroma/web/pleroma_api/views/chat_view.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
priv/repo/migrations/20200309123730_create_chats.exs [new file with mode: 0644]
priv/static/schemas/litepub-0.1.jsonld
test/chat_test.exs [new file with mode: 0644]
test/fixtures/create-chat-message.json [new file with mode: 0644]
test/upload_test.exs
test/web/activity_pub/object_validator_test.exs
test/web/activity_pub/object_validators/types/object_id_test.exs
test/web/activity_pub/object_validators/types/safe_text_test.exs [new file with mode: 0644]
test/web/activity_pub/side_effects_test.exs
test/web/activity_pub/transmogrifier/chat_message_test.exs [new file with mode: 0644]
test/web/common_api/common_api_test.exs
test/web/mastodon_api/views/account_view_test.exs
test/web/mastodon_api/views/notification_view_test.exs
test/web/node_info_test.exs
test/web/pleroma_api/controllers/chat_controller_test.exs [new file with mode: 0644]
test/web/pleroma_api/views/chat_message_view_test.exs [new file with mode: 0644]
test/web/pleroma_api/views/chat_view_test.exs [new file with mode: 0644]

diff --git a/docs/API/chats.md b/docs/API/chats.md
new file mode 100644 (file)
index 0000000..2e415e4
--- /dev/null
@@ -0,0 +1,222 @@
+# 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 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,
+  "updated_at": "2020-04-21T15:11:46.000Z"
+}
+```
+
+
+### 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.
+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. 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"
+}
+```
+
+### 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:
+
+```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 (file)
index 0000000..c4550a1
--- /dev/null
@@ -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 (file)
index 0000000..4c92a58
--- /dev/null
@@ -0,0 +1,105 @@
+# 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
+  import Ecto.Query
+
+  alias Pleroma.Object
+  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 last_message_for_chat(chat) do
+    messages_for_chat_query(chat)
+    |> order_by(desc: :id)
+    |> limit(1)
+    |> Repo.one()
+  end
+
+  def messages_for_chat_query(chat) do
+    chat =
+      chat
+      |> Repo.preload(:user)
+
+    from(o in Object,
+      where: fragment("?->>'type' = ?", o.data, "ChatMessage"),
+      where:
+        fragment(
+          """
+          (?->>'actor' = ? and ?->'to' = ?) 
+          OR (?->>'actor' = ? and ?->'to' = ?) 
+          """,
+          o.data,
+          ^chat.user.ap_id,
+          o.data,
+          ^[chat.recipient],
+          o.data,
+          ^chat.recipient,
+          o.data,
+          ^[chat.user.ap_id]
+        )
+    )
+  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
index 8aa9ed2d48f80098909de61aa53a9d41b4dcf7c2..80d3188b0aac46de049d6a1dcbb080c6f39caf95 100644 (file)
@@ -310,7 +310,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, []}
index 1be1a3a5b3e03c371b89549374b8cf9579187e0c..797555bffa324643d7105450f21cbc1090304c39 100644 (file)
@@ -67,6 +67,7 @@ defmodule Pleroma.Upload do
       {:ok,
        %{
          "type" => opts.activity_type,
+         "mediaType" => upload.content_type,
          "url" => [
            %{
              "type" => "Link",
index d752f4f04a800abe57893038ccc209a681d52ca6..db2499b8807f34e16b6f3dc2caad5a5649409d87 100644 (file)
@@ -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),
@@ -1047,6 +1054,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
@@ -1152,6 +1171,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
 
index 4a247ad0ca425834e032d8c01df612c6ea3f5fa4..0107a8baabec82c8ea6916d65e22163a467beb0e 100644 (file)
@@ -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
@@ -62,6 +63,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,
index 549e5e761e7b5f83983861f3291e019b8fc9a4b9..7f1e0171cfc828f1e6f15c8aff3a644cef853744 100644 (file)
@@ -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.EmojiReactValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
@@ -42,8 +44,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
@@ -58,17 +72,42 @@ 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 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 (file)
index 0000000..c4b502c
--- /dev/null
@@ -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 data["mediaType"] == "" do
+      data
+      |> Map.put("mediaType", "application/octet-stream")
+    else
+      data
+    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 (file)
index 0000000..138736f
--- /dev/null
@@ -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 (file)
index 0000000..fc58240
--- /dev/null
@@ -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
index f42c035105444a8b48eefa53389bdff4c3ab7d0a..e5d08eb5c7f0b380fa8733b990db348ed4e27cbc 100644 (file)
@@ -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
index 48fe61e1a9793e289fb98343c32f48c042e503fb..408e0f6ee6e114521a85aabf0f8dabed9cdbafcb 100644 (file)
@@ -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 (file)
index 0000000..822e8d2
--- /dev/null
@@ -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 (file)
index 0000000..47e2311
--- /dev/null
@@ -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
index bfc2ab845d7aac444c7ce8b8a563e768a6f3cfd9..8e64b4615bde849670cebc041827bf9f27c82d49 100644 (file)
@@ -6,11 +6,13 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   collection, and so on.
   """
   alias Pleroma.Activity
+  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 \\ [])
@@ -27,6 +29,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
+
   def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do
     with undone_object <- Activity.get_by_ap_id(undone_object),
          :ok <- handle_undoing(undone_object) do
@@ -94,6 +109,31 @@ 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"]))
+
+      [[actor, recipient], [recipient, actor]]
+      |> Enum.each(fn [user, other_user] ->
+        if user.local do
+          if user.ap_id == actor.ap_id do
+            Chat.get_or_create(user.id, other_user.ap_id)
+          else
+            Chat.bump_or_create(user.id, other_user.ap_id)
+          end
+        end
+      end)
+
+      {: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),
index 80701bb638fcb3228d430664491e30a233b4e54a..afc63d6b7cd7c95ca06b5dbe8f7d3246e3cdfb90 100644 (file)
@@ -662,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"] do
     with :ok <- ObjectValidator.fetch_actor_and_object(data),
          {:ok, activity, _meta} <-
@@ -1123,6 +1133,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 (file)
index 0000000..a1c5db5
--- /dev/null
@@ -0,0 +1,309 @@
+# 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")],
+      responses: %{
+        200 =>
+          Operation.response(
+            "The updated chat",
+            "application/json",
+            Chat
+          )
+      },
+      security: [
+        %{
+          "oAuth" => ["write"]
+        }
+      ]
+    }
+  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"]
+        }
+      ]
+    }
+  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()),
+      responses: %{
+        200 =>
+          Operation.response(
+            "The newly created ChatMessage",
+            "application/json",
+            ChatMessage
+          ),
+        400 => Operation.response("Bad Request", "application/json", ApiError)
+      },
+      security: [
+        %{
+          "oAuth" => ["write"]
+        }
+      ]
+    }
+  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"]
+        }
+      ]
+    }
+  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. 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
+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 (file)
index 0000000..b4986b7
--- /dev/null
@@ -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 (file)
index 0000000..3ee85aa
--- /dev/null
@@ -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
index 447dbe4e68791a0be6d04a457ec6aa457c5ed1d7..7a2c0a96d5a3d0742f3a725753a9be0c3d6b03eb 100644 (file)
@@ -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,45 @@ 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
+    content |> Formatter.html_escape("text/plain")
+  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),
index e8deee223657ffa788c007757d75ebe6fa8a5b42..ef7a9d967dd613b7c1f3d562d197a8d5de118ab4 100644 (file)
@@ -426,7 +426,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
index 45fffaad2e5901037088ed9a27a4be168ec26d5f..8ff9f39fd357e2fc4436bec00fcf784fc194988a 100644 (file)
@@ -233,6 +233,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,
index 8088306c3bd0c01f6f4d0d4ae725f48067a448a8..98050487a384b11b56b602cf920661525096964f 100644 (file)
@@ -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
index c46ddcf5578f456b30c18cdce99ff3a9b0baccd0..07d55a3e9c18fd6046e7a6824f0f031c7330e0b2 100644 (file)
@@ -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)
@@ -79,7 +81,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
 
     # Note: :relationships contain user mutes (needed for :muted flag in :status)
     status_render_opts = %{relationships: opts[:relationships]}
@@ -117,6 +133,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 +151,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 (file)
index 0000000..210c8ec
--- /dev/null
@@ -0,0 +1,138 @@
+# 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.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 Ecto.Query
+  import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1]
+
+  action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["write:statuses"]}
+    when action in [:post_chat_message, :create, :mark_as_read, :delete_message]
+  )
+
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["read:statuses"]} 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: %{ap_id: actor} = user}} = conn, %{
+        message_id: id
+      }) do
+    with %Object{
+           data: %{
+             "actor" => ^actor,
+             "id" => object,
+             "to" => [recipient],
+             "type" => "ChatMessage"
+           }
+         } = message <- Object.get_by_id(id),
+         %Chat{} = chat <- Chat.get(user.id, recipient),
+         %Activity{} = activity <- Activity.get_create_by_object_ap_id(object),
+         {:ok, _delete} <- CommonAPI.delete(activity.id, user) do
+      conn
+      |> put_view(ChatMessageView)
+      |> render("show.json", for: user, object: message, chat: chat)
+    else
+      _e -> {:error, :could_not_delete}
+    end
+  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) 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 =
+        chat
+        |> Chat.messages_for_chat_query()
+        |> 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} = 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]
+      )
+      |> 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
+
+  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_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex
new file mode 100644 (file)
index 0000000..b088a87
--- /dev/null
@@ -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 (file)
index 0000000..08d5110
--- /dev/null
@@ -0,0 +1,32 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PleromaAPI.ChatView do
+  use Pleroma.Web, :view
+
+  alias Pleroma.Chat
+  alias Pleroma.User
+  alias Pleroma.Web.CommonAPI.Utils
+  alias Pleroma.Web.MastodonAPI.AccountView
+  alias Pleroma.Web.PleromaAPI.ChatMessageView
+
+  def render("show.json", %{chat: %Chat{} = chat} = opts) do
+    recipient = User.get_cached_by_ap_id(chat.recipient)
+
+    last_message = Chat.last_message_for_chat(chat)
+
+    %{
+      id: chat.id |> to_string(),
+      account: AccountView.render("show.json", Map.put(opts, :user, recipient)),
+      unread: chat.unread,
+      last_message:
+        last_message && ChatMessageView.render("show.json", chat: chat, object: 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
index cbe320746f0b37638eb2824c3b6ab6734395bf94..cd742a0324726976e84b08e9c1c0afd369dfc6a8 100644 (file)
@@ -306,6 +306,14 @@ 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)
+
       get("/conversations/:id/statuses", ConversationController, :statuses)
       get("/conversations/:id", ConversationController, :show)
       post("/conversations/read", ConversationController, :mark_as_read)
diff --git a/priv/repo/migrations/20200309123730_create_chats.exs b/priv/repo/migrations/20200309123730_create_chats.exs
new file mode 100644 (file)
index 0000000..715d798
--- /dev/null
@@ -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
index 278ad2f96f6a9eeb7bf86e005893bab6d2491f4b..7cc3fee40875f7d8be2c92896da7acf7e4d76fcd 100644 (file)
@@ -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 (file)
index 0000000..dfcb642
--- /dev/null
@@ -0,0 +1,81 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ChatTest do
+  use Pleroma.DataCase, async: true
+
+  alias Pleroma.Chat
+  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 = Chat.last_message_for_chat(chat)
+
+      assert message.data["content"] == "ho"
+    end
+  end
+
+  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 (file)
index 0000000..9c23a1c
--- /dev/null
@@ -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"
+}
index 060a940bbaaa88e77042b903f1cbd0471cc63def..2abf0edec6b6ee61d7342923eb5749b7bfd57f1a 100644 (file)
@@ -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",
index 96eff1c30e57c6124fec8b0d574488dbe9d91eac..f9990bd2cc29be082ff9367a946a5cdee5224582 100644 (file)
@@ -2,14 +2,250 @@ 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 "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)
index 8342131828b8c76441ba05c44ea0801d64e22b99..c8911948e7379e8f25e3a4c33df0f3e79800aa36 100644 (file)
@@ -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 (file)
index 0000000..59ed0a1
--- /dev/null
@@ -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(&#39;foo&#39;)"} == SafeText.cast(text)
+  end
+
+  test "errors for non-text" do
+    assert :error == SafeText.cast(1)
+  end
+end
index a46254a05b358328ab5852a8d62dfa4962233940..d63264de982ef70fe94cf5f34d11132ad3c7000e 100644 (file)
@@ -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
@@ -289,4 +290,90 @@ 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, 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)
+
+      {:ok, _create_activity, _meta} =
+        SideEffects.handle(create_activity, local: false, object_data: chat_message_data)
+
+      chat = Chat.get(author.id, recipient.ap_id)
+      assert chat.unread == 0
+
+      chat = Chat.get(recipient.id, author.ap_id)
+      assert chat.unread == 1
+    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 (file)
index 0000000..820090d
--- /dev/null
@@ -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 this" 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(&#39;XSS&#39;)"
+      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
index 52e95397cf005107aa613c93069d97b7a686b38b..9e626fb9e688eb9d538e9e88ca9dd49e7da9fb92 100644 (file)
@@ -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,80 @@ 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)
+
+      {:ok, activity} =
+        CommonAPI.post_chat_message(
+          author,
+          recipient,
+          nil,
+          media_id: upload.id
+        )
+
+      assert activity
+    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 &lt;script&gt;alert(&#39;uuu&#39;)&lt;/script&gt; :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)
index 487ec26c2e6894b8ebdc89dece74a815c1ebc4a1..d5d7236a0ffade608d4c54f80cc0aa95d0773f9b 100644 (file)
@@ -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: [],
index 9839e48fc616712356ce57f09be45356509b543d..8db6a237944e25e3260a4df96011c74214464ea0 100644 (file)
@@ -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)
index 9bcc07b37c50f2a240b28b29bc5e47eb363ebbc4..00925caad9b14344ca24b621059137118df2daff 100644 (file)
@@ -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 (file)
index 0000000..d79aa31
--- /dev/null
@@ -0,0 +1,298 @@
+# 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.Object
+  alias Pleroma.User
+  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 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:statuses"])
+
+    test "it deletes a message for the author of the message", %{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)
+
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}")
+        |> json_response_and_validate_schema(200)
+
+      assert result["id"] == to_string(object.id)
+
+      object = Object.normalize(other_message, false)
+
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}")
+        |> json_response(400)
+
+      assert result == %{"error" => "could_not_delete"}
+    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/:id" do
+    setup do: oauth_access(["read:statuses"])
+
+    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:statuses"])
+
+    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 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 (file)
index 0000000..d7a2d10
--- /dev/null
@@ -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 (file)
index 0000000..6062a0c
--- /dev/null
@@ -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.ChatViewTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.Chat
+  alias Pleroma.Object
+  alias Pleroma.Web.CommonAPI
+  alias Pleroma.Web.CommonAPI.Utils
+  alias Pleroma.Web.MastodonAPI.AccountView
+  alias Pleroma.Web.PleromaAPI.ChatMessageView
+  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)
+
+    assert represented_chat[:last_message] ==
+             ChatMessageView.render("show.json", chat: chat, object: chat_message)
+  end
+end