require 'digest' # Request/worker authority comes only from a server-resolved user. Default is public. class HistoryAccess < ActiveSupport::CurrentAttributes attribute :user class Forbidden < StandardError; end KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'], 'nico_tag_versions' => ['tag_id'], 'tag_description_versions' => ['tag_id', 'language_code'], 'tag_group_versions' => ['tag_group_id'], 'material_versions' => ['material_id'] }.freeze def self.revision values = KEYS.keys.map do |table| table.classify.constantize.unscoped.where(event_type: 'discard').maximum(:id) end Digest::SHA256.hexdigest(values.to_json) end def self.authority { 'policy_revision' => revision, 'admin' => admin?, 'actor_id' => user&.id } end def self.authorised_artifact?(metadata) return false unless metadata['policy_revision'].is_a?(String) if metadata['admin'] admin? && user.id == metadata['actor_id'] else metadata['policy_revision'] == revision end end def self.admin? = user&.admin? == true def self.sql(model, row = model.table_name, references: true) return 'TRUE' if admin? table = model.table_name keys = KEYS.fetch(table) matches = keys.map { "boundary.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ') clauses = ["NOT EXISTS (SELECT 1 FROM #{ table } boundary WHERE #{ matches } " + "AND boundary.event_type = 'discard' AND boundary.version_no >= #{ row }.version_no)"] clauses += reference_sql(table, row) if references clauses.map { "(#{ _1 })" }.join(' AND ') end def self.discard_reference(table, key, expression) "NOT EXISTS (SELECT 1 FROM #{ table } blocked WHERE blocked.event_type = 'discard' " + "AND #{ expression.call("blocked.#{ key }") })" end def self.reference_sql(table, row) case table when 'post_versions' [['tag_versions', 'tag_id', 'tag_id'], ['nico_tag_versions', 'tag_id', 'external_tag_id']].map do |target, key, field| discard_reference(target, key, ->(id) { "JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ field }', #{ id }))" }) end + [discard_reference('post_versions', 'post_id', ->(id) { "FIND_IN_SET(#{ id }, REPLACE(#{ row }.parent_post_ids, ' ', ',')) > 0" })] when 'tag_versions' [discard_reference('tag_versions', 'tag_id', ->(id) { "FIND_IN_SET(#{ id }, REPLACE(#{ row }.parent_tag_ids, ' ', ',')) > 0" })] when 'tag_description_versions' [discard_reference('tag_versions', 'tag_id', ->(id) { "#{ row }.tag_id = #{ id }" })] when 'material_versions' [discard_reference('tag_versions', 'tag_id', ->(id) { "#{ row }.tag_id = #{ id }" }), discard_reference('material_versions', 'material_id', ->(id) { "#{ row }.parent_id = #{ id }" })] when 'tag_group_versions' [['tag_versions', 'tag_id'], ['tag_group_versions', 'tag_group_id']].map do |target, field| discard_reference(target, field, ->(id) { ['members', 'placements'].map do |column| "JSON_CONTAINS(COALESCE(JSON_EXTRACT(#{ row }.#{ column }, " + "'$**.#{ field }'), JSON_ARRAY()), CAST(#{ id } AS CHAR))" end.join(' OR ') }) end else [] end end def self.visible(scope, row = scope.klass.table_name) scope.where(Arel.sql(sql(scope.klass, row))) end def self.current(scope, version_model) return scope if admin? table = scope.klass.table_name keys = KEYS.fetch(version_model.table_name) matches = keys.map do |key| column = scope.klass.column_names.include?(key) ? key : 'id' "current_snapshot.#{ key } = #{ table }.#{ column }" end.join(' AND ') boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " + "AND current_snapshot.event_type = 'discard'" scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " + "current_snapshot WHERE #{ boundary })")) condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no" scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " + "current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))")) end def self.require_current!(record, version_model) return if admin? raise Forbidden unless current(record.class.where(id: record.id), version_model).exists? end def self.blob_visible?(blob, seen = []) return true if admin? return false if seen.include?(blob.id) seen = seen + [blob.id] private_versions = MaterialVersion.where(file_blob_id: blob.id) .where(Arel.sql("NOT (#{ sql(MaterialVersion) })")) return false if private_versions.exists? blob.attachments.each do |attachment| model, key = { 'Post' => [PostVersion, 'post_id'], 'Material' => [MaterialVersion, 'material_id'] }[attachment.record_type] if model && model.where(key => attachment.record_id, event_type: 'discard').exists? return false end if attachment.record_type == 'ActiveStorage::VariantRecord' original = ActiveStorage::VariantRecord.find_by(id: attachment.record_id)&.blob return false unless original && blob_visible?(original, seen) end end true end def self.require_many!(records) return if admin? || records.empty? ids = records.map(&:id).uniq raise Forbidden unless visible(records.first.class.where(id: ids)).count == ids.length end def self.require!(record) return record if admin? || visible(record.class.where(id: record.id)).exists? raise Forbidden end # This number deliberately ignores time, identity and content filters to prevent count probes. def self.population_count(model) = model.unscoped.count end