wrap_it_ruby 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bcdd8a5fa91589bc96243a96ae809d92cbe0f585ab9a58175213644fe1cd798a
4
+ data.tar.gz: 2199f6305cba48c81d659cedeeafc31d1a95c73380a94601dcd182bc8520deea
5
+ SHA512:
6
+ metadata.gz: 2818a78eab5310065a3b4b90bc712778db8c5c5b19db5cfd2e175a12761bc2233efa0a194bf20575695030c837e263a241861d13cb489efb87e15ed503d58b3e
7
+ data.tar.gz: 9f9cf00ff131d17f691599527468b9686b8751a6c9748d849c25ff5ef70d8894fe1e9d2d790d39a9d5d3bb4424e9f9d9a5abc938333e985582d820c052f3e379
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.pattern = "test/**/*_test.rb"
8
+ t.verbose = false
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,172 @@
1
+ // interception.js — Injected into proxied HTML by ScriptInjectionMiddleware.
2
+ //
3
+ // Rewrites cross-origin URLs through /_proxy/{host} and stamps
4
+ // X-Proxy-Host on fetch/XHR so RootRelativeProxyMiddleware can route
5
+ // root-relative requests.
6
+ //
7
+ // Intercepts: fetch, XMLHttpRequest, WebSocket, EventSource,
8
+ // <a> clicks, <form> submissions.
9
+ //
10
+ (function (window) {
11
+ 'use strict';
12
+
13
+ var PROXY_BASE = '/_proxy';
14
+
15
+ console.log('[interception]', 'loaded');
16
+
17
+ var _fetch = window.fetch;
18
+ var _XHR = window.XMLHttpRequest;
19
+ var _xhrOpen = _XHR.prototype.open;
20
+ var _xhrSend = _XHR.prototype.send;
21
+ var _xhrSetHeader = _XHR.prototype.setRequestHeader;
22
+ var _WebSocket = window.WebSocket;
23
+ var _EventSource = window.EventSource;
24
+
25
+ const PROXY_HOST = window.__proxyHost;
26
+
27
+ const rewriteUrl = (url) => {
28
+ if (typeof url !== 'string') return url
29
+ try {
30
+ const parsed = new URL(url)
31
+ if (parsed.host === window.__hostingSite) return url
32
+ return `/_proxy/${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`
33
+ } catch {
34
+ return url
35
+ }
36
+ }
37
+
38
+ // =========================== Navigation ====================================
39
+ //
40
+ document.addEventListener('click', (event) => {
41
+ const link = event.target.closest('a')
42
+ if (!link) return
43
+
44
+ const raw = link.getAttribute('href')
45
+ const url = raw.startsWith('/') ? `/_proxy/${window.__proxyHost}${raw}` : rewriteUrl(raw)
46
+
47
+ event.preventDefault()
48
+ window.location.href = url
49
+ }, true)
50
+
51
+ document.addEventListener('submit', (event) => {
52
+ event.preventDefault()
53
+ const form = event.target
54
+ const raw = form.getAttribute('action') || ''
55
+ const url = raw.startsWith('/') ? `/_proxy/${window.__proxyHost}${raw}` : rewriteUrl(raw)
56
+
57
+ form.setAttribute('action', url)
58
+ event.target.submit()
59
+ }, true)
60
+
61
+ //document.addEventListener('submit', (event) => {
62
+ // event.preventDefault()
63
+ // const form = event.target
64
+ // const raw = form.getAttribute('action') || ''
65
+ // const url = raw.startsWith('/') ? `/_proxy/${window.__proxyHost}${raw}` : rewriteUrl(raw)
66
+
67
+ // fetch(url, { method: form.method || 'POST', body: new FormData(form) })
68
+ // .then(res => res.text())
69
+ // .then(html => document.documentElement.innerHTML = html)
70
+ //}, true)
71
+
72
+ //document.addEventListener('click', (event) => {
73
+ // const link = event.target.closest('a')
74
+ // if (!link) return
75
+
76
+ // event.preventDefault()
77
+ // const raw = link.getAttribute('href')
78
+ // console.log('[interception] click', { raw })
79
+
80
+ // // Relative URLs need the proxy prefix added
81
+ // const url = raw.startsWith('/') ? `/_proxy/${window.__proxyHost}${raw}` : rewriteUrl(raw)
82
+
83
+ // fetch(url)
84
+ // .then(res => res.text())
85
+ // .then(html => document.documentElement.innerHTML = html)
86
+ //}, true)
87
+
88
+ // ============================== fetch ======================================
89
+
90
+ window.fetch = (input, init = {}) => {
91
+ const isReq = input instanceof Request
92
+ const url = isReq ? input.url : String(input instanceof URL ? input.href : input)
93
+
94
+ const headers = new Headers(init.headers || (isReq ? input.headers : {}))
95
+ headers.set('x-proxy-host', '_proxy')
96
+
97
+ const newInit = {
98
+ method: init.method || (isReq ? input.method : 'GET'),
99
+ headers,
100
+ body: 'body' in init ? init.body : (isReq ? input.body : null),
101
+ }
102
+
103
+ const passthrough = ['credentials', 'mode', 'cache', 'redirect', 'referrer',
104
+ 'referrerPolicy', 'integrity', 'keepalive', 'signal']
105
+ for (const key of passthrough) {
106
+ if (key in init) newInit[key] = init[key]
107
+ }
108
+
109
+ if (newInit.body instanceof ReadableStream) newInit.duplex = 'half'
110
+
111
+ return _fetch.call(window, rewriteUrl(url), newInit).catch((err) => {
112
+ console.warn('Proxied fetch failed, retrying without modifications:', err)
113
+ return _fetch.call(window, input, init)
114
+ })
115
+ }
116
+
117
+ // =========================== XMLHttpRequest ================================
118
+
119
+ // Patch open() to stash the original args — we need them to re-open
120
+ // with a rewritten URL since XHR won't let you change it after open()
121
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
122
+ this._proxyMeta = { method, url: String(url), openArgs: rest }
123
+ return _xhrOpen.call(this, method, url, ...rest)
124
+ }
125
+
126
+ XMLHttpRequest.prototype.send = function (body) {
127
+ const meta = this._proxyMeta
128
+ if (!meta) return _xhrSend.call(this, body)
129
+
130
+ const rewritten = rewriteUrl(meta.url)
131
+
132
+ // If the URL changed, we have to re-call open() with the new URL
133
+ if (rewritten !== meta.url) {
134
+ _xhrOpen.call(this, meta.method, rewritten, ...meta.openArgs)
135
+ }
136
+
137
+ _xhrSetHeader.call(this, 'x-proxy-host', '_proxy')
138
+ return _xhrSend.call(this, body)
139
+ }
140
+
141
+ // ============================= WebSocket ===================================
142
+
143
+ //window.WebSocket = function (url, protocols) {
144
+ // var req = { type: 'websocket', url: rewriteUrl(url), originalUrl: url,
145
+ // method: 'GET', headers: {}, body: null };
146
+ // runRequestHooks(req);
147
+ // if (protocols !== undefined) return new _WebSocket(req.url, protocols);
148
+ // return new _WebSocket(req.url);
149
+ //};
150
+ //window.WebSocket.prototype = _WebSocket.prototype;
151
+ //window.WebSocket.CONNECTING = _WebSocket.CONNECTING;
152
+ //window.WebSocket.OPEN = _WebSocket.OPEN;
153
+ //window.WebSocket.CLOSING = _WebSocket.CLOSING;
154
+ //window.WebSocket.CLOSED = _WebSocket.CLOSED;
155
+
156
+ //// ============================ EventSource ==================================
157
+
158
+ //if (_EventSource) {
159
+ // window.EventSource = function (url, dict) {
160
+ // var raw = typeof url === 'string' ? url : url.href;
161
+ // var req = { type: 'eventsource', url: rewriteUrl(raw), originalUrl: raw,
162
+ // method: 'GET', headers: {}, body: null };
163
+ // runRequestHooks(req);
164
+ // return new _EventSource(req.url, dict);
165
+ // };
166
+ // window.EventSource.prototype = _EventSource.prototype;
167
+ // window.EventSource.CONNECTING = _EventSource.CONNECTING;
168
+ // window.EventSource.OPEN = _EventSource.OPEN;
169
+ // window.EventSource.CLOSED = _EventSource.CLOSED;
170
+ //}
171
+
172
+ })(window);
@@ -0,0 +1,52 @@
1
+ /*
2
+ * WrapItRuby engine styles — full layout + iframe proxy.
3
+ */
4
+
5
+ html, body {
6
+ height: 100%;
7
+ margin: 0;
8
+ background-color: white;
9
+ }
10
+
11
+ #site-wrapper {
12
+ display: flex;
13
+ flex-direction: column;
14
+ height: 100%;
15
+ width: 100%;
16
+
17
+ #site-menu {
18
+ .ui.menu {
19
+ border: none;
20
+ }
21
+ }
22
+
23
+ #site-content {
24
+ flex-grow: 1;
25
+ margin: 7px;
26
+ margin-top: 0px;
27
+ border-radius: 15px;
28
+ background-color: grey;
29
+ overflow: hidden;
30
+ }
31
+ }
32
+
33
+ #proxy-content {
34
+ height: 100%;
35
+
36
+ .iframe-wrapper {
37
+ height: 100%;
38
+ position: relative;
39
+
40
+ iframe {
41
+ border: 0;
42
+ display: block;
43
+ position: absolute;
44
+ width: 100%;
45
+ height: 100%;
46
+ top: 0;
47
+ bottom: 0;
48
+ left: 0;
49
+ right: 0;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ class ApplicationController < ::ApplicationController
5
+ include WrapItRuby::Menu
6
+
7
+ layout "wrap_it_ruby/application"
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ class HomeController < ApplicationController
5
+ before_action :authenticate_user!
6
+
7
+ def index
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ class ProxyController < ApplicationController
5
+ before_action :authenticate_user!
6
+
7
+ def show
8
+ get_menu_item.then do |menu_item|
9
+ target_path = request.path.delete_prefix(menu_item["route"])
10
+ target_domain = menu_item["url"]
11
+
12
+ target_url = "#{target_domain}/#{target_path}"
13
+ @iframe_src = "/_proxy/#{target_url}"
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def get_menu_item
20
+ path = request.path
21
+ all_proxy_menu_items.find { |item| path.start_with?(item["route"]) }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ module IframeHelper
5
+ def iframe(**)
6
+ tag.div(class: "iframe-wrapper") do
7
+ tag.iframe(**)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ # View helper that exposes menu_config and related queries to templates.
5
+ # Delegates to WrapItRuby::Menu for the actual loading logic.
6
+ module MenuHelper
7
+ include WrapItRuby::Menu
8
+ end
9
+ end
@@ -0,0 +1,87 @@
1
+ // wrap_it_ruby/controllers/iframe_proxy_controller.js
2
+ //
3
+ // Manages browser history <-> iframe URL synchronisation.
4
+ //
5
+ // The proxy host and route segment differ:
6
+ // iframe: /_proxy/github.com/n-at-han-k
7
+ // browser: /github/n-at-han-k
8
+ //
9
+ // `host` is used when working with iframe paths (github.com)
10
+ // `current` is used when building browser paths (github)
11
+ //
12
+ // - History sync: on iframe load, translates the /_proxy prefixed
13
+ // iframe path back to the browser path and pushes state.
14
+ // - Breakout: if navigation inside the iframe lands on a path
15
+ // belonging to a DIFFERENT menu route, Turbo.visit
16
+ // swaps the frame to the new route.
17
+ //
18
+ import { Controller } from "@hotwired/stimulus"
19
+
20
+ export default class extends Controller {
21
+ static values = {
22
+ paths: Array, // iframe menu paths, e.g. ["/grafana", "/argocd"]
23
+ current: String, // route segment, e.g. "github"
24
+ host: String, // proxy host, e.g. "github.com"
25
+ }
26
+
27
+ connect() {
28
+ this.element.addEventListener("load", this.onLoad)
29
+ window.addEventListener("popstate", this.onPopstate)
30
+ }
31
+
32
+ disconnect() {
33
+ this.element.removeEventListener("load", this.onLoad)
34
+ window.removeEventListener("popstate", this.onPopstate)
35
+ }
36
+
37
+ onLoad = () => {
38
+ const { pathname, search } = this.element.contentWindow.location
39
+ if (pathname === "about:blank") return
40
+
41
+ const iframePath = pathname + search
42
+ const breakout = this.#detectBreakout(iframePath)
43
+
44
+ if (breakout) {
45
+ Turbo.visit(breakout, { action: "advance" })
46
+ } else {
47
+ this.#syncHistory(iframePath)
48
+ }
49
+ }
50
+
51
+ onPopstate = (event) => {
52
+ const src = event.state?.iframeSrc
53
+ if (src) this.element.contentWindow.location.replace(src)
54
+ }
55
+
56
+ // ---- private ----
57
+
58
+ // iframe path: /_proxy/github.com/n-at-han-k/repo
59
+ // Extract the host from the proxy path and check if it belongs to
60
+ // a different menu route.
61
+ #detectBreakout(iframePath) {
62
+ if (!iframePath.startsWith("/_proxy/")) return null
63
+
64
+ // Pull the host out: "github.com"
65
+ const afterProxy = iframePath.slice("/_proxy/".length)
66
+ const iframeHost = afterProxy.split("/")[0]
67
+
68
+ if (iframeHost === this.hostValue) return null
69
+
70
+ // Check if this host belongs to another menu route
71
+ // (would need host->route mapping; skip for now)
72
+ return null
73
+ }
74
+
75
+ #syncHistory(iframePath) {
76
+ const proxyBase = `/_proxy/${this.hostValue}`
77
+ const subPath = iframePath.startsWith(proxyBase)
78
+ ? iframePath.slice(proxyBase.length)
79
+ : iframePath
80
+
81
+ const browserPath = `/${this.currentValue}${subPath}`
82
+
83
+ if (browserPath !== window.location.pathname + window.location.search) {
84
+ history.pushState({ iframeSrc: iframePath }, "", browserPath)
85
+ }
86
+ }
87
+ }
@@ -0,0 +1 @@
1
+ <p>Welcome, <%= current_user&.display_name || current_user&.uid %>.</p>
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= content_for(:title) || site_title %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="application-name" content="<%= site_title %>">
8
+ <meta name="mobile-web-app-capable" content="yes">
9
+ <%= csrf_meta_tags %>
10
+ <%= csp_meta_tag %>
11
+
12
+ <%= yield :head %>
13
+
14
+ <link rel="icon" href="/icon.png" type="image/png">
15
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
16
+ <link rel="apple-touch-icon" href="/icon.png">
17
+
18
+ <%= stylesheet_link_tag "semantic.min.css", "data-turbo-track": "reload" %>
19
+ <%= stylesheet_link_tag "wrap_it_ruby/application", "data-turbo-track": "reload" %>
20
+ <%= fui_javascript_tags %>
21
+ <%= javascript_importmap_tags %>
22
+ </head>
23
+
24
+ <body>
25
+ <div id="site-wrapper">
26
+ <div id="site-menu"><%= render "wrap_it_ruby/shared/navbar" %></div>
27
+ <div id="site-content"><%= yield %></div>
28
+ </div>
29
+ </body>
30
+ </html>
@@ -0,0 +1,5 @@
1
+ output_buffer << turbo_frame_tag("proxy-content") do
2
+ tag.div(class: 'iframe-wrapper') do
3
+ tag.iframe( src: @iframe_src )
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ Menu(attached: true) {
2
+ WrapItRuby::Menu.menu_config.each do |group|
3
+ if group["items"]
4
+ group["items"].each do |item|
5
+ MenuItem(href: item["route"]) { text item["label"] }
6
+ end
7
+ else
8
+ MenuItem(href: group["route"]) { text group["label"] }
9
+ end
10
+ end
11
+ }
@@ -0,0 +1,6 @@
1
+ # Importmap pins for the WrapItRuby engine.
2
+ # These get merged into the host app's importmap via the engine initializer.
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"
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ WrapItRuby::Engine.routes.draw do
4
+ root "home#index"
5
+
6
+ get "/*path", to: "proxy#show", constraints: ->(req) {
7
+ WrapItRuby::Menu.proxy_paths.any? { |p| req.path.start_with?(p) }
8
+ }
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Install WrapItRuby into the host application"
9
+
10
+ def copy_menu_config
11
+ template "menu.yml", "config/menu.yml"
12
+ end
13
+
14
+ def mount_engine_routes
15
+ route 'mount WrapItRuby::Engine, at: "/"'
16
+ end
17
+
18
+ def show_post_install_message
19
+ say ""
20
+ say "WrapItRuby installed!", :green
21
+ say ""
22
+ say " 1. Edit config/menu.yml to configure your proxy routes"
23
+ say " 2. Make sure your ApplicationController defines authenticate_user!"
24
+ say " 3. Include wrap_it_ruby/application.css in your stylesheet"
25
+ say ""
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ # config/menu.yml
2
+ #
3
+ # Menu configuration for WrapItRuby.
4
+ #
5
+ # route: local path in this app (e.g. /grafana)
6
+ # url: upstream host to proxy (e.g. grafana.example.com)
7
+ #
8
+ # type:
9
+ # proxy - proxied service rendered inside an iframe
10
+ # link - standard navigation within the app
11
+
12
+ - label: Example
13
+ route: /example
14
+ url: example.com
15
+ icon: globe
16
+ type: proxy
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wrap_it_ruby/middleware/proxy_middleware"
4
+ require "wrap_it_ruby/middleware/root_relative_proxy_middleware"
5
+ require "wrap_it_ruby/middleware/script_injection_middleware"
6
+
7
+ module WrapItRuby
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace WrapItRuby
10
+
11
+ # Insert proxy middleware early — before Rails routing — so /_proxy/*
12
+ # requests never hit ActionDispatch at all.
13
+ #
14
+ # Order: RootRelativeProxy -> ScriptInjection -> Proxy
15
+ # 1. RootRelativeProxy rewrites root-relative paths to /_proxy/{host}
16
+ # 2. ScriptInjection strips Accept-Encoding, passes to Proxy,
17
+ # then injects <base> + interception.js into HTML responses
18
+ # 3. Proxy does the actual upstream proxying
19
+ initializer "wrap_it_ruby.middleware" do |app|
20
+ app.middleware.insert_before ActionDispatch::Static, WrapItRuby::Middleware::RootRelativeProxyMiddleware
21
+ app.middleware.insert_after WrapItRuby::Middleware::RootRelativeProxyMiddleware, WrapItRuby::Middleware::ScriptInjectionMiddleware
22
+ app.middleware.insert_after WrapItRuby::Middleware::ScriptInjectionMiddleware, WrapItRuby::Middleware::ProxyMiddleware
23
+ end
24
+
25
+ # Register importmap pins for the Stimulus controller
26
+ initializer "wrap_it_ruby.importmap", before: "importmap" do |app|
27
+ if app.config.respond_to?(:importmap)
28
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
29
+ app.config.importmap.cache_sweepers << Engine.root.join("app/javascript")
30
+ end
31
+ end
32
+
33
+ # Add engine assets to the asset load path (for propshaft/sprockets)
34
+ initializer "wrap_it_ruby.assets" do |app|
35
+ app.config.assets.paths << Engine.root.join("app/assets/javascripts")
36
+ app.config.assets.paths << Engine.root.join("app/assets/stylesheets")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module WrapItRuby
6
+ # Loads and queries the menu configuration from the host app's
7
+ # config/menu.yml file.
8
+ #
9
+ # Can be used as a module (extend self) or included in controllers/helpers.
10
+ #
11
+ module Menu
12
+ def menu_config = load_menu
13
+
14
+ def all_menu_items
15
+ menu_config.flat_map { |item| [item, *item.fetch("items", [])] }
16
+ end
17
+
18
+ def all_proxy_menu_items
19
+ all_menu_items.select { |item| item["type"] == "proxy" }
20
+ end
21
+
22
+ def proxy_paths
23
+ all_menu_items
24
+ .select { |item| item["type"] == "proxy" }
25
+ .map { |item| item["route"] }
26
+ end
27
+
28
+ extend self
29
+
30
+ private
31
+
32
+ def menu_file
33
+ Rails.root.join("config/menu.yml")
34
+ end
35
+
36
+ def load_menu
37
+ @menu_config ||= YAML.load_file(menu_file)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/http/client"
4
+ require "async/http/endpoint"
5
+ require "async/websocket/adapters/rack"
6
+
7
+ module WrapItRuby
8
+ module Middleware
9
+ # Rack middleware that proxies /_proxy/{host}/{path} to the upstream host.
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.
14
+ #
15
+ class ProxyMiddleware
16
+ PATTERN = %r{\A/_proxy/(?<host>[^/]+)(?<path>/.*)?\z}
17
+
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
28
+
29
+ def initialize(app)
30
+ @app = app
31
+ @clients = {}
32
+ end
33
+
34
+ def call(env)
35
+ PATTERN.match(env["PATH_INFO"].to_s).then do |match|
36
+ if match
37
+ host = match[:host]
38
+ path = match[:path] || "/"
39
+
40
+ if websocket?(env)
41
+ proxy_websocket(env, host, path)
42
+ else
43
+ proxy_http(env, host, path)
44
+ end
45
+ else
46
+ @app.call(env)
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # ---- HTTP ----
54
+
55
+ def proxy_http(env, host, path)
56
+ client = client_for(host)
57
+
58
+ query = env["QUERY_STRING"]
59
+ full_path = query && !query.empty? ? "#{path}?#{query}" : path
60
+ headers = forwarded_headers(env, host)
61
+ body = read_body(env)
62
+
63
+ request = Protocol::HTTP::Request.new(
64
+ "https", host, env["REQUEST_METHOD"], full_path, nil, headers, body
65
+ )
66
+
67
+ response = client.call(request)
68
+
69
+ rack_body = Enumerator.new do |y|
70
+ while (chunk = response.body&.read)
71
+ y << chunk
72
+ end
73
+ ensure
74
+ response.body&.close
75
+ end
76
+
77
+ rack_headers = strip_headers(response.headers, host)
78
+ [response.status, rack_headers, rack_body]
79
+ end
80
+
81
+ # ---- WebSocket ----
82
+
83
+ def websocket?(env)
84
+ Async::WebSocket::Adapters::Rack.websocket?(env)
85
+ false
86
+ end
87
+
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
+ )
95
+ end
96
+
97
+ def forwarded_headers(env, host)
98
+ headers = Protocol::HTTP::Headers.new
99
+
100
+ 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)
104
+ headers.add(name, value)
105
+ end
106
+
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)
110
+
111
+ headers
112
+ end
113
+
114
+ def strip_headers(upstream_headers, host)
115
+ result = {}
116
+ upstream_headers.each do |name, value|
117
+ 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)
123
+ result[name] = value
124
+ end
125
+
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"
129
+ begin
130
+ 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}"
136
+ end
137
+ rescue URI::InvalidURIError
138
+ # leave it alone
139
+ end
140
+ end
141
+
142
+ result
143
+ end
144
+
145
+ def read_body(env)
146
+ input = env["rack.input"]
147
+ return nil unless input
148
+ body = input.read
149
+ input.rewind rescue nil
150
+ body && !body.empty? ? Protocol::HTTP::Body::Buffered.wrap(body) : nil
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ module Middleware
5
+ # Handles root-relative requests (e.g. /logo.png, /api/data) that originate
6
+ # from within a proxied iframe page.
7
+ #
8
+ # Detects the proxy host from two sources (in priority order):
9
+ #
10
+ # 1. X-Proxy-Host header -- set by the interception script on fetch/XHR.
11
+ # Signals the request came from inside the proxy. The actual upstream
12
+ # host is resolved from the Referer header.
13
+ #
14
+ # 2. Referer header -- contains /_proxy/{host}/... for requests where the
15
+ # browser sends the full path. Works for both scripted requests and
16
+ # asset loads (<img>, <link>, <script>).
17
+ #
18
+ # Rewrites PATH_INFO to /_proxy/{host}{path} so ProxyMiddleware handles it.
19
+ # Must be inserted BEFORE ProxyMiddleware in the Rack stack.
20
+ #
21
+ class RootRelativeProxyMiddleware
22
+ PROXY_PREFIX = "/_proxy"
23
+ PROXY_HOST_HEADER = "HTTP_X_PROXY_HOST"
24
+ REFERER_PATTERN = %r{/_proxy/(?<host>[^/]+)}
25
+
26
+ def initialize(app)
27
+ @app = app
28
+ end
29
+
30
+ def call(env)
31
+ path = env["PATH_INFO"].to_s
32
+
33
+ unless path.start_with?(PROXY_PREFIX)
34
+ host = extract_proxy_host(env)
35
+ if host
36
+ env["PATH_INFO"] = "#{PROXY_PREFIX}/#{host}#{path}"
37
+ end
38
+ end
39
+
40
+ @app.call(env)
41
+ end
42
+
43
+ private
44
+
45
+ def extract_proxy_host(env)
46
+ referer = env["HTTP_REFERER"]
47
+ match = REFERER_PATTERN.match(referer) if referer
48
+ match[:host] if match
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ module Middleware
5
+ # Injects interception.js inline into HTML responses from the proxy.
6
+ #
7
+ # - Strips Accept-Encoding from proxy requests so upstream sends
8
+ # uncompressed HTML (avoids decompress/recompress just to inject).
9
+ # - Buffers HTML responses and injects the script as the first child
10
+ # of <head>.
11
+ # - Extracts the proxy host from PATH_INFO and injects it as
12
+ # window.__proxyHost so the script works even when the browser URL
13
+ # doesn't contain /_proxy/ (e.g. after root-relative navigation).
14
+ # - Injects window.__hostingSite so the interception script knows
15
+ # which host is the proxy server itself.
16
+ # - Reads the script file once and caches it.
17
+ #
18
+ class ScriptInjectionMiddleware
19
+ PROXY_PREFIX = "/_proxy/"
20
+ SCRIPT_FILE = File.expand_path("../../../app/assets/javascripts/wrap_it_ruby/interception.js", __dir__).freeze
21
+
22
+ def initialize(app)
23
+ @app = app
24
+ end
25
+
26
+ def call(env)
27
+ path = env["PATH_INFO"].to_s
28
+
29
+ if path.start_with?(PROXY_PREFIX)
30
+ host = path.delete_prefix(PROXY_PREFIX).split("/", 2).first
31
+ hosting_site = env["HTTP_HOST"]
32
+
33
+ env.delete("HTTP_ACCEPT_ENCODING")
34
+ status, headers, body = @app.call(env)
35
+
36
+ if html_response?(headers)
37
+ body = inject_script(body, headers, host, hosting_site)
38
+ end
39
+
40
+ [status, headers, body]
41
+ else
42
+ @app.call(env)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def script_source
49
+ @script_source ||= File.read(SCRIPT_FILE)
50
+ end
51
+
52
+ def html_response?(headers)
53
+ ct = headers["content-type"] || headers["Content-Type"] || ""
54
+ ct.include?("text/html")
55
+ end
56
+
57
+ def inject_script(body, headers, host, hosting_site)
58
+ html = +""
59
+ body.each { |chunk| html << chunk }
60
+ body.close if body.respond_to?(:close)
61
+ html.force_encoding("UTF-8")
62
+
63
+ tag = "<base href=\"/_proxy/#{host}/\">" \
64
+ "<script>" \
65
+ "window.__proxyHost=#{host.to_json};" \
66
+ "window.__hostingSite=#{hosting_site.to_json};" \
67
+ "#{script_source}" \
68
+ "</script>"
69
+
70
+ unless html.sub!(%r{(<head[^>]*>)}i, "\\1#{tag}")
71
+ html.prepend(tag)
72
+ end
73
+
74
+ headers.delete("content-length")
75
+ headers["content-length"] = html.bytesize.to_s
76
+ [html]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wrap_it_ruby/version"
4
+ require "wrap_it_ruby/engine"
5
+
6
+ module WrapItRuby
7
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wrap_it_ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Kidd
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: importmap-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: stimulus-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: async-http
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: async-websocket
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rails-active-ui
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ description: Wraps upstream web applications in an iframe via a same-origin reverse
97
+ proxy. Provides Rack middleware for proxying, script injection, and root-relative
98
+ URL rewriting, plus Stimulus controllers for browser history synchronisation.
99
+ email:
100
+ - nathankidd@hey.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - Rakefile
106
+ - app/assets/javascripts/wrap_it_ruby/interception.js
107
+ - app/assets/stylesheets/wrap_it_ruby/application.css
108
+ - app/controllers/wrap_it_ruby/application_controller.rb
109
+ - app/controllers/wrap_it_ruby/home_controller.rb
110
+ - app/controllers/wrap_it_ruby/proxy_controller.rb
111
+ - app/helpers/wrap_it_ruby/iframe_helper.rb
112
+ - app/helpers/wrap_it_ruby/menu_helper.rb
113
+ - app/javascript/wrap_it_ruby/controllers/iframe_proxy_controller.js
114
+ - app/views/wrap_it_ruby/home/index.html.erb
115
+ - app/views/wrap_it_ruby/layouts/application.html.erb
116
+ - app/views/wrap_it_ruby/proxy/show.html.ruby
117
+ - app/views/wrap_it_ruby/shared/_navbar.html.ruby
118
+ - config/importmap.rb
119
+ - config/routes.rb
120
+ - lib/generators/wrap_it_ruby/install/install_generator.rb
121
+ - lib/generators/wrap_it_ruby/install/templates/menu.yml
122
+ - lib/wrap_it_ruby.rb
123
+ - lib/wrap_it_ruby/engine.rb
124
+ - lib/wrap_it_ruby/menu.rb
125
+ - lib/wrap_it_ruby/middleware/proxy_middleware.rb
126
+ - lib/wrap_it_ruby/middleware/root_relative_proxy_middleware.rb
127
+ - lib/wrap_it_ruby/middleware/script_injection_middleware.rb
128
+ - lib/wrap_it_ruby/version.rb
129
+ homepage: https://github.com/n-at-han-k/wrap-it-ruby
130
+ licenses:
131
+ - Apache-2.0
132
+ metadata: {}
133
+ rdoc_options: []
134
+ require_paths:
135
+ - lib
136
+ required_ruby_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '3.2'
141
+ required_rubygems_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubygems_version: 3.7.2
148
+ specification_version: 4
149
+ summary: Rails engine for iframe-based reverse proxy portals
150
+ test_files: []