このコミットが含まれているのは:
2026-07-18 01:28:22 +09:00
コミット 09ac2576bb
8個のファイルの変更196行の追加35行の削除
+91 -3
ファイルの表示
@@ -1,6 +1,7 @@
class Post < ApplicationRecord
require 'date'
require 'mini_magick'
require 'nokogiri'
require 'stringio'
class RemoteThumbnailFetchFailed < StandardError; end
@@ -11,10 +12,13 @@ class Post < ApplicationRecord
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
MAX_SVG_DIMENSION = 4_096
MAX_SVG_PIXELS = 16_777_216
def self.resized_thumbnail_attachment(upload)
def self.resized_thumbnail_attachment(upload, content_type: nil)
upload.rewind
image = MiniMagick::Image.read(upload.read)
image = image_for_thumbnail_upload(upload.read, content_type:)
image.resize '180x180'
image.format 'jpg'
@@ -27,7 +31,9 @@ class Post < ApplicationRecord
def self.remote_thumbnail_attachment(raw_url)
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
resized_thumbnail_attachment(StringIO.new(response.body))
resized_thumbnail_attachment(
StringIO.new(response.body),
content_type: response.content_type)
rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed,
@@ -209,6 +215,88 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip
end
def self.image_for_thumbnail_upload(bytes, content_type: nil)
return MiniMagick::Image.read(bytes) unless svg_content_type?(content_type)
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
end
def self.svg_content_type?(content_type)
content_type.to_s.split(';', 2).first.to_s.downcase.strip == REMOTE_SVG_CONTENT_TYPE
end
def self.sanitised_svg_bytes(bytes)
document = Nokogiri::XML(
bytes,
nil,
nil,
Nokogiri::XML::ParseOptions::STRICT
| Nokogiri::XML::ParseOptions::NONET)
root = document.root
raise MiniMagick::Error, 'SVG が不正です.' if root == nil || root.name != 'svg'
raise MiniMagick::Error, 'SVG が不正です.' if document.internal_subset != nil
raise MiniMagick::Error, 'SVG が不正です.' if svg_uses_disallowed_features?(document)
width, height = svg_dimensions(root)
raise MiniMagick::Error, 'SVG が大きすぎます.' if width == nil || height == nil
if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS
raise MiniMagick::Error, 'SVG が大きすぎます.'
end
document.to_xml
rescue Nokogiri::XML::SyntaxError
raise MiniMagick::Error, 'SVG が不正です.'
end
def self.svg_uses_disallowed_features?(document)
document.traverse.any? do |node|
next false unless node.element?
name = node.name.to_s.downcase
next true if name == 'script' || name == 'foreignobject'
node.attribute_nodes.any? do |attribute|
attribute_name = attribute.name.to_s.downcase
attribute_value = attribute.value.to_s
attribute_name.start_with?('on') ||
attribute_name == 'href' ||
attribute_name == 'xlink:href' ||
attribute_name == 'src' ||
(attribute_name == 'style' && attribute_value.match?(/url\s*\(/i)) ||
attribute_value.match?(/\A\s*(?:https?:|data:|\/\/)/i)
end
end
end
def self.svg_dimensions(root)
width = svg_length_to_pixels(root['width'])
height = svg_length_to_pixels(root['height'])
return [width, height] if width && height
view_box = root['viewBox'].to_s.strip.split(/\s+/).map { Float(_1) rescue nil }
return [nil, nil] if view_box.length != 4 || view_box.any?(&:nil?)
[view_box[2], view_box[3]]
end
def self.svg_length_to_pixels(value)
return nil if value.blank?
matched = /\A([0-9]+(?:\.[0-9]+)?)(px)?\z/i.match(value.to_s.strip)
return nil if matched == nil
Float(matched[1])
rescue ArgumentError
nil
end
private_class_method :image_for_thumbnail_upload,
:svg_content_type?,
:sanitised_svg_bytes,
:svg_uses_disallowed_features?,
:svg_dimensions,
:svg_length_to_pixels
def parse_original_created_value field
raw_value = public_send("#{ field }_before_type_cast")
value = public_send(field)
+4 -2
ファイルの表示
@@ -1,12 +1,14 @@
class PostThumbnailUploadValidator
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::ALLOWED_IMAGE_CONTENT_TYPES.freeze
ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::RASTER_IMAGE_CONTENT_TYPES.freeze
class InvalidUpload < StandardError; end
def self.validate! thumbnail
return if thumbnail.blank?
raise InvalidUpload, 'thumbnail upload が不正です.' unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
raise InvalidUpload, 'thumbnail upload が不正です.'
end
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
+10 -6
ファイルの表示
@@ -1,9 +1,13 @@
module Preview
class ThumbnailFetcher
class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [
RASTER_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze
REMOTE_IMAGE_CONTENT_TYPES = [
*RASTER_IMAGE_CONTENT_TYPES,
'image/svg+xml'
].freeze
HTML_MAX_BYTES = 1.megabyte
NICONICO_XML_MAX_BYTES = 256.kilobytes
@@ -28,7 +32,7 @@ module Preview
def self.fetch_image_response(raw_url)
uri, = UrlSafety.validate(raw_url)
response = HttpFetcher.fetch(uri.to_s)
unless allowed_image_content_type?(response.content_type)
unless allowed_remote_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
@@ -51,7 +55,7 @@ module Preview
return nil if url.blank?
response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type)
return nil unless allowed_remote_image_content_type?(response.content_type)
response.body
rescue HttpFetcher::FetchTimeout
@@ -92,13 +96,13 @@ module Preview
nil
end
def self.allowed_image_content_type?(content_type)
def self.allowed_remote_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type)
REMOTE_IMAGE_CONTENT_TYPES.include?(mime_type)
end
private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url,
:allowed_image_content_type?
:allowed_remote_image_content_type?
end
end