515 行
16 KiB
Ruby
515 行
16 KiB
Ruby
class Post < ApplicationRecord
|
|
require 'date'
|
|
require 'mini_magick'
|
|
require 'nokogiri'
|
|
require 'stringio'
|
|
require 'timeout'
|
|
|
|
class RemoteThumbnailFetchFailed < StandardError; end
|
|
|
|
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
|
|
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
|
|
'オリジナルの作成日時は分単位で入力してください.'.freeze
|
|
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
|
|
THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
|
|
def self.resized_thumbnail_attachment(upload, content_type: nil)
|
|
upload.rewind
|
|
bytes = upload.read
|
|
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
|
|
image = image_for_thumbnail_upload(bytes, content_type:)
|
|
image.resize '180x180'
|
|
image.format 'jpg'
|
|
image.to_blob
|
|
end
|
|
|
|
{ io: StringIO.new(blob),
|
|
filename: 'resized_thumbnail.jpg',
|
|
content_type: 'image/jpeg' }
|
|
rescue Timeout::Error
|
|
raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました.'
|
|
ensure
|
|
upload.rewind
|
|
end
|
|
|
|
def self.remote_thumbnail_attachment(raw_url)
|
|
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
|
|
resized_thumbnail_attachment(
|
|
StringIO.new(response.body),
|
|
content_type: response.content_type)
|
|
rescue Preview::UrlSafety::UnsafeUrl,
|
|
Preview::ThumbnailFetcher::GenerationFailed,
|
|
Preview::HttpFetcher::FetchFailed,
|
|
Preview::HttpFetcher::FetchTimeout,
|
|
Preview::HttpFetcher::ResponseTooLarge,
|
|
Timeout::Error,
|
|
MiniMagick::Error => e
|
|
raise RemoteThumbnailFetchFailed, e.message
|
|
end
|
|
|
|
belongs_to :uploaded_user, class_name: 'User', optional: true
|
|
|
|
has_many :post_tags, dependent: :destroy, inverse_of: :post
|
|
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
|
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
|
has_many :tags, through: :active_post_tags
|
|
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
|
through: :active_post_tags, source: :tag
|
|
|
|
has_many :user_post_views, dependent: :delete_all
|
|
has_many :post_similarities, dependent: :delete_all
|
|
has_many :post_versions
|
|
has_many :gekanator_guessed_games,
|
|
class_name: 'GekanatorGame',
|
|
foreign_key: :guessed_post_id,
|
|
dependent: :delete_all,
|
|
inverse_of: :guessed_post
|
|
has_many :gekanator_correct_games,
|
|
class_name: 'GekanatorGame',
|
|
foreign_key: :correct_post_id,
|
|
dependent: :delete_all,
|
|
inverse_of: :correct_post
|
|
has_many :gekanator_question_examples, dependent: :delete_all
|
|
|
|
has_many :parent_post_implications,
|
|
class_name: 'PostImplication',
|
|
foreign_key: :post_id,
|
|
dependent: :destroy,
|
|
inverse_of: :post
|
|
has_many :parents, through: :parent_post_implications, source: :parent_post
|
|
|
|
has_many :child_post_implications,
|
|
class_name: 'PostImplication',
|
|
foreign_key: :parent_post_id,
|
|
dependent: :destroy,
|
|
inverse_of: :parent_post
|
|
has_many :children, through: :child_post_implications, source: :post
|
|
|
|
has_one_attached :thumbnail
|
|
|
|
attribute :version_no, :integer, default: 1
|
|
|
|
before_validation :normalise_url, if: :will_save_change_to_url?
|
|
|
|
validates :url, presence: true, uniqueness: true, length: { maximum: 768 }
|
|
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
|
|
validate :validate_original_created_range
|
|
validate :url_must_be_http_url
|
|
|
|
def parent_posts = parents
|
|
|
|
def child_posts = children
|
|
|
|
def sibling_posts
|
|
parent_post_ids = parent_posts.order(:id).pluck(:id)
|
|
|
|
parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] }
|
|
end
|
|
|
|
def as_json options = { }
|
|
super(options).merge(thumbnail: thumbnail.attached? ?
|
|
Rails.application.routes.url_helpers.rails_blob_url(
|
|
thumbnail, only_path: false) :
|
|
nil)
|
|
rescue
|
|
super(options).merge(thumbnail: nil)
|
|
end
|
|
|
|
def snapshot_tag_names
|
|
post_tags
|
|
.kept
|
|
.joins(tag: :tag_name)
|
|
.includes(:sections, tag: :tag_name)
|
|
.order('tag_names.name')
|
|
.map do |post_tag|
|
|
name = post_tag.tag.tag_name.name
|
|
sections = post_tag.sections.sort_by(&:begin_ms)
|
|
|
|
next name if sections.empty?
|
|
|
|
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
|
|
end
|
|
end
|
|
|
|
def self.section_literal section
|
|
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
|
|
end
|
|
|
|
def self.ms_to_time ms
|
|
total_s = ms / 1_000
|
|
s = total_s % 60
|
|
min = (total_s / 60) % 60
|
|
h = total_s / 3_600
|
|
remainder_ms = ms % 1_000
|
|
|
|
base =
|
|
if h.positive?
|
|
'%d:%02d:%02d' % [h, min, s]
|
|
else
|
|
'%d:%02d' % [min, s]
|
|
end
|
|
|
|
if remainder_ms.positive?
|
|
"#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }"
|
|
else
|
|
base
|
|
end
|
|
end
|
|
|
|
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
|
|
|
|
def related limit: nil
|
|
ids = post_similarities.order(cos: :desc)
|
|
ids = ids.limit(limit) if limit
|
|
ids = ids.pluck(:target_post_id)
|
|
return Post.none if ids.empty?
|
|
|
|
Post.where(id: ids)
|
|
.with_attached_thumbnail
|
|
.order(Arel.sql("FIELD(posts.id, #{ ids.join(',') })"))
|
|
end
|
|
|
|
def resized_thumbnail!
|
|
return unless thumbnail.attached?
|
|
|
|
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
|
|
end
|
|
|
|
def attach_thumbnail_from_url! raw_url
|
|
thumbnail.attach(self.class.remote_thumbnail_attachment(raw_url))
|
|
end
|
|
|
|
private
|
|
|
|
def validate_original_created_range
|
|
return if skip_original_created_validation?
|
|
|
|
f = parse_original_created_value(:original_created_from)
|
|
b = parse_original_created_value(:original_created_before)
|
|
return if f.nil? || b.nil?
|
|
|
|
if b <= f
|
|
errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE
|
|
return
|
|
end
|
|
|
|
if b - f < 1.minute
|
|
errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE
|
|
end
|
|
end
|
|
|
|
def url_must_be_http_url
|
|
begin
|
|
u = URI.parse(url)
|
|
rescue URI::InvalidURIError
|
|
errors.add(:url, 'URL が不正です.')
|
|
return
|
|
end
|
|
|
|
if !(u in URI::HTTP) || u.host.blank?
|
|
errors.add(:url, 'URL が不正です.')
|
|
return
|
|
end
|
|
end
|
|
|
|
def normalise_url
|
|
return if url.blank?
|
|
|
|
self.url = PostUrlNormaliser.normalise(url) || url.strip
|
|
end
|
|
|
|
def self.image_for_thumbnail_upload(bytes, content_type: nil)
|
|
if svg_content_type?(content_type) || svg_document_bytes?(bytes)
|
|
return decode_svg_thumbnail(bytes)
|
|
end
|
|
|
|
raise MiniMagick::Error, 'サムネイル画像の形式が不正です.' unless raster_thumbnail_bytes?(bytes)
|
|
|
|
decode_raster_thumbnail(bytes)
|
|
end
|
|
|
|
def self.raster_thumbnail_bytes?(bytes)
|
|
raster_thumbnail_format(bytes).present?
|
|
end
|
|
|
|
def self.remote_thumbnail_image_bytes?(bytes, content_type: nil)
|
|
return true if svg_content_type?(content_type) || svg_document_bytes?(bytes)
|
|
|
|
raster_thumbnail_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.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)
|
|
MiniMagick::Image.read(bytes)
|
|
end
|
|
|
|
def self.decode_svg_thumbnail(bytes)
|
|
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
|
|
end
|
|
|
|
def self.raster_thumbnail_format(bytes)
|
|
binary = bytes.to_s.b
|
|
return 'jpeg' if binary.start_with?("\xFF\xD8\xFF".b)
|
|
return 'png' if binary.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
return 'gif' if binary.start_with?('GIF87a'.b) || binary.start_with?('GIF89a'.b)
|
|
return 'webp' if binary.bytesize >= 12 &&
|
|
binary.start_with?('RIFF'.b) &&
|
|
binary.byteslice(8, 4) == 'WEBP'
|
|
|
|
nil
|
|
end
|
|
|
|
def self.sanitised_svg_bytes(bytes)
|
|
parse_options =
|
|
Nokogiri::XML::ParseOptions::STRICT |
|
|
Nokogiri::XML::ParseOptions::NONET
|
|
document = Nokogiri::XML(
|
|
bytes,
|
|
nil,
|
|
nil,
|
|
parse_options)
|
|
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'
|
|
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') ||
|
|
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?('#')
|
|
|
|
true
|
|
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'])
|
|
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?)
|
|
return [nil, nil] unless view_box[2].finite? && view_box[2].positive?
|
|
return [nil, nil] unless view_box[3].finite? && view_box[3].positive?
|
|
|
|
[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
|
|
|
|
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,
|
|
:raster_thumbnail_format,
|
|
: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
|
|
|
|
def parse_original_created_value field
|
|
raw_value = public_send("#{ field }_before_type_cast")
|
|
value = public_send(field)
|
|
return nil if raw_value.blank? && value.blank?
|
|
|
|
time =
|
|
case raw_value
|
|
when String
|
|
parse_original_created_string(raw_value)
|
|
when Time, ActiveSupport::TimeWithZone
|
|
raw_value.in_time_zone
|
|
else
|
|
value&.in_time_zone
|
|
end
|
|
if time.nil?
|
|
errors.add field, ORIGINAL_CREATED_INVALID_MESSAGE
|
|
return nil
|
|
end
|
|
unless minute_precision_time?(time)
|
|
errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
|
|
return nil
|
|
end
|
|
time
|
|
end
|
|
|
|
def parse_original_created_string raw_value
|
|
value = raw_value.to_s.strip
|
|
return nil if value.blank?
|
|
|
|
match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)
|
|
if match
|
|
year = match[1].to_i
|
|
month = match[2].to_i
|
|
day = match[3].to_i
|
|
hour = match[4].to_i
|
|
minute = match[5].to_i
|
|
return nil unless valid_original_created_components?(year, month, day, hour, minute, 0)
|
|
|
|
return Time.zone.local(year, month, day, hour, minute)
|
|
end
|
|
|
|
match =
|
|
value.match(
|
|
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
|
|
return nil if match.nil?
|
|
|
|
year = match[1].to_i
|
|
month = match[2].to_i
|
|
day = match[3].to_i
|
|
hour = match[4].to_i
|
|
minute = match[5].to_i
|
|
second = match[6]&.to_i || 0
|
|
fraction = match[7]
|
|
offset = match[8]
|
|
return nil unless valid_original_created_components?(year, month, day, hour, minute, second)
|
|
return nil if offset.present? && !(valid_original_created_offset?(offset))
|
|
|
|
if offset.present?
|
|
return Time.new(
|
|
year,
|
|
month,
|
|
day,
|
|
hour,
|
|
minute,
|
|
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
|
|
normalise_original_created_offset(offset)).in_time_zone
|
|
end
|
|
|
|
Time.zone.local(
|
|
year,
|
|
month,
|
|
day,
|
|
hour,
|
|
minute,
|
|
second).change(nsec: parse_original_created_nanoseconds(fraction))
|
|
rescue ArgumentError, TypeError
|
|
nil
|
|
end
|
|
|
|
def minute_precision_time? value
|
|
value.sec.zero? && value.nsec.zero?
|
|
end
|
|
|
|
def valid_original_created_components? year, month, day, hour, minute, second
|
|
return false unless Date.valid_date?(year, month, day)
|
|
return false unless hour.between?(0, 23)
|
|
return false unless minute.between?(0, 59)
|
|
return false unless second.between?(0, 59)
|
|
|
|
true
|
|
end
|
|
|
|
def valid_original_created_offset? value
|
|
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
|
|
return true if value == 'Z'
|
|
return false if match.nil?
|
|
|
|
hours = match[2].to_i
|
|
minutes = match[3].to_i
|
|
hours.between?(0, 23) && minutes.between?(0, 59)
|
|
end
|
|
|
|
def parse_original_created_nanoseconds value
|
|
return 0 if value.blank?
|
|
|
|
digits = value[0, 9].ljust(9, '0')
|
|
Integer(digits, 10)
|
|
end
|
|
|
|
def normalise_original_created_offset value
|
|
return '+00:00' if value == 'Z'
|
|
|
|
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
|
|
end
|
|
|
|
def skip_original_created_validation?
|
|
return false if new_record?
|
|
return false if will_save_change_to_original_created_from?
|
|
return false if will_save_change_to_original_created_before?
|
|
|
|
true
|
|
end
|
|
end
|