# frozen_string_literal: true require 'fancy_searchable/searchable' require 'booru/config' # == Schema Information # # Table name: posts # # id :bigint not null, primary key # anonymous :boolean default(FALSE), not null # commenting_allowed :boolean default(TRUE), not null # comments_count :integer default(0), not null # deletion_reason :string # description :string # description_editing_allowed :boolean default(TRUE), not null # destroyed_content :boolean default(FALSE), not null # downvotes_count :integer default(0), not null # duplication_checked :boolean default(FALSE), not null # faves_count :integer default(0), not null # first_seen_at :datetime # hidden_from_users :boolean default(FALSE), not null # hidden_key :text # hides_count :integer default(0), not null # ip :inet # media_type :string default("image"), not null # processed :boolean default(FALSE), not null # referrer :string default(""), not null # score :integer default(0), not null # scratchpad :string # source_url :string # tag_editing_allowed :boolean default(TRUE), not null # tag_list_cache :string # tag_list_plus_alias_cache :string # upvotes_count :integer default(0), not null # user_agent :string default(""), not null # created_at :datetime not null # updated_at :datetime not null # deleted_by_id :integer # duplicate_id :integer # user_id :integer # # Foreign Keys # # fk_rails_... (deleted_by_id => users.id) # fk_rails_... (duplicate_id => posts.id) # fk_rails_... (user_id => users.id) # class Post < ApplicationRecord include Hidable include Reportable include FancySearchable::Searchable include Indexable include AnonUserAttributable include ModLoggable include ActiveModel::Dirty TYPES = %w[image paste audio].freeze AUTOMATICALLY_CURATED_TAGS = (TYPES + Post::Image::AUTOMATICALLY_CURATED_TAGS).freeze define_attribute_method :tags define_attribute_method :mime_type resourcify attr_accessor :scraper_url, :scraper_cache, :disable_processing, :file_cache, :paste_input, :file_type_cache belongs_to :user, inverse_of: :posts, optional: true belongs_to :deleted_by, class_name: 'User', inverse_of: :deleted_posts, optional: true has_one :image has_one :paste has_one :post_thumbnail has_one :thumbnail, through: :post_thumbnail, class_name: 'Post', inverse_of: nil has_many :comments, validate: false has_many :tag_changes, validate: false, dependent: :delete_all has_many :source_changes, validate: false, dependent: :delete_all has_many :duplicate_reports has_many :notifications, inverse_of: :actor has_many :gallery_interactions has_many :galleries, through: :gallery_interactions has_many :mod_notes, as: :noteable has_many :subscriptions, dependent: :delete_all has_many :subscribers, through: :subscriptions, source: :user has_many :locations, validate: false has_many :features has_many :faves, class_name: 'Post::Fave', validate: false has_many :hides, validate: false has_many :votes, validate: false has_many :taggings, validate: false has_many :tags, through: :taggings, validate: false has_many :processing_logs#, through: :post_processing_logs, :class_name => 'Post::ProcessingLog' has_many :upvotes, -> { where(up: true) }, class_name: 'Post::Vote', inverse_of: false has_many :downvotes, -> { where(up: false) }, class_name: 'Post::Vote', inverse_of: false attr_accessor :tag_input, :tags_compare_against, :paste_input validates :description, length: { maximum: Booru::CONFIG.settings[:max_comment_length] } validates :source_url, length: { maximum: Booru::CONFIG.settings[:max_image_url_length] } validates :hidden_from_users, presence: { if: :duplicate_id } validates :media_type, presence: true, inclusion: TYPES validates :image, presence: { if: ->(post) { post.media_type == 'image' } } validates :paste, presence: { if: ->(post) { post.media_type == 'paste' } } accepts_nested_attributes_for :image, :paste, :locations validates_with DestroyOnlyIfHiddenValidator validates_with TagInputValidator, if: proc { |img| img.new_record? || img.tag_input.present? } before_save :record_tag_input, if: proc { |img| img.tag_input.present? } audited only: [:hidden_from_users, :deletion_reason, :tag_editing_allowed, :description_editing_allowed, :commenting_allowed], if: Proc.new { |image| User.audit_current_user&.staff? && image.user != User.audit_current_user } before_create do |img| img.first_seen_at = img.created_at end before_destroy :clear_notifications!, unless: :hidden_from_users delegate :file, to: :media delegate :sha512_hash, :orig_sha512_hash, to: :media def add_tags(added_tags) @tag_input = (tags + added_tags).uniq.map(&:name).join(',') end def remove_tags(removed_tags) @tag_input = (tags - removed_tags).map(&:name).join(',') end def record_tag_input update_tags(@tag_input) end def update_tags(new_tag_input, old_tag_input = tag_list) # Round up tags early so we aren't stuck in string land, use what the client saw as tag list if possible input_tags = Tag.parse_tag_list(new_tag_input) tags_present = Tag.parse_tag_list(old_tag_input) # Get the differences from what was there tags_to_remove = Tag.make_tags_from_names(tags_present - input_tags) tags_to_add = Tag.make_tags_from_names(input_tags - tags_present) # Add database-stored implications tags_to_add.concat Tag.where(id: tags_to_add.flat_map(&:implied_tag_ids).uniq) # Add configured implications for namespaced tags implying their namespace Booru::CONFIG.tag[:namespace_implications].each do |namespace| tags_to_add << Tag.find_tag_by_name(namespace) if tags_to_add.any? { |t| t.namespace == namespace } end # Add automatically-curated file type tags and remove any that should not be present. desired_auto_curated_tags = auto_curated_tags tags_to_add.concat Tag.make_tags_from_names(desired_auto_curated_tags) tags_to_remove.concat(Tag.make_tags_from_names(AUTOMATICALLY_CURATED_TAGS - desired_auto_curated_tags)) # Coalesce and deal with ratings # FIXME: For some reason this operation results in nil tags unless we reject them. This never happened before. # I think the reason is that I happened to be adding the `oc` tag plus the oc:floor bored tag, but it didn't exist yet. desired_tags = ((tags + tags_to_add) - tags_to_remove).uniq.reject(&:nil?) desired_tags = Tag.make_tags_from_names(Tag.merge_ratings(desired_tags.map(&:name))).uniq # the ACTUAL changes that'll happen tags_added = desired_tags - tags tags_removed = tags - desired_tags # Actually change the tags tags.push(tags_added) tags.delete(tags_removed) # Cleanup add_ids = tags_added.map(&:id) remove_ids = tags_removed.map(&:id) unless hidden_from_users Tag.where(id: remove_ids).update_all 'images_count = images_count - 1' Tag.where(id: add_ids).update_all 'images_count = images_count + 1' end BulkIndexUpdateJob.perform_later 'Tag', (remove_ids + add_ids) nuke_tag_caches! if persisted? CommentTagsReindexJob.perform_later(id) if comments_count > 0 [tags_added, tags_removed] end def auto_curated_tags [self.media_type] + self.media.auto_curated_tags#[MIME_TYPE_TAGS[self.mime_type]] end def tags_plus_aliased tags = Tag.arel_table Tag.where(tags[:id].in(tag_ids).or(tags[:aliased_tag_id].in(tag_ids))) end def clear_notifications! Notification.async_cleanup(self) nil end def source_url=(val) # wat? super(val) if val end def tag_list return '' if new_record? update_columns tag_list_cache: tags.display_order.pluck(:name).join(', ') if tag_list_cache.blank? tag_list_cache end def tag_list_plus_alias return '' if new_record? update_columns tag_list_plus_alias_cache: tags_plus_aliased.display_order.map(&:name).flatten.sort.join(', ') if tag_list_plus_alias_cache.blank? tag_list_plus_alias_cache end # wtf def nuke_tag_caches! assign_attributes tag_list_cache: nil, tag_list_plus_alias_cache: nil tag_list tag_list_plus_alias update_columns updated_at: Time.zone.now end def uploader_is?(user, ip) is_uploader = false if user is_uploader = (user.id == try(:user_id)) # Don't consider anons with same IP the uploader unless no user elsif !user_id is_uploader = (ip == self.ip) end is_uploader end def visible_to?(user) !hidden_from_users || can_see_when_hidden?(user) end def can_see_when_hidden?(user) user && (user.can?(:undelete, Post) || can_see_and_merged?(user) && !merged_into_deleted?) end def uploader if anonymous || user.nil? I18n.t('booru.anonymous_user') else user.name end end def title "Post ##{id}" end def link_to_route "/#{id}" end def artist_list @artist_list ||= tags.joins(:users).includes(:users).where(namespace: %w[artist photographer]).limit(2).flat_map(&:users).uniq end # Computes the Wilson confidence upper bound of approval for this image, based # on the number of votes it has, with 99.5% significance. # # See https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval # for mathematical detail. # # @return [Float] def wilson_score # Population size n = upvotes_count.to_f + downvotes_count return 0.0 if n <= 0 # Success proportion p_hat = upvotes_count / n # z and z^2-values for CI upper 99.5% z = 2.57583 z2 = 6.634900189 (p_hat + z2 / (2 * n) - z * Math.sqrt((p_hat * (1 - p_hat) + z2 / (4 * n)) / n)) / (1 + z2 / n) end def media self.send(self.media_type) # Safe, media_type is whitelisted to our own array. end def recount_votes! upvotes = self.upvotes.count downvotes = self.downvotes.count score = upvotes - downvotes self.update_columns( upvotes_count: upvotes, downvotes_count: downvotes, score: score ) IndexUpdateJob.perform_later('Post', self.id) end def self.featured Post.joins(:features).order('post_features.created_at desc').first end # quick and dirty local "upload" # takes in a local file path, user to associate the image with, source url, tags, description, anon, locations string def self.local_upload(path, user, source, tags, description, location_input = '', anonymous = true) # :source_url, :tag_input, :description, :anonymous) img = Post.new({ ip: '127.0.0.1', user_agent: 'LOCAL', referrer: 'LOCAL', user: user, source_url: source, tag_input: tags, description: description, anonymous: anonymous, media_type: 'image', image_attributes: { file: Pathname.new(path).open } }) img.save! locations = location_input.split(',') locations.each do |location| location, id_at_location = location.split(':') img.locations.create!(location: location, id_at_location: id_at_location.to_i) end img end private # For +#can_see_when_hidden?+ def can_see_and_merged?(user) duplicate_id && user.can?(:reject, DuplicateReport) end def merged_into_deleted? Post.find(id: duplicate_id).hidden_from_users rescue false end end