Edit via SFTP
<?php namespace ferret\users;
/*
  PURPOSE: for managing web sessions
  CONSTANTS NEEDED:
    fcGlobals::GetText_SessionCookieAffix() - the name of the cookie where the session key will be stored
  INTERNAL RULES:
    * Get the session cookie. (If no cookie, we're not logged in.)
    * Load the session record indicated by the cookie.
    * Check the session record to make sure it matches the current client.
    * If it does, the session's user ID is logged in; otherwise not.
    * A session record is also created for anonymous users.
  HISTORY:
    2013-10-25 stripped Session classes out of VbzCart shop.php for use in ATC project
    2013-11-09 backported improved Session classes back into user-session.php
    2016-04-03 moved RandomString() to fcString::Random().
    2020-12-12 some updates for v0.4
*/
use ferret\data\cRecordResult;
use ferret\data\cTableResult;
use ferret\data\cInsertResult;
use ferret\data\cSelectResult;
use ferret\data\cRowKit;
 
define('KS_EVENT_FERRETERIA_LOGIN_OKAY','fe.login.ok');
define('KS_EVENT_FERRETERIA_LOGIN_BAD','fe.login.bad');
define('KS_EVENT_FERRETERIA_LOGOUT','fe.logout');
 
/*::::
  PURPOSE: Handles the table of user sessions
*/
class ctSessions extends cLogicTable implements \ferret\odata\ifLoggableTable {
    use \ftLinkableTable;                   // needed for event logging
    use \ferret\odata\tLoggableTable;   // also needed for event logging
 
    // ++ CONFIG ++ //
 
    protected function GetTableName() : string { return 'user_session'; }           // CEMENT
    protected function SingleRowClass() : string { return KS_CLASS_USER_SESSION; }  // CEMENT
    #protected function MultiRowClass() : string { return crsSessions::class; }      // OVERRIDE
    // CEMENT
    public function GetActionKey() : string { return \ferret\globals\cAbstract::Me()->GetActionKey_UserSession(); }
    // -- collective
    #protected function FieldCollectiveClass() : string { return cSessionIOBank::class; }    // CEMENT
    #protected function StorageCollectiveClass() : string { return \ferret\field\cStorageCollective::class; }
    #protected function DisplayCollectiveClass() : string { return \ferret\field\cDisplayCollectiveStub::class; } // CEMENT
 
    // -- CONFIG -- //
    // ++ STATUS ++ //
 
    /*----
      MEANING: indicates whether a lack of session record is because the user was never logged in (FALSE)
        or because they were logged in but something changed and now the client can't be trusted (TRUE).
    */
    private $isMismatch;
    public function GetStatus_SessionMismatch() : bool { return $this->isMismatch; }
    protected function SetStatus_SessionMismatch(bool $b) { $this->isMismatch = $b; }
 
    // -- STATUS -- //
    // ++ COOKIE ++ //
 
      //++remote++//
 
    /*----
      ACTION: tosses the session cookie to the browser
      RETURNS: TRUE iff successful
      NOTES:
      * HTTP only sets the cookie when the page is reloaded.
        Because of this, and because $_COOKIE is read-only,
        we have to set a local variable when we create a new
        session so that subsequent requests during the same
        page-load don't think it hasn't been created yet,
        and end up creating multiple records for each new session.
        (It was creating 3 new records and using the last one.)
      HISTORY:
        2018-04-24 Decided there's no point in having a cookie-domain option,
          so removed commented-out code. Also, probably just moving cookie functionality
          to the App class/object.
        2018-04-28 Using fcGlobals for naming cookies now.
    */
    protected function ThrowCookie(string $sSessKey) : bool {
        $oApp = \fcApp::Me();
        $ok = $oApp->SetCookieValue($oApp->Globals()->GetText_SessionCookieAffix(),$sSessKey);
        return $ok;
    }
 
      //--remote--//
      //++local++//
 
    //private $sCookieVal;
    protected function SetCookieValue($sValue) {
	throw new exception('2018-04-28 Does anything still call this?');
	$this->sCookieVal = $sValue;
    }
    /*----
      RULES:
        * If local value is set, return that.
        * Otherwise, get actual cookie, set local value from it, and return that.
        In other words: if local value is set, that bypasses checking for an actual cookie.
          This assumes that the cookie will never get set later on during a page-load,
          which seems like a reasonable assumption. (Note: the COOKIE array is effectively read-only.)
    */
    protected function GetCookie() : \ferret\cValueResult {
        return \fcApp::Me()->GetCookie(
          \ferret\globals\cAbstract::Me()->GetText_SessionCookieAffix()
          );
    }
 
