このコミットが含まれているのは:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
新しい課題から参照
ユーザをブロックする