Merge branch 'media-preview-proxy-nostream' into 'develop'
authorfeld <feld@feld.me>
Fri, 18 Sep 2020 18:38:20 +0000 (18:38 +0000)
committerfeld <feld@feld.me>
Fri, 18 Sep 2020 18:38:20 +0000 (18:38 +0000)
Media preview proxy

See merge request pleroma/pleroma!3001

25 files changed:
CHANGELOG.md
config/config.exs
config/description.exs
docs/configuration/cheatsheet.md
lib/pleroma/helpers/media_helper.ex [new file with mode: 0644]
lib/pleroma/helpers/qt_fast_start.ex [new file with mode: 0644]
lib/pleroma/helpers/uri_helper.ex
lib/pleroma/instances/instance.ex
lib/pleroma/reverse_proxy/reverse_proxy.ex
lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
lib/pleroma/web/mastodon_api/views/account_view.ex
lib/pleroma/web/mastodon_api/views/status_view.ex
lib/pleroma/web/media_proxy/invalidation.ex
lib/pleroma/web/media_proxy/media_proxy.ex
lib/pleroma/web/media_proxy/media_proxy_controller.ex
lib/pleroma/web/metadata/utils.ex
lib/pleroma/web/oauth/oauth_controller.ex
lib/pleroma/web/router.ex
mix.lock
test/fixtures/image.gif [new file with mode: 0755]
test/fixtures/image.png [new file with mode: 0755]
test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs
test/web/mastodon_api/views/account_view_test.exs
test/web/media_proxy/media_proxy_controller_test.exs
test/web/media_proxy/media_proxy_test.exs

index 5e4d06c8278e245e805d156f318ae665e1f74871..5a7e27fd3ab3e184a757014e3f09abed73951f69 100644 (file)
@@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - The `discoverable` field in the `User` struct will now add a NOINDEX metatag to profile pages when false.
 - Users with the `discoverable` field set to false will not show up in searches.
 - Minimum lifetime for ephmeral activities changed to 10 minutes and made configurable (`:min_lifetime` option).
+
+### Added
+- Media preview proxy (requires media proxy be enabled; see `:media_preview_proxy` config for more details).
+
 ### Removed
 
 - **Breaking:** `Pleroma.Workers.Cron.StatsWorker` setting from Oban `:crontab` (moved to a simpler implementation).
