dscf-core 0.2.8 → 0.2.9

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/dscf/core/auditable_controller.rb +17 -17
  3. data/app/controllers/concerns/dscf/core/authenticatable.rb +3 -8
  4. data/app/controllers/concerns/dscf/core/authorizable.rb +66 -0
  5. data/app/controllers/concerns/dscf/core/common.rb +49 -19
  6. data/app/controllers/concerns/dscf/core/file_uploadable.rb +261 -0
  7. data/app/controllers/concerns/dscf/core/reviewable_controller.rb +31 -1
  8. data/app/controllers/dscf/core/addresses_controller.rb +0 -5
  9. data/app/controllers/dscf/core/application_controller.rb +3 -5
  10. data/app/controllers/dscf/core/auth_controller.rb +16 -11
  11. data/app/controllers/dscf/core/businesses_controller.rb +0 -5
  12. data/app/controllers/dscf/core/files_controller.rb +38 -0
  13. data/app/controllers/dscf/core/permissions_controller.rb +20 -0
  14. data/app/controllers/dscf/core/role_permissions_controller.rb +54 -0
  15. data/app/controllers/dscf/core/roles_controller.rb +30 -0
  16. data/app/controllers/dscf/core/user_roles_controller.rb +56 -0
  17. data/app/errors/dscf/core/file_upload_error.rb +9 -0
  18. data/app/jobs/dscf/core/audit_logger_job.rb +1 -3
  19. data/app/models/concerns/dscf/core/attachable.rb +403 -0
  20. data/app/models/dscf/core/file_attachment.rb +205 -0
  21. data/app/models/dscf/core/permission.rb +27 -0
  22. data/app/models/dscf/core/role.rb +8 -3
  23. data/app/models/dscf/core/role_permission.rb +18 -0
  24. data/app/models/dscf/core/user.rb +56 -0
  25. data/app/models/dscf/core/user_role.rb +23 -3
  26. data/app/policies/dscf/core/application_policy.rb +62 -0
  27. data/app/policies/dscf/core/business_policy.rb +29 -0
  28. data/app/policies/dscf/core/business_type_policy.rb +6 -0
  29. data/app/policies/dscf/core/permission_policy.rb +6 -0
  30. data/app/policies/dscf/core/role_policy.rb +25 -0
  31. data/app/serializers/dscf/core/attachment_serializer.rb +30 -0
  32. data/app/serializers/dscf/core/permission_serializer.rb +7 -0
  33. data/app/serializers/dscf/core/role_light_serializer.rb +7 -0
  34. data/app/serializers/dscf/core/role_permission_serializer.rb +9 -0
  35. data/app/serializers/dscf/core/role_serializer.rb +2 -2
  36. data/app/serializers/dscf/core/user_auth_serializer.rb +6 -2
  37. data/app/serializers/dscf/core/user_role_serializer.rb +2 -3
  38. data/app/services/dscf/core/file_storage/client.rb +210 -0
  39. data/app/services/dscf/core/file_storage/uploader.rb +127 -0
  40. data/app/services/dscf/core/file_storage.rb +44 -0
  41. data/app/services/dscf/core/token_service.rb +2 -1
  42. data/config/locales/en.yml +35 -2
  43. data/config/routes.rb +15 -0
  44. data/db/migrate/20250821185708_create_dscf_core_roles.rb +3 -1
  45. data/db/migrate/20250822054547_create_dscf_core_user_roles.rb +3 -1
  46. data/db/migrate/20260128000000_create_dscf_core_file_attachments.rb +48 -0
  47. data/db/migrate/20260304000001_create_dscf_core_permissions.rb +19 -0
  48. data/db/migrate/20260304000002_create_dscf_core_role_permissions.rb +11 -0
  49. data/lib/dscf/core/permission_registry.rb +58 -0
  50. data/lib/dscf/core/version.rb +1 -1
  51. data/lib/dscf/core.rb +12 -1
  52. data/spec/factories/dscf/core/permissions.rb +14 -0
  53. data/spec/factories/dscf/core/reviews.rb +0 -1
  54. data/spec/factories/dscf/core/role_permissions.rb +6 -0
  55. data/spec/factories/dscf/core/roles.rb +42 -2
  56. data/spec/factories/dscf/core/user_roles.rb +4 -2
  57. metadata +33 -3
