Merge branch 'media-preview-proxy-nostream' into 'develop'

Media preview proxy

See merge request pleroma/pleroma!3001
This commit is contained in:
feld 2020-09-18 18:38:20 +00:00
commit 6c052bd5b6
25 changed files with 982 additions and 127 deletions

View file

@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- The `discoverable` field in the `User` struct will now add a NOINDEX metatag to profile pages when false. - The `discoverable` field in the `User` struct will now add a NOINDEX metatag to profile pages when false.
- Users with the `discoverable` field set to false will not show up in searches. - Users with the `discoverable` field set to false will not show up in searches.
- Minimum lifetime for ephmeral activities changed to 10 minutes and made configurable (`:min_lifetime` option). - 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 ### Removed
- **Breaking:** `Pleroma.Workers.Cron.StatsWorker` setting from Oban `:crontab` (moved to a simpler implementation). - **Breaking:** `Pleroma.Workers.Cron.StatsWorker` setting from Oban `:crontab` (moved to a simpler implementation).

View file

@ -434,6 +434,8 @@
proxy_opts: [ proxy_opts: [
redirect_on_failure: false, redirect_on_failure: false,
max_body_length: 25 * 1_048_576, 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: [ http: [
follow_redirect: true, follow_redirect: true,
pool: :media pool: :media
@ -448,6 +450,14 @@
config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, script_path: nil config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, script_path: nil
# Note: media preview proxy depends on media proxy to be enabled
config :pleroma, :media_preview_proxy,
enabled: false,
thumbnail_max_width: 600,
thumbnail_max_height: 600,
image_quality: 85,
min_content_length: 100 * 1024
config :pleroma, :chat, enabled: true config :pleroma, :chat, enabled: true
config :phoenix, :format_encoders, json: Jason config :phoenix, :format_encoders, json: Jason
@ -753,8 +763,8 @@
], ],
media: [ media: [
size: 50, size: 50,
max_waiting: 10, max_waiting: 20,
recv_timeout: 10_000 recv_timeout: 15_000
], ],
upload: [ upload: [
size: 25, size: 25,

View file

@ -1887,6 +1887,7 @@
suggestions: [ suggestions: [
redirect_on_failure: false, redirect_on_failure: false,
max_body_length: 25 * 1_048_576, max_body_length: 25 * 1_048_576,
max_read_duration: 30_000,
http: [ http: [
follow_redirect: true, follow_redirect: true,
pool: :media pool: :media
@ -1907,6 +1908,11 @@
"Limits the content length to be approximately the " <> "Limits the content length to be approximately the " <>
"specified length. It is validated with the `content-length` header and also verified when proxying." "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, key: :http,
label: "HTTP", label: "HTTP",
@ -1953,6 +1959,43 @@
} }
] ]
}, },
%{
group: :pleroma,
key: :media_preview_proxy,
type: :group,
description: "Media preview proxy",
children: [
%{
key: :enabled,
type: :boolean,
description:
"Enables proxying of remote media preview to the instance's proxy. Requires enabled media proxy."
},
%{
key: :thumbnail_max_width,
type: :integer,
description:
"Max width of preview thumbnail for images (video preview always has original dimensions)."
},
%{
key: :thumbnail_max_height,
type: :integer,
description:
"Max height of preview thumbnail for images (video preview always has original dimensions)."
},
%{
key: :image_quality,
type: :integer,
description: "Quality of the output. Ranges from 0 (min quality) to 100 (max quality)."
},
%{
key: :min_content_length,
type: :integer,
description:
"Min content length to perform preview, in bytes. If greater than 0, media smaller in size will be served as is, without thumbnailing."
}
]
},
%{ %{
group: :pleroma, group: :pleroma,
key: Pleroma.Web.MediaProxy.Invalidation.Http, key: Pleroma.Web.MediaProxy.Invalidation.Http,

View file

@ -324,6 +324,14 @@ This section describe PWA manifest instance-specific values. Currently this opti
* `enabled`: Enables purge cache * `enabled`: Enables purge cache
* `provider`: Which one of the [purge cache strategy](#purge-cache-strategy) to use. * `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 instances 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 ### Purge cache strategy
#### Pleroma.Web.MediaProxy.Invalidation.Script #### Pleroma.Web.MediaProxy.Invalidation.Script

View file

@ -0,0 +1,150 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Helpers.MediaHelper do
@moduledoc """
Handles common media-related operations.
"""
alias Pleroma.HTTP
def image_resize(url, options) do
with executable when is_binary(executable) <- System.find_executable("convert"),
{:ok, args} <- prepare_image_resize_args(options),
{:ok, env} <- HTTP.get(url, [], pool: :media),
{:ok, fifo_path} <- mkfifo() do
args = List.flatten([fifo_path, args])
run_fifo(fifo_path, env, executable, args)
else
nil -> {:error, {:convert, :command_not_found}}
{:error, _} = error -> error
end
end
defp prepare_image_resize_args(
%{max_width: max_width, max_height: max_height, format: "png"} = options
) do
quality = options[:quality] || 85
resize = Enum.join([max_width, "x", max_height, ">"])
args = [
"-resize",
resize,
"-quality",
to_string(quality),
"png:-"
]
{:ok, args}
end
defp prepare_image_resize_args(%{max_width: max_width, max_height: max_height} = options) do
quality = options[:quality] || 85
resize = Enum.join([max_width, "x", max_height, ">"])
args = [
"-interlace",
"Plane",
"-resize",
resize,
"-quality",
to_string(quality),
"jpg:-"
]
{:ok, args}
end
defp prepare_image_resize_args(_), do: {:error, :missing_options}
# Note: video thumbnail is intentionally not resized (always has original dimensions)
def video_framegrab(url) do
with executable when is_binary(executable) <- System.find_executable("ffmpeg"),
{:ok, env} <- HTTP.get(url, [], pool: :media),
{:ok, fifo_path} <- mkfifo(),
args = [
"-y",
"-i",
fifo_path,
"-vframes",
"1",
"-f",
"mjpeg",
"-loglevel",
"error",
"-"
] do
run_fifo(fifo_path, env, executable, args)
else
nil -> {:error, {:ffmpeg, :command_not_found}}
{:error, _} = error -> error
end
end
defp run_fifo(fifo_path, env, executable, args) do
pid =
Port.open({:spawn_executable, executable}, [
:use_stdio,
:stream,
:exit_status,
:binary,
args: args
])
fifo = Port.open(to_charlist(fifo_path), [:eof, :binary, :stream, :out])
fix = Pleroma.Helpers.QtFastStart.fix(env.body)
true = Port.command(fifo, fix)
:erlang.port_close(fifo)
loop_recv(pid)
after
File.rm(fifo_path)
end
defp mkfifo do
path = Path.join(System.tmp_dir!(), "pleroma-media-preview-pipe-#{Ecto.UUID.generate()}")
case System.cmd("mkfifo", [path]) do
{_, 0} ->
spawn(fifo_guard(path))
{:ok, path}
{_, err} ->
{:error, {:fifo_failed, err}}
end
end
defp fifo_guard(path) do
pid = self()
fn ->
ref = Process.monitor(pid)
receive do
{:DOWN, ^ref, :process, ^pid, _} ->
File.rm(path)
end
end
end
defp loop_recv(pid) do
loop_recv(pid, <<>>)
end
defp loop_recv(pid, acc) do
receive do
{^pid, {:data, data}} ->
loop_recv(pid, acc <> data)
{^pid, {:exit_status, 0}} ->
{:ok, acc}
{^pid, {:exit_status, status}} ->
{:error, status}
after
5000 ->
:erlang.port_close(pid)
{:error, :timeout}
end
end
end

View file

@ -0,0 +1,131 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Helpers.QtFastStart do
@moduledoc """
(WIP) Converts a "slow start" (data before metadatas) mov/mp4 file to a "fast start" one (metadatas before data).
"""
# TODO: Cleanup and optimizations
# Inspirations: https://www.ffmpeg.org/doxygen/3.4/qt-faststart_8c_source.html
# https://github.com/danielgtaylor/qtfaststart/blob/master/qtfaststart/processor.py
# ISO/IEC 14496-12:2015, ISO/IEC 15444-12:2015
# Paracetamol
def fix(<<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::bits>> = binary) do
index = fix(binary, 0, nil, nil, [])
case index do
:abort -> binary
[{"ftyp", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index)
[{"ftyp", _, _, _, _}, {"free", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index)
_ -> binary
end
end
def fix(binary) do
binary
end
# MOOV have been seen before MDAT- abort
defp fix(<<_::bits>>, _, true, false, _) do
:abort
end
defp fix(
<<size::integer-big-size(32), fourcc::bits-size(32), rest::bits>>,
pos,
got_moov,
got_mdat,
acc
) do
full_size = (size - 8) * 8
<<data::bits-size(full_size), rest::bits>> = rest
acc = [
{fourcc, pos, pos + size, size,
<<size::integer-big-size(32), fourcc::bits-size(32), data::bits>>}
| acc
]
fix(rest, pos + size, got_moov || fourcc == "moov", got_mdat || fourcc == "mdat", acc)
end
defp fix(<<>>, _pos, _, _, acc) do
:lists.reverse(acc)
end
defp faststart(index) do
{{_ftyp, _, _, _, ftyp}, index} = List.keytake(index, "ftyp", 0)
# Skip re-writing the free fourcc as it's kind of useless.
# Why stream useless bytes when you can do without?
{free_size, index} =
case List.keytake(index, "free", 0) do
{{_, _, _, size, _}, index} -> {size, index}
_ -> {0, index}
end
{{_moov, _, _, moov_size, moov}, index} = List.keytake(index, "moov", 0)
offset = -free_size + moov_size
rest = for {_, _, _, _, data} <- index, do: data, into: []
<<moov_head::bits-size(64), moov_data::bits>> = moov
[ftyp, moov_head, fix_moov(moov_data, offset, []), rest]
end
defp fix_moov(
<<size::integer-big-size(32), fourcc::bits-size(32), rest::bits>>,
offset,
acc
) do
full_size = (size - 8) * 8
<<data::bits-size(full_size), rest::bits>> = rest
data =
cond do
fourcc in ["trak", "mdia", "minf", "stbl"] ->
# Theses contains sto or co64 part
[<<size::integer-big-size(32), fourcc::bits-size(32)>>, fix_moov(data, offset, [])]
fourcc in ["stco", "co64"] ->
# fix the damn thing
<<version::integer-big-size(32), count::integer-big-size(32), rest::bits>> = data
entry_size =
case fourcc do
"stco" -> 32
"co64" -> 64
end
[
<<size::integer-big-size(32), fourcc::bits-size(32), version::integer-big-size(32),
count::integer-big-size(32)>>,
rewrite_entries(entry_size, offset, rest, [])
]
true ->
[<<size::integer-big-size(32), fourcc::bits-size(32)>>, data]
end
acc = [acc | data]
fix_moov(rest, offset, acc)
end
defp fix_moov(<<>>, _, acc), do: acc
for size <- [32, 64] do
defp rewrite_entries(
unquote(size),
offset,
<<pos::integer-big-size(unquote(size)), rest::bits>>,
acc
) do
rewrite_entries(unquote(size), offset, rest, [
acc | <<pos + offset::integer-big-size(unquote(size))>>
])
end
end
defp rewrite_entries(_, _, <<>>, acc), do: acc
end

View file

@ -3,18 +3,22 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Helpers.UriHelper do 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) 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(%{}) existing_params = URI.query_decoder(uri.query || "") |> Map.new()
updated_params_keys = Enum.uniq(Map.keys(existing_params) ++ Map.keys(appended_params)) 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 = 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 uri
|> Map.put(:query, URI.encode_query(updated_params)) |> Map.put(:query, URI.encode_query(updated_params))
|> URI.to_string() |> URI.to_string()
|> String.replace_suffix("?", "")
end end
def maybe_add_base("/" <> uri, base), do: Path.join([base, uri]) def maybe_add_base("/" <> uri, base), do: Path.join([base, uri])

View file

@ -156,9 +156,7 @@ def get_or_update_favicon(%URI{host: host} = instance_uri) do
defp scrape_favicon(%URI{} = instance_uri) do defp scrape_favicon(%URI{} = instance_uri) do
try do try do
with {:ok, %Tesla.Env{body: html}} <- with {:ok, %Tesla.Env{body: html}} <-
Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], pool: :media),
adapter: [pool: :media]
),
{_, [favicon_rel | _]} when is_binary(favicon_rel) <- {_, [favicon_rel | _]} when is_binary(favicon_rel) <-
{:parse, {:parse,
html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")}, html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")},

View file

@ -17,6 +17,9 @@ defmodule Pleroma.ReverseProxy do
@failed_request_ttl :timer.seconds(60) @failed_request_ttl :timer.seconds(60)
@methods ~w(GET HEAD) @methods ~w(GET HEAD)
def max_read_duration_default, do: @max_read_duration
def default_cache_control_header, do: @default_cache_control_header
@moduledoc """ @moduledoc """
A reverse proxy. A reverse proxy.
@ -391,6 +394,8 @@ defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and
defp body_size_constraint(_, _), do: :ok 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) defp check_read_duration(duration, max)
when is_integer(duration) and is_integer(max) and max > 0 do when is_integer(duration) and is_integer(max) and max > 0 do
if duration > max do if duration > max do

View file

@ -12,17 +12,21 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
require Logger require Logger
@options [ @adapter_options [
pool: :media, pool: :media,
recv_timeout: 10_000 recv_timeout: 10_000
] ]
def perform(:prefetch, url) do def perform(:prefetch, url) do
Logger.debug("Prefetching #{inspect(url)}") # Fetching only proxiable resources
if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
# If preview proxy is enabled, it'll also hit media proxy (so we're caching both requests)
prefetch_url = MediaProxy.preview_url(url)
url Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}")
|> MediaProxy.url()
|> HTTP.get([], @options) HTTP.get(prefetch_url, [], @adapter_options)
end
end end
def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do

View file

@ -181,8 +181,10 @@ defp do_render("show.json", %{user: user} = opts) do
user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) user = User.sanitize_html(user, User.html_filter_policy(opts[:for]))
display_name = user.name || user.nickname display_name = user.name || user.nickname
image = User.avatar_url(user) |> MediaProxy.url() avatar = User.avatar_url(user) |> MediaProxy.url()
avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(static: true)
header = User.banner_url(user) |> MediaProxy.url() header = User.banner_url(user) |> MediaProxy.url()
header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true)
following_count = following_count =
if !user.hide_follows_count or !user.hide_follows or opts[:for] == user do if !user.hide_follows_count or !user.hide_follows or opts[:for] == user do
@ -247,10 +249,10 @@ defp do_render("show.json", %{user: user} = opts) do
statuses_count: user.note_count, statuses_count: user.note_count,
note: user.bio, note: user.bio,
url: user.uri || user.ap_id, url: user.uri || user.ap_id,
avatar: image, avatar: avatar,
avatar_static: image, avatar_static: avatar_static,
header: header, header: header,
header_static: header, header_static: header_static,
emojis: emojis, emojis: emojis,
fields: user.fields, fields: user.fields,
bot: bot, bot: bot,

