From 507ce1680e7539b019b9bf73caeb90cb81c1685f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 23 Jun 2026 22:05:11 +0900 Subject: [PATCH] #306 --- .../app/controllers/materials_controller.rb | 174 ++++++++-- backend/app/controllers/tags_controller.rb | 8 +- backend/app/models/material.rb | 11 + backend/app/models/material_export_item.rb | 48 +++ backend/app/models/material_import_block.rb | 29 ++ backend/app/models/material_version.rb | 18 + backend/app/models/version_record.rb | 16 +- backend/app/representations/material_repr.rb | 24 +- backend/app/services/material_file_sha256.rb | 25 ++ .../services/material_import_block_matcher.rb | 7 + .../app/services/material_version_recorder.rb | 73 +++++ backend/app/services/material_zip_exporter.rb | 118 +++++++ backend/app/services/version_recorder.rb | 3 +- backend/config/routes.rb | 7 +- ...60623000000_enhance_material_management.rb | 98 ++++++ backend/db/schema.rb | 51 ++- .../spec/models/material_export_item_spec.rb | 57 ++++ backend/spec/requests/materials_spec.rb | 307 ++++++++++++++++-- frontend/src/App.tsx | 2 +- .../materials/MaterialDetailPage.test.tsx | 5 +- .../pages/materials/MaterialDetailPage.tsx | 83 ++++- .../src/pages/materials/MaterialListPage.tsx | 21 +- .../src/pages/materials/MaterialNewPage.tsx | 18 +- frontend/src/test/factories.ts | 25 +- frontend/src/types.ts | 31 +- 25 files changed, 1148 insertions(+), 111 deletions(-) create mode 100644 backend/app/models/material_export_item.rb create mode 100644 backend/app/models/material_import_block.rb create mode 100644 backend/app/models/material_version.rb create mode 100644 backend/app/services/material_file_sha256.rb create mode 100644 backend/app/services/material_import_block_matcher.rb create mode 100644 backend/app/services/material_version_recorder.rb create mode 100644 backend/app/services/material_zip_exporter.rb create mode 100644 backend/db/migrate/20260623000000_enhance_material_management.rb create mode 100644 backend/spec/models/material_export_item_spec.rb diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 2a86b77..751b874 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -1,4 +1,7 @@ class MaterialsController < ApplicationController + rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty + rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path + def index page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -11,7 +14,7 @@ class MaterialsController < ApplicationController tag_id = params[:tag_id].presence parent_id = params[:parent_id].presence - q = Material.includes(:tag, :created_by_user).with_attached_file + q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file q = q.where(tag_id:) if tag_id q = q.where(parent_id:) if parent_id @@ -24,7 +27,7 @@ class MaterialsController < ApplicationController def show material = Material - .includes(:tag) + .includes(:tag, :material_export_items) .with_attached_file .find_by(id: params[:id]) return head :not_found unless material @@ -36,26 +39,38 @@ class MaterialsController < ApplicationController def create return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? tag_name_raw = params[:tag].to_s.strip file = params[:file] + file_sha256 = MaterialFileSha256.from_upload(file) url = params[:url].to_s.strip.presence return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? if file.blank? && url.blank? return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], url: ['ファイルまたは URL は必須です.'] } end + block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) + return render_material_import_block(block) if block - tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) - tag = tag_name.tag - tag = Tag.create!(tag_name:, category: :material) unless tag + material = nil + Material.transaction do + tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) + tag = tag_name.tag + tag = Tag.create!(tag_name:, category: :material) unless tag - material = Material.new(tag:, url:, - created_by_user: current_user, - updated_by_user: current_user) - material.file.attach(file) + material = Material.new(tag:, url:, + created_by_user: current_user, + updated_by_user: current_user) + material.file.attach(file) if file + apply_file_sha256!(material, file_sha256) + material.save! + upsert_export_paths!(material) + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: current_user) + end - if material.save + if material render json: MaterialRepr.base(material, host: request.base_url), status: :created else render_validation_error material @@ -71,29 +86,32 @@ class MaterialsController < ApplicationController tag_name_raw = params[:tag].to_s.strip file = params[:file] - url = params[:url].to_s.strip.presence + file_sha256 = MaterialFileSha256.from_upload(file) + url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? - if file.blank? && url.blank? + if file.blank? && url.blank? && !material.file.attached? return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], url: ['ファイルまたは URL は必須です.'] } end + block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) + return render_material_import_block(block) if block - tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) - tag = tag_name.tag - tag = Tag.create!(tag_name:, category: :material) unless tag + Material.transaction do + MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) + tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) + tag = tag_name.tag + tag = Tag.create!(tag_name:, category: :material) unless tag - material.update!(tag:, url:, updated_by_user: current_user) - if file - material.file.attach(file) - else - material.file.purge + material.update!(tag:, url:, updated_by_user: current_user) + material.file.attach(file) if file + apply_file_sha256!(material, file_sha256) + material.file.detach if params.key?(:url) && url.present? && file.blank? + upsert_export_paths!(material) + MaterialVersionRecorder.record!(material:, event_type: :update, + created_by_user: current_user) end - if material.save - render json: MaterialRepr.base(material, host: request.base_url) - else - render_validation_error material - end + render json: MaterialRepr.base(material, host: request.base_url) end def destroy @@ -103,7 +121,111 @@ class MaterialsController < ApplicationController material = Material.find_by(id: params[:id]) return head :not_found unless material - material.discard + Material.transaction do + MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) + material.discard! + MaterialVersionRecorder.record!(material:, event_type: :discard, + created_by_user: current_user) + end head :no_content end + + def download + zip = MaterialZipExporter.new(profile: params[:profile], + tag_id: params[:tag_id]).export + profile = params[:profile].presence || 'legacy_drive' + send_data zip, + type: 'application/zip', + disposition: 'attachment', + filename: "btrc-materials-#{ profile }.zip" + end + + def suppress_file + return head :unauthorized unless current_user + return head :forbidden unless current_user.admin? + + material = Material.with_attached_file.find_by(id: params[:id]) + return head :not_found unless material + + reason = params[:reason].to_s.strip.presence + return render_unprocessable_entity('理由は必須です.', field: :reason) unless reason + purge = bool?(:purge) + file_snapshot = purge_material_file_snapshot(material) if purge + attachment = purge && material.file.attached? ? material.file.attachment : nil + + Material.transaction do + MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) + material.update!(file_suppressed_at: Time.current, + file_suppressed_by_user: current_user, + file_suppression_reason: reason, + updated_by_user: current_user) + MaterialVersionRecorder.record!(material:, event_type: :suppress, + created_by_user: current_user, + file_snapshot:) + end + # Enqueue failure raises here after the suppress metadata has been committed. + # In that case the file remains suppressed in UI/ZIP and purge can be retried. + attachment&.purge_later + material.reload if purge + + render json: MaterialRepr.base(material, host: request.base_url) + end + + private + + def upsert_export_paths! material + raw = params[:export_paths] + return if raw.blank? + + export_paths = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h + export_paths.each do |profile, export_path| + profile = profile.to_s + export_path = export_path.to_s.strip + item = material.material_export_items.find_or_initialize_by(profile:) + + if export_path.blank? + item.destroy! if item.persisted? + next + end + + item.export_path = export_path + item.enabled = true + item.created_by_user ||= current_user + item.save! + end + end + + def render_zip_empty + render_unprocessable_entity('ZIP export 対象の素材がありません.') + end + + def render_zip_duplicate_path error + render_unprocessable_entity("ZIP export path が重複してゐます: #{ error.message }") + end + + def apply_file_sha256! material, file_sha256 + return if file_sha256.blank? + return unless material.file.attached? + + blob = material.file.blob + blob.metadata['sha256'] = file_sha256 + blob.save! if blob.changed? + end + + def purge_material_file_snapshot material + return nil unless material.file.attached? + + blob = material.file.blob + + { file_blob_id: blob.id, + file_filename: blob.filename.to_s, + file_content_type: blob.content_type, + file_byte_size: blob.byte_size, + file_checksum: blob.checksum, + file_sha256: blob.metadata['sha256'] || MaterialFileSha256.from_blob(blob) } + end + + def render_material_import_block block + render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] } + end end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index c9e585e..f847530 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -437,16 +437,10 @@ class TagsController < ApplicationController def build_tag_children tag material = tag.materials.first - file = nil - content_type = nil - if material&.file&.attached? - file = rails_storage_proxy_url(material.file, only_path: false) - content_type = material.file.blob.content_type - end TagRepr.base(tag).merge( children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, - material: material.as_json&.merge(file:, content_type:)) + material: material && MaterialRepr.base(material, host: request.base_url)) end def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index 417b292..719e221 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -9,6 +9,10 @@ class Material < ApplicationRecord belongs_to :tag, optional: true belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true + belongs_to :file_suppressed_by_user, class_name: 'User', optional: true + + has_many :material_versions, dependent: :destroy + has_many :material_export_items, dependent: :destroy has_one_attached :file, dependent: :purge @@ -18,11 +22,18 @@ class Material < ApplicationRecord validate :tag_must_be_material_category def content_type + return nil if file_suppressed? return nil unless file&.attached? file.blob.content_type end + def file_suppressed? = file_suppressed_at.present? + + def snapshot_export_paths + material_export_items.order(:profile).pluck(:profile, :export_path).to_h + end + private def file_must_be_attached diff --git a/backend/app/models/material_export_item.rb b/backend/app/models/material_export_item.rb new file mode 100644 index 0000000..5d13115 --- /dev/null +++ b/backend/app/models/material_export_item.rb @@ -0,0 +1,48 @@ +class MaterialExportItem < ApplicationRecord + VALID_PROFILES = ['legacy_drive'].freeze + + belongs_to :material + belongs_to :created_by_user, class_name: 'User', optional: true + + validates :profile, presence: true, inclusion: { in: VALID_PROFILES } + validates :export_path, presence: true, uniqueness: { scope: :profile } + validates :material_id, uniqueness: { scope: :profile } + validate :export_path_must_be_relative_safe_path + + scope :enabled, -> { where(enabled: true) } + + private + + def export_path_must_be_relative_safe_path + return if export_path.blank? + + if export_path.start_with?('/') + errors.add(:export_path, '絶対パスは使へません.') + end + + if export_path.match?(/\A[A-Za-z]:\//) + errors.add(:export_path, '絶対パスは使へません.') + end + + if export_path.include?('\\') + errors.add(:export_path, '/ 区切りで指定してください.') + end + + if export_path.include?("\0") + errors.add(:export_path, 'NUL は使へません.') + end + + parts = export_path.split('/') + if export_path.include?('//') + errors.add(:export_path, '空の path segment は使へません.') + end + + if parts.any? { |part| part.in?(['.', '..']) } + errors.add(:export_path, '.. は使へません.') + end + + if export_path.end_with?('/') + errors.add(:export_path, 'directory path は使へません.') + end + end +end diff --git a/backend/app/models/material_import_block.rb b/backend/app/models/material_import_block.rb new file mode 100644 index 0000000..b159c6c --- /dev/null +++ b/backend/app/models/material_import_block.rb @@ -0,0 +1,29 @@ +class MaterialImportBlock < ApplicationRecord + MATCH_KINDS = ['sha256', 'exact_path', 'path_prefix', 'manual'].freeze + REASONS = [ + 'copyright_high_risk', + 'copyright_takedown', + 'adult_or_sensitive', + 'personal_information', + 'malware_or_dangerous_file', + 'duplicate_or_low_quality', + 'source_owner_request', + 'other' + ].freeze + + belongs_to :created_by_user, class_name: 'User', optional: true + + validates :match_kind, presence: true, inclusion: { in: MATCH_KINDS } + validates :reason, presence: true, inclusion: { in: REASONS } + validates :sha256, length: { is: 64 }, allow_blank: true + validate :match_value_must_be_present + + private + + def match_value_must_be_present + return if match_kind == 'manual' + return if sha256.present? || external_path_pattern.present? + + errors.add(:base, 'sha256 または external_path_pattern は必須です.') + end +end diff --git a/backend/app/models/material_version.rb b/backend/app/models/material_version.rb new file mode 100644 index 0000000..9227314 --- /dev/null +++ b/backend/app/models/material_version.rb @@ -0,0 +1,18 @@ +class MaterialVersion < ApplicationRecord + EVENT_TYPE_MAP = { create: 'create', + update: 'update', + discard: 'discard', + restore: 'restore', + suppress: 'suppress' }.freeze + + include VersionRecord + + belongs_to :material + belongs_to :tag, optional: true + belongs_to :parent, class_name: 'Material', optional: true + belongs_to :updated_by_user, class_name: 'User', optional: true + + def export_paths_hash + export_paths_json || {} + end +end diff --git a/backend/app/models/version_record.rb b/backend/app/models/version_record.rb index 7224639..ccee900 100644 --- a/backend/app/models/version_record.rb +++ b/backend/app/models/version_record.rb @@ -1,15 +1,23 @@ module VersionRecord extend ActiveSupport::Concern + DEFAULT_EVENT_TYPE_MAP = { create: 'create', + update: 'update', + discard: 'discard', + restore: 'restore' }.freeze + def readonly? = persisted? included do + event_type_map = if const_defined?(:EVENT_TYPE_MAP, false) + const_get(:EVENT_TYPE_MAP) + else + DEFAULT_EVENT_TYPE_MAP + end + belongs_to :created_by_user, class_name: 'User', optional: true - enum :event_type, { create: 'create', - update: 'update', - discard: 'discard', - restore: 'restore' }, prefix: true, validate: true + enum :event_type, event_type_map, prefix: true, validate: true validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :event_type, presence: true diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index 44edd26..20e4834 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -2,7 +2,8 @@ module MaterialRepr - BASE = { only: [:id, :url, :created_at, :updated_at], + BASE = { only: [:id, :url, :version_no, :file_suppressed_at, + :file_suppression_reason, :created_at, :updated_at], methods: [:content_type], include: { tag: TagRepr::BASE, created_by_user: UserRepr::BASE, @@ -12,13 +13,30 @@ module MaterialRepr def base material, host: material.as_json(BASE).merge( - file: if material.file.attached? + file: if material.file.attached? && !material.file_suppressed? Rails.application.routes.url_helpers.rails_storage_proxy_url( material.file, host:) - end) + end, + export_paths: export_paths(material), + export_items: export_items(material)) end def many materials, host: materials.map { |m| base(m, host:) } end + + def export_paths material + material.material_export_items.each_with_object({ }) do |item, hash| + hash[item.profile] = item.enabled ? item.export_path : '' + end + end + + def export_items material + material.material_export_items.map do |item| + { id: item.id, + profile: item.profile, + export_path: item.export_path, + enabled: item.enabled } + end + end end diff --git a/backend/app/services/material_file_sha256.rb b/backend/app/services/material_file_sha256.rb new file mode 100644 index 0000000..b4243df --- /dev/null +++ b/backend/app/services/material_file_sha256.rb @@ -0,0 +1,25 @@ +require 'digest' + +class MaterialFileSha256 + def self.from_blob blob + sha256 = blob.metadata['sha256'] + return sha256 if sha256.present? + + blob.open do |file| + sha256 = Digest::SHA256.file(file.path).hexdigest + blob.metadata['sha256'] = sha256 + blob.save! if blob.changed? + sha256 + end + end + + def self.from_upload upload + tempfile = upload&.tempfile + return nil unless tempfile + + tempfile.rewind + Digest::SHA256.file(tempfile.path).hexdigest.tap do + tempfile.rewind + end + end +end diff --git a/backend/app/services/material_import_block_matcher.rb b/backend/app/services/material_import_block_matcher.rb new file mode 100644 index 0000000..cb0a92c --- /dev/null +++ b/backend/app/services/material_import_block_matcher.rb @@ -0,0 +1,7 @@ +class MaterialImportBlockMatcher + def self.match_for_sha256 sha256 + return nil if sha256.blank? + + MaterialImportBlock.find_by(match_kind: 'sha256', sha256:) + end +end diff --git a/backend/app/services/material_version_recorder.rb b/backend/app/services/material_version_recorder.rb new file mode 100644 index 0000000..d5d997f --- /dev/null +++ b/backend/app/services/material_version_recorder.rb @@ -0,0 +1,73 @@ +class MaterialVersionRecorder < VersionRecorder + EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze + + def self.record! material:, event_type:, created_by_user:, file_snapshot: nil + new(material:, event_type:, created_by_user:, file_snapshot:).record! + end + + def initialize material:, event_type:, created_by_user:, file_snapshot: nil + @file_snapshot = file_snapshot + + super(record: material, event_type:, created_by_user:) + end + + def self.ensure_snapshot! material, created_by_user: + return if material.material_versions.exists? + + record!(material:, event_type: :create, + created_by_user: material.created_by_user || created_by_user) + end + + private + + def version_class = MaterialVersion + def version_association = :material_versions + def record_key = :material + + def snapshot_attributes + blob = @record.file.attached? ? @record.file.blob : nil + file_snapshot = build_file_snapshot(blob) + + { url: @record.url, + parent: @record.parent, + tag: @record.tag, + tag_name: @record.tag&.name, + tag_category: @record.tag&.category, + export_paths_json: @record.snapshot_export_paths, + discarded_at: @record.discarded_at, + file_blob_id: file_snapshot[:file_blob_id], + file_filename: file_snapshot[:file_filename], + file_content_type: file_snapshot[:file_content_type], + file_byte_size: file_snapshot[:file_byte_size], + file_checksum: file_snapshot[:file_checksum], + file_sha256: file_snapshot[:file_sha256], + file_suppressed_at: @record.file_suppressed_at, + file_suppression_reason: @record.file_suppression_reason } + end + + def build_file_snapshot blob + return @file_snapshot if @file_snapshot + return empty_file_snapshot unless blob + + blob.metadata['sha256'] ||= MaterialFileSha256.from_blob(blob) + blob.save! if blob.changed? + + { file_blob_id: blob.id, + file_filename: blob.filename.to_s, + file_content_type: blob.content_type, + file_byte_size: blob.byte_size, + file_checksum: blob.checksum, + file_sha256: blob.metadata['sha256'] } + end + + def empty_file_snapshot + { file_blob_id: nil, + file_filename: nil, + file_content_type: nil, + file_byte_size: nil, + file_checksum: nil, + file_sha256: nil } + end + + def event_types = self.class::EVENT_TYPES +end diff --git a/backend/app/services/material_zip_exporter.rb b/backend/app/services/material_zip_exporter.rb new file mode 100644 index 0000000..4976e54 --- /dev/null +++ b/backend/app/services/material_zip_exporter.rb @@ -0,0 +1,118 @@ +require 'stringio' +require 'zlib' + +# Initial implementation keeps every file payload and the final ZIP in memory. +# Keep this service boundary stable so job/cached export paths can replace it later. +class MaterialZipExporter + Entry = Struct.new(:path, :data, :mtime, keyword_init: true) + + class EmptyExportError < StandardError; end + class DuplicatePathError < StandardError; end + + def initialize profile: 'legacy_drive', tag_id: nil + @profile = profile.presence || 'legacy_drive' + @tag_id = tag_id.presence + end + + def export + entries = build_entries + raise EmptyExportError if entries.empty? + + ZipWriter.write(entries) + end + + private + + def build_entries + rows = MaterialExportItem + .enabled + .includes(material: { file_attachment: :blob }) + .joins(:material) + .merge(Material.kept) + .where(profile: @profile) + .where(materials: { file_suppressed_at: nil }) + .order(:export_path) + + rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id + + entries = rows.filter_map do |item| + material = item.material + next unless material.file.attached? + + Entry.new(path: item.export_path, + data: material.file.blob.download, + mtime: material.updated_at || Time.current) + end + + paths = entries.map(&:path) + duplicated = paths.find { |path| paths.count(path) > 1 } + raise DuplicatePathError, duplicated if duplicated + + entries + end + + class ZipWriter + VERSION_NEEDED = 20 + GP_FLAG = 0x0800 + COMPRESSION_STORE = 0 + + def self.write entries + new(entries).write + end + + def initialize entries + @entries = entries + @central_directory = [] + end + + def write + io = StringIO.new(''.b) + + @entries.each do |entry| + write_entry(io, entry) + end + + central_start = io.pos + @central_directory.each { |header| io.write(header) } + central_size = io.pos - central_start + + io.write([0x06054b50, 0, 0, @entries.size, @entries.size, + central_size, central_start, 0].pack('VvvvvVVv')) + io.string + end + + private + + def write_entry io, entry + path = entry.path.b + data = entry.data.b + crc32 = Zlib.crc32(data) + dos_time, dos_date = dos_timestamp(entry.mtime) + offset = io.pos + + local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE, + dos_time, dos_date, crc32, data.bytesize, data.bytesize, + path.bytesize, 0].pack('VvvvvvVVVvv') + + io.write(local_header) + io.write(path) + io.write(data) + + @central_directory << central_header(path:, crc32:, size: data.bytesize, + dos_time:, dos_date:, offset:) + end + + def central_header path:, crc32:, size:, dos_time:, dos_date:, offset: + [0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE, + dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0, + offset].pack('VvvvvvvVVVvvvvvVV') + path + end + + def dos_timestamp time + local = time.to_time + dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2) + dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day + [dos_time, dos_date] + end + end +end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index 8175dba..e289b61 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -73,7 +73,7 @@ class VersionRecorder end def validate_event_type! - return if EVENT_TYPES.include?(@event_type) + return if event_types.include?(@event_type) raise ArgumentError, "Invalid event_type: #{ @event_type }" end @@ -84,4 +84,5 @@ class VersionRecorder def snapshot_attributes = raise NotImplementedError def record_class = @record.class + def event_types = self.class::EVENT_TYPES end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index f30959b..dfbfb83 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -113,5 +113,10 @@ Rails.application.routes.draw do resources :skip_events, controller: :theatre_skip_events, only: [:index] end - resources :materials, only: [:index, :show, :create, :update, :destroy] + get 'materials/download.zip', to: 'materials#download' + resources :materials, only: [:index, :show, :create, :update, :destroy] do + member do + patch :suppress_file + end + end end diff --git a/backend/db/migrate/20260623000000_enhance_material_management.rb b/backend/db/migrate/20260623000000_enhance_material_management.rb new file mode 100644 index 0000000..27f6338 --- /dev/null +++ b/backend/db/migrate/20260623000000_enhance_material_management.rb @@ -0,0 +1,98 @@ +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| + t.string :event_type + t.string :tag_name + t.string :tag_category + t.json :export_paths_json + t.bigint :file_blob_id + t.string :file_filename + t.string :file_content_type + 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 + UPDATE material_versions + SET event_type = CASE + WHEN version_no = 1 THEN 'create' + ELSE 'update' + END + WHERE event_type IS NULL + SQL + + change_column_null :material_versions, :event_type, false + add_index :material_versions, :file_blob_id + + add_check_constraint :material_versions, + "event_type IN ('create', 'update', 'discard', 'restore', 'suppress')", + name: 'material_versions_event_type_valid' + + create_table :material_export_items do |t| + t.references :material, null: false, foreign_key: true + t.string :profile, null: false, default: 'legacy_drive' + t.string :export_path, null: false + t.boolean :enabled, null: false, default: true + t.references :created_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.index [:profile, :export_path], unique: true + t.index [:material_id, :profile], unique: true + end + + create_table :material_import_blocks do |t| + t.string :match_kind, null: false + t.string :sha256 + t.string :external_path_pattern + t.string :reason, null: false + t.text :note + t.references :created_by_user, foreign_key: { to_table: :users } + t.timestamps + end + + execute <<~SQL.squish + UPDATE materials + SET version_no = COALESCE( + (SELECT MAX(material_versions.version_no) + FROM material_versions + WHERE material_versions.material_id = materials.id), + 1) + SQL + end + + def down + drop_table :material_import_blocks + drop_table :material_export_items + + remove_check_constraint :material_versions, name: 'material_versions_event_type_valid' + + remove_index :material_versions, :file_blob_id + remove_column :material_versions, :event_type + remove_column :material_versions, :tag_name + remove_column :material_versions, :tag_category + remove_column :material_versions, :export_paths_json + remove_column :material_versions, :file_blob_id + remove_column :material_versions, :file_filename + remove_column :material_versions, :file_content_type + 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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 8c988e5..7bc7d8e 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -130,6 +130,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true end + create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "material_id", null: false + t.string "profile", default: "legacy_drive", null: false + t.string "export_path", null: false + t.boolean "enabled", default: true, null: false + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_material_export_items_on_created_by_user_id" + t.index ["material_id", "profile"], name: "index_material_export_items_on_material_id_and_profile", unique: true + t.index ["material_id"], name: "index_material_export_items_on_material_id" + t.index ["profile", "export_path"], name: "index_material_export_items_on_profile_and_export_path", unique: true + end + + create_table "material_import_blocks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "match_kind", null: false + t.string "sha256" + t.string "external_path_pattern" + t.string "reason", null: false + t.text "note" + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id" + end + create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.integer "version_no", null: false @@ -141,14 +167,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.string "event_type", null: false + t.string "tag_name" + t.string "tag_category" + t.json "export_paths_json" + t.bigint "file_blob_id" + t.string "file_filename" + t.string "file_content_type" + t.bigint "file_byte_size" + t.string "file_checksum" + t.string "file_sha256" + t.datetime "file_suppressed_at" + t.string "file_suppression_reason" t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id" t.index ["discarded_at"], name: "index_material_versions_on_discarded_at" + t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id" t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true t.index ["material_id"], name: "index_material_versions_on_material_id" t.index ["parent_id"], name: "index_material_versions_on_parent_id" t.index ["tag_id"], name: "index_material_versions_on_tag_id" t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id" t.index ["url"], name: "index_material_versions_on_url" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore',_utf8mb4'suppress')", name: "material_versions_event_type_valid" end create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -161,9 +201,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do t.datetime "updated_at", null: false t.datetime "discarded_at" t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)" + t.integer "version_no", default: 1, null: false + t.datetime "file_suppressed_at" + t.bigint "file_suppressed_by_user_id" + t.string "file_suppression_reason" t.index ["active_url"], name: "index_materials_on_active_url", unique: true t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id" t.index ["discarded_at"], name: "index_materials_on_discarded_at" + t.index ["file_suppressed_by_user_id"], name: "index_materials_on_file_suppressed_by_user_id" t.index ["parent_id"], name: "index_materials_on_parent_id" t.index ["tag_id"], name: "index_materials_on_tag_id" t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id" @@ -567,6 +612,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "users", column: "created_by_id" + add_foreign_key "material_export_items", "materials" + add_foreign_key "material_export_items", "users", column: "created_by_user_id" + add_foreign_key "material_import_blocks", "users", column: "created_by_user_id" add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "tags" @@ -575,6 +623,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do add_foreign_key "materials", "materials", column: "parent_id" add_foreign_key "materials", "tags" add_foreign_key "materials", "users", column: "created_by_user_id" + add_foreign_key "materials", "users", column: "file_suppressed_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" diff --git a/backend/spec/models/material_export_item_spec.rb b/backend/spec/models/material_export_item_spec.rb new file mode 100644 index 0000000..d98e225 --- /dev/null +++ b/backend/spec/models/material_export_item_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +RSpec.describe MaterialExportItem, type: :model do + let(:user) { create(:user, :member) } + let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) } + let(:material) do + Material.create!(tag:, url: 'https://example.com/material', + created_by_user: user, updated_by_user: user) + end + + it 'rejects blank export_path' do + item = described_class.new(material:, profile: 'legacy_drive', export_path: '') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects absolute export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '/素材/a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects parent traversal export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '素材/../a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects double slash export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '素材//a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects dot segment export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: './素材/a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects trailing slash export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '素材/a/') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end +end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index d4085ec..1193ab7 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -1,7 +1,10 @@ require 'rails_helper' RSpec.describe 'Materials API', type: :request do + include ActiveJob::TestHelper + let!(:member_user) { create(:user, :member) } + let!(:admin_user) { create(:user, :admin) } let!(:guest_user) { create(:user) } def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') @@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do end def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil) - Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material| + Material.new(tag:, parent:, url:, + created_by_user: user, + updated_by_user: user).tap do |material| material.file.attach(file) if file material.save! end end describe 'GET /materials' do - let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) } - let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) } + let!(:tag_a) do + Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) + end + let!(:tag_b) do + Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) + end let!(:material_a) do build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png')) end let!(:material_b) do - build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png')) + build_material(tag: tag_b, user: member_user, parent: material_a, + file: dummy_upload(filename: 'b.png')) end before do @@ -97,7 +107,9 @@ RSpec.describe 'Materials API', type: :request do end describe 'GET /materials/:id' do - let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) } + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) + end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) end @@ -138,9 +150,22 @@ RSpec.describe 'Materials API', type: :request do end end - context 'when logged in' do + context 'when logged in but not member' do before { sign_in_as(guest_user) } + it 'returns 403' do + post '/materials', params: { + tag: 'material_create_guest_forbidden', + file: dummy_upload + } + + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before { sign_in_as(member_user) } + it 'returns 422 when tag is blank' do post '/materials', params: { tag: ' ', file: dummy_upload } @@ -162,24 +187,49 @@ RSpec.describe 'Materials API', type: :request do expect do post '/materials', params: { tag: 'material_create_new', - file: dummy_upload(filename: 'created.png') + file: dummy_upload(filename: 'created.png'), + export_paths: { legacy_drive: '伊地知ニジカ/created.png' } } end.to change(Material, :count).by(1) .and change(Tag, :count).by(1) .and change(TagName, :count).by(1) + .and change(MaterialVersion, :count).by(1) expect(response).to have_http_status(:created) material = Material.order(:id).last expect(material.tag.name).to eq('material_create_new') expect(material.tag.category).to eq('material') - expect(material.created_by_user).to eq(guest_user) - expect(material.updated_by_user).to eq(guest_user) + expect(material.created_by_user).to eq(member_user) + expect(material.updated_by_user).to eq(member_user) expect(material.file.attached?).to be(true) + expect(material.version_no).to eq(1) + expect(material.material_versions.first.event_type).to eq('create') + expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png') + expect(material.material_versions.first.export_paths_json).to eq( + 'legacy_drive' => '伊地知ニジカ/created.png' + ) expect(json['id']).to eq(material.id) expect(json.dig('tag', 'name')).to eq('material_create_new') expect(json['content_type']).to eq('image/png') + expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png') + end + + it 'snapshots attached file metadata and sha256' do + post '/materials', params: { + tag: 'material_create_file_version', + file: dummy_upload(filename: 'created.png', body: 'sha-body') + } + + expect(response).to have_http_status(:created) + + version = Material.order(:id).last.material_versions.first + expect(version.file_blob_id).to be_present + expect(version.file_filename).to eq('created.png') + expect(version.file_content_type).to eq('image/png') + expect(version.file_byte_size).to eq('sha-body'.bytesize) + expect(version.file_sha256).to eq(Digest::SHA256.hexdigest('sha-body')) end it 'returns 422 when the existing tag is not material/character' do @@ -219,11 +269,33 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:created) expect(json['url']).to eq('https://example.com/material-source') end + + it 'rejects sha256-blocked file upload' do + sha256 = Digest::SHA256.hexdigest('blocked-body') + MaterialImportBlock.create!(match_kind: 'sha256', + sha256:, + reason: 'copyright_high_risk', + created_by_user: admin_user) + + expect do + post '/materials', params: { + tag: 'material_blocked_create', + file: dummy_upload(filename: 'blocked.png', body: 'blocked-body') + } + end.not_to change(Material, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'file' => ['抑止された素材です: copyright_high_risk'] + ) + end end end describe 'PUT /materials/:id' do - let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) } + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) + end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) end @@ -277,25 +349,26 @@ RSpec.describe 'Materials API', type: :request do 'tag' => ['タグは必須です.']) end - it 'returns 422 when both file and url are blank' do + it 'keeps the existing file when file and url are omitted' do put "/materials/#{ material.id }", params: { tag: 'material_update_no_payload' } - expect(response).to have_http_status(:unprocessable_entity) - expect(json.fetch('errors')).to include( - 'file' => ['ファイルまたは URL は必須です.'], - 'url' => ['ファイルまたは URL は必須です.']) + expect(response).to have_http_status(:ok) + expect(material.reload.file.attached?).to be(true) end it 'updates tag, url, file, and updated_by_user' do old_blob_id = material.file.blob.id - put "/materials/#{ material.id }", params: { - tag: 'material_update_new', - url: 'https://example.com/updated-source', - file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg') - } + expect do + put "/materials/#{ material.id }", params: { + tag: 'material_update_new', + url: 'https://example.com/updated-source', + file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'), + export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' } + } + end.to change(MaterialVersion, :count).by(2) expect(response).to have_http_status(:ok) @@ -306,8 +379,15 @@ RSpec.describe 'Materials API', type: :request do expect(material.updated_by_user).to eq(member_user) expect(material.file.attached?).to be(true) expect(material.file.blob.id).not_to eq(old_blob_id) + expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true) expect(material.file.blob.filename.to_s).to eq('updated.jpg') expect(material.file.blob.content_type).to eq('image/jpeg') + expect(material.version_no).to eq(2) + expect(material.material_versions.order(:version_no).last.event_type).to eq('update') + expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/updated.jpg') + expect(material.material_versions.order(:version_no).last.export_paths_json).to eq( + 'legacy_drive' => '伊地知ニジカ/updated.jpg' + ) expect(json['id']).to eq(material.id) expect(json['file']).to be_present @@ -315,7 +395,7 @@ RSpec.describe 'Materials API', type: :request do expect(json.dig('tag', 'name')).to eq('material_update_new') end - it 'purges the existing file when file is omitted and url is provided' do + it 'detaches the existing file without purging blob when url replaces file' do old_blob_id = material.file.blob.id put "/materials/#{ material.id }", params: { @@ -331,9 +411,7 @@ RSpec.describe 'Materials API', type: :request do expect(material.updated_by_user).to eq(member_user) expect(material.file.attached?).to be(false) - expect( - ActiveStorage::Blob.where(id: old_blob_id).exists? - ).to be(false) + expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true) expect(json['id']).to eq(material.id) expect(json['file']).to be_nil @@ -341,11 +419,190 @@ RSpec.describe 'Materials API', type: :request do expect(json.dig('tag', 'name')).to eq('material_update_remove_file') expect(json['url']).to eq('https://example.com/updated-source') end + + it 'does not increase version for the same snapshot update' do + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + put "/materials/#{ material.id }", params: { + tag: 'material_update_old' + } + end.not_to change(MaterialVersion, :count) + + expect(response).to have_http_status(:ok) + expect(material.reload.version_no).to eq(1) + end + + it 'records update version when only export_path changes' do + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + put "/materials/#{ material.id }", params: { + tag: 'material_update_old', + export_paths: { legacy_drive: '素材/only-path.png' } + } + end.to change(MaterialVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(material.reload.material_export_items.first.export_path).to eq('素材/only-path.png') + expect(material.material_versions.order(:version_no).last.export_paths_json).to eq( + 'legacy_drive' => '素材/only-path.png' + ) + end + + it 'removes export_path item when blank is submitted' do + MaterialExportItem.create!(material:, profile: 'legacy_drive', + export_path: '素材/remove.png', + created_by_user: member_user) + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + put "/materials/#{ material.id }", params: { + tag: 'material_update_old', + export_paths: { legacy_drive: '' } + } + end.to change(MaterialExportItem, :count).by(-1) + .and change(MaterialVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(material.reload.material_export_items).to be_empty + expect(material.material_versions.order(:version_no).last.export_paths_json).to eq({}) + end + + it 'rejects sha256-blocked replacement file' do + sha256 = Digest::SHA256.hexdigest('blocked-update') + MaterialImportBlock.create!(match_kind: 'sha256', + sha256:, + reason: 'source_owner_request', + created_by_user: admin_user) + + put "/materials/#{ material.id }", params: { + tag: 'material_update_old', + file: dummy_upload(filename: 'blocked.png', body: 'blocked-update') + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(material.reload.file.blob.filename.to_s).to eq('old.png') + end + end + end + + describe 'GET /materials/download.zip' do + let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) } + let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) } + let!(:material_a) do + build_material(tag: tag_a, user: member_user, + file: dummy_upload(filename: 'a.png', body: 'zip-a')) + end + let!(:material_b) do + build_material(tag: tag_b, user: member_user, + file: dummy_upload(filename: 'b.png', body: 'zip-b')) + end + + before do + MaterialExportItem.create!(material: material_a, profile: 'legacy_drive', + export_path: '素材/a.png', + created_by_user: member_user) + MaterialExportItem.create!(material: material_b, profile: 'legacy_drive', + export_path: '素材/b.png', + created_by_user: member_user) + end + + it 'uses material_export_items.export_path as ZIP entry paths' do + get '/materials/download.zip', params: { profile: 'legacy_drive' } + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/zip') + expect(response.body.b).to include('素材/a.png'.b) + expect(response.body.b).to include('素材/b.png'.b) + end + + it 'filters by tag_id' do + get '/materials/download.zip', params: { profile: 'legacy_drive', tag_id: tag_a.id } + + expect(response).to have_http_status(:ok) + expect(response.body.b).to include('素材/a.png'.b) + 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') + + get '/materials/download.zip', params: { profile: 'legacy_drive' } + + expect(response).to have_http_status(:ok) + expect(response.body.b).to include('素材/a.png'.b) + expect(response.body.b).not_to include('素材/b.png'.b) + end + end + + describe 'PATCH /materials/:id/suppress_file' do + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material) + end + let!(:material) do + build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png')) + end + + it 'allows admin to suppress a file and records a suppress version' do + sign_in_as(admin_user) + 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) + + expect(response).to have_http_status(:ok) + + 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 + 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 + sign_in_as(member_user) + + patch "/materials/#{ material.id }/suppress_file", + params: { reason: 'copyright_high_risk' } + + expect(response).to have_http_status(:forbidden) end end describe 'DELETE /materials/:id' do - let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) } + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) + end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 78e7676..7796d7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,7 +69,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }> }/> }/> - }/> + }/> {/* }/> */} }/> diff --git a/frontend/src/pages/materials/MaterialDetailPage.test.tsx b/frontend/src/pages/materials/MaterialDetailPage.test.tsx index 2b697a9..bb6fc6b 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.test.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.test.tsx @@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ apiGet: vi.fn (), + apiPatch: vi.fn (), apiPut: vi.fn (), })) @@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi) const renderPage = () => renderWithProviders ( - }/> + }/> , { route: '/materials/8' }, ) @@ -73,6 +74,7 @@ describe ('MaterialDetailPage', () => { const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'new' } }) fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } }) + fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } }) fireEvent.click (screen.getByRole ('button', { name: '更新' })) await waitFor (() => { @@ -81,6 +83,7 @@ describe ('MaterialDetailPage', () => { const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('tag')).toBe ('new') expect (formData.get ('url')).toBe ('https://example.com/ref') + expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png') expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) }) }) diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 09f537e..782505b 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -13,22 +13,23 @@ import MainArea from '@/components/layout/MainArea' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' -import { apiGet, apiPut } from '@/lib/api' +import { apiGet, apiPatch, apiPut } from '@/lib/api' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' -import type { Material, Tag } from '@/types' +import type { Material, Tag, User } from '@/types' type MaterialWithTag = Material & { tag: Tag } -type MaterialFormField = 'tag' | 'file' | 'url' +type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' -const MaterialDetailPage: FC = () => { +const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { const { id } = useParams () + const [exportPath, setExportPath] = useState ('') const [file, setFile] = useState (null) const [filePreview, setFilePreview] = useState ('') const [loading, setLoading] = useState (false) @@ -49,6 +50,7 @@ const MaterialDetailPage: FC = () => { formData.append ('file', file) if (url.trim ()) formData.append ('url', url) + formData.append ('export_paths[legacy_drive]', exportPath) try { @@ -68,6 +70,30 @@ const MaterialDetailPage: FC = () => { } } + const handleSuppress = async () => { + const reason = window.prompt ('抑止理由を入力してください。') + if (reason == null || reason.trim () === '') + return + if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。')) + return + + try + { + const data = await apiPatch ( + `/materials/${ id }/suppress_file`, + { reason }, + ) + setMaterial (data) + setFile (null) + setFilePreview ('') + toast ({ title: '抑止しました' }) + } + catch + { + toast ({ title: '抑止に失敗しました' }) + } + } + useEffect (() => { if (!(id)) return @@ -82,11 +108,10 @@ const MaterialDetailPage: FC = () => { if (data.file && data.contentType) { setFilePreview (data.file) - setFile (new File ([await (await fetch (data.file)).blob ()], - data.file, - { type: data.contentType })) + setFile (null) } setURL (data.url ?? '') + setExportPath (data.exportPaths.legacyDrive ?? '') } finally { @@ -111,7 +136,14 @@ const MaterialDetailPage: FC = () => { withCount={false}/> - {(material.file && material.contentType) && ( + {material.fileSuppressedAt && ( +
+ 素材ファイルは抑止済みです。 + {material.fileSuppressionReason && ( + 理由: {material.fileSuppressionReason})} +
)} + + {(!material.fileSuppressedAt && material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( {material.tag.name)) || (/video\/.*/.test (material.contentType) && ( @@ -189,13 +221,36 @@ const MaterialDetailPage: FC = () => { className={inputClass (invalid)}/>)} + + {({ describedBy, invalid }) => ( + setExportPath (e.target.value)} + placeholder="伊地知ニジカ/表情/泣き.png" + aria-describedby={describedBy} + aria-invalid={invalid} + className={inputClass (invalid)}/>)} + + {/* 送信 */} - +
+ + {user?.role === 'admin' && !material.fileSuppressedAt && ( + )} +
diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index aa14ec8..413427a 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -9,7 +9,7 @@ import PageTitle from '@/components/common/PageTitle' import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' import MainArea from '@/components/layout/MainArea' -import { SITE_TITLE } from '@/config' +import { API_BASE_URL, SITE_TITLE } from '@/config' import { apiGet } from '@/lib/api' import type { FC } from 'react' @@ -30,10 +30,15 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { to={`/materials/${ tag.material.id }`} className="block w-40 h-40">
- {(tag.material.contentType && /image\/.*/.test (tag.material.contentType)) + {tag.material.fileSuppressedAt + ? 抑止済み + : (tag.material.contentType && /image\/.*/.test (tag.material.contentType)) ? : 照会}
@@ -108,7 +113,7 @@ const MaterialListPage: FC = () => { -
+
{tag.children.map (c2 => ( @@ -159,7 +164,11 @@ const MaterialListPage: FC = () => {

もしくは……

))} ) diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index bfbb357..0571580 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -17,7 +17,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' -type MaterialFormField = 'tag' | 'file' | 'url' +type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' const MaterialNewPage: FC = () => { @@ -32,6 +32,7 @@ const MaterialNewPage: FC = () => { const [sending, setSending] = useState (false) const [tag, setTag] = useState (tagQuery) const [url, setURL] = useState ('') + const [exportPath, setExportPath] = useState ('') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () @@ -45,6 +46,7 @@ const MaterialNewPage: FC = () => { formData.append ('file', file) if (url) formData.append ('url', url) + formData.append ('export_paths[legacy_drive]', exportPath) try { @@ -133,6 +135,20 @@ const MaterialNewPage: FC = () => { className={inputClass (invalid)}/>)} + + {({ describedBy, invalid }) => ( + setExportPath (e.target.value)} + placeholder="伊地知ニジカ/表情/泣き.png" + aria-describedby={describedBy} + aria-invalid={invalid} + className={inputClass (invalid)}/>)} + + {/* 送信 */}