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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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: "🚀", 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: "🗃", 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: "🔒", 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: "⚡", 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: "📄", 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: "📡", 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: "💥", 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 & 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. | 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">☆</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 · 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')">×</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;">×</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;">→ #{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 ↗</a>
|
|
718
|
-
<span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">✕</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> — 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;">⚠ Major/minor version change — 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> — not yet published).';
|
|
757
|
-
el.style.color='#cba6f7';
|
|
758
|
-
}else{
|
|
759
|
-
el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> — 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: "🚀", 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: "🗃", 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: "🔒", 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: "⚡", 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: "📄", 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: "📡", 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: "💥", 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 & 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. | 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">☆</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 · 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')">×</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;">×</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;">→ #{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 ↗</a>
|
|
718
|
+
<span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">✕</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> — 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;">⚠ Major/minor version change — 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> — not yet published).';
|
|
757
|
+
el.style.color='#cba6f7';
|
|
758
|
+
}else{
|
|
759
|
+
el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> — 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
|