<?php

/*
   Copyright (c) 2021, Daily Data, Inc. Redistribution and use in 
   source and binary forms, with or without modification, are permitted
   provided that the following conditions are met:

   * Redistributions of source code must retain the above copyright 
     notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above copyright 
     notice, this list of conditions and the following disclaimer in the 
     documentation and/or other materials provided with the distribution.

   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
   "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
   OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
   SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 
   LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
   DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
   THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
   OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

*/

/*
 * users.class.php
 * 
 * Authors: R. W. Rodolico
 * 
 */

/**
 * User Login class
 * 
 * IMPORTANT: Requires a data source. See UsersDataSourceMySQLi.class.php
 * for code which provides this for MySQLi
 * 
 * Users encapsulates a basic login and authentication package. It 
 * provides a login screen, authentication, the ability to edit oneself,
 * and for users with the admin flag set, the ability to edit others.
 * 
 * It also allows a user to be disabled.
 * 
 * Users was designed to be extensible, adding new fields by the calling
 * program, modifying the HTML elements, etc...
 * 
 * @author R. W. Rodolico <rodo@unixservertech.com>
 * 
 * @version 0.9.0 (beta)
 * @copyright 2021 Daily Data, Inc.
 * 
 */

 class Users {
   
   /**
    * @var string[] $configuration Contains the configuration for the class
    * 
    * May be modified by the calling program. Must be replicated in userDataSource class
    */
   protected $configuration = array(
      /*
       * what to use for html input fields
       * These are passed to sprintf, with label, fieldname, title, placeholder and current value, in that order
       */
      'screens'         => array (
         'login form' => "<h1>Login</h1>\n<form class='login_form' action='%s' method='post'>\n%s\n<input type='submit' value='Login'></form>\n",
         'edit form'  => "<form class='login_form' action='%s' method='post'>\n%s\n<input type='submit' name='btnUpdate' value='Update'>\n</form>",
         'loginScreen' => "<div class='login_field'>\n<input class='login_field' type='text' name='username' placeholder='Username' required autofocus>\n</div>\n<div class='login_field'>\n<input class='login_field' type='password' name='password' placeholder='Password' required>\n</div>",
         'adminScreen' => "<input type='hidden' name='doAdmin' value='1'>\n",
         'validateScript' => '',
         ),
      'html input fields' => array(
            'text'      => "<div class='login_field'>\n<label>%s\n<input class='login_field' type='text' name='%s' title='%s' placeholder='%s' value='~~%s~~'>\n</label>\n</div>",
            'password'  => "<div class='login_field'>\n<label>%s\n<input class='login_field' type='password' name='%s' title='%s' placeholder='%s'>\n</label>\n</div>",
            'boolean'   => "<div class='login_field'>\n<label>%s\n<input class='login_field' type='checkbox' name='%s' title='%s' value='1' %s ~~%s~~>\n</label>\n</div>",
            'textarea'  => "<div class='login_field'>\n<label>%s\n<textarea class='login_field' name='%s' title='%s' placeholder='%s'>~~%s~~</textarea>\n</label>\n</div>",
         ),
      'input prefix' => 'admin_', // prefix the name with this in a form
      'tables'    => array(
         'users'  => array(
            'table'     => '_users',   // table name for user records
            'id'        => '_user_id', // ID column name
            'display'   => array(      // fields which are displayed to select
               'login'
               ),         
            'form test' => 'login',    // field to test if form submitted
            'fields' => array(
               'login'  => array(
                     'label'        => 'Username',       // login name column name
                     'html type'    => 'text',
                     'filter'       => '/^[a-zA-Z0-9_]+$/',
                     'instructions' => 'Username can only contain alpha numerics and an underscore',
                     'hint'         => 'Change User Name',
                     'unique'       => true
                     ),
               'pass'   => array( 
                     'label'        => 'Password',    // password column name
                     'html type'    => 'password',
                     'instructions' => 'Leave blank to keep same password',
                     'hint'         => 'Change Password'
                     ),
               'admin'  => array(
                     'label'        => 'isAdmin',
                     'html type'    => 'boolean',
                     'restrict'     => true,
                     'instructions' => 'If checked, user will be able to add/edit users',
                     ),
               'enabled' => array(
                     'label'        => 'Enabled',
                     'html type'    => 'boolean',
                     'restrict'     => true,
                     'instructions' => 'Uncheck to disable log in',
                     ),
               ) // fields
            ) // table users
         ) // tables
      );

   /** @var string[] $data Contains the information for the current logged in user */
   protected $data = array();
   /** @var string[] $errors Contains errors that can occur */
   protected $errors = array();
   /** @var string[] $workingOn During administration, contains the record being modified */
   protected $workingOn = array();

   /**
    * constructor for an instance of the class
    * 
    * Anything in $customFields will be recursively merged with $configuration, overwriting
    * as necessary.
    * 
    * @param string[] $customFields array to merge into $configuration
    */
   public function __construct( $customFields = array() ) {
      if ( $customFields ) {
         $this->configuration = array_merge_recursive( $this->configuration, $customFields );
      }
   } // constructor
   
   /**
    * generic getter to retrieve stuff from $this->data
    * 
    * Just looks for the key and returns the value, or null if not found
    * 
    * @param string[] $name key to look up in $this->data
    * 
    * @return string $this->data[$name], or null if not set
    */
   public function __get( $name ) {
      return isset( $this->data[$name] ) ? $this->data[$name] : null;
   }
   
   /**
    * Checks if $name exists in $this->data
    * 
    * @param string[] $name key to look up in $this->data
    * 
    * @return bool true if $this->data[$name] is set
    */
   public function __isset( $name ) {
      return isset( $this->data[$name] );
   }
      
   
   /**
    * getter for $this->errors
    * 
    * @return string html div containing one paragraph for every error
    */
   public function errors() {
      $return = "<p>" . implode( "</p>\n<p>", $this->errors ) . "</p>\n";
      return "<div class='login_errors'>\n$return</div>\n";
   }
   
   /**
    * clears the errors array
    */
   public function clearErrors() {
      $this->errors = array();
   }
 
   /**
    * getter for isAdmin
    * 
    * @return boolean true if user is an admin, false if not
    */
   public function isAdmin() {
      return $this->data['admin'];
   }
   
   /**
    * getter for login name
    * 
    * @return string user name 
    */
   public function name() {
      return isset( $this->data['login'] ) ? $this->data['login'] : null;
   }
   
   
   /**
    * Main display function.
    * 
    * This function should be called to perform the login. It performs all functions
    * needed to log in and validate, but once logged in, will return an empty string.
    * 
    * @param usersDataSource $connection A connection to the data source
    * @param string $nextScript The url to be run when logged in
    * 
    * @return string A (possibly empty) HTML div
    */
   public function HTML( $connection, $nextScript = null ) {
      if ( isset( $_REQUEST['username'], $_REQUEST['password'] ) ) {
         $this->validate( $_REQUEST['username'], $_REQUEST['password'], $connection );
      }
      if ( isset( $_REQUEST['logout'] ) && $_REQUEST['logout'] == 'Logout' ) {
         $this->logOut();
      }
      if ( ! isset( $this->data['login'], $this->data['id'] ) ) {
         return $this->logInScreen();
      }
   }
   
   /**
    * Validates a connection and, on success, populates $data
    * 
    * Function will validate the username and password passed in, using
    * data connection $connection. On success, populates class member $data
    * with the values from the database (only those listed in $configuration)
    * 
    * On Failure, appends $error with a failure string
    * 
    * @param string $username The username to be matched in database
    * @param string $password The password (unencrypted) the user entered
    * @param usersDataSource $connection A connection to the data source
    * 
    */
   protected function validate( $username, $password, $connection ) {
      $result = $connection->getPassword( $username );
      if ( password_verify( $password, $result['pass'] ) ) {
         $result = $connection->getARecord( array( 'login' => $username ) );
         $this->data['id'] = $result['id'];
         foreach ( $this->configuration['tables']['users']['fields'] as $key => $record ) {
            if ( $key != 'pass' )
               $this->data[$key] = $result[$key];
         }
         return true;
      } else {
         $this->errors[] = 'Login Failed: Unknown username or password';
         foreach ( $this->configuration['tables']['users']['fields'] as $key => $record ) {
            $this->data[$key] = null;
         }
         return false;
      }
   } // validate
               
   /**
    * Get all users from data source and put them in an HTML list
    * 
    * Will retrieve the ID and login name of all users, putting them
    * in a list of anchors to allow an admin to select one for editing
    * 
    * @param  usersDataSource $connection A connection to the data source
    * @param string $nextPage The URL of the page to be used in the link
    * 
    * @return  string   an unordered list (UL) containing links with names
    */
   public function allUsersHTML ( $connection, $nextPage = null ) {
      $nextPage = self::getNextScript( $nextPage );
      $return = '';
      $allUsers = $connection->getAllUsers();
      foreach ( $allUsers as $row ) {
         if ( $row['id'] == $this->data['id'] ) // don't do ourselves
            continue;
         $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", $row['id'], $row['login'] );
      }
      $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", -1, 'Add New User' );
      // wrap in ul, then put a div around it
      $return = "<ul class='login_list'>\n$return\n</ul>\n";
      $return = "<div class='login_list'>\n$return\n</div>\n";
      return $return;
   }
   
   /**
    * Logs user out of system
    * 
    * destroys itself ($_SESSION['user'], then session, then calls
    * $nextScript by doing a header call.
    * 
    * @param string $nextScript URL of next script to call
    */
   public function logOut( $nextScript = null ) {
      $nextScript = $this->getNextScript( $nextScript );
      $_SESSION['user'] = null;
      session_destroy();
      header( "Location: $nextScript" );
   }
   
   /**
    * Simple helper script to calculate next script to call
    * 
    * Returns one of three URL strings, in order of precedence
    * $nextScript
    * $configuration['screens']['validateScript']
    * PHP_SELF
    * 
    * @param string $nextScript URL to call
    * @return string URL
    */
   protected function getNextScript( $nextScript = null ) {
      if ( ! isset( $nextScript ) ) {
         $nextScript = $this->configuration['screens']['validateScript'] ?:
                           htmlentities($_SERVER["PHP_SELF"]);
      }
      return $nextScript;
   }
   
   /**
    * Creates the fields needed for a login screen
    * 
    * Populates %s's in 'login form' with values for $nextScript and
    * 'loginScreen'
    * 
    * @param string $nextScript URL to call form
    * 
    * @return string HTML code for display
    */
   protected function logInScreen( $nextScript = null ) {
      $return =  sprintf( 
         $this->configuration['screens']['login form'],
         $this->getNextScript( $nextScript ),
         $this->configuration['screens']['loginScreen']
      );
      $return .= $this->errors();
      $this->clearErrors();
      return $return;
   }
   
   /**
    * Creates an HTML field for display
    * 
    * Retrieves the template for the record type, then populates it from
    * $record, $value and $field. The template MUST have %s's in the 
    * following order for an HTML INPUT field
    * label=
    * name=
    * title=
    * placeholder=
    * value
    * 
    * Knows how to handle INPUT types TEXT, TEXTAREA, PASSWORD and 
    * special html type boolean, which is checkboxes.
    * 
    * @param string $field name of the field to populate
    * @param string[] $record Record from $configuration[...][fields]
    * @param string $value the current value to put in INPUT
    * 
    * @return string An HTML INPUT entity
    */
   protected function makeHTMLField ( $field, $record, $value ) {
      $return = array();
      $temp = sprintf( $this->configuration['html input fields'][$record['html type']], 
                        $record['label'] ?: $field,
                        $this->configuration['input prefix'] . $field, 
                        !empty($record['instructions']) ? $record['instructions'] : '',
                        !empty($record['hint']) ? $record['hint'] : '',
                        $field
                     );

      switch ($record['html type'] ) {
         case 'text':
         case 'textarea':
                        $temp = preg_replace( "/~~$field~~/", isset( $value ) ? $value : '', $temp );
                        break;
         case 'password' :
                        break;
         case 'boolean' :  // boolean is set by checkboxes
                        $temp = preg_replace( "/~~$field~~/", $value ? 'checked' : '', $temp );
                        break;
      } // case
      return $temp;
      
   } // makeHTMLField
   
   /**
    * Creates an edit screen for display to user
    * 
    * This function will create an edit screen which, when displayed to
    * the user, allows them to edit a users record. The record is stored
    * in $this->workingOn
    * 
    * Function will go through each field in the users table and call makeHTMLField
    * for it, unless the field is restricted and the user is editing their own
    * entry. It will also create a hidden input field with the users ID
    * 
    * NOTE: this will not create the form; the form is created someplace else
    * 
    * @return string HTML containing all of the INPUT records a user can edit
    */
   public function editScreen( $connection ) {
      $return = array();
      $return[] = $this->configuration['screens']['adminScreen'];
      $return[] = "<input type='hidden' name='id' value='" . $this->workingOn['id'] . "'>\n";
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
         // if this field is restricted and we are not admin, just skip it
         // also skip if it is our record
         if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
            continue;
         // now process the field
         $return[] = $this->makeHTMLField( $field, $record, $this->workingOn[$field] ?? '' );
      }
      return implode( "\n", $return );
   } // editScreen
   
   /**
    * Creates a variable designed to replace $this->workingOn
    * 
    * Initializes all fields to something non-null and sets id to -1
    * 
    * @return string[] An array initialized with all records needed
    */
   protected function emptyWorkingOn() {
      $new = array();
      $new['id'] = -1;
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
         if ( isset( $record['default'] ) ) {
            $new[$field] = $record['default'];
         } else {
            switch ($record['html type']) {
               case 'text'    :
               case 'blob'    :  $new[$field] = '';
                                 break;
               case 'boolean' :  $new[$field] = 1;
                                 break;
               case 'password':  $new[$field] = '';
                                 break;
            }
         } // else
      } // foreach
      return $new;
   }
   
   protected function addEdit( $connection ) {
      $data = array();
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
         // if this field is restricted it is our record, skip it
         if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
            continue;
         $htmlFieldName = $this->configuration['input prefix'] . $field;
         $temp = '';
         switch ( $record['html type'] ) {
            case 'password':
               if ( ! empty( $_REQUEST[$htmlFieldName] ) ) {
                  $data[$field] = password_hash( $_REQUEST[$htmlFieldName], PASSWORD_DEFAULT );
                  if ( isset( $this->configuration['tables']['users']['fields']['last password change'] ) ) {
                     $data['last password change'] = date("YmdHis");
                  }
               }
               break;
            case 'boolean' :
               if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) != $this->workingOn[$field] ) ) {
                  $data[$field] = isset( $_REQUEST[$htmlFieldName] ) ? 1 : 0;
               }
               break;
            default : // text, textarea, other things like this
               if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) && $_REQUEST[$htmlFieldName] !== $this->workingOn[$field] ) ) {
                  $data[$field] = $_REQUEST[$htmlFieldName];
                  if ( isset( $record['filter'] ) && preg_match( $record['filter'], $data[$field] ) !== 1 ) {
                     $this->errors[] = sprintf( "Invalid characters in %s, %s", $record['label'], $record['instructions'] );
                     unset( $data[$field] );
                  }
               }
               break;
         } // switch
      } // foreach
      if ( count($this->errors) ) { // we have some errors
         $this->errors[] = 'Record not updated';
         return 'Error';
      }
      if ( $data ) {
         $data['id'] = $this->workingOn['id'];
         $return = $connection->update( $data ) ? "Updated" : "Failed";
      } else {
         $return = "No changes";
      }
   }
   
   protected function initWorkingOn( $connection, $id ) {
      if ( ! isset($id) || $id == $this->data['id'] ) { // we're working on ourself
         $this->workingOn = $this->data;
      } elseif ( $id == -1 ) { // a new user
         $this->workingOn = $this->emptyWorkingOn();
      } else { // this is an existing user
         $this->workingOn = $connection->getARecord( array( 'id' => $id ) );
      }
   } // initWorkingOn
   
   
   
   /**
    * Sets up the admin function which allows users to edit themselves and, optionally, others
    * 
    * This should be called the first time, then repeatedly called until it is done
    * (it returns the string "Updated", "Failed" or "No changes".
    * 
    * The first iteration returns an edit screen displaying the users
    * information for them to edit. It will display an HTML INPUT for
    * each field that is not restricted. The user can then edit the
    * chosen entries and press the button, which will call the script
    * again, and update the record.
    * 
    * If the user has the admin right, the Edit screen also displays a 
    * list of all users as an unsigned list of anchors. If the user 
    * clicks on one of those, it will choose that user, load their data
    * and allow the user to edit that users record. NOTE: this is the
    * only way to edit fields with the restrict flag set.
    * 
    * @param  usersDataSource $connection A connection to the data source
    * @param string $nextPage The URL of the page to be used in the link
    * 
    * @return string This may be an HTML table or a single screen
    */
   public function admin ( $connection, $nextScript = null ) {
      /*
       * Entering for first time, 
       *    admin=1, $workingOn not set
       *    update workingOn
       *    display screen
       * entering after selecting a user
       *    doAdmin=1, id=#, $workingOn[id] <> id
       *    update workingOn
       *    display screen
       * Have pressed update button
       *    update
       */
      
      $nextScript = $this->getNextScript( $nextScript );
      $id = isset($_REQUEST['id']) ? $_REQUEST['id'] : null;
      $return = array();
      if ( ! $this->workingOn || $this->workingOn['id'] != $id ) {
         // display screen for data entry
         //$return[] = 'Initializing $workingOn';
         $this->initWorkingOn( $connection, $id );
         //$return[] = "<pre>WorkingOn\n" . print_r( $this->workingOn, true) . '</pre>';
         $screen = $this->editScreen( $connection );
         if ( $this->data['admin'] ) {
            $screen .= $this->allUsersHTML( $connection );
         }
         $return[] = sprintf( $this->configuration['screens']['edit form'],
            $nextScript,
            $screen
            );
      } elseif ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { // they submitted the form
         //$return[] = 'Posting';
         $return[] = $this->addEdit( $connection );
         /*
          * if ( $this->workingOn['id'] == $this->data['id'] ) // we just updated us, reload record
            $this->data = $connection->getARecord( array( 'id' => $this->data['id'] ) );
         */
         unset( $this->workingOn );
      } else {
         print "<h3>We're in the damned 'else' in admin</h3>";
      }
      return implode( "<br />\n", $return );
   } // admin
   
} // class Users

?>