      //--local--//
 
    // -- COOKIE -- //
    // ++ CURRENT RECORD ++ //
 
    private $osSess = NULL;
    protected function SessionStatus() : cRecordResult {
        if (is_null($this->osSess)) {
            $osSess = new cRecordResult(new \ferret\cCheckPoint());
            $osSess->ClearIt();	// nothing loaded yet
            $this->osSess = $osSess;
        }
        return $this->osSess;
    }
    public function LoggedInUser() : cLoginStatus {
        $osUser = new cLoginStatus();
        $osSess = $this->SessionStatus();   // cRowStatus
        if ($osSess->HasIt()) {
            $osUser->SetUserRecord(TRUE,$osSess->GetIt());
        } else {
            $osUser->GetAccountStatus()->ClearIt();  // was tried but the current user is not logged in
        }
        return $osUser;
    }
 
    /*----
      PURPOSE: caches requested records so we don't needlessly create multiple objects
        ...because this was happening a lot, with the same Session being created 50+ times
      HISTORY:
        2020-01-11 written
    */
    private $arRecs = array();
    public function GetRow_fromKey(int $id) : cSelectResult {
        if (array_key_exists($id,$this->arRecs)) {
            $osRec = $this->arRecs[$id];
        } else {
            $osRec = parent::GetRow_fromKey($id);
            $this->arRecs[$id] = $osRec;
        }
        return $osRec;
    }
 
    /*----
      ACTION: returns a Session object for the current connection, whether or not one already exists
        * if session object has already been loaded, assume it has been validated and return it
        * if not, gets session key and auth from cookie
      ASSUMES: session recordset is either NULL or a valid single record (and will set it accordingly)
        ...therefore if there is one loaded already, we can assume it has been validated against the current client.
      HISTORY:
        2012-10-13 Added caching of the Session object to avoid creating multiple copies.
        2015-06-23 Fixed: Was throwing an error if there was no session key; it should just make a new session.
        2016-11-14 
          * Moved cookie fetching/storage into GetCookieValue().
          * Replacing screen output with public status methods.
          * Rewriting to make more logical sense.
        2019-12-10 no longer checks for multiple session-records because that is now handled by GetRow_fromKey().
        2021-04-16 changed to return cInsertStatus instead of cRecordStatus (now cRecordResult)
          The cInsertStatus probably needs to be refactored to be a cSpace.
    */
    public function MakeActiveRecord() : cInsertResult {
        // check to see if we've already loaded a session record
        $osrcSess = $this->SessionStatus();
        if (!$osrcSess->HasIt()) {
            // no session record loaded yet, so let's get one
            $osInsSess = new cInsertResult(new \ferret\cCheckPoint());
 
            $osSessKey = $this->GetCookie();	// get the session cookie
            if ($osSessKey->HasIt()) {
                $sSessKey = $osSessKey->GetIt();
                list($idRecd,$sToken) = explode('-',$sSessKey);
                if (!is_numeric($idRecd)) {
                    throw new fcSilentException("Malformed session cookie received. Hacking attempt? Value: [$sSessKey]");
                }
 
                // try to retrieve requested session record
                $osSelSess = $this->GetRow_fromKey($idRecd);	// RowStatus
                $osrcSess = $osSelSess->Record();
                if ($osrcSess->HasIt()) {
                    // session found
                    $rcSess = $osrcSess->GetIt();
                    // verify that it matches browser fingerprint
                    $ok = $rcSess->IsValidNow($sToken);	// do requested session's creds match browser's creds?
                    $osInsSess->Record()->SetIt($rcSess);
                } else {
                    // no session found with that ID; create a new one
                    $ok = FALSE;
                }
                // if found session not valid (or not found), then there was a session mismatch:
                $this->SetStatus_SessionMismatch(!$ok);	
            } else {
                // browser has no session
                $ok = FALSE;
            }
 
            if ($ok) {
                // session accessed -- update the record
                // (should we also log an event? maybe only in high-debug mode)
                $rcSess->DoUpdate(
                  array(
                    'WhenUsed'	=> 'NOW()'
                    )
                  );
            } else {
                // no session found, or session found is not valid: need a new one
                $rcSess = $this->SpawnRow();
                $rcSess->SetupAsNew();
                $osInsSess = $rcSess->CreateRecord();
                if (!$osInsSess->GetOkay()) {
                    $sError = 'New Session record was not created successfully.';
                    $e = new \ferret\except\cData($sError);
                    #$e->AddDiagnostic('SESSION ID: ['.$rcSess->GetKeyValue().']');
                    $e->AddDiagnostic('FIELD VALUES:'.$rcSess->DumpHTML());
                    $e->AddDiagnostic($osInsSess->DumpHTML());
                    throw $e;
                }
                $sSessKey = $rcSess->SessKey();
                $ok = $this->ThrowCookie($sSessKey);
                //$ok = $rcSess->CreateRecord_andThrowCookie();
                if (!$ok) {
                    // If this happens, some output must have been sent before the HTTP header, preventing the cookie.
                    $sError = "Cookie could not be sent for session key [$sSessKey].";
                    $e = new \ferret\except\cInternal($sError);
                    throw $e;
                }
                $osInsSess->Record()->SetIt($rcSess);
            }
        }
        return $osInsSess;
    }
 
