class Post < ApplicationRecord require 'date' require 'mini_magick' require 'nokogiri' require 'stringio' 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 def self.resized_thumbnail_attachment(upload, content_type: nil) upload.rewind image = image_for_thumbnail_upload(upload.read, content_type:) image.resize '180x180' image.format 'jpg' { io: StringIO.new(image.to_blob), filename: 'resized_thumbnail.jpg', content_type: 'image/jpeg' } 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, 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) 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) 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' 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) 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