From bbdad8556861c60ae1f526f63de9c5857c4ad547 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 8 May 2020 23:06:47 +0300 Subject: Initial implementation of image preview proxy. Media proxy tests refactoring. --- config/config.exs | 5 + lib/pleroma/helpers/mogrify_helper.ex | 25 ++++ lib/pleroma/web/mastodon_api/views/status_view.ex | 3 +- lib/pleroma/web/media_proxy/media_proxy.ex | 53 +++++++- .../web/media_proxy/media_proxy_controller.ex | 76 ++++++++++-- lib/pleroma/web/router.ex | 2 + test/web/media_proxy/media_proxy_test.exs | 133 ++++++++------------- 7 files changed, 197 insertions(+), 100 deletions(-) create mode 100644 lib/pleroma/helpers/mogrify_helper.ex diff --git a/config/config.exs b/config/config.exs index e703c1632..526901f83 100644 --- a/config/config.exs +++ b/config/config.exs @@ -388,6 +388,11 @@ config :pleroma, :media_proxy, ], whitelist: [] +config :pleroma, :media_preview_proxy, + enabled: false, + limit_dimensions: "400x200", + max_body_length: 25 * 1_048_576 + config :pleroma, :chat, enabled: true config :phoenix, :format_encoders, json: Jason diff --git a/lib/pleroma/helpers/mogrify_helper.ex b/lib/pleroma/helpers/mogrify_helper.ex new file mode 100644 index 000000000..67edb35c3 --- /dev/null +++ b/lib/pleroma/helpers/mogrify_helper.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.MogrifyHelper do + @moduledoc """ + Handles common Mogrify operations. + """ + + @spec store_as_temporary_file(String.t(), binary()) :: {:ok, String.t()} | {:error, atom()} + @doc "Stores binary content fetched from specified URL as a temporary file." + def store_as_temporary_file(url, body) do + path = Mogrify.temporary_path_for(%{path: url}) + with :ok <- File.write(path, body), do: {:ok, path} + end + + @spec store_as_temporary_file(String.t(), String.t()) :: Mogrify.Image.t() | any() + @doc "Modifies file at specified path by resizing to specified limit dimensions." + def in_place_resize_to_limit(path, resize_dimensions) do + path + |> Mogrify.open() + |> Mogrify.resize_to_limit(resize_dimensions) + |> Mogrify.save(in_place: true) + end +end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 24167f66f..2a206f743 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -419,6 +419,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 @@ -434,7 +435,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..f4791c758 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -20,6 +20,14 @@ defmodule Pleroma.Web.MediaProxy do end end + def preview_url(url) do + if disabled?() or whitelisted?(url) do + url + else + encode_preview_url(url) + end + end + defp disabled?, do: !Config.get([:media_proxy, :enabled], false) defp local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) @@ -43,17 +51,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 +91,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 +102,29 @@ 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 filename_matches(%{"filename" => _} = _, path, url) do + filename = filename(url) + + if filename && not basename_matches?(path, filename) do + {:wrong_filename, filename} + else + :ok + end + end + + def filename_matches(_, _, _), 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..fe3f61c18 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -5,19 +5,21 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do use Pleroma.Web, :controller + alias Pleroma.Config + alias Pleroma.Helpers.MogrifyHelper 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), + with config <- Config.get([:media_proxy], []), + {_, true} <- {:enabled, Keyword.get(config, :enabled, false)}, {:ok, url} <- MediaProxy.decode_url(sig64, url64), - :ok <- filename_matches(params, conn.request_path, url) do + :ok <- MediaProxy.filename_matches(params, conn.request_path, url) do ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts)) else - false -> + {:enabled, false} -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) {:error, :invalid_signature} -> @@ -28,20 +30,68 @@ 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} = params) do + with {_, true} <- {:enabled, Config.get([:media_preview_proxy, :enabled], false)}, + {:ok, url} <- MediaProxy.decode_url(sig64, url64), + :ok <- MediaProxy.filename_matches(params, conn.request_path, 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 - if filename && does_not_match(path, filename) do - {:wrong_filename, filename} + defp handle_preview(conn, url) do + with {:ok, %{status: status} = head_response} when status in 200..299 <- Tesla.head(url), + {_, true} <- {:acceptable_content_length, acceptable_body_length?(head_response)} 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}).") + + {:acceptable_content_length, false} -> + send_resp(conn, :unprocessable_entity, "Source file size exceeds limit.") end end - def filename_matches(_, _, _), do: :ok + defp handle_preview("image/" <> _, %{params: params} = conn, url) do + with {:ok, %{status: status, body: body}} when status in 200..299 <- Tesla.get(url), + {:ok, path} <- MogrifyHelper.store_as_temporary_file(url, body), + resize_dimensions <- + Map.get( + params, + "limit_dimensions", + Config.get([:media_preview_proxy, :limit_dimensions]) + ), + %Mogrify.Image{} <- MogrifyHelper.in_place_resize_to_limit(path, resize_dimensions) do + send_file(conn, 200, path) + else + {_, %{status: _}} -> + send_resp(conn, :failed_dependency, "Can't fetch the image.") + + _ -> + 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 acceptable_body_length?(head_response) do + max_body_length = Config.get([:media_preview_proxy, :max_body_length], nil) + content_length = Tesla.get_header(head_response, "content-length") + content_length = with {int, _} <- Integer.parse(content_length), do: int - defp does_not_match(path, filename) do - basename = Path.basename(path) - basename != filename and URI.decode(basename) != filename and URI.encode(basename) != filename + content_length == :error or + max_body_length in [nil, :infinity] or + content_length <= max_body_length end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 7a171f9fb..6fb47029a 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -663,6 +663,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 diff --git a/test/web/media_proxy/media_proxy_test.exs b/test/web/media_proxy/media_proxy_test.exs index 69c2d5dae..cad0acd30 100644 --- a/test/web/media_proxy/media_proxy_test.exs +++ b/test/web/media_proxy/media_proxy_test.exs @@ -5,42 +5,44 @@ defmodule Pleroma.Web.MediaProxyTest do use ExUnit.Case use Pleroma.Tests.Helpers - import Pleroma.Web.MediaProxy - alias Pleroma.Web.MediaProxy.MediaProxyController - setup do: clear_config([:media_proxy, :enabled]) - setup do: clear_config(Pleroma.Upload) + 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 - Pleroma.Config.put([:media_proxy, :enabled], true) - :ok - end + setup do: clear_config([:media_proxy, :enabled], true) test "ignores invalid url" do - assert url(nil) == nil - assert url("") == nil + assert MediaProxy.url(nil) == nil + assert MediaProxy.url("") == nil end test "ignores relative url" do - assert url("/local") == "/local" - assert url("/") == "/" + assert MediaProxy.url("/local") == "/local" + assert MediaProxy.url("/") == "/" end test "ignores local url" do - local_url = Pleroma.Web.Endpoint.url() <> "/hello" - local_root = Pleroma.Web.Endpoint.url() - assert url(local_url) == local_url - assert url(local_root) == local_root + local_url = Endpoint.url() <> "/hello" + local_root = Endpoint.url() + assert MediaProxy.url(local_url) == local_url + assert MediaProxy.url(local_root) == local_root end test "encodes and decodes URL" do url = "https://pleroma.soykaf.com/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) 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") @@ -50,62 +52,59 @@ defmodule Pleroma.Web.MediaProxyTest do test "encodes and decodes URL without a path" do url = "https://pleroma.soykaf.com" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end test "encodes and decodes URL without an extension" do url = "https://pleroma.soykaf.com/path/" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.ends_with?(encoded, "/path") assert decode_result(encoded) == url end test "encodes and decodes URL and ignores query params for the path" do url = "https://pleroma.soykaf.com/static/logo.png?93939393939&bunny=true" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.ends_with?(encoded, "/logo.png") assert decode_result(encoded) == url end test "validates signature" do - secret_key_base = Pleroma.Config.get([Pleroma.Web.Endpoint, :secret_key_base]) - - on_exit(fn -> - Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], secret_key_base) - end) + secret_key_base = Config.get([Endpoint, :secret_key_base]) + clear_config([Endpoint, :secret_key_base], secret_key_base) - encoded = url("https://pleroma.social") + encoded = MediaProxy.url("https://pleroma.social") - Pleroma.Config.put( - [Pleroma.Web.Endpoint, :secret_key_base], + Config.put( + [Endpoint, :secret_key_base], "00000000000000000000000000000000000000000000000" ) [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/") - assert decode_url(sig, base64) == {:error, :invalid_signature} + assert MediaProxy.decode_url(sig, base64) == {:error, :invalid_signature} end - test "filename_matches preserves the encoded or decoded path" do - assert MediaProxyController.filename_matches( + test "`filename_matches/_` preserves the encoded or decoded path" do + assert MediaProxy.filename_matches( %{"filename" => "/Hello world.jpg"}, "/Hello world.jpg", "http://pleroma.social/Hello world.jpg" ) == :ok - assert MediaProxyController.filename_matches( + assert MediaProxy.filename_matches( %{"filename" => "/Hello%20world.jpg"}, "/Hello%20world.jpg", "http://pleroma.social/Hello%20world.jpg" ) == :ok - assert MediaProxyController.filename_matches( + assert MediaProxy.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( + assert MediaProxy.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" @@ -116,7 +115,7 @@ defmodule Pleroma.Web.MediaProxyTest 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( + assert MediaProxy.filename_matches( true, request_path, "https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg" @@ -124,20 +123,12 @@ defmodule Pleroma.Web.MediaProxyTest do end test "uses the configured base_url" do - base_url = Pleroma.Config.get([:media_proxy, :base_url]) - - if base_url do - on_exit(fn -> - Pleroma.Config.put([:media_proxy, :base_url], base_url) - end) - end - - Pleroma.Config.put([:media_proxy, :base_url], "https://cache.pleroma.social") + clear_config([:media_proxy, :base_url], "https://cache.pleroma.social") url = "https://pleroma.soykaf.com/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) - assert String.starts_with?(encoded, Pleroma.Config.get([:media_proxy, :base_url])) + assert String.starts_with?(encoded, Config.get([:media_proxy, :base_url])) end # Some sites expect ASCII encoded characters in the URL to be preserved even if @@ -148,7 +139,7 @@ defmodule Pleroma.Web.MediaProxyTest do url = "https://pleroma.com/%20/%21/%22/%23/%24/%25/%26/%27/%28/%29/%2A/%2B/%2C/%2D/%2E/%2F/%30/%31/%32/%33/%34/%35/%36/%37/%38/%39/%3A/%3B/%3C/%3D/%3E/%3F/%40/%41/%42/%43/%44/%45/%46/%47/%48/%49/%4A/%4B/%4C/%4D/%4E/%4F/%50/%51/%52/%53/%54/%55/%56/%57/%58/%59/%5A/%5B/%5C/%5D/%5E/%5F/%60/%61/%62/%63/%64/%65/%66/%67/%68/%69/%6A/%6B/%6C/%6D/%6E/%6F/%70/%71/%72/%73/%74/%75/%76/%77/%78/%79/%7A/%7B/%7C/%7D/%7E/%7F/%80/%81/%82/%83/%84/%85/%86/%87/%88/%89/%8A/%8B/%8C/%8D/%8E/%8F/%90/%91/%92/%93/%94/%95/%96/%97/%98/%99/%9A/%9B/%9C/%9D/%9E/%9F/%C2%A0/%A1/%A2/%A3/%A4/%A5/%A6/%A7/%A8/%A9/%AA/%AB/%AC/%C2%AD/%AE/%AF/%B0/%B1/%B2/%B3/%B4/%B5/%B6/%B7/%B8/%B9/%BA/%BB/%BC/%BD/%BE/%BF/%C0/%C1/%C2/%C3/%C4/%C5/%C6/%C7/%C8/%C9/%CA/%CB/%CC/%CD/%CE/%CF/%D0/%D1/%D2/%D3/%D4/%D5/%D6/%D7/%D8/%D9/%DA/%DB/%DC/%DD/%DE/%DF/%E0/%E1/%E2/%E3/%E4/%E5/%E6/%E7/%E8/%E9/%EA/%EB/%EC/%ED/%EE/%EF/%F0/%F1/%F2/%F3/%F4/%F5/%F6/%F7/%F8/%F9/%FA/%FB/%FC/%FD/%FE/%FF" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end @@ -159,77 +150,55 @@ defmodule Pleroma.Web.MediaProxyTest do url = "https://pleroma.com/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890-._~:/?#[]@!$&'()*+,;=|^`{}" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end test "preserve unicode characters" do url = "https://ko.wikipedia.org/wiki/위키백과:대문" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end end describe "when disabled" do - setup do - enabled = Pleroma.Config.get([:media_proxy, :enabled]) - - if enabled do - Pleroma.Config.put([:media_proxy, :enabled], false) - - on_exit(fn -> - Pleroma.Config.put([:media_proxy, :enabled], enabled) - :ok - end) - end - - :ok - end + setup do: clear_config([:media_proxy, :enabled], false) test "does not encode remote urls" do - assert url("https://google.fr") == "https://google.fr" + assert MediaProxy.url("https://google.fr") == "https://google.fr" end end - defp decode_result(encoded) do - [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/") - {:ok, decoded} = decode_url(sig, base64) - decoded - end - describe "whitelist" do - setup do - Pleroma.Config.put([:media_proxy, :enabled], true) - :ok - end + setup do: clear_config([:media_proxy, :enabled], true) test "mediaproxy whitelist" do - Pleroma.Config.put([:media_proxy, :whitelist], ["google.com", "feld.me"]) + clear_config([:media_proxy, :whitelist], ["google.com", "feld.me"]) url = "https://feld.me/foo.png" - unencoded = url(url) + unencoded = MediaProxy.url(url) assert unencoded == url end test "does not change whitelisted urls" do - Pleroma.Config.put([:media_proxy, :whitelist], ["mycdn.akamai.com"]) - Pleroma.Config.put([:media_proxy, :base_url], "https://cache.pleroma.social") + clear_config([:media_proxy, :whitelist], ["mycdn.akamai.com"]) + clear_config([:media_proxy, :base_url], "https://cache.pleroma.social") media_url = "https://mycdn.akamai.com" url = "#{media_url}/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.starts_with?(encoded, media_url) end test "ensure Pleroma.Upload base_url is always whitelisted" do media_url = "https://media.pleroma.social" - Pleroma.Config.put([Pleroma.Upload, :base_url], media_url) + clear_config([Pleroma.Upload, :base_url], media_url) url = "#{media_url}/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.starts_with?(encoded, media_url) end -- cgit v1.2.3 From 1b23acf164ebc4fde3fe1e4fdca6e11b7caa90ef Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 11 May 2020 23:21:53 +0300 Subject: [#2497] Media preview proxy for images: fixes, tweaks, refactoring, tests adjustments. --- config/config.exs | 8 ++- lib/pleroma/reverse_proxy/reverse_proxy.ex | 4 ++ lib/pleroma/web/media_proxy/media_proxy.ex | 33 +++++++---- .../web/media_proxy/media_proxy_controller.ex | 62 ++++++++++++-------- mix.exs | 1 + mix.lock | 2 + test/web/media_proxy/media_proxy_test.exs | 66 +++++++++++++++------- 7 files changed, 119 insertions(+), 57 deletions(-) diff --git a/config/config.exs b/config/config.exs index 526901f83..0f92b1ef9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -381,6 +381,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 @@ -388,10 +390,14 @@ config :pleroma, :media_proxy, ], whitelist: [] +# Note: media preview proxy depends on media proxy to be enabled config :pleroma, :media_preview_proxy, enabled: false, limit_dimensions: "400x200", - max_body_length: 25 * 1_048_576 + proxy_opts: [ + head_request_max_read_duration: 5_000, + max_read_duration: 10_000 + ] config :pleroma, :chat, enabled: true 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/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index f4791c758..4e01c14e4 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -13,26 +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 + # 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 disabled?() or whitelisted?(url) do - url - else + if preview_enabled?() do encode_preview_url(url) + else + url end end - defp disabled?, do: !Config.get([:media_proxy, :enabled], false) + def enabled?, do: Config.get([:media_proxy, :enabled], false) - defp local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) + # 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) - defp whitelisted?(url) do + 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]) @@ -111,17 +117,24 @@ defmodule Pleroma.Web.MediaProxy do proxy_url("proxy/preview", sig_base64, url_base64, filename) end - def filename_matches(%{"filename" => _} = _, path, url) do + 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?(path, filename) do + if filename && not basename_matches?(request_path, filename) do {:wrong_filename, filename} else :ok end end - def filename_matches(_, _, _), do: :ok + def verify_request_path_and_url(_, _), do: :ok defp basename_matches?(path, filename) do basename = Path.basename(path) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index fe3f61c18..157365e08 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -10,14 +10,12 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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 <- Config.get([:media_proxy], []), - {_, true} <- {:enabled, 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 <- MediaProxy.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 {:enabled, false} -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) @@ -30,10 +28,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - def preview(conn, %{"sig" => sig64, "url" => url64} = params) do - with {_, true} <- {:enabled, Config.get([:media_preview_proxy, :enabled], false)}, + def preview(conn, %{"sig" => sig64, "url" => url64}) do + with {_, true} <- {:enabled, MediaProxy.preview_enabled?()}, {:ok, url} <- MediaProxy.decode_url(sig64, url64), - :ok <- MediaProxy.filename_matches(params, conn.request_path, url) do + :ok <- MediaProxy.verify_request_path_and_url(conn, url) do handle_preview(conn, url) else {:enabled, false} -> @@ -48,21 +46,27 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end defp handle_preview(conn, url) do - with {:ok, %{status: status} = head_response} when status in 200..299 <- Tesla.head(url), - {_, true} <- {:acceptable_content_length, acceptable_body_length?(head_response)} 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 {_, %{status: status}} -> send_resp(conn, :failed_dependency, "Can't fetch HTTP headers (HTTP #{status}).") - {:acceptable_content_length, false} -> - send_resp(conn, :unprocessable_entity, "Source file size exceeds limit.") + {: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/" <> _, %{params: params} = conn, url) do - with {:ok, %{status: status, body: body}} when status in 200..299 <- Tesla.get(url), + defp handle_preview("image/" <> _ = content_type, %{params: params} = conn, url) do + with {:ok, %{status: status, body: body}} when status in 200..299 <- + url + |> MediaProxy.url() + |> Tesla.get(opts: [adapter: [timeout: preview_timeout()]]), {:ok, path} <- MogrifyHelper.store_as_temporary_file(url, body), resize_dimensions <- Map.get( @@ -70,12 +74,19 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do "limit_dimensions", Config.get([:media_preview_proxy, :limit_dimensions]) ), - %Mogrify.Image{} <- MogrifyHelper.in_place_resize_to_limit(path, resize_dimensions) do - send_file(conn, 200, path) + %Mogrify.Image{} <- MogrifyHelper.in_place_resize_to_limit(path, resize_dimensions), + {:ok, image_binary} <- File.read(path), + _ <- File.rm(path) do + conn + |> put_resp_header("content-type", content_type) + |> send_resp(200, image_binary) else {_, %{status: _}} -> send_resp(conn, :failed_dependency, "Can't fetch the image.") + {:error, :recv_response_timeout} -> + send_resp(conn, :failed_dependency, "Downstream timeout.") + _ -> send_resp(conn, :failed_dependency, "Can't handle image preview.") end @@ -85,13 +96,14 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") end - defp acceptable_body_length?(head_response) do - max_body_length = Config.get([:media_preview_proxy, :max_body_length], nil) - content_length = Tesla.get_header(head_response, "content-length") - content_length = with {int, _} <- Integer.parse(content_length), do: int + defp preview_head_request_timeout do + Config.get([:media_preview_proxy, :proxy_opts, :head_request_max_read_duration]) || + preview_timeout() + end - content_length == :error or - max_body_length in [nil, :infinity] or - content_length <= max_body_length + 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/mix.exs b/mix.exs index 6d65e18d4..a9c4ad2e3 100644 --- a/mix.exs +++ b/mix.exs @@ -139,6 +139,7 @@ defmodule Pleroma.Mixfile do github: "ninenines/gun", ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc", override: true}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, + {:eimp, ">= 0.0.0"}, {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, diff --git a/mix.lock b/mix.lock index c400202b7..ede7b0ada 100644 --- a/mix.lock +++ b/mix.lock @@ -29,6 +29,7 @@ "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, "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.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, + "eimp": {:hex, :eimp, "1.0.14", "fc297f0c7e2700457a95a60c7010a5f1dcb768a083b6d53f49cd94ab95a28f22", [:rebar3], [{:p1_utils, "1.0.18", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "501133f3112079b92d9e22da8b88bf4f0e13d4d67ae9c15c42c30bd25ceb83b6"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, @@ -75,6 +76,7 @@ "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [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", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]}, + "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, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"}, "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [: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", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"}, diff --git a/test/web/media_proxy/media_proxy_test.exs b/test/web/media_proxy/media_proxy_test.exs index cad0acd30..ac5d8fd32 100644 --- a/test/web/media_proxy/media_proxy_test.exs +++ b/test/web/media_proxy/media_proxy_test.exs @@ -85,38 +85,62 @@ defmodule Pleroma.Web.MediaProxyTest do assert MediaProxy.decode_url(sig, base64) == {:error, :invalid_signature} end - test "`filename_matches/_` preserves the encoded or decoded path" do - assert MediaProxy.filename_matches( - %{"filename" => "/Hello world.jpg"}, - "/Hello world.jpg", - "http://pleroma.social/Hello world.jpg" + 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.filename_matches( - %{"filename" => "/Hello%20world.jpg"}, - "/Hello%20world.jpg", - "http://pleroma.social/Hello%20world.jpg" + assert MediaProxy.verify_request_path_and_url( + %Plug.Conn{params: %{}, request_path: "/path/to/file.jpg"}, + "https://instance.com/file.jpg" ) == :ok + end - assert MediaProxy.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 + 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 + ) - assert MediaProxy.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"} + 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 "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 MediaProxy.filename_matches( - true, + assert MediaProxy.verify_request_path_and_url( request_path, "https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg" ) == :ok -- cgit v1.2.3 From f1f588fd5271c0b3bf09df002a83dbb57c42bae0 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 14 May 2020 20:18:31 +0300 Subject: [#2497] Added support for :eimp for image resizing. --- config/config.exs | 4 +- .../web/media_proxy/media_proxy_controller.ex | 64 ++++++++++++++++++---- mix.exs | 2 +- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/config/config.exs b/config/config.exs index 0f92b1ef9..e9403c7c8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -393,7 +393,9 @@ config :pleroma, :media_proxy, # Note: media preview proxy depends on media proxy to be enabled config :pleroma, :media_preview_proxy, enabled: false, - limit_dimensions: "400x200", + enable_eimp: true, + thumbnail_max_width: 400, + thumbnail_max_height: 200, proxy_opts: [ head_request_max_read_duration: 5_000, max_read_duration: 10_000 diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 157365e08..8d8d073e9 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -62,24 +62,64 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end 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 thumbnail_binary(url, body, params) do + {thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions(params) + + with true <- Config.get([:media_preview_proxy, :enable_eimp]), + {:ok, [type: image_type, width: source_width, height: source_height]} <- + :eimp.identify(body), + scale_factor <- + Enum.max([source_width / thumbnail_max_width, source_height / thumbnail_max_height]), + {:ok, thumbnail_binary} = + :eimp.convert(body, image_type, [ + {:scale, {round(source_width / scale_factor), round(source_height / scale_factor)}} + ]) do + {:ok, thumbnail_binary} + else + _ -> + mogrify_dimensions = "#{thumbnail_max_width}x#{thumbnail_max_height}" + + with {:ok, path} <- MogrifyHelper.store_as_temporary_file(url, body), + %Mogrify.Image{} <- + MogrifyHelper.in_place_resize_to_limit(path, mogrify_dimensions), + {:ok, thumbnail_binary} <- File.read(path), + _ <- File.rm(path) do + {:ok, thumbnail_binary} + else + _ -> :error + end + end + end + defp handle_preview("image/" <> _ = content_type, %{params: params} = conn, url) do - with {:ok, %{status: status, body: body}} when status in 200..299 <- + with {:ok, %{status: status, body: image_contents}} when status in 200..299 <- url |> MediaProxy.url() |> Tesla.get(opts: [adapter: [timeout: preview_timeout()]]), - {:ok, path} <- MogrifyHelper.store_as_temporary_file(url, body), - resize_dimensions <- - Map.get( - params, - "limit_dimensions", - Config.get([:media_preview_proxy, :limit_dimensions]) - ), - %Mogrify.Image{} <- MogrifyHelper.in_place_resize_to_limit(path, resize_dimensions), - {:ok, image_binary} <- File.read(path), - _ <- File.rm(path) do + {:ok, thumbnail_binary} <- thumbnail_binary(url, image_contents, params) do conn |> put_resp_header("content-type", content_type) - |> send_resp(200, image_binary) + |> send_resp(200, thumbnail_binary) else {_, %{status: _}} -> send_resp(conn, :failed_dependency, "Can't fetch the image.") diff --git a/mix.exs b/mix.exs index a9c4ad2e3..332febe48 100644 --- a/mix.exs +++ b/mix.exs @@ -139,7 +139,7 @@ defmodule Pleroma.Mixfile do github: "ninenines/gun", ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc", override: true}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, - {:eimp, ">= 0.0.0"}, + {:eimp, "~> 1.0.14"}, {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, -- cgit v1.2.3 From 1871a5ddb4a803ebe4fae6943a9b9c94f1f9c1a8 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 20 May 2020 20:26:43 +0300 Subject: [#2497] Image preview proxy: implemented ffmpeg-based resizing, removed eimp & mogrify-based resizing. --- config/config.exs | 1 - lib/pleroma/helpers/media_helper.ex | 62 ++++++++++++++++++++++ lib/pleroma/helpers/mogrify_helper.ex | 25 --------- .../web/media_proxy/media_proxy_controller.ex | 50 ++++------------- mix.exs | 2 +- mix.lock | 2 + 6 files changed, 74 insertions(+), 68 deletions(-) create mode 100644 lib/pleroma/helpers/media_helper.ex delete mode 100644 lib/pleroma/helpers/mogrify_helper.ex diff --git a/config/config.exs b/config/config.exs index e9403c7c8..7de93511d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -393,7 +393,6 @@ config :pleroma, :media_proxy, # Note: media preview proxy depends on media proxy to be enabled config :pleroma, :media_preview_proxy, enabled: false, - enable_eimp: true, thumbnail_max_width: 400, thumbnail_max_height: 200, proxy_opts: [ 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 +# 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/helpers/mogrify_helper.ex b/lib/pleroma/helpers/mogrify_helper.ex deleted file mode 100644 index 67edb35c3..000000000 --- a/lib/pleroma/helpers/mogrify_helper.ex +++ /dev/null @@ -1,25 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Helpers.MogrifyHelper do - @moduledoc """ - Handles common Mogrify operations. - """ - - @spec store_as_temporary_file(String.t(), binary()) :: {:ok, String.t()} | {:error, atom()} - @doc "Stores binary content fetched from specified URL as a temporary file." - def store_as_temporary_file(url, body) do - path = Mogrify.temporary_path_for(%{path: url}) - with :ok <- File.write(path, body), do: {:ok, path} - end - - @spec store_as_temporary_file(String.t(), String.t()) :: Mogrify.Image.t() | any() - @doc "Modifies file at specified path by resizing to specified limit dimensions." - def in_place_resize_to_limit(path, resize_dimensions) do - path - |> Mogrify.open() - |> Mogrify.resize_to_limit(resize_dimensions) - |> Mogrify.save(in_place: true) - 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 8d8d073e9..fb4b80379 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do use Pleroma.Web, :controller alias Pleroma.Config - alias Pleroma.Helpers.MogrifyHelper + alias Pleroma.Helpers.MediaHelper alias Pleroma.ReverseProxy alias Pleroma.Web.MediaProxy @@ -82,51 +82,19 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do {thumbnail_max_width, thumbnail_max_height} end - defp thumbnail_binary(url, body, params) do - {thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions(params) - - with true <- Config.get([:media_preview_proxy, :enable_eimp]), - {:ok, [type: image_type, width: source_width, height: source_height]} <- - :eimp.identify(body), - scale_factor <- - Enum.max([source_width / thumbnail_max_width, source_height / thumbnail_max_height]), - {:ok, thumbnail_binary} = - :eimp.convert(body, image_type, [ - {:scale, {round(source_width / scale_factor), round(source_height / scale_factor)}} - ]) do - {:ok, thumbnail_binary} - else - _ -> - mogrify_dimensions = "#{thumbnail_max_width}x#{thumbnail_max_height}" - - with {:ok, path} <- MogrifyHelper.store_as_temporary_file(url, body), - %Mogrify.Image{} <- - MogrifyHelper.in_place_resize_to_limit(path, mogrify_dimensions), - {:ok, thumbnail_binary} <- File.read(path), - _ <- File.rm(path) do - {:ok, thumbnail_binary} - else - _ -> :error - end - end - end - defp handle_preview("image/" <> _ = content_type, %{params: params} = conn, url) do - with {:ok, %{status: status, body: image_contents}} when status in 200..299 <- - url - |> MediaProxy.url() - |> Tesla.get(opts: [adapter: [timeout: preview_timeout()]]), - {:ok, thumbnail_binary} <- thumbnail_binary(url, image_contents, params) 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 - {_, %{status: _}} -> - send_resp(conn, :failed_dependency, "Can't fetch the image.") - - {:error, :recv_response_timeout} -> - send_resp(conn, :failed_dependency, "Downstream timeout.") - _ -> send_resp(conn, :failed_dependency, "Can't handle image preview.") end diff --git a/mix.exs b/mix.exs index 9ace55eff..68de270f0 100644 --- a/mix.exs +++ b/mix.exs @@ -146,7 +146,6 @@ defmodule Pleroma.Mixfile do github: "ninenines/gun", ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc", override: true}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, - {:eimp, "~> 1.0.14"}, {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, @@ -198,6 +197,7 @@ defmodule Pleroma.Mixfile do ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, {:mox, "~> 0.5", only: :test}, {:restarter, path: "./restarter"}, + {:exexec, "~> 0.2"}, {:open_api_spex, git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"} diff --git a/mix.lock b/mix.lock index ebd0cbdf5..964b72127 100644 --- a/mix.lock +++ b/mix.lock @@ -32,6 +32,7 @@ "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, "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"}, + "erlexec": {:hex, :erlexec, "1.10.9", "3cbb3476f942bfb8b68b85721c21c1835061cf6dd35f5285c2362e85b100ddc7", [:rebar3], [], "hexpm", "271e5b5f2d91cdb9887efe74d89026c199bfc69f074cade0d08dab60993fa14e"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, @@ -42,6 +43,7 @@ "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"}, + "exexec": {:hex, :exexec, "0.2.0", "a6ffc48cba3ac9420891b847e4dc7120692fb8c08c9e82220ebddc0bb8d96103", [:mix], [{:erlexec, "~> 1.10", [hex: :erlexec, repo: "hexpm", optional: false]}], "hexpm", "312cd1c9befba9e078e57f3541e4f4257eabda6eb9c348154fe899d6ac633299"}, "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"}, "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, -- cgit v1.2.3 From 610343edb318654126d9539775ba4b9ff30c8831 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 21 May 2020 17:35:42 +0300 Subject: [#2497] Image preview proxy: image resize & background color fix with ffmpeg -filter_complex. --- lib/pleroma/helpers/media_helper.ex | 47 +++------------------- .../web/media_proxy/media_proxy_controller.ex | 7 ++-- 2 files changed, 9 insertions(+), 45 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 6d1f8ab22..ee6b76c41 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -9,12 +9,14 @@ defmodule Pleroma.Helpers.MediaHelper do @ffmpeg_opts [{:sync, true}, {:stdout, true}] - def ffmpeg_resize_remote(uri, max_width, max_height) do + def ffmpeg_resize_remote(uri, %{max_width: max_width, max_height: 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 | \ + ffmpeg -i pipe:0 -f lavfi -i color=c=white \ + -filter_complex "[0:v] scale='min(#{max_width},iw)':'min(#{max_height},ih)': \ + force_original_aspect_ratio=decrease [scaled]; \ + [1][scaled] scale2ref [bg][img]; [bg] setsar=1 [bg]; [bg][img] overlay=shortest=1" \ + -f image2 -vcodec mjpeg -frames:v 1 pipe:1 | \ cat """ @@ -22,41 +24,4 @@ defmodule Pleroma.Helpers.MediaHelper 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/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index fb4b80379..12d4401fa 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -82,17 +82,16 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do {thumbnail_max_width, thumbnail_max_height} end - defp handle_preview("image/" <> _ = content_type, %{params: params} = conn, url) do + 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 + %{max_width: thumbnail_max_width, max_height: thumbnail_max_height} ) do conn - |> put_resp_header("content-type", content_type) + |> put_resp_header("content-type", "image/jpeg") |> send_resp(200, thumbnail_binary) else _ -> -- cgit v1.2.3 From 3a1e810aaaea3e44c4dfc82a014485cf886d6b88 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 21 May 2020 21:47:32 +0300 Subject: [#2497] Customized `exexec` launch to support root operation (currently required by Gitlab CI). --- .gitlab-ci.yml | 1 + config/config.exs | 4 ++++ lib/pleroma/exec.ex | 38 +++++++++++++++++++++++++++++++++++++ lib/pleroma/helpers/media_helper.ex | 4 +--- mix.exs | 3 ++- test/exec_test.exs | 13 +++++++++++++ 6 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/exec.ex create mode 100644 test/exec_test.exs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aad28a2d8..14300f3bf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ variables: &global_variables POSTGRES_PASSWORD: postgres DB_HOST: postgres MIX_ENV: test + USER: root cache: &global_cache_policy key: ${CI_COMMIT_REF_SLUG} diff --git a/config/config.exs b/config/config.exs index 838508c1b..d1440b7bf 100644 --- a/config/config.exs +++ b/config/config.exs @@ -681,6 +681,10 @@ config :pleroma, :restrict_unauthenticated, config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false +config :pleroma, :exexec, + root_mode: false, + options: %{} + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/exec.ex b/lib/pleroma/exec.ex new file mode 100644 index 000000000..1b088d322 --- /dev/null +++ b/lib/pleroma/exec.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Exec do + @moduledoc "Pleroma wrapper around Exexec commands." + + alias Pleroma.Config + + def ensure_started(options_overrides \\ %{}) do + options = + if Config.get([:exexec, :root_mode]) || System.get_env("USER") == "root" do + # Note: running as `root` is discouraged (yet Gitlab CI does that by default) + %{root: true, user: "root", limit_users: ["root"]} + else + %{} + end + + options = + options + |> Map.merge(Config.get([:exexec, :options], %{})) + |> Map.merge(options_overrides) + + with {:error, {:already_started, pid}} <- Exexec.start(options) do + {:ok, pid} + end + end + + def run(cmd, options \\ %{}) do + ensure_started() + Exexec.run(cmd, options) + end + + def cmd(cmd, options \\ %{}) do + options = Map.merge(%{sync: true, stdout: true}, options) + run(cmd, options) + end +end diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index ee6b76c41..ecd234558 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -7,8 +7,6 @@ defmodule Pleroma.Helpers.MediaHelper do Handles common media-related operations. """ - @ffmpeg_opts [{:sync, true}, {:stdout, true}] - def ffmpeg_resize_remote(uri, %{max_width: max_width, max_height: max_height}) do cmd = ~s""" curl -L "#{uri}" | @@ -20,7 +18,7 @@ defmodule Pleroma.Helpers.MediaHelper do cat """ - with {:ok, [stdout: stdout_list]} <- Exexec.run(cmd, @ffmpeg_opts) do + with {:ok, [stdout: stdout_list]} <- Pleroma.Exec.cmd(cmd) do {:ok, Enum.join(stdout_list)} end end diff --git a/mix.exs b/mix.exs index 4c9bbc0ab..3215086ca 100644 --- a/mix.exs +++ b/mix.exs @@ -197,7 +197,8 @@ defmodule Pleroma.Mixfile do ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, {:mox, "~> 0.5", only: :test}, {:restarter, path: "./restarter"}, - {:exexec, "~> 0.2"}, + # Note: `runtime: true` for :exexec makes CI fail due to `root` user (see Pleroma.Exec) + {:exexec, "~> 0.2", runtime: false}, {:open_api_spex, git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"} diff --git a/test/exec_test.exs b/test/exec_test.exs new file mode 100644 index 000000000..45d3f778f --- /dev/null +++ b/test/exec_test.exs @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ExecTest do + alias Pleroma.Exec + + use Pleroma.DataCase + + test "it starts" do + assert {:ok, _} = Exec.ensure_started() + end +end -- cgit v1.2.3 From 0e23138b50f1fdd9ea78df31eec1b3caac905e2c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 22 May 2020 10:35:48 +0300 Subject: [#2497] Specified SHELL in .gitlab-ci.yml as required for `exexec`. --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 14300f3bf..e596aa0ec 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ variables: &global_variables POSTGRES_PASSWORD: postgres DB_HOST: postgres MIX_ENV: test + SHELL: /bin/sh USER: root cache: &global_cache_policy -- cgit v1.2.3 From 9faa63203717e71d666afb6755ff0b781b491823 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 5 Jul 2020 19:02:43 +0300 Subject: [#2497] Fixed merge issue. --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 0f4575e2f..583c177f2 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -12,8 +12,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do def remote(conn, %{"sig" => sig64, "url" => url64}) do with {_, true} <- {:enabled, MediaProxy.enabled?()}, - {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)}, {:ok, url} <- MediaProxy.decode_url(sig64, url64), + {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)}, :ok <- MediaProxy.verify_request_path_and_url(conn, url) do proxy_opts = Config.get([:media_proxy, :proxy_opts], []) ReverseProxy.call(conn, url, proxy_opts) -- cgit v1.2.3 From b8021016ebef23903c59e5140d4efb456a84a347 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 21 Jul 2020 20:03:14 +0300 Subject: [#2497] Resolved merge conflicts. --- .../media_proxy/media_proxy_controller_test.exs | 39 ---------------------- test/web/media_proxy/media_proxy_test.exs | 24 ++++--------- 2 files changed, 7 insertions(+), 56 deletions(-) diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index d4db44c63..0cda1e0b0 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -79,43 +79,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 06990464f..0e6df826c 100644 --- a/test/web/media_proxy/media_proxy_test.exs +++ b/test/web/media_proxy/media_proxy_test.exs @@ -126,6 +126,13 @@ defmodule Pleroma.Web.MediaProxyTest do :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", @@ -133,17 +140,6 @@ defmodule Pleroma.Web.MediaProxyTest do ) 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 MediaProxy.verify_request_path_and_url( - request_path, - "https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg" - ) == :ok - assert MediaProxy.decode_url(sig, base64) == {:error, :invalid_signature} - end - test "uses the configured base_url" do base_url = "https://cache.pleroma.social" clear_config([:media_proxy, :base_url], base_url) @@ -193,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) -- cgit v1.2.3 From 56ddf20208657487bf0298409cf91b11dac346ff Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 7 Aug 2020 09:43:49 +0300 Subject: Removed unused alias. --- test/web/media_proxy/media_proxy_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index 0cda1e0b0..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 -- cgit v1.2.3 From da116d81fb0028913c2a0f30ac35532fb500e8fc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 18 Aug 2020 18:23:27 +0300 Subject: [#2497] Added video preview proxy. Switched from exexec to Port. --- .gitlab-ci.yml | 2 - config/config.exs | 4 -- lib/pleroma/exec.ex | 38 ---------------- lib/pleroma/helpers/media_helper.ex | 19 +++++--- .../web/media_proxy/media_proxy_controller.ex | 50 +++++++++++++--------- mix.exs | 2 - mix.lock | 2 - test/exec_test.exs | 13 ------ 8 files changed, 41 insertions(+), 89 deletions(-) delete mode 100644 lib/pleroma/exec.ex delete mode 100644 test/exec_test.exs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3b6877039..9e9107ce3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,8 +6,6 @@ variables: &global_variables POSTGRES_PASSWORD: postgres DB_HOST: postgres MIX_ENV: test - SHELL: /bin/sh - USER: root cache: &global_cache_policy key: ${CI_COMMIT_REF_SLUG} diff --git a/config/config.exs b/config/config.exs index ab4508ccf..029f8ec20 100644 --- a/config/config.exs +++ b/config/config.exs @@ -761,10 +761,6 @@ config :floki, :html_parser, Floki.HTMLParser.FastHtml config :pleroma, Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.PleromaAuthenticator -config :pleroma, :exexec, - root_mode: false, - options: %{} - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/exec.ex b/lib/pleroma/exec.ex deleted file mode 100644 index 1b088d322..000000000 --- a/lib/pleroma/exec.ex +++ /dev/null @@ -1,38 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Exec do - @moduledoc "Pleroma wrapper around Exexec commands." - - alias Pleroma.Config - - def ensure_started(options_overrides \\ %{}) do - options = - if Config.get([:exexec, :root_mode]) || System.get_env("USER") == "root" do - # Note: running as `root` is discouraged (yet Gitlab CI does that by default) - %{root: true, user: "root", limit_users: ["root"]} - else - %{} - end - - options = - options - |> Map.merge(Config.get([:exexec, :options], %{})) - |> Map.merge(options_overrides) - - with {:error, {:already_started, pid}} <- Exexec.start(options) do - {:ok, pid} - end - end - - def run(cmd, options \\ %{}) do - ensure_started() - Exexec.run(cmd, options) - end - - def cmd(cmd, options \\ %{}) do - options = Map.merge(%{sync: true, stdout: true}, options) - run(cmd, options) - end -end diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index ecd234558..ca46698cc 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -7,19 +7,24 @@ defmodule Pleroma.Helpers.MediaHelper do Handles common media-related operations. """ - def ffmpeg_resize_remote(uri, %{max_width: max_width, max_height: max_height}) do + def ffmpeg_resize(uri_or_path, %{max_width: max_width, max_height: max_height}) do cmd = ~s""" - curl -L "#{uri}" | - ffmpeg -i pipe:0 -f lavfi -i color=c=white \ + ffmpeg -i #{uri_or_path} -f lavfi -i color=c=white \ -filter_complex "[0:v] scale='min(#{max_width},iw)':'min(#{max_height},ih)': \ force_original_aspect_ratio=decrease [scaled]; \ [1][scaled] scale2ref [bg][img]; [bg] setsar=1 [bg]; [bg][img] overlay=shortest=1" \ - -f image2 -vcodec mjpeg -frames:v 1 pipe:1 | \ - cat + -loglevel quiet -f image2 -vcodec mjpeg -frames:v 1 pipe:1 """ - with {:ok, [stdout: stdout_list]} <- Pleroma.Exec.cmd(cmd) do - {:ok, Enum.join(stdout_list)} + pid = Port.open({:spawn, cmd}, [:use_stdio, :in, :stream, :exit_status, :binary]) + + receive do + {^pid, {:data, data}} -> + send(pid, {self(), :close}) + {:ok, data} + + {^pid, {:exit_status, status}} when status > 0 -> + {:error, status} end 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 583c177f2..8861398dd 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -66,31 +66,23 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end 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 + defp handle_preview("image/" <> _ = _content_type, conn, url) do + handle_image_or_video_preview(conn, url) + end - thumbnail_max_height = - if h = params["thumbnail_max_height"] do - String.to_integer(h) - else - Keyword.fetch!(config, :thumbnail_max_height) - end + defp handle_preview("video/" <> _ = _content_type, conn, url) do + handle_image_or_video_preview(conn, url) + end - {thumbnail_max_width, thumbnail_max_height} + defp handle_preview(content_type, conn, _url) do + send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") end - defp handle_preview("image/" <> _ = _content_type, %{params: params} = conn, url) do + defp handle_image_or_video_preview(%{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( + MediaHelper.ffmpeg_resize( media_proxy_url, %{max_width: thumbnail_max_width, max_height: thumbnail_max_height} ) do @@ -99,12 +91,28 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do |> send_resp(200, thumbnail_binary) else _ -> - send_resp(conn, :failed_dependency, "Can't handle image preview.") + send_resp(conn, :failed_dependency, "Can't handle preview.") end end - defp handle_preview(content_type, conn, _url) do - send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") + 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 diff --git a/mix.exs b/mix.exs index 33c4411c4..11fdb1670 100644 --- a/mix.exs +++ b/mix.exs @@ -186,8 +186,6 @@ defmodule Pleroma.Mixfile do git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, {:restarter, path: "./restarter"}, - # Note: `runtime: true` for :exexec makes CI fail due to `root` user (see Pleroma.Exec) - {:exexec, "~> 0.2", runtime: false}, {:open_api_spex, git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"}, diff --git a/mix.lock b/mix.lock index f5acc89eb..553ac304a 100644 --- a/mix.lock +++ b/mix.lock @@ -33,7 +33,6 @@ "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"}, - "erlexec": {:hex, :erlexec, "1.10.9", "3cbb3476f942bfb8b68b85721c21c1835061cf6dd35f5285c2362e85b100ddc7", [:rebar3], [], "hexpm", "271e5b5f2d91cdb9887efe74d89026c199bfc69f074cade0d08dab60993fa14e"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, @@ -44,7 +43,6 @@ "ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.13.1", "b9f1697f7c9e0cfe15d1a1d737fb169c398803ffcbc57e672aa007e9fd42864c", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4bb550e045def1b4d531a37fb766cbbe1307f7628bf8f0414168b3f52021cce"}, - "exexec": {:hex, :exexec, "0.2.0", "a6ffc48cba3ac9420891b847e4dc7120692fb8c08c9e82220ebddc0bb8d96103", [:mix], [{:erlexec, "~> 1.10", [hex: :erlexec, repo: "hexpm", optional: false]}], "hexpm", "312cd1c9befba9e078e57f3541e4f4257eabda6eb9c348154fe899d6ac633299"}, "fast_html": {:hex, :fast_html, "2.0.1", "e126c74d287768ae78c48938da6711164517300d108a78f8a38993df8d588335", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "bdd6f8525c95ad391a4f10d9a1b3da4cea94078ec8638487aa8c24015ad9393a"}, "fast_sanitize": {:hex, :fast_sanitize, "0.2.0", "004b40d5bbecda182b6fdba762a51fffd3501e689e8eafe196e1a97eb0caf733", [:mix], [{:fast_html, "~> 2.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "11fcb37f26d272a3a2aff861872bf100be4eeacea69505908b8cdbcea5b0813a"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, diff --git a/test/exec_test.exs b/test/exec_test.exs deleted file mode 100644 index 45d3f778f..000000000 --- a/test/exec_test.exs +++ /dev/null @@ -1,13 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.ExecTest do - alias Pleroma.Exec - - use Pleroma.DataCase - - test "it starts" do - assert {:ok, _} = Exec.ensure_started() - end -end -- cgit v1.2.3 From 4ee15e991efb5bd5bf69d84d27dbbee81443d1dc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 19 Aug 2020 21:36:26 +0300 Subject: [#2497] Media preview proxy config refactoring & documentation. --- config/config.exs | 3 +- config/description.exs | 51 ++++++++++++++++++++++ .../web/media_proxy/media_proxy_controller.ex | 18 ++++---- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/config/config.exs b/config/config.exs index 029f8ec20..6e6231cf8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -444,8 +444,7 @@ config :pleroma, :media_preview_proxy, thumbnail_max_width: 400, thumbnail_max_height: 200, proxy_opts: [ - head_request_max_read_duration: 5_000, - max_read_duration: 10_000 + head_request_max_read_duration: 5_000 ] config :pleroma, :chat, enabled: true diff --git a/config/description.exs b/config/description.exs index e27abf40f..90d8eca65 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1831,6 +1831,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 @@ -1851,6 +1852,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", @@ -1897,6 +1903,51 @@ 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: :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, diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 8861398dd..31d18c119 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -15,8 +15,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do {:ok, url} <- MediaProxy.decode_url(sig64, url64), {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)}, :ok <- MediaProxy.verify_request_path_and_url(conn, url) do - proxy_opts = Config.get([:media_proxy, :proxy_opts], []) - ReverseProxy.call(conn, url, proxy_opts) + ReverseProxy.call(conn, url, media_proxy_opts()) else {:enabled, false} -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) @@ -116,13 +115,16 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end defp preview_head_request_timeout do - Config.get([:media_preview_proxy, :proxy_opts, :head_request_max_read_duration]) || - preview_timeout() + 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 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() + defp media_proxy_opts do + Config.get([:media_proxy, :proxy_opts], []) + end + + defp media_preview_proxy_opts do + Config.get([:media_preview_proxy, :proxy_opts], []) end end -- cgit v1.2.3 From 02ad1cd8e97c44824b92b53ea1879a965bbd8358 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 20 Aug 2020 09:58:50 +0300 Subject: [#2497] Media preview proxy: added Content-Disposition header with filename to response. --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 31d18c119..5513432f0 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -87,6 +87,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do ) do conn |> put_resp_header("content-type", "image/jpeg") + |> put_resp_header("content-disposition", "inline; filename=\"preview.jpg\"") |> send_resp(200, thumbnail_binary) else _ -> -- cgit v1.2.3 From aa0a5ffb4849880b5adbcc9188de01ef778381e3 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 21 Aug 2020 08:59:08 +0300 Subject: [#2497] Media preview proxy: added `quality` config setting, adjusted width/height defaults. --- config/config.exs | 5 +++-- config/description.exs | 5 +++++ lib/pleroma/helpers/media_helper.ex | 6 ++++-- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 4 +++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index 6e6231cf8..b399ce6d7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -441,8 +441,9 @@ 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: 400, - thumbnail_max_height: 200, + thumbnail_max_width: 600, + thumbnail_max_height: 600, + quality: 2, proxy_opts: [ head_request_max_read_duration: 5_000 ] diff --git a/config/description.exs b/config/description.exs index 90d8eca65..22da60900 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1925,6 +1925,11 @@ config :pleroma, :config_description, [ type: :integer, description: "Max height of preview thumbnail." }, + %{ + key: :quality, + type: :integer, + description: "Quality of the output. Ranges from 1 (max quality) to 31 (lowest quality)." + }, %{ key: :proxy_opts, type: :keyword, diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index ca46698cc..e11038052 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -7,13 +7,15 @@ defmodule Pleroma.Helpers.MediaHelper do Handles common media-related operations. """ - def ffmpeg_resize(uri_or_path, %{max_width: max_width, max_height: max_height}) do + def ffmpeg_resize(uri_or_path, %{max_width: max_width, max_height: max_height} = options) do + quality = options[:quality] || 1 + cmd = ~s""" ffmpeg -i #{uri_or_path} -f lavfi -i color=c=white \ -filter_complex "[0:v] scale='min(#{max_width},iw)':'min(#{max_height},ih)': \ force_original_aspect_ratio=decrease [scaled]; \ [1][scaled] scale2ref [bg][img]; [bg] setsar=1 [bg]; [bg][img] overlay=shortest=1" \ - -loglevel quiet -f image2 -vcodec mjpeg -frames:v 1 pipe:1 + -loglevel quiet -f image2 -vcodec mjpeg -frames:v 1 -q:v #{quality} pipe:1 """ pid = Port.open({:spawn, cmd}, [:use_stdio, :in, :stream, :exit_status, :binary]) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 5513432f0..1c51aa5e3 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -78,12 +78,14 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end defp handle_image_or_video_preview(%{params: params} = conn, url) do + quality = Config.get!([:media_preview_proxy, :quality]) + with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(params), media_proxy_url <- MediaProxy.url(url), {:ok, thumbnail_binary} <- MediaHelper.ffmpeg_resize( media_proxy_url, - %{max_width: thumbnail_max_width, max_height: thumbnail_max_height} + %{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality} ) do conn |> put_resp_header("content-type", "image/jpeg") -- cgit v1.2.3 From 967afa064bb0dc85c054495b795a57a13cdf3b3c Mon Sep 17 00:00:00 2001 From: href Date: Fri, 21 Aug 2020 17:02:57 +0000 Subject: Fix truncated images --- lib/pleroma/helpers/media_helper.ex | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index e11038052..f87be8874 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -19,14 +19,24 @@ defmodule Pleroma.Helpers.MediaHelper do """ pid = Port.open({:spawn, cmd}, [:use_stdio, :in, :stream, :exit_status, :binary]) + loop_recv(pid) + end + + defp loop_recv(pid) do + loop_recv(pid, <<>>) + end + defp loop_recv(pid, acc) do receive do {^pid, {:data, data}} -> - send(pid, {self(), :close}) - {:ok, data} + loop_recv(pid, acc <> data) - {^pid, {:exit_status, status}} when status > 0 -> + {^pid, {:exit_status, 0}} -> + {:ok, acc} + + {^pid, {:exit_status, status}} -> {:error, status} end end + end -- cgit v1.2.3 From 4e6eb22b4af70e611cc61f94ba3d81758036a392 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 21 Aug 2020 12:19:35 -0500 Subject: Try to warm the cache with the preview image if preview proxy enabled --- lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 -- cgit v1.2.3 From edde0d9b54b45a366ecdec01e9826f1ee8d1dc3a Mon Sep 17 00:00:00 2001 From: href Date: Fri, 21 Aug 2020 17:40:49 +0000 Subject: Remove newline for linter --- lib/pleroma/helpers/media_helper.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index f87be8874..89dd4204b 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -38,5 +38,4 @@ defmodule Pleroma.Helpers.MediaHelper do {:error, status} end end - end -- cgit v1.2.3 From 98f8851f29f940051656caa1715820bce70f8c29 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 22 Aug 2020 15:12:11 -0500 Subject: Use the image thumbnail for rich metadata (OGP/Twittercards) --- lib/pleroma/web/metadata/utils.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 -- cgit v1.2.3 From 899ea2da3e77ca64598e45eba986d5315b523120 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 25 Aug 2020 17:18:22 -0500 Subject: Switch to imagemagick, only support videos --- config/config.exs | 2 +- config/description.exs | 4 ++-- lib/pleroma/helpers/media_helper.ex | 13 ++++++------- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 15 +++++---------- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/config/config.exs b/config/config.exs index e1558e29e..972b96d2d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -444,7 +444,7 @@ config :pleroma, :media_preview_proxy, enabled: false, thumbnail_max_width: 600, thumbnail_max_height: 600, - quality: 2, + image_quality: 85, proxy_opts: [ head_request_max_read_duration: 5_000 ] diff --git a/config/description.exs b/config/description.exs index 0082cc84f..60f76be45 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1975,9 +1975,9 @@ config :pleroma, :config_description, [ description: "Max height of preview thumbnail." }, %{ - key: :quality, + key: :image_quality, type: :integer, - description: "Quality of the output. Ranges from 1 (max quality) to 31 (lowest quality)." + description: "Quality of the output. Ranges from 0 (min quality) to 100 (max quality)." }, %{ key: :proxy_opts, diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 89dd4204b..07e6dba5e 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -7,18 +7,17 @@ defmodule Pleroma.Helpers.MediaHelper do Handles common media-related operations. """ - def ffmpeg_resize(uri_or_path, %{max_width: max_width, max_height: max_height} = options) do - quality = options[:quality] || 1 + def image_resize(url, %{max_width: max_width, max_height: max_height} = options) do + quality = options[:quality] || 85 cmd = ~s""" - ffmpeg -i #{uri_or_path} -f lavfi -i color=c=white \ - -filter_complex "[0:v] scale='min(#{max_width},iw)':'min(#{max_height},ih)': \ - force_original_aspect_ratio=decrease [scaled]; \ - [1][scaled] scale2ref [bg][img]; [bg] setsar=1 [bg]; [bg][img] overlay=shortest=1" \ - -loglevel quiet -f image2 -vcodec mjpeg -frames:v 1 -q:v #{quality} pipe:1 + convert - -resize '#{max_width}x#{max_height}>' -quality #{quality} - """ pid = Port.open({:spawn, cmd}, [:use_stdio, :in, :stream, :exit_status, :binary]) + {:ok, env} = url |> Pleroma.Web.MediaProxy.url() |> Pleroma.HTTP.get() + image = env.body + Port.command(pid, image) loop_recv(pid) end diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 1c51aa5e3..b925973ba 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -66,25 +66,20 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end defp handle_preview("image/" <> _ = _content_type, conn, url) do - handle_image_or_video_preview(conn, url) - end - - defp handle_preview("video/" <> _ = _content_type, conn, url) do - handle_image_or_video_preview(conn, url) + handle_image_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_image_or_video_preview(%{params: params} = conn, url) do - quality = Config.get!([:media_preview_proxy, :quality]) + defp handle_image_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), - media_proxy_url <- MediaProxy.url(url), {:ok, thumbnail_binary} <- - MediaHelper.ffmpeg_resize( - media_proxy_url, + MediaHelper.image_resize( + url, %{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality} ) do conn -- cgit v1.2.3 From ddbddc08fc9fe5458edc983c81a77671da34a71f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 25 Aug 2020 17:31:55 -0500 Subject: Redirects for videos right now --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index b925973ba..6abbf9e23 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -69,6 +69,12 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do handle_image_preview(conn, url) end + defp handle_preview("video/" <> _ = _content_type, conn, url) do + mediaproxy_url = url |> MediaProxy.url() + + redirect(conn, external: mediaproxy_url) + end + defp handle_preview(content_type, conn, _url) do send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") end -- cgit v1.2.3 From afa03ca8e2cffc85628beb5f9a70401d984ab216 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 25 Aug 2020 17:36:53 -0500 Subject: Allow both stdin and stdout --- lib/pleroma/helpers/media_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 07e6dba5e..5fe135584 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Helpers.MediaHelper do convert - -resize '#{max_width}x#{max_height}>' -quality #{quality} - """ - pid = Port.open({:spawn, cmd}, [:use_stdio, :in, :stream, :exit_status, :binary]) + pid = Port.open({:spawn, cmd}, [:use_stdio, :stream, :exit_status, :binary]) {:ok, env} = url |> Pleroma.Web.MediaProxy.url() |> Pleroma.HTTP.get() image = env.body Port.command(pid, image) -- cgit v1.2.3 From a136e7e9b590e3f23e472bf27c7c6a81d8d7792b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 25 Aug 2020 18:10:27 -0500 Subject: Try specifying fd0, force jpg out --- lib/pleroma/helpers/media_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 5fe135584..01f42d9b0 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Helpers.MediaHelper do quality = options[:quality] || 85 cmd = ~s""" - convert - -resize '#{max_width}x#{max_height}>' -quality #{quality} - + convert fd:0 -resize '#{max_width}x#{max_height}>' -quality #{quality} jpg:- """ pid = Port.open({:spawn, cmd}, [:use_stdio, :stream, :exit_status, :binary]) -- cgit v1.2.3 From bc94f0c6da2405e2f1cdae89696970728b6e987f Mon Sep 17 00:00:00 2001 From: href Date: Wed, 26 Aug 2020 16:12:34 +0200 Subject: Use mkfifo to feed ImageMagick --- lib/pleroma/helpers/media_helper.ex | 70 +++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 01f42d9b0..a43352ae0 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -7,18 +7,66 @@ defmodule Pleroma.Helpers.MediaHelper do Handles common media-related operations. """ - def image_resize(url, %{max_width: max_width, max_height: max_height} = options) do + @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_resize_args(options), + url = Pleroma.Web.MediaProxy.url(url), + {:ok, env} <- Pleroma.HTTP.get(url), + {:ok, fifo_path} <- mkfifo() + do + run_fifo(fifo_path, env, executable, args) + else + nil -> {:error, {:convert, :command_not_found}} + {:error, _} = error -> error + end + end + + defp prepare_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 = [ + "-auto-orient", # Support for EXIF rotation + "-resize", resize, + "-quality", to_string(quality) + ] + {:ok, args} + end - cmd = ~s""" - convert fd:0 -resize '#{max_width}x#{max_height}>' -quality #{quality} jpg:- - """ + defp prepare_resize_args(_), do: {:error, :missing_options} - pid = Port.open({:spawn, cmd}, [:use_stdio, :stream, :exit_status, :binary]) - {:ok, env} = url |> Pleroma.Web.MediaProxy.url() |> Pleroma.HTTP.get() - image = env.body - Port.command(pid, image) + defp run_fifo(fifo_path, env, executable, args) do + args = List.flatten([fifo_path, args, "jpg:fd:1"]) + 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]) + true = Port.command(fifo, env.body) + :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 @@ -29,12 +77,14 @@ defmodule Pleroma.Helpers.MediaHelper 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 -- cgit v1.2.3 From d4d1192341868d978e19777c17be85e331367264 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 26 Aug 2020 14:28:25 +0000 Subject: Remove auto-orient; don't use it on previews, only originals --- lib/pleroma/helpers/media_helper.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index a43352ae0..db0c4b0cf 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -27,7 +27,6 @@ defmodule Pleroma.Helpers.MediaHelper do quality = options[:quality] || 85 resize = Enum.join([max_width, "x", max_height, ">"]) args = [ - "-auto-orient", # Support for EXIF rotation "-resize", resize, "-quality", to_string(quality) ] -- cgit v1.2.3 From 2c95533ead56217ec27e09e0ead0050e110dff22 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 26 Aug 2020 15:37:45 +0000 Subject: Change method of convert using stdout, make progressive jpegs --- lib/pleroma/helpers/media_helper.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index db0c4b0cf..3256802a0 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -27,6 +27,7 @@ defmodule Pleroma.Helpers.MediaHelper do quality = options[:quality] || 85 resize = Enum.join([max_width, "x", max_height, ">"]) args = [ + "-interlace", "Plane", "-resize", resize, "-quality", to_string(quality) ] @@ -36,7 +37,7 @@ defmodule Pleroma.Helpers.MediaHelper do defp prepare_resize_args(_), do: {:error, :missing_options} defp run_fifo(fifo_path, env, executable, args) do - args = List.flatten([fifo_path, args, "jpg:fd:1"]) + args = List.flatten([fifo_path, args, "jpg:-"]) 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]) true = Port.command(fifo, env.body) -- cgit v1.2.3 From eead2276e79f29c4d0e10d23eb7524a9ee5f5045 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 26 Aug 2020 16:18:11 -0500 Subject: Ensure GIFs are redirected to the original or they become static. --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 6abbf9e23..d465ce8d1 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -65,6 +65,12 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end + 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/" <> _ = _content_type, conn, url) do handle_image_preview(conn, url) end -- cgit v1.2.3 From 9567b96c7927be433eac4f023051adc5cbd6610c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 26 Aug 2020 16:40:13 -0500 Subject: Rename to make it obvious this is for images not videos --- lib/pleroma/helpers/media_helper.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 3256802a0..fe11dd460 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Helpers.MediaHelper do def image_resize(url, options) do with executable when is_binary(executable) <- System.find_executable("convert"), - {:ok, args} <- prepare_resize_args(options), + {:ok, args} <- prepare_image_resize_args(options), url = Pleroma.Web.MediaProxy.url(url), {:ok, env} <- Pleroma.HTTP.get(url), {:ok, fifo_path} <- mkfifo() @@ -23,7 +23,7 @@ defmodule Pleroma.Helpers.MediaHelper do end end - defp prepare_resize_args(%{max_width: max_width, max_height: max_height} = options) do + 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 = [ @@ -34,7 +34,7 @@ defmodule Pleroma.Helpers.MediaHelper do {:ok, args} end - defp prepare_resize_args(_), do: {:error, :missing_options} + defp prepare_image_resize_args(_), do: {:error, :missing_options} defp run_fifo(fifo_path, env, executable, args) do args = List.flatten([fifo_path, args, "jpg:-"]) -- cgit v1.2.3 From 697bea04731614bcd2e1e10f0564863dc49a49fa Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 26 Aug 2020 17:43:25 -0500 Subject: Move arg for images to the list so we can reuse these fifo functions for videos --- lib/pleroma/helpers/media_helper.ex | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index fe11dd460..0299b16ae 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -29,7 +29,8 @@ defmodule Pleroma.Helpers.MediaHelper do args = [ "-interlace", "Plane", "-resize", resize, - "-quality", to_string(quality) + "-quality", to_string(quality), + "jpg:-" ] {:ok, args} end @@ -37,7 +38,7 @@ defmodule Pleroma.Helpers.MediaHelper do defp prepare_image_resize_args(_), do: {:error, :missing_options} defp run_fifo(fifo_path, env, executable, args) do - args = List.flatten([fifo_path, args, "jpg:-"]) + args = List.flatten([fifo_path, args]) 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]) true = Port.command(fifo, env.body) -- cgit v1.2.3 From 157ecf402230c0b786f5765dd8b709d45c45974a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 27 Aug 2020 11:46:56 -0500 Subject: Follow redirects. I think we should be using some global adapter options here, though. --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index d465ce8d1..736b7db56 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -50,7 +50,9 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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 + 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 -- cgit v1.2.3 From ef9d12fcc500d7429bee0d6ccffe3596434aee52 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 27 Aug 2020 12:31:55 -0500 Subject: Attempt at supporting video thumbnails via ffmpeg --- lib/pleroma/helpers/media_helper.ex | 19 +++++++++++++++++++ lib/pleroma/web/media_proxy/media_proxy_controller.ex | 17 ++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 0299b16ae..7e1af8bac 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -37,6 +37,25 @@ defmodule Pleroma.Helpers.MediaHelper do defp prepare_image_resize_args(_), do: {:error, :missing_options} + def video_framegrab(url) do + with executable when is_binary(executable) <- System.find_executable("ffmpeg"), + args = [ + "-i", "-", + "-vframes", "1", + "-f", "mjpeg", + "-loglevel", "error", + "-" + ], + url = Pleroma.Web.MediaProxy.url(url), + {:ok, env} <- Pleroma.HTTP.get(url), + {:ok, fifo_path} <- mkfifo() 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 args = List.flatten([fifo_path, args]) pid = Port.open({:spawn_executable, executable}, [:use_stdio, :stream, :exit_status, :binary, args: args]) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 736b7db56..7ac1a97e2 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -78,9 +78,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end defp handle_preview("video/" <> _ = _content_type, conn, url) do - mediaproxy_url = url |> MediaProxy.url() - - redirect(conn, external: mediaproxy_url) + handle_video_preview(conn, url) end defp handle_preview(content_type, conn, _url) do @@ -106,6 +104,19 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end + defp handle_video_preview(conn, url) do + with {:ok, thumbnail_binary} <- + MediaHelper.video_framegrab(url) do + conn + |> put_resp_header("content-type", "image/jpeg") + |> put_resp_header("content-disposition", "inline; filename=\"preview.jpg\"") + |> send_resp(200, thumbnail_binary) + else + _ -> + send_resp(conn, :failed_dependency, "Can't handle preview.") + end + end + defp thumbnail_max_dimensions(params) do config = Config.get([:media_preview_proxy], []) -- cgit v1.2.3 From f1218a2b4e16178c8c1285157f7cd995dc950e3e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 27 Aug 2020 12:47:29 -0500 Subject: ffmpeg needs input from fifo path, not stdin --- lib/pleroma/helpers/media_helper.ex | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 7e1af8bac..7c2bfbc53 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -39,16 +39,16 @@ defmodule Pleroma.Helpers.MediaHelper do 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 = [ - "-i", "-", + "-i", fifo_path, "-vframes", "1", "-f", "mjpeg", "-loglevel", "error", "-" - ], - url = Pleroma.Web.MediaProxy.url(url), - {:ok, env} <- Pleroma.HTTP.get(url), - {:ok, fifo_path} <- mkfifo() do + ] do run_fifo(fifo_path, env, executable, args) else nil -> {:error, {:ffmpeg, :command_not_found}} @@ -57,7 +57,12 @@ defmodule Pleroma.Helpers.MediaHelper do end defp run_fifo(fifo_path, env, executable, args) do - args = List.flatten([fifo_path, args]) + args = + if _executable = System.find_executable("convert") do + List.flatten([fifo_path, args]) + else + args + end 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]) true = Port.command(fifo, env.body) -- cgit v1.2.3 From dd1de994d57e3d9c99bb4e4c7019c696b5153f50 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 27 Aug 2020 13:10:40 -0500 Subject: Try to trick ffmpeg into working with this named pipe --- lib/pleroma/helpers/media_helper.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 7c2bfbc53..385a4df81 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -43,11 +43,12 @@ defmodule Pleroma.Helpers.MediaHelper do {:ok, env} <- Pleroma.HTTP.get(url), {:ok, fifo_path} <- mkfifo(), args = [ + "-y", "-i", fifo_path, "-vframes", "1", "-f", "mjpeg", "-loglevel", "error", - "-" + "pipe:" ] do run_fifo(fifo_path, env, executable, args) else -- cgit v1.2.3 From 3a5231ec8fd0583d7f4bf05378d8bb81096c4f40 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 27 Aug 2020 16:33:37 -0500 Subject: Keep args construction within video/image scopes instead of mangling down in fifo town --- lib/pleroma/helpers/media_helper.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 385a4df81..b42612ccb 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Helpers.MediaHelper do {: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}} @@ -58,12 +59,6 @@ defmodule Pleroma.Helpers.MediaHelper do end defp run_fifo(fifo_path, env, executable, args) do - args = - if _executable = System.find_executable("convert") do - List.flatten([fifo_path, args]) - else - args - end 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]) true = Port.command(fifo, env.body) -- cgit v1.2.3 From 67c79394e81cf9f5404afad29a397acf32dece33 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 27 Aug 2020 17:15:23 -0500 Subject: Support static avatars and header images with Mediaproxy Preview --- lib/pleroma/web/mastodon_api/views/account_view.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 864c0417f..eef45b35d 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, -- cgit v1.2.3 From 5b4d483f522f470b9d2cdb7f43d98dde427a1241 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 27 Aug 2020 17:28:21 -0500 Subject: Add a note about the avatars and banners situation --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 7ac1a97e2..411dc95d0 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -67,6 +67,9 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end + # TODO: find a workaround so avatar_static and banner_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() -- cgit v1.2.3 From dfceb03cf47374fdeab60784476b2e266208a4bb Mon Sep 17 00:00:00 2001 From: href Date: Fri, 28 Aug 2020 21:14:28 +0200 Subject: Rewrite MP4/MOV binaries to be faststart In some cases, MP4/MOV files can have the data _before_ the meta-data. Thus, ffmpeg (and all similar tools) cannot really process the input if it's given over stdin/streaming/pipes. BUT I REALLY DON'T WANT TO MAKE TEMPORARY FILES so here we go, an implementation of qtfaststart in elixir. --- lib/pleroma/helpers/media_helper.ex | 59 +++++++++++----- lib/pleroma/helpers/qt_fast_start.ex | 131 +++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 lib/pleroma/helpers/qt_fast_start.ex diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index b42612ccb..5ac75b326 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -14,8 +14,7 @@ defmodule Pleroma.Helpers.MediaHelper do {:ok, args} <- prepare_image_resize_args(options), url = Pleroma.Web.MediaProxy.url(url), {:ok, env} <- Pleroma.HTTP.get(url), - {:ok, fifo_path} <- mkfifo() - do + {:ok, fifo_path} <- mkfifo() do args = List.flatten([fifo_path, args]) run_fifo(fifo_path, env, executable, args) else @@ -27,12 +26,17 @@ defmodule Pleroma.Helpers.MediaHelper do 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:-" + "-interlace", + "Plane", + "-resize", + resize, + "-quality", + to_string(quality), + "jpg:-" ] + {:ok, args} end @@ -45,11 +49,15 @@ defmodule Pleroma.Helpers.MediaHelper do {:ok, fifo_path} <- mkfifo(), args = [ "-y", - "-i", fifo_path, - "-vframes", "1", - "-f", "mjpeg", - "-loglevel", "error", - "pipe:" + "-i", + fifo_path, + "-vframes", + "1", + "-f", + "mjpeg", + "-loglevel", + "error", + "-" ] do run_fifo(fifo_path, env, executable, args) else @@ -59,9 +67,18 @@ defmodule Pleroma.Helpers.MediaHelper do end defp run_fifo(fifo_path, env, executable, args) do - pid = Port.open({:spawn_executable, executable}, [:use_stdio, :stream, :exit_status, :binary, args: args]) + 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]) - true = Port.command(fifo, env.body) + fix = Pleroma.Helpers.QtFastStart.fix(env.body) + true = Port.command(fifo, fix) :erlang.port_close(fifo) loop_recv(pid) after @@ -70,10 +87,12 @@ defmodule Pleroma.Helpers.MediaHelper do 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 @@ -81,8 +100,10 @@ defmodule Pleroma.Helpers.MediaHelper do defp fifo_guard(path) do pid = self() - fn() -> + + fn -> ref = Process.monitor(pid) + receive do {:DOWN, ^ref, :process, ^pid, _} -> File.rm(path) @@ -98,14 +119,16 @@ defmodule Pleroma.Helpers.MediaHelper 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} + 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..694b583b9 --- /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 +# 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(binary = <<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::binary>>) do + index = fix(binary, binary, 0, []) + + case index do + [{"ftyp", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index) + [{"ftyp", _, _, _, _}, {"free", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index) + _ -> binary + end + end + + def fix(binary) do + binary + end + + defp fix(<<>>, _bin, _pos, acc) do + :lists.reverse(acc) + end + + defp fix( + <>, + bin, + pos, + acc + ) do + if fourcc == "mdat" && size == 0 do + # mdat with 0 size means "seek to the end" -- also, in that case the file is probably OK. + acc = [ + {fourcc, pos, byte_size(bin) - pos, byte_size(bin) - pos, + <>} + | acc + ] + + fix(<<>>, bin, byte_size(bin), acc) + else + full_size = size - 8 + <> = rest + + acc = [ + {fourcc, pos, pos + size, size, + <>} + | acc + ] + + fix(rest, bin, pos + size, acc) + end + 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 + new_moov = fix_moov(moov_data, offset) + <> + end + + defp fix_moov(moov, offset) do + fix_moov(moov, offset, <<>>) + end + + defp fix_moov(<<>>, _, acc), do: acc + + defp fix_moov( + <>, + offset, + acc + ) do + full_size = size - 8 + <> = rest + + data = + cond do + fourcc in ["trak", "mdia", "minf", "stbl"] -> + # Theses contains sto or co64 part + <>)::binary>> + + fourcc in ["stco", "co64"] -> + # fix the damn thing + <> = data + + entry_size = + case fourcc do + "stco" -> 4 + "co64" -> 8 + end + + {_, result} = + Enum.reduce(1..count, {rest, <<>>}, fn _, + {<>, acc} -> + {rest, <>} + end) + + <> + + true -> + <> + end + + acc = <> + fix_moov(rest, offset, acc) + end +end -- cgit v1.2.3 From 24d522c3b366b54b23bebaf07371145d50820d4a Mon Sep 17 00:00:00 2001 From: href Date: Sat, 29 Aug 2020 13:05:23 +0200 Subject: QtFastStart: optimize ~4-6x faster ~3~4x memory usage reduction (now mostly adds what we are rewriting in the metadatas) --- lib/pleroma/helpers/qt_fast_start.ex | 115 +++++++++++++++++------------------ 1 file changed, 57 insertions(+), 58 deletions(-) diff --git a/lib/pleroma/helpers/qt_fast_start.ex b/lib/pleroma/helpers/qt_fast_start.ex index 694b583b9..8cba06e54 100644 --- a/lib/pleroma/helpers/qt_fast_start.ex +++ b/lib/pleroma/helpers/qt_fast_start.ex @@ -13,10 +13,11 @@ defmodule Pleroma.Helpers.QtFastStart do # ISO/IEC 14496-12:2015, ISO/IEC 15444-12:2015 # Paracetamol - def fix(binary = <<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::binary>>) do - index = fix(binary, binary, 0, []) + def fix(binary = <<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::bits>>) do + index = fix(binary, 0, nil, nil, []) case index do + :abort -> binary [{"ftyp", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index) [{"ftyp", _, _, _, _}, {"free", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index) _ -> binary @@ -27,37 +28,32 @@ defmodule Pleroma.Helpers.QtFastStart do binary end - defp fix(<<>>, _bin, _pos, acc) do - :lists.reverse(acc) + # MOOV have been seen before MDAT- abort + defp fix(<<_::bits>>, _, true, false, _) do + :abort end defp fix( - <>, - bin, + <>, pos, + got_moov, + got_mdat, acc ) do - if fourcc == "mdat" && size == 0 do - # mdat with 0 size means "seek to the end" -- also, in that case the file is probably OK. - acc = [ - {fourcc, pos, byte_size(bin) - pos, byte_size(bin) - pos, - <>} - | acc - ] - - fix(<<>>, bin, byte_size(bin), acc) - else - full_size = size - 8 - <> = rest - - acc = [ - {fourcc, pos, pos + size, size, - <>} - | acc - ] - - fix(rest, bin, pos + size, acc) - end + full_size = (size - 8) * 8 + <> = rest + + acc = [ + {fourcc, pos, pos + size, size, + <>} + | 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 @@ -72,60 +68,63 @@ defmodule Pleroma.Helpers.QtFastStart do {{_moov, _, _, moov_size, moov}, index} = List.keytake(index, "moov", 0) offset = -free_size + moov_size - rest = for {_, _, _, _, data} <- index, do: data, into: <<>> - <> = moov - new_moov = fix_moov(moov_data, offset) - <> - end - - defp fix_moov(moov, offset) do - fix_moov(moov, offset, <<>>) + rest = for {_, _, _, _, data} <- index, do: data, into: [] + <> = moov + [ftyp, moov_head, fix_moov(moov_data, offset, []), rest] end - defp fix_moov(<<>>, _, acc), do: acc - defp fix_moov( - <>, + <>, offset, acc ) do - full_size = size - 8 - <> = rest + full_size = (size - 8) * 8 + <> = rest data = cond do fourcc in ["trak", "mdia", "minf", "stbl"] -> # Theses contains sto or co64 part - <>)::binary>> + [<>, fix_moov(data, offset, [])] fourcc in ["stco", "co64"] -> # fix the damn thing - <> = data + <> = data entry_size = case fourcc do - "stco" -> 4 - "co64" -> 8 + "stco" -> 32 + "co64" -> 64 end - {_, result} = - Enum.reduce(1..count, {rest, <<>>}, fn _, - {<>, acc} -> - {rest, <>} - end) - - <> + [ + <>, + rewrite_entries(entry_size, offset, rest, []) + ] true -> - <> + [<>, data] end - acc = <> + 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, + <>, + acc + ) do + rewrite_entries(unquote(size), offset, rest, [ + acc | <> + ]) + end + end + + defp rewrite_entries(_, _, <<>>, acc), do: acc end -- cgit v1.2.3 From 2d2af75777ae468fb08a2b09dc5af4636106a04b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 30 Aug 2020 09:17:24 -0500 Subject: Support PNG previews to preserve alpha channels --- lib/pleroma/helpers/media_helper.ex | 17 ++++++++++++ .../web/media_proxy/media_proxy_controller.ex | 32 ++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 5ac75b326..d8a6db4e1 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -23,6 +23,23 @@ defmodule Pleroma.Helpers.MediaHelper do 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, ">"]) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 411dc95d0..94fae6cac 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -76,8 +76,12 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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_image_preview(conn, url) + handle_jpeg_preview(conn, url) end defp handle_preview("video/" <> _ = _content_type, conn, url) do @@ -88,7 +92,31 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") end - defp handle_image_preview(%{params: params} = conn, url) do + 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_resp_header("content-type", "image/png") + |> put_resp_header("content-disposition", "inline; filename=\"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]) with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(params), -- cgit v1.2.3 From 4ef210a587113313cd6887b7499832d0c0798f7f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 30 Aug 2020 09:32:22 -0500 Subject: Credo --- lib/pleroma/helpers/media_helper.ex | 2 +- lib/pleroma/helpers/qt_fast_start.ex | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index d8a6db4e1..9bd815c26 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -102,7 +102,7 @@ defmodule Pleroma.Helpers.MediaHelper do File.rm(fifo_path) end - defp mkfifo() do + defp mkfifo do path = "#{@tmp_base}#{to_charlist(:erlang.phash2(self()))}" case System.cmd("mkfifo", [path]) do diff --git a/lib/pleroma/helpers/qt_fast_start.ex b/lib/pleroma/helpers/qt_fast_start.ex index 8cba06e54..bb93224b5 100644 --- a/lib/pleroma/helpers/qt_fast_start.ex +++ b/lib/pleroma/helpers/qt_fast_start.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Helpers.QtFastStart do # ISO/IEC 14496-12:2015, ISO/IEC 15444-12:2015 # Paracetamol - def fix(binary = <<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::bits>>) do + def fix(<<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::bits>> = binary) do index = fix(binary, 0, nil, nil, []) case index do @@ -59,7 +59,8 @@ defmodule Pleroma.Helpers.QtFastStart do 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? + # 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} -- cgit v1.2.3 From 0a839d51a7adb034d6514ea647d90546c829813d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 31 Aug 2020 13:08:50 +0300 Subject: [#2497] Added Cache-Control response header for media proxy preview endpoint. --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 94fae6cac..2afcd861a 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -107,8 +107,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do } ) do conn - |> put_resp_header("content-type", "image/png") - |> put_resp_header("content-disposition", "inline; filename=\"preview.png\"") + |> put_preview_response_headers() |> send_resp(200, thumbnail_binary) else _ -> @@ -126,8 +125,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do %{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality} ) do conn - |> put_resp_header("content-type", "image/jpeg") - |> put_resp_header("content-disposition", "inline; filename=\"preview.jpg\"") + |> put_preview_response_headers() |> send_resp(200, thumbnail_binary) else _ -> @@ -139,8 +137,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do with {:ok, thumbnail_binary} <- MediaHelper.video_framegrab(url) do conn - |> put_resp_header("content-type", "image/jpeg") - |> put_resp_header("content-disposition", "inline; filename=\"preview.jpg\"") + |> put_preview_response_headers() |> send_resp(200, thumbnail_binary) else _ -> @@ -148,6 +145,13 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end + defp put_preview_response_headers(conn) do + conn + |> put_resp_header("content-type", "image/jpeg") + |> put_resp_header("content-disposition", "inline; filename=\"preview.jpg\"") + |> put_resp_header("cache-control", "max-age=0, private, must-revalidate") + end + defp thumbnail_max_dimensions(params) do config = Config.get([:media_preview_proxy], []) -- cgit v1.2.3 From 6ce28c409137972ee9b105b9d7ab4a0fd2a0d08b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 1 Sep 2020 21:21:58 +0300 Subject: [#2497] Fix for png media proxy preview response headers (content-type & content-disposition). --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 2afcd861a..961c73666 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -67,7 +67,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - # TODO: find a workaround so avatar_static and banner_static can work. + # 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 @@ -107,7 +107,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do } ) do conn - |> put_preview_response_headers() + |> put_preview_response_headers("image/png", "preview.png") |> send_resp(200, thumbnail_binary) else _ -> @@ -145,10 +145,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - defp put_preview_response_headers(conn) do + defp put_preview_response_headers(conn, content_type \\ "image/jpeg", filename \\ "preview.jpg") do conn - |> put_resp_header("content-type", "image/jpeg") - |> put_resp_header("content-disposition", "inline; filename=\"preview.jpg\"") + |> 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 -- cgit v1.2.3 From 60c925380da644866836fa4a275f4d57eaaada04 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 3 Sep 2020 20:13:29 +0300 Subject: [#2497] Added support for enforcing output format for media proxy preview, used for avatar_static & header_static (AccountView). --- lib/pleroma/helpers/uri_helper.ex | 1 + lib/pleroma/web/mastodon_api/views/account_view.ex | 4 ++-- lib/pleroma/web/media_proxy/media_proxy.ex | 15 +++++++++------ lib/pleroma/web/media_proxy/media_proxy_controller.ex | 11 ++++++++--- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/helpers/uri_helper.ex b/lib/pleroma/helpers/uri_helper.ex index 6d205a636..9c9e53447 100644 --- a/lib/pleroma/helpers/uri_helper.ex +++ b/lib/pleroma/helpers/uri_helper.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Helpers.UriHelper do 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]) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 7eb4e86fe..a811f81c2 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -182,9 +182,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do display_name = user.name || user.nickname avatar = User.avatar_url(user) |> MediaProxy.url() - avatar_static = User.avatar_url(user) |> MediaProxy.preview_url() + avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(output_format: "jpeg") header = User.banner_url(user) |> MediaProxy.url() - header_static = User.banner_url(user) |> MediaProxy.preview_url() + header_static = User.banner_url(user) |> MediaProxy.preview_url(output_format: "jpeg") following_count = if !user.hide_follows_count or !user.hide_follows or opts[:for] == user do diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 6695d49ce..4cbe1cf89 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -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 @@ -58,9 +59,9 @@ defmodule Pleroma.Web.MediaProxy do # 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 + def preview_url(url, preview_params \\ []) do if preview_enabled?() do - encode_preview_url(url) + encode_preview_url(url, preview_params) else url end @@ -116,10 +117,10 @@ defmodule Pleroma.Web.MediaProxy do build_url(sig64, base64, filename(url)) end - def encode_preview_url(url) do + def encode_preview_url(url, preview_params \\ []) do {base64, sig64} = base64_sig64(url) - build_preview_url(sig64, base64, filename(url)) + build_preview_url(sig64, base64, filename(url), preview_params) end def decode_url(sig, url) do @@ -155,8 +156,10 @@ defmodule Pleroma.Web.MediaProxy 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) + def build_preview_url(sig_base64, url_base64, filename \\ nil, preview_params \\ []) do + uri = proxy_url("proxy/preview", sig_base64, url_base64, filename) + + UriHelper.append_uri_params(uri, preview_params) end def verify_request_path_and_url( diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 961c73666..9dc76e928 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -67,9 +67,14 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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/" <> _ = _content_type, + %{params: %{"output_format" => "jpeg"}} = conn, + url + ) do + handle_jpeg_preview(conn, url) + end + defp handle_preview("image/gif" = _content_type, conn, url) do mediaproxy_url = url |> MediaProxy.url() -- cgit v1.2.3 From 6141eb94ab034b5141a5c60b2814fb45b829c1ac Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Sep 2020 12:40:42 -0500 Subject: Fetch preview requests through the MediaProxy. Separate connection options are not needed. Use a separate pool for preview requests --- config/config.exs | 10 ++++++---- config/description.exs | 21 --------------------- .../web/media_proxy/media_proxy_controller.ex | 17 ++--------------- 3 files changed, 8 insertions(+), 40 deletions(-) diff --git a/config/config.exs b/config/config.exs index 317ef84a9..d691753bd 100644 --- a/config/config.exs +++ b/config/config.exs @@ -445,10 +445,7 @@ 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 - ] + image_quality: 85 config :pleroma, :chat, enabled: true @@ -761,6 +758,11 @@ config :pleroma, :pools, max_waiting: 10, timeout: 10_000 ], + preview: [ + size: 50, + max_waiting: 10, + timeout: 10_000 + ], upload: [ size: 25, max_waiting: 5, diff --git a/config/description.exs b/config/description.exs index 868b89d29..73333d6e6 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1978,27 +1978,6 @@ config :pleroma, :config_description, [ 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"] } ] }, diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 961c73666..b1f00fa0c 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -33,8 +33,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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 + {:ok, url} <- MediaProxy.decode_url(sig64, url64) do handle_preview(conn, url) else {:enabled, false} -> @@ -50,9 +49,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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 + Pleroma.HTTP.request("head", MediaProxy.url(url), [], [], [adapter: [pool: :preview]]) do content_type = Tesla.get_header(head_response, "content-type") handle_preview(content_type, conn, url) else @@ -172,17 +169,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do {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 media_preview_proxy_opts do - Config.get([:media_preview_proxy, :proxy_opts], []) - end end -- cgit v1.2.3 From b529616e110b3d487f1f2c462791ceabe8f1baf3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Sep 2020 15:08:12 -0500 Subject: Increase pool and timeout for preview so it catches slow media pool responses --- config/config.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index d691753bd..b92d3ccbb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -760,8 +760,8 @@ config :pleroma, :pools, ], preview: [ size: 50, - max_waiting: 10, - timeout: 10_000 + max_waiting: 20, + timeout: 15_000 ], upload: [ size: 25, -- cgit v1.2.3 From f25b0e87f3dd73e02c954c5baab3c52becdd9c9e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Sep 2020 15:28:57 -0500 Subject: URL passed to helper is already MediaProxy Set :preview pool on the request --- lib/pleroma/helpers/media_helper.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 9bd815c26..cfb091f82 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -12,8 +12,7 @@ defmodule Pleroma.Helpers.MediaHelper do 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, env} <- Pleroma.HTTP.get(url, [], [adapter: [pool: :preview]]), {:ok, fifo_path} <- mkfifo() do args = List.flatten([fifo_path, args]) run_fifo(fifo_path, env, executable, args) @@ -61,8 +60,7 @@ defmodule Pleroma.Helpers.MediaHelper do 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, env} <- Pleroma.HTTP.get(url, [], [adapter: [pool: :preview]]), {:ok, fifo_path} <- mkfifo(), args = [ "-y", -- cgit v1.2.3 From c3b02341bf4ab610e9425d6811dca057e9f811a4 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 5 Sep 2020 16:16:35 +0300 Subject: [#2497] Made media preview proxy fall back to media proxy instead of to source url. Adjusted tests. Refactoring. --- lib/pleroma/helpers/media_helper.ex | 6 ++- lib/pleroma/web/media_proxy/media_proxy.ex | 4 +- .../web/media_proxy/media_proxy_controller.ex | 50 ++++++++++++---------- test/web/mastodon_api/views/account_view_test.exs | 37 +++++++++------- 4 files changed, 53 insertions(+), 44 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index cfb091f82..bb93d4915 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -7,12 +7,14 @@ defmodule Pleroma.Helpers.MediaHelper do Handles common media-related operations. """ + alias Pleroma.HTTP + @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), - {:ok, env} <- Pleroma.HTTP.get(url, [], [adapter: [pool: :preview]]), + {:ok, env} <- HTTP.get(url, [], adapter: [pool: :preview]), {:ok, fifo_path} <- mkfifo() do args = List.flatten([fifo_path, args]) run_fifo(fifo_path, env, executable, args) @@ -60,7 +62,7 @@ defmodule Pleroma.Helpers.MediaHelper do def video_framegrab(url) do with executable when is_binary(executable) <- System.find_executable("ffmpeg"), - {:ok, env} <- Pleroma.HTTP.get(url, [], [adapter: [pool: :preview]]), + {:ok, env} <- HTTP.get(url, [], adapter: [pool: :preview]), {:ok, fifo_path} <- mkfifo(), args = [ "-y", diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 4cbe1cf89..80017cde1 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -57,13 +57,11 @@ defmodule Pleroma.Web.MediaProxy do end end - # 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, preview_params \\ []) do if preview_enabled?() do encode_preview_url(url, preview_params) else - url + url(url) 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 33daa1e05..469fbae59 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -48,10 +48,12 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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", MediaProxy.url(url), [], [], [adapter: [pool: :preview]]) do + Pleroma.HTTP.request("head", media_proxy_url, [], [], adapter: [pool: :preview]) do content_type = Tesla.get_header(head_response, "content-type") - handle_preview(content_type, conn, url) + handle_preview(content_type, conn, media_proxy_url) else {_, %{status: status}} -> send_resp(conn, :failed_dependency, "Can't fetch HTTP headers (HTTP #{status}).") @@ -67,40 +69,38 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do defp handle_preview( "image/" <> _ = _content_type, %{params: %{"output_format" => "jpeg"}} = conn, - url + media_proxy_url ) do - handle_jpeg_preview(conn, url) + handle_jpeg_preview(conn, media_proxy_url) end - defp handle_preview("image/gif" = _content_type, conn, url) do - mediaproxy_url = url |> MediaProxy.url() - - redirect(conn, external: mediaproxy_url) + defp handle_preview("image/gif" = _content_type, conn, media_proxy_url) do + redirect(conn, external: media_proxy_url) end - defp handle_preview("image/png" <> _ = _content_type, conn, url) do - handle_png_preview(conn, url) + 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, url) do - handle_jpeg_preview(conn, url) + 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, url) do - handle_video_preview(conn, url) + defp handle_preview("video/" <> _ = _content_type, conn, media_proxy_url) do + handle_video_preview(conn, media_proxy_url) end - defp handle_preview(content_type, conn, _url) do + defp handle_preview(content_type, conn, _media_proxy_url) do send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") end - defp handle_png_preview(%{params: params} = conn, url) do + defp handle_png_preview(%{params: params} = conn, media_proxy_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, + media_proxy_url, %{ max_width: thumbnail_max_width, max_height: thumbnail_max_height, @@ -109,7 +109,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do } ) do conn - |> put_preview_response_headers("image/png", "preview.png") + |> put_preview_response_headers(["image/png", "preview.png"]) |> send_resp(200, thumbnail_binary) else _ -> @@ -117,13 +117,13 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - defp handle_jpeg_preview(%{params: params} = conn, url) do + defp handle_jpeg_preview(%{params: params} = conn, media_proxy_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, + media_proxy_url, %{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality} ) do conn @@ -135,9 +135,9 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - defp handle_video_preview(conn, url) do + defp handle_video_preview(conn, media_proxy_url) do with {:ok, thumbnail_binary} <- - MediaHelper.video_framegrab(url) do + MediaHelper.video_framegrab(media_proxy_url) do conn |> put_preview_response_headers() |> send_resp(200, thumbnail_binary) @@ -147,10 +147,14 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - defp put_preview_response_headers(conn, content_type \\ "image/jpeg", filename \\ "preview.jpg") do + 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}\"") + # TODO: enable caching |> put_resp_header("cache-control", "max-age=0, private, must-revalidate") end diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 8f37efa3c..2f56d9c8f 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -541,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, @@ -551,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 -- cgit v1.2.3 From f170d471307ba0082b98351190b3d6b808bdfe1a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 5 Sep 2020 20:19:09 +0300 Subject: [#2497] Adjusted media proxy preview invalidation. Allowed client-side caching for media preview. Adjusted prewarmer to fetch only proxiable URIs. Removed :preview pool in favor of existing :media one. Misc. refactoring. --- config/config.exs | 5 ---- lib/pleroma/helpers/media_helper.ex | 4 ++-- lib/pleroma/reverse_proxy/reverse_proxy.ex | 1 + .../activity_pub/mrf/media_proxy_warming_policy.ex | 27 +++++++++++++--------- lib/pleroma/web/media_proxy/invalidation.ex | 4 +++- lib/pleroma/web/media_proxy/media_proxy.ex | 20 ++++++++-------- .../web/media_proxy/media_proxy_controller.ex | 5 ++-- 7 files changed, 34 insertions(+), 32 deletions(-) diff --git a/config/config.exs b/config/config.exs index b92d3ccbb..e5b7e18df 100644 --- a/config/config.exs +++ b/config/config.exs @@ -754,11 +754,6 @@ config :pleroma, :pools, timeout: 10_000 ], media: [ - size: 50, - max_waiting: 10, - timeout: 10_000 - ], - preview: [ size: 50, max_waiting: 20, timeout: 15_000 diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index bb93d4915..a1205e10d 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Helpers.MediaHelper do 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, [], adapter: [pool: :preview]), + {:ok, env} <- HTTP.get(url, [], adapter: [pool: :media]), {:ok, fifo_path} <- mkfifo() do args = List.flatten([fifo_path, args]) run_fifo(fifo_path, env, executable, args) @@ -62,7 +62,7 @@ defmodule Pleroma.Helpers.MediaHelper do def video_framegrab(url) do with executable when is_binary(executable) <- System.find_executable("ffmpeg"), - {:ok, env} <- HTTP.get(url, [], adapter: [pool: :preview]), + {:ok, env} <- HTTP.get(url, [], adapter: [pool: :media]), {:ok, fifo_path} <- mkfifo(), args = [ "-y", diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 35637e934..8ae1157df 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -18,6 +18,7 @@ defmodule Pleroma.ReverseProxy do @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. 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 5d8bb72aa..1050b74ba 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 @@ -12,23 +12,28 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do require Logger - @options [ + @adapter_options [ pool: :media ] 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) - opts = - if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do - Keyword.put(@options, :recv_timeout, 10_000) - else - @options - end + Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}") - url - |> MediaProxy.preview_url() - |> HTTP.get([], adapter: opts) + HTTP.get(prefetch_url, [], adapter: adapter_options()) + end + end + + defp adapter_options do + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.put(@adapter_options, :recv_timeout, 10_000) + else + @adapter_options + end end def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index 5808861e6..4f4340478 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -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 diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 80017cde1..ba553998b 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -41,20 +41,16 @@ defmodule Pleroma.Web.MediaProxy do def url("/" <> _ = url), do: url def url(url) do - if not enabled?() 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 - else - true - end + not local?(url) and not whitelisted?(url) end def preview_url(url, preview_params \\ []) do @@ -69,7 +65,7 @@ defmodule Pleroma.Web.MediaProxy 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 preview_enabled?, do: enabled?() and !!Config.get([:media_preview_proxy, :enabled]) def local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) @@ -138,9 +134,13 @@ defmodule Pleroma.Web.MediaProxy do if path = URI.parse(url_or_path).path, do: Path.basename(path) end + 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()), + base_url(), path, sig_base64, url_base64, diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 469fbae59..89f4a23bd 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -51,7 +51,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController 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, [], [], adapter: [pool: :preview]) do + Pleroma.HTTP.request("head", media_proxy_url, [], [], adapter: [pool: :media]) do content_type = Tesla.get_header(head_response, "content-type") handle_preview(content_type, conn, media_proxy_url) else @@ -154,8 +154,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do conn |> put_resp_header("content-type", content_type) |> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"") - # TODO: enable caching - |> put_resp_header("cache-control", "max-age=0, private, must-revalidate") + |> put_resp_header("cache-control", ReverseProxy.default_cache_control_header()) end defp thumbnail_max_dimensions(params) do -- cgit v1.2.3 From 88a6ee4a5989036de5c1e82c6111291887597d98 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 5 Sep 2020 20:23:18 +0300 Subject: [#2497] Func defs grouping fix. --- .../web/activity_pub/mrf/media_proxy_warming_policy.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 1050b74ba..6c63fe15c 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 @@ -16,6 +16,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do pool: :media ] + defp adapter_options do + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.put(@adapter_options, :recv_timeout, 10_000) + else + @adapter_options + end + end + def perform(:prefetch, url) do # Fetching only proxiable resources if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do @@ -28,14 +36,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do end end - defp adapter_options do - if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do - Keyword.put(@adapter_options, :recv_timeout, 10_000) - else - @adapter_options - end - end - def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do Enum.each(attachments, fn %{"url" => url} when is_list(url) -> -- cgit v1.2.3 From 759f8bc3ae49580319a4ecb12770e8581826c6d9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 6 Sep 2020 15:30:11 +0300 Subject: [#2497] Fixed MediaProxyWarmingPolicyTest. --- test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs b/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs index 313d59a66..1710c4d2a 100644 --- a/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs +++ b/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs @@ -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) -- cgit v1.2.3 From 68a74d66596f0e35f0e080de25e4679d2c8b1b76 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 9 Sep 2020 19:30:42 +0300 Subject: [#2497] Added missing alias, removed legacy `:adapter` option specification for HTTP.get/_. --- lib/pleroma/helpers/media_helper.ex | 4 ++-- lib/pleroma/instances/instance.ex | 2 +- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 2 +- test/web/mastodon_api/views/account_view_test.exs | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index a1205e10d..d834b4a07 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Helpers.MediaHelper do 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, [], adapter: [pool: :media]), + {: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) @@ -62,7 +62,7 @@ defmodule Pleroma.Helpers.MediaHelper do def video_framegrab(url) do with executable when is_binary(executable) <- System.find_executable("ffmpeg"), - {:ok, env} <- HTTP.get(url, [], adapter: [pool: :media]), + {:ok, env} <- HTTP.get(url, [], pool: :media), {:ok, fifo_path} <- mkfifo(), args = [ "-y", diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 8bf53c090..4fe4b198d 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -157,7 +157,7 @@ defmodule Pleroma.Instances.Instance do try do with {:ok, %Tesla.Env{body: html}} <- Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], - adapter: [pool: :media] + pool: :media ), favicon_rel <- html diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 89f4a23bd..acb581459 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -51,7 +51,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController 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, [], [], adapter: [pool: :media]) do + Pleroma.HTTP.request("head", media_proxy_url, [], [], pool: :media) do content_type = Tesla.get_header(head_response, "content-type") handle_preview(content_type, conn, media_proxy_url) else diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 5c5aa6cee..793b44fca 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -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 -- cgit v1.2.3 From b4860c57a63b48ded8eaa37b9f40cc0851c78882 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 9 Sep 2020 19:43:36 +0300 Subject: [#2497] Formatting fix. --- lib/pleroma/instances/instance.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 4fe4b198d..ad7764f05 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -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"}], - pool: :media - ), + Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], pool: :media), favicon_rel <- html |> Floki.parse_document!() -- cgit v1.2.3 From 148bc244359e70c87ec2812c65da83fe87efbc68 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 10 Sep 2020 11:54:10 +0300 Subject: [#2497] Removed Hackney-specific code (no longer needed due to adapter options unification). --- .../web/activity_pub/mrf/media_proxy_warming_policy.ex | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) 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 6c63fe15c..0fb05d3c4 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 @@ -13,17 +13,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do require Logger @adapter_options [ - pool: :media + pool: :media, + recv_timeout: 10_000 ] - defp adapter_options do - if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do - Keyword.put(@adapter_options, :recv_timeout, 10_000) - else - @adapter_options - end - end - def perform(:prefetch, url) do # Fetching only proxiable resources if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do @@ -32,7 +25,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}") - HTTP.get(prefetch_url, [], adapter: adapter_options()) + HTTP.get(prefetch_url, [], @adapter_options) end end -- cgit v1.2.3 From dc4e06e1991379f9f1b64774c5bdaacec96639b7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 10 Sep 2020 21:28:07 +0300 Subject: [#2497] Removed support for thumbnail_max_* params for media preview proxy (per https://git.pleroma.social/pleroma/pleroma/-/merge_requests/2497#note_70771) --- .../web/media_proxy/media_proxy_controller.ex | 38 ++++++++-------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index acb581459..5621f72dc 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do alias Pleroma.Helpers.MediaHelper alias Pleroma.ReverseProxy alias Pleroma.Web.MediaProxy + alias Plug.Conn def remote(conn, %{"sig" => sig64, "url" => url64}) do with {_, true} <- {:enabled, MediaProxy.enabled?()}, @@ -18,29 +19,29 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do ReverseProxy.call(conn, url, media_proxy_opts()) else {:enabled, false} -> - send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) + send_resp(conn, 404, Conn.Status.reason_phrase(404)) {:in_banned_urls, true} -> - send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) + 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 preview(conn, %{"sig" => sig64, "url" => url64}) do + def preview(%Conn{} = conn, %{"sig" => sig64, "url" => url64}) do with {_, true} <- {:enabled, MediaProxy.preview_enabled?()}, {:ok, url} <- MediaProxy.decode_url(sig64, url64) do handle_preview(conn, url) else {:enabled, false} -> - send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) + 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_preview_url(sig64, url64, filename)) @@ -94,10 +95,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") end - defp handle_png_preview(%{params: params} = conn, media_proxy_url) do + defp handle_png_preview(conn, media_proxy_url) do quality = Config.get!([:media_preview_proxy, :image_quality]) - with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(params), + with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(), {:ok, thumbnail_binary} <- MediaHelper.image_resize( media_proxy_url, @@ -117,10 +118,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - defp handle_jpeg_preview(%{params: params} = conn, media_proxy_url) do + defp handle_jpeg_preview(conn, media_proxy_url) do quality = Config.get!([:media_preview_proxy, :image_quality]) - with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(params), + with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(), {:ok, thumbnail_binary} <- MediaHelper.image_resize( media_proxy_url, @@ -157,22 +158,11 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do |> put_resp_header("cache-control", ReverseProxy.default_cache_control_header()) end - defp thumbnail_max_dimensions(params) do + defp thumbnail_max_dimensions() 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 = Keyword.fetch!(config, :thumbnail_max_width) + thumbnail_max_height = Keyword.fetch!(config, :thumbnail_max_height) {thumbnail_max_width, thumbnail_max_height} end -- cgit v1.2.3 From 4d18a50f3c4b6654339a6a8df71160e23b45cac0 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 10 Sep 2020 21:54:26 +0300 Subject: [#2497] Formatting fix. --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 5621f72dc..ff7fd2409 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -158,7 +158,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do |> put_resp_header("cache-control", ReverseProxy.default_cache_control_header()) end - defp thumbnail_max_dimensions() do + defp thumbnail_max_dimensions do config = Config.get([:media_preview_proxy], []) thumbnail_max_width = Keyword.fetch!(config, :thumbnail_max_width) -- cgit v1.2.3 From 32831f371ff426ac0c6f5d6c1381313f5f92af42 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 12 Sep 2020 10:33:42 +0300 Subject: [#2497] Media preview proxy: redirecting to media proxy url in case of preview error or unsupported content type. --- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index ff7fd2409..08d62a51a 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -91,8 +91,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do handle_video_preview(conn, media_proxy_url) end - defp handle_preview(content_type, conn, _media_proxy_url) do - send_resp(conn, :unprocessable_entity, "Unsupported content type: #{content_type}.") + 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 @@ -114,7 +114,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do |> send_resp(200, thumbnail_binary) else _ -> - send_resp(conn, :failed_dependency, "Can't handle preview.") + fallback_on_preview_error(conn, media_proxy_url) end end @@ -132,7 +132,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do |> send_resp(200, thumbnail_binary) else _ -> - send_resp(conn, :failed_dependency, "Can't handle preview.") + fallback_on_preview_error(conn, media_proxy_url) end end @@ -144,10 +144,14 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do |> send_resp(200, thumbnail_binary) else _ -> - send_resp(conn, :failed_dependency, "Can't handle preview.") + fallback_on_preview_error(conn, media_proxy_url) end 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"] -- cgit v1.2.3 From cd234a5321b9d33146b90be95d84fa67aa4f7707 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 12 Sep 2020 11:20:41 +0300 Subject: [#2497] Media preview proxy: preview bypass for small images (basing on Content-Length and Content-Type). --- .../web/media_proxy/media_proxy_controller.ex | 25 ++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 08d62a51a..78df7763e 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do alias Pleroma.Web.MediaProxy alias Plug.Conn + @min_content_length_for_preview 100 * 1024 + def remote(conn, %{"sig" => sig64, "url" => url64}) do with {_, true} <- {:enabled, MediaProxy.enabled?()}, {:ok, url} <- MediaProxy.decode_url(sig64, url64), @@ -54,8 +56,12 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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") - handle_preview(content_type, conn, media_proxy_url) + content_length = Tesla.get_header(head_response, "content-length") + content_length = content_length && String.to_integer(content_length) + + handle_preview(content_type, content_length, conn, media_proxy_url) 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}).") @@ -69,29 +75,36 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do defp handle_preview( "image/" <> _ = _content_type, + _content_length, %{params: %{"output_format" => "jpeg"}} = conn, media_proxy_url ) do handle_jpeg_preview(conn, media_proxy_url) end - defp handle_preview("image/gif" = _content_type, conn, media_proxy_url) do + defp handle_preview("image/gif" = _content_type, _content_length, conn, media_proxy_url) do + redirect(conn, external: media_proxy_url) + end + + defp handle_preview("image/" <> _ = _content_type, content_length, conn, media_proxy_url) + when is_integer(content_length) and content_length > 0 and + content_length < @min_content_length_for_preview do redirect(conn, external: media_proxy_url) end - defp handle_preview("image/png" <> _ = _content_type, conn, media_proxy_url) do + defp handle_preview("image/png" <> _ = _content_type, _content_length, conn, media_proxy_url) do handle_png_preview(conn, media_proxy_url) end - defp handle_preview("image/" <> _ = _content_type, conn, media_proxy_url) do + defp handle_preview("image/" <> _ = _content_type, _content_length, conn, media_proxy_url) do handle_jpeg_preview(conn, media_proxy_url) end - defp handle_preview("video/" <> _ = _content_type, conn, media_proxy_url) do + defp handle_preview("video/" <> _ = _content_type, _content_length, conn, media_proxy_url) do handle_video_preview(conn, media_proxy_url) end - defp handle_preview(_unsupported_content_type, conn, media_proxy_url) do + defp handle_preview(_unsupported_content_type, _content_length, conn, media_proxy_url) do fallback_on_preview_error(conn, media_proxy_url) end -- cgit v1.2.3 From a781f41f969bd1a929005b2b5006a40d42855ae8 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 16 Sep 2020 22:30:42 +0300 Subject: [#2497] Media preview proxy: misc. improvements (`static` param support, dynamic fifo pipe path), refactoring. --- CHANGELOG.md | 3 +++ lib/pleroma/helpers/media_helper.ex | 4 +--- lib/pleroma/helpers/uri_helper.ex | 13 ++++++++----- lib/pleroma/web/mastodon_api/views/account_view.ex | 4 ++-- lib/pleroma/web/media_proxy/media_proxy.ex | 2 +- lib/pleroma/web/media_proxy/media_proxy_controller.ex | 19 ++++++++++++++++--- lib/pleroma/web/oauth/oauth_controller.ex | 4 ++-- 7 files changed, 33 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a372e11..adea6d019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Renamed `:timeout` in `pools` namespace to `:recv_timeout`, old name is deprecated. - 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). diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index d834b4a07..9b7348ee2 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -9,8 +9,6 @@ defmodule Pleroma.Helpers.MediaHelper do alias Pleroma.HTTP - @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), @@ -103,7 +101,7 @@ defmodule Pleroma.Helpers.MediaHelper do end defp mkfifo do - path = "#{@tmp_base}#{to_charlist(:erlang.phash2(self()))}" + path = Path.join(System.tmp_dir!(), "pleroma-media-preview-pipe-#{Ecto.UUID.generate()}") case System.cmd("mkfifo", [path]) do {_, 0} -> diff --git a/lib/pleroma/helpers/uri_helper.ex b/lib/pleroma/helpers/uri_helper.ex index 9c9e53447..f1301f055 100644 --- a/lib/pleroma/helpers/uri_helper.ex +++ b/lib/pleroma/helpers/uri_helper.ex @@ -3,14 +3,17 @@ # 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)) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index a811f81c2..121ba1693 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -182,9 +182,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do display_name = user.name || user.nickname avatar = User.avatar_url(user) |> MediaProxy.url() - avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(output_format: "jpeg") + 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(output_format: "jpeg") + 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 diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index ba553998b..8656b8cad 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -157,7 +157,7 @@ defmodule Pleroma.Web.MediaProxy do def build_preview_url(sig_base64, url_base64, filename \\ nil, preview_params \\ []) do uri = proxy_url("proxy/preview", sig_base64, url_base64, filename) - UriHelper.append_uri_params(uri, preview_params) + UriHelper.modify_uri_params(uri, preview_params) end def verify_request_path_and_url( diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 78df7763e..fe279e964 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do alias Pleroma.Config alias Pleroma.Helpers.MediaHelper + alias Pleroma.Helpers.UriHelper alias Pleroma.ReverseProxy alias Pleroma.Web.MediaProxy alias Plug.Conn @@ -74,14 +75,26 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end defp handle_preview( - "image/" <> _ = _content_type, + "image/gif" = _content_type, _content_length, - %{params: %{"output_format" => "jpeg"}} = conn, + %{params: %{"static" => static}} = conn, media_proxy_url - ) do + ) + when static in ["true", true] do handle_jpeg_preview(conn, media_proxy_url) end + defp handle_preview( + _content_type, + _content_length, + %{params: %{"static" => static}} = conn, + _media_proxy_url + ) + when static in ["true", true] do + uri_without_static_param = UriHelper.modify_uri_params(current_url(conn), %{}, ["static"]) + redirect(conn, external: uri_without_static_param) + end + defp handle_preview("image/gif" = _content_type, _content_length, conn, media_proxy_url) do redirect(conn, external: media_proxy_url) end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 26e68be42..a4152e840 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -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 -- cgit v1.2.3 From 7cdbd91d83c02a79c22783ca489ef82e82b31a51 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 17 Sep 2020 17:13:40 +0300 Subject: [#2497] Configurability of :min_content_length (preview proxy). Refactoring, documentation, tests. --- config/config.exs | 3 +- config/description.exs | 12 +- docs/configuration/cheatsheet.md | 8 + lib/pleroma/helpers/media_helper.ex | 1 + .../web/media_proxy/media_proxy_controller.ex | 90 +++---- test/fixtures/image.gif | Bin 0 -> 1001718 bytes test/fixtures/image.png | Bin 0 -> 104426 bytes .../media_proxy/media_proxy_controller_test.exs | 278 +++++++++++++++++++-- 8 files changed, 329 insertions(+), 63 deletions(-) create mode 100755 test/fixtures/image.gif create mode 100755 test/fixtures/image.png diff --git a/config/config.exs b/config/config.exs index 2ca2236a9..98c31ef86 100644 --- a/config/config.exs +++ b/config/config.exs @@ -444,7 +444,8 @@ config :pleroma, :media_preview_proxy, enabled: false, thumbnail_max_width: 600, thumbnail_max_height: 600, - image_quality: 85 + image_quality: 85, + min_content_length: 100 * 1024 config :pleroma, :chat, enabled: true diff --git a/config/description.exs b/config/description.exs index 79e3cc259..4a5d5f2ea 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1961,17 +1961,25 @@ config :pleroma, :config_description, [ %{ key: :thumbnail_max_width, type: :integer, - description: "Max width of preview thumbnail." + 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." + 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." } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 054b8fe43..d7c342383 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -314,6 +314,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 index 9b7348ee2..b6f35a24b 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -58,6 +58,7 @@ defmodule Pleroma.Helpers.MediaHelper do 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), diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index fe279e964..90651ed9b 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -12,8 +12,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do alias Pleroma.Web.MediaProxy alias Plug.Conn - @min_content_length_for_preview 100 * 1024 - def remote(conn, %{"sig" => sig64, "url" => url64}) do with {_, true} <- {:enabled, MediaProxy.enabled?()}, {:ok, url} <- MediaProxy.decode_url(sig64, url64), @@ -37,7 +35,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do def preview(%Conn{} = conn, %{"sig" => sig64, "url" => url64}) do with {_, true} <- {:enabled, MediaProxy.preview_enabled?()}, - {:ok, url} <- MediaProxy.decode_url(sig64, url64) do + {:ok, url} <- MediaProxy.decode_url(sig64, url64), + :ok <- MediaProxy.verify_request_path_and_url(conn, url) do handle_preview(conn, url) else {:enabled, false} -> @@ -59,8 +58,25 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController 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) - handle_preview(content_type, content_length, 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}} -> @@ -74,58 +90,27 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - defp handle_preview( - "image/gif" = _content_type, - _content_length, - %{params: %{"static" => static}} = conn, - media_proxy_url - ) - when static in ["true", true] do - handle_jpeg_preview(conn, media_proxy_url) - end - - defp handle_preview( - _content_type, - _content_length, - %{params: %{"static" => static}} = conn, - _media_proxy_url - ) - when static in ["true", true] do - uri_without_static_param = UriHelper.modify_uri_params(current_url(conn), %{}, ["static"]) - redirect(conn, external: uri_without_static_param) - end - - defp handle_preview("image/gif" = _content_type, _content_length, conn, media_proxy_url) do - redirect(conn, external: media_proxy_url) - end - - defp handle_preview("image/" <> _ = _content_type, content_length, conn, media_proxy_url) - when is_integer(content_length) and content_length > 0 and - content_length < @min_content_length_for_preview do - redirect(conn, external: media_proxy_url) - end - - defp handle_preview("image/png" <> _ = _content_type, _content_length, conn, media_proxy_url) do + 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, _content_length, conn, media_proxy_url) do + 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, _content_length, conn, media_proxy_url) do + 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, _content_length, conn, media_proxy_url) do + 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 {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(), - {:ok, thumbnail_binary} <- + with {:ok, thumbnail_binary} <- MediaHelper.image_resize( media_proxy_url, %{ @@ -146,9 +131,9 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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() - with {thumbnail_max_width, thumbnail_max_height} <- thumbnail_max_dimensions(), - {:ok, thumbnail_binary} <- + with {:ok, thumbnail_binary} <- MediaHelper.image_resize( media_proxy_url, %{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality} @@ -174,6 +159,15 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do 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 @@ -189,7 +183,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end defp thumbnail_max_dimensions do - config = Config.get([:media_preview_proxy], []) + config = media_preview_proxy_config() thumbnail_max_width = Keyword.fetch!(config, :thumbnail_max_width) thumbnail_max_height = Keyword.fetch!(config, :thumbnail_max_height) @@ -197,6 +191,14 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do {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 media_proxy_opts do Config.get([:media_proxy, :proxy_opts], []) end diff --git a/test/fixtures/image.gif b/test/fixtures/image.gif new file mode 100755 index 000000000..9df64778b 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 index 000000000..e999e8800 Binary files /dev/null and b/test/fixtures/image.png differ diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index 0dd2fd10c..33e6873f7 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -14,27 +14,28 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest 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) @@ -55,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 @@ -78,4 +79,249 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do end end end + + 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: "error"} + end) + + response = get(conn, url) + + assert response.status == 302 + assert redirected_to(response) == media_proxy_url + end + end end -- cgit v1.2.3 From 226fa3e486e3ea9f82e8d3a7025244fdf11d14db Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Thu, 17 Sep 2020 22:04:47 +0200 Subject: Make WebPushEncryption use Pleroma.HTTP as an HTTP adapter --- config/config.exs | 2 ++ test/web/push/impl_test.exs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/config/config.exs b/config/config.exs index c204814d0..b8864fe08 100644 --- a/config/config.exs +++ b/config/config.exs @@ -787,6 +787,8 @@ config :tzdata, :http_client, Pleroma.HTTP.Tzdata config :ex_aws, http_client: Pleroma.HTTP.ExAws +config :web_push_encryption, http_client: Pleroma.HTTP + config :pleroma, :instances_favicons, enabled: false config :floki, :html_parser, Floki.HTMLParser.FastHtml diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index aeb5c1fbd..c7c17e156 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -12,7 +12,9 @@ defmodule Pleroma.Web.Push.ImplTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Push.Impl alias Pleroma.Web.Push.Subscription + alias Pleroma.Web.WebPushHttpClientMock + import Mock import Pleroma.Factory setup do @@ -78,6 +80,22 @@ defmodule Pleroma.Web.Push.ImplTest do assert Impl.push_message(@message, @sub, @api_key, %Subscription{}) == :ok end + test_with_mock "uses WebPushHttpClientMock as an HTTP client", WebPushHttpClientMock, + post: fn _, _, _ -> {:ok, %{status_code: 200}} end do + Impl.push_message(@message, @sub, @api_key, %Subscription{}) + assert_called(WebPushHttpClientMock.post("https://example.com/example/1234", :_, :_)) + end + + test_with_mock "uses Pleroma.HTTP as an HTTP client", Pleroma.HTTP, + post: fn _, _, _ -> {:ok, %{status_code: 200}} end do + client = Application.get_env(:web_push_encryption, :http_client) + on_exit(fn -> Application.put_env(:web_push_encryption, :http_client, client) end) + Application.put_env(:web_push_encryption, :http_client, Pleroma.HTTP) + + Impl.push_message(@message, @sub, @api_key, %Subscription{}) + assert_called(Pleroma.HTTP.post("https://example.com/example/1234", :_, :_)) + end + @tag capture_log: true test "fail message sending" do assert Impl.push_message( -- cgit v1.2.3 From f2ef9735c52c648a03de4af41f19bb4ec857de03 Mon Sep 17 00:00:00 2001 From: Steven Fuchs Date: Fri, 18 Sep 2020 11:58:22 +0000 Subject: Federate data through persistent websocket connections --- CHANGELOG.md | 3 + config/config.exs | 12 ++ config/description.exs | 13 ++ config/test.exs | 5 + docs/configuration/cheatsheet.md | 10 ++ lib/pleroma/application.ex | 9 +- lib/pleroma/object/fetcher.ex | 60 ++++++-- lib/pleroma/signature.ex | 6 +- lib/pleroma/user.ex | 10 +- lib/pleroma/web/activity_pub/activity_pub.ex | 14 +- lib/pleroma/web/activity_pub/publisher.ex | 34 +++-- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- lib/pleroma/web/fed_sockets/fed_registry.ex | 185 ++++++++++++++++++++++++ lib/pleroma/web/fed_sockets/fed_socket.ex | 137 ++++++++++++++++++ lib/pleroma/web/fed_sockets/fed_sockets.ex | 182 +++++++++++++++++++++++ lib/pleroma/web/fed_sockets/fetch_registry.ex | 151 +++++++++++++++++++ lib/pleroma/web/fed_sockets/incoming_handler.ex | 88 +++++++++++ lib/pleroma/web/fed_sockets/ingester_worker.ex | 33 +++++ lib/pleroma/web/fed_sockets/outgoing_handler.ex | 146 +++++++++++++++++++ lib/pleroma/web/fed_sockets/socket_info.ex | 52 +++++++ lib/pleroma/web/fed_sockets/supervisor.ex | 59 ++++++++ mix.lock | 2 +- test/web/fed_sockets/fed_registry_test.exs | 124 ++++++++++++++++ test/web/fed_sockets/fetch_registry_test.exs | 67 +++++++++ test/web/fed_sockets/socket_info_test.exs | 118 +++++++++++++++ 25 files changed, 1476 insertions(+), 46 deletions(-) create mode 100644 lib/pleroma/web/fed_sockets/fed_registry.ex create mode 100644 lib/pleroma/web/fed_sockets/fed_socket.ex create mode 100644 lib/pleroma/web/fed_sockets/fed_sockets.ex create mode 100644 lib/pleroma/web/fed_sockets/fetch_registry.ex create mode 100644 lib/pleroma/web/fed_sockets/incoming_handler.ex create mode 100644 lib/pleroma/web/fed_sockets/ingester_worker.ex create mode 100644 lib/pleroma/web/fed_sockets/outgoing_handler.ex create mode 100644 lib/pleroma/web/fed_sockets/socket_info.ex create mode 100644 lib/pleroma/web/fed_sockets/supervisor.ex create mode 100644 test/web/fed_sockets/fed_registry_test.exs create mode 100644 test/web/fed_sockets/fetch_registry_test.exs create mode 100644 test/web/fed_sockets/socket_info_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1266d2dfe..5e4d06c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added +- Experimental websocket-based federation between Pleroma instances. + ### Changed - Renamed `:await_up_timeout` in `:connections_pool` namespace to `:connect_timeout`, old name is deprecated. diff --git a/config/config.exs b/config/config.exs index c204814d0..104013b41 100644 --- a/config/config.exs +++ b/config/config.exs @@ -130,6 +130,7 @@ config :pleroma, Pleroma.Web.Endpoint, dispatch: [ {:_, [ + {"/api/fedsocket/v1", Pleroma.Web.FedSockets.IncomingHandler, []}, {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, {"/websocket", Phoenix.Endpoint.CowboyWebSocket, {Phoenix.Transports.WebSocket, @@ -148,6 +149,16 @@ config :pleroma, Pleroma.Web.Endpoint, "SameSite=Lax" ] +config :pleroma, :fed_sockets, + enabled: false, + connection_duration: :timer.hours(8), + rejection_duration: :timer.minutes(15), + fed_socket_fetches: [ + default: 12_000, + interval: 3_000, + lazy: false + ] + # Configures Elixir's Logger config :logger, :console, level: :debug, @@ -532,6 +543,7 @@ config :pleroma, Oban, token_expiration: 5, federator_incoming: 50, federator_outgoing: 50, + ingestion_queue: 50, web_push: 50, mailer: 10, transmogrifier: 20, diff --git a/config/description.exs b/config/description.exs index 2b30f8148..6f3855918 100644 --- a/config/description.exs +++ b/config/description.exs @@ -270,6 +270,19 @@ config :pleroma, :config_description, [ } ] }, + %{ + group: :pleroma, + key: :fed_sockets, + type: :group, + description: "Websocket based federation", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enable FedSockets" + } + ] + }, %{ group: :pleroma, key: Pleroma.Emails.Mailer, diff --git a/config/test.exs b/config/test.exs index 0ee6f1b7f..93a0e2a61 100644 --- a/config/test.exs +++ b/config/test.exs @@ -19,6 +19,11 @@ config :logger, :console, level: :warn, format: "\n[$level] $message\n" +config :pleroma, :fed_sockets, + enabled: false, + connection_duration: 5, + rejection_duration: 5 + config :pleroma, :auth, oauth_consumer_strategies: [] config :pleroma, Pleroma.Upload, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 054b8fe43..9a275294e 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -225,6 +225,16 @@ Enables the worker which processes posts scheduled for deletion. Pinned posts ar * `enabled`: whether expired activities will be sent to the job queue to be deleted +## FedSockets +FedSockets is an experimental feature allowing for Pleroma backends to federate using a persistant websocket connection as opposed to making each federation a seperate http connection. This feature is currently off by default. It is configurable throught he following options. + +### :fedsockets +* `enabled`: Enables FedSockets for this instance. `false` by default. +* `connection_duration`: Time an idle websocket is kept open. +* `rejection_duration`: Failures to connect via FedSockets will not be retried for this period of time. +* `fed_socket_fetches` and `fed_socket_rejections`: Settings passed to `cachex` for the fetch registry, and rejection stacks. See `Pleroma.Web.FedSockets` for more details. + + ## Frontends ### :frontend_configurations diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index c39e24919..00ec79a2a 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -99,7 +99,7 @@ defmodule Pleroma.Application do {Oban, Config.get(Oban)} ] ++ task_children(@env) ++ - streamer_child(@env) ++ + dont_run_in_test(@env) ++ chat_child(@env, chat_enabled?()) ++ [ Pleroma.Web.Endpoint, @@ -188,16 +188,17 @@ defmodule Pleroma.Application do defp chat_enabled?, do: Config.get([:chat, :enabled]) - defp streamer_child(env) when env in [:test, :benchmark], do: [] + defp dont_run_in_test(env) when env in [:test, :benchmark], do: [] - defp streamer_child(_) do + defp dont_run_in_test(_) do [ {Registry, [ name: Pleroma.Web.Streamer.registry(), keys: :duplicate, partitions: System.schedulers_online() - ]} + ]}, + Pleroma.Web.FedSockets.Supervisor ] end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 24dc7cb95..169298b34 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Federator + alias Pleroma.Web.FedSockets require Logger require Pleroma.Constants @@ -182,9 +183,47 @@ defmodule Pleroma.Object.Fetcher do end end - def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do + def fetch_and_contain_remote_object_from_id(prm, opts \\ []) + + def fetch_and_contain_remote_object_from_id(%{"id" => id}, opts), + do: fetch_and_contain_remote_object_from_id(id, opts) + + def fetch_and_contain_remote_object_from_id(id, opts) when is_binary(id) do Logger.debug("Fetching object #{id} via AP") + with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, + {:ok, body} <- get_object(id, opts), + {:ok, data} <- safe_json_decode(body), + :ok <- Containment.contain_origin_from_id(id, data) do + {:ok, data} + else + {:scheme, _} -> + {:error, "Unsupported URI scheme"} + + {:error, e} -> + {:error, e} + + e -> + {:error, e} + end + end + + def fetch_and_contain_remote_object_from_id(_id, _opts), + do: {:error, "id must be a string"} + + defp get_object(id, opts) do + with false <- Keyword.get(opts, :force_http, false), + {:ok, fedsocket} <- FedSockets.get_or_create_fed_socket(id) do + Logger.debug("fetching via fedsocket - #{inspect(id)}") + FedSockets.fetch(fedsocket, id) + else + _other -> + Logger.debug("fetching via http - #{inspect(id)}") + get_object_http(id) + end + end + + defp get_object_http(id) do date = Pleroma.Signature.signed_date() headers = @@ -192,20 +231,13 @@ defmodule Pleroma.Object.Fetcher do |> maybe_date_fetch(date) |> sign_fetch(id, date) - Logger.debug("Fetch headers: #{inspect(headers)}") + case HTTP.get(id, headers) do + {:ok, %{body: body, status: code}} when code in 200..299 -> + {:ok, body} - with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, - {:ok, %{body: body, status: code}} when code in 200..299 <- HTTP.get(id, headers), - {:ok, data} <- Jason.decode(body), - :ok <- Containment.contain_origin_from_id(id, data) do - {:ok, data} - else {:ok, %{status: code}} when code in [404, 410] -> {:error, "Object has been deleted"} - {:scheme, _} -> - {:error, "Unsupported URI scheme"} - {:error, e} -> {:error, e} @@ -214,8 +246,6 @@ defmodule Pleroma.Object.Fetcher do end end - def fetch_and_contain_remote_object_from_id(%{"id" => id}), - do: fetch_and_contain_remote_object_from_id(id) - - def fetch_and_contain_remote_object_from_id(_id), do: {:error, "id must be a string"} + defp safe_json_decode(nil), do: {:ok, nil} + defp safe_json_decode(json), do: Jason.decode(json) end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 3aa6909d2..e388993b7 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -39,7 +39,7 @@ defmodule Pleroma.Signature do def fetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), {:ok, actor_id} <- key_id_to_actor_id(kid), - {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do + {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id, force_http: true) do {:ok, public_key} else e -> @@ -50,8 +50,8 @@ defmodule Pleroma.Signature do def refetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), {:ok, actor_id} <- key_id_to_actor_id(kid), - {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id), - {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do + {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id, force_http: true), + {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id, force_http: true) do {:ok, public_key} else e -> diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1ffe60dfc..d92484a40 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1820,12 +1820,12 @@ defmodule Pleroma.User do def html_filter_policy(_), do: Config.get([:markup, :scrub_policy]) - def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id) + def fetch_by_ap_id(ap_id, opts \\ []), do: ActivityPub.make_user_from_ap_id(ap_id, opts) - def get_or_fetch_by_ap_id(ap_id) do + def get_or_fetch_by_ap_id(ap_id, opts \\ []) do cached_user = get_cached_by_ap_id(ap_id) - maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id) + maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id, opts) case {cached_user, maybe_fetched_user} do {_, {:ok, %User{} = user}} -> @@ -1898,8 +1898,8 @@ defmodule Pleroma.User do def public_key(_), do: {:error, "key not found"} - def get_public_key_for_ap_id(ap_id) do - with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), + def get_public_key_for_ap_id(ap_id, opts \\ []) do + with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id, opts), {:ok, public_key} <- public_key(user) do {:ok, public_key} else diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 92fc1e422..06e8e1a7c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1270,10 +1270,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def fetch_follow_information_for_user(user) do with {:ok, following_data} <- - Fetcher.fetch_and_contain_remote_object_from_id(user.following_address), + Fetcher.fetch_and_contain_remote_object_from_id(user.following_address, + force_http: true + ), {:ok, hide_follows} <- collection_private(following_data), {:ok, followers_data} <- - Fetcher.fetch_and_contain_remote_object_from_id(user.follower_address), + Fetcher.fetch_and_contain_remote_object_from_id(user.follower_address, force_http: true), {:ok, hide_followers} <- collection_private(followers_data) do {:ok, %{ @@ -1347,8 +1349,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - def fetch_and_prepare_user_from_ap_id(ap_id) do - with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), + def fetch_and_prepare_user_from_ap_id(ap_id, opts \\ []) do + with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id, opts), {:ok, data} <- user_data_from_user_object(data) do {:ok, maybe_update_follow_information(data)} else @@ -1390,13 +1392,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - def make_user_from_ap_id(ap_id) do + def make_user_from_ap_id(ap_id, opts \\ []) do user = User.get_cached_by_ap_id(ap_id) if user && !User.ap_enabled?(user) do Transmogrifier.upgrade_user_from_ap_id(ap_id) else - with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do + with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, opts) do if user do user |> User.remote_user_changeset(data) diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index d88f7f3ee..9c3956683 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do alias Pleroma.User alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.FedSockets require Pleroma.Constants @@ -50,15 +51,35 @@ defmodule Pleroma.Web.ActivityPub.Publisher do def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = params) do Logger.debug("Federating #{id} to #{inbox}") - uri = URI.parse(inbox) + case FedSockets.get_or_create_fed_socket(inbox) do + {:ok, fedsocket} -> + Logger.debug("publishing via fedsockets - #{inspect(inbox)}") + FedSockets.publish(fedsocket, json) + _ -> + Logger.debug("publishing via http - #{inspect(inbox)}") + http_publish(inbox, actor, json, params) + end + end + + def publish_one(%{actor_id: actor_id} = params) do + actor = User.get_cached_by_id(actor_id) + + params + |> Map.delete(:actor_id) + |> Map.put(:actor, actor) + |> publish_one() + end + + defp http_publish(inbox, actor, json, params) do + uri = %{path: path} = URI.parse(inbox) digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) date = Pleroma.Signature.signed_date() signature = Pleroma.Signature.sign(actor, %{ - "(request-target)": "post #{uri.path}", + "(request-target)": "post #{path}", host: signature_host(uri), "content-length": byte_size(json), digest: digest, @@ -89,15 +110,6 @@ defmodule Pleroma.Web.ActivityPub.Publisher do end end - def publish_one(%{actor_id: actor_id} = params) do - actor = User.get_cached_by_id(actor_id) - - params - |> Map.delete(:actor_id) - |> Map.put(:actor, actor) - |> publish_one() - end - defp signature_host(%URI{port: port, scheme: scheme, host: host}) do if port == URI.default_port(scheme) do host diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index fcca014f0..aa6a69463 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1000,7 +1000,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def upgrade_user_from_ap_id(ap_id) do with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), - {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), + {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id, force_http: true), {:ok, user} <- update_user(user, data) do TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) {:ok, user} diff --git a/lib/pleroma/web/fed_sockets/fed_registry.ex b/lib/pleroma/web/fed_sockets/fed_registry.ex new file mode 100644 index 000000000..e00ea69c0 --- /dev/null +++ b/lib/pleroma/web/fed_sockets/fed_registry.ex @@ -0,0 +1,185 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.FedRegistry do + @moduledoc """ + The FedRegistry stores the active FedSockets for quick retrieval. + + The storage and retrieval portion of the FedRegistry is done in process through + elixir's `Registry` module for speed and its ability to monitor for terminated processes. + + Dropped connections will be caught by `Registry` and deleted. Since the next + message will initiate a new connection there is no reason to try and reconnect at that point. + + Normally outside modules should have no need to call or use the FedRegistry themselves. + """ + + alias Pleroma.Web.FedSockets.FedSocket + alias Pleroma.Web.FedSockets.SocketInfo + + require Logger + + @default_rejection_duration 15 * 60 * 1000 + @rejections :fed_socket_rejections + + @doc """ + Retrieves a FedSocket from the Registry given it's origin. + + The origin is expected to be a string identifying the endpoint "example.com" or "example2.com:8080" + + Will return: + * {:ok, fed_socket} for working FedSockets + * {:error, :rejected} for origins that have been tried and refused within the rejection duration interval + * {:error, some_reason} usually :missing for unknown origins + """ + def get_fed_socket(origin) do + case get_registry_data(origin) do + {:error, reason} -> + {:error, reason} + + {:ok, %{state: :connected} = socket_info} -> + {:ok, socket_info} + end + end + + @doc """ + Adds a connected FedSocket to the Registry. + + Always returns {:ok, fed_socket} + """ + def add_fed_socket(origin, pid \\ nil) do + origin + |> SocketInfo.build(pid) + |> SocketInfo.connect() + |> add_socket_info + end + + defp add_socket_info(%{origin: origin, state: :connected} = socket_info) do + case Registry.register(FedSockets.Registry, origin, socket_info) do + {:ok, _owner} -> + clear_prior_rejection(origin) + Logger.debug("fedsocket added: #{inspect(origin)}") + + {:ok, socket_info} + + {:error, {:already_registered, _pid}} -> + FedSocket.close(socket_info) + existing_socket_info = Registry.lookup(FedSockets.Registry, origin) + + {:ok, existing_socket_info} + + _ -> + {:error, :error_adding_socket} + end + end + + @doc """ + Mark this origin as having rejected a connection attempt. + This will keep it from getting additional connection attempts + for a period of time specified in the config. + + Always returns {:ok, new_reg_data} + """ + def set_host_rejected(uri) do + new_reg_data = + uri + |> SocketInfo.origin() + |> get_or_create_registry_data() + |> set_to_rejected() + |> save_registry_data() + + {:ok, new_reg_data} + end + + @doc """ + Retrieves the FedRegistryData from the Registry given it's origin. + + The origin is expected to be a string identifying the endpoint "example.com" or "example2.com:8080" + + Will return: + * {:ok, fed_registry_data} for known origins + * {:error, :missing} for uniknown origins + * {:error, :cache_error} indicating some low level runtime issues + """ + def get_registry_data(origin) do + case Registry.lookup(FedSockets.Registry, origin) do + [] -> + if is_rejected?(origin) do + Logger.debug("previously rejected fedsocket requested") + {:error, :rejected} + else + {:error, :missing} + end + + [{_pid, %{state: :connected} = socket_info}] -> + {:ok, socket_info} + + _ -> + {:error, :cache_error} + end + end + + @doc """ + Retrieves a map of all sockets from the Registry. The keys are the origins and the values are the corresponding SocketInfo + """ + def list_all do + (list_all_connected() ++ list_all_rejected()) + |> Enum.into(%{}) + end + + defp list_all_connected do + FedSockets.Registry + |> Registry.select([{{:"$1", :_, :"$3"}, [], [{{:"$1", :"$3"}}]}]) + end + + defp list_all_rejected do + {:ok, keys} = Cachex.keys(@rejections) + + {:ok, registry_data} = + Cachex.execute(@rejections, fn worker -> + Enum.map(keys, fn k -> {k, Cachex.get!(worker, k)} end) + end) + + registry_data + end + + defp clear_prior_rejection(origin), + do: Cachex.del(@rejections, origin) + + defp is_rejected?(origin) do + case Cachex.get(@rejections, origin) do + {:ok, nil} -> + false + + {:ok, _} -> + true + end + end + + defp get_or_create_registry_data(origin) do + case get_registry_data(origin) do + {:error, :missing} -> + %SocketInfo{origin: origin} + + {:ok, socket_info} -> + socket_info + end + end + + defp save_registry_data(%SocketInfo{origin: origin, state: :connected} = socket_info) do + {:ok, true} = Registry.update_value(FedSockets.Registry, origin, fn _ -> socket_info end) + socket_info + end + + defp save_registry_data(%SocketInfo{origin: origin, state: :rejected} = socket_info) do + rejection_expiration = + Pleroma.Config.get([:fed_sockets, :rejection_duration], @default_rejection_duration) + + {:ok, true} = Cachex.put(@rejections, origin, socket_info, ttl: rejection_expiration) + socket_info + end + + defp set_to_rejected(%SocketInfo{} = socket_info), + do: %SocketInfo{socket_info | state: :rejected} +end diff --git a/lib/pleroma/web/fed_sockets/fed_socket.ex b/lib/pleroma/web/fed_sockets/fed_socket.ex new file mode 100644 index 000000000..98d64e65a --- /dev/null +++ b/lib/pleroma/web/fed_sockets/fed_socket.ex @@ -0,0 +1,137 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.FedSocket do + @moduledoc """ + The FedSocket module abstracts the actions to be taken taken on connections regardless of + whether the connection started as inbound or outbound. + + + Normally outside modules will have no need to call the FedSocket module directly. + """ + + alias Pleroma.Object + alias Pleroma.Object.Containment + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectView + alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.FedSockets.FetchRegistry + alias Pleroma.Web.FedSockets.IngesterWorker + alias Pleroma.Web.FedSockets.OutgoingHandler + alias Pleroma.Web.FedSockets.SocketInfo + + require Logger + + @shake "61dd18f7-f1e6-49a4-939a-a749fcdc1103" + + def connect_to_host(uri) do + case OutgoingHandler.start_link(uri) do + {:ok, pid} -> + {:ok, pid} + + error -> + {:error, error} + end + end + + def close(%SocketInfo{pid: socket_pid}), + do: Process.send(socket_pid, :close, []) + + def publish(%SocketInfo{pid: socket_pid}, json) do + %{action: :publish, data: json} + |> Jason.encode!() + |> send_packet(socket_pid) + end + + def fetch(%SocketInfo{pid: socket_pid}, id) do + fetch_uuid = FetchRegistry.register_fetch(id) + + %{action: :fetch, data: id, uuid: fetch_uuid} + |> Jason.encode!() + |> send_packet(socket_pid) + + wait_for_fetch_to_return(fetch_uuid, 0) + end + + def receive_package(%SocketInfo{} = fed_socket, json) do + json + |> Jason.decode!() + |> process_package(fed_socket) + end + + defp wait_for_fetch_to_return(uuid, cntr) do + case FetchRegistry.check_fetch(uuid) do + {:error, :waiting} -> + Process.sleep(:math.pow(cntr, 3) |> Kernel.trunc()) + wait_for_fetch_to_return(uuid, cntr + 1) + + {:error, :missing} -> + Logger.error("FedSocket fetch timed out - #{inspect(uuid)}") + {:error, :timeout} + + {:ok, _fr} -> + FetchRegistry.pop_fetch(uuid) + end + end + + defp process_package(%{"action" => "publish", "data" => data}, %{origin: origin} = _fed_socket) do + if Containment.contain_origin(origin, data) do + IngesterWorker.enqueue("ingest", %{"object" => data}) + end + + {:reply, %{"action" => "publish_reply", "status" => "processed"}} + end + + defp process_package(%{"action" => "fetch_reply", "uuid" => uuid, "data" => data}, _fed_socket) do + FetchRegistry.register_fetch_received(uuid, data) + {:noreply, nil} + end + + defp process_package(%{"action" => "fetch", "uuid" => uuid, "data" => ap_id}, _fed_socket) do + {:ok, data} = render_fetched_data(ap_id, uuid) + {:reply, data} + end + + defp process_package(%{"action" => "publish_reply"}, _fed_socket) do + {:noreply, nil} + end + + defp process_package(other, _fed_socket) do + Logger.warn("unknown json packages received #{inspect(other)}") + {:noreply, nil} + end + + defp render_fetched_data(ap_id, uuid) do + {:ok, + %{ + "action" => "fetch_reply", + "status" => "processed", + "uuid" => uuid, + "data" => represent_item(ap_id) + }} + end + + defp represent_item(ap_id) do + case User.get_by_ap_id(ap_id) do + nil -> + object = Object.get_cached_by_ap_id(ap_id) + + if Visibility.is_public?(object) do + Phoenix.View.render_to_string(ObjectView, "object.json", object: object) + else + nil + end + + user -> + Phoenix.View.render_to_string(UserView, "user.json", user: user) + end + end + + defp send_packet(data, socket_pid) do + Process.send(socket_pid, {:send, data}, []) + end + + def shake, do: @shake +end diff --git a/lib/pleroma/web/fed_sockets/fed_sockets.ex b/lib/pleroma/web/fed_sockets/fed_sockets.ex new file mode 100644 index 000000000..035d54796 --- /dev/null +++ b/lib/pleroma/web/fed_sockets/fed_sockets.ex @@ -0,0 +1,182 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets do + @moduledoc """ + This documents the FedSockets framework. A framework for federating + ActivityPub objects between servers via persistant WebSocket connections. + + FedSockets allow servers to authenticate on first contact and maintain that + connection, eliminating the need to authenticate every time data needs to be shared. + + ## Protocol + FedSockets currently support 2 types of data transfer: + * `publish` method which doesn't require a response + * `fetch` method requires a response be sent + + ### Publish + The publish operation sends a json encoded map of the shape: + %{action: :publish, data: json} + and accepts (but does not require) a reply of form: + %{"action" => "publish_reply"} + + The outgoing params represent + * data: ActivityPub object encoded into json + + + ### Fetch + The fetch operation sends a json encoded map of the shape: + %{action: :fetch, data: id, uuid: fetch_uuid} + and requires a reply of form: + %{"action" => "fetch_reply", "uuid" => uuid, "data" => data} + + The outgoing params represent + * id: an ActivityPub object URI + * uuid: a unique uuid generated by the sender + + The reply params represent + * data: an ActivityPub object encoded into json + * uuid: the uuid sent along with the fetch request + + ## Examples + Clients of FedSocket transfers shouldn't need to use any of the functions outside of this module. + + A typical publish operation can be performed through the following code, and a fetch operation in a similar manner. + + case FedSockets.get_or_create_fed_socket(inbox) do + {:ok, fedsocket} -> + FedSockets.publish(fedsocket, json) + + _ -> + alternative_publish(inbox, actor, json, params) + end + + ## Configuration + FedSockets have the following config settings + + config :pleroma, :fed_sockets, + enabled: true, + ping_interval: :timer.seconds(15), + connection_duration: :timer.hours(1), + rejection_duration: :timer.hours(1), + fed_socket_fetches: [ + default: 12_000, + interval: 3_000, + lazy: false + ] + * enabled - turn FedSockets on or off with this flag. Can be toggled at runtime. + * connection_duration - How long a FedSocket can sit idle before it's culled. + * rejection_duration - After failing to make a FedSocket connection a host will be excluded + from further connections for this amount of time + * fed_socket_fetches - Use these parameters to pass options to the Cachex queue backing the FetchRegistry + * fed_socket_rejections - Use these parameters to pass options to the Cachex queue backing the FedRegistry + + Cachex options are + * default: the minimum amount of time a fetch can wait before it times out. + * interval: the interval between checks for timed out entries. This plus the default represent the maximum time allowed + * lazy: leave at false for consistant and fast lookups, set to true for stricter timeout enforcement + + """ + require Logger + + alias Pleroma.Web.FedSockets.FedRegistry + alias Pleroma.Web.FedSockets.FedSocket + alias Pleroma.Web.FedSockets.SocketInfo + + @doc """ + returns a FedSocket for the given origin. Will reuse an existing one or create a new one. + + address is expected to be a fully formed URL such as: + "http://www.example.com" or "http://www.example.com:8080" + + It can and usually does include additional path parameters, + but these are ignored as the FedSockets are organized by host and port info alone. + """ + def get_or_create_fed_socket(address) do + with {:cache, {:error, :missing}} <- {:cache, get_fed_socket(address)}, + {:connect, {:ok, _pid}} <- {:connect, FedSocket.connect_to_host(address)}, + {:cache, {:ok, fed_socket}} <- {:cache, get_fed_socket(address)} do + Logger.debug("fedsocket created for - #{inspect(address)}") + {:ok, fed_socket} + else + {:cache, {:ok, socket}} -> + Logger.debug("fedsocket found in cache - #{inspect(address)}") + {:ok, socket} + + {:connect, {:error, _host}} -> + Logger.debug("set host rejected for - #{inspect(address)}") + FedRegistry.set_host_rejected(address) + {:error, :rejected} + + {_, {:error, :disabled}} -> + {:error, :disabled} + + {_, {:error, reason}} -> + Logger.warn("get_or_create_fed_socket error - #{inspect(reason)}") + {:error, reason} + end + end + + @doc """ + returns a FedSocket for the given origin. Will not create a new FedSocket if one does not exist. + + address is expected to be a fully formed URL such as: + "http://www.example.com" or "http://www.example.com:8080" + """ + def get_fed_socket(address) do + origin = SocketInfo.origin(address) + + with {:config, true} <- {:config, Pleroma.Config.get([:fed_sockets, :enabled], false)}, + {:ok, socket} <- FedRegistry.get_fed_socket(origin) do + {:ok, socket} + else + {:config, _} -> + {:error, :disabled} + + {:error, :rejected} -> + Logger.debug("FedSocket previously rejected - #{inspect(origin)}") + {:error, :rejected} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Sends the supplied data via the publish protocol. + It will not block waiting for a reply. + Returns :ok but this is not an indication of a successful transfer. + + the data is expected to be JSON encoded binary data. + """ + def publish(%SocketInfo{} = fed_socket, json) do + FedSocket.publish(fed_socket, json) + end + + @doc """ + Sends the supplied data via the fetch protocol. + It will block waiting for a reply or timeout. + + Returns {:ok, object} where object is the requested object (or nil) + {:error, :timeout} in the event the message was not responded to + + the id is expected to be the URI of an ActivityPub object. + """ + def fetch(%SocketInfo{} = fed_socket, id) do + FedSocket.fetch(fed_socket, id) + end + + @doc """ + Disconnect all and restart FedSockets. + This is mainly used in development and testing but could be useful in production. + """ + def reset do + FedRegistry + |> Process.whereis() + |> Process.exit(:testing) + end + + def uri_for_origin(origin), + do: "ws://#{origin}/api/fedsocket/v1" +end diff --git a/lib/pleroma/web/fed_sockets/fetch_registry.ex b/lib/pleroma/web/fed_sockets/fetch_registry.ex new file mode 100644 index 000000000..7897f0fc6 --- /dev/null +++ b/lib/pleroma/web/fed_sockets/fetch_registry.ex @@ -0,0 +1,151 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.FetchRegistry do + @moduledoc """ + The FetchRegistry acts as a broker for fetch requests and return values. + This allows calling processes to block while waiting for a reply. + It doesn't impose it's own process instead using `Cachex` to handle fetches in process, allowing + multi threaded processes to avoid bottlenecking. + + Normally outside modules will have no need to call or use the FetchRegistry themselves. + + The `Cachex` parameters can be controlled from the config. Since exact timeout intervals + aren't necessary the following settings are used by default: + + config :pleroma, :fed_sockets, + fed_socket_fetches: [ + default: 12_000, + interval: 3_000, + lazy: false + ] + + """ + + defmodule FetchRegistryData do + defstruct uuid: nil, + sent_json: nil, + received_json: nil, + sent_at: nil, + received_at: nil + end + + alias Ecto.UUID + + require Logger + + @fetches :fed_socket_fetches + + @doc """ + Registers a json request wth the FetchRegistry and returns the identifying UUID. + """ + def register_fetch(json) do + %FetchRegistryData{uuid: uuid} = + json + |> new_registry_data + |> save_registry_data + + uuid + end + + @doc """ + Reports on the status of a Fetch given the identifying UUID. + + Will return + * {:ok, fetched_object} if a fetch has completed + * {:error, :waiting} if a fetch is still pending + * {:error, other_error} usually :missing to indicate a fetch that has timed out + """ + def check_fetch(uuid) do + case get_registry_data(uuid) do + {:ok, %FetchRegistryData{received_at: nil}} -> + {:error, :waiting} + + {:ok, %FetchRegistryData{} = reg_data} -> + {:ok, reg_data} + + e -> + e + end + end + + @doc """ + Retrieves the response to a fetch given the identifying UUID. + The completed fetch will be deleted from the FetchRegistry + + Will return + * {:ok, fetched_object} if a fetch has completed + * {:error, :waiting} if a fetch is still pending + * {:error, other_error} usually :missing to indicate a fetch that has timed out + """ + def pop_fetch(uuid) do + case check_fetch(uuid) do + {:ok, %FetchRegistryData{received_json: received_json}} -> + delete_registry_data(uuid) + {:ok, received_json} + + e -> + e + end + end + + @doc """ + This is called to register a fetch has returned. + It expects the result data along with the UUID that was sent in the request + + Will return the fetched object or :error + """ + def register_fetch_received(uuid, data) do + case get_registry_data(uuid) do + {:ok, %FetchRegistryData{received_at: nil} = reg_data} -> + reg_data + |> set_fetch_received(data) + |> save_registry_data() + + {:ok, %FetchRegistryData{} = reg_data} -> + Logger.warn("tried to add fetched data twice - #{uuid}") + reg_data + + {:error, _} -> + Logger.warn("Error adding fetch to registry - #{uuid}") + :error + end + end + + defp new_registry_data(json) do + %FetchRegistryData{ + uuid: UUID.generate(), + sent_json: json, + sent_at: :erlang.monotonic_time(:millisecond) + } + end + + defp get_registry_data(origin) do + case Cachex.get(@fetches, origin) do + {:ok, nil} -> + {:error, :missing} + + {:ok, reg_data} -> + {:ok, reg_data} + + _ -> + {:error, :cache_error} + end + end + + defp set_fetch_received(%FetchRegistryData{} = reg_data, data), + do: %FetchRegistryData{ + reg_data + | received_at: :erlang.monotonic_time(:millisecond), + received_json: data + } + + defp save_registry_data(%FetchRegistryData{uuid: uuid} = reg_data) do + {:ok, true} = Cachex.put(@fetches, uuid, reg_data) + reg_data + end + + defp delete_registry_data(origin), + do: {:ok, true} = Cachex.del(@fetches, origin) +end diff --git a/lib/pleroma/web/fed_sockets/incoming_handler.ex b/lib/pleroma/web/fed_sockets/incoming_handler.ex new file mode 100644 index 000000000..49d0d9d84 --- /dev/null +++ b/lib/pleroma/web/fed_sockets/incoming_handler.ex @@ -0,0 +1,88 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.IncomingHandler do + require Logger + + alias Pleroma.Web.FedSockets.FedRegistry + alias Pleroma.Web.FedSockets.FedSocket + alias Pleroma.Web.FedSockets.SocketInfo + + import HTTPSignatures, only: [validate_conn: 1, split_signature: 1] + + @behaviour :cowboy_websocket + + def init(req, state) do + shake = FedSocket.shake() + + with true <- Pleroma.Config.get([:fed_sockets, :enabled]), + sec_protocol <- :cowboy_req.header("sec-websocket-protocol", req, nil), + headers = %{"(request-target)" => ^shake} <- :cowboy_req.headers(req), + true <- validate_conn(%{req_headers: headers}), + %{"keyId" => origin} <- split_signature(headers["signature"]) do + req = + if is_nil(sec_protocol) do + req + else + :cowboy_req.set_resp_header("sec-websocket-protocol", sec_protocol, req) + end + + {:cowboy_websocket, req, %{origin: origin}, %{}} + else + _ -> + {:ok, req, state} + end + end + + def websocket_init(%{origin: origin}) do + case FedRegistry.add_fed_socket(origin) do + {:ok, socket_info} -> + {:ok, socket_info} + + e -> + Logger.error("FedSocket websocket_init failed - #{inspect(e)}") + {:error, inspect(e)} + end + end + + # Use the ping to check if the connection should be expired + def websocket_handle(:ping, socket_info) do + if SocketInfo.expired?(socket_info) do + {:stop, socket_info} + else + {:ok, socket_info, :hibernate} + end + end + + def websocket_handle({:text, data}, socket_info) do + socket_info = SocketInfo.touch(socket_info) + + case FedSocket.receive_package(socket_info, data) do + {:noreply, _} -> + {:ok, socket_info} + + {:reply, reply} -> + {:reply, {:text, Jason.encode!(reply)}, socket_info} + + {:error, reason} -> + Logger.error("incoming error - receive_package: #{inspect(reason)}") + {:ok, socket_info} + end + end + + def websocket_info({:send, message}, socket_info) do + socket_info = SocketInfo.touch(socket_info) + + {:reply, {:text, message}, socket_info} + end + + def websocket_info(:close, state) do + {:stop, state} + end + + def websocket_info(message, state) do + Logger.debug("#{__MODULE__} unknown message #{inspect(message)}") + {:ok, state} + end +end diff --git a/lib/pleroma/web/fed_sockets/ingester_worker.ex b/lib/pleroma/web/fed_sockets/ingester_worker.ex new file mode 100644 index 000000000..325f2a4ab --- /dev/null +++ b/lib/pleroma/web/fed_sockets/ingester_worker.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.IngesterWorker do + use Pleroma.Workers.WorkerHelper, queue: "ingestion_queue" + require Logger + + alias Pleroma.Web.Federator + + @impl Oban.Worker + def perform(%Job{args: %{"op" => "ingest", "object" => ingestee}}) do + try do + ingestee + |> Jason.decode!() + |> do_ingestion() + rescue + e -> + Logger.error("IngesterWorker error - #{inspect(e)}") + e + end + end + + defp do_ingestion(params) do + case Federator.incoming_ap_doc(params) do + {:error, reason} -> + {:error, reason} + + {:ok, object} -> + {:ok, object} + end + end +end diff --git a/lib/pleroma/web/fed_sockets/outgoing_handler.ex b/lib/pleroma/web/fed_sockets/outgoing_handler.ex new file mode 100644 index 000000000..6ddef17fe --- /dev/null +++ b/lib/pleroma/web/fed_sockets/outgoing_handler.ex @@ -0,0 +1,146 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.OutgoingHandler do + use GenServer + + require Logger + + alias Pleroma.Web.ActivityPub.InternalFetchActor + alias Pleroma.Web.FedSockets + alias Pleroma.Web.FedSockets.FedRegistry + alias Pleroma.Web.FedSockets.FedSocket + alias Pleroma.Web.FedSockets.SocketInfo + + def start_link(uri) do + GenServer.start_link(__MODULE__, %{uri: uri}) + end + + def init(%{uri: uri}) do + case initiate_connection(uri) do + {:ok, ws_origin, conn_pid} -> + FedRegistry.add_fed_socket(ws_origin, conn_pid) + + {:error, reason} -> + Logger.debug("Outgoing connection failed - #{inspect(reason)}") + :ignore + end + end + + def handle_info({:gun_ws, conn_pid, _ref, {:text, data}}, socket_info) do + socket_info = SocketInfo.touch(socket_info) + + case FedSocket.receive_package(socket_info, data) do + {:noreply, _} -> + {:noreply, socket_info} + + {:reply, reply} -> + :gun.ws_send(conn_pid, {:text, Jason.encode!(reply)}) + {:noreply, socket_info} + + {:error, reason} -> + Logger.error("incoming error - receive_package: #{inspect(reason)}") + {:noreply, socket_info} + end + end + + def handle_info(:close, state) do + Logger.debug("Sending close frame !!!!!!!") + {:close, state} + end + + def handle_info({:gun_down, _pid, _prot, :closed, _}, state) do + {:stop, :normal, state} + end + + def handle_info({:send, data}, %{conn_pid: conn_pid} = socket_info) do + socket_info = SocketInfo.touch(socket_info) + :gun.ws_send(conn_pid, {:text, data}) + {:noreply, socket_info} + end + + def handle_info({:gun_ws, _, _, :pong}, state) do + {:noreply, state, :hibernate} + end + + def handle_info(msg, state) do + Logger.debug("#{__MODULE__} unhandled event #{inspect(msg)}") + {:noreply, state} + end + + def terminate(reason, state) do + Logger.debug( + "#{__MODULE__} terminating outgoing connection for #{inspect(state)} for #{inspect(reason)}" + ) + + {:ok, state} + end + + def initiate_connection(uri) do + ws_uri = + uri + |> SocketInfo.origin() + |> FedSockets.uri_for_origin() + + %{host: host, port: port, path: path} = URI.parse(ws_uri) + + with {:ok, conn_pid} <- :gun.open(to_charlist(host), port), + {:ok, _} <- :gun.await_up(conn_pid), + reference <- :gun.get(conn_pid, to_charlist(path)), + {:response, :fin, 204, _} <- :gun.await(conn_pid, reference), + headers <- build_headers(uri), + ref <- :gun.ws_upgrade(conn_pid, to_charlist(path), headers, %{silence_pings: false}) do + receive do + {:gun_upgrade, ^conn_pid, ^ref, [<<"websocket">>], _} -> + {:ok, ws_uri, conn_pid} + after + 15_000 -> + Logger.debug("Fedsocket timeout connecting to #{inspect(uri)}") + {:error, :timeout} + end + else + {:response, :nofin, 404, _} -> + {:error, :fedsockets_not_supported} + + e -> + Logger.debug("Fedsocket error connecting to #{inspect(uri)}") + {:error, e} + end + end + + defp build_headers(uri) do + host_for_sig = uri |> URI.parse() |> host_signature() + + shake = FedSocket.shake() + digest = "SHA-256=" <> (:crypto.hash(:sha256, shake) |> Base.encode64()) + date = Pleroma.Signature.signed_date() + shake_size = byte_size(shake) + + signature_opts = %{ + "(request-target)": shake, + "content-length": to_charlist("#{shake_size}"), + date: date, + digest: digest, + host: host_for_sig + } + + signature = Pleroma.Signature.sign(InternalFetchActor.get_actor(), signature_opts) + + [ + {'signature', to_charlist(signature)}, + {'date', date}, + {'digest', to_charlist(digest)}, + {'content-length', to_charlist("#{shake_size}")}, + {to_charlist("(request-target)"), to_charlist(shake)} + ] + end + + defp host_signature(%{host: host, scheme: scheme, port: port}) do + if port == URI.default_port(scheme) do + host + else + "#{host}:#{port}" + end + end +end diff --git a/lib/pleroma/web/fed_sockets/socket_info.ex b/lib/pleroma/web/fed_sockets/socket_info.ex new file mode 100644 index 000000000..d6fdffe1a --- /dev/null +++ b/lib/pleroma/web/fed_sockets/socket_info.ex @@ -0,0 +1,52 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.SocketInfo do + defstruct origin: nil, + pid: nil, + conn_pid: nil, + state: :default, + connected_until: nil + + alias Pleroma.Web.FedSockets.SocketInfo + @default_connection_duration 15 * 60 * 1000 + + def build(uri, conn_pid \\ nil) do + uri + |> build_origin() + |> build_pids(conn_pid) + |> touch() + end + + def touch(%SocketInfo{} = socket_info), + do: %{socket_info | connected_until: new_ttl()} + + def connect(%SocketInfo{} = socket_info), + do: %{socket_info | state: :connected} + + def expired?(%{connected_until: connected_until}), + do: connected_until < :erlang.monotonic_time(:millisecond) + + def origin(uri), + do: build_origin(uri).origin + + defp build_pids(socket_info, conn_pid), + do: struct(socket_info, pid: self(), conn_pid: conn_pid) + + defp build_origin(uri) when is_binary(uri), + do: uri |> URI.parse() |> build_origin + + defp build_origin(%{host: host, port: nil, scheme: scheme}), + do: build_origin(%{host: host, port: URI.default_port(scheme)}) + + defp build_origin(%{host: host, port: port}), + do: %SocketInfo{origin: "#{host}:#{port}"} + + defp new_ttl do + connection_duration = + Pleroma.Config.get([:fed_sockets, :connection_duration], @default_connection_duration) + + :erlang.monotonic_time(:millisecond) + connection_duration + end +end diff --git a/lib/pleroma/web/fed_sockets/supervisor.ex b/lib/pleroma/web/fed_sockets/supervisor.ex new file mode 100644 index 000000000..a5f4bebfb --- /dev/null +++ b/lib/pleroma/web/fed_sockets/supervisor.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.Supervisor do + use Supervisor + import Cachex.Spec + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(args) do + children = [ + build_cache(:fed_socket_fetches, args), + build_cache(:fed_socket_rejections, args), + {Registry, keys: :unique, name: FedSockets.Registry, meta: [rejected: %{}]} + ] + + opts = [strategy: :one_for_all, name: Pleroma.Web.Streamer.Supervisor] + Supervisor.init(children, opts) + end + + defp build_cache(name, args) do + opts = get_opts(name, args) + + %{ + id: String.to_atom("#{name}_cache"), + start: {Cachex, :start_link, [name, opts]}, + type: :worker + } + end + + defp get_opts(cache_name, args) + when cache_name in [:fed_socket_fetches, :fed_socket_rejections] do + default = get_opts_or_config(args, cache_name, :default, 15_000) + interval = get_opts_or_config(args, cache_name, :interval, 3_000) + lazy = get_opts_or_config(args, cache_name, :lazy, false) + + [expiration: expiration(default: default, interval: interval, lazy: lazy)] + end + + defp get_opts(name, args) do + Keyword.get(args, name, []) + end + + defp get_opts_or_config(args, name, key, default) do + args + |> Keyword.get(name, []) + |> Keyword.get(key) + |> case do + nil -> + Pleroma.Config.get([:fed_sockets, name, key], default) + + value -> + value + end + end +end diff --git a/mix.lock b/mix.lock index a28c47017..a17e8c0fc 100644 --- a/mix.lock +++ b/mix.lock @@ -118,5 +118,5 @@ "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, "web_push_encryption": {:hex, :web_push_encryption, "0.3.0", "598b5135e696fd1404dc8d0d7c0fa2c027244a4e5d5e5a98ba267f14fdeaabc8", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "f10bdd1afe527ede694749fb77a2f22f146a51b054c7fa541c9fd920fba7c875"}, - "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, + "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []} } diff --git a/test/web/fed_sockets/fed_registry_test.exs b/test/web/fed_sockets/fed_registry_test.exs new file mode 100644 index 000000000..19ac874d6 --- /dev/null +++ b/test/web/fed_sockets/fed_registry_test.exs @@ -0,0 +1,124 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.FedRegistryTest do + use ExUnit.Case + + alias Pleroma.Web.FedSockets + alias Pleroma.Web.FedSockets.FedRegistry + alias Pleroma.Web.FedSockets.SocketInfo + + @good_domain "http://good.domain" + @good_domain_origin "good.domain:80" + + setup do + start_supervised({Pleroma.Web.FedSockets.Supervisor, []}) + build_test_socket(@good_domain) + Process.sleep(10) + + :ok + end + + describe "add_fed_socket/1 without conflicting sockets" do + test "can be added" do + Process.sleep(10) + assert {:ok, %SocketInfo{origin: origin}} = FedRegistry.get_fed_socket(@good_domain_origin) + assert origin == "good.domain:80" + end + + test "multiple origins can be added" do + build_test_socket("http://anothergood.domain") + Process.sleep(10) + + assert {:ok, %SocketInfo{origin: origin_1}} = + FedRegistry.get_fed_socket(@good_domain_origin) + + assert {:ok, %SocketInfo{origin: origin_2}} = + FedRegistry.get_fed_socket("anothergood.domain:80") + + assert origin_1 == "good.domain:80" + assert origin_2 == "anothergood.domain:80" + assert FedRegistry.list_all() |> Enum.count() == 2 + end + end + + describe "add_fed_socket/1 when duplicate sockets conflict" do + setup do + build_test_socket(@good_domain) + build_test_socket(@good_domain) + Process.sleep(10) + :ok + end + + test "will be ignored" do + assert {:ok, %SocketInfo{origin: origin, pid: pid_one}} = + FedRegistry.get_fed_socket(@good_domain_origin) + + assert origin == "good.domain:80" + + assert FedRegistry.list_all() |> Enum.count() == 1 + end + + test "the newer process will be closed" do + pid_two = build_test_socket(@good_domain) + + assert {:ok, %SocketInfo{origin: origin, pid: pid_one}} = + FedRegistry.get_fed_socket(@good_domain_origin) + + assert origin == "good.domain:80" + Process.sleep(10) + + refute Process.alive?(pid_two) + + assert FedRegistry.list_all() |> Enum.count() == 1 + end + end + + describe "get_fed_socket/1" do + test "returns missing for unknown hosts" do + assert {:error, :missing} = FedRegistry.get_fed_socket("not_a_dmoain") + end + + test "returns rejected for hosts previously rejected" do + "rejected.domain:80" + |> FedSockets.uri_for_origin() + |> FedRegistry.set_host_rejected() + + assert {:error, :rejected} = FedRegistry.get_fed_socket("rejected.domain:80") + end + + test "can retrieve a previously added SocketInfo" do + build_test_socket(@good_domain) + Process.sleep(10) + assert {:ok, %SocketInfo{origin: origin}} = FedRegistry.get_fed_socket(@good_domain_origin) + assert origin == "good.domain:80" + end + + test "removes references to SocketInfos when the process crashes" do + assert {:ok, %SocketInfo{origin: origin, pid: pid}} = + FedRegistry.get_fed_socket(@good_domain_origin) + + assert origin == "good.domain:80" + + Process.exit(pid, :testing) + Process.sleep(100) + assert {:error, :missing} = FedRegistry.get_fed_socket(@good_domain_origin) + end + end + + def build_test_socket(uri) do + Kernel.spawn(fn -> fed_socket_almost(uri) end) + end + + def fed_socket_almost(origin) do + FedRegistry.add_fed_socket(origin) + + receive do + :close -> + :ok + after + 5_000 -> :timeout + end + end +end diff --git a/test/web/fed_sockets/fetch_registry_test.exs b/test/web/fed_sockets/fetch_registry_test.exs new file mode 100644 index 000000000..7bd2d995a --- /dev/null +++ b/test/web/fed_sockets/fetch_registry_test.exs @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.FetchRegistryTest do + use ExUnit.Case + + alias Pleroma.Web.FedSockets.FetchRegistry + alias Pleroma.Web.FedSockets.FetchRegistry.FetchRegistryData + + @json_message "hello" + @json_reply "hello back" + + setup do + start_supervised( + {Pleroma.Web.FedSockets.Supervisor, + [ + ping_interval: 8, + connection_duration: 15, + rejection_duration: 5, + fed_socket_fetches: [default: 10, interval: 10] + ]} + ) + + :ok + end + + test "fetches can be stored" do + uuid = FetchRegistry.register_fetch(@json_message) + + assert {:error, :waiting} = FetchRegistry.check_fetch(uuid) + end + + test "fetches can return" do + uuid = FetchRegistry.register_fetch(@json_message) + task = Task.async(fn -> FetchRegistry.register_fetch_received(uuid, @json_reply) end) + + assert {:error, :waiting} = FetchRegistry.check_fetch(uuid) + Task.await(task) + + assert {:ok, %FetchRegistryData{received_json: received_json}} = + FetchRegistry.check_fetch(uuid) + + assert received_json == @json_reply + end + + test "fetches are deleted once popped from stack" do + uuid = FetchRegistry.register_fetch(@json_message) + task = Task.async(fn -> FetchRegistry.register_fetch_received(uuid, @json_reply) end) + Task.await(task) + + assert {:ok, %FetchRegistryData{received_json: received_json}} = + FetchRegistry.check_fetch(uuid) + + assert received_json == @json_reply + assert {:ok, @json_reply} = FetchRegistry.pop_fetch(uuid) + + assert {:error, :missing} = FetchRegistry.check_fetch(uuid) + end + + test "fetches can time out" do + uuid = FetchRegistry.register_fetch(@json_message) + assert {:error, :waiting} = FetchRegistry.check_fetch(uuid) + Process.sleep(500) + assert {:error, :missing} = FetchRegistry.check_fetch(uuid) + end +end diff --git a/test/web/fed_sockets/socket_info_test.exs b/test/web/fed_sockets/socket_info_test.exs new file mode 100644 index 000000000..db3d6edcd --- /dev/null +++ b/test/web/fed_sockets/socket_info_test.exs @@ -0,0 +1,118 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.FedSockets.SocketInfoTest do + use ExUnit.Case + + alias Pleroma.Web.FedSockets + alias Pleroma.Web.FedSockets.SocketInfo + + describe "uri_for_origin" do + test "provides the fed_socket URL given the origin information" do + endpoint = "example.com:4000" + assert FedSockets.uri_for_origin(endpoint) =~ "ws://" + assert FedSockets.uri_for_origin(endpoint) =~ endpoint + end + end + + describe "origin" do + test "will provide the origin field given a url" do + endpoint = "example.com:4000" + assert SocketInfo.origin("ws://#{endpoint}") == endpoint + assert SocketInfo.origin("http://#{endpoint}") == endpoint + assert SocketInfo.origin("https://#{endpoint}") == endpoint + end + + test "will proide the origin field given a uri" do + endpoint = "example.com:4000" + uri = URI.parse("http://#{endpoint}") + + assert SocketInfo.origin(uri) == endpoint + end + end + + describe "touch" do + test "will update the TTL" do + endpoint = "example.com:4000" + socket = SocketInfo.build("ws://#{endpoint}") + Process.sleep(2) + touched_socket = SocketInfo.touch(socket) + + assert socket.connected_until < touched_socket.connected_until + end + end + + describe "expired?" do + setup do + start_supervised( + {Pleroma.Web.FedSockets.Supervisor, + [ + ping_interval: 8, + connection_duration: 5, + rejection_duration: 5, + fed_socket_rejections: [lazy: true] + ]} + ) + + :ok + end + + test "tests if the TTL is exceeded" do + endpoint = "example.com:4000" + socket = SocketInfo.build("ws://#{endpoint}") + refute SocketInfo.expired?(socket) + Process.sleep(10) + + assert SocketInfo.expired?(socket) + end + end + + describe "creating outgoing connection records" do + test "can be passed a string" do + assert %{conn_pid: :pid, origin: _origin} = SocketInfo.build("example.com:4000", :pid) + end + + test "can be passed a URI" do + uri = URI.parse("http://example.com:4000") + assert %{conn_pid: :pid, origin: origin} = SocketInfo.build(uri, :pid) + assert origin =~ "example.com:4000" + end + + test "will include the port number" do + assert %{conn_pid: :pid, origin: origin} = SocketInfo.build("http://example.com:4000", :pid) + + assert origin =~ ":4000" + end + + test "will provide the port if missing" do + assert %{conn_pid: :pid, origin: "example.com:80"} = + SocketInfo.build("http://example.com", :pid) + + assert %{conn_pid: :pid, origin: "example.com:443"} = + SocketInfo.build("https://example.com", :pid) + end + end + + describe "creating incoming connection records" do + test "can be passed a string" do + assert %{pid: _, origin: _origin} = SocketInfo.build("example.com:4000") + end + + test "can be passed a URI" do + uri = URI.parse("example.com:4000") + assert %{pid: _, origin: _origin} = SocketInfo.build(uri) + end + + test "will include the port number" do + assert %{pid: _, origin: origin} = SocketInfo.build("http://example.com:4000") + + assert origin =~ ":4000" + end + + test "will provide the port if missing" do + assert %{pid: _, origin: "example.com:80"} = SocketInfo.build("http://example.com") + assert %{pid: _, origin: "example.com:443"} = SocketInfo.build("https://example.com") + end + end +end -- cgit v1.2.3 From 26c571df339ebfdadf760f75871b9343131a2d81 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 18 Sep 2020 16:09:37 +0300 Subject: FedSockets: fix log spam on cached rejects --- lib/pleroma/web/fed_sockets/fed_sockets.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/web/fed_sockets/fed_sockets.ex b/lib/pleroma/web/fed_sockets/fed_sockets.ex index 035d54796..1fd5899c8 100644 --- a/lib/pleroma/web/fed_sockets/fed_sockets.ex +++ b/lib/pleroma/web/fed_sockets/fed_sockets.ex @@ -104,6 +104,9 @@ defmodule Pleroma.Web.FedSockets do Logger.debug("fedsocket found in cache - #{inspect(address)}") {:ok, socket} + {:cache, {:error, :rejected} = e} -> + e + {:connect, {:error, _host}} -> Logger.debug("set host rejected for - #{inspect(address)}") FedRegistry.set_host_rejected(address) -- cgit v1.2.3