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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +434 -544
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +389 -97
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +144 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1497 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +562 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +463 -35
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +162 -6
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +331 -27
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +551 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +118 -21
- metadata +68 -8
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
31
|
-
return
|
|
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
|
-
#
|
|
73
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 & 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">⭐ 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 · 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')">×</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;">→ #{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 ↗</a>
|
|
441
|
+
<span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">✕</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
|