このコミットが含まれているのは:
2026-06-26 01:20:30 +09:00
コミット d7b136c198
9個のファイルの変更407行の追加154行の削除
+10 -2
ファイルの表示
@@ -379,12 +379,20 @@ class TagsController < ApplicationController
end
def visible_root_tag_ids graph
graph[:tags_by_id].filter_map do |tag_id, _attrs|
root_ids = Set.new
graph[:tags_by_id].each do |tag_id, attrs|
next unless visible_root_tag?(tag_id, graph)
next unless visible_subtree?(tag_id, graph)
tag_id
if attrs[:deprecated]
collect_visible_child_tag_ids(tag_id, graph, root_ids, Set.new)
else
root_ids << tag_id
end
end
root_ids.to_a
end
def visible_root_tag? tag_id, graph
+15 -1
ファイルの表示
@@ -44,6 +44,12 @@ class MaterialSyncImporter
:update
end
uploaded_blob = uploaded_blob!
if @import_block
return Result.new(material: nil,
action: :suppressed,
suppressed: true,
suppression: @import_block)
end
Material.transaction do
if material.persisted?
@@ -129,11 +135,19 @@ class MaterialSyncImporter
return nil unless tempfile
tempfile.rewind
file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest
@attributes[:file_sha256] = file_sha256
@import_block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
if @import_block
tempfile.rewind
return nil
end
blob = ActiveStorage::Blob.create_and_upload!(
io: tempfile,
filename: @attributes[:filename],
content_type: @attributes[:content_type])
file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest
blob.metadata['sha256'] = file_sha256 if file_sha256.present?
blob.save! if blob.changed?
tempfile.rewind
+1 -11
ファイルの表示
@@ -2,9 +2,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
def up
change_table :materials, bulk: true do |t|
t.integer :version_no, null: false, default: 1
t.datetime :file_suppressed_at
t.references :file_suppressed_by_user, foreign_key: { to_table: :users }
t.string :file_suppression_reason
end
change_table :material_versions, bulk: true do |t|
@@ -18,8 +15,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
t.bigint :file_byte_size
t.string :file_checksum
t.string :file_sha256
t.datetime :file_suppressed_at
t.string :file_suppression_reason
end
execute <<~SQL.squish
@@ -35,7 +30,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
add_index :material_versions, :file_blob_id
add_check_constraint :material_versions,
"event_type IN ('create', 'update', 'discard', 'restore', 'suppress')",
"event_type IN ('create', 'update', 'discard', 'restore')",
name: 'material_versions_event_type_valid'
create_table :material_export_items do |t|
@@ -87,12 +82,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
remove_column :material_versions, :file_byte_size
remove_column :material_versions, :file_checksum
remove_column :material_versions, :file_sha256
remove_column :material_versions, :file_suppressed_at
remove_column :material_versions, :file_suppression_reason
remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users }
remove_column :materials, :version_no
remove_column :materials, :file_suppressed_at
remove_column :materials, :file_suppression_reason
end
end
+76 -52
ファイルの表示
@@ -64,27 +64,36 @@ RSpec.describe 'Materials API', type: :request do
'name' => 'material_index_b',
'category' => 'material'
)
expect(row['created_by_user']).to include(
'id' => member_user.id,
'name' => member_user.name
)
expect(row['content_type']).to eq('image/png')
expect(row['media_kind']).to eq('image')
end
it 'filters materials by tag_id' do
get '/materials', params: { tag_id: material_a.tag_id }
it 'filters materials by q' do
get '/materials', params: { q: 'material_index_a' }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
end
it 'filters materials by parent_id' do
get '/materials', params: { parent_id: material_a.id }
it 'filters materials by tag_state' do
untagged =
build_material(tag: nil, user: member_user,
file: dummy_upload(filename: 'untagged.png'))
get '/materials', params: { tag_state: 'untagged' }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
expect(response_materials.map { |m| m['id'] }).to eq([untagged.id])
end
it 'filters materials by media_kind' do
get '/materials', params: { media_kind: 'image' }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.map { |m| m['media_kind'] }).to all(eq('image'))
end
it 'paginates and keeps total count' do
@@ -528,9 +537,8 @@ RSpec.describe 'Materials API', type: :request do
expect(response.body.b).not_to include('素材/b.png'.b)
end
it 'does not include suppressed materials' do
material_b.update!(file_suppressed_at: Time.current,
file_suppression_reason: 'copyright_high_risk')
it 'does not include disabled export items' do
material_b.material_export_items.first.update!(enabled: false)
get '/materials/download.zip', params: { profile: 'legacy_drive' }
@@ -540,62 +548,78 @@ RSpec.describe 'Materials API', type: :request do
end
end
describe 'PATCH /materials/:id/suppress_file' do
describe 'GET /materials/versions' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material)
Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png'))
build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png'))
end
it 'allows admin to suppress a file and records a suppress version' do
sign_in_as(admin_user)
it 'returns material versions in reverse chronological order' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
material.update!(url: 'https://example.com/history')
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: admin_user)
get '/materials/versions', params: { material_id: material.id }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
versions = json.fetch('versions')
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
expect(versions.first).to include(
'material_id' => material.id,
'event_type' => 'update',
'tag_name' => 'material_history',
'file_filename' => 'history.png',
'url' => 'https://example.com/history'
)
expect(versions.first['created_by_user']).to include('id' => admin_user.id)
end
it 'filters material versions by tag and event_type' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_high_risk' }
end.to change(MaterialVersion, :count).by(1)
get '/materials/versions', params: {
tag: 'material_history',
event_type: 'create'
}
expect(response).to have_http_status(:ok)
expect(json.fetch('versions').map { |v| v['material_id'] }).to include(material.id)
expect(json.fetch('versions').map { |v| v['event_type'] }).to all(eq('create'))
end
end
material.reload
expect(material.file_suppressed_at).to be_present
expect(material.file_suppressed_by_user).to eq(admin_user)
expect(material.file_suppression_reason).to eq('copyright_high_risk')
expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress')
expect(json['file']).to be_nil
expect(json['file_suppressed_at']).to be_present
describe 'material sync suppressions' do
it 'requires member permission for index' do
sign_out
get '/materials/suppressions'
expect(response).to have_http_status(:unauthorized)
end
it 'purges blob when purge=true is requested' do
sign_in_as(admin_user)
old_blob_id = material.file.blob.id
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_takedown', purge: '1' }
end.to have_enqueued_job(ActiveStorage::PurgeJob)
expect(response).to have_http_status(:ok)
version = material.material_versions.order(:version_no).last
expect(version.event_type).to eq('suppress')
expect(version.file_blob_id).to eq(old_blob_id)
expect(version.file_filename).to eq('suppress.png')
expect(version.file_sha256).to be_present
end
it 'rejects member suppression' do
it 'creates source suppression as member' do
sign_in_as(member_user)
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_high_risk' }
expect do
post '/materials/suppressions', params: {
source_kind: 'google_drive_path_prefix',
drive_path: '伊地知ニジカ/危険素材',
reason: 'copyright_high_risk'
}
end.to change(MaterialSyncSuppression, :count).by(1)
expect(response).to have_http_status(:forbidden)
expect(response).to have_http_status(:created)
expect(json).to include(
'source_kind' => 'google_drive_path_prefix',
'drive_path' => '伊地知ニジカ/危険素材',
'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材'
)
end
end
+128
ファイルの表示
@@ -0,0 +1,128 @@
require 'rails_helper'
RSpec.describe MaterialSyncImporter do
let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) }
def tempfile_for body
Tempfile.new(['material-sync-importer', '.png']).tap do |file|
file.binmode
file.write(body)
file.rewind
end
end
it 'creates an untagged material from a sync candidate with file_downloader' do
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/a.png',
source_file_id: 'drive-file-a',
normalized_source_key: 'google_drive_file:drive-file-a',
filename: 'a.png',
content_type: 'image/png',
file_sha256: Digest::SHA256.hexdigest('sync-body'),
file_downloader: -> { tempfile_for('sync-body') },
export_path: '素材/a.png',
tag: nil,
url: nil,
created_by_user: user,
updated_by_user: user)
material = result.material.reload
expect(result.action).to eq(:imported)
expect(material.tag).to be_nil
expect(material.url).to be_nil
expect(material.file).to be_attached
expect(material.file.blob.filename.to_s).to eq('a.png')
expect(material.material_export_items.first.export_path).to eq('素材/a.png')
end
it 'does not clear a human-assigned tag or URL on existing Drive sync' do
material =
Material.create!(tag:, url: 'https://example.com/human',
source_kind: 'google_drive_file',
source_path: '素材/a.png',
source_file_id: 'drive-file-a',
created_by_user: user,
updated_by_user: user)
material.file.attach(
io: StringIO.new('same-body'),
filename: 'a.png',
content_type: 'image/png')
material.file.blob.metadata['sha256'] = Digest::SHA256.hexdigest('same-body')
material.file.blob.save!
MaterialExportItem.create!(material:,
profile: 'legacy_drive',
export_path: '素材/a.png',
created_by_user: user)
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/a.png',
source_file_id: 'drive-file-a',
filename: 'a.png',
content_type: 'image/png',
file_sha256: Digest::SHA256.hexdigest('same-body'),
export_path: '素材/a.png',
tag: nil,
url: nil,
updated_by_user: user)
expect(result.action).to eq(:unchanged)
expect(material.reload.tag).to eq(tag)
expect(material.url).to eq('https://example.com/human')
end
it 'suppresses a Google Drive candidate by relative path prefix' do
MaterialSyncSuppression.create!(source_kind: 'google_drive_path_prefix',
drive_path: '素材/危険',
reason: 'copyright_high_risk',
created_by_user: user)
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/危険/a.png',
source_file_id: 'drive-file-b',
filename: 'a.png',
content_type: 'image/png',
file_sha256: Digest::SHA256.hexdigest('blocked-body'),
file_downloader: -> { tempfile_for('blocked-body') },
export_path: '素材/危険/a.png',
updated_by_user: user)
expect(result.action).to eq(:suppressed)
expect(result.suppression.normalized_source_key)
.to eq('google_drive_path_prefix:素材/危険')
expect(Material.find_by(source_file_id: 'drive-file-b')).to be_nil
end
it 'rechecks import blocks after downloaded file sha256 is known' do
MaterialImportBlock.create!(match_kind: 'sha256',
sha256: Digest::SHA256.hexdigest('blocked-body'),
reason: 'copyright_high_risk',
created_by_user: user)
blob_count = ActiveStorage::Blob.count
expect do
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/blocked.png',
source_file_id: 'drive-file-blocked',
filename: 'blocked.png',
content_type: 'image/png',
file_downloader: -> { tempfile_for('blocked-body') },
export_path: '素材/blocked.png',
updated_by_user: user)
expect(result.action).to eq(:suppressed)
expect(result.suppression.reason).to eq('copyright_high_risk')
end.not_to change(Material, :count)
expect(ActiveStorage::Blob.count).to eq(blob_count)
expect(Material.find_by(source_file_id: 'drive-file-blocked')).to be_nil
end
end