Class: Tina4::RackApp

Inherits:
Object
  • Object
show all
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.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.



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

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



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



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



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