View file

@ -415,6 +415,7 @@ def render("attachment.json", %{attachment: attachment}) do
[attachment_url | _] = attachment["url"] [attachment_url | _] = attachment["url"]
media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image" media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
href = attachment_url["href"] |> MediaProxy.url() href = attachment_url["href"] |> MediaProxy.url()
href_preview = attachment_url["href"] |> MediaProxy.preview_url()
type = type =
cond do cond do
@ -430,7 +431,7 @@ def render("attachment.json", %{attachment: attachment}) do
id: to_string(attachment["id"] || hash_id), id: to_string(attachment["id"] || hash_id),
url: href, url: href,
remote_url: href, remote_url: href,
preview_url: href, preview_url: href_preview,
text_url: href, text_url: href,
type: type, type: type,
description: attachment["name"], description: attachment["name"],

View file

@ -33,6 +33,8 @@ defp do_purge(urls) do
def prepare_urls(urls) do def prepare_urls(urls) do
urls urls
|> List.wrap() |> 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
end end

View file

@ -4,6 +4,7 @@
defmodule Pleroma.Web.MediaProxy do defmodule Pleroma.Web.MediaProxy do
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.Helpers.UriHelper
alias Pleroma.Upload alias Pleroma.Upload
alias Pleroma.Web alias Pleroma.Web
alias Pleroma.Web.MediaProxy.Invalidation alias Pleroma.Web.MediaProxy.Invalidation
@ -40,27 +41,35 @@ def url(url) when is_nil(url) or url == "", do: nil
def url("/" <> _ = url), do: url def url("/" <> _ = url), do: url
def url(url) do def url(url) do
if disabled?() or not url_proxiable?(url) do if enabled?() and url_proxiable?(url) do
url
else
encode_url(url) encode_url(url)
else
url
end end
end end
@spec url_proxiable?(String.t()) :: boolean() @spec url_proxiable?(String.t()) :: boolean()
def url_proxiable?(url) do def url_proxiable?(url) do
if local?(url) or whitelisted?(url) do not local?(url) and not whitelisted?(url)
false end
def preview_url(url, preview_params \\ []) do
if preview_enabled?() do
encode_preview_url(url, preview_params)
else else
true url(url)
end end
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])
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) %{host: domain} = URI.parse(url)
mediaproxy_whitelist_domains = mediaproxy_whitelist_domains =
@ -85,17 +94,29 @@ defp maybe_get_domain_from_url("http" <> _ = url) do
defp maybe_get_domain_from_url(domain), do: domain defp maybe_get_domain_from_url(domain), do: domain
def encode_url(url) do defp base64_sig64(url) do
base64 = Base.url_encode64(url, @base64_opts) base64 = Base.url_encode64(url, @base64_opts)
sig64 = sig64 =
base64 base64
|> signed_url |> signed_url()
|> Base.url_encode64(@base64_opts) |> Base.url_encode64(@base64_opts)
{base64, sig64}
end
def encode_url(url) do
{base64, sig64} = base64_sig64(url)
build_url(sig64, base64, filename(url)) build_url(sig64, base64, filename(url))
end end
def encode_preview_url(url, preview_params \\ []) do
{base64, sig64} = base64_sig64(url)
build_preview_url(sig64, base64, filename(url), preview_params)
end
def decode_url(sig, url) do def decode_url(sig, url) do
with {:ok, sig} <- Base.url_decode64(sig, @base64_opts), with {:ok, sig} <- Base.url_decode64(sig, @base64_opts),
signature when signature == sig <- signed_url(url) do signature when signature == sig <- signed_url(url) do
@ -113,10 +134,14 @@ def filename(url_or_path) do
if path = URI.parse(url_or_path).path, do: Path.basename(path) if path = URI.parse(url_or_path).path, do: Path.basename(path)
end end
def build_url(sig_base64, url_base64, filename \\ nil) do def base_url do
Config.get([:media_proxy, :base_url], Web.base_url())
end
defp proxy_url(path, sig_base64, url_base64, filename) do
[ [
Config.get([:media_proxy, :base_url], Web.base_url()), base_url(),
"proxy", path,
sig_base64, sig_base64,
url_base64, url_base64,
filename filename
@ -124,4 +149,38 @@ def build_url(sig_base64, url_base64, filename \\ nil) do
|> Enum.filter(& &1) |> Enum.filter(& &1)
|> Path.join() |> Path.join()
end end
def build_url(sig_base64, url_base64, filename \\ nil) do
proxy_url("proxy", sig_base64, url_base64, filename)
end
def build_preview_url(sig_base64, url_base64, filename \\ nil, preview_params \\ []) do
uri = proxy_url("proxy/preview", sig_base64, url_base64, filename)
UriHelper.modify_uri_params(uri, preview_params)
end
def verify_request_path_and_url(
%Plug.Conn{params: %{"filename" => _}, request_path: request_path},
url
) do
verify_request_path_and_url(request_path, url)
end
def verify_request_path_and_url(request_path, url) when is_binary(request_path) do
filename = filename(url)
if filename && not basename_matches?(request_path, filename) do
{:wrong_filename, filename}
else
:ok
end
end
def verify_request_path_and_url(_, _), do: :ok
defp basename_matches?(path, filename) do
basename = Path.basename(path)
basename == filename or URI.decode(basename) == filename or URI.encode(basename) == filename
end
end end

View file

@ -5,44 +5,201 @@
defmodule Pleroma.Web.MediaProxy.MediaProxyController do defmodule Pleroma.Web.MediaProxy.MediaProxyController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Config
alias Pleroma.Helpers.MediaHelper
alias Pleroma.Helpers.UriHelper
alias Pleroma.ReverseProxy alias Pleroma.ReverseProxy
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
alias Plug.Conn
@default_proxy_opts [max_body_length: 25 * 1_048_576, http: [follow_redirect: true]] def remote(conn, %{"sig" => sig64, "url" => url64}) do
with {_, true} <- {:enabled, MediaProxy.enabled?()},
def remote(conn, %{"sig" => sig64, "url" => url64} = params) do
with config <- Pleroma.Config.get([:media_proxy], []),
true <- Keyword.get(config, :enabled, false),
{:ok, url} <- MediaProxy.decode_url(sig64, url64), {:ok, url} <- MediaProxy.decode_url(sig64, url64),
{_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)}, {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)},
:ok <- filename_matches(params, conn.request_path, url) do :ok <- MediaProxy.verify_request_path_and_url(conn, url) do
ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts)) ReverseProxy.call(conn, url, media_proxy_opts())
else else
error when error in [false, {:in_banned_urls, true}] -> {: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, Conn.Status.reason_phrase(404))
{:error, :invalid_signature} -> {: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} -> {:wrong_filename, filename} ->
redirect(conn, external: MediaProxy.build_url(sig64, url64, filename)) redirect(conn, external: MediaProxy.build_url(sig64, url64, filename))
end end
end end
def filename_matches(%{"filename" => _} = _, path, url) do def preview(%Conn{} = conn, %{"sig" => sig64, "url" => url64}) do
filename = MediaProxy.filename(url) with {_, true} <- {:enabled, MediaProxy.preview_enabled?()},
{:ok, url} <- MediaProxy.decode_url(sig64, url64),
if filename && does_not_match(path, filename) do :ok <- MediaProxy.verify_request_path_and_url(conn, url) do
{:wrong_filename, filename} handle_preview(conn, url)
else else
:ok {:enabled, false} ->
send_resp(conn, 404, Conn.Status.reason_phrase(404))
{:error, :invalid_signature} ->
send_resp(conn, 403, Conn.Status.reason_phrase(403))
{:wrong_filename, filename} ->
redirect(conn, external: MediaProxy.build_preview_url(sig64, url64, filename))
end end
end end
def filename_matches(_, _, _), do: :ok defp handle_preview(conn, url) do
media_proxy_url = MediaProxy.url(url)
defp does_not_match(path, filename) do with {:ok, %{status: status} = head_response} when status in 200..299 <-
basename = Path.basename(path) Pleroma.HTTP.request("head", media_proxy_url, [], [], pool: :media) do
basename != filename and URI.decode(basename) != filename and URI.encode(basename) != filename content_type = Tesla.get_header(head_response, "content-type")
content_length = Tesla.get_header(head_response, "content-length")
content_length = content_length && String.to_integer(content_length)
static = conn.params["static"] in ["true", true]
cond do
static and content_type == "image/gif" ->
handle_jpeg_preview(conn, media_proxy_url)
static ->
drop_static_param_and_redirect(conn)
content_type == "image/gif" ->
redirect(conn, external: media_proxy_url)
min_content_length_for_preview() > 0 and content_length > 0 and
content_length < min_content_length_for_preview() ->
redirect(conn, external: media_proxy_url)
true ->
handle_preview(content_type, conn, media_proxy_url)
end
else
# If HEAD failed, redirecting to media proxy URI doesn't make much sense; returning an error
{_, %{status: status}} ->
send_resp(conn, :failed_dependency, "Can't fetch HTTP headers (HTTP #{status}).")
{:error, :recv_response_timeout} ->
send_resp(conn, :failed_dependency, "HEAD request timeout.")
_ ->
send_resp(conn, :failed_dependency, "Can't fetch HTTP headers.")
end
end
defp handle_preview("image/png" <> _ = _content_type, conn, media_proxy_url) do
handle_png_preview(conn, media_proxy_url)
end
defp handle_preview("image/" <> _ = _content_type, conn, media_proxy_url) do
handle_jpeg_preview(conn, media_proxy_url)
end
defp handle_preview("video/" <> _ = _content_type, conn, media_proxy_url) do
handle_video_preview(conn, media_proxy_url)
end
defp handle_preview(_unsupported_content_type, conn, media_proxy_url) do
fallback_on_preview_error(conn, media_proxy_url)
end
defp handle_png_preview(conn, media_proxy_url) do
quality = Config.get!([:media_preview_proxy, :image_quality])
{thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions()
with {:ok, thumbnail_binary} <-
MediaHelper.image_resize(
media_proxy_url,
%{
max_width: thumbnail_max_width,
max_height: thumbnail_max_height,
quality: quality,
format: "png"
}
) do
conn
|> put_preview_response_headers(["image/png", "preview.png"])
|> send_resp(200, thumbnail_binary)
else
_ ->
fallback_on_preview_error(conn, media_proxy_url)
end
end
defp handle_jpeg_preview(conn, media_proxy_url) do
quality = Config.get!([:media_preview_proxy, :image_quality])
{thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions()
with {:ok, thumbnail_binary} <-
MediaHelper.image_resize(
media_proxy_url,
%{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality}
) do
conn
|> put_preview_response_headers()
|> send_resp(200, thumbnail_binary)
else
_ ->
fallback_on_preview_error(conn, media_proxy_url)
end
end
defp handle_video_preview(conn, media_proxy_url) do
with {:ok, thumbnail_binary} <-
MediaHelper.video_framegrab(media_proxy_url) do
conn
|> put_preview_response_headers()
|> send_resp(200, thumbnail_binary)
else
_ ->
fallback_on_preview_error(conn, media_proxy_url)
end
end
defp drop_static_param_and_redirect(conn) do
uri_without_static_param =
conn
|> current_url()
|> UriHelper.modify_uri_params(%{}, ["static"])
redirect(conn, external: uri_without_static_param)
end
defp fallback_on_preview_error(conn, media_proxy_url) do
redirect(conn, external: media_proxy_url)
end
defp put_preview_response_headers(
conn,
[content_type, filename] = _content_info \\ ["image/jpeg", "preview.jpg"]
) do
conn
|> put_resp_header("content-type", content_type)
|> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"")
|> put_resp_header("cache-control", ReverseProxy.default_cache_control_header())
end
defp thumbnail_max_dimensions do
config = media_preview_proxy_config()
thumbnail_max_width = Keyword.fetch!(config, :thumbnail_max_width)
thumbnail_max_height = Keyword.fetch!(config, :thumbnail_max_height)
{thumbnail_max_width, thumbnail_max_height}
end
defp min_content_length_for_preview do
Keyword.get(media_preview_proxy_config(), :min_content_length, 0)
end
defp media_preview_proxy_config do
Config.get!([:media_preview_proxy])
end
defp media_proxy_opts do
Config.get([:media_proxy, :proxy_opts], [])
end end
end end

View file

@ -38,7 +38,7 @@ def scrub_html(content) when is_binary(content) do
def scrub_html(content), do: content def scrub_html(content), do: content
def attachment_url(url) do def attachment_url(url) do
MediaProxy.url(url) MediaProxy.preview_url(url)
end end
def user_name_string(user) do def user_name_string(user) do

View file

@ -119,7 +119,7 @@ defp handle_existing_authorization(
redirect_uri = redirect_uri(conn, redirect_uri) redirect_uri = redirect_uri(conn, redirect_uri)
url_params = %{access_token: token.token} url_params = %{access_token: token.token}
url_params = Maps.put_if_present(url_params, :state, params["state"]) 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) redirect(conn, external: url)
else else
conn conn
@ -161,7 +161,7 @@ def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{
redirect_uri = redirect_uri(conn, redirect_uri) redirect_uri = redirect_uri(conn, redirect_uri)
url_params = %{code: auth.token} url_params = %{code: auth.token}
url_params = Maps.put_if_present(url_params, :state, auth_attrs["state"]) 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) redirect(conn, external: url)
else else
conn conn

View file

@ -679,6 +679,8 @@ defmodule Pleroma.Web.Router do
end end
scope "/proxy/", Pleroma.Web.MediaProxy do 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", MediaProxyController, :remote)
get("/:sig/:url/:filename", MediaProxyController, :remote) get("/:sig/:url/:filename", MediaProxyController, :remote)
end end

View file

@ -31,6 +31,7 @@
"ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"},
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
"ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, "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"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
"esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"},
"eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"},
@ -80,6 +81,7 @@
"nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]},
"oban": {:hex, :oban, "2.0.0", "e6ce70d94dd46815ec0882a1ffb7356df9a9d5b8a40a64ce5c2536617a447379", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cf574813bd048b98a698aa587c21367d2e06842d4e1b1993dcd6a696e9e633bd"}, "oban": {:hex, :oban, "2.0.0", "e6ce70d94dd46815ec0882a1ffb7356df9a9d5b8a40a64ce5c2536617a447379", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cf574813bd048b98a698aa587c21367d2e06842d4e1b1993dcd6a696e9e633bd"},
"open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]},
"p1_utils": {:hex, :p1_utils, "1.0.18", "3fe224de5b2e190d730a3c5da9d6e8540c96484cf4b4692921d1e28f0c32b01c", [:rebar3], [], "hexpm", "1fc8773a71a15553b179c986b22fbeead19b28fe486c332d4929700ffeb71f88"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
"pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"},
"phoenix": {:hex, :phoenix, "1.4.17", "1b1bd4cff7cfc87c94deaa7d60dd8c22e04368ab95499483c50640ef3bd838d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a8e5d7a3d76d452bb5fb86e8b7bd115f737e4f8efe202a463d4aeb4a5809611"}, "phoenix": {:hex, :phoenix, "1.4.17", "1b1bd4cff7cfc87c94deaa7d60dd8c22e04368ab95499483c50640ef3bd838d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a8e5d7a3d76d452bb5fb86e8b7bd115f737e4f8efe202a463d4aeb4a5809611"},

