Merge remote-tracking branch 'remotes/origin/develop' into feature/object-hashtags...
[akkoma] / lib / pleroma / object.ex
index 52556bf31546a3ff74f02663d6d2a39a6d2261f9..1d756bcd1743af73eb413999cddcfd120f15f62e 100644 (file)
@@ -1,19 +1,22 @@
 # Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Object do
   use Ecto.Schema
 
+  import Ecto.Query
+  import Ecto.Changeset
+
   alias Pleroma.Activity
+  alias Pleroma.Config
+  alias Pleroma.Hashtag
   alias Pleroma.Object
   alias Pleroma.Object.Fetcher
   alias Pleroma.ObjectTombstone
   alias Pleroma.Repo
   alias Pleroma.User
-
-  import Ecto.Query
-  import Ecto.Changeset
+  alias Pleroma.Workers.AttachmentsCleanupWorker
 
   require Logger
 
@@ -21,9 +24,13 @@ defmodule Pleroma.Object do
 
   @derive {Jason.Encoder, only: [:data]}
 
+  @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
   schema "objects" do
     field(:data, :map)
 
+    many_to_many(:hashtags, Hashtag, join_through: "hashtags_objects", on_replace: :delete)
+
     timestamps()
   end
 
@@ -45,7 +52,8 @@ defmodule Pleroma.Object do
   end
 
   def create(data) do
-    Object.change(%Object{}, %{data: data})
+    %Object{}
+    |> Object.change(%{data: data})
     |> Repo.insert()
   end
 
@@ -54,8 +62,37 @@ defmodule Pleroma.Object do
     |> cast(params, [:data])
     |> validate_required([:data])
     |> unique_constraint(:ap_id, name: :objects_unique_apid_index)
+    |> maybe_handle_hashtags_change(struct)
+  end
+
+  defp maybe_handle_hashtags_change(changeset, struct) do
+    with data_hashtags_change = get_change(changeset, :data),
+         true <- hashtags_changed?(struct, data_hashtags_change),
+         {:ok, hashtag_records} <-
+           data_hashtags_change
+           |> object_data_hashtags()
+           |> Hashtag.get_or_create_by_names() do
+      put_assoc(changeset, :hashtags, hashtag_records)
+    else
+      false ->
+        changeset
+
+      {:error, hashtag_changeset} ->
+        failed_hashtag = get_field(hashtag_changeset, :name)
+
+        validate_change(changeset, :data, fn _, _ ->
+          [data: "error referencing hashtag: #{failed_hashtag}"]
+        end)
+    end
+  end
+
+  defp hashtags_changed?(%Object{} = struct, %{"tag" => _} = data) do
+    Enum.sort(embedded_hashtags(struct)) !=
+      Enum.sort(object_data_hashtags(data))
   end
 
+  defp hashtags_changed?(_, _), do: false
+
   def get_by_id(nil), do: nil
   def get_by_id(id), do: Repo.get(Object, id)
 
@@ -138,25 +175,30 @@ defmodule Pleroma.Object do
 
   def normalize(_, _, _), do: nil
 
-  # Owned objects can only be mutated by their owner
-  def authorize_mutation(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}),
-    do: actor == ap_id
+  # Owned objects can only be accessed by their owner
+  def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do
+    if actor == ap_id do
+      :ok
+    else
+      {:error, :forbidden}
+    end
+  end
 
-  # Legacy objects can be mutated by anybody
-  def authorize_mutation(%Object{}, %User{}), do: true
+  # Legacy objects can be accessed by anybody
+  def authorize_access(%Object{}, %User{}), do: :ok
 
+  @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil
   def get_cached_by_ap_id(ap_id) do
     key = "object:#{ap_id}"
 
-    Cachex.fetch!(:object_cache, key, fn _ ->
-      object = get_by_ap_id(ap_id)
-
-      if object do
-        {:commit, object}
-      else
-        {:ignore, object}
-      end
-    end)
+    with {:ok, nil} <- @cachex.get(:object_cache, key),
+         object when not is_nil(object) <- get_by_ap_id(ap_id),
+         {:ok, true} <- @cachex.put(:object_cache, key, object) do
+      object
+    else
+      {:ok, object} -> object
+      nil -> nil
+    end
   end
 
   def context_mapping(context) do
@@ -183,29 +225,39 @@ defmodule Pleroma.Object do
   def delete(%Object{data: %{"id" => id}} = object) do
     with {:ok, _obj} = swap_object_with_tombstone(object),
          deleted_activity = Activity.delete_all_by_object_ap_id(id),