    // -- CURRENT RECORD -- //
 
}
/*::::
  PURPOSE: Represents a single user session record
  HISTORY:
    2020-01-06 changed parent from cSingleRowSourced to cRecordDeluxe
*/
class crcSession extends cLogicRecord {
    use \ferret\odata\tLoggableRecord;
 
    // ++ SETUP ++ //
 
    /*----
      HISTORY:
        2019-12-21 renaming from InitNew() to SetupAsNew() to clarify purpose
        2020-03-10 $this->UnloadCurrentRow() (former first line of code) must be redundant
          now that we're doing row data separately from rowsets. Removed.
          Also updated function call to set the values (current first line).
    */
    public function SetupAsNew() {
        $this->ClearCells();
        $this->SetCells(
          array(
            'Token'       => \fcString::Random(31),
            'ID_Client'   => NULL,
            'ID_Acct'     => NULL,
            'WhenCreated' => NULL		// hasn't been created until written to db
            )
          );
        $this->ClientRecord_needed();	// connect to client record (create if needed)
    }
 
    // -- SETUP -- //
    // ++ CLASSES ++ //
 
    protected function ClientsClass() : string { return ctClients::class; }
    protected function AccountsClass() : string { return ctAccts::class; }
 
    // -- CLASSES -- //
    // ++ TABLES ++ //
 
    protected function ClientTable() : cTableResult {
        return $this->Space()->Database()->GetIt()->MakeTable($this->ClientsClass());
    }
    protected function ClientRecord(int $id) : cRowKit {
        return $this->Space()->Database()->GetIt()->MakeRow($this->ClientsClass(),$id);
    }
    protected function AccountTable() : cTableResult {
        return $this->Space()->Database()->GetIt()->MakeTable($this->AccountsClass());
    }
    protected function AccountRecord(int $id) : cRowKit {
        return $this->Space()->Database()->GetIt()->MakeRow($this->AccountsClass(),$id);
    }
 
    // -- TABLES -- //
    // ++ RECORDS ++ //
 
    /*----
      ACTION: Create a new session record from the current memory data.
      HISTORY:
        2016-11-14 Made PROTECTED, and renamed Create() -> CreateRecord().
        2016-12-18 Needs to be PUBLIC so that the Table class can call it.
          Also, no sanitization done here anymore. What isn't now handled elsewhere
          is unnecessary.
        2019-12-19 moved from fcrUserSession to fcrUserSessions
        2019-12-21 moved it back to fcrUserSession because it's clearly a single-record operation
        2020-03-10 replaced call to SetKeyValue() with call to (new) LoadSelf(); see the latter for question.
      PUBLIC so Table class can call it.
    */
    public function CreateRecord() : \ferret\data\cInsertStatus {
        $tbl = $this->GetTable_Keyed();
        $osIns = $tbl->DoInsert(
          array(
            'ID_Client'   => $this->Make_ClientID(),
            'ID_Acct'     => $this->GetUserID_SQL(),
            'Token'       => $this->GetToken_SQL(),
            'WhenCreated' => 'NOW()'
            )
          );
        if (!$osIns->GetOkay()) {
            echo '<b>SQL</b>: '.$osIns->GetSQL().'<br>';
            throw new exception('Could not create new Session record.');
        }
        #echo 'NEW ID: '.$osIns->GetID().'<br>';
        $this->SetKeyValue($osIns->ID()->GetIt());
        #echo 'FIELDS: '.$this->DumpHTML();
        $osClient = $this->ClientRecord_needed()->Record();
        $rcClient = $osClient->GetIt();
        if (!$rcClient->isNew()) {
            $rcClient->Stamp();
        }
        return $osIns;   }
 
