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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/wrap_it_ruby/interception.js +54 -31
- data/app/assets/stylesheets/wrap_it_ruby/application.css +71 -1
- data/app/controllers/wrap_it_ruby/menu_settings_controller.rb +64 -0
- data/app/controllers/wrap_it_ruby/proxy_controller.rb +2 -1
- data/app/helpers/wrap_it_ruby/menu_helper.rb +298 -22
- data/app/javascript/wrap_it_ruby/controllers/sortable_tree_controller.js +198 -0
- data/app/views/wrap_it_ruby/menu_settings/_tree.html.erb +1 -0
- data/app/views/wrap_it_ruby/menu_settings/index.html.erb +1 -0
- data/config/importmap.rb +7 -3
- data/config/routes.rb +7 -1
- data/lib/wrap_it_ruby/engine.rb +14 -13
- data/lib/wrap_it_ruby/middleware/proxy_middleware.rb +159 -62
- data/lib/wrap_it_ruby/middleware/root_relative_proxy_middleware.rb +10 -2
- data/lib/wrap_it_ruby/middleware/script_injection_middleware.rb +7 -1
- data/lib/wrap_it_ruby/middleware/websocket_proxy.rb +92 -0
- data/lib/wrap_it_ruby/version.rb +1 -1
- data/lib/wrap_it_ruby.rb +2 -2
- metadata +6 -1
|
@@ -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(
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
data/lib/wrap_it_ruby/engine.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
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
|
|
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
|
|
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(
|
|
30
|
-
app.config.importmap.cache_sweepers << Engine.root.join(
|
|
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
|
|
36
|
-
app.config.assets.paths << Engine.root.join(
|
|
37
|
-
app.config.assets.paths << Engine.root.join(
|
|
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(
|
|
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
|
|
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
|
|
4
|
-
require
|
|
5
|
-
require
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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[
|
|
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
|
-
#
|
|
96
|
+
# ── HTTP proxying ──
|
|
54
97
|
|
|
55
98
|
def proxy_http(env, host, path)
|
|
56
99
|
client = client_for(host)
|
|
57
100
|
|
|
58
|
-
query = env[
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 =
|
|
121
|
+
rack_headers = build_response_headers(response.headers, host)
|
|
78
122
|
[response.status, rack_headers, rack_body]
|
|
79
123
|
end
|
|
80
124
|
|
|
81
|
-
#
|
|
125
|
+
# ── WebSocket (safety net — handled by WebSocketProxy before Rack) ──
|
|
82
126
|
|
|
83
127
|
def websocket?(env)
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
upgrade = env['HTTP_UPGRADE']
|
|
129
|
+
upgrade && upgrade.casecmp?('websocket')
|
|
86
130
|
end
|
|
87
131
|
|
|
88
|
-
|
|
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
|
-
|
|
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?(
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
headers.add(
|
|
109
|
-
headers.add(
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
result[result_key] = "/_proxy/#{host}#{
|
|
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
|
-
|
|
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[
|
|
238
|
+
input = env['rack.input']
|
|
147
239
|
return nil unless input
|
|
240
|
+
|
|
148
241
|
body = input.read
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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]
|