MediaProxy: improve inline rendering of remote image attachments
Two related changes that ensure remote images render inline in timelines instead of as clickable links. reverse_proxy: always force inline for whitelisted content types When `inline_content_types` is configured as a whitelist, the upstream's `content-disposition` header used to be passed through unchanged. Some origins send `content-disposition: attachment` for media we want embedded, so the proxy now always emits `content-disposition: inline` for whitelisted types (overriding the upstream header), deriving a filename from `MIME.extensions/1` (e.g. `inline.gif`) so browsers have something to use for "Save As". When the type has no known extension, the bare token `inline` is emitted. The `inline_content_types: true` (pass-through) and `:false` (always attachment) behaviours are unchanged. ActivityPub: sniff MIME type for extensionless/generic attachments Some remotes (e.g. extensionless Cloudflare Images URLs) ship attachments with no usable `mediaType` (missing, empty, or `application/octet-stream`), which would render as links instead of inline images. `AttachmentValidator.fix_media_type/1` now fetches the first 8 KB of the URL and runs it through libmagic when the declared type carries no real information. Only `image/*` results replace the declared type; any other result, non-2xx response, transport error, or exception yields `nil` and the original type is kept, so ingestion never blocks and non-image media is never reclassified. Present real types are left untouched (no fetch is attempted). The new logic lives in `Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer`. Tests: comprehensive coverage of both changes, including a dedicated suite for the sniffer covering happy paths, non-image bodies, HTTP failures, raised exceptions, guard clauses, and verification that the Range header is sent; AttachmentValidator tests for mimeType fallback, empty-string mediaType, multi-URL sniffing, and HTTP-failure fallback; ReverseProxy tests for inline_content_types `true`/`false`/custom whitelists, bare-inline for types without a MIME extension, and attachment_name handling.
This commit is contained in:
parent
b0ae451942
commit
b11e8888d1
6 changed files with 670 additions and 11 deletions
|
|
@ -59,7 +59,9 @@ defmodule Pleroma.ReverseProxy do
|
||||||
* `inline_content_types`:
|
* `inline_content_types`:
|
||||||
* `true` will not alter `content-disposition` (up to the upstream),
|
* `true` will not alter `content-disposition` (up to the upstream),
|
||||||
* `false` will add `content-disposition: attachment` to any request,
|
* `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.
|
* `req_headers`, `resp_headers` additional headers.
|
||||||
|
|
||||||
|
|
@ -386,7 +388,27 @@ defmodule Pleroma.ReverseProxy do
|
||||||
|
|
||||||
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
|
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
|
|
||||||
alias Pleroma.EctoType.ActivityPub.ObjectValidators
|
alias Pleroma.EctoType.ActivityPub.ObjectValidators
|
||||||
|
alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
|
@ -61,9 +62,27 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
|
||||||
end
|
end
|
||||||
|
|
||||||
def fix_media_type(data) do
|
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
|
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
|
defp handle_href(href, mediaType, data) do
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
|
|
|
||||||
|
|
@ -298,21 +298,21 @@ defmodule Pleroma.ReverseProxyTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp disposition_headers_mock(headers) do
|
defp disposition_headers_mock(headers, url \\ "/disposition") do
|
||||||
ClientMock
|
ClientMock
|
||||||
|> expect(:request, fn :get, "/disposition", _, _, _ ->
|
|> expect(:request, fn :get, ^url, _, _, _ ->
|
||||||
Registry.register(ClientMock, "/disposition", 0)
|
Registry.register(ClientMock, url, 0)
|
||||||
|
|
||||||
{:ok, 200, headers, %{url: "/disposition"}}
|
{:ok, 200, headers, %{url: url}}
|
||||||
end)
|
end)
|
||||||
|> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
|
|> expect(:stream_body, 2, fn %{url: ^url} = client ->
|
||||||
case Registry.lookup(ClientMock, "/disposition") do
|
case Registry.lookup(ClientMock, url) do
|
||||||
[{_, 0}] ->
|
[{_, 0}] ->
|
||||||
Registry.update_value(ClientMock, "/disposition", &(&1 + 1))
|
Registry.update_value(ClientMock, url, &(&1 + 1))
|
||||||
{:ok, "", client}
|
{:ok, "", client}
|
||||||
|
|
||||||
[{_, 1}] ->
|
[{_, 1}] ->
|
||||||
Registry.unregister(ClientMock, "/disposition")
|
Registry.unregister(ClientMock, url)
|
||||||
:done
|
:done
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
@ -328,6 +328,40 @@ defmodule Pleroma.ReverseProxyTest do
|
||||||
conn = ReverseProxy.call(conn, "/disposition")
|
conn = ReverseProxy.call(conn, "/disposition")
|
||||||
|
|
||||||
assert {"content-type", "image/gif"} in conn.resp_headers
|
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
|
end
|
||||||
|
|
||||||
test "with content-disposition header", %{conn: conn} do
|
test "with content-disposition header", %{conn: conn} do
|
||||||
|
|
@ -340,6 +374,132 @@ defmodule Pleroma.ReverseProxyTest do
|
||||||
|
|
||||||
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
|
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "content-type sanitisation" do
|
describe "content-type sanitisation" do
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -215,5 +215,226 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do
|
||||||
AttachmentValidator.cast_and_validate(attachment)
|
AttachmentValidator.cast_and_validate(attachment)
|
||||||
|> Ecto.Changeset.apply_action(:insert)
|
|> Ecto.Changeset.apply_action(:insert)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue