I am building a web app in Ruby but without Rails. So far I just have a very basic web server. What improvements can I make to it?
require 'socket'
require 'uri'
# Files will be served from this directory
WEB_ROOT = './public'
# Map extensions to their content type
CONTENT_TYPE_MAPPING = {
'html' => 'text/html',
'txt' => 'text/plain',
'png' => 'image/png',
'jpg' => 'image/jpeg'
}
# Treat as binary data if content type cannot be found
DEFAULT_CONTENT_TYPE = 'application/octet-stream'
# This helper function parses the extension of the
# requested file and then looks up its content type.
def content_type(path)
ext = File.extname(path).split(".").last
CONTENT_TYPE_MAPPING.fetch(ext, DEFAULT_CONTENT_TYPE)
end
# This helper function parses the Request-Line and
# generates a path to a file on the server.
def requested_file(request_line)
request_uri = request_line.split(" ")[1]
path = URI.unescape(URI(request_uri).path)
clean = []
# Split the path into components
parts = path.split("/")
parts.each do |part|
# skip any empty or current directory (".") path components
next if part.empty? || part == '.'
# If the path component goes up one directory level (".."),
# remove the last clean component.
# Otherwise, add the component to the Array of clean components
part == '..' ? clean.pop : clean << part
end
# return the web root joined to the clean path
File.join(WEB_ROOT, *clean)
end
# Except where noted below, the general approach of
# handling requests and generating responses is
# similar to that of the "Hello World" example
# shown earlier.
server = TCPServer.new('localhost', 2000)
loop do
socket = server.accept
request_line = socket.gets
STDERR.puts request_line
path = requested_file(request_line)
path = File.join(path, 'index.html') if File.directory?(path)
# Make sure the file exists and is not a directory
# before attempting to open it.
if File.exist?(path) && !File.directory?(path)
File.open(path, "rb") do |file|
socket.print "HTTP/1.1 200 OK\r\n" +
"Content-Type: #{content_type(file)}\r\n" +
"Content-Length: #{file.size}\r\n" +
"Connection: close\r\n"
socket.print "\r\n"
# write the contents of the file to the socket
IO.copy_stream(file, socket)
end
else
message = "The file you were looking for does not exist brother\n"
# respond with a 404 error code to indicate the file does not exist
socket.print "HTTP/1.1 404 Not Found\r\n" +
"Content-Type: text/plain\r\n" +
"Content-Length: #{message.size}\r\n" +
"Connection: close\r\n"
socket.print "\r\n"
socket.print message
end
socket.close
end
1 Answer 1
A full HTTP 1.1 implementation is extremely complex. I'm not going to remark on incompleteness of features.
Two problems become apparent if you try to fetch a file for which the server does not have read permission: the server crashes without sending a response, and the level of abstraction to fix that crash elegantly is lacking. The main event loop should be kept as short as possible, with the request handler in its own function. Instead of checking to see if the file is likely to be readable, just do it — let the operating system enforce the permissions, and handle any possible exceptions. Finally, I suggest writing a send_response
function so that handle_request
doesn't have to worry about details such as where to put the CR-LF line terminators.
require 'stringio'
def send_response(socket, status_line, headers, payload)
socket.print("HTTP/1.1 #{status_line}\r\n")
headers.each { |k, v| socket.print("#{k}: #{v}\r\n") }
socket.print("\r\n")
IO.copy_stream(payload, socket)
end
def handle_request(socket, request_line)
STDERR.puts request_line
path = requested_file(request_line)
path = File.join(path, 'index.html') if File.directory?(path)
begin
File.open(path, 'rb') do |resource|
send_response(socket, '200 OK', {
'Content-Type' => content_type(resource),
'Content-Length' => resource.size,
'Connection' => 'close',
},
resource
)
end
rescue Errno::EACCES
message = "Permission denied"
send_response(socket, '403 Forbidden', {
'Content-Type' => 'text/plain',
'Content-Length' => message.size,
'Connection' => 'close'
},
StringIO.new(message)
)
rescue Errno::ENOENT, Errno::EISDIR
message = "The file you were looking for does not exist brother"
send_response(socket, '404 Not Found', {
'Content-Type' => 'text/plain',
'Content-Length' => message.size,
'Connection' => 'close'
},
StringIO.new(message)
)
end
end
# Except where noted below, the general approach of
# handling requests and generating responses is
# similar to that of the "Hello World" example
# shown earlier.
server = TCPServer.new('localhost', 2000)
loop do
begin
socket = server.accept
handle_request(socket, socket.gets)
rescue StandardError => e
STDERR.puts e
ensure
socket.close unless socket.nil?
end
end
STDERR.puts
just for debug? It looks out of place. If it is for debug, consider using a logging tool and keep STDERR clean. \$\endgroup\$".jpg"
does not ensure the file is actually a JPEG file. Of course, since you control which files are served, you can make sure you always serve the right type of file, but it's something to keep in mind. I believe the gem "mime-types" makes an educated guess of the actual mime type of a file. \$\endgroup\$