wsv 0.9.0 → 0.10.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d932b12a288921b6935081095a656825c9efaaabb9d086350104c6d92c76d42
4
- data.tar.gz: 91c097132033a2030f4e5e9f8f4aa9c799078e7006320cbfff566ea88ae6c1b9
3
+ metadata.gz: 782b303e2d6eeb0f45f6b3a012523b94611a4991d59ef6669768b21936f47a8e
4
+ data.tar.gz: 25b99b347007975bac86ccd93c212f5fb0bc85522054c36badac601bae5f3638
5
5
  SHA512:
6
- metadata.gz: a272e137f58400e80dd58d4d32f6dd1f998ee5eb148c281d95674be1b087ea72c966ea4d3b84381b28589181d0e3e1a787accea3133e899f2dd8490c04ace055
7
- data.tar.gz: 7250c73ce5e90e6c85d598b5966bdc01467bc5aeff6869c3e63e2031a8f7c7d2baa8d873f0e347bf76d55e7e567787ba041cd3c4b790a2d192b5ff8fc806d2de
6
+ metadata.gz: b3619a62d69f29a3498081d8174a0a01c61484ede73f9875de9867b4a4dd451f639dcbe0bee99fb3bea00b78c0ba289618ab7eeec3b066705d8ca437a9cea134
7
+ data.tar.gz: c58379c2eab12176f492d993d1e194dc0f3a33c16ba7d8736134498e4d13ce8031816a5d22688f1639c0ea3127c5787ad4c235874b20929490eac52881c3e9b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1
4
+
5
+ - Update gem description and README tagline to position wsv as
6
+ "Defensive by design" — surfaces the actual security defaults on
7
+ RubyGems.org and `gem search` (the 0.10.0 description still said
8
+ "tiny static web server").
9
+ - Refactor `Wsv::Server` (~250 → 150 lines): per-connection lifecycle
10
+ is now `Server::Connection` (`#serve` / `#reject` + safe write /
11
+ drain / close), concurrency cap is `Server::ConnectionThrottle`
12
+ (`#try_spawn`). Behavior unchanged.
13
+ - Extract `Server::UrlHost.format` so `Banner` and `BrowserLauncher`
14
+ share the IPv6 bracketing / RFC 6874 zone-id encoding rule (was
15
+ duplicated in two places, fixed in tandem once already).
16
+ - Simplify `Response::FileBuilder` body construction: the HEAD guard
17
+ and range/full body branch collapse into a single method.
18
+ - Move executable from `bin/wsv` to `exe/wsv` per Bundler convention.
19
+ - Various README polish: Gemfile install path first, Examples for
20
+ Jekyll / Astro / Vite, GHFM `> [!WARNING]` for the prod-use caveat.
21
+
22
+ ## 0.10.0
23
+
24
+ - Add `--cors` flag. When set, every response carries
25
+ `Access-Control-Allow-Origin: *` and `Vary: Origin`, and `OPTIONS`
26
+ preflight requests get `204 No Content` with `Access-Control-Allow-Methods:
27
+ GET, HEAD, OPTIONS` (echoing `Access-Control-Request-Headers` when
28
+ present). Unblocks browser fetches from a frontend served on a different
29
+ port (or a Service Worker) during local development. Matches the `--cors`
30
+ convention used by `http-server`, `serve`, and `live-server`. Without the
31
+ flag, no CORS headers are added and `OPTIONS` continues to return `405`.
32
+ - Add `--open` flag that launches the OS default browser at the served URL
33
+ on startup. Uses `open` (macOS), `xdg-open` (Linux/BSD), or `cmd /c start`
34
+ (Windows). Best-effort: unsupported platforms or spawn failures are logged
35
+ but never abort the server. Wildcard binds (`0.0.0.0`, `::`) translate to
36
+ the matching loopback address for the URL.
37
+ - Custom 404 page convention: when the served directory contains a
38
+ `404.html` file, it is served as the body of every `404 Not Found`
39
+ response (`Content-Type: text/html`) instead of the built-in plain
40
+ text. Matches Jekyll / Hugo / Netlify behaviour. `403` and other
41
+ error statuses are not rewritten.
42
+ - Add `--spa` flag for single-page-app routing: a request whose path resolves
43
+ to `404` falls back to the root `index.html` so client-side routers (React
44
+ Router, Vue Router, etc.) get the SPA shell instead of a real 404. `403`
45
+ and other errors are unaffected -- dotfile and traversal blocks still
46
+ apply, and existing files at the requested path are served normally.
47
+ - Send `X-Content-Type-Options: nosniff` on every response so browsers
48
+ honour the declared `Content-Type` instead of MIME-sniffing the body.
49
+ - TLS / HTTPS support via Ruby's built-in `openssl` (no extra gem dependency).
50
+ - `--tls` enables HTTPS. Without `--cert / --key`, wsv looks for
51
+ `~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` (respecting
52
+ `XDG_CONFIG_HOME`); if neither is present, an ephemeral self-signed
53
+ certificate is generated in memory and a warning is printed.
54
+ - `--cert PATH --key PATH` uses a user-supplied PEM cert / key pair and
55
+ implies `--tls`. Specifying only one of the two is an error.
56
+ - `~/.config/wsv/` (or `$XDG_CONFIG_HOME/wsv/`) is the recommended location
57
+ for mkcert-issued certificates: `mkcert -cert-file ~/.config/wsv/cert.pem
58
+ -key-file ~/.config/wsv/key.pem localhost 127.0.0.1 ::1`.
59
+ - The HTTP scheme in the startup banner switches to `https://` when TLS is
60
+ enabled.
61
+ - The TLS handshake honours the per-request read deadline, so slow-handshake
62
+ clients cannot hold a worker beyond the configured timeout.
63
+
3
64
  ## 0.9.0
