dscf-core 0.3.8 → 0.3.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/dscf/core/auth_controller.rb +20 -1
- data/app/services/dscf/core/file_storage/client.rb +87 -126
- data/lib/dscf/core/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7a5e4e4e0fa072a549085c2e512fb502b59d3b7547d9265bbc5e969ff3b5225
|
|
4
|
+
data.tar.gz: 246be4bbcbb6660743154695649c2320998b85c890814d5629cc78899a3b3e30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c60bf6536fecbfff1826fa883c26abf8d5535d5e9ad8a8df96e2aaad29158b9f4d219737932398bfb23e6ec71c02fe511a9b4ffa981d2a4ef4c171da47e42af5
|
|
7
|
+
data.tar.gz: 45400ff88afccceafcf353327a53c787d1a7bb5ddeee7ab101f63e9f48cc6ce573b00a6232dedfca7b0860508c710cd4facbf18397eb842b9238c1f59b7f5695
|
|
@@ -154,13 +154,32 @@ module Dscf
|
|
|
154
154
|
def create_retailer_from_signup!(user)
|
|
155
155
|
return unless defined?(Dscf::Marketplace::Retailer)
|
|
156
156
|
|
|
157
|
-
Dscf::Marketplace::Retailer.create!(
|
|
157
|
+
retailer = Dscf::Marketplace::Retailer.create!(
|
|
158
158
|
name: retailer_signup_params[:name],
|
|
159
159
|
phone: retailer_signup_params[:phone],
|
|
160
160
|
tin_number: retailer_signup_params[:tin_number],
|
|
161
161
|
location: retailer_signup_params[:location],
|
|
162
162
|
user: user
|
|
163
163
|
)
|
|
164
|
+
|
|
165
|
+
if retailer.location.present? && defined?(Dscf::Core::Address)
|
|
166
|
+
lat_str, lng_str = retailer.location.split(",")
|
|
167
|
+
lat = lat_str.to_f
|
|
168
|
+
lng = lng_str.to_f
|
|
169
|
+
|
|
170
|
+
if lat != 0.0 && lng != 0.0
|
|
171
|
+
Dscf::Core::Address.create!(
|
|
172
|
+
user: user,
|
|
173
|
+
address_type: :shipping,
|
|
174
|
+
country: "Ethiopia",
|
|
175
|
+
city: "Addis Ababa",
|
|
176
|
+
latitude: lat,
|
|
177
|
+
longitude: lng
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
rescue => e
|
|
182
|
+
logger.warn "Failed to auto-create address from retailer onboarding location: #{e.message}"
|
|
164
183
|
end
|
|
165
184
|
|
|
166
185
|
def agent_signup_params
|
|
@@ -1,79 +1,106 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "aws-sdk-s3"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
3
6
|
module Dscf
|
|
4
7
|
module Core
|
|
5
8
|
module FileStorage
|
|
6
|
-
# Low-level
|
|
7
|
-
# This is an internal class - other engines should NOT use this directly
|
|
9
|
+
# Low-level S3 client for object storage (Garage / MinIO / any S3-compatible service).
|
|
10
|
+
# This is an internal class - other engines should NOT use this directly; go through
|
|
11
|
+
# FileStorage, Uploader, or the Attachable concern instead.
|
|
12
|
+
#
|
|
13
|
+
# Configuration is read from the environment:
|
|
14
|
+
# S3_ENDPOINT_URL - e.g. http://garage:3900 (required)
|
|
15
|
+
# S3_BUCKET - bucket name (required)
|
|
16
|
+
# S3_ACCESS_KEY_ID - access key (required)
|
|
17
|
+
# S3_SECRET_ACCESS_KEY - secret key (required)
|
|
18
|
+
# S3_REGION - region (optional, defaults to "garage")
|
|
19
|
+
#
|
|
20
|
+
# File keys are flat UUIDs (no "/") so they stay valid for the
|
|
21
|
+
# `files/:file_key` route constraint (`/[^\/]+/`).
|
|
8
22
|
class Client
|
|
9
|
-
include HTTParty
|
|
10
|
-
|
|
11
23
|
class UploadError < StandardError; end
|
|
12
24
|
class DownloadError < StandardError; end
|
|
13
25
|
class ConfigurationError < StandardError; end
|
|
14
26
|
|
|
27
|
+
DEFAULT_REGION = "garage"
|
|
28
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
29
|
+
|
|
30
|
+
# @param app_name [String, nil] retained for backwards compatibility (unused)
|
|
31
|
+
# @param record_class [Class, nil] retained for backwards compatibility (unused)
|
|
15
32
|
def initialize(app_name: nil, record_class: nil)
|
|
16
|
-
@
|
|
17
|
-
@
|
|
18
|
-
@
|
|
33
|
+
@endpoint = ENV["S3_ENDPOINT_URL"].presence
|
|
34
|
+
@bucket = ENV["S3_BUCKET"].presence
|
|
35
|
+
@access_key_id = ENV["S3_ACCESS_KEY_ID"].presence
|
|
36
|
+
@secret_access_key = ENV["S3_SECRET_ACCESS_KEY"].presence
|
|
37
|
+
@region = ENV.fetch("S3_REGION", DEFAULT_REGION)
|
|
19
38
|
validate_configuration!
|
|
20
39
|
end
|
|
21
40
|
|
|
22
|
-
# Upload a file to
|
|
23
|
-
# @param file [ActionDispatch::Http::UploadedFile, File,
|
|
41
|
+
# Upload a file to object storage.
|
|
42
|
+
# @param file [ActionDispatch::Http::UploadedFile, File, Hash] the file to upload
|
|
24
43
|
# @param filename [String] optional custom filename
|
|
25
44
|
# @return [Hash] { file_key:, filename:, size:, content_type: }
|
|
26
45
|
def upload(file, filename: nil)
|
|
27
46
|
prepared = prepare_file(file, filename)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
file_key = generate_file_key(prepared[:filename])
|
|
48
|
+
io = prepared[:io]
|
|
49
|
+
io.rewind if io.respond_to?(:rewind)
|
|
50
|
+
|
|
51
|
+
s3.put_object(
|
|
52
|
+
bucket: @bucket,
|
|
53
|
+
key: file_key,
|
|
54
|
+
body: io,
|
|
55
|
+
content_type: prepared[:content_type] || DEFAULT_CONTENT_TYPE,
|
|
56
|
+
metadata: {"filename" => prepared[:filename].to_s}
|
|
35
57
|
)
|
|
36
58
|
|
|
37
|
-
|
|
59
|
+
{
|
|
60
|
+
file_key: file_key,
|
|
61
|
+
filename: prepared[:filename],
|
|
62
|
+
size: prepared[:size],
|
|
63
|
+
content_type: prepared[:content_type]
|
|
64
|
+
}
|
|
65
|
+
rescue Aws::Errors::ServiceError => e
|
|
66
|
+
raise UploadError, "Upload failed: #{e.message}"
|
|
38
67
|
end
|
|
39
68
|
|
|
40
|
-
# Download a file from
|
|
41
|
-
# @param file_key [String] the
|
|
69
|
+
# Download a file from object storage.
|
|
70
|
+
# @param file_key [String] the object key
|
|
42
71
|
# @return [Hash] { data:, filename:, content_type: }
|
|
43
72
|
def download(file_key)
|
|
44
|
-
response =
|
|
45
|
-
"#{@base_uri}/download/#{URI.encode_www_form_component(file_key)}",
|
|
46
|
-
headers: auth_headers,
|
|
47
|
-
timeout: 60
|
|
48
|
-
)
|
|
73
|
+
response = s3.get_object(bucket: @bucket, key: file_key)
|
|
49
74
|
|
|
50
|
-
|
|
75
|
+
{
|
|
76
|
+
data: response.body.read,
|
|
77
|
+
filename: response.metadata["filename"].presence || file_key,
|
|
78
|
+
content_type: response.content_type.presence || DEFAULT_CONTENT_TYPE
|
|
79
|
+
}
|
|
80
|
+
rescue Aws::S3::Errors::NoSuchKey => e
|
|
81
|
+
raise DownloadError, "File not found: #{file_key} (#{e.message})"
|
|
82
|
+
rescue Aws::Errors::ServiceError => e
|
|
83
|
+
raise DownloadError, "Download failed: #{e.message}"
|
|
51
84
|
end
|
|
52
85
|
|
|
53
|
-
# Check if
|
|
54
|
-
# @param file_key [String] the
|
|
86
|
+
# Check if an object exists.
|
|
87
|
+
# @param file_key [String] the object key
|
|
55
88
|
# @return [Boolean]
|
|
56
89
|
def exists?(file_key)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
response.success?
|
|
90
|
+
s3.head_object(bucket: @bucket, key: file_key)
|
|
91
|
+
true
|
|
92
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
|
|
93
|
+
false
|
|
63
94
|
rescue StandardError
|
|
64
95
|
false
|
|
65
96
|
end
|
|
66
97
|
|
|
67
|
-
# Delete
|
|
68
|
-
# @param file_key [String] the
|
|
98
|
+
# Delete an object.
|
|
99
|
+
# @param file_key [String] the object key
|
|
69
100
|
# @return [Boolean]
|
|
70
101
|
def delete(file_key)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
headers: auth_headers,
|
|
74
|
-
timeout: 30
|
|
75
|
-
)
|
|
76
|
-
response.success?
|
|
102
|
+
s3.delete_object(bucket: @bucket, key: file_key)
|
|
103
|
+
true
|
|
77
104
|
rescue StandardError => e
|
|
78
105
|
Rails.logger.error("FileStorage::Client delete error: #{e.message}")
|
|
79
106
|
false
|
|
@@ -81,60 +108,32 @@ module Dscf
|
|
|
81
108
|
|
|
82
109
|
private
|
|
83
110
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
111
|
+
def s3
|
|
112
|
+
@s3 ||= Aws::S3::Client.new(
|
|
113
|
+
endpoint: @endpoint,
|
|
114
|
+
region: @region,
|
|
115
|
+
access_key_id: @access_key_id,
|
|
116
|
+
secret_access_key: @secret_access_key,
|
|
117
|
+
force_path_style: true
|
|
118
|
+
)
|
|
124
119
|
end
|
|
125
120
|
|
|
126
121
|
def validate_configuration!
|
|
127
|
-
|
|
122
|
+
missing = []
|
|
123
|
+
missing << "S3_ENDPOINT_URL" if @endpoint.blank?
|
|
124
|
+
missing << "S3_BUCKET" if @bucket.blank?
|
|
125
|
+
missing << "S3_ACCESS_KEY_ID" if @access_key_id.blank?
|
|
126
|
+
missing << "S3_SECRET_ACCESS_KEY" if @secret_access_key.blank?
|
|
128
127
|
|
|
129
|
-
return
|
|
128
|
+
return if missing.empty?
|
|
130
129
|
|
|
131
|
-
|
|
132
|
-
raise ConfigurationError,
|
|
133
|
-
"MINIO_API_KEY is not configured. Looked for: MINIO_API_KEY_DSCF_#{detected}, MINIO_API_KEY_#{detected}, MINIO_API_KEY"
|
|
130
|
+
raise ConfigurationError, "Object storage is not configured. Missing: #{missing.join(', ')}"
|
|
134
131
|
end
|
|
135
132
|
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
# Flat, unique, slash-free key (kept compatible with the files/:file_key route).
|
|
134
|
+
def generate_file_key(filename)
|
|
135
|
+
ext = filename ? File.extname(filename.to_s).downcase : ""
|
|
136
|
+
"#{SecureRandom.uuid}#{ext}"
|
|
138
137
|
end
|
|
139
138
|
|
|
140
139
|
def prepare_file(file, custom_filename)
|
|
@@ -166,44 +165,6 @@ module Dscf
|
|
|
166
165
|
raise ArgumentError, "Unsupported file type: #{file.class}"
|
|
167
166
|
end
|
|
168
167
|
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
168
|
end
|
|
208
169
|
end
|
|
209
170
|
end
|
data/lib/dscf/core/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dscf-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: 4.1.0
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: aws-sdk-s3
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: bcrypt
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|