Class: Tina4::RackApp

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/rack_app.rb

Constant Summary collapse

STATIC_DIRS =
%w[public src/public src/assets assets].freeze
FRAMEWORK_PUBLIC_DIR =

Framework's own public directory (bundled static assets like the logo)

File.expand_path("public", __dir__).freeze
DEV_RELOAD_WS_HANDLER =

WebSocket handler for the dev-reload channel (/__dev_reload).

Connections are accepted and held open; the shared Tina4::DevReload manager keeps a reference (wired in handle_websocket_upgrade) so POST /__dev/api/reload can broadcast an instant reload to every browser. Incoming frames are ignored — the open socket is the whole point.

proc { |_connection, _event, _data| nil }

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root_dir: Dir.pwd) ⇒ RackApp

Returns a new instance of RackApp.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/tina4/rack_app.rb', line 29

def initialize(root_dir: Dir.pwd)
  @root_dir = root_dir
  # Pre-compute static roots at boot (not per-request)
  # Project dirs are checked first; framework's bundled public dir is the fallback
  project_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
                             .select { |d| Dir.exist?(d) }
  fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
  @static_roots = (project_roots + fallback).freeze

  # Shared WebSocket engine for route-based WS handling. Publish it as the
  # process-wide "current" engine so Frond.push_live (and other framework
  # code) can broadcast live-block updates without a threaded reference.
  @websocket_engine = Tina4::WebSocket.new
  Tina4::WebSocket.current = @websocket_engine

  # Register the dev-reload WebSocket route (debug mode only) so a browser
  # handshake to /__dev_reload is accepted and held open by the connection
  # manager. Without this the handshake never matches a route and falls
  # through to 404, silently degrading the whole reloader to polling.
  RackApp.register_dev_reload_ws if dev_mode?
end

Class Method Details

._extract_form_token(body_str, env) ⇒ Object

Extract a formToken from the request body. Supports JSON body ({ "formToken": "..." }) and URL-encoded form data (formToken=...).



1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
# File 'lib/tina4/rack_app.rb', line 1256

def self._extract_form_token(body_str, env)
  return nil if body_str.nil? || body_str.empty?

  content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"] || ""

  if content_type.include?("application/json")
    begin
      parsed = JSON.parse(body_str)
      return parsed["formToken"] if parsed.is_a?(Hash) && parsed["formToken"]
    rescue JSON::ParserError
      # Not valid JSON — fall through
    end
  end

  # URL-encoded form data (or fallback for any content type)
  if body_str.include?("formToken=")
    match = body_str.match(/(?:^|&)formToken=([^&]+)/)
    return URI.decode_www_form_component(match[1]) if match
  end

  nil
end

._read_rack_body(env) ⇒ Object



1245
1246
1247
1248
1249
1250
1251
1252
# File 'lib/tina4/rack_app.rb', line 1245

def self._read_rack_body(env)
  input = env["rack.input"]
  return "" unless input
  input.rewind if input.respond_to?(:rewind)
  body = input.read || ""
  input.rewind if input.respond_to?(:rewind)
  body
end

.enforce_route_auth(env, route) ⇒ Object

Read and rewind the Rack input body. Returns the raw body string. Enforce the secure-by-default write-route auth gate.

Returns nil when the route is public OR a valid token is present (and, as a side effect, sets env and, for a body formToken, env). Returns a Rack 401 tuple when an auth-required route has no valid token.

A CLASS method on purpose: both the live RackApp#handle_route and the in-process TestClient call it, so the test surface enforces the identical gate as production (Python #PY2 parity). Instantiating a RackApp just to reach the check would run full boot/route-discovery — this needs none of it.



1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
# File 'lib/tina4/rack_app.rb', line 1193

def self.enforce_route_auth(env, route)
  return nil unless route.auth_required

  token = nil
  token_source = nil  # :header, :body, :session

  # Priority 1: Authorization Bearer header
  auth_header = env["HTTP_AUTHORIZATION"] || ""
  if auth_header =~ /\ABearer\s+(.+)\z/i
    token = Regexp.last_match(1)
    token_source = :header
  end

  # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
  if token.nil?
    body_str = _read_rack_body(env)
    form_token = _extract_form_token(body_str, env)
    if form_token && !form_token.empty?
      token = form_token
      token_source = :body
    end
  end

  # Priority 3: Session token (for secured GET routes after login)
  if token.nil?
    session = Tina4::Session.new(env)
    session_token = session.get("token")
    if session_token && !session_token.empty?
      token = session_token
      token_source = :session
    end
  end

  # API_KEY bypass — matches tina4_python behavior
  api_key = ENV["TINA4_API_KEY"]
  if api_key && !api_key.empty? && token == api_key
    env["tina4.auth_payload"] = { "api_key" => true }
  elsif token
    unless Tina4::Auth.valid_token(token)
      return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
    end
    env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)

    # When body formToken validates, store a refreshed token for the FreshToken response header
    env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if token_source == :body
  else
    return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
  end

  nil