index 147f09722417dd4d1ff9d08d3e79cf854420dad7..00624bf001a72d9486d1ec165b52ab7b26db139b 100644 (file)
@@ -434,6 +434,8 @@ config :pleroma, :media_proxy,
   proxy_opts: [
     redirect_on_failure: false,
     max_body_length: 25 * 1_048_576,
+    # Note: max_read_duration defaults to Pleroma.ReverseProxy.max_read_duration_default/1
+    max_read_duration: 30_000,
     http: [
       follow_redirect: true,
       pool: :media
@@ -448,6 +450,14 @@ config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http,
 
 config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, script_path: nil
 
+# Note: media preview proxy depends on media proxy to be enabled
+config :pleroma, :media_preview_proxy,
+  enabled: false,
+  thumbnail_max_width: 600,
+  thumbnail_max_height: 600,
+  image_quality: 85,
+  min_content_length: 100 * 1024
+
 config :pleroma, :chat, enabled: true
 
 config :phoenix, :format_encoders, json: Jason
@@ -753,8 +763,8 @@ config :pleroma, :pools,
   ],
   media: [
     size: 50,
-    max_waiting: 10,
-    recv_timeout: 10_000
+    max_waiting: 20,
+    recv_timeout: 15_000
   ],
   upload: [
     size: 25,
index 6f3855918a836c356937bcaad1d080534bcf38b0..097e986332c8c6c1242f9e74c80030d8c8e65f3a 100644 (file)
@@ -1887,6 +1887,7 @@ config :pleroma, :config_description, [
         suggestions: [
           redirect_on_failure: false,
           max_body_length: 25 * 1_048_576,
+          max_read_duration: 30_000,
           http: [
             follow_redirect: true,
             pool: :media
@@ -1907,6 +1908,11 @@ config :pleroma, :config_description, [
               "Limits the content length to be approximately the " <>
                 "specified length. It is validated with the `content-length` header and also verified when proxying."
           },
+          %{
+            key: :max_read_duration,
+            type: :integer,
+            description: "Timeout (in milliseconds) of GET request to remote URI."
+          },
           %{
             key: :http,
             label: "HTTP",
@@ -1953,6 +1959,43 @@ config :pleroma, :config_description, [
       }
     ]
   },
+  %{
+    group: :pleroma,
+    key: :media_preview_proxy,
+    type: :group,
+    description: "Media preview proxy",
+    children: [
+      %{
+        key: :enabled,
+        type: :boolean,
+        description:
+          "Enables proxying of remote media preview to the instance's proxy. Requires enabled media proxy."
+      },
+      %{
+        key: :thumbnail_max_width,
+        type: :integer,
+        description:
+          "Max width of preview thumbnail for images (video preview always has original dimensions)."
+      },
+      %{
+        key: :thumbnail_max_height,
+        type: :integer,
+        description:
+          "Max height of preview thumbnail for images (video preview always has original dimensions)."
+      },
+      %{
+        key: :image_quality,
+        type: :integer,
+        description: "Quality of the output. Ranges from 0 (min quality) to 100 (max quality)."
+      },
+      %{
+        key: :min_content_length,
+        type: :integer,
+        description:
+          "Min content length to perform preview, in bytes. If greater than 0, media smaller in size will be served as is, without thumbnailing."
+      }
+    ]
+  },
   %{
     group: :pleroma,
     key: Pleroma.Web.MediaProxy.Invalidation.Http,
index 9a275294e7876071ea72a65cdc0cc9d22457c34f..42e5fe80886c4b9d687484f83ebc1aa1e7a509e5 100644 (file)
@@ -324,6 +324,14 @@ This section describe PWA manifest instance-specific values. Currently this opti
   * `enabled`: Enables purge cache
   * `provider`: Which one of  the [purge cache strategy](#purge-cache-strategy) to use.
 
+## :media_preview_proxy
+
+* `enabled`: Enables proxying of remote media preview to the instance’s proxy. Requires enabled media proxy (`media_proxy/enabled`).
+* `thumbnail_max_width`: Max width of preview thumbnail for images (video preview always has original dimensions).
+* `thumbnail_max_height`: Max height of preview thumbnail for images (video preview always has original dimensions).
+* `image_quality`: Quality of the output. Ranges from 0 (min quality) to 100 (max quality).
+* `min_content_length`: Min content length to perform preview, in bytes. If greater than 0, media smaller in size will be served as is, without thumbnailing.
+
 ### Purge cache strategy
 
 #### Pleroma.Web.MediaProxy.Invalidation.Script
diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex
new file mode 100644 (file)
index 0000000..b6f35a2
--- /dev/null
@@ -0,0 +1,150 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Helpers.MediaHelper do
+  @moduledoc """
+  Handles common media-related operations.
+  """
+
+  alias Pleroma.HTTP
+
+  def image_resize(url, options) do
+    with executable when is_binary(executable) <- System.find_executable("convert"),
+         {:ok, args} <- prepare_image_resize_args(options),
+         {:ok, env} <- HTTP.get(url, [], pool: :media),
+         {:ok, fifo_path} <- mkfifo() do
+      args = List.flatten([fifo_path, args])
+      run_fifo(fifo_path, env, executable, args)
+    else
+      nil -> {:error, {:convert, :command_not_found}}
+      {:error, _} = error -> error
+    end
+  end
+
+  defp prepare_image_resize_args(
+         %{max_width: max_width, max_height: max_height, format: "png"} = options
+       ) do
+    quality = options[:quality] || 85
+    resize = Enum.join([max_width, "x", max_height, ">"])
+
+    args = [
+      "-resize",
+      resize,
+      "-quality",
+      to_string(quality),
+      "png:-"
+    ]
+
+    {:ok, args}
+  end
+
+  defp prepare_image_resize_args(%{max_width: max_width, max_height: max_height} = options) do
+    quality = options[:quality] || 85
+    resize = Enum.join([max_width, "x", max_height, ">"])
+
+    args = [
+      "-interlace",
+      "Plane",
+      "-resize",
+      resize,
+      "-quality",
+      to_string(quality),
+      "jpg:-"
+    ]
+
+    {:ok, args}
+  end
+
+  defp prepare_image_resize_args(_), do: {:error, :missing_options}
+
+  # Note: video thumbnail is intentionally not resized (always has original dimensions)
+  def video_framegrab(url) do
+    with executable when is_binary(executable) <- System.find_executable("ffmpeg"),
+         {:ok, env} <- HTTP.get(url, [], pool: :media),
+         {:ok, fifo_path} <- mkfifo(),
+         args = [
+           "-y",
+           "-i",
+           fifo_path,
+           "-vframes",
+           "1",
+           "-f",
+           "mjpeg",
+           "-loglevel",
+           "error",
+           "-"
+         ] do
+      run_fifo(fifo_path, env, executable, args)
+    else
+      nil -> {:error, {:ffmpeg, :command_not_found}}
+      {:error, _} = error -> error
+    end
+  end
+
+  defp run_fifo(fifo_path, env, executable, args) do
+    pid =
+      Port.open({:spawn_executable, executable}, [
+        :use_stdio,
+        :stream,
+        :exit_status,
+        :binary,
+        args: args
+      ])
+
+    fifo = Port.open(to_charlist(fifo_path), [:eof, :binary, :stream, :out])
+    fix = Pleroma.Helpers.QtFastStart.fix(env.body)
+    true = Port.command(fifo, fix)
+    :erlang.port_close(fifo)
+    loop_recv(pid)
+  after
+    File.rm(fifo_path)
+  end
+
+  defp mkfifo do
+    path = Path.join(System.tmp_dir!(), "pleroma-media-preview-pipe-#{Ecto.UUID.generate()}")
+
+    case System.cmd("mkfifo", [path]) do
+      {_, 0} ->
+        spawn(fifo_guard(path))
+        {:ok, path}
+
+      {_, err} ->
+        {:error, {:fifo_failed, err}}
+    end
+  end
+
+  defp fifo_guard(path) do
+    pid = self()
+
+    fn ->
+      ref = Process.monitor(pid)
+
+      receive do
+        {:DOWN, ^ref, :process, ^pid, _} ->
+          File.rm(path)
+      end
+    end
+  end
+
+  defp loop_recv(pid) do
+    loop_recv(pid, <<>>)
+  end
+
+  defp loop_recv(pid, acc) do
+    receive do
+      {^pid, {:data, data}} ->
+        loop_recv(pid, acc <> data)
+
+      {^pid, {:exit_status, 0}} ->
+        {:ok, acc}
+
+      {^pid, {:exit_status, status}} ->
+        {:error, status}
+    after
+      5000 ->
+        :erlang.port_close(pid)
+        {:error, :timeout}
+    end
+  end
+end
diff --git a/lib/pleroma/helpers/qt_fast_start.ex b/lib/pleroma/helpers/qt_fast_start.ex
new file mode 100644 (file)
index 0000000..bb93224
--- /dev/null
@@ -0,0 +1,131 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Helpers.QtFastStart do
+  @moduledoc """
+  (WIP) Converts a "slow start" (data before metadatas) mov/mp4 file to a "fast start" one (metadatas before data).
+  """
+
+  # TODO: Cleanup and optimizations
+  # Inspirations: https://www.ffmpeg.org/doxygen/3.4/qt-faststart_8c_source.html
+  #               https://github.com/danielgtaylor/qtfaststart/blob/master/qtfaststart/processor.py
+  #               ISO/IEC 14496-12:2015, ISO/IEC 15444-12:2015
+  #               Paracetamol
+
+  def fix(<<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::bits>> = binary) do
+    index = fix(binary, 0, nil, nil, [])
+
+    case index do
+      :abort -> binary
+      [{"ftyp", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index)
+      [{"ftyp", _, _, _, _}, {"free", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index)
+      _ -> binary
+    end
+  end
+
+  def fix(binary) do
+    binary
+  end
+
+  # MOOV have been seen before MDAT- abort
+  defp fix(<<_::bits>>, _, true, false, _) do
+    :abort
+  end
+
+  defp fix(
+         <<size::integer-big-size(32), fourcc::bits-size(32), rest::bits>>,
+         pos,
+         got_moov,
+         got_mdat,
+         acc
+       ) do
+    full_size = (size - 8) * 8
+    <<data::bits-size(full_size), rest::bits>> = rest
+
+    acc = [
+      {fourcc, pos, pos + size, size,
+       <<size::integer-big-size(32), fourcc::bits-size(32), data::bits>>}
+      | acc
+    ]
+
+    fix(rest, pos + size, got_moov || fourcc == "moov", got_mdat || fourcc == "mdat", acc)
+  end
+
+  defp fix(<<>>, _pos, _, _, acc) do
+    :lists.reverse(acc)
+  end
+
+  defp faststart(index) do
+    {{_ftyp, _, _, _, ftyp}, index} = List.keytake(index, "ftyp", 0)
+
+    # Skip re-writing the free fourcc as it's kind of useless.
+    # Why stream useless bytes when you can do without?
+    {free_size, index} =
+      case List.keytake(index, "free", 0) do
+        {{_, _, _, size, _}, index} -> {size, index}
+        _ -> {0, index}
+      end
+
+    {{_moov, _, _, moov_size, moov}, index} = List.keytake(index, "moov", 0)
+    offset = -free_size + moov_size
+    rest = for {_, _, _, _, data} <- index, do: data, into: []
+    <<moov_head::bits-size(64), moov_data::bits>> = moov
+    [ftyp, moov_head, fix_moov(moov_data, offset, []), rest]
+  end
+
+  defp fix_moov(
+         <<size::integer-big-size(32), fourcc::bits-size(32), rest::bits>>,
+         offset,
+         acc
+       ) do
+    full_size = (size - 8) * 8
+    <<data::bits-size(full_size), rest::bits>> = rest
+
+    data =
+      cond do
+        fourcc in ["trak", "mdia", "minf", "stbl"] ->
+          # Theses contains sto or co64 part
+          [<<size::integer-big-size(32), fourcc::bits-size(32)>>, fix_moov(data, offset, [])]
+
+        fourcc in ["stco", "co64"] ->
+          # fix the damn thing
+          <<version::integer-big-size(32), count::integer-big-size(32), rest::bits>> = data
+
+          entry_size =
+            case fourcc do
+              "stco" -> 32
+              "co64" -> 64
+            end
+
+          [
+            <<size::integer-big-size(32), fourcc::bits-size(32), version::integer-big-size(32),
+              count::integer-big-size(32)>>,
+            rewrite_entries(entry_size, offset, rest, [])
+          ]
+
+        true ->
+          [<<size::integer-big-size(32), fourcc::bits-size(32)>>, data]
+      end
+
+    acc = [acc | data]
+    fix_moov(rest, offset, acc)
+  end
+
+  defp fix_moov(<<>>, _, acc), do: acc
+
+  for size <- [32, 64] do
+    defp rewrite_entries(
+           unquote(size),
+           offset,
+           <<pos::integer-big-size(unquote(size)), rest::bits>>,
+           acc
+         ) do
+      rewrite_entries(unquote(size), offset, rest, [
+        acc | <<pos + offset::integer-big-size(unquote(size))>>
+      ])
+    end
+  end
+
+  defp rewrite_entries(_, _, <<>>, acc), do: acc
+end
index 6d205a636bb9fbe1fec59b4e7ae2cfa31baf7af8..f1301f055d3efae303ef284a00ad0e104a96c025 100644 (file)
@@ -3,18 +3,22 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Helpers.UriHelper do
-  def append_uri_params(uri, appended_params) do
+  def modify_uri_params(uri, overridden_params, deleted_params \\ []) do
     uri = URI.parse(uri)
-    appended_params = for {k, v} <- appended_params, into: %{}, do: {to_string(k), v}
-    existing_params = URI.query_decoder(uri.query || "") |> Enum.into(%{})
-    updated_params_keys = Enum.uniq(Map.keys(existing_params) ++ Map.keys(appended_params))
+
+    existing_params = URI.query_decoder(uri.query || "") |> Map.new()
+    overridden_params = Map.new(overridden_params, fn {k, v} -> {to_string(k), v} end)
+    deleted_params = Enum.map(deleted_params, &to_string/1)
 
     updated_params =
-      for k <- updated_params_keys, do: {k, appended_params[k] || existing_params[k]}
+      existing_params
+      |> Map.merge(overridden_params)
+      |> Map.drop(deleted_params)
 
     uri
     |> Map.put(:query, URI.encode_query(updated_params))
     |> URI.to_string()
+    |> String.replace_suffix("?", "")
   end
 
   def maybe_add_base("/" <> uri, base), do: Path.join([base, uri])
index 6948651c7bacdbba4768fe8df50a68904a5eeae5..f0f6014690a090b1d97e04f7a47aa4a3b2fe641d 100644 (file)
@@ -156,9 +156,7 @@ defmodule Pleroma.Instances.Instance do
   defp scrape_favicon(%URI{} = instance_uri) do
     try do
       with {:ok, %Tesla.Env{body: html}} <-
-             Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}],
-               adapter: [pool: :media]
-             ),
+             Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], pool: :media),
            {_, [favicon_rel | _]} when is_binary(favicon_rel) <-
              {:parse,
               html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")},
index 0de4e2309c5e30f8816d3f68fd50071696184900..8ae1157df1630f41e90c647de7028c6bcc27ad7a 100644 (file)
@@ -17,6 +17,9 @@ defmodule Pleroma.ReverseProxy do
   @failed_request_ttl :timer.seconds(60)
   @methods ~w(GET HEAD)
 
+  def max_read_duration_default, do: @max_read_duration
+  def default_cache_control_header, do: @default_cache_control_header
+
   @moduledoc """
   A reverse proxy.
 
@@ -391,6 +394,8 @@ defmodule Pleroma.ReverseProxy do
 
   defp body_size_constraint(_, _), do: :ok
 
+  defp check_read_duration(nil = _duration, max), do: check_read_duration(@max_read_duration, max)
+
   defp check_read_duration(duration, max)
        when is_integer(duration) and is_integer(max) and max > 0 do
     if duration > max do
index 98d5954690f188133095a330de9191cb68a57fed..0fb05d3c4be9dafa2cee8427b0922b55e6dcc48d 100644 (file)
@@ -12,17 +12,21 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
 
   require Logger
 
-  @options [
+  @adapter_options [
     pool: :media,
     recv_timeout: 10_000
   ]
 
   def perform(:prefetch, url) do
-    Logger.debug("Prefetching #{inspect(url)}")
+    # Fetching only proxiable resources
+    if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
+      # If preview proxy is enabled, it'll also hit media proxy (so we're caching both requests)
+      prefetch_url = MediaProxy.preview_url(url)
 
-    url
-    |> MediaProxy.url()
-    |> HTTP.get([], @options)
+      Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}")
+
+      HTTP.get(prefetch_url, [], @adapter_options)
+    end
   end
 
   def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do
index d2a30a5483022d426da265b9a43bf2a931c48d77..121ba1693155adf30f4c17b9be4d0c9b111bf001 100644 (file)
@@ -181,8 +181,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do
     user = User.sanitize_html(user, User.html_filter_policy(opts[:for]))
     display_name = user.name || user.nickname
 
-    image = User.avatar_url(user) |> MediaProxy.url()
+    avatar = User.avatar_url(user) |> MediaProxy.url()
+    avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(static: true)
     header = User.banner_url(user) |> MediaProxy.url()
+    header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true)
 
     following_count =
       if !user.hide_follows_count or !user.hide_follows or opts[:for] == user do
@@ -247,10 +249,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do
       statuses_count: user.note_count,
       note: user.bio,
       url: user.uri || user.ap_id,
-      avatar: image,
-      avatar_static: image,
+      avatar: avatar,
+      avatar_static: avatar_static,
       header: header,
-      header_static: header,
+      header_static: header_static,
       emojis: emojis,
       fields: user.fields,
       bot: bot,
index 94b8dc8c6859808726292f432e91bb3c1782fb6f..435bcde157552b063deb98a72829c3527e6ea60f 100644 (file)
@@ -415,6 +415,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
     [attachment_url | _] = attachment["url"]
     media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
     href = attachment_url["href"] |> MediaProxy.url()
+    href_preview = attachment_url["href"] |> MediaProxy.preview_url()
 
     type =
       cond do
@@ -430,7 +431,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       id: to_string(attachment["id"] || hash_id),
       url: href,
       remote_url: href,
-      preview_url: href,
+      preview_url: href_preview,
       text_url: href,
       type: type,
       description: attachment["name"],
index 5808861e692b15a27b5e38506a2c05d953111270..4f434047830760f72b0ab007fda44685d016f0f9 100644 (file)
@@ -33,6 +33,8 @@ defmodule Pleroma.Web.MediaProxy.Invalidation do
   def prepare_urls(urls) do
     urls
     |> List.wrap()
-    |> Enum.map(&MediaProxy.url/1)
+    |> Enum.map(fn url -> [MediaProxy.url(url), MediaProxy.preview_url(url)] end)
+    |> List.flatten()
+    |> Enum.uniq()
   end
 end
index e18dd8224808cdcaa4a0647397e6278a3b8a29c0..8656b8cad31eb12910d10556ee58f741c34f48a8 100644 (file)
@@ -4,6 +4,7 @@
 
 defmodule Pleroma.Web.MediaProxy do
   alias Pleroma.Config
+  alias Pleroma.Helpers.UriHelper
   alias Pleroma.Upload
   alias Pleroma.Web
   alias Pleroma.Web.MediaProxy.Invalidation
@@ -40,27 +41,35 @@ defmodule Pleroma.Web.MediaProxy do
   def url("/" <> _ = url), do: url
 
   def url(url) do
-    if disabled?() or not url_proxiable?(url) do
-      url
-    else
+    if enabled?() and url_proxiable?(url) do
       encode_url(url)
+    else
+      url
     end
   end
 
   @spec url_proxiable?(String.t()) :: boolean()
   def url_proxiable?(url) do
-    if local?(url) or whitelisted?(url) do
-      false
+    not local?(url) and not whitelisted?(url)
+  end
+
+  def preview_url(url, preview_params \\ []) do
+    if preview_enabled?() do
+      encode_preview_url(url, preview_params)
     else
-      true
+      url(url)
     end
   end
 
-  defp disabled?, do: !Config.get([:media_proxy, :enabled], false)
+  def enabled?, do: Config.get([:media_proxy, :enabled], false)
+
+  # Note: media proxy must be enabled for media preview proxy in order to load all
+  #   non-local non-whitelisted URLs through it and be sure that body size constraint is preserved.
+  def preview_enabled?, do: enabled?() and !!Config.get([:media_preview_proxy, :enabled])
 
-  defp local?(url), do: String.starts_with?(url, Pleroma.Web.base_url())
+  def local?(url), do: String.starts_with?(url, Pleroma.Web.base_url())
 
-  defp whitelisted?(url) do
+  def whitelisted?(url) do
     %{host: domain} = URI.parse(url)
 
     mediaproxy_whitelist_domains =
@@ -85,17 +94,29 @@ defmodule Pleroma.Web.MediaProxy do
 
   defp maybe_get_domain_from_url(domain), do: domain
 
-  def encode_url(url) do
+  defp base64_sig64(url) do
     base64 = Base.url_encode64(url, @base64_opts)
 
     sig64 =
       base64
-      |> signed_url
+      |> signed_url()
       |> Base.url_encode64(@base64_opts)
 
+    {base64, sig64}
+  end
+
+  def encode_url(url) do
+    {base64, sig64} = base64_sig64(url)
+
     build_url(sig64, base64, filename(url))
   end
 
+  def encode_preview_url(url, preview_params \\ []) do
+    {base64, sig64} = base64_sig64(url)
+
+    build_preview_url(sig64, base64, filename(url), preview_params)
+  end
+
   def decode_url(sig, url) do
     with {:ok, sig} <- Base.url_decode64(sig, @base64_opts),
          signature when signature == sig <- signed_url(url) do
@@ -113,10 +134,14 @@ defmodule Pleroma.Web.MediaProxy do
     if path = URI.parse(url_or_path).path, do: Path.basename(path)
   end
 
-  def build_url(sig_base64, url_base64, filename \\ nil) do
+  def base_url do
+    Config.get([:media_proxy, :base_url], Web.base_url())
+  end
+
+  defp proxy_url(path, sig_base64, url_base64, filename) do
     [
-      Config.get([:media_proxy, :base_url], Web.base_url()),
-      "proxy",
+      base_url(),
+      path,
       sig_base64,
       url_base64,
       filename
@@ -124,4 +149,38 @@ defmodule Pleroma.Web.MediaProxy do
     |> Enum.filter(& &1)
     |> Path.join()
   end
+
+  def build_url(sig_base64, url_base64, filename \\ nil) do
+    proxy_url("proxy", sig_base64, url_base64, filename)
+  end
+
+  def build_preview_url(sig_base64, url_base64, filename \\ nil, preview_params \\ []) do
+    uri = proxy_url("proxy/preview", sig_base64, url_base64, filename)
+
+    UriHelper.modify_uri_params(uri, preview_params)
+  end
+
+  def verify_request_path_and_url(
+        %Plug.Conn{params: %{"filename" => _}, request_path: request_path},
+        url
+      ) do
+    verify_request_path_and_url(request_path, url)
+  end
+
+  def verify_request_path_and_url(request_path, url) when is_binary(request_path) do
+    filename = filename(url)
+
+    if filename && not basename_matches?(request_path, filename) do
+      {:wrong_filename, filename}
+    else
+      :ok
+    end
+  end
+
+  def verify_request_path_and_url(_, _), do: :ok
+
+  defp basename_matches?(path, filename) do
+    basename = Path.basename(path)
+    basename == filename or URI.decode(basename) == filename or URI.encode(basename) == filename
+  end
 end
index 9a64b0ef35776f9b2a3c189481fed15c30ae698a..90651ed9bcd10e626a96abe170bd5c6c74cf4049 100644 (file)
 defmodule Pleroma.Web.MediaProxy.MediaProxyController do
   use Pleroma.Web, :controller
 
+  alias Pleroma.Config
+  alias Pleroma.Helpers.MediaHelper
+  alias Pleroma.Helpers.UriHelper
   alias Pleroma.ReverseProxy
   alias Pleroma.Web.MediaProxy
+  alias Plug.Conn
 
-  @default_proxy_opts [max_body_length: 25 * 1_048_576, http: [follow_redirect: true]]
-
-  def remote(conn, %{"sig" => sig64, "url" => url64} = params) do
-    with config <- Pleroma.Config.get([:media_proxy], []),
-         true <- Keyword.get(config, :enabled, false),
+  def remote(conn, %{"sig" => sig64, "url" => url64}) do
+    with {_, true} <- {:enabled, MediaProxy.enabled?()},
          {:ok, url} <- MediaProxy.decode_url(sig64, url64),
          {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)},
-         :ok <- filename_matches(params, conn.request_path, url) do
-      ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts))
+         :ok <- MediaProxy.verify_request_path_and_url(conn, url) do
+      ReverseProxy.call(conn, url, media_proxy_opts())
     else
-      error when error in [false, {:in_banned_urls, true}] ->
-        send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404))
+      {:enabled, false} ->
+        send_resp(conn, 404, Conn.Status.reason_phrase(404))
+
+      {:in_banned_urls, true} ->
+        send_resp(conn, 404, Conn.Status.reason_phrase(404))
 
       {:error, :invalid_signature} ->
-        send_resp(conn, 403, Plug.Conn.Status.reason_phrase(403))
+        send_resp(conn, 403, Conn.Status.reason_phrase(403))
 
       {:wrong_filename, filename} ->
         redirect(conn, external: MediaProxy.build_url(sig64, url64, filename))
     end
   end
 
-  def filename_matches(%{"filename" => _} = _, path, url) do
-    filename = MediaProxy.filename(url)
+  def preview(%Conn{} = conn, %{"sig" => sig64, "url" => url64}) do
+    with {_, true} <- {:enabled, MediaProxy.preview_enabled?()},
+         {:ok, url} <- MediaProxy.decode_url(sig64, url64),
+         :ok <- MediaProxy.verify_request_path_and_url(conn, url) do
+      handle_preview(conn, url)
+    else
+      {:enabled, false} ->
+        send_resp(conn, 404, Conn.Status.reason_phrase(404))
+
+      {:error, :invalid_signature} ->
+        send_resp(conn, 403, Conn.Status.reason_phrase(403))
+
+      {:wrong_filename, filename} ->
+        redirect(conn, external: MediaProxy.build_preview_url(sig64, url64, filename))
+    end
+  end
+
+  defp handle_preview(conn, url) do
+    media_proxy_url = MediaProxy.url(url)
+
+    with {:ok, %{status: status} = head_response} when status in 200..299 <-
+           Pleroma.HTTP.request("head", media_proxy_url, [], [], pool: :media) do
+      content_type = Tesla.get_header(head_response, "content-type")
+      content_length = Tesla.get_header(head_response, "content-length")
+      content_length = content_length && String.to_integer(content_length)
+      static = conn.params["static"] in ["true", true]
+
+      cond do
+        static and content_type == "image/gif" ->
+          handle_jpeg_preview(conn, media_proxy_url)
+
+        static ->
+          drop_static_param_and_redirect(conn)
+
+        content_type == "image/gif" ->
+          redirect(conn, external: media_proxy_url)
+
+        min_content_length_for_preview() > 0 and content_length > 0 and
+            content_length < min_content_length_for_preview() ->
+          redirect(conn, external: media_proxy_url)
+
+        true ->
+          handle_preview(content_type, conn, media_proxy_url)
+      end
+    else
+      # If HEAD failed, redirecting to media proxy URI doesn't make much sense; returning an error
+      {_, %{status: status}} ->
+        send_resp(conn, :failed_dependency, "Can't fetch HTTP headers (HTTP #{status}).")
+
+      {:error, :recv_response_timeout} ->
+        send_resp(conn, :failed_dependency, "HEAD request timeout.")
+
+      _ ->
+        send_resp(conn, :failed_dependency, "Can't fetch HTTP headers.")
+    end
+  end
+
+  defp handle_preview("image/png" <> _ = _content_type, conn, media_proxy_url) do
+    handle_png_preview(conn, media_proxy_url)
+  end
+
+  defp handle_preview("image/" <> _ = _content_type, conn, media_proxy_url) do
+    handle_jpeg_preview(conn, media_proxy_url)
+  end
+
+  defp handle_preview("video/" <> _ = _content_type, conn, media_proxy_url) do
+    handle_video_preview(conn, media_proxy_url)
+  end
+
+  defp handle_preview(_unsupported_content_type, conn, media_proxy_url) do
+    fallback_on_preview_error(conn, media_proxy_url)
+  end
+
+  defp handle_png_preview(conn, media_proxy_url) do
+    quality = Config.get!([:media_preview_proxy, :image_quality])
+    {thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions()
+
+    with {:ok, thumbnail_binary} <-
+           MediaHelper.image_resize(
+             media_proxy_url,
+             %{
+               max_width: thumbnail_max_width,
+               max_height: thumbnail_max_height,
+               quality: quality,
+               format: "png"
+             }
+           ) do
+      conn
+      |> put_preview_response_headers(["image/png", "preview.png"])
+      |> send_resp(200, thumbnail_binary)
+    else
+      _ ->
+        fallback_on_preview_error(conn, media_proxy_url)
+    end
+  end
+
+  defp handle_jpeg_preview(conn, media_proxy_url) do
+    quality = Config.get!([:media_preview_proxy, :image_quality])
+    {thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions()
 
-    if filename && does_not_match(path, filename) do
-      {:wrong_filename, filename}
+    with {:ok, thumbnail_binary} <-
+           MediaHelper.image_resize(
+             media_proxy_url,
+             %{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality}
+           ) do
+      conn
+      |> put_preview_response_headers()
+      |> send_resp(200, thumbnail_binary)
     else
-      :ok
+      _ ->
+        fallback_on_preview_error(conn, media_proxy_url)
     end
   end
 
-  def filename_matches(_, _, _), do: :ok
+  defp handle_video_preview(conn, media_proxy_url) do
+    with {:ok, thumbnail_binary} <-
+           MediaHelper.video_framegrab(media_proxy_url) do
+      conn
+      |> put_preview_response_headers()
+      |> send_resp(200, thumbnail_binary)
+    else
+      _ ->
+        fallback_on_preview_error(conn, media_proxy_url)
+    end
+  end
+
+  defp drop_static_param_and_redirect(conn) do
+    uri_without_static_param =
+      conn
+      |> current_url()
+      |> UriHelper.modify_uri_params(%{}, ["static"])
+
+    redirect(conn, external: uri_without_static_param)
+  end
+
+  defp fallback_on_preview_error(conn, media_proxy_url) do
+    redirect(conn, external: media_proxy_url)
+  end
+
+  defp put_preview_response_headers(
+         conn,
+         [content_type, filename] = _content_info \\ ["image/jpeg", "preview.jpg"]
+       ) do
+    conn
+    |> put_resp_header("content-type", content_type)
+    |> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"")
+    |> put_resp_header("cache-control", ReverseProxy.default_cache_control_header())
+  end
+
+  defp thumbnail_max_dimensions do
+    config = media_preview_proxy_config()
+
+    thumbnail_max_width = Keyword.fetch!(config, :thumbnail_max_width)
+    thumbnail_max_height = Keyword.fetch!(config, :thumbnail_max_height)
+
+    {thumbnail_max_width, thumbnail_max_height}
+  end
+
+  defp min_content_length_for_preview do
+    Keyword.get(media_preview_proxy_config(), :min_content_length, 0)
+  end
+
+  defp media_preview_proxy_config do
+    Config.get!([:media_preview_proxy])
+  end
 
-  defp does_not_match(path, filename) do
-    basename = Path.basename(path)
-    basename != filename and URI.decode(basename) != filename and URI.encode(basename) != filename
+  defp media_proxy_opts do
+    Config.get([:media_proxy, :proxy_opts], [])
   end
 end
index 2f0dfb474b16e8d1780d15bb6e606be4922b4378..8a206e019a35808b40a52287a17c4f6f6fba8682 100644 (file)
@@ -38,7 +38,7 @@ defmodule Pleroma.Web.Metadata.Utils do
   def scrub_html(content), do: content
 
   def attachment_url(url) do
-    MediaProxy.url(url)
+    MediaProxy.preview_url(url)
   end
 
   def user_name_string(user) do
index 26e68be428461fb56af9f1aca878dc74e5ef1d98..a4152e84090426b8e396e1f10f4a68d3d6611baf 100644 (file)
@@ -119,7 +119,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
       redirect_uri = redirect_uri(conn, redirect_uri)
       url_params = %{access_token: token.token}
       url_params = Maps.put_if_present(url_params, :state, params["state"])
-      url = UriHelper.append_uri_params(redirect_uri, url_params)
+      url = UriHelper.modify_uri_params(redirect_uri, url_params)
       redirect(conn, external: url)
     else
       conn
@@ -161,7 +161,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
       redirect_uri = redirect_uri(conn, redirect_uri)
       url_params = %{code: auth.token}
       url_params = Maps.put_if_present(url_params, :state, auth_attrs["state"])
-      url = UriHelper.append_uri_params(redirect_uri, url_params)
+      url = UriHelper.modify_uri_params(redirect_uri, url_params)
       redirect(conn, external: url)
     else
       conn
index a4a58c2c47162db4f326154de633b2f847316cfe..67fbbde9223e43dbf02faad2e3d17e98310edde5 100644 (file)
@@ -679,6 +679,8 @@ defmodule Pleroma.Web.Router do
   end
 
   scope "/proxy/", Pleroma.Web.MediaProxy do
+    get("/preview/:sig/:url", MediaProxyController, :preview)
+    get("/preview/:sig/:url/:filename", MediaProxyController, :preview)
     get("/:sig/:url", MediaProxyController, :remote)
     get("/:sig/:url/:filename", MediaProxyController, :remote)
   end
index a17e8c0fcdcac4f23627aab99ccb6f774709eb48..adb3f024a7ae588556e4d6f476f1547e257ee0db 100644 (file)
--- a/mix.lock
+++ b/mix.lock
@@ -31,6 +31,7 @@
   "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"},
   "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
   "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"},
+  "eimp": {:hex, :eimp, "1.0.14", "fc297f0c7e2700457a95a60c7010a5f1dcb768a083b6d53f49cd94ab95a28f22", [:rebar3], [{:p1_utils, "1.0.18", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "501133f3112079b92d9e22da8b88bf4f0e13d4d67ae9c15c42c30bd25ceb83b6"},
   "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
   "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"},
   "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"},
@@ -80,6 +81,7 @@
   "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]},
   "oban": {:hex, :oban, "2.0.0", "e6ce70d94dd46815ec0882a1ffb7356df9a9d5b8a40a64ce5c2536617a447379", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cf574813bd048b98a698aa587c21367d2e06842d4e1b1993dcd6a696e9e633bd"},
   "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]},
+  "p1_utils": {:hex, :p1_utils, "1.0.18", "3fe224de5b2e190d730a3c5da9d6e8540c96484cf4b4692921d1e28f0c32b01c", [:rebar3], [], "hexpm", "1fc8773a71a15553b179c986b22fbeead19b28fe486c332d4929700ffeb71f88"},
   "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
   "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"},
   "phoenix": {:hex, :phoenix, "1.4.17", "1b1bd4cff7cfc87c94deaa7d60dd8c22e04368ab95499483c50640ef3bd838d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a8e5d7a3d76d452bb5fb86e8b7bd115f737e4f8efe202a463d4aeb4a5809611"},
diff --git a/test/fixtures/image.gif b/test/fixtures/image.gif
new file mode 100755 (executable)
index 0000000..9df6477
Binary files /dev/null and b/test/fixtures/image.gif differ
diff --git a/test/fixtures/image.png b/test/fixtures/image.png
new file mode 100755 (executable)
index 0000000..e999e88
Binary files /dev/null and b/test/fixtures/image.png differ
index 313d59a66169637baafc1c01a53da6c4729765ae..1710c4d2ae98e975bd17aebf9fdafaa985732315 100644 (file)
@@ -22,6 +22,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
     }
   }
 
+  setup do: clear_config([:media_proxy, :enabled], true)
+
   test "it prefetches media proxy URIs" do
     with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
       MediaProxyWarmingPolicy.filter(@message)
index a54b765efdb8a110349d5f01781292a6e0b9d399..a5f39b215dd6e5ad92ba2962b49506ed21abaa60 100644 (file)
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
   use Pleroma.DataCase
 
+  alias Pleroma.Config
   alias Pleroma.User
   alias Pleroma.UserRelationship
   alias Pleroma.Web.CommonAPI
@@ -540,8 +541,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
     end
   end
 
-  test "uses mediaproxy urls when it's enabled" do
+  test "uses mediaproxy urls when it's enabled (regardless of media preview proxy state)" do
     clear_config([:media_proxy, :enabled], true)
+    clear_config([:media_preview_proxy, :enabled])
 
     user =
       insert(:user,
@@ -550,20 +552,24 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         emoji: %{"joker_smile" => "https://evil.website/society.png"}
       )
 
-    AccountView.render("show.json", %{user: user, skip_visibility_check: true})
-    |> Enum.all?(fn
-      {key, url} when key in [:avatar, :avatar_static, :header, :header_static] ->
-        String.starts_with?(url, Pleroma.Web.base_url())
-
-      {:emojis, emojis} ->
-        Enum.all?(emojis, fn %{url: url, static_url: static_url} ->
-          String.starts_with?(url, Pleroma.Web.base_url()) &&
-            String.starts_with?(static_url, Pleroma.Web.base_url())
-        end)
-
-      _ ->
-        true
-    end)
-    |> assert()
+    with media_preview_enabled <- [false, true] do
+      Config.put([:media_preview_proxy, :enabled], media_preview_enabled)
+
+      AccountView.render("show.json", %{user: user, skip_visibility_check: true})
+      |> Enum.all?(fn
+        {key, url} when key in [:avatar, :avatar_static, :header, :header_static] ->
+          String.starts_with?(url, Pleroma.Web.base_url())
+
+        {:emojis, emojis} ->
+          Enum.all?(emojis, fn %{url: url, static_url: static_url} ->
+            String.starts_with?(url, Pleroma.Web.base_url()) &&
+              String.starts_with?(static_url, Pleroma.Web.base_url())
+          end)
+
+        _ ->
+          true
+      end)
+      |> assert()
+    end
   end
 end
index d4db44c6312c7248c988fd80fbb5ea69ec255ec8..33e6873f79d85620ea0a6b76c172c71f7aff0895 100644 (file)
@@ -8,34 +8,34 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
   import Mock
 
   alias Pleroma.Web.MediaProxy
-  alias Pleroma.Web.MediaProxy.MediaProxyController
   alias Plug.Conn
 
   setup do
     on_exit(fn -> Cachex.clear(:banned_urls_cache) end)
   end
 
-  test "it returns 404 when MediaProxy disabled", %{conn: conn} do
-    clear_config([:media_proxy, :enabled], false)
-
-    assert %Conn{
-             status: 404,
-             resp_body: "Not Found"
-           } = get(conn, "/proxy/hhgfh/eeeee")
-
-    assert %Conn{
-             status: 404,
-             resp_body: "Not Found"
-           } = get(conn, "/proxy/hhgfh/eeee/fff")
-  end
-
-  describe "" do
+  describe "Media Proxy" do
     setup do
       clear_config([:media_proxy, :enabled], true)
       clear_config([Pleroma.Web.Endpoint, :secret_key_base], "00000000000")
+
       [url: MediaProxy.encode_url("https://google.fn/test.png")]
     end
 
+    test "it returns 404 when disabled", %{conn: conn} do
+      clear_config([:media_proxy, :enabled], false)
+
+      assert %Conn{
+               status: 404,
+               resp_body: "Not Found"
+             } = get(conn, "/proxy/hhgfh/eeeee")
+
+      assert %Conn{
+               status: 404,
+               resp_body: "Not Found"
+             } = get(conn, "/proxy/hhgfh/eeee/fff")
+    end
+
     test "it returns 403 for invalid signature", %{conn: conn, url: url} do
       Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], "000")
       %{path: path} = URI.parse(url)
@@ -56,7 +56,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
              } = get(conn, "/proxy/hhgfh/eeee/fff")
     end
 
-    test "redirects on valid url when filename is invalidated", %{conn: conn, url: url} do
+    test "redirects to valid url when filename is invalidated", %{conn: conn, url: url} do
       invalid_url = String.replace(url, "test.png", "test-file.png")
       response = get(conn, invalid_url)
       assert response.status == 302
@@ -80,42 +80,248 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
     end
   end
 
-  describe "filename_matches/3" do
-    test "preserves the encoded or decoded path" do
-      assert MediaProxyController.filename_matches(
-               %{"filename" => "/Hello world.jpg"},
-               "/Hello world.jpg",
-               "http://pleroma.social/Hello world.jpg"
-             ) == :ok
-
-      assert MediaProxyController.filename_matches(
-               %{"filename" => "/Hello%20world.jpg"},
-               "/Hello%20world.jpg",
-               "http://pleroma.social/Hello%20world.jpg"
-             ) == :ok
-
-      assert MediaProxyController.filename_matches(
-               %{"filename" => "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg"},
-               "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg",
-               "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg"
-             ) == :ok
-
-      assert MediaProxyController.filename_matches(
-               %{"filename" => "/my%2Flong%2Furl%2F2019%2F07%2FS.jp"},
-               "/my%2Flong%2Furl%2F2019%2F07%2FS.jp",
-               "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg"
-             ) == {:wrong_filename, "my%2Flong%2Furl%2F2019%2F07%2FS.jpg"}
-    end
-
-    test "encoded url are tried to match for proxy as `conn.request_path` encodes the url" do
-      # conn.request_path will return encoded url
-      request_path = "/ANALYSE-DAI-_-LE-STABLECOIN-100-D%C3%89CENTRALIS%C3%89-BQ.jpg"
-
-      assert MediaProxyController.filename_matches(
-               true,
-               request_path,
-               "https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg"
-             ) == :ok
+  describe "Media Preview Proxy" do
+    setup do
+      clear_config([:media_proxy, :enabled], true)
+      clear_config([:media_preview_proxy, :enabled], true)
+      clear_config([Pleroma.Web.Endpoint, :secret_key_base], "00000000000")
+
+      original_url = "https://google.fn/test.png"
+
+      [
+        url: MediaProxy.encode_preview_url(original_url),
+        media_proxy_url: MediaProxy.encode_url(original_url)
+      ]
+    end
+
+    test "returns 404 when media proxy is disabled", %{conn: conn} do
+      clear_config([:media_proxy, :enabled], false)
+
+      assert %Conn{
+               status: 404,
+               resp_body: "Not Found"
+             } = get(conn, "/proxy/preview/hhgfh/eeeee")
+
+      assert %Conn{
+               status: 404,
+               resp_body: "Not Found"
+             } = get(conn, "/proxy/preview/hhgfh/fff")
+    end
+
+    test "returns 404 when disabled", %{conn: conn} do
+      clear_config([:media_preview_proxy, :enabled], false)
+
+      assert %Conn{
+               status: 404,
+               resp_body: "Not Found"
+             } = get(conn, "/proxy/preview/hhgfh/eeeee")
+
+      assert %Conn{
+               status: 404,
+               resp_body: "Not Found"
+             } = get(conn, "/proxy/preview/hhgfh/fff")
+    end
+
+    test "it returns 403 for invalid signature", %{conn: conn, url: url} do
+      Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], "000")
+      %{path: path} = URI.parse(url)
+
+      assert %Conn{
+               status: 403,
+               resp_body: "Forbidden"
+             } = get(conn, path)
+
+      assert %Conn{
+               status: 403,
+               resp_body: "Forbidden"
+             } = get(conn, "/proxy/preview/hhgfh/eeee")
+
+      assert %Conn{
+               status: 403,
+               resp_body: "Forbidden"
+             } = get(conn, "/proxy/preview/hhgfh/eeee/fff")
+    end
+
+    test "redirects to valid url when filename is invalidated", %{conn: conn, url: url} do
+      invalid_url = String.replace(url, "test.png", "test-file.png")
+      response = get(conn, invalid_url)
+      assert response.status == 302
+      assert redirected_to(response) == url
+    end
+
+    test "responds with 424 Failed Dependency if HEAD request to media proxy fails", %{
+      conn: conn,
+      url: url,
+      media_proxy_url: media_proxy_url
+    } do
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{status: 500, body: ""}
+      end)
+
+      response = get(conn, url)
+      assert response.status == 424
+      assert response.resp_body == "Can't fetch HTTP headers (HTTP 500)."
+    end
+
+    test "redirects to media proxy URI on unsupported content type", %{
+      conn: conn,
+      url: url,
+      media_proxy_url: media_proxy_url
+    } do
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: "", headers: [{"content-type", "application/pdf"}]}
+      end)
+
+      response = get(conn, url)
+      assert response.status == 302
+      assert redirected_to(response) == media_proxy_url
+    end
+
+    test "with `static=true` and GIF image preview requested, responds with JPEG image", %{
+      conn: conn,
+      url: url,
+      media_proxy_url: media_proxy_url
+    } do
+      # Setting a high :min_content_length to ensure this scenario is not affected by its logic
+      clear_config([:media_preview_proxy, :min_content_length], 1_000_000_000)
+
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{
+            status: 200,
+            body: "",
+            headers: [{"content-type", "image/gif"}, {"content-length", "1001718"}]
+          }
+
+        %{method: :get, url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: File.read!("test/fixtures/image.gif")}
+      end)
+
+      response = get(conn, url <> "?static=true")
+
+      assert response.status == 200
+      assert Conn.get_resp_header(response, "content-type") == ["image/jpeg"]
+      assert response.resp_body != ""
+    end
+
+    test "with GIF image preview requested and no `static` param, redirects to media proxy URI",
+         %{
+           conn: conn,
+           url: url,
+           media_proxy_url: media_proxy_url
+         } do
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/gif"}]}
+      end)
+
+      response = get(conn, url)
+
+      assert response.status == 302
+      assert redirected_to(response) == media_proxy_url
+    end
+
+    test "with `static` param and non-GIF image preview requested, " <>
+           "redirects to media preview proxy URI without `static` param",
+         %{
+           conn: conn,
+           url: url,
+           media_proxy_url: media_proxy_url
+         } do
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]}
+      end)
+
+      response = get(conn, url <> "?static=true")
+
+      assert response.status == 302
+      assert redirected_to(response) == url
+    end
+
+    test "with :min_content_length setting not matched by Content-Length header, " <>
+           "redirects to media proxy URI",
+         %{
+           conn: conn,
+           url: url,
+           media_proxy_url: media_proxy_url
+         } do
+      clear_config([:media_preview_proxy, :min_content_length], 100_000)
+
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{
+            status: 200,
+            body: "",
+            headers: [{"content-type", "image/gif"}, {"content-length", "5000"}]
+          }
+      end)
+
+      response = get(conn, url)
+
+      assert response.status == 302
+      assert redirected_to(response) == media_proxy_url
+    end
+
+    test "thumbnails PNG images into PNG", %{
+      conn: conn,
+      url: url,
+      media_proxy_url: media_proxy_url
+    } do
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/png"}]}
+
+        %{method: :get, url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: File.read!("test/fixtures/image.png")}
+      end)
+
+      response = get(conn, url)
+
+      assert response.status == 200
+      assert Conn.get_resp_header(response, "content-type") == ["image/png"]
+      assert response.resp_body != ""
+    end
+
+    test "thumbnails JPEG images into JPEG", %{
+      conn: conn,
+      url: url,
+      media_proxy_url: media_proxy_url
+    } do
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]}
+
+        %{method: :get, url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: File.read!("test/fixtures/image.jpg")}
+      end)
+
+      response = get(conn, url)
+
+      assert response.status == 200
+      assert Conn.get_resp_header(response, "content-type") == ["image/jpeg"]
+      assert response.resp_body != ""
+    end
+
+    test "redirects to media proxy URI in case of thumbnailing error", %{
+      conn: conn,
+      url: url,
+      media_proxy_url: media_proxy_url
+    } do
+      Tesla.Mock.mock(fn
+        %{method: "head", url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]}
+
+        %{method: :get, url: ^media_proxy_url} ->
+          %Tesla.Env{status: 200, body: "<html><body>error</body></html>"}
+      end)
+
+      response = get(conn, url)
+
+      assert response.status == 302
+      assert redirected_to(response) == media_proxy_url
     end
   end
 end
index 72885cfdd09f0bb081f2e61a9625d830f48e6737..0e6df826c3b612fd874df34b8938b6967d90771e 100644 (file)
@@ -6,9 +6,16 @@ defmodule Pleroma.Web.MediaProxyTest do
   use ExUnit.Case
   use Pleroma.Tests.Helpers
 
+  alias Pleroma.Config
   alias Pleroma.Web.Endpoint
   alias Pleroma.Web.MediaProxy
 
+  defp decode_result(encoded) do
+    [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/")
+    {:ok, decoded} = MediaProxy.decode_url(sig, base64)
+    decoded
+  end
+
   describe "when enabled" do
     setup do: clear_config([:media_proxy, :enabled], true)
 
@@ -35,7 +42,7 @@ defmodule Pleroma.Web.MediaProxyTest do
 
       assert String.starts_with?(
                encoded,
-               Pleroma.Config.get([:media_proxy, :base_url], Pleroma.Web.base_url())
+               Config.get([:media_proxy, :base_url], Pleroma.Web.base_url())
              )
 
       assert String.ends_with?(encoded, "/logo.png")
@@ -75,6 +82,64 @@ defmodule Pleroma.Web.MediaProxyTest do
       assert MediaProxy.decode_url(sig, base64) == {:error, :invalid_signature}
     end
 
+    def test_verify_request_path_and_url(request_path, url, expected_result) do
+      assert MediaProxy.verify_request_path_and_url(request_path, url) == expected_result
+
+      assert MediaProxy.verify_request_path_and_url(
+               %Plug.Conn{
+                 params: %{"filename" => Path.basename(request_path)},
+                 request_path: request_path
+               },
+               url
+             ) == expected_result
+    end
+
+    test "if first arg of `verify_request_path_and_url/2` is a Plug.Conn without \"filename\" " <>
+           "parameter, `verify_request_path_and_url/2` returns :ok " do
+      assert MediaProxy.verify_request_path_and_url(
+               %Plug.Conn{params: %{}, request_path: "/some/path"},
+               "https://instance.com/file.jpg"
+             ) == :ok
+
+      assert MediaProxy.verify_request_path_and_url(
+               %Plug.Conn{params: %{}, request_path: "/path/to/file.jpg"},
+               "https://instance.com/file.jpg"
+             ) == :ok
+    end
+
+    test "`verify_request_path_and_url/2` preserves the encoded or decoded path" do
+      test_verify_request_path_and_url(
+        "/Hello world.jpg",
+        "http://pleroma.social/Hello world.jpg",
+        :ok
+      )
+
+      test_verify_request_path_and_url(
+        "/Hello%20world.jpg",
+        "http://pleroma.social/Hello%20world.jpg",
+        :ok
+      )
+
+      test_verify_request_path_and_url(
+        "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg",
+        "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg",
+        :ok
+      )
+
+      test_verify_request_path_and_url(
+        # Note: `conn.request_path` returns encoded url
+        "/ANALYSE-DAI-_-LE-STABLECOIN-100-D%C3%89CENTRALIS%C3%89-BQ.jpg",
+        "https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg",
+        :ok
+      )
+
+      test_verify_request_path_and_url(
+        "/my%2Flong%2Furl%2F2019%2F07%2FS",
+        "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg",
+        {:wrong_filename, "my%2Flong%2Furl%2F2019%2F07%2FS.jpg"}
+      )
+    end
+
     test "uses the configured base_url" do
       base_url = "https://cache.pleroma.social"
       clear_config([:media_proxy, :base_url], base_url)
@@ -124,12 +189,6 @@ defmodule Pleroma.Web.MediaProxyTest do
     end
   end
 
-  defp decode_result(encoded) do
-    [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/")
-    {:ok, decoded} = MediaProxy.decode_url(sig, base64)
-    decoded
-  end
-
   describe "whitelist" do
     setup do: clear_config([:media_proxy, :enabled], true)