tina4ruby 3.2.1 → 3.9.2
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 +19 -20
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +75 -2
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +131 -28
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +148 -2
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +17 -8
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +128 -90
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +194 -18
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +64 -4
- metadata +12 -3
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -19,6 +19,9 @@ module Tina4
|
|
|
19
19
|
.select { |d| Dir.exist?(d) }
|
|
20
20
|
fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
|
|
21
21
|
@static_roots = (project_roots + fallback).freeze
|
|
22
|
+
|
|
23
|
+
# Shared WebSocket engine for route-based WS handling
|
|
24
|
+
@websocket_engine = Tina4::WebSocket.new
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def call(env)
|
|
@@ -29,6 +32,15 @@ module Tina4
|
|
|
29
32
|
# Fast-path: OPTIONS preflight
|
|
30
33
|
return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
|
|
31
34
|
|
|
35
|
+
# WebSocket upgrade — match against registered ws_routes
|
|
36
|
+
if websocket_upgrade?(env)
|
|
37
|
+
ws_result = Tina4::Router.find_ws_route(path)
|
|
38
|
+
if ws_result
|
|
39
|
+
ws_route, ws_params = ws_result
|
|
40
|
+
return handle_websocket_upgrade(env, ws_route, ws_params)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
32
44
|
# Dev dashboard routes (handled before anything else)
|
|
33
45
|
if path.start_with?("/__dev")
|
|
34
46
|
dev_response = Tina4::DevAdmin.handle_request(env)
|
|
@@ -88,6 +100,23 @@ module Tina4
|
|
|
88
100
|
end
|
|
89
101
|
end
|
|
90
102
|
|
|
103
|
+
# Save session and set cookie if session was used
|
|
104
|
+
if result && defined?(rack_response)
|
|
105
|
+
status, headers, body_parts = rack_response
|
|
106
|
+
request_obj = env["tina4.request"]
|
|
107
|
+
if request_obj&.instance_variable_get(:@session)
|
|
108
|
+
sess = request_obj.session
|
|
109
|
+
sess.save
|
|
110
|
+
sid = sess.id
|
|
111
|
+
cookie_val = (env["HTTP_COOKIE"] || "")[/tina4_session=([^;]+)/, 1]
|
|
112
|
+
if sid && sid != cookie_val
|
|
113
|
+
ttl = Integer(ENV.fetch("TINA4_SESSION_TTL", 3600))
|
|
114
|
+
headers["set-cookie"] = "tina4_session=#{sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{ttl}"
|
|
115
|
+
end
|
|
116
|
+
rack_response = [status, headers, body_parts]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
91
120
|
rack_response
|
|
92
121
|
rescue => e
|
|
93
122
|
handle_500(e, env)
|
|
@@ -96,15 +125,43 @@ module Tina4
|
|
|
96
125
|
private
|
|
97
126
|
|
|
98
127
|
def handle_route(env, route, path_params)
|
|
99
|
-
# Auth check
|
|
128
|
+
# Auth check (legacy per-route auth_handler)
|
|
100
129
|
if route.auth_handler
|
|
101
130
|
auth_result = route.auth_handler.call(env)
|
|
102
131
|
return handle_403(env["PATH_INFO"] || "/") unless auth_result
|
|
103
132
|
end
|
|
104
133
|
|
|
134
|
+
# Secure-by-default: enforce bearer-token auth on write routes
|
|
135
|
+
if route.auth_required
|
|
136
|
+
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
137
|
+
token = auth_header =~ /\ABearer\s+(.+)\z/i ? Regexp.last_match(1) : nil
|
|
138
|
+
|
|
139
|
+
# API_KEY bypass — matches tina4_python behavior
|
|
140
|
+
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
141
|
+
if api_key && !api_key.empty? && token == api_key
|
|
142
|
+
env["tina4.auth_payload"] = { "api_key" => true }
|
|
143
|
+
elsif token
|
|
144
|
+
payload = Tina4::Auth.valid_token(token)
|
|
145
|
+
unless payload
|
|
146
|
+
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
147
|
+
end
|
|
148
|
+
env["tina4.auth_payload"] = payload
|
|
149
|
+
else
|
|
150
|
+
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
105
154
|
request = Tina4::Request.new(env, path_params)
|
|
155
|
+
request.user = env["tina4.auth_payload"] if env["tina4.auth_payload"]
|
|
156
|
+
env["tina4.request"] = request # Store for session save after response
|
|
106
157
|
response = Tina4::Response.new
|
|
107
158
|
|
|
159
|
+
# Run global middleware (block-based + class-based before_* methods)
|
|
160
|
+
unless Tina4::Middleware.run_before(request, response)
|
|
161
|
+
# Middleware halted the request -- return whatever response was set
|
|
162
|
+
return response.to_rack
|
|
163
|
+
end
|
|
164
|
+
|
|
108
165
|
# Run per-route middleware
|
|
109
166
|
if route.respond_to?(:run_middleware)
|
|
110
167
|
unless route.run_middleware(request, response)
|
|
@@ -112,21 +169,19 @@ module Tina4
|
|
|
112
169
|
end
|
|
113
170
|
end
|
|
114
171
|
|
|
115
|
-
# Execute handler —
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
route.handler.call(request)
|
|
172
|
+
# Execute handler — inject path params by name, then request/response
|
|
173
|
+
handler_params = route.handler.parameters.map(&:last)
|
|
174
|
+
route_params = path_params || {}
|
|
175
|
+
args = handler_params.map do |name|
|
|
176
|
+
if route_params.key?(name)
|
|
177
|
+
route_params[name]
|
|
178
|
+
elsif name == :request || name == :req
|
|
179
|
+
request
|
|
124
180
|
else
|
|
125
|
-
|
|
181
|
+
response
|
|
126
182
|
end
|
|
127
|
-
else
|
|
128
|
-
route.handler.call(request, response)
|
|
129
183
|
end
|
|
184
|
+
result = args.empty? ? route.handler.call : route.handler.call(*args)
|
|
130
185
|
|
|
131
186
|
# Template rendering: when a template is set and the handler returned a Hash,
|
|
132
187
|
# render the template with the hash as data and return the HTML response.
|
|
@@ -138,6 +193,10 @@ module Tina4
|
|
|
138
193
|
|
|
139
194
|
# Skip auto_detect if handler already returned the response object
|
|
140
195
|
final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
|
|
196
|
+
|
|
197
|
+
# Run global after middleware (block-based + class-based after_* methods)
|
|
198
|
+
Tina4::Middleware.run_after(request, final_response)
|
|
199
|
+
|
|
141
200
|
final_response.to_rack
|
|
142
201
|
end
|
|
143
202
|
|
|
@@ -199,10 +258,12 @@ module Tina4
|
|
|
199
258
|
end
|
|
200
259
|
|
|
201
260
|
def handle_404(path)
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
261
|
+
# Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
|
|
262
|
+
template_response = try_serve_template(path)
|
|
263
|
+
return template_response if template_response
|
|
264
|
+
|
|
265
|
+
# Show landing page for GET "/"
|
|
266
|
+
return render_landing_page if path == "/"
|
|
206
267
|
|
|
207
268
|
Tina4::Log.warning("404 Not Found: #{path}")
|
|
208
269
|
body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
|
|
@@ -215,6 +276,62 @@ module Tina4
|
|
|
215
276
|
%w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
|
|
216
277
|
end
|
|
217
278
|
|
|
279
|
+
def try_serve_template(path)
|
|
280
|
+
tpl_file = resolve_template(path)
|
|
281
|
+
return nil unless tpl_file
|
|
282
|
+
|
|
283
|
+
templates_dir = File.join(@root_dir, "src", "templates")
|
|
284
|
+
body = Tina4::Template.render(tpl_file, {}) rescue File.read(File.join(templates_dir, tpl_file))
|
|
285
|
+
[200, { "content-type" => "text/html" }, [body]]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Resolve a URL path to a template file.
|
|
289
|
+
# Dev mode: checks filesystem every time for live changes.
|
|
290
|
+
# Production: uses a cached lookup built once at startup.
|
|
291
|
+
def resolve_template(path)
|
|
292
|
+
clean_path = path.sub(%r{^/}, "")
|
|
293
|
+
clean_path = "index" if clean_path.empty?
|
|
294
|
+
is_dev = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "false").downcase)
|
|
295
|
+
|
|
296
|
+
if is_dev
|
|
297
|
+
templates_dir = File.join(@root_dir, "src", "templates")
|
|
298
|
+
%w[.twig .html].each do |ext|
|
|
299
|
+
candidate = clean_path + ext
|
|
300
|
+
return candidate if File.file?(File.join(templates_dir, candidate))
|
|
301
|
+
end
|
|
302
|
+
return nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Production: cached lookup
|
|
306
|
+
@template_cache ||= build_template_cache
|
|
307
|
+
@template_cache[clean_path]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def build_template_cache
|
|
311
|
+
cache = {}
|
|
312
|
+
templates_dir = File.join(@root_dir, "src", "templates")
|
|
313
|
+
return cache unless File.directory?(templates_dir)
|
|
314
|
+
|
|
315
|
+
Dir.glob(File.join(templates_dir, "**", "*.{twig,html}")).each do |f|
|
|
316
|
+
rel = f.sub(templates_dir + File::SEPARATOR, "").tr("\\", "/")
|
|
317
|
+
url_path = rel.sub(/\.(twig|html)$/, "")
|
|
318
|
+
cache[url_path] ||= rel
|
|
319
|
+
end
|
|
320
|
+
cache
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def try_serve_index_template
|
|
324
|
+
templates_dir = File.join(@root_dir, "src", "templates")
|
|
325
|
+
%w[index.html index.twig index.erb].each do |f|
|
|
326
|
+
path = File.join(templates_dir, f)
|
|
327
|
+
if File.file?(path)
|
|
328
|
+
body = Tina4::Template.render(f, {}) rescue File.read(path)
|
|
329
|
+
return [200, { "content-type" => "text/html" }, [body]]
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
218
335
|
def render_landing_page
|
|
219
336
|
port = ENV["PORT"] || "7145"
|
|
220
337
|
|
|
@@ -363,7 +480,22 @@ module Tina4
|
|
|
363
480
|
btn.disabled = false;
|
|
364
481
|
btn.textContent = 'Deploy & Try';
|
|
365
482
|
} else {
|
|
366
|
-
|
|
483
|
+
// Wait for the newly deployed route to become reachable before navigating
|
|
484
|
+
var attempts = 0;
|
|
485
|
+
var maxAttempts = 5;
|
|
486
|
+
function pollRoute() {
|
|
487
|
+
fetch(tryUrl, {method: 'HEAD'}).then(function() {
|
|
488
|
+
window.location.href = tryUrl;
|
|
489
|
+
}).catch(function() {
|
|
490
|
+
attempts++;
|
|
491
|
+
if (attempts < maxAttempts) {
|
|
492
|
+
setTimeout(pollRoute, 500);
|
|
493
|
+
} else {
|
|
494
|
+
window.location.href = tryUrl;
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
setTimeout(pollRoute, 500);
|
|
367
499
|
}
|
|
368
500
|
}).catch(function(e) {
|
|
369
501
|
alert('Deploy error: ' + e.message);
|
|
@@ -420,6 +552,50 @@ module Tina4
|
|
|
420
552
|
Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
|
|
421
553
|
end
|
|
422
554
|
|
|
555
|
+
def websocket_upgrade?(env)
|
|
556
|
+
upgrade = env["HTTP_UPGRADE"] || ""
|
|
557
|
+
upgrade.downcase == "websocket"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def handle_websocket_upgrade(env, ws_route, ws_params)
|
|
561
|
+
# Rack hijack is required for WebSocket upgrades
|
|
562
|
+
unless env["rack.hijack"]
|
|
563
|
+
Tina4::Log.warning("WebSocket upgrade requested but rack.hijack not available")
|
|
564
|
+
return [426, { "content-type" => "text/plain" }, ["WebSocket upgrade requires rack.hijack support"]]
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
env["rack.hijack"].call
|
|
568
|
+
socket = env["rack.hijack_io"]
|
|
569
|
+
|
|
570
|
+
# Wire the route handler into the WebSocket engine events
|
|
571
|
+
handler = ws_route.handler
|
|
572
|
+
|
|
573
|
+
# Create a dedicated WebSocket engine for this route so handlers stay isolated
|
|
574
|
+
ws = Tina4::WebSocket.new
|
|
575
|
+
|
|
576
|
+
ws.on(:open) do |connection|
|
|
577
|
+
connection.params = ws_params
|
|
578
|
+
handler.call(connection, :open, nil)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
ws.on(:message) do |connection, data|
|
|
582
|
+
handler.call(connection, :message, data)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
ws.on(:close) do |connection|
|
|
586
|
+
handler.call(connection, :close, nil)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
ws.on(:error) do |connection, error|
|
|
590
|
+
Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
ws.handle_upgrade(env, socket)
|
|
594
|
+
|
|
595
|
+
# Return async response (-1 signals Rack the response is handled via hijack)
|
|
596
|
+
[-1, {}, []]
|
|
597
|
+
end
|
|
598
|
+
|
|
423
599
|
def inject_dev_overlay(body, request_info)
|
|
424
600
|
version = Tina4::VERSION
|
|
425
601
|
method = request_info[:method]
|
data/lib/tina4/request.rb
CHANGED
|
@@ -6,6 +6,12 @@ module Tina4
|
|
|
6
6
|
class Request
|
|
7
7
|
attr_reader :env, :method, :path, :query_string, :content_type,
|
|
8
8
|
:path_params, :ip
|
|
9
|
+
attr_accessor :user
|
|
10
|
+
|
|
11
|
+
# Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
|
|
12
|
+
TINA4_MAX_UPLOAD_SIZE = Integer(ENV.fetch("TINA4_MAX_UPLOAD_SIZE", 10_485_760))
|
|
13
|
+
|
|
14
|
+
class PayloadTooLarge < StandardError; end
|
|
9
15
|
|
|
10
16
|
def initialize(env, path_params = {})
|
|
11
17
|
@env = env
|
|
@@ -15,6 +21,13 @@ module Tina4
|
|
|
15
21
|
@content_type = env["CONTENT_TYPE"] || ""
|
|
16
22
|
@path_params = path_params
|
|
17
23
|
|
|
24
|
+
# Check upload size limit
|
|
25
|
+
content_length = (env["CONTENT_LENGTH"] || 0).to_i
|
|
26
|
+
if content_length > TINA4_MAX_UPLOAD_SIZE
|
|
27
|
+
raise PayloadTooLarge,
|
|
28
|
+
"Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{TINA4_MAX_UPLOAD_SIZE} bytes)"
|
|
29
|
+
end
|
|
30
|
+
|
|
18
31
|
# Client IP with X-Forwarded-For support
|
|
19
32
|
@ip = extract_client_ip
|
|
20
33
|
|
|
@@ -52,7 +65,7 @@ module Tina4
|
|
|
52
65
|
end
|
|
53
66
|
|
|
54
67
|
def session
|
|
55
|
-
@session ||= @env
|
|
68
|
+
@session ||= Tina4::Session.new(@env)
|
|
56
69
|
end
|
|
57
70
|
|
|
58
71
|
# Raw body string
|
data/lib/tina4/response.rb
CHANGED
|
@@ -110,6 +110,32 @@ module Tina4
|
|
|
110
110
|
self
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
+
# Standard error response envelope.
|
|
114
|
+
#
|
|
115
|
+
# Usage:
|
|
116
|
+
# response.error("VALIDATION_FAILED", "Email is required", 400)
|
|
117
|
+
#
|
|
118
|
+
def error(code, message, status_code = 400)
|
|
119
|
+
@status_code = status_code
|
|
120
|
+
@headers["content-type"] = JSON_CONTENT_TYPE
|
|
121
|
+
@body = JSON.generate({
|
|
122
|
+
error: true,
|
|
123
|
+
code: code,
|
|
124
|
+
message: message,
|
|
125
|
+
status: status_code
|
|
126
|
+
})
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Build a standard error envelope hash (class method).
|
|
131
|
+
#
|
|
132
|
+
# Usage:
|
|
133
|
+
# response.json(Tina4::Response.error_envelope("NOT_FOUND", "Resource not found", 404), status: 404)
|
|
134
|
+
#
|
|
135
|
+
def self.error_envelope(code, message, status = 400)
|
|
136
|
+
{ error: true, code: code, message: message, status: status }
|
|
137
|
+
end
|
|
138
|
+
|
|
113
139
|
# Chainable header setter
|
|
114
140
|
def header(name, value = nil)
|
|
115
141
|
if value.nil?
|
data/lib/tina4/router.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Tina4
|
|
|
4
4
|
class Route
|
|
5
5
|
attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
|
|
6
6
|
:path_regex, :param_names, :middleware, :template
|
|
7
|
+
attr_accessor :auth_required, :cached
|
|
7
8
|
|
|
8
9
|
def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
|
|
9
10
|
@method = method.to_s.upcase.freeze
|
|
@@ -13,11 +14,34 @@ module Tina4
|
|
|
13
14
|
@swagger_meta = swagger_meta
|
|
14
15
|
@middleware = middleware.freeze
|
|
15
16
|
@template = template&.freeze
|
|
17
|
+
@auth_required = %w[POST PUT PATCH DELETE].include?(@method)
|
|
18
|
+
@cached = false
|
|
16
19
|
@param_names = []
|
|
17
20
|
@path_regex = compile_pattern(@path)
|
|
18
21
|
@param_names.freeze
|
|
19
22
|
end
|
|
20
23
|
|
|
24
|
+
# Mark this route as requiring bearer-token authentication.
|
|
25
|
+
# Returns self for chaining: Router.get("/path") { ... }.secure
|
|
26
|
+
def secure
|
|
27
|
+
@auth_required = true
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Opt out of the secure-by-default auth on write routes.
|
|
32
|
+
# Returns self for chaining: Router.post("/login") { ... }.no_auth
|
|
33
|
+
def no_auth
|
|
34
|
+
@auth_required = false
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Mark this route as cacheable.
|
|
39
|
+
# Returns self for chaining: Router.get("/path") { ... }.cache
|
|
40
|
+
def cache
|
|
41
|
+
@cached = true
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
21
45
|
# Returns params hash if matched, false otherwise
|
|
22
46
|
def match?(request_path, request_method = nil)
|
|
23
47
|
return false if request_method && @method != "ANY" && @method != request_method.to_s.upcase
|
|
@@ -105,12 +129,98 @@ module Tina4
|
|
|
105
129
|
end
|
|
106
130
|
end
|
|
107
131
|
|
|
132
|
+
# A registered WebSocket route with path pattern matching (reuses Route's compile logic)
|
|
133
|
+
class WebSocketRoute
|
|
134
|
+
attr_reader :path, :handler, :path_regex, :param_names
|
|
135
|
+
|
|
136
|
+
def initialize(path, handler)
|
|
137
|
+
@path = normalize_path(path).freeze
|
|
138
|
+
@handler = handler
|
|
139
|
+
@param_names = []
|
|
140
|
+
@path_regex = compile_pattern(@path)
|
|
141
|
+
@param_names.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns params hash if matched, false otherwise
|
|
145
|
+
def match?(request_path)
|
|
146
|
+
match = @path_regex.match(request_path)
|
|
147
|
+
return false unless match
|
|
148
|
+
|
|
149
|
+
if @param_names.empty?
|
|
150
|
+
{}
|
|
151
|
+
else
|
|
152
|
+
params = {}
|
|
153
|
+
@param_names.each_with_index do |param_def, i|
|
|
154
|
+
raw_value = match[i + 1]
|
|
155
|
+
params[param_def[:name]] = raw_value
|
|
156
|
+
end
|
|
157
|
+
params
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def normalize_path(path)
|
|
164
|
+
p = path.to_s.gsub("\\", "/")
|
|
165
|
+
p = "/#{p}" unless p.start_with?("/")
|
|
166
|
+
p = p.chomp("/") unless p == "/"
|
|
167
|
+
p
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def compile_pattern(path)
|
|
171
|
+
return Regexp.new("\\A/\\z") if path == "/"
|
|
172
|
+
|
|
173
|
+
parts = path.split("/").reject(&:empty?)
|
|
174
|
+
regex_parts = parts.map do |part|
|
|
175
|
+
if part =~ /\A\{(\w+)\}\z/
|
|
176
|
+
name = Regexp.last_match(1)
|
|
177
|
+
@param_names << { name: name.to_sym }
|
|
178
|
+
'([^/]+)'
|
|
179
|
+
else
|
|
180
|
+
Regexp.escape(part)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
Regexp.new("\\A/#{regex_parts.join("/")}\\z")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
108
187
|
module Router
|
|
109
188
|
class << self
|
|
110
189
|
def routes
|
|
111
190
|
@routes ||= []
|
|
112
191
|
end
|
|
113
192
|
|
|
193
|
+
# Registered WebSocket routes
|
|
194
|
+
def ws_routes
|
|
195
|
+
@ws_routes ||= []
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Register a WebSocket route.
|
|
199
|
+
# The handler block receives (connection, event, data) where:
|
|
200
|
+
# connection — WebSocketConnection with #send, #broadcast, #close, #params
|
|
201
|
+
# event — :open, :message, or :close
|
|
202
|
+
# data — String payload for :message, nil for :open/:close
|
|
203
|
+
def websocket(path, &block)
|
|
204
|
+
ws_route = WebSocketRoute.new(path, block)
|
|
205
|
+
ws_routes << ws_route
|
|
206
|
+
Tina4::Log.debug("WebSocket route registered: #{path}")
|
|
207
|
+
ws_route
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Find a matching WebSocket route for a given path.
|
|
211
|
+
# Returns [ws_route, params] or nil.
|
|
212
|
+
def find_ws_route(path)
|
|
213
|
+
normalized = path.gsub("\\", "/")
|
|
214
|
+
normalized = "/#{normalized}" unless normalized.start_with?("/")
|
|
215
|
+
normalized = normalized.chomp("/") unless normalized == "/"
|
|
216
|
+
|
|
217
|
+
ws_routes.each do |ws_route|
|
|
218
|
+
params = ws_route.match?(normalized)
|
|
219
|
+
return [ws_route, params] if params
|
|
220
|
+
end
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
114
224
|
# Routes indexed by HTTP method for O(1) method lookup
|
|
115
225
|
def method_index
|
|
116
226
|
@method_index ||= Hash.new { |h, k| h[k] = [] }
|
|
@@ -169,9 +279,26 @@ module Tina4
|
|
|
169
279
|
nil
|
|
170
280
|
end
|
|
171
281
|
|
|
282
|
+
# Register a class-based middleware globally.
|
|
283
|
+
# The class should define static before_* and/or after_* methods.
|
|
284
|
+
# Example:
|
|
285
|
+
# class AuthMiddleware
|
|
286
|
+
# def self.before_auth(request, response)
|
|
287
|
+
# unless request.headers["authorization"]
|
|
288
|
+
# return [request, response.json({ error: "Unauthorized" }, 401)]
|
|
289
|
+
# end
|
|
290
|
+
# [request, response]
|
|
291
|
+
# end
|
|
292
|
+
# end
|
|
293
|
+
# Tina4::Router.use(AuthMiddleware)
|
|
294
|
+
def use(klass)
|
|
295
|
+
Tina4::Middleware.use(klass)
|
|
296
|
+
end
|
|
297
|
+
|
|
172
298
|
def clear!
|
|
173
299
|
@routes = []
|
|
174
300
|
@method_index = Hash.new { |h, k| h[k] = [] }
|
|
301
|
+
@ws_routes = []
|
|
175
302
|
end
|
|
176
303
|
|
|
177
304
|
def group(prefix, auth_handler: nil, middleware: [], &block)
|
data/lib/tina4/service_runner.rb
CHANGED
|
@@ -171,7 +171,7 @@ module Tina4
|
|
|
171
171
|
|
|
172
172
|
def run_loop(name, handler, options, ctx)
|
|
173
173
|
max_retries = options.fetch(:max_retries, 3)
|
|
174
|
-
sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] ||
|
|
174
|
+
sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 5).to_i.to_f
|
|
175
175
|
|
|
176
176
|
if options[:daemon]
|
|
177
177
|
run_daemon(name, handler, options, ctx, max_retries)
|
data/lib/tina4/session.rb
CHANGED
|
@@ -108,7 +108,8 @@ module Tina4
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def cookie_header
|
|
111
|
-
|
|
111
|
+
samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
|
|
112
|
+
"#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=#{samesite}; Max-Age=#{@options[:max_age]}"
|
|
112
113
|
end
|
|
113
114
|
|
|
114
115
|
private
|
|
@@ -135,6 +136,10 @@ module Tina4
|
|
|
135
136
|
Tina4::SessionHandlers::RedisHandler.new(@options[:handler_options])
|
|
136
137
|
when :mongo, :mongodb
|
|
137
138
|
Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
|
|
139
|
+
when :valkey
|
|
140
|
+
Tina4::SessionHandlers::ValkeyHandler.new(@options[:handler_options])
|
|
141
|
+
when :database, :db
|
|
142
|
+
Tina4::SessionHandlers::DatabaseHandler.new(@options[:handler_options])
|
|
138
143
|
else
|
|
139
144
|
Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
|
|
140
145
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module SessionHandlers
|
|
7
|
+
class DatabaseHandler
|
|
8
|
+
TABLE_NAME = "tina4_session"
|
|
9
|
+
|
|
10
|
+
CREATE_TABLE_SQL = <<~SQL
|
|
11
|
+
CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
|
|
12
|
+
session_id VARCHAR(255) PRIMARY KEY,
|
|
13
|
+
data TEXT NOT NULL,
|
|
14
|
+
expires_at REAL NOT NULL
|
|
15
|
+
)
|
|
16
|
+
SQL
|
|
17
|
+
|
|
18
|
+
def initialize(options = {})
|
|
19
|
+
@ttl = options[:ttl] || 86400
|
|
20
|
+
@db = options[:db] || Tina4::Database.new(ENV["DATABASE_URL"])
|
|
21
|
+
ensure_table
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read(session_id)
|
|
25
|
+
row = @db.fetch_one("SELECT data, expires_at FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
|
|
26
|
+
return nil unless row
|
|
27
|
+
|
|
28
|
+
expires_at = row["expires_at"].to_f
|
|
29
|
+
if expires_at > 0 && expires_at < Time.now.to_f
|
|
30
|
+
destroy(session_id)
|
|
31
|
+
return nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
JSON.parse(row["data"])
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write(session_id, data)
|
|
40
|
+
expires_at = @ttl > 0 ? Time.now.to_f + @ttl : 0.0
|
|
41
|
+
json_data = JSON.generate(data)
|
|
42
|
+
|
|
43
|
+
existing = @db.fetch_one("SELECT session_id FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
|
|
44
|
+
if existing
|
|
45
|
+
@db.execute("UPDATE #{TABLE_NAME} SET data = ?, expires_at = ? WHERE session_id = ?", [json_data, expires_at, session_id])
|
|
46
|
+
else
|
|
47
|
+
@db.execute("INSERT INTO #{TABLE_NAME} (session_id, data, expires_at) VALUES (?, ?, ?)", [session_id, json_data, expires_at])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def destroy(session_id)
|
|
52
|
+
@db.execute("DELETE FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cleanup
|
|
56
|
+
@db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def ensure_table
|
|
62
|
+
@db.execute(CREATE_TABLE_SQL)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Tina4
|
|
|
18
18
|
{
|
|
19
19
|
"openapi" => "3.0.3",
|
|
20
20
|
"info" => {
|
|
21
|
-
"title" => ENV["PROJECT_NAME"] || "Tina4
|
|
21
|
+
"title" => ENV["SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
|
|
22
22
|
"version" => ENV["VERSION"] || Tina4::VERSION,
|
|
23
23
|
"description" => "Auto-generated API documentation"
|
|
24
24
|
},
|