Merge remote-tracking branch 'origin/main' into feature/351
このコミットが含まれているのは:
@@ -1,6 +1,9 @@
|
||||
class Material < ApplicationRecord
|
||||
include MyDiscard
|
||||
|
||||
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file',
|
||||
'legacy_drive_path'].freeze
|
||||
|
||||
default_scope -> { kept }
|
||||
|
||||
belongs_to :parent, class_name: 'Material', optional: true
|
||||
@@ -10,9 +13,18 @@ class Material < ApplicationRecord
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
|
||||
has_one_attached :file, dependent: :purge
|
||||
has_many :material_versions, dependent: :destroy
|
||||
has_many :material_export_items, dependent: :destroy
|
||||
|
||||
validates :tag_id, presence: true, uniqueness: true
|
||||
has_one_attached :file, dependent: :purge
|
||||
has_one_attached :thumbnail, dependent: :purge
|
||||
|
||||
before_validation :assign_normalized_source_key
|
||||
|
||||
validates :tag_id, uniqueness: true, allow_nil: true
|
||||
validates :source_kind,
|
||||
inclusion: { in: SOURCE_KINDS },
|
||||
allow_blank: true
|
||||
|
||||
validate :file_must_be_attached
|
||||
validate :tag_must_be_material_category
|
||||
@@ -23,8 +35,37 @@ class Material < ApplicationRecord
|
||||
file.blob.content_type
|
||||
end
|
||||
|
||||
def file_byte_size
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.byte_size
|
||||
end
|
||||
|
||||
def file_filename
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.filename.to_s
|
||||
end
|
||||
|
||||
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,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
|
||||
@@ -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
|
||||
@@ -0,0 +1,45 @@
|
||||
class MaterialSyncSource < ApplicationRecord
|
||||
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file', 'legacy_drive_path'].freeze
|
||||
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
|
||||
validates :name, presence: true
|
||||
validates :source_kind,
|
||||
presence: true,
|
||||
inclusion: { in: SOURCE_KINDS }
|
||||
validates :profile, presence: true, inclusion: { in: MaterialExportItem::VALID_PROFILES }
|
||||
validate :source_value_must_be_present
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
def normalized_source_key
|
||||
MaterialSyncSuppression.normalize_source_key(source_kind:,
|
||||
source_uri:,
|
||||
source_path:,
|
||||
source_file_id:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def source_value_must_be_present
|
||||
return if source_value_present?
|
||||
|
||||
errors.add(:base, '同期元を指定してください.')
|
||||
end
|
||||
|
||||
def source_value_present?
|
||||
case source_kind
|
||||
when 'uri'
|
||||
source_uri.present?
|
||||
when 'google_drive_file'
|
||||
source_file_id.present? || source_uri.present?
|
||||
when 'google_drive_path'
|
||||
source_file_id.present? || source_uri.present?
|
||||
when 'legacy_drive_path'
|
||||
source_path.present?
|
||||
else
|
||||
normalized_source_key.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
class MaterialSyncSuppression < ApplicationRecord
|
||||
SOURCE_KINDS = [
|
||||
'uri',
|
||||
'google_drive_path',
|
||||
'google_drive_path_prefix',
|
||||
'google_drive_file',
|
||||
'legacy_drive_path',
|
||||
'legacy_drive_path_prefix'
|
||||
].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
|
||||
validate :drive_path_must_be_safe_relative_path
|
||||
|
||||
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', 'google_drive_path_prefix',
|
||||
'legacy_drive_path', 'legacy_drive_path_prefix'
|
||||
(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
|
||||
normalize_path_fields!
|
||||
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
|
||||
|
||||
def drive_path_must_be_safe_relative_path
|
||||
return unless source_kind.in?(
|
||||
['google_drive_path', 'google_drive_path_prefix',
|
||||
'legacy_drive_path', 'legacy_drive_path_prefix'])
|
||||
|
||||
value = drive_path.to_s
|
||||
return if value.blank?
|
||||
|
||||
if value.start_with?('/') || value.match?(/\A[A-Za-z]:\//)
|
||||
errors.add(:drive_path, '相対 path を指定してください.')
|
||||
end
|
||||
if value.start_with?('My Drive/', 'マイドライブ/')
|
||||
errors.add(:drive_path, '同期元フォルダからの相対 path を指定してください.')
|
||||
end
|
||||
errors.add(:drive_path, 'NUL は使へません.') if value.include?("\0")
|
||||
errors.add(:drive_path, '連続スラッシュは使へません.') if value.include?('//')
|
||||
errors.add(:drive_path, '末尾スラッシュは使へません.') if value.end_with?('/')
|
||||
|
||||
parts = value.split('/')
|
||||
if parts.any? { |part| part.in?(['.', '..']) }
|
||||
errors.add(:drive_path, '. や .. は使へません.')
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_path_fields!
|
||||
return unless source_kind.in?(
|
||||
['google_drive_path', 'google_drive_path_prefix',
|
||||
'legacy_drive_path', 'legacy_drive_path_prefix'])
|
||||
|
||||
self.drive_path = drive_path.to_s.tr('\\', '/').strip.presence
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class MaterialVersion < ApplicationRecord
|
||||
EVENT_TYPE_MAP = { create: 'create',
|
||||
update: 'update',
|
||||
discard: 'discard',
|
||||
restore: 'restore' }.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
|
||||
@@ -1,5 +1,19 @@
|
||||
class Post < ApplicationRecord
|
||||
require 'mini_magick'
|
||||
require 'stringio'
|
||||
|
||||
def self.resized_thumbnail_attachment(upload)
|
||||
upload.rewind
|
||||
image = MiniMagick::Image.read(upload.read)
|
||||
image.resize '180x180'
|
||||
image.format 'jpg'
|
||||
|
||||
{ io: StringIO.new(image.to_blob),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg' }
|
||||
ensure
|
||||
upload.rewind
|
||||
end
|
||||
|
||||
belongs_to :uploaded_user, class_name: 'User', optional: true
|
||||
|
||||
@@ -123,12 +137,7 @@ class Post < ApplicationRecord
|
||||
def resized_thumbnail!
|
||||
return unless thumbnail.attached?
|
||||
|
||||
image = MiniMagick::Image.read(thumbnail.download)
|
||||
image.resize '180x180'
|
||||
thumbnail.purge
|
||||
thumbnail.attach(io: File.open(image.path),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -6,6 +6,7 @@ class TagImplication < ApplicationRecord
|
||||
validates :parent_tag_id, presence: true
|
||||
|
||||
validate :parent_tag_mustnt_be_itself
|
||||
validate :parent_tag_mustnt_create_cycle
|
||||
|
||||
private
|
||||
|
||||
@@ -14,4 +15,27 @@ class TagImplication < ApplicationRecord
|
||||
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
|
||||
end
|
||||
end
|
||||
|
||||
def parent_tag_mustnt_create_cycle
|
||||
return if tag_id.blank? || parent_tag_id.blank?
|
||||
return if errors[:parent_tag_id].present?
|
||||
|
||||
seen = { }
|
||||
stack = [parent_tag_id]
|
||||
|
||||
until stack.empty?
|
||||
current_id = stack.pop
|
||||
next if seen[current_id]
|
||||
|
||||
seen[current_id] = true
|
||||
|
||||
if current_id == tag_id
|
||||
errors.add :parent_tag_id, '親タグに子孫タグを指定すると循環します.'
|
||||
errors.add :base, 'タグの親子関係が循環します.'
|
||||
return
|
||||
end
|
||||
|
||||
stack.concat(TagImplication.where(tag_id: current_id).pluck(:parent_tag_id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
新しい課題から参照
ユーザをブロックする