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`:
|
||||
* `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.
|
||||
|
||||
|
|
@ -386,7 +388,27 @@ defmodule Pleroma.ReverseProxy do
|
|||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
alias Pleroma.EctoType.ActivityPub.ObjectValidators
|
||||
alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
|
|
@ -61,9 +62,27 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
|
|||
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
|
||||
[
|
||||
%{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue