wsv 0.8.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 +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +97 -0
- data/bin/wsv +6 -0
- data/lib/wsv/app.rb +38 -0
- data/lib/wsv/cli.rb +88 -0
- data/lib/wsv/mime_types.rb +32 -0
- data/lib/wsv/path_resolver.rb +111 -0
- data/lib/wsv/request.rb +68 -0
- data/lib/wsv/response.rb +67 -0
- data/lib/wsv/server.rb +217 -0
- data/lib/wsv/status.rb +22 -0
- data/lib/wsv/version.rb +5 -0
- data/lib/wsv.rb +14 -0
- data/test/app_test.rb +77 -0
- data/test/cli_test.rb +71 -0
- data/test/path_resolver_test.rb +183 -0
- data/test/request_test.rb +61 -0
- data/test/response_test.rb +48 -0
- data/test/server_test.rb +310 -0
- data/test/test_helper.rb +9 -0
- data/wsv.gemspec +36 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3439ce5afffd7bfe4dc3e2a18da2d3f9d15ffd10b28e9cc4bb33f5c3e14ada12
|
|
4
|
+
data.tar.gz: 98e65904c83834cc8e910931f1884f52e652346bad94612a8ba31933e607ff8c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 382457dc4151007dca51ab8a550021b06bfe4f4127d5ff40c3c9da7e566b3987ec16b57838b8ce5e30a210d538b87231b9f0f0e5e8b5ef91ef20cc5fdc8f9395
|
|
7
|
+
data.tar.gz: 2b6210d0c5063c4ba5a8b6c3d723cfe5db3abe9c41b20b6c56a99d64cac0a9b961d694fe2a7b2458250441102ed959034f13f7be169fa0a8102ef46405d8a4f4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Bound request size: 8 KiB request line, 8 KiB per header line, 16 KiB total
|
|
6
|
+
headers, 100 header lines. Returns 414 / 431 when exceeded.
|
|
7
|
+
- Per-request read deadline (default 10s) for slow/idle clients. Returns 408
|
|
8
|
+
on timeout. Configurable via `Server.new(... read_timeout:)`.
|
|
9
|
+
- Reject CR/LF in response header values to prevent header injection.
|
|
10
|
+
- Drain receive buffer before closing to deliver error responses cleanly
|
|
11
|
+
instead of resetting the connection.
|
|
12
|
+
- Minimum Ruby version raised to 3.2 (uses `IO#timeout=`).
|
|
13
|
+
- Apply the dotfile filter to the resolved real path so a symlink inside the
|
|
14
|
+
root cannot smuggle access to `.git/`, `.env`, etc. Symlink loops and
|
|
15
|
+
permission errors now resolve to 404 cleanly.
|
|
16
|
+
- Concurrent connection handling: a thread is spawned per accepted client up
|
|
17
|
+
to `max_connections` (default 8). Idle servers hold no worker threads.
|
|
18
|
+
Connections beyond the cap receive 503 and are closed.
|
|
19
|
+
- Print a `WARNING` to stderr when binding to a non-loopback address so
|
|
20
|
+
exposing the served directory to the network is intentional, not silent.
|
|
21
|
+
- Cap the post-response receive drain at 5 seconds so a malicious or stuck
|
|
22
|
+
client cannot tie up a worker indefinitely while sending body bytes after
|
|
23
|
+
the response has been written.
|
|
24
|
+
- Make the accept loop resilient to transient errors: per-connection failures
|
|
25
|
+
(`ECONNABORTED`, `EMFILE`, `ENOMEM`, etc.) are logged and skipped instead of
|
|
26
|
+
killing the server. A 50 ms backoff prevents tight error loops.
|
|
27
|
+
- Add RuboCop with `rubocop-minitest` / `rubocop-rake` plugins. `rake` now
|
|
28
|
+
runs both `test` and `rubocop`.
|
|
29
|
+
- Add GitHub Actions CI: tests on Ruby 3.2 / 3.3 / 3.4 plus a separate
|
|
30
|
+
RuboCop job.
|
|
31
|
+
- Document the security model in README (what `wsv` protects against and
|
|
32
|
+
what is explicitly out of scope).
|
|
33
|
+
|
|
34
|
+
## 0.1.0
|
|
35
|
+
|
|
36
|
+
- Initial release.
|
|
37
|
+
- Requests to dotfiles and dot-directories (e.g. `/.git/...`, `/.env`) return 403.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 takahashim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# wsv
|
|
2
|
+
|
|
3
|
+
`wsv` is a minimal static web server for local previews.
|
|
4
|
+
|
|
5
|
+
It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install wsv
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local development:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
gem build wsv.gemspec
|
|
17
|
+
gem install ./wsv-0.1.0.gem
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
wsv [options] [directory]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
wsv
|
|
30
|
+
wsv public
|
|
31
|
+
wsv -h 0.0.0.0 -p 3000 ./dist
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
-h, --host HOST Bind host (default: 127.0.0.1)
|
|
38
|
+
-p, --port PORT Bind port (default: 8000)
|
|
39
|
+
--help Show help
|
|
40
|
+
--version Show version
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Behavior
|
|
44
|
+
|
|
45
|
+
- Serves files from the selected directory.
|
|
46
|
+
- Serves `index.html` for directories that contain it.
|
|
47
|
+
- Does not render directory listings.
|
|
48
|
+
- Supports `GET` and `HEAD`.
|
|
49
|
+
- Rejects paths that resolve outside the served directory.
|
|
50
|
+
- Sends `Cache-Control: no-cache` for local development.
|
|
51
|
+
|
|
52
|
+
## Security model
|
|
53
|
+
|
|
54
|
+
`wsv` is intended for **local development previews, not for production or internet-facing use**.
|
|
55
|
+
Within that scope it tries to behave defensively:
|
|
56
|
+
|
|
57
|
+
### What `wsv` protects against
|
|
58
|
+
|
|
59
|
+
- Path traversal — `..`, absolute paths, and URL-encoded forms (`%2e%2e`) are
|
|
60
|
+
resolved and rejected if they escape the served directory.
|
|
61
|
+
- Symlink-based escape — symlinks pointing outside the served directory are
|
|
62
|
+
rejected (403). Symlinks that resolve inside the directory are followed.
|
|
63
|
+
- Symlink-to-dotfile bypass — even if a non-dotfile name is requested, the
|
|
64
|
+
resolved real path is checked again so an internal symlink cannot smuggle
|
|
65
|
+
access to `.git/`, `.env`, etc.
|
|
66
|
+
- Dotfile exposure — any path segment beginning with `.` is rejected (403),
|
|
67
|
+
whether at the URL layer or after symlinks resolve.
|
|
68
|
+
- Unintended LAN exposure — the default bind is `127.0.0.1`. Passing
|
|
69
|
+
`--host 0.0.0.0` (or any non-loopback address) prints a `WARNING` to
|
|
70
|
+
stderr so the choice is explicit.
|
|
71
|
+
- Resource exhaustion from oversized requests — request line, header line,
|
|
72
|
+
total header bytes, and header count are bounded; offending clients receive
|
|
73
|
+
`414` or `431` and are disconnected.
|
|
74
|
+
- Slow / idle clients — each request has a per-request read deadline
|
|
75
|
+
(default 10s, configurable). Stalled connections receive `408`.
|
|
76
|
+
- Header injection — CR/LF in response header values is rejected at
|
|
77
|
+
construction time, so user-derived strings cannot inject extra headers.
|
|
78
|
+
- Single-client monopolisation — connections are handled by a thread pool
|
|
79
|
+
capped at `max_connections` (default 8). Excess clients receive `503`.
|
|
80
|
+
- Transient `accept(2)` errors — per-connection failures (`ECONNABORTED`,
|
|
81
|
+
`EMFILE`, etc.) are logged and skipped instead of killing the server.
|
|
82
|
+
|
|
83
|
+
### What `wsv` does NOT do
|
|
84
|
+
|
|
85
|
+
- Authentication, authorization, or rate limiting.
|
|
86
|
+
- TLS / HTTPS.
|
|
87
|
+
- Range requests, conditional `GET`, or HTTP keep-alive.
|
|
88
|
+
- Production-grade DoS resistance under hostile network load.
|
|
89
|
+
- Protect a directory you should not be sharing in the first place. The
|
|
90
|
+
bound is the directory you pass on the command line; if it contains
|
|
91
|
+
secrets, do not run `wsv` against it.
|
|
92
|
+
|
|
93
|
+
If you need any of the above, use a real production server.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
data/bin/wsv
ADDED
data/lib/wsv/app.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_resolver"
|
|
4
|
+
require_relative "response"
|
|
5
|
+
|
|
6
|
+
module Wsv
|
|
7
|
+
class App
|
|
8
|
+
ALLOWED_METHODS = %w[GET HEAD].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(root)
|
|
11
|
+
@resolver = PathResolver.new(root)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(request)
|
|
15
|
+
head = request.head?
|
|
16
|
+
|
|
17
|
+
unless ALLOWED_METHODS.include?(request.method)
|
|
18
|
+
return Response.text(405, headers: { "Allow" => "GET, HEAD" }, head: head)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
raw_path, query = request.target.split("?", 2)
|
|
22
|
+
result = @resolver.resolve(raw_path)
|
|
23
|
+
|
|
24
|
+
return Response.text(result.status, head: head) if result.error?
|
|
25
|
+
return Response.redirect(redirect_location(raw_path, query), head: head) if result.redirect?
|
|
26
|
+
|
|
27
|
+
Response.file(result.file, head: head)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def redirect_location(raw_path, query)
|
|
33
|
+
location = raw_path.end_with?("/") ? raw_path : "#{raw_path}/"
|
|
34
|
+
location += "?#{query}" if query && !query.empty?
|
|
35
|
+
location
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/wsv/cli.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Wsv
|
|
7
|
+
class CLI
|
|
8
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
9
|
+
DEFAULT_PORT = 8000
|
|
10
|
+
|
|
11
|
+
attr_reader :argv
|
|
12
|
+
|
|
13
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
14
|
+
@argv = argv.dup
|
|
15
|
+
@out = out
|
|
16
|
+
@err = err
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
options = parse_options(argv)
|
|
21
|
+
return 0 if options[:handled]
|
|
22
|
+
|
|
23
|
+
root = resolve_root(options[:directory])
|
|
24
|
+
server = Server.new(host: options[:host], port: options[:port], root: root, out: @out, err: @err)
|
|
25
|
+
server.start
|
|
26
|
+
0
|
|
27
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
28
|
+
@err.puts "wsv: #{e.message}"
|
|
29
|
+
@err.puts "Try `wsv --help` for usage."
|
|
30
|
+
1
|
|
31
|
+
rescue SystemCallError => e
|
|
32
|
+
@err.puts "wsv: #{e.message}"
|
|
33
|
+
1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_options(args)
|
|
37
|
+
options = {
|
|
38
|
+
host: DEFAULT_HOST,
|
|
39
|
+
port: DEFAULT_PORT,
|
|
40
|
+
directory: Dir.pwd
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
parser = OptionParser.new do |opts|
|
|
44
|
+
opts.banner = "Usage: wsv [options] [directory]"
|
|
45
|
+
|
|
46
|
+
opts.on("-h", "--host HOST", "Bind host (default: #{DEFAULT_HOST})") do |host|
|
|
47
|
+
options[:host] = host
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on("-p", "--port PORT", Integer, "Bind port (default: #{DEFAULT_PORT})") do |port|
|
|
51
|
+
options[:port] = validate_port(port)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
opts.on("--help", "Show help") do
|
|
55
|
+
@out.puts opts
|
|
56
|
+
options[:handled] = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on("--version", "Show version") do
|
|
60
|
+
@out.puts Wsv::VERSION
|
|
61
|
+
options[:handled] = true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
parser.parse!(args)
|
|
66
|
+
raise ArgumentError, "too many directories" if args.length > 1
|
|
67
|
+
|
|
68
|
+
options[:directory] = args.first if args.first
|
|
69
|
+
options
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def resolve_root(directory)
|
|
75
|
+
path = Pathname.new(directory).expand_path
|
|
76
|
+
raise ArgumentError, "directory does not exist: #{directory}" unless path.exist?
|
|
77
|
+
raise ArgumentError, "not a directory: #{directory}" unless path.directory?
|
|
78
|
+
|
|
79
|
+
path.realpath.to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_port(port)
|
|
83
|
+
raise ArgumentError, "port must be between 1 and 65535" unless port.between?(1, 65_535)
|
|
84
|
+
|
|
85
|
+
port
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
module MimeTypes
|
|
5
|
+
DEFAULT = "application/octet-stream"
|
|
6
|
+
|
|
7
|
+
TABLE = {
|
|
8
|
+
".css" => "text/css; charset=utf-8",
|
|
9
|
+
".gif" => "image/gif",
|
|
10
|
+
".html" => "text/html; charset=utf-8",
|
|
11
|
+
".htm" => "text/html; charset=utf-8",
|
|
12
|
+
".ico" => "image/x-icon",
|
|
13
|
+
".jpeg" => "image/jpeg",
|
|
14
|
+
".jpg" => "image/jpeg",
|
|
15
|
+
".js" => "text/javascript; charset=utf-8",
|
|
16
|
+
".json" => "application/json; charset=utf-8",
|
|
17
|
+
".mjs" => "text/javascript; charset=utf-8",
|
|
18
|
+
".pdf" => "application/pdf",
|
|
19
|
+
".png" => "image/png",
|
|
20
|
+
".svg" => "image/svg+xml; charset=utf-8",
|
|
21
|
+
".txt" => "text/plain; charset=utf-8",
|
|
22
|
+
".wasm" => "application/wasm",
|
|
23
|
+
".webp" => "image/webp",
|
|
24
|
+
".woff" => "font/woff",
|
|
25
|
+
".woff2" => "font/woff2"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def self.for_file(file)
|
|
29
|
+
TABLE.fetch(File.extname(file).downcase, DEFAULT)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Wsv
|
|
6
|
+
class PathResolver
|
|
7
|
+
class Result
|
|
8
|
+
attr_reader :status, :file
|
|
9
|
+
|
|
10
|
+
def initialize(kind:, status: nil, file: nil)
|
|
11
|
+
@kind = kind
|
|
12
|
+
@status = status
|
|
13
|
+
@file = file
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def file?
|
|
17
|
+
@kind == :file
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def redirect?
|
|
21
|
+
@kind == :redirect
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def error?
|
|
25
|
+
@kind == :error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.file(path)
|
|
29
|
+
new(kind: :file, status: 200, file: path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.redirect
|
|
33
|
+
new(kind: :redirect, status: 301)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.error(status)
|
|
37
|
+
new(kind: :error, status: status)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(root)
|
|
42
|
+
@root = root
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve(raw_path)
|
|
46
|
+
decoded = decode(raw_path)
|
|
47
|
+
return Result.error(400) unless decoded
|
|
48
|
+
|
|
49
|
+
relative = decoded.sub(%r{\A/+}, "")
|
|
50
|
+
return Result.error(403) if hidden_segment?(relative)
|
|
51
|
+
|
|
52
|
+
candidate = File.expand_path(relative, @root)
|
|
53
|
+
return Result.error(403) unless within?(candidate)
|
|
54
|
+
return Result.error(404) unless File.exist?(candidate)
|
|
55
|
+
|
|
56
|
+
real = File.realpath(candidate)
|
|
57
|
+
return Result.error(403) unless within?(real)
|
|
58
|
+
return Result.error(403) if hidden_under_root?(real)
|
|
59
|
+
|
|
60
|
+
if File.directory?(real)
|
|
61
|
+
return Result.redirect unless decoded.end_with?("/")
|
|
62
|
+
|
|
63
|
+
index = File.join(real, "index.html")
|
|
64
|
+
return Result.error(404) unless File.file?(index)
|
|
65
|
+
|
|
66
|
+
return Result.file(index)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return Result.error(404) unless File.file?(real)
|
|
70
|
+
|
|
71
|
+
Result.file(real)
|
|
72
|
+
rescue Errno::ENOENT, Errno::ELOOP, Errno::EACCES
|
|
73
|
+
Result.error(404)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def decode(raw_path)
|
|
79
|
+
path = URI(raw_path.to_s).path
|
|
80
|
+
percent_decode(path)
|
|
81
|
+
rescue URI::InvalidURIError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def percent_decode(string)
|
|
86
|
+
decoded = string.gsub(/%([0-9a-fA-F]{2})/) { ::Regexp.last_match(1).hex.chr }
|
|
87
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
88
|
+
return nil unless decoded.valid_encoding?
|
|
89
|
+
|
|
90
|
+
decoded
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def hidden_segment?(relative)
|
|
94
|
+
relative.split("/").any? do |segment|
|
|
95
|
+
next false if segment.empty? || segment == "." || segment == ".."
|
|
96
|
+
|
|
97
|
+
segment.start_with?(".")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def within?(path)
|
|
102
|
+
path == @root || path.start_with?("#{@root}#{File::SEPARATOR}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def hidden_under_root?(real)
|
|
106
|
+
return false if real == @root
|
|
107
|
+
|
|
108
|
+
hidden_segment?(real[(@root.length + 1)..])
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/wsv/request.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Request
|
|
5
|
+
REQUEST_LINE_LIMIT = 8192
|
|
6
|
+
HEADER_LINE_LIMIT = 8192
|
|
7
|
+
HEADER_COUNT_LIMIT = 100
|
|
8
|
+
HEADER_TOTAL_LIMIT = 16384
|
|
9
|
+
|
|
10
|
+
class TooLarge < StandardError
|
|
11
|
+
attr_reader :status_code
|
|
12
|
+
|
|
13
|
+
def initialize(status_code)
|
|
14
|
+
super("request exceeded size limit (#{status_code})")
|
|
15
|
+
@status_code = status_code
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :method, :target, :version, :headers
|
|
20
|
+
|
|
21
|
+
def initialize(method:, target:, version:, headers:)
|
|
22
|
+
@method = method
|
|
23
|
+
@target = target
|
|
24
|
+
@version = version
|
|
25
|
+
@headers = headers
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def head?
|
|
29
|
+
method == "HEAD"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.parse(io)
|
|
33
|
+
line = io.gets(REQUEST_LINE_LIMIT)
|
|
34
|
+
return :empty unless line
|
|
35
|
+
raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
|
|
36
|
+
|
|
37
|
+
method, target, version = line.split(/\s+/, 3)
|
|
38
|
+
version = version&.strip
|
|
39
|
+
return :malformed unless method && target && version&.start_with?("HTTP/")
|
|
40
|
+
|
|
41
|
+
headers = read_headers(io)
|
|
42
|
+
new(method: method, target: target, version: version, headers: headers)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.read_headers(io)
|
|
46
|
+
headers = {}
|
|
47
|
+
total = 0
|
|
48
|
+
count = 0
|
|
49
|
+
while (line = io.gets(HEADER_LINE_LIMIT))
|
|
50
|
+
raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
|
|
51
|
+
|
|
52
|
+
stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
|
|
53
|
+
break if stripped.empty?
|
|
54
|
+
|
|
55
|
+
count += 1
|
|
56
|
+
raise TooLarge, 431 if count > HEADER_COUNT_LIMIT
|
|
57
|
+
|
|
58
|
+
total += line.bytesize
|
|
59
|
+
raise TooLarge, 431 if total > HEADER_TOTAL_LIMIT
|
|
60
|
+
|
|
61
|
+
name, value = stripped.split(":", 2)
|
|
62
|
+
headers[name.downcase] = value.strip if name && value
|
|
63
|
+
end
|
|
64
|
+
headers
|
|
65
|
+
end
|
|
66
|
+
private_class_method :read_headers
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/wsv/response.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "mime_types"
|
|
5
|
+
require_relative "status"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
|
|
8
|
+
module Wsv
|
|
9
|
+
class Response
|
|
10
|
+
SERVER_NAME = "wsv/#{Wsv::VERSION}".freeze
|
|
11
|
+
|
|
12
|
+
INVALID_HEADER_NAME = /[\s:]/
|
|
13
|
+
INVALID_HEADER_VALUE = /[\r\n]/
|
|
14
|
+
|
|
15
|
+
attr_reader :status, :headers, :body
|
|
16
|
+
|
|
17
|
+
def initialize(status:, headers: {}, body: "")
|
|
18
|
+
headers.each do |name, value|
|
|
19
|
+
raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
|
|
20
|
+
raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
|
|
21
|
+
end
|
|
22
|
+
@status = status
|
|
23
|
+
@headers = headers
|
|
24
|
+
@body = body
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reason
|
|
28
|
+
Status.reason(status)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_to(io)
|
|
32
|
+
io.write "HTTP/1.1 #{status} #{reason}\r\n"
|
|
33
|
+
io.write "Server: #{SERVER_NAME}\r\n"
|
|
34
|
+
io.write "Connection: close\r\n"
|
|
35
|
+
headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
|
|
36
|
+
io.write "\r\n"
|
|
37
|
+
io.write body
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.text(status, headers: {}, head: false)
|
|
41
|
+
body = "#{status} #{Status.reason(status)}\n"
|
|
42
|
+
base = {
|
|
43
|
+
"Content-Type" => "text/plain; charset=utf-8",
|
|
44
|
+
"Content-Length" => body.bytesize.to_s,
|
|
45
|
+
"Cache-Control" => "no-cache"
|
|
46
|
+
}
|
|
47
|
+
new(status: status, headers: base.merge(headers), body: head ? "" : body)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.file(path, head: false)
|
|
51
|
+
new(
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: {
|
|
54
|
+
"Content-Type" => MimeTypes.for_file(path),
|
|
55
|
+
"Content-Length" => File.size(path).to_s,
|
|
56
|
+
"Last-Modified" => File.mtime(path).httpdate,
|
|
57
|
+
"Cache-Control" => "no-cache"
|
|
58
|
+
},
|
|
59
|
+
body: head ? "" : File.binread(path)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.redirect(location, head: false)
|
|
64
|
+
text(301, headers: { "Location" => location }, head: head)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|