From 8efbdc01ff27397d614eb2702d0916375a634f90 Mon Sep 17 00:00:00 2001 From: Jordon Bedwell Date: Tue, 16 Jun 2015 20:15:18 -0500 Subject: [PATCH] Fix #3791/#3478 * Add support for SSL through command line switches. * Add suppport for file/index.html > file.html > directory. * Add support for custom-headers through configuration. * Modernize and split up the serve. * Add a few basic tests. --- lib/jekyll/commands/serve.rb | 271 +++++++++++++++------------ lib/jekyll/commands/serve/servlet.rb | 56 ++++++ site/_docs/configuration.md | 36 ++++ test/test_commands_serve.rb | 115 ++++++++++++ 4 files changed, 360 insertions(+), 118 deletions(-) create mode 100644 lib/jekyll/commands/serve/servlet.rb create mode 100644 test/test_commands_serve.rb diff --git a/lib/jekyll/commands/serve.rb b/lib/jekyll/commands/serve.rb index 389ae5a2..aa8fc6a8 100644 --- a/lib/jekyll/commands/serve.rb +++ b/lib/jekyll/commands/serve.rb @@ -1,161 +1,196 @@ -# -*- encoding: utf-8 -*- module Jekyll module Commands class Serve < Command - class << self + COMMAND_OPTIONS = { + "ssl_cert" => ["--ssl-cert [CERT]", "X.509 (SSL) certificate."], + "host" => ["host", "-H", "--host [HOST]", "Host to bind to"], + "open_url" => ["-o", "--open-url", "Launch your browser with your site."], + "detach" => ["-B", "--detach", "Run the server in the background (detach)"], + "ssl_key" => ["--ssl-key [KEY]", "X.509 (SSL) Private Key."], + "port" => ["-P", "--port [PORT]", "Port to listen on"], + "baseurl" => ["-b", "--baseurl [URL]", "Base URL"], + "skip_initial_build" => ["skip_initial_build", "--skip-initial-build", + "Skips the initial site build which occurs before the server is started."] + } + + # def init_with_program(prog) - prog.command(:serve) do |c| - c.syntax 'serve [options]' - c.description 'Serve your site locally' - c.alias :server - c.alias :s + prog.command(:serve) do |cmd| + cmd.description "Serve your site locally" + cmd.syntax "serve [options]" + cmd.alias :server + cmd.alias :s - add_build_options(c) + add_build_options(cmd) + COMMAND_OPTIONS.each do |key, val| + cmd.option key, *val + end - c.option 'detach', '-B', '--detach', 'Run the server in the background (detach)' - c.option 'port', '-P', '--port [PORT]', 'Port to listen on' - c.option 'host', '-H', '--host [HOST]', 'Host to bind to' - c.option 'baseurl', '-b', '--baseurl [URL]', 'Base URL' - c.option 'skip_initial_build', '--skip-initial-build', 'Skips the initial site build which occurs before the server is started.' - c.option 'open_url', '-o', '--open-url', 'Opens the local URL in your default browser' - - c.action do |args, options| - options["serving"] = true - options["watch"] = true unless options.key?("watch") - Jekyll::Commands::Build.process(options) - Jekyll::Commands::Serve.process(options) + cmd.action do |_, opts| + opts["serving"] = true + opts["watch" ] = true unless opts.key?("watch") + Build.process(opts) + Serve.process(opts) end end end - # Boot up a WEBrick server which points to the compiled site's root. - def process(options) - options = configuration_from_options(options) - destination = options['destination'] + # + + def process(opts) + opts = configuration_from_options(opts) + destination = opts["destination"] setup(destination) - s = WEBrick::HTTPServer.new(webrick_options(options)) - s.unmount("") + server = WEBrick::HTTPServer.new(webrick_opts(opts)).tap { |o| o.unmount("") } + server.mount(opts["baseurl"], Servlet, destination, file_handler_opts) + Jekyll.logger.info "Server address:", server_address(server, opts) + launch_browser server, opts if opts["open_url"] + boot_or_detach server, opts + end - s.mount( - options['baseurl'], - custom_file_handler, - destination, - file_handler_options - ) + # Do a base pre-setup of WEBRick so that everything is in place + # when we get ready to party, checking for an setting up an error page + # and making sure our destination exists. + private + def setup(destination) + require_relative "serve/servlet" - server_address_str = server_address(s, options) - Jekyll.logger.info "Server address:", server_address_str - - if options["open_url"] - command = Utils::Platforms.windows?? "start" : Utils::Platforms.osx?? \ - "open" : "xdg-open" - - system command, server_address_str + FileUtils.mkdir_p(destination) + if File.exist?(File.join(destination, "404.html")) + WEBrick::HTTPResponse.class_eval do + def create_error_page + @header["Content-Type"] = "text/html; charset=UTF-8" + @body = IO.read(File.join(@config[:DocumentRoot], "404.html")) + end + end end + end + + # + + private + def webrick_opts(opts) + opts = { + :JekyllOptions => opts, + :DoNotReverseLookup => true, + :MimeTypes => mime_types, + :DocumentRoot => opts["destination"], + :StartCallback => start_callback(opts["detach"]), + :BindAddress => opts["host"], + :Port => opts["port"], + :DirectoryIndex => %W( + index.htm + index.html + index.rhtml + index.cgi + index.xml + ) + } + + enable_ssl(opts) + enable_logging(opts) + opts + end + + # Recreate NondisclosureName under utf-8 circumstance + + private + def file_handler_opts + WEBrick::Config::FileHandler.merge({ + :FancyIndexing => true, + :NondisclosureName => [ + '.ht*','~*' + ] + }) + end + + # + + private + def server_address(server, opts) + address = server.config[:BindAddress] + baseurl = "#{opts["baseurl"]}/" if opts["baseurl"] + port = server.config[:Port] + + "http://#{address}:#{port}#{baseurl}" + end + + # + + private + def launch_browser(server, opts) + command = Utils::Platforms.windows?? "start" : Utils::Platforms.osx?? "open" : "xdg-open" + system command, server_address(server, opts) + end + + # Keep in our area with a thread or detach the server as requested + # by the user. This method determines what we do based on what you + # ask us to do. + + private + def boot_or_detach(server, opts) + if opts["detach"] + pid = Process.fork do + server.start + end - if options['detach'] # detach the server - pid = Process.fork { s.start } Process.detach(pid) - Jekyll.logger.info "Server detached with pid '#{pid}'.", "Run `pkill -f jekyll' or `kill -9 #{pid}' to stop the server." - else # create a new server thread, then join it with current terminal - t = Thread.new { s.start } - trap("INT") { s.shutdown } + Jekyll.logger.info "Server detached with pid '#{pid}'.", \ + "Run `pkill -f jekyll' or `kill -9 #{pid}' to stop the server." + else + t = Thread.new { server.start } + trap("INT") { server.shutdown } t.join end end - def setup(destination) - require 'webrick' + # Make the stack verbose if the user requests it. - FileUtils.mkdir_p(destination) - - # monkey patch WEBrick using custom 404 page (/404.html) - if File.exist?(File.join(destination, '404.html')) - WEBrick::HTTPResponse.class_eval do - def create_error_page - @header['content-type'] = "text/html; charset=UTF-8" - @body = IO.read(File.join(@config[:DocumentRoot], '404.html')) - end - end - end + private + def enable_logging(opts) + opts[:AccessLog] = [] + level = WEBrick::Log.const_get(opts[:JekyllOptions]["verbose"] ? :DEBUG : :WARN) + opts[:Logger] = WEBrick::Log.new($stdout, level) end - def webrick_options(config) - opts = { - :BindAddress => config['host'], - :DirectoryIndex => %w(index.html index.htm index.cgi index.rhtml index.xml), - :DocumentRoot => config['destination'], - :DoNotReverseLookup => true, - :MimeTypes => mime_types, - :Port => config['port'], - :StartCallback => start_callback(config['detach']) - } + # Add SSL to the stack if the user triggers --enable-ssl and they + # provide both types of certificates commonly needed. Raise if they + # forget to add one of the certificates. - if config['verbose'] - opts.merge!({ - :Logger => WEBrick::Log.new($stdout, WEBrick::Log::DEBUG) - }) - else - opts.merge!({ - :AccessLog => [], - :Logger => WEBrick::Log.new([], WEBrick::Log::WARN) - }) + private + def enable_ssl(opts) + return if !opts[:JekyllOptions]["ssl_cert"] && !opts[:JekyllOptions]["ssl_key"] + if !opts[:JekyllOptions]["ssl_cert"] || !opts[:JekyllOptions]["ssl_key"] + raise RuntimeError, "--ssl-cert or --ssl-key missing." end - opts - end - - # Custom WEBrick FileHandler servlet for serving "/file.html" at "/file" - # when no exact match is found. This mirrors the behavior of GitHub - # Pages and many static web server configs. - def custom_file_handler - Class.new WEBrick::HTTPServlet::FileHandler do - def search_file(req, res, basename) - if file = super - file - else - super(req, res, "#{basename}.html") - end - end - end + require "openssl"; require "webrick/https" + source_key = Jekyll.sanitized_path(opts[:JekyllOptions]["source"], opts[:JekyllOptions]["ssl_key" ]) + source_certificate = Jekyll.sanitized_path(opts[:JekyllOptions]["source"], opts[:JekyllOptions]["ssl_cert"]) + opts[:SSLCertificate] = OpenSSL::X509::Certificate.new(File.read(source_certificate)) + opts[:SSLPrivateKey ] = OpenSSL::PKey::RSA.new(File.read(source_key)) + opts[:EnableSSL] = true end + private def start_callback(detached) unless detached - Proc.new { Jekyll.logger.info "Server running...", "press ctrl-c to stop." } + proc do + Jekyll.logger.info("Server running...", "press ctrl-c to stop.") + end end end + private def mime_types - mime_types_file = File.expand_path('../mime.types', File.dirname(__FILE__)) - WEBrick::HTTPUtils::load_mime_types(mime_types_file) + file = File.expand_path('../mime.types', File.dirname(__FILE__)) + WEBrick::HTTPUtils.load_mime_types(file) end - - def server_address(server, options) - baseurl = "#{options['baseurl']}/" if options['baseurl'] - [ - "http://", - server.config[:BindAddress], - ":", - server.config[:Port], - baseurl || "" - ].map(&:to_s).join("") - end - - # recreate NondisclosureName under utf-8 circumstance - def file_handler_options - WEBrick::Config::FileHandler.merge({ - :FancyIndexing => true, - :NondisclosureName => ['.ht*','~*'] - }) - end - end - end end end diff --git a/lib/jekyll/commands/serve/servlet.rb b/lib/jekyll/commands/serve/servlet.rb new file mode 100644 index 00000000..4b11ad0d --- /dev/null +++ b/lib/jekyll/commands/serve/servlet.rb @@ -0,0 +1,56 @@ +require "webrick" + +module Jekyll + module Commands + class Serve + class Servlet < WEBrick::HTTPServlet::FileHandler + HEADER_DEFAULTS = {} + + def initialize(server, root, callbacks) + extract_headers(server.config[:JekyllOptions]) + super + end + + # + + def do_GET(req, res) + res.header.merge!(@headers) if @headers.any? + return super + end + + # --------------------------------------------------------------------- + # file > file/index.html > file.html > directory -> Having a directory + # with the same name as a file will result in the file being served the + # way that Nginx behaves (probably not exactly...) For browsing. + # --------------------------------------------------------------------- + + def search_file(req, res, basename) + return file if (file = super) || (file = super req, res, "#{basename}.html") + + file = "#{req.path.gsub(/\/\Z/, "")}.html" + if file && File.file?(File.join(@config[:DocumentRoot], file)) + return ".html" + end + nil + end + + def extract_headers(opts) + @headers = add_defaults(opts.fetch("webrick", {}).fetch("headers", {})) + end + + def add_defaults(opts) + control_development_cache(opts) + HEADER_DEFAULTS.each_with_object(opts) do |(k, v), h| + h[k] = v if !h[k] + end + end + + def control_development_cache(opts) + if !opts.has_key?("Cache-Control") && Jekyll.env == "development" + opts["Cache-Control"] = "private, max-age=0, proxy-revalidate, no-store, no-cache, must-revalidate" + end + end + end + end + end +end diff --git a/site/_docs/configuration.md b/site/_docs/configuration.md index 2bb65e82..f6a71ed2 100644 --- a/site/_docs/configuration.md +++ b/site/_docs/configuration.md @@ -352,6 +352,24 @@ before your site is served.

