コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker d830ac0311 AI: address review on PR #466 2026-11-12 23:33:15 +09:00
Codex Night Worker 7585f51e01 AI: address issue #438 2026-11-12 23:28:48 +09:00
19個のファイルの変更、238行の追加、24行の削除
+1
ファイルの表示
@@ -5,6 +5,7 @@ class MaterialVersionsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
+1
ファイルの表示
@@ -14,6 +14,7 @@ class MaterialsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
+1
ファイルの表示
@@ -15,6 +15,7 @@ class NicoTagsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
post_tag_max_sql = post_tag_max_sql =
PostExternalTag PostExternalTag
+1
ファイルの表示
@@ -11,6 +11,7 @@ class PostVersionsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
+7 -6
ファイルの表示
@@ -34,10 +34,11 @@ class PostsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
history_join_required = order[0] == 'updated_at' || updated_between.any?(&:present?) needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
q = q =
filtered_posts(locale) filtered_posts(locale)
@@ -57,8 +58,8 @@ class PostsController < ApplicationController
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q count_scope = q
if history_join_required if needs_history_join
history_times = PostVersion.all history_times = HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS latest_at').group(:post_id) .select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " + q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id') 'ON history_times.post_id = posts.id')
@@ -72,7 +73,7 @@ class PostsController < ApplicationController
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?) count_scope = q if updated_between.any?(&:present?)
if history_join_required if needs_history_join
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at") q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end end
@@ -92,10 +93,10 @@ class PostsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times = latest_version_times =
if history_join_required if needs_history_join
{ } { }
else else
PostVersion.where(post_id: posts.map(&:id)) HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at) .group(:post_id).maximum(:created_at)
end end
+1
ファイルの表示
@@ -49,6 +49,7 @@ class TagDescriptionsController < ApplicationController
private private
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
def index def index
locale = resolve_locale! locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id) groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a) render json: TagGroupRepr.many(locale, groups.to_a)
end end
+1
ファイルの表示
@@ -7,6 +7,7 @@ class TagVersionsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
+1
ファイルの表示
@@ -33,6 +33,7 @@ class TagsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
+2
ファイルの表示
@@ -2,6 +2,7 @@ class TheatreCommentsController < ApplicationController
def index def index
limit = params[:limit].to_i limit = params[:limit].to_i
limit = 20 if limit <= 0 limit = 20 if limit <= 0
limit = 100 if limit > 100
no_gt = params[:no_gt].to_i no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt < 0 no_gt = 0 if no_gt < 0
@@ -9,6 +10,7 @@ class TheatreCommentsController < ApplicationController
comments = TheatreComment comments = TheatreComment
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt) .where('no > ?', no_gt)
.includes(:user)
.order(no: :desc) .order(no: :desc)
.limit(limit) .limit(limit)
+2 -1
ファイルの表示
@@ -2,6 +2,7 @@ class TheatreProgrammesController < ApplicationController
def index def index
limit = params[:limit].to_i limit = params[:limit].to_i
limit = 100 if limit <= 0 limit = 100 if limit <= 0
limit = 100 if limit > 100
position_gt = params[:position_gt].to_i position_gt = params[:position_gt].to_i
position_gt = 0 if position_gt < 0 position_gt = 0 if position_gt < 0
@@ -10,7 +11,7 @@ class TheatreProgrammesController < ApplicationController
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt) .where('position > ?', position_gt)
.includes(:post) .includes(:post)
.order(position: :desc).limit(100) .order(position: :desc)
.limit(limit) .limit(limit)
render json: programmes.map { |programme| render json: programmes.map { |programme|
+1
ファイルの表示
@@ -2,6 +2,7 @@ class TheatreSkipEventsController < ApplicationController
def index def index
limit = params[:limit].to_i limit = params[:limit].to_i
limit = 50 if limit <= 0 limit = 50 if limit <= 0
limit = 100 if limit > 100
events = events =
TheatreSkipEvent TheatreSkipEvent
+6 -5
ファイルの表示
@@ -8,11 +8,12 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = descriptions =
TagDescription.where(language_code: locale.language_code) HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code)
.includes(tag: :tag_names) .includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc) descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc) tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
if title.present? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -40,13 +41,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String) return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language) return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names) names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language }) .where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name| candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:) WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name| candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:) WikiPageRepr.autolink_group(locale, id:, name:, language:)
end) end)
render json: candidates render json: candidates
@@ -410,7 +411,7 @@ class WikiPagesController < ApplicationController
end end
def tag_description_by_title locale, title def tag_description_by_title locale, title
TagDescription.joins(tag: :tag_names) HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.find_by(language_code: locale.language_code, .find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title }) tag_names: { language_code: locale.language_code, name: title })
end end
+23
ファイルの表示
@@ -147,6 +147,29 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
scope.where(Arel.sql(sql(scope.klass, row))) scope.where(Arel.sql(sql(scope.klass, row)))
end end
def self.current(scope, version_model)
return scope if admin?
table = scope.klass.table_name
keys = KEYS.fetch(version_model.table_name)
matches = keys.map do |key|
column = scope.klass.column_names.include?(key) ? key : 'id'
"current_snapshot.#{ key } = #{ table }.#{ column }"
end.join(' AND ')
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
"AND current_snapshot.event_type = 'discard'"
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ boundary })"))
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
end
def self.require_current!(record, version_model)
return if admin?
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
end
def self.blob_visible?(blob, seen = []) def self.blob_visible?(blob, seen = [])
return true if admin? return true if admin?
return false if seen.include?(blob.id) return false if seen.include?(blob.id)
+1
ファイルの表示
@@ -44,6 +44,7 @@ module TagGroupRepr
end end
def one locale, group def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first many(locale, [group]).first
end end
+1
ファイルの表示
@@ -5,6 +5,7 @@ module WikiPageRepr
module_function module_function
def tag_description locale, description def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag tag = description.tag
{ 'id' => description.id, { 'id' => description.id,
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
end end
end end
it 'keeps discarded history private while exposing the current description' do it 'denies the same discarded description through Counts, the facade and the ordinary API' do
get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history', get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' } mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -22,8 +22,8 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
'excluded_count' => 2, 'versions' => []) 'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path| ["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' } get path, params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:not_found)
expect(response.parsed_body.fetch('body')).to eq(description.body) expect(response.body).not_to include('private body')
end end
get '/sitemap_entries/descriptions', params: { locale: 'ja' } get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
+174
ファイルの表示
@@ -0,0 +1,174 @@
require 'rails_helper'
RSpec.describe 'List API limits', type: :request do
shared_examples 'a capped list' do |default_count, minimum_count, paginated|
def response_rows
body = json
body.is_a?(Array) ? body : body.fetch(collection_key)
end
it 'caps oversized limits at 100 and preserves the 100 boundary' do
get path, params: { locale: 'ja', limit: 100 }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(100)
boundary_rows = response_rows
[101, 10_000].each do |limit|
get path, params: { locale: 'ja', limit: }
expect(response).to have_http_status(:ok)
expect(response_rows).to eq(boundary_rows)
end
end
it 'preserves the default, nonpositive and small positive limits' do
get path, params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(default_count)
[0, -1].each do |limit|
get path, params: { locale: 'ja', limit: }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(minimum_count)
end
get path, params: { locale: 'ja', limit: 2 }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(2)
end
if paginated
it 'uses the capped limit for page offsets and preserves total count' do
get path, params: { locale: 'ja', limit: 100 }
first_page = response_rows
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(101)
get path, params: { locale: 'ja', limit: 100, page: 2 }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(1)
second_page = response_rows
expect(first_page & second_page).to be_empty
get path, params: { locale: 'ja', limit: 101, page: 2 }
expect(response).to have_http_status(:ok)
expect(response_rows).to eq(second_page)
expect(json.fetch('count')).to eq(101)
end
end
end
context 'posts' do
let(:path) { '/posts' }
let(:collection_key) { 'posts' }
before { create_list(:post, 101) }
include_examples 'a capped list', 20, 1, true
end
context 'tags' do
let(:path) { '/tags' }
let(:collection_key) { 'tags' }
before { create_list(:tag, 101) }
include_examples 'a capped list', 20, 1, true
end
context 'nico tags' do
let(:path) { '/tags/nico' }
let(:collection_key) { 'tags' }
before { create_list(:external_tag, 101) }
include_examples 'a capped list', 20, 1, true
end
context 'materials' do
let(:path) { '/materials' }
let(:collection_key) { 'materials' }
before do
101.times { |index| Material.create!(url: "https://example.com/material-#{ index }") }
end
include_examples 'a capped list', 20, 1, true
end
context 'post versions' do
let(:path) { '/posts/versions' }
let(:collection_key) { 'versions' }
let(:post) { create(:post) }
before do
101.times do |index|
PostVersion.create!(post:, version_no: index + 1, event_type: 'update',
url: post.url, tags: '', tags_json: [], parent_post_ids: '')
end
end
include_examples 'a capped list', 20, 1, true
end
context 'tag versions' do
let(:path) { '/tags/versions' }
let(:collection_key) { 'versions' }
let(:tag) { create(:tag) }
before do
101.times do |index|
TagVersion.create!(tag:, version_no: index + 1, event_type: 'update',
name: 'limit tag', category: 'general', aliases: '',
parent_tag_ids: '', names: {
ja: { name: 'limit tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } })
end
end
include_examples 'a capped list', 20, 1, true
end
context 'material versions' do
let(:path) { '/materials/versions' }
let(:collection_key) { 'versions' }
let(:material) { Material.create!(url: 'https://example.com/material') }
before do
101.times do |index|
MaterialVersion.create!(material:, version_no: index + 1,
event_type: 'update', url: material.url)
end
end
include_examples 'a capped list', 20, 1, true
end
context 'theatre lists' do
let(:theatre) { create(:theatre) }
let(:post) { create(:post) }
context 'comments' do
let(:path) { "/theatres/#{ theatre.id }/comments" }
before { create_list(:theatre_comment, 101, theatre:, user: theatre.created_by_user) }
include_examples 'a capped list', 20, 20, false
end
context 'programmes' do
let(:path) { "/theatres/#{ theatre.id }/programmes" }
before do
101.times do |index|
TheatreProgramme.create!(theatre:, post:, position: index + 1)
end
end
include_examples 'a capped list', 100, 100, false
end
context 'skip events' do
let(:path) { "/theatres/#{ theatre.id }/skip_events" }
before do
101.times do |index|
TheatreSkipEvent.create!(theatre:, post:,
skipped_by_user: theatre.created_by_user,
created_at: Time.utc(2026, 1, 1) + index.seconds)
end
end
include_examples 'a capped list', 50, 50, false
end
end
end
+10 -8
ファイルの表示
@@ -69,38 +69,40 @@ RSpec.describe 'Post history privacy', type: :request do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous' sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end end
it 'uses the latest version timestamp for display regardless of role' do it 'uses only authorised history timestamps for display' do
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id } row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + 4.days expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected) expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end end
it 'uses the latest version timestamp for date bounds and count regardless of role' do it 'uses the same visibility for date bounds and count' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601, get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 } updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id') ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq([post_record.id]) expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length) expect(response.parsed_body.fetch('count')).to eq(ids.length)
end end
it 'sorts using the latest version timestamps in both directions regardless of role' do it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction| ['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" } get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expected = [other_post.id, post_record.id] expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc' expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected) expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end end
end end
it 'applies latest version timestamp ordering before pagination regardless of role' do it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 } get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([post_record.id]) expected = role == 'admin' ? post_record.id : other_post.id
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2) expect(response.parsed_body.fetch('count')).to eq(2)
end end