diff options
-rw-r--r-- | config/config.exs | 12 | ||||
-rw-r--r-- | config/description.exs | 56 | ||||
-rw-r--r-- | lib/pleroma/helpers/media_helper.ex | 151 | ||||
-rw-r--r-- | lib/pleroma/helpers/qt_fast_start.ex | 131 | ||||
-rw-r--r-- | lib/pleroma/reverse_proxy/reverse_proxy.ex | 4 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex | 2 | ||||
-rw-r--r-- | lib/pleroma/web/mastodon_api/views/account_view.ex | 10 | ||||
-rw-r--r-- | lib/pleroma/web/mastodon_api/views/status_view.ex | 3 | ||||
-rw-r--r-- | lib/pleroma/web/media_proxy/media_proxy.ex | 74 | ||||
-rw-r--r-- | lib/pleroma/web/media_proxy/media_proxy_controller.ex | 174 | ||||
-rw-r--r-- | lib/pleroma/web/metadata/utils.ex | 2 | ||||
-rw-r--r-- | lib/pleroma/web/router.ex | 2 | ||||
-rw-r--r-- | mix.lock | 2 | ||||
-rw-r--r-- | test/web/media_proxy/media_proxy_controller_test.exs | 40 | ||||
-rw-r--r-- | test/web/media_proxy/media_proxy_test.exs | 73 |
15 files changed, 657 insertions, 79 deletions
diff --git a/config/config.exs b/config/config.exs index ed37b93c0..317ef84a9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -424,6 +424,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 @@ -438,6 +440,16 @@ 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, + proxy_opts: [ + head_request_max_read_duration: 5_000 + ] + config :pleroma, :chat, enabled: true config :phoenix, :format_encoders, json: Jason diff --git a/config/description.exs b/config/description.exs index 5e08ba109..868b89d29 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1880,6 +1880,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 @@ -1901,6 +1902,11 @@ config :pleroma, :config_description, [ "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", type: :keyword, @@ -1948,6 +1954,56 @@ 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." + }, + %{ + key: :thumbnail_max_height, + type: :integer, + description: "Max height of preview thumbnail." + }, + %{ + key: :image_quality, + type: :integer, + description: "Quality of the output. Ranges from 0 (min quality) to 100 (max quality)." + }, + %{ + key: :proxy_opts, + type: :keyword, + description: "Media proxy options", + suggestions: [ + head_request_max_read_duration: 5_000 + ], + children: [ + %{ + key: :head_request_max_read_duration, + type: :integer, + description: "Timeout (in milliseconds) of HEAD request to remote URI." + } + ] + }, + %{ + key: :whitelist, + type: {:list, :string}, + description: "List of hosts with scheme to bypass the mediaproxy", + suggestions: ["http://example.com"] + } + ] + }, + %{ + group: :pleroma, key: Pleroma.Web.MediaProxy.Invalidation.Http, type: :group, description: "HTTP invalidate settings", diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex new file mode 100644 index 000000000..9bd815c26 --- /dev/null +++ b/lib/pleroma/helpers/media_helper.ex @@ -0,0 +1,151 @@ +# 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. + """ + + @tmp_base "/tmp/pleroma-media_preview-pipe" + + def image_resize(url, options) do + with executable when is_binary(executable) <- System.find_executable("convert"), + {:ok, args} <- prepare_image_resize_args(options), + url = Pleroma.Web.MediaProxy.url(url), + {:ok, env} <- Pleroma.HTTP.get(url), + {: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} + + def video_framegrab(url) do + with executable when is_binary(executable) <- System.find_executable("ffmpeg"), + url = Pleroma.Web.MediaProxy.url(url), + {:ok, env} <- Pleroma.HTTP.get(url), + {: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 = "#{@tmp_base}#{to_charlist(:erlang.phash2(self()))}" + + 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 index 000000000..bb93224b5 --- /dev/null +++ b/lib/pleroma/helpers/qt_fast_start.ex @@ -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 diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 0de4e2309..35637e934 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -17,6 +17,8 @@ defmodule Pleroma.ReverseProxy do @failed_request_ttl :timer.seconds(60) @methods ~w(GET HEAD) + def max_read_duration_default, do: @max_read_duration + @moduledoc """ A reverse proxy. @@ -391,6 +393,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 diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index dfab105a3..5d8bb72aa 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do end url - |> MediaProxy.url() + |> MediaProxy.preview_url() |> HTTP.get([], adapter: opts) end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index d2a30a548..7eb4e86fe 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -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() header = User.banner_url(user) |> MediaProxy.url() + header_static = User.banner_url(user) |> MediaProxy.preview_url() 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, diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 3fe1967be..0882d680e 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -433,6 +433,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 @@ -448,7 +449,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"], diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index e18dd8224..6695d49ce 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -40,7 +40,7 @@ defmodule Pleroma.Web.MediaProxy do def url("/" <> _ = url), do: url def url(url) do - if disabled?() or not url_proxiable?(url) do + if not enabled?() or not url_proxiable?(url) do url else encode_url(url) @@ -56,11 +56,25 @@ defmodule Pleroma.Web.MediaProxy do end end - defp disabled?, do: !Config.get([:media_proxy, :enabled], false) + # Note: routing all URLs to preview handler (even local and whitelisted). + # Preview handler will call url/1 on decoded URLs, and applicable ones will detour media proxy. + def preview_url(url) do + if preview_enabled?() do + encode_preview_url(url) + else + url + end + end - defp local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) + def enabled?, do: Config.get([:media_proxy, :enabled], false) - defp whitelisted?(url) do + # 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], false) + + def local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) + + def whitelisted?(url) do %{host: domain} = URI.parse(url) mediaproxy_whitelist_domains = @@ -85,17 +99,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) do + {base64, sig64} = base64_sig64(url) + + build_preview_url(sig64, base64, filename(url)) + 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 +139,10 @@ 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 + defp proxy_url(path, sig_base64, url_base64, filename) do [ Config.get([:media_proxy, :base_url], Web.base_url()), - "proxy", + path, sig_base64, url_base64, filename @@ -124,4 +150,36 @@ 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) do + proxy_url("proxy/preview", sig_base64, url_base64, filename) + 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 diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 9a64b0ef3..961c73666 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -5,20 +5,22 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do use Pleroma.Web, :controller + alias Pleroma.Config + alias Pleroma.Helpers.MediaHelper alias Pleroma.ReverseProxy alias Pleroma.Web.MediaProxy - @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}] -> + {:enabled, false} -> + send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) + + {:in_banned_urls, true} -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) {:error, :invalid_signature} -> @@ -29,20 +31,158 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - def filename_matches(%{"filename" => _} = _, path, url) do - filename = MediaProxy.filename(url) + def preview(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, Plug.Conn.Status.reason_phrase(404)) + + {:error, :invalid_signature} -> + send_resp(conn, 403, Plug.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 + with {:ok, %{status: status} = head_response} when status in 200..299 <- + Tesla.head(url, + opts: [adapter: [timeout: preview_head_request_timeout(), follow_redirect: true]] + ) do + content_type = Tesla.get_header(head_response, "content-type") + handle_preview(content_type, conn, url) + else + {_, %{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 + + # TODO: find a workaround so avatar_static and header_static can work. + # Those only permit GIFs for animation, so we have to permit a way to + # allow those to get real static variants. + defp handle_preview("image/gif" = _content_type, conn, url) do + mediaproxy_url = url |> MediaProxy.url() + + redirect(conn, external: mediaproxy_url) + end + + defp handle_preview("image/png" <> _ = _content_type, conn, url) do + handle_png_preview(conn, url) + end + + defp handle_preview("image/" <> _ = _content_type, conn, url) do + handle_jpeg_preview(conn, url) + end + + defp handle_preview("video/" <> _ = _content_type, conn, url) do + handle_video_preview(conn, url) + end + + defp handle_preview(content_type, conn, _url) do + send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") + end + + defp handle_png_preview(%{params: params} = conn, url) do + quality = Config.get!([:media_preview_proxy, :image_quality]) + + with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(params), + {:ok, thumbnail_binary} <- + MediaHelper.image_resize( + 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 + _ -> + send_resp(conn, :failed_dependency, "Can't handle preview.") + end + end + + defp handle_jpeg_preview(%{params: params} = conn, url) do + quality = Config.get!([:media_preview_proxy, :image_quality]) - if filename && does_not_match(path, filename) do - {:wrong_filename, filename} + with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(params), + {:ok, thumbnail_binary} <- + MediaHelper.image_resize( + 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 + _ -> + send_resp(conn, :failed_dependency, "Can't handle preview.") end end - def filename_matches(_, _, _), do: :ok + defp handle_video_preview(conn, url) do + with {:ok, thumbnail_binary} <- + MediaHelper.video_framegrab(url) do + conn + |> put_preview_response_headers() + |> send_resp(200, thumbnail_binary) + else + _ -> + send_resp(conn, :failed_dependency, "Can't handle preview.") + end + end + + defp put_preview_response_headers(conn, content_type \\ "image/jpeg", filename \\ "preview.jpg") do + conn + |> put_resp_header("content-type", content_type) + |> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"") + |> put_resp_header("cache-control", "max-age=0, private, must-revalidate") + end + + defp thumbnail_max_dimensions(params) do + config = Config.get([:media_preview_proxy], []) + + thumbnail_max_width = + if w = params["thumbnail_max_width"] do + String.to_integer(w) + else + Keyword.fetch!(config, :thumbnail_max_width) + end + + thumbnail_max_height = + if h = params["thumbnail_max_height"] do + String.to_integer(h) + else + Keyword.fetch!(config, :thumbnail_max_height) + end + + {thumbnail_max_width, thumbnail_max_height} + end + + defp preview_head_request_timeout do + Keyword.get(media_preview_proxy_opts(), :head_request_max_read_duration) || + Keyword.get(media_proxy_opts(), :max_read_duration) || + ReverseProxy.max_read_duration_default() + end + + defp media_proxy_opts do + Config.get([:media_proxy, :proxy_opts], []) + 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_preview_proxy_opts do + Config.get([:media_preview_proxy, :proxy_opts], []) end end diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 2f0dfb474..8a206e019 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index c6433cc53..edb635ecc 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -670,6 +670,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 @@ -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/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index d4db44c63..0dd2fd10c 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do import Mock alias Pleroma.Web.MediaProxy - alias Pleroma.Web.MediaProxy.MediaProxyController alias Plug.Conn setup do @@ -79,43 +78,4 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do end 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 - end - end end diff --git a/test/web/media_proxy/media_proxy_test.exs b/test/web/media_proxy/media_proxy_test.exs index 72885cfdd..0e6df826c 100644 --- a/test/web/media_proxy/media_proxy_test.exs +++ b/test/web/media_proxy/media_proxy_test.exs @@ -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) |