BIN
test/fixtures/image.gif vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

BIN
test/fixtures/image.png vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

@ -22,6 +22,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
} }
} }
setup do: clear_config([:media_proxy, :enabled], true)
test "it prefetches media proxy URIs" do test "it prefetches media proxy URIs" do
with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
MediaProxyWarmingPolicy.filter(@message) MediaProxyWarmingPolicy.filter(@message)

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.MastodonAPI.AccountViewTest do defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
use Pleroma.DataCase use Pleroma.DataCase
alias Pleroma.Config
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserRelationship alias Pleroma.UserRelationship
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
@ -540,8 +541,9 @@ test "shows non-zero when historical unapproved requests are present" do
end end
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_proxy, :enabled], true)
clear_config([:media_preview_proxy, :enabled])
user = user =
insert(:user, insert(:user,
@ -550,6 +552,9 @@ test "uses mediaproxy urls when it's enabled" do
emoji: %{"joker_smile" => "https://evil.website/society.png"} emoji: %{"joker_smile" => "https://evil.website/society.png"}
) )
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}) AccountView.render("show.json", %{user: user, skip_visibility_check: true})
|> Enum.all?(fn |> Enum.all?(fn
{key, url} when key in [:avatar, :avatar_static, :header, :header_static] -> {key, url} when key in [:avatar, :avatar_static, :header, :header_static] ->
@ -567,3 +572,4 @@ test "uses mediaproxy urls when it's enabled" do
|> assert() |> assert()
end end
end end
end

View file

@ -8,14 +8,21 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
import Mock import Mock
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
alias Pleroma.Web.MediaProxy.MediaProxyController
alias Plug.Conn alias Plug.Conn
setup do setup do
on_exit(fn -> Cachex.clear(:banned_urls_cache) end) on_exit(fn -> Cachex.clear(:banned_urls_cache) end)
end end
test "it returns 404 when MediaProxy disabled", %{conn: conn} 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) clear_config([:media_proxy, :enabled], false)
assert %Conn{ assert %Conn{
@ -29,13 +36,6 @@ test "it returns 404 when MediaProxy disabled", %{conn: conn} do
} = get(conn, "/proxy/hhgfh/eeee/fff") } = get(conn, "/proxy/hhgfh/eeee/fff")
end end
describe "" 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 403 for invalid signature", %{conn: conn, url: url} do test "it returns 403 for invalid signature", %{conn: conn, url: url} do
Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], "000") Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], "000")
%{path: path} = URI.parse(url) %{path: path} = URI.parse(url)
@ -56,7 +56,7 @@ test "it returns 403 for invalid signature", %{conn: conn, url: url} do
} = get(conn, "/proxy/hhgfh/eeee/fff") } = get(conn, "/proxy/hhgfh/eeee/fff")
end 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") invalid_url = String.replace(url, "test.png", "test-file.png")
response = get(conn, invalid_url) response = get(conn, invalid_url)
assert response.status == 302 assert response.status == 302
@ -80,42 +80,248 @@ test "it returns 404 when url is in banned_urls cache", %{conn: conn, url: url}
end end
end end
describe "filename_matches/3" do describe "Media Preview Proxy" do
test "preserves the encoded or decoded path" do setup do
assert MediaProxyController.filename_matches( clear_config([:media_proxy, :enabled], true)
%{"filename" => "/Hello world.jpg"}, clear_config([:media_preview_proxy, :enabled], true)
"/Hello world.jpg", clear_config([Pleroma.Web.Endpoint, :secret_key_base], "00000000000")
"http://pleroma.social/Hello world.jpg"
) == :ok
assert MediaProxyController.filename_matches( original_url = "https://google.fn/test.png"
%{"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"}, url: MediaProxy.encode_preview_url(original_url),
"/my%2Flong%2Furl%2F2019%2F07%2FS.jpg", media_proxy_url: MediaProxy.encode_url(original_url)
"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 end
test "encoded url are tried to match for proxy as `conn.request_path` encodes the url" do test "returns 404 when media proxy is disabled", %{conn: conn} do
# conn.request_path will return encoded url clear_config([:media_proxy, :enabled], false)
request_path = "/ANALYSE-DAI-_-LE-STABLECOIN-100-D%C3%89CENTRALIS%C3%89-BQ.jpg"
assert MediaProxyController.filename_matches( assert %Conn{
true, status: 404,
request_path, resp_body: "Not Found"
"https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg" } = get(conn, "/proxy/preview/hhgfh/eeeee")
) == :ok
assert %Conn{
status: 404,
resp_body: "Not Found"
} = get(conn, "/proxy/preview/hhgfh/fff")
end
test "returns 404 when disabled", %{conn: conn} do
clear_config([:media_preview_proxy, :enabled], false)
assert %Conn{
status: 404,
resp_body: "Not Found"
} = get(conn, "/proxy/preview/hhgfh/eeeee")
assert %Conn{
status: 404,
resp_body: "Not Found"
} = get(conn, "/proxy/preview/hhgfh/fff")
end
test "it returns 403 for invalid signature", %{conn: conn, url: url} do
Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], "000")
%{path: path} = URI.parse(url)
assert %Conn{
status: 403,
resp_body: "Forbidden"
} = get(conn, path)
assert %Conn{
status: 403,
resp_body: "Forbidden"
} = get(conn, "/proxy/preview/hhgfh/eeee")
assert %Conn{
status: 403,
resp_body: "Forbidden"
} = get(conn, "/proxy/preview/hhgfh/eeee/fff")
end
test "redirects to valid url when filename is invalidated", %{conn: conn, url: url} do
invalid_url = String.replace(url, "test.png", "test-file.png")
response = get(conn, invalid_url)
assert response.status == 302
assert redirected_to(response) == url
end
test "responds with 424 Failed Dependency if HEAD request to media proxy fails", %{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{status: 500, body: ""}
end)
response = get(conn, url)
assert response.status == 424
assert response.resp_body == "Can't fetch HTTP headers (HTTP 500)."
end
test "redirects to media proxy URI on unsupported content type", %{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "", headers: [{"content-type", "application/pdf"}]}
end)
response = get(conn, url)
assert response.status == 302
assert redirected_to(response) == media_proxy_url
end
test "with `static=true` and GIF image preview requested, responds with JPEG image", %{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
# Setting a high :min_content_length to ensure this scenario is not affected by its logic
clear_config([:media_preview_proxy, :min_content_length], 1_000_000_000)
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{
status: 200,
body: "",
headers: [{"content-type", "image/gif"}, {"content-length", "1001718"}]
}
%{method: :get, url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: File.read!("test/fixtures/image.gif")}
end)
response = get(conn, url <> "?static=true")
assert response.status == 200
assert Conn.get_resp_header(response, "content-type") == ["image/jpeg"]
assert response.resp_body != ""
end
test "with GIF image preview requested and no `static` param, redirects to media proxy URI",
%{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/gif"}]}
end)
response = get(conn, url)
assert response.status == 302
assert redirected_to(response) == media_proxy_url
end
test "with `static` param and non-GIF image preview requested, " <>
"redirects to media preview proxy URI without `static` param",
%{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]}
end)
response = get(conn, url <> "?static=true")
assert response.status == 302
assert redirected_to(response) == url
end
test "with :min_content_length setting not matched by Content-Length header, " <>
"redirects to media proxy URI",
%{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
clear_config([:media_preview_proxy, :min_content_length], 100_000)
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{
status: 200,
body: "",
headers: [{"content-type", "image/gif"}, {"content-length", "5000"}]
}
end)
response = get(conn, url)
assert response.status == 302
assert redirected_to(response) == media_proxy_url
end
test "thumbnails PNG images into PNG", %{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/png"}]}
%{method: :get, url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: File.read!("test/fixtures/image.png")}
end)
response = get(conn, url)
assert response.status == 200
assert Conn.get_resp_header(response, "content-type") == ["image/png"]
assert response.resp_body != ""
end
test "thumbnails JPEG images into JPEG", %{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]}
%{method: :get, url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: File.read!("test/fixtures/image.jpg")}
end)
response = get(conn, url)
assert response.status == 200
assert Conn.get_resp_header(response, "content-type") == ["image/jpeg"]
assert response.resp_body != ""
end
test "redirects to media proxy URI in case of thumbnailing error", %{
conn: conn,
url: url,
media_proxy_url: media_proxy_url
} do
Tesla.Mock.mock(fn
%{method: "head", url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]}
%{method: :get, url: ^media_proxy_url} ->
%Tesla.Env{status: 200, body: "<html><body>error</body></html>"}
end)
response = get(conn, url)
assert response.status == 302
assert redirected_to(response) == media_proxy_url
end end
end end
end end

