Skip to content

Commit dd94eba

Browse files
Fix static conditional requests.
Assisted-By: devx/b3414b50-d642-461b-95a8-8f773c4d087b
1 parent 7723649 commit dd94eba

3 files changed

Lines changed: 58 additions & 46 deletions

File tree

lib/utopia/static/local_file.rb

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,21 @@
1414
module Utopia
1515
# A middleware which serves static files from the specified root directory.
1616
module Static
17-
# Represents a local file on disk which can be served directly, or passed upstream to sendfile.
17+
# Represents a local static resource and constructs responses for it.
1818
class LocalFile
1919
# Initialize metadata for a file beneath a static root.
2020
# @parameter root [String] The root directory.
2121
# @parameter path [Utopia::Path | String] The path.
2222
def initialize(root, path)
2323
@root = root
2424
@path = path
25-
@etag = Digest::SHA1.hexdigest("#{File.size(full_path)}#{mtime_date}")
26-
27-
@range = nil
25+
fingerprint = Digest::SHA1.hexdigest("#{File.size(full_path)}#{mtime_date}")
26+
@etag = %Q{"#{fingerprint}"}
2827
end
2928

3029
attr :root
3130
attr :path
3231
attr :etag
33-
attr :range
34-
35-
# Expose the filesystem path for upstream sendfile support.
36-
def to_path
37-
full_path
38-
end
3932

4033
# Resolve this file beneath its configured root.
4134
# @returns [String] The full filesystem path.
@@ -55,41 +48,16 @@ def bytesize
5548
File.size(full_path)
5649
end
5750

58-
# This reflects whether calling each would yield anything.
59-
def empty?
60-
bytesize == 0
61-
end
62-
63-
alias size bytesize
64-
65-
# Enumerate the contained values.
66-
# @returns [Enumerator] An enumerator over the resulting values.
67-
def each
68-
File.open(full_path, "rb") do |file|
69-
file.seek(@range.begin)
70-
remaining = @range.end - @range.begin+1
71-
72-
while remaining > 0
73-
break unless part = file.read([8192, remaining].min)
74-
75-
remaining -= part.length
76-
77-
yield part
78-
end
79-
end
80-
end
81-
8251
# Check whether the file has changed since the request validators.
8352
# @parameter request [Utopia::Request] The request.
8453
# @returns [Boolean] Whether the file is newer than the request validators.
8554
def modified?(request)
86-
if modified_since = request.headers["if-modified-since"]
87-
return false if File.mtime(full_path) <= Time.parse(modified_since)
55+
if etags = request.headers["if-none-match"]
56+
return !etags.weak_match?(@etag)
8857
end
8958

90-
if etags = request.headers["if-none-match"]
91-
etags = etags.split(/\s*,\s*/)
92-
return false if etags.include?(etag) || etags.include?("*")
59+
if modified_since = request.headers["if-modified-since"]
60+
return File.mtime(full_path).to_i > modified_since.to_time.to_i
9361
end
9462

9563
return true
@@ -104,6 +72,7 @@ def modified?(request)
10472
# @returns [Protocol::HTTP::Response] The response.
10573
def serve(request, response_headers)
10674
ranges = byte_ranges(request.headers["range"])
75+
size = bytesize
10776

10877
# puts "Requesting ranges: #{ranges.inspect} (#{size})"
10978

@@ -112,18 +81,18 @@ def serve(request, response_headers)
11281
# TODO: Support multiple byte-ranges, for now just send entire file:
11382
status = 200
11483
response_headers[CONTENT_LENGTH] = size.to_s
115-
@range = 0...size
84+
range = nil
11685
else
11786
# Partial content:
118-
@range = ranges[0]
119-
partial_size = @range.size
87+
range = ranges[0]
88+
partial_size = range.size
12089

12190
status = 206
12291
response_headers[CONTENT_LENGTH] = partial_size.to_s
123-
response_headers[CONTENT_RANGE] = "bytes #{@range.min}-#{@range.max}/#{size}"
92+
response_headers[CONTENT_RANGE] = "bytes #{range.min}-#{range.max}/#{size}"
12493
end
12594

126-
body = Protocol::HTTP::Body::File.open(full_path, status == 206 ? @range : nil, size: size)
95+
body = Protocol::HTTP::Body::File.open(full_path, range, size: size)
12796

12897
return Response[status, response_headers, body]
12998
end
@@ -134,7 +103,7 @@ def serve(request, response_headers)
134103
def byte_ranges(range)
135104
return nil unless range&.bytes?
136105

137-
return range.resolve(size)
106+
return range.resolve(bytesize)
138107
end
139108
end
140109
end

lib/utopia/static/middleware.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ def freeze
4747
# @parameter path [Utopia::Path | String] The path.
4848
# @returns [LocalFile | Nil] The local file, or `nil` when it does not exist.
4949
def fetch_file(path)
50-
# We need file_path to be an absolute path for X-Sendfile to work correctly.
5150
file_path = File.join(@root, path.components)
5251

5352
if File.exist?(file_path)

test/utopia/static.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,50 @@
2424
expect(last_response.body).to be_a(Protocol::HTTP::Body::File)
2525
end
2626

27+
it "returns not modified for matching entity tags" do
28+
get "/test.txt"
29+
etag = last_response.headers["etag"]
30+
31+
expect(etag).to be(:start_with?, '"')
32+
expect(etag).to be(:end_with?, '"')
33+
34+
get "/test.txt", {"if-none-match" => etag}
35+
expect(last_response.status).to be == 304
36+
37+
get "/test.txt", {"if-none-match" => "W/#{etag}"}
38+
expect(last_response.status).to be == 304
39+
40+
get "/test.txt", {"if-none-match" => "*"}
41+
expect(last_response.status).to be == 304
42+
end
43+
44+
it "gives entity tags precedence over modification dates" do
45+
get "/test.txt", {
46+
"if-none-match" => '"different"',
47+
"if-modified-since" => (Time.now + 3600).httpdate,
48+
}
49+
50+
expect(last_response.status).to be == 200
51+
end
52+
53+
it "returns not modified when the modification time matches" do
54+
get "/test.txt"
55+
last_modified = last_response.headers["last-modified"]
56+
57+
get "/test.txt", {"if-modified-since" => last_modified}
58+
59+
expect(last_response.status).to be == 304
60+
end
61+
62+
it "returns modified when the modification time is newer" do
63+
get "/test.txt"
64+
last_modified = last_response.headers["last-modified"].to_time
65+
66+
get "/test.txt", {"if-modified-since" => (last_modified - 1).httpdate}
67+
68+
expect(last_response.status).to be == 200
69+
end
70+
2771
it "should return partial content" do
2872
get "/test.txt", {"range" => "bytes=1-4"}
2973

0 commit comments

Comments
 (0)