tina4ruby 0.5.2 → 3.2.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +434 -544
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +389 -97
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +144 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1497 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +562 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +463 -35
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +162 -6
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +331 -27
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +551 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +118 -21
  88. metadata +68 -8
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
@@ -1,34 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
+ require "securerandom"
3
4
 
4
5
  module Tina4
5
6
  class RackApp
6
7
  STATIC_DIRS = %w[public src/public src/assets assets].freeze
7
8
 
8
- # Pre-built frozen responses for zero-allocation fast paths
9
- CORS_HEADERS = {
10
- "access-control-allow-origin" => "*",
11
- "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
12
- "access-control-allow-headers" => "Content-Type, Authorization, Accept",
13
- "access-control-max-age" => "86400"
14
- }.freeze
9
+ # CORS is now handled by Tina4::CorsMiddleware
15
10
 
16
- OPTIONS_RESPONSE = [204, CORS_HEADERS, [""]].freeze
11
+ # Framework's own public directory (bundled static assets like the logo)
12
+ FRAMEWORK_PUBLIC_DIR = File.expand_path("public", __dir__).freeze
17
13
 
18
14
  def initialize(root_dir: Dir.pwd)
19
15
  @root_dir = root_dir
20
16
  # Pre-compute static roots at boot (not per-request)
21
- @static_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
22
- .select { |d| Dir.exist?(d) }
23
- .freeze
17
+ # Project dirs are checked first; framework's bundled public dir is the fallback
18
+ project_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
19
+ .select { |d| Dir.exist?(d) }
20
+ fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
21
+ @static_roots = (project_roots + fallback).freeze
24
22
  end
25
23
 
26
24
  def call(env)
27
25
  method = env["REQUEST_METHOD"]
28
26
  path = env["PATH_INFO"] || "/"
27
+ request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
28
 
30
- # Fast-path: OPTIONS preflight (zero allocation)
31
- return OPTIONS_RESPONSE if method == "OPTIONS"
29
+ # Fast-path: OPTIONS preflight
30
+ return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
31
+
32
+ # Dev dashboard routes (handled before anything else)
33
+ if path.start_with?("/__dev")
34
+ dev_response = Tina4::DevAdmin.handle_request(env)
35
+ return dev_response if dev_response
36
+ end
32
37
 
33
38
  # Fast-path: API routes skip static file + swagger checks entirely
34
39
  unless path.start_with?("/api/")
@@ -49,12 +54,43 @@ module Tina4
49
54
  result = Tina4::Router.find_route(path, method)
50
55
  if result
51
56
  route, path_params = result
52
- handle_route(env, route, path_params)
57
+ rack_response = handle_route(env, route, path_params)
58
+ matched_pattern = route.path
53
59
  else
54
- handle_404(path)
60
+ rack_response = handle_404(path)
61
+ matched_pattern = nil
62
+ end
63
+
64
+ # Capture request for dev inspector
65
+ if dev_mode? && !path.start_with?("/__dev")
66
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
67
+ Tina4::DevAdmin.request_inspector.capture(
68
+ method: method,
69
+ path: path,
70
+ status: rack_response[0],
71
+ duration: duration_ms
72
+ )
73
+ end
74
+
75
+ # Inject dev overlay button for HTML responses in dev mode
76
+ if dev_mode? && !path.start_with?("/__dev")
77
+ status, headers, body_parts = rack_response
78
+ content_type = headers["content-type"] || ""
79
+ if content_type.include?("text/html")
80
+ request_info = {
81
+ method: method,
82
+ path: path,
83
+ matched_pattern: matched_pattern || "(no match)",
84
+ }
85
+ joined = body_parts.join
86
+ overlay = inject_dev_overlay(joined, request_info)
87
+ rack_response = [status, headers, [overlay]]
88
+ end
55
89
  end
90
+
91
+ rack_response
56
92
  rescue => e
57
- handle_500(e)
93
+ handle_500(e, env)
58
94
  end
59
95
 
60
96
  private
@@ -63,14 +99,42 @@ module Tina4
63
99
  # Auth check
