rack-spa 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/rack/csp.rb +18 -0
- data/lib/rack/dynamic_config_writer.rb +59 -0
- data/lib/rack/immutable.rb +32 -0
- data/lib/rack/lambda_app.rb +27 -0
- data/lib/rack/remote_ip.rb +182 -0
- data/lib/rack/service_worker_allowed.rb +20 -0
- data/lib/rack/simple_redirect.rb +55 -0
- data/lib/rack/spa_app.rb +37 -0
- data/lib/rack/spa_rewrite.rb +53 -0
- data/lib/rack_spa.rb +5 -0
- metadata +203 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f987e16a684d7095a44f36c3aa3c829d16f13a6d6eff194b3275a2a546941afc
|
4
|
+
data.tar.gz: dc4df61474e03ef8c60d80f72a3dace7c32b17105a3466dbaede1e95d9bfc62b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8a31ce6bd3909f435824c945dce2f99037fa9acac5aca3dbd916bcbe61999c6bf93d2b0e47833217d37015980d50990cd88039ca85d0bd15074e535d1a1ea358
|
7
|
+
data.tar.gz: e62de5586131f91df9f0d5f52bcea43cc1cf525597448a8dd3f4904f342e82cddb3001094ddc13dea481022f89928f42fbb59ff6103cc34492bc4de45d6b3516
|
data/lib/rack/csp.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
class Csp
|
7
|
+
def initialize(app, policy:)
|
8
|
+
@app = app
|
9
|
+
@policy = policy
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
status, headers, body = @app.call(env)
|
14
|
+
headers["Content-Security-Policy"] = @policy
|
15
|
+
[status, headers, body]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
require "nokogiri"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
# Allow dynamic configuration of a SPA.
|
8
|
+
# When the backend app starts up, it should run #emplace.
|
9
|
+
# This will 1) copy the index.html to a 'backup' location
|
10
|
+
# if it does not exist, 2) replace a placeholder string
|
11
|
+
# in the index.html with the given keys and values
|
12
|
+
# (use .pick_env_vars to pull everything like 'REACT_APP_'),
|
13
|
+
# and write it out to index.html.
|
14
|
+
#
|
15
|
+
# IMPORTANT: This sort of dynamic config writing is not normal
|
16
|
+
# for SPAs so needs some further explanation.
|
17
|
+
# The build process should be exactly the same;
|
18
|
+
# for example, you'd still run `npm run build`,
|
19
|
+
# and generate a totally normal build output.
|
20
|
+
# It's the *backend* running that modifies index.html
|
21
|
+
# (and creates index.html.original) *at backend startup*,
|
22
|
+
# not at build time.
|
23
|
+
module Rack
|
24
|
+
class DynamicConfigWriter
|
25
|
+
GLOBAL_ASSIGN = "window.rackDynamicConfig"
|
26
|
+
BACKUP_SUFFIX = ".original"
|
27
|
+
|
28
|
+
def initialize(
|
29
|
+
index_html_path,
|
30
|
+
global_assign: GLOBAL_ASSIGN,
|
31
|
+
backup_suffix: BACKUP_SUFFIX
|
32
|
+
)
|
33
|
+
@index_html_path = index_html_path
|
34
|
+
@global_assign = global_assign
|
35
|
+
@index_html_backup = index_html_path + backup_suffix
|
36
|
+
end
|
37
|
+
|
38
|
+
def emplace(keys_and_values)
|
39
|
+
self.prepare
|
40
|
+
json = JSON.generate(keys_and_values)
|
41
|
+
script = "#{@global_assign}=#{json}"
|
42
|
+
::File.open(@index_html_backup) do |f|
|
43
|
+
doc = Nokogiri::HTML5(f)
|
44
|
+
doc.at("head").prepend_child("<script>#{script}</script>")
|
45
|
+
::File.write(@index_html_path, doc.serialize)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
protected def prepare
|
50
|
+
return if ::File.exist?(@index_html_backup)
|
51
|
+
::FileUtils.move(@index_html_path, @index_html_backup)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.pick_env(regex_or_prefix)
|
55
|
+
return ENV.to_a.select { |(k, _v)| k.start_with?(regex_or_prefix) }.to_h if regex_or_prefix.is_a?(String)
|
56
|
+
return ENV.to_a.select { |(k, _v)| regex_or_prefix.match?(k) }.to_h
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
class Immutable
|
7
|
+
# By default, match strings like 'main.abc123.js'.
|
8
|
+
# Assume the fingerprint is a Git SHA value of at least 6 characters,
|
9
|
+
# comes before the extension, and has some preceding segment of the path.
|
10
|
+
DEFAULT_MATCH = /.*\.[A-Za-z\d]{6}[A-Za-z\d]*\.[a-z]+/
|
11
|
+
IMMUTABLE = "public, max-age=604800, immutable"
|
12
|
+
|
13
|
+
def initialize(app, match: nil, cache_control: nil)
|
14
|
+
@app = app
|
15
|
+
@match = match || DEFAULT_MATCH
|
16
|
+
@cache_control = cache_control || IMMUTABLE
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(env)
|
20
|
+
status, headers, body = @app.call(env)
|
21
|
+
headers[Rack::CACHE_CONTROL] = @cache_control if
|
22
|
+
self._matches(env["PATH_INFO"], env)
|
23
|
+
return status, headers, body
|
24
|
+
end
|
25
|
+
|
26
|
+
def _matches(path, env)
|
27
|
+
return @match == path if @match.is_a?(String)
|
28
|
+
return @match.match?(path) if @match.is_a?(Regexp)
|
29
|
+
return @match.call(env)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
# Call the inner proc as the app.
|
6
|
+
# If proc returns nil, keep calling middleware;
|
7
|
+
# if proc returns a result, return it.
|
8
|
+
module Rack
|
9
|
+
class LambdaApp
|
10
|
+
# LambdaApp.new returns itself, which has a +new+ method so it can
|
11
|
+
# be used like normal middleware.
|
12
|
+
def initialize(proc)
|
13
|
+
@proc = proc
|
14
|
+
end
|
15
|
+
|
16
|
+
def new(app)
|
17
|
+
@app = app
|
18
|
+
return self
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(env)
|
22
|
+
result = @proc.call(env)
|
23
|
+
return result if result
|
24
|
+
return @app.call(env)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ipaddr"
|
4
|
+
|
5
|
+
# This is based almost entirely on
|
6
|
+
# https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/middleware/remote_ip.rb
|
7
|
+
# It hews as close to it as possible, while removign the dependency on ActionDispatch,
|
8
|
+
# which we do not want/need. Unlike ActiveSupport, it is pretty tightly tied to Rails.
|
9
|
+
#
|
10
|
+
# ORIGINAL COMMENT
|
11
|
+
#
|
12
|
+
# This middleware calculates the IP address of the remote client that is
|
13
|
+
# making the request. It does this by checking various headers that could
|
14
|
+
# contain the address, and then picking the last-set address that is not
|
15
|
+
# on the list of trusted IPs. This follows the precedent set by e.g.
|
16
|
+
# {the Tomcat server}[https://issues.apache.org/bugzilla/show_bug.cgi?id=50453].
|
17
|
+
# A more detailed explanation of the algorithm is given at GetIp#calculate_ip.
|
18
|
+
#
|
19
|
+
# Some Rack servers concatenate repeated headers, like {HTTP RFC 2616}[https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2]
|
20
|
+
# requires. Some Rack servers simply drop preceding headers, and only report
|
21
|
+
# the value that was {given in the last header}[https://andre.arko.net/2011/12/26/repeated-headers-and-ruby-web-servers].
|
22
|
+
# If you are behind multiple proxy servers (like NGINX to HAProxy to Unicorn)
|
23
|
+
# then you should test your Rack server to make sure your data is good.
|
24
|
+
#
|
25
|
+
# IF YOU DON'T USE A PROXY, THIS MAKES YOU VULNERABLE TO IP SPOOFING.
|
26
|
+
# This middleware assumes that there is at least one proxy sitting around
|
27
|
+
# and setting headers with the client's remote IP address. If you don't use
|
28
|
+
# a proxy, because you are hosted on e.g. Heroku without SSL, any client can
|
29
|
+
# claim to have any IP address by setting the +X-Forwarded-For+ header. If you
|
30
|
+
# care about that, then you need to explicitly drop or ignore those headers
|
31
|
+
# sometime before this middleware runs. Alternatively, remove this middleware
|
32
|
+
# to avoid inadvertently relying on it.
|
33
|
+
module Rack
|
34
|
+
class RemoteIp
|
35
|
+
class IpSpoofAttackError < StandardError; end
|
36
|
+
|
37
|
+
# The default trusted IPs list simply includes IP addresses that are
|
38
|
+
# guaranteed by the IP specification to be private addresses. Those will
|
39
|
+
# not be the ultimate client IP in production, and so are discarded. See
|
40
|
+
# https://en.wikipedia.org/wiki/Private_network for details.
|
41
|
+
TRUSTED_PROXIES = [
|
42
|
+
"127.0.0.0/8", # localhost IPv4 range, per RFC-3330
|
43
|
+
"::1", # localhost IPv6
|
44
|
+
"fc00::/7", # private IPv6 range fc00::/7
|
45
|
+
"10.0.0.0/8", # private IPv4 range 10.x.x.x
|
46
|
+
"172.16.0.0/12", # private IPv4 range 172.16.0.0 .. 172.31.255.255
|
47
|
+
"192.168.0.0/16", # private IPv4 range 192.168.x.x
|
48
|
+
].map { |proxy| IPAddr.new(proxy) }
|
49
|
+
|
50
|
+
attr_reader :check_ip, :proxies
|
51
|
+
|
52
|
+
# Create a new +RemoteIp+ middleware instance.
|
53
|
+
#
|
54
|
+
# The +skip_ip_spoofing_check+ option is off by default (so spoofing is enabled).
|
55
|
+
# When on, an exception
|
56
|
+
# is raised if it looks like the client is trying to lie about its own IP
|
57
|
+
# address. It makes sense to turn off this check on sites aimed at non-IP
|
58
|
+
# clients (like WAP devices), or behind proxies that set headers in an
|
59
|
+
# incorrect or confusing way (like AWS ELB).
|
60
|
+
#
|
61
|
+
# The +custom_proxies+ argument can take an enumerable which will be used
|
62
|
+
# instead of +TRUSTED_PROXIES+. Any proxy setup will put the value you
|
63
|
+
# want in the middle (or at the beginning) of the +X-Forwarded-For+ list,
|
64
|
+
# with your proxy servers after it. If your proxies aren't removed, pass
|
65
|
+
# them in via the +custom_proxies+ parameter. That way, the middleware will
|
66
|
+
# ignore those IP addresses, and return the one that you want.
|
67
|
+
def initialize(app, skip_ip_spoofing_check: false, custom_proxies: [])
|
68
|
+
@app = app
|
69
|
+
@check_ip = !skip_ip_spoofing_check
|
70
|
+
@proxies = TRUSTED_PROXIES + custom_proxies
|
71
|
+
end
|
72
|
+
|
73
|
+
# Since the IP address may not be needed, we store the object here
|
74
|
+
# without calculating the IP to keep from slowing down the majority of
|
75
|
+
# requests. For those requests that do need to know the IP, the
|
76
|
+
# GetIp#calculate_ip method will calculate the memoized client IP address.
|
77
|
+
def call(env)
|
78
|
+
env["remote_ip"] = GetIp.new(env, self.check_ip, self.proxies)
|
79
|
+
@app.call(env)
|
80
|
+
end
|
81
|
+
|
82
|
+
# The GetIp class exists as a way to defer processing of the request data
|
83
|
+
# into an actual IP address. If env['remote_ip'].to_s is called,
|
84
|
+
# this class will calculate the value and then memoize it.
|
85
|
+
class GetIp
|
86
|
+
def initialize(env, check_ip, proxies)
|
87
|
+
@env = env
|
88
|
+
@check_ip = check_ip
|
89
|
+
@proxies = proxies
|
90
|
+
end
|
91
|
+
|
92
|
+
def remote_addr = @remote_addr ||= @env["REMOTE_ADDR"]
|
93
|
+
def client_ip = @client_ip ||= @env["HTTP_CLIENT_IP"]
|
94
|
+
def x_forwarded_for = @x_forwarded_for ||= @env["HTTP_X_FORWARDED_FOR"]
|
95
|
+
|
96
|
+
# Sort through the various IP address headers, looking for the IP most
|
97
|
+
# likely to be the address of the actual remote client making this
|
98
|
+
# request.
|
99
|
+
#
|
100
|
+
# REMOTE_ADDR will be correct if the request is made directly against the
|
101
|
+
# Ruby process, on e.g. Heroku. When the request is proxied by another
|
102
|
+
# server like HAProxy or NGINX, the IP address that made the original
|
103
|
+
# request will be put in an +X-Forwarded-For+ header. If there are multiple
|
104
|
+
# proxies, that header may contain a list of IPs. Other proxy services
|
105
|
+
# set the +Client-Ip+ header instead, so we check that too.
|
106
|
+
#
|
107
|
+
# As discussed in {this post about Rails IP Spoofing}[https://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection/],
|
108
|
+
# while the first IP in the list is likely to be the "originating" IP,
|
109
|
+
# it could also have been set by the client maliciously.
|
110
|
+
#
|
111
|
+
# In order to find the first address that is (probably) accurate, we
|
112
|
+
# take the list of IPs, remove known and trusted proxies, and then take
|
113
|
+
# the last address left, which was presumably set by one of those proxies.
|
114
|
+
def calculate_ip
|
115
|
+
# Set by the Rack web server, this is a single value.
|
116
|
+
remote_addr = ips_from(self.remote_addr).last
|
117
|
+
|
118
|
+
# Could be a CSV list and/or repeated headers that were concatenated.
|
119
|
+
client_ips = ips_from(self.client_ip).reverse
|
120
|
+
forwarded_ips = ips_from(self.x_forwarded_for).reverse
|
121
|
+
|
122
|
+
# +Client-Ip+ and +X-Forwarded-For+ should not, generally, both be set.
|
123
|
+
# If they are both set, it means that either:
|
124
|
+
#
|
125
|
+
# 1) This request passed through two proxies with incompatible IP header
|
126
|
+
# conventions.
|
127
|
+
# 2) The client passed one of +Client-Ip+ or +X-Forwarded-For+
|
128
|
+
# (whichever the proxy servers weren't using) themselves.
|
129
|
+
#
|
130
|
+
# Either way, there is no way for us to determine which header is the
|
131
|
+
# right one after the fact. Since we have no idea, if we are concerned
|
132
|
+
# about IP spoofing we need to give up and explode. (If you're not
|
133
|
+
# concerned about IP spoofing you can turn the +ip_spoofing_check+
|
134
|
+
# option off.)
|
135
|
+
should_check_ip = @check_ip && client_ips.last && forwarded_ips.last
|
136
|
+
if should_check_ip && !forwarded_ips.include?(client_ips.last)
|
137
|
+
# We don't know which came from the proxy, and which from the user
|
138
|
+
raise IpSpoofAttackError, "IP spoofing attack?! " \
|
139
|
+
"HTTP_CLIENT_IP=#{env['HTTP_CLIENT_IP'].inspect} " \
|
140
|
+
"HTTP_X_FORWARDED_FOR=#{env['HTTP_X_FORWARDED_FOR'].inspect}"
|
141
|
+
end
|
142
|
+
|
143
|
+
# We assume these things about the IP headers:
|
144
|
+
#
|
145
|
+
# - X-Forwarded-For will be a list of IPs, one per proxy, or blank
|
146
|
+
# - Client-Ip is propagated from the outermost proxy, or is blank
|
147
|
+
# - REMOTE_ADDR will be the IP that made the request to Rack
|
148
|
+
ips = [forwarded_ips, client_ips].flatten.compact
|
149
|
+
|
150
|
+
# If every single IP option is in the trusted list, return the IP
|
151
|
+
# that's furthest away
|
152
|
+
filter_proxies(ips + [remote_addr]).first || ips.last || remote_addr
|
153
|
+
end
|
154
|
+
|
155
|
+
# Memoizes the value returned by #calculate_ip and returns it for
|
156
|
+
# ActionDispatch::Request to use.
|
157
|
+
def to_s
|
158
|
+
return @to_s ||= self.calculate_ip
|
159
|
+
end
|
160
|
+
|
161
|
+
private def ips_from(header)
|
162
|
+
return [] unless header
|
163
|
+
# Split the comma-separated list into an array of strings.
|
164
|
+
ips = header.strip.split(/[,\s]+/)
|
165
|
+
ips.select do |ip|
|
166
|
+
# Only return IPs that are valid according to the IPAddr#new method.
|
167
|
+
range = IPAddr.new(ip).to_range
|
168
|
+
# We want to make sure nobody is sneaking a netmask in.
|
169
|
+
range.begin == range.end
|
170
|
+
rescue ArgumentError
|
171
|
+
nil
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
private def filter_proxies(ips)
|
176
|
+
ips.reject do |ip|
|
177
|
+
@proxies.any? { |proxy| proxy === ip }
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
# Set the Service-Worker-Allowed header to the given scope
|
6
|
+
# if this is a service worker request.
|
7
|
+
module Rack
|
8
|
+
class ServiceWorkerAllowed
|
9
|
+
def initialize(app, scope:)
|
10
|
+
@app = app
|
11
|
+
@scope = scope
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
status, headers, body = @app.call(env)
|
16
|
+
headers["Service-Worker-Allowed"] = @scope if env["HTTP_SERVICE_WORKER"]
|
17
|
+
return status, headers, body
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
# Redirect a request based on the route it matches.
|
6
|
+
module Rack
|
7
|
+
class SimpleRedirect
|
8
|
+
# The keys in +routes+ can be strings, regular expressions, or callables.
|
9
|
+
# For a key that matches (using == for strings, .match?(path) for regexes,
|
10
|
+
# or call(env) for callables), the Location in the redirect
|
11
|
+
# is equal to the value of the key, or if the value is a callable,
|
12
|
+
# the returned result of calling the value with env.
|
13
|
+
def initialize(app, routes: {}, status: 302)
|
14
|
+
@app = app
|
15
|
+
@routes = routes
|
16
|
+
@status = status
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(env)
|
20
|
+
path = env["PATH_INFO"]
|
21
|
+
loc = nil
|
22
|
+
@routes.each do |route, result|
|
23
|
+
if self._matches(path, env, route)
|
24
|
+
loc = result.respond_to?(:call) ? result[env] : result
|
25
|
+
break
|
26
|
+
end
|
27
|
+
end
|
28
|
+
return @app.call(env) if loc.nil?
|
29
|
+
return [@status, {"Location" => loc}, []]
|
30
|
+
end
|
31
|
+
|
32
|
+
def _matches(path, env, route)
|
33
|
+
return route == path if route.is_a?(String)
|
34
|
+
return route.match?(path) if route.is_a?(Regexp)
|
35
|
+
return route.call(env)
|
36
|
+
end
|
37
|
+
|
38
|
+
def _check_routes_opts(h)
|
39
|
+
h.each do |k, v|
|
40
|
+
case k
|
41
|
+
when String, Regexp, Proc
|
42
|
+
nil
|
43
|
+
else
|
44
|
+
raise "SimpleRedirect routes keys must be strings, regexes, or procs"
|
45
|
+
end
|
46
|
+
case v
|
47
|
+
when String, Proc
|
48
|
+
nil
|
49
|
+
else
|
50
|
+
raise "SimpleRedirect routes values must be strings or procs"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/lib/rack/spa_app.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack/immutable"
|
4
|
+
require "rack/lambda_app"
|
5
|
+
require "rack/simple_redirect"
|
6
|
+
require "rack/spa_rewrite"
|
7
|
+
|
8
|
+
module Rack
|
9
|
+
class SpaApp
|
10
|
+
def self.dependencies(build_folder, immutable: true, enforce_ssl: true, service_worker_allowed: nil)
|
11
|
+
result = []
|
12
|
+
result << [Rack::SslEnforcer, {redirect_html: false}] if enforce_ssl
|
13
|
+
result << [Rack::ConditionalGet, {}]
|
14
|
+
result << [Rack::ETag, {}]
|
15
|
+
result << [Rack::Immutable, {match: immutable.is_a?(TrueClass) ? nil : immutable}] if immutable
|
16
|
+
result << [Rack::SpaRewrite, {index_path: "#{build_folder}/index.html", html_only: true}]
|
17
|
+
result << [Rack::ServiceWorkerAllowed, {scope: service_worker_allowed}] if service_worker_allowed
|
18
|
+
result << [Rack::Static, {urls: [""], root: build_folder.to_s, cascade: true}]
|
19
|
+
result << [Rack::SpaRewrite, {index_path: "#{build_folder}/index.html", html_only: false}]
|
20
|
+
return result
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.install(builder, dependencies)
|
24
|
+
dependencies.each { |cls, opts| builder.use(cls, **opts) }
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.run(builder)
|
28
|
+
builder.run Rack::LambdaApp.new(->(_) { raise "Should not see SpaApp fallback" })
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.run_spa_app(builder, build_folder, enforce_ssl: true, immutable: true, **kw)
|
32
|
+
deps = self.dependencies(build_folder, enforce_ssl:, immutable:, **kw)
|
33
|
+
self.install(builder, deps)
|
34
|
+
self.run(builder)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
class SpaRewrite
|
7
|
+
ALLOWED_VERBS = ["GET", "HEAD", "OPTIONS"].freeze
|
8
|
+
ALLOW_HEADER = ALLOWED_VERBS.join(", ")
|
9
|
+
|
10
|
+
def initialize(app, index_path:, html_only:)
|
11
|
+
@app = app
|
12
|
+
@index_path = index_path
|
13
|
+
@html_only = html_only
|
14
|
+
begin
|
15
|
+
@index_mtime = ::File.mtime(@index_path).httpdate
|
16
|
+
rescue Errno::ENOENT
|
17
|
+
@index_mtime = Time.at(0)
|
18
|
+
end
|
19
|
+
@index_bytes = nil
|
20
|
+
@head = Rack::Head.new(->(env) { get env })
|
21
|
+
end
|
22
|
+
|
23
|
+
def call(env)
|
24
|
+
# HEAD requests drop the response body, including 4xx error messages.
|
25
|
+
@head.call env
|
26
|
+
end
|
27
|
+
|
28
|
+
def get(env)
|
29
|
+
request = Rack::Request.new env
|
30
|
+
return @app.call(env) if @html_only && !request.path_info.end_with?(".html")
|
31
|
+
return [405, {"Allow" => ALLOW_HEADER}, ["Method Not Allowed"]] unless
|
32
|
+
ALLOWED_VERBS.include?(request.request_method)
|
33
|
+
|
34
|
+
path_info = Rack::Utils.unescape_path(request.path_info)
|
35
|
+
return [400, {}, ["Bad Request"]] unless Rack::Utils.valid_path?(path_info)
|
36
|
+
|
37
|
+
return [200, {"Allow" => ALLOW_HEADER, Rack::CONTENT_LENGTH => "0"}, []] if
|
38
|
+
request.options?
|
39
|
+
|
40
|
+
lastmod = ::File.mtime(@index_path)
|
41
|
+
lastmodhttp = lastmod.httpdate
|
42
|
+
return [304, {}, []] if request.get_header("HTTP_IF_MODIFIED_SINCE") == lastmodhttp
|
43
|
+
|
44
|
+
@index_bytes = ::File.read(@index_path) if @index_bytes.nil? || @index_mtime < lastmodhttp
|
45
|
+
headers = {
|
46
|
+
"content-length" => @index_bytes.bytesize.to_s,
|
47
|
+
"content-type" => "text/html",
|
48
|
+
"last-modified" => lastmodhttp,
|
49
|
+
}
|
50
|
+
return [200, headers, [@index_bytes]]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
data/lib/rack_spa.rb
ADDED
metadata
ADDED
@@ -0,0 +1,203 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rack-spa
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.10.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Lithic Tech
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-03-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: nokogiri
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.10'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.10'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rack
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.0'
|
34
|
+
- - "<"
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: '4.0'
|
37
|
+
type: :runtime
|
38
|
+
prerelease: false
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '2.0'
|
44
|
+
- - "<"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '4.0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: rackup
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '2.1'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '2.1'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: rspec
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '3.10'
|
68
|
+
type: :development
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '3.10'
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: rspec-core
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '3.10'
|
82
|
+
type: :development
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - "~>"
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: '3.10'
|
89
|
+
- !ruby/object:Gem::Dependency
|
90
|
+
name: rspec-temp_dir
|
91
|
+
requirement: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - "~>"
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: '1.1'
|
96
|
+
type: :development
|
97
|
+
prerelease: false
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - "~>"
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '1.1'
|
103
|
+
- !ruby/object:Gem::Dependency
|
104
|
+
name: rubocop
|
105
|
+
requirement: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - "~>"
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 1.25.1
|
110
|
+
type: :development
|
111
|
+
prerelease: false
|
112
|
+
version_requirements: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - "~>"
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: 1.25.1
|
117
|
+
- !ruby/object:Gem::Dependency
|
118
|
+
name: rubocop-performance
|
119
|
+
requirement: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - "~>"
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: 1.13.3
|
124
|
+
type: :development
|
125
|
+
prerelease: false
|
126
|
+
version_requirements: !ruby/object:Gem::Requirement
|
127
|
+
requirements:
|
128
|
+
- - "~>"
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: 1.13.3
|
131
|
+
- !ruby/object:Gem::Dependency
|
132
|
+
name: simplecov
|
133
|
+
requirement: !ruby/object:Gem::Requirement
|
134
|
+
requirements:
|
135
|
+
- - "~>"
|
136
|
+
- !ruby/object:Gem::Version
|
137
|
+
version: '0'
|
138
|
+
type: :development
|
139
|
+
prerelease: false
|
140
|
+
version_requirements: !ruby/object:Gem::Requirement
|
141
|
+
requirements:
|
142
|
+
- - "~>"
|
143
|
+
- !ruby/object:Gem::Version
|
144
|
+
version: '0'
|
145
|
+
- !ruby/object:Gem::Dependency
|
146
|
+
name: timecop
|
147
|
+
requirement: !ruby/object:Gem::Requirement
|
148
|
+
requirements:
|
149
|
+
- - "~>"
|
150
|
+
- !ruby/object:Gem::Version
|
151
|
+
version: '0.9'
|
152
|
+
type: :development
|
153
|
+
prerelease: false
|
154
|
+
version_requirements: !ruby/object:Gem::Requirement
|
155
|
+
requirements:
|
156
|
+
- - "~>"
|
157
|
+
- !ruby/object:Gem::Version
|
158
|
+
version: '0.9'
|
159
|
+
description: 'Rack middlewares to make building and serving a Single Page App from
|
160
|
+
a Ruby Rack app easy.
|
161
|
+
|
162
|
+
'
|
163
|
+
email: hello@lithic.tech
|
164
|
+
executables: []
|
165
|
+
extensions: []
|
166
|
+
extra_rdoc_files: []
|
167
|
+
files:
|
168
|
+
- lib/rack/csp.rb
|
169
|
+
- lib/rack/dynamic_config_writer.rb
|
170
|
+
- lib/rack/immutable.rb
|
171
|
+
- lib/rack/lambda_app.rb
|
172
|
+
- lib/rack/remote_ip.rb
|
173
|
+
- lib/rack/service_worker_allowed.rb
|
174
|
+
- lib/rack/simple_redirect.rb
|
175
|
+
- lib/rack/spa_app.rb
|
176
|
+
- lib/rack/spa_rewrite.rb
|
177
|
+
- lib/rack_spa.rb
|
178
|
+
homepage: https://github.com/lithictech/rack-spa
|
179
|
+
licenses:
|
180
|
+
- MIT
|
181
|
+
metadata:
|
182
|
+
rubygems_mfa_required: 'true'
|
183
|
+
post_install_message:
|
184
|
+
rdoc_options: []
|
185
|
+
require_paths:
|
186
|
+
- lib
|
187
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
188
|
+
requirements:
|
189
|
+
- - ">="
|
190
|
+
- !ruby/object:Gem::Version
|
191
|
+
version: 3.1.0
|
192
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
193
|
+
requirements:
|
194
|
+
- - ">="
|
195
|
+
- !ruby/object:Gem::Version
|
196
|
+
version: '0'
|
197
|
+
requirements: []
|
198
|
+
rubygems_version: 3.3.7
|
199
|
+
signing_key:
|
200
|
+
specification_version: 4
|
201
|
+
summary: Rack middlewares to make building and serving a Single Page App from a Ruby
|
202
|
+
Rack app easy.
|
203
|
+
test_files: []
|