    private $osClient=NULL;
    protected function GetClientStatus() : cRowKit {
        if (is_null($this->osClient)) {
            $oTrack = new \ferret\cCheckPoint(__FILE__,__LINE__,$this);
            $this->osClient = new cRowKit($oTrack);
        }
        return $this->osClient;
    }
    protected function SetClientStatus(cRowKit $os) { $this->osClient = $os; }
    /*----
      HISTORY:
        2014-09-18 Creating multiple ClientRecord() methods for different circumstances:
          ClientRecord_asSet() - the client record last used for the session
          ClientRecord_current() - the current client record; NULL if it does not match browser fingerprint
          ClientRecord_needed() - a client record that can be used; creates new one if current record
            does not match browser fingerprint
        2016-12-18 This now checks to make sure the session recordset actually has a row.
        2019-12-10 Since this class is now a SingleRow type, checking for a current row no longer makes sense; removing that condition.
        2019-12-20 Commented out because it needs refactoring.
        2020-03-10 A usage-case has arisen, so uncommenting & fixing.
    */
    protected function ClientRecord_asSet() : cRowKit {
        $osStuff = $this->GetClientStatus();
        $osRow = $osStuff->Record();
        if (!$osRow->HasIt()) {
            $osID = $this->ClientID();
            if ($osID->IsNonZeroInt()) {
                $idCli = $osID->GetValue();
                if (is_null($idCli)) {
                    throw new \exception('Houston, we have a problem.');
                }
                // there's a client ID, so get the client record from that:
                $oStat = $this->ClientRecord($idCli);
                //$this->rcClient = $oStat->GetRow();
                $this->osClient = $oStat;
            }
        }
        return $this->osClient;	// at this point, the status object has been updated as needed
    }
    /*----
      ACTION: checks the currently-set Client record for validity
      RETURNS: row status, with "found" set to FALSE if record is not valid
      HISTORY:
        2019-12-20 Commented out because it needs refactoring.
        2020-03-10 A usage-case has arisen, so uncommenting & fixing.
          Depends on ClientRecord_asSet() returning the proper type, so fixing that.
    */
    protected function ClientRecord_current() : cRowKit {
        $osStuff = $this->ClientRecord_asSet();
        $osRow = $osStuff->Record();
        if ($osRow->HasIt()) {
            $rcCli = $osRow->GetIt();
            if (!$rcCli->IsValidNow()) {
                $osRow->ClearIt();	// doesn't match current client; need a new one
            }
        }
        return $osStuff;
    }
    /*----
      ACTION: if the session's client record matches, then load the client record; otherwise create a new one.
      HISTORY:
        2019-12-20 Commented out because it needs refactoring.
        2020-03-10 A usage-case has arisen, so uncommenting & fixing.
          Depends on ClientRecord_current() returning the proper type, so fixing that.
    */
    protected function ClientRecord_needed() : cRowKit {
        $osStuff = $this->ClientRecord_current();
        $osRow = $osStuff->Record();
        if (!$osRow->HasIt()) {
            // 2021-01-29 We *could* get the Table from $osStuff...
            $osRow = $this->ClientTable()->GetIt()->MakeRecord_forCRC();
            if ($osRow->HasIt()) {
                $rc = $osRow->GetIt();
                $osStuff->Record()->SetIt($rc);
                $osID = $rc->GetKeyStatus();
                $this->SetClientID($osID->GetIt());
            } else {
                $e = new \ferret\except\cInternal("Could not create Session record for CRC");
                throw $e;
            }
        }
        return $osStuff;
    }
 
    // -- RECORDS -- //
    // ++ USER RECORD ++ //
 