end

.register_dev_reload_wsObject

Register the /__dev_reload WebSocket route (idempotent). Guarded on the router's actual state rather than a one-shot flag so that a Router.clear! (specs, hot-reload rescans) followed by a fresh RackApp re-registers it.



62
63
64
65
66
# File 'lib/tina4/rack_app.rb', line 62

def self.register_dev_reload_ws
  return if Tina4::Router.find_ws_route("/__dev_reload")

  Tina4::Router.websocket("/__dev_reload", &DEV_RELOAD_WS_HANDLER)
end

Instance Method Details

#call(env) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/tina4/rack_app.rb', line 68

def call(env)
  method = env["REQUEST_METHOD"]
  path = env["PATH_INFO"] || "/"
  request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  # Request-scoped query cache boundary (v3.13.23). Tina4 Ruby runs a
  # long-running Rack server, so the request-scoped DB cache (default-on)
  # would otherwise serve rows from a previous request. Clear it on every
  # live connection at the very start of each request, before any routing.
  # No-op for persistent-mode (TINA4_DB_CACHE=true) connections.
  Tina4::Database.reset_request_caches if defined?(Tina4::Database)

  # Fast-path: CORS preflight. Real CORS preflight requests carry an
  # Origin header AND an Access-Control-Request-Method header — the
  # browser is asking "may I send this method?" before the actual
  # request. If neither is present, the OPTIONS is a plain protocol-
  # introspection request (link checker, monitoring probe, RFC 9110
  # §9.3.7 OPTIONS) and must fall through to the router's generic
  # Allow-header response. Otherwise we'd shadow the framework's own
  # OPTIONS support and force every operator to hand-register CORS
  # exceptions for every introspection client.
  if method == "OPTIONS" && (env["HTTP_ORIGIN"] || env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"])
    return Tina4::CorsMiddleware.preflight_response(env)
  end

  # WebSocket upgrade — match against registered ws_routes
  if websocket_upgrade?(env)
    ws_result = Tina4::Router.find_ws_route(path)
    if ws_result
      ws_route, ws_params = ws_result
      return handle_websocket_upgrade(env, ws_route, ws_params)
    end
  end

  # Dev dashboard routes (handled before anything else)
  if path.start_with?("/__dev")
    # Block live-reload endpoint on the AI port — AI tools must get stable responses
    if path == "/__dev_reload" && env["tina4.ai_port"]
      return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
    end
    dev_response = Tina4::DevAdmin.handle_request(env)
    return dev_response if dev_response
  end

  # Customer feedback widget routes (parity with Python's /__feedback/*
  # surface — see tina4/feedback.rb). Always available — the master
  # switch (TINA4_ENABLE_FEEDBACK) is enforced INSIDE handle_request
  # so route shape stays stable across environments.
  if path.start_with?("/__feedback")
    fb_response = Tina4::Feedback.handle_request(env)
    return fb_response if fb_response
  end

  # Fast-path: API routes skip static file + swagger checks entirely
  unless path.start_with?("/api/")
    # Swagger
    if path == "/swagger" || path == "/swagger/"
      return serve_swagger_ui
    end
    if path == "/swagger/openapi.json"
      return serve_openapi_json
    end

    # Static files (only for non-API paths)
    static_response = try_static(path, env)
    return static_response if static_response
  end

  # Route matching
  result = Tina4::Router.match(method, path)
  if result
    route, path_params = result
    rack_response = handle_route(env, route, path_params)
    matched_pattern = route.path
  else
    # RFC 9110 conformance — before falling through to 404, check whether
    # the PATH is known to the router under any OTHER method.
    #   - OPTIONS request → 204 with Allow header (§9.3.7). Bare OPTIONS
    #     on an unknown path also returns 204 (empty Allow header) —
    #     OPTIONS is a discovery method; rejecting unknown probes with
    #     404 confuses link checkers and monitoring tools and breaks
    #     CORS preflight that lacks the Origin/ACRM headers our earlier
    #     fast-path requires. Matches PHP/Node behaviour. Fixes
    #     spec/rack_app_spec.rb OPTIONS preflight.
    #   - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
    #     → 405 with Allow header (§15.5.6 + §10.2.1) when the path
    #     exists; → 404 when nothing about the path is known.
    allowed = Tina4::Router.methods_allowed_for_path(path)
    if method.to_s.upcase == "OPTIONS"
      allow_header = allowed.empty? ? "" : allowed.join(", ")
      rack_response = [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
      matched_pattern = nil
    elsif !allowed.empty?
      allow_header = allowed.join(", ")
      body = %({"error":"Method Not Allowed","path":"#{path}","method":"#{method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
      rack_response = [405, {
        "allow" => allow_header,
        "content-type" => "application/json",
        "content-length" => body.bytesize.to_s
      }, [body]]
      matched_pattern = nil
    else
      rack_response = handle_404(path)
      matched_pattern = nil
    end
  end

  # RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip
  # the body unconditionally and record what Content-Length the GET
  # would have sent. Cache validators / link checkers / monitoring
  # probes use that header to estimate sizes.
  if method.to_s.upcase == "HEAD"
    status, headers, body_parts = rack_response
    joined = body_parts.respond_to?(:join) ? body_parts.join : body_parts.to_s
    unless joined.empty?
      new_headers = headers.dup
      new_headers["content-length"] = joined.bytesize.to_s
      rack_response = [status, new_headers, [""]]
    end
  end

  # Capture request for dev inspector
  if dev_mode? && !path.start_with?("/__dev")
    duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
    Tina4::DevAdmin.request_inspector.capture(
      method: method,
      path: path,
      status: rack_response[0],
      duration: duration_ms
    )
  end

  # Request log line (v3.13.14). The dev inspector above only feeds the
  # /__dev UI — it never reached stdout, so `tina4ruby serve` printed the
  # banner then went silent. Emit a per-request line through Tina4::Log so
  # it lands on stdout (docker logs / k8s). On by default in dev, opt-in in
  # production via TINA4_LOG_REQUESTS. Same format across all four frameworks.
  if request_logging_enabled? && !path.start_with?("/__dev")
    log_elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
    Tina4::Log.info("#{method} #{path} -> #{rack_response[0]} (#{log_elapsed}ms)")
  end

  # Inject dev overlay button for HTML responses in dev mode
  if dev_mode? && !path.start_with?("/__dev")
    status, headers, body_parts = rack_response
    content_type = headers["content-type"] || ""
    if content_type.include?("text/html")
      request_info = {
        method: method,
        path: path,
        matched_pattern: matched_pattern || "(no match)",
      }
      joined = body_parts.join
      overlay = inject_dev_overlay(joined, request_info, ai_port: env["tina4.ai_port"])
      rack_response = [status, headers, [overlay]]
    end
  end

  # Customer feedback widget injection — runs LAST so its <script>
  # tag survives any earlier post-processing. No-op if disabled
  # (TINA4_ENABLE_FEEDBACK off), the user isn't whitelisted, the
  # path is /__dev or /__feedback, or the body isn't text/html with
  # a closing </body> tag. Mirrors Python's server.py call site —
  # see tina4_python/core/server.py around line 1543.
  begin
    status, headers, body_parts = rack_response
    content_type = headers["content-type"] || ""
    if content_type.include?("text/html") && body_parts.respond_to?(:join)
      joined = body_parts.join
      if joined.include?("</body>")
        injected = Tina4::Feedback.inject_feedback_widget(
          Struct.new(:path, :env).new(path, env),
          joined
        )
        if injected != joined
          new_headers = headers.dup
          new_headers["content-length"] = injected.bytesize.to_s if new_headers["content-length"]
          rack_response = [status, new_headers, [injected]]
        end
      end
    end
  rescue StandardError
    # Injection is best-effort — never break the response.
  end

  # Save session and set cookie if session was used
  if result && defined?(rack_response)
    status, headers, body_parts = rack_response
    request_obj = env["tina4.request"]
    if request_obj&.instance_variable_get(:@session)
      sess = request_obj.session
      sess.save

      # Probabilistic garbage collection (~1% of requests)
      if rand(1..100) == 1
        begin
          sess.gc
        rescue StandardError
          # GC failure is non-critical — silently ignore
        end
      end

      sid = sess.id
      cookie_val = (env["HTTP_COOKIE"] || "")[/tina4_session=([^;]+)/, 1]
      if sid && sid != cookie_val
        ttl = Integer(ENV.fetch("TINA4_SESSION_TTL", 3600))
        headers["set-cookie"] = "tina4_session=#{sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{ttl}"
      end
      rack_response = [status, headers, body_parts]
    end
  end

  rack_response
rescue => e
  handle_500(e, env)
end

#handle(request) ⇒ Object

Dispatch a pre-built Request through the Rack app and return the Rack response triple. Useful for testing and embedding without starting an HTTP server.



287
288
289
290
291
# File 'lib/tina4/rack_app.rb', line 287

def handle(request)
  env = request.env
  env["rack.input"].rewind if env["rack.input"].respond_to?(:rewind)
  call(env)
end