# encoding: utf-8

#  Phusion Passenger - https://www.phusionpassenger.com/
#  Copyright (c) 2014-2025 Asynchronous B.V.
#
#  "Passenger", "Phusion Passenger" and "Union Station" are registered
#  trademarks of Asynchronous B.V.
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in
#  all copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
#  THE SOFTWARE.

require 'optparse'
PhusionPassenger.require_passenger_lib 'constants'
PhusionPassenger.require_passenger_lib 'config/command'

module PhusionPassenger
  module Config

    class ValidateInstallCommand < Command
      # Signifies that there is at least 1 error.
      FAIL_EXIT_CODE = 1
      # Signifies that there are no error, but at least 1 warning.
      WARN_EXIT_CODE = 2
      # Internal error occurred.
      INTERNAL_ERROR_CODE = 9

      def run
        @orig_argv = @argv.dup
        parse_options
        prepare
        begin
          if !@options[:auto] && !@options[:invoked_from_installer]
            ask_what_to_validate
          end
          if @options[:validate_apache2]
            initialize_apache_envvars
            if !@options[:auto] && !@options[:invoked_from_installer]
              check_whether_there_are_multiple_apache_installs
            end
          end

          if @options[:validate_passenger]
            check_tools_in_path
            check_no_other_installs_in_path
          end
          if @options[:validate_apache2]
            if check_apache2_installed
              check_apache2_load_module_config
            end
          end

          if @options[:summary]
            summarize
          end
          exit(FAIL_EXIT_CODE) if @error_count > 0
          exit(WARN_EXIT_CODE) if @warning_count > 0
        ensure
          reset_terminal
        end
      end

    private
      def self.create_default_options
        {
          auto: !STDIN.tty?,
          validate_passenger: true,
          colors: STDOUT.tty?,
          summary: true,
        }
      end

      def self.create_option_parser(options)
        OptionParser.new do |opts|
          nl = "\n" + ' ' * 37
          opts.banner = "Usage: passenger-config validate-install [OPTIONS]\n"
          opts.separator ""
          opts.separator "  Validate this #{SHORT_PROGRAM_NAME} installation and/or its integration with web servers."
          opts.separator "  If you run this command in a terminal, it will run interactively and ask you questions."
          opts.separator "  You can run this command non-interactively either by running it without a terminal, "
          opts.separator "  or by passing --auto."
          opts.separator ""
          opts.separator "  When running non-interactively, the default is to validate the #{SHORT_PROGRAM_NAME}"
          opts.separator "  installation only (so e.g. Apache is not validated). You can customize this"
          opts.separator "  using the appropriate command line options."
          opts.separator ""
          opts.separator "  The exit codes are as follows:"
          opts.separator "    0 - All checks passed. No errors, no warnings."
          opts.separator "    #{FAIL_EXIT_CODE} - Some checks failed with an error."
          opts.separator "    #{WARN_EXIT_CODE} - No checks failed with an error, but some produced warnings."
          opts.separator "    #{INTERNAL_ERROR_CODE} - Some internal error occurred."
          opts.separator ""

          opts.separator "Options:"
          opts.on("--auto", "Run non-interactively") do
            options[:auto] = true
          end
          opts.on("--no-validate-passenger", "Do not validate the #{SHORT_PROGRAM_NAME} installation#{nl}" +
            "itself") do
            options[:validate_passenger] = false
          end
          opts.on("--validate-apache2", "Validate Apache 2 integration") do
            options[:validate_apache2] = true
          end
          opts.on("--apxs2-path=PATH", String, "Apache installation to validate") do |val|
            ENV['APXS2'] = val
          end
          opts.separator ""
          opts.on("--no-colors", "Never output colors") do
            options[:colors] = false
          end
          opts.on("--no-summary", "Do not display a summary") do
            options[:summary] = false
          end
          opts.separator ""
          opts.on("-h", "--help", "Show this help") do
            options[:help] = true
          end

          opts.separator ""
          opts.separator "Internal options:"
          opts.on("--invoked-from-installer", "Indicate that this program is invoked from#{nl}" +
            "passenger-install-apache2-module") do
            options[:invoked_from_installer] = true
          end
        end
      end

      def prepare
        begin
          require 'rubygems'
        rescue LoadError
        end
        PhusionPassenger.require_passenger_lib 'utils/ansi_colors'
        PhusionPassenger.require_passenger_lib 'utils/terminal_choice_menu'
        PhusionPassenger.require_passenger_lib 'platform_info'
        PhusionPassenger.require_passenger_lib 'platform_info/ruby'
        PhusionPassenger.require_passenger_lib 'platform_info/apache'
        PhusionPassenger.require_passenger_lib 'platform_info/apache_detector'
        PhusionPassenger.require_passenger_lib 'platform_info/depcheck'
        require 'stringio'
        require 'pathname'

        @error_count = 0
        @warning_count = 0

        @colors = Utils::AnsiColors.new(@options[:colors])

        prepare_terminal
      end

      def prepare_terminal
        STDOUT.write(@colors.default_terminal_color)
        STDOUT.flush
      end

      def reset_terminal
        STDOUT.write(@colors.reset)
        STDOUT.flush
      end

      def ask_what_to_validate
        log "<banner>What would you like to validate?</banner>"
        log "Use <space> to select."
        log "<dgray>If the menu doesn't display correctly, press '!'</dgray>"
        puts

        menu = Utils::TerminalChoiceMenu.new([
          "#{SHORT_PROGRAM_NAME} itself",
          "Apache",
        ])
        menu["#{SHORT_PROGRAM_NAME} itself"].checked = @options[:validate_passenger]
        menu["Apache"].checked = @options[:validate_apache2]

        begin
          menu.query
        rescue Interrupt
          exit(INTERNAL_ERROR_CODE)
        end
        display_separator

        @options[:validate_passenger] = menu.selected_choices.include?("#{SHORT_PROGRAM_NAME} itself")
        @options[:validate_apache2] = menu.selected_choices.include?("Apache")
      end

      def initialize_apache_envvars
        # The Apache executable may be located in an 'sbin' folder. We add
        # the 'sbin' folders to $PATH just in case. On some systems
        # 'sbin' isn't in $PATH unless the user is logged in as root from
        # the start (i.e. not via 'su' or 'sudo').
        ENV["PATH"] += ":/usr/sbin:/sbin:/usr/local/sbin"
      end

      def check_tools_in_path
        checking "whether this #{SHORT_PROGRAM_NAME} install is in PATH"
        paths = ENV['PATH'].to_s.split(':')
        if paths.include?(gem_bindir) ||
           paths.include?(homebrew_bindir) ||
           paths.include?(PhusionPassenger.bin_dir)
          check_ok
        else
          check_warning
          suggest %Q(
            Please add #{PhusionPassenger.bin_dir} to PATH.
            Otherwise you will get "command not found" errors upon running
            any Passenger commands.

            Learn more at about PATH at:

              https://www.phusionpassenger.com/library/indepth/environment_variables.html#the-path-environment-variable
          )
        end
      end

      def check_no_other_installs_in_path
        checking "whether there are no other #{SHORT_PROGRAM_NAME} installations"

        paths = ENV['PATH'].to_s.split(':')
        if Process.uid == 0 &&
           (sudo_user = ENV['SUDO_USER']) &&
           (bash = PlatformInfo.find_command("bash")) &&
           PlatformInfo.find_command("sudo")
          # If we were invoked through sudo then we need to check the original user's PATH too.
          output = `sudo -u #{sudo_user} #{bash} -lc 'echo; echo PATH FOLLOWS; echo "$PATH"' 2>&1`
          output.sub!(/.*\nPATH FOLLOWS\n/m, '')
          output.strip!
          paths.concat(output.split(':'))
        end

        # These may not be in PATH if the user did not run this command through sudo.
        paths << "/usr/bin"
        paths << "/usr/sbin"
        # Some of the paths may be symlinks, so we take the realpaths when
        # possible and remove duplicates. This is especially important on
        # Red Hat 7, where /bin is a symlink to /usr/bin.
        paths.map! do |path|
          try_realpath(path)
        end

        paths.delete(try_realpath(gem_bindir))
        paths.delete(try_realpath(homebrew_bindir))
        paths.delete(try_realpath(rbenv_shims_dir))
        paths.delete(try_realpath(PhusionPassenger.bin_dir))
        paths.uniq!

        other_installs = []
        paths.each do |path|
          filename = "#{path}/passenger"
          if File.exist?(filename)
            other_installs << filename
          end
        end
        if other_installs.empty?
          check_ok
        else
          check_warning
          suggest %Q(
            You are currently validating against #{PROGRAM_NAME} #{VERSION_STRING}, located in:

              #{PhusionPassenger.bin_dir}/passenger

            Besides this #{SHORT_PROGRAM_NAME} installation, the following other
            #{SHORT_PROGRAM_NAME} installations have also been detected:

              #{other_installs.join("\n              ")}

            Please uninstall these other #{SHORT_PROGRAM_NAME} installations to avoid
            confusion or conflicts.
          )
        end
      end

      def check_whether_there_are_multiple_apache_installs
        if PlatformInfo.httpd.nil? || PlatformInfo.apxs2.nil?
          # check_apache2_installed will handle this.
          return
        end

        log '<banner>Checking whether there are multiple Apache installations...</banner>'

        output = StringIO.new
        detector = PlatformInfo::ApacheDetector.new(output)
        begin
          detector.detect_all
          detector.report
          apache2 = detector.result_for(PlatformInfo.apxs2)

          if apache2.nil?
            # Print an extra newline because the autodetection routines
            # may have run some commands which printed stuff to stderr.
            puts

            if Process.uid == 0
              # More information will be displayed in #check_no_duplicate_apache2_load_module_config,
              # which should also fail.
              log "<red>Your Apache installation appears to be broken. More information will be displayed later.</red>"
            else
              whoami = `whoami`.strip
              sudo = PhusionPassenger::PlatformInfo.ruby_sudo_command
              selfcommand = "#{PhusionPassenger.bin_dir}/passenger-config validate-install #{@orig_argv.join(' ')}"

              log "<red>Permission problems</red>"
              log "This program must be able to analyze your Apache installation. But it can't"
              log "do that, because you're running the installer as <b>#{whoami}</b>."
              log "Please give this program root privileges, by re-running it with <yellow>#{sudo}</yellow>:"
              log ""
              log "  <b>export ORIG_PATH=\"$PATH\"</b>"
              log "  <b>#{sudo_s_e}</b>"
              log "  <b>export PATH=\"$ORIG_PATH\"</b>"
              log "  <b>#{ruby_command} #{selfcommand}</b>"
              exit(INTERNAL_ERROR_CODE)
            end
          elsif detector.results.size > 1
            other_installs = detector.results - [ apache2 ]

            log "<yellow>Multiple Apache installations detected!</yellow>"
            log ""
            log "You are about to validate #{SHORT_PROGRAM_NAME} against the following"
            log "Apache installation:"
            log ""
            log "  <b>Apache #{apache2.version}</b>"
            log "  <b>apxs2     : #{apache2.apxs2}</b>"
            log "  <b>Executable: #{apache2.httpd}</b>"
            log ""
            log "However, #{other_installs.size} other Apache installation(s) have been found on your system:"
            log ""
            other_installs.each do |result|
              log "  <b>Apache #{result.version}</b>"
              log "  <b>apxs2     : #{result.apxs2}</b>"
              log "  <b>Executable: #{result.httpd}</b>"
              log ""
            end

            result = prompt_confirmation "Are you sure you want to validate " +
              "against Apache #{apache2.version} (#{apache2.apxs2})?"
            if !result
              puts
              display_separator
              other_installs.each do |result|
                log " * To validate against <yellow>Apache #{result.version} (#{result.apxs2})</yellow>:"
                log "   Re-run this program with: <b>--apxs2-path '#{result.apxs2}'</b>"
              end
              log ""
              log "You may also want to read the \"Installation\" section of Passenger Library"
              log "installation troubleshooting:"
              log ""
              log "  https://www.phusionpassenger.com/library/install/apache/"
              log ""
              log "If you keep having problems installing, please visit the following website for"
              log "support:"
              log ""
              log "  #{SUPPORT_URL}"
              exit(INTERNAL_ERROR_CODE)
            end
          else
            log '<green>Only a single installation detected. This is good.</green>'
          end

          display_separator
        ensure
          detector.finish
        end
      end

      def check_apache2_installed
        checking "whether Apache is installed"

        if PlatformInfo.httpd
          # macOS >= 10.13 High Sierra no longer includes apxs2, but that's
          # okay because we know Apache is installed
          if PlatformInfo.apxs2 ||
            (PlatformInfo.os_name_simple == 'macosx' &&
             PlatformInfo.os_version >= '10.13' &&
             PlatformInfo.httpd == '/usr/sbin/httpd')
            check_ok
            true
          else
            check_error

            PlatformInfo::Depcheck.load("depcheck_specs/apache2")
            dep = PlatformInfo::Depcheck.find("apache2-dev")
            install_instructions = dep.install_instructions.split("\n").join("\n              ")

            if !@options[:invoked_from_installer]
              next_step = "When done, please re-run this program."
            end

            suggest %Q(
              Unable to validate your Apache installation: more software required

              This program requires the <b>apxs2</b> tool in order to be able to validate your
              Apache installation. This tool is currently not installed. You can solve this
              as follows:

              #{install_instructions}

              #{next_step}
            )

            false
          end
        else
          check_error

          PlatformInfo::Depcheck.load("depcheck_specs/apache2")
          dep = PlatformInfo::Depcheck.find("apache2")
          install_instructions = dep.install_instructions.split("\n").join("\n            ")

          suggest %Q(
            Apache is not installed. You can solve this as follows:

            #{install_instructions}
          )

          false
        end
      end

      def check_apache2_load_module_config
        checking "whether the Passenger module is correctly configured in Apache"

        if PlatformInfo.httpd_default_config_file.nil?
          check_error
          passenger_config = "#{PhusionPassenger.bin_dir}/passenger-config"
          suggest %Q(
            Your Apache installation might be broken

            You are about to validate #{PROGRAM_NAME} against the following
            Apache installation:

               apxs2     : #{PlatformInfo.apxs2 || 'OS-provided installation'}
               Executable: #{PlatformInfo.httpd || 'unknown'}
               Version   : #{PlatformInfo.httpd_version || 'unknown'}

            However, this Apache installation appears to be broken, so this program
            cannot continue. To find out why this program thinks the above Apache
            installation is broken, run:

               export ORIG_PATH="$PATH"
               #{sudo_s_e}
               export PATH="$ORIG_PATH"
               #{ruby_command} #{passenger_config} --detect-apache2
          )
          return
        end

        result = PlatformInfo.httpd_included_config_files(
          PlatformInfo.httpd_default_config_file)
        if !result[:unreadable_files].empty?
          check_error

          if Process.uid == 0
            suggest %Q(
              Permission problems

              This program must be able to analyze your Apache installation. But it can't
              do that despite running with root privileges. In particular, it failed to
              read the following files:

                #{result[:unreadable_files].join("\n                ")}

              This program doesn't know why this error occurred. Your system is probably
              secured using some mechanism that this program is not familiar with. Please
              consult your operating system's manual to learn which security mechanisms
              may be preventing this program from accessing the above files. On Linux
              systems, SELinux and AppArmor might be responsible.

              When you've solved the problem, please re-run this program.
            )
          else
            whoami = `whoami`.strip
            sudo = PhusionPassenger::PlatformInfo.ruby_sudo_command
            selfcommand = "#{PhusionPassenger.bin_dir}/passenger-config validate-install #{@orig_argv.join(' ')}"

            suggest %Q(
              Permission problems

              This program must be able to analyze your Apache installation. But it can't
              do that, because you're running the installer as '#{whoami}'. In particular,
              it failed to read the following files:

                #{result[:unreadable_files].join("\n                ")}

              Please give this program root privileges, by re-running it with '#{sudo}':

                 export ORIG_PATH=\"$PATH\"
                 #{sudo_s_e}
                 export PATH=\"$ORIG_PATH\"
                 #{ruby_command} #{selfcommand}
            )
          end
          return
        end

        occurrences = 0
        occurrence_files = []
        module_path = nil

        result[:files].each do |path|
          lines = File.open(path, "rb") do |f|
            f.read.split("\n")
          end
          lines.each do |line|
            # Get rid of trailing CR
            line = line.strip

            if line !~ /^[\s\t]*#/ && line =~ /LoadModule[\s\t]+passenger_module[\s\t]+(.*)/
              module_path = $1
              occurrences += 1
              occurrence_files << path
            end
          end
        end

        if occurrences == 1
          if module_path !~ /^\//
            # Non-absolute path. Absolutize using ServerRoot.
            module_path = "#{PlatformInfo.httpd_default_root}/#{module_path}"
          end
          # Resolve symlinks.
          module_path = try_realpath(module_path)
          expected_module_path = try_realpath(PhusionPassenger.apache2_module_path)

          if module_path == expected_module_path
            check_ok
          else
            check_error
            suggest %Q(
              Incorrect #{SHORT_PROGRAM_NAME} module path detected

              #{PROGRAM_NAME} for Apache requires a 'LoadModule passenger_module'
              directive inside an Apache configuration file. This directive has been
              detected in the following config file:

                 #{occurrence_files[0]}

              However, the directive refers to the following Apache module, which is wrong:

                 #{module_path}

              Please edit the config file and change the directive to this instead:

                 LoadModule passenger_module #{PhusionPassenger.apache2_module_path}
            )
          end
        elsif occurrences == 0
          if @options[:invoked_from_installer]
            check_warning
            suggest %Q(
              You did not specify 'LoadModule passenger_module' in any of your Apache
              configuration files. Please paste the configuration snippet that this
              installer printed earlier, into one of your Apache configuration files, such
              as #{PlatformInfo.httpd_default_config_file}.
            )
          else
            check_error
            installer_command = "#{PhusionPassenger.bin_dir}/passenger-install-apache2-module"
            suggest %Q(
              You did not specify 'LoadModule passenger_module' in any of your
              Apache configuration files. This means that #{PROGRAM_NAME}
              for Apache is not installed or not active. Please run the
              #{PROGRAM_NAME} Apache module installer:

                 #{ruby_command} #{installer_command} --apxs2=#{PlatformInfo.apxs2 || 'none'}
            )
          end
        else
          check_error

          suggest %Q{
            You have #{occurrences} 'LoadModule passenger_module' directives in your Apache
            configuration files. However, you are only supposed to have one such
            directive.

            Please fix this by removing all 'LoadModule passenger_module' directives
            besides the one for #{SHORT_PROGRAM_NAME} version #{VERSION_STRING}. The directives were
            found in these files:

               #{occurrence_files.uniq.join("\n               ")}

            Note: 'LoadModule passenger_module' may be placed inside the global context
            only (so not within a VirtualHost).
          }
        end
      end

      def summarize
        puts
        if @error_count == 0 && @warning_count == 0
          log "<green>Everything looks good. :-)</green>"
        elsif @error_count == 0
          log "<yellow>Detected 0 error(s), #{@warning_count} warning(s).</yellow>"
        else
          log "<red>Detected #{@error_count} error(s), #{@warning_count} warning(s).</red>"
        end
      end

      # Returns the RubyGems bin dir, if Phusion Passenger is installed through RubyGems.
      def gem_bindir
        if defined?(Gem) &&
           PhusionPassenger.originally_packaged? &&
           PhusionPassenger.build_system_dir =~ /^#{Regexp.escape Gem.dir}\// &&
           File.exist?("#{Gem.bindir}/passenger-config")
          Gem.bindir
        else
          nil
        end
      end

      # Returns the Homebrew bin dir, if Phusion Passenger is installed through Homebrew.
      def homebrew_bindir
        if PhusionPassenger.packaging_method == "homebrew"
          "/usr/local/bin"
        else
          nil
        end
      end

      # Returns the ~/.rbenv/shims directory if it exists.
      def rbenv_shims_dir
        home = PhusionPassenger.home_dir
        "#{home}/.rbenv/shims"
      end

      def logn(message)
        STDOUT.write(@colors.ansi_colorize(message))
        STDOUT.flush
      end

      def log(message)
        STDOUT.puts(@colors.ansi_colorize(message))
      end

      def display_separator
        puts
        puts "-------------------------------------------------------------------------"
        puts
      end

      def checking(message)
        logn " * Checking #{message}... "
      end

      def check_ok(message = "✓")
        log "<green>#{message}</green>"
      end

      def check_error(message = "✗")
        log "<red>#{message}</red>"
        @error_count += 1
      end

      def check_warning(message = "(!)")
        log "<yellow>#{message}</yellow>"
        @warning_count += 1
      end

      def suggest(message)
        puts
        log reindent(unindent(message), 3)
        puts
      end

      def prompt_confirmation(message)
        result = prompt("#{message} [y/n]") do |value|
          if value.downcase == 'y' || value.downcase == 'n'
            true
          else
            log "<red>Invalid input '#{value}'; please enter either 'y' or 'n'.</red>"
            false
          end
        end
        result.downcase == 'y'
      rescue Interrupt
        exit(INTERNAL_ERROR_CODE)
      end

      def prompt(message, default_value = nil)
        done = false
        while !done
          logn "#{message}: "

          if default_value
            puts default_value
            return default_value
          end

          begin
            result = STDIN.readline
          rescue EOFError
            exit(INTERNAL_ERROR_CODE)
          end
          result.strip!
          if result.empty?
            if default_value
              result = default_value
              done = true
            else
              done = !block_given? || yield(result)
            end
          else
            done = !block_given? || yield(result)
          end
        end
        result
      rescue Interrupt
        exit(INTERNAL_ERROR_CODE)
      end

      def unindent(text)
        PlatformInfo.send(:unindent, text)
      end

      def reindent(text, level)
        PlatformInfo.send(:reindent, text, level)
      end

      def sudo_s_e
        PlatformInfo.ruby_sudo_shell_command("-E")
      end

      def ruby_command
        PlatformInfo.ruby_command
      end

      def try_realpath(path)
        if path
          begin
            Pathname.new(path).realpath.to_s
          rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
            path
          end
        else
          nil
        end
      end
    end

  end # module Config
end # module PhusionPassenger
