tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -1,817 +1,817 @@
1
- # frozen_string_literal: true
2
- require "json"
3
- require "securerandom"
4
- require "uri"
5
-
6
- module Tina4
7
- # Middleware wrapper that tags requests arriving on the AI dev port.
8
- # Suppresses live-reload behaviour so AI tools get stable responses.
9
- class AiPortRackApp
10
- def initialize(app)
11
- @app = app
12
- end
13
-
14
- def call(env)
15
- env["tina4.ai_port"] = true
16
- @app.call(env)
17
- end
18
- end
19
-
20
- class RackApp
21
- STATIC_DIRS = %w[public src/public src/assets assets].freeze
22
-
23
- # CORS is now handled by Tina4::CorsMiddleware
24
-
25
- # Framework's own public directory (bundled static assets like the logo)
26
- FRAMEWORK_PUBLIC_DIR = File.expand_path("public", __dir__).freeze
27
-
28
- def initialize(root_dir: Dir.pwd)
29
- @root_dir = root_dir
30
- # Pre-compute static roots at boot (not per-request)
31
- # Project dirs are checked first; framework's bundled public dir is the fallback
32
- project_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
33
- .select { |d| Dir.exist?(d) }
34
- fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
35
- @static_roots = (project_roots + fallback).freeze
36
-
37
- # Shared WebSocket engine for route-based WS handling
38
- @websocket_engine = Tina4::WebSocket.new
39
- end
40
-
41
- def call(env)
42
- method = env["REQUEST_METHOD"]
43
- path = env["PATH_INFO"] || "/"
44
- request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
-
46
- # Fast-path: OPTIONS preflight
47
- return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
48
-
49
- # WebSocket upgrade — match against registered ws_routes
50
- if websocket_upgrade?(env)
51
- ws_result = Tina4::Router.find_ws_route(path)
52
- if ws_result
53
- ws_route, ws_params = ws_result
54
- return handle_websocket_upgrade(env, ws_route, ws_params)
55
- end
56
- end
57
-
58
- # Dev dashboard routes (handled before anything else)
59
- if path.start_with?("/__dev")
60
- # Block live-reload endpoint on the AI port — AI tools must get stable responses
61
- if path == "/__dev_reload" && env["tina4.ai_port"]
62
- return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
63
- end
64
- dev_response = Tina4::DevAdmin.handle_request(env)
65
- return dev_response if dev_response
66
- end
67
-
68
- # Fast-path: API routes skip static file + swagger checks entirely
69
- unless path.start_with?("/api/")
70
- # Swagger
71
- if path == "/swagger" || path == "/swagger/"
72
- return serve_swagger_ui
73
- end
74
- if path == "/swagger/openapi.json"
75
- return serve_openapi_json
76
- end
77
-
78
- # Static files (only for non-API paths)
79
- static_response = try_static(path)
80
- return static_response if static_response
81
- end
82
-
83
- # Route matching
84
- result = Tina4::Router.match(method, path)
85
- if result
86
- route, path_params = result
87
- rack_response = handle_route(env, route, path_params)
88
- matched_pattern = route.path
89
- else
90
- rack_response = handle_404(path)
91
- matched_pattern = nil
92
- end
93
-
94
- # Capture request for dev inspector
95
- if dev_mode? && !path.start_with?("/__dev")
96
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
97
- Tina4::DevAdmin.request_inspector.capture(
98
- method: method,
99
- path: path,
100
- status: rack_response[0],
101
- duration: duration_ms
102
- )
103
- end
104
-
105
- # Inject dev overlay button for HTML responses in dev mode
106
- if dev_mode? && !path.start_with?("/__dev")
107
- status, headers, body_parts = rack_response
108
- content_type = headers["content-type"] || ""
109
- if content_type.include?("text/html")
110
- request_info = {
111
- method: method,
112
- path: path,
113
- matched_pattern: matched_pattern || "(no match)",
114
- }
115
- joined = body_parts.join
116
- overlay = inject_dev_overlay(joined, request_info, ai_port: env["tina4.ai_port"])
117
- rack_response = [status, headers, [overlay]]
118
- end
119
- end
120
-
121
- # Save session and set cookie if session was used
122
- if result && defined?(rack_response)
123
- status, headers, body_parts = rack_response
124
- request_obj = env["tina4.request"]
125
- if request_obj&.instance_variable_get(:@session)
126
- sess = request_obj.session
127
- sess.save
128
-
129
- # Probabilistic garbage collection (~1% of requests)
130
- if rand(1..100) == 1
131
- begin
132
- sess.gc
133
- rescue StandardError
134
- # GC failure is non-critical — silently ignore
135
- end
136
- end
137
-
138
- sid = sess.id
139
- cookie_val = (env["HTTP_COOKIE"] || "")[/tina4_session=([^;]+)/, 1]
140
- if sid && sid != cookie_val
141
- ttl = Integer(ENV.fetch("TINA4_SESSION_TTL", 3600))
142
- headers["set-cookie"] = "tina4_session=#{sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{ttl}"
143
- end
144
- rack_response = [status, headers, body_parts]
145
- end
146
- end
147
-
148
- rack_response
149
- rescue => e
150
- handle_500(e, env)
151
- end
152
-
153
- # Dispatch a pre-built Request through the Rack app and return the Rack response triple.
154
- # Useful for testing and embedding without starting an HTTP server.
155
- def handle(request)
156
- env = request.env
157
- env["rack.input"].rewind if env["rack.input"].respond_to?(:rewind)
158
- call(env)
159
- end
160
-
161
- private
162
-
163
- def handle_route(env, route, path_params)
164
- # Auth check (legacy per-route auth_handler)
165
- if route.auth_handler
166
- auth_result = route.auth_handler.call(env)
167
- return handle_403(env["PATH_INFO"] || "/") unless auth_result
168
- end
169
-
170
- # Secure-by-default: enforce bearer-token auth on write routes
171
- if route.auth_required
172
- token = nil
173
- token_source = nil # :header, :body, :session
174
-
175
- # Priority 1: Authorization Bearer header
176
- auth_header = env["HTTP_AUTHORIZATION"] || ""
177
- if auth_header =~ /\ABearer\s+(.+)\z/i
178
- token = Regexp.last_match(1)
179
- token_source = :header
180
- end
181
-
182
- # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
183
- if token.nil?
184
- body_str = _read_rack_body(env)
185
- form_token = _extract_form_token(body_str, env)
186
- if form_token && !form_token.empty?
187
- token = form_token
188
- token_source = :body
189
- end
190
- end
191
-
192
- # Priority 3: Session token (for secured GET routes after login)
193
- if token.nil?
194
- session = Tina4::Session.new(env)
195
- session_token = session.get("token")
196
- if session_token && !session_token.empty?
197
- token = session_token
198
- token_source = :session
199
- end
200
- end
201
-
202
- # API_KEY bypass — matches tina4_python behavior
203
- api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
204
- if api_key && !api_key.empty? && token == api_key
205
- env["tina4.auth_payload"] = { "api_key" => true }
206
- elsif token
207
- unless Tina4::Auth.valid_token(token)
208
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
209
- end
210
- env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
211
-
212
- # When body formToken validates, store a refreshed token for the FreshToken response header
213
- if token_source == :body
214
- env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token)
215
- end
216
- else
217
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
218
- end
219
- end
220
-
221
- request = Tina4::Request.new(env, path_params)
222
- request.user = env["tina4.auth_payload"] if env["tina4.auth_payload"]
223
- env["tina4.request"] = request # Store for session save after response
224
- response = Tina4::Response.new
225
-
226
- # Run global middleware (block-based + class-based before_* methods)
227
- unless Tina4::Middleware.run_before(Tina4::Middleware.global_middleware, request, response)
228
- # Middleware halted the request -- return whatever response was set
229
- return response.to_rack
230
- end
231
-
232
- # Run per-route middleware
233
- if route.respond_to?(:run_middleware)
234
- unless route.run_middleware(request, response)
235
- return [403, { "content-type" => "text/html" }, ["403 Forbidden"]]
236
- end
237
- end
238
-
239
- # Execute handler — inject path params by name, then request/response
240
- handler_params = route.handler.parameters.map(&:last)
241
- route_params = path_params || {}
242
- args = handler_params.map do |name|
243
- if route_params.key?(name)
244
- route_params[name]
245
- elsif name == :request || name == :req
246
- request
247
- else
248
- response
249
- end
250
- end
251
- result = args.empty? ? route.handler.call : route.handler.call(*args)
252
-
253
- # Template rendering: when a template is set and the handler returned a Hash,
254
- # render the template with the hash as data and return the HTML response.
255
- if route.template && result.is_a?(Hash)
256
- html = Tina4::Template.render(route.template, result)
257
- response.html(html)
258
- return response.to_rack
259
- end
260
-
261
- # Skip auto_detect if handler already returned the response object
262
- final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
263
-
264
- # Run global after middleware (block-based + class-based after_* methods)
265
- Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, final_response)
266
-
267
- # Inject FreshToken header when body formToken was used for auth
268
- if env["tina4.fresh_token"]
269
- final_response.add_header("FreshToken", env["tina4.fresh_token"])
270
- end
271
-
272
- final_response.to_rack
273
- end
274
-
275
- def try_static(path)
276
- return nil if path.include?("..")
277
-
278
- @static_roots.each do |root|
279
- full_path = File.join(root, path)
280
- if File.file?(full_path)
281
- return serve_static_file(full_path)
282
- end
283
-
284
- # Only try index.html for directory-like paths
285
- if path.end_with?("/") || !path.include?(".")
286
- index_path = File.join(full_path, "index.html")
287
- if File.file?(index_path)
288
- return serve_static_file(index_path)
289
- end
290
- end
291
- end
292
- nil
293
- end
294
-
295
- def serve_static_file(full_path)
296
- ext = File.extname(full_path).downcase
297
- content_type = Tina4::Response::MIME_TYPES[ext] || "application/octet-stream"
298
- [200, { "content-type" => content_type }, [File.binread(full_path)]]
299
- end
300
-
301
- def serve_swagger_ui
302
- html = <<~HTML
303
- <!DOCTYPE html>
304
- <html lang="en">
305
- <head>
306
- <meta charset="UTF-8">
307
- <title>API Documentation</title>
308
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
309
- </head>
310
- <body>
311
- <div id="swagger-ui"></div>
312
- <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
313
- <script>
314
- SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
315
- </script>
316
- </body>
317
- </html>
318
- HTML
319
- [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
320
- end
321
-
322
- def serve_openapi_json
323
- @openapi_json ||= JSON.generate(Tina4::Swagger.generate)
324
- [200, { "content-type" => "application/json; charset=utf-8" }, [@openapi_json]]
325
- end
326
-
327
- def handle_403(path = "")
328
- body = Tina4::Template.render_error(403, { "path" => path }) rescue "403 Forbidden"
329
- [403, { "content-type" => "text/html" }, [body]]
330
- end
331
-
332
- def handle_404(path)
333
- # Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
334
- template_response = try_serve_template(path)
335
- return template_response if template_response
336
-
337
- # Show landing page for GET "/"
338
- return render_landing_page if path == "/"
339
-
340
- Tina4::Log.warning("404 Not Found: #{path}")
341
- body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
342
- [404, { "content-type" => "text/html" }, [body]]
343
- end
344
-
345
- def should_show_landing_page?
346
- # Check if any index template exists in src/templates/
347
- templates_dir = File.join(@root_dir, "src", "templates")
348
- %w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
349
- end
350
-
351
- def try_serve_template(path)
352
- tpl_file = resolve_template(path)
353
- return nil unless tpl_file
354
-
355
- templates_dir = File.join(@root_dir, "src", "templates")
356
- body = Tina4::Template.render(tpl_file, {}) rescue File.read(File.join(templates_dir, tpl_file))
357
- [200, { "content-type" => "text/html" }, [body]]
358
- end
359
-
360
- # Resolve a URL path to a template file.
361
- # Dev mode: checks filesystem every time for live changes.
362
- # Production: uses a cached lookup built once at startup.
363
- def resolve_template(path)
364
- clean_path = path.sub(%r{^/}, "")
365
- clean_path = "index" if clean_path.empty?
366
- is_dev = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "false").downcase)
367
-
368
- if is_dev
369
- templates_dir = File.join(@root_dir, "src", "templates")
370
- %w[.twig .html].each do |ext|
371
- candidate = clean_path + ext
372
- return candidate if File.file?(File.join(templates_dir, candidate))
373
- end
374
- return nil
375
- end
376
-
377
- # Production: cached lookup
378
- @template_cache ||= build_template_cache
379
- @template_cache[clean_path]
380
- end
381
-
382
- def build_template_cache
383
- cache = {}
384
- templates_dir = File.join(@root_dir, "src", "templates")
385
- return cache unless File.directory?(templates_dir)
386
-
387
- Dir.glob(File.join(templates_dir, "**", "*.{twig,html}")).each do |f|
388
- rel = f.sub(templates_dir + File::SEPARATOR, "").tr("\\", "/")
389
- url_path = rel.sub(/\.(twig|html)$/, "")
390
- cache[url_path] ||= rel
391
- end
392
- cache
393
- end
394
-
395
- def try_serve_index_template
396
- templates_dir = File.join(@root_dir, "src", "templates")
397
- %w[index.html index.twig index.erb].each do |f|
398
- path = File.join(templates_dir, f)
399
- if File.file?(path)
400
- body = Tina4::Template.render(f, {}) rescue File.read(path)
401
- return [200, { "content-type" => "text/html" }, [body]]
402
- end
403
- end
404
- nil
405
- end
406
-
407
- def render_landing_page
408
- port = ENV["PORT"] || "7145"
409
-
410
- # Check deployed state for each gallery item
411
- project_src = File.join(@root_dir, "src")
412
- gallery_items = [
413
- { id: "rest-api", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", icon: "&#128640;", accent: "red", try_url: "/api/gallery/hello", file_check: "routes/api/gallery_hello.rb" },
414
- { id: "orm", name: "ORM", desc: "Product model with CRUD endpoints", icon: "&#128451;", accent: "green", try_url: "/api/gallery/products", file_check: "routes/api/gallery_products.rb" },
415
- { id: "auth", name: "Auth", desc: "JWT login form with token display", icon: "&#128274;", accent: "purple", try_url: "/gallery/auth", file_check: "routes/api/gallery_auth.rb" },
416
- { id: "queue", name: "Queue", desc: "Background job producer and consumer", icon: "&#9889;", accent: "red", try_url: "/api/gallery/queue/produce", file_check: "routes/api/gallery_queue.rb" },
417
- { id: "templates", name: "Templates", desc: "Twig template with dynamic data", icon: "&#128196;", accent: "green", try_url: "/gallery/page", file_check: "routes/gallery_page.rb" },
418
- { id: "database", name: "Database", desc: "Raw SQL queries with the Database class", icon: "&#128225;", accent: "purple", try_url: "/api/gallery/db/tables", file_check: "routes/api/gallery_db.rb" },
419
- { id: "error-overlay", name: "Error Overlay", desc: "See the rich debug error page with stack trace", icon: "&#128165;", accent: "red", try_url: "/api/gallery/crash", file_check: "routes/api/gallery_crash.rb" }
420
- ]
421
-
422
- gallery_cards = gallery_items.map do |item|
423
- deployed = File.file?(File.join(project_src, item[:file_check]))
424
- deployed_badge = deployed ? '<span style="position:absolute;top:0.75rem;right:0.75rem;background:#22c55e;color:#fff;font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:0.25rem;font-weight:600;">DEPLOYED</span>' : ''
425
- try_btn = if deployed
426
- %(<a href="#{item[:try_url]}" class="gbtn gbtn-try" target="_blank">Try It</a>)
427
- else
428
- %(<button class="gbtn gbtn-deploy" onclick="deployGallery('#{item[:id]}','#{item[:try_url]}')">Deploy &amp; Try</button>)
429
- end
430
- view_btn = %(<button class="gbtn gbtn-view" onclick="viewGallery('#{item[:id]}')">View</button>)
431
-
432
- <<~CARD
433
- <div class="gallery-card">
434
- <div class="accent accent-#{item[:accent]}"></div>
435
- #{deployed_badge}
436
- <div class="icon">#{item[:icon]}</div>
437
- <h3>#{item[:name]}</h3>
438
- <p>#{item[:desc]}</p>
439
- <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">#{try_btn}#{view_btn}</div>
440
- </div>
441
- CARD
442
- end.join
443
-
444
- html = <<~HTML
445
- <!DOCTYPE html>
446
- <html lang="en">
447
- <head>
448
- <meta charset="utf-8">
449
- <meta name="viewport" content="width=device-width, initial-scale=1">
450
- <title>Tina4Ruby</title>
451
- <style>
452
- *{margin:0;padding:0;box-sizing:border-box}
453
- body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
454
- .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
455
- .hero{text-align:center;z-index:1;padding:3rem 2rem 2rem}
456
- .logo{width:120px;height:120px;margin-bottom:1.5rem}
457
- h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
458
- .tagline{color:#64748b;font-size:1.1rem;margin-bottom:2rem}
459
- .actions{display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap;margin-bottom:2.5rem}
460
- .btn{padding:0.6rem 1.5rem;border-radius:0.5rem;font-size:0.9rem;font-weight:600;cursor:pointer;text-decoration:none;transition:all 0.15s;border:1px solid #334155;color:#94a3b8;background:transparent;min-width:140px;text-align:center;display:inline-block}
461
- .btn:hover{border-color:#64748b;color:#e2e8f0}
462
- .status{display:flex;gap:2rem;justify-content:center;align-items:center;color:#64748b;font-size:0.85rem;margin-bottom:1.5rem}
463
- .status .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;margin-right:0.4rem}
464
- .footer{color:#334155;font-size:0.8rem;letter-spacing:0.5px}
465
- .section{z-index:1;width:100%;max-width:800px;padding:0 2rem;margin-bottom:2.5rem}
466
- .card{background:#1e293b;border-radius:0.75rem;padding:2rem;border:1px solid #334155}
467
- .card h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0}
468
- .code-block{background:#0f172a;border-radius:0.5rem;padding:1.25rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:0.85rem;line-height:1.6;color:#4ade80;border:1px solid #1e293b}
469
- .gallery{z-index:1;width:100%;max-width:900px;padding:0 2rem;margin-bottom:3rem}
470
- .gallery h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0;text-align:center}
471
- .gallery-card{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}
472
- .gallery-card .accent{position:absolute;top:0;left:0;right:0;height:3px}
473
- .gallery-card .accent-red{background:#CC342D}
474
- .gallery-card .accent-green{background:#22c55e}
475
- .gallery-card .accent-purple{background:#a78bfa}
476
- .gallery-card .icon{font-size:1.5rem;margin-bottom:0.75rem}
477
- .gallery-card h3{font-size:1rem;font-weight:600;margin-bottom:0.5rem;color:#e2e8f0}
478
- .gallery-card p{font-size:0.85rem;color:#94a3b8;line-height:1.5}
479
- .gbtn{padding:0.35rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;font-weight:600;cursor:pointer;text-decoration:none;border:none;transition:all 0.15s}
480
- .gbtn-try{background:#22c55e;color:#fff}
481
- .gbtn-try:hover{background:#16a34a}
482
- .gbtn-deploy{background:#CC342D;color:#fff}
483
- .gbtn-deploy:hover{background:#a12a24}
484
- .gbtn-view{background:transparent;color:#94a3b8;border:1px solid #334155}
485
- .gbtn-view:hover{border-color:#64748b;color:#e2e8f0}
486
- .view-modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:10000;align-items:center;justify-content:center}
487
- .view-modal.active{display:flex}
488
- .view-modal-content{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:2rem;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;position:relative}
489
- .view-modal-close{position:absolute;top:0.75rem;right:1rem;color:#94a3b8;cursor:pointer;font-size:1.25rem;background:none;border:none}
490
- .view-modal-close:hover{color:#e2e8f0}
491
- @keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
492
- .star-wiggle{display:inline-block;transform-origin:center}
493
- </style>
494
- </head>
495
- <body>
496
- <img src="/images/tina4-logo-icon.webp" class="bg-watermark" alt="">
497
- <div class="hero">
498
- <img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
499
- <h1>Tina4Ruby</h1>
500
- <p class="tagline">The Intelligent Native Application 4ramework</p>
501
- <p class="tagline" style="font-size:0.95rem;margin-top:-1rem">Simple. Fast. Human. &nbsp;|&nbsp; Built for AI. Built for you.</p>
502
- <div class="actions">
503
- <a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
504
- <a href="/__dev" class="btn">Dev Admin</a>
505
- <a href="#gallery" class="btn">Gallery</a>
506
- <a href="https://github.com/tina4stack/tina4-ruby" class="btn" target="_blank">GitHub</a>
507
- <a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank"><span class="star-wiggle">&#9734;</span> Star</a>
508
- </div>
509
- <div class="status">
510
- <span><span class="dot"></span>Server running</span>
511
- <span>Port #{port}</span>
512
- <span>v#{Tina4::VERSION}</span>
513
- </div>
514
- <p class="footer">Zero dependencies &middot; Convention over configuration</p>
515
- </div>
516
- <div class="section">
517
- <div class="card">
518
- <h2>Getting Started</h2>
519
- <pre class="code-block"><code><span style="color:#64748b"># app.rb</span>
520
- <span style="color:#c084fc">require</span> <span style="color:#4ade80">"tina4"</span>
521
-
522
- Tina4::Router.<span style="color:#38bdf8">get</span>(<span style="color:#4ade80">"/hello"</span>) <span style="color:#c084fc">do</span> |request, response|
523
- response.<span style="color:#38bdf8">json</span>({ <span style="color:#fbbf24">message:</span> <span style="color:#4ade80">"Hello World!"</span> })
524
- <span style="color:#c084fc">end</span>
525
-
526
- Tina4::WebServer.new(<span style="color:#fbbf24">port:</span> <span style="color:#38bdf8">7145</span>).start <span style="color:#64748b"># starts on port 7145</span></code></pre>
527
- </div>
528
- </div>
529
- <div class="gallery">
530
- <h2 id="gallery">Gallery</h2>
531
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
532
- #{gallery_cards}
533
- </div>
534
- </div>
535
- <div class="view-modal" id="viewModal">
536
- <div class="view-modal-content">
537
- <button class="view-modal-close" onclick="document.getElementById('viewModal').classList.remove('active')">&times;</button>
538
- <h3 id="viewModalTitle" style="margin-bottom:1rem;color:#e2e8f0;"></h3>
539
- <div id="viewModalBody"></div>
540
- </div>
541
- </div>
542
- <script>
543
- function deployGallery(name, tryUrl) {
544
- if (!confirm('Deploy the "' + name + '" gallery example into your project?')) return;
545
- var btn = event.target;
546
- btn.disabled = true;
547
- btn.textContent = 'Deploying...';
548
- fetch('/__dev/api/gallery/deploy', {
549
- method: 'POST',
550
- headers: {'Content-Type': 'application/json'},
551
- body: JSON.stringify({ name: name })
552
- }).then(function(r) { return r.json(); }).then(function(d) {
553
- if (d.error) {
554
- alert('Deploy failed: ' + d.error);
555
- btn.disabled = false;
556
- btn.textContent = 'Deploy & Try';
557
- } else {
558
- // Wait for the newly deployed route to become reachable before navigating
559
- var attempts = 0;
560
- var maxAttempts = 5;
561
- function pollRoute() {
562
- fetch(tryUrl, {method: 'HEAD'}).then(function() {
563
- window.open(tryUrl, '_blank');
564
- }).catch(function() {
565
- attempts++;
566
- if (attempts < maxAttempts) {
567
- setTimeout(pollRoute, 500);
568
- } else {
569
- window.open(tryUrl, '_blank');
570
- }
571
- });
572
- }
573
- setTimeout(pollRoute, 500);
574
- }
575
- }).catch(function(e) {
576
- alert('Deploy error: ' + e.message);
577
- btn.disabled = false;
578
- btn.textContent = 'Deploy & Try';
579
- });
580
- }
581
- function viewGallery(name) {
582
- fetch('/__dev/api/gallery').then(function(r) { return r.json(); }).then(function(d) {
583
- var item = (d.gallery || []).find(function(g) { return g.id === name; });
584
- if (!item) { alert('Gallery item not found'); return; }
585
- var title = document.getElementById('viewModalTitle');
586
- var body = document.getElementById('viewModalBody');
587
- title.textContent = item.name + ' — ' + item.description;
588
- var html = '<p style="color:#94a3b8;margin-bottom:1rem;">Files that will be deployed:</p><ul style="list-style:none;padding:0;">';
589
- (item.files || []).forEach(function(f) {
590
- html += '<li style="padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem;">src/' + f + '</li>';
591
- });
592
- html += '</ul>';
593
- if (item.try_url) {
594
- html += '<p style="color:#94a3b8;margin-top:1rem;">Try URL: <code style="color:#38bdf8;">' + item.try_url + '</code></p>';
595
- }
596
- body.innerHTML = html;
597
- document.getElementById('viewModal').classList.add('active');
598
- });
599
- }
600
- document.getElementById('viewModal').addEventListener('click', function(e) {
601
- if (e.target === this) this.classList.remove('active');
602
- });
603
- (function(){
604
- var star=document.querySelector('.star-wiggle');
605
- if(!star)return;
606
- function doWiggle(){
607
- star.style.animation='wiggle 1.2s ease-in-out';
608
- star.addEventListener('animationend',function onEnd(){
609
- star.removeEventListener('animationend',onEnd);
610
- star.style.animation='none';
611
- var delay=3000+Math.random()*15000;
612
- setTimeout(doWiggle,delay);
613
- });
614
- }
615
- setTimeout(doWiggle,3000);
616
- })();
617
- </script>
618
- </body>
619
- </html>
620
- HTML
621
-
622
- [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
623
- end
624
-
625
- def handle_500(error, env = nil)
626
- Tina4::Log.error("500 Internal Server Error: #{error.message}")
627
- Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
628
- if dev_mode?
629
- # Rich error overlay with stack trace, source context, and line numbers
630
- body = Tina4::ErrorOverlay.render_error_overlay(error, request: env)
631
- else
632
- body = Tina4::Template.render_error(500, {
633
- "error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
634
- "request_id" => SecureRandom.hex(6)
635
- }) rescue "500 Internal Server Error: #{error.message}"
636
- end
637
- [500, { "content-type" => "text/html" }, [body]]
638
- end
639
-
640
- def dev_mode?
641
- Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
642
- end
643
-
644
- def websocket_upgrade?(env)
645
- upgrade = env["HTTP_UPGRADE"] || ""
646
- upgrade.downcase == "websocket"
647
- end
648
-
649
- def handle_websocket_upgrade(env, ws_route, ws_params)
650
- # Rack hijack is required for WebSocket upgrades
651
- unless env["rack.hijack"]
652
- Tina4::Log.warning("WebSocket upgrade requested but rack.hijack not available")
653
- return [426, { "content-type" => "text/plain" }, ["WebSocket upgrade requires rack.hijack support"]]
654
- end
655
-
656
- env["rack.hijack"].call
657
- socket = env["rack.hijack_io"]
658
-
659
- # Wire the route handler into the WebSocket engine events
660
- handler = ws_route.handler
661
-
662
- # Create a dedicated WebSocket engine for this route so handlers stay isolated
663
- ws = Tina4::WebSocket.new
664
-
665
- ws.on(:open) do |connection|
666
- connection.params = ws_params
667
- handler.call(connection, :open, nil)
668
- end
669
-
670
- ws.on(:message) do |connection, data|
671
- handler.call(connection, :message, data)
672
- end
673
-
674
- ws.on(:close) do |connection|
675
- handler.call(connection, :close, nil)
676
- end
677
-
678
- ws.on(:error) do |connection, error|
679
- Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
680
- end
681
-
682
- ws.handle_upgrade(env, socket)
683
-
684
- # Return async response (-1 signals Rack the response is handled via hijack)
685
- [-1, {}, []]
686
- end
687
-
688
- def inject_dev_overlay(body, request_info, ai_port: false)
689
- version = Tina4::VERSION
690
- method = request_info[:method]
691
- path = request_info[:path]
692
- matched_pattern = request_info[:matched_pattern]
693
- request_id = Tina4::Log.get_request_id || "-"
694
- route_count = Tina4::Router.routes.length
695
-
696
- ai_badge = ai_port ? '<span style="background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold;">AI PORT</span>' : ""
697
-
698
- toolbar = <<~HTML.strip
699
- <div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
700
- #{ai_badge}<span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
701
- <div id="tina4-ver-modal" style="display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #d32f2f;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:monospace;font-size:13px;color:#cdd6f4;">
702
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
703
- <strong style="color:#89b4fa;">Version Info</strong>
704
- <span onclick="document.getElementById('tina4-ver-modal').style.display='none'" style="cursor:pointer;color:#888;">&times;</span>
705
- </div>
706
- <div id="tina4-ver-body" style="line-height:1.8;">
707
- <div>Current: <strong style="color:#a6e3a1;">v#{version}</strong></div>
708
- <div id="tina4-ver-latest" style="color:#888;">Checking for updates...</div>
709
- </div>
710
- </div>
711
- <span style="color:#4caf50;">#{method}</span>
712
- <span>#{path}</span>
713
- <span style="color:#666;">&rarr; #{matched_pattern}</span>
714
- <span style="color:#ffeb3b;">req:#{request_id}</span>
715
- <span style="color:#90caf9;">#{route_count} routes</span>
716
- <span style="color:#888;">Ruby #{RUBY_VERSION}</span>
717
- <a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #CC342D;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
718
- <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
719
- </div>
720
- <script>
721
- function tina4VersionModal(){
722
- var m=document.getElementById('tina4-ver-modal');
723
- if(m.style.display==='block'){m.style.display='none';return;}
724
- m.style.display='block';
725
- var el=document.getElementById('tina4-ver-latest');
726
- el.innerHTML='Checking for updates...';
727
- el.style.color='#888';
728
- fetch('/__dev/api/version-check')
729
- .then(function(r){return r.json()})
730
- .then(function(d){
731
- var latest=d.latest;
732
- var current=d.current;
733
- if(latest===current){
734
- el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> &mdash; You are up to date!';
735
- el.style.color='#a6e3a1';
736
- }else{
737
- var cParts=current.split('.').map(Number);
738
- var lParts=latest.split('.').map(Number);
739
- var isNewer=false;
740
- for(var i=0;i<Math.max(cParts.length,lParts.length);i++){
741
- var c=cParts[i]||0,l=lParts[i]||0;
742
- if(l>c){isNewer=true;break;}
743
- if(l<c)break;
744
- }
745
- var isAhead=false;
746
- if(!isNewer){for(var i=0;i<Math.max(cParts.length,lParts.length);i++){var c2=cParts[i]||0,l2=lParts[i]||0;if(c2>l2){isAhead=true;break;}if(c2<l2)break;}}
747
- if(isNewer){
748
- var breaking=(lParts[0]!==cParts[0]||lParts[1]!==cParts[1]);
749
- el.innerHTML='Latest: <strong style="color:#f9e2af;">v'+latest+'</strong>';
750
- if(breaking){
751
- el.innerHTML+='<div style="color:#f38ba8;margin-top:6px;">&#9888; Major/minor version change &mdash; check the <a href="https://github.com/tina4stack/tina4-ruby/releases" target="_blank" style="color:#89b4fa;">changelog</a> for breaking changes before upgrading.</div>';
752
- }else{
753
- el.innerHTML+='<div style="color:#f9e2af;margin-top:6px;">Patch update available. Run: <code style="background:#313244;padding:2px 6px;border-radius:3px;">gem install tina4ruby</code></div>';
754
- }
755
- }else if(isAhead){
756
- el.innerHTML='You are running <strong style="color:#cba6f7;">v'+current+'</strong> (ahead of RubyGems <strong>v'+latest+'</strong> &mdash; not yet published).';
757
- el.style.color='#cba6f7';
758
- }else{
759
- el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> &mdash; You are up to date!';
760
- el.style.color='#a6e3a1';
761
- }
762
- }
763
- })
764
- .catch(function(){
765
- el.innerHTML='Could not check for updates (offline?)';
766
- el.style.color='#f38ba8';
767
- });
768
- }
769
- #{ai_port ? "" : "/* tina4:reload-js */"}
770
- </script>
771
- HTML
772
-
773
- if body.include?("</body>")
774
- body.sub("</body>", "#{toolbar}\n</body>")
775
- else
776
- body + "\n" + toolbar
777
- end
778
- end
779
-
780
-
781
- # Read and rewind the Rack input body. Returns the raw body string.
782
- def _read_rack_body(env)
783
- input = env["rack.input"]
784
- return "" unless input
785
- input.rewind if input.respond_to?(:rewind)
786
- body = input.read || ""
787
- input.rewind if input.respond_to?(:rewind)
788
- body
789
- end
790
-
791
- # Extract a formToken from the request body.
792
- # Supports JSON body ({ "formToken": "..." }) and URL-encoded form data (formToken=...).
793
- def _extract_form_token(body_str, env)
794
- return nil if body_str.nil? || body_str.empty?
795
-
796
- content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"] || ""
797
-
798
- if content_type.include?("application/json")
799
- begin
800
- parsed = JSON.parse(body_str)
801
- return parsed["formToken"] if parsed.is_a?(Hash) && parsed["formToken"]
802
- rescue JSON::ParserError
803
- # Not valid JSON — fall through
804
- end
805
- end
806
-
807
- # URL-encoded form data (or fallback for any content type)
808
- if body_str.include?("formToken=")
809
- match = body_str.match(/(?:^|&)formToken=([^&]+)/)
810
- return URI.decode_www_form_component(match[1]) if match
811
- end
812
-
813
- nil
814
- end
815
-
816
- end
817
- end
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module Tina4
7
+ # Middleware wrapper that tags requests arriving on the AI dev port.
8
+ # Suppresses live-reload behaviour so AI tools get stable responses.
9
+ class AiPortRackApp
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ env["tina4.ai_port"] = true
16
+ @app.call(env)
17
+ end
18
+ end
19
+
20
+ class RackApp
21
+ STATIC_DIRS = %w[public src/public src/assets assets].freeze
22
+
23
+ # CORS is now handled by Tina4::CorsMiddleware
24
+
25
+ # Framework's own public directory (bundled static assets like the logo)
26
+ FRAMEWORK_PUBLIC_DIR = File.expand_path("public", __dir__).freeze
27
+
28
+ def initialize(root_dir: Dir.pwd)
29
+ @root_dir = root_dir
30
+ # Pre-compute static roots at boot (not per-request)
31
+ # Project dirs are checked first; framework's bundled public dir is the fallback
32
+ project_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
33
+ .select { |d| Dir.exist?(d) }
34
+ fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
35
+ @static_roots = (project_roots + fallback).freeze
36
+
37
+ # Shared WebSocket engine for route-based WS handling
38
+ @websocket_engine = Tina4::WebSocket.new
39
+ end
40
+
41
+ def call(env)
42
+ method = env["REQUEST_METHOD"]
43
+ path = env["PATH_INFO"] || "/"
44
+ request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+
46
+ # Fast-path: OPTIONS preflight
47
+ return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
48
+
49
+ # WebSocket upgrade — match against registered ws_routes
50
+ if websocket_upgrade?(env)
51
+ ws_result = Tina4::Router.find_ws_route(path)
52
+ if ws_result
53
+ ws_route, ws_params = ws_result
54
+ return handle_websocket_upgrade(env, ws_route, ws_params)
55
+ end
56
+ end
57
+
58
+ # Dev dashboard routes (handled before anything else)
59
+ if path.start_with?("/__dev")
60
+ # Block live-reload endpoint on the AI port — AI tools must get stable responses
61
+ if path == "/__dev_reload" && env["tina4.ai_port"]
62
+ return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
63
+ end
64
+ dev_response = Tina4::DevAdmin.handle_request(env)
65
+ return dev_response if dev_response
66
+ end
67
+
68
+ # Fast-path: API routes skip static file + swagger checks entirely
69
+ unless path.start_with?("/api/")
70
+ # Swagger
71
+ if path == "/swagger" || path == "/swagger/"
72
+ return serve_swagger_ui
73
+ end
74
+ if path == "/swagger/openapi.json"
75
+ return serve_openapi_json
76
+ end
77
+
78
+ # Static files (only for non-API paths)
79
+ static_response = try_static(path)
80
+ return static_response if static_response
81
+ end
82
+
83
+ # Route matching
84
+ result = Tina4::Router.match(method, path)
85
+ if result
86
+ route, path_params = result
87
+ rack_response = handle_route(env, route, path_params)
88
+ matched_pattern = route.path
89
+ else
90
+ rack_response = handle_404(path)
91
+ matched_pattern = nil
92
+ end
93
+
94
+ # Capture request for dev inspector
95
+ if dev_mode? && !path.start_with?("/__dev")
96
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
97
+ Tina4::DevAdmin.request_inspector.capture(
98
+ method: method,
99
+ path: path,
100
+ status: rack_response[0],
101
+ duration: duration_ms
102
+ )
103
+ end
104
+
105
+ # Inject dev overlay button for HTML responses in dev mode
106
+ if dev_mode? && !path.start_with?("/__dev")
107
+ status, headers, body_parts = rack_response
108
+ content_type = headers["content-type"] || ""
109
+ if content_type.include?("text/html")
110
+ request_info = {
111
+ method: method,
112
+ path: path,
113
+ matched_pattern: matched_pattern || "(no match)",
114
+ }
115
+ joined = body_parts.join
116
+ overlay = inject_dev_overlay(joined, request_info, ai_port: env["tina4.ai_port"])
117
+ rack_response = [status, headers, [overlay]]
118
+ end
119
+ end
120
+
121
+ # Save session and set cookie if session was used
122
+ if result && defined?(rack_response)
123
+ status, headers, body_parts = rack_response
124
+ request_obj = env["tina4.request"]
125
+ if request_obj&.instance_variable_get(:@session)
126
+ sess = request_obj.session
127
+ sess.save
128
+
129
+ # Probabilistic garbage collection (~1% of requests)
130
+ if rand(1..100) == 1
131
+ begin
132
+ sess.gc
133
+ rescue StandardError
134
+ # GC failure is non-critical — silently ignore
135
+ end
136
+ end
137
+
138
+ sid = sess.id
139
+ cookie_val = (env["HTTP_COOKIE"] || "")[/tina4_session=([^;]+)/, 1]
140
+ if sid && sid != cookie_val
141
+ ttl = Integer(ENV.fetch("TINA4_SESSION_TTL", 3600))
142
+ headers["set-cookie"] = "tina4_session=#{sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{ttl}"
143
+ end
144
+ rack_response = [status, headers, body_parts]
145
+ end
146
+ end
147
+
148
+ rack_response
149
+ rescue => e
150
+ handle_500(e, env)
151
+ end
152
+
153
+ # Dispatch a pre-built Request through the Rack app and return the Rack response triple.
154
+ # Useful for testing and embedding without starting an HTTP server.
155
+ def handle(request)
156
+ env = request.env
157
+ env["rack.input"].rewind if env["rack.input"].respond_to?(:rewind)
158
+ call(env)
159
+ end
160
+
161
+ private
162
+
163
+ def handle_route(env, route, path_params)
164
+ # Auth check (legacy per-route auth_handler)
165
+ if route.auth_handler
166
+ auth_result = route.auth_handler.call(env)
167
+ return handle_403(env["PATH_INFO"] || "/") unless auth_result
168
+ end
169
+
170
+ # Secure-by-default: enforce bearer-token auth on write routes
171
+ if route.auth_required
172
+ token = nil
173
+ token_source = nil # :header, :body, :session
174
+
175
+ # Priority 1: Authorization Bearer header
176
+ auth_header = env["HTTP_AUTHORIZATION"] || ""
177
+ if auth_header =~ /\ABearer\s+(.+)\z/i
178
+ token = Regexp.last_match(1)
179
+ token_source = :header
180
+ end
181
+
182
+ # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
183
+ if token.nil?
184
+ body_str = _read_rack_body(env)
185
+ form_token = _extract_form_token(body_str, env)
186
+ if form_token && !form_token.empty?
187
+ token = form_token
188
+ token_source = :body
189
+ end
190
+ end
191
+
192
+ # Priority 3: Session token (for secured GET routes after login)
193
+ if token.nil?
194
+ session = Tina4::Session.new(env)
195
+ session_token = session.get("token")
196
+ if session_token && !session_token.empty?
197
+ token = session_token
198
+ token_source = :session
199
+ end
200
+ end
201
+
202
+ # API_KEY bypass — matches tina4_python behavior
203
+ api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
204
+ if api_key && !api_key.empty? && token == api_key
205
+ env["tina4.auth_payload"] = { "api_key" => true }
206
+ elsif token
207
+ unless Tina4::Auth.valid_token(token)
208
+ return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
209
+ end
210
+ env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
211
+
212
+ # When body formToken validates, store a refreshed token for the FreshToken response header
213
+ if token_source == :body
214
+ env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token)
215
+ end
216
+ else
217
+ return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
218
+ end
219
+ end
220
+
221
+ request = Tina4::Request.new(env, path_params)
222
+ request.user = env["tina4.auth_payload"] if env["tina4.auth_payload"]
223
+ env["tina4.request"] = request # Store for session save after response
224
+ response = Tina4::Response.new
225
+
226
+ # Run global middleware (block-based + class-based before_* methods)
227
+ unless Tina4::Middleware.run_before(Tina4::Middleware.global_middleware, request, response)
228
+ # Middleware halted the request -- return whatever response was set
229
+ return response.to_rack
230
+ end
231
+
232
+ # Run per-route middleware
233
+ if route.respond_to?(:run_middleware)
234
+ unless route.run_middleware(request, response)
235
+ return [403, { "content-type" => "text/html" }, ["403 Forbidden"]]
236
+ end
237
+ end
238
+
239
+ # Execute handler — inject path params by name, then request/response
240
+ handler_params = route.handler.parameters.map(&:last)
241
+ route_params = path_params || {}
242
+ args = handler_params.map do |name|
243
+ if route_params.key?(name)
244
+ route_params[name]
245
+ elsif name == :request || name == :req
246
+ request
247
+ else
248
+ response
249
+ end
250
+ end
251
+ result = args.empty? ? route.handler.call : route.handler.call(*args)
252
+
253
+ # Template rendering: when a template is set and the handler returned a Hash,
254
+ # render the template with the hash as data and return the HTML response.
255
+ if route.template && result.is_a?(Hash)
256
+ html = Tina4::Template.render(route.template, result)
257
+ response.html(html)
258
+ return response.to_rack
259
+ end
260
+
261
+ # Skip auto_detect if handler already returned the response object
262
+ final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
263
+
264
+ # Run global after middleware (block-based + class-based after_* methods)
265
+ Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, final_response)
266
+
267
+ # Inject FreshToken header when body formToken was used for auth
268
+ if env["tina4.fresh_token"]
269
+ final_response.add_header("FreshToken", env["tina4.fresh_token"])
270
+ end
271
+
272
+ final_response.to_rack
273
+ end
274
+
275
+ def try_static(path)
276
+ return nil if path.include?("..")
277
+
278
+ @static_roots.each do |root|
279
+ full_path = File.join(root, path)
280
+ if File.file?(full_path)
281
+ return serve_static_file(full_path)
282
+ end
283
+
284
+ # Only try index.html for directory-like paths
285
+ if path.end_with?("/") || !path.include?(".")
286
+ index_path = File.join(full_path, "index.html")
287
+ if File.file?(index_path)
288
+ return serve_static_file(index_path)
289
+ end
290
+ end
291
+ end
292
+ nil
293
+ end
294
+
295
+ def serve_static_file(full_path)
296
+ ext = File.extname(full_path).downcase
297
+ content_type = Tina4::Response::MIME_TYPES[ext] || "application/octet-stream"
298
+ [200, { "content-type" => content_type }, [File.binread(full_path)]]
299
+ end
300
+
301
+ def serve_swagger_ui
302
+ html = <<~HTML
303
+ <!DOCTYPE html>
304
+ <html lang="en">
305
+ <head>
306
+ <meta charset="UTF-8">
307
+ <title>API Documentation</title>
308
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
309
+ </head>
310
+ <body>
311
+ <div id="swagger-ui"></div>
312
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
313
+ <script>
314
+ SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
315
+ </script>
316
+ </body>
317
+ </html>
318
+ HTML
319
+ [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
320
+ end
321
+
322
+ def serve_openapi_json
323
+ @openapi_json ||= JSON.generate(Tina4::Swagger.generate)
324
+ [200, { "content-type" => "application/json; charset=utf-8" }, [@openapi_json]]
325
+ end
326
+
327
+ def handle_403(path = "")
328
+ body = Tina4::Template.render_error(403, { "path" => path }) rescue "403 Forbidden"
329
+ [403, { "content-type" => "text/html" }, [body]]
330
+ end
331
+
332
+ def handle_404(path)
333
+ # Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
334
+ template_response = try_serve_template(path)
335
+ return template_response if template_response
336
+
337
+ # Show landing page for GET "/"
338
+ return render_landing_page if path == "/"
339
+
340
+ Tina4::Log.warning("404 Not Found: #{path}")
341
+ body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
342
+ [404, { "content-type" => "text/html" }, [body]]
343
+ end
344
+
345
+ def should_show_landing_page?
346
+ # Check if any index template exists in src/templates/
347
+ templates_dir = File.join(@root_dir, "src", "templates")
348
+ %w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
349
+ end
350
+
351
+ def try_serve_template(path)
352
+ tpl_file = resolve_template(path)
353
+ return nil unless tpl_file
354
+
355
+ templates_dir = File.join(@root_dir, "src", "templates")
356
+ body = Tina4::Template.render(tpl_file, {}) rescue File.read(File.join(templates_dir, tpl_file))
357
+ [200, { "content-type" => "text/html" }, [body]]
358
+ end
359
+
360
+ # Resolve a URL path to a template file.
361
+ # Dev mode: checks filesystem every time for live changes.
362
+ # Production: uses a cached lookup built once at startup.
363
+ def resolve_template(path)
364
+ clean_path = path.sub(%r{^/}, "")
365
+ clean_path = "index" if clean_path.empty?
366
+ is_dev = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "false").downcase)
367
+
368
+ if is_dev
369
+ templates_dir = File.join(@root_dir, "src", "templates")
370
+ %w[.twig .html].each do |ext|
371
+ candidate = clean_path + ext
372
+ return candidate if File.file?(File.join(templates_dir, candidate))
373
+ end
374
+ return nil
375
+ end
376
+
377
+ # Production: cached lookup
378
+ @template_cache ||= build_template_cache
379
+ @template_cache[clean_path]
380
+ end
381
+
382
+ def build_template_cache
383
+ cache = {}
384
+ templates_dir = File.join(@root_dir, "src", "templates")
385
+ return cache unless File.directory?(templates_dir)
386
+
387
+ Dir.glob(File.join(templates_dir, "**", "*.{twig,html}")).each do |f|
388
+ rel = f.sub(templates_dir + File::SEPARATOR, "").tr("\\", "/")
389
+ url_path = rel.sub(/\.(twig|html)$/, "")
390
+ cache[url_path] ||= rel
391
+ end
392
+ cache
393
+ end
394
+
395
+ def try_serve_index_template
396
+ templates_dir = File.join(@root_dir, "src", "templates")
397
+ %w[index.html index.twig index.erb].each do |f|
398
+ path = File.join(templates_dir, f)
399
+ if File.file?(path)
400
+ body = Tina4::Template.render(f, {}) rescue File.read(path)
401
+ return [200, { "content-type" => "text/html" }, [body]]
402
+ end
403
+ end
404
+ nil
405
+ end
406
+
407
+ def render_landing_page
408
+ port = ENV["PORT"] || "7145"
409
+
410
+ # Check deployed state for each gallery item
411
+ project_src = File.join(@root_dir, "src")
412
+ gallery_items = [
413
+ { id: "rest-api", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", icon: "&#128640;", accent: "red", try_url: "/api/gallery/hello", file_check: "routes/api/gallery_hello.rb" },
414
+ { id: "orm", name: "ORM", desc: "Product model with CRUD endpoints", icon: "&#128451;", accent: "green", try_url: "/api/gallery/products", file_check: "routes/api/gallery_products.rb" },
415
+ { id: "auth", name: "Auth", desc: "JWT login form with token display", icon: "&#128274;", accent: "purple", try_url: "/gallery/auth", file_check: "routes/api/gallery_auth.rb" },
416
+ { id: "queue", name: "Queue", desc: "Background job producer and consumer", icon: "&#9889;", accent: "red", try_url: "/api/gallery/queue/produce", file_check: "routes/api/gallery_queue.rb" },
417
+ { id: "templates", name: "Templates", desc: "Twig template with dynamic data", icon: "&#128196;", accent: "green", try_url: "/gallery/page", file_check: "routes/gallery_page.rb" },
418
+ { id: "database", name: "Database", desc: "Raw SQL queries with the Database class", icon: "&#128225;", accent: "purple", try_url: "/api/gallery/db/tables", file_check: "routes/api/gallery_db.rb" },
419
+ { id: "error-overlay", name: "Error Overlay", desc: "See the rich debug error page with stack trace", icon: "&#128165;", accent: "red", try_url: "/api/gallery/crash", file_check: "routes/api/gallery_crash.rb" }
420
+ ]
421
+
422
+ gallery_cards = gallery_items.map do |item|
423
+ deployed = File.file?(File.join(project_src, item[:file_check]))
424
+ deployed_badge = deployed ? '<span style="position:absolute;top:0.75rem;right:0.75rem;background:#22c55e;color:#fff;font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:0.25rem;font-weight:600;">DEPLOYED</span>' : ''
425
+ try_btn = if deployed
426
+ %(<a href="#{item[:try_url]}" class="gbtn gbtn-try" target="_blank">Try It</a>)
427
+ else
428
+ %(<button class="gbtn gbtn-deploy" onclick="deployGallery('#{item[:id]}','#{item[:try_url]}')">Deploy &amp; Try</button>)
429
+ end
430
+ view_btn = %(<button class="gbtn gbtn-view" onclick="viewGallery('#{item[:id]}')">View</button>)
431
+
432
+ <<~CARD
433
+ <div class="gallery-card">
434
+ <div class="accent accent-#{item[:accent]}"></div>
435
+ #{deployed_badge}
436
+ <div class="icon">#{item[:icon]}</div>
437
+ <h3>#{item[:name]}</h3>
438
+ <p>#{item[:desc]}</p>
439
+ <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">#{try_btn}#{view_btn}</div>
440
+ </div>
441
+ CARD
442
+ end.join
443
+
444
+ html = <<~HTML
445
+ <!DOCTYPE html>
446
+ <html lang="en">
447
+ <head>
448
+ <meta charset="utf-8">
449
+ <meta name="viewport" content="width=device-width, initial-scale=1">
450
+ <title>Tina4Ruby</title>
451
+ <style>
452
+ *{margin:0;padding:0;box-sizing:border-box}
453
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
454
+ .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
455
+ .hero{text-align:center;z-index:1;padding:3rem 2rem 2rem}
456
+ .logo{width:120px;height:120px;margin-bottom:1.5rem}
457
+ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
458
+ .tagline{color:#64748b;font-size:1.1rem;margin-bottom:2rem}
459
+ .actions{display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap;margin-bottom:2.5rem}
460
+ .btn{padding:0.6rem 1.5rem;border-radius:0.5rem;font-size:0.9rem;font-weight:600;cursor:pointer;text-decoration:none;transition:all 0.15s;border:1px solid #334155;color:#94a3b8;background:transparent;min-width:140px;text-align:center;display:inline-block}
461
+ .btn:hover{border-color:#64748b;color:#e2e8f0}
462
+ .status{display:flex;gap:2rem;justify-content:center;align-items:center;color:#64748b;font-size:0.85rem;margin-bottom:1.5rem}
463
+ .status .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;margin-right:0.4rem}
464
+ .footer{color:#334155;font-size:0.8rem;letter-spacing:0.5px}
465
+ .section{z-index:1;width:100%;max-width:800px;padding:0 2rem;margin-bottom:2.5rem}
466
+ .card{background:#1e293b;border-radius:0.75rem;padding:2rem;border:1px solid #334155}
467
+ .card h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0}
468
+ .code-block{background:#0f172a;border-radius:0.5rem;padding:1.25rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:0.85rem;line-height:1.6;color:#4ade80;border:1px solid #1e293b}
469
+ .gallery{z-index:1;width:100%;max-width:900px;padding:0 2rem;margin-bottom:3rem}
470
+ .gallery h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0;text-align:center}
471
+ .gallery-card{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}
472
+ .gallery-card .accent{position:absolute;top:0;left:0;right:0;height:3px}
473
+ .gallery-card .accent-red{background:#CC342D}
474
+ .gallery-card .accent-green{background:#22c55e}
475
+ .gallery-card .accent-purple{background:#a78bfa}
476
+ .gallery-card .icon{font-size:1.5rem;margin-bottom:0.75rem}
477
+ .gallery-card h3{font-size:1rem;font-weight:600;margin-bottom:0.5rem;color:#e2e8f0}
478
+ .gallery-card p{font-size:0.85rem;color:#94a3b8;line-height:1.5}
479
+ .gbtn{padding:0.35rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;font-weight:600;cursor:pointer;text-decoration:none;border:none;transition:all 0.15s}
480
+ .gbtn-try{background:#22c55e;color:#fff}
481
+ .gbtn-try:hover{background:#16a34a}
482
+ .gbtn-deploy{background:#CC342D;color:#fff}
483
+ .gbtn-deploy:hover{background:#a12a24}
484
+ .gbtn-view{background:transparent;color:#94a3b8;border:1px solid #334155}
485
+ .gbtn-view:hover{border-color:#64748b;color:#e2e8f0}
486
+ .view-modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:10000;align-items:center;justify-content:center}
487
+ .view-modal.active{display:flex}
488
+ .view-modal-content{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:2rem;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;position:relative}
489
+ .view-modal-close{position:absolute;top:0.75rem;right:1rem;color:#94a3b8;cursor:pointer;font-size:1.25rem;background:none;border:none}
490
+ .view-modal-close:hover{color:#e2e8f0}
491
+ @keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
492
+ .star-wiggle{display:inline-block;transform-origin:center}
493
+ </style>
494
+ </head>
495
+ <body>
496
+ <img src="/images/tina4-logo-icon.webp" class="bg-watermark" alt="">
497
+ <div class="hero">
498
+ <img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
499
+ <h1>Tina4Ruby</h1>
500
+ <p class="tagline">The Intelligent Native Application 4ramework</p>
501
+ <p class="tagline" style="font-size:0.95rem;margin-top:-1rem">Simple. Fast. Human. &nbsp;|&nbsp; Built for AI. Built for you.</p>
502
+ <div class="actions">
503
+ <a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
504
+ <a href="/__dev" class="btn">Dev Admin</a>
505
+ <a href="#gallery" class="btn">Gallery</a>
506
+ <a href="https://github.com/tina4stack/tina4-ruby" class="btn" target="_blank">GitHub</a>
507
+ <a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank"><span class="star-wiggle">&#9734;</span> Star</a>
508
+ </div>
509
+ <div class="status">
510
+ <span><span class="dot"></span>Server running</span>
511
+ <span>Port #{port}</span>
512
+ <span>v#{Tina4::VERSION}</span>
513
+ </div>
514
+ <p class="footer">Zero dependencies &middot; Convention over configuration</p>
515
+ </div>
516
+ <div class="section">
517
+ <div class="card">
518
+ <h2>Getting Started</h2>
519
+ <pre class="code-block"><code><span style="color:#64748b"># app.rb</span>
520
+ <span style="color:#c084fc">require</span> <span style="color:#4ade80">"tina4"</span>
521
+
522
+ Tina4::Router.<span style="color:#38bdf8">get</span>(<span style="color:#4ade80">"/hello"</span>) <span style="color:#c084fc">do</span> |request, response|
523
+ response.<span style="color:#38bdf8">json</span>({ <span style="color:#fbbf24">message:</span> <span style="color:#4ade80">"Hello World!"</span> })
524
+ <span style="color:#c084fc">end</span>
525
+
526
+ Tina4::WebServer.new(<span style="color:#fbbf24">port:</span> <span style="color:#38bdf8">7145</span>).start <span style="color:#64748b"># starts on port 7145</span></code></pre>
527
+ </div>
528
+ </div>
529
+ <div class="gallery">
530
+ <h2 id="gallery">Gallery</h2>
531
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
532
+ #{gallery_cards}
533
+ </div>
534
+ </div>
535
+ <div class="view-modal" id="viewModal">
536
+ <div class="view-modal-content">
537
+ <button class="view-modal-close" onclick="document.getElementById('viewModal').classList.remove('active')">&times;</button>
538
+ <h3 id="viewModalTitle" style="margin-bottom:1rem;color:#e2e8f0;"></h3>
539
+ <div id="viewModalBody"></div>
540
+ </div>
541
+ </div>
542
+ <script>
543
+ function deployGallery(name, tryUrl) {
544
+ if (!confirm('Deploy the "' + name + '" gallery example into your project?')) return;
545
+ var btn = event.target;
546
+ btn.disabled = true;
547
+ btn.textContent = 'Deploying...';
548
+ fetch('/__dev/api/gallery/deploy', {
549
+ method: 'POST',
550
+ headers: {'Content-Type': 'application/json'},
551
+ body: JSON.stringify({ name: name })
552
+ }).then(function(r) { return r.json(); }).then(function(d) {
553
+ if (d.error) {
554
+ alert('Deploy failed: ' + d.error);
555
+ btn.disabled = false;
556
+ btn.textContent = 'Deploy & Try';
557
+ } else {
558
+ // Wait for the newly deployed route to become reachable before navigating
559
+ var attempts = 0;
560
+ var maxAttempts = 5;
561
+ function pollRoute() {
562
+ fetch(tryUrl, {method: 'HEAD'}).then(function() {
563
+ window.open(tryUrl, '_blank');
564
+ }).catch(function() {
565
+ attempts++;
566
+ if (attempts < maxAttempts) {
567
+ setTimeout(pollRoute, 500);
568
+ } else {
569
+ window.open(tryUrl, '_blank');
570
+ }
571
+ });
572
+ }
573
+ setTimeout(pollRoute, 500);
574
+ }
575
+ }).catch(function(e) {
576
+ alert('Deploy error: ' + e.message);
577
+ btn.disabled = false;
578
+ btn.textContent = 'Deploy & Try';
579
+ });
580
+ }
581
+ function viewGallery(name) {
582
+ fetch('/__dev/api/gallery').then(function(r) { return r.json(); }).then(function(d) {
583
+ var item = (d.gallery || []).find(function(g) { return g.id === name; });
584
+ if (!item) { alert('Gallery item not found'); return; }
585
+ var title = document.getElementById('viewModalTitle');
586
+ var body = document.getElementById('viewModalBody');
587
+ title.textContent = item.name + ' — ' + item.description;
588
+ var html = '<p style="color:#94a3b8;margin-bottom:1rem;">Files that will be deployed:</p><ul style="list-style:none;padding:0;">';
589
+ (item.files || []).forEach(function(f) {
590
+ html += '<li style="padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem;">src/' + f + '</li>';
591
+ });
592
+ html += '</ul>';
593
+ if (item.try_url) {
594
+ html += '<p style="color:#94a3b8;margin-top:1rem;">Try URL: <code style="color:#38bdf8;">' + item.try_url + '</code></p>';
595
+ }
596
+ body.innerHTML = html;
597
+ document.getElementById('viewModal').classList.add('active');
598
+ });
599
+ }
600
+ document.getElementById('viewModal').addEventListener('click', function(e) {
601
+ if (e.target === this) this.classList.remove('active');
602
+ });
603
+ (function(){
604
+ var star=document.querySelector('.star-wiggle');
605
+ if(!star)return;
606
+ function doWiggle(){
607
+ star.style.animation='wiggle 1.2s ease-in-out';
608
+ star.addEventListener('animationend',function onEnd(){
609
+ star.removeEventListener('animationend',onEnd);
610
+ star.style.animation='none';
611
+ var delay=3000+Math.random()*15000;
612
+ setTimeout(doWiggle,delay);
613
+ });
614
+ }
615
+ setTimeout(doWiggle,3000);
616
+ })();
617
+ </script>
618
+ </body>
619
+ </html>
620
+ HTML
621
+
622
+ [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
623
+ end
624
+
625
+ def handle_500(error, env = nil)
626
+ Tina4::Log.error("500 Internal Server Error: #{error.message}")
627
+ Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
628
+ if dev_mode?
629
+ # Rich error overlay with stack trace, source context, and line numbers
630
+ body = Tina4::ErrorOverlay.render_error_overlay(error, request: env)
631
+ else
632
+ body = Tina4::Template.render_error(500, {
633
+ "error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
634
+ "request_id" => SecureRandom.hex(6)
635
+ }) rescue "500 Internal Server Error: #{error.message}"
636
+ end
637
+ [500, { "content-type" => "text/html" }, [body]]
638
+ end
639
+
640
+ def dev_mode?
641
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
642
+ end
643
+
644
+ def websocket_upgrade?(env)
645
+ upgrade = env["HTTP_UPGRADE"] || ""
646
+ upgrade.downcase == "websocket"
647
+ end
648
+
649
+ def handle_websocket_upgrade(env, ws_route, ws_params)
650
+ # Rack hijack is required for WebSocket upgrades
651
+ unless env["rack.hijack"]
652
+ Tina4::Log.warning("WebSocket upgrade requested but rack.hijack not available")
653
+ return [426, { "content-type" => "text/plain" }, ["WebSocket upgrade requires rack.hijack support"]]
654
+ end
655
+
656
+ env["rack.hijack"].call
657
+ socket = env["rack.hijack_io"]
658
+
659
+ # Wire the route handler into the WebSocket engine events
660
+ handler = ws_route.handler
661
+
662
+ # Create a dedicated WebSocket engine for this route so handlers stay isolated
663
+ ws = Tina4::WebSocket.new
664
+
665
+ ws.on(:open) do |connection|
666
+ connection.params = ws_params
667
+ handler.call(connection, :open, nil)
668
+ end
669
+
670
+ ws.on(:message) do |connection, data|
671
+ handler.call(connection, :message, data)
672
+ end
673
+
674
+ ws.on(:close) do |connection|
675
+ handler.call(connection, :close, nil)
676
+ end
677
+
678
+ ws.on(:error) do |connection, error|
679
+ Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
680
+ end
681
+
682
+ ws.handle_upgrade(env, socket)
683
+
684
+ # Return async response (-1 signals Rack the response is handled via hijack)
685
+ [-1, {}, []]
686
+ end
687
+
688
+ def inject_dev_overlay(body, request_info, ai_port: false)
689
+ version = Tina4::VERSION
690
+ method = request_info[:method]
691
+ path = request_info[:path]
692
+ matched_pattern = request_info[:matched_pattern]
693
+ request_id = Tina4::Log.get_request_id || "-"
694
+ route_count = Tina4::Router.routes.length
695
+
696
+ ai_badge = ai_port ? '<span style="background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold;">AI PORT</span>' : ""
697
+
698
+ toolbar = <<~HTML.strip
699
+ <div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
700
+ #{ai_badge}<span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
701
+ <div id="tina4-ver-modal" style="display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #d32f2f;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:monospace;font-size:13px;color:#cdd6f4;">
702
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
703
+ <strong style="color:#89b4fa;">Version Info</strong>
704
+ <span onclick="document.getElementById('tina4-ver-modal').style.display='none'" style="cursor:pointer;color:#888;">&times;</span>
705
+ </div>
706
+ <div id="tina4-ver-body" style="line-height:1.8;">
707
+ <div>Current: <strong style="color:#a6e3a1;">v#{version}</strong></div>
708
+ <div id="tina4-ver-latest" style="color:#888;">Checking for updates...</div>
709
+ </div>
710
+ </div>
711
+ <span style="color:#4caf50;">#{method}</span>
712
+ <span>#{path}</span>
713
+ <span style="color:#666;">&rarr; #{matched_pattern}</span>
714
+ <span style="color:#ffeb3b;">req:#{request_id}</span>
715
+ <span style="color:#90caf9;">#{route_count} routes</span>
716
+ <span style="color:#888;">Ruby #{RUBY_VERSION}</span>
717
+ <a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #CC342D;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
718
+ <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
719
+ </div>
720
+ <script>
721
+ function tina4VersionModal(){
722
+ var m=document.getElementById('tina4-ver-modal');
723
+ if(m.style.display==='block'){m.style.display='none';return;}
724
+ m.style.display='block';
725
+ var el=document.getElementById('tina4-ver-latest');
726
+ el.innerHTML='Checking for updates...';
727
+ el.style.color='#888';
728
+ fetch('/__dev/api/version-check')
729
+ .then(function(r){return r.json()})
730
+ .then(function(d){
731
+ var latest=d.latest;
732
+ var current=d.current;
733
+ if(latest===current){
734
+ el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> &mdash; You are up to date!';
735
+ el.style.color='#a6e3a1';
736
+ }else{
737
+ var cParts=current.split('.').map(Number);
738
+ var lParts=latest.split('.').map(Number);
739
+ var isNewer=false;
740
+ for(var i=0;i<Math.max(cParts.length,lParts.length);i++){
741
+ var c=cParts[i]||0,l=lParts[i]||0;
742
+ if(l>c){isNewer=true;break;}
743
+ if(l<c)break;
744
+ }
745
+ var isAhead=false;
746
+ if(!isNewer){for(var i=0;i<Math.max(cParts.length,lParts.length);i++){var c2=cParts[i]||0,l2=lParts[i]||0;if(c2>l2){isAhead=true;break;}if(c2<l2)break;}}
747
+ if(isNewer){
748
+ var breaking=(lParts[0]!==cParts[0]||lParts[1]!==cParts[1]);
749
+ el.innerHTML='Latest: <strong style="color:#f9e2af;">v'+latest+'</strong>';
750
+ if(breaking){
751
+ el.innerHTML+='<div style="color:#f38ba8;margin-top:6px;">&#9888; Major/minor version change &mdash; check the <a href="https://github.com/tina4stack/tina4-ruby/releases" target="_blank" style="color:#89b4fa;">changelog</a> for breaking changes before upgrading.</div>';
752
+ }else{
753
+ el.innerHTML+='<div style="color:#f9e2af;margin-top:6px;">Patch update available. Run: <code style="background:#313244;padding:2px 6px;border-radius:3px;">gem install tina4ruby</code></div>';
754
+ }
755
+ }else if(isAhead){
756
+ el.innerHTML='You are running <strong style="color:#cba6f7;">v'+current+'</strong> (ahead of RubyGems <strong>v'+latest+'</strong> &mdash; not yet published).';
757
+ el.style.color='#cba6f7';
758
+ }else{
759
+ el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> &mdash; You are up to date!';
760
+ el.style.color='#a6e3a1';
761
+ }
762
+ }
763
+ })
764
+ .catch(function(){
765
+ el.innerHTML='Could not check for updates (offline?)';
766
+ el.style.color='#f38ba8';
767
+ });
768
+ }
769
+ #{ai_port ? "" : "/* tina4:reload-js */"}
770
+ </script>
771
+ HTML
772
+
773
+ if body.include?("</body>")
774
+ body.sub("</body>", "#{toolbar}\n</body>")
775
+ else
776
+ body + "\n" + toolbar
777
+ end
778
+ end
779
+
780
+
781
+ # Read and rewind the Rack input body. Returns the raw body string.
782
+ def _read_rack_body(env)
783
+ input = env["rack.input"]
784
+ return "" unless input
785
+ input.rewind if input.respond_to?(:rewind)
786
+ body = input.read || ""
787
+ input.rewind if input.respond_to?(:rewind)
788
+ body
789
+ end
790
+
791
+ # Extract a formToken from the request body.
792
+ # Supports JSON body ({ "formToken": "..." }) and URL-encoded form data (formToken=...).
793
+ def _extract_form_token(body_str, env)
794
+ return nil if body_str.nil? || body_str.empty?
795
+
796
+ content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"] || ""
797
+
798
+ if content_type.include?("application/json")
799
+ begin
800
+ parsed = JSON.parse(body_str)
801
+ return parsed["formToken"] if parsed.is_a?(Hash) && parsed["formToken"]
802
+ rescue JSON::ParserError
803
+ # Not valid JSON — fall through
804
+ end
805
+ end
806
+
807
+ # URL-encoded form data (or fallback for any content type)
808
+ if body_str.include?("formToken=")
809
+ match = body_str.match(/(?:^|&)formToken=([^&]+)/)
810
+ return URI.decode_www_form_component(match[1]) if match
811
+ end
812
+
813
+ nil
814
+ end
815
+
816
+ end
817
+ end