utopia 2.30.2 → 2.31.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/utopia/server.rb +1 -1
- data/bake/utopia/site.rb +3 -3
- data/context/getting-started.md +93 -0
- data/context/index.yaml +32 -0
- data/context/integrating-with-javascript.md +75 -0
- data/context/middleware.md +157 -0
- data/context/server-setup.md +116 -0
- data/context/updating-utopia.md +69 -0
- data/context/what-is-xnode.md +41 -0
- data/lib/utopia/content/document.rb +39 -37
- data/lib/utopia/content/link.rb +1 -2
- data/lib/utopia/content/links.rb +2 -2
- data/lib/utopia/content/markup.rb +10 -10
- data/lib/utopia/content/middleware.rb +195 -0
- data/lib/utopia/content/namespace.rb +1 -1
- data/lib/utopia/content/node.rb +1 -1
- data/lib/utopia/content/response.rb +1 -1
- data/lib/utopia/content/tags.rb +1 -1
- data/lib/utopia/content.rb +4 -186
- data/lib/utopia/controller/actions.md +8 -8
- data/lib/utopia/controller/actions.rb +1 -1
- data/lib/utopia/controller/base.rb +4 -4
- data/lib/utopia/controller/middleware.rb +133 -0
- data/lib/utopia/controller/respond.rb +2 -46
- data/lib/utopia/controller/responder.rb +103 -0
- data/lib/utopia/controller/rewrite.md +2 -2
- data/lib/utopia/controller/rewrite.rb +1 -1
- data/lib/utopia/controller/variables.rb +11 -5
- data/lib/utopia/controller.rb +4 -126
- data/lib/utopia/exceptions/mailer.rb +4 -4
- data/lib/utopia/extensions/array_split.rb +2 -2
- data/lib/utopia/extensions/date_comparisons.rb +3 -3
- data/lib/utopia/import_map.rb +374 -0
- data/lib/utopia/localization/middleware.rb +173 -0
- data/lib/utopia/localization/wrapper.rb +52 -0
- data/lib/utopia/localization.rb +4 -202
- data/lib/utopia/path.rb +26 -11
- data/lib/utopia/redirection.rb +2 -2
- data/lib/utopia/session/lazy_hash.rb +1 -1
- data/lib/utopia/session/middleware.rb +218 -0
- data/lib/utopia/session/serialization.rb +1 -1
- data/lib/utopia/session.rb +4 -205
- data/lib/utopia/static/local_file.rb +19 -19
- data/lib/utopia/static/middleware.rb +120 -0
- data/lib/utopia/static/mime_types.rb +1 -1
- data/lib/utopia/static.rb +4 -108
- data/lib/utopia/version.rb +1 -1
- data/lib/utopia.rb +1 -0
- data/readme.md +7 -0
- data/releases.md +7 -0
- data/setup/site/config.ru +1 -1
- data.tar.gz.sig +0 -0
- metadata +31 -4
- metadata.gz.sig +0 -0
- data/lib/utopia/locale.rb +0 -29
- data/lib/utopia/responder.rb +0 -59
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2014-2025, by Samuel Williams.
|
|
5
|
+
# Copyright, 2019, by Huba Nagy.
|
|
6
|
+
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "digest/sha2"
|
|
9
|
+
require "console"
|
|
10
|
+
require "json"
|
|
11
|
+
|
|
12
|
+
require_relative "lazy_hash"
|
|
13
|
+
require_relative "serialization"
|
|
14
|
+
|
|
15
|
+
module Utopia
|
|
16
|
+
module Session
|
|
17
|
+
# A middleware which provides a secure client-side session storage using a private symmetric encrpytion key.
|
|
18
|
+
class Middleware
|
|
19
|
+
class PayloadError < StandardError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
MAXIMUM_SIZE = 1024*32
|
|
23
|
+
|
|
24
|
+
SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze
|
|
25
|
+
|
|
26
|
+
RACK_SESSION = "rack.session".freeze
|
|
27
|
+
CIPHER_ALGORITHM = "aes-256-cbc"
|
|
28
|
+
|
|
29
|
+
# The session will expire if no requests were made within 24 hours:
|
|
30
|
+
DEFAULT_EXPIRES_AFTER = 3600*24
|
|
31
|
+
|
|
32
|
+
# At least, the session will be updated every 1 hour:
|
|
33
|
+
DEFAULT_UPDATE_TIMEOUT = 3600
|
|
34
|
+
|
|
35
|
+
# @param session_name [String] The name of the session cookie.
|
|
36
|
+
# @param secret [Array] The secret text used to generate a symetric encryption key for the coookie data.
|
|
37
|
+
# @param same_site [Symbol, String] Controls how the cookie is provided to the site.
|
|
38
|
+
# @param expires_after [String] The cache-control header to set for static content.
|
|
39
|
+
# @param options [Hash<Symbol,Object>] Additional defaults used for generating the cookie by `Rack::Utils.set_cookie_header!`.
|
|
40
|
+
def initialize(app, session_name: RACK_SESSION, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, secure: false, same_site: :lax, maximum_size: MAXIMUM_SIZE, **options)
|
|
41
|
+
@app = app
|
|
42
|
+
|
|
43
|
+
@session_name = session_name
|
|
44
|
+
@cookie_name = @session_name + ".encrypted"
|
|
45
|
+
|
|
46
|
+
if secret.nil? or secret.empty?
|
|
47
|
+
raise ArgumentError, "invalid session secret: #{secret.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# This generates a 32-byte key suitable for aes.
|
|
51
|
+
@key = Digest::SHA2.digest(secret)
|
|
52
|
+
|
|
53
|
+
@expires_after = expires_after
|
|
54
|
+
@update_timeout = update_timeout
|
|
55
|
+
|
|
56
|
+
@cookie_defaults = {
|
|
57
|
+
domain: nil,
|
|
58
|
+
path: "/",
|
|
59
|
+
|
|
60
|
+
# The SameSite attribute controls when the cookie is sent to the server, from 3rd parties (None), from requests with external referrers (Lax) or from within the site itself (Strict).
|
|
61
|
+
same_site: same_site,
|
|
62
|
+
|
|
63
|
+
# The Secure attribute is meant to keep cookie communication limited to encrypted transmission, directing browsers to use cookies only via secure/encrypted connections. However, if a web server sets a cookie with a secure attribute from a non-secure connection, the cookie can still be intercepted when it is sent to the user by man-in-the-middle attacks. Therefore, for maximum security, cookies with the Secure attribute should only be set over a secure connection.
|
|
64
|
+
secure: secure,
|
|
65
|
+
|
|
66
|
+
# The HttpOnly attribute directs browsers not to expose cookies through channels other than HTTP (and HTTPS) requests. This means that the cookie cannot be accessed via client-side scripting languages (notably JavaScript), and therefore cannot be stolen easily via cross-site scripting (a pervasive attack technique).
|
|
67
|
+
http_only: true,
|
|
68
|
+
}.merge(options)
|
|
69
|
+
|
|
70
|
+
@serialization = Serialization.new
|
|
71
|
+
@maximum_size = maximum_size
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
attr :cookie_name
|
|
75
|
+
attr :key
|
|
76
|
+
|
|
77
|
+
attr :expires_after
|
|
78
|
+
attr :update_timeout
|
|
79
|
+
|
|
80
|
+
attr :cookie_defaults
|
|
81
|
+
|
|
82
|
+
def freeze
|
|
83
|
+
return self if frozen?
|
|
84
|
+
|
|
85
|
+
@cookie_name.freeze
|
|
86
|
+
@key.freeze
|
|
87
|
+
@expires_after.freeze
|
|
88
|
+
@update_timeout.freeze
|
|
89
|
+
@cookie_defaults.freeze
|
|
90
|
+
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def call(env)
|
|
95
|
+
session_hash = prepare_session(env)
|
|
96
|
+
|
|
97
|
+
status, headers, body = @app.call(env)
|
|
98
|
+
|
|
99
|
+
update_session(env, session_hash, headers)
|
|
100
|
+
|
|
101
|
+
return [status, headers, body]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
protected
|
|
105
|
+
|
|
106
|
+
def prepare_session(env)
|
|
107
|
+
env[RACK_SESSION] = LazyHash.new do
|
|
108
|
+
self.load_session_values(env)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def update_session(env, session_hash, headers)
|
|
113
|
+
if session_hash.needs_update?(@update_timeout)
|
|
114
|
+
values = session_hash.values
|
|
115
|
+
|
|
116
|
+
values[:updated_at] = Time.now.utc
|
|
117
|
+
|
|
118
|
+
data = encrypt(session_hash.values)
|
|
119
|
+
|
|
120
|
+
commit(data, values[:updated_at], headers)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Constructs a valid session for the given request. These fields must match as per the checks performed in `valid_session?`:
|
|
125
|
+
def build_initial_session(request)
|
|
126
|
+
{
|
|
127
|
+
user_agent: request.user_agent,
|
|
128
|
+
created_at: Time.now.utc,
|
|
129
|
+
updated_at: Time.now.utc,
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Load session from user supplied cookie. If the data is invalid or otherwise fails validation, `build_iniital_session` is invoked.
|
|
134
|
+
# @return hash of values.
|
|
135
|
+
def load_session_values(env)
|
|
136
|
+
request = Rack::Request.new(env)
|
|
137
|
+
|
|
138
|
+
# Decrypt the data from the user if possible:
|
|
139
|
+
if data = request.cookies[@cookie_name]
|
|
140
|
+
begin
|
|
141
|
+
if values = decrypt(data)
|
|
142
|
+
validate_session!(request, values)
|
|
143
|
+
|
|
144
|
+
return values
|
|
145
|
+
end
|
|
146
|
+
rescue => error
|
|
147
|
+
Console.error(self, error)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# If we couldn't create a session
|
|
152
|
+
return build_initial_session(request)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate_session!(request, values)
|
|
156
|
+
if values[:user_agent] != request.user_agent
|
|
157
|
+
raise PayloadError, "Invalid session because supplied user agent #{request.user_agent.inspect} does not match session user agent #{values[:user_agent].inspect}!"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if expires_at = expires(values[:updated_at])
|
|
161
|
+
if expires_at < Time.now.utc
|
|
162
|
+
raise PayloadError, "Expired session cookie, user agent submitted a cookie that should have expired at #{expires_at}."
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def expires(updated_at=Time.now.utc)
|
|
170
|
+
if @expires_after
|
|
171
|
+
return updated_at + @expires_after
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def commit(value, updated_at, headers)
|
|
176
|
+
cookie = {
|
|
177
|
+
value: value,
|
|
178
|
+
expires: expires(updated_at)
|
|
179
|
+
}.merge(@cookie_defaults)
|
|
180
|
+
|
|
181
|
+
Rack::Utils.set_cookie_header!(headers, @cookie_name, cookie)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def encrypt(hash)
|
|
185
|
+
c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
|
|
186
|
+
c.encrypt
|
|
187
|
+
|
|
188
|
+
# your pass is what is used to encrypt/decrypt
|
|
189
|
+
c.key = @key
|
|
190
|
+
c.iv = iv = c.random_iv
|
|
191
|
+
|
|
192
|
+
e = c.update(@serialization.dump(hash))
|
|
193
|
+
e << c.final
|
|
194
|
+
|
|
195
|
+
return [iv, e].pack("m16m*")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def decrypt(data)
|
|
199
|
+
if @maximum_size and data.bytesize > @maximum_size
|
|
200
|
+
raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds maximum allowed size #{@maximum_size}bytes!"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
iv, e = data.unpack("m16m*")
|
|
204
|
+
|
|
205
|
+
c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
|
|
206
|
+
c.decrypt
|
|
207
|
+
|
|
208
|
+
c.key = @key
|
|
209
|
+
c.iv = iv
|
|
210
|
+
|
|
211
|
+
d = c.update(e)
|
|
212
|
+
d << c.final
|
|
213
|
+
|
|
214
|
+
return @serialization.load(d)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
data/lib/utopia/session.rb
CHANGED
|
@@ -4,213 +4,12 @@
|
|
|
4
4
|
# Copyright, 2014-2025, by Samuel Williams.
|
|
5
5
|
# Copyright, 2019, by Huba Nagy.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
require "digest/sha2"
|
|
9
|
-
require "console"
|
|
10
|
-
require "json"
|
|
11
|
-
|
|
12
|
-
require_relative "session/lazy_hash"
|
|
13
|
-
require_relative "session/serialization"
|
|
7
|
+
require_relative "session/middleware"
|
|
14
8
|
|
|
15
9
|
module Utopia
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
MAXIMUM_SIZE = 1024*32
|
|
22
|
-
|
|
23
|
-
SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze
|
|
24
|
-
|
|
25
|
-
RACK_SESSION = "rack.session".freeze
|
|
26
|
-
CIPHER_ALGORITHM = "aes-256-cbc"
|
|
27
|
-
|
|
28
|
-
# The session will expire if no requests were made within 24 hours:
|
|
29
|
-
DEFAULT_EXPIRES_AFTER = 3600*24
|
|
30
|
-
|
|
31
|
-
# At least, the session will be updated every 1 hour:
|
|
32
|
-
DEFAULT_UPDATE_TIMEOUT = 3600
|
|
33
|
-
|
|
34
|
-
# @param session_name [String] The name of the session cookie.
|
|
35
|
-
# @param secret [Array] The secret text used to generate a symetric encryption key for the coookie data.
|
|
36
|
-
# @param same_site [Symbol, String] Controls how the cookie is provided to the site.
|
|
37
|
-
# @param expires_after [String] The cache-control header to set for static content.
|
|
38
|
-
# @param options [Hash<Symbol,Object>] Additional defaults used for generating the cookie by `Rack::Utils.set_cookie_header!`.
|
|
39
|
-
def initialize(app, session_name: RACK_SESSION, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, secure: false, same_site: :lax, maximum_size: MAXIMUM_SIZE, **options)
|
|
40
|
-
@app = app
|
|
41
|
-
|
|
42
|
-
@session_name = session_name
|
|
43
|
-
@cookie_name = @session_name + ".encrypted"
|
|
44
|
-
|
|
45
|
-
if secret.nil? or secret.empty?
|
|
46
|
-
raise ArgumentError, "invalid session secret: #{secret.inspect}"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# This generates a 32-byte key suitable for aes.
|
|
50
|
-
@key = Digest::SHA2.digest(secret)
|
|
51
|
-
|
|
52
|
-
@expires_after = expires_after
|
|
53
|
-
@update_timeout = update_timeout
|
|
54
|
-
|
|
55
|
-
@cookie_defaults = {
|
|
56
|
-
domain: nil,
|
|
57
|
-
path: "/",
|
|
58
|
-
|
|
59
|
-
# The SameSite attribute controls when the cookie is sent to the server, from 3rd parties (None), from requests with external referrers (Lax) or from within the site itself (Strict).
|
|
60
|
-
same_site: same_site,
|
|
61
|
-
|
|
62
|
-
# The Secure attribute is meant to keep cookie communication limited to encrypted transmission, directing browsers to use cookies only via secure/encrypted connections. However, if a web server sets a cookie with a secure attribute from a non-secure connection, the cookie can still be intercepted when it is sent to the user by man-in-the-middle attacks. Therefore, for maximum security, cookies with the Secure attribute should only be set over a secure connection.
|
|
63
|
-
secure: secure,
|
|
64
|
-
|
|
65
|
-
# The HttpOnly attribute directs browsers not to expose cookies through channels other than HTTP (and HTTPS) requests. This means that the cookie cannot be accessed via client-side scripting languages (notably JavaScript), and therefore cannot be stolen easily via cross-site scripting (a pervasive attack technique).
|
|
66
|
-
http_only: true,
|
|
67
|
-
}.merge(options)
|
|
68
|
-
|
|
69
|
-
@serialization = Serialization.new
|
|
70
|
-
@maximum_size = maximum_size
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
attr :cookie_name
|
|
74
|
-
attr :key
|
|
75
|
-
|
|
76
|
-
attr :expires_after
|
|
77
|
-
attr :update_timeout
|
|
78
|
-
|
|
79
|
-
attr :cookie_defaults
|
|
80
|
-
|
|
81
|
-
def freeze
|
|
82
|
-
return self if frozen?
|
|
83
|
-
|
|
84
|
-
@cookie_name.freeze
|
|
85
|
-
@key.freeze
|
|
86
|
-
@expires_after.freeze
|
|
87
|
-
@update_timeout.freeze
|
|
88
|
-
@cookie_defaults.freeze
|
|
89
|
-
|
|
90
|
-
super
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def call(env)
|
|
94
|
-
session_hash = prepare_session(env)
|
|
95
|
-
|
|
96
|
-
status, headers, body = @app.call(env)
|
|
97
|
-
|
|
98
|
-
update_session(env, session_hash, headers)
|
|
99
|
-
|
|
100
|
-
return [status, headers, body]
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
protected
|
|
104
|
-
|
|
105
|
-
def prepare_session(env)
|
|
106
|
-
env[RACK_SESSION] = LazyHash.new do
|
|
107
|
-
self.load_session_values(env)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def update_session(env, session_hash, headers)
|
|
112
|
-
if session_hash.needs_update?(@update_timeout)
|
|
113
|
-
values = session_hash.values
|
|
114
|
-
|
|
115
|
-
values[:updated_at] = Time.now.utc
|
|
116
|
-
|
|
117
|
-
data = encrypt(session_hash.values)
|
|
118
|
-
|
|
119
|
-
commit(data, values[:updated_at], headers)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# Constructs a valid session for the given request. These fields must match as per the checks performed in `valid_session?`:
|
|
124
|
-
def build_initial_session(request)
|
|
125
|
-
{
|
|
126
|
-
user_agent: request.user_agent,
|
|
127
|
-
created_at: Time.now.utc,
|
|
128
|
-
updated_at: Time.now.utc,
|
|
129
|
-
}
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# Load session from user supplied cookie. If the data is invalid or otherwise fails validation, `build_iniital_session` is invoked.
|
|
133
|
-
# @return hash of values.
|
|
134
|
-
def load_session_values(env)
|
|
135
|
-
request = Rack::Request.new(env)
|
|
136
|
-
|
|
137
|
-
# Decrypt the data from the user if possible:
|
|
138
|
-
if data = request.cookies[@cookie_name]
|
|
139
|
-
begin
|
|
140
|
-
if values = decrypt(data)
|
|
141
|
-
validate_session!(request, values)
|
|
142
|
-
|
|
143
|
-
return values
|
|
144
|
-
end
|
|
145
|
-
rescue => error
|
|
146
|
-
Console.error(self, error)
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# If we couldn't create a session
|
|
151
|
-
return build_initial_session(request)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def validate_session!(request, values)
|
|
155
|
-
if values[:user_agent] != request.user_agent
|
|
156
|
-
raise PayloadError, "Invalid session because supplied user agent #{request.user_agent.inspect} does not match session user agent #{values[:user_agent].inspect}!"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
if expires_at = expires(values[:updated_at])
|
|
160
|
-
if expires_at < Time.now.utc
|
|
161
|
-
raise PayloadError, "Expired session cookie, user agent submitted a cookie that should have expired at #{expires_at}."
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
return true
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def expires(updated_at=Time.now.utc)
|
|
169
|
-
if @expires_after
|
|
170
|
-
return updated_at + @expires_after
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def commit(value, updated_at, headers)
|
|
175
|
-
cookie = {
|
|
176
|
-
value: value,
|
|
177
|
-
expires: expires(updated_at)
|
|
178
|
-
}.merge(@cookie_defaults)
|
|
179
|
-
|
|
180
|
-
Rack::Utils.set_cookie_header!(headers, @cookie_name, cookie)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def encrypt(hash)
|
|
184
|
-
c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
|
|
185
|
-
c.encrypt
|
|
186
|
-
|
|
187
|
-
# your pass is what is used to encrypt/decrypt
|
|
188
|
-
c.key = @key
|
|
189
|
-
c.iv = iv = c.random_iv
|
|
190
|
-
|
|
191
|
-
e = c.update(@serialization.dump(hash))
|
|
192
|
-
e << c.final
|
|
193
|
-
|
|
194
|
-
return [iv, e].pack("m16m*")
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def decrypt(data)
|
|
198
|
-
if @maximum_size and data.bytesize > @maximum_size
|
|
199
|
-
raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds maximum allowed size #{@maximum_size}bytes!"
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
iv, e = data.unpack("m16m*")
|
|
203
|
-
|
|
204
|
-
c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
|
|
205
|
-
c.decrypt
|
|
206
|
-
|
|
207
|
-
c.key = @key
|
|
208
|
-
c.iv = iv
|
|
209
|
-
|
|
210
|
-
d = c.update(e)
|
|
211
|
-
d << c.final
|
|
212
|
-
|
|
213
|
-
return @serialization.load(d)
|
|
10
|
+
module Session
|
|
11
|
+
def self.new(...)
|
|
12
|
+
Middleware.new(...)
|
|
214
13
|
end
|
|
215
14
|
end
|
|
216
15
|
end
|
|
@@ -8,83 +8,83 @@ require "digest/sha1"
|
|
|
8
8
|
|
|
9
9
|
module Utopia
|
|
10
10
|
# A middleware which serves static files from the specified root directory.
|
|
11
|
-
|
|
11
|
+
module Static
|
|
12
12
|
# Represents a local file on disk which can be served directly, or passed upstream to sendfile.
|
|
13
13
|
class LocalFile
|
|
14
14
|
def initialize(root, path)
|
|
15
15
|
@root = root
|
|
16
16
|
@path = path
|
|
17
17
|
@etag = Digest::SHA1.hexdigest("#{File.size(full_path)}#{mtime_date}")
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
@range = nil
|
|
20
20
|
end
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
attr :root
|
|
23
23
|
attr :path
|
|
24
24
|
attr :etag
|
|
25
25
|
attr :range
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
# Fit in with Rack::Sendfile
|
|
28
28
|
def to_path
|
|
29
29
|
full_path
|
|
30
30
|
end
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
def full_path
|
|
33
33
|
File.join(@root, @path.components)
|
|
34
34
|
end
|
|
35
|
-
|
|
35
|
+
|
|
36
36
|
def mtime_date
|
|
37
37
|
File.mtime(full_path).httpdate
|
|
38
38
|
end
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
def bytesize
|
|
41
41
|
File.size(full_path)
|
|
42
42
|
end
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
# This reflects whether calling each would yield anything.
|
|
45
45
|
def empty?
|
|
46
46
|
bytesize == 0
|
|
47
47
|
end
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
alias size bytesize
|
|
50
|
-
|
|
50
|
+
|
|
51
51
|
def each
|
|
52
52
|
File.open(full_path, "rb") do |file|
|
|
53
53
|
file.seek(@range.begin)
|
|
54
54
|
remaining = @range.end - @range.begin+1
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
while remaining > 0
|
|
57
57
|
break unless part = file.read([8192, remaining].min)
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
remaining -= part.length
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
yield part
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
def modified?(env)
|
|
67
67
|
if modified_since = env["HTTP_IF_MODIFIED_SINCE"]
|
|
68
68
|
return false if File.mtime(full_path) <= Time.parse(modified_since)
|
|
69
69
|
end
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
if etags = env["HTTP_IF_NONE_MATCH"]
|
|
72
72
|
etags = etags.split(/\s*,\s*/)
|
|
73
73
|
return false if etags.include?(etag) || etags.include?("*")
|
|
74
74
|
end
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
return true
|
|
77
77
|
end
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
CONTENT_LENGTH = Rack::CONTENT_LENGTH
|
|
80
80
|
CONTENT_RANGE = "Content-Range".freeze
|
|
81
81
|
|
|
82
82
|
def serve(env, response_headers)
|
|
83
83
|
ranges = Rack::Utils.get_byte_ranges(env["HTTP_RANGE"], size)
|
|
84
84
|
response = [200, response_headers, self]
|
|
85
|
-
|
|
85
|
+
|
|
86
86
|
# puts "Requesting ranges: #{ranges.inspect} (#{size})"
|
|
87
|
-
|
|
87
|
+
|
|
88
88
|
if ranges == nil or ranges.size != 1
|
|
89
89
|
# No ranges, or multiple ranges (which we don't support).
|
|
90
90
|
# TODO: Support multiple byte-ranges, for now just send entire file:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2009-2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "../middleware"
|
|
7
|
+
require_relative "../localization"
|
|
8
|
+
|
|
9
|
+
require_relative "local_file"
|
|
10
|
+
require_relative "mime_types"
|
|
11
|
+
|
|
12
|
+
require "traces/provider"
|
|
13
|
+
|
|
14
|
+
module Utopia
|
|
15
|
+
module Static
|
|
16
|
+
# A middleware which serves static files from the specified root directory.
|
|
17
|
+
class Middleware
|
|
18
|
+
DEFAULT_CACHE_CONTROL = "public, max-age=3600".freeze
|
|
19
|
+
|
|
20
|
+
# @param root [String] The root directory to serve files from.
|
|
21
|
+
# @param types [Array] The mime-types (and file extensions) to recognize/serve.
|
|
22
|
+
# @param cache_control [String] The cache-control header to set for static content.
|
|
23
|
+
def initialize(app, root: Utopia::default_root, types: MIME_TYPES[:default], cache_control: DEFAULT_CACHE_CONTROL)
|
|
24
|
+
@app = app
|
|
25
|
+
@root = root
|
|
26
|
+
|
|
27
|
+
@extensions = MimeTypeLoader.extensions_for(types)
|
|
28
|
+
|
|
29
|
+
@cache_control = cache_control
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def freeze
|
|
33
|
+
return self if frozen?
|
|
34
|
+
|
|
35
|
+
@root.freeze
|
|
36
|
+
@extensions.freeze
|
|
37
|
+
@cache_control.freeze
|
|
38
|
+
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fetch_file(path)
|
|
43
|
+
# We need file_path to be an absolute path for X-Sendfile to work correctly.
|
|
44
|
+
file_path = File.join(@root, path.components)
|
|
45
|
+
|
|
46
|
+
if File.exist?(file_path)
|
|
47
|
+
return LocalFile.new(@root, path)
|
|
48
|
+
else
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr :extensions
|
|
54
|
+
|
|
55
|
+
LAST_MODIFIED = "Last-Modified".freeze
|
|
56
|
+
CONTENT_TYPE = HTTP::CONTENT_TYPE
|
|
57
|
+
CACHE_CONTROL = HTTP::CACHE_CONTROL
|
|
58
|
+
ETAG = "ETag".freeze
|
|
59
|
+
ACCEPT_RANGES = "Accept-Ranges".freeze
|
|
60
|
+
|
|
61
|
+
def response_headers_for(file, content_type)
|
|
62
|
+
if @cache_control.respond_to?(:call)
|
|
63
|
+
cache_control = @cache_control.call(file)
|
|
64
|
+
else
|
|
65
|
+
cache_control = @cache_control
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
LAST_MODIFIED => file.mtime_date,
|
|
70
|
+
CONTENT_TYPE => content_type,
|
|
71
|
+
CACHE_CONTROL => cache_control,
|
|
72
|
+
ETAG => file.etag,
|
|
73
|
+
ACCEPT_RANGES => "bytes"
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def respond(env, path_info, extension)
|
|
78
|
+
path = Path[path_info].simplify
|
|
79
|
+
|
|
80
|
+
if locale = env[Localization::CURRENT_LOCALE_KEY]
|
|
81
|
+
path.last.insert(path.last.rindex(".") || -1, ".#{locale}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if file = fetch_file(path)
|
|
85
|
+
response_headers = self.response_headers_for(file, @extensions[extension])
|
|
86
|
+
|
|
87
|
+
if file.modified?(env)
|
|
88
|
+
return file.serve(env, response_headers)
|
|
89
|
+
else
|
|
90
|
+
return [304, response_headers, []]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def call(env)
|
|
96
|
+
path_info = env[Rack::PATH_INFO]
|
|
97
|
+
extension = File.extname(path_info)
|
|
98
|
+
|
|
99
|
+
if @extensions.key?(extension.downcase)
|
|
100
|
+
if response = self.respond(env, path_info, extension)
|
|
101
|
+
return response
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# else if no file was found:
|
|
106
|
+
return @app.call(env)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Traces::Provider(Static) do
|
|
111
|
+
def respond(env, path_info, extension)
|
|
112
|
+
attributes = {
|
|
113
|
+
path_info: path_info,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Traces.trace("utopia.static.respond", attributes: attributes) {super}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|