64
100
  if route.auth_handler
65
101
  auth_result = route.auth_handler.call(env)
66
- return handle_403 unless auth_result
102
+ return handle_403(env["PATH_INFO"] || "/") unless auth_result
67
103
  end
68
104
 
69
105
  request = Tina4::Request.new(env, path_params)
70
106
  response = Tina4::Response.new
71
107
 
72
- # Execute handler
73
- result = route.handler.call(request, response)
108
+ # Run per-route middleware
109
+ if route.respond_to?(:run_middleware)
110
+ unless route.run_middleware(request, response)
111
+ return [403, { "content-type" => "text/html" }, ["403 Forbidden"]]
112
+ end
113
+ end
114
+
115
+ # Execute handler — support (), (response), (request), or (request, response) signatures
116
+ # When 1 param: if named :request or :req, pass request; otherwise pass response
117
+ result = case route.handler.arity
118
+ when 0
119
+ route.handler.call
120
+ when 1
121
+ param_name = route.handler.parameters.first&.last
122
+ if param_name == :request || param_name == :req
123
+ route.handler.call(request)
124
+ else
125
+ route.handler.call(response)
126
+ end
127
+ else
128
+ route.handler.call(request, response)
129
+ end
130
+
131
+ # Template rendering: when a template is set and the handler returned a Hash,
132
+ # render the template with the hash as data and return the HTML response.
133
+ if route.template && result.is_a?(Hash)
134
+ html = Tina4::Template.render(route.template, result)
135
+ response.html(html)
136
+ return response.to_rack
137
+ end
74
138
 
75
139
  # Skip auto_detect if handler already returned the response object
76
140
  final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
@@ -129,22 +193,262 @@ module Tina4
129
193
  [200, { "content-type" => "application/json; charset=utf-8" }, [@openapi_json]]
130
194
  end
131
195
 
132
- def handle_403
133
- body = Tina4::Template.render_error(403) rescue "403 Forbidden"
196
+ def handle_403(path = "")
197
+ body = Tina4::Template.render_error(403, { "path" => path }) rescue "403 Forbidden"
134
198
  [403, { "content-type" => "text/html" }, [body]]
135
199
  end
136
200
 
137
201
  def handle_404(path)
138
- Tina4::Debug.warning("404 Not Found: #{path}")
139
- body = Tina4::Template.render_error(404) rescue "404 Not Found"
202
+ # Show landing page for GET "/" when no user route or template index exists
203
+ if path == "/" && should_show_landing_page?
204
+ return render_landing_page
205
+ end
206
+
207
+ Tina4::Log.warning("404 Not Found: #{path}")
208
+ body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
140
209
  [404, { "content-type" => "text/html" }, [body]]
141
210
  end
142
211
 
