Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added GitHub Enterprise and Private GitHub support #83

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
61 changes: 41 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,61 @@ Jekyll plugin for building Jekyll sites with any public GitHub-hosted theme

1. Add the following to your Gemfile

```ruby
gem "jekyll-remote-theme"
```
```ruby
gem "jekyll-remote-theme"
```

and run `bundle install` to install the plugin
and run `bundle install` to install the plugin

2. Add the following to your site's `_config.yml` to activate the plugin

```yml
plugins:
- jekyll-remote-theme
```
Note: If you are using a Jekyll version less than 3.5.0, use the `gems` key instead of `plugins`.
```yml
plugins:
- jekyll-remote-theme
```
Note: If you are using a Jekyll version less than 3.5.0, use the `gems` key instead of `plugins`.

3. Add the following to your site's `_config.yml` to choose your theme

```yml
remote_theme: benbalter/retlab
```
or <sup>1</sup>
```yml
remote_theme: http[s]://github.<Enterprise>.com/benbalter/retlab
```
<sup>1</sup> The codeload subdomain needs to be available on your github enterprise instance for this to work.
```yml
remote_theme: benbalter/retlab
```
or
```yml
remote_theme: https://github.<Enterprise>.com/benbalter/retlab
```
or
```yml
repository: https://github.<Enterprise>.com
remote_theme: benbalter/retlab
```


## Declaring your theme

Remote themes are specified by the `remote_theme` key in the site's config.
Remote themes are specified by the `remote_theme` key in the site's config. By default, this will use `https://github.com` as the host. If you would like to specify your own host, either specify the host part of the `remote_theme` (e.g., `https://github.com/benbalter/retlab`) or by specifying `repository` (e.g., `https://github.com`).

