wrap_it_ruby 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import SortableTree from "sortable-tree"
3
+ import { patch, post, destroy } from "@rails/request.js"
4
+
5
+ // Wraps the sortable-tree library as a Stimulus controller.
6
+ // Handles drag-to-sort, click-to-edit, and CRUD via modals.
7
+
8
+ export default class extends Controller {
9
+ static values = {
10
+ nodes: Array,
11
+ sortUrl: String,
12
+ lockRoot: { type: Boolean, default: true },
13
+ collapseLevel: { type: Number, default: 2 },
14
+ }
15
+
16
+ connect() {
17
+ this.tree = new SortableTree({
18
+ nodes: this.nodesValue,
19
+ element: this.element,
20
+ lockRootLevel: this.lockRootValue,
21
+ initCollapseLevel: this.collapseLevelValue,
22
+ styles: {
23
+ tree: "st-tree",
24
+ node: "st-node",
25
+ nodeHover: "st-node--hover",
26
+ nodeDragging: "st-node--dragging",
27
+ nodeDropBefore: "st-node--drop-before",
28
+ nodeDropInside: "st-node--drop-inside",
29
+ nodeDropAfter: "st-node--drop-after",
30
+ label: "st-label",
31
+ subnodes: "st-subnodes",
32
+ collapse: "st-collapse",
33
+ },
34
+ icons: {
35
+ collapsed: '<i class="caret right icon"></i>',
36
+ open: '<i class="caret down icon"></i>',
37
+ },
38
+ renderLabel: (data) => {
39
+ const icon = data.icon ? `<i class="${data.icon} icon"></i> ` : ""
40
+ const route = data.route ? `<span class="st-route">${data.route}</span>` : ""
41
+ const typeBadge = data.item_type === "group"
42
+ ? '<span class="ui mini label">group</span> '
43
+ : ""
44
+ return `<span class="st-label-inner">${icon}${typeBadge}<strong>${data.title}</strong>${route}</span>`
45
+ },
46
+ onChange: async ({ nodes, movedNode, srcParentNode, targetParentNode }) => {
47
+ if (!this.hasSortUrlValue) return
48
+ await this.persistTree(nodes)
49
+ },
50
+ onClick: (event, node) => {
51
+ this.editNode(node)
52
+ },
53
+ })
54
+
55
+ // Expose global functions for the modal buttons
56
+ this.registerGlobalFunctions()
57
+ }
58
+
59
+ disconnect() {
60
+ if (this.tree) {
61
+ this.tree.destroy()
62
+ this.tree = null
63
+ }
64
+ this.unregisterGlobalFunctions()
65
+ }
66
+
67
+ // -- Edit --
68
+
69
+ editNode(node) {
70
+ const data = node.data
71
+ this._currentEditId = data.id
72
+
73
+ document.getElementById("menu-edit-id").value = data.id
74
+ document.getElementById("menu-edit-label").value = data.title || ""
75
+ document.getElementById("menu-edit-icon").value = data.icon || ""
76
+ document.getElementById("menu-edit-route").value = data.route || ""
77
+ document.getElementById("menu-edit-url").value = data.url || ""
78
+ document.getElementById("menu-edit-type").value = data.item_type || "proxy"
79
+
80
+ menuSettingsToggleProxyFields("menu-edit")
81
+
82
+ $("#menu-edit-modal").modal({ allowMultiple: true }).modal("show")
83
+ }
84
+
85
+ async saveNode() {
86
+ const id = document.getElementById("menu-edit-id").value
87
+ const data = {
88
+ label: document.getElementById("menu-edit-label").value,
89
+ icon: document.getElementById("menu-edit-icon").value,
90
+ route: document.getElementById("menu-edit-route").value,
91
+ url: document.getElementById("menu-edit-url").value,
92
+ item_type: document.getElementById("menu-edit-type").value,
93
+ }
94
+
95
+ await patch(`/menu/settings/${id}`, {
96
+ body: JSON.stringify(data),
97
+ contentType: "application/json",
98
+ responseKind: "turbo-stream",
99
+ })
100
+
101
+ $("#menu-edit-modal").modal("hide")
102
+ }
103
+
104
+ async deleteNode() {
105
+ const id = document.getElementById("menu-edit-id").value
106
+ if (!confirm("Delete this item and all its children?")) return
107
+
108
+ await destroy(`/menu/settings/${id}`, {
109
+ responseKind: "turbo-stream",
110
+ })
111
+
112
+ $("#menu-edit-modal").modal("hide")
113
+ }
114
+
115
+ // -- Add --
116
+
117
+ showAdd() {
118
+ document.getElementById("menu-add-label").value = ""
119
+ document.getElementById("menu-add-icon").value = ""
120
+ document.getElementById("menu-add-route").value = ""
121
+ document.getElementById("menu-add-url").value = ""
122
+ document.getElementById("menu-add-type").value = "group"
123
+ document.getElementById("menu-add-parent").value = ""
124
+
125
+ menuSettingsToggleProxyFields("menu-add")
126
+
127
+ $("#menu-add-modal").modal({ allowMultiple: true }).modal("show")
128
+ }
129
+
130
+ async createNode() {
131
+ const data = {
132
+ label: document.getElementById("menu-add-label").value,
133
+ icon: document.getElementById("menu-add-icon").value,
134
+ route: document.getElementById("menu-add-route").value,
135
+ url: document.getElementById("menu-add-url").value,
136
+ item_type: document.getElementById("menu-add-type").value,
137
+ parent_id: document.getElementById("menu-add-parent").value || null,
138
+ }
139
+
140
+ await post("/menu/settings", {
141
+ body: JSON.stringify(data),
142
+ contentType: "application/json",
143
+ responseKind: "turbo-stream",
144
+ })
145
+
146
+ $("#menu-add-modal").modal("hide")
147
+ }
148
+
149
+ // -- Sort --
150
+
151
+ async persistTree(nodes) {
152
+ const ordering = this.flattenTree(nodes)
153
+ await patch(this.sortUrlValue, {
154
+ body: JSON.stringify({ ordering }),
155
+ contentType: "application/json",
156
+ responseKind: "turbo-stream",
157
+ })
158
+ }
159
+
160
+ flattenTree(nodes, parentId = null) {
161
+ const result = []
162
+ nodes.forEach((node, index) => {
163
+ result.push({
164
+ id: node.element.data.id,
165
+ parent_id: parentId,
166
+ position: index + 1,
167
+ })
168
+ if (node.subnodes && node.subnodes.length > 0) {
169
+ result.push(...this.flattenTree(node.subnodes, node.element.data.id))
170
+ }
171
+ })
172
+ return result
173
+ }
174
+
175
+ // -- Global functions (called by modal button onclick handlers) --
176
+
177
+ registerGlobalFunctions() {
178
+ window.menuSettingsSave = () => this.saveNode()
179
+ window.menuSettingsDelete = () => this.deleteNode()
180
+ window.menuSettingsCreate = () => this.createNode()
181
+ window.menuSettingsShowAdd = () => this.showAdd()
182
+ window.menuSettingsToggleProxyFields = (prefix) => {
183
+ const type = document.getElementById(`${prefix}-type`).value
184
+ const proxyFields = document.getElementById(`${prefix}-proxy-fields`)
185
+ if (proxyFields) {
186
+ proxyFields.style.display = type === "proxy" ? "" : "none"
187
+ }
188
+ }
189
+ }
190
+
191
+ unregisterGlobalFunctions() {
192
+ delete window.menuSettingsSave
193
+ delete window.menuSettingsDelete
194
+ delete window.menuSettingsCreate
195
+ delete window.menuSettingsShowAdd
196
+ delete window.menuSettingsToggleProxyFields
197
+ }
198
+ }
@@ -0,0 +1 @@
1
+ <div id="menu-tree-container"><%= sortable_menu_tree %></div>
@@ -0,0 +1 @@
1
+ <%= render partial: "wrap_it_ruby/menu_settings/tree" %>
data/config/importmap.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # Importmap pins for the WrapItRuby engine.
2
2
  # These get merged into the host app's importmap via the engine initializer.
