Add the content type for external URLs

This commit is contained in:
Oliver Günther
2026-05-29 10:25:20 +02:00
parent 6f63faeed1
commit 5330745e69
4 changed files with 42 additions and 24 deletions
+19 -4
View File
@@ -93,7 +93,9 @@ class Attachment < ApplicationRecord
# specifically when using S3 for attachments. In the case of S3 the file name for the downloaded # specifically when using S3 for attachments. In the case of S3 the file name for the downloaded
# file will still be correct as it's part of the URL before the query. # file will still be correct as it's part of the URL before the query.
def external_url_options(expires_in: nil) def external_url_options(expires_in: nil)
{ content_disposition: content_disposition(include_filename: false), expires_in: } { content_disposition: content_disposition(include_filename: false),
content_type: served_content_type,
expires_in: }
end end
def external_storage? def external_storage?
@@ -119,11 +121,24 @@ class Attachment < ApplicationRecord
end end
end end
# Returns the Content-Type to use when serving this file inline in a browser.
# Text files are normalised to text/plain (prevents script execution) with an
# explicit charset. Non-inlineable files get application/octet-stream so the
# browser is forced to download them.
def served_content_type
if is_text?
"text/plain; charset=#{charset.presence || Setting.attachment_default_charset}"
elsif inlineable?
content_type
else
"application/octet-stream"
end
end
# Returns the content type to use when serving the file to a browser. # Returns the content type to use when serving the file to a browser.
# For text files, ensures a charset is always present so browsers don't # For text files, ensures a charset is always present so browsers don't
# fall back to ISO-8859-1. # fall back to ISO-8859-1. Preserves the real MIME subtype (e.g. text/x-ruby)
# We use a configurable fallback (default utf-8) so that administrators # unlike served_content_type which normalises to text/plain for security.
# can control content types for previously uploaded attachments
def serving_content_type def serving_content_type
return content_type unless is_text? return content_type unless is_text?
+13 -9
View File
@@ -74,6 +74,7 @@ class FogFileUploader < CarrierWave::Uploader::Base
# #
# @param options [Hash] Options hash. # @param options [Hash] Options hash.
# @option options [String] :content_disposition Pass this content disposition to S3 so that it serves the file with it. # @option options [String] :content_disposition Pass this content disposition to S3 so that it serves the file with it.
# @option options [String] :content_type Pass this content type to S3 so that it serves the file with it.
# @option options [DateTime] :expires_at Date at which the link should expire (default: now + 5 minutes) # @option options [DateTime] :expires_at Date at which the link should expire (default: now + 5 minutes)
# @option options [ActiveSupport::Duration] :expires_in Duration in which the link should expire. # @option options [ActiveSupport::Duration] :expires_in Duration in which the link should expire.
# #
@@ -82,6 +83,7 @@ class FogFileUploader < CarrierWave::Uploader::Base
url_options = {} url_options = {}
set_content_disposition!(url_options, options:) set_content_disposition!(url_options, options:)
set_content_type!(url_options, options:)
set_expires_at!(url_options, options:) set_expires_at!(url_options, options:)
remote_file.url url_options remote_file.url url_options
@@ -103,15 +105,17 @@ class FogFileUploader < CarrierWave::Uploader::Base
private private
def set_content_disposition!(url_options, options:) def set_content_disposition!(url_options, options:)
if options[:content_disposition].present? return if options[:content_disposition].blank?
url_options[:query] = {
# Passing this option to S3 will make it serve the file with the (url_options[:query] ||= {})["response-content-disposition"] = options[:content_disposition]
# respective content disposition. Without it no content disposition end
# header is sent. This only works for S3 but we don't support
# anything else anyway (see carrierwave.rb). def set_content_type!(url_options, options:)
"response-content-disposition" => options[:content_disposition] return if options[:content_type].blank?
}
end # Like the content disposition above, this makes S3 serve the file with the
# given Content-Type, overriding the stored object type.
(url_options[:query] ||= {})["response-content-type"] = options[:content_type]
end end
def set_expires_at!(url_options, options:) def set_expires_at!(url_options, options:)
+1 -11
View File
@@ -100,17 +100,7 @@ module API
end end
def attachment_content_type(attachment) def attachment_content_type(attachment)
if attachment.is_text? attachment.served_content_type
# Even if the text mime type might differ, always output plain text
# so this doesn't get interpreted as e.g., a script or html file
charset = attachment.charset.presence || Setting.attachment_default_charset
"text/plain; charset=#{charset}"
elsif attachment.inlineable?
attachment.content_type
else
# For security reasons, mark all non-inlinable files as an octet-stream first
"application/octet-stream"
end
end end
def set_cache_headers def set_cache_headers
+9
View File
@@ -320,6 +320,11 @@ RSpec.describe Attachment do
it_behaves_like "it uses content disposition inline" do it_behaves_like "it uses content disposition inline" do
let(:attachment) { text_attachment } let(:attachment) { text_attachment }
end end
it "includes response-content-type with text/plain and the detected charset in the S3 URL" do
url = text_attachment.external_url.to_s
expect(url).to include "response-content-type=text%2Fplain%3B%20charset%3D"
end
end end
describe "for a video file" do describe "for a video file" do
@@ -351,6 +356,10 @@ RSpec.describe Attachment do
expect(binary_attachment.content_disposition).to eq "attachment; filename=textfile.txt.gz" expect(binary_attachment.content_disposition).to eq "attachment; filename=textfile.txt.gz"
expect(binary_attachment.external_url.to_s).to include "response-content-disposition=attachment" expect(binary_attachment.external_url.to_s).to include "response-content-disposition=attachment"
end end
it "includes response-content-type application/octet-stream in the S3 URL" do
expect(binary_attachment.external_url.to_s).to include "response-content-type=application%2Foctet-stream"
end
end end
end end