Delete attachments when status is deleted
authorRoman Chvanikov <chvanikoff@pm.me>
Sun, 12 Jan 2020 18:48:58 +0000 (18:48 +0000)
committerfeld <feld@feld.me>
Sun, 12 Jan 2020 18:48:58 +0000 (18:48 +0000)
CHANGELOG.md
lib/pleroma/object.ex
lib/pleroma/uploaders/local.ex
lib/pleroma/uploaders/s3.ex
lib/pleroma/uploaders/uploader.ex
test/object_test.exs
test/uploaders/local_test.exs
test/uploaders/s3_test.exs

index 0907fbd5376de9896149523d33cd9780b9d6633a..39734830460c238dd70e685f1fa4cc92760bc7f9 100644 (file)
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - **Breaking**: MDII uploader
 
 ### Changed
+- **Breaking:** attachments are removed along with statuses when there are no other references to it
 - **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
 - **Breaking:** attachment links (`config :pleroma, :instance, no_attachment_links` and `config :pleroma, Pleroma.Upload, link_name`) disabled by default
 - Replaced [pleroma_job_queue](https://git.pleroma.social/pleroma/pleroma_job_queue) and `Pleroma.Web.Federator.RetryQueue` with [Oban](https://github.com/sorentwo/oban) (see [`docs/config.md`](docs/config.md) on migrating customized worker / retry settings)
index eb37b95a6ed88da494ee69adc64bb2e4b7112e23..2452a7389345af649730392804dde6ecf2e13942 100644 (file)
@@ -17,6 +17,8 @@ defmodule Pleroma.Object do
 
   require Logger
 
+  @type t() :: %__MODULE__{}
+
   schema "objects" do
     field(:data, :map)
 
@@ -79,6 +81,20 @@ defmodule Pleroma.Object do
     Repo.one(from(object in Object, where: fragment("(?)->>'id' = ?", object.data, ^ap_id)))
   end
 
+  @doc """
+  Get a single attachment by it's name and href
+  """
+  @spec get_attachment_by_name_and_href(String.t(), String.t()) :: Object.t() | nil
+  def get_attachment_by_name_and_href(name, href) do
+    query =
+      from(o in Object,
+        where: fragment("(?)->>'name' = ?", o.data, ^name),
+        where: fragment("(?)->>'href' = ?", o.data, ^href)
+      )
+
+    Repo.one(query)
+  end
+
   defp warn_on_no_object_preloaded(ap_id) do
     "Object.normalize() called without preloaded object (#{inspect(ap_id)}). Consider preloading the object"
     |> Logger.debug()
@@ -164,6 +180,7 @@ defmodule Pleroma.Object do
 
   def delete(%Object{data: %{"id" => id}} = object) do
     with {:ok, _obj} = swap_object_with_tombstone(object),
+         :ok <- delete_attachments(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
@@ -171,6 +188,77 @@ defmodule Pleroma.Object do
     end
   end
 
+  defp delete_attachments(%{data: %{"attachment" => [_ | _] = attachments, "actor" => actor}}) do
+    hrefs =
+      Enum.flat_map(attachments, fn attachment ->
+        Enum.map(attachment["url"], & &1["href"])
+      end)
+
+    names = Enum.map(attachments, & &1["name"])
+
+    uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
+
+    # find all objects for copies of the attachments, name and actor doesn't matter here
+    delete_ids =
+      from(o in Object,
+        where:
+          fragment(
+            "to_jsonb(array(select jsonb_array_elements((?)#>'{url}') ->> 'href'))::jsonb \\?| (?)",
+            o.data,
+            ^hrefs
+          )
+      )
+      |> Repo.all()
+      # we should delete 1 object for any given attachment, but don't delete files if
+      # there are more than 1 object for it
+      |> Enum.reduce(%{}, fn %{
+                               id: id,
+                               data: %{
+                                 "url" => [%{"href" => href}],
+                                 "actor" => obj_actor,
+                                 "name" => name
+                               }
+                             },
+                             acc ->
+        Map.update(acc, href, %{id: id, count: 1}, fn val ->
+          case obj_actor == actor and name in names do
+            true ->
+              # set id of the actor's object that will be deleted
+              %{val | id: id, count: val.count + 1}
+
+            false ->
+              # another actor's object, just increase count to not delete file
+              %{val | count: val.count + 1}
+          end
+        end)
+      end)
+      |> Enum.map(fn {href, %{id: id, count: count}} ->
+        # only delete files that have single instance
+        with 1 <- count do
+          prefix =
+            case Pleroma.Config.get([Pleroma.Upload, :base_url]) do
+              nil -> "media"
+              _ -> ""
+            end
+
+          base_url = Pleroma.Config.get([__MODULE__, :base_url], Pleroma.Web.base_url())
+
+          file_path = String.trim_leading(href, "#{base_url}/#{prefix}")
+
+          uploader.delete_file(file_path)
+        end
+
+        id
+      end)
+
+    from(o in Object, where: o.id in ^delete_ids)
+    |> Repo.delete_all()
+
+    :ok
+  end
+
+  defp delete_attachments(%{data: _data}), do: :ok
+
   def prune(%Object{data: %{"id" => id}} = object) do
     with {:ok, object} <- Repo.delete(object),
          {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"),
index 36b3c35ecdbbb48b4df021d3bdb7ce1d0d72a27e..2e6fe32921ebe8eb25c6109033f9392c270d23f1 100644 (file)
@@ -5,10 +5,12 @@
 defmodule Pleroma.Uploaders.Local do
   @behaviour Pleroma.Uploaders.Uploader
 
+  @impl true
   def get_file(_) do
     {:ok, {:static_dir, upload_path()}}
   end
 
+  @impl true
   def put_file(upload) do
     {local_path, file} =
       case Enum.reverse(Path.split(upload.path)) do
@@ -33,4 +35,15 @@ defmodule Pleroma.Uploaders.Local do
   def upload_path do
     Pleroma.Config.get!([__MODULE__, :uploads])
   end
+
+  @impl true
+  def delete_file(path) do
+    upload_path()
+    |> Path.join(path)
+    |> File.rm()
+    |> case do
+      :ok -> :ok
+      {:error, posix_error} -> {:error, to_string(posix_error)}
+    end
+  end
 end
index 9876b639806b2d000b277ba21f93d4db0cca3820..feb89cea6431f5e960a418ca6f8244ba9fde6890 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Uploaders.S3 do
 
   # The file name is re-encoded with S3's constraints here to comply with previous
   # links with less strict filenames
+  @impl true
   def get_file(file) do
     config = Config.get([__MODULE__])
     bucket = Keyword.fetch!(config, :bucket)
@@ -35,6 +36,7 @@ defmodule Pleroma.Uploaders.S3 do
       ])}}
   end
 