3
3
 
4
- pin_all_from WrapItRuby::Engine.root.join("app/javascript/wrap_it_ruby/controllers"),
5
- under: "controllers/wrap_it_ruby",
6
- to: "wrap_it_ruby/controllers"
4
+ pin_all_from WrapItRuby::Engine.root.join('app/javascript/wrap_it_ruby/controllers'),
5
+ under: 'controllers/wrap_it_ruby',
6
+ to: 'wrap_it_ruby/controllers'
7
+
8
+ # Sortable tree + request helpers
9
+ pin 'sortable-tree', to: 'https://esm.sh/sortable-tree@0.7.6'
10
+ pin '@rails/request.js', to: 'https://ga.jspm.io/npm:@rails/request.js@0.0.11/src/index.js'
data/config/routes.rb CHANGED
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  WrapItRuby::Engine.routes.draw do
4
- get "/*path", to: "proxy#show", constraints: ->(req) {
4
+ get 'menu/settings', to: 'menu_settings#index', as: :menu_settings
5
+ post 'menu/settings', to: 'menu_settings#create', as: :create_menu_setting
6
+ patch 'menu/settings/:id/sort', to: 'menu_settings#sort', as: :sort_menu_setting
7
+ patch 'menu/settings/:id', to: 'menu_settings#update', as: :update_menu_setting
8
+ delete 'menu/settings/:id', to: 'menu_settings#destroy', as: :destroy_menu_setting
9
+
10
+ get '/*path', to: 'proxy#show', constraints: lambda { |req|
5
11
  WrapItRuby::MenuHelper.proxy_paths.any? { |p| req.path.start_with?(p) }
6
12
  }
7
13
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ui"
4
- require "wrap_it_ruby/middleware/proxy_middleware"
5
- require "wrap_it_ruby/middleware/root_relative_proxy_middleware"
6
- require "wrap_it_ruby/middleware/script_injection_middleware"
3
+ require 'ui'
4
+ require 'wrap_it_ruby/middleware/proxy_middleware'
5
+ require 'wrap_it_ruby/middleware/root_relative_proxy_middleware'
6
+ require 'wrap_it_ruby/middleware/script_injection_middleware'
7
7
 
8
8
  module WrapItRuby
9
9
  class Engine < ::Rails::Engine
@@ -17,33 +17,34 @@ module WrapItRuby
17
17
  # 2. ScriptInjection strips Accept-Encoding, passes to Proxy,
18
18
  # then injects <base> + interception.js into HTML responses
19
19
  # 3. Proxy does the actual upstream proxying
20
- initializer "wrap_it_ruby.middleware" do |app|
20
+ initializer 'wrap_it_ruby.middleware' do |app|
21
21
  app.middleware.insert_before ActionDispatch::Static, WrapItRuby::Middleware::RootRelativeProxyMiddleware
22
22
  app.middleware.insert_after WrapItRuby::Middleware::RootRelativeProxyMiddleware, WrapItRuby::Middleware::ScriptInjectionMiddleware
23
23
  app.middleware.insert_after WrapItRuby::Middleware::ScriptInjectionMiddleware, WrapItRuby::Middleware::ProxyMiddleware
24
24
  end
25
25
 
26
26
  # Register importmap pins for the Stimulus controller
27
- initializer "wrap_it_ruby.importmap", before: "importmap" do |app|
27
+ initializer 'wrap_it_ruby.importmap', before: 'importmap' do |app|
28
28
  if app.config.respond_to?(:importmap)
29
- app.config.importmap.paths << Engine.root.join("config/importmap.rb")
30
- app.config.importmap.cache_sweepers << Engine.root.join("app/javascript")
29
+ app.config.importmap.paths << Engine.root.join('config/importmap.rb')
30
+ app.config.importmap.cache_sweepers << Engine.root.join('app/javascript')
31
31
  end
32
32
  end
33
33
 
34
34
  # Add engine assets to the asset load path (for propshaft/sprockets)
35
- initializer "wrap_it_ruby.assets" do |app|
36
- app.config.assets.paths << Engine.root.join("app/assets/javascripts")
37
- app.config.assets.paths << Engine.root.join("app/assets/stylesheets")
35
+ initializer 'wrap_it_ruby.assets' do |app|
36
+ app.config.assets.paths << Engine.root.join('app/javascript')
37
+ app.config.assets.paths << Engine.root.join('app/assets/javascripts')
38
+ app.config.assets.paths << Engine.root.join('app/assets/stylesheets')
38
39
 
39
40
  # rails-active-ui ships stylesheets.css directly in app/assets/
40
- app.config.assets.paths << Ui::Engine.root.join("app/assets")
41
+ app.config.assets.paths << Ui::Engine.root.join('app/assets')
41
42
  end
42
43
 
43
44
  # Make engine helpers (MenuHelper, IframeHelper) available in host app views.
44
45
  # MenuHelper#render_menu depends on ComponentHelper from rails-active-ui,
45
46
  # which is already injected into ActionView by the Ui engine.
46
- initializer "wrap_it_ruby.helpers" do
47
+ initializer 'wrap_it_ruby.helpers' do
47
48
  ActiveSupport.on_load(:action_view) do
48
49
  include WrapItRuby::MenuHelper
49
50
  include WrapItRuby::IframeHelper
@@ -1,41 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "async/http/client"
4
- require "async/http/endpoint"
5
- require "async/websocket/adapters/rack"
3
+ require 'async/http/client'
4
+ require 'async/http/endpoint'
5
+ require 'cgi'
6
6
 
7
7
  module WrapItRuby
8
8
  module Middleware
9
9
  # Rack middleware that proxies /_proxy/{host}/{path} to the upstream host.
10
10
  #
11
- # Strips frame-blocking headers so responses render inside an iframe.
12
- # Rewrites Location headers so redirects stay within the proxy.
13
- # Detects WebSocket upgrades and pipes frames bidirectionally.
11
+ # Header strategy (both request and response):
12
+ # 1. Whitelist only known-safe headers pass through
13
+ # 2. Modify — rewrite values (host, origin, cookies, redirects)
14
+ # 3. Add — set headers the upstream/browser needs
14
15
  #
15
16
  class ProxyMiddleware
16
17
  PATTERN = %r{\A/_proxy/(?<host>[^/]+)(?<path>/.*)?\z}
17
18
 
18
- HOP_HEADERS = %w[
19
- connection
20
- keep-alive
21
- proxy-authenticate
22
- proxy-authorization
23
- te
24
- trailers
25
- transfer-encoding
26
- upgrade
27
- ].freeze
19
+ # ── Request headers whitelisted for forwarding to upstream ──
20
+ #
21
+ # Everything not on this list is dropped. This prevents leaking
22
+ # proxy-internal headers, browser metadata, and hop-by-hop headers.
23
+ #
24
+ REQUEST_WHITELIST = %w[
25
+ accept
26
+ accept-language
27
+ authorization
28
+ cache-control
29
+ content-type
30
+ content-length
31
+ cookie
32
+ if-match
33
+ if-modified-since
34
+ if-none-match
35
+ if-range
36
+ if-unmodified-since
37
+ pragma
38
+ range
39
+ x-requested-with
40
+ ].to_set.freeze
41
+
42
+ # ── Response headers whitelisted for forwarding to browser ──
43
+ #
44
+ # Everything not on this list is dropped. This prevents frame-
45
+ # blocking headers, CSP, HSTS, and hop-by-hop headers from
46
+ # reaching the browser inside the iframe.
47
+ #
48
+ RESPONSE_WHITELIST = %w[
49
+ accept-ranges
50
+ age
51
+ cache-control
52
+ content-disposition
53
+ content-language
54
+ content-length
55
+ content-range
56
+ content-type
57
+ date
58
+ etag
59
+ expires
60
+ last-modified
61
+ location
62
+ pragma
63
+ retry-after
64
+ set-cookie
65
+ vary
66
+ x-content-type-options
67
+ x-xss-protection
68
+ ].to_set.freeze
28
69
 
29
70
  def initialize(app)
30
- @app = app
31
- @clients = {}
71
+ @app = app
72
+ @clients = {}
73
+ @proxy_host = ENV['WRAP_IT_PROXY_HOST']
74
+ @cookie_domain = ENV.fetch('WRAP_IT_COOKIE_DOMAIN', '.cia.net')
32
75
  end
33
76
 
34
77
  def call(env)
35
- PATTERN.match(env["PATH_INFO"].to_s).then do |match|
78
+ PATTERN.match(env['PATH_INFO'].to_s).then do |match|
36
79
  if match
37
- host = match[:host]
38
- path = match[:path] || "/"
80
+ host = match[:host].delete_suffix('.')
81
+ path = match[:path] || '/'
39
82
 
40
83
  if websocket?(env)
41
84
  proxy_websocket(env, host, path)
@@ -50,18 +93,19 @@ module WrapItRuby
50
93
 
51
94
  private
52
95
 
53
- # ---- HTTP ----
96
+ # ── HTTP proxying ──
54
97
 
55
98
  def proxy_http(env, host, path)
56
99
  client = client_for(host)
57
100
 
58
- query = env["QUERY_STRING"]
59
- full_path = query && !query.empty? ? "#{path}?#{query}" : path
60
- headers = forwarded_headers(env, host)
101
+ query = env['QUERY_STRING']
102
+ query = deproxify_query(query) if query && !query.empty?
103
+ full_path = (query && !query.empty?) ? "#{path}?#{query}" : path
104
+ headers = build_request_headers(env, host)
61
105
  body = read_body(env)
62
106
 
63
107
  request = Protocol::HTTP::Request.new(
64
- "https", host, env["REQUEST_METHOD"], full_path, nil, headers, body
108
+ 'https', host, env['REQUEST_METHOD'], full_path, nil, headers, body
65
109
  )
66
110
 
67
111
  response = client.call(request)
@@ -74,80 +118,133 @@ module WrapItRuby
74
118
  response.body&.close
75
119
  end
76
120
 
77
- rack_headers = strip_headers(response.headers, host)
121
+ rack_headers = build_response_headers(response.headers, host)
78
122
  [response.status, rack_headers, rack_body]
79
123
  end
80
124
 
81
- # ---- WebSocket ----
125
+ # ── WebSocket (safety net — handled by WebSocketProxy before Rack) ──
82
126
 
83
127
  def websocket?(env)
84
- Async::WebSocket::Adapters::Rack.websocket?(env)
85
- false
128
+ upgrade = env['HTTP_UPGRADE']
129
+ upgrade && upgrade.casecmp?('websocket')
86
130
  end
87
131
 
88
- # ---- Helpers ----
89
-
90
- def client_for(host)
91
- @clients[host] ||= Async::HTTP::Client.new(
92
- Async::HTTP::Endpoint.parse("https://#{host}"),
93
- retries: 0
94
- )
132
+ def proxy_websocket(_env, _host, _path)
133
+ [502, { 'content-type' => 'text/plain' }, ['WebSocket requests must be handled at Protocol::HTTP level']]
95
134
  end
96
135
 
97
- def forwarded_headers(env, host)
136
+ # ── Request headers: whitelist → modify → add ──
137
+
138
+ def build_request_headers(env, host)
98
139
  headers = Protocol::HTTP::Headers.new
99
140
 
141
+ # 1. Whitelist: only forward known-safe headers
100
142
  env.each do |key, value|
101
- next unless key.start_with?("HTTP_")
102
- name = key.delete_prefix("HTTP_").downcase.tr("_", "-")
103
- next if name == "host" || HOP_HEADERS.include?(name)
143
+ next unless key.start_with?('HTTP_')
144
+
145
+ name = key.delete_prefix('HTTP_').downcase.tr('_', '-')
146
+ next unless REQUEST_WHITELIST.include?(name)
147
+
104
148
  headers.add(name, value)
105
149
  end
106
150
 
107
- headers.add("content-type", env["CONTENT_TYPE"]) if env["CONTENT_TYPE"]
108
- headers.add("content-length", env["CONTENT_LENGTH"]) if env["CONTENT_LENGTH"]
109
- headers.add("host", host)
151
+ # Also forward content-type/content-length from Rack env
152
+ headers.add('content-type', env['CONTENT_TYPE']) if env['CONTENT_TYPE']
153
+ headers.add('content-length', env['CONTENT_LENGTH']) if env['CONTENT_LENGTH']
154
+
155
+ # 2. Modify: nothing to modify at this stage (cookies pass through
156
+ # as-is since the .cia.net domain covers both proxy and upstream)
157
+
158
+ # 3. Add: set host for the upstream
159
+ headers.add('host', host)
110
160
 
111
161
  headers
112
162
  end
113
163
 
114
- def strip_headers(upstream_headers, host)
164
+ # ── Response headers: whitelist → modify → add ──
165
+
166
+ def build_response_headers(upstream_headers, host)
115
167
  result = {}
168
+
169
+ # 1. Whitelist: only pass through known-safe headers
116
170
  upstream_headers.each do |name, value|
117
171
  key = name.downcase
118
- next if key == "x-frame-options"
119
- next if key == "content-security-policy"
120
- next if key == "content-security-policy-report-only"
121
- next if key == "content-encoding"
122
- next if HOP_HEADERS.include?(key)
172
+ next unless RESPONSE_WHITELIST.include?(key)
173
+
174
+ # 2. Modify: rewrite specific headers
175
+ case key
176
+ when 'set-cookie'
177
+ value = rewrite_cookie_domain(value)
178
+ when 'location'
179
+ # Handled below after the loop
180
+ end
181
+
123
182
  result[name] = value
124
183
  end
125
184
 
126
- # Rewrite Location headers so redirects stay within the proxy
127
- if (location = result["location"] || result["Location"])
128
- result_key = result.key?("location") ? "location" : "Location"
185
+ # Rewrite Location header (redirects stay within proxy)
186
+ rewrite_location(result, host)
187
+
188
+ result
189
+ end
190
+
191
+ # ── Helpers ──
192
+
193
+ def client_for(host)
194
+ @clients[host] ||= Async::HTTP::Client.new(
195
+ Async::HTTP::Endpoint.parse("https://#{host}"),
196
+ retries: 0
197
+ )
198
+ end
199
+
200
+ # Rewrites proxy URLs in query parameter values back to real upstream
201
+ # URLs. Fixes auth flows where upstream validates return_url against
202
+ # its own host.
203
+ def deproxify_query(query)
204
+ return query unless @proxy_host
205
+
206
+ proxy_prefix = "https://#{@proxy_host}/_proxy/"
207
+ proxy_prefix_encoded = CGI.escape("https://#{@proxy_host}/_proxy/")
208
+
209
+ query
210
+ .gsub(proxy_prefix_encoded) { |_m| CGI.escape('https://') }
211
+ .gsub(proxy_prefix) { 'https://' }
212
+ end
213
+
214
+ # Rewrites Location headers so redirects stay within the proxy.
215
+ def rewrite_location(result, host)
216
+ if (location = result['location'] || result['Location'])
217
+ result_key = result.key?('location') ? 'location' : 'Location'
129
218
  begin
130
219
  uri = URI.parse(location)
131
- if uri.host == host || uri.host&.end_with?(".#{host}")
132
- redirect_host = uri.host || host
133
- result[result_key] = "/_proxy/#{redirect_host}#{uri.path}#{"?#{uri.query}" if uri.query}"
134
- elsif uri.relative?
135
- result[result_key] = "/_proxy/#{host}#{location}"
220
+ if uri.host
221
+ result[result_key] = "/_proxy/#{uri.host.delete_suffix('.')}#{uri.path}#{"?#{uri.query}" if uri.query}"
222
+ else
223
+ resolved = URI.join("https://#{host}/", location)
224
+ result[result_key] = "/_proxy/#{host}#{resolved.path}#{"?#{resolved.query}" if resolved.query}"
136
225
  end
137
226
  rescue URI::InvalidURIError
138
227
  # leave it alone
139
228
  end
140
229
  end
230
+ end
141
231
 
142
- result
232
+ # Rewrites Set-Cookie Domain to the configured cookie domain.
233
+ def rewrite_cookie_domain(cookie)
234
+ cookie.gsub(/Domain=[^;]+/i, "Domain=#{@cookie_domain}")
143
235
  end
144
236
 
145
237
  def read_body(env)
146
- input = env["rack.input"]
238
+ input = env['rack.input']
147
239
  return nil unless input
240
+
148
241
  body = input.read
149
- input.rewind rescue nil
150
- body && !body.empty? ? Protocol::HTTP::Body::Buffered.wrap(body) : nil
242
+ begin
243
+ input.rewind
244
+ rescue StandardError
245
+ nil
246
+ end
247
+ (body && !body.empty?) ? Protocol::HTTP::Body::Buffered.wrap(body) : nil
151
248
  end
152
249
  end
153
250
  end
@@ -43,9 +43,17 @@ module WrapItRuby
43
43
  private
44
44
 
45
45
  def extract_proxy_host(env)
46
+ # 1. Try the Referer path (works for browser-initiated asset loads)
46
47
  referer = env["HTTP_REFERER"]
47
- match = REFERER_PATTERN.match(referer) if referer
48
- match[:host] if match
48
+ if referer
49
+ match = REFERER_PATTERN.match(referer)
50
+ return match[:host].delete_suffix(".") if match
51
+ end
52
+
53
+ # 2. Fall back to X-Proxy-Host header (set by interception.js on
54
+ # fetch/XHR — covers cases where the Referer lost the /_proxy/
55
+ # prefix after a server-side rewrite hop)
56
+ env[PROXY_HOST_HEADER]&.delete_suffix(".")
49
57
  end
50
58
  end
51
59
  end
@@ -27,7 +27,7 @@ module WrapItRuby
27
27
  path = env["PATH_INFO"].to_s
28
28
 
29
29
  if path.start_with?(PROXY_PREFIX)
30
- host = path.delete_prefix(PROXY_PREFIX).split("/", 2).first
30
+ host = path.delete_prefix(PROXY_PREFIX).split("/", 2).first.delete_suffix(".")
31
31
  hosting_site = env["HTTP_HOST"]
32
32
 
33
33
  env.delete("HTTP_ACCEPT_ENCODING")
@@ -71,6 +71,12 @@ module WrapItRuby
71
71
  html.prepend(tag)
72
72
  end
73
73
 
74
+ # Strip restrictive referrer policies (e.g. strict-origin) that
75
+ # truncate the Referer to just the origin. RootRelativeProxyMiddleware
76
+ # needs the full path (/_proxy/{host}/…) in the Referer to route
77
+ # root-relative asset requests back through the proxy.
78
+ html.gsub!(%r{<meta[^>]+name=["']referrer["'][^>]*>}i, "")
79
+
74
80
  headers.delete("content-length")
75
81
  headers["content-length"] = html.bytesize.to_s
76
82
  [html]