Merge branch 'develop' of git.pleroma.social:pleroma/pleroma into remake-remodel-dms
authorlain <lain@soykaf.club>
Fri, 8 May 2020 11:13:37 +0000 (13:13 +0200)
committerlain <lain@soykaf.club>
Fri, 8 May 2020 11:13:37 +0000 (13:13 +0200)
43 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/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/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/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/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/controllers/timeline_controller_test.exs
test/web/mastodon_api/views/account_view_test.exs
test/web/mastodon_api/views/notification_view_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..8d92598
--- /dev/null
@@ -0,0 +1,204 @@
+# Chats
+
+Chats are a way to represent an IM-style conversation between two actors. They are not the same as direct messages and they are not `Status`es, even though they have a lot in common.
+
+## Why Chats?
+
+There are no 'visibility levels' in ActivityPub, their definition is purely a Mastodon convention. Direct Messaging between users on the fediverse has mostly been modeled by using ActivityPub addressing following Mastodon conventions on normal `Note` objects. In this case, a 'direct message' would be a message that has no followers addressed and also does not address the special public actor, but just the recipients in the `to` field. It would still be a `Note` and is presented with other `Note`s as a `Status` in the API.
+
+This is an awkward setup for a few reasons:
+
+- As DMs generally still follow the usual `Status` conventions, it is easy to accidentally pull somebody into a DM thread by mentioning them. (e.g. "I hate @badguy so much")
+- It is possible to go from a publicly addressed `Status` to a DM reply, back to public, then to a 'followers only' reply, and so on. This can be become very confusing, as it is unclear which user can see which part of the conversation.
+- The standard `Status` format of implicit addressing also leads to rather ugly results if you try to display the messages as a chat, because all the recipients are always mentioned by name in the message.
+- As direct messages are posted with the same api call (and usually same frontend component) as public messages, accidentally making a public message private or vice versa can happen easily. Client bugs can also lead to this, accidentally making private messages public.
+
+As a measure to improve this situation, the `Conversation` concept and related Pleroma extensions were introduced. While it made it possible to work around a few of the issues, many of the problems remained and it didn't see much adoption because it was too complicated to use correctly. 
+
+## Chats explained
+For this reasons, Chats are a new and different entity, both in the API as well as in ActivityPub. A quick overview:
+
+- Chats are meant to represent an instant message conversation between two actors. For now these are only 1-on-1 conversations, but the other actor can be a group in the future.
+- Chat messages have the ActivityPub type `ChatMessage`. They are not `Note`s. Servers that don't understand them will just drop them.
+- The only addressing allowed in `ChatMessage`s is one single ActivityPub actor in the `to` field.
+- There's always only one Chat between two actors. If you start chatting with someone and later start a 'new' Chat, the old Chat will be continued.
+- `ChatMessage`s are posted with a different api, making it very hard to accidentally send a message to the wrong person.
+- `ChatMessage`s don't show up in the existing timelines.
+- Chats can never go from private to public. They are always private between the two actors.
+
+## Caveats
+
+- Chats are NOT E2E encrypted (yet). Security is still the same as email.
+
+## API
+
+In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. `Group`s will later be supported by making them a sub-type of `Account`.
+
+This is the overview of using the API. The API is also documented via OpenAPI, so you can view it and play with it by pointing SwaggerUI or a similar OpenAPI tool to `https://yourinstance.tld/api/openapi`.
+
+### Creating or getting a chat.
+
+To create or get an existing Chat for a certain recipient (identified by Account ID)
+you can call:
+
+`POST /api/v1/pleroma/chats/by-account-id/{account_id}`
+
+The account id is the normal FlakeId of the usre
+
+```
+POST /api/v1/pleroma/chats/by-account-id/someflakeid
+```
+
+There will only ever be ONE Chat for you and a given recipient, so this call
+will return the same Chat if you already have one with that user.
+
+Returned data:
+
+```json
+{
+  "account": {
+    "id": "someflakeid",
+    "username": "somenick",
+    ...
+  },
+  "id" : "1",
+  "unread" : 2
+}
+```
+
+### Marking a chat as read
+
+To set the `unread` count of a chat to 0, call
+
+`POST /api/v1/pleroma/chats/:id/read`
+
+Returned data:
+
+```json
+{
+  "account": {
+    "id": "someflakeid",
+    "username": "somenick",
+    ...
+  },
+  "id" : "1",
+  "unread" : 0
+}
+```
+
+
+### Getting a list of Chats
+
+`GET /api/v1/pleroma/chats`
+
+This will return a list of chats that you have been involved in, sorted by their
+last update (so new chats will be at the top).
+
+Returned data:
+
+```json
+[
+   {
+      "account": {
+        "id": "someflakeid",
+        "username": "somenick",
+        ...
+      },
+      "id" : "1",
+      "unread" : 2
+   }
+]
+```
+
+The recipient of messages that are sent to this chat is given by their AP ID.
+The usual pagination options are implemented.
+
+### Getting the messages for a Chat
+
+For a given Chat id, you can get the associated messages with
+
+`GET /api/v1/pleroma/chats/{id}/messages`
+
+This will return all messages, sorted by most recent to least recent. The usual
+pagination options are implemented.
+
+Returned data:
+
+```json
+[
+  {
+    "account_id": "someflakeid",
+    "chat_id": "1",
+    "content": "Check this out :firefox:",
+    "created_at": "2020-04-21T15:11:46.000Z",
+    "emojis": [
+      {
+        "shortcode": "firefox",
+        "static_url": "https://dontbulling.me/emoji/Firefox.gif",
+        "url": "https://dontbulling.me/emoji/Firefox.gif",
+        "visible_in_picker": false
+      }
+    ],
+    "id": "13"
+  },
+  {
+    "account_id": "someflakeid",
+    "chat_id": "1",
+    "content": "Whats' up?",
+    "created_at": "2020-04-21T15:06:45.000Z",
+    "emojis": [],
+    "id": "12"
+  }
+]
+```
+
+### Posting a chat message
+
+Posting a chat message for given Chat id works like this:
+
+`POST /api/v1/pleroma/chats/{id}/messages`
+
+Parameters:
+- content: The text content of the message
+
+Currently, no formatting beyond basic escaping and emoji is implemented, as well as no
+attachments. This will most probably change.
+
+Returned data:
+
+```json
+{
+  "account_id": "someflakeid",
+  "chat_id": "1",
+  "content": "Check this out :firefox:",
+  "created_at": "2020-04-21T15:11:46.000Z",
+  "emojis": [
+    {
+      "shortcode": "firefox",
+      "static_url": "https://dontbulling.me/emoji/Firefox.gif",
+      "url": "https://dontbulling.me/emoji/Firefox.gif",
+      "visible_in_picker": false
+    }
+  ],
+  "id": "13"
+}
+```
+
+### Notifications
+
+There's a new `pleroma:chat_mention` notification, which has this form:
+
+```json
+{
+  "id": "someid",
+  "type": "pleroma:chat_mention",
+  "account": { ... } // User account of the sender,
+  "chat_message": {
+    "chat_id": "1",
+    "id": "10",
+    "content": "Hello",
+    "account_id": "someflakeid"
+  },
+  "created_at": "somedate"
+}
+```
diff --git a/docs/ap_extensions.md b/docs/ap_extensions.md
new file mode 100644 (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..1a092b9
--- /dev/null
@@ -0,0 +1,70 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Chat do
+  use Ecto.Schema
+  import Ecto.Changeset
+
+  alias Pleroma.Repo
+  alias Pleroma.User
+
+  @moduledoc """
+  Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet).
+
+  It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages.
+  """
+
+  schema "chats" do
+    belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
+    field(:recipient, :string)
+    field(:unread, :integer, default: 0, read_after_writes: true)
+
+    timestamps()
+  end
+
+  def creation_cng(struct, params) do
+    struct
+    |> cast(params, [:user_id, :recipient, :unread])
+    |> validate_change(:recipient, fn
+      :recipient, recipient ->
+        case User.get_cached_by_ap_id(recipient) do
+          nil -> [recipient: "must be an existing user"]
+          _ -> []
+        end
+    end)
+    |> validate_required([:user_id, :recipient])
+    |> unique_constraint(:user_id, name: :chats_user_id_recipient_index)
+  end
+
+  def get(user_id, recipient) do
+    __MODULE__
+    |> Repo.get_by(user_id: user_id, recipient: recipient)
+  end
+
+  def get_or_create(user_id, recipient) do
+    %__MODULE__{}
+    |> creation_cng(%{user_id: user_id, recipient: recipient})
+    |> Repo.insert(
+      # Need to set something, otherwise we get nothing back at all
+      on_conflict: [set: [recipient: recipient]],
+      returning: true,
+      conflict_target: [:user_id, :recipient]
+    )
+  end
+
+  def bump_or_create(user_id, recipient) do
+    %__MODULE__{}
+    |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1})
+    |> Repo.insert(
+      on_conflict: [set: [updated_at: NaiveDateTime.utc_now()], inc: [unread: 1]],
+      conflict_target: [:user_id, :recipient]
+    )
+  end
+
+  def mark_as_read(chat) do
+    chat
+    |> change(%{unread: 0})
+    |> Repo.update()
+  end
+end
index c135306caf8cab8ca327a9a951e525a9256e3121..5df3927bd157dcba8e3d9c88b19ada6e7580544c 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 4955243ab3ac884606b8e0e17f9c5a572e2066f0..b4bccedefc62a3927836b137affeccb369540435 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),
@@ -1049,6 +1056,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
@@ -1154,6 +1173,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 922a444a9b7704080afcaeca04bdc1c8be5e981d..2a21a38119628e263582de341943dc6c8e07997d 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 like(User.t(), Object.t()) :: {:ok, map(), keyword()}
   def like(actor, object) do
     with {:ok, data, meta} <- object_action(actor, object) do
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..16ed490
--- /dev/null
@@ -0,0 +1,72 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
+  use Ecto.Schema
+
+  alias Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator
+
+  import Ecto.Changeset
+
+  @primary_key false
+  embedded_schema do
+    field(:type, :string)
+    field(:mediaType, :string)
+    field(:name, :string)
+
+    embeds_many(:url, UrlObjectValidator)
+  end
+
+  def cast_and_validate(data) do
+    data
+    |> cast_data()
+    |> validate_data()
+  end
+
+  def cast_data(data) do
+    %__MODULE__{}
+    |> changeset(data)
+  end
+
+  def changeset(struct, data) do
+    data =
+      data
+      |> fix_media_type()
+      |> fix_url()
+
+    struct
+    |> cast(data, [:type, :mediaType, :name])
+    |> cast_embed(:url, required: true)
+  end
+
+  def fix_media_type(data) do
+    data
+    |> Map.put_new("mediaType", data["mimeType"])
+  end
+
+  def fix_url(data) do
+    case data["url"] do
+      url when is_binary(url) ->
+        data
+        |> Map.put(
+          "url",
+          [
+            %{
+              "href" => url,
+              "type" => "Link",
+              "mediaType" => data["mediaType"]
+            }
+          ]
+        )
+
+      _ ->
+        data
+    end
+  end
+
+  def validate_data(cng) do
+    cng
+    |> validate_required([:mediaType, :url, :type])
+  end
+end
diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex
new file mode 100644 (file)
index 0000000..e40c80a
--- /dev/null
@@ -0,0 +1,102 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do
+  use Ecto.Schema
+
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator
+  alias Pleroma.Web.ActivityPub.ObjectValidators.Types
+
+  import Ecto.Changeset
+  import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1]
+
+  @primary_key false
+  @derive Jason.Encoder
+
+  embedded_schema do
+    field(:id, Types.ObjectID, primary_key: true)
+    field(:to, Types.Recipients, default: [])
+    field(:type, :string)
+    field(:content, Types.SafeText)
+    field(:actor, Types.ObjectID)
+    field(:published, Types.DateTime)
+    field(:emoji, :map, default: %{})
+
+    embeds_one(:attachment, AttachmentValidator)
+  end
+
+  def cast_and_apply(data) do
+    data
+    |> cast_data
+    |> apply_action(:insert)
+  end
+
+  def cast_and_validate(data) do
+    data
+    |> cast_data()
+    |> validate_data()
+  end
+
+  def cast_data(data) do
+    %__MODULE__{}
+    |> changeset(data)
+  end
+
+  def fix(data) do
+    data
+    |> fix_emoji()
+    |> Map.put_new("actor", data["attributedTo"])
+  end
+
+  def changeset(struct, data) do
+    data = fix(data)
+
+    struct
+    |> cast(data, List.delete(__schema__(:fields), :attachment))
+    |> cast_embed(:attachment)
+  end
+
+  def validate_data(data_cng) do
+    data_cng
+    |> validate_inclusion(:type, ["ChatMessage"])
+    |> validate_required([:id, :actor, :to, :type, :content, :published])
+    |> validate_length(:to, is: 1)
+    |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit]))
+    |> validate_local_concern()
+  end
+
+  @doc """
+  Validates the following
+  - If both users are in our system
+  - If at least one of the users in this ChatMessage is a local user
+  - If the recipient is not blocking the actor
+  """
+  def validate_local_concern(cng) do
+    with actor_ap <- get_field(cng, :actor),
+         {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)},
+         {_, %User{} = recipient} <-
+           {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())},
+         {_, false} <- {:blocking_actor?, User.blocks?(recipient, actor)},
+         {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do
+      cng
+    else
+      {:blocking_actor?, true} ->
+        cng
+        |> add_error(:actor, "actor is blocked by recipient")
+
+      {:local?, false} ->
+        cng
+        |> add_error(:actor, "actor and recipient are both remote")
+
+      {:find_actor, _} ->
+        cng
+        |> add_error(:actor, "can't find user")
+
+      {:find_recipient, _} ->
+        cng
+        |> add_error(:to, "can't find user")
+    end
+  end
+end
diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex
new file mode 100644 (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 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..28b5194322cb775170b352a7771a8cbaa2399126 100644 (file)
@@ -5,12 +5,14 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   liked object, a `Follow` activity will add the user to the follower
   collection, and so on.
   """
+  alias Pleroma.Chat
   alias Pleroma.Activity
   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,27 @@ 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
+          Chat.bump_or_create(user.id, other_user.ap_id)
+        end
+      end)
+
+      {:ok, object, meta}
+    end
+  end
+
+  # Nothing to do
+  def handle_object_creation(object) do
+    {:ok, object}
+  end
+
   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 be7b57f13bb20ad9abe2f2c29b2b131a734cf4cc..29f668cadbb0d6098738e4b56dc8c00500162d36 100644 (file)
@@ -656,6 +656,16 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     |> handle_incoming(options)
   end
 
+  def handle_incoming(
+        %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data,
+        _options
+      ) do
+    with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
+         {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
+      {:ok, activity}
+    end
+  end
+
   def handle_incoming(%{"type" => type} = data, _options) when type in ["Like", "EmojiReact"] do
     with :ok <- ObjectValidator.fetch_actor_and_object(data),
          {:ok, activity, _meta} <-
diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex
new file mode 100644 (file)
index 0000000..8b9dc2e
--- /dev/null
@@ -0,0 +1,248 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.ChatOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Chat
+  alias Pleroma.Web.ApiSpec.Schemas.ChatMessage
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  @spec open_api_operation(atom) :: Operation.t()
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def mark_as_read_operation do
+    %Operation{
+      tags: ["chat"],
+      summary: "Mark all messages in the chat as read",
+      operationId: "ChatController.mark_as_read",
+      parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")],
+      responses: %{
+        200 =>
+          Operation.response(
+            "The updated chat",
+            "application/json",
+            Chat
+          )
+      },
+      security: [
+        %{
+          "oAuth" => ["write"]
+        }
+      ]
+    }
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["chat"],
+      summary: "Create a chat",
+      operationId: "ChatController.create",
+      parameters: [
+        Operation.parameter(
+          :id,
+          :path,
+          :string,
+          "The account id of the recipient of this chat",
+          required: true,
+          example: "someflakeid"
+        )
+      ],
+      responses: %{
+        200 =>
+          Operation.response(
+            "The created or existing chat",
+            "application/json",
+            Chat
+          )
+      },
+      security: [
+        %{
+          "oAuth" => ["write"]
+        }
+      ]
+    }
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["chat"],
+      summary: "Get a list of chats that you participated in",
+      operationId: "ChatController.index",
+      parameters: pagination_params(),
+      responses: %{
+        200 => Operation.response("The chats of the user", "application/json", chats_response())
+      },
+      security: [
+        %{
+          "oAuth" => ["read"]
+        }
+      ]
+    }
+  end
+
+  def messages_operation do
+    %Operation{
+      tags: ["chat"],
+      summary: "Get the most recent messages of the chat",
+      operationId: "ChatController.messages",
+      parameters:
+        [Operation.parameter(:id, :path, :string, "The ID of the Chat")] ++
+          pagination_params(),
+      responses: %{
+        200 =>
+          Operation.response(
+            "The messages in the chat",
+            "application/json",
+            chat_messages_response()
+          )
+      },
+      security: [
+        %{
+          "oAuth" => ["read"]
+        }
+      ]
+    }
+  end
+
+  def post_chat_message_operation do
+    %Operation{
+      tags: ["chat"],
+      summary: "Post a message to the chat",
+      operationId: "ChatController.post_chat_message",
+      parameters: [
+        Operation.parameter(:id, :path, :string, "The ID of the Chat")
+      ],
+      requestBody: request_body("Parameters", chat_message_create(), required: true),
+      responses: %{
+        200 =>
+          Operation.response(
+            "The newly created ChatMessage",
+            "application/json",
+            ChatMessage
+          )
+      },
+      security: [
+        %{
+          "oAuth" => ["write"]
+        }
+      ]
+    }
+  end
+
+  def chats_response do
+    %Schema{
+      title: "ChatsResponse",
+      description: "Response schema for multiple Chats",
+      type: :array,
+      items: Chat,
+      example: [
+        %{
+          "account" => %{
+            "pleroma" => %{
+              "is_admin" => false,
+              "confirmation_pending" => false,
+              "hide_followers_count" => false,
+              "is_moderator" => false,
+              "hide_favorites" => true,
+              "ap_id" => "https://dontbulling.me/users/lain",
+              "hide_follows_count" => false,
+              "hide_follows" => false,
+              "background_image" => nil,
+              "skip_thread_containment" => false,
+              "hide_followers" => false,
+              "relationship" => %{},
+              "tags" => []
+            },
+            "avatar" =>
+              "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg",
+            "following_count" => 0,
+            "header_static" => "https://originalpatchou.li/images/banner.png",
+            "source" => %{
+              "sensitive" => false,
+              "note" => "lain",
+              "pleroma" => %{
+                "discoverable" => false,
+                "actor_type" => "Person"
+              },
+              "fields" => []
+            },
+            "statuses_count" => 1,
+            "locked" => false,
+            "created_at" => "2020-04-16T13:40:15.000Z",
+            "display_name" => "lain",
+            "fields" => [],
+            "acct" => "lain@dontbulling.me",
+            "id" => "9u6Qw6TAZANpqokMkK",
+            "emojis" => [],
+            "avatar_static" =>
+              "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg",
+            "username" => "lain",
+            "followers_count" => 0,
+            "header" => "https://originalpatchou.li/images/banner.png",
+            "bot" => false,
+            "note" => "lain",
+            "url" => "https://dontbulling.me/users/lain"
+          },
+          "id" => "1",
+          "unread" => 2
+        }
+      ]
+    }
+  end
+
+  def chat_messages_response do
+    %Schema{
+      title: "ChatMessagesResponse",
+      description: "Response schema for multiple ChatMessages",
+      type: :array,
+      items: ChatMessage,
+      example: [
+        %{
+          "emojis" => [
+            %{
+              "static_url" => "https://dontbulling.me/emoji/Firefox.gif",
+              "visible_in_picker" => false,
+              "shortcode" => "firefox",
+              "url" => "https://dontbulling.me/emoji/Firefox.gif"
+            }
+          ],
+          "created_at" => "2020-04-21T15:11:46.000Z",
+          "content" => "Check this out :firefox:",
+          "id" => "13",
+          "chat_id" => "1",
+          "actor_id" => "someflakeid"
+        },
+        %{
+          "actor_id" => "someflakeid",
+          "content" => "Whats' up?",
+          "id" => "12",
+          "chat_id" => "1",
+          "emojis" => [],
+          "created_at" => "2020-04-21T15:06:45.000Z"
+        }
+      ]
+    }
+  end
+
+  def chat_message_create do
+    %Schema{
+      title: "ChatMessageCreateRequest",
+      description: "POST body for creating an chat message",
+      type: :object,
+      properties: %{
+        content: %Schema{type: :string, description: "The content of your message"},
+        media_id: %Schema{type: :string, description: "The id of an upload"}
+      },
+      required: [:content],
+      example: %{
+        "content" => "Hey wanna buy feet pics?"
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex
new file mode 100644 (file)
index 0000000..4d385d6
--- /dev/null
@@ -0,0 +1,70 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Chat do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "Chat",
+    description: "Response schema for a Chat",
+    type: :object,
+    properties: %{
+      id: %Schema{type: :string, nullable: false},
+      account: %Schema{type: :object, nullable: false},
+      unread: %Schema{type: :integer, nullable: false}
+    },
+    example: %{
+      "account" => %{
+        "pleroma" => %{
+          "is_admin" => false,
+          "confirmation_pending" => false,
+          "hide_followers_count" => false,
+          "is_moderator" => false,
+          "hide_favorites" => true,
+          "ap_id" => "https://dontbulling.me/users/lain",
+          "hide_follows_count" => false,
+          "hide_follows" => false,
+          "background_image" => nil,
+          "skip_thread_containment" => false,
+          "hide_followers" => false,
+          "relationship" => %{},
+          "tags" => []
+        },
+        "avatar" =>
+          "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg",
+        "following_count" => 0,
+        "header_static" => "https://originalpatchou.li/images/banner.png",
+        "source" => %{
+          "sensitive" => false,
+          "note" => "lain",
+          "pleroma" => %{
+            "discoverable" => false,
+            "actor_type" => "Person"
+          },
+          "fields" => []
+        },
+        "statuses_count" => 1,
+        "locked" => false,
+        "created_at" => "2020-04-16T13:40:15.000Z",
+        "display_name" => "lain",
+        "fields" => [],
+        "acct" => "lain@dontbulling.me",
+        "id" => "9u6Qw6TAZANpqokMkK",
+        "emojis" => [],
+        "avatar_static" =>
+          "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg",
+        "username" => "lain",
+        "followers_count" => 0,
+        "header" => "https://originalpatchou.li/images/banner.png",
+        "bot" => false,
+        "note" => "lain",
+        "url" => "https://dontbulling.me/users/lain"
+      },
+      "id" => "1",
+      "unread" => 2
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex
new file mode 100644 (file)
index 0000000..89e062d
--- /dev/null
@@ -0,0 +1,40 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "ChatMessage",
+    description: "Response schema for a ChatMessage",
+    type: :object,
+    properties: %{
+      id: %Schema{type: :string},
+      account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"},
+      chat_id: %Schema{type: :string},
+      content: %Schema{type: :string},
+      created_at: %Schema{type: :string, format: :"date-time"},
+      emojis: %Schema{type: :array},
+      attachment: %Schema{type: :object, nullable: true}
+    },
+    example: %{
+      "account_id" => "someflakeid",
+      "chat_id" => "1",
+      "content" => "hey you again",
+      "created_at" => "2020-04-21T15:06:45.000Z",
+      "emojis" => [
+        %{
+          "static_url" => "https://dontbulling.me/emoji/Firefox.gif",
+          "visible_in_picker" => false,
+          "shortcode" => "firefox",
+          "url" => "https://dontbulling.me/emoji/Firefox.gif"
+        }
+      ],
+      "id" => "14",
+      "attachment" => nil
+    }
+  })
+end
index c538a634f2e66c7419619e6d7bdd307a9063a763..ad2096c16135e99b1054b795eaf6fd1e68b47a6f 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,36 @@ defmodule Pleroma.Web.CommonAPI do
   require Pleroma.Constants
   require Logger
 
+  def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do
+    with :ok <- validate_chat_content_length(content),
+         maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]),
+         {_, {:ok, chat_message_data, _meta}} <-
+           {:build_object,
+            Builder.chat_message(
+              user,
+              recipient.ap_id,
+              content |> Formatter.html_escape("text/plain"),
+              attachment: maybe_attachment
+            )},
+         {_, {:ok, create_activity_data, _meta}} <-
+           {:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])},
+         {_, {:ok, %Activity{} = activity, _meta}} <-
+           {:common_pipeline,
+            Pipeline.common_pipeline(create_activity_data,
+              local: true
+            )} do
+      {:ok, activity}
+    end
+  end
+
+  defp validate_chat_content_length(content) do
+    if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do
+      :ok
+    else
+      {:error, :content_too_long}
+    end
+  end
+
   def unblock(blocker, blocked) do
     with %Activity{} = block <- Utils.fetch_latest_block(blocker, blocked),
          {:ok, unblock_data, _} <- Builder.undo(blocker, block),
index 793f2e7f8e6356e3dab3e9725c505b78fa1e5652..47fd6a523f10a8242f18e1188573a42c88079d0a 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 420bd586f9d4a26582a7741fac0f29b6a1ad8e4d..057f4f4579226bed52da17f841609cb78803e3b0 100644 (file)
@@ -234,6 +234,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 4da1ab67f58385e2548c1e923fb742f09afc5f7b..2a99518317007c6bd52cc1e5a6dedaf149a8b3b3 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)
@@ -81,7 +83,21 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
       end
     end
 
-    mastodon_type = Activity.mastodon_notification_type(activity)
+    # This returns the notification type by activity, but both chats and statuses
+    # are in "Create" activities.
+    mastodon_type =
+      case Activity.mastodon_notification_type(activity) do
+        "mention" ->
+          object = Object.normalize(activity)
+
+          case object do
+            %{data: %{"type" => "ChatMessage"}} -> "pleroma:chat_mention"
+            _ -> "mention"
+          end
+
+        type ->
+          type
+      end
 
     render_opts = %{
       relationships: opts[:relationships],
@@ -122,6 +138,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
           |> put_status(parent_activity_fn.(), reading_user, render_opts)
           |> put_emoji(activity)
 
+        "pleroma:chat_mention" ->
+          put_chat_message(response, activity, reading_user, render_opts)
+
         type when type in ["follow", "follow_request"] ->
           response
 
@@ -137,6 +156,16 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
     Map.put(response, :emoji, activity.data["content"])
   end
 
+  defp put_chat_message(response, activity, reading_user, opts) do
+    object = Object.normalize(activity)
+    author = User.get_cached_by_ap_id(object.data["actor"])
+    chat = Pleroma.Chat.get(reading_user.id, author.ap_id)
+    render_opts = Map.merge(opts, %{object: object, for: reading_user, chat: chat})
+    chat_message_render = ChatMessageView.render("show.json", render_opts)
+
+    Map.put(response, :chat_message, chat_message_render)
+  end
+
   defp put_status(response, activity, reading_user, opts) do
     status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user})
     status_render = StatusView.render("show.json", status_render_opts)
diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex
new file mode 100644 (file)
index 0000000..450d853
--- /dev/null
@@ -0,0 +1,120 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+defmodule Pleroma.Web.PleromaAPI.ChatController do
+  use Pleroma.Web, :controller
+
+  alias Pleroma.Chat
+  alias Pleroma.Object
+  alias Pleroma.Pagination
+  alias Pleroma.Plugs.OAuthScopesPlug
+  alias Pleroma.Repo
+  alias Pleroma.User
+  alias Pleroma.Web.CommonAPI
+  alias Pleroma.Web.PleromaAPI.ChatMessageView
+  alias Pleroma.Web.PleromaAPI.ChatView
+
+  import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1]
+
+  import Ecto.Query
+
+  # TODO
+  # - Error handling
+
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create, :mark_as_read]
+  )
+
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["read:statuses"]} when action in [:messages, :index]
+  )
+
+  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError)
+
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation
+
+  def post_chat_message(
+        %{body_params: %{content: content} = params, assigns: %{user: %{id: user_id} = user}} =
+          conn,
+        %{
+          id: id
+        }
+      ) do
+    with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id),
+         %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient),
+         {:ok, activity} <-
+           CommonAPI.post_chat_message(user, recipient, content, media_id: params[:media_id]),
+         message <- Object.normalize(activity) do
+      conn
+      |> put_view(ChatMessageView)
+      |> render("show.json", for: user, object: message, chat: chat)
+    end
+  end
+
+  def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do
+    with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id),
+         {:ok, chat} <- Chat.mark_as_read(chat) do
+      conn
+      |> put_view(ChatView)
+      |> render("show.json", chat: chat)
+    end
+  end
+
+  def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do
+    with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do
+      messages =
+        from(o in Object,
+          where: fragment("?->>'type' = ?", o.data, "ChatMessage"),
+          where:
+            fragment(
+              """
+              (?->>'actor' = ? and ?->'to' = ?) 
+              OR (?->>'actor' = ? and ?->'to' = ?) 
+              """,
+              o.data,
+              ^user.ap_id,
+              o.data,
+              ^[chat.recipient],
+              o.data,
+              ^chat.recipient,
+              o.data,
+              ^[user.ap_id]
+            )
+        )
+        |> Pagination.fetch_paginated(params |> stringify_keys())
+
+      conn
+      |> put_view(ChatMessageView)
+      |> render("index.json", for: user, objects: messages, chat: chat)
+    else
+      _ ->
+        conn
+        |> put_status(:not_found)
+        |> json(%{error: "not found"})
+    end
+  end
+
+  def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do
+    chats =
+      from(c in Chat,
+        where: c.user_id == ^user_id,
+        order_by: [desc: c.updated_at]
+      )
+      |> Pagination.fetch_paginated(params |> stringify_keys)
+
+    conn
+    |> put_view(ChatView)
+    |> render("index.json", chats: chats)
+  end
+
+  def create(%{assigns: %{user: user}} = conn, params) do
+    with %User{ap_id: recipient} <- User.get_by_id(params[:id]),
+         {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do
+      conn
+      |> put_view(ChatView)
+      |> render("show.json", chat: chat)
+    end
+  end
+end
diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex
new file mode 100644 (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..bc3af5e
--- /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.PleromaAPI.ChatView do
+  use Pleroma.Web, :view
+
+  alias Pleroma.Chat
+  alias Pleroma.User
+  alias Pleroma.Web.MastodonAPI.AccountView
+
+  def render("show.json", %{chat: %Chat{} = chat} = opts) do
+    recipient = User.get_cached_by_ap_id(chat.recipient)
+
+    %{
+      id: chat.id |> to_string(),
+      account: AccountView.render("show.json", Map.put(opts, :user, recipient)),
+      unread: chat.unread
+    }
+  end
+
+  def render("index.json", %{chats: chats}) do
+    render_many(chats, __MODULE__, "show.json")
+  end
+end
index 7a171f9fbf6462091e132aafe8fa4ca2dd76803e..4b264c43ec1bea39a6b6f2297925457e7f39d3fe 100644 (file)
@@ -302,6 +302,16 @@ defmodule Pleroma.Web.Router do
   end
 
   scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
+    scope [] do
+      pipe_through(:authenticated_api)
+
+      post("/chats/by-account-id/:id", ChatController, :create)
+      get("/chats", ChatController, :index)
+      get("/chats/:id/messages", ChatController, :messages)
+      post("/chats/:id/messages", ChatController, :post_chat_message)
+      post("/chats/:id/read", ChatController, :mark_as_read)
+    end
+
     scope [] do
       pipe_through(:authenticated_api)
 
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..943e481
--- /dev/null
@@ -0,0 +1,64 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ChatTest do
+  use Pleroma.DataCase, async: true
+
+  alias Pleroma.Chat
+
+  import Pleroma.Factory
+
+  describe "creation and getting" do
+    test "it only works if the recipient is a valid user (for now)" do
+      user = insert(:user)
+
+      assert {:error, _chat} = Chat.bump_or_create(user.id, "http://some/nonexisting/account")
+      assert {:error, _chat} = Chat.get_or_create(user.id, "http://some/nonexisting/account")
+    end
+
+    test "it creates a chat for a user and recipient" do
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id)
+
+      assert chat.id
+    end
+
+    test "it returns and bumps a chat for a user and recipient if it already exists" do
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id)
+      {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id)
+
+      assert chat.id == chat_two.id
+      assert chat_two.unread == 2
+    end
+
+    test "it returns a chat for a user and recipient if it already exists" do
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
+      {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id)
+
+      assert chat.id == chat_two.id
+    end
+
+    test "a returning chat will have an updated `update_at` field and an incremented unread count" do
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id)
+      assert chat.unread == 1
+      :timer.sleep(1500)
+      {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id)
+      assert chat_two.unread == 2
+
+      assert chat.id == chat_two.id
+      assert chat.updated_at != chat_two.updated_at
+    end
+  end
+end
diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json
new file mode 100644 (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 f382adf3e7e38ecdbae370507d4bb9da46ffb9fc..6164d176dfe457002af2169c582e32600f8c3dd3 100644 (file)
@@ -2,14 +2,160 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
   use Pleroma.DataCase
 
   alias Pleroma.Object
+  alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.ActivityPub.Builder
   alias Pleroma.Web.ActivityPub.ObjectValidator
+  alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.CommonAPI
 
   import Pleroma.Factory
 
+  describe "attachments" do
+    test "it turns mastodon attachments into our attachments" do
+      attachment = %{
+        "url" =>
+          "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg",
+        "type" => "Document",
+        "name" => nil,
+        "mediaType" => "image/jpeg"
+      }
+
+      {:ok, attachment} =
+        AttachmentValidator.cast_and_validate(attachment)
+        |> Ecto.Changeset.apply_action(:insert)
+
+      assert [
+               %{
+                 href:
+                   "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg",
+                 type: "Link",
+                 mediaType: "image/jpeg"
+               }
+             ] = attachment.url
+    end
+  end
+
+  describe "chat message create activities" do
+    test "it is invalid if the object already exists" do
+      user = insert(:user)
+      recipient = insert(:user)
+      {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey")
+      object = Object.normalize(activity, false)
+
+      {:ok, create_data, _} = Builder.create(user, object.data, [recipient.ap_id])
+
+      {:error, cng} = ObjectValidator.validate(create_data, [])
+
+      assert {:object, {"The object to create already exists", []}} in cng.errors
+    end
+
+    test "it is invalid if the object data has a different `to` or `actor` field" do
+      user = insert(:user)
+      recipient = insert(:user)
+      {:ok, object_data, _} = Builder.chat_message(recipient, user.ap_id, "Hey")
+
+      {:ok, create_data, _} = Builder.create(user, object_data, [recipient.ap_id])
+
+      {:error, cng} = ObjectValidator.validate(create_data, [])
+
+      assert {:to, {"Recipients don't match with object recipients", []}} in cng.errors
+      assert {:actor, {"Actor doesn't match with object actor", []}} in cng.errors
+    end
+  end
+
+  describe "chat messages" do
+    setup do
+      clear_config([:instance, :remote_limit])
+      user = insert(:user)
+      recipient = insert(:user, local: false)
+
+      {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey :firefox:")
+
+      %{user: user, recipient: recipient, valid_chat_message: valid_chat_message}
+    end
+
+    test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do
+      assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, [])
+
+      assert Map.put(valid_chat_message, "attachment", nil) == object
+    end
+
+    test "validates for a basic object with an attachment", %{
+      valid_chat_message: valid_chat_message,
+      user: user
+    } do
+      file = %Plug.Upload{
+        content_type: "image/jpg",
+        path: Path.absname("test/fixtures/image.jpg"),
+        filename: "an_image.jpg"
+      }
+
+      {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id)
+
+      valid_chat_message =
+        valid_chat_message
+        |> Map.put("attachment", attachment.data)
+
+      assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, [])
+
+      assert object["attachment"]
+    end
+
+    test "does not validate if the message is longer than the remote_limit", %{
+      valid_chat_message: valid_chat_message
+    } do
+      Pleroma.Config.put([:instance, :remote_limit], 2)
+      refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, []))
+    end
+
+    test "does not validate if the recipient is blocking the actor", %{
+      valid_chat_message: valid_chat_message,
+      user: user,
+      recipient: recipient
+    } do
+      Pleroma.User.block(recipient, user)
+      refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, []))
+    end
+
+    test "does not validate if the actor or the recipient is not in our system", %{
+      valid_chat_message: valid_chat_message
+    } do
+      chat_message =
+        valid_chat_message
+        |> Map.put("actor", "https://raymoo.com/raymoo")
+
+      {:error, _} = ObjectValidator.validate(chat_message, [])
+
+      chat_message =
+        valid_chat_message
+        |> Map.put("to", ["https://raymoo.com/raymoo"])
+
+      {:error, _} = ObjectValidator.validate(chat_message, [])
+    end
+
+    test "does not validate for a message with multiple recipients", %{
+      valid_chat_message: valid_chat_message,
+      user: user,
+      recipient: recipient
+    } do
+      chat_message =
+        valid_chat_message
+        |> Map.put("to", [user.ap_id, recipient.ap_id])
+
+      assert {:error, _} = ObjectValidator.validate(chat_message, [])
+    end
+
+    test "does not validate if it doesn't concern local users" do
+      user = insert(:user, local: false)
+      recipient = insert(:user, local: false)
+
+      {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey")
+      assert {:error, _} = ObjectValidator.validate(valid_chat_message, [])
+    end
+  end
+
   describe "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 b29a7a7be8dd4a3f9617d16b10d4b92cbd2749ab..b618d33225120c1094ba2b9749a091863a89a974 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
@@ -223,4 +224,69 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
       assert Repo.get_by(Notification, user_id: poster.id, activity_id: like.id)
     end
   end
+
+  describe "creation of ChatMessages" do
+    test "notifies the recipient" do
+      author = insert(:user, local: false)
+      recipient = insert(:user, local: true)
+
+      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey")
+
+      {:ok, create_activity_data, _meta} =
+        Builder.create(author, chat_message_data["id"], [recipient.ap_id])
+
+      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false)
+
+      {:ok, _create_activity, _meta} =
+        SideEffects.handle(create_activity, local: false, object_data: chat_message_data)
+
+      assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id)
+    end
+
+    test "it creates a Chat for the local users and bumps the unread count" do
+      author = insert(:user, local: false)
+      recipient = insert(:user, local: true)
+
+      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey")
+
+      {:ok, create_activity_data, _meta} =
+        Builder.create(author, chat_message_data["id"], [recipient.ap_id])
+
+      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false)
+
+      {:ok, _create_activity, _meta} =
+        SideEffects.handle(create_activity, local: false, object_data: chat_message_data)
+
+      # An object is created
+      assert Object.get_by_ap_id(chat_message_data["id"])
+
+      # The remote user won't get a chat
+      chat = Chat.get(author.id, recipient.ap_id)
+      refute chat
+
+      # The local user will get a chat
+      chat = Chat.get(recipient.id, author.ap_id)
+      assert chat
+
+      author = insert(:user, local: true)
+      recipient = insert(:user, local: true)
+
+      {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey")
+
+      {:ok, create_activity_data, _meta} =
+        Builder.create(author, chat_message_data["id"], [recipient.ap_id])
+
+      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false)
+
+      {:ok, _create_activity, _meta} =
+        SideEffects.handle(create_activity, local: false, object_data: chat_message_data)
+
+      # Both users are local and get the chat
+      chat = Chat.get(author.id, recipient.ap_id)
+      assert chat
+
+      chat = Chat.get(recipient.id, author.ap_id)
+      assert chat
+    end
+  end
 end
diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs
new file mode 100644 (file)
index 0000000..85644d7
--- /dev/null
@@ -0,0 +1,116 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do
+  use Pleroma.DataCase
+
+  import Pleroma.Factory
+
+  alias Pleroma.Activity
+  alias Pleroma.Chat
+  alias Pleroma.Object
+  alias Pleroma.Web.ActivityPub.Transmogrifier
+
+  describe "handle_incoming" do
+    test "it rejects messages that don't contain content" do
+      data =
+        File.read!("test/fixtures/create-chat-message.json")
+        |> Poison.decode!()
+
+      object =
+        data["object"]
+        |> Map.delete("content")
+
+      data =
+        data
+        |> Map.put("object", object)
+
+      _author =
+        insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now())
+
+      _recipient =
+        insert(:user,
+          ap_id: List.first(data["to"]),
+          local: true,
+          last_refreshed_at: DateTime.utc_now()
+        )
+
+      {:error, _} = Transmogrifier.handle_incoming(data)
+    end
+
+    test "it rejects messages that don't concern local users" do
+      data =
+        File.read!("test/fixtures/create-chat-message.json")
+        |> Poison.decode!()
+
+      _author =
+        insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now())
+
+      _recipient =
+        insert(:user,
+          ap_id: List.first(data["to"]),
+          local: false,
+          last_refreshed_at: DateTime.utc_now()
+        )
+
+      {:error, _} = Transmogrifier.handle_incoming(data)
+    end
+
+    test "it rejects messages where the `to` field of activity and object don't match" do
+      data =
+        File.read!("test/fixtures/create-chat-message.json")
+        |> Poison.decode!()
+
+      author = insert(:user, ap_id: data["actor"])
+      _recipient = insert(:user, ap_id: List.first(data["to"]))
+
+      data =
+        data
+        |> Map.put("to", author.ap_id)
+
+      assert match?({:error, _}, Transmogrifier.handle_incoming(data))
+      refute Object.get_by_ap_id(data["object"]["id"])
+    end
+
+    test "it fetches the actor if they aren't in our system" do
+      Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
+
+      data =
+        File.read!("test/fixtures/create-chat-message.json")
+        |> Poison.decode!()
+        |> Map.put("actor", "http://mastodon.example.org/users/admin")
+        |> put_in(["object", "actor"], "http://mastodon.example.org/users/admin")
+
+      _recipient = insert(:user, ap_id: List.first(data["to"]), local: true)
+
+      {:ok, %Activity{} = _activity} = Transmogrifier.handle_incoming(data)
+    end
+
+    test "it inserts it and creates a chat" do
+      data =
+        File.read!("test/fixtures/create-chat-message.json")
+        |> Poison.decode!()
+
+      author =
+        insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now())
+
+      recipient = insert(:user, ap_id: List.first(data["to"]), local: true)
+
+      {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data)
+      assert activity.local == false
+
+      assert activity.actor == author.ap_id
+      assert activity.recipients == [recipient.ap_id, author.ap_id]
+
+      %Object{} = object = Object.get_by_ap_id(activity.data["object"])
+
+      assert object
+      assert object.data["content"] == "You expected a cute girl? Too bad. alert(&#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 2fd17a1b896992d785a47a03079dacc8a99096ad..61affda5dec0c6c18c3d20ca41a30024511a4905 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,55 @@ defmodule Pleroma.Web.CommonAPITest do
   setup do: clear_config([:instance, :limit])
   setup do: clear_config([:instance, :max_pinned_statuses])
 
+  describe "posting chat messages" do
+    setup do: clear_config([:instance, :chat_limit])
+
+    test "it posts a chat message" do
+      author = insert(:user)
+      recipient = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post_chat_message(
+          author,
+          recipient,
+          "a test message <script>alert('uuu')</script> :firefox:"
+        )
+
+      assert activity.data["type"] == "Create"
+      assert activity.local
+      object = Object.normalize(activity)
+
+      assert object.data["type"] == "ChatMessage"
+      assert object.data["to"] == [recipient.ap_id]
+
+      assert object.data["content"] ==
+               "a test message &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)
+    end
+
+    test "it reject messages over the local limit" do
+      Pleroma.Config.put([:instance, :chat_limit], 2)
+
+      author = insert(:user)
+      recipient = insert(:user)
+
+      {:error, message} =
+        CommonAPI.post_chat_message(
+          author,
+          recipient,
+          "123"
+        )
+
+      assert message == :content_too_long
+    end
+  end
+
   describe "deletion" do
     test "it allows users to delete their posts" do
       user = insert(:user)
index 06efdc901aa763ae6dd58a7628b544f5a06c0a43..a5c227991ebb0cf6eba00e30184d3f991946adcd 100644 (file)
@@ -51,6 +51,9 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
       {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"})
       {:ok, _, _} = CommonAPI.repeat(activity.id, following)
 
+      # This one should not show up in the TL
+      {:ok, _activity} = CommonAPI.post_chat_message(third_user, user, ":gun:")
+
       ret_conn = get(conn, uri)
 
       assert Enum.empty?(json_response(ret_conn, :ok))
index 5fb162141ea0685253be1008a8e95b95fc2e1b87..df6b995d387ebced6c12b3bcf069ada8b5235dd9 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: [],
@@ -141,6 +142,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         fields: []
       },
       pleroma: %{
+        ap_id: user.ap_id,
         background_image: nil,
         confirmation_pending: false,
         tags: [],
@@ -339,6 +341,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         fields: []
       },
       pleroma: %{
+        ap_id: user.ap_id,
         background_image: nil,
         confirmation_pending: false,
         tags: [],
index 0806269a265f644d4704828af25c4b4c8f3c5de9..2416772bb672de53edff7086b5732020eb1b34ed 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)
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..b4b73da
--- /dev/null
@@ -0,0 +1,210 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do
+  use Pleroma.Web.ConnCase, async: true
+
+  alias Pleroma.Chat
+  alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.CommonAPI
+
+  import Pleroma.Factory
+
+  describe "POST /api/v1/pleroma/chats/:id/read" do
+    setup do: oauth_access(["write:statuses"])
+
+    test "it marks all messages in a chat as read", %{conn: conn, user: user} do
+      other_user = insert(:user)
+
+      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id)
+
+      assert chat.unread == 1
+
+      result =
+        conn
+        |> post("/api/v1/pleroma/chats/#{chat.id}/read")
+        |> json_response_and_validate_schema(200)
+
+      assert result["unread"] == 0
+
+      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
+
+      assert chat.unread == 0
+    end
+  end
+
+  describe "POST /api/v1/pleroma/chats/:id/messages" do
+    setup do: oauth_access(["write:statuses"])
+
+    test "it posts a message to the chat", %{conn: conn, user: user} do
+      other_user = insert(:user)
+
+      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
+
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"})
+        |> json_response_and_validate_schema(200)
+
+      assert result["content"] == "Hallo!!"
+      assert result["chat_id"] == chat.id |> to_string()
+    end
+
+    test "it works with an attachment", %{conn: conn, user: user} do
+      file = %Plug.Upload{
+        content_type: "image/jpg",
+        path: Path.absname("test/fixtures/image.jpg"),
+        filename: "an_image.jpg"
+      }
+
+      {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id)
+
+      other_user = insert(:user)
+
+      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
+
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{
+          "content" => "Hallo!!",
+          "media_id" => to_string(upload.id)
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert result["content"] == "Hallo!!"
+      assert result["chat_id"] == chat.id |> to_string()
+    end
+  end
+
+  describe "GET /api/v1/pleroma/chats/:id/messages" do
+    setup do: oauth_access(["read:statuses"])
+
+    test "it paginates", %{conn: conn, user: user} do
+      recipient = insert(:user)
+
+      Enum.each(1..30, fn _ ->
+        {:ok, _} = CommonAPI.post_chat_message(user, recipient, "hey")
+      end)
+
+      chat = Chat.get(user.id, recipient.ap_id)
+
+      result =
+        conn
+        |> get("/api/v1/pleroma/chats/#{chat.id}/messages")
+        |> json_response_and_validate_schema(200)
+
+      assert length(result) == 20
+
+      result =
+        conn
+        |> get("/api/v1/pleroma/chats/#{chat.id}/messages?max_id=#{List.last(result)["id"]}")
+        |> json_response_and_validate_schema(200)
+
+      assert length(result) == 10
+    end
+
+    test "it returns the messages for a given chat", %{conn: conn, user: user} do
+      other_user = insert(:user)
+      third_user = insert(:user)
+
+      {:ok, _} = CommonAPI.post_chat_message(user, other_user, "hey")
+      {:ok, _} = CommonAPI.post_chat_message(user, third_user, "hey")
+      {:ok, _} = CommonAPI.post_chat_message(user, other_user, "how are you?")
+      {:ok, _} = CommonAPI.post_chat_message(other_user, user, "fine, how about you?")
+
+      chat = Chat.get(user.id, other_user.ap_id)
+
+      result =
+        conn
+        |> get("/api/v1/pleroma/chats/#{chat.id}/messages")
+        |> json_response_and_validate_schema(200)
+
+      result
+      |> Enum.each(fn message ->
+        assert message["chat_id"] == chat.id |> to_string()
+      end)
+
+      assert length(result) == 3
+
+      # Trying to get the chat of a different user
+      result =
+        conn
+        |> assign(:user, other_user)
+        |> get("/api/v1/pleroma/chats/#{chat.id}/messages")
+
+      assert result |> json_response(404)
+    end
+  end
+
+  describe "POST /api/v1/pleroma/chats/by-account-id/:id" do
+    setup do: oauth_access(["write:statuses"])
+
+    test "it creates or returns a chat", %{conn: conn} do
+      other_user = insert(:user)
+
+      result =
+        conn
+        |> post("/api/v1/pleroma/chats/by-account-id/#{other_user.id}")
+        |> json_response_and_validate_schema(200)
+
+      assert result["id"]
+    end
+  end
+
+  describe "GET /api/v1/pleroma/chats" do
+    setup do: oauth_access(["read:statuses"])
+
+    test "it paginates", %{conn: conn, user: user} do
+      Enum.each(1..30, fn _ ->
+        recipient = insert(:user)
+        {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id)
+      end)
+
+      result =
+        conn
+        |> get("/api/v1/pleroma/chats")
+        |> json_response_and_validate_schema(200)
+
+      assert length(result) == 20
+
+      result =
+        conn
+        |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}")
+        |> json_response_and_validate_schema(200)
+
+      assert length(result) == 10
+    end
+
+    test "it return a list of chats the current user is participating in, in descending order of updates",
+         %{conn: conn, user: user} do
+      har = insert(:user)
+      jafnhar = insert(:user)
+      tridi = insert(:user)
+
+      {:ok, chat_1} = Chat.get_or_create(user.id, har.ap_id)
+      :timer.sleep(1000)
+      {:ok, _chat_2} = Chat.get_or_create(user.id, jafnhar.ap_id)
+      :timer.sleep(1000)
+      {:ok, chat_3} = Chat.get_or_create(user.id, tridi.ap_id)
+      :timer.sleep(1000)
+
+      # bump the second one
+      {:ok, chat_2} = Chat.bump_or_create(user.id, jafnhar.ap_id)
+
+      result =
+        conn
+        |> get("/api/v1/pleroma/chats")
+        |> json_response_and_validate_schema(200)
+
+      ids = Enum.map(result, & &1["id"])
+
+      assert ids == [
+               chat_2.id |> to_string(),
+               chat_3.id |> to_string(),
+               chat_1.id |> to_string()
+             ]
+    end
+  end
+end
diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs
new file mode 100644 (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..1ac3483
--- /dev/null
@@ -0,0 +1,28 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PleromaAPI.ChatViewTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.Chat
+  alias Pleroma.Web.MastodonAPI.AccountView
+  alias Pleroma.Web.PleromaAPI.ChatView
+
+  import Pleroma.Factory
+
+  test "it represents a chat" do
+    user = insert(:user)
+    recipient = insert(:user)
+
+    {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id)
+
+    represented_chat = ChatView.render("show.json", chat: chat)
+
+    assert represented_chat == %{
+             id: "#{chat.id}",
+             account: AccountView.render("show.json", user: recipient),
+             unread: 0
+           }
+  end
+end