server.rb 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. require 'time'
  2. require 'uri'
  3. module Sprockets
  4. # `Server` is a concern mixed into `Environment` and
  5. # `Index` that provides a Rack compatible `call`
  6. # interface and url generation helpers.
  7. module Server
  8. # `call` implements the Rack 1.x specification which accepts an
  9. # `env` Hash and returns a three item tuple with the status code,
  10. # headers, and body.
  11. #
  12. # Mapping your environment at a url prefix will serve all assets
  13. # in the path.
  14. #
  15. # map "/assets" do
  16. # run Sprockets::Environment.new
  17. # end
  18. #
  19. # A request for `"/assets/foo/bar.js"` will search your
  20. # environment for `"foo/bar.js"`.
  21. def call(env)
  22. start_time = Time.now.to_f
  23. time_elapsed = lambda { ((Time.now.to_f - start_time) * 1000).to_i }
  24. msg = "Served asset #{env['PATH_INFO']} -"
  25. # Mark session as "skipped" so no `Set-Cookie` header is set
  26. env['rack.session.options'] ||= {}
  27. env['rack.session.options'][:defer] = true
  28. env['rack.session.options'][:skip] = true
  29. # Extract the path from everything after the leading slash
  30. path = unescape(env['PATH_INFO'].to_s.sub(/^\//, ''))
  31. # URLs containing a `".."` are rejected for security reasons.
  32. if forbidden_request?(path)
  33. return forbidden_response
  34. end
  35. # Strip fingerprint
  36. if fingerprint = path_fingerprint(path)
  37. path = path.sub("-#{fingerprint}", '')
  38. end
  39. # Look up the asset.
  40. asset = find_asset(path, :bundle => !body_only?(env))
  41. # `find_asset` returns nil if the asset doesn't exist
  42. if asset.nil?
  43. logger.info "#{msg} 404 Not Found (#{time_elapsed.call}ms)"
  44. # Return a 404 Not Found
  45. not_found_response
  46. # Check request headers `HTTP_IF_NONE_MATCH` against the asset digest
  47. elsif etag_match?(asset, env)
  48. logger.info "#{msg} 304 Not Modified (#{time_elapsed.call}ms)"
  49. # Return a 304 Not Modified
  50. not_modified_response(asset, env)
  51. else
  52. logger.info "#{msg} 200 OK (#{time_elapsed.call}ms)"
  53. # Return a 200 with the asset contents
  54. ok_response(asset, env)
  55. end
  56. rescue Exception => e
  57. logger.error "Error compiling asset #{path}:"
  58. logger.error "#{e.class.name}: #{e.message}"
  59. case content_type_of(path)
  60. when "application/javascript"
  61. # Re-throw JavaScript asset exceptions to the browser
  62. logger.info "#{msg} 500 Internal Server Error\n\n"
  63. return javascript_exception_response(e)
  64. when "text/css"
  65. # Display CSS asset exceptions in the browser
  66. logger.info "#{msg} 500 Internal Server Error\n\n"
  67. return css_exception_response(e)
  68. else
  69. raise
  70. end
  71. end
  72. private
  73. def forbidden_request?(path)
  74. # Prevent access to files elsewhere on the file system
  75. #
  76. # http://example.org/assets/../../../etc/passwd
  77. #
  78. path.include?("..")
  79. end
  80. # Returns a 403 Forbidden response tuple
  81. def forbidden_response
  82. [ 403, { "Content-Type" => "text/plain", "Content-Length" => "9" }, [ "Forbidden" ] ]
  83. end
  84. # Returns a 404 Not Found response tuple
  85. def not_found_response
  86. [ 404, { "Content-Type" => "text/plain", "Content-Length" => "9", "X-Cascade" => "pass" }, [ "Not found" ] ]
  87. end
  88. # Returns a JavaScript response that re-throws a Ruby exception
  89. # in the browser
  90. def javascript_exception_response(exception)
  91. err = "#{exception.class.name}: #{exception.message}"
  92. body = "throw Error(#{err.inspect})"
  93. [ 200, { "Content-Type" => "application/javascript", "Content-Length" => Rack::Utils.bytesize(body).to_s }, [ body ] ]
  94. end
  95. # Returns a CSS response that hides all elements on the page and
  96. # displays the exception
  97. def css_exception_response(exception)
  98. message = "\n#{exception.class.name}: #{exception.message}"
  99. backtrace = "\n #{exception.backtrace.first}"
  100. body = <<-CSS
  101. html {
  102. padding: 18px 36px;
  103. }
  104. head {
  105. display: block;
  106. }
  107. body {
  108. margin: 0;
  109. padding: 0;
  110. }
  111. body > * {
  112. display: none !important;
  113. }
  114. head:after, body:before, body:after {
  115. display: block !important;
  116. }
  117. head:after {
  118. font-family: sans-serif;
  119. font-size: large;
  120. font-weight: bold;
  121. content: "Error compiling CSS asset";
  122. }
  123. body:before, body:after {
  124. font-family: monospace;
  125. white-space: pre-wrap;
  126. }
  127. body:before {
  128. font-weight: bold;
  129. content: "#{escape_css_content(message)}";
  130. }
  131. body:after {
  132. content: "#{escape_css_content(backtrace)}";
  133. }
  134. CSS
  135. [ 200, { "Content-Type" => "text/css;charset=utf-8", "Content-Length" => Rack::Utils.bytesize(body).to_s }, [ body ] ]
  136. end
  137. # Escape special characters for use inside a CSS content("...") string
  138. def escape_css_content(content)
  139. content.
  140. gsub('\\', '\\\\005c ').
  141. gsub("\n", '\\\\000a ').
  142. gsub('"', '\\\\0022 ').
  143. gsub('/', '\\\\002f ')
  144. end
  145. # Compare the requests `HTTP_IF_NONE_MATCH` against the assets digest
  146. def etag_match?(asset, env)
  147. env["HTTP_IF_NONE_MATCH"] == etag(asset)
  148. end
  149. # Test if `?body=1` or `body=true` query param is set
  150. def body_only?(env)
  151. env["QUERY_STRING"].to_s =~ /body=(1|t)/
  152. end
  153. # Returns a 304 Not Modified response tuple
  154. def not_modified_response(asset, env)
  155. [ 304, {}, [] ]
  156. end
  157. # Returns a 200 OK response tuple
  158. def ok_response(asset, env)
  159. [ 200, headers(env, asset, asset.length), asset ]
  160. end
  161. def headers(env, asset, length)
  162. Hash.new.tap do |headers|
  163. # Set content type and length headers
  164. headers["Content-Type"] = asset.content_type
  165. headers["Content-Length"] = length.to_s
  166. # Set caching headers
  167. headers["Cache-Control"] = "public"
  168. headers["Last-Modified"] = asset.mtime.httpdate
  169. headers["ETag"] = etag(asset)
  170. # If the request url contains a fingerprint, set a long
  171. # expires on the response
  172. if path_fingerprint(env["PATH_INFO"])
  173. headers["Cache-Control"] << ", max-age=31536000"
  174. # Otherwise set `must-revalidate` since the asset could be modified.
  175. else
  176. headers["Cache-Control"] << ", must-revalidate"
  177. end
  178. end
  179. end
  180. # Gets digest fingerprint.
  181. #
  182. # "foo-0aa2105d29558f3eb790d411d7d8fb66.js"
  183. # # => "0aa2105d29558f3eb790d411d7d8fb66"
  184. #
  185. def path_fingerprint(path)
  186. path[/-([0-9a-f]{7,40})\.[^.]+$/, 1]
  187. end
  188. # URI.unescape is deprecated on 1.9. We need to use URI::Parser
  189. # if its available.
  190. if defined? URI::DEFAULT_PARSER
  191. def unescape(str)
  192. str = URI::DEFAULT_PARSER.unescape(str)
  193. str.force_encoding(Encoding.default_internal) if Encoding.default_internal
  194. str
  195. end
  196. else
  197. def unescape(str)
  198. URI.unescape(str)
  199. end
  200. end
  201. # Helper to quote the assets digest for use as an ETag.
  202. def etag(asset)
  203. %("#{asset.digest}")
  204. end
  205. end
  206. end