    private $rcUser = NULL;   // logged-in user
    private $osrUser = NULL;  // status of logged-in user
    /*----
      PUBLIC for App object
      ASSUMES: if user-state changes, $osrUser will be updated or NULLed.
    */
    public function LoggedInUserStatus() : cAcctStatus {
        if (is_null($this->osrUser)) {
            $osrUser = new cAcctStatus();
            $sErrBase = 'Ferreteria internal error: trying to retrieve logged-in user record, but ';
            $idUser = $this->GetUserID();
            if (is_null($idUser)) {
                $osrUser->SetOkay(FALSE);
                $osrUser->SetMessage($sErrBase.'session has no user attached.');
                // Reminder: "logged in" means that the session has a user ID attached.
                $ok = FALSE;
            } else {
                $osdUser = $this->AccountRecord($idUser)->Record();
                $ok = $osdUser->HasIt();
            }
            if ($ok) {
                $rcUser = $osdUser->GetIt();
                $osrUser->Record()->SetIt($rcUser);
            } else {
                $osrUser->Record()->ClearIt();
                $osrUser->SetMessage($sErrBase."user record for ID=$idUser could not be loaded.");
            }
            $this->osrUser = $osrUser;
        }
        return $this->osrUser;
    }
 
    // -- USER RECORD -- //
    // ++ FIELD VALUES ++ //
 
    protected function GetClientID() : int { return $this->GetCell('ID_Client'); }
    protected function SetClientID(int $id) { $this->SetCell('ID_Client',$id); }
    protected function ClientID() : \ferret\cValueResult { return $this->CellStatus('ID_Client'); }
 
    #protected function SetClientID(int $id) { return $this->SetFieldValue('ID_Client',$id); }
    #protected function GetClientID() : ?int { return $this->GetFieldValue('ID_Client'); }
    #protected function StatusOfClientID() : \ferret\cElementResult { return $this->CellStatus('ID_Client'); }
    /*----
      HISTORY:
        2020-01-08 created as a more general way of checking the user ID (ID_Acct) status
        2020-01-11 made PUBLIC for App object
    */
    #public function UserID_status() : \ferret\cElementResult { return $this->CellStatus('ID_Acct'); }
    /*----
      HISTORY:
        2020-01-08 changing PUBLIC to PROTECTED until usage is documented
        2020-01-11 there is usage
          (App object calls it, apparently for the Cart object in VbzCart)
          but it seems better to make UserID_status() public instead. Doing that.
    */
    public function UserID() : \ferret\cValueResult { return $this->CellStatus('ID_Acct'); }
    protected function GetUserID() : ?int   { return $this->UserID()->GetIt(); }
    protected function SetUserID(int $id)   { $this->SetCell('ID_Acct',$id); }
    protected function ClearUserID()        { $this->ClearCell('ID_Acct'); }
 
    protected function GetToken() : string  { return $this->GetCell('Token'); }
 
      //++stash++//
 
    protected function FetchStash() : array {
        $oa = \fcApp::Me();
        $og = \fcApp::Globals();
        $osStash = $oa->GetCookie(
          $og->GetText_StashCookieAffix()
          );
 
        if ($osStash->HasIt()) {
            $sStash = $osStash->GetIt();
            $arStash = unserialize($sStash);
        } else {
            $arStash = array();
        }
        return $arStash;
    }
    protected function StoreStash(array $ar) : void {
        $oa = \fcApp::Me();
        $og = \fcApp::Globals();
        $sStashKey = $og->GetText_StashCookieAffix();
        if (count($ar) > 0) {
            $sStashVal = serialize($ar);
            $oa->SetCookieValue($sStashKey,$sStashVal);
        } else {
            $oa->ClearCookie($sStashKey);
        }
    }
 
    public function SetStashValue(string $sName,string $sValue) : void {
        $arStash = $this->FetchStash();
        $sApp = \fcApp::Me()->Globals()->GetAppKeyString();
        $arStash[$sApp][$sName] = $sValue;
        $this->StoreStash($arStash);
    }
    // NOTE 2020-11-26 NULL values are sometimes getting stashed. Is there a reason for this?
    public function GetStashValue(string $sName) : string {
        #$oa = \fcApp::Me();
        $og = \fcApp::Globals();
 
        $arStash = $this->FetchStash();
        $sApp = $og->GetAppKeyString();
        $arAppStash = \fcArray::Nz($arStash,$sApp);
        $sValue = \fcArray::Nz($arAppStash,$sName);
        return is_null($sValue)?'':$sValue;
    }
    // ACTION: retrieve the value from the stash and remove it
    public function PullStashValue(string $sName) : string {
        $sValue = $this->GetStashValue($sName);
        $this->ClearStashValue($sName);
        return $sValue;
    }
    // ACTION: delete the given value from the stash
    protected function ClearStashValue(string $sName) : void {
        #$oa = \fcApp::Me();
        $og = \fcApp::Globals();
 
        $sApp = $og->GetAppKeyString();
        $arStash = $this->FetchStash();
        unset($arStash[$sApp][$sName]);
        $this->StoreStash($arStash);
    }
 
