full_metal_body 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ module InputKeyUtils
5
+
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ # Check if key is a numeric.
11
+ # @param [String,Integer] key
12
+ # @return [Boolean] (true, false)
13
+ def key_numeric?(key)
14
+ key.to_i.to_s == key || key.is_a?(Numeric)
15
+ end
16
+
17
+ # Divide the keys by the number representing the array.
18
+ # @param [Array<String,Symbol>] keys
19
+ # @return [Array<Array>] parent_keys and child_keys
20
+ def separate_by_array_key(keys)
21
+ idx = keys.find_index { |k| key_numeric?(k) }
22
+ parent_keys, child_keys = keys.partition.with_index { |_, i| i <= idx }
23
+ parent_keys.delete_at(-1) # parent_keys.last is unnecessary.
24
+ return parent_keys, child_keys
25
+ end
26
+
27
+ end
28
+
29
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'full_metal_body/input_key_utils'
4
+ require 'full_metal_body/internal/input_file_validator'
5
+ require 'full_metal_body/internal/input_string_validator'
6
+ require 'full_metal_body/internal/reasonable_boolean_validator'
7
+ require 'full_metal_body/internal/reasonable_date_validator'
8
+
9
+ module FullMetalBody
10
+ class InputValidation
11
+
12
+ include InputKeyUtils
13
+ include ActiveModel::Validations
14
+
15
+ attr_accessor :key, :value, :key_type
16
+
17
+ validates :key, "full_metal_body/internal/input_string": true, if: :check_key?
18
+
19
+ # @param [Array<String, Symbol>] key
20
+ # @param [Object] value
21
+ # @param [Hash] whitelist
22
+ def initialize(key, value, whitelist)
23
+ @key = key
24
+ @value = value
25
+ set_key_type(whitelist)
26
+ if value_validate
27
+ class_eval %(
28
+ validates :value, #{value_validate} # validates :value, input_string: true
29
+ ), __FILE__, __LINE__ - 2
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Whether to validate the key
36
+ #
37
+ # @return [Boolean] (true, false)
38
+ def check_key?
39
+ !@key_type
40
+ end
41
+
42
+ # Which validator to validate the value with
43
+ #
44
+ # @return [String] validator type
45
+ def value_validate
46
+ if @key_type.nil?
47
+ if @value.is_a?(ActionDispatch::Http::UploadedFile)
48
+ return "'full_metal_body/internal/input_file': true"
49
+ end
50
+
51
+ return "'full_metal_body/internal/input_string': true"
52
+ end
53
+
54
+ case @key_type['type']
55
+ when 'string'
56
+ options = @key_type['options']&.symbolize_keys || true
57
+ "'full_metal_body/internal/input_string': #{options}"
58
+ when 'number'
59
+ options = @key_type['options']&.symbolize_keys || { allow_blank: true }
60
+ if @value.is_a?(Numeric)
61
+ "numericality: #{options}"
62
+ else
63
+ "'full_metal_body/internal/input_string': true, numericality: #{options}"
64
+ end
65
+ when 'date'
66
+ if @value.is_a?(Date)
67
+ "'full_metal_body/internal/reasonable_date': true"
68
+ else
69
+ "'full_metal_body/internal/input_string': true, 'full_metal_body/internal/reasonable_date': true"
70
+ end
71
+ when 'file'
72
+ options = @key_type['options']&.symbolize_keys || true
73
+ "'full_metal_body/internal/input_file': #{options}"
74
+ when 'boolean'
75
+ options = @key_type['options']&.symbolize_keys || true
76
+ if [TrueClass, FalseClass].any? { |klass| @value.is_a?(klass) }
77
+ "'full_metal_body/internal/reasonable_boolean': #{options}"
78
+ else
79
+ "'full_metal_body/internal/input_string': true, 'full_metal_body/internal/reasonable_boolean': #{options}"
80
+ end
81
+ end
82
+ end
83
+
84
+ # Set type-definition to @key_type
85
+ #
86
+ # @param [Hash] whitelist
87
+ def set_key_type(whitelist)
88
+ return @key_type = nil if whitelist.nil?
89
+
90
+ # When ype-definition by key is exists in whitelist
91
+ return @key_type = whitelist.dig(*@key) if has_type?(whitelist.dig(*@key))
92
+
93
+ # When @key has the index number of the array
94
+ if @key.find_index { |k| key_numeric?(k) }
95
+ result = get_key_type_recursively(@key, whitelist)
96
+ return @key_type = result if has_type?(result)
97
+ end
98
+
99
+ # When no type-definition by key in whitelist
100
+ @key_type = nil
101
+ end
102
+
103
+ # Search type-definition by key recursively from whitelist
104
+ #
105
+ # @param [Array<String>] keys
106
+ # @param [Hash] whitelist
107
+ #
108
+ # @return [Hash, NilClass] type-definition by key
109
+ def get_key_type_recursively(keys, whitelist)
110
+ parent_keys, child_keys = separate_by_array_key(keys)
111
+ parent = parent_keys.empty? ? whitelist : whitelist.dig(*parent_keys)
112
+
113
+ if parent['type'] == 'array'
114
+ if child_keys.empty?
115
+ # { type: array, properties: { type: string } }
116
+ parent['properties']
117
+ elsif parent['properties'].dig(*child_keys)
118
+ # { type: array, properties: { hoge: { type: string } } }
119
+ parent['properties'].dig(*child_keys)
120
+ elsif parent.dig('properties', 'type') == 'array'
121
+ # { type: array, properties: { type: array, properties: { ... } } }
122
+ get_key_type_recursively(child_keys, parent['properties'])
123
+ elsif parent.dig('properties', child_keys.first, 'type') == 'array'
124
+ # { type: array, properties: { hoge: { type: array, properties: { ... } } } }
125
+ first_key = child_keys.shift
126
+ get_key_type_recursively(child_keys, parent.dig('properties', first_key))
127
+ else
128
+ nil
129
+ end
130
+ else
131
+ nil
132
+ end
133
+ end
134
+
135
+ # Whether or not the item has a type attribute.
136
+ #
137
+ # @param [Object] item
138
+ #
139
+ # @return [Boolean] (true, false)
140
+ def has_type?(item)
141
+ return false unless item.is_a?(Hash)
142
+
143
+ %w(string number date file boolean).include?(item['type'])
144
+ end
145
+
146
+ end
147
+
148
+ end
@@ -0,0 +1,47 @@
1
+
2
+ module FullMetalBody
3
+ module Internal
4
+ class InputFileValidator < ActiveModel::EachValidator
5
+
6
+ DEFAULT_CONTENT_TYPES = '*'.freeze
7
+ DEFAULT_FILE_SIZE = 100.megabytes
8
+
9
+ def check_validity!
10
+ if options[:content_type]
11
+ value = options[:content_type]
12
+ unless value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
13
+ raise ArgumentError, ":content_type must be String or Array<String>"
14
+ end
15
+ end
16
+ if options[:file_size]
17
+ value = options[:file_size]
18
+ raise ArgumentError, ":file_size must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
19
+ end
20
+ end
21
+
22
+ def validate_each(record, attribute, value)
23
+ return if value.nil?
24
+
25
+ content_types = options[:content_type] || DEFAULT_CONTENT_TYPES
26
+ file_size = options[:file_size] || DEFAULT_FILE_SIZE
27
+
28
+ # type
29
+ unless value.is_a? ActionDispatch::Http::UploadedFile
30
+ return record.errors.add(attribute, :file_wrong_type, value: value)
31
+ end
32
+
33
+ # content type
34
+ if content_types != '*' && Array(content_types).exclude?(value.content_type)
35
+ return record.errors.add(attribute, :invalid_content_type)
36
+ end
37
+
38
+ # file size
39
+ if value.size > file_size
40
+ record.errors.add(attribute, :too_large_size)
41
+ end
42
+ end
43
+
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,60 @@
1
+ module FullMetalBody
2
+ module Internal
3
+
4
+ class InputStringValidator < ActiveModel::EachValidator
5
+
6
+ DEFAULT_MAX_LENGTH = 1024
7
+
8
+ def check_validity!
9
+ if options[:max_length]
10
+ value = options[:max_length]
11
+ raise ArgumentError, ":max_length must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
12
+ end
13
+ end
14
+
15
+ def validate_each(record, attribute, value)
16
+ return if value.nil?
17
+
18
+ if value.is_a?(Array)
19
+ value.each do |v|
20
+ validate_value(record, attribute, v.dup)
21
+ end
22
+ else
23
+ validate_value(record, attribute, value.dup)
24
+ end
25
+ end
26
+
27
+ def validate_value(record, attribute, value)
28
+ max_length = options[:max_length] || DEFAULT_MAX_LENGTH
29
+
30
+ # type
31
+ unless value.is_a? String
32
+ return record.errors.add(attribute, :wrong_type, value: value)
33
+ end
34
+
35
+ # length
36
+ if value.size > max_length
37
+ return record.errors.add(attribute, :too_long, value: byteslice(value), count: value.size)
38
+ end
39
+
40
+ # encoding
41
+ original_encoding = value.encoding.name
42
+ unless value.force_encoding('UTF-8').valid_encoding?
43
+ return record.errors.add(attribute, :wrong_encoding, value: byteslice(value), encoding: original_encoding)
44
+ end
45
+
46
+ # cntrl
47
+ # Replace and delete line feed codes and horizontal tabs (vertical tabs are not)
48
+ value = value.gsub(/\R|\t/, '')
49
+ if /[[:cntrl:]]/.match?(value)
50
+ record.errors.add(attribute, :include_cntrl, value: byteslice(value))
51
+ end
52
+ end
53
+
54
+ def byteslice(value)
55
+ value.byteslice(0, 1024)
56
+ end
57
+
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,16 @@
1
+ module FullMetalBody
2
+ module Internal
3
+ class ReasonableBooleanValidator < ActiveModel::EachValidator
4
+
5
+ TRUE_VALUES = [true, "1", "t", "T", "true", "TRUE", "on", "ON"].to_set.freeze
6
+ FALSE_VALUES = [false, "0", "f", "F", "false", "FALSE", "off", "OFF"].to_set.freeze
7
+
8
+ def validate_each(record, attribute, value)
9
+ unless [TRUE_VALUES, FALSE_VALUES].any? { |b| b.include?(value) }
10
+ record.errors.add(attribute, :not_reasonable_boolean, value: value)
11
+ end
12
+ end
13
+
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ module FullMetalBody
2
+ module Internal
3
+
4
+ class ReasonableDateValidator < ActiveModel::EachValidator
5
+
6
+ def validate_each(record, attribute, value)
7
+ return if value.blank? || value.is_a?(Date)
8
+
9
+ unless date_valid?(value)
10
+ record.errors.add(attribute, :not_reasonable_date, value: value)
11
+ end
12
+ end
13
+
14
+ def date_valid?(str)
15
+ # Allow only slash and hyphen separators
16
+ unless str.match?(%r{^\d{4}/\d{1,2}/\d{1,2}$|^\d{4}-\d{1,2}-\d{1,2}$})
17
+ return false
18
+ end
19
+
20
+ !!Date.parse(str)
21
+ rescue
22
+ false
23
+ end
24
+
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class BlockedAction < ActiveRecord::Base
5
+
6
+ has_many :blocked_keys, dependent: :destroy
7
+
8
+ validates :controller, presence: true, uniqueness: { scope: :action }
9
+ validates :action, presence: true
10
+
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class BlockedKey < ActiveRecord::Base
5
+
6
+ belongs_to :blocked_action
7
+
8
+ validates :blocked_key, presence: true
9
+ validates :blocked_action, uniqueness: { scope: :blocked_key }
10
+
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module FullMetalBody
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class SaveBlockedKeysService
5
+
6
+ class << self
7
+
8
+ # Save blocked_keys to the database using bulk insert.
9
+ #
10
+ # @param [String] controller_path
11
+ # @param [String] action_name
12
+ # @param [Array<Array<String>>] blocked_keys
13
+ # @return [ActiveRecord::Result]
14
+ def execute!(controller_path, action_name, blocked_keys)
15
+ ApplicationRecord.transaction do
16
+ blocked_action = BlockedAction.find_or_create_by!(controller: controller_path, action: action_name)
17
+ now = Time.zone.now
18
+ if rails_6_1_and_up?
19
+ attributes = blocked_keys.map { |key| { blocked_key: key } }
20
+ blocked_action.blocked_keys.create_with(
21
+ created_at: now,
22
+ updated_at: now,
23
+ ).insert_all(attributes, returning: [:id], unique_by: [:blocked_action_id, :blocked_key])
24
+ elsif rails_6_0?
25
+ attributes = blocked_keys.map do |key|
26
+ {
27
+ blocked_action_id: blocked_action.id,
28
+ blocked_key: key,
29
+ created_at: now,
30
+ updated_at: now,
31
+ }
32
+ end
33
+ blocked_action.blocked_keys.insert_all(
34
+ attributes,
35
+ returning: [:id],
36
+ unique_by: [:blocked_action_id, :blocked_key],
37
+ )
38
+ else
39
+ blocked_keys.each { |key| blocked_action.blocked_keys.find_or_create_by(blocked_key: key) }
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def rails_6_1_and_up?
47
+ Gem::Version.new(Rails.version) >= Gem::Version.new("6.1.0")
48
+ end
49
+
50
+ def rails_6_0?
51
+ Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 0
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module FullMetalBody
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'full_metal_body/deep_sort'
5
+ require 'full_metal_body/dynamic_whitelist_generator'
6
+ using FullMetalBody::DeepSort
7
+
8
+ module FullMetalBody
9
+
10
+ class WhitelistWriter
11
+
12
+ # @param [String] controller_path
13
+ # @param [String] action_name
14
+ def initialize(controller_path, action_name)
15
+ @controller_path = controller_path
16
+ @action_name = action_name
17
+ @data = {}
18
+ end
19
+
20
+ # @param [Array<Array>] blocked_keys
21
+ def write!(blocked_keys)
22
+ blocked_keys.each { |key| update_data!(key) }
23
+
24
+ dir = File.dirname(save_path)
25
+ FileUtils.mkdir_p(dir, mode: 0o777) unless Dir.exist?(dir)
26
+ if File.exist?(save_path)
27
+ whitelist = YAML.load_file(save_path)
28
+ @data.deep_merge!(whitelist)
29
+ end
30
+ @data.deep_sort!
31
+ File.open(save_path, "w") do |file|
32
+ YAML.dump(@data, file)
33
+ end
34
+ FileUtils.chmod(0o777, save_path)
35
+ end
36
+
37
+ # Return path to store the whitelist.
38
+ # @return [Pathname]
39
+ def save_path
40
+ @save_path ||= if Rails.env.test?
41
+ Rails.root.join('tmp', "test#{ENV.fetch('TEST_ENV_NUMBER', '')}", 'whitelist', "#{@controller_path}.yml")
42
+ else
43
+ Rails.root.join('tmp', 'whitelist', "#{@controller_path}.yml")
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :action_name
50
+
51
+ def controller_name
52
+ @controller_path.split('/').last
53
+ end
54
+
55
+ # Dynamically generate whitelist from keys.
56
+ # @param [Array] keys
57
+ def update_data!(keys)
58
+ whitelist = DynamicWhitelistGenerator.new(keys, 'string').execute!
59
+ @data.bury!([controller_name, action_name], whitelist.to_hash)
60
+ end
61
+
62
+ end
63
+
64
+ end
@@ -0,0 +1,9 @@
1
+ require "full_metal_body/version"
2
+ require "full_metal_body/railtie"
3
+ require "bury"
4
+
5
+ module FullMetalBody
6
+ require 'full_metal_body/models/blocked_action'
7
+ require 'full_metal_body/models/blocked_key'
8
+ require 'full_metal_body/controllers/concerns/input_validation_action'
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class InstallGenerator < ::Rails::Generators::Base
5
+ include ::Rails::Generators::Migration
6
+
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ def self.next_migration_number(_path)
10
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
11
+ end
12
+
13
+ def create_migration_file
14
+ template = 'create_blocked_actions'
15
+ migration_dir = File.expand_path('db/migrate')
16
+ migration_template(
17
+ "#{template}.rb.erb",
18
+ "db/migrate/#{template}.rb",
19
+ migration_version: migration_version,
20
+ )
21
+ end
22
+
23
+ def create_whitelist_dir
24
+ empty_directory('config/whitelist')
25
+ end
26
+
27
+ private
28
+
29
+ def migration_version
30
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if over_rails5?
31
+ end
32
+
33
+ def over_rails5?
34
+ Rails::VERSION::MAJOR >= 5
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBlockedActions < ActiveRecord::Migration<%= migration_version %>
4
+
5
+ def change
6
+ create_table :blocked_actions do |t|
7
+ t.string :controller, null: false
8
+ t.string :action, null: false
9
+
10
+ t.timestamps
11
+ end
12
+ add_index :blocked_actions, [:controller, :action], unique: true
13
+
14
+ create_table :blocked_keys do |t|
15
+ t.references :blocked_action, foreign_key: true, null: false
16
+ t.string :blocked_key, array: true, null: false
17
+
18
+ t.timestamps
19
+ end
20
+ add_index :blocked_keys, [:blocked_action_id, :blocked_key], unique: true
21
+ end
22
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :full_metal_body do
3
+ # # Task goes here
4
+ # end