-         {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"),
-         {:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do
-      with true <- Pleroma.Config.get([:instance, :cleanup_attachments]) do
-        {:ok, _} =
-          Pleroma.Workers.AttachmentsCleanupWorker.enqueue("cleanup_attachments", %{
-            "object" => object
-          })
-      end
+         {:ok, _} <- invalid_object_cache(object) do
+      cleanup_attachments(
+        Config.get([:instance, :cleanup_attachments]),
+        %{"object" => object}
+      )
 
       {:ok, object, deleted_activity}
     end
   end
 
-  def prune(%Object{data: %{"id" => id}} = object) do
+  @spec cleanup_attachments(boolean(), %{required(:object) => map()}) ::
+          {:ok, Oban.Job.t() | nil}
+  def cleanup_attachments(true, %{"object" => _} = params) do
+    AttachmentsCleanupWorker.enqueue("cleanup_attachments", params)
+  end
+
+  def cleanup_attachments(_, _), do: {:ok, nil}
+
+  def prune(%Object{data: %{"id" => _id}} = object) do
     with {:ok, object} <- Repo.delete(object),
-         {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"),
-         {:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do
+         {:ok, _} <- invalid_object_cache(object) do
       {:ok, object}
     end
   end
 
+  def invalid_object_cache(%Object{data: %{"id" => id}}) do
+    with {:ok, true} <- @cachex.del(:object_cache, "object:#{id}") do
+      @cachex.del(:web_resp_cache, URI.parse(id).path)
+    end
+  end
+
   def set_cache(%Object{data: %{"id" => ap_id}} = object) do
-    Cachex.put(:object_cache, "object:#{ap_id}", object)
+    @cachex.put(:object_cache, "object:#{ap_id}", object)
     {:ok, object}
   end
 
@@ -238,6 +290,10 @@ defmodule Pleroma.Object do
     end
   end
 
+  defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true
+
+  defp poll_is_multiple?(_), do: false
+
   def decrease_replies_count(ap_id) do
     Object
     |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
@@ -261,13 +317,13 @@ defmodule Pleroma.Object do
     end
   end
 
-  def increase_vote_count(ap_id, name) do
+  def increase_vote_count(ap_id, name, actor) do
     with %Object{} = object <- Object.normalize(ap_id),
          "Question" <- object.data["type"] do
-      multiple = Map.has_key?(object.data, "anyOf")
+      key = if poll_is_multiple?(object), do: "anyOf", else: "oneOf"
 
       options =
-        (object.data["anyOf"] || object.data["oneOf"] || [])
+        object.data[key]
         |> Enum.map(fn
           %{"name" => ^name} = option ->
             Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1))
@@ -276,12 +332,12 @@ defmodule Pleroma.Object do
             option
         end)
 
+      voters = [actor | object.data["voters"] || []] |> Enum.uniq()
+
       data =
-        if multiple do
-          Map.put(object.data, "anyOf", options)
-        else
-          Map.put(object.data, "oneOf", options)
-        end
+        object.data
+        |> Map.put(key, options)
+        |> Map.put("voters", voters)
 
       object
       |> Object.change(%{data: data})
@@ -301,4 +357,45 @@ defmodule Pleroma.Object do
   def local?(%Object{data: %{"id" => id}}) do
     String.starts_with?(id, Pleroma.Web.base_url() <> "/")
   end
+
+  def replies(object, opts \\ []) do
+    object = Object.normalize(object)
+
+    query =
+      Object
+      |> where(
+        [o],
+        fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
+      )
+      |> order_by([o], asc: o.id)
+
+    if opts[:self_only] do
+      actor = object.data["actor"]
+      where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
+    else
+      query
+    end
+  end
+
+  def self_replies(object, opts \\ []),
+    do: replies(object, Keyword.put(opts, :self_only, true))
+
+  def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
+
+  def tags(_), do: []
+
+  def hashtags(object), do: embedded_hashtags(object)
+
+  defp embedded_hashtags(%Object{data: data}) do
+    object_data_hashtags(data)
+  end
+
+  defp embedded_hashtags(_), do: []
+
+  defp object_data_hashtags(%{"tag" => tags}) when is_list(tags) do
+    # Note: AS2 map-type elements are ignored
+    Enum.filter(tags, &is_bitstring(&1))
+  end
+
+  defp object_data_hashtags(_), do: []
 end