      //--stash--//
 
    // -- FIELD VALUES -- //
    // ++ FIELD CALCULATIONS ++ //
 
    // TODO: rename to GetAcctID_SQL()
    protected function GetUserID_SQL() : string {
        if ($this->HasCell('ID_Acct')) {
            $idAcct = $this->CellStatus('ID_Acct')->GetIt();
            if (is_null($idAcct)) {
                return 'NULL';
            } else {
                return $idAcct;
            }
        } else {
            return 'NULL';
        }
    }
    /*----
      NOTE: The token comes from the database and will never have punctuation in it,
        so does not need to be sanitized before use in SQL (only needs quoting).
    */
    protected function GetToken_SQL() : string { return '"'.$this->GetToken().'"'; }
    /*----
      RETURNS: TRUE iff the user has been attached to the session
        ...which requires that the login was authorized at some point,
        possibly during an earlier execution run.
    */
    public function UserIsLoggedIn() : bool { return !is_null($this->GetUserID()); }
    /*-----
      INPUT: $sToken should be the token-string generated from the current browser specs
      RETURNS: TRUE if the stored browser specs (GetToken()) match the given browser specs ($sToken)
        Right now, this means everything has to match (cookie token, IP address, browser string)
        but in the future we might allow users to reduce their individual security level
        by turning off the IP address check and/or the browser check. (This may require
        table modifications.)
      PUBLIC so fctUserSessions can call it
      HISTORY:
        2015-04-26 This sometimes comes up with no record -- I'm guessing that happens when a matching
          Session isn't found. (Not sure why this isn't detected elsewhere.)
        2016-04-03 Removed commented-out section.
        2019-12-10 The more-rigorous record-handling means that it doesn't really make sense
          for a single record to not load without this failure being detected earlier. That is,
          we wouldn't be trying to do stuff with the record's data if it hadn't been loaded.
          Therefore: removing the IsNew() check.
        2019-12-20 Reverse-engineering my own code -- why do we do anything after checking the token for a match?
          Commenting that out until I understand what it's for.
    */
    public function IsValidNow(string $sToken) : bool {
        $ok = ($this->GetToken() == $sToken);
        return $ok;
    }
    public function SessKey() : string {
        if ($this->IsNew()) {
            $sError = 'Trying to generate a session key when session record has no ID.';
            $e = new \ferret\except\cData($sError);
            $e->AddDiagnostic('FIELDS:'.$this->Cells()->DumpHTML());
            #$e->AddDiagnostic('HAS ROW: ['.$this->HasCurrentRow().']');
            throw $e;
        }
        return $this->GetKeyValue().'-'.$this->GetToken();
    }
    /*----
      RETURNS: User's login name, or NULL if user not logged in
      TODO: Rename this to GetLoginString() or GetAccountNameString()
    */
    public function UserString() : ?string {
        $osrUser = $this->LoggedInUserStatus()->Record();
        if ($osrUser->HasIt()) {
            $rc = $osrUser->GetIt();
            return $rc->LoginName();
        } else {
            return NULL;
        }
    }
    /*----
      RETURNS: User's email address, or NULL if user not logged in
    */
    public function UserEmailAddress() : ?string {
        if ($this->UserIsLoggedIn()) {
            return $this->UserRecord()->EmailAddress();
        } else {
            return NULL;
        }
    }
 
    // -- FIELD CALCULATIONS -- //
    // ++ ACTIONS ++ //
 