4
65
 
5
66
  - Normalize the redirect `Location` to an origin-form path. Previously, an
data/README.md CHANGED
@@ -1,20 +1,27 @@
1
1
  # wsv
2
2
 
3
- `wsv` is a minimal static web server for local previews.
3
+ `wsv` is a zero-dependency static preview server for Ruby projects. Defensive by design: blocks dotfiles and binds to loopback by default.
4
4
 
5
- It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP.
5
+ It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP/HTTPS.
6
+
7
+ Requires Ruby 3.2 or later.
6
8
 
7
9
  ## Installation
8
10
 
9
- ```sh
10
- gem install wsv
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ group :development do
15
+ gem "wsv"
16
+ end
11
17
  ```
12
18
 
13
- For local development:
19
+ Then run `bundle install` and start with `bundle exec wsv`.
20
+
21
+ Or install globally:
14
22
 
15
23
  ```sh
16
- gem build wsv.gemspec
17
- gem install ./wsv-0.1.0.gem
24
+ gem install wsv
18
25
  ```
19
26
 
20
27
  ## Usage
@@ -26,8 +33,11 @@ wsv [options] [directory]
26
33
  Examples:
27
34
 
28
35
  ```sh
29
- wsv
30
- wsv public
36
+ wsv # serve current directory
37
+ wsv _site # Jekyll / Bridgetown output
38
+ wsv build # Astro / Hugo output
39
+ wsv --spa dist # Vite / esbuild / webpack SPA output
40
+ wsv --tls --open # HTTPS, open browser at startup
31
41
  wsv -h 0.0.0.0 -p 3000 ./dist
32
42
  ```
33
43
 
@@ -36,10 +46,43 @@ Options:
36
46
  ```text
37
47
  -h, --host HOST Bind host (default: 127.0.0.1)
38
48
  -p, --port PORT Bind port (default: 8000)
49
+ --tls Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)
50
+ --cert PATH TLS certificate file (PEM); implies --tls
51
+ --key PATH TLS private key file (PEM); implies --tls
52
+ --spa Single-page-app mode: fall back to root index.html on 404
53
+ --open Open the served URL in the default browser at startup
54
+ --cors Send Access-Control-Allow-Origin: * on every response
39
55
  --help Show help
40
56
  --version Show version
41
57
  ```
42
58
 