--skip-initial-build

+ + +

X.509 (SSL) Private Key

+

SSL Private Key.

+ + +

--ssl-key

+ + + + +

X.509 (SSL) Certificate

+

SSL Public certificate.

+ + +

--ssl-cert

+ + @@ -364,6 +382,24 @@ before your site is served.

+## Custom WEBRick Headers + +You can provide custom headers for your site by adding them to `_config.yml` + +{% highlight yaml %} +# File: _config.yml +webrick: + headers: + My-Header: My-Value + My-Other-Header: My-Other-Value +{% endhighlight %} + +### Defaults + +We only provide on default and that's a Content-Type header that disables +caching in development so that you don't have to fight with Chrome's aggressive +caching when you are in development mode. + ## Specifying a Jekyll environment at build time In the build (or serve) arguments, you can specify a Jekyll environment and value. The build will then apply this value in any conditional statements in your content. diff --git a/test/test_commands_serve.rb b/test/test_commands_serve.rb new file mode 100644 index 00000000..87472c3d --- /dev/null +++ b/test/test_commands_serve.rb @@ -0,0 +1,115 @@ +require "webrick" +require "mercenary" +require "helper" + +class TestCommandsServe < JekyllUnitTest + def custom_opts(what) + @cmd.send( + :webrick_opts, what + ) + end + + context "with a program" do + setup do + @merc, @cmd = nil, Jekyll::Commands::Serve + Mercenary.program(:jekyll) do |p| + @merc = @cmd.init_with_program( + p + ) + end + end + + should "label itself" do + assert_equal( + @merc.name, :serve + ) + end + + should "have aliases" do + assert_includes @merc.aliases, :s + assert_includes @merc.aliases, :server + end + + should "have a description" do + refute_nil( + @merc.description + ) + end + + should "have an action" do + refute_empty( + @merc.actions + ) + end + + should "not have an empty options set" do + refute_empty( + @merc.options + ) + end + + context "with custom options" do + should "create a default set of mimetypes" do + refute_nil custom_opts({})[ + :MimeTypes + ] + end + + should "use user destinations" do + assert_equal "foo", custom_opts({ "destination" => "foo" })[ + :DocumentRoot + ] + end + + should "use user port" do + # WHAT?!?!1 Over 9000? That's impossible. + assert_equal 9001, custom_opts( { "port" => 9001 })[ + :Port + ] + end + + context "verbose" do + should "debug when verbose" do + assert_equal custom_opts({ "verbose" => true })[:Logger].level, 5 + end + + should "warn when not verbose" do + assert_equal custom_opts({})[:Logger].level, 3 + end + end + + context "enabling ssl" do + should "raise if enabling without key or cert" do + assert_raises RuntimeError do + custom_opts({ + "ssl_key" => "foo" + }) + end + + assert_raises RuntimeError do + custom_opts({ + "ssl_key" => "foo" + }) + end + end + + should "allow SSL with a key and cert" do + expect(OpenSSL::PKey::RSA).to receive(:new).and_return("c2") + expect(OpenSSL::X509::Certificate).to receive(:new).and_return("c1") + allow(File).to receive(:read).and_return("foo") + + result = custom_opts({ + "ssl_cert" => "foo", + "source" => "bar", + "enable_ssl" => true, + "ssl_key" => "bar" + }) + + assert result[:EnableSSL] + assert_equal result[:SSLPrivateKey ], "c2" + assert_equal result[:SSLCertificate], "c1" + end + end + end + end +end