@@ -14,6 +14,62 @@ module Dscf
14
14
 
15
15
  before_create :set_default_temp_password
16
16
 
17
+ # --- RBAC Helper Methods ---
18
+
19
+ # rubocop:disable Naming/PredicateName
20
+ def has_role?(role_code)
21
+ active_role_codes.include?(role_code.to_s.upcase)
22
+ end
23
+
24
+ def has_permission?(permission_code)
25
+ return true if super_admin?
26
+
27
+ active_permissions.include?(permission_code.to_s)
28
+ end
29
+
30
+ alias can? has_permission?
31
+
32
+ def has_all_permissions?(*permission_codes)
33
+ permission_codes.flatten.all? { |code| has_permission?(code) }
34
+ end
35
+
36
+ def has_any_permission?(*permission_codes)
37
+ permission_codes.flatten.any? { |code| has_permission?(code) }
38
+ end
39
+ # rubocop:enable Naming/PredicateName
40
+
41
+ def super_admin?
42
+ active_role_codes.include?("SUPER_ADMIN")
43
+ end
44
+
45
+ def active_permissions
46
+ @active_permissions ||= Set.new(
47
+ Permission
48
+ .joins(role_permissions: {role: :user_roles})
49
+ .where(
50
+ dscf_core_user_roles: {user_id: id},
51
+ dscf_core_roles: {active: true},
52
+ dscf_core_permissions: {active: true}
53
+ )
54
+ .where("dscf_core_user_roles.expires_at IS NULL OR dscf_core_user_roles.expires_at > ?", Time.current)
55
+ .pluck("dscf_core_permissions.code")
56
+ )
57
+ end
58
+
59
+ def active_role_codes
60
+ @active_role_codes ||= roles
61
+ .where(active: true)
62
+ .joins(:user_roles)
63
+ .where(dscf_core_user_roles: {user_id: id})
64
+ .where("dscf_core_user_roles.expires_at IS NULL OR dscf_core_user_roles.expires_at > ?", Time.current)
65
+ .pluck("dscf_core_roles.code")
66
+ end
67
+
68
+ def clear_permission_cache!
69
+ @active_permissions = nil
70
+ @active_role_codes = nil
71
+ end
72
+
17
73
  def self.ransackable_attributes(_auth_object = nil)
18
74
  %w[id email phone verified_at created_at updated_at]
19
75
  end
@@ -1,10 +1,30 @@
1
1
  module Dscf
2
2
  module Core
3
3
  class UserRole < ApplicationRecord
4
- belongs_to :user
5
- belongs_to :role
4
+ belongs_to :user, class_name: "Dscf::Core::User"
5
+ belongs_to :role, class_name: "Dscf::Core::Role"
6
+ belongs_to :assigned_by, class_name: "Dscf::Core::User", foreign_key: :assigned_by_id, optional: true
6
7
 
7
- validates_uniqueness_of :user_id, scope: :role_id
8
+ validates :user_id, uniqueness: {scope: :role_id}
9
+
10
+ scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
11
+ scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
12
+
13
+ def expired?
14
+ expires_at.present? && expires_at <= Time.current
15
+ end
16
+
17
+ def active?
18
+ !expired?
19
+ end
20
+
21
+ def self.ransackable_attributes(_auth_object = nil)
22
+ %w[id user_id role_id assigned_by_id expires_at created_at updated_at]
23
+ end
24
+
25
+ def self.ransackable_associations(_auth_object = nil)
26
+ %w[user role assigned_by]
27
+ end
8
28
  end
9
29
  end
10
30
  end
