gr_api_manager 0.1.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +456 -187
- data/README_ES.md +615 -0
- data/lib/gr_api_manager.rb +766 -59
- metadata +72 -8
data/lib/gr_api_manager.rb
CHANGED
|
@@ -1,22 +1,589 @@
|
|
|
1
1
|
require 'sinatra/base'
|
|
2
2
|
require 'json'
|
|
3
3
|
require 'dotenv/load'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'openssl'
|
|
4
8
|
|
|
5
9
|
module GRApiManager
|
|
10
|
+
# Convenience size helpers — use in max_body_size:
|
|
11
|
+
# GRApiManager.mb(50) => 50 MB in bytes
|
|
12
|
+
# GRApiManager.gb(2) => 2 GB in bytes
|
|
13
|
+
def self.mb(n) = n * 1_024 * 1_024
|
|
14
|
+
def self.gb(n) = n * 1_024 * 1_024 * 1_024
|
|
15
|
+
|
|
16
|
+
# Extracts the client IP from proxy headers (Cloudflare, X-Real-IP, X-Forwarded-For),
|
|
17
|
+
# falling back to request.ip or REMOTE_ADDR.
|
|
18
|
+
def self.extract_client_ip(request)
|
|
19
|
+
env = request.respond_to?(:env) ? request.env : request
|
|
20
|
+
return (request.respond_to?(:ip) ? request.ip : '127.0.0.1') unless env.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
if env['HTTP_CF_CONNECTING_IP'] && !env['HTTP_CF_CONNECTING_IP'].to_s.strip.empty?
|
|
23
|
+
return env['HTTP_CF_CONNECTING_IP'].to_s.strip
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if env['HTTP_X_REAL_IP'] && !env['HTTP_X_REAL_IP'].to_s.strip.empty?
|
|
27
|
+
return env['HTTP_X_REAL_IP'].to_s.strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if env['HTTP_X_FORWARDED_FOR'] && !env['HTTP_X_FORWARDED_FOR'].to_s.strip.empty?
|
|
31
|
+
client = env['HTTP_X_FORWARDED_FOR'].to_s.split(',').first
|
|
32
|
+
return client.strip if client && !client.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
request.respond_to?(:ip) ? request.ip : (env['REMOTE_ADDR'] || '127.0.0.1')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# JWT — Zero-dependency JSON Web Token encoder and decoder (HS256).
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
module JWT
|
|
42
|
+
class DecodeError < StandardError; end
|
|
43
|
+
class ExpiredSignature < DecodeError; end
|
|
44
|
+
|
|
45
|
+
def self.base64url_encode(str)
|
|
46
|
+
Base64.urlsafe_encode64(str, padding: false)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.base64url_decode(str)
|
|
50
|
+
padded = str + ('=' * ((4 - (str.length % 4)) % 4))
|
|
51
|
+
Base64.urlsafe_decode64(padded)
|
|
52
|
+
rescue ArgumentError => e
|
|
53
|
+
raise DecodeError, "Invalid Base64URL string: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Encodes a payload hash into a JWT token signed with HMAC-SHA256.
|
|
57
|
+
# Options:
|
|
58
|
+
# exp: Integer – expiration timestamp (epoch in seconds).
|
|
59
|
+
def self.encode(payload, secret, exp: nil, algorithm: 'HS256')
|
|
60
|
+
raise ArgumentError, "JWT secret cannot be blank" if secret.to_s.strip.empty?
|
|
61
|
+
|
|
62
|
+
data = payload.dup
|
|
63
|
+
data = data.transform_keys(&:to_sym) if data.is_a?(Hash)
|
|
64
|
+
data[:exp] = exp.to_i if exp
|
|
65
|
+
|
|
66
|
+
header = { typ: 'JWT', alg: algorithm }
|
|
67
|
+
header_b64 = base64url_encode(header.to_json)
|
|
68
|
+
payload_b64 = base64url_encode(data.to_json)
|
|
69
|
+
signing_input = "#{header_b64}.#{payload_b64}"
|
|
70
|
+
|
|
71
|
+
digest = OpenSSL::Digest.new('sha256')
|
|
72
|
+
signature = OpenSSL::HMAC.digest(digest, secret.to_s, signing_input)
|
|
73
|
+
signature_b64 = base64url_encode(signature)
|
|
74
|
+
|
|
75
|
+
"#{signing_input}.#{signature_b64}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Decodes and verifies a JWT token. Returns the payload hash with symbolized keys.
|
|
79
|
+
def self.decode(token, secret)
|
|
80
|
+
raise ArgumentError, "JWT secret cannot be blank" if secret.to_s.strip.empty?
|
|
81
|
+
raise DecodeError, "Token cannot be blank" if token.nil? || token.to_s.strip.empty?
|
|
82
|
+
|
|
83
|
+
parts = token.to_s.split('.')
|
|
84
|
+
raise DecodeError, "Invalid JWT format. Expected 3 segments separated by dots." unless parts.size == 3
|
|
85
|
+
|
|
86
|
+
header_b64, payload_b64, signature_b64 = parts
|
|
87
|
+
signing_input = "#{header_b64}.#{payload_b64}"
|
|
88
|
+
|
|
89
|
+
digest = OpenSSL::Digest.new('sha256')
|
|
90
|
+
expected_sig = OpenSSL::HMAC.digest(digest, secret.to_s, signing_input)
|
|
91
|
+
actual_sig = base64url_decode(signature_b64)
|
|
92
|
+
|
|
93
|
+
is_valid = if OpenSSL.respond_to?(:secure_compare)
|
|
94
|
+
OpenSSL.secure_compare(expected_sig, actual_sig)
|
|
95
|
+
else
|
|
96
|
+
expected_sig == actual_sig
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
raise DecodeError, "Invalid JWT signature" unless is_valid
|
|
100
|
+
|
|
101
|
+
payload_json = base64url_decode(payload_b64)
|
|
102
|
+
payload = JSON.parse(payload_json, symbolize_names: true)
|
|
103
|
+
|
|
104
|
+
if payload[:exp]
|
|
105
|
+
exp_time = payload[:exp].to_i
|
|
106
|
+
raise ExpiredSignature, "JWT signature has expired" if Time.now.to_i > exp_time
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
payload
|
|
110
|
+
rescue JSON::ParserError => e
|
|
111
|
+
raise DecodeError, "Invalid JSON payload in token: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Validator — declarative schema and type validation.
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
module Validator
|
|
119
|
+
EMAIL_REGEX = /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/
|
|
120
|
+
URL_REGEX = /\Ahttps?:\/\/\S+\z/i
|
|
121
|
+
|
|
122
|
+
# Validates *params* against *schema* (Hash of field => expected_type).
|
|
123
|
+
# Returns [is_valid, errors_hash].
|
|
124
|
+
def self.validate(params, schema)
|
|
125
|
+
errors = {}
|
|
126
|
+
|
|
127
|
+
schema.each do |field, rule|
|
|
128
|
+
key = field.to_sym
|
|
129
|
+
val = params[key]
|
|
130
|
+
|
|
131
|
+
# Check presence
|
|
132
|
+
if val.nil? || (val.is_a?(String) && val.strip.empty?)
|
|
133
|
+
errors[key] = "is required"
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Validate type / contract rule
|
|
138
|
+
error_msg = validate_rule(val, rule)
|
|
139
|
+
errors[key] = error_msg if error_msg
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
[errors.empty?, errors]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private_class_method
|
|
146
|
+
|
|
147
|
+
def self.validate_rule(val, rule)
|
|
148
|
+
case rule
|
|
149
|
+
when :email
|
|
150
|
+
"must be a valid email address" unless val.to_s.match?(EMAIL_REGEX)
|
|
151
|
+
when :url
|
|
152
|
+
"must be a valid URL (http/https)" unless val.to_s.match?(URL_REGEX)
|
|
153
|
+
when :boolean
|
|
154
|
+
"must be a boolean (true or false)" unless val == true || val == false
|
|
155
|
+
when :file
|
|
156
|
+
"must be an uploaded file" unless val.is_a?(GRApiManager::FilePayload)
|
|
157
|
+
when Class
|
|
158
|
+
if rule == Integer
|
|
159
|
+
"must be an Integer" unless val.is_a?(Integer)
|
|
160
|
+
elsif rule == Float
|
|
161
|
+
"must be a Float" unless val.is_a?(Float)
|
|
162
|
+
elsif rule == Numeric
|
|
163
|
+
"must be a Numeric" unless val.is_a?(Numeric)
|
|
164
|
+
elsif rule == String
|
|
165
|
+
"must be a String" unless val.is_a?(String)
|
|
166
|
+
elsif rule == Hash
|
|
167
|
+
"must be an Object/Hash" unless val.is_a?(Hash)
|
|
168
|
+
elsif rule == Array
|
|
169
|
+
"must be an Array" unless val.is_a?(Array)
|
|
170
|
+
else
|
|
171
|
+
"must be a #{rule}" unless val.is_a?(rule)
|
|
172
|
+
end
|
|
173
|
+
when Array
|
|
174
|
+
"must be one of: #{rule.map(&:to_s).join(', ')}" unless rule.map(&:to_s).include?(val.to_s)
|
|
175
|
+
when Regexp
|
|
176
|
+
"does not match expected format" unless val.to_s.match?(rule)
|
|
177
|
+
when Proc
|
|
178
|
+
"is invalid" unless rule.call(val)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# RateLimiter — thread-safe sliding-window rate limiter per IP/key.
|
|
185
|
+
#
|
|
186
|
+
# Supports pluggable storage backends (default: MemoryStore with Mutex).
|
|
187
|
+
# Prevents client flooding and supports distributed stores like Redis.
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
class RateLimiter
|
|
190
|
+
attr_reader :max_requests, :window_seconds, :store
|
|
191
|
+
|
|
192
|
+
# In-memory sliding-window store using Mutex.
|
|
193
|
+
class MemoryStore
|
|
194
|
+
def initialize
|
|
195
|
+
@store = Hash.new { |h, k| h[k] = [] }
|
|
196
|
+
@mutex = Mutex.new
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def allow?(key, max, window)
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
now = Time.now.to_f
|
|
202
|
+
cutoff = now - window
|
|
203
|
+
@store[key].reject! { |t| t < cutoff }
|
|
204
|
+
return false if @store[key].size >= max
|
|
205
|
+
@store[key] << now
|
|
206
|
+
true
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def remaining(key, max, window)
|
|
211
|
+
@mutex.synchronize do
|
|
212
|
+
cutoff = Time.now.to_f - window
|
|
213
|
+
active = @store[key].count { |t| t >= cutoff }
|
|
214
|
+
[max - active, 0].max
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def cleanup!(window)
|
|
219
|
+
@mutex.synchronize do
|
|
220
|
+
cutoff = Time.now.to_f - window
|
|
221
|
+
@store.each_value { |times| times.reject! { |t| t < cutoff } }
|
|
222
|
+
@store.delete_if { |_, times| times.empty? }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def reset!(key = nil)
|
|
227
|
+
@mutex.synchronize do
|
|
228
|
+
if key
|
|
229
|
+
@store.delete(key)
|
|
230
|
+
else
|
|
231
|
+
@store.clear
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def size
|
|
237
|
+
@mutex.synchronize { @store.size }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def initialize(max_requests:, window_seconds:, store: nil)
|
|
242
|
+
@max_requests = max_requests
|
|
243
|
+
@window_seconds = window_seconds
|
|
244
|
+
@store = store || MemoryStore.new
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Returns true if the request from *key* is within the allowed rate.
|
|
248
|
+
def allow?(key)
|
|
249
|
+
if @store.respond_to?(:allow?)
|
|
250
|
+
if @store.method(:allow?).arity == 1
|
|
251
|
+
@store.allow?(key)
|
|
252
|
+
else
|
|
253
|
+
@store.allow?(key, @max_requests, @window_seconds)
|
|
254
|
+
end
|
|
255
|
+
else
|
|
256
|
+
true
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Remaining requests allowed for *key* in the current window.
|
|
261
|
+
def remaining(key)
|
|
262
|
+
if @store.respond_to?(:remaining)
|
|
263
|
+
if @store.method(:remaining).arity == 1
|
|
264
|
+
@store.remaining(key)
|
|
265
|
+
else
|
|
266
|
+
@store.remaining(key, @max_requests, @window_seconds)
|
|
267
|
+
end
|
|
268
|
+
else
|
|
269
|
+
@max_requests
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Removes stale entries — call periodically to prevent memory growth.
|
|
274
|
+
def cleanup!
|
|
275
|
+
if @store.respond_to?(:cleanup!)
|
|
276
|
+
if @store.method(:cleanup!).arity == 0
|
|
277
|
+
@store.cleanup!
|
|
278
|
+
else
|
|
279
|
+
@store.cleanup!(@window_seconds)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Resets counters for a specific key or all keys.
|
|
285
|
+
def reset!(key = nil)
|
|
286
|
+
@store.reset!(key) if @store.respond_to?(:reset!)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Summary hash — safe to log or expose on a diagnostic endpoint.
|
|
290
|
+
def stats
|
|
291
|
+
tracked = @store.respond_to?(:size) ? @store.size : :external
|
|
292
|
+
{ tracked_ips: tracked, max_requests: @max_requests, window_seconds: @window_seconds }
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# FilePayload — wraps an uploaded file (multipart or raw) with a clean API.
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
class FilePayload
|
|
300
|
+
attr_reader :filename, :content_type, :size, :tempfile
|
|
301
|
+
|
|
302
|
+
def initialize(tempfile:, filename:, content_type:)
|
|
303
|
+
@tempfile = tempfile
|
|
304
|
+
@filename = filename.to_s
|
|
305
|
+
@content_type = content_type.to_s
|
|
306
|
+
@size = tempfile.respond_to?(:size) ? tempfile.size : (tempfile.respond_to?(:length) ? tempfile.length : tempfile.to_s.bytesize)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Returns the raw binary content of the file as a String (encoding: BINARY).
|
|
310
|
+
def read
|
|
311
|
+
if @tempfile.respond_to?(:read)
|
|
312
|
+
@tempfile.rewind if @tempfile.respond_to?(:rewind)
|
|
313
|
+
@tempfile.read
|
|
314
|
+
else
|
|
315
|
+
@tempfile.to_s.dup.force_encoding(Encoding::BINARY)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Returns the file content encoded as a Base64 string (no newlines).
|
|
320
|
+
def to_base64
|
|
321
|
+
Base64.strict_encode64(read)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Returns the file content encoded as a lowercase hexadecimal string.
|
|
325
|
+
def to_hex
|
|
326
|
+
read.unpack1('H*')
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Saves the uploaded content to *dest_path* on disk. Returns dest_path.
|
|
330
|
+
def save_to(dest_path)
|
|
331
|
+
dir = File.dirname(dest_path)
|
|
332
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
333
|
+
File.open(dest_path, 'wb') { |f| f.write(read) }
|
|
334
|
+
dest_path
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Convenience: the file extension derived from the original filename.
|
|
338
|
+
def extension
|
|
339
|
+
File.extname(@filename).downcase
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Human-friendly summary — safe to include in JSON responses.
|
|
343
|
+
def to_h
|
|
344
|
+
{
|
|
345
|
+
filename: @filename,
|
|
346
|
+
content_type: @content_type,
|
|
347
|
+
size: @size,
|
|
348
|
+
extension: extension
|
|
349
|
+
}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def inspect
|
|
353
|
+
"#<GRApiManager::FilePayload filename=#{@filename.inspect} " \
|
|
354
|
+
"content_type=#{@content_type.inspect} size=#{@size}>"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# BodyParser — detects Content-Type and returns an appropriate parsed result.
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
module BodyParser
|
|
362
|
+
|
|
363
|
+
BINARY_MIME_PREFIXES = %w[
|
|
364
|
+
image/ video/ audio/ application/pdf application/msword
|
|
365
|
+
application/vnd. application/zip application/x-tar
|
|
366
|
+
application/x-rar application/octet-stream
|
|
367
|
+
].freeze
|
|
368
|
+
|
|
369
|
+
# Returns a Hash of parsed body values.
|
|
370
|
+
def self.parse(request)
|
|
371
|
+
content_type = (request.content_type || '').split(';').first.to_s.strip.downcase
|
|
372
|
+
|
|
373
|
+
case content_type
|
|
374
|
+
when 'application/json'
|
|
375
|
+
parse_json(request)
|
|
376
|
+
|
|
377
|
+
when 'multipart/form-data'
|
|
378
|
+
parse_multipart(request)
|
|
379
|
+
|
|
380
|
+
when 'application/x-www-form-urlencoded'
|
|
381
|
+
{}
|
|
382
|
+
|
|
383
|
+
when 'text/plain'
|
|
384
|
+
text = request.body.read.to_s.force_encoding(Encoding::UTF_8)
|
|
385
|
+
{ _raw_text: text }
|
|
386
|
+
|
|
387
|
+
else
|
|
388
|
+
if binary_content_type?(content_type)
|
|
389
|
+
parse_raw_binary(request, content_type)
|
|
390
|
+
else
|
|
391
|
+
parse_json(request) rescue {}
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
private_class_method
|
|
397
|
+
|
|
398
|
+
def self.parse_json(request)
|
|
399
|
+
body = request.body.read.to_s
|
|
400
|
+
return {} if body.strip.empty?
|
|
401
|
+
JSON.parse(body, symbolize_names: true)
|
|
402
|
+
rescue JSON::ParserError => e
|
|
403
|
+
raise ArgumentError, "Invalid JSON body: #{e.message}"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def self.parse_multipart(request)
|
|
407
|
+
result = {}
|
|
408
|
+
files = {}
|
|
409
|
+
|
|
410
|
+
request.params.each do |key, value|
|
|
411
|
+
sym = key.to_sym
|
|
412
|
+
|
|
413
|
+
if value.is_a?(Hash) && value.key?(:tempfile)
|
|
414
|
+
files[sym] = FilePayload.new(
|
|
415
|
+
tempfile: value[:tempfile],
|
|
416
|
+
filename: value[:filename] || key,
|
|
417
|
+
content_type: value[:type] || 'application/octet-stream'
|
|
418
|
+
)
|
|
419
|
+
elsif value.is_a?(Array)
|
|
420
|
+
files[sym] = value.map do |v|
|
|
421
|
+
if v.is_a?(Hash) && v.key?(:tempfile)
|
|
422
|
+
FilePayload.new(
|
|
423
|
+
tempfile: v[:tempfile],
|
|
424
|
+
filename: v[:filename] || key,
|
|
425
|
+
content_type: v[:type] || 'application/octet-stream'
|
|
426
|
+
)
|
|
427
|
+
else
|
|
428
|
+
v
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
else
|
|
432
|
+
result[sym] = value
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
result[:_files] = files unless files.empty?
|
|
437
|
+
result
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def self.parse_raw_binary(request, content_type)
|
|
441
|
+
raw = request.body.read
|
|
442
|
+
return {} if raw.nil? || raw.empty?
|
|
443
|
+
|
|
444
|
+
disposition = request.env['HTTP_CONTENT_DISPOSITION'] || ''
|
|
445
|
+
filename = disposition[/filename="?([^";]+)"?/, 1] || "upload#{ext_for(content_type)}"
|
|
446
|
+
|
|
447
|
+
io = StringIO.new(raw.force_encoding(Encoding::BINARY))
|
|
448
|
+
|
|
449
|
+
payload = FilePayload.new(
|
|
450
|
+
tempfile: io,
|
|
451
|
+
filename: filename,
|
|
452
|
+
content_type: content_type
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
{ _raw_binary: payload }
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def self.binary_content_type?(ct)
|
|
459
|
+
BINARY_MIME_PREFIXES.any? { |prefix| ct.start_with?(prefix) }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def self.ext_for(content_type)
|
|
463
|
+
{
|
|
464
|
+
'image/jpeg' => '.jpg',
|
|
465
|
+
'image/png' => '.png',
|
|
466
|
+
'image/gif' => '.gif',
|
|
467
|
+
'image/webp' => '.webp',
|
|
468
|
+
'image/svg+xml' => '.svg',
|
|
469
|
+
'image/bmp' => '.bmp',
|
|
470
|
+
'video/mp4' => '.mp4',
|
|
471
|
+
'video/webm' => '.webm',
|
|
472
|
+
'audio/mpeg' => '.mp3',
|
|
473
|
+
'audio/wav' => '.wav',
|
|
474
|
+
'application/pdf' => '.pdf',
|
|
475
|
+
'application/msword' => '.doc',
|
|
476
|
+
'application/zip' => '.zip',
|
|
477
|
+
'application/x-tar' => '.tar',
|
|
478
|
+
'application/octet-stream' => '.bin'
|
|
479
|
+
}.fetch(content_type, '.bin')
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
# RouteGroup — provides nested route grouping with shared prefixes and options.
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
class RouteGroup
|
|
487
|
+
attr_reader :server, :prefix, :options
|
|
488
|
+
|
|
489
|
+
def initialize(server, prefix = '', options = {})
|
|
490
|
+
@server = server
|
|
491
|
+
@prefix = prefix.to_s
|
|
492
|
+
@options = options
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Dynamically generate routing methods inside the group (get, post, put, patch, delete).
|
|
496
|
+
%w[get post put patch delete].each do |verb|
|
|
497
|
+
define_method(verb) do |path, route_options = {}, &block|
|
|
498
|
+
combined_path = File.join('/', @prefix, path.to_s).gsub(%r{/+}, '/')
|
|
499
|
+
merged_options = @options.merge(route_options)
|
|
500
|
+
|
|
501
|
+
if @options[:requires] && route_options[:requires]
|
|
502
|
+
merged_options[:requires] = merge_requires(@options[:requires], route_options[:requires])
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
@server.register_route(verb, combined_path, merged_options, &block)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Nested sub-grouping.
|
|
510
|
+
def group(sub_prefix = '', sub_options = {}, &block)
|
|
511
|
+
combined_prefix = File.join('/', @prefix, sub_prefix.to_s).gsub(%r{/+}, '/')
|
|
512
|
+
merged_options = @options.merge(sub_options)
|
|
513
|
+
|
|
514
|
+
if @options[:requires] && sub_options[:requires]
|
|
515
|
+
merged_options[:requires] = merge_requires(@options[:requires], sub_options[:requires])
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
sub_group = RouteGroup.new(@server, combined_prefix, merged_options)
|
|
519
|
+
block.call(sub_group) if block
|
|
520
|
+
sub_group
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
private
|
|
524
|
+
|
|
525
|
+
def merge_requires(req1, req2)
|
|
526
|
+
if req1.is_a?(Hash) && req2.is_a?(Hash)
|
|
527
|
+
req1.merge(req2)
|
|
528
|
+
elsif req1.is_a?(Array) && req2.is_a?(Array)
|
|
529
|
+
(req1 + req2).uniq
|
|
530
|
+
else
|
|
531
|
+
req2
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
# Server — the public-facing DSL.
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
6
539
|
class Server
|
|
7
|
-
attr_reader :app_class
|
|
540
|
+
attr_reader :app_class, :rate_limiter, :jwt_secret
|
|
8
541
|
|
|
9
542
|
# Initializes the server configuration.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
543
|
+
#
|
|
544
|
+
# Options:
|
|
545
|
+
# port: Integer – listening port (default: ENV['PORT'] || 4000)
|
|
546
|
+
# bearer_token: String – Bearer token for auth (default: ENV['API_TOKEN'])
|
|
547
|
+
# jwt_secret: String – Secret key for signing/decoding JWTs (default: ENV['JWT_SECRET'])
|
|
548
|
+
# permitted_hosts: Array – host allowlist; empty = allow all
|
|
549
|
+
# prefix: String – route prefix, e.g. '/api/v1'
|
|
550
|
+
# max_body_size: Integer – maximum accepted body in bytes (default: 50 MB)
|
|
551
|
+
# dev_mode: Boolean – show full stack traces on 500 (default: false)
|
|
552
|
+
# rate_limit: Integer – max requests per IP per window (nil = disabled)
|
|
553
|
+
# rate_limit_window: Integer – sliding window in seconds (default: 60)
|
|
554
|
+
# rate_limit_store: Object – custom store object (default: MemoryStore)
|
|
555
|
+
# trust_proxy_headers: Boolean – inspect Cloudflare/X-Real-IP/X-Forwarded-For headers (default: true)
|
|
556
|
+
def initialize(
|
|
557
|
+
port: nil,
|
|
558
|
+
bearer_token: nil,
|
|
559
|
+
jwt_secret: nil,
|
|
560
|
+
permitted_hosts: [],
|
|
561
|
+
prefix: '',
|
|
562
|
+
max_body_size: GRApiManager.mb(50),
|
|
563
|
+
dev_mode: false,
|
|
564
|
+
rate_limit: nil,
|
|
565
|
+
rate_limit_window: 60,
|
|
566
|
+
rate_limit_store: nil,
|
|
567
|
+
trust_proxy_headers: true
|
|
568
|
+
)
|
|
569
|
+
@port = port || ENV['PORT'] || 4000
|
|
570
|
+
@token = bearer_token || ENV['API_TOKEN']
|
|
571
|
+
@jwt_secret = jwt_secret || ENV['JWT_SECRET']
|
|
572
|
+
@permitted_hosts = permitted_hosts.empty? ? [] : permitted_hosts
|
|
573
|
+
@prefix = prefix
|
|
574
|
+
@max_body_size = max_body_size
|
|
575
|
+
@dev_mode = dev_mode
|
|
576
|
+
@trust_proxy_headers = trust_proxy_headers
|
|
577
|
+
@rate_limiter = rate_limit ? GRApiManager::RateLimiter.new(
|
|
578
|
+
max_requests: rate_limit,
|
|
579
|
+
window_seconds: rate_limit_window,
|
|
580
|
+
store: rate_limit_store
|
|
581
|
+
) : nil
|
|
582
|
+
|
|
16
583
|
@app_class = Class.new(Sinatra::Base) do
|
|
17
|
-
|
|
584
|
+
|
|
18
585
|
# Logs HTTP requests with status-based color coding.
|
|
19
|
-
def log_request(method, path,
|
|
586
|
+
def log_request(method, path, status_code)
|
|
20
587
|
color = status_code.between?(200, 299) ? "\e[32m" : "\e[31m"
|
|
21
588
|
puts "[#{Time.now.strftime('%H:%M:%S')}] #{color}#{method} #{path} - #{status_code}\e[0m"
|
|
22
589
|
end
|
|
@@ -24,11 +591,12 @@ module GRApiManager
|
|
|
24
591
|
# Casts string URL parameters to native Ruby types (Integer, Float, Boolean).
|
|
25
592
|
def smart_parse(hash)
|
|
26
593
|
hash.transform_values do |val|
|
|
594
|
+
next val unless val.is_a?(String)
|
|
27
595
|
case val
|
|
28
|
-
when 'true'
|
|
29
|
-
when 'false'
|
|
30
|
-
when
|
|
31
|
-
when
|
|
596
|
+
when 'true' then true
|
|
597
|
+
when 'false' then false
|
|
598
|
+
when /^-?\d+$/ then val.to_i
|
|
599
|
+
when /^-?\d+\.\d+$/ then val.to_f
|
|
32
600
|
else val
|
|
33
601
|
end
|
|
34
602
|
end
|
|
@@ -38,22 +606,74 @@ module GRApiManager
|
|
|
38
606
|
configure_app
|
|
39
607
|
end
|
|
40
608
|
|
|
609
|
+
# Encodes a payload into a JWT token using the configured jwt_secret.
|
|
610
|
+
def jwt_encode(payload, exp: nil)
|
|
611
|
+
raise "No jwt_secret configured for this server" unless @jwt_secret
|
|
612
|
+
GRApiManager::JWT.encode(payload, @jwt_secret, exp: exp)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Decodes a JWT token using the configured jwt_secret.
|
|
616
|
+
def jwt_decode(token)
|
|
617
|
+
raise "No jwt_secret configured for this server" unless @jwt_secret
|
|
618
|
+
GRApiManager::JWT.decode(token, @jwt_secret)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Groups routes under a common prefix with inherited options.
|
|
622
|
+
def group(prefix = '', options = {}, &block)
|
|
623
|
+
route_group = RouteGroup.new(self, prefix, options)
|
|
624
|
+
block.call(route_group) if block
|
|
625
|
+
route_group
|
|
626
|
+
end
|
|
627
|
+
|
|
41
628
|
private
|
|
42
629
|
|
|
43
|
-
# Sets up Sinatra environment, CORS policies, and global error handlers.
|
|
630
|
+
# Sets up Sinatra environment, CORS policies, body size limit, rate limiting, and global error handlers.
|
|
44
631
|
def configure_app
|
|
45
|
-
app
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
632
|
+
app = @app_class
|
|
633
|
+
max_body = @max_body_size
|
|
634
|
+
rate_limiter = @rate_limiter
|
|
635
|
+
trust_proxy_headers = @trust_proxy_headers
|
|
636
|
+
|
|
637
|
+
app.set :port, @port
|
|
638
|
+
app.set :bind, '0.0.0.0'
|
|
639
|
+
app.set :token, @token
|
|
640
|
+
app.set :jwt_secret, @jwt_secret
|
|
641
|
+
app.set :dev_mode, @dev_mode
|
|
642
|
+
app.set :show_exceptions, false
|
|
643
|
+
app.set :raise_errors, false
|
|
644
|
+
app.set :dump_errors, false
|
|
50
645
|
app.set :host_authorization, { permitted_hosts: @permitted_hosts }
|
|
646
|
+
app.enable :static
|
|
51
647
|
|
|
52
|
-
# Enable broad CORS and handle preflight requests.
|
|
53
648
|
app.before do
|
|
54
|
-
headers 'Access-Control-Allow-Origin'
|
|
55
|
-
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
|
56
|
-
'Access-Control-Allow-Headers' => 'Content-Type, Authorization'
|
|
649
|
+
headers 'Access-Control-Allow-Origin' => '*',
|
|
650
|
+
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
|
651
|
+
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, Content-Disposition'
|
|
652
|
+
|
|
653
|
+
# Rate limiting — checked before anything else.
|
|
654
|
+
if rate_limiter
|
|
655
|
+
client_ip = trust_proxy_headers ? GRApiManager.extract_client_ip(request) : request.ip
|
|
656
|
+
unless rate_limiter.allow?(client_ip)
|
|
657
|
+
remaining_reset = rate_limiter.window_seconds
|
|
658
|
+
headers 'Retry-After' => remaining_reset.to_s,
|
|
659
|
+
'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
|
|
660
|
+
'X-RateLimit-Remaining' => '0',
|
|
661
|
+
'X-RateLimit-Reset' => (Time.now.to_i + remaining_reset).to_s
|
|
662
|
+
halt 429, { error: "Too many requests", retry_after_seconds: remaining_reset }.to_json
|
|
663
|
+
end
|
|
664
|
+
# Add rate-limit headers on allowed requests too.
|
|
665
|
+
headers 'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
|
|
666
|
+
'X-RateLimit-Remaining' => rate_limiter.remaining(client_ip).to_s
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Body size limit (skip for read-only / headerless verbs).
|
|
670
|
+
unless %w[GET DELETE OPTIONS HEAD].include?(request.request_method)
|
|
671
|
+
content_length = request.content_length.to_i
|
|
672
|
+
if content_length > max_body
|
|
673
|
+
halt 413, { error: "Payload too large", max_bytes: max_body }.to_json
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
57
677
|
content_type :json
|
|
58
678
|
end
|
|
59
679
|
|
|
@@ -61,87 +681,174 @@ module GRApiManager
|
|
|
61
681
|
halt 200
|
|
62
682
|
end
|
|
63
683
|
|
|
64
|
-
# JSON formatted 404 response.
|
|
65
684
|
app.not_found do
|
|
66
685
|
status 404
|
|
686
|
+
content_type :json
|
|
67
687
|
{ error: "Endpoint not found", path: request.path_info }.to_json
|
|
68
688
|
end
|
|
69
689
|
|
|
70
|
-
# JSON formatted 500 response.
|
|
71
690
|
app.error do
|
|
72
691
|
e = env['sinatra.error']
|
|
73
692
|
status 500
|
|
74
|
-
|
|
693
|
+
content_type :json
|
|
694
|
+
if settings.dev_mode
|
|
695
|
+
{ error: "Internal Server Error", details: e&.message,
|
|
696
|
+
class: e&.class&.to_s, backtrace: e&.backtrace&.first(15) }.to_json
|
|
697
|
+
else
|
|
698
|
+
{ error: "Internal Server Error", details: e&.message }.to_json
|
|
699
|
+
end
|
|
75
700
|
end
|
|
76
701
|
end
|
|
77
702
|
|
|
78
703
|
public
|
|
79
704
|
|
|
80
|
-
# Dynamically generate routing methods (get, post, put, delete).
|
|
81
|
-
%w[get post put delete].each do |verb|
|
|
705
|
+
# Dynamically generate routing methods (get, post, put, patch, delete).
|
|
706
|
+
%w[get post put patch delete].each do |verb|
|
|
82
707
|
define_method(verb) do |path, options = {}, &block|
|
|
83
708
|
register_route(verb, path, options, &block)
|
|
84
709
|
end
|
|
85
710
|
end
|
|
86
711
|
|
|
87
|
-
# Core routing logic: auth validation,
|
|
712
|
+
# Core routing logic: auth validation, body parsing, param merging, validation, execution.
|
|
88
713
|
def register_route(verb, path, options = {}, &block)
|
|
89
|
-
|
|
90
|
-
require_auth
|
|
91
|
-
required_params = options.fetch(:requires,
|
|
92
|
-
|
|
714
|
+
verb_up = verb.to_s.upcase
|
|
715
|
+
require_auth = options.fetch(:auth, true)
|
|
716
|
+
required_params = options.fetch(:requires, nil)
|
|
717
|
+
|
|
93
718
|
# Construct the full path with the optional prefix.
|
|
94
719
|
full_path = File.join('/', @prefix.to_s, path.to_s).gsub(%r{/+}, '/')
|
|
95
720
|
|
|
96
721
|
handler = proc do
|
|
97
|
-
|
|
98
722
|
# 1. Authentication check
|
|
723
|
+
jwt_user = nil
|
|
99
724
|
if require_auth
|
|
100
725
|
auth_header = request.env["HTTP_AUTHORIZATION"]
|
|
101
726
|
halt 401, { error: "Token required. Format: 'Bearer <token>'" }.to_json if auth_header.nil?
|
|
102
|
-
halt 403, { error: "Invalid token" }.to_json if auth_header.split(" ").last != settings.token
|
|
103
|
-
end
|
|
104
727
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
728
|
+
raw_token = auth_header.split(" ").last
|
|
729
|
+
|
|
730
|
+
if require_auth == :jwt || (require_auth == true && settings.jwt_secret && settings.token.nil?)
|
|
731
|
+
# JWT authentication mode
|
|
732
|
+
halt 500, { error: "Server error: jwt_secret is not configured" }.to_json unless settings.jwt_secret
|
|
110
733
|
begin
|
|
111
|
-
|
|
112
|
-
rescue
|
|
113
|
-
halt
|
|
734
|
+
jwt_user = GRApiManager::JWT.decode(raw_token, settings.jwt_secret)
|
|
735
|
+
rescue GRApiManager::JWT::DecodeError => e
|
|
736
|
+
halt 401, { error: "Invalid token: #{e.message}" }.to_json
|
|
737
|
+
end
|
|
738
|
+
else
|
|
739
|
+
# Static Bearer Token mode
|
|
740
|
+
if raw_token != settings.token
|
|
741
|
+
# Fallback: if jwt_secret is set, try JWT decoding
|
|
742
|
+
if settings.jwt_secret
|
|
743
|
+
begin
|
|
744
|
+
jwt_user = GRApiManager::JWT.decode(raw_token, settings.jwt_secret)
|
|
745
|
+
rescue GRApiManager::JWT::DecodeError
|
|
746
|
+
halt 403, { error: "Invalid token" }.to_json
|
|
747
|
+
end
|
|
748
|
+
else
|
|
749
|
+
halt 403, { error: "Invalid token" }.to_json
|
|
750
|
+
end
|
|
114
751
|
end
|
|
115
752
|
end
|
|
116
753
|
end
|
|
117
754
|
|
|
118
|
-
#
|
|
119
|
-
|
|
755
|
+
# 2. Body parsing — smart detection based on Content-Type
|
|
756
|
+
parsed_body = {}
|
|
757
|
+
if %w[POST PUT PATCH].include?(verb_up)
|
|
758
|
+
begin
|
|
759
|
+
parsed_body = GRApiManager::BodyParser.parse(request)
|
|
760
|
+
rescue ArgumentError => e
|
|
761
|
+
halt 400, { error: e.message }.to_json
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# 3. Merge query/path parameters with parsed body.
|
|
766
|
+
url_params = smart_parse(params.reject { |_, v| v.is_a?(Hash) && v.key?(:tempfile) })
|
|
767
|
+
all_params = url_params.merge(parsed_body)
|
|
120
768
|
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
769
|
+
# Inyect JWT payload if authenticated via JWT
|
|
770
|
+
if jwt_user
|
|
771
|
+
all_params[:current_user] = jwt_user
|
|
772
|
+
all_params[:jwt_payload] = jwt_user
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# 4. Declarative parameter validation (Array of keys or Hash schema)
|
|
776
|
+
if required_params.is_a?(Hash)
|
|
777
|
+
is_valid, errors = GRApiManager::Validator.validate(all_params, required_params)
|
|
778
|
+
unless is_valid
|
|
779
|
+
status 400
|
|
780
|
+
log_request(verb_up, full_path, 400)
|
|
781
|
+
next { error: "Validation failed", errors: errors }.to_json
|
|
782
|
+
end
|
|
783
|
+
elsif required_params.is_a?(Array) && required_params.any?
|
|
784
|
+
missing = required_params.select do |p|
|
|
785
|
+
val = all_params[p.to_sym]
|
|
786
|
+
val.nil? || (val.is_a?(String) && val.strip.empty?)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
if missing.any?
|
|
790
|
+
status 400
|
|
791
|
+
log_request(verb_up, full_path, 400)
|
|
792
|
+
next { error: "Missing required parameters", required: missing }.to_json
|
|
793
|
+
end
|
|
127
794
|
end
|
|
128
795
|
|
|
129
796
|
# 5. Execute user-defined block
|
|
130
797
|
result = instance_exec(all_params, &block)
|
|
131
|
-
log_request(
|
|
132
|
-
|
|
798
|
+
log_request(verb_up, full_path, response.status)
|
|
799
|
+
|
|
800
|
+
result.is_a?(String) ? result : result.to_json
|
|
133
801
|
end
|
|
134
802
|
|
|
135
803
|
@app_class.send(verb.downcase, full_path, &handler)
|
|
136
804
|
end
|
|
137
805
|
|
|
138
|
-
# Starts the Sinatra server
|
|
139
|
-
def run!
|
|
806
|
+
# Starts the Sinatra server.
|
|
807
|
+
def run!(workers: nil, threads: '2:8')
|
|
808
|
+
w = (workers || ENV.fetch('WEB_CONCURRENCY', 2)).to_i
|
|
809
|
+
min_t, max_t = threads.to_s.split(':').map(&:to_i)
|
|
810
|
+
max_t ||= min_t
|
|
811
|
+
mb = (@max_body_size.to_f / 1_048_576).round(1)
|
|
812
|
+
|
|
813
|
+
# Use Puma as the application server for concurrency.
|
|
814
|
+
@app_class.set :server, :puma
|
|
815
|
+
@app_class.set :server_settings, {
|
|
816
|
+
workers: w,
|
|
817
|
+
min_threads: min_t,
|
|
818
|
+
max_threads: max_t
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
# Background thread to purge stale rate-limit entries.
|
|
822
|
+
if @rate_limiter
|
|
823
|
+
rl = @rate_limiter
|
|
824
|
+
Thread.new do
|
|
825
|
+
loop do
|
|
826
|
+
sleep rl.window_seconds * 2
|
|
827
|
+
rl.cleanup!
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
rl_info = if @rate_limiter
|
|
833
|
+
"#{@rate_limiter.max_requests} req / #{@rate_limiter.window_seconds}s per IP"
|
|
834
|
+
else
|
|
835
|
+
'Disabled'
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
auth_info = []
|
|
839
|
+
auth_info << "Bearer Token" if @token
|
|
840
|
+
auth_info << "JWT (HS256)" if @jwt_secret
|
|
841
|
+
auth_display = auth_info.empty? ? "Public (no token)" : auth_info.join(' + ')
|
|
842
|
+
|
|
140
843
|
puts "============================================="
|
|
141
844
|
puts " GR API MANAGER STARTED"
|
|
142
|
-
puts " Port
|
|
143
|
-
puts " Auth
|
|
144
|
-
puts " Prefix
|
|
845
|
+
puts " Port : #{@port}"
|
|
846
|
+
puts " Auth : #{auth_display}"
|
|
847
|
+
puts " Prefix : #{@prefix.empty? ? '/' : @prefix}"
|
|
848
|
+
puts " Max Body : #{mb} MB"
|
|
849
|
+
puts " Workers : #{w} | Threads: #{min_t}:#{max_t}"
|
|
850
|
+
puts " Rate Limit: #{rl_info}"
|
|
851
|
+
puts " Dev Mode : #{@dev_mode ? 'ON ⚠️ (disable in production)' : 'Off'}"
|
|
145
852
|
puts "============================================="
|
|
146
853
|
@app_class.run!
|
|
147
854
|
end
|