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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/utopia/server.rb +1 -1
  4. data/bake/utopia/site.rb +3 -3
  5. data/context/getting-started.md +93 -0
  6. data/context/index.yaml +32 -0
  7. data/context/integrating-with-javascript.md +75 -0
  8. data/context/middleware.md +157 -0
  9. data/context/server-setup.md +116 -0
  10. data/context/updating-utopia.md +69 -0
  11. data/context/what-is-xnode.md +41 -0
  12. data/lib/utopia/content/document.rb +39 -37
  13. data/lib/utopia/content/link.rb +1 -2
  14. data/lib/utopia/content/links.rb +2 -2
  15. data/lib/utopia/content/markup.rb +10 -10
  16. data/lib/utopia/content/middleware.rb +195 -0
  17. data/lib/utopia/content/namespace.rb +1 -1
  18. data/lib/utopia/content/node.rb +1 -1
  19. data/lib/utopia/content/response.rb +1 -1
  20. data/lib/utopia/content/tags.rb +1 -1
  21. data/lib/utopia/content.rb +4 -186
  22. data/lib/utopia/controller/actions.md +8 -8
  23. data/lib/utopia/controller/actions.rb +1 -1
  24. data/lib/utopia/controller/base.rb +4 -4
  25. data/lib/utopia/controller/middleware.rb +133 -0
  26. data/lib/utopia/controller/respond.rb +2 -46
  27. data/lib/utopia/controller/responder.rb +103 -0
  28. data/lib/utopia/controller/rewrite.md +2 -2
  29. data/lib/utopia/controller/rewrite.rb +1 -1
  30. data/lib/utopia/controller/variables.rb +11 -5
  31. data/lib/utopia/controller.rb +4 -126
  32. data/lib/utopia/exceptions/mailer.rb +4 -4
  33. data/lib/utopia/extensions/array_split.rb +2 -2
  34. data/lib/utopia/extensions/date_comparisons.rb +3 -3
  35. data/lib/utopia/import_map.rb +374 -0
  36. data/lib/utopia/localization/middleware.rb +173 -0
  37. data/lib/utopia/localization/wrapper.rb +52 -0
  38. data/lib/utopia/localization.rb +4 -202
  39. data/lib/utopia/path.rb +26 -11
  40. data/lib/utopia/redirection.rb +2 -2
  41. data/lib/utopia/session/lazy_hash.rb +1 -1
  42. data/lib/utopia/session/middleware.rb +218 -0
  43. data/lib/utopia/session/serialization.rb +1 -1
  44. data/lib/utopia/session.rb +4 -205
  45. data/lib/utopia/static/local_file.rb +19 -19
  46. data/lib/utopia/static/middleware.rb +120 -0
  47. data/lib/utopia/static/mime_types.rb +1 -1
  48. data/lib/utopia/static.rb +4 -108
  49. data/lib/utopia/version.rb +1 -1
  50. data/lib/utopia.rb +1 -0
  51. data/readme.md +7 -0
  52. data/releases.md +7 -0
  53. data/setup/site/config.ru +1 -1
  54. data.tar.gz.sig +0 -0
  55. metadata +31 -4
  56. metadata.gz.sig +0 -0
  57. data/lib/utopia/locale.rb +0 -29
  58. 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
@@ -9,7 +9,7 @@ require "time"
9
9
  require "date"
10
10
 
11
11
  module Utopia
12
- class Session
12
+ module Session
13
13
  class Serialization
14
14
  def initialize
15
15
  @factory = MessagePack::Factory.new
@@ -4,213 +4,12 @@
4
4
  # Copyright, 2014-2025, by Samuel Williams.
5
5
  # Copyright, 2019, by Huba Nagy.
6
6
 
7
- require "openssl"
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
- # A middleware which provides a secure client-side session storage using a private symmetric encrpytion key.
17
- class Session
18
- class PayloadError < StandardError
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
- class Static
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
@@ -7,7 +7,7 @@ require "mime/types"
7
7
 
8
8
  module Utopia
9
9
  # A middleware which serves static files from the specified root directory.
10
- class Static
10
+ module Static
11
11
  # Default mime-types which are common for files served over HTTP:
12
12
  MIME_TYPES = {
13
13
  :xiph => {