@@ -0,0 +1,62 @@
1
+ module Dscf
2
+ module Core
3
+ class ApplicationPolicy
4
+ attr_reader :user, :record
5
+
6
+ def initialize(user, record)
7
+ @user = user
8
+ @record = record
9
+ end
10
+
11
+ def index?
12
+ user.has_permission?(permission_code(:index))
13
+ end
14
+
15
+ def show?
16
+ user.has_permission?(permission_code(:show))
17
+ end
18
+
19
+ def create?
20
+ user.has_permission?(permission_code(:create))
21
+ end
22
+
23
+ def update?
24
+ user.has_permission?(permission_code(:update))
25
+ end
26
+
27
+ def destroy?
28
+ user.has_permission?(permission_code(:destroy))
29
+ end
30
+
31
+ class Scope
32
+ attr_reader :user, :scope
33
+
34
+ def initialize(user, scope)
35
+ @user = user
36
+ @scope = scope
37
+ end
38
+
39
+ def resolve
40
+ return scope.all if user.super_admin?
41
+ return scope.none unless user.has_permission?(index_permission_code)
42
+
43
+ scope.all
44
+ end
45
+
46
+ private
47
+
48
+ def index_permission_code
49
+ resource_name = scope.name.demodulize.underscore.pluralize
50
+ "#{resource_name}.index"
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def permission_code(action)
57
+ resource_name = record.class.name.demodulize.underscore.pluralize
58
+ "#{resource_name}.#{action}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,29 @@
1
+ module Dscf
2
+ module Core
3
+ class BusinessPolicy < ApplicationPolicy
4
+ def submit?
5
+ user.has_permission?("businesses.submit")
6
+ end
7
+
8
+ def approve?
9
+ user.has_permission?("businesses.approve")
10
+ end
11
+
12
+ def reject?
13
+ user.has_permission?("businesses.reject")
14
+ end
15
+
16
+ def request_modification?
17
+ user.has_permission?("businesses.request_modification")
18
+ end
19
+
20
+ def resubmit?
21
+ user.has_permission?("businesses.resubmit")
22
+ end
23
+
24
+ def my_business?
25
+ user.has_permission?("businesses.index")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,6 @@
1
+ module Dscf
2
+ module Core
3
+ class BusinessTypePolicy < ApplicationPolicy
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Dscf
2
+ module Core
3
+ class PermissionPolicy < ApplicationPolicy
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,25 @@
1
+ module Dscf
2
+ module Core
3
+ class RolePolicy < ApplicationPolicy
4
+ def submit?
5
+ false
6
+ end
7
+
8
+ def approve?
9
+ false
10
+ end
11
+
12
+ def reject?
13
+ false
14
+ end
15
+
16
+ def request_modification?
17
+ false
18
+ end
19
+
20
+ def resubmit?
21
+ false
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dscf
4
+ module Core
5
+ class AttachmentSerializer < ActiveModel::Serializer
6
+ attributes :file_key, :filename, :content_type, :size,
7
+ :human_size, :extension, :is_image, :is_pdf, :url
8
+
9
+ def human_size
10
+ object.human_size
11
+ end
12
+
13
+ def extension
14
+ object.extension
15
+ end
16
+
17
+ def is_image
18
+ object.image?
19
+ end
20
+
21
+ def is_pdf
22
+ object.pdf?
23
+ end
24
+
25
+ def url
26
+ object.url
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,7 @@
1
+ module Dscf
2
+ module Core
3
+ class PermissionSerializer < ActiveModel::Serializer
4
+ attributes :id, :code, :resource, :action, :engine, :description, :active
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Dscf
2
+ module Core
3
+ class RoleLightSerializer < ActiveModel::Serializer
4
+ attributes :id, :code, :name, :description, :active
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module Dscf
2
+ module Core
3
+ class RolePermissionSerializer < ActiveModel::Serializer
4
+ attributes :id, :role_id, :permission_id, :created_at
5
+
6
+ belongs_to :permission, serializer: Dscf::Core::PermissionSerializer
7
+ end
8
+ end
9
+ end
@@ -1,9 +1,9 @@
1
1
  module Dscf
2
2
  module Core
3
3
  class RoleSerializer < ActiveModel::Serializer
4
- attributes :id, :code, :name, :created_at, :updated_at
4
+ attributes :id, :code, :name, :description, :active, :created_at, :updated_at
5
5
 
6
- has_many :user_roles, serializer: Dscf::Core::UserRoleSerializer
6
+ has_many :permissions, serializer: Dscf::Core::PermissionSerializer
7
7
  end
8
8
  end
9
9
  end
@@ -1,10 +1,14 @@
1
1
  module Dscf
2
2
  module Core
3
3
  class UserAuthSerializer < ActiveModel::Serializer
4
- attributes :id, :email, :phone, :verified_at
4
+ attributes :id, :email, :phone, :verified_at, :permissions
5
5
 
6
6
  has_one :user_profile, serializer: Dscf::Core::UserProfileSerializer
