Class: Tina4::RackApp
- Inherits:
-
Object
- Object
- Tina4::RackApp
- Includes:
- DispatchPipeline
- 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.("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 }
Constants included from DispatchPipeline
DispatchPipeline::ALWAYS_STAGES, DispatchPipeline::REQUEST_STAGES, DispatchPipeline::RESPONSE_STAGES, DispatchPipeline::ROUTE_STAGES
Class Attribute Summary collapse
-
.current ⇒ Object
The process-wide RackApp — the app actually serving traffic.
Class Method Summary collapse
-
._extract_form_token(body_str, env) ⇒ Object
Extract a formToken from the request body.
- ._read_rack_body(env) ⇒ Object
-
.enforce_route_auth(env, route) ⇒ Object
Read and rewind the Rack input body.
-
.register_dev_reload_ws ⇒ Object
Register the /__dev_reload WebSocket route (idempotent).
Instance Method Summary collapse
-
#call(env) ⇒ Object
Run the dispatch pipeline.
-
#handle(request) ⇒ Object
Dispatch a pre-built Request through the Rack app and return the Rack response triple.
-
#initialize(root_dir: Dir.pwd) ⇒ RackApp
constructor
A new instance of RackApp.
Constructor Details
#initialize(root_dir: Dir.pwd) ⇒ RackApp
Returns a new instance of RackApp.
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/tina4/rack_app.rb', line 41 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? # Publish as the process-wide app (see RackApp.current). RackApp.current = self end |
Class Attribute Details
.current ⇒ Object
The process-wide RackApp — the app actually serving traffic. Set by #initialize (last one wins, the same convention as Tina4::WebSocket.current). Tina4::TestClient defaults to this so an in-process test request runs through the SAME app object a live request does, instead of building a second one with a different static root and clobbering the shared WebSocket engine. (feature-recount D6)
31 32 33 |
# File 'lib/tina4/rack_app.rb', line 31 def current @current 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=...).
1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 |
# File 'lib/tina4/rack_app.rb', line 1075 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
1064 1065 1066 1067 1068 1069 1070 1071 |
# File 'lib/tina4/rack_app.rb', line 1064 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.
1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 |
# File 'lib/tina4/rack_app.rb', line 1004 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? # Request path, so the same log-loud-then-degrade policy as # Request#session (ADR-0021): an unreachable session store must not turn # the auth gate into a 500. It degrades to an empty session, which means # no token, which means the ordinary 401 below - a SERVED request. session = Tina4::Session.new(env, degrade_on_backend_failure: true) session_token = session.get("token") if session_token && !session_token.empty? token = session_token token_source = :session end end # API_KEY bypass — routed through the timing-safe Tina4::Auth.validate_api_key # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth. # It used to be a plain `token == api_key`, which returns as soon as two # bytes differ — so response timing leaks the key prefix and the key can be # recovered a character at a time. validate_api_key also covers the unset / # blank / wrong-length cases the old guard spelled out by hand. if Tina4::Auth.validate_api_key(token) env["tina4.auth_payload"] = { "_auth" => "api_key" } 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_ws ⇒ Object
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.
77 78 79 80 81 |
# File 'lib/tina4/rack_app.rb', line 77 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
Run the dispatch pipeline. See REQUEST_STAGES / RESPONSE_STAGES above.
Every branch this used to hold now lives in a named stage, so the only control flow left here is "walk the list, stop when a stage answers".
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 |
# File 'lib/tina4/rack_app.rb', line 87 def call(env) ctx = DispatchContext.new( env: env, method: env["REQUEST_METHOD"], path: env["PATH_INFO"] || "/", started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), bypass_response_stages: false ) response = nil REQUEST_STAGES.each do |stage| response = send(stage, ctx) break if response end unless ctx.bypass_response_stages RESPONSE_STAGES.each do |stage| replacement = send(stage, ctx, response) response = replacement if replacement end end # LAST, and unconditionally. RFC 9110 s9.3.2 applies to EVERY response # however it was produced - the swagger and static branches that skip the # stages above, AND anything those stages added. # # Running it FIRST was wrong twice over: the static branches skipped it # (the bug this group was created to fix), and in dev mode # dev_toolbar_inject then put 8.5KB of markup back into an # already-stripped HEAD response. CI caught the second one because it # sets TINA4_DEBUG; a local run without it did not. # # Running it last also makes Content-Length right: it reports the body # AFTER injection, which is exactly what the equivalent GET would send # (s9.3.2 SHOULD - same headers as the GET). ALWAYS_STAGES.each do |stage| replacement = send(stage, ctx, response) response = replacement if replacement end response rescue Tina4::Request::PayloadTooLarge => e # 413, not 500. PayloadTooLarge was raised by Request and rescued by # nobody, so it fell into the generic handler below and an oversized # upload answered "Internal Server Error" - which tells the caller to # retry the exact request that will fail again. # # Measured on Puma with a 1MB TINA4_MAX_UPLOAD_SIZE and an 8MB body: # HTTP 500, and the same for a chunked body. Memory stayed flat (Puma # bounds the read), so unlike Node and Python this was only ever the # status code - but the status code is what a client acts on. body = JSON.generate({ "error" => e. }) [413, { "content-type" => "application/json", "content-length" => body.bytesize.to_s }, [body]] 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.
147 148 149 150 151 |
# File 'lib/tina4/rack_app.rb', line 147 def handle(request) env = request.env env["rack.input"].rewind if env["rack.input"].respond_to?(:rewind) call(env) end |