Files
danbooru/app/logical/post_query.rb
evazion a442658f8a Fix #5237: Deleted comments can be viewed by other users
* Fix it so non-moderators can't search deleted comments using the
  `updater`, `body`, `score`, `do_not_bump_post`, or `is_sticky` fields.
  Searching for these fields will exclude deleted comments.

* Fix it so non-moderators can search for their own deleted comments using the
  `creator` field, but not for deleted comments belonging to other users.

* Fix it so that if a regular user searches `commenter:<username>`, they
  can only see posts with undeleted comments by that user. If a moderator or
  the commenter themselves searches `commenter:<username>`, they can see all
  posts the user has commented on, including posts with deleted comments.

* Fix it so the comment count on user profiles only counts visible
  comments. Regular users can only see the number of undeleted comments
  a user has, while moderators and the commenter themselves can see the
  total number of comments.

Known issue:

* It's still possible to order deleted comments by score, which can let
  you infer the score of deleted comments.
2022-09-22 19:17:33 -05:00

304 lines
9.3 KiB
Ruby

# frozen_string_literal: true
class PostQuery
extend Memoist
class Error < StandardError; end
class TagLimitError < Error; end
# Metatags that don't count against the user's tag limit.
UNLIMITED_METATAGS = %w[
status rating limit is id date age filesize filetype parent child md5 width
height duration mpixels ratio score upvote downvotes favcount embedded
tagcount pixiv_id pixiv
]
# Metatags that define the order of search results. These metatags can't be used more than once per query.
ORDER_METATAGS = %w[order ordfav ordfavgroup ordpool]
# Metatags that can't be used more than once per query, and that can't be used with OR or NOT operators.
SINGLETON_METATAGS = ORDER_METATAGS + %w[limit random]
attr_reader :current_user
private attr_reader :tag_limit, :safe_mode, :builder
delegate :tag?, :metatag?, :wildcard?, :metatags, :wildcards, :tag_names, :to_infix, :to_pretty_string, to: :ast
alias_method :safe_mode?, :safe_mode
alias_method :to_s, :to_infix
# Return a new PostQuery with aliases replaced.
def self.normalize(search, ...)
search = search.to_s.strip
# Optimize zero tag and single tag searches
if search.blank?
PostQuery.new(AST.all, ...)
elsif search.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9();/+!?&'._~-]*\z}) && !search.downcase.in?(["and", "or"])
PostQuery.new(AST.tag(search), ...).replace_aliases
else
PostQuery.new(search, ...).replace_aliases.rewrite_opts.trim
end
end
# Perform a search and return the resulting posts
def self.search(search, ...)
post_query = PostQuery.normalize(search, ...)
post_query.validate_tag_limit!
post_query.with_implicit_metatags.posts
end
def initialize(search_or_ast, current_user: User.anonymous, tag_limit: nil, safe_mode: false)
if search_or_ast.is_a?(AST)
@ast = search_or_ast
else
@search = search_or_ast.to_s
end
@current_user = current_user
@tag_limit = tag_limit
@safe_mode = safe_mode
end
# Build a new PostQuery from the given AST and the current settings.
def build(ast)
PostQuery.new(ast, current_user: current_user, tag_limit: tag_limit, safe_mode: safe_mode)
end
def builder
@builder ||= PostQueryBuilder.new(search, current_user, tag_limit: tag_limit, safe_mode: safe_mode)
end
def search
@search ||= ast.to_infix
end
def ast
@ast ||= Parser.parse(search)
end
def posts
validate_metatags!
builder.posts(to_cnf)
end
def paginated_posts(...)
validate_metatags!
builder.paginated_posts(to_cnf, ...)
end
# The name of the only tag in the query, if the query contains a single tag. The tag may not exist. The query may contain other metatags or wildcards, and the tag may be negated.
def tag_name
tag_names.first if has_single_tag?
end
# The only tag in the query, if the query contains a single tag. The query may contain other metatags or wildcards, and the tag may be negated.
def tag
tags.first if has_single_tag?
end
# The list of all tags contained in the query.
def tags
Tag.where(name: tag_names)
end
# True if this search would return all posts (normally because the search is the empty string).
def is_empty_search?
ast.all?
end
# True if this search would return nothing (normally because there was a syntax error).
def is_null_search?
ast.none?
end
# True if the search is a single, non-negated metatag search for the given metatag. Assumes the query has been normalized.
def is_metatag?(name, value = nil)
if value.nil?
metatag? && has_metatag?(name)
else
metatag? && find_metatag(name) == value.to_s
end
end
# True if the search consists of a single tag, metatag, or wildcard.
def is_single_term?
tag_names.size + metatags.size + wildcards.size == 1
end
# True if this search consists only of a single non-negated tag, with no other metatags or operators.
def is_simple_tag?
ast.tag?
end
# True if the search contains a single tag. It may have other metatags or wildcards, and the tag may be negated.
def has_single_tag?
tag_names.one?
end
# True if the search depends on the current user because of permissions or privacy settings.
def is_user_dependent_search?
metatags.any? do |metatag|
metatag.name.in?(%w[upvoter upvote downvoter downvote commenter comm search flagger fav ordfav favgroup ordfavgroup]) ||
metatag.name == "status" && metatag.value == "unmoderated" ||
metatag.name == "disapproved" && !metatag.value.downcase.in?(PostDisapproval::REASONS)
end
end
def select_metatags(*names)
metatags.select { |metatag| metatag.name.in?(names.map(&:to_s).map(&:downcase)) }
end
def has_metatag?(*names)
select_metatags(*names).present?
end
def find_metatag(*names)
select_metatags(*names).first&.value
end
# Return a new PostQuery with unnecessary AND and OR clauses eliminated.
def trim
build(ast.trim)
end
# Return a new PostQuery with the '~' operator replaced with OR clauses.
def rewrite_opts
build(ast.rewrite_opts)
end
# Return a new PostQuery with aliases replaced.
def replace_aliases
return self if aliases.empty?
build(ast.replace_tags(aliases))
end
# Return a new PostQuery with implicit metatags (rating:safe and -status:deleted) added.
def with_implicit_metatags
return self if implicit_metatags.empty?
build(AST.new(:and, [ast, *implicit_metatags]))
end
# Return a new PostQuery with terms sorted into alphabetical order.
def sort
build(ast.sort)
end
# Return a new PostQuery converted to conjunctive normal form.
def to_cnf
build(ast.to_cnf)
end
# Return a hash mapping aliased tag names to real tag names.
def aliases
TagAlias.aliases_for(tag_names)
end
# Implicit metatags are metatags added by the user's account settings. rating:g,s is implicit under safe mode.
def implicit_metatags
return [] unless safe_mode?
tags = Danbooru.config.safe_mode_restricted_tags.map { |tag| -AST.tag(tag) }
[AST.metatag("rating", "g"), *tags]
end
concerning :CountMethods do
# @return [Integer, nil] The number of posts returned by the search, or nil on timeout.
def post_count
@post_count ||= fast_count
end
# Return an estimate of the number of posts returned by the search. By default, we try to use an
# estimated or cached count before doing an exact count.
#
# @param timeout [Integer] The database timeout in milliseconds
# @param estimate_count [Boolean] If true, estimate the count with inexact methods.
# @param skip_cache [Boolean] If true, don't use the cached count.
# @return [Integer, nil] The number of posts, or nil on timeout.
def fast_count(timeout: 1_000, estimate_count: true, skip_cache: false)
count = nil
count = estimated_count if estimate_count
count = cached_count(timeout) if count.nil? && !skip_cache
count = exact_count(timeout) if count.nil? && skip_cache
count
end
def estimated_count
if is_empty_search?
estimated_row_count
elsif is_simple_tag?
tag.try(:post_count)
elsif is_metatag?(:rating)
estimated_row_count
elsif is_metatag?(:pool) || is_metatag?(:ordpool)
name = find_metatag(:pool, :ordpool)
Pool.find_by_name(name)&.post_count || 0
elsif is_metatag?(:fav) || is_metatag?(:ordfav)
name = find_metatag(:fav, :ordfav)
user = User.find_by_name(name)
if user.nil?
0
elsif Pundit.policy!(current_user, user).can_see_favorites?
user.favorite_count
else
nil
end
end
end
# Estimate the count by parsing the Postgres EXPLAIN output.
def estimated_row_count
ExplainParser.new(posts).row_count
end
def cached_count(timeout, duration: 5.minutes)
Cache.get(count_cache_key, duration) do
exact_count(timeout)
end
end
def exact_count(timeout)
Post.with_timeout(timeout) do
posts.count
end
end
def count_cache_key
if is_user_dependent_search?
"pfc[#{current_user.id.to_i}]:#{to_s}"
else
"pfc:#{to_s}"
end
end
end
concerning :ValidationMethods do
def validate_tag_limit!
return if is_empty_search? || is_simple_tag?
raise TagLimitError if tag_limit.present? && term_count > tag_limit
end
def validate_metatags!
return if is_empty_search? || is_simple_tag?
return if metatags.empty?
order_metatags = select_metatags(*ORDER_METATAGS)
raise Error, "#{order_metatags.to_sentence} can't be used together." if order_metatags.size > 1
SINGLETON_METATAGS.each do |name|
metatag = select_metatags(name).first
raise Error, "'#{name}:' can't be used more than once." if select_metatags(name).size > 1
raise Error, "'#{metatag}' can't be negated." if metatag&.parents&.any?(&:not?)
raise Error, "'#{metatag}' can't be used with the 'or' operator." if metatag&.parents&.any?(&:or?)
end
end
# The number of unique tags, wildcards, and metatags in the search, excluding metatags that don't count against the user's tag limit.
def term_count
tag_names.size + wildcards.size + metatags.count { !_1.name.in?(UNLIMITED_METATAGS) }
end
end
memoize :tags, :replace_aliases, :with_implicit_metatags, :to_cnf, :aliases, :implicit_metatags, :term_count
end