143
- def handle_500(error)
144
- Tina4::Debug.error("500 Internal Server Error: #{error.message}")
145
- Tina4::Debug.error(error.backtrace&.first(10)&.join("\n"))
146
- body = Tina4::Template.render_error(500) rescue "500 Internal Server Error: #{error.message}"
212
+ def should_show_landing_page?
213
+ # Check if any index template exists in src/templates/
214
+ templates_dir = File.join(@root_dir, "src", "templates")
215
+ %w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
216
+ end
217
+
218
+ def render_landing_page
219
+ port = ENV["PORT"] || "7145"
220
+
221
+ # Check deployed state for each gallery item
222
+ project_src = File.join(@root_dir, "src")
223
+ gallery_items = [
224
+ { id: "rest-api", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", icon: "🚀", accent: "red", try_url: "/api/gallery/hello", file_check: "routes/api/gallery_hello.rb" },
225
+ { id: "orm", name: "ORM", desc: "Product model with CRUD endpoints", icon: "🗃", accent: "green", try_url: "/api/gallery/products", file_check: "routes/api/gallery_products.rb" },
226
+ { id: "auth", name: "Auth", desc: "JWT login form with token display", icon: "🔒", accent: "purple", try_url: "/gallery/auth", file_check: "routes/api/gallery_auth.rb" },
227
+ { id: "queue", name: "Queue", desc: "Background job producer and consumer", icon: "⚡", accent: "red", try_url: "/api/gallery/queue/produce", file_check: "routes/api/gallery_queue.rb" },
228
+ { id: "templates", name: "Templates", desc: "Twig template with dynamic data", icon: "📄", accent: "green", try_url: "/gallery/page", file_check: "routes/gallery_page.rb" },
229
+ { id: "database", name: "Database", desc: "Raw SQL queries with the Database class", icon: "📡", accent: "purple", try_url: "/api/gallery/db/tables", file_check: "routes/api/gallery_db.rb" },
230
+ { id: "error-overlay", name: "Error Overlay", desc: "See the rich debug error page with stack trace", icon: "💥", accent: "red", try_url: "/api/gallery/crash", file_check: "routes/api/gallery_crash.rb" }
231
+ ]
232
+
233
+ gallery_cards = gallery_items.map do |item|
234
+ deployed = File.file?(File.join(project_src, item[:file_check]))
235
+ 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>' : ''
236
+ try_btn = if deployed
237
+ %(<a href="#{item[:try_url]}" class="gbtn gbtn-try" target="_blank">Try It</a>)
238
+ else
239
+ %(<button class="gbtn gbtn-deploy" onclick="deployGallery('#{item[:id]}','#{item[:try_url]}')">Deploy &amp; Try</button>)
240
+ end
241
+ view_btn = %(<button class="gbtn gbtn-view" onclick="viewGallery('#{item[:id]}')">View</button>)
242
+
243
+ <<~CARD
244
+ <div class="gallery-card">
245
+ <div class="accent accent-#{item[:accent]}"></div>
246
+ #{deployed_badge}
247
+ <div class="icon">#{item[:icon]}</div>
248
+ <h3>#{item[:name]}</h3>
249
+ <p>#{item[:desc]}</p>
250
+ <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">#{try_btn}#{view_btn}</div>
251
+ </div>
252
+ CARD
253
+ end.join
254
+
255
+ html = <<~HTML
256
+ <!DOCTYPE html>
257
+ <html lang="en">
258
+ <head>
259
+ <meta charset="utf-8">
260
+ <meta name="viewport" content="width=device-width, initial-scale=1">
261
+ <title>Tina4Ruby</title>
262
+ <style>
263
+ *{margin:0;padding:0;box-sizing:border-box}
264
+ 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}
265
+ .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
266
+ .hero{text-align:center;z-index:1;padding:3rem 2rem 2rem}
267
+ .logo{width:120px;height:120px;margin-bottom:1.5rem}
268
+ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
269
+ .tagline{color:#64748b;font-size:1.1rem;margin-bottom:2rem}
270
+ .actions{display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap;margin-bottom:2.5rem}
271
+ .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}
272
+ .btn:hover{border-color:#64748b;color:#e2e8f0}
273
+ .status{display:flex;gap:2rem;justify-content:center;align-items:center;color:#64748b;font-size:0.85rem;margin-bottom:1.5rem}
274
+ .status .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;margin-right:0.4rem}
275
+ .footer{color:#334155;font-size:0.8rem;letter-spacing:0.5px}
276
+ .section{z-index:1;width:100%;max-width:800px;padding:0 2rem;margin-bottom:2.5rem}
277
+ .card{background:#1e293b;border-radius:0.75rem;padding:2rem;border:1px solid #334155}
278
+ .card h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0}
279
+ .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}
280
+ .gallery{z-index:1;width:100%;max-width:900px;padding:0 2rem;margin-bottom:3rem}
281
+ .gallery h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0;text-align:center}
282
+ .gallery-card{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}
283
+ .gallery-card .accent{position:absolute;top:0;left:0;right:0;height:3px}
284
+ .gallery-card .accent-red{background:#CC342D}
285
+ .gallery-card .accent-green{background:#22c55e}
286
+ .gallery-card .accent-purple{background:#a78bfa}
287
+ .gallery-card .icon{font-size:1.5rem;margin-bottom:0.75rem}
288
+ .gallery-card h3{font-size:1rem;font-weight:600;margin-bottom:0.5rem;color:#e2e8f0}
289
+ .gallery-card p{font-size:0.85rem;color:#94a3b8;line-height:1.5}
290
+ .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}
291
+ .gbtn-try{background:#22c55e;color:#fff}
292
+ .gbtn-try:hover{background:#16a34a}
293
+ .gbtn-deploy{background:#CC342D;color:#fff}
294
+ .gbtn-deploy:hover{background:#a12a24}
295
+ .gbtn-view{background:transparent;color:#94a3b8;border:1px solid #334155}
296
+ .gbtn-view:hover{border-color:#64748b;color:#e2e8f0}
297
+ .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}
298
+ .view-modal.active{display:flex}
299
+ .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}
300
+ .view-modal-close{position:absolute;top:0.75rem;right:1rem;color:#94a3b8;cursor:pointer;font-size:1.25rem;background:none;border:none}
301
+ .view-modal-close:hover{color:#e2e8f0}
302
+ </style>
303
+ </head>
304
+ <body>
305
+ <img src="/images/tina4-logo-icon.webp" class="bg-watermark" alt="">
306
+ <div class="hero">
307
+ <img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
308
+ <h1>Tina4Ruby</h1>
309
+ <p class="tagline">This is not a framework</p>
310
+ <div class="actions">
311
+ <a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
312
+ <a href="/__dev" class="btn">Dev Admin</a>
313
+ <a href="#gallery" class="btn">Gallery</a>
314
+ <a href="https://github.com/tina4stack/tina4-ruby" class="btn" target="_blank">GitHub</a>
315
+ <a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank">&#11088; Star</a>
316
+ </div>
317
+ <div class="status">
318
+ <span><span class="dot"></span>Server running</span>
319
+ <span>Port #{port}</span>
320
+ <span>v#{Tina4::VERSION}</span>
321
+ </div>
322
+ <p class="footer">Zero dependencies &middot; Convention over configuration</p>
323
+ </div>
324
+ <div class="section">
325
+ <div class="card">
326
+ <h2>Getting Started</h2>
327
+ <pre class="code-block"><code><span style="color:#64748b"># app.rb</span>
328
+ <span style="color:#c084fc">require</span> <span style="color:#4ade80">"tina4"</span>
329
+
330
+ Tina4::Router.<span style="color:#38bdf8">get</span>(<span style="color:#4ade80">"/hello"</span>) <span style="color:#c084fc">do</span> |request, response|
331
+ response.<span style="color:#38bdf8">json</span>({ <span style="color:#fbbf24">message:</span> <span style="color:#4ade80">"Hello World!"</span> })
332
+ <span style="color:#c084fc">end</span>
333
+
334
+ 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>
335
+ </div>
336
+ </div>
337
+ <div class="gallery">
338
+ <h2 id="gallery">Gallery</h2>
339
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
340
+ #{gallery_cards}
341
+ </div>
342
+ </div>
343
+ <div class="view-modal" id="viewModal">
344
+ <div class="view-modal-content">
345
+ <button class="view-modal-close" onclick="document.getElementById('viewModal').classList.remove('active')">&times;</button>
346
+ <h3 id="viewModalTitle" style="margin-bottom:1rem;color:#e2e8f0;"></h3>
347
+ <div id="viewModalBody"></div>
348
+ </div>
349
+ </div>
350
+ <script>
351
+ function deployGallery(name, tryUrl) {
352
+ if (!confirm('Deploy the "' + name + '" gallery example into your project?')) return;
353
+ var btn = event.target;
354
+ btn.disabled = true;
355
+ btn.textContent = 'Deploying...';
356
+ fetch('/__dev/api/gallery/deploy', {
357
+ method: 'POST',
358
+ headers: {'Content-Type': 'application/json'},
359
+ body: JSON.stringify({ name: name })
360
+ }).then(function(r) { return r.json(); }).then(function(d) {
361
+ if (d.error) {
362
+ alert('Deploy failed: ' + d.error);
363
+ btn.disabled = false;
364
+ btn.textContent = 'Deploy & Try';
365
+ } else {
366
+ window.location.href = tryUrl;
367
+ }
368
+ }).catch(function(e) {
369
+ alert('Deploy error: ' + e.message);
370
+ btn.disabled = false;
371
+ btn.textContent = 'Deploy & Try';
372
+ });
373
+ }
374
+ function viewGallery(name) {
375
+ fetch('/__dev/api/gallery').then(function(r) { return r.json(); }).then(function(d) {
376
+ var item = (d.gallery || []).find(function(g) { return g.id === name; });
377
+ if (!item) { alert('Gallery item not found'); return; }
378
+ var title = document.getElementById('viewModalTitle');
379
+ var body = document.getElementById('viewModalBody');
380
+ title.textContent = item.name + ' — ' + item.description;
381
+ var html = '<p style="color:#94a3b8;margin-bottom:1rem;">Files that will be deployed:</p><ul style="list-style:none;padding:0;">';
382
+ (item.files || []).forEach(function(f) {
383
+ html += '<li style="padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem;">src/' + f + '</li>';
384
+ });
385
+ html += '</ul>';
386
+ if (item.try_url) {
387
+ html += '<p style="color:#94a3b8;margin-top:1rem;">Try URL: <code style="color:#38bdf8;">' + item.try_url + '</code></p>';
388
+ }
389
+ body.innerHTML = html;
390
+ document.getElementById('viewModal').classList.add('active');
391
+ });
392
+ }
393
+ document.getElementById('viewModal').addEventListener('click', function(e) {
394
+ if (e.target === this) this.classList.remove('active');
395
+ });
396
+ </script>
397
+ </body>
398
+ </html>
399
+ HTML
400
+
401
+ [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
402
+ end
403
+
404
+ def handle_500(error, env = nil)
405
+ Tina4::Log.error("500 Internal Server Error: #{error.message}")
406
+ Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
407
+ if dev_mode?
408
+ # Rich error overlay with stack trace, source context, and line numbers
409
+ body = Tina4::ErrorOverlay.render(error, request: env)
410
+ else
411
+ body = Tina4::Template.render_error(500, {
412
+ "error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
413
+ "request_id" => SecureRandom.hex(6)
414
+ }) rescue "500 Internal Server Error: #{error.message}"
415
+ end
147
416
  [500, { "content-type" => "text/html" }, [body]]
148
417
  end
418
+
419
+ def dev_mode?
420
+ Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
421
+ end
422
+
423
+ def inject_dev_overlay(body, request_info)
424
+ version = Tina4::VERSION
425
+ method = request_info[:method]
426
+ path = request_info[:path]
427
+ matched_pattern = request_info[:matched_pattern]
428
+ request_id = Tina4::Log.request_id || "-"
429
+ route_count = Tina4::Router.routes.length
430
+
431
+ toolbar = <<~HTML.strip
432
+ <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;">
433
+ <span style="color:#d32f2f;font-weight:bold;">Tina4 v#{version}</span>
434
+ <span style="color:#4caf50;">#{method}</span>
435
+ <span>#{path}</span>
436
+ <span style="color:#666;">&rarr; #{matched_pattern}</span>
437
+ <span style="color:#ffeb3b;">req:#{request_id}</span>
438
+ <span style="color:#90caf9;">#{route_count} routes</span>
439
+ <span style="color:#888;">Ruby #{RUBY_VERSION}</span>
440
+ <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;bottom:2rem;right:1rem;width:min(90vw,1200px);height:min(80vh,700px);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>
441
+ <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
442
+ </div>
443
+ HTML
444
+
445
+ if body.include?("</body>")
446
+ body.sub("</body>", "#{toolbar}\n</body>")
447
+ else
448
+ body + "\n" + toolbar
449
+ end
450
+ end
451
+
452
+
149
453
  end
150
454
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ class RateLimiter
5
+ DEFAULT_LIMIT = 100
6
+ DEFAULT_WINDOW = 60 # seconds
7
+
8
+ attr_reader :limit, :window
9
+
10
+ def initialize(limit: nil, window: nil)
11
+ @limit = (limit || ENV["TINA4_RATE_LIMIT"] || DEFAULT_LIMIT).to_i
12
+ @window = (window || ENV["TINA4_RATE_WINDOW"] || DEFAULT_WINDOW).to_i
13
+ @store = {} # ip => [timestamps]
14
+ @mutex = Mutex.new
15
+ @last_cleanup = Time.now
16
+ end
17
+
18
+ # Check if the given IP is rate limited.
19
+ # Returns a hash with rate limit info:
20
+ # { allowed: true/false, limit:, remaining:, reset:, retry_after: }
21
+ def check(ip)
22
+ now = Time.now
23
+ cleanup_if_needed(now)
24
+
25
+ @mutex.synchronize do
26
+ @store[ip] ||= []
27
+ entries = @store[ip]
28
+
29
+ # Remove expired entries (sliding window)
30
+ cutoff = now - @window
31
+ entries.reject! { |t| t < cutoff }
32
+
33
+ if entries.length >= @limit
34
+ # Rate limited
35
+ oldest = entries.first
36
+ reset_at = (oldest + @window).to_i
37
+ retry_after = [(oldest + @window - now).ceil, 1].max
38
+
39
+ {
40
+ allowed: false,
41
+ limit: @limit,
42
+ remaining: 0,
43
+ reset: reset_at,
44
+ retry_after: retry_after
45
+ }
46
+ else
47
+ entries << now
48
+
49
+ {
50
+ allowed: true,
51
+ limit: @limit,
52
+ remaining: @limit - entries.length,
53
+ reset: (now + @window).to_i,
54
+ retry_after: nil
55
+ }
56
+ end
57
+ end
58
+ end
59
+
60
+ # Convenience predicate
61
+ def rate_limited?(ip)
62
+ !check(ip)[:allowed]
63
+ end
64
+
65
+ # Apply rate limit headers to a response object and return 429 if exceeded.
66
+ # Returns [status, headers_hash] or nil if allowed.
67
+ def apply(ip, response)
68
+ result = check(ip)
69
+
70
+ # Always set rate limit headers
71
+ response.headers["X-RateLimit-Limit"] = result[:limit].to_s
72
+ response.headers["X-RateLimit-Remaining"] = result[:remaining].to_s
73
+ response.headers["X-RateLimit-Reset"] = result[:reset].to_s
74
+
75
+ unless result[:allowed]
76
+ response.headers["Retry-After"] = result[:retry_after].to_s
77
+ response.status_code = 429
78
+ response.headers["content-type"] = "application/json; charset=utf-8"
79
+ response.body = JSON.generate({
80
+ error: "Too Many Requests",
81
+ retry_after: result[:retry_after]
82
+ })
83
+ return false
84
+ end
85
+
86
+ true
87
+ end
88
+
89
+ # Reset tracking for a specific IP (useful for testing)
90
+ def reset!(ip = nil)
91
+ @mutex.synchronize do
92
+ if ip
93
+ @store.delete(ip)
94
+ else
95
+ @store.clear
96
+ end
97
+ end
98
+ end
99
+
100
+ # Returns current entry count (for monitoring)
101
+ def entry_count
102
+ @mutex.synchronize { @store.length }
103
+ end
104
+
105
+ private
106
+
107
+ # Clean up expired entries periodically (every window interval)
108
+ def cleanup_if_needed(now)
109
+ return if now - @last_cleanup < @window
110
+
111
+ @mutex.synchronize do
112
+ return if now - @last_cleanup < @window
113
+
114
+ cutoff = now - @window
115
+ @store.delete_if do |_ip, entries|
116
+ entries.reject! { |t| t < cutoff }
117
+ entries.empty?
118
+ end
119
+ @last_cleanup = now
120
+ end
121
+ end
122
+ end
123
+ end