+  @impl true
   def put_file(%Pleroma.Upload{} = upload) do
     config = Config.get([__MODULE__])
     bucket = Keyword.get(config, :bucket)
@@ -69,6 +71,18 @@ defmodule Pleroma.Uploaders.S3 do
     end
   end
 
+  @impl true
+  def delete_file(file) do
+    [__MODULE__, :bucket]
+    |> Config.get()
+    |> ExAws.S3.delete_object(file)
+    |> ExAws.request()
+    |> case do
+      {:ok, %{status_code: 204}} -> :ok
+      error -> {:error, inspect(error)}
+    end
+  end
+
   @regex Regex.compile!("[^0-9a-zA-Z!.*/'()_-]")
   def strict_encode(name) do
     String.replace(name, @regex, "-")
index c0b22c28a4cf7af16b37767f12b7fb2ff9808a6b..d71e213d28e7f1766507c86ad8b6758ab409a971 100644 (file)
@@ -36,6 +36,8 @@ defmodule Pleroma.Uploaders.Uploader do
   @callback put_file(Pleroma.Upload.t()) ::
               :ok | {:ok, file_spec()} | {:error, String.t()} | :wait_callback
 
+  @callback delete_file(file :: String.t()) :: :ok | {:error, String.t()}
+
   @callback http_callback(Plug.Conn.t(), Map.t()) ::
               {:ok, Plug.Conn.t()}
               | {:ok, Plug.Conn.t(), file_spec()}