59
+ ## TLS / HTTPS
60
+
61
+ `--tls` enables HTTPS on the chosen `--port`. Three modes:
62
+
63
+ 1. Ephemeral self-signed: `wsv --tls` with no cert configured: wsv
64
+ generates an in-memory self-signed certificate. Browsers will show a
65
+ security warning; click through "Advanced → Proceed" once per session.
66
+ 2. `~/.config/wsv/` auto-detection (recommended): if both
67
+ `~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` exist (resolved via
68
+ `$XDG_CONFIG_HOME` if set), `--tls` uses them. If only one of the two
69
+ files is present, wsv refuses to start so the misconfiguration does not
70
+ silently fall back to a self-signed certificate. Combine with
71
+ [mkcert](https://github.com/FiloSottile/mkcert) to skip browser warnings:
72
+
73
+ ```sh
74
+ mkcert -install # one-time: register a local CA in your trust stores
75
+ mkdir -p ~/.config/wsv
76
+ mkcert -cert-file ~/.config/wsv/cert.pem \
77
+ -key-file ~/.config/wsv/key.pem \
78
+ localhost 127.0.0.1 ::1
79
+ chmod 600 ~/.config/wsv/key.pem
80
+ wsv --tls # → https://localhost:8000/ with no warning
81
+ ```
82
+
83
+ 3. Explicit cert/key files: `wsv --cert path/to/cert.pem --key path/to/key.pem`
84
+ for project-specific certificates. Both flags must be provided together.
85
+
43
86
  ## Behavior
44
87
 
45
88
  - Serves files from the selected directory.
@@ -50,10 +93,24 @@ Options:
50
93
  - Honours `If-Modified-Since` and returns `304 Not Modified` when applicable.
51
94
  - Rejects paths that resolve outside the served directory.
52
95
  - Sends `Cache-Control: no-cache` so the browser revalidates each request.
96
+ - With `--spa`, serves the root `index.html` instead of `404` when a path
97
+ resolves to "not found" (so client-side routers like React Router or
98
+ Vue Router work). `403` and other errors are unaffected, so dotfile and
99
+ traversal blocks still apply.
100
+ - If the served directory contains a `404.html` file, it is served as the
101
+ body of every `404 Not Found` response (with `Content-Type: text/html`)
102
+ instead of the built-in plain text. Matches the convention of Jekyll,
103
+ Hugo, and many static hosts.
104
+ - With `--cors`, every response carries `Access-Control-Allow-Origin: *`
105
+ (and `Vary: Origin`), and `OPTIONS` preflight requests get `204 No Content`
106
+ with the matching CORS headers. Lets a frontend on a different port (or a
107
+ Service Worker) fetch assets from `wsv` during local development.
53
108
 
54
109
  ## Security model
55
110
 
56
- `wsv` is intended for **local development previews, not for production or internet-facing use**.
111
+ > [!WARNING]
112
+ > `wsv` is intended for local development previews, not for production or internet-facing use.
113
+
57
114
  Within that scope it tries to behave defensively:
58
115
 
59
116
  ### What `wsv` protects against
@@ -77,16 +134,22 @@ Within that scope it tries to behave defensively:
77
134
  (default 10s, configurable). Stalled connections receive `408`.
78
135
  - Header injection — CR/LF in response header values is rejected at
79
136
  construction time, so user-derived strings cannot inject extra headers.
137
+ - MIME sniffing — every response carries `X-Content-Type-Options: nosniff`
138
+ so browsers honour the declared `Content-Type` rather than guessing from
139
+ body contents.
80
140
  - Single-client monopolisation — connections are handled by a thread pool
81
- capped at `max_connections` (default 8). Excess clients receive `503`.
141
+ capped at `max_connections` (default 8). Excess clients receive `503`
142
+ (or are closed without response in TLS mode, since writing plaintext over
143
+ a half-handshaked TLS socket would corrupt the client's view of the
144
+ protocol).
82
145
  - Transient `accept(2)` errors — per-connection failures (`ECONNABORTED`,
83
146
  `EMFILE`, etc.) are logged and skipped instead of killing the server.
84
147
 
85
148
  ### What `wsv` does NOT do
86
149
 
87
150
  - Authentication, authorization, or rate limiting.
88
- - TLS / HTTPS.
89
151
  - HTTP keep-alive (each response sets `Connection: close`).
152
+ - HTTP/2. Use Caddy / nginx as a front proxy if you need it.
90
153
  - ETags / `If-None-Match`.
91
154
  - Production-grade DoS resistance under hostile network load.
92
155
  - Defend against TOCTOU attacks from other local processes that can write
@@ -106,8 +169,7 @@ If you need any of the above, use a real production server.
106
169
  `wsv` follows [Semantic Versioning](https://semver.org/). The public API
107
170
  that SemVer covers is the CLI:
108
171
 
109
- - The flags listed above (`-h` / `--host`, `-p` / `--port`, `--help`,
110
- `--version`) and their meanings.
172
+ - The flags listed in Options above and their meanings.
111
173
  - The directory argument and the default behaviour when it is omitted.
112
174
  - Process exit codes (`0` for success, `1` for usage / setup errors).
113
175
 
@@ -115,11 +177,9 @@ Within a major version, `wsv` will not silently change the default bind
115
177
  host, default port, the dotfile-blocking rule, or the security posture in
116
178
  ways that would surprise an existing user.
117
179
 
118
- The Ruby classes inside `lib/wsv/` (`Wsv::Server`, `Wsv::App`,
119
- `Wsv::PathResolver`, `Wsv::Request`, `Wsv::Response`, `Wsv::MimeTypes`,
120
- `Wsv::Status`) are implementation details. They may change at any
121
- time, including in patch releases. If you want to embed `wsv` as a
122
- library, pin a specific version.
180
+ The Ruby classes inside `lib/wsv/` are implementation details. They may
181
+ change in any release, including patches. Pin the gem version if you
182
+ embed `wsv` as a library.
123
183
 
124
184
  ## License
125
185
 
data/lib/wsv/app.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "time"
4
4
  require "uri"
5
+ require_relative "cors"
5
6
  require_relative "path_resolver"
6
7
  require_relative "response"
7
8
 
@@ -10,27 +11,59 @@ module Wsv
10
11
  ALLOWED_METHODS = %w[GET HEAD].freeze
11
12
  RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
12
13
 
13
- def initialize(root)
14
+ def initialize(root, spa: false, cors: false)
14
15
  @resolver = PathResolver.new(root)
16
+ @spa = spa
17
+ @cors = Cors.new if cors
15
18
  end
16
19
 
17
20
  def call(request)
21
+ return @cors.preflight(request) if @cors && request.method == "OPTIONS"
22
+
23
+ response = build_response(request)
24
+ @cors ? @cors.overlay(response) : response
25
+ end
26
+
27
+ private
28
+
29
+ def build_response(request)
18
30
  head = request.head?
19
31
 
20
32
  unless ALLOWED_METHODS.include?(request.method)
21
- return Response.text(405, headers: { "Allow" => "GET, HEAD" }, head: head)
33
+ return Response.text(405, headers: { "Allow" => allow_methods }, head: head)
22
34
  end
23
35
 
24
36
  raw_path, query = request.target.split("?", 2)
25
37
  result = @resolver.resolve(raw_path)
26
38
 
27
- return Response.text(result.status, head: head) if result.error?
39
+ # SPA fallback: when the path resolves to 404, retry with "/" so client-side
40
+ # routes (React Router etc.) get index.html instead of a real 404. Other
41
+ # error statuses (403/400) are not rewritten, so dotfile / traversal blocks
42
+ # still take effect.
43
+ if @spa && result.error? && result.status == 404
44
+ fallback = @resolver.resolve("/")
45
+ result = fallback if fallback.file?
46
+ end
47
+
48
+ return error_response(result.status, head: head) if result.error?
28
49
  return Response.redirect(redirect_location(raw_path, query), head: head) if result.redirect?
29
50
 
30
51
  file_response(result.file, request, head: head)
31
52
  end
32
53
 
33
- private
54
+ def allow_methods
55
+ @cors ? Cors::ALLOW_METHODS : "GET, HEAD"
56
+ end
57
+
58
+ def error_response(status, head:)
59
+ if status == 404
60
+ # Custom 404 page convention: when the served root contains a `404.html`
61
+ # file, serve it as the body of any 404 response (Content-Type: text/html).
62
+ custom = @resolver.resolve("/404.html")
63
+ return Response.file(custom.file, status: 404, head: head) if custom.file?
64
+ end
65
+ Response.text(status, head: head)
66
+ end
34
67
 
35
68
  def file_response(file, request, head:)
36
69
  return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
@@ -60,6 +93,8 @@ module Wsv
60
93
  return nil if header_value.nil? || header_value.empty?
61
94
 
62
95
  match = header_value.match(RANGE_PATTERN)
96
+ # Per RFC 7233, an unparseable Range is treated as if absent: fall
97
+ # through as nil so the caller serves a normal 200 instead of 416.
63
98
  return nil unless match
64
99
 
65
100
  first, last = match.captures
data/lib/wsv/cli.rb CHANGED
@@ -21,7 +21,13 @@ module Wsv
21
21
  return 0 if options[:handled]
22
22
 
23
23
  root = resolve_root(options[:directory])
24
- server = Server.new(host: options[:host], port: options[:port], root: root, out: @out, err: @err)
24
+ tls = resolve_tls(options)
25
+ server = Server.new(
26
+ host: options[:host], port: options[:port], root: root,
27
+ out: @out, err: @err, tls: tls,
28
+ spa: options[:spa] || false, open: options[:open] || false,
29
+ cors: options[:cors] || false
30
+ )
25
31
  server.start
26
32
  0
27
33
  rescue OptionParser::ParseError, ArgumentError => e
@@ -33,14 +39,14 @@ module Wsv
33
39
  1
34
40
  end
35
41
 
36
- def parse_options(args)
42
+ def parse_options(args) # rubocop:disable Metrics/AbcSize
37
43
  options = {
38
44
  host: DEFAULT_HOST,
39
45
  port: DEFAULT_PORT,
40
46
  directory: Dir.pwd
41
47
  }
42
48
 
43
- parser = OptionParser.new do |opts|
49
+ parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
44
50
  opts.banner = "Usage: wsv [options] [directory]"
45
51
 
46
52
  opts.on("-h", "--host HOST", "Bind host (default: #{DEFAULT_HOST})") do |host|
@@ -51,6 +57,30 @@ module Wsv
51
57
  options[:port] = validate_port(port)
52
58
  end
53
59
 
60
+ opts.on("--tls", "Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)") do
61
+ options[:tls] = true
62
+ end
63
+
64
+ opts.on("--cert PATH", "TLS certificate file (PEM); implies --tls") do |path|
65
+ options[:cert] = path
66
+ end
67
+
68
+ opts.on("--key PATH", "TLS private key file (PEM); implies --tls") do |path|
69
+ options[:key] = path
70
+ end
71
+
72
+ opts.on("--spa", "Single-page-app mode: fall back to root index.html on 404") do
73
+ options[:spa] = true
74
+ end
75
+
76
+ opts.on("--open", "Open the served URL in the default browser at startup") do
77
+ options[:open] = true
78
+ end
79
+
80
+ opts.on("--cors", "Send Access-Control-Allow-Origin: * on every response") do
81
+ options[:cors] = true
82
+ end
83
+
54
84
  opts.on("--help", "Show help") do
55
85
  @out.puts opts
56
86
  options[:handled] = true
@@ -60,6 +90,14 @@ module Wsv
60
90
  @out.puts Wsv::VERSION
61
91
  options[:handled] = true
62
92
  end
93
+
94
+ opts.separator ""
95
+ opts.separator "Examples:"
96
+ opts.separator " wsv # serve current dir"
97
+ opts.separator " wsv _site # Jekyll / Bridgetown output"
98
+ opts.separator " wsv build # Astro / Hugo output"
99
+ opts.separator " wsv --spa dist # Vite / esbuild / webpack SPA output"
100
+ opts.separator " wsv --tls --open # HTTPS, open browser"
63
101
  end
64
102
 
65
103
  parser.parse!(args)
@@ -84,5 +122,13 @@ module Wsv
84
122
 
85
123
  port
86
124
  end
125
+
126
+ def resolve_tls(options)
127
+ return nil unless options[:tls] || options[:cert] || options[:key]
128
+
129
+ TlsContext::Resolver.resolve(cert_path: options[:cert], key_path: options[:key])
130
+ rescue OpenSSL::OpenSSLError => e
131
+ raise ArgumentError, "TLS configuration error: #{e.message}"
132
+ end
87
133
  end
88
134
  end
data/lib/wsv/cors.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "response"
4
+
5
+ module Wsv
6
+ class Cors
7
+ ALLOW_ORIGIN = "*"
8
+ ALLOW_METHODS = "GET, HEAD, OPTIONS"
9
+ MAX_AGE = "86400"
10
+
11
+ def preflight(request)
12
+ headers = {
13
+ "Access-Control-Allow-Origin" => ALLOW_ORIGIN,
14
+ "Access-Control-Allow-Methods" => ALLOW_METHODS,
15
+ "Access-Control-Max-Age" => MAX_AGE,
16
+ "Vary" => "Origin"
17
+ }
18
+ requested = request.headers["access-control-request-headers"]
19
+ headers["Access-Control-Allow-Headers"] = requested if requested
20
+ Response.new(status: 204, headers: headers)
21
+ end
22
+
23
+ def overlay(response)
24
+ response.with_headers(
25
+ "Access-Control-Allow-Origin" => ALLOW_ORIGIN,
26
+ "Vary" => "Origin"
27
+ )
28
+ end
29
+ end
30
+ end
@@ -50,6 +50,12 @@ module Wsv
50
50
  decoded = decode(raw_path)
51
51
  return Result.error(400) unless decoded
52
52
 
53
+ # Layered defense:
54
+ # 1. URL-level: reject `..` traversal and dotfile segments before
55
+ # touching the filesystem.
56
+ # 2. realpath-level: re-check after symlinks resolve, so an internal
57
+ # symlink cannot smuggle access to outside-root paths or to
58
+ # `.git/` / `.env` etc. via a non-dotfile-looking URL.
53
59
  relative = decoded.sub(%r{\A/+}, "")
54
60
  return Result.error(403) if hidden_segment?(relative)
55
61
 
@@ -13,7 +13,7 @@ module Wsv
13
13
  end
14
14
 
15
15
  def parse
16
- line = @io.gets(REQUEST_LINE_LIMIT)
16
+ line = @io.gets("\n", REQUEST_LINE_LIMIT)
17
17
  return :empty unless line
18
18
  raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
19
19
 
@@ -30,7 +30,7 @@ module Wsv
30
30
  headers = {}
31
31
  total = 0
32
32
  count = 0
33
- while (line = @io.gets(HEADER_LINE_LIMIT))
33
+ while (line = @io.gets("\n", HEADER_LINE_LIMIT))
34
34
  raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
35
35
 
36
36
  stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Request
5
+ # Raised by Request::Parser when the incoming request line, an individual
6
+ # header line, the total header bytes, or the header count exceeds the
7
+ # configured limit. The status_code chooses between 414 (URI Too Long)
8
+ # and 431 (Request Header Fields Too Large) at the call site.
9
+ class TooLarge < StandardError
10
+ attr_reader :status_code
11
+
12
+ def initialize(status_code)
13
+ super("request exceeded size limit (#{status_code})")
14
+ @status_code = status_code
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/wsv/request.rb CHANGED
@@ -1,18 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "request/too_large"
3
4
  require_relative "request/parser"
4
5
 
5
6
  module Wsv
6
7
  class Request
7
- class TooLarge < StandardError
8
- attr_reader :status_code
9
-
10
- def initialize(status_code)
11
- super("request exceeded size limit (#{status_code})")
12
- @status_code = status_code
13
- end
14
- end
15
-
16
8
  attr_reader :method, :target, :version, :headers
17
9
 
18
10
  def initialize(method:, target:, version:, headers:)
@@ -6,17 +6,18 @@ require_relative "../mime_types"
6
6
  module Wsv
7
7
  class Response
8
8
  class FileBuilder
9
- def initialize(path, head: false, range: nil)
9
+ def initialize(path, head: false, range: nil, status: 200)
10
10
  @path = path
11
11
  @head = head
12
12
  @range = range
13
+ @status = status
13
14
  end
14
15
 
15
16
  def build
16
17
  if @range
17
- Response.new(status: 206, headers: range_headers, body: range_body)
18
+ Response.new(status: 206, headers: range_headers, body: body)
18
19
  else
19
- Response.new(status: 200, headers: full_headers, body: full_body)
20
+ Response.new(status: @status, headers: full_headers, body: body)
20
21
  end
21
22
  end
22
23
 
@@ -46,17 +47,12 @@ module Wsv
46
47
  base_headers.merge("Content-Length" => size.to_s)
47
48
  end
48
49
 
49
- def range_body
50
+ def body
50
51
  return StringBody.new("") if @head
52
+ return FileBody.new(@path) unless @range
51
53
 
52
54
  FileBody.new(@path, offset: @range.begin, length: @range.size)
53
55
  end
54
-
55
- def full_body
56
- return StringBody.new("") if @head
57
-
58
- FileBody.new(@path)
59
- end
60
56
  end
61
57
  end
62
58
  end
data/lib/wsv/response.rb CHANGED
@@ -31,10 +31,19 @@ module Wsv
31
31
  Status.reason(status)
32
32
  end
33
33
 
34
+ # Returns a new Response with `extra` merged into the headers, sharing the
35
+ # same body object so streaming (FileBody) is preserved.
36
+ def with_headers(extra)
37
+ self.class.new(status: @status, headers: @headers.merge(extra), body: @body)
38
+ end
39
+
34
40
  def write_to(io)
35
41
  io.write "HTTP/1.1 #{status} #{reason}\r\n"
36
42
  io.write "Server: #{SERVER_NAME}\r\n"
37
43
  io.write "Connection: close\r\n"
44
+ unless headers.any? { |name, _value| name.to_s.casecmp?("X-Content-Type-Options") }
45
+ io.write "X-Content-Type-Options: nosniff\r\n"
46
+ end
38
47
  headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
39
48
  io.write "\r\n"
40
49
  @body.write_to(io)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "url_host"
4
+
5
+ module Wsv
6
+ class Server
7
+ # Renders the startup announcement (the "Serving / Bind / Local / Stop"
8
+ # block plus warnings about non-loopback binds and self-signed certs).
9
+ class Banner
10
+ def initialize(host:, port:, root:, out:, err:, tls:)
11
+ @host = host
12
+ @port = port
13
+ @root = root
14
+ @out = out
15
+ @err = err
16
+ @tls = tls
17
+ end
18
+
19
+ def emit
20
+ @out.puts "Serving: #{@root}"
21
+ @out.puts "Bind: #{url_for(@host)}"
22
+ @out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(@host)
23
+ @out.puts "Stop: Ctrl-C"
24
+ warn_public_bind unless localhost?(@host)
25
+ warn_ephemeral_cert if @tls&.ephemeral?
26
+ end
27
+
28
+ private
29
+
30
+ def warn_public_bind
31
+ @err.puts "WARNING: binding to #{@host} exposes #{@root} on your network."
32
+ @err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
33
+ end
34
+
35
+ def warn_ephemeral_cert
36
+ @err.puts "WARNING: serving with a self-signed certificate. Browsers will"
37
+ @err.puts " show a security warning. Pass --cert / --key for a real cert."
38
+ end
39
+
40
+ def url_for(display_host)
41
+ "#{scheme}://#{UrlHost.format(display_host)}:#{@port}/"
42
+ end
43
+
44
+ def scheme
45
+ @tls ? "https" : "http"
46
+ end
47
+
48
+ def localhost?(display_host)
49
+ ["127.0.0.1", "localhost", "::1"].include?(display_host)
50
+ end
51
+ end
52
+ end
53
+ end