このコミットが含まれているのは:
@@ -0,0 +1,49 @@
|
||||
class MaterialSyncSuppressionsController < ApplicationController
|
||||
def index
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
suppressions =
|
||||
MaterialSyncSuppression
|
||||
.includes(:created_by_user)
|
||||
.order(created_at: :desc, id: :desc)
|
||||
|
||||
render json: { suppressions: suppressions.map { |s| repr(s) } }
|
||||
end
|
||||
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
suppression =
|
||||
MaterialSyncSuppressionRegistrar.create!(suppression_params,
|
||||
created_by_user: current_user)
|
||||
|
||||
render json: repr(suppression), status: :created
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_validation_error e.record
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def suppression_params
|
||||
params.permit(:source_kind,
|
||||
:source_uri,
|
||||
:drive_path,
|
||||
:drive_file_id,
|
||||
:reason)
|
||||
end
|
||||
|
||||
def repr suppression
|
||||
{ id: suppression.id,
|
||||
source_kind: suppression.source_kind,
|
||||
source_uri: suppression.source_uri,
|
||||
drive_path: suppression.drive_path,
|
||||
drive_file_id: suppression.drive_file_id,
|
||||
normalized_source_key: suppression.normalized_source_key,
|
||||
reason: suppression.reason,
|
||||
created_by_user: suppression.created_by_user&.as_json(UserRepr::BASE),
|
||||
created_at: suppression.created_at,
|
||||
updated_at: suppression.updated_at }
|
||||
end
|
||||
end
|
||||
@@ -17,8 +17,6 @@ class MaterialsController < ApplicationController
|
||||
thumbnail_attachment: :blob,
|
||||
file_attachment: :blob,
|
||||
tag: :tag_name)
|
||||
q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active'
|
||||
q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed'
|
||||
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
|
||||
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
|
||||
q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from]
|
||||
@@ -136,7 +134,6 @@ class MaterialsController < ApplicationController
|
||||
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
||||
if uploaded_blob
|
||||
material.file.attach(uploaded_blob)
|
||||
clear_file_suppression!(material)
|
||||
elsif params.key?(:url) && url.present? && file.blank?
|
||||
material.file.detach
|
||||
end
|
||||
@@ -182,37 +179,6 @@ class MaterialsController < ApplicationController
|
||||
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 material_index_filters
|
||||
@@ -225,9 +191,6 @@ class MaterialsController < ApplicationController
|
||||
media_kind = 'all'
|
||||
end
|
||||
|
||||
suppression = params[:suppression].to_s.presence
|
||||
suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression)
|
||||
|
||||
sort = params[:sort].to_s.presence
|
||||
unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
|
||||
'version_no', 'id'].include?(sort)
|
||||
@@ -240,7 +203,6 @@ class MaterialsController < ApplicationController
|
||||
{ q: params[:q].to_s.strip.presence,
|
||||
tag_state:,
|
||||
media_kind:,
|
||||
suppression:,
|
||||
created_from: parse_time_param(:created_from),
|
||||
created_to: parse_time_param(:created_to),
|
||||
updated_from: parse_time_param(:updated_from),
|
||||
@@ -410,26 +372,6 @@ class MaterialsController < ApplicationController
|
||||
file.tempfile.rewind if file&.tempfile
|
||||
end
|
||||
|
||||
def clear_file_suppression! material
|
||||
material.file_suppressed_at = nil
|
||||
material.file_suppressed_by_user = nil
|
||||
material.file_suppression_reason = nil
|
||||
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
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -17,13 +16,17 @@ class Material < ApplicationRecord
|
||||
has_one_attached :file, dependent: :purge
|
||||
has_one_attached :thumbnail, dependent: :purge
|
||||
|
||||
before_validation :assign_normalized_source_key
|
||||
|
||||
validates :tag_id, presence: true, uniqueness: true
|
||||
validates :source_kind,
|
||||
inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS },
|
||||
allow_blank: true
|
||||
|
||||
validate :file_must_be_attached
|
||||
validate :tag_must_be_material_category
|
||||
|
||||
def content_type
|
||||
return nil if file_suppressed?
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.content_type
|
||||
@@ -41,14 +44,25 @@ class Material < ApplicationRecord
|
||||
file.blob.filename.to_s
|
||||
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 assign_normalized_source_key
|
||||
if source_kind.blank?
|
||||
self.normalized_source_key = nil
|
||||
return
|
||||
end
|
||||
|
||||
self.normalized_source_key =
|
||||
MaterialSyncSuppression.normalize_source_key(source_kind:,
|
||||
source_uri:,
|
||||
drive_path: source_path,
|
||||
drive_file_id: source_file_id)
|
||||
end
|
||||
|
||||
def file_must_be_attached
|
||||
return if url.present? || file.attached?
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class MaterialSyncSuppression < ApplicationRecord
|
||||
SOURCE_KINDS = [
|
||||
'uri',
|
||||
'google_drive_path',
|
||||
'google_drive_file',
|
||||
'legacy_drive_path'
|
||||
].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
|
||||
|
||||
before_validation :assign_normalized_source_key
|
||||
|
||||
validates :source_kind, presence: true, inclusion: { in: SOURCE_KINDS }
|
||||
validates :reason, presence: true, inclusion: { in: REASONS }
|
||||
validates :normalized_source_key, presence: true, uniqueness: true
|
||||
validate :source_value_must_be_present
|
||||
|
||||
def self.normalize_source_key source_kind:,
|
||||
source_uri: nil,
|
||||
drive_path: nil,
|
||||
drive_file_id: nil,
|
||||
source_path: nil,
|
||||
source_file_id: nil
|
||||
kind = source_kind.to_s
|
||||
value =
|
||||
case kind
|
||||
when 'uri'
|
||||
source_uri.to_s.strip
|
||||
when 'google_drive_path', 'legacy_drive_path'
|
||||
(source_path || drive_path).to_s.strip.gsub(%r{/+}, '/')
|
||||
when 'google_drive_file'
|
||||
(source_file_id || drive_file_id).to_s.strip
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
return nil if kind.blank? || value.blank?
|
||||
|
||||
"#{ kind }:#{ value }"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_normalized_source_key
|
||||
self.normalized_source_key =
|
||||
self.class.normalize_source_key(source_kind:,
|
||||
source_uri:,
|
||||
drive_path:,
|
||||
drive_file_id:)
|
||||
end
|
||||
|
||||
def source_value_must_be_present
|
||||
return if normalized_source_key.present?
|
||||
|
||||
errors.add(:base, '同期元を指定してください.')
|
||||
end
|
||||
end
|
||||
@@ -2,8 +2,7 @@ class MaterialVersion < ApplicationRecord
|
||||
EVENT_TYPE_MAP = { create: 'create',
|
||||
update: 'update',
|
||||
discard: 'discard',
|
||||
restore: 'restore',
|
||||
suppress: 'suppress' }.freeze
|
||||
restore: 'restore' }.freeze
|
||||
|
||||
include VersionRecord
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
|
||||
module MaterialRepr
|
||||
BASE = { only: [:id, :url, :version_no, :file_suppressed_at,
|
||||
:file_suppression_reason, :created_at, :updated_at],
|
||||
BASE = { only: [:id, :url, :version_no, :source_kind, :source_uri,
|
||||
:source_path, :source_file_id, :normalized_source_key,
|
||||
:created_at, :updated_at],
|
||||
methods: [:content_type],
|
||||
include: { tag: TagRepr::BASE,
|
||||
created_by_user: UserRepr::BASE,
|
||||
@@ -13,7 +14,7 @@ module MaterialRepr
|
||||
|
||||
def base material, host:
|
||||
material.as_json(BASE).merge(
|
||||
file: if material.file.attached? && !material.file_suppressed?
|
||||
file: if material.file.attached?
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
material.file, host:)
|
||||
end,
|
||||
@@ -34,6 +35,11 @@ module MaterialRepr
|
||||
{ id: material.id,
|
||||
version_no: material.version_no,
|
||||
url: material.url,
|
||||
source_kind: material.source_kind,
|
||||
source_uri: material.source_uri,
|
||||
source_path: material.source_path,
|
||||
source_file_id: material.source_file_id,
|
||||
normalized_source_key: material.normalized_source_key,
|
||||
tag: compact_tag(material.tag),
|
||||
thumbnail: thumbnail_url(material, host:),
|
||||
thumbnail_fallback_text: thumbnail_fallback_text(material),
|
||||
@@ -41,7 +47,6 @@ module MaterialRepr
|
||||
media_kind: media_kind(material),
|
||||
content_type: material.content_type,
|
||||
file_byte_size: material.file_byte_size,
|
||||
file_suppressed_at: material.file_suppressed_at,
|
||||
created_at: material.created_at,
|
||||
updated_at: material.updated_at,
|
||||
export_paths: export_paths(material),
|
||||
@@ -68,7 +73,6 @@ module MaterialRepr
|
||||
end
|
||||
|
||||
def thumbnail_url material, host:
|
||||
return nil if material.file_suppressed?
|
||||
return nil unless material.thumbnail.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
@@ -84,7 +88,6 @@ module MaterialRepr
|
||||
end
|
||||
|
||||
def media_kind material
|
||||
return 'suppressed' if material.file_suppressed?
|
||||
return 'url_only' unless material.file.attached?
|
||||
|
||||
content_type = material.file.blob.content_type.to_s
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
class MaterialSyncImporter
|
||||
Result = Struct.new(:material, :suppressed, :suppression, keyword_init: true)
|
||||
|
||||
def self.import! attributes
|
||||
new(attributes).import!
|
||||
end
|
||||
|
||||
def initialize attributes
|
||||
@attributes = attributes
|
||||
end
|
||||
|
||||
def import!
|
||||
source = source_attributes
|
||||
key = normalized_source_key(source)
|
||||
raise ArgumentError, 'normalized_source_key is required for material sync' if key.blank?
|
||||
|
||||
suppression = MaterialSyncSuppressionMatcher.match(**source)
|
||||
return Result.new(material: nil, suppressed: true, suppression:) if suppression
|
||||
|
||||
material =
|
||||
Material
|
||||
.unscoped
|
||||
.find_or_initialize_by(normalized_source_key: key)
|
||||
material.assign_attributes(material_attributes.merge(source))
|
||||
material.save!
|
||||
|
||||
Result.new(material:, suppressed: false, suppression: nil)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def source_attributes
|
||||
{ source_kind: @attributes[:source_kind],
|
||||
source_uri: @attributes[:source_uri],
|
||||
source_path: @attributes[:source_path],
|
||||
source_file_id: @attributes[:source_file_id] }
|
||||
end
|
||||
|
||||
def normalized_source_key source
|
||||
MaterialSyncSuppression.normalize_source_key(source_kind: source[:source_kind],
|
||||
source_uri: source[:source_uri],
|
||||
source_path: source[:source_path],
|
||||
source_file_id: source[:source_file_id])
|
||||
end
|
||||
|
||||
def material_attributes
|
||||
@attributes.except(:source_kind, :source_uri, :source_path, :source_file_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class MaterialSyncSuppressionMatcher
|
||||
def self.match source_kind:,
|
||||
source_uri: nil,
|
||||
drive_path: nil,
|
||||
drive_file_id: nil,
|
||||
source_path: nil,
|
||||
source_file_id: nil
|
||||
key = MaterialSyncSuppression.normalize_source_key(source_kind:,
|
||||
source_uri:,
|
||||
drive_path:,
|
||||
drive_file_id:,
|
||||
source_path:,
|
||||
source_file_id:)
|
||||
return nil if key.blank?
|
||||
|
||||
MaterialSyncSuppression.find_by(normalized_source_key: key)
|
||||
end
|
||||
|
||||
def self.suppressed?(...)
|
||||
match(...).present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
class MaterialSyncSuppressionRegistrar
|
||||
def self.create! attributes, created_by_user:
|
||||
new(attributes, created_by_user:).create!
|
||||
end
|
||||
|
||||
def initialize attributes, created_by_user:
|
||||
@attributes = attributes
|
||||
@created_by_user = created_by_user
|
||||
end
|
||||
|
||||
def create!
|
||||
suppression = nil
|
||||
|
||||
MaterialSyncSuppression.transaction do
|
||||
suppression = MaterialSyncSuppression.create!(
|
||||
@attributes.merge(created_by_user: @created_by_user))
|
||||
discard_existing_materials!(suppression)
|
||||
end
|
||||
|
||||
suppression
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def discard_existing_materials! suppression
|
||||
Material.unscoped
|
||||
.kept
|
||||
.where(normalized_source_key: suppression.normalized_source_key)
|
||||
.find_each do |material|
|
||||
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: @created_by_user)
|
||||
material.discard!
|
||||
MaterialVersionRecorder.record!(material:,
|
||||
event_type: :discard,
|
||||
created_by_user: @created_by_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class MaterialVersionRecorder < VersionRecorder
|
||||
EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze
|
||||
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
|
||||
|
||||
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
|
||||
new(material:, event_type:, created_by_user:, file_snapshot:).record!
|
||||
@@ -40,9 +40,7 @@ class MaterialVersionRecorder < VersionRecorder
|
||||
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 }
|
||||
file_sha256: file_snapshot[:file_sha256] }
|
||||
end
|
||||
|
||||
def build_file_snapshot blob
|
||||
|
||||
@@ -40,7 +40,6 @@ class MaterialZipExporter
|
||||
.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
|
||||
|
||||
新しい課題から参照
ユーザをブロックする