rack-tail 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,13 @@
1
+ Do this to generate your change history
2
+
3
+ git log --pretty=format:' * %h - %s (%an, %ad)'
4
+
5
+ ### 1.0.0 (9 April 2014)
6
+
7
+ * 4e008e1 - Adding more tests around headers (bethesque, Wed Apr 9 09:25:07 2014 +1000)
8
+ * 9e3c82e - Refactored request handling to use an object with class methods (bethesque, Wed Apr 9 09:12:07 2014 +1000)
9
+ * 203214f - Adding more tests (bethesque, Wed Apr 9 08:44:47 2014 +1000)
10
+ * 530ee19 - Renamed Rack::TailFile to Rack::Tail (bethesque, Tue Apr 8 12:47:52 2014 +1000)
11
+ * 1414ef9 - Removing realpath from root calculations (bethesque, Tue Apr 8 07:45:56 2014 +1000)
12
+ * d015a68 - Added a 403 response when trying to access a file outside of the root (bethesque, Tue Apr 8 07:43:12 2014 +1000)
13
+ * 80e128d - Initial commit (bethesque, Mon Apr 7 15:01:17 2014 +1000)
@@ -1,176 +1,19 @@
1
1
  require "rack/tail/version"
2
-
3
- require 'elif'
4
- require 'time'
5
- require 'rack/utils'
6
- require 'rack/mime'
2
+ require "rack/tail/app"
7
3
 
8
4
  module Rack
9
- # Rack::File serves files below the +root+ directory given, according to the
5
+
6
+ # Rack::Tail is shamelessly ripped off Rake::File
7
+
8
+ # Rack::Tail serves files below the +root+ directory given, according to the
10
9
  # path info of the Rack request.
11
10
  # e.g. when Rack::File.new("/etc") is used, you can access 'passwd' file
12
11
  # as http://localhost:9292/passwd
13
- #
14
- # Handlers can detect if bodies are a Rack::File, and use mechanisms
15
- # like sendfile on the +path+.
16
-
17
- class Tail
18
-
19
- SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact)
20
- ALLOWED_VERBS = %w[GET HEAD]
21
-
22
- attr_accessor :root
23
- attr_accessor :path
24
- attr_accessor :cache_control
25
-
26
- alias :to_path :path
27
-
28
- def initialize(root, headers={}, default_mime = 'text/plain')
29
- @root = root
30
- @headers = headers
31
- @default_mime = default_mime
32
- end
33
-
34
- def call(env)
35
- dup._call(env)
36
- end
37
-
38
- F = ::File
39
-
40
- def _call(env)
41
- return fail(405, "Method Not Allowed") unless method_allowed?(env)
42
- return fail(403, "Forbidden") unless path_is_within_root?(env)
43
-
44
- @path = file_path(env)
45
-
46
- if available?
47
- serving(env)
48
- else
49
- fail(404, "File not found: #{path_info_for(env)}")
50
- end
51
- end
52
-
53
- def method_allowed? env
54
- ALLOWED_VERBS.include? env["REQUEST_METHOD"]
55
- end
56
-
57
- def available?
58
- begin
59
- F.file?(@path) && F.readable?(@path)
60
- rescue SystemCallError
61
- false
62
- end
63
- end
64
-
65
- def path_info_for env
66
- Utils.unescape(env["PATH_INFO"])
67
- end
68
-
69
- def path_is_within_root? env
70
- root = root env
71
- target = target_file env
72
- !target.relative_path_from(root).to_s.split(SEPS).any?{|p| p == ".."}
73
- end
74
-
75
- def target_file env
76
- path_info = Pathname.new("").join(*path_info_for(env).split(SEPS))
77
- root = root env
78
- root.join(path_info)
79
- end
80
-
81
- def root env
82
- Pathname.new(@root)
83
- end
84
-
85
- def file_path(env)
86
- target_file env
87
- end
88
-
89
- def serving(env)
90
- last_modified = F.mtime(@path).httpdate
91
- return [304, {}, []] if env['HTTP_IF_MODIFIED_SINCE'] == last_modified
92
-
93
- headers = { "Last-Modified" => last_modified }
94
- mime = Mime.mime_type(F.extname(@path), @default_mime)
95
- headers["Content-Type"] = mime if mime
96
-
97
- # Set custom headers
98
- @headers.each { |field, content| headers[field] = content } if @headers
99
-
100
- response = [ 200, headers, env["REQUEST_METHOD"] == "HEAD" ? [] : self ]
101
- response[1]["Content-Length"] = requested_size(env, response).to_s
102
- response
103
- end
104
-
105
- def requested_lines_size(env)
106
- (env.fetch("QUERY_STRING")[/\d+/] || 50).to_i
107
- end
108
-
109
- def tail_size_for line_count
110
- elif = Elif.new(@path)
111
- tail_size = 0
112
- line_count.times do
113
- begin
114
- tail_size += Rack::Utils.bytesize(elif.readline)
115
- rescue EOFError
116
- return tail_size
117
- end
118
- end
119
- tail_size - 1 # Don't include the first \n
120
- end
121
-
122
-
123
- def requested_size(env, response)
124
- # NOTE:
125
- # We check via File::size? whether this file provides size info
126
- # via stat (e.g. /proc files often don't), otherwise we have to
127
- # figure it out by reading the whole file into memory.
128
- size = F.size?(@path) || Utils.bytesize(F.read(@path))
129
-
130
- #TODO handle invalid lines
131
- tail_size = tail_size_for requested_lines_size(env)
132
-
133
- if tail_size == size
134
- response[0] = 200
135
- @range = 0..size-1
136
- else
137
- start_byte = size - tail_size - 1
138
- @range = start_byte..size-1
139
- response[0] = 206
140
- response[1]["Content-Range"] = "bytes #{@range.begin}-#{@range.end}/#{size}"
141
- size = @range.end - @range.begin + 1
142
- end
143
-
144
- size
145
- end
146
-
147
- def each
148
- F.open(@path, "rb") do |file|
149
- file.seek(@range.begin)
150
- remaining_len = @range.end-@range.begin+1
151
- while remaining_len > 0
152
- part = file.read([8192, remaining_len].min)
153
- break unless part
154
- remaining_len -= part.length
155
-
156
- yield part
157
- end
158
- end
159
- end
160
12
 
