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:
parent
d10dc01290
commit
8efbdc01ff
|
@ -1,161 +1,196 @@
|
||||||
# -*- encoding: utf-8 -*-
|
|
||||||
module Jekyll
|
module Jekyll
|
||||||
module Commands
|
module Commands
|
||||||
class Serve < Command
|
class Serve < Command
|
||||||
|
|
||||||
class << self
|
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)
|
def init_with_program(prog)
|
||||||
prog.command(:serve) do |c|
|
prog.command(:serve) do |cmd|
|
||||||
c.syntax 'serve [options]'
|
cmd.description "Serve your site locally"
|
||||||
c.description 'Serve your site locally'
|
cmd.syntax "serve [options]"
|
||||||
c.alias :server
|
cmd.alias :server
|
||||||
c.alias :s
|
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)'
|
cmd.action do |_, opts|
|
||||||
c.option 'port', '-P', '--port [PORT]', 'Port to listen on'
|
opts["serving"] = true
|
||||||
c.option 'host', '-H', '--host [HOST]', 'Host to bind to'
|
opts["watch" ] = true unless opts.key?("watch")
|
||||||
c.option 'baseurl', '-b', '--baseurl [URL]', 'Base URL'
|
Build.process(opts)
|
||||||
c.option 'skip_initial_build', '--skip-initial-build', 'Skips the initial site build which occurs before the server is started.'
|
Serve.process(opts)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Boot up a WEBrick server which points to the compiled site's root.
|
#
|
||||||
def process(options)
|
|
||||||
options = configuration_from_options(options)
|
def process(opts)
|
||||||
destination = options['destination']
|
opts = configuration_from_options(opts)
|
||||||
|
destination = opts["destination"]
|
||||||
setup(destination)
|
setup(destination)
|
||||||
|
|
||||||
s = WEBrick::HTTPServer.new(webrick_options(options))
|
server = WEBrick::HTTPServer.new(webrick_opts(opts)).tap { |o| o.unmount("") }
|
||||||
s.unmount("")
|
server.mount(opts["baseurl"], Servlet, destination, file_handler_opts)
|
||||||
|
Jekyll.logger.info "Server address:", server_address(server, opts)
|
||||||
s.mount(
|
launch_browser server, opts if opts["open_url"]
|
||||||
options['baseurl'],
|
boot_or_detach server, opts
|
||||||
custom_file_handler,
|
end
|
||||||
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.
|
||||||
|
|
||||||
server_address_str = server_address(s, options)
|
private
|
||||||
Jekyll.logger.info "Server address:", server_address_str
|
def setup(destination)
|
||||||
|
require_relative "serve/servlet"
|
||||||
if options["open_url"]
|
|
||||||
command = Utils::Platforms.windows?? "start" : Utils::Platforms.osx?? \
|
FileUtils.mkdir_p(destination)
|
||||||
"open" : "xdg-open"
|
if File.exist?(File.join(destination, "404.html"))
|
||||||
|
WEBrick::HTTPResponse.class_eval do
|
||||||
system command, server_address_str
|
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
|
end
|
||||||
|
|
||||||
if options['detach'] # detach the server
|
|
||||||
pid = Process.fork { s.start }
|
|
||||||
Process.detach(pid)
|
Process.detach(pid)
|
||||||
Jekyll.logger.info "Server detached with pid '#{pid}'.", "Run `pkill -f jekyll' or `kill -9 #{pid}' to stop the server."
|
Jekyll.logger.info "Server detached with pid '#{pid}'.", \
|
||||||
else # create a new server thread, then join it with current terminal
|
"Run `pkill -f jekyll' or `kill -9 #{pid}' to stop the server."
|
||||||
t = Thread.new { s.start }
|
else
|
||||||
trap("INT") { s.shutdown }
|
t = Thread.new { server.start }
|
||||||
|
trap("INT") { server.shutdown }
|
||||||
t.join
|
t.join
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def setup(destination)
|
# Make the stack verbose if the user requests it.
|
||||||
require 'webrick'
|
|
||||||
|
|
||||||
FileUtils.mkdir_p(destination)
|
private
|
||||||
|
def enable_logging(opts)
|
||||||
# monkey patch WEBrick using custom 404 page (/404.html)
|
opts[:AccessLog] = []
|
||||||
if File.exist?(File.join(destination, '404.html'))
|
level = WEBrick::Log.const_get(opts[:JekyllOptions]["verbose"] ? :DEBUG : :WARN)
|
||||||
WEBrick::HTTPResponse.class_eval do
|
opts[:Logger] = WEBrick::Log.new($stdout, level)
|
||||||
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
|
end
|
||||||
|
|
||||||
def webrick_options(config)
|
# Add SSL to the stack if the user triggers --enable-ssl and they
|
||||||
opts = {
|
# provide both types of certificates commonly needed. Raise if they
|
||||||
:BindAddress => config['host'],
|
# forget to add one of the certificates.
|
||||||
: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'])
|
|
||||||
}
|
|
||||||
|
|
||||||
if config['verbose']
|
private
|
||||||
opts.merge!({
|
def enable_ssl(opts)
|
||||||
:Logger => WEBrick::Log.new($stdout, WEBrick::Log::DEBUG)
|
return if !opts[:JekyllOptions]["ssl_cert"] && !opts[:JekyllOptions]["ssl_key"]
|
||||||
})
|
if !opts[:JekyllOptions]["ssl_cert"] || !opts[:JekyllOptions]["ssl_key"]
|
||||||
else
|
raise RuntimeError, "--ssl-cert or --ssl-key missing."
|
||||||
opts.merge!({
|
|
||||||
:AccessLog => [],
|
|
||||||
:Logger => WEBrick::Log.new([], WEBrick::Log::WARN)
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
opts
|
require "openssl"; require "webrick/https"
|
||||||
end
|
source_key = Jekyll.sanitized_path(opts[:JekyllOptions]["source"], opts[:JekyllOptions]["ssl_key" ])
|
||||||
|
source_certificate = Jekyll.sanitized_path(opts[:JekyllOptions]["source"], opts[:JekyllOptions]["ssl_cert"])
|
||||||
# Custom WEBrick FileHandler servlet for serving "/file.html" at "/file"
|
opts[:SSLCertificate] = OpenSSL::X509::Certificate.new(File.read(source_certificate))
|
||||||
# when no exact match is found. This mirrors the behavior of GitHub
|
opts[:SSLPrivateKey ] = OpenSSL::PKey::RSA.new(File.read(source_key))
|
||||||
# Pages and many static web server configs.
|
opts[:EnableSSL] = true
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
def start_callback(detached)
|
def start_callback(detached)
|
||||||
unless 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
def mime_types
|
def mime_types
|
||||||
mime_types_file = File.expand_path('../mime.types', File.dirname(__FILE__))
|
file = File.expand_path('../mime.types', File.dirname(__FILE__))
|
||||||
WEBrick::HTTPUtils::load_mime_types(mime_types_file)
|
WEBrick::HTTPUtils.load_mime_types(file)
|
||||||
end
|
end
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -352,6 +352,24 @@ before your site is served.
|
||||||
<p><code class="flag">--skip-initial-build</code></p>
|
<p><code class="flag">--skip-initial-build</code></p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -364,6 +382,24 @@ before your site is served.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
## 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.
|
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.
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue