diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/pleroma/helpers/media_helper.ex | 62 | ||||
-rw-r--r-- | lib/pleroma/reverse_proxy/reverse_proxy.ex | 4 | ||||
-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 | 104 | ||||
-rw-r--r-- | lib/pleroma/web/router.ex | 2 |
6 files changed, 223 insertions, 26 deletions
diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex new file mode 100644 index 000000000..6d1f8ab22 --- /dev/null +++ b/lib/pleroma/helpers/media_helper.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.MediaHelper do + @moduledoc """ + Handles common media-related operations. + """ + + @ffmpeg_opts [{:sync, true}, {:stdout, true}] + + def ffmpeg_resize_remote(uri, max_width, max_height) do + cmd = ~s""" + curl -L "#{uri}" | + ffmpeg -i pipe:0 -vf \ + "scale='min(#{max_width},iw)':min'(#{max_height},ih)':force_original_aspect_ratio=decrease" \ + -f image2 pipe:1 | \ + cat + """ + + with {:ok, [stdout: stdout_list]} <- Exexec.run(cmd, @ffmpeg_opts) do + {:ok, Enum.join(stdout_list)} + end + end + + @doc "Returns a temporary path for an URI" + def temporary_path_for(uri) do + name = Path.basename(uri) + random = rand_uniform(999_999) + Path.join(System.tmp_dir(), "#{random}-#{name}") + end + + @doc "Stores binary content fetched from specified URL as a temporary file." + @spec store_as_temporary_file(String.t(), binary()) :: {:ok, String.t()} | {:error, atom()} + def store_as_temporary_file(url, body) do + path = temporary_path_for(url) + with :ok <- File.write(path, body), do: {:ok, path} + end + + @doc "Modifies image file at specified path by resizing to specified limit dimensions." + @spec mogrify_resize_to_limit(String.t(), String.t()) :: :ok | any() + def mogrify_resize_to_limit(path, resize_dimensions) do + with %Mogrify.Image{} <- + path + |> Mogrify.open() + |> Mogrify.resize_to_limit(resize_dimensions) + |> Mogrify.save(in_place: true) do + :ok + end + end + + defp rand_uniform(high) do + Code.ensure_loaded(:rand) + + if function_exported?(:rand, :uniform, 1) do + :rand.uniform(high) + else + # Erlang/OTP < 19 + apply(:crypto, :rand_uniform, [1, high]) + end + end +end diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 4bbeb493c..aeaf9bd39 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -16,6 +16,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. @@ -370,6 +372,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/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 8e3715093..e31e45553 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -413,6 +413,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 @@ -428,7 +429,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 b2b524524..4e01c14e4 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -13,18 +13,32 @@ defmodule Pleroma.Web.MediaProxy do def url("/" <> _ = url), do: url def url(url) do - if disabled?() or local?(url) or whitelisted?(url) do + if not enabled?() or local?(url) or whitelisted?(url) do url else encode_url(url) 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 = Config.get([:media_proxy, :whitelist]) @@ -43,17 +57,29 @@ defmodule Pleroma.Web.MediaProxy do end) end - 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 @@ -71,10 +97,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 [ Pleroma.Config.get([:media_proxy, :base_url], Web.base_url()), - "proxy", + path, sig_base64, url_base64, filename @@ -82,4 +108,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 4657a4383..fb4b80379 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -5,19 +5,19 @@ 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), - :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 + proxy_opts = Config.get([:media_proxy, :proxy_opts], []) + ReverseProxy.call(conn, url, proxy_opts) else - false -> + {:enabled, false} -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) {:error, :invalid_signature} -> @@ -28,20 +28,90 @@ 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)) - if filename && does_not_match(path, filename) do - {:wrong_filename, filename} + {: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()]]) do + content_type = Tesla.get_header(head_response, "content-type") + handle_preview(content_type, conn, url) else - :ok + {_, %{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 - def filename_matches(_, _, _), do: :ok + 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 handle_preview("image/" <> _ = content_type, %{params: params} = conn, url) do + with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(params), + media_proxy_url <- MediaProxy.url(url), + {:ok, thumbnail_binary} <- + MediaHelper.ffmpeg_resize_remote( + media_proxy_url, + thumbnail_max_width, + thumbnail_max_height + ) do + conn + |> put_resp_header("content-type", content_type) + |> send_resp(200, thumbnail_binary) + else + _ -> + send_resp(conn, :failed_dependency, "Can't handle image preview.") + end + end + + defp handle_preview(content_type, conn, _url) do + send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") + end + + defp preview_head_request_timeout do + Config.get([:media_preview_proxy, :proxy_opts, :head_request_max_read_duration]) || + preview_timeout() + 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 preview_timeout do + Config.get([:media_preview_proxy, :proxy_opts, :max_read_duration]) || + Config.get([:media_proxy, :proxy_opts, :max_read_duration]) || + ReverseProxy.max_read_duration_default() end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 4cacf6255..c0599a39c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -667,6 +667,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 |