161
- private
13
+ module Tail
162
14
 
163
- def fail(status, body)
164
- body += "\n"
165
- [
166
- status,
167
- {
168
- "Content-Type" => "text/plain",
169
- "Content-Length" => body.size.to_s,
170
- "X-Cascade" => "pass"
171
- },
172
- [body]
173
- ]
15
+ def self.new(root, headers={}, default_mime = 'text/plain', default_lines = 50)
16
+ App.new(root, headers, default_mime, default_lines)
174
17
  end
175
18
 
176
19
  end
@@ -0,0 +1,28 @@
1
+ require "rack/tail/request_handler"
2
+
3
+ module Rack
4
+
5
+ module Tail
6
+
7
+ class App
8
+
9
+ attr_accessor :cache_control
10
+ attr_reader :root
11
+
12
+ def initialize(root, headers, default_mime, default_lines)
13
+ @root = root
14
+ @headers = headers
15
+ @default_mime = default_mime
16
+ @default_lines = default_lines
17
+ end
18
+
19
+ def call(env)
20
+ RequestHandler.new(env, @root, @headers, @default_mime, @default_lines).call
21
+ end
22
+
23
+ end
24
+
25
+ end
26
+
27
+ end
28
+
@@ -0,0 +1,154 @@
1
+ require 'elif'
2
+ require 'time'
3
+ require 'rack/utils'
4
+ require 'rack/mime'
5
+
6
+ module Rack
7
+ module Tail
8
+
9
+ class RequestHandler
10
+
11
+ SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact)
12
+ ALLOWED_VERBS = %w[GET HEAD]
13
+
14
+ attr_accessor :env
15
+ attr_accessor :root
16
+ attr_accessor :range
17
+ attr_accessor :path
18
+ attr_accessor :default_lines
19
+
20
+ def initialize(env, root, headers, default_mime, default_lines)
21
+ @root = Pathname.new(root)
22
+ @headers = headers
23
+ @default_mime = default_mime
24
+ @default_lines = default_lines
25
+ @env = env
26
+ end
27
+
28
+ F = ::File
29
+
30
+ def call
31
+ return fail(405, "Method Not Allowed") unless method_allowed?
32
+ return fail(403, "Forbidden") unless path_is_within_root?
33
+
34
+ if available?
35
+ serving
36
+ else
37
+ fail(404, "File not found: #{path_info}")
38
+ end
39
+ end
40
+
41
+ def method_allowed?
42
+ ALLOWED_VERBS.include? env["REQUEST_METHOD"]
43
+ end
44
+
45
+ def available?
46
+ begin
47
+ F.file?(path) && F.readable?(path)
48
+ rescue SystemCallError
49
+ false
50
+ end
51
+ end
52
+
53
+ def path_info
54
+ Utils.unescape(env["PATH_INFO"])
55
+ end
56
+
57
+ def path_is_within_root?
58
+ !path.relative_path_from(root).to_s.split(SEPS).any?{|p| p == ".."}
59
+ end
60
+
61
+ def path
62
+ @path ||= root.join(Pathname.new("").join(*path_info.split(SEPS)))
63
+ end
64
+
65
+ def serving
66
+ last_modified = F.mtime(path).httpdate
67
+ return [304, {}, []] if env['HTTP_IF_MODIFIED_SINCE'] == last_modified
68
+
69
+ headers = { "Last-Modified" => last_modified }
70
+ mime = Mime.mime_type(F.extname(path), @default_mime)
71
+ headers["Content-Type"] = mime if mime
72
+
73
+ # Set custom headers
74
+ @headers.each { |field, content| headers[field] = content } if @headers
75
+
76
+ response = [ 200, headers, env["REQUEST_METHOD"] == "HEAD" ? [] : self ]
77
+ response[1]["Content-Length"] = requested_size(response).to_s
78
+ response
79
+ end
80
+
81
+ def requested_lines_size
82
+ (env.fetch("QUERY_STRING")[/\d+/] || default_lines).to_i
83
+ end
84
+
85
+ def tail_size_for line_count
86
+ elif = Elif.new(path)
87
+ tail_size = 0
88
+ line_count.times do
89
+ begin
90
+ tail_size += Rack::Utils.bytesize(elif.readline)
91
+ rescue EOFError
92
+ return tail_size
93
+ end
94
+ end
95
+ tail_size - 1 # Don't include the first \n
96
+ end
97
+
98
+
99
+ def requested_size(response)
100
+ # NOTE:
101
+ # We check via File::size? whether this file provides size info
102
+ # via stat (e.g. /proc files often don't), otherwise we have to
103
+ # figure it out by reading the whole file into memory.
104
+ size = F.size?(path) || Utils.bytesize(F.read(path))
105
+
106
+ # TODO handle invalid lines eg. lines=-3
107
+ tail_size = tail_size_for requested_lines_size
108
+
109
+ if tail_size == size
110
+ response[0] = 200
111
+ @range = 0..size-1
112
+ else
113
+ start_byte = size - tail_size - 1
114
+ @range = start_byte..size-1
115
+ response[0] = 206
116
+ response[1]["Content-Range"] = "bytes #{@range.begin}-#{@range.end}/#{size}"
117
+ size = @range.end - @range.begin + 1
118
+ end
119
+
120
+ size
121
+ end
122
+
123
+ def each
124
+ F.open(path, "rb") do |file|
125
+ file.seek(range.begin)
126
+ remaining_len = range.end-range.begin+1
127
+ while remaining_len > 0
128
+ part = file.read([8192, remaining_len].min)
129
+ break unless part
130
+ remaining_len -= part.length
131
+
132
+ yield part
133
+ end
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def fail(status, body)
140
+ body += "\n"
141
+ [
142
+ status,
143
+ {
144
+ "Content-Type" => "text/plain",
145
+ "Content-Length" => body.size.to_s,
146
+ "X-Cascade" => "pass"
147
+ },
148
+ [body]
149
+ ]
150
+ end
151
+
152
+ end
153
+ end
154
+ end
@@ -1,7 +1,7 @@
1
1
  module Rack