@@ -43,7 +45,6 @@ defmodule Pleroma.Uploaders.Uploader do
   @optional_callbacks http_callback: 2
 
   @spec put_file(module(), Pleroma.Upload.t()) :: {:ok, file_spec()} | {:error, String.t()}
-
   def put_file(uploader, upload) do
     case uploader.put_file(upload) do
       :ok -> {:ok, {:file, upload.path}}
index 9247a6d841a06276b23e2bf3104aafd5502ed2f6..b002c2bae96ebf2fb0b5cd4f1778c73f83d824ce 100644 (file)
@@ -71,6 +71,74 @@ defmodule Pleroma.ObjectTest do
     end
   end
 
+  describe "delete attachments" do
+    clear_config([Pleroma.Upload])
+
+    test "in subdirectories" do
+      Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+
+      file = %Plug.Upload{
+        content_type: "image/jpg",
+        path: Path.absname("test/fixtures/image.jpg"),
+        filename: "an_image.jpg"
+      }
+
+      user = insert(:user)
+
+      {:ok, %Object{} = attachment} =
+        Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
+
+      %{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
+        note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
+
+      uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
+
+      path = href |> Path.dirname() |> Path.basename()
+
+      assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
+
+      Object.delete(note)
+
+      assert Object.get_by_id(attachment.id) == nil
+
+      assert {:ok, []} == File.ls("#{uploads_dir}/#{path}")
+    end
+
+    test "with dedupe enabled" do
+      Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+      Pleroma.Config.put([Pleroma.Upload, :filters], [Pleroma.Upload.Filter.Dedupe])
+
+      uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
+
+      File.mkdir_p!(uploads_dir)
+
+      file = %Plug.Upload{
+        content_type: "image/jpg",
+        path: Path.absname("test/fixtures/image.jpg"),
+        filename: "an_image.jpg"
+      }
+
+      user = insert(:user)
+
+      {:ok, %Object{} = attachment} =
+        Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
+
+      %{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
+        note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
+
+      filename = Path.basename(href)
+
+      assert {:ok, files} = File.ls(uploads_dir)
+      assert filename in files
+
+      Object.delete(note)
+
+      assert Object.get_by_id(attachment.id) == nil
+      assert {:ok, files} = File.ls(uploads_dir)
+      refute filename in files
+    end
+  end
+
   describe "normalizer" do
     test "fetches unknown objects by default" do
       %Object{} =
index fc442d0f1fe58b53b16ba1ea92f17066ff1b84f1..1963dac232137ff8445a433ffdf340a5e5ecd1eb 100644 (file)
@@ -29,4 +29,25 @@ defmodule Pleroma.Uploaders.LocalTest do
              |> File.exists?()
     end
   end
+
+  describe "delete_file/1" do
+    test "deletes local file" do
+      file_path = "local_upload/files/image.jpg"
+
+      file = %Pleroma.Upload{
+        name: "image.jpg",
+        content_type: "image/jpg",
+        path: file_path,
+        tempfile: Path.absname("test/fixtures/image_tmp.jpg")
+      }
+
+      :ok = Local.put_file(file)
+      local_path = Path.join([Local.upload_path(), file_path])
+      assert File.exists?(local_path)
+
+      Local.delete_file(file_path)
+
+      refute File.exists?(local_path)
+    end
+  end
 end
index 171316340db8ead0811158546a8be01981447215..ab7795c3bea51251c01a0067b0f4ca2787420073 100644 (file)
@@ -79,4 +79,11 @@ defmodule Pleroma.Uploaders.S3Test do
       end
     end
   end
+
+  describe "delete_file/1" do
+    test_with_mock "deletes file", ExAws, request: fn _req -> {:ok, %{status_code: 204}} end do
+      assert :ok = S3.delete_file("image.jpg")
+      assert_called(ExAws.request(:_))
+    end
+  end
 end