    /*----
      ACTION: Make sure the Client ID is set correctly for the current browser client
        If not set or doesn't match, get a new one.
    */
    protected function Make_ClientID() : int {
        $osCli = $this->ClientRecord_needed()->Record();
        $rcCli = $osCli->GetIt();
        $idCli = $rcCli->GetKeyValue();
        $this->SetClientID($idCli);
        return $idCli;
    }
    /*----
      ACTION:
        * use [accounts table]->AuthorizeLogin() to check credentials
        * update the current Session record accordingly
        * (LATER IF NEEDED: save the login status object locally)
      RETURNS: login status object
      TODO: Record event before starting login, then log an event_done after trying it.
      DEBUGPOINT for login processes
    */
    public function UserLogin(string $sUser,string $sPass) : \ferret\users\cLoginStatus {
        // prepare data for event log
        $arData = array(
          'user'	=> $sUser
          );
 
        // check login credentials
        $tUsers = $this->AccountTable()->GetIt();		// fctUserAccts
        $osLogin = $tUsers->AuthorizeLogin($sUser,$sPass);	// fcLoginStatus
        #echo 'OSLOGIN: '.$osLogin->DumpHTML().'<br>';
        #$this->SetLoginStatus($osLogin);
 
        $arLog = array(                            // $arEventExt
          'user.login'  => $sUser,
          'action'      => 'login',
          );
 
        //$this->SetUserStatus($osUser);
        if ($osLogin->GetOkay()) {
            // SUCCESSFUL LOGIN
 
            $osrcUser = $osLogin->Account()->Record();
 
            // set user for this session
            $idUser = $osrcUser->GetIt()->GetKeyValue();
            $this->SetUserID($idUser);
 
            $arLog['success'] = 'Y';
            $this->CreateEvent(
              KS_EVENT_FERRETERIA_LOGIN_OKAY,   // $sCode
              $arLog                            // $arEventExt
              );
 
            $this->DoUpdate(
              array(
                'ID_Acct'	=> $idUser,
                'WhenUsed'	=> 'NOW()'
                )
              );
 
            // TODO: check results from DoUpdate() and log results with above event
 
            /* 2020-03-11 old event system
            $tEvents->CreateBaseEvent(
              KS_EVENT_FERRETERIA_LOGIN_OKAY,	// $sCode
              $sText,				// $sText
              $arData				// array $arData = NULL
              );				
              */
        } else {
            // LOGIN ATTEMPT FAILED
 
            // clear any existing user (security precaution):
            $this->ClearUserID();
            $sErr = $osLogin->GetMessage();
            #$sText = $sUser.' not logged in: '.$sErr;
 
            $arLog['success'] = 'n';
            $arLog['err.text'] = $sErr;
            $this->CreateEvent(
              KS_EVENT_FERRETERIA_LOGIN_BAD,    // $sCode
              $arLog                            // $arEventExt
              );
 
            /* 2020-03-11 old event system
            $tEvents->CreateBaseEvent(
              KS_EVENT_FERRETERIA_LOGIN_BAD,
              $sText,
              $arData
              );
              */
        }
        return $osLogin;
    }
    /*----
      ACTION: Logs the current user out. (Clears ID_Acct in session record.)
      HISTORY:
        2020-12-12 Significant updates & changes.
    */
    public function UserLogout() : void {
        $oApp = \fcApp::Me();
        $db = $this->Database()->GetIt();
        #$osUser = $this->GetUserStatus();
        #$osUser = $this->AcctData()->GetTable()->LoggedInUser();
        $osAcct = $this->LoggedInUserStatus();
        #echo 'OSACCT class: '.get_class($osAcct).'<br>';
        #echo $osAcct->DumpHTML();
        #$osAcct = $osLogin->GetLoggedInUser();
        $osrcAcct = $osAcct->Record();
        if ($osrcAcct->HasIt()) {
            $rcAcct = $osrcAcct->GetIt();
            #echo 'RCACCT class '.get_class($rcAcct).'<br>';
            $sLogin = $rcAcct->LoginName();
            $arData = array(
              'user.login'	=> $sLogin,
              'user.id'	=> $rcAcct->GetKeyValue(),
              'what'    => $sLogin.' logged out',
              );
            #$idEvent = $oApp->CreateEvent(KS_EVENT_FERRETERIA_LOGOUT,$sLogin.' logged out',$arData);
            $osEvent = \fcApp::Me()->LogData()->CreateEvent(KS_EVENT_FERRETERIA_LOGOUT,$arData);
            $isProblem = !$db->GetOkay();
 
            #echo 'IDEVENT CLASS: '.get_class($idEvent).'<br>'; throw new exception('2020-01-07 Just checking...');	// this *should* be a status object
            if ($isProblem) {
                echo 'SQL: '.$db->sql;
                throw new exception('Ferreteria event-logging error recording user logout: "'.$db->ErrorString());
            }
            $this->SetCell('ID_Acct',NULL);
            $arUpd = array(
              'ID_Acct'=>'NULL'
              );
            $this->DoUpdate($arUpd);
            $isProblem = !$db->GetOkay();
 
            if ($isProblem) {
                // 2021-04-20 This could use some updating.
                echo 'SQL: '.$db->sql;
                throw new \exception(
                  'Ferreteria event-logging error recording completion of user logout: "'.
                  $db->ErrorString()
                  );
            }
        } else {
            #$oLog = $oApp->LogData(); // \ferret\odata\cLogData
            $osIns = $this->CreateEvent( // cInsertStatus
              KS_EVENT_FERRETERIA_LOGOUT,
              array(
                'summary' => 'redundant logout' // 2020-12-12 dunno if this will work
                )
              );
            // WORKING HERE
            #$idEvent = $oApp->CreateEvent(KS_EVENT_FERRETERIA_LOGOUT,'redundant logout');
        }
    }
    /*----
      TODO: convert this to use UpdateArray() and Save().
      HISTORY:
        2016-12-21 No longer needs to be public, so making it protected.
    */
    protected function SaveUserID(int $idUser) : void {
        $ar = array('ID_Acct'=>$idUser);
        $this->DoUpdate($ar);				// save account ID to database
        $this->SetFieldValue('ID_Acct',$idUser);	// update it in RAM as well
    }
 