2
2
 
3
- class Tail
4
- VERSION = "0.0.1"
3
+ module Tail
4
+ VERSION = "1.0.0"
5
5
  end
6
6
 
7
7
  end
@@ -0,0 +1,5 @@
1
+ 1
2
+ 2
3
+ 3
4
+ 4
5
+ 5
@@ -1,63 +1,111 @@
1
1
  require 'rack/tail'
2
2
  require 'rack/test'
3
+
3
4
  describe Rack::Tail do
4
5
 
5
6
  include Rack::Test::Methods
6
7
 
7
- def app
8
- Rack::Tail.new root
9
- end
8
+ let(:app) { Rack::Tail.new root, headers, mime_type, default_lines }
10
9
 
11
10
  let(:root) { './spec/fixtures' }
12
11
  let(:file_path) { root + file_name }
13
12
  let(:file_name) { "/test.txt" }
14
- let(:file_contents) { File.read(file_path )}
13
+ let(:file_contents) { File.readlines(file_path).last(default_lines).join}
14
+ let(:default_lines) { File.readlines(file_path).count + 1 }
15
+ let(:mime_type) { 'content-type' }
16
+ let(:headers) { {"X-Custom-Header" => "blah"} }
17
+
18
+
15
19
 
16
20
  describe "GET" do
