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.
This commit is contained in:
Jordon Bedwell 2015-06-16 20:15:18 -05:00
parent d10dc01290
commit 8efbdc01ff
4 changed files with 360 additions and 118 deletions

View File

@ -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

View File

@ -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

View File

@ -352,6 +352,24 @@ before your site is served.
<p><code class="flag">--skip-initial-build</code></p>
</td>
</tr>
<tr class="setting">
<td>
<p class="name"><strong>X.509 (SSL) Private Key</strong></p>
<p class="description">SSL Private Key.</p>
</td>
<td class="align-center">
<p><code class="flag">--ssl-key</code></p>
</td>
</tr>
<tr class="setting">
<td>
<p class="name"><strong>X.509 (SSL) Certificate</strong></p>
<p class="description">SSL Public certificate.</p>
</td>
<td class="align-center">
<p><code class="flag">--ssl-cert</code></p>
</td>
</tr>
</tbody>
</table>
</div>
@ -364,6 +382,24 @@ before your site is served.
</p>
</div>
## 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.

115
test/test_commands_serve.rb Normal file
View File

@ -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