diff options
31 files changed, 1053 insertions, 2 deletions
diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex new file mode 100644 index 000000000..c2044881f --- /dev/null +++ b/lib/pleroma/chat.ex @@ -0,0 +1,63 @@ +# 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.User +  alias Pleroma.Repo + +  @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 a 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( +      on_conflict: :nothing, +      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 +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 86b105b7f..b6ba91052 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -397,6 +397,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      end    end +  # TODO: Is this even used now?    # TODO: This is weird, maybe we shouldn't check here if we can make the activity.    @spec like(User.t(), Object.t(), String.t() | nil, boolean()) ::            {:ok, Activity.t(), Object.t()} | {:error, any()} @@ -1206,6 +1207,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 @@ -1311,6 +1324,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      |> restrict_instance(opts)      |> Activity.restrict_deactivated_users()      |> exclude_poll_votes(opts) +    |> exclude_chat_messages(opts)      |> exclude_visibility(opts)    end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 429a510b8..f0a6c1e1b 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -10,6 +10,28 @@ defmodule Pleroma.Web.ActivityPub.Builder do    alias Pleroma.Web.ActivityPub.Utils    alias Pleroma.Web.ActivityPub.Visibility +  def create(actor, object_id, recipients) do +    {:ok, +     %{ +       "id" => Utils.generate_activity_id(), +       "actor" => actor.ap_id, +       "to" => recipients, +       "object" => object_id, +       "type" => "Create" +     }, []} +  end + +  def chat_message(actor, recipient, content) do +    {:ok, +     %{ +       "id" => Utils.generate_object_id(), +       "actor" => actor.ap_id, +       "type" => "ChatMessage", +       "to" => [recipient], +       "content" => content +     }, []} +  end +    @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()}    def like(actor, object) do      object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index dc4bce059..259bbeb64 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -12,18 +12,48 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do    alias Pleroma.Object    alias Pleroma.User    alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator    @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()}    def validate(object, meta)    def validate(%{"type" => "Like"} = object, meta) do      with {:ok, object} <- -           object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do +           object +           |> LikeValidator.cast_and_validate() +           |> Ecto.Changeset.apply_action(:insert) do        object = stringify_keys(object |> Map.from_struct())        {:ok, object, meta}      end    end +  def validate(%{"type" => "ChatMessage"} = object, meta) do +    with {:ok, object} <- +           object +           |> ChatMessageValidator.cast_and_validate() +           |> Ecto.Changeset.apply_action(:insert) do +      object = stringify_keys(object) +      {:ok, object, meta} +    end +  end + +  def validate(%{"type" => "Create"} = object, meta) do +    with {:ok, object} <- +           object +           |> CreateChatMessageValidator.cast_and_validate() +           |> Ecto.Changeset.apply_action(:insert) do +      object = stringify_keys(object) +      {:ok, object, meta} +    end +  end + +  def stringify_keys(%{__struct__: _} = object) do +    object +    |> Map.from_struct() +    |> stringify_keys +  end +    def stringify_keys(object) do      object      |> Map.new(fn {key, val} -> {to_string(key), val} end) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex new file mode 100644 index 000000000..a4e4460cd --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -0,0 +1,84 @@ +# 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.Web.ActivityPub.ObjectValidators.Types +  alias Pleroma.User + +  import Ecto.Changeset + +  @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, :string) +    field(:actor, Types.ObjectID) +    field(:published, Types.DateTime) +  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 +    |> Map.put_new("actor", data["attributedTo"]) +  end + +  def changeset(struct, data) do +    data = fix(data) + +    struct +    |> cast(data, __schema__(:fields)) +  end + +  def validate_data(data_cng) do +    data_cng +    |> validate_inclusion(:type, ["ChatMessage"]) +    |> validate_required([:id, :actor, :to, :type, :content]) +    |> validate_length(:to, is: 1) +    |> validate_local_concern() +  end + +  @doc "Validates if at least one of the users in this ChatMessage is a local user, otherwise we don't want the message in our system. It also validates the presence of both users in our system." +  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())}, +         {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do +      cng +    else +      {: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/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index b479c3918..02f3a6438 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -8,7 +8,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do    alias Pleroma.Object    alias Pleroma.User -  def validate_actor_presence(cng, field_name \\ :actor) do +  def validate_actor_presence(cng) do +    validate_user_presence(cng, :actor) +  end + +  def validate_user_presence(cng, field_name) do      cng      |> validate_change(field_name, fn field_name, actor ->        if User.get_cached_by_ap_id(actor) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex new file mode 100644 index 000000000..ce52d5623 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +# NOTES +# - Can probably be a generic create validator +# - doesn't embed, will only get the object id +# - object has to be validated first, maybe with some meta info from the surrounding create +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do +  use Ecto.Schema + +  alias Pleroma.Web.ActivityPub.ObjectValidators.Types + +  import Ecto.Changeset + +  @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 + +  # No validation yet +  def cast_and_validate(data) do +    cast_data(data) +  end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex index 926804ce7..926804ce7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex new file mode 100644 index 000000000..5a3040842 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -0,0 +1,23 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do +  use Ecto.Type + +  def type, do: {:array, :string} + +  def cast(object) when is_binary(object) do +    cast([object]) +  end + +  def cast([_ | _] = data), do: {:ok, data} + +  def cast(_) do +    :error +  end + +  def dump(data) do +    {:ok, data} +  end + +  def load(data) do +    {:ok, data} +  end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 666a4e310..594f32700 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,8 +5,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do    liked object, a `Follow` activity will add the user to the follower    collection, and so on.    """ +  alias Pleroma.Chat    alias Pleroma.Notification    alias Pleroma.Object +  alias Pleroma.User    alias Pleroma.Web.ActivityPub.Utils    def handle(object, meta \\ []) @@ -21,8 +23,35 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do      {:ok, object, meta}    end +  def handle(%{data: %{"type" => "Create", "object" => object_id}} = activity, meta) do +    object = Object.get_by_ap_id(object_id) + +    {:ok, _object} = handle_object_creation(object) + +    {:ok, activity, meta} +  end +    # Nothing to do    def handle(object, meta) do      {:ok, object, meta}    end + +  def handle_object_creation(%{data: %{"type" => "ChatMessage"}} = object) 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} +  end + +  # Nothing to do +  def handle_object_creation(object) do +    {:ok, object} +  end  end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 39feae285..ad77a5037 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do    alias Pleroma.Web.ActivityPub.ObjectValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator    alias Pleroma.Web.ActivityPub.Pipeline +  alias Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling    alias Pleroma.Web.ActivityPub.Utils    alias Pleroma.Web.ActivityPub.Visibility    alias Pleroma.Web.Federator @@ -643,6 +644,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do      |> handle_incoming(options)    end +  def handle_incoming( +        %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, +        options +      ), +      do: ChatMessageHandling.handle_incoming(data, options) +    def handle_incoming(%{"type" => "Like"} = data, _options) do      with {_, {:ok, cast_data_sym}} <-             {:casting_data, diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex new file mode 100644 index 000000000..815b866c9 --- /dev/null +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do +  alias Pleroma.Object +  alias Pleroma.Web.ActivityPub.ObjectValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator +  alias Pleroma.Web.ActivityPub.Pipeline + +  def handle_incoming( +        %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data, +        _options +      ) do +    with {_, {:ok, cast_data_sym}} <- +           {:casting_data, data |> CreateChatMessageValidator.cast_and_apply()}, +         cast_data = ObjectValidator.stringify_keys(cast_data_sym), +         {_, {:ok, object_cast_data_sym}} <- +           {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, +         object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), +         {_, {:ok, validated_object, _meta}} <- +           {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, +         {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, +         {_, {:ok, activity, _meta}} <- +           {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do +      {:ok, activity} +    else +      e -> +        {:error, e} +    end +  end +end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex new file mode 100644 index 000000000..038ebb29d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -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.Web.ApiSpec.ChatOperation do +  alias OpenApiSpex.Operation +  alias OpenApiSpex.Schema + +  @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 create_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Create a chat", +      responses: %{ +        200 => +          Operation.response("Chat", "application/json", %Schema{ +            type: :object, +            description: "A created chat is returned", +            properties: %{ +              id: %Schema{type: :integer} +            } +          }) +      } +    } +  end + +  def index_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Get a list of chats that you participated in", +      responses: %{ +        200 => +          Operation.response("Chats", "application/json", %Schema{ +            type: :array, +            description: "A list of chats", +            items: %Schema{ +              type: :object, +              description: "A chat" +            } +          }) +      } +    } +  end + +  def messages_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Get the most recent messages of the chat", +      responses: %{ +        200 => +          Operation.response("Messages", "application/json", %Schema{ +            type: :array, +            description: "A list of chat messages", +            items: %Schema{ +              type: :object, +              description: "A chat message" +            } +          }) +      } +    } +  end + +  def post_chat_message_operation do +    %Operation{ +      tags: ["chat"], +      summary: "Post a message to the chat", +      responses: %{ +        200 => +          Operation.response("Message", "application/json", %Schema{ +            type: :object, +            description: "A chat message" +          }) +      } +    } +  end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c56756a3d..2c25850db 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.CommonAPI do    alias Pleroma.Conversation.Participation    alias Pleroma.FollowingRelationship    alias Pleroma.Object +  alias Pleroma.Repo    alias Pleroma.ThreadMute    alias Pleroma.User    alias Pleroma.UserRelationship @@ -16,6 +17,7 @@ defmodule Pleroma.Web.CommonAPI do    alias Pleroma.Web.ActivityPub.Pipeline    alias Pleroma.Web.ActivityPub.Utils    alias Pleroma.Web.ActivityPub.Visibility +  alias Pleroma.Formatter    import Pleroma.Web.Gettext    import Pleroma.Web.CommonAPI.Utils @@ -23,6 +25,33 @@ defmodule Pleroma.Web.CommonAPI do    require Pleroma.Constants    require Logger +  def post_chat_message(%User{} = user, %User{} = recipient, content) do +    transaction = +      Repo.transaction(fn -> +        with {_, {:ok, chat_message_data, _meta}} <- +               {:build_object, +                Builder.chat_message( +                  user, +                  recipient.ap_id, +                  content |> Formatter.html_escape("text/plain") +                )}, +             {_, {:ok, chat_message_object}} <- +               {:create_object, Object.create(chat_message_data)}, +             {_, {:ok, create_activity_data, _meta}} <- +               {:build_create_activity, +                Builder.create(user, chat_message_object.data["id"], [recipient.ap_id])}, +             {_, {:ok, %Activity{} = activity, _meta}} <- +               {:common_pipeline, Pipeline.common_pipeline(create_activity_data, local: true)} do +          {:ok, activity} +        end +      end) + +    case transaction do +      {:ok, value} -> value +      error -> error +    end +  end +    def follow(follower, followed) do      timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 8fb96a22a..f20453744 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -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, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex new file mode 100644 index 000000000..5ec546847 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -0,0 +1,90 @@ +# 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.Repo +  alias Pleroma.User +  alias Pleroma.Web.CommonAPI +  alias Pleroma.Web.PleromaAPI.ChatView +  alias Pleroma.Web.PleromaAPI.ChatMessageView + +  import Ecto.Query + +  # TODO +  # - Oauth stuff +  # - Views / Representers +  # - Error handling + +  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation + +  def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ +        "id" => id, +        "content" => content +      }) 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), +         message <- Object.normalize(activity) do +      conn +      |> put_view(ChatMessageView) +      |> render("show.json", for: user, object: message, chat: chat) +    end +  end + +  def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) 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] +            ), +          order_by: [desc: o.id] +        ) +        |> Repo.all() + +      conn +      |> put_view(ChatMessageView) +      |> render("index.json", for: user, objects: messages, chat: chat) +    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] +      ) +      |> Repo.all() + +    conn +    |> put_view(ChatView) +    |> render("index.json", chats: chats) +  end + +  def create(%{assigns: %{user: user}} = conn, params) do +    recipient = params["ap_id"] |> URI.decode_www_form() + +    with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do +      conn +      |> put_view(ChatView) +      |> render("show.json", chat: chat) +    end +  end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex new file mode 100644 index 000000000..2df591358 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -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.ChatMessageView do +  use Pleroma.Web, :view + +  alias Pleroma.Chat + +  def render( +        "show.json", +        %{ +          object: %{id: id, data: %{"type" => "ChatMessage"} = chat_message}, +          chat: %Chat{id: chat_id} +        } +      ) do +    %{ +      id: id, +      content: chat_message["content"], +      chat_id: chat_id, +      actor: chat_message["actor"] +    } +  end + +  def render("index.json", opts) do +    render_many(opts[:objects], __MODULE__, "show.json", Map.put(opts, :as, :object)) +  end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex new file mode 100644 index 000000000..ee48385bf --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -0,0 +1,21 @@ +# 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 + +  def render("show.json", %{chat: %Chat{} = chat}) do +    %{ +      id: chat.id, +      recipient: chat.recipient, +      unread: chat.unread +    } +  end + +  def render("index.json", %{chats: chats}) do +    render_many(chats, __MODULE__, "show.json") +  end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5f5ec1c81..b10bf4466 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -287,6 +287,15 @@ defmodule Pleroma.Web.Router do      scope [] do        pipe_through(:authenticated_api) +      post("/chats/by-ap-id/:ap_id", ChatController, :create) +      get("/chats", ChatController, :index) +      get("/chats/:id/messages", ChatController, :messages) +      post("/chats/:id/messages", ChatController, :post_chat_message) +    end + +    scope [] do +      pipe_through(:authenticated_api) +        get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)        get("/conversations/:id", PleromaAPIController, :conversation)        post("/conversations/read", PleromaAPIController, :read_conversations) diff --git a/priv/repo/migrations/20200309123730_create_chats.exs b/priv/repo/migrations/20200309123730_create_chats.exs new file mode 100644 index 000000000..715d798ea --- /dev/null +++ b/priv/repo/migrations/20200309123730_create_chats.exs @@ -0,0 +1,16 @@ +defmodule Pleroma.Repo.Migrations.CreateChats do +  use Ecto.Migration + +  def change do +    create table(:chats) do +      add(:user_id, references(:users, type: :uuid)) +      # Recipient is an ActivityPub id, to future-proof for group support. +      add(:recipient, :string) +      add(:unread, :integer, default: 0) +      timestamps() +    end + +    # There's only one chat between a user and a recipient. +    create(index(:chats, [:user_id, :recipient], unique: true)) +  end +end diff --git a/test/chat_test.exs b/test/chat_test.exs new file mode 100644 index 000000000..952598c87 --- /dev/null +++ b/test/chat_test.exs @@ -0,0 +1,53 @@ +# 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 a chat for a user and recipient if it already exists" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) +      {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) + +      assert chat.id == chat_two.id +    end + +    test "a returning chat will have an updated `update_at` field and an incremented unread count" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) +      assert chat.unread == 1 +      :timer.sleep(1500) +      {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) +      assert chat_two.unread == 2 + +      assert chat.id == chat_two.id +      assert chat.updated_at != chat_two.updated_at +    end +  end +end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json new file mode 100644 index 000000000..2e4608f43 --- /dev/null +++ b/test/fixtures/create-chat-message.json @@ -0,0 +1,19 @@ +{ +    "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" +        ], +        "type": "ChatMessage" +    }, +    "published": "2018-02-12T14:08:20Z", +    "to": [ +        "http://2hu.gensokyo/users/marisa" +    ], +    "type": "Create" +} diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 3c5c3696e..bf0bfdfaf 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -5,9 +5,61 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do    alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator    alias Pleroma.Web.ActivityPub.Utils    alias Pleroma.Web.CommonAPI +  alias Pleroma.Web.ActivityPub.Builder    import Pleroma.Factory +  describe "chat messages" do +    setup do +      user = insert(:user) +      recipient = insert(:user, local: false) + +      {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + +      %{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, []) +    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 "likes" do      setup do        user = insert(:user) diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs new file mode 100644 index 000000000..2f9218774 --- /dev/null +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do +  alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients +  use Pleroma.DataCase + +  test "it works with a list" do +    list = ["https://lain.com/users/lain"] +    assert {:ok, list} == Recipients.cast(list) +  end + +  test "it turns a single string into a list" do +    recipient = "https://lain.com/users/lain" + +    assert {:ok, [recipient]} == Recipients.cast(recipient) +  end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b67bd14b3..b629d0d5d 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -5,6 +5,7 @@  defmodule Pleroma.Web.ActivityPub.SideEffectsTest do    use Pleroma.DataCase +  alias Pleroma.Chat    alias Pleroma.Object    alias Pleroma.Web.ActivityPub.ActivityPub    alias Pleroma.Web.ActivityPub.Builder @@ -31,4 +32,49 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do        assert user.ap_id in object.data["likes"]      end    end + +  describe "creation of ChatMessages" do +    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, chat_message_object} = Object.create(chat_message_data) + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + +      # 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, chat_message_object} = Object.create(chat_message_data) + +      {:ok, create_activity_data, _meta} = +        Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + +      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + +      {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + +      # Both users are local and get the chat +      chat = Chat.get(author.id, recipient.ap_id) +      assert chat + +      chat = Chat.get(recipient.id, author.ap_id) +      assert chat +    end +  end  end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs new file mode 100644 index 000000000..5b238f9c4 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -0,0 +1,62 @@ +# 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.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) +      _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + +      {: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) +      _recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + +      {:error, _} = 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) +      recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + +      {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) + +      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 +    end +  end +end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index b12be973f..abe3e6f8d 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -5,6 +5,7 @@  defmodule Pleroma.Web.CommonAPITest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Chat    alias Pleroma.Conversation.Participation    alias Pleroma.Object    alias Pleroma.User @@ -21,6 +22,33 @@ defmodule Pleroma.Web.CommonAPITest do    setup do: clear_config([:instance, :limit])    setup do: clear_config([:instance, :max_pinned_statuses]) +  describe "posting chat messages" do +    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>" +        ) + +      assert activity.data["type"] == "Create" +      assert activity.local +      object = Object.normalize(activity) + +      assert object.data["type"] == "ChatMessage" +      assert object.data["to"] == [recipient.ap_id] + +      assert object.data["content"] == +               "a test message <script>alert('uuu')</script>" + +      assert Chat.get(author.id, recipient.ap_id) +      assert Chat.get(recipient.id, author.ap_id) +    end +  end +    test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do      user = insert(:user)      {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 06efdc901..a5c227991 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -51,6 +51,9 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do        {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"})        {:ok, _, _} = CommonAPI.repeat(activity.id, following) +      # This one should not show up in the TL +      {:ok, _activity} = CommonAPI.post_chat_message(third_user, user, ":gun:") +        ret_conn = get(conn, uri)        assert Enum.empty?(json_response(ret_conn, :ok)) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 4435f69ff..2be0d8d0f 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -82,6 +82,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: [], @@ -152,6 +153,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do          fields: []        },        pleroma: %{ +        ap_id: user.ap_id,          background_image: nil,          confirmation_pending: false,          tags: [], @@ -351,6 +353,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do          fields: []        },        pleroma: %{ +        ap_id: user.ap_id,          background_image: nil,          confirmation_pending: false,          tags: [], diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs new file mode 100644 index 000000000..dad37a889 --- /dev/null +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -0,0 +1,104 @@ +# 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.CommonAPI + +  import Pleroma.Factory + +  describe "POST /api/v1/pleroma/chats/:id/messages" do +    test "it posts a message to the chat", %{conn: conn} do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + +      result = +        conn +        |> assign(:user, user) +        |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) +        |> json_response(200) + +      assert result["content"] == "Hallo!!" +      assert result["chat_id"] == chat.id +    end +  end + +  describe "GET /api/v1/pleroma/chats/:id/messages" do +    # TODO +    # - Test the case where it's not the user's chat +    test "it returns the messages for a given chat", %{conn: conn} do +      user = insert(:user) +      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 +        |> assign(:user, user) +        |> get("/api/v1/pleroma/chats/#{chat.id}/messages") +        |> json_response(200) + +      result +      |> Enum.each(fn message -> +        assert message["chat_id"] == chat.id +      end) + +      assert length(result) == 3 +    end +  end + +  describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do +    test "it creates or returns a chat", %{conn: conn} do +      user = insert(:user) +      other_user = insert(:user) + +      result = +        conn +        |> assign(:user, user) +        |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") +        |> json_response(200) + +      assert result["id"] +    end +  end + +  describe "GET /api/v1/pleroma/chats" do +    test "it return a list of chats the current user is participating in, in descending order of updates", +         %{conn: conn} do +      user = insert(:user) +      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 +        |> assign(:user, user) +        |> get("/api/v1/pleroma/chats") +        |> json_response(200) + +      ids = Enum.map(result, & &1["id"]) + +      assert ids == [chat_2.id, chat_3.id, chat_1.id] +    end +  end +end diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs new file mode 100644 index 000000000..e690da022 --- /dev/null +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -0,0 +1,42 @@ +# 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.CommonAPI +  alias Pleroma.Web.PleromaAPI.ChatMessageView + +  import Pleroma.Factory + +  test "it displays a chat message" do +    user = insert(:user) +    recipient = insert(:user) +    {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis") + +    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 +    assert chat_message[:content] == "kippis" +    assert chat_message[:actor] == user.ap_id +    assert chat_message[:chat_id] + +    {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk") + +    object = Object.normalize(activity) + +    chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + +    assert chat_message_two[:id] == object.id +    assert chat_message_two[:content] == "gkgkgk" +    assert chat_message_two[:actor] == recipient.ap_id +    assert chat_message_two[:chat_id] == chat_message[:chat_id] +  end +end  | 