17
21
 
18
- context "when the number of lines required is not specified" do
22
+ before do
23
+ subject
24
+ end
25
+
26
+ subject { get file_name }
27
+
28
+
29
+ it "adds the configured headers" do
30
+ expect(last_response.headers["X-Custom-Header"]).to eq "blah"
31
+ end
32
+
33
+ context "when the content type of the file can be determined" do
34
+ it "sets the content type" do
35
+ expect(last_response.headers['Content-Type']).to eq 'text/plain'
36
+ end
37
+ end
38
+
39
+ context "when the content type can't be determined" do
40
+ let(:file_name) { "/test" }
19
41
 
20
- subject { get file_name }
42
+ context "when the default mime type is not specified" do
43
+
44
+ let(:app) { Rack::Tail.new root }
45
+
46
+ it "sets the content type to text/plain" do
47
+ expect(last_response.headers['Content-Type']).to eq 'text/plain'
48
+ end
49
+ end
50
+
51
+ context "when the default mime type is specified" do
52
+
53
+ it "uses the default mime type" do
54
+ expect(last_response.headers['Content-Type']).to eq mime_type
55
+ end
56
+ end
57
+
58
+ end
59
+
60
+ context "when the number of lines required is not specified and the default lines is less than the entire file" do
61
+
62
+ let(:default_lines) { 3 }
63
+
64
+ it "returns the default number of lines" do
65
+ expect(last_response.body).to eq(file_contents)
66
+ end
67
+
68
+ it "sets a status of 206" do
69
+ expect(last_response.status).to eq 206
70
+ end
71
+ end
72
+
73
+ context "when the number of lines required is not specified and the default lines is more than the number of lines in the file" do
21
74
 
22
75
  it "returns the entire file" do
23
- subject
24
76
  expect(last_response.body).to eq(file_contents)
25
77
  end
26
78
 
27
79
  it "sets a status of 200" do
28
- subject
29
80
  expect(last_response.status).to eq 200
30
81
  end
31
82
  end
32
83
 
33
- context "when a number of lines is specified" do
84
+ context "when a number of lines is specified that is less than the number of lines in the file" do
34
85
  let(:file_contents) { File.readlines(file_path).last(2).join }
35
86
 
36
87
  subject { get file_name, "lines" => "2" }
37
88
 
38
89
  it "returns the last lines" do
39
- subject
40
90
  expect(last_response.body).to eq(file_contents)
41
91
  end
42
92
 
43
93
  it "sets a status of 206" do
44
- subject
45
94
  expect(last_response.status).to eq 206
46
95
  end
47
96
  end
48
97
 
49
98
  context "when a number of lines is specified that is more than the number of lines in the file" do
50
99
 
51
- subject { get file_name, "lines" => "50" }
100
+ subject { get file_name, "lines" => "40" }
52
101
  let(:file_contents) { File.read(file_path )}
102
+ let(:default_lines) { 1 }
53
103
 
54
104
  it "returns the entire file" do
55
- subject
56
105
  expect(last_response.body).to eq(file_contents)
57
106
  end
58
107
 
59
108
  it "sets a status of 200" do
60
- subject
61
109
  expect(last_response.status).to eq 200
62
110
  end
63
111
  end
@@ -66,7 +114,6 @@ describe Rack::Tail do
66
114
  subject { get "something.txt" }
67
115
 
68
116
  it "sets a status of 404" do
69
- subject
70
117
  expect(last_response.status).to eq 404
