# frozen_string_literal: true class Post < ApplicationRecord class RevertError < StandardError; end class DeletionError < StandardError; end # Tags to copy when copying notes. NOTE_COPY_TAGS = %w[translated partially_translated check_translation translation_request reverse_translation annotated partially_annotated check_annotation annotation_request] RESTRICTED_TAGS_REGEX = /(?:^| )(?:#{Danbooru.config.restricted_tags.join("|")})(?:$| )/o RATINGS = { g: "General", s: "Sensitive", q: "Questionable", e: "Explicit", }.with_indifferent_access RATING_ALIASES = { safe: ["s"], nsfw: ["q", "e"], sfw: ["g", "s"], }.with_indifferent_access deletable has_bit_flags %w[has_embedded_notes _unused_has_cropped is_taken_down] normalize :source, :normalize_source before_validation :merge_old_changes before_validation :apply_pre_metatags before_validation :normalize_tags before_validation :blank_out_nonexistent_parents before_validation :remove_parent_loops validates :md5, uniqueness: { message: ->(post, _data) { "Duplicate of post ##{Post.find_by_md5(post.md5).id}" }}, on: :create validates :rating, presence: { message: "not selected" } validates :rating, inclusion: { in: RATINGS.keys, message: "must be #{RATINGS.keys.map(&:upcase).to_sentence(last_word_connector: ", or ")}" }, if: -> { rating.present? } validates :source, length: { maximum: 1200 } validate :post_is_not_its_own_parent validate :uploader_is_not_limited, on: :create before_save :parse_pixiv_id before_save :added_tags_are_valid before_save :removed_tags_are_valid before_save :has_artist_tag before_save :has_copyright_tag before_save :has_enough_tags before_save :update_tag_post_counts before_save :update_tag_category_counts before_create :autoban after_save :create_version after_save :update_parent_on_save after_save :apply_post_metatags after_create_commit :update_iqdb belongs_to :approver, class_name: "User", optional: true belongs_to :uploader, :class_name => "User", :counter_cache => "post_upload_count" belongs_to :parent, class_name: "Post", optional: true has_one :media_asset, -> { active }, foreign_key: :md5, primary_key: :md5 has_one :media_metadata, through: :media_asset has_one :artist_commentary, :dependent => :destroy has_one :vote_by_current_user, -> { active.where(user_id: CurrentUser.id) }, class_name: "PostVote" # XXX using current user here is wrong has_many :flags, :class_name => "PostFlag", :dependent => :destroy has_many :appeals, :class_name => "PostAppeal", :dependent => :destroy has_many :votes, :class_name => "PostVote", :dependent => :destroy has_many :notes, :dependent => :destroy has_many :comments, :dependent => :destroy has_many :children, -> {order("posts.id")}, :class_name => "Post", :foreign_key => "parent_id" has_many :approvals, :class_name => "PostApproval", :dependent => :destroy has_many :disapprovals, :class_name => "PostDisapproval", :dependent => :destroy has_many :favorites, dependent: :destroy has_many :replacements, class_name: "PostReplacement", :dependent => :destroy has_many :ai_tags, through: :media_asset has_many :events, class_name: "PostEvent" has_many :mod_actions, as: :subject, dependent: :destroy attr_accessor :old_tag_string, :old_parent_id, :old_source, :old_rating, :has_constraints, :disable_versioning, :post_edit scope :pending, -> { where(is_pending: true) } scope :flagged, -> { where(is_flagged: true) } scope :banned, -> { where(is_banned: true) } # XXX conflict with deletable scope :active, -> { where(is_pending: false, is_deleted: false, is_flagged: false).where.not(id: PostAppeal.pending) } scope :appealed, -> { where(id: PostAppeal.pending.select(:post_id)) } scope :in_modqueue, -> { where_union(pending, flagged, appealed) } scope :expired, -> { pending.where("posts.created_at < ?", Danbooru.config.moderation_period.ago) } scope :unflagged, -> { where(is_flagged: false) } scope :has_notes, -> { where.not(last_noted_at: nil) } scope :for_user, ->(user_id) { where(uploader_id: user_id) } if PostVersion.enabled? has_many :versions, -> { Rails.env.test? ? order("post_versions.updated_at ASC, post_versions.id ASC") : order("post_versions.updated_at ASC") }, class_name: "PostVersion", dependent: :destroy end def self.new_from_upload(upload_media_asset, tag_string: nil, rating: nil, parent_id: nil, source: nil, artist_commentary_title: nil, artist_commentary_desc: nil, translated_commentary_title: nil, translated_commentary_desc: nil, is_pending: nil, add_artist_tag: false) upload = upload_media_asset.upload media_asset = upload_media_asset.media_asset # XXX depends on CurrentUser commentary = ArtistCommentary.new( original_title: artist_commentary_title, original_description: artist_commentary_desc, translated_title: translated_commentary_title, translated_description: translated_commentary_desc, ) if add_artist_tag tag_string = "#{tag_string} #{upload_media_asset.source_extractor&.artists.to_a.map(&:tag).map(&:name).join(" ")}".strip tag_string += " " if tag_string.present? end post = Post.new( uploader: upload.uploader, md5: media_asset&.md5, file_ext: media_asset&.file_ext, file_size: media_asset&.file_size, image_width: media_asset&.image_width, image_height: media_asset&.image_height, source: source.to_s, tag_string: tag_string, rating: rating, parent_id: parent_id, is_pending: !upload.uploader.is_contributor? || is_pending.to_s.truthy?, artist_commentary: (commentary if commentary.any_field_present?), ) end concerning :FileMethods do def seo_tags presenter.humanized_essential_tag_string.gsub(/[^a-z0-9]+/, "_").gsub(/(?:^_+)|(?:_+$)/, "").gsub(/_{2,}/, "_") end def file(type = :original) media_asset.variant(type).open_file! end def tagged_file_url(tagged_filenames: !CurrentUser.user.disable_tagged_filenames?) slug = seo_tags if tagged_filenames media_asset.variant(:original).file_url(slug) end def tagged_large_file_url(tagged_filenames: !CurrentUser.user.disable_tagged_filenames?) slug = seo_tags if tagged_filenames if media_asset.has_variant?(:sample) media_asset.variant(:sample).file_url(slug) else media_asset.variant(:original).file_url(slug) end end def file_url media_asset.variant(:original).file_url end def large_file_url if media_asset.has_variant?(:sample) media_asset.variant(:sample).file_url else media_asset.variant(:original).file_url end end def preview_file_url # XXX hack to return placeholder thumbnail for Flash files the /posts.json API. return Danbooru.config.storage_manager.file_url("/images/flash-preview.png") if media_asset.is_flash? media_asset.variant(:preview).file_url end def file_url_for(user) if user.default_image_size == "large" && image_width > Danbooru.config.large_image_width tagged_large_file_url else tagged_file_url end end def is_image? file_ext.in?(%w[jpg gif png webp avif]) end def is_flash? file_ext =~ /swf/i end def is_video? file_ext.in?(%w[webm mp4]) end def is_ugoira? file_ext =~ /zip/i end def has_preview? is_image? || is_video? || is_ugoira? end end concerning :ImageMethods do def has_large? return false if has_tag?("animated_gif") || has_tag?("animated_png") return true if is_ugoira? is_image? && image_width.present? && image_width > Danbooru.config.large_image_width end alias has_large has_large? def large_image_width if has_large? [Danbooru.config.large_image_width, image_width.to_i].min else image_width.to_i end end def large_image_height ratio = Danbooru.config.large_image_width.to_f / image_width.to_f if has_large? && ratio < 1 (image_height * ratio).to_i else image_height end end def image_width_for(user) if user.default_image_size == "large" large_image_width else image_width end end def image_height_for(user) if user.default_image_size == "large" large_image_height else image_height end end def resize_percentage return 100 if image_width.to_i == 0 100 * large_image_width.to_f / image_width.to_f end # XXX def current_image_size has_large? && CurrentUser.default_image_size == "large" ? "large" : "original" end end concerning :ApprovalMethods do def in_modqueue? is_pending? || is_flagged? || is_appealed? end def is_active? !is_deleted? && !in_modqueue? end def is_appealed? is_deleted? && appeals.any?(&:pending?) end def is_appealable? is_deleted? && !is_appealed? end def is_approvable?(user = CurrentUser.user) !is_active? && uploader != user end def autoban if has_tag?("banned_artist") || has_tag?("paid_reward") self.is_banned = true end end end concerning :PresenterMethods do def presenter @presenter ||= PostPresenter.new(self) end def status_flags flags = [] flags << "pending" if is_pending? flags << "flagged" if is_flagged? flags << "deleted" if is_deleted? flags << "banned" if is_banned? flags.join(" ") end # g => 0, s => 1, q => 2, e => 3 def rating_id RATINGS.keys.index(rating) end def pretty_rating RATINGS.fetch(rating) end def parsed_source Source::URL.parse(source) if web_source? end def normalized_source parsed_source&.page_url || source end def source_domain parsed_source&.domain.to_s end end concerning :TagMethods do def tag_array tag_string.split end def tag_array_was tag_string_was.split end def tags Tag.where(name: tag_array) end def tags_was Tag.where(name: tag_array_was) end def added_tags tags - tags_was end def decrement_tag_post_counts Tag.where(:name => tag_array).update_all("post_count = post_count - 1") if tag_array.any? end def update_tag_post_counts decrement_tags = tag_array_was - tag_array increment_tags = tag_array - tag_array_was if increment_tags.any? Tag.increment_post_counts(increment_tags) end if decrement_tags.any? Tag.decrement_post_counts(decrement_tags) end end # Update tag_count_general, tag_count_copyright, etc. def update_tag_category_counts TagCategory.categories.each do |category_name| tag_count = tags.select { |t| t.category_name.downcase == category_name }.size send("tag_count_#{category_name}=", tag_count) end self.tag_count = tag_array.size end def merge_old_changes if old_parent_id == "" old_parent_id = nil else old_parent_id = old_parent_id.to_i end if old_parent_id == parent_id self.parent_id = parent_id_before_last_save || parent_id_was end if old_source == source.to_s self.source = source_before_last_save || source_was end if old_rating == rating self.rating = rating_before_last_save || rating_was end @post_edit = PostEdit.new(self, tag_string_was, old_tag_string || tag_string_was, tag_string) end def normalize_tags self.tag_string = Tag.create_for_list(post_edit.tag_names).uniq.sort.join(" ") end def add_automatic_tags(tags) tags -= %w[incredibly_absurdres absurdres highres lowres flash video ugoira animated_gif animated_png exif_rotation non-repeating_animation non-web_source wide_image tall_image] if tags.size >= 30 tags -= ["tagme"] elsif tags.empty? tags << "tagme" end if image_width >= 10_000 || image_height >= 10_000 tags << "incredibly_absurdres" end if image_width >= 3200 || image_height >= 2400 tags << "absurdres" end if image_width >= 1600 || image_height >= 1200 tags << "highres" end if image_width <= 500 && image_height <= 500 tags << "lowres" end if image_width >= 1024 && image_width.to_f / image_height >= 4 tags << "wide_image" elsif image_height >= 1024 && image_height.to_f / image_width >= 4 tags << "tall_image" end if is_flash? tags << "flash" end if is_video? tags << "video" end if is_ugoira? tags << "ugoira" end if source.present? && !web_source? tags << "non-web_source" end source_url = parsed_source if source_url.present? && source_url.recognized? # A bad_link is an image URL from a recognized site that can't be converted to a page URL. if source_url.image_url? && source_url.page_url.nil? tags << "bad_link" else tags -= ["bad_link"] end # A bad_source is a source from a recognized site that isn't an image url or a page url. if !source_url.image_url? && !source_url.page_url? tags << "bad_source" else tags -= ["bad_source"] end end # Allow only Flash files to be manually tagged as `animated`; GIFs, PNGs, videos, and ugoiras are automatically tagged. tags -= ["animated"] unless is_flash? tags << "animated" if media_asset.is_animated? tags << "animated_gif" if media_asset.is_animated_gif? tags << "animated_png" if media_asset.is_animated_png? tags << "greyscale" if media_asset.is_greyscale? tags << "exif_rotation" if media_asset.is_rotated? tags << "non-repeating_animation" if media_asset.is_non_repeating_animation? tags << "ai-generated" if media_asset.is_ai_generated? # Allow Flash files to be manually tagged as `sound`; other files are automatically tagged. tags -= ["sound"] unless is_flash? tags << "sound" if media_asset.has_sound? tags end def apply_post_metatags post_edit.post_metatag_terms.each do |metatag| case [metatag.name, metatag.value] in "-pool", /^\d+$/ => pool_id pool = Pool.find_by_id(pool_id) pool&.remove!(self) in "-pool", name pool = Pool.find_by_name(name) pool&.remove!(self) in "pool", /^\d+$/ => pool_id pool = Pool.find_by_id(pool_id) pool&.add!(self) in "pool", name pool = Pool.find_by_name(name) pool&.add!(self) in "newpool", name pool = Pool.find_by_name(name) # XXX race condition if pool.nil? Pool.create!(name: name, description: "This pool was automatically generated", post_ids: [id]) else pool.add!(self) end in "fav", name raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, Favorite).create? Favorite.create(post: self, user: CurrentUser.user) in "-fav", name raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, Favorite).create? Favorite.destroy_by(post: self, user: CurrentUser.user) in "upvote", name vote!(1, CurrentUser.user) in "downvote", name vote!(-1, CurrentUser.user) in "status", "active" raise User::PrivilegeError unless CurrentUser.is_approver? approvals.create!(user: CurrentUser.user) in "status", "banned" raise User::PrivilegeError unless CurrentUser.is_approver? ban!(CurrentUser.user) in "-status", "banned" raise User::PrivilegeError unless CurrentUser.is_approver? unban!(CurrentUser.user) in "disapproved", reason raise User::PrivilegeError unless CurrentUser.is_approver? disapprovals.create!(user: CurrentUser.user, reason: reason.downcase) in "child", "none" children.each do |post| post.update!(parent_id: nil) end in "-child", ids next if ids.blank? children.where_numeric_matches(:id, ids).each do |post| post.update!(parent_id: nil) end in "child", ids next if ids.blank? Post.where_numeric_matches(:id, ids).where.not(id: id).limit(10).each do |post| post.update!(parent_id: id) end in "-favgroup", name favgroup = FavoriteGroup.find_by_name_or_id!(name, CurrentUser.user) raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, favgroup).update? favgroup&.remove(self) in "favgroup", name favgroup = FavoriteGroup.find_by_name_or_id!(name, CurrentUser.user) raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, favgroup).update? favgroup&.add(self) end end rescue # XXX Silently ignore errors so that the edit doesn't fail. We can't let # the edit fail because then it will create a new post version even if # the edit didn't go through. nil end def apply_pre_metatags post_edit.pre_metatag_terms.each do |metatag| case [metatag.name, metatag.value] in "parent", ("none" | "0") self.parent_id = nil in "-parent", /^\d+$/ => new_parent_id if parent_id == new_parent_id.to_i self.parent_id = nil end in "parent", /^\d+$/ => new_parent_id if new_parent_id.to_i != id && Post.exists?(new_parent_id) self.parent_id = new_parent_id.to_i remove_parent_loops end in "rating", /\A([#{RATINGS.keys.join}])/i self.rating = $1.downcase in "source", "none" self.source = "" in "source", value self.source = value in category, name if category.in?(PostEdit::CATEGORIZATION_METATAGS) Tag.find_or_create_by_name(name, category: category, current_user: CurrentUser.user) else nil end end end def web_source? source.match?(%r{\Ahttps?://}i) end def has_tag?(tag) tag_array.include?(tag) end def add_tag(tag) self.tag_string = "#{tag_string} #{tag}" end def remove_tag(tag) self.tag_string = (tag_array - Array(tag)).join(" ") end def tag_categories @tag_categories ||= Tag.categories_for(tag_array) end def typed_tags(name) @typed_tags ||= {} @typed_tags[name] ||= begin tag_array.select do |tag| tag_categories[tag] == TagCategory.mapping[name] end end end TagCategory.categories.each do |category| define_method("tag_string_#{category}") do typed_tags(category).join(" ") end end end concerning :FavoriteMethods do def favorited_by?(user) return false if user.is_anonymous? Favorite.exists?(post: self, user: user) end def favorite_groups FavoriteGroup.for_post(id) end def remove_from_fav_groups FavoriteGroup.for_post(id).find_each do |favgroup| favgroup.remove(self) end end end concerning :PoolMethods do def pools Pool.where("pools.post_ids && array[?]", id) end def has_active_pools? pools.undeleted.present? end def remove_from_all_pools pools.find_each do |pool| pool.remove!(self) end end end concerning :VoteMethods do def vote!(score, voter) # Ignore vote if user doesn't have permission to vote. return unless Pundit.policy!(voter, PostVote).create? with_lock do votes.create!(user: voter, score: score) unless votes.active.exists?(user: voter, score: score) reload # PostVote.create modifies our score. Reload to get the new score. end end end concerning :ParentMethods do # A parent has many children. A child belongs to a parent. # A parent cannot have a parent. # # After expunging a child: # - Move favorites to parent. # - Does the parent have any children? # - Yes: Done. # - No: Update parent's has_children flag to false. # # After expunging a parent: # - Move favorites to the first child. # - Reparent all children to the first child. def update_has_children_flag update(has_children: children.exists?, has_active_children: children.undeleted.exists?) end def blank_out_nonexistent_parents if parent_id.present? && parent.nil? self.parent_id = nil end end def remove_parent_loops if parent.present? && parent.parent_id.present? && parent.parent_id == id parent.parent_id = nil parent.save end end def update_parent_on_destroy parent&.update_has_children_flag end def update_children_on_destroy children.update(parent: nil) end def update_parent_on_save return unless saved_change_to_parent_id? || saved_change_to_is_deleted? parent.update_has_children_flag if parent.present? Post.find(parent_id_before_last_save).update_has_children_flag if parent_id_before_last_save.present? rescue # XXX Silently ignore errors so that the edit doesn't fail. We can't let # the edit fail because then it will create a new post version even if # the edit didn't go through. nil end def give_favorites_to_parent(current_user = CurrentUser.user) return if parent.nil? transaction do favorites.each do |fav| fav.destroy! Favorite.create(post: parent, user: fav.user) end end ModAction.log("moved favorites from post ##{id} to post ##{parent.id}", :post_move_favorites, subject: self, user: current_user) end def has_visible_children? return true if has_active_children? return true if has_children? && CurrentUser.user.show_deleted_children? return true if has_children? && is_deleted? return false end def has_visible_children has_visible_children? end end concerning :DeletionMethods do def expunge!(current_user = CurrentUser.user) transaction do Post.without_timeout do ModAction.log("permanently deleted post ##{id} (md5=#{md5})", :post_permanent_delete, subject: nil, user: current_user) update_children_on_destroy decrement_tag_post_counts remove_from_all_pools remove_from_fav_groups media_asset.trash!(current_user, log: false) destroy update_parent_on_destroy end end remove_iqdb # this is non-transactional end def ban!(current_user) return if is_banned? update_column(:is_banned, true) ModAction.log("banned post ##{id}", :post_ban, subject: self, user: current_user) end def unban!(current_user) return unless is_banned? update_column(:is_banned, false) ModAction.log("unbanned post ##{id}", :post_unban, subject: self, user: current_user) end def delete!(reason, move_favorites: false, user: CurrentUser.user) with_lock do automated = (user == User.system) flags.pending.update!(status: :succeeded) appeals.pending.update!(status: :rejected) flags.create!(reason: reason, is_deletion: true, creator: user, status: :succeeded) update!(is_deleted: true, is_pending: false, is_flagged: false) # XXX This must happen *after* the `is_deleted` flag is set to true (issue #3419). give_favorites_to_parent if move_favorites uploader.upload_limit.update_limit!(is_pending?, false) unless automated ModAction.log("deleted post ##{id}, reason: #{reason}", :post_delete, subject: self, user: user) end end end end concerning :VersionMethods do # XXX `create_version` must be called before `apply_post_metatags` because # `apply_post_metatags` may update the post itself, which will clear all # changes to the post and make saved_change_to_*? return false. def create_version(force = false) if new_record? || saved_change_to_watched_attributes? || force create_new_version end end def saved_change_to_watched_attributes? saved_change_to_rating? || saved_change_to_source? || saved_change_to_parent_id? || saved_change_to_tag_string? end def merge_version? prev = versions.last prev && prev.updater_id == CurrentUser.user.id && prev.updated_at > 1.hour.ago end def create_new_version User.where(id: CurrentUser.id).update_all("post_update_count = post_update_count + 1") PostVersion.queue(self) if PostVersion.enabled? end def revert_to(target) if id != target.post_id raise RevertError, "You cannot revert to a previous version of another post." end self.tag_string = target.tags self.rating = target.rating self.source = target.source self.parent_id = target.parent_id end def revert_to!(target) revert_to(target) save! end end concerning :NoteMethods do def has_notes? last_noted_at.present? end def copy_notes_to(other_post, copy_tags: NOTE_COPY_TAGS) transaction do if id == other_post.id errors.add(:base, "Source and destination posts are the same") return false end unless has_notes? errors.add(:post, "has no notes") return false end notes.active.each do |note| note.copy_to(other_post) end dummy = Note.new if notes.active.length == 1 dummy.body = "Copied 1 note from post ##{id}." else dummy.body = "Copied #{notes.active.length} notes from post ##{id}." end dummy.is_active = false dummy.post_id = other_post.id dummy.x = dummy.y = dummy.width = dummy.height = 0 dummy.save copy_tags.each do |tag| other_post.remove_tag(tag) other_post.add_tag(tag) if has_tag?(tag) end other_post.has_embedded_notes = has_embedded_notes other_post.save end end end concerning :ApiMethods do def legacy_attributes hash = { "has_comments" => last_commented_at.present?, "parent_id" => parent_id, "status" => status, "has_children" => has_children?, "created_at" => created_at.to_formatted_s(:db), "has_notes" => has_notes?, "rating" => rating, "author" => uploader.name, "creator_id" => uploader_id, "width" => image_width, "source" => source, "score" => score, "tags" => tag_string, "height" => image_height, "file_size" => file_size, "id" => id, } if visible? hash["file_url"] = file_url hash["preview_url"] = preview_file_url hash["md5"] = md5 end hash end def status if is_pending? "pending" elsif is_deleted? "deleted" elsif is_flagged? "flagged" else "active" end end end concerning :SearchMethods do class_methods do # Return a set of up to N random posts. May return less if there aren't # enough posts. # # @param n [Integer] The maximum number of posts to return # @return [ActiveRecord::Relation] def random(n = 1) posts = n.times.map do key = SecureRandom.hex(16) random_up(key) || random_down(key) end.compact.uniq reorder(nil).in_order_of(:id, posts.map(&:id)) end def random_up(key) where("posts.md5 < ?", key).reorder(md5: :desc).first end def random_down(key) where("posts.md5 >= ?", key).reorder(md5: :asc).first end def sample(query, sample_size) user_tag_match(query, safe_mode: false).reorder(:md5).limit(sample_size) end # unflattens the tag_string into one tag per row. def with_unflattened_tags joins("CROSS JOIN unnest(string_to_array(tag_string, ' ')) AS tag") end def with_comment_stats relation = left_outer_joins(:comments).group(:id).select("posts.*") relation = relation.select("COUNT(comments.id) AS comment_count") relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = TRUE) AS deleted_comment_count") relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = FALSE) AS active_comment_count") relation end def with_note_stats relation = left_outer_joins(:notes).group(:id).select("posts.*") relation = relation.select("COUNT(notes.id) AS note_count") relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = TRUE) AS active_note_count") relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = FALSE) AS deleted_note_count") relation end def with_flag_stats relation = left_outer_joins(:flags).group(:id).select("posts.*") relation.select("COUNT(post_flags.id) AS flag_count") end def with_appeal_stats relation = left_outer_joins(:appeals).group(:id).select("posts.*") relation = relation.select("COUNT(post_appeals.id) AS appeal_count") relation end def with_approval_stats relation = left_outer_joins(:approvals).group(:id).select("posts.*") relation = relation.select("COUNT(post_approvals.id) AS approval_count") relation end def with_replacement_stats relation = left_outer_joins(:replacements).group(:id).select("posts.*") relation = relation.select("COUNT(post_replacements.id) AS replacement_count") relation end def with_child_stats relation = left_outer_joins(:children).group(:id).select("posts.*") relation = relation.select("COUNT(children_posts.id) AS child_count") relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = TRUE) AS deleted_child_count") relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = FALSE) AS active_child_count") relation end def with_pool_stats pool_posts = Pool.joins("CROSS JOIN unnest(post_ids) AS post_id").select(:id, :is_deleted, :category, "post_id") relation = joins("LEFT OUTER JOIN (#{pool_posts.to_sql}) pools ON pools.post_id = posts.id").group(:id).select("posts.*") relation = relation.select("COUNT(pools.id) AS pool_count") relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = TRUE) AS deleted_pool_count") relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = FALSE) AS active_pool_count") relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'series') AS series_pool_count") relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'collection') AS collection_pool_count") relation end def with_queued_at relation = group(:id) relation = relation.left_outer_joins(:flags, :appeals) relation = relation.select("posts.*") relation = relation.select(Arel.sql("MAX(GREATEST(posts.created_at, post_flags.created_at, post_appeals.created_at)) AS queued_at")) relation end def with_stats(tables) return all if tables.empty? relation = all tables.each do |table| relation = relation.send("with_#{table}_stats") end from(relation.arel.as("posts")) end def available_for_moderation(user, hidden: false) disapproved_posts = user.post_disapprovals.select(:post_id) if hidden.present? in_modqueue.where(id: disapproved_posts) else in_modqueue.where.not(id: disapproved_posts) end end def is_matches(value, current_user = User.anonymous) case value.downcase when "parent" where(has_children: true) when "child" where.not(parent: nil) when *AutocompleteService::POST_STATUSES status_matches(value, current_user) when *MediaAsset::FILE_TYPES attribute_matches(value, "media_assets.file_ext", :enum).joins(:media_asset) when *Post::RATINGS.values.map(&:downcase) rating_matches(value) when *Post::RATING_ALIASES.keys where(rating: Post::RATING_ALIASES.fetch(value.downcase)) else none end end def has_matches(value) case value.downcase when "parent" where.not(parent: nil) when "child", "children" where(has_children: true) when "source" where.not(source: "") when "appeals" where(PostAppeal.where("post_appeals.post_id = posts.id").arel.exists) when "flags" where(PostFlag.by_users.where("post_flags.post_id = posts.id").arel.exists) when "replacements" where(PostReplacement.where("post_replacements.post_id = posts.id").arel.exists) when "comments" where(Comment.undeleted.where("comments.post_id = posts.id").arel.exists) when "commentary" where(ArtistCommentary.undeleted.where("artist_commentaries.post_id = posts.id").arel.exists) when "notes" where(Note.active.where("notes.post_id = posts.id").arel.exists) when "pools" where(id: Pool.undeleted.select("unnest(post_ids)")) else none end end def status_matches(status, current_user = User.anonymous) case status.downcase when "pending" pending when "flagged" flagged when "appealed" appealed when "modqueue" in_modqueue when "deleted" deleted when "banned" banned when "active" active when "unmoderated" available_for_moderation(current_user, hidden: false) when "all", "any" where("TRUE") else none end end def parent_matches(parent) case parent.downcase when "none" where(parent: nil) when "any" where.not(parent: nil) when "pending", "flagged", "appealed", "modqueue", "deleted", "banned", "active", "unmoderated" where.not(parent: nil).where(parent: status_matches(parent)) when /\A\d+\z/ # XXX must use `attribute_matches(parent, :parent_id)` instead of `where(parent_id: parent)` so that `-parent:1` works where(id: parent).or(attribute_matches(parent, :parent_id)) else none end end def child_matches(child) case child.downcase when "none" where(has_children: false) when "any" where(has_children: true) when "pending", "flagged", "appealed", "modqueue", "deleted", "banned", "active", "unmoderated" where(has_children: true).where(children: status_matches(child)) else none end end def rating_matches(rating) where(rating: rating.downcase.split(/,/).map(&:first)) end def source_matches(source, quoted = false) if source.empty? where(source: "") elsif source.downcase == "none" && !quoted where(source: "") else where_ilike(:source, source + "*") end end def embedded_matches(embedded) if embedded.truthy? bit_flags_match(:has_embedded_notes, true) elsif embedded.falsy? bit_flags_match(:has_embedded_notes, false) else none end end def commentary_matches(query, quoted = false) case query.downcase in "none" | "false" unless quoted where.not(artist_commentary: ArtistCommentary.all).or(where(artist_commentary: ArtistCommentary.deleted)) in "any" | "true" unless quoted where(artist_commentary: ArtistCommentary.undeleted) in "translated" unless quoted where(artist_commentary: ArtistCommentary.translated) in "untranslated" unless quoted where(artist_commentary: ArtistCommentary.untranslated) else where(artist_commentary: ArtistCommentary.text_matches(query)) end end def disapproved_matches(query, current_user = User.anonymous) if query.downcase.in?(PostDisapproval::REASONS) where(disapprovals: PostDisapproval.where(reason: query.downcase)) else user = User.find_by_name(query) where(disapprovals: PostDisapproval.visible_for_search(:user, current_user).where(user: user)) end end def note_matches(query) where(notes: Note.where_text_matches(:body, query)) end def comment_matches(query) where(comments: Comment.where_text_matches(:body, query)) end def saved_search_matches(label, current_user = User.anonymous) case label.downcase when "all" where(id: SavedSearch.post_ids_for(current_user.id)) else where(id: SavedSearch.post_ids_for(current_user.id, label: label)) end end def pool_matches(pool_name) case pool_name.downcase when "none" where.not(id: Pool.select("unnest(post_ids)")) when "any" where(id: Pool.select("unnest(post_ids)")) when "series" where(id: Pool.series.select("unnest(post_ids)")) when "collection" where(id: Pool.collection.select("unnest(post_ids)")) when /\*/ where(id: Pool.name_contains(pool_name).select("unnest(post_ids)")) else where(id: Pool.named(pool_name).select("unnest(post_ids)")) end end def ordpool_matches(pool_name) # XXX unify with Pool#posts pool_posts = Pool.named(pool_name).joins("CROSS JOIN unnest(pools.post_ids) WITH ORDINALITY AS row(post_id, pool_index)").select(:post_id, :pool_index) joins("JOIN (#{pool_posts.to_sql}) pool_posts ON pool_posts.post_id = posts.id").order("pool_posts.pool_index ASC") end def favgroup_matches(query, current_user) case query.downcase when "none" favgroups = FavoriteGroup.where(creator: current_user) where.not(id: favgroups.select("unnest(post_ids)")) when "any" favgroups = FavoriteGroup.where(creator: current_user) where(id: favgroups.select("unnest(post_ids)")) else favgroup = FavoriteGroup.visible(current_user).name_or_id_matches(query, current_user) where(id: favgroup.select("unnest(post_ids)")) end end def ordfavgroup_matches(query, current_user) # XXX unify with FavoriteGroup#posts favgroup = FavoriteGroup.visible(current_user).name_or_id_matches(query, current_user) favgroup_posts = favgroup.joins("CROSS JOIN unnest(favorite_groups.post_ids) WITH ORDINALITY AS row(post_id, favgroup_index)").select(:post_id, :favgroup_index) joins("JOIN (#{favgroup_posts.to_sql}) favgroup_posts ON favgroup_posts.post_id = posts.id").order("favgroup_posts.favgroup_index ASC") end def favorites_include(username, current_user = User.anonymous) favuser = User.find_by_name(username) if favuser.present? && Pundit.policy!(current_user, favuser).can_see_favorites? where(id: favuser.favorites.select(:post_id)) else none end end def ordfav_matches(username, current_user = User.anonymous) user = User.find_by_name(username) if user.present? && Pundit.policy!(current_user, user).can_see_favorites? joins(:favorites).merge(Favorite.where(user: user)).order("favorites.id DESC") else none end end def exif_matches(string) where(md5: MediaAsset.exif_matches(string).select(:md5)) end def ai_tags_include(value, default_confidence: ">=50") name, confidence = value.split(",") confidence = (confidence || default_confidence).to_s.delete("%") tag = Tag.find_by_name_or_alias(name) return none if tag.nil? if confidence == "0" ai_tags = AITag.joins(:media_asset).where(tag: tag) where.not(ai_tags.where("media_assets.md5 = posts.md5").arel.exists) else ai_tags = AITag.joins(:media_asset).where(tag: tag).where_numeric_matches(:score, confidence) where(ai_tags.where("media_assets.md5 = posts.md5").arel.exists) end end def uploader_matches(username) case username.downcase when "any" where.not(uploader: nil) when "none" where(uploader: nil) else user = User.find_by_name(username) return none if user.nil? where(uploader: user) end end def approver_matches(username) case username.downcase when "any" where.not(approver: nil) when "none" where(approver: nil) else user = User.find_by_name(username) return none if user.nil? # XXX must use `attribute_matches(user.id, :approver_id)` instead of `where(approver: user)` so that `-approver:evazion` works attribute_matches(user.id, :approver_id) end end def user_subquery_matches(subquery, username, current_user, field: :creator) subquery = subquery.where("post_id = posts.id").select(1) if username.downcase == "any" where("EXISTS (#{subquery.to_sql})") elsif username.downcase == "none" where("NOT EXISTS (#{subquery.to_sql})") else user = User.find_by_name(username) return none if user.nil? subquery = subquery.visible_for_search(field, current_user).where(field => user) where("EXISTS (#{subquery.to_sql})") end end def tags_include(*tags) where_array_includes_all("string_to_array(posts.tag_string, ' ')", tags) end def raw_tag_match(tag) Post.where_array_includes_all("string_to_array(posts.tag_string, ' ')", [tag]) end # Perform a tag search as an anonymous user. No tag limit is enforced. def anon_tag_match(query) user_tag_match(query, User.anonymous, tag_limit: nil, safe_mode: false) end # Perform a tag search as the system user, DanbooruBot. The search will # have moderator-level permissions. No tag limit is enforced. def system_tag_match(query) user_tag_match(query, User.system, tag_limit: nil, safe_mode: false) end # Perform a tag search as the current user, or as another user. # # @param query [String] the tag search to perform # @param user [User] the user to perform the search as # @param tag_limit [Integer] the maximum number of tags allowed per search. # An exception will be raised if the search has too many tags. # @param safe_mode [Boolean] if true, automatically add rating:s to the search # @return [ActiveRecord::Relation] the set of resulting posts def user_tag_match(query, user = CurrentUser.user, tag_limit: user.tag_query_limit, safe_mode: CurrentUser.safe_mode?) post_query = PostQuery.normalize(query, current_user: user, tag_limit: tag_limit, safe_mode: safe_mode) post_query.validate_tag_limit! posts = post_query.with_implicit_metatags.posts and_relation(posts) end def search(params, current_user) q = search_attributes( params, [:id, :created_at, :updated_at, :rating, :source, :pixiv_id, :fav_count, :score, :up_score, :down_score, :md5, :file_ext, :file_size, :image_width, :image_height, :tag_count, :has_children, :has_active_children, :is_pending, :is_flagged, :is_deleted, :is_banned, :last_comment_bumped_at, :last_commented_at, :last_noted_at, :uploader, :approver, :parent, :artist_commentary, :flags, :appeals, :notes, :comments, :children, :approvals, :replacements, :media_metadata], current_user: current_user ) if params[:tags].present? q = q.where(id: user_tag_match(params[:tags], current_user).select(:id)) end if params[:order].present? q = PostQueryBuilder.new(nil).search_order(q, params[:order]) else q = q.apply_default_order(params) end q end end end concerning :PixivMethods do def parse_pixiv_id self.pixiv_id = nil return unless web_source? site = Source::Extractor::Pixiv.new(source) if site.match? self.pixiv_id = site.illust_id end end end concerning :RegenerationMethods do def regenerate_later!(category, user) RegeneratePostJob.perform_later(post: self, category: category, user: user) end def regenerate!(category, user) if category == "iqdb" update_iqdb ModAction.log("regenerated IQDB for post ##{id}", :post_regenerate_iqdb, subject: self, user: user) else media_asset.regenerate! ModAction.log("regenerated image samples for post ##{id}", :post_regenerate, subject: self, user: user) end end end concerning :IqdbMethods do def update_iqdb # performs IqdbClient.new.add_post(post) IqdbAddPostJob.perform_later(self) end def remove_iqdb # performs IqdbClient.new.remove(id) IqdbRemovePostJob.perform_later(id) end end concerning :ValidationMethods do def post_is_not_its_own_parent if !new_record? && id == parent_id errors.add(:base, "Post cannot have itself as a parent") end end def uploader_is_not_limited errors.add(:uploader, "have reached your upload limit") if uploader.upload_limit.limited? end def added_tags_are_valid new_tags = added_tags.select(&:empty?) new_artist_tags, new_general_tags = new_tags.partition(&:artist?) if new_general_tags.present? n = new_general_tags.size tag_wiki_links = new_general_tags.map { |tag| "[[#{tag.name}]]" } warnings.add(:base, "Created #{n} new #{(n == 1) ? "tag" : "tags"}: #{tag_wiki_links.join(", ")}") end new_artist_tags.each do |tag| if tag.artist.blank? new_artist_path = Routes.new_artist_path(artist: { name: tag.name }) warnings.add(:base, "Artist [[#{tag.name}]] requires an artist entry. \"Create new artist entry\":[#{new_artist_path}]") end end post_edit.invalid_added_tags.each do |tag| tag.errors.messages.each do |_attribute, messages| warnings.add(:base, "Couldn't add tag: #{messages.join(';')}") end end deprecated_tags = post_edit.deprecated_added_tag_names if deprecated_tags.present? tag_list = deprecated_tags.map { |tag| "[[#{tag}]]" }.to_sentence warnings.add(:base, "The following tags are deprecated and could not be added: #{tag_list}") end end def removed_tags_are_valid attempted_removed_tags = post_edit.user_removed_tag_names unremoved_tags = tag_array & attempted_removed_tags if unremoved_tags.present? unremoved_tags_list = unremoved_tags.map { |t| "[[#{t}]]" }.to_sentence warnings.add(:base, "#{unremoved_tags_list} could not be removed. Check for implications and try again") end end def has_artist_tag return if !new_record? return if !web_source? return if has_tag?("artist_request") || has_tag?("official_art") return if tags.any?(&:artist?) return if Source::Extractor.find(source).is_a?(Source::Extractor::Null) new_artist_path = Routes.new_artist_path(artist: { source: source }) warnings.add(:base, "Artist tag is required. \"Create new artist tag\":[#{new_artist_path}]. Ask on the forum if you need naming help") end def has_copyright_tag return if !new_record? return if has_tag?("copyright_request") || tags.any?(&:copyright?) warnings.add(:base, "Copyright tag is required. Consider adding [[copyright request]] or [[original]]") end def has_enough_tags return if !new_record? if tags.count(&:general?) < 10 warnings.add(:base, "Uploads must have at least 10 general tags. Read [[howto:tag]] for guidelines on tagging your uploads") end end end def safeblocked? CurrentUser.safe_mode? && (rating != "g" || Danbooru.config.safe_mode_restricted_tags.any? { |tag| tag.in?(tag_array) }) end def levelblocked?(user = CurrentUser.user) #!user.is_gold? && RESTRICTED_TAGS.any? { |tag| has_tag?(tag) } user.id != uploader_id && !user.is_gold? && tag_string.match?(RESTRICTED_TAGS_REGEX) end def banblocked?(user = CurrentUser.user) return true if is_taken_down? && !user.is_moderator? return true if is_banned? && has_tag?("paid_reward") && !user.is_approver? return true if is_banned? && !user.is_approver? false end def visible?(user = CurrentUser.user) !safeblocked? && !levelblocked?(user) && !banblocked?(user) end def reload(options = nil) super @pools = nil @tag_categories = nil @typed_tags = nil self end def self.normalize_source(source) source.to_s.strip.unicode_normalize(:nfc) end def mark_as_translated(params) add_tag("check_translation") if params["check_translation"].to_s.truthy? remove_tag("check_translation") if params["check_translation"].to_s.falsy? add_tag("partially_translated") if params["partially_translated"].to_s.truthy? remove_tag("partially_translated") if params["partially_translated"].to_s.falsy? if has_tag?("check_translation") || has_tag?("partially_translated") add_tag("translation_request") remove_tag("translated") else add_tag("translated") remove_tag("translation_request") end save end def self.model_restriction(table) super.where(table[:is_pending].eq(false)).where(table[:is_flagged].eq(false)).where(table[:is_deleted].eq(false)) end def self.available_includes # attributes accessible through the ?only= parameter %i[ uploader approver flags appeals events parent children notes comments approvals disapprovals replacements artist_commentary media_asset media_metadata ai_tags ] end end