For public GitHub, remote themes must be in the form of `OWNER/REPOSITORY`, and must represent a public GitHub-hosted Jekyll theme. See [the Jekyll documentation](https://jekyllrb.com/docs/themes/) for more information on authoring a theme. Note that you do not need to upload the gem to RubyGems or include a `.gemspec` file.
For GitHub, remote themes must be in the form of `OWNER/REPOSITORY`, and must represent a Jekyll theme. See [the Jekyll documentation](https://jekyllrb.com/docs/themes/) for more information on authoring a theme. Note that you do not need to upload the gem to RubyGems or include a `.gemspec` file.

You may also optionally specify a branch, tag, or commit to use by appending an `@` and the Git ref (e.g., `benbalter/[email protected]` or `benbalter/retlab@develop`). If you don't specify a Git ref, the `master` branch will be used.

For Enterprise GitHub, remote themes must be in the form of `http[s]://GITHUBHOST.com/OWNER/REPOSITORY`, and must represent a public (non-private repository) GitHub-hosted Jekyll theme. Other than requiring the fully qualified domain name of the enterprise GitHub instance, this works exactly the same as the public usage.
To use your own host, such as for Enterprise GitHub, you can specify `remote_theme` providing the full url (e.g., `https://GITHUBHOST.com/OWNER/REPOSITORY`), and must represent GitHub-hosted Jekyll theme. Alternatively, you can specify `repository`. This works exactly the same as the GitHub usage.

## Private and Internal Themes

If you would like to use a private or internal hosted theme (or have other specific headers needs):

1. Create a Personal Access Token following the [Creating a personal access token on GitHub guides](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token)

2. Add the `remote_header` in your `_config.yml`

```yml
remote_header:
Authorization: token <personal autorization token>
```
Where the personal autorization token is the token provided by the github repo setting page.

> :warning: **Storing credentials can lead to security issues** - As stated in the [Creating a personal access token on GitHub guides](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) Treat your tokens like passwords and keep them secret. When working with the API, use tokens as environment variables instead of hardcoding them into your programs.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this is explicitly called out in the documentation, I'd be hesitant to create a workflow that requires committing the secret to the repository. Could we use an environmental variable as suggested?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fully agree that specifying an environment variable here would prevent "hardcoding" any credentials which can be a security hazard. Unfortunately, unless some changes are done on the server side to allow the GitHub Pages to specify credentials for a privately hosted themes, this was the easiest way to solve the issue.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So does this mean that there is no way for GitHub Secrets to be accessed in the GitHub Pages environment?


## Debugging

Expand Down
5 changes: 4 additions & 1 deletion lib/jekyll-remote-theme.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ class DownloadError < StandardError; end
autoload :Theme, "jekyll-remote-theme/theme"
autoload :VERSION, "jekyll-remote-theme/version"

CONFIG_KEY = "remote_theme"
CONFIG_REPOSITORY_KEY = "repository"
CONFIG_THEME_KEY = "remote_theme"
CONFIG_HEADERS_KEY = "remote_headers"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would a user need to be able to specify custom headers?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To allow downloading a private hosted theme and additional headers required in a large organization.


LOG_KEY = "Remote Theme:"
TEMP_PREFIX = "jekyll-remote-theme-"

Expand Down
75 changes: 55 additions & 20 deletions lib/jekyll-remote-theme/downloader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ class Downloader
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError,
].freeze

def initialize(theme)
def initialize(theme, remote_headers)
@theme = theme
@remote_headers = remote_headers
end

def run
Expand All @@ -29,6 +30,16 @@ def downloaded?
@downloaded ||= theme_dir_exists? && !theme_dir_empty?
end

def remote_headers
default_headers = {
"User-Agent" => USER_AGENT,
"Accept" => "application/vnd.github.v3+json",
}

@remote_headers ||= default_headers
@remote_headers.merge(default_headers)
end

private

attr_reader :theme
Expand All @@ -37,31 +48,55 @@ def zip_file
@zip_file ||= Tempfile.new([TEMP_PREFIX, ".zip"], :binmode => true)
end

def download
Jekyll.logger.debug LOG_KEY, "Downloading #{zip_url} to #{zip_file.path}"
Net::HTTP.start(zip_url.host, zip_url.port, :use_ssl => true) do |http|
http.request(request) do |response|
raise_unless_sucess(response)
enforce_max_file_size(response.content_length)
response.read_body do |chunk|
zip_file.write chunk
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reorganizing code for better readability and usability

def create_request(url)
req = Net::HTTP::Get.new url.path

remote_headers&.each do |key, value|
req[key] = value unless value.nil?
end

req
end
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a request including the remote_headers configuration provided to the downloader


def handle_response(response)
raise_unless_success(response)
enforce_max_file_size(response.content_length)
response.read_body do |chunk|
zip_file.write chunk
end
end
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the archive file from the server as per the download code above


def fetch(uri_str, limit = 10)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you describe the change here? I'm having trouble following.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It takes the remote_headers from the configuration file and ensures that the custom headers are passed to the request of the download of the remote_theme. I had to reorganize the download function code for readability and usability.

More details are provided with additional comments of the relevant code.

Jekyll.logger.debug LOG_KEY, "Finding redirect of #{uri_str}"

raise DownloadError, "Too many redirect" if limit.zero?

url = URI.parse(uri_str)
req = create_request(url)

Net::HTTP.start(url.host, url.port, :use_ssl => true) do |http|
http.request(req) do |response|
case response
when Net::HTTPSuccess
handle_response(response)
when Net::HTTPRedirection
fetch(response["location"], limit - 1)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our organization is using a custom DNS, and the REST Api are creating some redirections. This new download code supports HTTP Redirections to a maximum of 10 hops.

else
raise DownloadError, "#{response.code} - #{response.message}"
end
end
end
end

def download
Jekyll.logger.debug LOG_KEY, "Downloading #{zip_url} to #{zip_file.path}"
fetch(zip_url)
@downloaded = true
rescue *NET_HTTP_ERRORS => e
raise DownloadError, e.message
end

def request
return @request if defined? @request

@request = Net::HTTP::Get.new zip_url.request_uri
@request["User-Agent"] = USER_AGENT
@request
end

def raise_unless_sucess(response)
def raise_unless_success(response)
return if response.is_a?(Net::HTTPSuccess)

raise DownloadError, "#{response.code} - #{response.message}"
Expand Down Expand Up @@ -91,8 +126,8 @@ def unzip
def zip_url
@zip_url ||= Addressable::URI.new(
:scheme => theme.scheme,
:host => "codeload.#{theme.host}",
:path => [theme.owner, theme.name, "zip", theme.git_ref].join("/")
:host => theme.host,
:path => [theme.owner, theme.name, "archive", theme.git_ref + ".zip"].join("/")
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the GitHub API documentation, host "api.github.com" and rest endpoint "zipball" is the proper endpoint for getting the repository archive zip file. As per the documentation, this requires support for redirections.

Using "archive" should extend the the support to other git private repository such as gitlab and still work for github repos.

).normalize
end

Expand Down
30 changes: 22 additions & 8 deletions lib/jekyll-remote-theme/munger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ def initialize(site)
end

def munge!
return unless raw_theme
return unless remote_theme

unless theme.valid?
Jekyll.logger.error LOG_KEY, "#{raw_theme.inspect} is not a valid remote theme"
Jekyll.logger.error LOG_KEY, "#{theme} is not a valid remote theme"
return
end

Jekyll.logger.info LOG_KEY, "Using theme #{theme.name_with_owner}"
Jekyll.logger.info LOG_KEY, "Using theme #{theme}"
unless munged?
downloader.run
configure_theme
Expand All @@ -36,21 +36,35 @@ def munged?
end

def theme
@theme ||= Theme.new(raw_theme)
@theme ||= Theme.new(repository, remote_theme)
end

def raw_theme
config[CONFIG_KEY]
def remote_theme
config[CONFIG_THEME_KEY]
end

def remote_header
config[CONFIG_HEADERS_KEY]
end

def repository
config[CONFIG_REPOSITORY_KEY]
end

def downloader
@downloader ||= Downloader.new(theme)
@downloader ||= Downloader.new(theme, remote_header)
end

def setup_site_config
site.config["theme"] = theme.name
site.config["repository"] = "#{theme.scheme}://#{theme.host}"
end

def configure_theme
return unless theme

site.config["theme"] = theme.name
setup_site_config

site.theme = theme
site.theme.configure_sass if site.theme.respond_to?(:configure_sass)
site.send(:configure_include_paths)
Expand Down
83 changes: 56 additions & 27 deletions lib/jekyll-remote-theme/theme.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,35 @@
module Jekyll
module RemoteTheme
class Theme < Jekyll::Theme
OWNER_REGEX = %r!(?<owner>[a-z0-9\-]+)!i.freeze
NAME_REGEX = %r!(?<name>[a-z0-9\._\-]+)!i.freeze
REF_REGEX = %r!@(?<ref>[a-z0-9\._\-]+)!i.freeze # May be a branch, tag, or commit
THEME_REGEX = %r!\A#{OWNER_REGEX}/#{NAME_REGEX}(?:#{REF_REGEX})?\z!i.freeze
DEFAULT_SCHEME = "https"
DEFAULT_HOST = "github.com"
ALPHANUMERIC_WITH_DASH_REGEX = %r![\w\-]+!i.freeze
ALPHANUMERIC_WITH_DASH_DOT_REGEX = %r![\w\-\.]+!i.freeze
OWNER_REGEX = %r!(?<owner>#{ALPHANUMERIC_WITH_DASH_REGEX})!i.freeze
NAME_REGEX = %r!(?<name>#{ALPHANUMERIC_WITH_DASH_DOT_REGEX})!i.freeze
REF_REGEX = %r!@(?<ref>#{ALPHANUMERIC_WITH_DASH_DOT_REGEX})!i.freeze
THEME_REGEX = %r!\A/?#{OWNER_REGEX}/#{NAME_REGEX}(?:#{REF_REGEX})?\z!i.freeze

# Initializes a new Jekyll::RemoteTheme::Theme
#
# raw_theme can be in the form of:
# here are the valid combinations for repository/remote_theme
#
# 1. owner/theme-name - a GitHub owner + theme-name string
# 2. owner/theme-name@git_ref - a GitHub owner + theme-name + Git ref string
# 3. http[s]://github.<yourEnterprise>.com/owner/theme-name
# - An enterprise GitHub instance + a GitHub owner + a theme-name string
# 4. http[s]://github.<yourEnterprise>.com/owner/theme-name@git_ref
# - An enterprise GitHub instance + a GitHub owner + a theme-name + Git ref string
def initialize(raw_theme)
@raw_theme = raw_theme.to_s.downcase.strip
super(@raw_theme)
# [scheme://host/]owner/theme-name[@git_ref]
#
# optional scheme://host
# scheme (default: https): Could be any scheme but generally should be http, https or git
# host (default: github.com): Could be any host
#
# owner: Git repo owner
# theme-name: Git repo name
# optional @git_ref (default: master): Git Reference hash, tag or branch
#
# Header to pass to remote call
#
def initialize(repository, remote_theme)
@repository = repository
@remote_theme = remote_theme.to_s.downcase
super(@remote_theme)
end

def name
Expand Down Expand Up @@ -63,26 +74,45 @@ def inspect
" ref=\"#{git_ref}\" root=\"#{root}\">"
end

def to_s
uri.to_s
end

private

def default_host
ENV["GITHUB_HOSTNAME"] || ENV["PAGES_GITHUB_HOSTNAME"] || DEFAULT_HOST
end

def uri
return @uri if defined? @uri

@uri = if @raw_theme =~ THEME_REGEX
Addressable::URI.new(
:scheme => "https",
:host => "github.com",
:path => @raw_theme
)
else
Addressable::URI.parse @raw_theme
end
remote_theme_parsed = Addressable::URI.parse(@remote_theme)
@uri = if @repository
# Use the remote host as the uri and the remote theme as the path
repository_parsed = Addressable::URI.parse(@repository)
Addressable::URI.new(
:scheme => repository_parsed.scheme,
:host => repository_parsed.host,
:path => remote_theme_parsed.path
)
elsif remote_theme_parsed.scheme && remote_theme_parsed.host
# Use the remote theme as the uri
remote_theme_parsed
else
# Otherwise, make some assumptions, using remote theme as the path
Addressable::URI.new(
:scheme => DEFAULT_SCHEME,
:host => default_host,
:path => remote_theme_parsed.path
)
end
rescue Addressable::URI::InvalidURIError
@uri = nil
end

def theme_parts
@theme_parts ||= uri.path[1..-1].match(THEME_REGEX) if uri
@theme_parts ||= uri.path.match(THEME_REGEX) if uri
end

def gemspec
Expand All @@ -91,9 +121,8 @@ def gemspec

def valid_hosts
@valid_hosts ||= [
"github.com",
ENV["PAGES_GITHUB_HOSTNAME"],
ENV["GITHUB_HOSTNAME"],
host,
default_host,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As currently written, this is an anti-abuse mechanism, which it looks like this change removes. In an untrusted environment, a malicious actor could request a remote theme from a malicious Git server, leading to a denial of service or other attack. How would we limit hosts in untrusted environments?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem for us is that since we are using custom DNS, privately hosted repos, the themes are not hosted on any of theses endpoints.

].compact.to_set
end
end
Expand Down
9 changes: 9 additions & 0 deletions spec/fixtures/site/_config_remote_as_path.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
plugins:
- jekyll-remote-theme

remote_theme: pages-themes/PrImeR

whitelist:
- jekyll-remote-theme
- jekyll-seo-tag
- jekyll-github-metadata
Loading