71
118
  end
72
119
  end
@@ -75,12 +122,10 @@ describe Rack::Tail do
75
122
  subject { get "../lib/rack/tail_file_spec.rb" }
76
123
 
77
124
  it "returns a 403 Forbidden" do
78
- subject
79
125
  expect(last_response.status).to eq 403
80
126
  end
81
127
 
82
128
  it "does not return the file" do
83
- subject
84
129
  expect(last_response.body).to_not eq(file_contents)
85
130
  end
86
131
  end
@@ -90,44 +135,56 @@ describe Rack::Tail do
90
135
  subject { get "../fixtures/blah/..#{file_name}" }
91
136
 
92
137
  it "returns a 200 Success" do
93
- subject
94
- expect(last_response.status).to eq 200
138
+ expect(last_response).to be_successful
95
139
  end
96
140
  end
97
141
  end
98
142
 
99
143
  describe "HEAD" do
100
144
 
145
+ before do
146
+ subject
147
+ end
148
+
101
149
  subject { head file_name }
102
150
 
103
- context "when the number of lines required is not specified" do
104
- let(:file_contents) { File.read(file_path )}
151
+ context "when the number of lines required is not specified and the default is less than the number of lines in the file" do
152
+
153
+ it "returns an empty body" do
154
+ expect(last_response.body.size).to eq 0
155
+ end
156
+
157
+ xit "sets a status of 206" do
158
+ expect(last_response.status).to eq 206
159
+ end
160
+ end
161
+
162
+ context "when the number of lines required is not specified and the default is more than the number of lines in the file" do
163
+
164
+ let(:default_lines) { File.readlines(file_path).count + 1 }
105
165
 
106
166
  it "returns an empty body" do
107
- subject
108
167
  expect(last_response.body.size).to eq 0
109
168
  end
110
169
 
111
170
  it "sets a status of 200" do
112
- subject
113
171
  expect(last_response.status).to eq 200
114
172
  end
115
173
  end
116
174
 
117
- context "when a number of lines is specified" do
118
- let(:file_contents) { File.readlines(file_path).last(2).join }
175
+ context "when a number of lines is specified that is less than the number of lines in the file" do
119
176
 
120
177
  subject { head file_name, "lines" => "2" }
178
+ let(:default_lines) { File.readlines(file_path).count + 1 }
121
179
 
122
180
  it "returns an empty body" do
123
- subject
124
181
  expect(last_response.body.size).to eq 0
125
182
  end
126
183
 
127
184
  xit "sets a status of 206" do
128
- subject
129
185
  expect(last_response.status).to eq 206
130
186
  end
187
+
131
188
  end
132
189
 
133
190
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-tail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 1.0.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -116,13 +116,17 @@ extra_rdoc_files: []
116
116
  files:
117
117
  - .gitignore
118
118
  - .rspec
119
+ - CHANGELOG.md
119
120
  - Gemfile
120
121
  - LICENSE.txt
121
122
  - README.md
122
123
  - Rakefile
123
124
  - lib/rack/tail.rb
125
+ - lib/rack/tail/app.rb
126
+ - lib/rack/tail/request_handler.rb
124
127
  - lib/rack/tail/version.rb
125
128
  - rack-tail_file.gemspec
129
+ - spec/fixtures/test
126
130
  - spec/fixtures/test.txt
127
131
  - spec/lib/rack/tail_spec.rb
128
132
  homepage: ''
@@ -140,7 +144,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
140
144
  version: '0'
141
145
  segments:
142
146
  - 0
143
- hash: -2096215012195119719
147
+ hash: -4478541751073078281
144
148
  required_rubygems_version: !ruby/object:Gem::Requirement
145
149
  none: false
146
150
  requirements:
@@ -149,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
149
153
  version: '0'
150
154
  segments:
151
155
  - 0
152
- hash: -2096215012195119719
156
+ hash: -4478541751073078281
153
157
  requirements: []
154
158
  rubyforge_project:
155
159
  rubygems_version: 1.8.23
@@ -157,5 +161,6 @@ signing_key:
157
161
  specification_version: 3
158
162
  summary: A rack app that serves the last lines of a file
159
163
  test_files:
164
+ - spec/fixtures/test
160
165
  - spec/fixtures/test.txt
161
166
  - spec/lib/rack/tail_spec.rb