stipa 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.
@@ -0,0 +1,163 @@
1
+ module Stipa
2
+ # Raised for any HTTP/1.1 protocol violation.
3
+ # Caught by Connection#dispatch and turned into a 400 response.
4
+ # Not a subclass of StandardError to avoid accidental rescue-all clauses
5
+ # catching it — but we keep it as StandardError for practical simplicity.
6
+ class BadRequest < StandardError; end
7
+
8
+ # Parses a raw HTTP/1.1 header block (already read by Connection) and
9
+ # reads the body from the socket using Content-Length.
10
+ #
11
+ # Design notes:
12
+ # - Headers are stored with lower-cased keys for O(1) case-insensitive
13
+ # lookup (RFC 7230 §3.2: header names are case-insensitive).
14
+ # - Body is read with IO.select + read_nonblock rather than SO_RCVTIMEO
15
+ # because SO_RCVTIMEO behaves inconsistently across Ruby versions and
16
+ # platforms. IO.select releases the GVL while waiting.
17
+ # - `id` and `params` are writable: RequestId middleware sets `id`,
18
+ # the router sets `params` after matching.
19
+ # - We reject header folding (obsolete since RFC 7230) with a 400.
20
+ # - Chunked Transfer-Encoding is not supported in this version.
21
+ class Request
22
+ MAX_HEADER_SIZE = 8 * 1024 # 8 KB — slow-loris defence
23
+ MAX_BODY_SIZE = 1 * 1024 * 1024 # 1 MB default; configurable per-server
24
+ VALID_METHODS = %w[GET POST PUT PATCH DELETE HEAD OPTIONS TRACE CONNECT].freeze
25
+
26
+ attr_accessor :id, :params
27
+ attr_reader :method, :path, :query_string, :http_version,
28
+ :headers, :body, :bytes_in
29
+
30
+ # Factory — called by Connection after reading the header block.
31
+ #
32
+ # @param raw_headers [String] everything up to (not including) \r\n\r\n
33
+ # @param socket [Socket] live socket for reading remaining body bytes
34
+ # @param peer [String] remote address string (for error messages)
35
+ # @param body_timeout [Numeric] seconds allowed for body read
36
+ # @param socket_buffer [String] body bytes already read by Connection
37
+ # when it over-read past the header boundary
38
+ # @param config [Hash] server-level config overrides
39
+ def self.parse(raw_headers, socket:, peer:, body_timeout:,
40
+ socket_buffer: '', config: {})
41
+ new(raw_headers, socket:, peer:, body_timeout:,
42
+ socket_buffer:, config:)
43
+ end
44
+
45
+ def initialize(raw_headers, socket:, peer:, body_timeout:,
46
+ socket_buffer: '', config: {})
47
+ @socket = socket
48
+ @peer = peer
49
+ @body_timeout = body_timeout
50
+ @socket_buffer = socket_buffer.b # binary copy of pre-read body bytes
51
+ @max_body = config.fetch(:max_body_size, MAX_BODY_SIZE)
52
+ @bytes_in = raw_headers.bytesize
53
+ @id = nil # set by RequestId middleware
54
+ @params = {} # set by App#dispatch after route match
55
+ parse_headers(raw_headers)
56
+ read_body
57
+ end
58
+
59
+ # Case-insensitive header lookup: req['Content-Type'] or req['content-type']
60
+ def [](name)
61
+ @headers[name.to_s.downcase]
62
+ end
63
+
64
+ private
65
+
66
+ def parse_headers(raw)
67
+ lines = raw.split("\r\n", -1)
68
+ raise BadRequest, 'empty request' if lines.empty? || lines[0].empty?
69
+
70
+ parse_request_line(lines.shift)
71
+
72
+ @headers = {}
73
+ lines.each do |line|
74
+ # RFC 7230 §3.2.4: header field folding is obsolete — reject with 400
75
+ raise BadRequest, 'header folding not supported' if line.start_with?(' ', "\t")
76
+ name, value = line.split(':', 2)
77
+ raise BadRequest, "malformed header: #{line.inspect}" unless value
78
+ # RFC 7230: no whitespace between field name and colon
79
+ raise BadRequest, "whitespace before colon" if name != name.rstrip
80
+ @headers[name.downcase.strip] = value.strip
81
+ end
82
+ end
83
+
84
+ def parse_request_line(line)
85
+ parts = line.split(' ', 3)
86
+ raise BadRequest, "bad request line: #{line.inspect}" unless parts.length == 3
87
+
88
+ @method, full_path, @http_version = parts
89
+
90
+ raise BadRequest, "unknown method: #{@method}" unless VALID_METHODS.include?(@method)
91
+ raise BadRequest, "unknown HTTP version: #{@http_version}" \
92
+ unless @http_version.match?(/\AHTTP\/1\.[01]\z/)
93
+
94
+ # Separate path from query string
95
+ if (q = full_path.index('?'))
96
+ @path = full_path[0, q]
97
+ @query_string = full_path[q + 1..]
98
+ else
99
+ @path = full_path
100
+ @query_string = ''
101
+ end
102
+ end
103
+
104
+ def read_body
105
+ cl = @headers['content-length']
106
+ unless cl
107
+ @body = ''
108
+ return
109
+ end
110
+
111
+ # Integer() raises ArgumentError on non-numeric strings
112
+ begin
113
+ length = Integer(cl, 10)
114
+ rescue ArgumentError
115
+ raise BadRequest, "invalid Content-Length: #{cl.inspect}"
116
+ end
117
+
118
+ raise BadRequest, 'negative Content-Length' if length < 0
119
+ raise BadRequest, "body exceeds #{@max_body} bytes limit" if length > @max_body
120
+
121
+ @body = read_exactly(length)
122
+ @bytes_in += @body.bytesize
123
+ end
124
+
125
+ # Read exactly `n` bytes for the body.
126
+ #
127
+ # Drains @socket_buffer first (bytes already read by Connection when it
128
+ # over-read past the header/body boundary), then reads remaining bytes
129
+ # from the live socket using IO.select + read_nonblock.
130
+ #
131
+ # GVL note: IO.select releases the GVL while waiting, so other Ruby
132
+ # threads continue running during I/O waits.
133
+ def read_exactly(n)
134
+ return '' if n == 0
135
+
136
+ # Start with whatever Connection already buffered
137
+ buf = @socket_buffer.byteslice(0, n) || ''
138
+ buf = String.new(buf, encoding: 'BINARY')
139
+
140
+ return buf if buf.bytesize >= n # entire body was pre-buffered
141
+
142
+ deadline = Time.now + @body_timeout
143
+
144
+ while buf.bytesize < n
145
+ remaining = deadline - Time.now
146
+ raise BadRequest, 'body read timeout' if remaining <= 0
147
+
148
+ readable = IO.select([@socket], nil, nil, remaining)
149
+ raise BadRequest, 'body read timeout' if readable.nil?
150
+
151
+ want = [n - buf.bytesize, 65_536].min
152
+ chunk = @socket.read_nonblock(want, exception: false)
153
+
154
+ raise BadRequest, 'unexpected EOF during body read' if chunk.nil?
155
+ next if chunk == :wait_readable # spurious wakeup, retry
156
+
157
+ buf << chunk
158
+ end
159
+
160
+ buf
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,148 @@
1
+ require 'json'
2
+
3
+ module Stipa
4
+ # Builds a valid HTTP/1.1 response and serializes it to wire bytes.
5
+ #
6
+ # Design notes:
7
+ # - Content-Length is ALWAYS computed from body.bytesize in to_http,
8
+ # never stored manually. This prevents handler authors from setting
9
+ # a wrong value and makes binary correctness automatic.
10
+ # - Header names are stored in Title-Case for wire compatibility but
11
+ # set_header accepts any casing for developer ergonomics.
12
+ # - Keep-alive vs Connection:close is decided in to_http based on the
13
+ # request's HTTP version, so handler code never needs to think about it.
14
+ # - The Date header is injected automatically — required by RFC 7231.
15
+ class Response
16
+ STATUS_MESSAGES = {
17
+ 200 => 'OK',
18
+ 201 => 'Created',
19
+ 204 => 'No Content',
20
+ 301 => 'Moved Permanently',
21
+ 302 => 'Found',
22
+ 304 => 'Not Modified',
23
+ 400 => 'Bad Request',
24
+ 401 => 'Unauthorized',
25
+ 403 => 'Forbidden',
26
+ 404 => 'Not Found',
27
+ 405 => 'Method Not Allowed',
28
+ 408 => 'Request Timeout',
29
+ 413 => 'Payload Too Large',
30
+ 422 => 'Unprocessable Entity',
31
+ 429 => 'Too Many Requests',
32
+ 500 => 'Internal Server Error',
33
+ 502 => 'Bad Gateway',
34
+ 503 => 'Service Unavailable',
35
+ 504 => 'Gateway Timeout',
36
+ }.freeze
37
+
38
+ attr_accessor :status, :body, :template_engine
39
+ attr_reader :headers
40
+
41
+ def initialize
42
+ @status = 200
43
+ @headers = {}
44
+ @body = ''
45
+ @template_engine = nil
46
+ end
47
+
48
+ # Set a response header. Name is normalized to Title-Case.
49
+ # Accepts any casing: set_header('content-type', 'text/html') is fine.
50
+ def set_header(name, value)
51
+ @headers[titlecase(name)] = value.to_s
52
+ end
53
+ alias []= set_header
54
+
55
+ def [](name)
56
+ @headers[titlecase(name)]
57
+ end
58
+
59
+ # Render an ERB template and set the body + Content-Type to text/html.
60
+ #
61
+ # Requires a template engine to be configured on the app:
62
+ # app = Stipa::App.new(views: 'views')
63
+ #
64
+ # Examples:
65
+ # res.render('home')
66
+ # res.render('users/show', locals: { user: @user })
67
+ # res.render('welcome', locals: { name: 'Alice' }, layout: false)
68
+ # res.render('dashboard', layout: 'layouts/admin')
69
+ #
70
+ # Returns self for chaining.
71
+ def render(template, locals: {}, layout: :default)
72
+ raise 'No template engine configured. Pass views: "path" to Stipa::App.new.' \
73
+ unless @template_engine
74
+
75
+ set_header('Content-Type', 'text/html; charset=utf-8')
76
+ @body = @template_engine.render(template, locals: locals, layout: layout)
77
+ self
78
+ end
79
+
80
+ # Set body to a JSON representation of `data` and set Content-Type.
81
+ # Returns self so it can be used as the last expression in a handler.
82
+ def json(data)
83
+ @body = JSON.generate(data)
84
+ set_header('Content-Type', 'application/json; charset=utf-8')
85
+ self
86
+ end
87
+
88
+ # Serialize to the exact HTTP/1.1 bytes to write to the socket.
89
+ # `req` is used to decide the Connection header (keep-alive or close).
90
+ def to_http(req = nil)
91
+ # Force binary encoding so bytesize is always the byte count,
92
+ # not the character count (matters for multi-byte UTF-8 bodies).
93
+ body_bytes = @body.to_s.b
94
+
95
+ finalize_headers(body_bytes, req)
96
+
97
+ status_text = STATUS_MESSAGES.fetch(@status, 'Unknown')
98
+ status_line = "HTTP/1.1 #{@status} #{status_text}"
99
+ header_block = @headers.map { |k, v| "#{k}: #{v}" }.join("\r\n")
100
+
101
+ # RFC 7230: blank line (CRLF CRLF) separates header block from body
102
+ "#{status_line}\r\n#{header_block}\r\n\r\n#{body_bytes}"
103
+ end
104
+
105
+ private
106
+
107
+ # Inject protocol-level headers last so handlers cannot accidentally
108
+ # set a wrong Content-Length or omit a required header.
109
+ def finalize_headers(body_bytes, req)
110
+ # Content-Length: always recomputed from actual bytes
111
+ set_header('Content-Length', body_bytes.bytesize)
112
+
113
+ # Default Content-Type if handler didn't set one
114
+ set_header('Content-Type', 'text/plain; charset=utf-8') \
115
+ unless @headers.key?('Content-Type')
116
+
117
+ # Date: required by RFC 7231 §7.1.1.2
118
+ set_header('Date', Time.now.utc.strftime('%a, %d %b %Y %H:%M:%S GMT'))
119
+
120
+ # Server: identifies the framework
121
+ set_header('Server', "Stipa/#{Stipa::VERSION}")
122
+
123
+ inject_connection_header(req) if req
124
+ end
125
+
126
+ # Set Connection header based on HTTP version and client's preference.
127
+ # HTTP/1.1 defaults to keep-alive; HTTP/1.0 defaults to close.
128
+ def inject_connection_header(req)
129
+ if keep_alive?(req)
130
+ set_header('Connection', 'keep-alive')
131
+ set_header('Keep-Alive', 'timeout=5, max=100')
132
+ else
133
+ set_header('Connection', 'close')
134
+ end
135
+ end
136
+
137
+ def keep_alive?(req)
138
+ return false if @status >= 500 # always close after server errors
139
+ conn = req['connection']&.downcase
140
+ req.http_version == 'HTTP/1.1' ? conn != 'close' : conn == 'keep-alive'
141
+ end
142
+
143
+ # Convert any casing to HTTP Title-Case: "content-type" → "Content-Type"
144
+ def titlecase(name)
145
+ name.to_s.split('-').map(&:capitalize).join('-')
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,190 @@
1
+ require 'socket'
2
+ require_relative 'thread_pool'
3
+ require_relative 'connection'
4
+ require_relative 'request'
5
+ require_relative 'response'
6
+ require_relative 'logger'
7
+
8
+ module Stipa
9
+ # TCP accept loop and connection lifecycle manager.
10
+ #
11
+ # Architecture:
12
+ #
13
+ # Main thread (accept loop) Worker threads (pool)
14
+ # ───────────────────────── ──────────────────────────────────
15
+ # TCPServer.accept_nonblock Connection.new(socket, ...).run
16
+ # └─> pool.submit(job) ─────> └─> Request.parse
17
+ # (drop → 503) └─> app.call(req, res)
18
+ # (accept continues) └─> write_response
19
+ #
20
+ # Socket options:
21
+ # SO_REUSEADDR — always set; allows rebinding immediately after SIGTERM
22
+ # without waiting for the TIME_WAIT timeout (~60 s).
23
+ # SO_REUSEPORT — set on Linux ≥ 3.9 when available; multiple processes
24
+ # can bind the same port simultaneously, enabling zero-
25
+ # downtime rolling restarts via a process supervisor.
26
+ # TCP_NODELAY — disables Nagle's algorithm; reduces latency for small
27
+ # responses (JSON APIs) by sending immediately rather than
28
+ # waiting to coalesce small writes.
29
+ # listen(1024) — kernel-level SYN backlog; OSes cap at net.core.somaxconn.
30
+ #
31
+ # Backpressure (when all workers are busy and the queue is full):
32
+ # We write a 503 directly on the accept thread without involving a worker.
33
+ # This keeps the accept loop free to continue processing new connections
34
+ # and avoids wasting a worker thread on a connection we'll immediately reject.
35
+ #
36
+ # Graceful shutdown (SIGTERM / SIGINT):
37
+ # 1. @running = false → accept loop exits after the current poll returns
38
+ # 2. pool.shutdown(drain_timeout:) → waits for in-flight requests to finish
39
+ # 3. server socket is closed in the ensure block of start
40
+ class Server
41
+ DEFAULT_CONFIG = {
42
+ host: '0.0.0.0',
43
+ port: 3710,
44
+ pool_size: 32,
45
+ queue_depth: 64,
46
+ drain_timeout: 30,
47
+ header_read_timeout: 10,
48
+ body_read_timeout: 30,
49
+ write_timeout: 10,
50
+ keepalive_timeout: 5,
51
+ max_requests: 100,
52
+ max_header_size: 8 * 1024,
53
+ max_body_size: 1 * 1024 * 1024,
54
+ backpressure: :drop, # :drop (503) or :block (wait briefly)
55
+ log_level: :info,
56
+ }.freeze
57
+
58
+ def initialize(app:, **overrides)
59
+ @app = app # compiled middleware+router callable
60
+ @config = DEFAULT_CONFIG.merge(overrides)
61
+ @logger = Logger.new(level: @config[:log_level])
62
+ @pool = ThreadPool.new(
63
+ size: @config[:pool_size],
64
+ queue_depth: @config[:queue_depth],
65
+ on_error: method(:pool_error),
66
+ )
67
+ @running = false
68
+ end
69
+
70
+ # Start the server. Blocks until SIGTERM/SIGINT.
71
+ def start
72
+ @server = build_server_socket
73
+ @running = true
74
+
75
+ register_signals
76
+
77
+ @logger.info(
78
+ req: nil, res: nil,
79
+ msg: 'Stīpa listening',
80
+ host: @config[:host],
81
+ port: @config[:port],
82
+ workers: @config[:pool_size],
83
+ queue: @config[:queue_depth],
84
+ )
85
+
86
+ accept_loop
87
+ ensure
88
+ @server&.close rescue nil
89
+ @logger.info(req: nil, res: nil, msg: 'Stīpa stopped')
90
+ end
91
+
92
+ private
93
+
94
+ def accept_loop
95
+ while @running
96
+ begin
97
+ # accept_nonblock raises IO::WaitReadable when no connection is
98
+ # waiting. We use IO.select to poll every 100ms so @running is
99
+ # checked regularly for clean shutdown.
100
+ socket = @server.accept_nonblock
101
+ rescue IO::WaitReadable
102
+ IO.select([@server], nil, nil, 0.1)
103
+ retry
104
+ rescue Errno::ECONNABORTED, Errno::EPROTO
105
+ # Connection was aborted between SYN and accept — ignore and continue
106
+ retry
107
+ rescue IOError, Errno::EBADF
108
+ # Server socket was closed (shutdown path) — exit the loop
109
+ break
110
+ end
111
+
112
+ configure_client(socket)
113
+ enqueue_or_503(socket)
114
+ end
115
+ end
116
+
117
+ # Try to hand the socket to the thread pool. If the queue is full,
118
+ # write a 503 directly on the accept thread and close the socket.
119
+ def enqueue_or_503(socket)
120
+ submitted = @pool.submit(mode: @config[:backpressure]) do
121
+ Connection.new(
122
+ socket,
123
+ app: @app,
124
+ logger: @logger,
125
+ config: @config,
126
+ ).run
127
+ end
128
+
129
+ return if submitted
130
+
131
+ # Queue is full — fast reject
132
+ @logger.warn('backpressure 503', queue_depth: @pool.queue_depth)
133
+ begin
134
+ socket.write(
135
+ "HTTP/1.1 503 Service Unavailable\r\n" \
136
+ "Content-Type: text/plain\r\n" \
137
+ "Content-Length: 19\r\n" \
138
+ "Connection: close\r\n" \
139
+ "\r\n" \
140
+ "Service Unavailable"
141
+ )
142
+ rescue nil
143
+ ensure
144
+ socket.close rescue nil
145
+ end
146
+ end
147
+
148
+ def build_server_socket
149
+ server = TCPServer.new(@config[:host], @config[:port])
150
+
151
+ # SO_REUSEADDR: rebind immediately after SIGTERM without TIME_WAIT delay
152
+ server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
153
+
154
+ # SO_REUSEPORT (Linux ≥ 3.9): allows multiple processes on the same port
155
+ # for zero-downtime rolling restarts. Guard with const_defined? for
156
+ # portability across macOS, BSD, and older Linux kernels.
157
+ if Socket.const_defined?(:SO_REUSEPORT)
158
+ server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true)
159
+ end
160
+
161
+ # Backlog of 1024: kernel queues up to this many SYN_RCVD connections.
162
+ # The actual limit is min(1024, net.core.somaxconn) on Linux.
163
+ server.listen(1024)
164
+ server
165
+ end
166
+
167
+ def configure_client(socket)
168
+ # TCP_NODELAY disables Nagle's algorithm so small responses
169
+ # (e.g., a 200-byte JSON body) are sent in one TCP segment
170
+ # rather than waiting 40–200ms for more data to coalesce.
171
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
172
+ rescue StandardError
173
+ # Not fatal — continue without TCP_NODELAY
174
+ end
175
+
176
+ def register_signals
177
+ shutdown_proc = ->(_signal) {
178
+ @logger.warn('shutdown signal received')
179
+ @running = false
180
+ @pool.shutdown(drain_timeout: @config[:drain_timeout])
181
+ }
182
+ trap('TERM', &shutdown_proc)
183
+ trap('INT', &shutdown_proc)
184
+ end
185
+
186
+ def pool_error(err, _job)
187
+ @logger.error("worker crash: #{err.class}: #{err.message}")
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,92 @@
1
+ module Stipa
2
+ module Middleware
3
+ # Serve static files from a directory (typically 'public/').
4
+ #
5
+ # Features:
6
+ # - Path traversal prevention (no ../../ escaping the root)
7
+ # - Correct MIME types for web assets including .vue and .mjs files
8
+ # - ETag-based conditional GET (304 Not Modified)
9
+ # - HEAD request support
10
+ # - Only intercepts GET/HEAD; other methods fall through to the app
11
+ #
12
+ # Usage:
13
+ # app.use Stipa::Middleware::Static, root: 'public'
14
+ # app.use Stipa::Middleware::Static, root: '/srv/myapp/public', prefix: '/assets'
15
+ class Static
16
+ MIME_TYPES = {
17
+ '.html' => 'text/html; charset=utf-8',
18
+ '.css' => 'text/css; charset=utf-8',
19
+ '.js' => 'application/javascript; charset=utf-8',
20
+ '.mjs' => 'application/javascript; charset=utf-8',
21
+ # .vue files are served as JS so the browser can import them as ES modules
22
+ '.vue' => 'application/javascript; charset=utf-8',
23
+ '.json' => 'application/json; charset=utf-8',
24
+ '.png' => 'image/png',
25
+ '.jpg' => 'image/jpeg',
26
+ '.jpeg' => 'image/jpeg',
27
+ '.gif' => 'image/gif',
28
+ '.webp' => 'image/webp',
29
+ '.svg' => 'image/svg+xml',
30
+ '.ico' => 'image/x-icon',
31
+ '.woff' => 'font/woff',
32
+ '.woff2' => 'font/woff2',
33
+ '.ttf' => 'font/ttf',
34
+ '.eot' => 'application/vnd.ms-fontobject',
35
+ '.map' => 'application/json',
36
+ '.txt' => 'text/plain; charset=utf-8',
37
+ '.xml' => 'application/xml; charset=utf-8',
38
+ }.freeze
39
+
40
+ # root: directory to serve files from (absolute or relative to cwd)
41
+ # prefix: URL path prefix that triggers static file serving (default '/')
42
+ def initialize(next_app, root:, prefix: '/')
43
+ @next_app = next_app
44
+ @root = File.expand_path(root)
45
+ @prefix = prefix.chomp('/')
46
+ end
47
+
48
+ def call(req, res)
49
+ if %w[GET HEAD].include?(req.method) && req.path.start_with?("#{@prefix}/", @prefix)
50
+ rel = req.path.delete_prefix(@prefix)
51
+ found = serve_static(rel, req, res)
52
+ return found if found
53
+ end
54
+ @next_app.call(req, res)
55
+ end
56
+
57
+ private
58
+
59
+ def serve_static(rel_path, req, res)
60
+ full_path = File.expand_path(File.join(@root, rel_path))
61
+
62
+ # Security: reject any path that escapes the root directory.
63
+ # File.expand_path resolves '..' so this comparison is reliable.
64
+ root_with_sep = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
65
+ return nil unless full_path.start_with?(root_with_sep)
66
+ return nil unless File.file?(full_path)
67
+
68
+ stat = File.stat(full_path)
69
+ etag = %("stipa-#{stat.mtime.to_i}-#{stat.size}")
70
+ content_type = MIME_TYPES.fetch(File.extname(full_path).downcase, 'application/octet-stream')
71
+
72
+ res['ETag'] = etag
73
+ res['Cache-Control'] = 'public, max-age=3600'
74
+ res['Content-Type'] = content_type
75
+
76
+ if req['if-none-match'] == etag
77
+ res.status = 304
78
+ res.body = ''
79
+ elsif req.method == 'HEAD'
80
+ res.status = 200
81
+ res.body = ''
82
+ res['Content-Length'] = stat.size.to_s
83
+ else
84
+ res.status = 200
85
+ res.body = File.binread(full_path)
86
+ end
87
+
88
+ res
89
+ end
90
+ end
91
+ end
92
+ end