Class: Dommy::Rack::Navigation

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/rack/navigation.rb,
sig/dommy/rack.rbs

Overview

URL resolution, redirect following, same-origin enforcement, meta refresh.

Constant Summary collapse

KEEP_METHOD_STATUSES =
[307, 308].freeze
QUERY_METHODS =

Verbs whose params belong in the URL query (vs a request body).

%w[GET HEAD].freeze

Instance Method Summary collapse

Constructor Details

#initialize(session, config) ⇒ Navigation

Returns a new instance of Navigation.

Parameters:



16
17
18
19
# File 'lib/dommy/rack/navigation.rb', line 16

def initialize(session, config)
  @session = session
  @config = config
end

Instance Method Details

#append_query(url, params) ⇒ Object

Merge ordered [name, value] params into url's query, preserving any existing query and keeping a fragment last (browser address-bar form).



35
36
37
38
39
40
41
42
# File 'lib/dommy/rack/navigation.rb', line 35

def append_query(url, params)
  encoded = URI.encode_www_form(params)
  return url if encoded.empty?

  base, hash, fragment = url.to_s.partition("#")
  sep = base.include?("?") ? "&" : "?"
  "#{base}#{sep}#{encoded}#{hash}#{fragment}"
end

#check_same_origin!(url) ⇒ void

This method returns an undefined value.

Parameters:

  • url (String)

Raises:



44
45
46
47
48
49
# File 'lib/dommy/rack/navigation.rb', line 44

def check_same_origin!(url)
  return unless @config.enforce_same_origin
  return if same_origin?(url, @config.default_host)

  raise CrossOriginError, "cross-origin request to #{url} is not allowed"
end

#fetch(url, method: "GET", params: nil, body: nil, headers: {}, redirect: :follow) ⇒ Response

Fetch-style request: resolves and enforces origin, runs the redirect loop per mode, and returns the Response without touching session state.

Parameters:

  • url (Object)
  • method: (Object) (defaults to: "GET")
  • params: (params, nil) (defaults to: nil)
  • body: (Object) (defaults to: nil)
  • headers: (headers) (defaults to: {})
  • redirect: (redirect_mode) (defaults to: :follow)

Returns:



78
79
80
81
82
83
# File 'lib/dommy/rack/navigation.rb', line 78

def fetch(url, method: "GET", params: nil, body: nil, headers: {}, redirect: :follow)
  verb = method.to_s.upcase
  target = resolve_url(url, @session.current_url)
  check_same_origin!(target)
  run_fetch(verb, target, params: params, body: body, headers: headers, redirect: redirect)
end

#fetch_resolved(exchange, method, target, params: nil, body: nil, headers: {}, redirect: :follow) ⇒ Object

Worker-safe variant of #fetch: target is already absolute and origin- checked (the page thread did both before handing off), and every request in the redirect loop is issued through exchange (which touches only thread-safe state) instead of the session. Returns the Response. This is the primitive a network worker runs for the async-network path.



90
91
92
93
# File 'lib/dommy/rack/navigation.rb', line 90

def fetch_resolved(exchange, method, target, params: nil, body: nil, headers: {}, redirect: :follow)
  run_fetch(method.to_s.upcase, target, params: params, body: body, headers: headers,
            redirect: redirect, exchange: exchange)
end

Perform a navigation, following redirects per session policy, then apply the final response to the session (updating document + history).

Parameters:

  • method: (Object)
  • url: (Object)
  • params: (params, nil) (defaults to: nil)
  • body: (Object) (defaults to: nil)
  • headers: (headers) (defaults to: {})

Returns:



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/dommy/rack/navigation.rb', line 53

