require 'benchmark' require 'jekyll_plugin_support' require 'securerandom' require_relative 'flexible_include/version' module JekyllFlexibleIncludeName PLUGIN_NAME = 'flexible_include'.freeze end module FlexibleClassMethods def access_allowed(path) return true unless @read_regexes @read_regexes.find { |regex| regex.match(normalize_path(path)) } end def self.escape_html(string) string.gsub("&", "&") .gsub("{", "{") .gsub("}", "}") .gsub("<", "<") end def normalize_path(path) JekyllPluginHelper.expand_env(path, die_if_undefined: true) .gsub('~', Dir.home) end def number_content(content) lines = content.split("\n") digits = lines.length.to_s.length i = 0 numbered_content = lines.map do |line| i += 1 number = i.to_s.rjust(digits, ' ') " #{number}: #{line}" end result = numbered_content.join "\n" result += "\n" unless result.end_with? "\n" result end # If FLEXIBLE_INCLUDE_PATHS='~/lib/.*:.*:$WORK/.*' # Then @read_regexes will be set to regexes of ['/home/my_user_id/lib/.*', '/pwd/.*', '/work/envar/path/.*'] def security_check @execution_denied = ENV.fetch('DISABLE_FLEXIBLE_INCLUDE', nil) return if @read_regexes flexible_include_paths = ENV.fetch('FLEXIBLE_INCLUDE_PATHS', nil) read_paths = normalize_path(flexible_include_paths) if flexible_include_paths return unless read_paths @read_regexes = read_paths.split(':').map do |path| abs_path = path.start_with?('/') ? path : (Pathname.new(Dir.pwd) + path).to_s Regexp.new(abs_path) end end end FlexibleIncludeError = Class.new(Liquid::Error) class FlexibleInclude < JekyllSupport::JekyllTag # rubocop: disable Metrics/ClassLength include JekyllFlexibleIncludePluginVersion def render_impl # rubocop:disable Metrics/AbcSize, Metrics/MethodLength setup path = JekyllPluginHelper.expand_env(@filename) case path when /\A\// # Absolute path return denied("Access to #{path} denied by FLEXIBLE_INCLUDE_PATHS value.") unless self.class.access_allowed(path) @logger.debug { "Absolute path=#{path}, @filename=#{@filename}" } when /\A~/ # Relative path to user's home directory return denied("Access to #{path} denied by FLEXIBLE_INCLUDE_PATHS value.") unless self.class.access_allowed(path) @logger.debug { "User home start @filename=#{@filename}, path=#{path}" } @filename = @filename.delete_prefix '~/' path = File.join(Dir.home, @filename) @logger.debug { "User home end @filename=#{@filename}, path=#{path}" } when /\A!/ # Run command and return response return denied('Arbitrary command execution denied by DISABLE_FLEXIBLE_INCLUDE value.') if @execution_denied @filename = JekyllPluginHelper.remove_quotes(@helper.argv.first) if @helper.argv.first @filename = @filename.delete_prefix '!' contents = run(@filename) else # Relative path source = File.expand_path(@site.config['source']) # website root directory path = File.join(source, @filename) # Fully qualified path of include file from relative path @relative = true @logger.debug { "Relative end @filename=#{@filename}, path=#{path}" } end render_completion(path, contents) rescue Errno::EACCES => e msg = format_error_message e.message @logger.error msg raise FlexibleIncludeError, msg, [] if @die_on_file_error "FlexibleIncludeError: #{msg}" rescue Errno::ENOENT => e msg = format_error_message e.message @logger.error msg raise FlexibleIncludeError, msg, [] if @die_on_path_denied "FlexibleIncludeError: #{msg}" rescue FlexibleIncludeError => e @logger.error e.message raise e rescue StandardError => e msg = format_error_message e.message @logger.error msg raise FlexibleIncludeError, msg, [] if @die_on_other_error "FlexibleIncludeError: #{msg}" end private class << self include FlexibleClassMethods end def denied(msg) msg_no_html = remove_html_tags(msg) @logger.error("#{@page['path']} - #{msg_no_html}") raise FlexibleIncludeError, "#{@page['path']} - #{msg_no_html.red}", [] if @die_on_path_denied "

#{msg}

" end def format_error_message(message) "#{message} on line #{@line_number} (after front matter) of #{@page['path']}}" end def highlight(content, pattern) content.gsub(Regexp.new(pattern), "\\0") end def parse_args @copy_button = @helper.parameter_specified? 'copyButton' @dark = ' dark' if @helper.parameter_specified? 'dark' @do_not_escape = @helper.parameter_specified? 'do_not_escape' @download = @helper.parameter_specified? 'download' @highlight_pattern = @helper.parameter_specified? 'highlight' @label = @helper.parameter_specified? 'label' @label_specified = @label @number_lines = @helper.parameter_specified? 'number' @strip = @helper.parameter_specified? 'strip' # Download, dark, label or number implies pre @pre = @helper.parameter_specified?('pre') || @copy_button || @dark || @download || @label_specified || @number_lines @filename = @helper.parameter_specified? 'file' @filename ||= @helper.params.first # Do this after all options have been checked for @label ||= @filename # If a label was specified, use it, otherwise concatenate any dangling parameters and use that as the label @label ||= @helper.params[1..].join(' ') @logger.debug("@filename=#{@filename}") end # Not used, delete def realpath_prefixed_with?(path, dir) File.exist?(path) && File.realpath(path).start_with?(dir) rescue StandardError => _e raise FlexibleIncludeError, remove_html_tags(e.message).red, [] if @die_on_file_error end def remove_html_tags(string) string.gsub(/<[^>]*>/, '') end def render_completion(path, contents) contents ||= File.read(path) contents.strip! if @strip contents2 = @do_not_escape ? contents : FlexibleClassMethods.escape_html(contents) contents2 = highlight(contents2, @highlight_pattern) if @highlight_pattern contents2 = FlexibleInclude.number_content(contents2) if @number_lines @pre ? wrap_in_pre(path, contents2) : contents2 end def run(cmd) if cmd.empty? msg = format_error_message 'FlexibleIncludeError: Empty command string' @do_not_escape = true return "#{msg}" unless @die_on_other_error raise FlexibleIncludeError, msg, [] end @logger.debug { "Executing #{cmd}" } %x[#{cmd}].chomp rescue FlexibleIncludeError => e raise e rescue StandardError => e msg = format_error_message "#{e.class}: #{e.message.strip}" @logger.error msg @do_not_escape = true return "#{msg}" unless @die_on_run_error e.set_backtrace [] raise e end def setup self.class.security_check config = @config[JekyllFlexibleIncludeName::PLUGIN_NAME] if config @die_on_file_error = config['die_on_file_error'] == true @die_on_other_error = config['die_on_other_error'] == true @die_on_path_denied = config['die_on_path_denied'] == true @die_on_run_error = config['die_on_run_error'] == true end parse_args end PREFIX = "".freeze def wrap_in_pre(path, content) basename = File.basename(path) label_or_href = if @download label = @label_specified ? @label : basename <<~END_HREF #{label} END_HREF else @label_specified ? @label : basename end pre_id = "id#{SecureRandom.hex 6}" copy_button = @copy_button ? "#{PREFIX}'##{pre_id}'#{SUFFIX}" : '' dark_label = ' darkLabel' if @dark <<~END_PRE
#{label_or_href}
#{copy_button}#{content}
END_PRE end JekyllPluginHelper.register(self, 'flexible_include') end