7
- has_many :roles, serializer: Dscf::Core::RoleSerializer
7
+ has_many :roles, serializer: Dscf::Core::RoleLightSerializer
8
+
9
+ def permissions
10
+ object.active_permissions.to_a
11
+ end
8
12
  end
9
13
  end
10
14
  end
@@ -1,10 +1,9 @@
1
1
  module Dscf
2
2
  module Core
3
3
  class UserRoleSerializer < ActiveModel::Serializer
4
- attributes :id, :created_at, :updated_at
4
+ attributes :id, :user_id, :role_id, :assigned_by_id, :expires_at, :created_at
5
5
 
6
- belongs_to :user, serializer: UserSerializer
7
- belongs_to :role, serializer: RoleSerializer
6
+ belongs_to :role, serializer: Dscf::Core::RoleSerializer
8
7
  end
9
8
  end
10
9
  end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dscf
4
+ module Core
5
+ module FileStorage
6
+ # Low-level HTTP client for MinIO service
7
+ # This is an internal class - other engines should NOT use this directly
8
+ class Client
9
+ include HTTParty
10
+
11
+ class UploadError < StandardError; end
12
+ class DownloadError < StandardError; end
13
+ class ConfigurationError < StandardError; end
14
+
15
+ def initialize(app_name: nil, record_class: nil)
16
+ @base_uri = ENV.fetch("MINIO_SERVICE_URL", "http://localhost:3001")
17
+ @record_class = record_class
18
+ @api_key = resolve_api_key(app_name)
19
+ validate_configuration!
20
+ end
21
+
22
+ # Upload a file to MinIO
23
+ # @param file [ActionDispatch::Http::UploadedFile, File, IO] the file to upload
24
+ # @param filename [String] optional custom filename
25
+ # @return [Hash] { file_key:, filename:, size:, content_type: }
26
+ def upload(file, filename: nil)
27
+ prepared = prepare_file(file, filename)
28
+
29
+ response = self.class.post(
30
+ "#{@base_uri}/upload",
31
+ headers: auth_headers,
32
+ body: {file: prepared[:io]},
33
+ multipart: true,
34
+ timeout: 60
35
+ )
36
+
37
+ handle_upload_response(response, prepared)
38
+ end
39
+
40
+ # Download a file from MinIO
41
+ # @param file_key [String] the file key/name in MinIO
42
+ # @return [Hash] { data:, filename:, content_type: }
43
+ def download(file_key)
44
+ response = self.class.get(
45
+ "#{@base_uri}/download/#{URI.encode_www_form_component(file_key)}",
46
+ headers: auth_headers,
47
+ timeout: 60
48
+ )
49
+
50
+ handle_download_response(response, file_key)
51
+ end
52
+
53
+ # Check if file exists
54
+ # @param file_key [String] the file key/name in MinIO
55
+ # @return [Boolean]
56
+ def exists?(file_key)
57
+ response = self.class.head(
58
+ "#{@base_uri}/download/#{URI.encode_www_form_component(file_key)}",
59
+ headers: auth_headers,
60
+ timeout: 10
61
+ )
62
+ response.success?
63
+ rescue StandardError
64
+ false
65
+ end
66
+
67
+ # Delete a file from MinIO
68
+ # @param file_key [String] the file key/name in MinIO
69
+ # @return [Boolean]
70
+ def delete(file_key)
71
+ response = self.class.delete(
72
+ "#{@base_uri}/delete/#{URI.encode_www_form_component(file_key)}",
73
+ headers: auth_headers,
74
+ timeout: 30
75
+ )
76
+ response.success?
77
+ rescue StandardError => e
78
+ Rails.logger.error("FileStorage::Client delete error: #{e.message}")
79
+ false
80
+ end
81
+
82
+ private
83
+
84
+ def resolve_api_key(app_name)
85
+ # Priority: explicit app name -> engine-specific -> default
86
+ key_name = app_name&.upcase&.gsub("-", "_") || detect_engine_name
87
+
88
+ # Check all possible env var keys
89
+ possible_keys = [
90
+ "MINIO_API_KEY_DSCF_#{key_name}",
91
+ "MINIO_API_KEY_#{key_name}",
92
+ "MINIO_API_KEY"
93
+ ]
94
+
95
+ api_key = possible_keys.find { |k| ENV[k].present? }.then { |k| ENV[k] }
96
+
97
+ Rails.logger.debug("MinIO API Key lookup: key_name=#{key_name}, tried=#{possible_keys.inspect}, found=#{api_key.present?}")
98
+ Rails.logger.debug("Available MINIO env vars: #{ENV.select { |k, _v| k.start_with?('MINIO_') }.keys.inspect}")
99
+
100
+ api_key
101
+ end
102
+
103
+ def detect_engine_name
104
+ # First try to detect from record class namespace
105
+ if @record_class
106
+ namespace = @record_class.name.split("::")[1] # Dscf::Credit::Bank -> Credit
107
+ if namespace && namespace != "Core"
108
+ Rails.logger.debug("MinIO engine detection: from record_class=#{@record_class.name}, detected=#{namespace.upcase}")
109
+ return namespace.upcase
110
+ end
111
+ end
112
+
113
+ # Fallback: Auto-detect engine based on caller (skip dscf_core since storage is in core)
114
+ caller_paths = caller.select { |c| c.include?("/dscf_") && !c.include?("/dscf_core/") }
115
+
116
+ caller_path = caller_paths.first
117
+ return "CORE" unless caller_path
118
+
119
+ match = caller_path.match(%r{/dscf_(\w+)/})
120
+ detected = match ? match[1].upcase : "CORE"
121
+
122
+ Rails.logger.debug("MinIO engine detection: caller_path=#{caller_path}, detected=#{detected}")
123
+ detected
124
+ end
125
+
126
+ def validate_configuration!
127
+ raise ConfigurationError, "MINIO_SERVICE_URL is not configured" if @base_uri.blank?
128
+
129
+ return unless @api_key.blank?
130
+
131
+ detected = detect_engine_name
132
+ raise ConfigurationError,
133
+ "MINIO_API_KEY is not configured. Looked for: MINIO_API_KEY_DSCF_#{detected}, MINIO_API_KEY_#{detected}, MINIO_API_KEY"
134
+ end
135
+
136
+ def auth_headers
137
+ {"x-api-key" => @api_key}
138
+ end
139
+
140
+ def prepare_file(file, custom_filename)
141
+ # Use duck typing for better test compatibility
142
+ if file.respond_to?(:tempfile) && file.respond_to?(:original_filename)
143
+ # ActionDispatch::Http::UploadedFile or similar
144
+ {
145
+ io: file.tempfile,
146
+ filename: custom_filename || file.original_filename,
147
+ content_type: file.content_type,
148
+ size: file.size
149
+ }
150
+ elsif file.is_a?(File)
151
+ {
152
+ io: file,
153
+ filename: custom_filename || File.basename(file.path),
154
+ content_type: Marcel::MimeType.for(file),
155
+ size: file.size
156
+ }
157
+ elsif file.is_a?(Hash)
158
+ # Support { io:, filename:, content_type: } format
159
+ {
160
+ io: file[:io],
161
+ filename: custom_filename || file[:filename],
162
+ content_type: file[:content_type],
163
+ size: file[:io].respond_to?(:size) ? file[:io].size : nil
164
+ }
165
+ else
166
+ raise ArgumentError, "Unsupported file type: #{file.class}"
167
+ end
168
+ end
169
+
170
+ def handle_upload_response(response, prepared)
171
+ unless response.success?
172
+ error_message = response.parsed_response&.dig("message") || "Upload failed"
173
+ raise UploadError, "#{error_message} (HTTP #{response.code})"
174
+ end
175
+
176
+ body = response.parsed_response
177
+ {
178
+ file_key: body["fileName"],
179
+ filename: prepared[:filename],
180
+ size: body["size"] || prepared[:size],
181
+ content_type: body["mimeType"] || prepared[:content_type]
182
+ }
183
+ end
184
+
185
+ def handle_download_response(response, file_key)
186
+ unless response.success?
187
+ error_message = response.parsed_response&.dig("message") || "Download failed"
188
+ raise DownloadError, "#{error_message} (HTTP #{response.code})"
189
+ end
190
+
191
+ content_disposition = response.headers["content-disposition"]
192
+ filename = extract_filename(content_disposition) || file_key
193
+
194
+ {
195
+ data: response.body,
196
+ filename: filename,
197
+ content_type: response.headers["content-type"] || "application/octet-stream"
198
+ }
199
+ end
200
+
201
+ def extract_filename(content_disposition)
202
+ return nil unless content_disposition
203
+
204
+ match = content_disposition.match(/filename="?([^";\s]+)"?/)
205
+ match&.[](1)
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dscf
4
+ module Core
5
+ module FileStorage
6
+ # Handles file uploads with validation
7
+ class Uploader
8
+ DEFAULT_MAX_SIZE = 5.megabytes
9
+ DEFAULT_ALLOWED_TYPES = %w[
10
+ application/pdf
11
+ image/png
12
+ image/jpeg
13
+ image/webp
14
+ ].freeze
15
+
16
+ attr_reader :errors
17
+
18
+ def initialize(options = {})
19
+ @max_size = options[:max_size] || DEFAULT_MAX_SIZE
20
+ @allowed_types = options[:allowed_types] || DEFAULT_ALLOWED_TYPES
21
+ @record_class = options[:record_class]
22
+ @client = options[:client] || Client.new(record_class: @record_class)
23
+ @errors = []
24
+ end
25
+
26
+ # Upload a single file
27
+ # @param file [ActionDispatch::Http::UploadedFile, File, IO, Hash]
28
+ # @param options [Hash] additional options (filename, metadata)
29
+ # @return [Hash, nil] hash with file_key, filename, content_type, size, metadata
30
+ def upload(file, **options)
31
+ @errors = []
32
+
33
+ return nil unless validate_file(file)
34
+
35
+ result = @client.upload(file, filename: options[:filename])
36
+
37
+ {
38
+ file_key: result[:file_key],
39
+ filename: result[:filename],
40
+ content_type: result[:content_type],
41
+ size: result[:size],
42
+ metadata: options[:metadata] || {}
43
+ }
44
+ rescue Client::UploadError => e
45
+ @errors << e.message
46
+ nil
47
+ rescue StandardError => e
48
+ Rails.logger.error("FileStorage::Uploader error: #{e.message}")
49
+ @errors << "An unexpected error occurred during upload"
50
+ nil
51
+ end
52
+
53
+ # Upload multiple files
54
+ # @param files [Array<ActionDispatch::Http::UploadedFile>]
55
+ # @param options [Hash] additional options
56
+ # @return [Array<Hash>] array of hashes with file info
57
+ def upload_many(files, **options)
58
+ Array(files).filter_map do |file|
59
+ upload(file, **options)
60
+ end
61
+ end
62
+
63
+ # Check if last upload was successful
64
+ def success?
65
+ @errors.empty?
66
+ end
67
+
68
+ private
69
+
70
+ def validate_file(file)
71
+ validate_presence(file) &&
72
+ validate_size(file) &&
73
+ validate_type(file)
74
+ end
75
+
76
+ def validate_presence(file)
77
+ if file.blank?
78
+ @errors << "No file provided"
79
+ return false
80
+ end
81
+ true
82
+ end
83
+
84
+ def validate_size(file)
85
+ size = extract_size(file)
86
+ return true if size.nil? # Can't validate if size unknown
87
+
88
+ if size > @max_size
89
+ max_human = ActiveSupport::NumberHelper.number_to_human_size(@max_size)
90
+ @errors << "File size exceeds maximum allowed (#{max_human})"
91
+ return false
92
+ end
93
+ true
94
+ end
95
+
96
+ def validate_type(file)
97
+ content_type = extract_content_type(file)
98
+ return true if content_type.nil? # Can't validate if type unknown
99
+
100
+ unless @allowed_types.include?(content_type)
101
+ @errors << "File type '#{content_type}' is not allowed. Allowed: #{@allowed_types.join(', ')}"
102
+ return false
103
+ end
104
+ true
105
+ end
106
+
107
+ def extract_size(file)
108
+ if file.respond_to?(:size)
109
+ file.size
110
+ elsif file.is_a?(Hash) && file[:io].respond_to?(:size)
111
+ file[:io].size
112
+ end
113
+ end
114
+
115
+ def extract_content_type(file)
116
+ if file.respond_to?(:content_type)
117
+ file.content_type
118
+ elsif file.is_a?(Hash)
119
+ file[:content_type]
120
+ elsif file.is_a?(File)
121
+ Marcel::MimeType.for(file)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end