stir_fry 0.1.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 +7 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/Gemfile +16 -0
- data/LICENSE +21 -0
- data/README.md +99 -0
- data/Rakefile +40 -0
- data/lib/stir_fry/app.rb +290 -0
- data/lib/stir_fry/component.rb +80 -0
- data/lib/stir_fry/middleware/logger.rb +88 -0
- data/lib/stir_fry/middleware.rb +8 -0
- data/lib/stir_fry/pages/error_page.rb +68 -0
- data/lib/stir_fry/pages/layout.rb +15 -0
- data/lib/stir_fry/pages/not_found_page.rb +31 -0
- data/lib/stir_fry/pages/routes_page.rb +21 -0
- data/lib/stir_fry/pages.rb +10 -0
- data/lib/stir_fry/request.rb +43 -0
- data/lib/stir_fry/response.rb +351 -0
- data/lib/stir_fry/static/stir_fry.css +64 -0
- data/lib/stir_fry/version.rb +6 -0
- data/lib/stir_fry.rb +180 -0
- data/stir_fry.gemspec +45 -0
- metadata +180 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module StirFry
|
|
6
|
+
module Middleware
|
|
7
|
+
# Logger is our custom logger for a better format using a shared logger
|
|
8
|
+
class Logger
|
|
9
|
+
# Create the new middleware for logging
|
|
10
|
+
def initialize(app, logger = ::Logger.new($stdout))
|
|
11
|
+
@app = app
|
|
12
|
+
@logger = logger
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Middleware call that logs the request and it's response.
|
|
16
|
+
def call(env)
|
|
17
|
+
began_at = Time.now.to_f
|
|
18
|
+
content = @app.call(env)
|
|
19
|
+
log(env, Time.now.to_f - began_at)
|
|
20
|
+
content
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def log(env, elapsed)
|
|
26
|
+
req = env.fetch(App::REQUEST_ENV_KEY, Rack::Request.new(env))
|
|
27
|
+
resp = env.fetch(App::RESPONSE_ENV_KEY, Rack::Response.new(env))
|
|
28
|
+
log_method(resp).call(log_line(req, resp, elapsed))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def log_method(resp)
|
|
32
|
+
if resp.status.between?(100, 299)
|
|
33
|
+
@logger.method(:info)
|
|
34
|
+
elsif resp.status.between?(300, 499)
|
|
35
|
+
@logger.method(:warn)
|
|
36
|
+
else
|
|
37
|
+
@logger.method(:error)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def log_line(req, resp, elapsed)
|
|
42
|
+
{
|
|
43
|
+
method: req.request_method,
|
|
44
|
+
path: req.path_info,
|
|
45
|
+
query: req.query_string,
|
|
46
|
+
status: resp.status,
|
|
47
|
+
user: client_log(req),
|
|
48
|
+
request: req_log(req),
|
|
49
|
+
response: resp_log(resp, elapsed)
|
|
50
|
+
}.compact
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def client_log(req)
|
|
54
|
+
{
|
|
55
|
+
ip: req.ip,
|
|
56
|
+
remote_user: req.get_header("REMOTE_USER")
|
|
57
|
+
}.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def req_log(req)
|
|
61
|
+
{
|
|
62
|
+
accept_encoding: req.get_header("HTTP_ACCEPT"),
|
|
63
|
+
scheme: req.scheme,
|
|
64
|
+
server_authority: req.server_authority,
|
|
65
|
+
referer: req.referer,
|
|
66
|
+
script_name: req.script_name,
|
|
67
|
+
media_type: req.media_type,
|
|
68
|
+
proto: req.get_header("SERVER_PROTOCOL")
|
|
69
|
+
}.compact
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resp_log(resp, elapsed)
|
|
73
|
+
line = {
|
|
74
|
+
content_type: resp.headers["Content-Type"],
|
|
75
|
+
content_length: resp.headers["Content-Length"],
|
|
76
|
+
elapsed: elapsed % 60
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if resp.respond_to?(:request_error)
|
|
80
|
+
line[:error] = resp.request_error&.message&.to_s
|
|
81
|
+
line[:error_stacktrace] = resp.request_error&.backtrace
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
line.compact
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StirFry
|
|
4
|
+
module Pages
|
|
5
|
+
# ErrorPage is a custom component and main handler for server errors. It will
|
|
6
|
+
# respond with a specific mime type depending on what was requested.
|
|
7
|
+
class ErrorPage < Component
|
|
8
|
+
# error is the error raised during the request cycle.
|
|
9
|
+
attr_reader :error
|
|
10
|
+
|
|
11
|
+
# Create a new error page that will display the message and status.
|
|
12
|
+
def initialize(error: "", **args)
|
|
13
|
+
super
|
|
14
|
+
@error = error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# format a message from the error
|
|
18
|
+
def message = error.message == error.class.name ? "" : error.message
|
|
19
|
+
|
|
20
|
+
# status will derive a response status from the error raised.
|
|
21
|
+
def status
|
|
22
|
+
@status ||= case error
|
|
23
|
+
when NotFound then :not_found
|
|
24
|
+
when BadRequest then :bad_request
|
|
25
|
+
when Unauthorized then :unauthorized
|
|
26
|
+
when PreconditionFailed then :precondition_failed
|
|
27
|
+
else :internal_server_error
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Overrides Component#call to customize the output based on mime.
|
|
32
|
+
def call
|
|
33
|
+
if request.json? then response.json(render_json, status)
|
|
34
|
+
elsif request.html? then response.html(render, status)
|
|
35
|
+
else response.text(message, status)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def title # :nodoc:
|
|
40
|
+
Rack::Utils::HTTP_STATUS_CODES[response.status].to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def render_json
|
|
46
|
+
{
|
|
47
|
+
error: title.downcase.tr(" ", "_"),
|
|
48
|
+
message: message
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
__END__
|
|
56
|
+
<StirFry.Pages.Layout>
|
|
57
|
+
<section>
|
|
58
|
+
<header><h1>{title}</h1></header>
|
|
59
|
+
<section class="error-page">
|
|
60
|
+
<p class="error-page__code">{response.status}</p>
|
|
61
|
+
<p class="error-page__path">{ "[#{request.request_method.upcase}] #{request.host_with_port}#{request.path_info}" }</p>
|
|
62
|
+
<p class="error-page__message">{message}</p>
|
|
63
|
+
<ul class="error-page__backtrace">
|
|
64
|
+
{error.backtrace.map {|t| <li>{t.to_s}</li> }.join }
|
|
65
|
+
</ul>
|
|
66
|
+
</section>
|
|
67
|
+
</section>
|
|
68
|
+
</StirFry.Pages.Layout>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StirFry
|
|
4
|
+
module Pages
|
|
5
|
+
# Layout is the layout of the frameworks pages that it serves.
|
|
6
|
+
class Layout < Component; end
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
__END__
|
|
11
|
+
<!DOCTYPE html>
|
|
12
|
+
<html>
|
|
13
|
+
<head><link rel="stylesheet" href="/stir_fry/stir_fry.css" /></head>
|
|
14
|
+
<body>{ yield }</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StirFry
|
|
4
|
+
module Pages
|
|
5
|
+
# NotFoundPage is a simple page to display 404 errors appropriate for the
|
|
6
|
+
# request mime type
|
|
7
|
+
class NotFoundPage < Component
|
|
8
|
+
# Overrides Component#call to customize the output based on mime.
|
|
9
|
+
def call
|
|
10
|
+
if request.json? then response.json({ error: "not_found", message: "Not Found" }, :not_found)
|
|
11
|
+
elsif request.html? then response.html(render, :not_found)
|
|
12
|
+
else response.text("Not Found", :not_found)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
__END__
|
|
20
|
+
<StirFry.Pages.Layout>
|
|
21
|
+
<section>
|
|
22
|
+
<header><h1>Not Found</h1></header>
|
|
23
|
+
<section class="error-page">
|
|
24
|
+
<p class="error-page__code">404</p>
|
|
25
|
+
<p class="error-page__message">Request path did not match any registered routes.</p>
|
|
26
|
+
<ul class="error-page__backtrace">
|
|
27
|
+
{application.all_routes.map { |route| <li>{route}</li> }.join }
|
|
28
|
+
</ul>
|
|
29
|
+
</section>
|
|
30
|
+
</section>
|
|
31
|
+
</StirFry.Pages.Layout>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StirFry
|
|
4
|
+
module Pages
|
|
5
|
+
# Displays routes that are defined by the app. This is only enabled in development
|
|
6
|
+
# and the route will not be define otherwise.
|
|
7
|
+
class RoutesPage < Component; end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
__END__
|
|
12
|
+
<StirFry.Pages.Layout>
|
|
13
|
+
<section>
|
|
14
|
+
<header><h1>Routes</h1></header>
|
|
15
|
+
<section>
|
|
16
|
+
<ul class="error-page__backtrace">
|
|
17
|
+
{application.all_routes.map { |route| <li>{route}</li> }.join }
|
|
18
|
+
</ul>
|
|
19
|
+
</section>
|
|
20
|
+
</section>
|
|
21
|
+
</StirFry.Pages.Layout>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StirFry
|
|
4
|
+
module Pages # :nodoc:
|
|
5
|
+
autoload :Layout, "stir_fry/pages/layout"
|
|
6
|
+
autoload :ErrorPage, "stir_fry/pages/error_page"
|
|
7
|
+
autoload :RoutesPage, "stir_fry/pages/routes_page"
|
|
8
|
+
autoload :NotFoundPage, "stir_fry/pages/not_found_page"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module StirFry
|
|
7
|
+
# Request expands Rack::Request to include url args
|
|
8
|
+
class Request < Rack::Request
|
|
9
|
+
# args are the parameters that were parsed from the url path such as resource ids.
|
|
10
|
+
attr_accessor :args
|
|
11
|
+
|
|
12
|
+
# The unescaped path from the request.
|
|
13
|
+
def path = @path ||= Rack::Utils.unescape_path(path_info)
|
|
14
|
+
# Returns true if the request accepts json
|
|
15
|
+
def json? = accept?("application/json")
|
|
16
|
+
# Returns true if the request accepts html
|
|
17
|
+
def html? = accept?("text/html")
|
|
18
|
+
# Returns true if the request accepts text
|
|
19
|
+
def text? = accept?("text/plain")
|
|
20
|
+
# Returns true if the request accepts xml
|
|
21
|
+
def xml? = accept?("application/xml")
|
|
22
|
+
# Returns true if the request accepts csv
|
|
23
|
+
def csv? = accept?("text/csv")
|
|
24
|
+
# Returns true if the request accepts header matches a mime type string
|
|
25
|
+
def accept?(mime) = accept_types.include?(mime)
|
|
26
|
+
|
|
27
|
+
# params wraps rack:request params and captures common errors. This is copied
|
|
28
|
+
# from Sinatra's handling of params.
|
|
29
|
+
def params
|
|
30
|
+
super
|
|
31
|
+
rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e
|
|
32
|
+
raise BadRequest, "Invalid query parameters: #{Rack::Utils.escape_html(e.message)}"
|
|
33
|
+
rescue EOFError => e
|
|
34
|
+
raise BadRequest, "Invalid multipart/form-data: #{Rack::Utils.escape_html(e.message)}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def accept_types
|
|
40
|
+
@accept_types ||= get_header("HTTP_ACCEPT").to_s.split(",").filter_map { |part| part.split(";", 2).first&.strip }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module StirFry
|
|
7
|
+
# Response is a wrapper around Rack::Response that makes it easier to generate
|
|
8
|
+
# rich responses easily.
|
|
9
|
+
class Response
|
|
10
|
+
# the corresponding reqest to this reponse.
|
|
11
|
+
attr_reader :req
|
|
12
|
+
# the inner Rack::Response that we are building.
|
|
13
|
+
attr_reader :resp
|
|
14
|
+
# the rack request env.
|
|
15
|
+
attr_reader :env
|
|
16
|
+
# if there was an error during the request it will be set here.
|
|
17
|
+
attr_accessor :request_error
|
|
18
|
+
|
|
19
|
+
MULTIPART_BOUNDARY = "AaB03x" # :nodoc:
|
|
20
|
+
MULTIPART_FORM_DATA_REPLACEMENT_TABLE = { # :nodoc:
|
|
21
|
+
'"' => "%22",
|
|
22
|
+
"\r" => "%0D",
|
|
23
|
+
"\n" => "%0A"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# create a new Reponse
|
|
27
|
+
def initialize(env)
|
|
28
|
+
@env = env
|
|
29
|
+
@req = StirFry::Request.new(env)
|
|
30
|
+
@resp = Rack::Response.new
|
|
31
|
+
content_type(Rack::MediaType.type(env["HTTP_ACCEPT"]))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# whether or not the status is set to 1xx
|
|
35
|
+
def informational? = status.between?(100, 199)
|
|
36
|
+
# whether or not the status is set to 2xx
|
|
37
|
+
def success? = status.between?(200, 299)
|
|
38
|
+
# whether or not the status is set to 3xx
|
|
39
|
+
def redirect? = status.between?(300, 399)
|
|
40
|
+
# whether or not the status is set to 4xx
|
|
41
|
+
def client_error? = status.between?(400, 499)
|
|
42
|
+
# whether or not the status is set to 5xx
|
|
43
|
+
def server_error? = status.between?(500, 599)
|
|
44
|
+
# whether or not the status is set to 404
|
|
45
|
+
def not_found? = status == 404
|
|
46
|
+
# whether or not the status is set to 400
|
|
47
|
+
def bad_request? = status == 400
|
|
48
|
+
|
|
49
|
+
# sugar for `respond(body, :ok)`
|
|
50
|
+
def ok(body) = respond(body, :ok)
|
|
51
|
+
# sugar for `respond(body, :not_found)`
|
|
52
|
+
def not_found(body) = respond(body, :not_found)
|
|
53
|
+
# sugar for `respond(body, :bad_request)`
|
|
54
|
+
def bad_request(body) = respond(body, :bad_request)
|
|
55
|
+
# sugar for `respond(body, :unauthorized)`
|
|
56
|
+
def unauthorized(body) = respond(body, :unauthorized)
|
|
57
|
+
# sugar for `respond(body, :internal_error)`
|
|
58
|
+
def internal_error(body) = respond(body, :internal_server_error)
|
|
59
|
+
|
|
60
|
+
# immediately return okay status without running any following middleware.
|
|
61
|
+
def ok!(msg = "") = raise OKAY, msg
|
|
62
|
+
# immediately return not found status without running any following middleware.
|
|
63
|
+
def not_found!(msg = "") = raise NotFound, msg
|
|
64
|
+
# immediately return bad request status without running any following middleware.
|
|
65
|
+
def bad_request!(msg = "") = raise BadRequest, msg
|
|
66
|
+
# immediately return unauthorized status without running any following middleware.
|
|
67
|
+
def unauthorized!(msg = "") = raise Unauthorized, msg
|
|
68
|
+
# immediately return internal error status without running any following middleware.
|
|
69
|
+
def internal_error!(msg = "") = raise InternalError, msg
|
|
70
|
+
|
|
71
|
+
# respond with text content
|
|
72
|
+
def text(body, stat = :ok) = answer(stat, :text, body)
|
|
73
|
+
# respond with json content
|
|
74
|
+
def json(body, stat = :ok) = answer(stat, :json, JSON.dump(body))
|
|
75
|
+
# respond with html content
|
|
76
|
+
def html(body, stat = :ok) = answer(stat, :html, body)
|
|
77
|
+
# respond with a rendered component. sugar for `html(view.render)`
|
|
78
|
+
def render(klass, stat = :ok) = html(klass.render, stat)
|
|
79
|
+
# start a streaming body that doesn't immeadiately close the connection.
|
|
80
|
+
def stream(&block) = resp.body = block.to_proc
|
|
81
|
+
|
|
82
|
+
# respond will choose what kind of content to respond with depending on what
|
|
83
|
+
# kind of data you give it.
|
|
84
|
+
#
|
|
85
|
+
# `respond({message: "hi"}, :ok)` => application/json JSON response
|
|
86
|
+
# `respond(AppView, :ok)` => text/html HTML Component response
|
|
87
|
+
# `respond("OKAY", :ok)` => text/plain response
|
|
88
|
+
def respond(obj, stat = :ok)
|
|
89
|
+
case obj
|
|
90
|
+
when Hash then json(obj, stat)
|
|
91
|
+
when StirFry::Component then render(obj, stat)
|
|
92
|
+
when String then text(obj, stat)
|
|
93
|
+
else status(stat)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# redirect the request to a uri
|
|
98
|
+
def redirect(uri)
|
|
99
|
+
http_version = env["SERVER_PROTOCOL"]
|
|
100
|
+
if (http_version == "HTTP/1.1") && (env["REQUEST_METHOD"] != "GET")
|
|
101
|
+
status(303)
|
|
102
|
+
else
|
|
103
|
+
status(302)
|
|
104
|
+
end
|
|
105
|
+
headers["Location"] = uri.to_s
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build and format a link with the current host and port.
|
|
109
|
+
def uri(addr = nil, absolute: true)
|
|
110
|
+
port_required = !req.forwarded_authority.nil? || (req.port != (req.ssl? ? 443 : 80))
|
|
111
|
+
uri = [host = String.new]
|
|
112
|
+
if absolute
|
|
113
|
+
host.concat("http#{"s" if req.ssl?}://", port_required ? req.host_with_port : req.host)
|
|
114
|
+
end
|
|
115
|
+
uri << (addr || req.path_info).to_s
|
|
116
|
+
File.join uri
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Sugar for redirect(request.referer)
|
|
120
|
+
def redirect_back = redirect(req.referer)
|
|
121
|
+
# Readonly body content. To change the content you should use one of the response methods
|
|
122
|
+
# text, html, render, json, stream or other methods available to set the body
|
|
123
|
+
# in a valid way.
|
|
124
|
+
def body = resp.body
|
|
125
|
+
# Response headers.
|
|
126
|
+
def headers = resp.headers
|
|
127
|
+
|
|
128
|
+
# Set the response status, and return the current status.
|
|
129
|
+
def status(value = nil)
|
|
130
|
+
resp.status = Rack::Utils.status_code(value) if value
|
|
131
|
+
resp.status
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Set the content type header on the response
|
|
135
|
+
def content_type(kind = nil)
|
|
136
|
+
headers["Content-Type"] = mime_type(kind) if kind
|
|
137
|
+
headers["Content-Type"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Set the content length header on the response
|
|
141
|
+
def content_length(len = nil)
|
|
142
|
+
headers["Content-Length"] = len.to_s if len
|
|
143
|
+
headers["Content-Length"]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Specify response freshness policy for HTTP caches (Cache-Control header).
|
|
147
|
+
# Any number of non-value directives (:public, :private, :no_cache,
|
|
148
|
+
# :no_store, :must_revalidate, :proxy_revalidate) may be passed along with
|
|
149
|
+
# a Hash of value directives (:max_age, :s_maxage).
|
|
150
|
+
#
|
|
151
|
+
# cache_control :public, :must_revalidate, :max_age => 60
|
|
152
|
+
# => Cache-Control: public, must-revalidate, max-age=60
|
|
153
|
+
#
|
|
154
|
+
def cache_control(*values)
|
|
155
|
+
hash = extract_cache_control_hash(values)
|
|
156
|
+
values.map! { |value| value.to_s.tr("_", "-") }
|
|
157
|
+
hash.each { |key, value| values << cache_control_pair(key, value) }
|
|
158
|
+
|
|
159
|
+
headers["Cache-Control"] = values.join(", ") if values.any?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Set the response entity tag (HTTP 'ETag' header) and halt if conditional
|
|
163
|
+
# GET matches. The +value+ argument is an identifier that uniquely
|
|
164
|
+
# identifies the current version of the resource. The weak argument will allow
|
|
165
|
+
# a weak value but defaults to strong.
|
|
166
|
+
#
|
|
167
|
+
# When the current request includes an 'If-None-Match' header with a
|
|
168
|
+
# matching etag, execution is immediately halted. If the request method is
|
|
169
|
+
# GET or HEAD, a '304 Not Modified' response is sent.
|
|
170
|
+
def etag(value, weak: false, new_resource: req.post?)
|
|
171
|
+
headers["ETag"] = etag_header_value(value, weak)
|
|
172
|
+
return unless success? || status == 304
|
|
173
|
+
|
|
174
|
+
raise(request_safe? ? NotModified : PreconditionFailed) if etag_matches?(env["HTTP_IF_NONE_MATCH"], new_resource)
|
|
175
|
+
raise PreconditionFailed if env["HTTP_IF_MATCH"] && !etag_matches?(env["HTTP_IF_MATCH"], new_resource)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Set the Expires header and Cache-Control/max-age directive. Amount
|
|
179
|
+
# can be an integer number of seconds in the future or a Time object
|
|
180
|
+
# indicating when the response should be considered "stale". The remaining
|
|
181
|
+
# "values" arguments are passed to the #cache_control helper:
|
|
182
|
+
#
|
|
183
|
+
# expires 500, :public, :must_revalidate
|
|
184
|
+
# => Cache-Control: public, must-revalidate, max-age=500
|
|
185
|
+
# => Expires: Mon, 08 Jun 2009 08:50:17 GMT
|
|
186
|
+
#
|
|
187
|
+
def expires(amount, *values)
|
|
188
|
+
values << {} unless values.last.is_a?(Hash)
|
|
189
|
+
time, max_age = expires_time_and_max_age(amount)
|
|
190
|
+
|
|
191
|
+
values.last.merge!(max_age: max_age) { |_key, v1, v2| v1 || v2 }
|
|
192
|
+
cache_control(*values)
|
|
193
|
+
|
|
194
|
+
headers["Expires"] = time.httpdate
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Set the last modified time of the resource (HTTP 'Last-Modified' header)
|
|
198
|
+
# and halt if conditional GET matches. The +time+ argument is a Time,
|
|
199
|
+
# DateTime, or other object that responds to +to_time+.
|
|
200
|
+
#
|
|
201
|
+
# When the current request includes an 'If-Modified-Since' header that is
|
|
202
|
+
# equal or later than the time specified, execution is immediately halted
|
|
203
|
+
# with a '304 Not Modified' response.
|
|
204
|
+
def last_modified(time)
|
|
205
|
+
return unless time
|
|
206
|
+
|
|
207
|
+
time = time_for(time)
|
|
208
|
+
headers["Last-Modified"] = time.httpdate
|
|
209
|
+
return if env["HTTP_IF_NONE_MATCH"]
|
|
210
|
+
|
|
211
|
+
check_if_modified_since(time)
|
|
212
|
+
check_if_unmodified_since(time)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Responde directly with a file.
|
|
216
|
+
def send_file(req, filename, attachment: false)
|
|
217
|
+
if attachment
|
|
218
|
+
headers["Content-Disposition"] =
|
|
219
|
+
format('attachment; filename="%s"',
|
|
220
|
+
File.basename(filename).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE))
|
|
221
|
+
end
|
|
222
|
+
serve_file(req, filename)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def finish # :nodoc:
|
|
226
|
+
clear_body_headers if informational? || [204, 304].include?(status)
|
|
227
|
+
resp.body = [] if [204, 304].include?(status)
|
|
228
|
+
content_length(resp.body.sum(&:bytesize)) if resp.body.is_a?(Array) && !content_length
|
|
229
|
+
resp.to_a
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def answer(stat, contenttype, value)
|
|
235
|
+
status(stat)
|
|
236
|
+
content_type(contenttype)
|
|
237
|
+
resp.body = value.is_a?(String) ? [value.to_str] : value
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def request_safe?
|
|
241
|
+
req.get? || req.head? || req.options? || req.trace?
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def mime_type(type)
|
|
245
|
+
return type if type.nil?
|
|
246
|
+
return type.to_s if type.to_s.include?("/")
|
|
247
|
+
|
|
248
|
+
type = ".#{type}" unless type.to_s[0] == "."
|
|
249
|
+
Rack::Mime.mime_type(type, nil)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def etag_matches?(list, new_resource = req.post?)
|
|
253
|
+
return !new_resource if list == "*"
|
|
254
|
+
|
|
255
|
+
list.to_s.split(",").map(&:strip).include?(headers["ETag"])
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def etag_header_value(value, weak) = format('%<weak>s"%<value>s"', weak: weak ? "W/" : "", value: value)
|
|
259
|
+
|
|
260
|
+
def extract_cache_control_hash(values)
|
|
261
|
+
return {} unless values.last.is_a?(Hash)
|
|
262
|
+
|
|
263
|
+
hash = values.pop
|
|
264
|
+
hash.reject! { |_k, v| v == false }
|
|
265
|
+
hash.reject! { |k, v| values << k if v == true }
|
|
266
|
+
hash
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def cache_control_pair(key, value)
|
|
270
|
+
key = key.to_s.tr("_", "-")
|
|
271
|
+
value = value.to_i if %w[max-age s-maxage].include? key
|
|
272
|
+
"#{key}=#{value}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def expires_time_and_max_age(amount)
|
|
276
|
+
return [Time.now + amount.to_i, amount] if amount.is_a? Integer
|
|
277
|
+
|
|
278
|
+
time = time_for(amount)
|
|
279
|
+
[time, time - Time.now]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def check_if_modified_since(time)
|
|
283
|
+
return unless (status == 200) && env["HTTP_IF_MODIFIED_SINCE"]
|
|
284
|
+
|
|
285
|
+
since = Time.httpdate(env["HTTP_IF_MODIFIED_SINCE"]).to_i
|
|
286
|
+
raise NotModified if since >= time.to_i
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def check_if_unmodified_since(time)
|
|
290
|
+
return unless (success? || (status == 412)) && env["HTTP_IF_UNMODIFIED_SINCE"]
|
|
291
|
+
|
|
292
|
+
since = Time.httpdate(env["HTTP_IF_UNMODIFIED_SINCE"]).to_i
|
|
293
|
+
raise PreconditionFailed if since < time.to_i
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def time_for(value)
|
|
297
|
+
if value.is_a? Numeric
|
|
298
|
+
Time.at value
|
|
299
|
+
elsif value.respond_to? :to_s
|
|
300
|
+
Time.parse value.to_s
|
|
301
|
+
else
|
|
302
|
+
value.to_time
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def serve_file(req, filename)
|
|
307
|
+
content_type(File.extname(filename))
|
|
308
|
+
last_modified(File.mtime(filename))
|
|
309
|
+
size = ::File.size?(filename) || ::File.read(filename).bytesize
|
|
310
|
+
ranges = Rack::Utils.get_byte_ranges(req.get_header("HTTP_RANGE"), size)
|
|
311
|
+
if ranges.nil? then send_full_file_content(filename, size)
|
|
312
|
+
elsif ranges.empty? then bad_file_send_range(size)
|
|
313
|
+
else send_partial_file(filename, ranges, size)
|
|
314
|
+
end
|
|
315
|
+
rescue Errno::ENOENT
|
|
316
|
+
raise NotFound
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def send_full_file_content(filename, size)
|
|
320
|
+
content_length(size)
|
|
321
|
+
resp.body = if req.head?
|
|
322
|
+
[]
|
|
323
|
+
else
|
|
324
|
+
Rack::Files::Iterator.new(filename, [0..(size - 1)], mime_type: content_type,
|
|
325
|
+
size: size)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def bad_file_send_range(size)
|
|
330
|
+
headers["content-range"] = "bytes */#{size}"
|
|
331
|
+
answer(416, :text, "Byte range unsatisfiable")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def send_partial_file(filename, ranges, size)
|
|
335
|
+
if ranges.size == 1
|
|
336
|
+
headers["content-range"] = "bytes #{ranges[0].begin}-#{ranges[0].end}/#{size}"
|
|
337
|
+
else
|
|
338
|
+
content_type("multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}")
|
|
339
|
+
end
|
|
340
|
+
status(206)
|
|
341
|
+
body = Rack::Files::BaseIterator.new(filename, ranges, mime_type: content_type, size: size)
|
|
342
|
+
content_length(body.bytesize)
|
|
343
|
+
resp.body = req.head? ? [] : body
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def clear_body_headers
|
|
347
|
+
headers.delete "content-length"
|
|
348
|
+
headers.delete "content-type"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|