    // -- ACTIONS -- //
    // ++ DEPRECATED ++ //
 
    /*---
      NOTE: As of 2016-11-03, this will return the same result as UserIsLoggedIn() because
        we use UserID > 0 as a way of detecting whether the user is logged in -- but that
        might change. This function will always return a boolean which answers the question
        "do we know the user's ID?". That might conceivably different if, say, we want to
        access some non-sensitive information about the user such as layout preferences.
        Some sites will recognize users in that sort of way even when they are logged out.
        I'm not sure if this is good security practice, but it's a possibility which
        should be allowed for in the API even if Ferreteria doesn't currently support it.
    */
    public function UserIsKnown() : bool {
	throw new exception('2020-01-08 Who calls this, and why?');
	return $this->GetUserID() > 0;	// for now, user ID is cleared from session when user is logged out
    }
    /*----
      ACTION: retrieve this session's user record or throw an error
      HISTORY:
        2020-01-10 created
    */
    protected function GetLoggedInUserRecord() : crcAcct {
	throw new exception('2020-01-10 Call LoggedInUserStatus()->GetHasRow() instead.');
	if (is_null($this->rcUser)) {
	    $sErrBase = 'Ferreteria internal error: trying to retrieve logged-in user record, but ';
	    $idUser = $this->GetUserID();
	    if (is_null($idUser)) {
		throw new exception($sErrBase.'session has no user attached.');
		// Reminder: "logged in" means that the session has a user ID attached.
	    }
	    $osrUser = $this->AcctData($idUser);
	    if ($osrUser->GetHasRow()) {
		$this->rcUser = $osrUser->GetRow();
	    } else {
		throw new exception($sErrBase."user record for ID=$idUser could not be loaded.");
	    }
	}
	return $this->rcUser;
    }
 
    // -- DEPRECATED -- //
}
class cSessionFields extends \ferret\data\cSpaceIOBank {
    #use \ferret\tFMapRouterForFieldset;
 
    protected function MakeFields() : void {
        $oField = new \ferret\field\cNative_Num($this,'ID_Client');
          #$oCtrl = new ferret\field\cDisplay_HTML_DropDown($oField);
            #$oCtrl->SetRecords($this->ClientTable()->GetRecords_forDropDown());
 
        $oField = new \ferret\field\cNative_Num($this,'ID_Acct');
          $oCtrl = new \ferret\field\cDisplay_HTML_DropDown($oField);
            $oCtrl->Records()->SetIt($this->GetStorage()->GetTable()->AccountTable()->GetRecords_forDropDown());
 
        $oField = new \ferret\field\cNative_Text($this,'Stash');
          #$oCtrl = new ferret\field\cDisplay_HTML($oField,array('size'=>60));
 
        $oField = new \ferret\field\cNative_Time($this,'WhenCreated');
          $oField->GetDisplay()->SetEditable(FALSE);
 
        $oField = new \ferret\field\cNative_Time($this,'WhenUsed');
          $oField->GetDisplay()->SetEditable(FALSE);
 
        $oField = new \ferret\field\cNative_Time($this,'WhenExpires');
    }
}