このコミットが含まれているのは:
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
+45 -8
ファイルの表示
@@ -35,7 +35,9 @@ Web とワーカーは同一リリース、Rails secret、アプリケーショ
- POST `/counts/export/jobs`:要求検証・実行時点の固定・署名付きジョブトークンの発行。
- GET `/counts/export/jobs/:token`:状態、実際の処理済みレコード数、判明している総数。
- DELETE `/counts/export/jobs/:token`:取消を永続記録。
- GET `/counts/export/files/:token`:生成完了後の署名付きファイルトークンによる取得。
- POST `/counts/export/jobs/:token/download`:セッション所有者の開始確認要求を登録。
- GET `/counts/export/files/:token?job=...&attempt=...`:実 GET の認可・送出開始の確認。
- GET `/counts/export/files/:token`:既存クライアント向けの認可付きダウンロード。
ジョブ・ファイルのトークンは作成元のセッションに紐付く。
既存の BAN・CSRF 制御を使用する。フロントは約800ms間隔で状態を取得する。
@@ -80,16 +82,22 @@ processed は CSV では実際に生成したデータ行数、JSON のクロス
通常の Counts 応答では、使用していない全履歴の population_count は計算しない。
キーは null、population_count_computed は false とし、0に置き換えない。
現在条件の output_count は権限適用前、visible_output_count は権限適用後の
通常の集計行数(明細では明細行数)。小計・合計の物理行は含めない。
summary は非公開履歴を含む同じ母集団を集計する。非公開の軸はサーバーの CASE 式で
単一の予約値へまとめてから GROUP BY し、output_count / visible_output_count はその
安全化した通常行数を表す。excluded_count は返さない。details は従来どおり
output_count が権限適用前、visible_output_count が適用後の明細行数である。
小計・合計の物理行は行数に含めない。
CSV の物理行と JSON の pivot_cell には集計レコードを含むため、両者を混同しない。
除票境界・参照禁止集合は GROUP BY で識別キー単位にまとめる。
投稿の参照判定は、全除票履歴を JSON_CONTAINS / FIND_IN_SET で反復照合せず、
保存済みスナップショットから参照IDを JSON_TABLE で展開して照合する。
権限条件付きの母集団件数は同一 export インスタンス内で再利用する。
多段小計は、認可済み母集団の CTE を MySQL の無制限 LIMIT で実体化させ、
各枝が同じ認可判定をやり直すことを避ける。計数は COUNT(DISTINCT v.id) のまま。
多段小計は、全軸の JOIN へ実際に寄与するレコードを CTE に保持し、各枝で同じ
母集合を使う。changed_field には直前版がある実変更だけが寄与する。タグが行軸に
ない場合は DISTINCT 投稿版を数える。タグが行軸にある場合は投稿版×タグを数え、
全行軸の葉単位から小計・合計を求める。変更項目も行軸ならその寄与も保持する。
private のタグ・変更項目をまとめても、内部の寄与は捨てず、ラベルだけを保護する。
スキーマ上、以下の追加インデックスが候補となる。migration は作成・実行していない。
@@ -122,6 +130,35 @@ systemd の EnvironmentFile と Web の環境を照合し、directory の owner
アクセス制限、容量、期限切れ削除、worker heartbeat と restart を公開前に確認する。
この作業では systemctl・worker 起動・本番環境への適用を行っていない。
リリース保留:非公開グループと複数値・変更項目軸の交差集計、生成ファイルへの秘匿値の
非混入、実 GET の middleware/proxy 経由の開始通知は、回帰テストと実環境の確認が必要。
テスト・型チェック・build は未実行。
これらのコード対策と回帰テストを追加しているが、テスト・型チェック・build は未実行。
middleware/proxy 経由の転送は別途実環境で確認する。
## 開始確認の競合と権限
開始受付は pending、実 GET の単独取得は streaming、最初の Rack yield 後は started。
同じ job の重複受付は既存 attempt を返す。streaming への遷移はプロセス間ロックで
単独取得し、同一 attempt の二重 GET を拒否する。開始前の attempt は45秒で失効する。
Frontend は30秒の独立タイマーで確認を打ち切る。一時的なポーリング通信断は同じ
attempt で再取得するが、別の GET は始めない。取消失敗後の再試行は利用者の再操作で行う。
ファイル・ジョブは owner、署名、期限、現在の権限 revision 及び artifact_policy を
検証する。最初の送出前にも現ユーザー・BAN・ファイル権限を再確認する。旧ポリシーの
生成物は再利用しない。送出中は各チャンクで取消状態を確認し、例外時はログと failed
状態を記録して body を閉じる。コミット後に head で別レスポンスを書かない。
ResponseBody に to_path を提供せず、sendfile middleware による ACK の迂回を避ける。
Last-Modified と X-Accel-Buffering: no を設定するが、プロキシの動作まで保証しない。
サーバーの最初の Rack yield は、クライアントの受信開始の厳密な証明ではない。
非管理者の summary では、公開集計属性は操作日時・異動区分。その他の属性に対する
抽出条件・補助タグ検索は拒否する。measures / column_order はマスク後の式だけと
比較するため、推測した private ID は一致しない。予約値そのものの条件指定は拒否する。
private ID の正誤に応じて許可可否を変えないため、条件の一致探索を避けられる。
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
公開列の値を使うソートは引き続き利用できる。
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む
スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。
区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から
名前の公開可否を楽観的に推定しない。
+148
ファイルの表示
@@ -0,0 +1,148 @@
# Counts リリース前確認
この作業では Git、テスト、lint、typecheck、build、migration、サーバー・worker・
systemd の起動、本番適用を行っていない。コードの静的確認とテストコードの編集のみ。
以下の実行と結果確認は利用者が行う。未実行の状態は GO としない。
## 対応箇所
- 全件集計と安全な軸:counts_axes.rb / counts_table_query.rb / counts_pivot_totals.rb。
非公開値は GROUP BY 前に統合。通常セル・ソート用集計・小計は同じ寄与母集合を使う。
- 出力:counts_table_export.rb / countsDisplay.ts / CountsSummaryResults.tsx。
CSV とコピーは伏字。JSON の非公開座標は予約値のみ。非公開セルはリンクを作らない。
- 配布:counts_exports_controller.rb / counts_export_jobs.rb / counts_export_download.rb。
owner/job/attempt、単独 GET、期限・現在権限・旧 artifact policy の拒否を確認する。
- 表示:summary 行番号を廃止し「行数」を表示。details の行番号・件数は維持。
非公開列座標のソート・並び替え可否はサーバーの能力フラグへ従う。
- テスト基盤:requestAnimationFrame の同期・時刻0固定を修正。JSDOM の Canvas は
計測非対応を返すテスト専用モックを置く。実ブラウザーの計測動作は変更していない。
## 利用者によるテスト
backend ディレクトリで提案する対象(未実行):
```sh
bundle exec rspec spec/requests/counts_table_spec.rb spec/requests/counts_download_spec.rb spec/services/counts_pivot_security_spec.rb spec/services/counts_export_spec.rb spec/models/history_access_spec.rb spec/requests/post_locale_filter_identity_spec.rb spec/requests/tag_description_wiki_spec.rb
```
frontend ディレクトリで提案する対象(未実行):
```sh
npm run test:run -- src/lib/counts.test.ts src/lib/api.test.ts src/pages/CountsResourcePage.test.tsx src/pages/help/HelpChangesPage.test.tsx src/components/counts/CountsControls.test.tsx src/components/counts/CountsSummaryResults.test.tsx src/components/counts/CountsExportProgress.test.tsx
```
SSR は frontend/server.ssr.test.mjs が dist/server/entry-server.js を読み込む。
ソースの PageHead はサイト名付きの共通タイトルを使用している。古い成果物のまま
SSR テストを実行しても現行ソースの検証にならない。利用者が最新成果物を用意してから
`npm run test:ssr` を実行する。Codex はビルド・SSR テストとも実行していない。
主要テストの後に既存の全テスト群でも回帰の有無を確認する。
## ブラウザーと本番切替前
- anonymous / guest / member / admin で同じ期間・公開条件を指定し、通常セル・総計の
数値を比較する。安全なグループ数は権限で異なり得る。details は秘匿版を出さない。
- 投稿 A,B / A / B,C のタグ行軸は5。タグを行軸から外した合計は distinct 投稿数。
changed_field、NULL、複数段小計、ページ境界、CSV / JSON でも値を比較する。
- 伏字セル、API 辞書、DOM、コピー、CSV / JSON に非公開の値・ID がないこと。
非公開セルのドリルダウンはなく、private 条件の summary は安全な403となること。
- 8軸・100列の専用エラー、SQL timeout、worker 不在、認証・権限、取消失敗を確認する。
- CSV / JSON とも ready / 100% だけでは閉じず、実 GET の matching attempt が
started となってから閉じること。通信遮断・取消・再描画・タイムアウトも確認する。
サーバー送出開始はブラウザーの受信完了・保存完了を保証しない。
- PC・スマホで独立/共通スクロール、D&D、列幅、セル選択・文字列選択・キャレット、
内表 JSON の折返し、ページタイトル、Counts の noindex・先読み禁止を確認する。
locale 切替後も対象 ID と集計値は同一で、全言語の登録値を保持すること。
- Web と worker の同一リリース・secret・DB・COUNTS_EXPORT_DIRECTORY、所有者、
flock / atomic rename、容量、期限切れ掃除、heartbeat、ログ、再起動を照合する。
- systemd unit の User / WorkingDirectory / EnvironmentFile / PATH を実環境と照合する。
設定適用・サービス起動・本番切替はこの作業では行っていない。
- ロール変更・logout・新しい discard 後に、生成済みファイルが再取得できないこと。
- ロールバック時も公開ダウンロードを止め、旧生成物を再配布しない。古いコードへ戻すと
artifact_policy やマスクの保護を失う可能性があるため、無条件に巻き戻さない。
P0 の回帰テストと利用者の動作確認が済むまで判定は NO-GO。
## 静的な対応状況
| 対象 | 状態 | 根拠 |
| --- | --- | --- |
| 非公開軸・参照・全件母集団 | 対応済み(静的確認のみ) | HistoryAccess / CountsAxes / CountsTableQuery と回帰コード |
| タグ・変更項目・小計 | 対応済み(静的確認のみ) | CountsPivotTotals と全セル・ソートの母集合の統一 |
| API / CSV / JSON / コピー / URL | 対応済み(静的確認のみ) | マスク後の値のみを表示・比較し、予約座標の条件化を拒否 |
| GET ACK・競合・取消・期限 | 対応済み(静的確認のみ) | サーバー状態遷移、単独 GET、独立タイマー、回帰コード |
| P1・既知テストのソース上の問題 | 対応済み(静的確認のみ) | 行番号、行数、エラー、RAF、fixture、title 契約 |
| テスト成功・実ブラウザー・本番稼働 | 判定不能 | 実行禁止のため未検証 |
## 今回の編集ファイル
すべて Counts 本体に必要な変更。共有箇所の例外は HistoryAccess の参照秘匿と、
api.ts の Abort/CSRF 待機・native download の機構であり、全域の安全性にも必要。
SQL の速度改善・索引追加は別 Issue とし、今回 migration は追加していない。
### Backend App
- backend/app/models/history_access.rb:外部タグの記録名参照と、投稿の記録済み参照版にも
同じ可視性判定を適用。旧 artifact policy の生成物を拒否。
- backend/app/services/counts_population.rb:軸条件の型・予約値の検証。
- backend/app/services/counts_axes.rb:マスク前の内部寄与を計数し、直前版が非公開の場合も
changed_field の名前を秘匿。
- backend/app/services/counts_table_query.rb:通常セル・ソートの寄与母集合、整数順、
条件表示名、公開列座標だけの操作、summary メタデータを整合。
- backend/app/services/counts_pivot_totals.rb:全セルに寄与する母集合と、タグ行軸の加算。
- backend/app/services/counts_table_export.rb:CSV の予約値を伏字化。
- backend/app/services/counts_export_jobs.rb:開始確認の排他・状態遷移・失効と安全なエラーコード。
- backend/app/services/counts_export_download.rb:64KiB の Rack body、開始確認、取消・close。
- backend/app/controllers/counts_exports_controller.rb:GET 直前・送出直前の再認可と専用エラー。
### Frontend App
- frontend/src/lib/api.ts:CSRF 待機中も中断可能にし、iframe の寿命・失敗通知を整理。
- frontend/src/lib/counts.ts:マスク予約値と列操作の API 型。
- frontend/src/lib/countsDisplay.ts:マスク表示と既知エラーの i18n 解決。
- frontend/src/components/counts/CountsExportProgress.tsx:一つのジョブを維持し、matching ACK、
独立期限、取消・再描画・通信断を処理。保存完了の成功表示はしない。
- frontend/src/components/counts/CountsSummaryResults.tsx:行番号コードの撤去、私的セルの
ドリルダウン抑止、公開列のソート維持。
- frontend/src/components/counts/CountsSortHeader.tsx:禁止座標のソート抑止と伏字の理由表示。
- frontend/src/pages/CountsResourcePage.tsx:専用エラーと、初回失敗後にも再実行できる状態。
- frontend/src/locales/ja/counts.json:承認されたエラー文言。
- frontend/src/locales/ain-Kana/counts.json:同じキーと未翻訳・日本語 fallback の管理。
### テスト・テスト専用基盤
- backend/spec/services/counts_pivot_security_spec.rb(新規):4権限、タグ寄与5、distinct、
changed_field、private group、NULL、整数順、ページ、座標探索、JSON/CSV。
- backend/spec/requests/counts_download_spec.rb(新規):CSV/JSON の実 GET、HEAD、二重取得、
別セッション、失効、取消、ファイル不在、新しい discard。
- backend/spec/services/counts_export_spec.rb:owner/job/attempt の ACK と Rack body の失敗。
- backend/spec/models/history_access_spec.rb:旧 policy、外部タグ参照、記録済みタグ参照版。
- backend/spec/requests/counts_table_spec.rb:専用軸数エラー。
- frontend/src/components/counts/CountsExportProgress.test.tsx(新規):matching ACK、StrictMode、
再描画、開始受付失敗、通信断、独立期限、取消の競合・再試行、unmount、iframe 失敗。
- frontend/src/components/counts/CountsSummaryResults.test.tsx(新規):伏字・理由・コピー・旧行番号。
- frontend/src/components/counts/CountsControls.test.tsx:summary 行番号廃止と表示件数選択。
- frontend/src/pages/CountsResourcePage.test.tsx:初期エラーからの再実行、サイト名付き title。
- frontend/src/lib/api.test.ts:共有 CSRF 待機中の中断、iframe の解放・重複失敗通知。
- frontend/src/lib/counts.test.ts:理由別エラーと任意のサーバー文字列を表示しないこと。
- frontend/src/test/setup.ts:時刻が進む非同期 RAF と Canvas のテスト専用 fallback。
- frontend/server.ssr.test.mjs:共通 title の既存 assertion を維持し canonical と秘匿を補足。
### 文書
- docs/counts-exports.md:実装と一致する計数・ACK・権限・運用上の限界。
- docs/counts-release-checklist.md:この一覧と利用者の検証・公開判定手順。
## 既知失敗の静的な確認結果
- counts_table_spec.rb:TagVersion.names の auto_generated、script_code、aliases があることを
確認。新しい投稿タグ fixture にも schema 必須の sections を設定。
- post_locale_filter_identity_spec.rb:検索文字列で基準集合を取得した後、tag_filters の
応答から references を定義して構造化 URL を作る修正を維持。
- tag_description_wiki_spec.rb:first / last とも対象 ID に限定した delete_all を維持。
- CountsResourcePage.test.tsx / HelpChangesPage.test.tsx:共有 RAF の同期・時刻0固定を修正。
Help の辞書応答は実 getHelpChanges が language を付ける契約も照合済み。
- SSR title:現行ソースはサイト名を含む。dist を使うテストの assertion は削除していない。
上記はソース照合の結果であり、過去の失敗件数が0になったという実行結果ではない。
既知の未修正 P0 を残して GO とする判断はしていない。現時点の阻害事項は、ユーザーに
よる MySQL・ブラウザー・worker/proxy を含む実検証がまだ行われていないことである。
+3
ファイルの表示
@@ -345,6 +345,9 @@ test ('counts SSR uses query/definitions, noindex and the common site title', as
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
assert.match (result.head, /rel="canonical"/)
assert.doesNotMatch (result.html, /private-discard|hidden-title|__counts_private__/)
assert.ok (requests.some (request => {
const url = new URL (request.url, origin)
return url.pathname === '/counts/query' && url.searchParams.get ('resource') === resource
+14
ファイルの表示
@@ -117,3 +117,17 @@ it ('derives mixed parent selection and permits selecting all children again aft
'tags.name_ja', 'tags.name_ain', 'tags.aliases_ja', 'tags.aliases_ain'])
expect (params.get ('columns')).toBe ('tags')
})
it ('offers row numbers only in details and keeps limit in display settings', () => {
const view = renderControls (appliedParams ({ mode: 'summary', row_numbers: '1' }))
expect (screen.queryByRole ('checkbox', { name: '行番号' })).not.toBeInTheDocument ()
const limit = screen.getByRole ('combobox', { name: '表示件数' })
expect (Array.from (limit.querySelectorAll ('option')).map (option => option.value))
.toEqual (['20', '50', '100'])
expect (limit).toHaveValue ('100')
expect (limit.querySelector ('span')).toBeNull ()
view.unmount ()
renderControls (appliedParams ({ row_numbers: '1' }))
expect (screen.getByRole ('checkbox', { name: '行番号' })).toBeChecked ()
})
+198
ファイルの表示
@@ -0,0 +1,198 @@
import { act, cleanup, screen } from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CountsExportProgress from '@/components/counts/CountsExportProgress'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (), apiGet: vi.fn (), apiDelete: vi.fn (), startAPIDownload: vi.fn () }))
vi.mock ('@/lib/api', async original => ({
...await original<typeof import ('@/lib/api')> (), ...api }))
const ready = { status: 'ready', processed: 5, total: 5, token: 'file' }
const receipt = { downloadAttempt: 'attempt', downloadStatus: 'pending', token: 'file' }
const controls = () => ({ close: vi.fn (), setActions: vi.fn (), confirm: vi.fn () })
const flush = async (milliseconds = 0) => act (async () => {
await vi.advanceTimersByTimeAsync (milliseconds)
})
beforeEach (() => {
vi.useFakeTimers ()
Object.values (api).forEach (mock => mock.mockReset ())
api.apiPost.mockImplementation (async (path: string) =>
path.endsWith ('/download') ? receipt : { job: 'job' })
api.apiGet.mockResolvedValue (ready)
api.apiDelete.mockResolvedValue ({ status: 'cancelled', processed: 5, total: 5 })
api.startAPIDownload.mockReturnValue (vi.fn ())
})
afterEach (() => { cleanup (); vi.useRealTimers () })
const renderProgress = (format = 'csv') => {
const actions = controls ()
const handle = { cancel: async () => false }
const element = (next = actions) => <StrictMode>
<CountsExportProgress payload={{ resource: 'posts', format }} locale="ja"
handle={handle} controls={next}/>
</StrictMode>
return { ...renderWithProviders (element ()), actions, handle, element }
}
describe ('Counts native download acknowledgement', () => {
it.each (['csv', 'json']) ('closes %s after the matching GET acknowledgement', async format => {
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValue ({ ...ready,
downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress (format)
await flush ()
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
expect (view.actions.close).not.toHaveBeenCalled ()
const query = api.startAPIDownload.mock.calls[0][3] as URLSearchParams
expect (Object.fromEntries (query)).toEqual ({ job: 'job', attempt: 'attempt' })
const next = controls ()
view.rerender (view.element (next))
await flush (800)
expect (next.close).toHaveBeenCalledTimes (1)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('times out independently of pending status requests', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockImplementation (() => new Promise (() => {}))
const view = renderProgress ()
await flush ()
await flush (30_000)
expect (screen.getByRole ('alert')).toHaveTextContent ('タイムアウト')
expect (view.actions.close).not.toHaveBeenCalled ()
expect (api.startAPIDownload.mock.results[0].value).toHaveBeenCalled ()
})
it ('retains the modal after acceptance failure without repeated POSTs', async () => {
api.apiPost.mockResolvedValueOnce ({ job: 'job' })
.mockRejectedValueOnce (new Error ('offline'))
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).not.toHaveBeenCalled ()
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('retries a temporary status transport failure without a second native GET', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValueOnce (new Error ('offline'))
.mockResolvedValue ({ ...ready, downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress ()
await flush ()
await flush (2800)
expect (view.actions.close).toHaveBeenCalledTimes (1)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('cancels an acceptance in flight and never starts the resulting receipt', async () => {
let resolve!: (value: typeof receipt) => void
api.apiPost.mockResolvedValueOnce ({ job: 'job' }).mockImplementationOnce (
() => new Promise (complete => { resolve = complete }))
const view = renderProgress ()
await flush ()
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
await act (async () => { resolve (receipt) })
await flush (1000)
expect (api.startAPIDownload).not.toHaveBeenCalled ()
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('releases an unmounted iframe and ignores late acknowledgements', async () => {
const view = renderProgress ()
await flush ()
const release = api.startAPIDownload.mock.results[0].value
view.unmount ()
await flush ()
expect (release).toHaveBeenCalledTimes (1)
await flush (40_000)
expect (view.actions.close).not.toHaveBeenCalled ()
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('keeps authentication expiry and iframe errors visible', async () => {
const view = renderProgress ()
await flush ()
act (() => { api.startAPIDownload.mock.calls[0][2] () })
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('shows the worker-specific reason instead of a download-success message', async () => {
api.apiPost.mockRejectedValueOnce ({ isAxiosError: true,
response: { status: 503, data: { error: 'export_worker_unavailable' } } })
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('ワーカーが稼働していません')
expect (view.actions.close).not.toHaveBeenCalled ()
expect (await view.handle.cancel ()).toBe (true)
})
it ('shows revoked authority and does not close after a successful acceptance', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValue ({ isAxiosError: true,
response: { status: 404 } })
const view = renderProgress ()
await flush ()
await flush (800)
expect (screen.getByRole ('alert')).toHaveTextContent ('認可が失われたか')
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('stops after cancellation failure and permits an explicit cancellation retry', async () => {
const view = renderProgress ()
await flush ()
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
await act (async () => { expect (await view.handle.cancel ()).toBe (false) })
const requests = api.apiGet.mock.calls.length
await flush (40_000)
expect (api.apiGet).toHaveBeenCalledTimes (requests)
expect (screen.getByRole ('alert')).toBeInTheDocument ()
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
expect (api.apiDelete).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('ignores a different attempt even when it reports started', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValueOnce ({ ...ready,
downloadAttempt: 'another', downloadStatus: 'started' }).mockResolvedValue ({ ...ready,
downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress ()
await flush ()
await flush (800)
expect (view.actions.close).not.toHaveBeenCalled ()
await flush (800)
expect (view.actions.close).toHaveBeenCalledTimes (1)
})
it ('keeps the created job available for cancellation retry during initial registration', async () => {
let resolve!: (value: { job: string }) => void
api.apiPost.mockImplementationOnce (() => new Promise (complete => { resolve = complete }))
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
const view = renderProgress ()
await flush ()
let pending!: Promise<boolean>
act (() => { pending = view.handle.cancel () })
await act (async () => {
resolve ({ job: 'created-during-cancel' })
expect (await pending).toBe (false)
})
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
expect (api.apiDelete.mock.calls.map (call => call[0])).toEqual ([
'/counts/export/jobs/created-during-cancel', '/counts/export/jobs/created-during-cancel'])
expect (api.startAPIDownload).not.toHaveBeenCalled ()
})
it ('stops immediately if native download navigation cannot be created', async () => {
api.startAPIDownload.mockImplementationOnce (() => { throw new Error ('navigation blocked') })
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (api.apiGet).toHaveBeenCalledTimes (1)
expect (view.actions.close).not.toHaveBeenCalled ()
})
})
+169 -113
ファイルの表示
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import LocalisedText from '@/components/LocalisedText'
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
import { countsErrorMessage } from '@/lib/countsDisplay'
import { useClientMotion } from '@/lib/useClientMotion'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils'
@@ -26,178 +27,228 @@ const CountsExportProgress = (
const motion = useClientMotion ()
const [state, setState] = useState<ExportStatus> ({
status: 'starting', processed: 0, total: null })
const [cancelError, setCancelError] = useState (false)
const [cancelError, setCancelError] = useState<unknown> (null)
const [failure, setFailure] = useState<unknown> (null)
const [downloadError, setDownloadError] = useState<string | null> (null)
// One mounted dialogue owns one immutable job, even if its parent rerenders.
const initial = useRef ({ payload, locale })
const actions = useRef (controls)
actions.current = controls
const terminal = useRef (false)
const finished = useRef (false)
const cancelAction = useRef<() => Promise<boolean>> (async () => false)
const cancelled = useRef (false)
const generation = useRef (0)
const job = useRef<string | null> (null)
const creation = useRef<Promise<{ job: string }> | null> (null)
const downloaded = useRef (false)
const registration = useRef (false)
const registration = useRef<Promise<ExportStatus> | null> (null)
const attempt = useRef<string | null> (null)
const downloadDeadline = useRef (0)
const acknowledgementTimer = useRef<ReturnType<typeof setTimeout> | null> (null)
const releaseDownload = useRef<(() => void) | null> (null)
const [downloadError, setDownloadError] = useState<string | null> (null)
const pollRequest = useRef<AbortController | null> (null)
const cancellation = useRef<Promise<boolean> | null> (null)
useEffect (() => { handle.cancel = () => cancelAction.current () }, [handle])
useEffect (() => {
handle.cancel = async () => {
if (terminal.current) return true
const epoch = ++generation.current
let active = true
let lastResponse = Date.now ()
let timer: ReturnType<typeof setTimeout> | null = null
let acknowledgementTimer: ReturnType<typeof setTimeout> | null = null
let request: AbortController | null = null
const current = () => active && generation.current === epoch
const stop = (reason: string) => {
if (!current () || terminal.current) return
terminal.current = true
if (timer != null) clearTimeout (timer)
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
request?.abort ()
releaseDownload.current?.()
releaseDownload.current = null
setDownloadError (reason)
}
const cancel = (): Promise<boolean> => {
if (finished.current || (terminal.current && job.current == null))
return Promise.resolve (true)
cancelled.current = true
if (job.current != null)
{
terminal.current = true
if (timer != null) clearTimeout (timer)
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
request?.abort ()
releaseDownload.current?.()
releaseDownload.current = null
cancellation.current ??= (async () => {
const controller = new AbortController ()
const deadline = setTimeout (() => controller.abort (), 15_000)
try
{
setCancelError (false)
const created = await creation.current
if (created == null) return false
job.current = created.job
const result = await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (job.current)}`, {
locale, signal: controller.signal })
setState (result)
`/counts/export/jobs/${encodeURIComponent (created.job)}`, {
locale: initial.current.locale, signal: controller.signal })
if (current ())
{
setState (result)
setCancelError (null)
finished.current = ['cancelled', 'failed'].includes (result.status)
terminal.current = finished.current
if (!terminal.current)
{
if (timer != null) clearTimeout (timer)
timer = setTimeout (() => poll (created.job), 800)
}
}
return ['cancelled', 'failed'].includes (result.status)
}
catch
catch (error)
{
setCancelError (true)
if (isApiError (error) && error.response?.status === 404) return true
if (current ()) setCancelError (error)
return false
}
finally
{
clearTimeout (deadline)
cancellation.current = null
}
}
return false
}) ()
return cancellation.current
}
cancelAction.current = cancel
const armDeadline = () => {
if (downloadDeadline.current === 0) return
acknowledgementTimer = setTimeout (() => stop ('timeout'),
Math.max (0, downloadDeadline.current - Date.now ()))
}
}, [handle, locale])
useEffect (() => {
let active = true
let lastResponse = Date.now ()
let timer: ReturnType<typeof setTimeout> | undefined
const poll = async (token: string) => {
if (!current () || terminal.current) return
const controller = new AbortController ()
pollRequest.current = controller
request = controller
const deadline = setTimeout (() => controller.abort (), 15_000)
try
{
const response = await apiGet<ExportStatus> (
const result = await apiGet<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, {
locale, signal: controller.signal })
if (!active || terminal.current) return
const result = (cancelled.current && response.status === 'ready')
? await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, {
locale, signal: controller.signal }) : response
if (!active || terminal.current) return
locale: initial.current.locale, signal: controller.signal })
if (!current () || terminal.current) return
lastResponse = Date.now ()
setState (result)
terminal.current = ['failed', 'cancelled'].includes (result.status)
if (result.status === 'ready' && result.token != null && !downloaded.current)
if (cancelled.current)
{
registration.current = true
const receipt = await apiPost<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
locale, signal: controller.signal })
if (!active || cancelled.current) return
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
downloaded.current = true
attempt.current = receipt.downloadAttempt
downloadDeadline.current = Date.now () + 30_000
acknowledgementTimer.current = setTimeout (() => {
if (!active || terminal.current) return
terminal.current = true
pollRequest.current?.abort ()
releaseDownload.current?.()
setDownloadError ('timeout')
}, 30_000)
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
if (active)
if (await cancel ()) return
}
else if (result.status === 'ready')
{
if (result.token == null) { stop ('failed'); return }
if (attempt.current == null)
{
registration.current ??= apiPost<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
locale: initial.current.locale, signal: controller.signal })
const receipt = await registration.current
if (!current () || terminal.current || cancelled.current) return
if (receipt.downloadAttempt == null) { stop ('failed'); return }
attempt.current = receipt.downloadAttempt
downloadDeadline.current = Date.now () + 30_000
armDeadline ()
try
{
releaseDownload.current = startAPIDownload ('/counts/export', result.token,
() => stop ('failed'), new URLSearchParams ({
job: token, attempt: receipt.downloadAttempt }))
}
catch
{
stop ('failed')
return
}
}
if (result.downloadAttempt === attempt.current)
{
if (result.downloadStatus === 'started')
{
terminal.current = true
setDownloadError ('failed')
finished.current = true
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
// Rack yielded a chunk; browser receipt and saving are not confirmed.
releaseDownload.current = null
actions.current.close ()
return
}
}, new URLSearchParams ({ job: token, attempt: receipt.downloadAttempt }))
}
if (attempt.current != null && result.downloadAttempt === attempt.current)
{
if (result.downloadStatus === 'started' && !cancelled.current)
{
terminal.current = true
// Native navigation continues after the dialogue unmounts.
releaseDownload.current = null
controls.close ()
return
}
if (['failed', 'unavailable'].includes (result.downloadStatus ?? ''))
{
terminal.current = true
setDownloadError (result.downloadStatus!)
if (['failed', 'unavailable', 'timeout'].includes (result.downloadStatus ?? ''))
{ stop (result.downloadStatus!); return }
}
}
if (attempt.current != null && Date.now () > downloadDeadline.current)
else if (['failed', 'cancelled'].includes (result.status))
{
finished.current = true
terminal.current = true
setDownloadError ('timeout')
releaseDownload.current?.()
return
}
if (!terminal.current) timer = setTimeout (() => poll (token), 800)
if (timer != null) clearTimeout (timer)
timer = setTimeout (() => poll (token), 800)
}
catch (error)
{
if (active && !terminal.current)
{
if (downloaded.current || registration.current || (isApiError (error) && error.response?.status === 404)
|| Date.now () - lastResponse > 30_000)
{
terminal.current = true
setDownloadError ('unavailable')
setState (previous => ({ ...previous, status: 'failed' }))
return
}
setCancelError (true)
timer = setTimeout (() => poll (token), 2000)
}
if (!current () || terminal.current) return
const status = isApiError (error) ? error.response?.status : null
if ([400, 401, 403, 422].includes (status ?? 0)) setFailure (error)
if (registration.current != null && attempt.current == null)
{ stop ('failed'); return }
if (status != null && status >= 400 && status < 500)
{ stop ('unavailable'); return }
if (Date.now () - lastResponse >= 30_000)
{ stop ('timeout'); return }
timer = setTimeout (() => poll (token), 2000)
}
finally
{
clearTimeout (deadline)
if (pollRequest.current === controller) pollRequest.current = null
if (request === controller) request = null
}
}
if (creation.current == null)
{
const controller = new AbortController ()
const deadline = setTimeout (() => controller.abort (), 15_000)
creation.current = apiPost<{ job: string }> ('/counts/export/jobs', payload, {
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
creation.current = apiPost<{ job: string }> (
'/counts/export/jobs', initial.current.payload, {
locale: initial.current.locale, signal: controller.signal })
.finally (() => clearTimeout (deadline))
}
creation.current.then (async result => {
if (!active || terminal.current) return
armDeadline ()
void creation.current.then (result => {
if (!current () || terminal.current) return
job.current = result.job
if (cancelled.current) await handle.cancel ()
await poll (result.job)
}).catch (() => {
if (active)
{
setDownloadError ('failed')
terminal.current = true
setState (previous => ({ ...previous, status: 'failed' }))
}
void poll (result.job)
}).catch (error => {
if (current ()) setFailure (error)
stop ('failed')
})
return () => {
active = false
if (timer != null) clearTimeout (timer)
pollRequest.current?.abort ()
releaseDownload.current?.()
if (acknowledgementTimer.current != null) clearTimeout (acknowledgementTimer.current)
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
request?.abort ()
// StrictMode immediately installs another effect; real unmount releases the frame.
queueMicrotask (() => {
if (generation.current !== epoch) return
releaseDownload.current?.()
releaseDownload.current = null
})
}
}, [handle, locale])
useEffect (() => {
controls.setActions ([])
}, [controls])
}, [])
useEffect (() => { controls.setActions ([]) }, [controls])
const percent = state.total == null ? null : (state.total === 0 ? 100
: Math.min (100, Math.floor (state.processed / state.total * 100)))
const busy = !['ready', 'failed', 'cancelled'].includes (state.status)
const phase = message (`exportProgress.${state.status === 'running'
? state.phase ?? 'running' : state.status}`)
const busy = downloadError == null && cancelError == null
&& !['failed', 'cancelled'].includes (state.status)
const phaseKey = (downloadError != null || cancelError != null) ? 'failed'
: (state.status === 'running' ? state.phase ?? 'running'
: (state.status === 'ready' ? 'writing' : state.status))
const phase = message (`exportProgress.${phaseKey}`)
const failureMessage = countsErrorMessage (failure ?? state, locale)
const cancellationMessage = countsErrorMessage (cancelError, locale)
return <div className="space-y-3 py-2" aria-busy={busy}>
<p>{label ('exportProgress.format', {
format: message (`exportFormats.${payload.format}`).text })}</p>
@@ -214,9 +265,14 @@ const CountsExportProgress = (
? label ('exportProgress.processed', { count: state.processed })
: label ('exportProgress.measured', {
processed: state.processed, total: state.total ?? 0, percent })}</p>
{downloadError != null && <p role="alert">
{label (`downloadErrors.${downloadError}`)}</p>}
{cancelError && <p role="alert">{label ('common:fetchError')}</p>}
{(downloadError != null || state.status === 'failed') && <p role="alert"
lang={failureMessage.language}>
{failure != null || state.status === 'failed'
? failureMessage.text
: label (`downloadErrors.${downloadError}`)}
{downloadError != null && <><br/>{label ('errors.stopped')}</>}</p>}
{cancelError != null && <p role="alert" lang={cancellationMessage.language}>
{cancellationMessage.text}</p>}
</div>
}
+7 -5
ファイルの表示
@@ -11,14 +11,15 @@ import type { CountSort } from '@/lib/counts'
const CountsSortHeader = (
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true, dragId, totalKind }: {
{ field, label, sorts, change, structured = false, rowSpan, colSpan,
resizable = true, dragId, totalKind, disabled = false }: {
field: string
label: { text: string; language: string }
sorts: CountSort[]
change: (key: string, additive: boolean) => void
structured?: boolean
rowSpan?: number; colSpan?: number; resizable?: boolean
dragId?: string; totalKind?: string },
dragId?: string; totalKind?: string; disabled?: boolean },
) => {
const { message } = useUITranslation ('counts')
const family = useContext (CountsTableScope)
@@ -84,7 +85,7 @@ const CountsSortHeader = (
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
<div ref={heading} className="flex min-w-0 items-center">
{shell?.handle}
<Button type="button" variant="ghost" size="sm"
<Button type="button" variant="ghost" size="sm" disabled={disabled}
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
lang={aria.language} aria-label={aria.text} title={title.text}
onPointerDown={event => {
@@ -141,8 +142,9 @@ const CountsSortHeader = (
</div>
{resizable && <CountsResizeHandle field={field} label={label}/>}
</th>)
return dragId == null ? render () : <SortableItem id={dragId} name={message ('reorder', { column: label.text })}
handleId={`column-${dragId}`} moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
return dragId == null ? render () : <SortableItem id={dragId}
name={message ('reorder', { column: label.text })} handleId={`column-${dragId}`}
moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
}
+46
ファイルの表示
@@ -0,0 +1,46 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import { CountsDefinitionContext } from '@/lib/countsDefinitions'
import { postDefinition } from '@/test/fixtures/counts'
import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts'
const result: CountsResult = {
count: 4, page: 1, limit: 100, groupCount: 1, outputCount: 1,
rows: [{ field: 'resource_id' }], cols: [], columnValuesEditable: false,
rowHeaders: [{ values: ['__counts_private__'], kind: 'data' },
{ values: [], kind: 'grand_total' }],
columnHeaders: [{ values: [], kind: 'grand_total' }],
cells: [{ values: ['__counts_private__'], count: 4 }],
pivotTotals: [{ row: [], column: [], count: 4 }], axisLabels: {} }
describe ('Counts summary privacy and row numbering', () => {
it ('copies masked labels and omits private links and legacy numbering', () => {
const { container } = renderWithProviders (
<CountsDefinitionContext.Provider value={postDefinition}>
<CountsTableLayout><CountsSummaryResults resource="posts" data={result}
params={new URLSearchParams ('mode=summary&row_numbers=1&rows=resource_id&cols=')}
pending={false} update={vi.fn ()}/></CountsTableLayout>
</CountsDefinitionContext.Provider>, { route: '/ja/counts/posts' })
const label = screen.getByText ('********')
expect (label).toHaveAttribute ('title', expect.stringContaining ('集計件数には含まれます'))
expect (label).toHaveClass ('text-red-600', 'dark:text-red-400')
const row = label.closest ('tr')!
expect (row.querySelector ('a')).toBeNull ()
expect (container.innerHTML).not.toContain ('__counts_private__')
expect (screen.queryByText ('No.')).not.toBeInTheDocument ()
expect (row.querySelectorAll ('td')).toHaveLength (2)
const table = screen.getByRole ('grid')
table.focus ()
fireEvent.keyDown (table, { key: 'Home' })
const setData = vi.fn ()
fireEvent.copy (table, { clipboardData: { setData } })
const plain = setData.mock.calls.find (entry => entry[0] === 'text/plain')?.[1]
expect (plain).toContain ('********')
expect (plain).not.toContain ('__counts_private__')
})
})
+28 -26
ファイルの表示
@@ -1,17 +1,17 @@
import SortableColumns from '@/components/common/SortableColumns'
import { countsSummaryValue } from '@/lib/countsDisplay'
import PrefetchLink from '@/components/PrefetchLink'
import CountsRowNumber from '@/components/counts/CountsRowNumber'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { changeCountSort, countsMaskedValue, countsRequestParams, readCountSort, writeCountSort }
from '@/lib/counts'
import { countsAxisLabel, useCountsDefinition }
from '@/lib/countsDefinitions'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState }
from '@/lib/queryState'
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
@@ -21,12 +21,12 @@ const CountsSummaryTable = (
{ resource, data, params, pending, update, dragIds = {} }: {
dragIds?: Record<string, string>
resource: CountsResource; data: CountsResult; params: URLSearchParams
pending: boolean; update: (key: string, value: string, extra?: Record<string, string>) => void },
pending: boolean
update: (key: string, value: string, extra?: Record<string, string>) => void },
) => {
const definition = useCountsDefinition ()
const locale = useLocale ()
const { message } = useUITranslation ('counts')
const numbered = false
const rows = data.rows ?? []
const cols = data.cols ?? []
const cells = data.cells ?? []
@@ -49,7 +49,8 @@ const CountsSummaryTable = (
const headingLevels = cols.length === 0 ? 1 : levels * 2
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
header.values.map ((value, index) => display (value, axes[index], offset + index))
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
const columnKey = (header: CountsPivotHeader) =>
`${header.kind}:${JSON.stringify (header.values)}`
const measures = readCountState<Record<string, string | number | null>[]> (
params, 'measures', [])
const measureKey = (header: CountsPivotHeader) => {
@@ -66,13 +67,14 @@ const CountsSummaryTable = (
const next = new URLSearchParams (params)
const nextSort = changeCountSort (sorts, key, additive)
const active = new Set (nextSort.map (sort => sort.key))
const records = measures.filter (record => record.key !== key && active.has (String (record.key)))
const records = measures.filter (record =>
record.key !== key && active.has (String (record.key)))
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
update ('sort', writeCountSort (nextSort),
queryRecordParams (next, 'measures'))
}
const keys = [...(numbered ? ['row_number'] : []),
const keys = [
...Array.from ({ length: leading }, (_, index) => `a${index}`),
...columnHeaders.map (columnKey)]
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
@@ -86,22 +88,21 @@ const CountsSummaryTable = (
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
const next = new URLSearchParams (params)
const existing = readCountState<Record<string, string | number | null>[]> (params, 'conditions', [])
const existing = readCountState<Record<string, string | number | null>[]> (
params, 'conditions', [])
setCountState (next, 'conditions', encodeCountState ('conditions', [...existing,
...row.values.map ((value, index) => ({ ...rows[index], operator: 'axis_eq', value })),
...column.values.map ((value, index) => ({ ...cols[index], operator: 'axis_eq', value }))]))
...column.values.map ((value, index) => ({
...cols[index], operator: 'axis_eq', value }))]))
next.set ('mode', 'details')
next.delete ('sort')
next.delete ('page')
return `/counts/${resource}?${next}`
}
let ordinal = (data.page - 1) * data.limit
const rowLabels = rowHeaders.map (header => {
const labels = names (header, rows, 0)
// With no row axes the sole result row is not an additional rollup row.
const number = rows.length === 0 ? '1' : (header.kind === 'data' ? String (++ordinal) : '')
if (header.kind !== 'data') labels.push (aggregateLabel (header))
return [...(numbered ? [number] : []),
return [
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
@@ -121,11 +122,10 @@ const CountsSummaryTable = (
return span
}))
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
if (span === 0) labels[column] = ''
}))
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
...Array.from ({ length: leading }, (_, index) =>
level === 0 && rows[index] != null
? countsAxisLabel (rows[index], definition, locale).text : ''),
@@ -142,7 +142,7 @@ const CountsSummaryTable = (
return display (header.values[level], cols[level], rows.length + level)
})])
const axisHeadingRows = cols.map ((axis, level) => [
...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''),
...Array.from ({ length: leading }, () => ''),
...columnHeaders.map ((header, index) => {
if (header.values.length < level) return ''
const previous = columnHeaders[index - 1]
@@ -155,7 +155,7 @@ const CountsSummaryTable = (
: axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
if (cols.length > 0)
{
for (let index = 0; index < leading + (numbered ? 1 : 0); ++index)
for (let index = 0; index < leading; ++index)
{
headingRows[0][index] = valueHeadingRows[0][index]
headingRows[1][index] = ''
@@ -214,6 +214,7 @@ const CountsSummaryTable = (
const field = leaf ? columnKey (header) : sortKey
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
colSpan={span} resizable={leaf}
disabled={header.values.includes (countsMaskedValue)}
rowSpan={(aggregate && cols.length > 0) ? (levels - level) * 2 - 1 : 1}
totalKind={header.kind !== 'data' ? header.kind : undefined}
dragId={level === 0 && header.values.length > 0
@@ -235,7 +236,6 @@ const CountsSummaryTable = (
<thead className="sticky top-0 z-20">
{headingRows.map ((_, tier) => <tr key={tier}>
{tier === 0 && <>
{numbered && <CountsRowNumber rowSpan={headingLevels}/>}
{Array.from ({ length: leading }, (_, index) => rows[index] == null
? <th key={index} rowSpan={headingLevels}/>
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
@@ -249,7 +249,7 @@ const CountsSummaryTable = (
</thead>
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => {
const axis = offset - (numbered ? 1 : 0)
const axis = offset
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
@@ -259,8 +259,8 @@ const CountsSummaryTable = (
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
numbered && offset === 0 && 'text-right tabular-nums')}>{value === '********' ? <span
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700">
{value === '********' ? <span
className="text-red-600 dark:text-red-400"
title={message ('maskedReason').text}>{value}</span> : value}</td>
})}
@@ -273,8 +273,9 @@ const CountsSummaryTable = (
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className="border px-2 py-1 dark:border-slate-700">
{[...row.values, ...column.values].includes ('__counts_private__')
? <span>{count (row, column)}</span> : <PrefetchLink to={drill (row, column)} aria-label={
{[...row.values, ...column.values].includes (countsMaskedValue)
? <span>{count (row, column)}</span>
: <PrefetchLink to={drill (row, column)} aria-label={
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
: `${message (totalLabels.get (
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
@@ -291,7 +292,8 @@ const CountsSummaryResults = (props: Parameters<typeof CountsSummaryTable>[0]) =
const { message } = useUITranslation ('counts')
const headers = props.data.columnHeaders ?? []
const columns = props.data.cols ?? []
if (columns.length === 0) return <CountsSummaryTable {...props}/>
if (columns.length === 0 || props.data.columnValuesEditable === false)
return <CountsSummaryTable {...props}/>
const groups = [...new Set (headers.filter (header => header.values.length > 0)
.map (header => JSON.stringify (header.values.slice (0, 1))))]
const ids = groups.map ((_, index) => `group${index}`)
+36
ファイルの表示
@@ -162,4 +162,40 @@ describe ('api helpers', () => {
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('aborts one write while a shared CSRF lookup remains pending for another write', async () => {
let complete!: (value: { data: { token: string } }) => void
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
const { apiPost } = await import ('@/lib/api')
const controller = new AbortController ()
const first = apiPost ('/counts/export/jobs', {}, { signal: controller.signal })
const second = apiPost ('/posts', {})
const rejected = expect (first).rejects.toMatchObject ({ name: 'AbortError' })
controller.abort ()
await rejected
complete ({ data: { token: 'shared' } })
await second
expect (mocks.client.get).toHaveBeenCalledWith ('/users/csrf',
expect.objectContaining ({ timeout: 15_000 }))
expect (mocks.client.post).toHaveBeenCalledTimes (1)
expect (mocks.client.post.mock.calls[0][0]).toBe ('/posts')
})
it ('releases native download frames after failure or explicit cancellation', async () => {
const { startAPIDownload } = await import ('@/lib/api')
const failure = vi.fn ()
const release = startAPIDownload ('/counts/export', 'file', failure,
new URLSearchParams ({ job: 'job', attempt: 'attempt' }))
const frame = document.querySelector ('iframe')!
expect (new URL (frame.src).searchParams.get ('attempt')).toBe ('attempt')
frame.dispatchEvent (new Event ('error'))
frame.dispatchEvent (new Event ('error'))
expect (failure).toHaveBeenCalledTimes (1)
expect (frame).not.toBeInTheDocument ()
release ()
const cancel = startAPIDownload ('/counts/export', 'file', failure)
cancel ()
expect (document.querySelector ('iframe')).toBeNull ()
expect (failure).toHaveBeenCalledTimes (1)
})
})
+42 -10
ファイルの表示
@@ -63,7 +63,22 @@ const withSession = (opt?: Opt): AxiosRequestConfig => {
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
const token = await csrfToken ()
const signal = opt?.signal
if (signal?.aborted) throw new DOMException ('Aborted', 'AbortError')
const pending = csrfToken ()
// A shared CSRF lookup must not make an individual aborted write wait forever.
const token = signal == null ? await pending : await new Promise<string> ((resolve, reject) => {
const abort = () => reject (new DOMException ('Aborted', 'AbortError'))
signal.addEventListener ('abort', abort, { once: true })
if (signal.aborted) abort ()
void pending.then (value => {
signal.removeEventListener ('abort', abort)
resolve (value)
}, error => {
signal.removeEventListener ('abort', abort)
reject (error)
})
})
const config = withSession (opt)
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
}
@@ -73,7 +88,8 @@ const csrfToken = async (): Promise<string> => {
const state = csrfState ()
if (state.token != null)
return state.token
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
state.pending ??= client.get<{ token: string }> (
'/users/csrf', { ...withSession (), timeout: 15_000 })
.then (response => {
if (csrfState () !== state)
return csrfToken ()
@@ -223,19 +239,35 @@ export const startAPIDownload = (
): (() => void) => {
const frame = document.createElement ('iframe')
frame.hidden = true
let timer: ReturnType<typeof window.setTimeout> | null = null
let closed = false
const release = () => {
if (closed) return
closed = true
if (timer != null) window.clearTimeout (timer)
frame.remove ()
}
const failure = () => {
if (closed) return
release ()
onError?.()
}
frame.addEventListener ('load', () => {
const document = frame.contentDocument
if (document != null && document.URL !== 'about:blank')
try
{
onError?.()
frame.remove ()
const content = frame.contentDocument
// Attachment navigations normally have no document. An error document is failure.
if (content != null && content.URL !== 'about:blank') failure ()
}
catch
{
// Cross-origin navigation cannot be inspected; the authenticated ACK decides.
}
})
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
frame.addEventListener ('error', failure)
const suffix = query == null ? '' : `?${query.toString ()}`
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}${suffix}`
document.body.append (frame)
// Attachments do not reliably fire load; keep this isolated from page navigation.
const timer = window.setTimeout (() => frame.remove (), 10 * 60_000)
return () => { window.clearTimeout (timer); frame.remove () }
timer = window.setTimeout (release, 10 * 60_000)
return release
}
+22 -1
ファイルの表示
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
import { countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
import { countsErrorMessage, countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
from '@/lib/countsDisplay'
import { countsClipboard } from '@/lib/countsClipboard'
import { createUII18n, translateUIMessage } from '@/lib/i18n'
@@ -157,3 +157,24 @@ describe ('Counts logical display and exports', () => {
expect (isCountsPath (path.split ('?')[0])).toBe (true)
})
})
describe ('Counts safe failure messages', () => {
it.each ([
[400, { error: 'too_many_axes', max_axes: 8 }, '最大8個'],
[422, { error: 'too_many_columns', max_columns: 73 }, '最大73列'],
[503, { error: 'counts_query_timeout' }, '時間制限'],
[503, { error: 'export_worker_unavailable' }, 'ワーカー'],
[400, {}, '入力条件'], [401, {}, '認証状態'], [403, {}, '権限']]) (
'distinguishes HTTP %s with a known safe response', (status, data, expected) => {
const failure = { isAxiosError: true, response: { status, data } }
expect (countsErrorMessage (failure, 'ja').text).toContain (expected)
})
it ('never renders arbitrary exception messages or private identifiers', () => {
const failure = { isAxiosError: true, response: { status: 500,
data: { error: 'SELECT private_title FROM secret_table', message: 'private-user-17' } } }
const text = countsErrorMessage (failure, 'ja').text
expect (text).not.toMatch (/SELECT|private|secret_table/)
expect (countsErrorMessage ({ error: 'worker_stalled' }, 'ja').text).toContain ('停止')
})
})
+3
ファイルの表示
@@ -8,6 +8,8 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
export const countsMaskedValue = '__counts_private__'
export type CountsResource = 'posts' | 'tags' | 'nico_tags'
| 'tag_descriptions' | 'tag_groups' | 'materials'
export type CountsName = TagNameSnapshot & {
@@ -77,6 +79,7 @@ export type CountsResult = {
executionAt?: string
columnValues?: (string | number | null)[][]
cells?: { values: (string | number | null)[]; count: number }[]
columnValuesEditable?: boolean
conditionLabels?: Record<string, Record<string, string | null>>
axisLabels?: Record<string, Record<string, string | null>>
axisNames?: Record<string, Record<string, Record<string, unknown>>>
+35 -2
ファイルの表示
@@ -1,4 +1,5 @@
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
import { isApiError } from '@/lib/api'
import { countsMaskedValue, fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
import { languageUIMessage, translateUIMessage } from '@/lib/i18n'
@@ -12,6 +13,37 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
// Render only known codes and numerical limits, never arbitrary server error text.
export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
const response = isApiError (failure) ? failure.response : null
const body = (response?.data ?? failure) as {
error?: unknown; max_axes?: unknown; maxAxes?: unknown
max_columns?: unknown; maxColumns?: unknown } | null
const code = body != null && typeof body === 'object' ? body.error : null
const message = (key: string, values?: Record<string, string | number>) =>
translateUIMessage (key, 'counts', locale, values)
if (code === 'too_many_axes' || code === 'too_many_columns')
{
const maximum = code === 'too_many_axes' ? body?.maxAxes ?? body?.max_axes
: body?.maxColumns ?? body?.max_columns
if (typeof maximum === 'number')
return message (code === 'too_many_axes' ? 'axisLimit' : 'columnLimit.message', {
max: maximum })
}
if (code === 'private_condition') return message ('privateCondition')
if (code === 'counts_query_timeout' || code === 'generation_timeout')
return message ('errors.queryTimeout')
if (code === 'export_worker_unavailable' || code === 'worker_unavailable')
return message ('errors.workerUnavailable')
if (['worker_stalled', 'interrupted', 'generation_failed', 'export_too_large',
'export_storage_full'].includes (String (code))) return message ('errors.stopped')
if (response?.status === 401) return message ('errors.authentication')
if (response?.status === 403) return message ('errors.permission')
if (response?.status === 400 || response?.status === 422) return message ('errors.input')
return message ('common:fetchError')
}
export const countsFieldValue = (row: CountRecord, field: string): unknown =>
row[field] ?? row[field.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())]
@@ -450,6 +482,7 @@ export const countsConditionValue = (
axis: CountsAxis, locale: LocaleCode, timezone: string,
labels?: Record<string, Record<string, string | null>>,
): string => {
if (value === countsMaskedValue) return '********'
if (axis.field === 'user' && value == null)
return translateUIMessage ('bot', 'counts', locale).text
if (value == null) return 'NULL'
@@ -488,7 +521,7 @@ export const countsSummaryValue = (
): string => {
const message = (key: string, values?: Record<string, string | number>) =>
translateUIMessage (key, 'counts', locale, values).text
if (value === '__counts_private__') return '********'
if (value === countsMaskedValue) return '********'
if (value == null) return axis.field === 'user' ? message ('bot') : ''
if (['language_code', 'source_language_code'].includes (axis.field))
return languageUIMessage (String (value), locale).text
+11 -2
ファイルの表示
@@ -5,7 +5,8 @@
"maskedReason",
"downloadErrors",
"axisLimit",
"privateCondition"
"privateCondition",
"errors"
],
"outputRows": "行数:{{value}} 件",
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
@@ -15,5 +16,13 @@
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
},
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
"errors": {
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
"workerUnavailable": "エクスポートワーカーが稼働していません。",
"stopped": "エクスポート処理が停止しました。再実行してください。",
"input": "入力条件が正しくありません。",
"permission": "この操作を実行する権限がありません。",
"authentication": "認証状態を確認して再実行してください。"
}
}
+9 -1
ファイルの表示
@@ -299,5 +299,13 @@
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
},
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
"errors": {
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
"workerUnavailable": "エクスポートワーカーが稼働していません。",
"stopped": "エクスポート処理が停止しました。再実行してください。",
"input": "入力条件が正しくありません。",
"permission": "この操作を実行する権限がありません。",
"authentication": "認証状態を確認して再実行してください。"
}
}
+18 -1
ファイルの表示
@@ -26,7 +26,6 @@ const renderPage = (extra = '') => renderWithProviders (<>
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
beforeEach (() => {
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
fetchCounts.mockReset ().mockResolvedValue (detailResult)
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
})
@@ -104,4 +103,22 @@ describe ('Counts execution and URL state', () => {
expect (await screen.findByRole ('alert')).toHaveTextContent ('73')
expect (screen.getByRole ('alert')).toHaveTextContent ('列数が上限')
})
it ('can execute after an initial column-limit failure with no execution timestamp', async () => {
fetchCounts.mockRejectedValueOnce ({ isAxiosError: true,
response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } })
renderPage ()
await screen.findByRole ('alert')
execute ()
await screen.findByText ('Recorded title')
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
expect (fetchCounts).toHaveBeenCalledTimes (2)
})
it ('keeps the page heading and common site title consistent', async () => {
renderPage ()
await screen.findByText ('Recorded title')
await waitFor (() => expect (document.title).toContain ('広場集計 | '))
expect (screen.getByRole ('heading', { name: '広場集計' })).toBeInTheDocument ()
})
})
+4 -10
ファイルの表示
@@ -17,7 +17,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { countsErrorMessage, countsExportLabels } from '@/lib/countsDisplay'
import { isApiError } from '@/lib/api'
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
visibleCountsResources, countsFieldLabel,
@@ -231,13 +231,7 @@ const CountsResourceContent = (
|| pending || fetching || placeholder
|| (!params.has ('execution_at') && !errorFlg)))
const failure = runError ? runFailure : queryError
const errorBody = isApiError (failure) ? failure.response?.data as {
error?: string; max_axes?: number; maxAxes?: number } : undefined
const errorCode = errorBody?.error
const failureMessage = errorCode === 'too_many_axes'
? label ('axisLimit', { max: errorBody?.maxAxes ?? errorBody?.max_axes ?? '' })
: (errorCode === 'private_condition' ? label ('privateCondition')
: label ('common:fetchError'))
const failureMessage = countsErrorMessage (failure, locale)
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
@@ -355,7 +349,7 @@ const CountsResourceContent = (
resource={resource} applied={params}
conditionLabels={errorFlg ? undefined : data?.conditionLabels}
conditionLabelsParams={resultParams.toString ()}
pending={running || fetching || !params.has ('execution_at')}
pending={running || fetching}
open={open} setOpen={setOpen}
reorder={value => update ('order', value)}
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
@@ -381,7 +375,7 @@ const CountsResourceContent = (
<p>{label ('columnLimit.title')}</p>
<p>{label ('columnLimit.message', { max: columnLimit })}</p>
</div> : ((definitionError || errorFlg || runError)
&& <p role="alert">{failureMessage}</p>)}
&& <p role="alert" lang={failureMessage.language}>{failureMessage.text}</p>)}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
aria-busy={waiting}>
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
+9 -5
ファイルの表示
@@ -1,6 +1,10 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
import { afterEach, beforeEach, vi } from 'vitest'
beforeEach (() => {
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
})
afterEach (() => {
cleanup ()
@@ -16,10 +20,10 @@ window.scroll = function () {
;
}
window.requestAnimationFrame = callback => {
callback (0)
return 0
}
// Animation callbacks must advance time and must not recurse synchronously.
window.requestAnimationFrame = callback => window.setTimeout (
() => callback (performance.now ()), 16)
window.cancelAnimationFrame = identifier => window.clearTimeout (identifier)
Object.defineProperty (window, 'matchMedia', {
configurable: true,