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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 575dd50f589e60290a2aefab52de0f5d1dc1eda3c55b04631faa0c2ff7809ec6
|
|
4
|
+
data.tar.gz: 49b4d98a1b1cbb3d9493c53aae40bb17a7233feb03a2b87359b036362099cc01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1de6428f9e48f4d1caf0414cb4b9fd568e60486430e83f6ab5dcb2d39b4891fb52c97fc09d0c310c102ca3c79c5a714e256d2856f5038adf8e6c9af01f82ac28
|
|
7
|
+
data.tar.gz: 165304a18afc35d61ccc5127f2e35eb3a235275452342ffb5c4d26078b8a45a165a128931c6570ad3cc12914f1cde7e8351d3c254bf152a3a0412017df237e4e
|
|
@@ -47,23 +47,6 @@ module Dscf
|
|
|
47
47
|
self._audit_configs += [config]
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# Normalize associated configuration to hash format
|
|
51
|
-
# Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
|
|
52
|
-
# Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
|
|
53
|
-
private def normalize_associated_for_config(associated)
|
|
54
|
-
return {} if associated.blank?
|
|
55
|
-
|
|
56
|
-
if associated.is_a?(Array)
|
|
57
|
-
# Old format: [:reviews, :comments] -> convert to hash
|
|
58
|
-
associated.each_with_object({}) { |name, h| h[name] = {} }
|
|
59
|
-
elsif associated.is_a?(Hash)
|
|
60
|
-
# New format: already a hash
|
|
61
|
-
associated
|
|
62
|
-
else
|
|
63
|
-
{}
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
50
|
# Validate audit configuration at definition time
|
|
68
51
|
def validate_audit_config!(associated:, only:, except:, on:, action:)
|
|
69
52
|
# Validate associated format
|
|
@@ -164,6 +147,23 @@ module Dscf
|
|
|
164
147
|
def auditable_record_resolvers
|
|
165
148
|
@_auditable_record_resolvers || {}
|
|
166
149
|
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Normalize associated configuration to hash format
|
|
154
|
+
# Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
|
|
155
|
+
# Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
|
|
156
|
+
def normalize_associated_for_config(associated)
|
|
157
|
+
return {} if associated.blank?
|
|
158
|
+
|
|
159
|
+
if associated.is_a?(Array)
|
|
160
|
+
associated.each_with_object({}) { |name, h| h[name] = {} }
|
|
161
|
+
elsif associated.is_a?(Hash)
|
|
162
|
+
associated
|
|
163
|
+
else
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
167
|
end
|
|
168
168
|
|
|
169
169
|
private
|
|
@@ -4,7 +4,6 @@ module Dscf
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
|
-
before_action :authenticate_user, if: :authentication_required?
|
|
8
7
|
rescue_from AuthenticationError, with: :handle_authentication_error
|
|
9
8
|
end
|
|
10
9
|
|
|
@@ -12,12 +11,12 @@ module Dscf
|
|
|
12
11
|
@current_user ||= authenticate_from_token
|
|
13
12
|
end
|
|
14
13
|
|
|
15
|
-
def authenticate_user
|
|
14
|
+
def authenticate_user
|
|
16
15
|
raise AuthenticationError, "Authentication required" unless current_user
|
|
17
16
|
end
|
|
18
17
|
|
|
19
|
-
def authenticate_user
|
|
20
|
-
|
|
18
|
+
def authenticate_user!
|
|
19
|
+
raise AuthenticationError, "Authentication required" unless current_user
|
|
21
20
|
end
|
|
22
21
|
|
|
23
22
|
def sign_in(user, request)
|
|
@@ -69,10 +68,6 @@ module Dscf
|
|
|
69
68
|
params[:refresh_token]
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
def authentication_required?
|
|
73
|
-
true # Override in specific controllers if needed
|
|
74
|
-
end
|
|
75
|
-
|
|
76
71
|
def handle_authentication_error(error)
|
|
77
72
|
render json: error.to_hash, status: error.status_code
|
|
78
73
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "pundit"
|
|
2
|
+
|
|
3
|
+
module Dscf
|
|
4
|
+
module Core
|
|
5
|
+
module Authorizable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
include ::Pundit::Authorization
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
after_action :verify_authorized
|
|
12
|
+
rescue_from ::Pundit::NotAuthorizedError, with: :handle_not_authorized
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def authorize_action!
|
|
16
|
+
policy_target = resolve_policy_target
|
|
17
|
+
return skip_authorization unless policy_target
|
|
18
|
+
|
|
19
|
+
authorize policy_target
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def pundit_user
|
|
23
|
+
current_user
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Override authorize to fall back to ApplicationPolicy when no specific policy is defined.
|
|
27
|
+
# In Pundit 2.5, authorize delegates to Pundit::Context which bypasses the instance policy method.
|
|
28
|
+
def authorize(record, query = nil, policy_class: nil)
|
|
29
|
+
super
|
|
30
|
+
rescue ::Pundit::NotDefinedError
|
|
31
|
+
fallback_policy = Dscf::Core::ApplicationPolicy.new(pundit_user, record)
|
|
32
|
+
effective_query = query || "#{action_name}?"
|
|
33
|
+
# Guard against undefined query methods — fail closed (deny) rather than raise NoMethodError
|
|
34
|
+
unless fallback_policy.respond_to?(effective_query, true) && fallback_policy.public_send(effective_query)
|
|
35
|
+
raise ::Pundit::NotAuthorizedError, policy: fallback_policy, query: effective_query, record: record
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
record
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Override policy_scope to fall back to ApplicationPolicy::Scope when no specific scope is defined.
|
|
42
|
+
def policy_scope(scope, policy_scope_class: nil)
|
|
43
|
+
super
|
|
44
|
+
rescue ::Pundit::NotDefinedError
|
|
45
|
+
Dscf::Core::ApplicationPolicy::Scope.new(pundit_user, scope).resolve
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def resolve_policy_target
|
|
51
|
+
# Try @obj first (set by Common's set_object for show/update/destroy)
|
|
52
|
+
return @obj if @obj.present?
|
|
53
|
+
|
|
54
|
+
# Try @clazz (set by Common's set_clazz for index/create)
|
|
55
|
+
return @clazz.new if @clazz.present?
|
|
56
|
+
|
|
57
|
+
# Cannot determine policy target — skip authorization (public/special action)
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_not_authorized(_exception)
|
|
62
|
+
render_error("errors.unauthorized", status: :forbidden)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -12,6 +12,8 @@ module Dscf
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def index
|
|
15
|
+
authorize @clazz.new, :index?
|
|
16
|
+
|
|
15
17
|
data = nil
|
|
16
18
|
options = {}
|
|
17
19
|
if block_given?
|
|
@@ -24,7 +26,7 @@ module Dscf
|
|
|
24
26
|
data = incoming
|
|
25
27
|
end
|
|
26
28
|
else
|
|
27
|
-
data = @clazz
|
|
29
|
+
data = policy_scope(@clazz)
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
# Apply eager loading if defined
|
|
@@ -61,6 +63,8 @@ module Dscf
|
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
def show
|
|
66
|
+
authorize @obj
|
|
67
|
+
|
|
64
68
|
data = nil
|
|
65
69
|
options = {}
|
|
66
70
|
if block_given?
|
|
@@ -86,6 +90,8 @@ module Dscf
|
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
def create
|
|
93
|
+
authorize @clazz.new, :create?
|
|
94
|
+
|
|
89
95
|
obj = nil
|
|
90
96
|
options = {}
|
|
91
97
|
if block_given?
|
|
@@ -102,25 +108,38 @@ module Dscf
|
|
|
102
108
|
obj = @clazz.new(model_params)
|
|
103
109
|
end
|
|
104
110
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
# Wrap in transaction for atomic file upload support
|
|
112
|
+
ActiveRecord::Base.transaction do
|
|
113
|
+
if obj.save
|
|
114
|
+
# Store in instance variable for auditing and other concerns
|
|
115
|
+
@obj = obj
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
# Hook for optional post-save operations (e.g., file attachments)
|
|
118
|
+
# If file upload fails in strict mode, this raises FileUploadError
|
|
119
|
+
after_save_hook(obj) if respond_to?(:after_save_hook, true)
|
|
111
120
|
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
|
|
122
|
+
@obj = obj # Update with reloaded version
|
|
114
123
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
includes = serializer_includes_for_action(:create)
|
|
125
|
+
options[:include] = includes if includes.present?
|
|
126
|
+
|
|
127
|
+
render_success(data: obj, serializer_options: options, status: :created)
|
|
128
|
+
else
|
|
129
|
+
render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
|
|
130
|
+
end
|
|
118
131
|
end
|
|
132
|
+
rescue ::Pundit::NotAuthorizedError
|
|
133
|
+
raise
|
|
134
|
+
rescue Dscf::Core::FileUploadError => e
|
|
135
|
+
render_error(errors: e.message, status: :unprocessable_entity)
|
|
119
136
|
rescue StandardError => e
|
|
120
137
|
render_error(error: e.message)
|
|
121
138
|
end
|
|
122
139
|
|
|
123
140
|
def update
|
|
141
|
+
authorize @obj
|
|
142
|
+
|
|
124
143
|
obj = nil
|
|
125
144
|
options = {}
|
|
126
145
|
if block_given?
|
|
@@ -137,17 +156,28 @@ module Dscf
|
|
|
137
156
|
obj = set_object
|
|
138
157
|
end
|
|
139
158
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
# Wrap in transaction for atomic file upload support
|
|
160
|
+
ActiveRecord::Base.transaction do
|
|
161
|
+
if obj.update(model_params)
|
|
162
|
+
# Hook for optional post-update operations (e.g., file attachments)
|
|
163
|
+
# If file upload fails in strict mode, this raises FileUploadError
|
|
164
|
+
after_save_hook(obj) if respond_to?(:after_save_hook, true)
|
|
143
165
|
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
|
|
167
|
+
@obj = obj # Update with reloaded version for auditing
|
|
146
168
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
169
|
+
includes = serializer_includes_for_action(:update)
|
|
170
|
+
options[:include] = includes if includes.present?
|
|
171
|
+
|
|
172
|
+
render_success(data: obj, serializer_options: options)
|
|
173
|
+
else
|
|
174
|
+
render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
|
|
175
|
+
end
|
|
150
176
|
end
|
|
177
|
+
rescue ::Pundit::NotAuthorizedError
|
|
178
|
+
raise
|
|
179
|
+
rescue Dscf::Core::FileUploadError => e
|
|
180
|
+
render_error(errors: e.message, status: :unprocessable_entity)
|
|
151
181
|
rescue StandardError => e
|
|
152
182
|
render_error(error: e.message)
|
|
153
183
|
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dscf
|
|
4
|
+
module Core
|
|
5
|
+
# Include this concern in controllers that need to handle file uploads
|
|
6
|
+
# Works seamlessly with Dscf::Core::Common for automatic file handling in CRUD operations
|
|
7
|
+
#
|
|
8
|
+
# File validation happens BEFORE save, and if it fails, the entire operation is rolled back.
|
|
9
|
+
# This ensures atomic behavior - either everything succeeds or nothing is saved.
|
|
10
|
+
#
|
|
11
|
+
# @example With Common module (recommended - zero config!)
|
|
12
|
+
# class ProductsController < ApplicationController
|
|
13
|
+
# include Dscf::Core::Common
|
|
14
|
+
# include Dscf::Core::FileUploadable # Add after Common
|
|
15
|
+
#
|
|
16
|
+
# # That's it! Files are auto-attached from params on create/update
|
|
17
|
+
# # Just make sure your model has: has_one_file :avatar or has_many_files :images
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example With custom file param names
|
|
21
|
+
# class ProductsController < ApplicationController
|
|
22
|
+
# include Dscf::Core::Common
|
|
23
|
+
# include Dscf::Core::FileUploadable
|
|
24
|
+
#
|
|
25
|
+
# # Override to specify which params are file uploads
|
|
26
|
+
# def file_upload_params
|
|
27
|
+
# { avatar: params[:product][:avatar], images: params[:product][:images] }
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @example Soft failure mode (don't fail on upload errors)
|
|
32
|
+
# class ProductsController < ApplicationController
|
|
33
|
+
# include Dscf::Core::Common
|
|
34
|
+
# include Dscf::Core::FileUploadable
|
|
35
|
+
#
|
|
36
|
+
# def file_upload_strict_mode?
|
|
37
|
+
# false # Record will be saved even if file upload fails
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
module FileUploadable
|
|
42
|
+
extend ActiveSupport::Concern
|
|
43
|
+
|
|
44
|
+
included do
|
|
45
|
+
attr_reader :file_upload_errors
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Hook called by Common module after create/update
|
|
49
|
+
# Automatically attaches files from params with strict validation
|
|
50
|
+
def after_save_hook(record)
|
|
51
|
+
super if defined?(super)
|
|
52
|
+
|
|
53
|
+
success = auto_attach_files(record)
|
|
54
|
+
|
|
55
|
+
# In strict mode, raise error to trigger rollback if upload failed
|
|
56
|
+
return unless !success && file_upload_strict_mode?
|
|
57
|
+
|
|
58
|
+
error_message = @file_upload_errors&.first || "File upload failed"
|
|
59
|
+
raise FileUploadError, error_message
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Whether to fail the entire operation if file upload fails
|
|
63
|
+
# Override in controller to change behavior
|
|
64
|
+
# @return [Boolean] true = fail on upload error, false = save record anyway
|
|
65
|
+
def file_upload_strict_mode?
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Attach files to a model's attachment field
|
|
70
|
+
# @param record [ActiveRecord::Base] the record to attach files to
|
|
71
|
+
# @param attachment_name [Symbol] the attachment field name
|
|
72
|
+
# @param files [ActionDispatch::Http::UploadedFile, Array] the file(s) to attach
|
|
73
|
+
# @param options [Hash] additional options
|
|
74
|
+
# @return [Boolean] success status
|
|
75
|
+
def attach_files(record, attachment_name, files, **options)
|
|
76
|
+
return true if files.blank?
|
|
77
|
+
return true unless record.respond_to?(attachment_name)
|
|
78
|
+
|
|
79
|
+
attachment = record.send(attachment_name)
|
|
80
|
+
return true unless attachment.respond_to?(:attach)
|
|
81
|
+
|
|
82
|
+
# Auto-set uploaded_by if available
|
|
83
|
+
attachment = attachment.uploaded_by(current_user) if respond_to?(:current_user) && current_user
|
|
84
|
+
|
|
85
|
+
result = attachment.attach(files, **options)
|
|
86
|
+
|
|
87
|
+
unless result
|
|
88
|
+
@file_upload_errors ||= []
|
|
89
|
+
@file_upload_errors.concat(Array(attachment.errors))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Automatically attach file uploads from params to a record
|
|
96
|
+
# Detects file params and attaches them to matching model attachments
|
|
97
|
+
# @param record [ActiveRecord::Base] the record to attach files to
|
|
98
|
+
# @param namespace [Symbol] the params namespace (default: model name underscored)
|
|
99
|
+
# @return [Boolean] success status (false if any attachment failed)
|
|
100
|
+
def auto_attach_files(record, namespace: nil)
|
|
101
|
+
@file_upload_errors = []
|
|
102
|
+
|
|
103
|
+
# Use custom file params if defined
|
|
104
|
+
files_to_attach = if respond_to?(:file_upload_params, true)
|
|
105
|
+
file_upload_params
|
|
106
|
+
else
|
|
107
|
+
extract_file_params(record, namespace)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
all_success = true
|
|
111
|
+
files_to_attach.each do |attachment_name, file_value|
|
|
112
|
+
success = attach_files(record, attachment_name.to_sym, file_value)
|
|
113
|
+
all_success = false unless success
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
all_success
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Upload a file directly (without attaching to a model)
|
|
120
|
+
# Useful for standalone upload endpoints
|
|
121
|
+
# @param file [ActionDispatch::Http::UploadedFile] the file to upload
|
|
122
|
+
# @param options [Hash] uploader options
|
|
123
|
+
# @return [Dscf::Core::FileStorage::Attachment, nil]
|
|
124
|
+
def upload_file(file, **options)
|
|
125
|
+
@file_upload_errors = []
|
|
126
|
+
|
|
127
|
+
if file.blank?
|
|
128
|
+
@file_upload_errors << "No file provided"
|
|
129
|
+
return nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
uploader = FileStorage::Uploader.new(options)
|
|
133
|
+
result = uploader.upload(file)
|
|
134
|
+
|
|
135
|
+
unless result
|
|
136
|
+
@file_upload_errors = uploader.errors
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
result
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Upload multiple files directly
|
|
144
|
+
# @param files [Array<ActionDispatch::Http::UploadedFile>] the files to upload
|
|
145
|
+
# @param options [Hash] uploader options
|
|
146
|
+
# @return [Array<Hash>] array of uploaded file hashes
|
|
147
|
+
def upload_files(files, **options)
|
|
148
|
+
@file_upload_errors = []
|
|
149
|
+
|
|
150
|
+
if files.blank?
|
|
151
|
+
@file_upload_errors << "No files provided"
|
|
152
|
+
return []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
uploader = FileStorage::Uploader.new(options)
|
|
156
|
+
results = uploader.upload_many(files)
|
|
157
|
+
|
|
158
|
+
@file_upload_errors = uploader.errors unless uploader.success?
|
|
159
|
+
|
|
160
|
+
results
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Download a file and send it as response
|
|
164
|
+
# @param file_key [String] the file key in storage
|
|
165
|
+
# @param options [Hash] download options
|
|
166
|
+
def send_stored_file(file_key, **options)
|
|
167
|
+
client = FileStorage::Client.new
|
|
168
|
+
result = client.download(file_key)
|
|
169
|
+
|
|
170
|
+
send_data(
|
|
171
|
+
result[:data],
|
|
172
|
+
filename: options[:filename] || result[:filename],
|
|
173
|
+
type: options[:content_type] || result[:content_type],
|
|
174
|
+
disposition: options[:disposition] || "inline"
|
|
175
|
+
)
|
|
176
|
+
rescue FileStorage::Client::DownloadError => e
|
|
177
|
+
render json: {error: e.message}, status: :not_found
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Download a file from an attachment
|
|
181
|
+
# @param attachment [Dscf::Core::FileAttachment] the attachment record
|
|
182
|
+
# @param options [Hash] download options
|
|
183
|
+
def send_attachment(attachment, **options)
|
|
184
|
+
unless attachment&.attached?
|
|
185
|
+
render json: {error: "File not found"}, status: :not_found
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
send_stored_file(
|
|
190
|
+
attachment.file_key,
|
|
191
|
+
filename: options[:filename] || attachment.filename,
|
|
192
|
+
content_type: options[:content_type] || attachment.content_type,
|
|
193
|
+
disposition: options[:disposition] || "inline"
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Process base64 encoded file
|
|
198
|
+
# @param base64_data [String] base64 encoded file data
|
|
199
|
+
# @param filename [String] desired filename
|
|
200
|
+
# @param content_type [String] MIME type
|
|
201
|
+
# @return [Hash] file hash suitable for uploading
|
|
202
|
+
def process_base64_file(base64_data, filename:, content_type:)
|
|
203
|
+
return nil if base64_data.blank?
|
|
204
|
+
|
|
205
|
+
# Handle data URL format: "data:image/png;base64,..."
|
|
206
|
+
if base64_data.include?(",")
|
|
207
|
+
content_type_match = base64_data.match(/data:([^;]+);base64/)
|
|
208
|
+
content_type = content_type_match[1] if content_type_match
|
|
209
|
+
base64_data = base64_data.split(",").last
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
decoded = Base64.decode64(base64_data)
|
|
213
|
+
io = StringIO.new(decoded)
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
io: io,
|
|
217
|
+
filename: filename,
|
|
218
|
+
content_type: content_type
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Upload a base64 encoded file
|
|
223
|
+
# @param base64_data [String] base64 encoded file data
|
|
224
|
+
# @param filename [String] desired filename
|
|
225
|
+
# @param content_type [String] MIME type
|
|
226
|
+
# @param options [Hash] uploader options
|
|
227
|
+
# @return [Hash, nil] uploaded file hash
|
|
228
|
+
def upload_base64_file(base64_data, filename:, content_type:, **options)
|
|
229
|
+
file_hash = process_base64_file(base64_data, filename: filename, content_type: content_type)
|
|
230
|
+
return nil unless file_hash
|
|
231
|
+
|
|
232
|
+
upload_file(file_hash, **options)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
# Extract file upload params from request params
|
|
238
|
+
# Looks for UploadedFile objects in the model's param namespace
|
|
239
|
+
# @param record [ActiveRecord::Base] the record to find files for
|
|
240
|
+
# @param namespace [Symbol, nil] optional param namespace override
|
|
241
|
+
# @return [Hash] hash of attachment_name => file pairs
|
|
242
|
+
def extract_file_params(record, namespace = nil)
|
|
243
|
+
namespace ||= record.class.name.demodulize.underscore.to_sym
|
|
244
|
+
model_params = params[namespace] || {}
|
|
245
|
+
|
|
246
|
+
files = {}
|
|
247
|
+
model_params.each do |key, value|
|
|
248
|
+
# Handle single file uploads
|
|
249
|
+
if value.is_a?(ActionDispatch::Http::UploadedFile)
|
|
250
|
+
files[key] = value
|
|
251
|
+
# Handle array of files
|
|
252
|
+
elsif value.is_a?(Array) && value.first.is_a?(ActionDispatch::Http::UploadedFile)
|
|
253
|
+
files[key] = value
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
files
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -216,6 +216,8 @@ module Dscf
|
|
|
216
216
|
if model_updates.present? && !reviewable_resource.update(model_updates)
|
|
217
217
|
return render_error(errors: reviewable_resource.errors.full_messages)
|
|
218
218
|
end
|
|
219
|
+
|
|
220
|
+
reviewable_resource.reload # Ensure we have latest data
|
|
219
221
|
rescue ActionController::ParameterMissing => e
|
|
220
222
|
return render_error(errors: [e.message])
|
|
221
223
|
end
|
|
@@ -298,7 +300,35 @@ module Dscf
|
|
|
298
300
|
end
|
|
299
301
|
|
|
300
302
|
def authorize_review_action!
|
|
301
|
-
|
|
303
|
+
permission_code = "#{controller_name}.#{action_name}"
|
|
304
|
+
|
|
305
|
+
unless current_user&.has_permission?(permission_code)
|
|
306
|
+
skip_authorization
|
|
307
|
+
return render_error("errors.unauthorized", status: :forbidden)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Ownership enforcement: submit/resubmit can only be performed by the record owner.
|
|
311
|
+
# SUPER_ADMIN bypasses this check to allow admin data correction scenarios.
|
|
312
|
+
submitter_actions = %w[submit resubmit]
|
|
313
|
+
if submitter_actions.include?(action_name) &&
|
|
314
|
+
!current_user&.super_admin? &&
|
|
315
|
+
@reviewable_resource.respond_to?(:user) &&
|
|
316
|
+
!@reviewable_resource.user.nil? &&
|
|
317
|
+
@reviewable_resource.user != current_user
|
|
318
|
+
skip_authorization
|
|
319
|
+
return render_error("errors.unauthorized", status: :forbidden)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Self-review prevention: cannot approve/reject/request_modification your own submission
|
|
323
|
+
non_submitter_actions = %w[approve reject request_modification]
|
|
324
|
+
if non_submitter_actions.include?(action_name) &&
|
|
325
|
+
@reviewable_resource.respond_to?(:user) &&
|
|
326
|
+
@reviewable_resource.user == current_user
|
|
327
|
+
skip_authorization
|
|
328
|
+
return render_error("errors.unauthorized", status: :forbidden)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
skip_authorization # This before_action handles authorization — satisfy verify_authorized
|
|
302
332
|
end
|
|
303
333
|
|
|
304
334
|
def submission_ready?(resource)
|
|
@@ -4,9 +4,11 @@ module Dscf
|
|
|
4
4
|
include Authenticatable
|
|
5
5
|
include TokenAuthenticatable
|
|
6
6
|
include JsonResponse
|
|
7
|
+
include Authorizable
|
|
7
8
|
|
|
8
|
-
# Handle CORS for authentication
|
|
9
9
|
before_action :set_cors_headers
|
|
10
|
+
before_action :authenticate_user
|
|
11
|
+
before_action :authorize_action!
|
|
10
12
|
|
|
11
13
|
private
|
|
12
14
|
|
|
@@ -16,10 +18,6 @@ module Dscf
|
|
|
16
18
|
headers["Access-Control-Allow-Headers"] = "Origin, Content-Type, Accept, Authorization, X-Requested-With"
|
|
17
19
|
headers["Access-Control-Allow-Credentials"] = "false"
|
|
18
20
|
end
|
|
19
|
-
|
|
20
|
-
def authentication_required?
|
|
21
|
-
false # Override in specific controllers
|
|
22
|
-
end
|
|
23
21
|
end
|
|
24
22
|
end
|
|
25
23
|
end
|