Skip to content

Commit 128e673

Browse files
committed
feat: implement client proxy for Astro-rendered websites
- Added 'http' and 'jwt' gems for proxy functionality - Implemented ClientRenderingConstraint to route requests based on website rendering mode - Added Pwb::ClientProxyController to handle proxying requests to the Astro client service - Configured routes for client-admin and public proxying - Added specs for the constraint and controller
1 parent 6319d19 commit 128e673

File tree

8 files changed

+566
-1
lines changed

8 files changed

+566
-1
lines changed

Gemfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ gem "graphiql-rails", group: :development
164164

165165
gem "faraday", "~> 2.3"
166166

167+
# HTTP client for reverse proxy (client-rendered websites)
168+
gem "http", "~> 5.0"
169+
170+
# JWT for proxy authentication tokens
171+
gem "jwt", "~> 2.7"
172+
167173
# RETS gem removed - MLS/RETS integration deprecated (Dec 2024)
168174
# See docs/claude_thoughts/DEPRECATED_FEATURES.md for details
169175

Gemfile.lock

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ GEM
177177
warden (~> 1.2.3)
178178
diff-lcs (1.6.2)
179179
docile (1.4.1)
180+
domain_name (0.6.20240107)
180181
dotenv (3.2.0)
181182
dotenv-rails (3.2.0)
182183
dotenv (= 3.2.0)
@@ -212,6 +213,9 @@ GEM
212213
ffi (1.17.3-x86_64-darwin)
213214
ffi (1.17.3-x86_64-linux-gnu)
214215
ffi (1.17.3-x86_64-linux-musl)
216+
ffi-compiler (1.3.2)
217+
ffi (>= 1.15.5)
218+
rake
215219
fiber-storage (1.0.1)
216220
firebase (0.2.8)
217221
googleauth
@@ -275,6 +279,14 @@ GEM
275279
hashdiff (1.2.1)
276280
hashie (5.1.0)
277281
logger
282+
http (5.3.1)
283+
addressable (~> 2.8)
284+
http-cookie (~> 1.0)
285+
http-form_data (~> 2.2)
286+
llhttp-ffi (~> 0.5.0)
287+
http-cookie (1.1.0)
288+
domain_name (~> 0.5)
289+
http-form_data (2.3.0)
278290
httpclient (2.9.0)
279291
mutex_m
280292
i18n (1.14.8)
@@ -301,7 +313,7 @@ GEM
301313
json_spec (1.1.5)
302314
multi_json (~> 1.0)
303315
rspec (>= 2.0, < 4.0)
304-
jwt (3.1.2)
316+
jwt (2.10.2)
305317
base64
306318
language_server-protocol (3.17.0.5)
307319
launchy (3.1.1)
@@ -314,6 +326,9 @@ GEM
314326
liquid (5.11.0)
315327
bigdecimal
316328
strscan (>= 3.1.1)
329+
llhttp-ffi (0.5.1)
330+
ffi-compiler (~> 1.0)
331+
rake (~> 13.0)
317332
logger (1.7.0)
318333
lograge (0.14.0)
319334
actionpack (>= 4)
@@ -768,11 +783,13 @@ DEPENDENCIES
768783
graphiql-rails
769784
graphql (~> 2.0)
770785
groupdate (~> 6.0)
786+
http (~> 5.0)
771787
i18n (~> 1.10)
772788
i18n-active_record (~> 1.1)
773789
image_processing (~> 1.2)
774790
importmap-rails (~> 2.0)
775791
json_spec
792+
jwt (~> 2.7)
776793
launchy
777794
letter_opener
778795
liquid (~> 5.3)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
# Routing constraint to identify requests for client-rendered websites
4+
# Used to route requests to the Astro proxy for websites using A themes
5+
class ClientRenderingConstraint
6+
# Paths that should always go to Rails, even for client-rendered sites
7+
EXCLUDED_PATHS = %w[
8+
/site_admin
9+
/tenant_admin
10+
/api
11+
/api_public
12+
/users
13+
/rails
14+
/assets
15+
/packs
16+
/cable
17+
/active_storage
18+
/health
19+
/setup
20+
/signup
21+
/pwb_login
22+
/pwb_sign_up
23+
/pwb_forgot_password
24+
/pwb_change_password
25+
/auth
26+
/graphql
27+
/graphiql
28+
/api-docs
29+
/.well-known
30+
].freeze
31+
32+
# Check if request should be handled by Astro proxy
33+
def matches?(request)
34+
return false if excluded_path?(request.path)
35+
36+
website = website_from_request(request)
37+
website&.client_rendering? || false
38+
end
39+
40+
private
41+
42+
# Find website from request (by custom domain or subdomain)
43+
def website_from_request(request)
44+
# Try custom domain first
45+
website = Pwb::Website.find_by(custom_domain: request.host)
46+
return website if website
47+
48+
# Try subdomain
49+
subdomain = extract_subdomain(request.host)
50+
Pwb::Website.find_by(subdomain: subdomain) if subdomain
51+
end
52+
53+
# Extract subdomain from host
54+
def extract_subdomain(host)
55+
# Skip if it's an IP address
56+
return nil if host.match?(/\A\d+\.\d+\.\d+\.\d+\z/)
57+
58+
parts = host.split('.')
59+
return nil if parts.length < 3
60+
61+
# Handle cases like tenant.propertywebbuilder.com
62+
parts.first
63+
end
64+
65+
# Check if path should be excluded from proxy
66+
def excluded_path?(path)
67+
EXCLUDED_PATHS.any? { |prefix| path.start_with?(prefix) }
68+
end
69+
end
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# frozen_string_literal: true
2+
3+
require_dependency 'pwb/application_controller'
4+
5+
module Pwb
6+
# Proxy controller for client-rendered websites (A themes)
7+
# Routes requests through Rails to the Astro client server while maintaining
8+
# authentication and tenant context
9+
class ClientProxyController < ApplicationController
10+
include SubdomainTenant
11+
12+
# Skip standard Rails processing for proxied requests
13+
skip_before_action :verify_authenticity_token, only: [:public_proxy, :admin_proxy]
14+
15+
before_action :ensure_client_rendering_mode
16+
before_action :authenticate_for_admin_routes!, only: [:admin_proxy]
17+
18+
# Proxy public pages to Astro client
19+
def public_proxy
20+
proxy_to_astro_client(request.fullpath)
21+
end
22+
23+
# Proxy admin/management pages to Astro client (requires auth)
24+
def admin_proxy
25+
proxy_to_astro_client(request.fullpath, with_auth: true)
26+
end
27+
28+
private
29+
30+
# Ensure this controller only handles client-rendered websites
31+
def ensure_client_rendering_mode
32+
unless @current_website&.client_rendering?
33+
# Fall through to normal Rails rendering
34+
raise ActionController::RoutingError, 'Not Found'
35+
end
36+
end
37+
38+
# Require authentication for client admin routes
39+
def authenticate_for_admin_routes!
40+
return if user_signed_in?
41+
42+
store_location_for(:user, request.fullpath)
43+
redirect_to new_user_session_path, alert: 'Please sign in to access this page.'
44+
end
45+
46+
# Main proxy method - forwards requests to Astro client
47+
def proxy_to_astro_client(path, with_auth: false)
48+
astro_url = build_astro_url(path)
49+
50+
# Build request headers
51+
headers = proxy_headers
52+
headers.merge!(auth_headers) if with_auth
53+
54+
begin
55+
response = HTTP
56+
.timeout(connect: 5, read: 30)
57+
.headers(headers)
58+
.request(request.method.downcase.to_sym, astro_url, body: request.body.read)
59+
60+
render_proxy_response(response)
61+
rescue HTTP::Error, HTTP::TimeoutError => e
62+
Rails.logger.error "Astro proxy error: #{e.message}"
63+
render_proxy_error
64+
end
65+
end
66+
67+
# Build the full URL to the Astro client
68+
def build_astro_url(path)
69+
"#{astro_client_url}#{path}"
70+
end
71+
72+
# Get Astro client URL from environment
73+
def astro_client_url
74+
ENV.fetch('ASTRO_CLIENT_URL', 'http://localhost:4321')
75+
end
76+
77+
# Headers to forward to Astro client
78+
def proxy_headers
79+
{
80+
'X-Forwarded-Host' => request.host,
81+
'X-Forwarded-Proto' => request.protocol.chomp('://'),
82+
'X-Forwarded-For' => request.remote_ip,
83+
'X-Website-Slug' => @current_website&.subdomain,
84+
'X-Website-Id' => @current_website&.id.to_s,
85+
'X-Rendering-Mode' => 'client',
86+
'X-Client-Theme' => @current_website&.client_theme_name,
87+
'Accept' => request.headers['Accept'] || '*/*',
88+
'Accept-Language' => request.headers['Accept-Language'],
89+
'Content-Type' => request.content_type
90+
}.compact
91+
end
92+
93+
# Authentication headers for Astro to verify
94+
def auth_headers
95+
{
96+
'X-User-Id' => current_user&.id.to_s,
97+
'X-User-Email' => current_user&.email,
98+
'X-User-Role' => current_user_role,
99+
'X-Auth-Token' => generate_proxy_auth_token
100+
}.compact
101+
end
102+
103+
# Get current user's role for this website
104+
def current_user_role
105+
return 'guest' unless current_user
106+
107+
membership = current_user.user_memberships.find_by(website: @current_website)
108+
membership&.role || 'member'
109+
end
110+
111+
# Generate short-lived JWT for Astro to verify request authenticity
112+
def generate_proxy_auth_token
113+
payload = {
114+
user_id: current_user&.id,
115+
website_id: @current_website&.id,
116+
exp: 5.minutes.from_now.to_i,
117+
iat: Time.current.to_i
118+
}
119+
JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
120+
end
121+
122+
# Render the response from Astro client
123+
def render_proxy_response(response)
124+
# Copy relevant response headers
125+
response.headers.each do |key, value|
126+
# Skip hop-by-hop headers
127+
next if hop_by_hop_header?(key)
128+
129+
headers[key] = value
130+
end
131+
132+
# Handle content type
133+
content_type = response.content_type&.mime_type || 'text/html'
134+
135+
render body: response.body.to_s,
136+
status: response.status,
137+
content_type: content_type
138+
end
139+
140+
# Check if header is a hop-by-hop header (should not be forwarded)
141+
def hop_by_hop_header?(header_name)
142+
%w[
143+
connection
144+
keep-alive
145+
transfer-encoding
146+
te
147+
trailer
148+
upgrade
149+
proxy-authorization
150+
proxy-authenticate
151+
].include?(header_name.downcase)
152+
end
153+
154+
# Render error page when Astro client is unavailable
155+
def render_proxy_error
156+
if request.format.html?
157+
render 'pwb/errors/proxy_unavailable', status: :service_unavailable, layout: false
158+
else
159+
render json: { error: 'Client application unavailable' },
160+
status: :service_unavailable
161+
end
162+
end
163+
end
164+
end

0 commit comments

Comments
 (0)