View file

@ -6,9 +6,16 @@ defmodule Pleroma.Web.MediaProxyTest do
use ExUnit.Case use ExUnit.Case
use Pleroma.Tests.Helpers use Pleroma.Tests.Helpers
alias Pleroma.Config
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
alias Pleroma.Web.MediaProxy 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 describe "when enabled" do
setup do: clear_config([:media_proxy, :enabled], true) setup do: clear_config([:media_proxy, :enabled], true)
@ -35,7 +42,7 @@ test "encodes and decodes URL" do
assert String.starts_with?( assert String.starts_with?(
encoded, 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") assert String.ends_with?(encoded, "/logo.png")
@ -75,6 +82,64 @@ test "validates signature" do
assert MediaProxy.decode_url(sig, base64) == {:error, :invalid_signature} assert MediaProxy.decode_url(sig, base64) == {:error, :invalid_signature}
end end
def test_verify_request_path_and_url(request_path, url, expected_result) do
assert MediaProxy.verify_request_path_and_url(request_path, url) == expected_result
assert MediaProxy.verify_request_path_and_url(
%Plug.Conn{
params: %{"filename" => Path.basename(request_path)},
request_path: request_path
},
url
) == expected_result
end
test "if first arg of `verify_request_path_and_url/2` is a Plug.Conn without \"filename\" " <>
"parameter, `verify_request_path_and_url/2` returns :ok " do
assert MediaProxy.verify_request_path_and_url(
%Plug.Conn{params: %{}, request_path: "/some/path"},
"https://instance.com/file.jpg"
) == :ok
assert MediaProxy.verify_request_path_and_url(
%Plug.Conn{params: %{}, request_path: "/path/to/file.jpg"},
"https://instance.com/file.jpg"
) == :ok
end
test "`verify_request_path_and_url/2` preserves the encoded or decoded path" do
test_verify_request_path_and_url(
"/Hello world.jpg",
"http://pleroma.social/Hello world.jpg",
:ok
)
test_verify_request_path_and_url(
"/Hello%20world.jpg",
"http://pleroma.social/Hello%20world.jpg",
:ok
)
test_verify_request_path_and_url(
"/my%2Flong%2Furl%2F2019%2F07%2FS.jpg",
"http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg",
:ok
)
test_verify_request_path_and_url(
# Note: `conn.request_path` returns encoded url
"/ANALYSE-DAI-_-LE-STABLECOIN-100-D%C3%89CENTRALIS%C3%89-BQ.jpg",
"https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg",
:ok
)
test_verify_request_path_and_url(
"/my%2Flong%2Furl%2F2019%2F07%2FS",
"http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg",
{:wrong_filename, "my%2Flong%2Furl%2F2019%2F07%2FS.jpg"}
)
end
test "uses the configured base_url" do test "uses the configured base_url" do
base_url = "https://cache.pleroma.social" base_url = "https://cache.pleroma.social"
clear_config([:media_proxy, :base_url], base_url) clear_config([:media_proxy, :base_url], base_url)
@ -124,12 +189,6 @@ test "does not encode remote urls" do
end end
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 describe "whitelist" do
setup do: clear_config([:media_proxy, :enabled], true) setup do: clear_config([:media_proxy, :enabled], true)