def navigate(method:, url:, params: nil, body: nil, headers: {}, replace: false)
  return navigate_about(url.to_s) if url.to_s.start_with?("about:")

  verb = method.to_s.upcase
  target = resolve_url(url, @session.current_url)
  # A GET-style navigation carries its data in the URL: fold params into the
  # query so current_url (the address the user sees, and reloads) reflects
  # what was submitted — exactly what a browser shows after a GET form. POST
  # keeps params as the request body. (params and body are mutually
  # exclusive, so a GET never has a body to conflict with.)
  if params && QUERY_METHODS.include?(verb)
    target = append_query(target, params)
    params = nil
  end
  check_same_origin!(target)

  response, final_url = run(method: verb, url: target, params: params, body: body, headers: headers)
  # replace: a location.replace() / reload() / redirect updates the current
  # history entry in place rather than pushing a new one.
  @session.apply_navigation_response(response, final_url, replace: replace)
  maybe_follow_meta_refresh(response) || response
end

#resolve_url(url_or_path, base_url) ⇒ String

Resolve a possibly-relative URL against a base (current URL or host).

Parameters:

  • url_or_path (Object)
  • base_url (String, nil)

Returns:

  • (String)


22
23
24
25
26
27
28
29
30
31
# File 'lib/dommy/rack/navigation.rb', line 22

def resolve_url(url_or_path, base_url)
  base = base_url || @config.default_host
  # A link/redirect target may carry raw UTF-8 (e.g. /hashtag/応援); the
  # ASCII-only URI parser needs it percent-encoded first (what a browser
  # does), or URI.join raises and the rescue would leak a non-ASCII URL
  # that crashes downstream (cookie matching, request building).
  URI.join(base, Url.encode_iri(url_or_path)).to_s
rescue URI::InvalidURIError
  url_or_path.to_s
end

#revisit(url) ⇒ Response

Re-navigate to an already-resolved URL without pushing a new history entry (used by Session#back / #forward).

Parameters:

  • url (String)

Returns:



97
98
99
100
101
# File 'lib/dommy/rack/navigation.rb', line 97

def revisit(url)
  response, final_url = run(method: "GET", url: url)
  @session.apply_navigation_response(response, final_url, push_history: false)
  response
end

#run(method:, url:, params: nil, body: nil, headers: {}, follow: true, exchange: nil) ⇒ [ Response, String ]

Run the request/redirect loop. Returns [response, final_url]. Public so Session#fetch can reuse it without applying navigation state.

Parameters:

  • method: (Object)
  • url: (Object)
  • params: (params, nil) (defaults to: nil)
  • body: (Object) (defaults to: nil)
  • headers: (headers) (defaults to: {})
  • follow: (Boolean) (defaults to: true)

Returns:



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
# File 'lib/dommy/rack/navigation.rb', line 105

def run(method:, url:, params: nil, body: nil, headers: {}, follow: true, exchange: nil)
  verb = method
  target = url
  # Carry the fragment across redirects: a redirect Location without its
  # own fragment preserves the previous one (browser behavior).
  fragment = uri_fragment(target)
  redirect_count = 0
  chain = []

  loop do
    # Default path issues through the session (page thread). When an
    # `exchange` is injected, the very same loop runs on a network worker.
    response =
      if exchange
        exchange.request(verb, target, params: params, body: body, headers: headers)
      else
        @session.raw_request(verb, target, params: params, body: body, headers: headers)
      end

    unless follow && redirect_to_follow?(response)
      response.redirects = chain
      return [response, with_fragment(target, fragment)]
    end

    chain << {status: response.status, url: target, location: response.location_header}
    redirect_count += 1
    if redirect_count > @config.max_redirects
      raise TooManyRedirectsError, "exceeded #{@config.max_redirects} redirects"
    end

    target = resolve_url(response.location_header, target)
    check_same_origin!(target)
    location_fragment = uri_fragment(target)
    fragment = location_fragment unless location_fragment.nil? || location_fragment.empty?

    unless KEEP_METHOD_STATUSES.include?(response.status)
      params = nil
      body = nil
    end
    verb = redirect_method(response.status, verb)
  end
end