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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -32
  3. data/lib/tina4/auth.rb +137 -27
  4. data/lib/tina4/auto_crud.rb +55 -3
  5. data/lib/tina4/cli.rb +228 -28
  6. data/lib/tina4/cors.rb +1 -1
  7. data/lib/tina4/database.rb +230 -26
  8. data/lib/tina4/database_result.rb +122 -8
  9. data/lib/tina4/dev_mailbox.rb +1 -1
  10. data/lib/tina4/env.rb +1 -1
  11. data/lib/tina4/frond.rb +314 -7
  12. data/lib/tina4/gallery/queue/meta.json +1 -1
  13. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
  14. data/lib/tina4/localization.rb +1 -1
  15. data/lib/tina4/messenger.rb +111 -33
  16. data/lib/tina4/middleware.rb +349 -1
  17. data/lib/tina4/migration.rb +132 -11
  18. data/lib/tina4/orm.rb +149 -18
  19. data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
  20. data/lib/tina4/public/js/tina4js.min.js +47 -0
  21. data/lib/tina4/query_builder.rb +374 -0
  22. data/lib/tina4/queue.rb +219 -61
  23. data/lib/tina4/queue_backends/lite_backend.rb +42 -7
  24. data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
  25. data/lib/tina4/rack_app.rb +200 -11
  26. data/lib/tina4/request.rb +14 -1
  27. data/lib/tina4/response.rb +26 -0
  28. data/lib/tina4/response_cache.rb +446 -29
  29. data/lib/tina4/router.rb +127 -0
  30. data/lib/tina4/service_runner.rb +1 -1
  31. data/lib/tina4/session.rb +6 -1
  32. data/lib/tina4/session_handlers/database_handler.rb +66 -0
  33. data/lib/tina4/swagger.rb +1 -1
  34. data/lib/tina4/templates/errors/404.twig +2 -2
  35. data/lib/tina4/templates/errors/500.twig +1 -1
  36. data/lib/tina4/validator.rb +174 -0
  37. data/lib/tina4/version.rb +1 -1
  38. data/lib/tina4/websocket.rb +23 -4
  39. data/lib/tina4/websocket_backplane.rb +118 -0
  40. data/lib/tina4.rb +126 -5
  41. metadata +40 -3
@@ -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
- result = route.handler.call(request, response)
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
- # Show landing page for GET "/" when no user route or template index exists
190
- if path == "/" && should_show_landing_page?
191
- return render_landing_page
192
- end
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
- window.location.href = tryUrl;
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["tina4.session"] || {}
68
+ @session ||= Tina4::Session.new(@env)
56
69
  end
57
70
 
58
71
  # Raw body string
@@ -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?