このコミットが含まれているのは:
2026-10-31 13:50:10 +09:00
コミット dba409c3e8
33個のファイルの変更、1490行の追加、236行の削除
+59 -25
ファイルの表示
@@ -12,6 +12,15 @@ class CountsExportsController < ApplicationController
render json: { error: 'export_worker_unavailable' }, status: :service_unavailable
rescue CountsExportDownload::Busy
head :too_many_requests
rescue CountsAxes::TooManyAxes
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
rescue CountsAxes::PrivateCondition
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
@@ -48,6 +57,15 @@ class CountsExportsController < ApplicationController
head :gateway_timeout
rescue CountsExportDownload::StorageFull, Errno::ENOSPC
head :insufficient_storage
rescue CountsAxes::TooManyAxes
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
rescue CountsAxes::PrivateCondition
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
@@ -79,36 +97,43 @@ class CountsExportsController < ApplicationController
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'unavailable')
return head :not_found
end
# Open before committing the response, but never buffer the entire export.
stream = File.open(file.fetch('path'), 'rb')
job = params[:job]
authority = HistoryAccess.user
first_check = true
stream = CountsExportDownload::ResponseBody.new(file.fetch('path')) do |status|
if status == 'active'
if first_check
first_check = false
fresh = authority && User.find_by(id: authority.id)
next false if authority && (!fresh || fresh.banned?)
authority = fresh
HistoryAccess.set(user: authority) do
CountsExportDownload.find(arguments[:file_token], owner) &&
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status:)
end
end
end
unless CountsExportJobs.download_attempt(job, owner, **arguments, status: 'streaming')
stream.close
return head :conflict
end
response.headers['Cache-Control'] = 'private, no-store'
send_file_headers! filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
job = params[:job]
authority = HistoryAccess.user
self.response_body = Enumerator.new do |output|
begin
first = true
while (chunk = stream.read(64 * 1024))
output << chunk
if first
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'started')
end
first = false
end
end
rescue IOError, SystemCallError => error
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'failed')
end
ensure
stream.close
end
end
response.headers['Last-Modified'] = File.mtime(file.fetch('path')).httpdate
response.headers['X-Accel-Buffering'] = 'no'
self.response_body = stream
rescue IOError, SystemCallError
stream&.close
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'failed')
head :service_unavailable
end
@@ -138,6 +163,15 @@ class CountsExportsController < ApplicationController
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
rescue CountsAxes::TooManyAxes
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
rescue CountsAxes::PrivateCondition
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
+39 -2
ファイルの表示
@@ -4,6 +4,7 @@ require 'digest'
class HistoryAccess < ActiveSupport::CurrentAttributes
attribute :user
class Forbidden < StandardError; end
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
'nico_tag_versions' => ['tag_id'],
@@ -18,10 +19,12 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
end
def self.authority
{ 'policy_revision' => revision, 'admin' => admin?, 'actor_id' => user&.id }
{ 'artifact_policy' => ARTIFACT_POLICY, 'policy_revision' => revision,
'admin' => admin?, 'actor_id' => user&.id }
end
def self.authorised_artifact?(metadata)
return false unless metadata['artifact_policy'] == ARTIFACT_POLICY
return false unless metadata['policy_revision'].is_a?(String)
if metadata['admin']
admin? && user.id == metadata['actor_id']
@@ -72,13 +75,47 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))"
end
def self.snapshot_reference(model, row, external: false)
key = external ? 'external_tag_id' : 'tag_id'
table = model.table_name
source = "JSON_TABLE(COALESCE(#{ row }.tags_json, JSON_ARRAY()), '$[*]' COLUMNS (" +
"tag_id BIGINT PATH '$.#{ key }', version_no INT PATH '$.version_no')) tag_reference"
match = 'reference_version.tag_id = tag_reference.tag_id'
if external
match += " AND reference_version.created_at <= #{ row }.created_at"
latest = "AND NOT EXISTS (SELECT 1 FROM #{ table } later_reference " +
'WHERE later_reference.tag_id = reference_version.tag_id ' +
"AND later_reference.created_at <= #{ row }.created_at " +
'AND later_reference.version_no > reference_version.version_no)'
else
match += ' AND reference_version.version_no = tag_reference.version_no'
latest = ''
end
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })"
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|
json_reference(target, key, "JSON_EXTRACT(#{ row }.tags_json, '$[*].#{ field }')")
end + [list_reference('post_versions', 'post_id', "#{ row }.parent_post_ids")]
end + [list_reference('post_versions', 'post_id', "#{ row }.parent_post_ids"),
snapshot_reference(TagVersion, row),
snapshot_reference(NicoTagVersion, row, external: true)]
when 'nico_tag_versions'
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names
# conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
"ON blocked.tag_id = private_tag.tag_id " +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> '' AND " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
when 'tag_versions'
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
when 'tag_description_versions'
+9 -3
ファイルの表示
@@ -61,7 +61,11 @@ class CountsAxes
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
expression = "CASE WHEN #{ HistoryAccess.sql(@definition.model, 'v') } " +
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"
end
expression
@@ -72,8 +76,10 @@ class CountsAxes
return 'COUNT(DISTINCT v.id)' unless rows.any? { _1['field'] == 'tags' }
# Keep tag occurrences even when several private tags share one masked label.
"COUNT(DISTINCT v.id, COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id)))"
values = ['v.id', "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))"]
values << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
"COUNT(DISTINCT #{ values.join(', ') })"
end
def joins(fields = all.map { _1['field'] })
+40
ファイルの表示
@@ -13,6 +13,46 @@ class CountsExportDownload
class TimedOut < StandardError; end
class StorageFull < StandardError; end
# Rack body deliberately has no to_path: sendfile middleware must not bypass ACK.
class ResponseBody
def initialize(path, &state)
File.open(path, 'rb') { |file| file.stat }
@path = path
@stream = nil
@closed = false
@state = state
end
def each
return if @closed
@stream = File.open(@path, 'rb')
first = true
while (chunk = @stream.read(64 * 1024))
raise IOError, 'Download no longer authorised' unless @state.call('active')
yield chunk
if first
# This confirms yielding to Rack, never browser receipt or saving.
@state.call('started')
first = false
end
end
@state.call('failed') if first
rescue StandardError => error
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
@state.call('failed')
raise
ensure
close
end
def close
@closed = true
@stream.close if @stream && !@stream.closed?
end
end
def self.directory
Rails.root.join(ENV.fetch('COUNTS_EXPORT_DIRECTORY', 'tmp/counts_exports'))
end
+62 -4
ファイルの表示
@@ -45,6 +45,20 @@ class CountsExportJobs
write(state)
end
def self.failure_code(error)
case error
when Cancelled then 'cancelled'
when CountsAxes::TooManyAxes then 'too_many_axes'
when CountsAxes::PrivateCondition then 'private_condition'
when CountsTableQuery::TooManyColumns then 'too_many_columns'
when ActiveRecord::StatementTimeout then 'counts_query_timeout'
when CountsExportDownload::TimedOut then 'generation_timeout'
when CountsExportDownload::TooLarge then 'export_too_large'
when CountsExportDownload::StorageFull, Errno::ENOSPC then 'export_storage_full'
else 'generation_failed'
end
end
def self.directory = CountsExportDownload.directory.join('jobs')
def self.verifier = Rails.application.message_verifier('counts_export_job')
@@ -176,6 +190,12 @@ class CountsExportJobs
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
expire_stalled(state)
if (state['status'] == 'ready' &&
['pending', 'streaming'].include?(state['download_status']) &&
state.fetch('download_expires_at', 0) <= Time.current.to_f)
state['download_status'] = 'timeout'
write(state)
end
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
@@ -183,8 +203,11 @@ class CountsExportJobs
state.delete('token')
write(state)
end
state.slice('status', 'phase', 'processed', 'total', 'format', 'token', 'error',
result = state.slice('status', 'phase', 'processed', 'total', 'format', 'token', 'error',
'updated_at', 'heartbeat_at', 'download_attempt', 'download_status')
result['max_axes'] = CountsAxes::MAX_AXES if state['error'] == 'too_many_axes'
result['max_columns'] = CountsTableQuery::MAX_COLUMNS if state['error'] == 'too_many_columns'
result
end
end
@@ -198,18 +221,53 @@ class CountsExportJobs
state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state))
if attempt
return unless state['download_attempt'] == attempt && state['token'] == file_token
return unless ['pending', 'started'].include?(state['download_status'])
return unless state['download_status'] == 'started' ||
state.fetch('download_expires_at', 0) > Time.current.to_f
state['download_status'] = status if status
transitions = { 'streaming' => ['pending'], 'started' => ['streaming'],
'failed' => ['pending', 'streaming', 'started'],
'unavailable' => ['pending', 'streaming'] }
if status == 'active'
return state.slice('download_attempt', 'download_status', 'token') if
['streaming', 'started'].include?(state['download_status'])
return
end
if status
return unless transitions.fetch(status, []).include?(state['download_status'])
state['download_status'] = status
else
return unless state['download_status'] == 'pending'
end
else
# Repeated acceptance POSTs do not invalidate an in-flight GET or issue duplicates.
if (state['download_status'] == 'started' ||
(['pending', 'streaming'].include?(state['download_status']) &&
state.fetch('download_expires_at', 0) > Time.current.to_f))
return state.slice('download_attempt', 'download_status', 'token')
end
state['download_attempt'] = SecureRandom.hex(24)
state['download_status'] = 'pending'
state['download_expires_at'] = Time.current.to_f + 45
end
write(state)
state.slice('download_attempt', 'download_status', 'token')
end
end
# GET already verified authority. During streaming, observe cancellation without
# repeating the six-table policy revision aggregate for every 64KiB chunk.
def self.stream_active?(token, owner, file_token:, attempt:)
id = authorised_id(token, owner)
state = id && read(id)
state && state['owner'] == owner && state['status'] == 'ready' &&
state['expires_at'] > Time.current.to_f && state['token'] == file_token &&
state['download_attempt'] == attempt &&
(state['download_status'] == 'started' ||
(state['download_status'] == 'streaming' &&
state.fetch('download_expires_at', 0) > Time.current.to_f))
end
def self.checkpoint(id, processed = nil, total = nil, phase: nil)
synchronise do
state = read(id)
@@ -398,7 +456,7 @@ class CountsExportJobs
if current
unless current['status'] == 'failed'
current['status'] = error.is_a?(Cancelled) ? 'cancelled' : 'failed'
current['error'] = error.class.name
current['error'] = failure_code(error)
end
current['updated_at'] = Time.current.to_f
write(current)
+14 -7
ファイルの表示
@@ -1,4 +1,4 @@
# Re-aggregate the population, never add up already grouped cells (multi-value axes overlap).
# Re-aggregate distinct membership; tag row tuples alone use additive contributions.
class CountsPivotTotals
def initialize(population, axes, row_headers, column_headers)
@population = population
@@ -8,8 +8,12 @@ class CountsPivotTotals
end
def call
# MySQL's unlimited LIMIT prevents CTE merging: authorise once for all branches.
basis = @population.scope.except(:select, :order).select('v.*')
# Materialise full-cell membership once, including omitted multi-value axes.
membership = @population.scope.except(:select, :order)
joins = @axes.joins
membership = membership.joins(joins) unless joins.empty?
# Retain exactly the records contributing a full cell, including changed_field.
basis = membership.select('v.*').distinct
.limit(18_446_744_073_709_551_615).to_sql
queries = @rows.flat_map do |row_depth, headers|
@columns.filter_map do |column_depth, columns|
@@ -18,7 +22,7 @@ class CountsPivotTotals
indexes = (0...row_depth).to_a +
(0...column_depth).map { @axes.rows.length + _1 }
expressions = indexes.map { @axes.expressions[_1] }
scope = @population.scope.except(:select, :order, :where, :from).from('authorised v')
scope = @population.scope.except(:select, :order, :where, :from).from('population_members v')
additive = @axes.rows.any? { _1['field'] == 'tags' }
retained = additive ? (0...@axes.rows.length).to_a | indexes : indexes
joins = @axes.joins(retained.map { @axes.all[_1].fetch('field') })
@@ -47,9 +51,12 @@ class CountsPivotTotals
if additive
# Sum full row tuples, not page cells; preserve DISTINCT inside each tuple.
leaf = retained.map { @axes.expressions[_1] }
selected = leaf.each_with_index.map { |expression, index| "#{ expression } AS k#{ index }" }
selected = leaf.each_with_index.map do |expression, index|
"#{ expression } AS k#{ index }"
end
selected << "#{ @axes.count_expression } AS count"
inner = scope.group(*leaf.map { Arel.sql(_1) })
.select(Arel.sql((selected + ["#{ @axes.count_expression } AS count"]).join(', '))).to_sql
.select(Arel.sql(selected.join(', '))).to_sql
coordinates = indexes.map { "k#{ retained.index(_1) }" }
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
@@ -67,7 +74,7 @@ class CountsPivotTotals
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
@population.definition.model.connection.select_all(
"WITH authorised AS (#{ basis }) " +
"WITH population_members AS (#{ basis }) " +
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record|
row_depth = record['row_depth'].to_i
column_depth = record['column_depth'].to_i
+12 -1
ファイルの表示
@@ -57,7 +57,8 @@ class CountsPopulation
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
end
# Unrestricted predicates are used only for numerical totals, never record output.
# Aggregate sources are unrestricted; CountsAxes masks their output coordinates.
# Never serialise raw records from an unrestricted population.
def access_sql(model, row)
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
end
@@ -67,6 +68,16 @@ class CountsPopulation
key = entry.fetch('field')
raise ArgumentError unless definition.axes(history).include?(key)
value = entry['value']
unless value.nil?
valid = case definition.fields[key]
when :integer
value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A-?[0-9]+\z/))
when :boolean then [true, false].include?(value)
else value.is_a?(String)
end
raise ArgumentError unless valid
end
if entry.key?('grain')
unless definition.fields[key] == :datetime && CountsAxes::GRAINS.include?(entry['grain'])
raise ArgumentError
+2
ファイルの表示
@@ -253,6 +253,8 @@ class CountsTableExport
end
def summary_value(value, index, page)
return '********' if value == CountsAxes::MASKED_VALUE
field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil?
if ['language_code', 'source_language_code'].include?(field)
+31 -9
ファイルの表示
@@ -49,12 +49,18 @@ class CountsTableQuery
result = metadata.merge(mode == 'summary' ? summary : details)
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
totals = output_totals(visible)
result.merge(visible_output_count: visible,
response = result.merge(visible_output_count: visible,
**totals,
output_count_scope: 'before_authorisation',
output_count_unit: 'regular_result_rows',
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
result[:group_count] > limit)
if mode == 'summary'
response.except!(:excluded_count, :excluded_count_scope)
response[:column_values_editable] = HistoryAccess.admin? ||
!result.fetch(:column_values, []).flatten.include?(CountsAxes::MASKED_VALUE)
end
response
end
end
@@ -228,17 +234,17 @@ class CountsTableQuery
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
private_parameters = ['tags', 'tag_refs', 'tag', 'kind', 'resource_id', 'user_id',
'name_scope'].any? do |key|
keys = ['tags', 'tag_refs', 'tag', 'kind', 'resource_id', 'user_id']
private_parameters = keys.any? do |key|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
end
raise CountsAxes::PrivateCondition if private_fields || private_parameters
['column_order', 'measures'].each do |key|
next if QueryState.records(@params, key).empty?
columns = QueryState.axes(@params.fetch('cols', ''))
raise CountsAxes::PrivateCondition if columns.any? do |axis|
!CountsAxes::PUBLIC_FIELDS.include?(axis['field'])
# Coordinates are compared only to already masked expressions, never raw
# values. Public coordinates remain sortable; a guessed private ID cannot match.
raise CountsAxes::PrivateCondition if QueryState.records(@params, key).any? do |record|
record['value'] == CountsAxes::MASKED_VALUE
end
end
end
@@ -350,6 +356,11 @@ class CountsTableQuery
def measure_relation values
row_expressions = axes.expressions.first(axes.rows.length)
source = summary_population.scope.except(:select, :order)
# Omitted multi-value column axes still determine full-cell membership.
membership = summary_population.scope.except(:select, :order)
all_joins = axes.joins
membership = membership.joins(all_joins) unless all_joins.empty?
source = source.where("v.id IN (#{ membership.select('v.id').distinct.to_sql })")
fields = axes.rows + axes.columns.first(values.length)
joins = axes.joins(fields.map { _1.fetch('field') })
source = source.joins(joins) unless joins.empty?
@@ -367,7 +378,15 @@ class CountsTableQuery
def axis_order_sql key, direction = 'asc', qualifier: nil
column = qualifier ? "#{ qualifier }.#{ key }" : key
expression = column
if axes.all.fetch(key.delete_prefix('a').to_i).fetch('field') == 'category'
field = axes.all.fetch(key.delete_prefix('a').to_i).fetch('field')
if !HistoryAccess.admin? && [:integer, :boolean].include?(@definition.fields[field])
masked = @connection.quote(CountsAxes::MASKED_VALUE)
expression = "CASE WHEN #{ column } <=> #{ masked } THEN NULL " +
"ELSE CAST(#{ column } AS SIGNED) END"
return "#{ column } IS NULL ASC, (#{ column } <=> #{ masked }) ASC, " +
"#{ expression } #{ direction.upcase }"
end
if field == 'category'
# Tag's enum preserves the same category order as TagsController.
cases = Tag.categories.keys.each_with_index.map { |category, index|
"WHEN #{ @connection.quote(category) } THEN #{ index }"
@@ -418,7 +437,10 @@ class CountsTableQuery
labels = case field
when 'tags' then tag_labels(values)
when 'user'
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
actors = HistoryAccess.visible(population.scope, 'v').except(:select, :order)
.select('v.created_by_user_id')
User.where(id: values).where(id: actors)
.pluck(:id, :name).to_h.transform_keys(&:to_s)
else {}
end
[field, labels]
+61
ファイルの表示
@@ -67,3 +67,64 @@ RSpec.describe HistoryAccess do
end
end
end
RSpec.describe 'Counts artifact policy and external references' do
it 'rejects files created before the masked-summary policy' do
HistoryAccess.set(user: nil) do
metadata = HistoryAccess.authority.except('artifact_policy')
expect(HistoryAccess.authorised_artifact?(metadata)).to be(false)
end
end
it 'does not expose a private linked tag name through an external history snapshot' do
tag = create(:tag, primary_name: 'private-linked-name')
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
names: { ja: { name: 'private-linked-name', script_code: 'Jpan',
auto_generated: false, aliases: [] } })
external = create(:external_tag, name: 'public-external')
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:public-external', linked_tags: 'private-linked-name',
created_at: Time.utc(2024))
post = create(:post)
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
title: 'external reference', url: post.url, tags: '',
tags_json: [{ external_tag_id: external.id }], parent_post_ids: '',
created_at: Time.utc(2024, 2))
HistoryAccess.set(user: nil) do
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to be_empty
expect(HistoryAccess.visible(PostVersion.where(id: snapshot.id))).to be_empty
end
HistoryAccess.set(user: create(:user, :admin)) do
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
end
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
name: 'nico:public-external', linked_tags: 'private-linked-name-extra',
created_at: Time.utc(2024, 7))
HistoryAccess.set(user: nil) do
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
expect(HistoryAccess.visible(PostVersion.where(id: snapshot.id))).to be_empty
end
end
it 'propagates the recorded tag version policy to its post snapshot' do
parent = create(:tag, primary_name: 'private parent')
child = create(:tag, primary_name: 'referencing child')
[[parent, 'discard', '', 'private parent'],
[child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name|
TagVersion.create!(tag:, version_no: 1, event_type: event, name:,
category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023),
names: { ja: { name:, script_code: 'Jpan',
auto_generated: false, aliases: [] } })
end
post = create(:post)
row = PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'snapshot',
url: post.url, tags: '', parent_post_ids: '',
tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }],
created_at: Time.utc(2024))
HistoryAccess.set(user: nil) do
expect(HistoryAccess.visible(PostVersion.where(id: row.id))).to be_empty
end
end
end
+98
ファイルの表示
@@ -0,0 +1,98 @@
require 'rails_helper'
require 'tmpdir'
RSpec.describe 'Counts native GET acknowledgement', type: :request do
include ActiveSupport::Testing::TimeHelpers
around do |example|
Dir.mktmpdir('counts-download-request') do |path|
allow(CountsExportDownload).to receive(:directory).and_return(Pathname.new(path))
example.run
end
end
def ready_job(format)
CountsExportJobs.pulse
post '/counts/export/jobs', params: { resource: 'posts', definition: 'history',
mode: 'details', columns: 'resource_id', locale: 'ja', format: }
expect(response).to have_http_status(:accepted)
job = response.parsed_body.fetch('job')
signature = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job')
owner = signature.fetch('owner')
exporter = instance_double(CountsExporter, resource: 'posts', format:, body: ['payload'])
file = HistoryAccess.set(user: nil) { CountsExportDownload.prepare(exporter, owner) }
state = CountsExportJobs.read(signature.fetch('id'))
state.merge!('status' => 'ready', 'token' => file)
CountsExportJobs.write(state)
post "/counts/export/jobs/#{ job }/download"
expect(response).to have_http_status(:ok)
[job, file, response.parsed_body.fetch('download_attempt')]
end
['csv', 'json'].each do |format|
it "acknowledges only an authorised #{ format } GET body, not HEAD or acceptance" do
job, file, attempt = ready_job(format)
get "/counts/export/jobs/#{ job }"
expect(response.parsed_body['download_status']).to eq('pending')
head "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:method_not_allowed)
get "/counts/export/jobs/#{ job }"
expect(response.parsed_body['download_status']).to eq('pending')
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:ok)
expect(response.headers['Content-Disposition']).to include("counts-posts.#{ format }")
expect(response.body).to eq('payload')
get "/counts/export/jobs/#{ job }"
expect(response.parsed_body['download_status']).to eq('started')
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:not_found)
end
end
it 'rejects wrong attempts, cancellation and a newly discarded resource' do
job, file, attempt = ready_job('json')
get "/counts/export/files/#{ file }", params: { job:, attempt: 'wrong' }
expect(response).to have_http_status(:not_found)
delete "/counts/export/jobs/#{ job }"
expect(response).to have_http_status(:ok)
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:not_found)
job, file, attempt = ready_job('json')
record = create(:post)
PostVersion.create!(post: record, version_no: 1, event_type: 'discard', title: 'private',
url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('payload', 'private')
end
it 'does not let a second browser session inspect the job or download its file' do
job, file, attempt = ready_job('json')
other = ActionDispatch::Integration::Session.new(Rails.application)
other.get("/counts/export/jobs/#{ job }")
expect(other.response).to have_http_status(:not_found)
other.get("/counts/export/files/#{ file }", params: { job:, attempt: })
expect(other.response).to have_http_status(:not_found)
expect(other.response.body).not_to include('payload')
end
it 'does not acknowledge an expired attempt' do
job, file, attempt = ready_job('json')
travel_to(Time.current + 46.seconds) do
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:not_found)
get "/counts/export/jobs/#{ job }"
expect(response.parsed_body['download_status']).to eq('timeout')
end
end
it 'records a missing file without accepting the GET as started' do
job, file, attempt = ready_job('json')
owner = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job').fetch('owner')
metadata = HistoryAccess.set(user: nil) { CountsExportDownload.find(file, owner) }
File.unlink(metadata.fetch('path'))
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:not_found)
get "/counts/export/jobs/#{ job }"
expect(response.parsed_body['download_status']).to eq('unavailable')
end
end
+8
ファイルの表示
@@ -266,4 +266,12 @@ RSpec.describe 'Counts table API', type: :request do
expect(json).to eq('error' => 'private_condition')
end
it 'reports the combined axis limit without replacing it with a generic 400' do
get '/counts/query', params: base.merge(mode: 'summary', sort: '',
rows: 'resource_id version_no event_type user created_at:month',
cols: 'title url thumbnail_base video_ms')
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body).to eq('error' => 'too_many_axes', 'max_axes' => 8)
end
end
+39 -1
ファイルの表示
@@ -193,11 +193,49 @@ RSpec.describe 'Counts download acknowledgement' do
arguments = { file_token: 'file-token', attempt: receipt.fetch('download_attempt') }
expect(CountsExportJobs.download_attempt(token, 'other', **arguments)).to be_nil
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments.merge(file_token: 'wrong'))).to be_nil
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'streaming')
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'started')
expect(CountsExportJobs.status(token, 'owner')['download_status']).to eq('started')
CountsExportJobs.download_attempt(token, 'owner')
expect(CountsExportJobs.download_attempt(token, 'owner')['download_attempt'])
.to eq(receipt['download_attempt'])
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'failed')
fresh = CountsExportJobs.download_attempt(token, 'owner')
expect(fresh['download_attempt']).not_to eq(receipt['download_attempt'])
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments)).to be_nil
CountsExportJobs.status(token, 'owner', cancel: true)
expect(CountsExportJobs.download_attempt(token, 'owner')).to be_nil
end
end
RSpec.describe CountsExportDownload::ResponseBody do
it 'acknowledges only after the first Rack yield and closes its bounded file body' do
Dir.mktmpdir('counts-body-spec') do |directory|
path = File.join(directory, 'file')
File.binwrite(path, 'x' * (64 * 1024 + 3))
states = []
body = described_class.new(path) { |state| states << state; true }
chunks = []
body.each do |chunk|
expect(states).not_to include('started') if chunks.empty?
chunks << chunk
end
expect(chunks.map(&:bytesize)).to eq([64 * 1024, 3])
expect(states.count('started')).to eq(1)
expect(body).not_to respond_to(:to_path)
expect { body.close }.not_to raise_error
end
end
it 'records a failed stream without converting an exception into a second HTTP response' do
Dir.mktmpdir('counts-body-spec') do |directory|
path = File.join(directory, 'file')
File.binwrite(path, 'data')
states = []
body = described_class.new(path) { |state| states << state; true }
expect { body.each { raise IOError, 'connection closed' } }.to raise_error(IOError)
expect(states).to include('failed')
expect(states).not_to include('started')
expect { body.close }.not_to raise_error
end
end
end
+169
ファイルの表示
@@ -0,0 +1,169 @@
require 'rails_helper'
require 'csv'
RSpec.describe 'Counts masked pivot contracts' do
let(:admin) { create(:user, :admin) }
let(:member) { create(:user, :member) }
let(:guest) { create(:user, :guest) }
let(:params) do
{ 'resource' => 'posts', 'definition' => 'history', 'mode' => 'summary',
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
'timezone' => 'UTC', 'rows' => 'tags', 'cols' => 'event_type', 'sort' => '' }
end
let!(:tags) do
['A', 'B', 'C'].map do |name|
tag = create(:tag, primary_name: name)
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name:,
category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023))
tag
end
end
before do
[[0, 1], [0], [1, 2]].each_with_index do |indexes, index|
post = create(:post)
snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } }
PostVersion.create!(post:, version_no: 1, event_type: 'create',
title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot,
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1))
next if index == 2
PostVersion.create!(post:, version_no: 2, event_type: 'discard',
title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [],
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2))
end
end
def result(user, extra = {})
HistoryAccess.set(user:) { CountsTableQuery.new(params.merge(extra)).call }
end
def grand(result)
result.fetch(:pivot_totals).find { _1[:row].empty? && _1[:column].empty? }.fetch(:count)
end
it 'preserves five tag occurrences after merging private tags and across pages' do
[nil, guest, member, admin].each do |user|
page = result(user)
expect(grand(page)).to eq(5)
expect(page[:cells].sum { _1[:count] }).to eq(5)
expect(page).not_to have_key(:excluded_count)
paged = result(user, 'limit' => '1')
expect(grand(paged)).to eq(5)
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
.to eq(page[:group_count])
end
public_page = result(nil)
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
expect(hidden.sum { _1[:count] }).to eq(3)
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
end
it 'retains distinct membership when tags are not a row axis' do
[nil, admin].each do |user|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
expect(details[:count]).to eq(user == admin ? 5 : 1)
end
end
it 'uses changed-record membership for totals and hides changes to private predecessors' do
post = PostVersion.order(:id).last.post
PostVersion.create!(post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
url: post.url, tags: '',
tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }],
parent_post_ids: '', created_at: Time.utc(2024, 3))
restored = PostVersion.order(:id).first.post
PostVersion.create!(post: restored, version_no: 3, event_type: 'restore',
title: 'restored-public-title', url: restored.url, tags: '',
tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '',
created_at: Time.utc(2024, 4))
[nil, admin].each do |user|
page = result(user, 'rows' => 'tags changed_field', 'cols' => 'event_type')
expect(grand(page)).to eq(page[:cells].sum { _1[:count] })
expect(page[:cells]).not_to be_empty
if user.nil?
expect(page[:cells].any? { _1[:values][1] == CountsAxes::MASKED_VALUE }).to be(true)
end
expect(page.to_json).not_to include('hidden-title', 'private-discard')
end
end
it 'counts distinct changed records when tags are not additive row tuples' do
[nil, admin].each do |user|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
expect(grand(page)).to eq(2)
if user.nil?
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
end
# Changed discard snapshots have no tags: no full cell contributes here.
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
expect(empty[:cells]).to be_empty
expect(grand(empty)).to eq(0)
end
end
it 'keeps NULL as a public data value rather than treating it as a rollup' do
post = create(:post)
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'public bot',
url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5))
page = result(nil, 'rows' => 'user', 'cols' => 'event_type')
expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1)
expect(grand(page)).to eq(6)
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
end
it 'sorts public numeric values numerically alongside a masked group' do
post = PostVersion.order(:id).last.post
[2, 10].each do |number|
PostVersion.create!(post:, version_no: number, event_type: 'update',
title: 'public numeric sort', url: post.url, tags: '', tags_json: [],
parent_post_ids: '', created_at: Time.utc(2024, 5))
end
page = result(nil, 'rows' => 'version_no', 'cols' => '')
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
end
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
{ 'tags' => 'A' }, { 'tag_refs.0.name' => 'A', 'tag_refs.0.tag_id' => tags.first.id.to_s },
{ 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
'conditions.0.value' => 'hidden-title-0' },
{ 'drill.0.field' => 'tags', 'drill.0.value' => "internal:#{ tags.first.id }" },
{ 'cols' => 'tags', 'column_order.0.value' => CountsAxes::MASKED_VALUE }]
HistoryAccess.set(user: nil) do
probes.each do |probe|
expect { CountsTableQuery.new(params.merge(probe)) }
.to raise_error(CountsAxes::PrivateCondition)
end
end
end
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
'column_order.0.value' => "internal:#{ tags.first.id }")
expect(probe[:column_values]).to eq(first[:column_values])
expect(probe[:cells]).to eq(first[:cells])
expect(probe[:column_values_editable]).to be(false)
expect(probe.to_json).not_to include("internal:#{ tags.first.id }")
end
it 'exports masked coordinates and safe CSV labels from the same pivot' do
HistoryAccess.set(user: nil) do
payload = params.merge('format' => 'json')
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
expect(json.to_json).not_to include('hidden-title', 'private-discard')
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(csv).to include('********')
expect(csv).not_to include(CountsAxes::MASKED_VALUE, 'hidden-title', 'private-discard')
end
end
end