### Copyright 2025 Pixar

###
###    Licensed under the Apache License, Version 2.0 (the "Apache License")
###    with the following modification; you may not use this file except in
###    compliance with the Apache License and the following modification to it:
###    Section 6. Trademarks. is deleted and replaced with:
###
###    6. Trademarks. This License does not grant permission to use the trade
###       names, trademarks, service marks, or product names of the Licensor
###       and its affiliates, except as required to comply with Section 4(c) of
###       the License and to reproduce the content of the NOTICE file.
###
###    You may obtain a copy of the Apache License at
###
###        http://www.apache.org/licenses/LICENSE-2.0
###
###    Unless required by applicable law or agreed to in writing, software
###    distributed under the Apache License with the above modification is
###    distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
###    KIND, either express or implied. See the Apache License for the specific
###    language governing permissions and limitations under the Apache License.
###
###

require 'mysql'

###
module Jamf

  ### A mysql connection to the JSS database.
  ###
  ### This is a singleton class, only one can exist at a time, and it
  ### is created, but not connected, automatically when the module loads.
  ###
  ### Use it via the Jamf::DB_CNX constant (for connection metadata)
  ### and the Jamf::DB_CNX.db attribute (which contains the actual mysql
  ### query interface) for making queries
  ###
  ### Direct MySQL access is minimal and discouraged, since it
  ### bypasses the API, and can be very dangerous. However, it's necessary
  ### to overcome some limitations of the API or to access custom tables.
  ###
  ### While a database connction isn't required for most things,
  ### warnings will be sent to stderr when functionality is limited due to
  ### a lack of a database connection i.e. when Jamf::DB_CNX.connected? == false
  ###
  ### To make a connection with credentials, just call the #connect method thus:
  ###    Jamf::DB_CNX.connect :server => 'server.company.com', :user => "user", :pw => "pw"
  ###
  ### Other options include:
  ###   :db_name => which database to connect to, defaults to 'jamfsoftware'
  ###   :port => tcp port for connection to server, defaults to the standard mysql port.
  ###   :connect_timeout => seconds to wait before giving up on connection, defaults to 120
  ###   :read_timeout => seconds to wait before giving up on recieving data, defaults to 120
  ###   :write_timeout => seconds to wait before giving up on sending data, defaults to 120
  ###   :timeout => sets all three timeouts to the same value, defaults to 120
  ###
  ### Calling Jamf::DB_CNX.connect again will re-use any values not provided.
  ### but will create a new connection.
  ###
  class DBConnection

    include Singleton

    #####################################
    ### Class Constants
    #####################################

    ### The name of the JSS database on the mysql server
    DEFAULT_DB_NAME = 'jamfsoftware'.freeze

    ### give the connection a 60 second timeout, for really slow
    ### net connections (like... from airplanes)
    DFT_TIMEOUT = 60

    ###
    DFT_SOCKET = '/var/mysql/mysql.sock'.freeze

    ### the default MySQL port
    DFT_PORT = 3306

    ### The default encoding in the tables - JAMF wisely uses UTF-8
    DFT_CHARSET = 'utf8'.freeze

    ### the strftime format for reading/writing dates in the db
    SQL_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'.freeze

    attr_reader :server, :port, :socket, :user, :db_name, :connect_timeout, :read_timeout, :write_timeout, :connected

    def initialize
      @mysql = Mysql.init
      @connected = false
    end # init

    ###
    ### Connect to the JSS MySQL database.
    ###
    ### @param args[Hash] the keyed arguments for connection.
    ###
    ### @option args :server[String] Required, the hostname of the JSS API server
    ###
    ### @option args :port[Integer] the port number to connect with, defaults to the default Mysql TCP port
    ###
    ### @option args :socket[String,Pathname] when the server is 'localhost', the path to the connection socket.
    ###
    ### @option args :db_name[String] the name of the database to use, defaults to 'jamfsoftware'
    ###
    ### @option args :user[String] Required, the mysql user to connect as
    ###
    ### @option args :pw[String,Symbol] Required, the password for that user, or :prompt, or :stdin
    ###   If :prompt, the user is promted on the commandline to enter the password for the :user.
    ###   If :stdin#, the password is read from a line of std in represented by the digit at #,
    ###   so :stdin3 reads the passwd from the third line of standard input. defaults to line 2,
    ###   if no digit is supplied. see {JSS.stdin}
    ###
    ### @option args :connect_timeout[Integer] the number of seconds to wait for an initial response, defaults to 120
    ###
    ### @option args :read_timeout[Integer] the number of seconds before read-request times out, defaults to 120
    ###
    ### @option args :write_timeout[Integer] the number of seconds before write-request times out, defaults to 120
    ###
    ### @option args :timeout[Integer] used for any of the timeouts that aren't explicitly set.
    ###
    ### @return [true] the connection was successfully made.
    ###
    def connect(**args)
      begin
        disconnect if @connected
      rescue Mysql::ClientError::ServerGoneError
        @connected = false
      end

      # server might come frome several places
      # if not given in the args, use #hostname to figure out
      # which
      @server = args[:server] || hostname

      # settings from config if they aren't in the args
      args[:port] ||= Jamf.config.db_server_port || Mysql::MYSQL_TCP_PORT
      args[:socket] ||= Jamf.config.db_server_socket || DFT_SOCKET
      args[:db_name] ||= Jamf.config.db_name || DEFAULT_DB_NAME
      args[:user] ||= Jamf.config.db_username
      args[:connect_timeout] ||= Jamf.config.db_connect_timeout
      args[:read_timeout] ||= Jamf.config.db_read_timeout
      args[:write_timeout] ||= Jamf.config.db_write_timeout
      args[:charset] ||= DFT_CHARSET

      ### if one timeout was given, use it for all three
      args[:connect_timeout] ||= args[:timeout] || DFT_TIMEOUT
      args[:read_timeout] ||= args[:timeout] || DFT_TIMEOUT
      args[:write_timeout] ||= args[:timeout] || DFT_TIMEOUT

      @port = args[:port]
      @socket = args[:socket]
      @mysql_name = args[:db_name]
      @user = args[:user]
      @connect_timeout = args[:connect_timeout]
      @read_timeout = args[:read_timeout]
      @write_timeout = args[:write_timeout]

      # make sure we have a user, pw, server
      raise Jamf::MissingDataError, 'No MySQL user specified, or defined in configuration.' unless args[:user]
      raise Jamf::MissingDataError, "Missing :pw (or :prompt/:stdin) for user '#{@user}'" unless args[:pw]
      raise Jamf::MissingDataError, 'No MySQL Server hostname specified, or listed in configuration.' unless @server

      @pw = if args[:pw] == :prompt
              JSS.prompt_for_password "Enter the password for the MySQL user #{@user}@#{@server}:"
            elsif args[:pw].is_a?(Symbol) && args[:pw].to_s.start_with?('stdin')
              args[:pw].to_s =~ /^stdin(\d+)$/
              line = Regexp.last_match(1)
              line ||= 2
              JSS.stdin line
            else
              args[:pw]
            end

      @mysql = Mysql.init

      @mysql.options Mysql::OPT_CONNECT_TIMEOUT, @connect_timeout
      @mysql.options Mysql::OPT_READ_TIMEOUT, @read_timeout
      @mysql.options Mysql::OPT_WRITE_TIMEOUT, @write_timeout
      @mysql.charset = args[:charset]
      @mysql.connect @server, @user, @pw, @mysql_name, @port, @socket

      @connected = true

      @server
    rescue Mysql::ServerError::NotSupportedAuthMode => e
      raise Mysql::ServerError::AccessDeniedError, 
            "Probable unknown MySQL user '#{@user}'. Original error was 'Mysql::ServerError::NotSupportedAuthMode: #{e}' which is sometimes raised when the user does not exist on the server."
    end # connect

    ###
    ### @return [Mysql] The mysql database connection itself
    ###
    def db
      raise Jamf::InvalidConnectionError, 'No database connection. Please use Jamf::DB_CNX.connect' unless Jamf::DB_CNX.connected?

      @mysql
    end

    ###
    ### close the connection to the database
    ### it'll have to be re-connected before using again
    ###
    def disconnect
      @mysql.close! if @mysql.protocol
      @server = nil
      @port = nil
      @socket = nil
      @user = nil
      @connection_timeout = DFT_TIMEOUT
      @read_timeout = DFT_TIMEOUT
      @write_timeout = DFT_TIMEOUT
      @connected = false
      nil
    end # disconnect

    ### Test that a given hostname is a MySQL server
    ###
    ### @param server[String] The hostname to test
    ###
    ### @return [Boolean] does the server host a MySQL server?
    ###
    def valid_server?(server, port = DFT_PORT)
      mysql = Mysql.init
      mysql.options Mysql::OPT_CONNECT_TIMEOUT, 60
      mysql.charset = DFT_CHARSET

      begin
        # this connection should get an access denied error if there is
        # a mysql server there. I'm assuming no one will use this username
        # and pw for anything real
        # Also with newer versions of mysql, a Mysql instance that has
        # never authenticated will raise Mysql::ServerError::NotSupportedAuthMode
        # rather than Mysql::ServerError::AccessDeniedError, until a
        # successful connection is made. After that, re-connecting will
        # raise AccessDeniedError when credentials are invalid.

        mysql.connect server, 'notArealUser', "definatelyNotA#{$PROCESS_ID}password", 'not_a_db', port
      rescue Mysql::ServerError::AccessDeniedError, Mysql::ServerError::NotSupportedAuthMode
        return true
      rescue
        return false
      end
      false
    end

    ### The server to which we are connected, or will
    ### try connecting to if none is specified with the
    ### call to #connect
    ###
    ### @return [String] the hostname of the server
    ###
    def hostname
      # return it if already set
      return @server if @server

      # otherwise, from the config
      srvr = Jamf.config.db_server_name
      # otherwise, assume its on the JSS server to which this client talks
      srvr ||= Jamf::Client.jss_server
      srvr
    end

    #### Aliases

    alias connected? connected

  end # class DBConnection

  ### The single instance of the DBConnection
  DB_CNX = DBConnection.instance

  ###
  ### @return [Mysql] The mysql database available through the DBConnection.instance
  ###
  def self.db
    DB_CNX.db
  end

end # module
