Class: Tina4::RackApp

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

Constant Summary collapse

STATIC_DIRS =

ONE static search set + order across the four frameworks (ST-SEARCHDIR-DIVERGE): the app's public then src/public. TINA4_PUBLIC_DIR is prepended per-request in #try_static, and the framework's bundled public dir is appended as the fallback in #initialize.

%w[public src/public].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 }

Constants included from DispatchPipeline

DispatchPipeline::ALWAYS_STAGES, DispatchPipeline::REQUEST_STAGES, DispatchPipeline::RESPONSE_STAGES, DispatchPipeline::ROUTE_STAGES

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root_dir: Dir.pwd) ⇒ RackApp

Returns a new instance of RackApp.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/tina4/rack_app.rb', line 46

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

.currentObject

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)



32
33
34
# File 'lib/tina4/rack_app.rb', line 32

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=...).



1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
# File 'lib/tina4/rack_app.rb', line 1217

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



1207
1208
1209
1210
1211
1212
1213
# File 'lib/tina4/rack_app.rb', line 1207

def self._read_rack_body(env)
  # Route through the capped reader so the running per-chunk upload cap is
  # enforced on the form-token / body path too: an over-limit body raises
  # PayloadTooLarge (rescued into 413 by dispatch_pipeline) instead of being
  # read whole.
  Tina4::Request.read_stream_capped(env["rack.input"])
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.



1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
# File 'lib/tina4/rack_app.rb', line 1147

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_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.



82
83
84
85
86
# File 'lib/tina4/rack_app.rb', line 82

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

Rack entry point. Establishes the per-request correlation id (feature 43), runs the dispatch pipeline, and echoes the id on the response.



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/tina4/rack_app.rb', line 90

def call(env)
  # Honour a sanitized inbound X-Request-ID so a client or upstream service
  # can thread its own id through - a CR/LF, over-long or illegal-charset
  # value is rejected (never echoed) - else generate one. Thread it into the
  # logger NOW (thread-local, so a Puma worker thread never sees another
  # request's id), so every log line for this request carries it, and echo it
  # on the response whatever outcome the pipeline produced (200/404/500/413).
  request_id = Tina4::Log.sanitize_request_id(env["HTTP_X_REQUEST_ID"]) || SecureRandom.hex(4)
  Tina4::Log.set_request_id(request_id)

  begin
    result = dispatch_pipeline(env)
    result[1]["x-request-id"] = request_id if result.is_a?(Array) && result[1].is_a?(Hash)
    result
  ensure
    # The request pipeline installs the id before its first log and
    # clears it in `finally`/`ensure` after its last (Decision 12 /
    # LOG-Q03), so an overlapping request on another thread can never
    # observe a stale id from a request that already finished.
    Tina4::Log.clear_request_id
  end
end

#dispatch_pipeline(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".



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
# File 'lib/tina4/rack_app.rb', line 117

def dispatch_pipeline(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.message })
  [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.



177
178
179
180
181
# File 'lib/tina4/rack_app.rb', line 177

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