このコミットが含まれているのは:
@@ -4,7 +4,7 @@ require 'digest'
|
||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
class Forbidden < StandardError; end
|
||||
ARTIFACT_POLICY = 'counts-masked-summary-2'.freeze
|
||||
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
|
||||
|
||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||
'nico_tag_versions' => ['tag_id'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class CountsAxes
|
||||
MAX_AXES = 8
|
||||
MASKED_TEXT = '********'.freeze
|
||||
MASKED_VALUE = '__counts_private__'.freeze
|
||||
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
|
||||
'created_at'].freeze
|
||||
@@ -8,6 +9,33 @@ class CountsAxes
|
||||
|
||||
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
||||
|
||||
def self.private_field?(field)
|
||||
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
|
||||
end
|
||||
|
||||
def self.visible_ids_sql(definition)
|
||||
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
|
||||
HistoryAccess.sql(definition.model, 'counts_visible')
|
||||
end
|
||||
|
||||
def self.visibility_sql(definition, field)
|
||||
return 'TRUE' if HistoryAccess.admin?
|
||||
|
||||
source = visible_ids_sql(definition)
|
||||
visible = "v.id IN (#{ source })"
|
||||
return visible unless field == 'changed_field'
|
||||
|
||||
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
|
||||
end
|
||||
|
||||
def self.masked_sql(expression, definition, field)
|
||||
return expression if HistoryAccess.admin? || !private_field?(field)
|
||||
|
||||
visible = visibility_sql(definition, field)
|
||||
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
|
||||
end
|
||||
|
||||
def self.time_expression(expression, grain, timezone)
|
||||
raise ArgumentError unless GRAINS.include?(grain)
|
||||
|
||||
@@ -61,13 +89,9 @@ class CountsAxes
|
||||
@definition.fields[field] == :datetime ? self.class.time_expression(
|
||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||
end
|
||||
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
||||
visible = HistoryAccess.sql(@definition.model, 'v')
|
||||
if field == 'changed_field'
|
||||
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
|
||||
end
|
||||
expression = "CASE WHEN #{ visible } " +
|
||||
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
|
||||
if !HistoryAccess.admin? && self.class.private_field?(field)
|
||||
expression = self.class.masked_sql(
|
||||
"CAST((#{ expression }) AS CHAR)", @definition, field)
|
||||
end
|
||||
expression
|
||||
end
|
||||
@@ -78,10 +102,12 @@ class CountsAxes
|
||||
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
||||
identities = ['v.id']
|
||||
if rows.any? { _1['field'] == 'tags' }
|
||||
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
|
||||
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||
"CONCAT('external:', tag_entry.external_tag_id))"
|
||||
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
||||
identities << self.class.masked_sql(
|
||||
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
|
||||
if rows.any? { _1['field'] == 'changed_field' }
|
||||
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
|
||||
end
|
||||
end
|
||||
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
||||
selected += identities.each_with_index.map { |expression, index|
|
||||
@@ -99,8 +125,17 @@ class CountsAxes
|
||||
def joins(fields = all.map { _1['field'] })
|
||||
result = ''
|
||||
if fields.include?('tags')
|
||||
document = 'CAST(v.tags_json AS JSON)'
|
||||
unless HistoryAccess.admin?
|
||||
visible = self.class.visible_ids_sql(@definition)
|
||||
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
|
||||
'ON counts_tags_visible.id = v.id '
|
||||
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
|
||||
"THEN #{ document } ELSE JSON_ARRAY(" +
|
||||
"JSON_OBJECT('tag_id', 0)) END"
|
||||
end
|
||||
result += <<~SQL.squish
|
||||
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
||||
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
|
||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
||||
|
||||
@@ -113,6 +148,13 @@ class CountsAxes
|
||||
condition = @definition.changes.map do |field|
|
||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||
end.join(' OR ')
|
||||
unless HistoryAccess.admin?
|
||||
visible = self.class.visibility_sql(@definition, 'changed_field')
|
||||
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||
fields += " UNION ALL SELECT #{ marker } AS field_key"
|
||||
condition = "((#{ visible }) AND (#{ condition })) OR " +
|
||||
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
|
||||
end
|
||||
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||
end
|
||||
result
|
||||
|
||||
@@ -82,14 +82,32 @@ class CountsCondition
|
||||
|
||||
def apply(scope, entry)
|
||||
raise ArgumentError unless entry.is_a?(Hash)
|
||||
|
||||
if entry['operator'] == 'axis_eq'
|
||||
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
||||
value = entry['value']
|
||||
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
|
||||
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
|
||||
[true, false].include?(value))
|
||||
raise ArgumentError
|
||||
end
|
||||
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
||||
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
|
||||
(value == CountsAxes::MASKED_VALUE ||
|
||||
(value == CountsAxes::MASKED_TEXT &&
|
||||
(@definition.fields[entry['field']] != :string ||
|
||||
['tags', 'changed_field'].include?(entry['field'])))))
|
||||
field = entry.fetch('field')
|
||||
raise ArgumentError unless @definition.axes(@population.history).include?(field)
|
||||
if entry.key?('grain')
|
||||
unless (@definition.fields[field] == :datetime &&
|
||||
CountsAxes::GRAINS.include?(entry['grain']))
|
||||
raise ArgumentError
|
||||
end
|
||||
end
|
||||
return scope.none
|
||||
end
|
||||
scope = @population.condition_scope(scope, entry.fetch('field'))
|
||||
return @population.axis_condition(scope, entry)
|
||||
end
|
||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||
@@ -111,6 +129,13 @@ class CountsCondition
|
||||
|
||||
source, expression = source_for(field[:key])
|
||||
type = field[:type]
|
||||
# Preserve typed input compatibility; display markers are never real typed values.
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
|
||||
return scope.none unless entry['operator'] == 'ne'
|
||||
|
||||
return @population.condition_scope(scope, field[:key])
|
||||
end
|
||||
blank = blank_sql(expression, type)
|
||||
if entry['blank']
|
||||
sql = if source && field[:collection]
|
||||
@@ -119,6 +144,7 @@ class CountsCondition
|
||||
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||
else blank
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
return scope.where(Arel.sql(sql))
|
||||
end
|
||||
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||
@@ -131,6 +157,7 @@ class CountsCondition
|
||||
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||
operator = entry['operator']
|
||||
if type == :interval
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
return interval(scope, source, expression, parsed)
|
||||
end
|
||||
clauses = []
|
||||
@@ -158,6 +185,7 @@ class CountsCondition
|
||||
elsif operator == 'ne'
|
||||
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
scope.where(predicate, *binds)
|
||||
end
|
||||
|
||||
@@ -168,6 +196,13 @@ class CountsCondition
|
||||
|
||||
values = entry['values']
|
||||
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||
values.include?(CountsAxes::MASKED_TEXT) &&
|
||||
(field[:type] != :string || field[:set_type] == 'tag_reference'))
|
||||
return scope.none if entry['operator'] == 'all'
|
||||
|
||||
values = values - [CountsAxes::MASKED_TEXT]
|
||||
end
|
||||
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
||||
|
||||
parsed = values.map do |value|
|
||||
@@ -191,7 +226,10 @@ class CountsCondition
|
||||
|
||||
operator = entry['operator']
|
||||
if parsed.empty?
|
||||
return ['in', 'any'].include?(operator) ? scope.none : scope
|
||||
return scope.none if ['in', 'any'].include?(operator)
|
||||
return scope if entry['values'].empty?
|
||||
|
||||
return @population.condition_scope(scope, field[:key])
|
||||
end
|
||||
source, expression = source_for(field[:key])
|
||||
if field[:set_type] == 'tag_reference'
|
||||
@@ -212,6 +250,7 @@ class CountsCondition
|
||||
predicate = source ? "NOT #{ predicate }" :
|
||||
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
scope.where(predicate, *parsed)
|
||||
end
|
||||
|
||||
|
||||
@@ -70,6 +70,13 @@ class CountsPopulation
|
||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||
end
|
||||
|
||||
# Each predicate keeps its own visibility restriction, including OR branches.
|
||||
def condition_scope(scope, field)
|
||||
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
|
||||
|
||||
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
|
||||
end
|
||||
|
||||
def axis_condition(scope, entry)
|
||||
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
||||
|
||||
@@ -192,88 +199,7 @@ class CountsPopulation
|
||||
|
||||
def apply_condition(entry)
|
||||
@condition ||= CountsCondition.new(self)
|
||||
unless summary_private_condition?(entry)
|
||||
@scope = @condition.apply(@scope, entry)
|
||||
return
|
||||
end
|
||||
|
||||
return if masked_noop?(entry)
|
||||
|
||||
# Evaluate private predicates only on authorised versions. On other versions,
|
||||
# the entire field is the same opaque marker, regardless of its stored value.
|
||||
original = @scope
|
||||
visible = private_visibility_sql(entry['field'])
|
||||
authorised = authorised_private_condition(original.where(Arel.sql(visible)), entry)
|
||||
@scope = if masked_match?(entry)
|
||||
authorised.or(original.where(Arel.sql("NOT (#{ visible })")))
|
||||
else
|
||||
authorised
|
||||
end
|
||||
end
|
||||
|
||||
def authorised_private_condition(scope, entry)
|
||||
marker = '********'
|
||||
if entry['operator'] == 'axis_eq'
|
||||
return scope.none if entry['value'] == marker &&
|
||||
definition.fields[entry['field']] != :string
|
||||
|
||||
return @condition.apply(scope, entry)
|
||||
end
|
||||
|
||||
field = definition.filter_metadata.find { _1[:key] == entry['field'] }
|
||||
return @condition.apply(scope, entry) unless field
|
||||
return @condition.apply(scope, entry) if field[:type] == :string &&
|
||||
field[:set_type] != 'tag_reference'
|
||||
|
||||
if entry['value'] == marker
|
||||
return scope.none if ['eq', 'gt', 'ge', 'lt', 'le', 'range'].include?(entry['operator'])
|
||||
return scope if entry['operator'] == 'ne'
|
||||
end
|
||||
if entry['values'].is_a?(Array) && entry['values'].include?(marker)
|
||||
return scope.none if entry['operator'] == 'all'
|
||||
|
||||
return @condition.apply(scope, entry.merge('values' => entry['values'] - [marker]))
|
||||
end
|
||||
@condition.apply(scope, entry)
|
||||
end
|
||||
|
||||
def summary_private_condition?(entry)
|
||||
params.fetch('mode', 'summary') == 'summary' && !@authorisation_flg &&
|
||||
!HistoryAccess.admin? && !CountsAxes::PUBLIC_FIELDS.include?(entry['field']) &&
|
||||
entry['field'] != 'user.id'
|
||||
end
|
||||
|
||||
def private_visibility_sql(field)
|
||||
visible = HistoryAccess.sql(definition.model, 'v')
|
||||
return visible unless field == 'changed_field'
|
||||
|
||||
"(#{ visible }) AND (#{ HistoryAccess.sql(definition.model, 'previous') })"
|
||||
end
|
||||
|
||||
def masked_noop?(entry)
|
||||
return false if entry['operator'] == 'axis_eq' || entry['blank']
|
||||
set = ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||
return !entry.key?('values') if set
|
||||
|
||||
[entry['value'], entry['end']].all? { _1.nil? || _1 == '' }
|
||||
end
|
||||
|
||||
def masked_match?(entry)
|
||||
marker = '********'
|
||||
return false if entry['blank']
|
||||
|
||||
value = entry['value']
|
||||
case entry['operator']
|
||||
when 'axis_eq', 'eq' then value == marker
|
||||
when 'ne' then value != marker
|
||||
when 'like'
|
||||
pattern = Regexp.escape(value.to_s).gsub('%', '.*').gsub('_', '.')
|
||||
/\A#{ pattern }\z/i.match?(marker)
|
||||
when 'in', 'any' then entry.fetch('values', []).include?(marker)
|
||||
when 'not_in', 'none' then !entry.fetch('values', []).include?(marker)
|
||||
when 'all' then entry.fetch('values', []).all? { _1 == marker }
|
||||
else false
|
||||
end
|
||||
@scope = @condition.apply(@scope, entry)
|
||||
end
|
||||
|
||||
def snapshot_membership(value)
|
||||
@@ -290,10 +216,7 @@ class CountsPopulation
|
||||
def apply_tags
|
||||
raise ArgumentError unless definition.resource == 'posts'
|
||||
|
||||
private = params.fetch('mode', 'summary') == 'summary' && !@authorisation_flg &&
|
||||
!HistoryAccess.admin?
|
||||
original = @scope if private
|
||||
@scope = HistoryAccess.visible(@scope, 'v') if private
|
||||
@scope = condition_scope(@scope, 'tags')
|
||||
if params['tag'].present?
|
||||
@scope = @scope.where(snapshot_membership(
|
||||
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
||||
@@ -317,21 +240,9 @@ class CountsPopulation
|
||||
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
|
||||
literal[:negative] ? "NOT #{ expression }" : expression
|
||||
end
|
||||
if predicates.empty?
|
||||
@scope = original if private && params['tag'].blank?
|
||||
return
|
||||
end
|
||||
return if predicates.empty?
|
||||
|
||||
any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' }
|
||||
@scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })"))
|
||||
return unless private && params['tag'].blank?
|
||||
|
||||
# A masked tag collection cannot match a real tag. Negative terms match
|
||||
# the opaque marker independently of any hidden snapshot membership.
|
||||
hidden = any ? literals.any? { _1[:negative] } : literals.all? { _1[:negative] }
|
||||
if hidden
|
||||
visible = HistoryAccess.sql(definition.model, 'v')
|
||||
@scope = @scope.or(original.where(Arel.sql("NOT (#{ visible })")))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -253,7 +253,7 @@ class CountsTableExport
|
||||
end
|
||||
|
||||
def summary_value(value, index, page)
|
||||
return '********' if value == CountsAxes::MASKED_VALUE
|
||||
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
|
||||
|
||||
field = @query.axes.all.fetch(index).fetch('field')
|
||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||
|
||||
@@ -220,9 +220,8 @@ class CountsTableQuery
|
||||
|
||||
private
|
||||
|
||||
# Summary predicates are evaluated against public coordinates or the masked
|
||||
# value by CountsPopulation. Never accept a private server-only coordinate in
|
||||
# user-supplied ordering/measure state.
|
||||
# Private predicates require visibility independently of their operator.
|
||||
# Ordering/measure state must not select a server-only masked coordinate.
|
||||
def validate_summary_conditions!
|
||||
return if mode != 'summary' || HistoryAccess.admin?
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする