From d5f48101b806f973ded8f5cf3276538b269ec3b4 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 25 Jul 2026 00:06:03 +0400 Subject: [PATCH] fix(media-proxy): safely detect extensionless images --- changelog.d/mediaproxy-content-type.fix | 1 + lib/pleroma/reverse_proxy.ex | 138 +++++++++-- .../attachment_type_sniffer.ex | 59 ----- .../object_validators/attachment_validator.ex | 21 +- .../web/mastodon_api/views/status_view.ex | 1 + .../web/media_proxy/media_proxy_controller.ex | 1 + test/pleroma/reverse_proxy_test.exs | 178 ++++++-------- .../attachment_type_sniffer_test.exs | 178 -------------- .../attachment_validator_test.exs | 221 ------------------ .../mastodon_api/views/status_view_test.exs | 17 ++ .../media_proxy_controller_test.exs | 5 +- 11 files changed, 211 insertions(+), 609 deletions(-) create mode 100644 changelog.d/mediaproxy-content-type.fix delete mode 100644 lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex delete mode 100644 test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs diff --git a/changelog.d/mediaproxy-content-type.fix b/changelog.d/mediaproxy-content-type.fix new file mode 100644 index 000000000..f36dd6283 --- /dev/null +++ b/changelog.d/mediaproxy-content-type.fix @@ -0,0 +1 @@ +Detect image content types for extensionless MediaProxy responses. diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 11f041c57..061b2de9b 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -17,6 +17,7 @@ defmodule Pleroma.ReverseProxy do @max_read_duration :timer.seconds(30) @max_body_length :infinity @failed_request_ttl :timer.seconds(60) + @sniff_bytes 8 * 1024 @methods ~w(GET HEAD) @allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], []) @@ -63,6 +64,9 @@ defmodule Pleroma.ReverseProxy do is always set (overriding any upstream header) so the media can be embedded in pages; the filename is derived from the content type + * `sniff_content_type` (default `false`): detects an image MIME type from the first + response chunk when the upstream type is missing or `application/octet-stream`. + * `req_headers`, `resp_headers` additional headers. * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun). @@ -71,11 +75,15 @@ defmodule Pleroma.ReverseProxy do @default_options [pool: :media] @inline_content_types [ + "image/apng", + "image/avif", + "image/bmp", "image/gif", "image/jpeg", "image/jpg", "image/png", "image/svg+xml", + "image/webp", "audio/mpeg", "audio/mp3", "video/webm", @@ -94,6 +102,7 @@ defmodule Pleroma.ReverseProxy do | {:req_headers, [{String.t(), String.t()}]} | {:resp_headers, [{String.t(), String.t()}]} | {:inline_content_types, boolean() | list(String.t())} + | {:sniff_content_type, boolean()} | {:redirect_on_failure, boolean()} @spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t() @@ -186,12 +195,14 @@ defmodule Pleroma.ReverseProxy do defp response(conn, client, url, status, headers, opts) do Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}") + {headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts) + result = conn |> put_resp_headers(build_resp_headers(headers, opts)) |> streaming_compat |> send_chunked(status) - |> chunk_reply(client, opts) + |> chunk_reply(client, opts, prefetched) case result do {:ok, conn} -> @@ -211,18 +222,28 @@ defmodule Pleroma.ReverseProxy do end end - defp chunk_reply(conn, client, opts) do + defp chunk_reply(conn, client, opts, :none) 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) + defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn} + defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn} + + defp chunk_reply(conn, client, opts, {:ok, data, duration}) do + with :ok <- + body_size_constraint( + byte_size(data), + Keyword.get(opts, :max_body_length, @max_body_length) ), - {:ok, data, client} <- client().stream_body(client), - {:ok, duration} <- increase_read_duration(duration), + {:ok, conn} <- chunk(conn, data) do + chunk_reply(conn, client, opts, byte_size(data), duration) + else + {:error, error} -> {:error, error, conn} + end + end + + defp chunk_reply(conn, client, opts, sent_so_far, duration) do + with {:ok, data, client, duration} <- read_chunk(client, duration, opts), sent_so_far = sent_so_far + byte_size(data), :ok <- body_size_constraint( @@ -237,6 +258,66 @@ defmodule Pleroma.ReverseProxy do end end + defp read_chunk(client, duration, opts) do + with {:ok, timer} <- + 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(timer) do + {:ok, data, client, duration} + end + end + + defp maybe_prefetch_for_content_type(headers, client, opts) do + if Keyword.get(opts, :sniff_content_type, false) and generic_content_type?(headers) do + case read_chunk(client, 0, opts) do + {:ok, data, client, duration} -> + {maybe_put_image_content_type(headers, data), client, {:ok, data, duration}} + + :done -> + {headers, client, :done} + + {:error, error} -> + {headers, client, {:error, error}} + end + else + {headers, client, :none} + end + end + + defp generic_content_type?(headers) do + headers + |> get_content_type() + |> String.trim() + |> String.downcase() + |> then(&(&1 in ["", "application/octet-stream"])) + end + + defp maybe_put_image_content_type(headers, data) do + with false <- data == "", + {:ok, %{mime_type: "image/" <> _ = content_type}} <- + Majic.perform({:bytes, binary_part(data, 0, min(byte_size(data), @sniff_bytes))}, + pool: Pleroma.MajicPool + ) do + [ + {"content-type", content_type} + | Enum.reject(headers, fn {key, _} -> key == "content-type" end) + ] + else + _ -> headers + end + rescue + error -> + Logger.debug("#{__MODULE__}: content-type sniffing failed: #{Exception.message(error)}") + headers + catch + kind, reason -> + Logger.debug("#{__MODULE__}: content-type sniffing failed: #{kind}: #{inspect(reason)}") + headers + end + defp head_response(conn, url, code, headers, opts) do Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}") @@ -283,17 +364,34 @@ defmodule Pleroma.ReverseProxy do |> 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, [])) + |> merge_headers(Keyword.get(opts, :req_headers, [])) + |> maybe_force_identity_encoding(opts) + end + + defp merge_headers(headers, extra_headers) do + Enum.reduce(extra_headers, headers, fn {key, value}, headers -> + List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value}) + end) + end + + defp maybe_force_identity_encoding(headers, opts) do + if Keyword.get(opts, :sniff_content_type, false) do + List.keystore(headers, "accept-encoding", 0, {"accept-encoding", "identity"}) + else + headers + end 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 + cond do + range? && List.keymember?(headers, "accept-encoding", 0) -> + List.keydelete(headers, "accept-encoding", 0) + + true -> + headers end end @@ -386,7 +484,7 @@ defmodule Pleroma.ReverseProxy do disposition = "attachment; filename=\"#{name}\"" - List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) + replace_header(headers, "content-disposition", disposition) else if opt == true do headers @@ -400,11 +498,15 @@ defmodule Pleroma.ReverseProxy do "inline" end - List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) + replace_header(headers, "content-disposition", disposition) end end end + defp replace_header(headers, key, value) do + [{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)] + end + defp inline_filename(content_type) do case MIME.extensions(content_type) do [ext | _] when ext != "" -> "inline.#{ext}" @@ -443,7 +545,9 @@ defmodule Pleroma.ReverseProxy do end end - defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit} + defp check_read_duration(_, _), do: {:ok, :no_duration_limit} + + defp increase_read_duration(:no_duration_limit), do: {:ok, :no_duration_limit} defp increase_read_duration({previous_duration, started}) when is_integer(previous_duration) and is_integer(started) do 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 deleted file mode 100644 index 19215b627..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex +++ /dev/null @@ -1,59 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# 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 b6e30a438..5ee9e7549 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer import Ecto.Changeset @@ -62,27 +61,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do end def fix_media_type(data) do - 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) + Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream") 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 [ %{ diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 6949c5f2d..725dc2ce7 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -620,6 +620,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do String.contains?(media_type, "image") -> "image" String.contains?(media_type, "video") -> "video" String.contains?(media_type, "audio") -> "audio" + attachment["type"] == "Image" -> "image" true -> "unknown" end diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index a0aafc32e..93f692008 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -208,6 +208,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do defp media_proxy_opts do Config.get([:media_proxy, :proxy_opts], []) + |> Keyword.put_new(:sniff_content_type, true) end defp sandbox(conn, _params) do diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 5f75f2a07..ad1d75f33 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -222,6 +222,56 @@ defmodule Pleroma.ReverseProxyTest do assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] end + test "sniffs and preserves an image body with a generic content type", %{conn: conn} do + body = File.read!("test/fixtures/image.jpg") + + ClientMock + |> expect(:request, fn :get, "/extensionless", headers, _, _ -> + assert {"accept-encoding", "identity"} in headers + {:ok, 200, [{"content-type", "application/octet-stream"}], %{body: body}} + end) + |> expect(:stream_body, fn %{body: ^body} = client -> + {:ok, body, Map.delete(client, :body)} + end) + |> expect(:stream_body, fn %{} -> :done end) + + conn = + ReverseProxy.call(conn, "/extensionless", + sniff_content_type: true, + max_read_duration: :infinity, + req_headers: [{"accept-encoding", "gzip"}] + ) + + assert conn.resp_body == body + assert Conn.get_resp_header(conn, "content-type") == ["image/jpeg"] + + assert Conn.get_resp_header(conn, "content-disposition") == [ + "inline; filename=\"inline.jpg\"" + ] + end + + test "preserves a generic content type for a non-image body", %{conn: conn} do + body = "not an image" + + ClientMock + |> expect(:request, fn :get, "/extensionless", _, _, _ -> + {:ok, 200, [], %{body: body}} + end) + |> expect(:stream_body, fn %{body: ^body} = client -> + {:ok, body, Map.delete(client, :body)} + end) + |> expect(:stream_body, fn %{} -> :done end) + + conn = ReverseProxy.call(conn, "/extensionless", sniff_content_type: true) + + assert conn.resp_body == body + assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] + + assert Conn.get_resp_header(conn, "content-disposition") == [ + "attachment; filename=\"extensionless\"" + ] + end + defp headers_mock(_) do ClientMock |> expect(:request, fn :get, "/headers", headers, _, _ -> @@ -298,21 +348,21 @@ defmodule Pleroma.ReverseProxyTest do end end - defp disposition_headers_mock(headers, url \\ "/disposition") do + defp disposition_headers_mock(headers) do ClientMock - |> expect(:request, fn :get, ^url, _, _, _ -> - Registry.register(ClientMock, url, 0) + |> expect(:request, fn :get, "/disposition", _, _, _ -> + Registry.register(ClientMock, "/disposition", 0) - {:ok, 200, headers, %{url: url}} + {:ok, 200, headers, %{url: "/disposition"}} end) - |> expect(:stream_body, 2, fn %{url: ^url} = client -> - case Registry.lookup(ClientMock, url) do + |> expect(:stream_body, 2, fn %{url: "/disposition"} = client -> + case Registry.lookup(ClientMock, "/disposition") do [{_, 0}] -> - Registry.update_value(ClientMock, url, &(&1 + 1)) + Registry.update_value(ClientMock, "/disposition", &(&1 + 1)) {:ok, "", client} [{_, 1}] -> - Registry.unregister(ClientMock, url) + Registry.unregister(ClientMock, "/disposition") :done end end) @@ -337,6 +387,7 @@ defmodule Pleroma.ReverseProxyTest do disposition_headers_mock([ {"content-type", "image/png"}, {"content-disposition", "attachment; filename=\"filename.png\""}, + {"content-disposition", "attachment; filename=\"duplicate.png\""}, {"content-length", "0"} ]) @@ -347,23 +398,6 @@ defmodule Pleroma.ReverseProxyTest do 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\""}, @@ -390,69 +424,6 @@ defmodule Pleroma.ReverseProxyTest do 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 @@ -469,36 +440,17 @@ defmodule Pleroma.ReverseProxyTest do 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. + test "serves modern browser image types inline", %{conn: conn} do disposition_headers_mock([ - {"content-type", "image/bmp"}, - {"content-disposition", "attachment; filename=\"upstream.bmp\""}, + {"content-type", "image/webp"}, {"content-length", "0"} ]) - conn = - ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"]) + conn = ReverseProxy.call(conn, "/disposition") - [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 + assert Conn.get_resp_header(conn, "content-disposition") == [ + "inline; filename=\"inline.webp\"" + ] 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 deleted file mode 100644 index 52b1b67a1..000000000 --- a/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs +++ /dev/null @@ -1,178 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# 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 870ad90cc..744ae8704 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 @@ -215,226 +215,5 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do 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 diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 76123cd0f..faeca050e 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -668,6 +668,23 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do assert expected == StatusView.render("attachment.json", %{attachment: object}) assert_schema(expected, "Attachment", api_spec) end + + test "uses the ActivityStreams image type when the MIME type is generic" do + object = %{ + "type" => "Image", + "url" => [ + %{ + "mediaType" => "application/octet-stream", + "href" => "https://example.com/extensionless" + } + ] + } + + attachment = StatusView.render("attachment.json", %{attachment: object}) + + assert attachment.type == "image" + assert attachment.pleroma.mime_type == "application/octet-stream" + end end test "put the url advertised in the Activity in to the url attribute" do diff --git a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs index f7e52483c..800159c21 100644 --- a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs +++ b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs @@ -71,7 +71,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do with_mock Pleroma.ReverseProxy, - call: fn _conn, _url, _opts -> %Conn{status: :success} end do + call: fn _conn, _url, opts -> + assert opts[:sniff_content_type] + %Conn{status: :success} + end do assert %Conn{status: :success} = get(conn, url) end end