tina4ruby 3.0.0 → 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 +120 -32
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +228 -28
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +230 -26
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +314 -7
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +149 -18
- 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 +219 -61
- 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 +200 -11
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/response_cache.rb +446 -29
- 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/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +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 +126 -5
- metadata +40 -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,23 +100,68 @@ 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
|
-
handle_500(e)
|
|
122
|
+
handle_500(e, env)
|
|
94
123
|
end
|
|
95
124
|
|
|
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,8 +169,19 @@ module Tina4
|
|
|
112
169
|
end
|
|
113
170
|
end
|
|
114
171
|
|
|
115
|
-
# Execute handler
|
|
116
|
-
|
|
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
|
|
180
|
+
else
|
|
181
|
+
response
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
result = args.empty? ? route.handler.call : route.handler.call(*args)
|
|
117
185
|
|
|
118
186
|
# Template rendering: when a template is set and the handler returned a Hash,
|
|
119
187
|
# render the template with the hash as data and return the HTML response.
|
|
@@ -125,6 +193,10 @@ module Tina4
|
|
|
125
193
|
|
|
126
194
|
# Skip auto_detect if handler already returned the response object
|
|
127
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
|
+
|
|
128
200
|
final_response.to_rack
|
|
129
201
|
end
|
|
130
202
|
|
|
@@ -186,10 +258,12 @@ module Tina4
|
|
|
186
258
|
end
|
|
187
259
|
|
|
188
260
|
def handle_404(path)
|
|
189
|
-
#
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 == "/"
|
|
193
267
|
|
|
194
268
|
Tina4::Log.warning("404 Not Found: #{path}")
|
|
195
269
|
body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
|
|
@@ -202,6 +276,62 @@ module Tina4
|
|
|
202
276
|
%w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
|
|
203
277
|
end
|
|
204
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
|
+
|
|
205
335
|
def render_landing_page
|
|
206
336
|
port = ENV["PORT"] || "7145"
|
|
207
337
|
|
|
@@ -350,7 +480,22 @@ module Tina4
|
|
|
350
480
|
btn.disabled = false;
|
|
351
481
|
btn.textContent = 'Deploy & Try';
|
|
352
482
|
} else {
|
|
353
|
-
|
|
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);
|
|
354
499
|
}
|
|
355
500
|
}).catch(function(e) {
|
|
356
501
|
alert('Deploy error: ' + e.message);
|
|
@@ -388,12 +533,12 @@ module Tina4
|
|
|
388
533
|
[200, { "content-type" => "text/html; charset=utf-8" }, [html]]
|
|
389
534
|
end
|
|
390
535
|
|
|
391
|
-
def handle_500(error)
|
|
536
|
+
def handle_500(error, env = nil)
|
|
392
537
|
Tina4::Log.error("500 Internal Server Error: #{error.message}")
|
|
393
538
|
Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
|
|
394
539
|
if dev_mode?
|
|
395
540
|
# Rich error overlay with stack trace, source context, and line numbers
|
|
396
|
-
body = Tina4::ErrorOverlay.render(error)
|
|
541
|
+
body = Tina4::ErrorOverlay.render(error, request: env)
|
|
397
542
|
else
|
|
398
543
|
body = Tina4::Template.render_error(500, {
|
|
399
544
|
"error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
|
|
@@ -407,6 +552,50 @@ module Tina4
|
|
|
407
552
|
Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
|
|
408
553
|
end
|
|
409
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
|
+
|
|
410
599
|
def inject_dev_overlay(body, request_info)
|
|
411
600
|
version = Tina4::VERSION
|
|
412
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?
|