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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/dscf/core/auditable_controller.rb +17 -17
- data/app/controllers/concerns/dscf/core/authenticatable.rb +3 -8
- data/app/controllers/concerns/dscf/core/authorizable.rb +66 -0
- data/app/controllers/concerns/dscf/core/common.rb +49 -19
- data/app/controllers/concerns/dscf/core/file_uploadable.rb +261 -0
- data/app/controllers/concerns/dscf/core/reviewable_controller.rb +31 -1
- data/app/controllers/dscf/core/addresses_controller.rb +0 -5
- data/app/controllers/dscf/core/application_controller.rb +3 -5
- data/app/controllers/dscf/core/auth_controller.rb +16 -11
- data/app/controllers/dscf/core/businesses_controller.rb +0 -5
- data/app/controllers/dscf/core/files_controller.rb +38 -0
- data/app/controllers/dscf/core/permissions_controller.rb +20 -0
- data/app/controllers/dscf/core/role_permissions_controller.rb +54 -0
- data/app/controllers/dscf/core/roles_controller.rb +30 -0
- data/app/controllers/dscf/core/user_roles_controller.rb +56 -0
- data/app/errors/dscf/core/file_upload_error.rb +9 -0
- data/app/jobs/dscf/core/audit_logger_job.rb +1 -3
- data/app/models/concerns/dscf/core/attachable.rb +403 -0
- data/app/models/dscf/core/file_attachment.rb +205 -0
- data/app/models/dscf/core/permission.rb +27 -0
- data/app/models/dscf/core/role.rb +8 -3
- data/app/models/dscf/core/role_permission.rb +18 -0
- data/app/models/dscf/core/user.rb +56 -0
- data/app/models/dscf/core/user_role.rb +23 -3
- data/app/policies/dscf/core/application_policy.rb +62 -0
- data/app/policies/dscf/core/business_policy.rb +29 -0
- data/app/policies/dscf/core/business_type_policy.rb +6 -0
- data/app/policies/dscf/core/permission_policy.rb +6 -0
- data/app/policies/dscf/core/role_policy.rb +25 -0
- data/app/serializers/dscf/core/attachment_serializer.rb +30 -0
- data/app/serializers/dscf/core/permission_serializer.rb +7 -0
- data/app/serializers/dscf/core/role_light_serializer.rb +7 -0
- data/app/serializers/dscf/core/role_permission_serializer.rb +9 -0
- data/app/serializers/dscf/core/role_serializer.rb +2 -2
- data/app/serializers/dscf/core/user_auth_serializer.rb +6 -2
- data/app/serializers/dscf/core/user_role_serializer.rb +2 -3
- data/app/services/dscf/core/file_storage/client.rb +210 -0
- data/app/services/dscf/core/file_storage/uploader.rb +127 -0
- data/app/services/dscf/core/file_storage.rb +44 -0
- data/app/services/dscf/core/token_service.rb +2 -1
- data/config/locales/en.yml +35 -2
- data/config/routes.rb +15 -0
- data/db/migrate/20250821185708_create_dscf_core_roles.rb +3 -1
- data/db/migrate/20250822054547_create_dscf_core_user_roles.rb +3 -1
- data/db/migrate/20260128000000_create_dscf_core_file_attachments.rb +48 -0
- data/db/migrate/20260304000001_create_dscf_core_permissions.rb +19 -0
- data/db/migrate/20260304000002_create_dscf_core_role_permissions.rb +11 -0
- data/lib/dscf/core/permission_registry.rb +58 -0
- data/lib/dscf/core/version.rb +1 -1
- data/lib/dscf/core.rb +12 -1
- data/spec/factories/dscf/core/permissions.rb +14 -0
- data/spec/factories/dscf/core/reviews.rb +0 -1
- data/spec/factories/dscf/core/role_permissions.rb +6 -0
- data/spec/factories/dscf/core/roles.rb +42 -2
- data/spec/factories/dscf/core/user_roles.rb +4 -2
- 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
|
-
|
|
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,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
|
|
@@ -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 :
|
|
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::
|
|
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, :
|
|
4
|
+
attributes :id, :user_id, :role_id, :assigned_by_id, :expires_at, :created_at
|
|
5
5
|
|
|
6
|
-
belongs_to :
|
|
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
|