このコミットが含まれているのは:
@@ -120,30 +120,64 @@ class PostsController < ApplicationController
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||
|
||||
preview = PostImportPreviewer.new.preview_rows(
|
||||
rows: [{
|
||||
source_row: 1,
|
||||
url: params[:url].to_s,
|
||||
attributes: { },
|
||||
provenance: { },
|
||||
tag_sources: { } }],
|
||||
fetch_metadata: true).first
|
||||
return render_validation_error fields: preview[:validation_errors] if preview[:validation_errors].present?
|
||||
normal_url = PostUrlNormaliser.normalise(params[:url].to_s)
|
||||
return render_validation_error(fields: { url: ['URL が不正です.'] }) if normal_url.blank?
|
||||
|
||||
Preview::UrlSafety.validate(normal_url)
|
||||
existing_post = Post.with_attached_thumbnail.find_by(url: normal_url)
|
||||
if existing_post.present?
|
||||
return render json: {
|
||||
url: normal_url,
|
||||
title: nil,
|
||||
thumbnail_base: nil,
|
||||
tags: nil,
|
||||
original_created_from: nil,
|
||||
original_created_before: nil,
|
||||
duration: nil,
|
||||
video_ms: nil,
|
||||
field_warnings: { },
|
||||
base_warnings: [],
|
||||
existing_post: compact_post(existing_post.id) }
|
||||
end
|
||||
|
||||
metadata = PostMetadataFetcher.fetch(normal_url)
|
||||
field_warnings = { }
|
||||
field_warnings[:title] = ['タイトルを取得できませんでした.'] if metadata[:title].blank?
|
||||
if metadata[:thumbnail_base].blank?
|
||||
field_warnings[:thumbnail_base] = ['サムネールを取得できませんでした.']
|
||||
end
|
||||
|
||||
render json: {
|
||||
url: preview[:url],
|
||||
title: preview[:attributes]['title'],
|
||||
thumbnail_base: preview[:attributes]['thumbnail_base'],
|
||||
tags: preview[:attributes]['tags'],
|
||||
original_created_from: preview[:attributes]['original_created_from'],
|
||||
original_created_before: preview[:attributes]['original_created_before'],
|
||||
duration: preview[:attributes]['duration'],
|
||||
video_ms: preview[:attributes]['video_ms'],
|
||||
field_warnings: preview[:field_warnings],
|
||||
base_warnings: preview[:base_warnings],
|
||||
existing_post: compact_post(preview[:existing_post_id]) }
|
||||
url: normal_url,
|
||||
title: metadata[:title],
|
||||
thumbnail_base: metadata[:thumbnail_base],
|
||||
tags: metadata[:tags],
|
||||
original_created_from: metadata[:original_created_from],
|
||||
original_created_before: metadata[:original_created_before],
|
||||
duration: metadata[:duration],
|
||||
video_ms: metadata[:video_ms],
|
||||
field_warnings: field_warnings,
|
||||
base_warnings: [],
|
||||
existing_post: nil }
|
||||
rescue ArgumentError => e
|
||||
render_bad_request e.message
|
||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||
render_validation_error fields: { url: [e.message] }
|
||||
rescue Preview::HttpFetcher::FetchFailed,
|
||||
Preview::HttpFetcher::FetchTimeout,
|
||||
Preview::HttpFetcher::ResponseTooLarge
|
||||
render json: {
|
||||
url: normal_url,
|
||||
title: nil,
|
||||
thumbnail_base: nil,
|
||||
tags: nil,
|
||||
original_created_from: nil,
|
||||
original_created_before: nil,
|
||||
duration: nil,
|
||||
video_ms: nil,
|
||||
field_warnings: { url: ['自動取得に失敗しました.'] },
|
||||
base_warnings: [],
|
||||
existing_post: nil }
|
||||
end
|
||||
|
||||
def show
|
||||
|
||||
@@ -3,6 +3,7 @@ class Post < ApplicationRecord
|
||||
require 'mini_magick'
|
||||
require 'nokogiri'
|
||||
require 'stringio'
|
||||
require 'timeout'
|
||||
|
||||
class RemoteThumbnailFetchFailed < StandardError; end
|
||||
|
||||
@@ -15,6 +16,7 @@ class Post < ApplicationRecord
|
||||
REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
|
||||
MAX_SVG_DIMENSION = 4_096
|
||||
MAX_SVG_PIXELS = 16_777_216
|
||||
THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
|
||||
|
||||
def self.resized_thumbnail_attachment(upload, content_type: nil)
|
||||
upload.rewind
|
||||
@@ -216,15 +218,37 @@ class Post < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.image_for_thumbnail_upload(bytes, content_type: nil)
|
||||
return MiniMagick::Image.read(bytes) unless svg_content_type?(content_type)
|
||||
return decode_raster_thumbnail(bytes) unless svg_content_type?(content_type) || svg_document_bytes?(bytes)
|
||||
|
||||
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
|
||||
decode_svg_thumbnail(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.svg_document_bytes?(bytes)
|
||||
document = Nokogiri::XML(
|
||||
bytes,
|
||||
nil,
|
||||
nil,
|
||||
Nokogiri::XML::ParseOptions::STRICT |
|
||||
Nokogiri::XML::ParseOptions::NONET)
|
||||
document.root&.name == 'svg'
|
||||
rescue Nokogiri::XML::SyntaxError
|
||||
false
|
||||
end
|
||||
|
||||
def self.decode_raster_thumbnail(bytes)
|
||||
Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) { MiniMagick::Image.read(bytes) }
|
||||
end
|
||||
|
||||
def self.decode_svg_thumbnail(bytes)
|
||||
Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
|
||||
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
|
||||
end
|
||||
end
|
||||
|
||||
def self.sanitised_svg_bytes(bytes)
|
||||
parse_options =
|
||||
Nokogiri::XML::ParseOptions::STRICT |
|
||||
@@ -256,20 +280,44 @@ class Post < ApplicationRecord
|
||||
|
||||
name = node.name.to_s.downcase
|
||||
next true if name == 'script' || name == 'foreignobject'
|
||||
next style_contains_disallowed_urls?(node.text.to_s) if name == 'style'
|
||||
|
||||
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)
|
||||
external_svg_reference?(attribute_name, attribute_value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.external_svg_reference?(attribute_name, attribute_value)
|
||||
return external_svg_url?(attribute_value) if ['href', 'xlink:href', 'src'].include?(attribute_name)
|
||||
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
|
||||
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def self.external_svg_url?(value)
|
||||
stripped = value.to_s.strip
|
||||
return false if stripped.blank? || stripped.start_with?('#')
|
||||
|
||||
stripped.match?(/\A(?:https?:|data:|\/\/)/i)
|
||||
end
|
||||
|
||||
def self.style_contains_disallowed_urls?(value)
|
||||
text = value.to_s
|
||||
text.match?(/@import/i) || svg_url_function_disallowed?(text)
|
||||
end
|
||||
|
||||
def self.svg_url_function_disallowed?(value)
|
||||
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
|
||||
reference = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"')
|
||||
reference.present? && !(reference.start_with?('#'))
|
||||
end
|
||||
end
|
||||
|
||||
def self.svg_dimensions(root)
|
||||
width = svg_length_to_pixels(root['width'])
|
||||
height = svg_length_to_pixels(root['height'])
|
||||
@@ -287,15 +335,24 @@ class Post < ApplicationRecord
|
||||
matched = /\A([0-9]+(?:\.[0-9]+)?)(px)?\z/i.match(value.to_s.strip)
|
||||
return nil if matched == nil
|
||||
|
||||
Float(matched[1])
|
||||
pixels = Float(matched[1])
|
||||
return nil unless pixels.finite? && pixels.positive?
|
||||
|
||||
pixels
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
private_class_method :image_for_thumbnail_upload,
|
||||
:svg_content_type?,
|
||||
:decode_raster_thumbnail,
|
||||
:decode_svg_thumbnail,
|
||||
:sanitised_svg_bytes,
|
||||
:svg_uses_disallowed_features?,
|
||||
:external_svg_reference?,
|
||||
:external_svg_url?,
|
||||
:style_contains_disallowed_urls?,
|
||||
:svg_url_function_disallowed?,
|
||||
:svg_dimensions,
|
||||
:svg_length_to_pixels
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ module PostCompactRepr
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
post.thumbnail,
|
||||
only_path: false)
|
||||
rescue
|
||||
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,8 +11,10 @@ class PostThumbnailUploadValidator
|
||||
end
|
||||
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
|
||||
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
|
||||
raise InvalidUpload, 'サムネイル画像の形式が不正です.' if Post.svg_document_bytes?(thumbnail.read)
|
||||
thumbnail.rewind
|
||||
|
||||
attachment = Post.resized_thumbnail_attachment(thumbnail)
|
||||
attachment = Post.resized_thumbnail_attachment(thumbnail, content_type: thumbnail.content_type)
|
||||
attachment[:io].close if attachment[:io].respond_to?(:close)
|
||||
rescue MiniMagick::Error
|
||||
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
|
||||
|
||||
@@ -32,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_remote_image_content_type?(response.content_type)
|
||||
unless allowed_remote_image_content_type?(response.content_type) || Post.svg_document_bytes?(response.body)
|
||||
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
|
||||
end
|
||||
|
||||
|
||||
新しい課題から参照
ユーザをブロックする