class MaterialsController < ApplicationController rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file def index page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i page = 1 if page < 1 limit = 1 if limit < 1 offset = (page - 1) * limit tag_id = params[:tag_id].presence parent_id = params[:parent_id].presence 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 count = q.count materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count } end def show material = Material .includes(:tag, :material_export_items) .with_attached_file .find_by(id: params[:id]) return head :not_found unless material wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:) end 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 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) 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 render json: MaterialRepr.base(material, host: request.base_url), status: :created else render_validation_error material end end def update return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? material = Material.with_attached_file.find_by(id: params[:id]) return head :not_found unless material tag_name_raw = params[:tag].to_s.strip file = params[:file] 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? && !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 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) 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 render json: MaterialRepr.base(material, host: request.base_url) end def destroy return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? material = Material.find_by(id: params[:id]) return head :not_found unless material 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 render_zip_missing_file error missing_files = error.missing_files.map do |missing_file| { material_id: missing_file.material_id, export_path: missing_file.export_path, blob_id: missing_file.blob_id, filename: missing_file.filename } end render json: { type: 'validation_error', message: 'ZIP export に必要な素材ファイルが欠損しています.', errors: { }, base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'], missing_files: }, status: :unprocessable_entity 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, allow_download: true) } end def render_material_import_block block render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] } end end