Page MenuHomePhorge

No OneTemporary

Size
62 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex
index c7ee47c6e..11f041c57 100644
--- a/lib/pleroma/reverse_proxy.ex
+++ b/lib/pleroma/reverse_proxy.ex
@@ -1,471 +1,493 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ReverseProxy do
alias Pleroma.Utils.URIEncoding
@range_headers ~w(range if-range)
@keep_req_headers ~w(accept accept-encoding cache-control if-modified-since) ++
~w(if-unmodified-since if-none-match) ++ @range_headers
@resp_cache_headers ~w(etag date last-modified)
@keep_resp_headers @resp_cache_headers ++
~w(content-length content-type content-disposition content-encoding) ++
~w(content-range accept-ranges vary)
@default_cache_control_header "public, max-age=1209600, immutable"
@valid_resp_codes [200, 206, 304]
@max_read_duration :timer.seconds(30)
@max_body_length :infinity
@failed_request_ttl :timer.seconds(60)
@methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
def max_read_duration_default, do: @max_read_duration
def default_cache_control_header, do: @default_cache_control_header
@moduledoc """
A reverse proxy.
Pleroma.ReverseProxy.call(conn, url, options)
It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
Responses are chunked to the client while downloading from the upstream.
Some request / responses headers are preserved:
* request: `#{inspect(@keep_req_headers)}`
* response: `#{inspect(@keep_resp_headers)}`
Options:
* `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP
errors. Any error during body processing will not be redirected as the response is chunked. This may expose
remote URL, clients IPs, ….
* `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the
specified length. It is validated with the `content-length` header and also verified when proxying.
* `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
read from the remote upstream.
* `failed_request_ttl` (default `#{inspect(@failed_request_ttl)}` ms): the time the failed request is cached and cannot be retried.
* `inline_content_types`:
* `true` will not alter `content-disposition` (up to the upstream),
* `false` will add `content-disposition: attachment` to any request,
- * a list of whitelisted content types
+ * a list of whitelisted content types for which `content-disposition: inline`
+ is always set (overriding any upstream header) so the media can be embedded
+ in pages; the filename is derived from the content type
* `req_headers`, `resp_headers` additional headers.
* `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun).
"""
@default_options [pool: :media]
@inline_content_types [
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
"audio/mpeg",
"audio/mp3",
"video/webm",
"video/mp4",
"video/quicktime"
]
require Logger
import Plug.Conn
@type option() ::
{:max_read_duration, non_neg_integer() | :infinity}
| {:max_body_length, non_neg_integer() | :infinity}
| {:failed_request_ttl, non_neg_integer() | :infinity}
| {:http, keyword()}
| {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean() | list(String.t())}
| {:redirect_on_failure, boolean()}
@spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t()
def call(_conn, _url, _opts \\ [])
def call(conn = %{method: method}, url, opts) when method in @methods do
client_opts = Keyword.merge(@default_options, Keyword.get(opts, :http, []))
req_headers = build_req_headers(conn.req_headers, opts)
opts =
if filename = Pleroma.Web.MediaProxy.filename(url) do
Keyword.put_new(opts, :attachment_name, filename)
else
opts
end
with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url),
{:ok, code, headers, client} <- request(method, url, req_headers, client_opts),
:ok <-
header_length_constraint(
headers,
Keyword.get(opts, :max_body_length, @max_body_length)
) do
response(conn, client, url, code, headers, opts)
else
{:ok, true} ->
conn
|> error_or_redirect(url, 500, "Request failed", opts)
|> halt()
{:ok, code, headers} ->
head_response(conn, url, code, headers, opts)
|> halt()
{:error, {:invalid_http_response, code}} ->
Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
track_failed_url(url, code, opts)
conn
|> error_or_redirect(
url,
code,
"Request failed: " <> Plug.Conn.Status.reason_phrase(code),
opts
)
|> halt()
{:error, error} ->
Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
track_failed_url(url, error, opts)
conn
|> error_or_redirect(url, 500, "Request failed", opts)
|> halt()
end
end
def call(conn, _, _) do
conn
|> send_resp(400, Plug.Conn.Status.reason_phrase(400))
|> halt()
end
defp request(method, url, headers, opts) do
method = method |> String.downcase() |> String.to_existing_atom()
url = maybe_encode_url(url)
Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
case client().request(method, url, headers, "", opts) do
{:ok, code, headers, client} when code in @valid_resp_codes ->
{:ok, code, downcase_headers(headers), client}
{:ok, code, headers} when code in @valid_resp_codes ->
{:ok, code, downcase_headers(headers)}
{:ok, code, _, _} ->
{:error, {:invalid_http_response, code}}
{:ok, code, _} ->
{:error, {:invalid_http_response, code}}
{:error, error} ->
{:error, error}
end
end
defp response(conn, client, url, status, headers, opts) do
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
result =
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> streaming_compat
|> send_chunked(status)
|> chunk_reply(client, opts)
case result do
{:ok, conn} ->
halt(conn)
{:error, :closed, conn} ->
client().close(client)
halt(conn)
{:error, error, conn} ->
Logger.warning(
"#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
)
client().close(client)
halt(conn)
end
end
defp chunk_reply(conn, client, opts) do
chunk_reply(conn, client, opts, 0, 0)
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, duration} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(duration),
sent_so_far = sent_so_far + byte_size(data),
:ok <-
body_size_constraint(
sent_so_far,
Keyword.get(opts, :max_body_length, @max_body_length)
),
{:ok, conn} <- chunk(conn, data) do
chunk_reply(conn, client, opts, sent_so_far, duration)
else
:done -> {:ok, conn}
{:error, error} -> {:error, error, conn}
end
end
defp head_response(conn, url, code, headers, opts) do
Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> send_resp(code, "")
end
defp error_or_redirect(conn, url, code, body, opts) do
if Keyword.get(opts, :redirect_on_failure, false) do
conn
|> Phoenix.Controller.redirect(external: url)
|> halt()
else
conn
|> send_resp(code, body)
|> halt
end
end
defp downcase_headers(headers) do
Enum.map(headers, fn {k, v} ->
{String.downcase(k), v}
end)
end
defp get_content_type(headers) do
{_, content_type} =
List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
[content_type | _] = String.split(content_type, ";")
content_type
end
defp put_resp_headers(conn, headers) do
Enum.reduce(headers, conn, fn {k, v}, conn ->
put_resp_header(conn, k, v)
end)
end
defp build_req_headers(headers, opts) do
headers
|> downcase_headers()
|> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
|> build_req_range_or_encoding_header(opts)
|> build_req_user_agent_header(opts)
|> Keyword.merge(Keyword.get(opts, :req_headers, []))
end
# Disable content-encoding if any @range_headers are requested (see #1823).
defp build_req_range_or_encoding_header(headers, _opts) do
range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
if range? && List.keymember?(headers, "accept-encoding", 0) do
List.keydelete(headers, "accept-encoding", 0)
else
headers
end
end
defp build_req_user_agent_header(headers, _opts) do
List.keystore(
headers,
"user-agent",
0,
{"user-agent", Pleroma.Application.user_agent()}
)
end
defp build_resp_headers(headers, opts) do
headers
|> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
|> build_resp_cache_headers(opts)
|> sanitise_content_type()
|> build_resp_content_disposition_header(opts)
|> Keyword.merge(Keyword.get(opts, :resp_headers, []))
end
defp sanitise_content_type(headers) do
original_ct = get_content_type(headers)
safe_ct =
Pleroma.Web.Plugs.Utils.get_safe_mime_type(
%{allowed_mime_types: @allowed_mime_types},
original_ct
)
[
{"content-type", safe_ct}
| Enum.filter(headers, fn {k, _v} -> k != "content-type" end)
]
end
defp build_resp_cache_headers(headers, _opts) do
has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
cond do
has_cache? ->
# There's caching header present but no cache-control -- we need to set our own
# as Plug defaults to "max-age=0, private, must-revalidate"
List.keystore(
headers,
"cache-control",
0,
{"cache-control", @default_cache_control_header}
)
true ->
List.keystore(
headers,
"cache-control",
0,
{"cache-control", @default_cache_control_header}
)
end
end
defp build_resp_content_disposition_header(headers, opts) do
opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
content_type = get_content_type(headers)
attachment? =
cond do
is_list(opt) && !Enum.member?(opt, content_type) -> true
opt == false -> true
true -> false
end
if attachment? do
name =
try do
{{"content-disposition", content_disposition_string}, _} =
List.keytake(headers, "content-disposition", 0)
[name | _] =
Regex.run(
~r/filename="((?:[^"\\]|\\.)*)"/u,
content_disposition_string || "",
capture: :all_but_first
)
name
rescue
MatchError -> Keyword.get(opts, :attachment_name, "attachment")
end
disposition = "attachment; filename=\"#{name}\""
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
else
- headers
+ if opt == true do
+ headers
+ else
+ name = inline_filename(content_type)
+
+ disposition =
+ if name do
+ "inline; filename=\"#{name}\""
+ else
+ "inline"
+ end
+
+ List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
+ end
+ end
+ end
+
+ defp inline_filename(content_type) do
+ case MIME.extensions(content_type) do
+ [ext | _] when ext != "" -> "inline.#{ext}"
+ _ -> nil
end
end
defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
with {_, size} <- List.keyfind(headers, "content-length", 0),
{size, _} <- Integer.parse(size),
true <- size <= limit do
:ok
else
false ->
{:error, :body_too_large}
_ ->
:ok
end
end
defp header_length_constraint(_, _), do: :ok
defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
{:error, :body_too_large}
end
defp body_size_constraint(_, _), do: :ok
defp check_read_duration(duration, max)
when is_integer(duration) and is_integer(max) and max > 0 do
if duration > max do
{:error, :read_duration_exceeded}
else
{:ok, {duration, :erlang.system_time(:millisecond)}}
end
end
defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
defp increase_read_duration({previous_duration, started})
when is_integer(previous_duration) and is_integer(started) do
duration = :erlang.system_time(:millisecond) - started
{:ok, previous_duration + duration}
end
defp client, do: Pleroma.ReverseProxy.Client.Wrapper
defp track_failed_url(url, error, opts) do
ttl =
unless error in [:body_too_large, 400, 204] do
Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
else
nil
end
@cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)
end
# When Cowboy handles a chunked response with a content-length header it streams
# over HTTP 1.1 instead of chunking. Bandit cannot stream over HTTP 1.1 so the header
# must be stripped or it breaks RFC compliance for Transfer Encoding: Chunked. RFC9112§6.2
#
# HTTP2 is always streamed for all adapters.
defp streaming_compat(conn) do
with Phoenix.Endpoint.Cowboy2Adapter <- Pleroma.Web.Endpoint.config(:adapter) do
conn
else
_ -> delete_resp_header(conn, "content-length")
end
end
# Only when Tesla adapter is Hackney or Finch does the URL
# need encoding before Reverse Proxying as both end up
# using the raw Hackney client and cannot leverage our
# EncodeUrl Tesla middleware
# Also do it for test environment
defp maybe_encode_url(url) do
case Application.get_env(:tesla, :adapter) do
Tesla.Adapter.Hackney -> URIEncoding.encode_url(url)
{Tesla.Adapter.Finch, _} -> URIEncoding.encode_url(url)
Tesla.Mock -> URIEncoding.encode_url(url)
_ -> url
end
end
end
diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex
new file mode 100644
index 000000000..19215b627
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex
@@ -0,0 +1,59 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer do
+ @moduledoc """
+ Best-effort MIME sniffing for remote attachments whose declared type is
+ unusable (missing or `application/octet-stream`).
+
+ Fetches the first chunk of the URL and runs it through libmagic. Only
+ `image/*` results are returned; anything else, or any failure, yields `nil` —
+ so this never reclassifies non-image media and never blocks ingestion. This
+ exists because some remotes (e.g. extensionless Cloudflare Images URLs) ship
+ attachments with no usable `mediaType`, which would otherwise render in the
+ timeline as a clickable link instead of an inline image.
+ """
+
+ alias Pleroma.HTTP
+
+ require Logger
+
+ # libmagic only needs the file header; a few KB is plenty and keeps the
+ # request cheap even when the origin ignores Range.
+ @sniff_bytes 8 * 1024
+ @timeout 5_000
+
+ @spec sniff_image_type(binary() | nil) :: {:ok, binary() | nil}
+ def sniff_image_type(url) when is_binary(url) and url != "" do
+ do_sniff(url)
+ rescue
+ e ->
+ Logger.debug(
+ "Attachment type sniff failed for #{url}: #{Exception.format(:error, e, __STACKTRACE__)}"
+ )
+
+ {:ok, nil}
+ end
+
+ def sniff_image_type(_), do: {:ok, nil}
+
+ defp do_sniff(url) do
+ headers = [{"range", "bytes=0-#{@sniff_bytes - 1}"}]
+ opts = [pool: :media, timeout: @timeout, recv_timeout: @timeout]
+
+ with {:ok, %Tesla.Env{status: status, body: body}} when status in 200..299 <-
+ HTTP.get(url, headers, opts),
+ false <- blank?(body),
+ {:ok, %{mime_type: mime}} <- Majic.perform({:bytes, body}, pool: Pleroma.MajicPool),
+ true <- String.starts_with?(mime, "image/") do
+ {:ok, mime}
+ else
+ _ -> {:ok, nil}
+ end
+ end
+
+ defp blank?(nil), do: true
+ defp blank?(""), do: true
+ defp blank?(_), do: false
+end
diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
index 5ee9e7549..b6e30a438 100644
--- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
@@ -1,97 +1,116 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
use Ecto.Schema
alias Pleroma.EctoType.ActivityPub.ObjectValidators
+ alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
import Ecto.Changeset
@primary_key false
embedded_schema do
field(:id, :string)
field(:type, :string, default: "Link")
field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream")
field(:name, :string)
field(:summary, :string)
field(:blurhash, :string)
embeds_many :url, UrlObjectValidator, primary_key: false do
field(:type, :string, default: "Link")
field(:href, ObjectValidators.Uri)
field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream")
field(:width, :integer)
field(:height, :integer)
end
end
def cast_and_validate(data) do
data
|> cast_data()
|> validate_data()
end
def cast_data(data) do
%__MODULE__{}
|> changeset(data)
end
def changeset(struct, data) do
data =
data
|> fix_media_type()
|> fix_url()
struct
|> cast(data, [:id, :type, :mediaType, :name, :summary, :blurhash])
|> cast_embed(:url, with: &url_changeset/2, required: true)
|> validate_inclusion(:type, ~w[Link Document Audio Image Video])
|> validate_required([:type, :mediaType])
end
def url_changeset(struct, data) do
data = fix_media_type(data)
struct
|> cast(data, [:type, :href, :mediaType, :width, :height])
|> validate_inclusion(:type, ["Link"])
|> validate_required([:type, :href, :mediaType])
end
def fix_media_type(data) do
- Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream")
+ current = data["mediaType"] || data["mimeType"] || "application/octet-stream"
+
+ resolved =
+ if sniffable?(current) do
+ case AttachmentTypeSniffer.sniff_image_type(data["href"]) do
+ {:ok, mime} when is_binary(mime) -> mime
+ _ -> current
+ end
+ else
+ current
+ end
+
+ Map.put(data, "mediaType", resolved)
end
+ # Only sniff when the declared type carries no real information. Everything
+ # else (including unrecognized-but-present types) is left as the remote sent it.
+ defp sniffable?("application/octet-stream"), do: true
+ defp sniffable?(""), do: true
+ defp sniffable?(_), do: false
+
defp handle_href(href, mediaType, data) do
[
%{
"href" => href,
"type" => "Link",
"mediaType" => mediaType,
"width" => data["width"],
"height" => data["height"]
}
]
end
defp fix_url(data) do
cond do
is_binary(data["url"]) ->
Map.put(data, "url", handle_href(data["url"], data["mediaType"], data))
is_binary(data["href"]) and data["url"] == nil ->
Map.put(data, "url", handle_href(data["href"], data["mediaType"], data))
true ->
data
end
end
defp validate_data(cng) do
cng
|> validate_inclusion(:type, ~w[Document Audio Image Video])
|> validate_required([:mediaType, :type])
end
end
diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs
index ec4470379..5f75f2a07 100644
--- a/test/pleroma/reverse_proxy_test.exs
+++ b/test/pleroma/reverse_proxy_test.exs
@@ -1,461 +1,621 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ReverseProxyTest do
use Pleroma.Web.ConnCase
import ExUnit.CaptureLog
import Mox
alias Pleroma.ReverseProxy
alias Pleroma.ReverseProxy.ClientMock
alias Plug.Conn
setup_all do
{:ok, _} = Registry.start_link(keys: :unique, name: ClientMock)
:ok
end
setup :verify_on_exit!
defp request_mock(invokes) do
ClientMock
|> expect(:request, fn :get, url, headers, _body, _opts ->
Registry.register(ClientMock, url, 0)
body = headers |> Enum.into(%{}) |> Jason.encode!()
{:ok, 200,
[
{"content-type", "application/json"},
{"content-length", byte_size(body) |> to_string()}
], %{url: url, body: body}}
end)
|> expect(:stream_body, invokes, fn %{url: url, body: body} = client ->
case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
Registry.update_value(ClientMock, url, &(&1 + 1))
{:ok, body, client}
[{_, 1}] ->
Registry.unregister(ClientMock, url)
:done
end
end)
end
describe "reverse proxy" do
test "do not track successful request", %{conn: conn} do
request_mock(2)
url = "/success"
conn = ReverseProxy.call(conn, url)
assert conn.status == 200
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil}
end
end
test "use Pleroma's user agent in the request; don't pass the client's", %{conn: conn} do
request_mock(2)
conn =
conn
|> Plug.Conn.put_req_header("user-agent", "fake/1.0")
|> ReverseProxy.call("/user-agent")
# Convert the response to a map without relying on json_response
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
assert response == %{"user-agent" => Pleroma.Application.user_agent()}
end
test "closed connection", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/closed", _, _, _ -> {:ok, 200, [], %{}} end)
|> expect(:stream_body, fn _ -> {:error, :closed} end)
|> expect(:close, fn _ -> :ok end)
conn = ReverseProxy.call(conn, "/closed")
assert conn.halted
end
defp stream_mock(invokes, with_close? \\ false) do
ClientMock
|> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ ->
Registry.register(ClientMock, "/stream-bytes/" <> length, 0)
{:ok, 200, [{"content-type", "application/octet-stream"}],
%{url: "/stream-bytes/" <> length}}
end)
|> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} = client ->
max = String.to_integer(length)
case Registry.lookup(ClientMock, "/stream-bytes/" <> length) do
[{_, current}] when current < max ->
Registry.update_value(
ClientMock,
"/stream-bytes/" <> length,
&(&1 + 10)
)
{:ok, "0123456789", client}
[{_, ^max}] ->
Registry.unregister(ClientMock, "/stream-bytes/" <> length)
:done
end
end)
if with_close? do
expect(ClientMock, :close, fn _ -> :ok end)
end
end
describe "max_body" do
test "length returns error if content-length more than option", %{conn: conn} do
request_mock(0)
assert capture_log(fn ->
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
end) =~
"[error] Elixir.Pleroma.ReverseProxy: request to \"/huge-file\" failed: :body_too_large"
assert {:ok, true} == Cachex.get(:failed_proxy_url_cache, "/huge-file")
assert capture_log(fn ->
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
end) == ""
end
test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
stream_mock(3, true)
assert capture_log(fn ->
ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 30)
end) =~
"Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
end
end
describe "HEAD requests" do
test "common", %{conn: conn} do
ClientMock
|> expect(:request, fn :head, "/head", _, _, _ ->
{:ok, 200, [{"content-type", "image/png"}]}
end)
conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["image/png"]
assert conn.resp_body == ""
end
end
defp error_mock(status) when is_integer(status) do
ClientMock
|> expect(:request, fn :get, "/status/" <> _, _, _, _ ->
{:error, status}
end)
end
describe "returns error on" do
test "500", %{conn: conn} do
error_mock(500)
url = "/status/500"
capture_log(fn -> ReverseProxy.call(conn, url) end) =~
"[error] Elixir.Pleroma.ReverseProxy: request to /status/500 failed with HTTP status 500"
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
{:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url)
assert ttl <= 60_000
end
test "400", %{conn: conn} do
error_mock(400)
url = "/status/400"
capture_log(fn -> ReverseProxy.call(conn, url) end) =~
"[error] Elixir.Pleroma.ReverseProxy: request to /status/400 failed with HTTP status 400"
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil}
end
test "403", %{conn: conn} do
error_mock(403)
url = "/status/403"
capture_log(fn ->
ReverseProxy.call(conn, url, failed_request_ttl: :timer.seconds(120))
end) =~
"[error] Elixir.Pleroma.ReverseProxy: request to /status/403 failed with HTTP status 403"
{:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url)
assert ttl > 100_000
end
test "204", %{conn: conn} do
url = "/status/204"
expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end)
capture_log(fn ->
conn = ReverseProxy.call(conn, url)
assert conn.resp_body == "Request failed: No Content"
assert conn.halted
end) =~
"[error] Elixir.Pleroma.ReverseProxy: request to \"/status/204\" failed with HTTP status 204"
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil}
end
end
test "streaming", %{conn: conn} do
stream_mock(21)
conn = ReverseProxy.call(conn, "/stream-bytes/200")
assert conn.state == :chunked
assert byte_size(conn.resp_body) == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
defp headers_mock(_) do
ClientMock
|> expect(:request, fn :get, "/headers", headers, _, _ ->
Registry.register(ClientMock, "/headers", 0)
{:ok, 200, [{"content-type", "application/json"}], %{url: "/headers", headers: headers}}
end)
|> expect(:stream_body, 2, fn %{url: url, headers: headers} = client ->
case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
Registry.update_value(ClientMock, url, &(&1 + 1))
headers = for {k, v} <- headers, into: %{}, do: {String.capitalize(k), v}
{:ok, Jason.encode!(%{headers: headers}), client}
[{_, 1}] ->
Registry.unregister(ClientMock, url)
:done
end
end)
:ok
end
describe "keep request headers" do
setup [:headers_mock]
test "header passes", %{conn: conn} do
conn =
Conn.put_req_header(
conn,
"accept",
"text/html"
)
|> ReverseProxy.call("/headers")
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
headers = response["headers"]
assert headers["Accept"] == "text/html"
end
test "header is filtered", %{conn: conn} do
conn =
Conn.put_req_header(
conn,
"accept-language",
"en-US"
)
|> ReverseProxy.call("/headers")
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
headers = response["headers"]
refute headers["Accept-Language"]
end
end
test "returns 400 on non GET, HEAD requests", %{conn: conn} do
conn = ReverseProxy.call(Map.put(conn, :method, "POST"), "/ip")
assert conn.status == 400
end
describe "cache resp headers" do
test "add cache-control", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/cache", _, _, _ ->
{:ok, 200, [{"ETag", "some ETag"}], %{}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/cache")
assert {"cache-control", "public, max-age=1209600, immutable"} in conn.resp_headers
end
end
- defp disposition_headers_mock(headers) do
+ defp disposition_headers_mock(headers, url \\ "/disposition") do
ClientMock
- |> expect(:request, fn :get, "/disposition", _, _, _ ->
- Registry.register(ClientMock, "/disposition", 0)
+ |> expect(:request, fn :get, ^url, _, _, _ ->
+ Registry.register(ClientMock, url, 0)
- {:ok, 200, headers, %{url: "/disposition"}}
+ {:ok, 200, headers, %{url: url}}
end)
- |> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
- case Registry.lookup(ClientMock, "/disposition") do
+ |> expect(:stream_body, 2, fn %{url: ^url} = client ->
+ case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
- Registry.update_value(ClientMock, "/disposition", &(&1 + 1))
+ Registry.update_value(ClientMock, url, &(&1 + 1))
{:ok, "", client}
[{_, 1}] ->
- Registry.unregister(ClientMock, "/disposition")
+ Registry.unregister(ClientMock, url)
:done
end
end)
end
describe "response content disposition header" do
test "not attachment", %{conn: conn} do
disposition_headers_mock([
{"content-type", "image/gif"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition")
assert {"content-type", "image/gif"} in conn.resp_headers
+ assert {"content-disposition", "inline; filename=\"inline.gif\""} in conn.resp_headers
+ end
+
+ test "forces inline for inline content types overriding upstream attachment", %{
+ conn: conn
+ } do
+ disposition_headers_mock([
+ {"content-type", "image/png"},
+ {"content-disposition", "attachment; filename=\"filename.png\""},
+ {"content-length", "0"}
+ ])
+
+ conn = ReverseProxy.call(conn, "/disposition")
+
+ [disposition] = Conn.get_resp_header(conn, "content-disposition")
+ assert String.starts_with?(disposition, "inline")
+ refute String.starts_with?(disposition, "attachment")
+ end
+
+ test "forces inline based on content type even without a file extension in the url", %{
+ conn: conn
+ } do
+ disposition_headers_mock(
+ [
+ {"content-type", "image/jpeg"},
+ {"content-length", "0"}
+ ],
+ "/original"
+ )
+
+ conn = ReverseProxy.call(conn, "/original")
+
+ [disposition] = Conn.get_resp_header(conn, "content-disposition")
+ assert disposition == "inline; filename=\"inline.jpg\""
end
test "with content-disposition header", %{conn: conn} do
disposition_headers_mock([
{"content-disposition", "attachment; filename=\"filename.jpg\""},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition")
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
end
+
+ test "with inline_content_types: true leaves upstream headers untouched", %{
+ conn: conn
+ } do
+ # opt == true: the proxy must not synthesise or rewrite content-disposition.
+ disposition_headers_mock([
+ {"content-type", "image/png"},
+ {"content-disposition", "attachment; filename=\"upstream.png\""},
+ {"content-length", "0"}
+ ])
+
+ conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true)
+
+ assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers
+ end
+
+ test "with inline_content_types: true does not synthesise inline when upstream is absent", %{
+ conn: conn
+ } do
+ disposition_headers_mock([
+ {"content-type", "image/png"},
+ {"content-length", "0"}
+ ])
+
+ conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true)
+
+ assert Conn.get_resp_header(conn, "content-disposition") == []
+ end
+
+ test "with inline_content_types: false forces attachment for everything", %{
+ conn: conn
+ } do
+ disposition_headers_mock([
+ {"content-type", "image/png"},
+ {"content-disposition", "inline; filename=\"pic.png\""},
+ {"content-length", "0"}
+ ])
+
+ conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false)
+
+ [disposition] = Conn.get_resp_header(conn, "content-disposition")
+ assert String.starts_with?(disposition, "attachment")
+ end
+
+ test "with inline_content_types: false derives attachment filename from the URL basename when no upstream filename", %{
+ conn: conn
+ } do
+ # No content-disposition header: the attachment branch falls back to
+ # attachment_name, which by default comes from MediaProxy.filename/1
+ # (the URL basename).
+ disposition_headers_mock([
+ {"content-type", "image/png"},
+ {"content-length", "0"}
+ ])
+
+ conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false)
+
+ [disposition] = Conn.get_resp_header(conn, "content-disposition")
+ assert disposition == "attachment; filename=\"disposition\""
+ end
+
+ test "with inline_content_types: false honours an explicit attachment_name opt", %{
+ conn: conn
+ } do
+ disposition_headers_mock([
+ {"content-type", "image/png"},
+ {"content-length", "0"}
+ ])
+
+ conn =
+ ReverseProxy.call(conn, "/disposition",
+ inline_content_types: false,
+ attachment_name: "custom.bin"
+ )
+
+ [disposition] = Conn.get_resp_header(conn, "content-disposition")
+ assert disposition == "attachment; filename=\"custom.bin\""
+ end
+
+ test "forces bare inline for a whitelisted type with no MIME extension", %{
+ conn: conn
+ } do
+ # image/x-foo-bar has no entry in MIME's database, so inline_filename/1
+ # returns nil and the disposition should be the bare token "inline".
+ disposition_headers_mock([
+ {"content-type", "image/x-foo-bar"},
+ {"content-length", "0"}
+ ])
+
+ conn = ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/x-foo-bar"])
+
+ [disposition] = Conn.get_resp_header(conn, "content-disposition")
+ assert disposition == "inline"
+ end
+
+ test "honours a custom inline_content_types whitelist", %{conn: conn} do
+ # image/bmp is NOT in the default whitelist; with a custom whitelist
+ # that includes it, the proxy should force inline.
+ disposition_headers_mock([
+ {"content-type", "image/bmp"},
+ {"content-disposition", "attachment; filename=\"upstream.bmp\""},
+ {"content-length", "0"}
+ ])
+
+ conn =
+ ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"])
+
+ [disposition] = Conn.get_resp_header(conn, "content-disposition")
+ assert String.starts_with?(disposition, "inline")
+ assert String.ends_with?(disposition, "inline.bmp\"")
+ end
+
+ test "treats a type outside the whitelist as attachment even on custom whitelist", %{
+ conn: conn
+ } do
+ disposition_headers_mock([
+ {"content-type", "image/png"},
+ {"content-disposition", "attachment; filename=\"filename.png\""},
+ {"content-length", "0"}
+ ])
+
+ conn =
+ ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"])
+
+ assert {"content-disposition", "attachment; filename=\"filename.png\""} in conn.resp_headers
+ end
end
describe "content-type sanitisation" do
test "preserves allowed image type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "image/png"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["image/png"]
end
test "preserves allowed video type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "video/mp4"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["video/mp4"]
end
test "sanitizes ActivityPub content type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "application/activity+json"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
test "sanitizes LD-JSON content type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "application/ld+json"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
end
# Hackney is used for Reverse Proxy when Hackney or Finch is the Tesla Adapter
# Gun is able to proxy through Tesla, so it does not need testing as the
# test cases in the Pleroma.HTTPTest module are sufficient
describe "Hackney URL encoding:" do
setup do
ClientMock
|> expect(:request, fn
:get,
"https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz",
_headers,
_body,
_opts ->
{:ok, 200, [{"content-type", "image/png"}], "It works!"}
:get,
"https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4",
_headers,
_body,
_opts ->
{:ok, 200, [{"content-type", "video/mp4"}], "Allowed reserved chars."}
:get, "https://example.com/media/unicode%20%F0%9F%99%82%20.gif", _headers, _body, _opts ->
{:ok, 200, [{"content-type", "image/gif"}], "Unicode emoji in path"}
end)
|> stub(:stream_body, fn _ -> :done end)
|> stub(:close, fn _ -> :ok end)
:ok
end
test "properly encodes URLs with spaces", %{conn: conn} do
url_with_space = "https://example.com/emoji/Pack 1/koronebless.png?foo=bar baz"
result = ReverseProxy.call(conn, url_with_space)
assert result.status == 200
end
test "properly encoded URL should not be altered", %{conn: conn} do
properly_encoded_url = "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"
result = ReverseProxy.call(conn, properly_encoded_url)
assert result.status == 200
end
test "properly encodes URLs with allowed reserved characters", %{conn: conn} do
url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4"
result = ReverseProxy.call(conn, url_with_reserved_chars)
assert result.status == 200
end
test "properly encodes URLs with unicode in path", %{conn: conn} do
url_with_unicode = "https://example.com/media/unicode 🙂 .gif"
result = ReverseProxy.call(conn, url_with_unicode)
assert result.status == 200
end
end
end
diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs
new file mode 100644
index 000000000..52b1b67a1
--- /dev/null
+++ b/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs
@@ -0,0 +1,178 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSnifferTest do
+ use Pleroma.DataCase, async: true
+
+ alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
+
+ # @sniff_bytes must mirror the value in the module under test; we assert the
+ # Range header that the sniffer sends, so the test should fail loudly if the
+ # constant drifts.
+ @sniff_bytes 8 * 1024
+ @range_header "bytes=0-#{@sniff_bytes - 1}"
+
+ describe "sniff_image_type/1 with a real image body" do
+ test "returns image/jpeg for a JPEG body" do
+ url = "https://example.com/media/image.jpg"
+ jpeg = File.read!("test/fixtures/image.jpg")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: jpeg}
+ end)
+
+ assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns image/png for a PNG body" do
+ url = "https://example.com/media/image.png"
+ png = File.read!("test/fixtures/image.png")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: png}
+ end)
+
+ assert {:ok, "image/png"} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns image/gif for a GIF body" do
+ url = "https://example.com/media/image.gif"
+ gif = File.read!("test/fixtures/image.gif")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: gif}
+ end)
+
+ assert {:ok, "image/gif"} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+ end
+
+ describe "sniff_image_type/1 sends a ranged request" do
+ test "sends a Range header asking for the first chunk" do
+ url = "https://example.com/media/ranged"
+ jpeg = File.read!("test/fixtures/image.jpg")
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^url, headers: headers} ->
+ headers_map = Enum.into(headers, %{})
+ assert headers_map["range"] == @range_header
+
+ %Tesla.Env{status: 200, body: jpeg}
+
+ %{method: :get, url: ^url} ->
+ # If the sniffer did not send the Range header, fail explicitly.
+ flunk("expected Range header to be sent with the request")
+ end)
+
+ assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+ end
+
+ describe "sniff_image_type/1 with a non-image body" do
+ test "returns nil for plain text" do
+ url = "https://example.com/media/note.txt"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: "just some plain text, not an image at all"}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns nil for an HTML body" do
+ url = "https://example.com/page"
+ html = File.read!("test/fixtures/rel_me_anchor.html")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: html}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns nil for an empty body" do
+ url = "https://example.com/media/empty"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: ""}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns nil for a nil body" do
+ url = "https://example.com/media/nil-body"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: nil}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+ end
+
+ describe "sniff_image_type/1 on HTTP failure" do
+ test "returns nil on a 404" do
+ url = "https://example.com/media/missing"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 404, body: ""}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns nil on a 500" do
+ url = "https://example.com/media/oops"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 500, body: "server error"}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns nil on a 3xx (not in 200..299)" do
+ url = "https://example.com/media/redirect"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 302, body: ""}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns nil on a transport-level error" do
+ url = "https://example.com/media/econnrefused"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ {:error, :econnrefused}
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+
+ test "returns nil when the HTTP adapter raises" do
+ url = "https://example.com/media/raises"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ raise "boom"
+ end)
+
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
+ end
+ end
+
+ describe "sniff_image_type/1 guard clauses" do
+ test "returns nil for nil input without making a request" do
+ # No Tesla mock is configured; if a request were made, Tesla.Mock would
+ # raise and fail the test.
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(nil)
+ end
+
+ test "returns nil for an empty-string URL without making a request" do
+ assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type("")
+ end
+ end
+end
diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs
index 744ae8704..870ad90cc 100644
--- a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs
+++ b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs
@@ -1,219 +1,440 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do
use Pleroma.DataCase, async: true
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator
import Mox
import Pleroma.Factory
describe "attachments" do
test "works with apng" do
attachment =
%{
"mediaType" => "image/apng",
"name" => "",
"type" => "Document",
"url" =>
"https://media.misskeyusercontent.com/io/2859c26e-cd43-4550-848b-b6243bc3fe28.apng"
}
assert {:ok, attachment} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "image/apng"
end
test "fails without url" do
attachment = %{
"mediaType" => "",
"name" => "",
"summary" => "298p3RG7j27tfsZ9RQ.jpg",
"type" => "Document"
}
assert {:error, _cng} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
end
test "works with honkerific attachments" do
honk = %{
"mediaType" => "",
"summary" => "Select your spirit chonk",
"name" => "298p3RG7j27tfsZ9RQ.jpg",
"type" => "Document",
"url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg"
}
assert {:ok, attachment} =
honk
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "application/octet-stream"
assert attachment.summary == "Select your spirit chonk"
assert attachment.name == "298p3RG7j27tfsZ9RQ.jpg"
end
test "works with an unknown but valid mime type" do
attachment = %{
"mediaType" => "x-custom/x-type",
"type" => "Document",
"url" => "https://example.org"
}
assert {:ok, attachment} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "x-custom/x-type"
end
test "works with invalid mime types" do
attachment = %{
"mediaType" => "x-customx-type",
"type" => "Document",
"url" => "https://example.org"
}
assert {:ok, attachment} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "application/octet-stream"
attachment = %{
"mediaType" => "https://example.org",
"type" => "Document",
"url" => "https://example.org"
}
assert {:ok, attachment} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "application/octet-stream"
end
test "it turns mastodon attachments into our attachments" do
attachment = %{
"url" =>
"http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg",
"type" => "Document",
"name" => nil,
"mediaType" => "image/jpeg",
"blurhash" => "UD9jJz~VSbR#xT$~%KtQX9R,WAs9RjWBs:of"
}
{:ok, attachment} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
assert [
%{
href:
"http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg",
type: "Link",
mediaType: "image/jpeg"
}
] = attachment.url
assert attachment.mediaType == "image/jpeg"
assert attachment.blurhash == "UD9jJz~VSbR#xT$~%KtQX9R,WAs9RjWBs:of"
end
test "it handles our own uploads" do
user = insert(:user)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
ConfigMock
|> stub_with(Pleroma.Test.StaticConfig)
{:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id)
{:ok, attachment} =
attachment.data
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "image/jpeg"
end
test "it handles image dimensions" do
attachment = %{
"url" => [
%{
"type" => "Link",
"mediaType" => "image/jpeg",
"href" => "https://example.com/images/1.jpg",
"width" => 200,
"height" => 100
}
],
"type" => "Document",
"name" => nil,
"mediaType" => "image/jpeg"
}
{:ok, attachment} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
assert [
%{
href: "https://example.com/images/1.jpg",
type: "Link",
mediaType: "image/jpeg",
width: 200,
height: 100
}
] = attachment.url
assert attachment.mediaType == "image/jpeg"
end
test "it transforms image dimensions to our internal format" do
attachment = %{
"type" => "Document",
"name" => "Hello world",
"url" => "https://media.example.tld/1.jpg",
"width" => 880,
"height" => 960,
"mediaType" => "image/jpeg",
"blurhash" => "eTKL26+HDjcEIBVl;ds+K6t301W.t7nit7y1E,R:v}ai4nXSt7V@of"
}
expected = %AttachmentValidator{
type: "Document",
name: "Hello world",
mediaType: "image/jpeg",
blurhash: "eTKL26+HDjcEIBVl;ds+K6t301W.t7nit7y1E,R:v}ai4nXSt7V@of",
url: [
%AttachmentValidator.UrlObjectValidator{
type: "Link",
mediaType: "image/jpeg",
href: "https://media.example.tld/1.jpg",
width: 880,
height: 960
}
]
}
{:ok, ^expected} =
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
end
+
+ test "sniffs image/jpeg for octet-stream attachments whose body is an image" do
+ url = "https://example.com/media/no-extension/original"
+ jpeg = File.read!("test/fixtures/image.jpg")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: jpeg}
+ end)
+
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "application/octet-stream",
+ "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url
+ end
+
+ test "sniffs image/jpeg for attachments with a missing mediaType" do
+ url = "https://example.com/media/missing-type/original"
+ jpeg = File.read!("test/fixtures/image.jpg")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: jpeg}
+ end)
+
+ attachment = %{
+ "type" => "Document",
+ "url" => [%{"type" => "Link", "href" => url}]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url
+ end
+
+ test "leaves non-image octet-stream attachments as octet-stream" do
+ url = "https://example.com/media/some-document"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: "just some plain text, not an image at all"}
+ end)
+
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "application/octet-stream",
+ "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [%{mediaType: "application/octet-stream"}] = attachment.url
+ end
+
+ test "does not sniff when the remote already provided a real mediaType" do
+ # No Tesla mock is set up: if the validator tried to fetch, Tesla.Mock
+ # would raise and fail the test. A real image type must be kept as-is.
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "image/png",
+ "url" => [
+ %{"type" => "Link", "href" => "https://example.com/x", "mediaType" => "image/png"}
+ ]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [%{mediaType: "image/png"}] = attachment.url
+ end
+ end
+
+ describe "fix_media_type fallbacks and sniffing" do
+ test "uses mimeType as a fallback when mediaType is absent (real type, not sniffed)" do
+ # No Tesla mock: if the validator tried to fetch, the test would fail.
+ # A real image type coming from mimeType must be preserved as-is on the
+ # outer attachment and on the synthesised url entry (string url form).
+ attachment = %{
+ "type" => "Document",
+ "mimeType" => "image/png",
+ "url" => "https://example.com/x.png"
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert attachment.mediaType == "image/png"
+ assert [%{mediaType: "image/png"}] = attachment.url
+ end
+
+ test "prefers mediaType over mimeType when both are present" do
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "image/gif",
+ "mimeType" => "image/png",
+ "url" => "https://example.com/x.gif"
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert attachment.mediaType == "image/gif"
+ assert [%{mediaType: "image/gif"}] = attachment.url
+ end
+
+ test "sniffs when the url entry's mediaType is application/octet-stream and the body is an image" do
+ # The outer attachment mediaType is not sniffed (no top-level href); only
+ # the url entry is. We assert only on the url entry.
+ url = "https://example.com/media/mime-type-octet"
+ jpeg = File.read!("test/fixtures/image.jpg")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: jpeg}
+ end)
+
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "application/octet-stream",
+ "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [%{mediaType: "image/jpeg"}] = attachment.url
+ end
+
+ test "sniffs when mediaType is the empty string and the body is an image" do
+ url = "https://example.com/media/empty-mediatype"
+ png = File.read!("test/fixtures/image.png")
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 200, body: png}
+ end)
+
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "",
+ "url" => [%{"type" => "Link", "href" => url, "mediaType" => ""}]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [%{mediaType: "image/png"}] = attachment.url
+ end
+
+ test "falls back to the declared type when the sniffer HTTP request fails" do
+ url = "https://example.com/media/sniff-404"
+
+ Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
+ %Tesla.Env{status: 404, body: ""}
+ end)
+
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "application/octet-stream",
+ "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [%{mediaType: "application/octet-stream"}] = attachment.url
+ end
+
+ test "sniffs each url entry independently" do
+ jpeg_url = "https://example.com/media/a"
+ octet_url = "https://example.com/media/b"
+ jpeg = File.read!("test/fixtures/image.jpg")
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^jpeg_url} ->
+ %Tesla.Env{status: 200, body: jpeg}
+
+ %{method: :get, url: ^octet_url} ->
+ %Tesla.Env{status: 200, body: "definitely not an image"}
+ end)
+
+ attachment = %{
+ "type" => "Document",
+ "mediaType" => "application/octet-stream",
+ "url" => [
+ %{"type" => "Link", "href" => jpeg_url, "mediaType" => "application/octet-stream"},
+ %{"type" => "Link", "href" => octet_url, "mediaType" => "application/octet-stream"}
+ ]
+ }
+
+ {:ok, attachment} =
+ attachment
+ |> AttachmentValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert)
+
+ assert [
+ %{href: ^jpeg_url, mediaType: "image/jpeg"},
+ %{href: ^octet_url, mediaType: "application/octet-stream"}
+ ] = attachment.url
+ end
end
end

File Metadata

Mime Type
text/x-diff
Expires
Sat, Sep 19, 7:39 AM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1769119
Default Alt Text
(62 KB)

Event Timeline