Edit via SFTP
<?php
/*
  FILE: app.php
  PURPOSE: generic/abstract application framework
  NOTE: This was written with the assumption that we're writing a *web* application,
    not desktop. If Ferreteria ever diversifies into desktop, these classes may need
    further refinement.
  HISTORY:
    2012-05-08 split off from store.php
    2013-10-23 stripped for use with ATC app (renamed as app.php)
    2013-11-11 re-adapted for general library
    2016-10-01 changes to work with db.v2
    2017-03-25 moving user-security permission constants here
*/
 
define('KWP_FERRETERIA_DOC','http://wooz.dev/Ferreteria');
define('KWP_FERRETERIA_DOC_ERRORS',KWP_FERRETERIA_DOC.'/errors');
 
// ++ EVENTS ++ //
 
define('KS_EVENT_SUCCESS','fe.OK');
define('KS_EVENT_FAILED','fe.ERR');
define('KS_EVENT_NO_CHANGE','fe.STET');	// data left unaltered
define('KS_EVENT_NEW_RECORD','fe.NEW');
define('KS_EVENT_CHANGE_RECORD','fe.CHG');
define('KS_EVENT_FERRETERIA_SUSPICIOUS_INPUT','fe.suspicious');
define('KS_EVENT_FERRETERIA_EMAIL_SENT','fe.email.sent');
define('KS_EVENT_FERRETERIA_SENDING_ADMIN_EMAIL','fe.email.req');
 
use ferret\cValueResult;
use ferret\data\cTableResult;
use ferret\data\cRecordResult;
use ferret\data\cIOResult;
 
/*::::
  PURPOSE: application framework base class -- container for the application
    This API assumes a single primary database, although it's possible to access another db explicitly.
    See Greenmine:FinanceFerret for an example.
*/
abstract class fcApp {
    use \ferret\globals\tSingleton, ftVerbalObject;
 
    // ++ STATIC ++ //
 
    static public function Globals() { return \ferret\globals\cAbstract::Me(); }
 
    // -- STATIC -- //
    // ++ SETUP ++ //
 
    private $wp = NULL;
    /*----
      INPUT: $wp = web path relative to domain
      PUBLIC so index.php setups can call it
    */
    public function SetBasePath($wp) { $this->wp = $wp; 
    throw new exception('2021-01-25 Who calls this?'); // should use GetWebPath_forAppBase()
    }
    // PUBLIC because it tentatively looks like fcLinkBuilder needs it
    protected function GetBasePath() {
    throw new exception('2021-01-25 Call GetBaseWebPath() instead.'); // should use GetWebPath_forAppBase()
        $wp = $this->wp;
        if (is_null($wp)) {
            throw new exception('Ferreteria setup error: need to call SetBasePath().');
        }
        return $wp;
    }
    //SHORTCUT
    protected function GetBaseWebPath() : string { return self::Globals()->GetWebPath_forAppBase(); }
 
    // -- SETUP -- //
    // ++ ACTION ++ //
 
    abstract public function Go();
    abstract public function AddContentString(string $s);
 
      //++cookies++//
/*
  RULES:
    * Cookie names set are prefixed with the application key (KS_APP_KEY).
    * The local array only stores cookies to be set (modified).
    * Only those are written (thrown) to the browser.
    * For received cookie values, only those prefixed with KS_APP_KEY are returned.
      (KS_APP_KEY is prepended to the keyname requested before it is looked up.)
  HISTORY:
    2018-04-24 written, originally to replace storage of stashed messages, but
      on further thought I decided it's (maybe?) more efficient to keep saving them
      on the server as I had been doing (though it's a pretty close call).
      ...but it still provides a more generalized way of storing the session key,
      so now I'm using it for that.
      
      ...and then on further further thought, I decided: well, having written this,
      why not use it for now, rather than getting into the complexities of fixing
      the on-disk storage method?
    2018-04-28 removed ThrowCookie() and ThrowCookies()
      SetRawCookieValue() now actually sets (throws) the cookie.
*/
 
    private $arCookies=NULL;	// local copy of $_COOKIE[], updated with any sent-cookie values
    // 2018-04-25 written
    protected function SetRawCookieValue(string $sKey,string $sVal) : bool {
        $wpBase = $this->GetBaseWebPath();
        $ok = setcookie($sKey,$sVal,0,$wpBase);
        if ($ok) {
            $this->arCookies[$sKey] = $sVal;
        } else {
            // DEBUGGING (TODO: log when this happens)
            echo "Could not set cookie [<b>$sKey</b>] to value [<b>$sVal</b>].<br>";
            $oTrace = new ferret\cStackTrace();
            echo $oTrace->RenderAllRows();
        }
        return $ok;
    }
    /*----
      THINKING: I *think* the way it works is that cookies are set from the array,
        so only what's in the array will get set. There doesn't seem to be any
        system call for removing a cookie.
      HISTORY:
        2020-12-12 written so we can enforce string as the cookie value
    */
    public function ClearCookie(string $sKey) : void {
        unset($this->arCookies[$sKey]);
    }
    protected function GetRawCookie(string $sKey) : cValueResult {
        if (is_null($this->arCookies)) {
            $this->arCookies = $_COOKIE;
        }
        $bFound = array_key_exists($sKey,$this->arCookies);
        if ($bFound) {
            $v = $this->arCookies[$sKey];
        } else {
            $v = NULL;
        }
        $os = new cValueResult($bFound,$v);
        return $os;
    }
    static protected function MakeRawCookieKey(string $sKey) : string {
        return self::Globals()->GetAppKeyString().'-'.$sKey;
    }
    // 2018-04-24 written
    public function SetCookieValue(string $sKey,string $sValue) {
        $sKeyFull = self::MakeRawCookieKey($sKey);
        return $this->SetRawCookieValue($sKeyFull,$sValue);
    }
    // 2018-04-24 written
    public function GetCookie(string $sKey) : cValueResult {
        $sKeyFull = self::MakeRawCookieKey($sKey);
        return $this->GetRawCookie($sKeyFull);
    }
 
      //--cookies--//
 
    // -- ACTION -- //
    // ++ CLASSES ++ //
 
    abstract protected function GetPageClass() : string;
    abstract protected function GetKioskClass() : string;
    abstract protected function GetDropinManagerClass() : string;
 
    // -- CLASSES -- //
    // ++ OBJECTS ++ //
 
    private $oPage = NULL;
    public function GetPageObject() {
        if (is_null($this->oPage)) {
            $sClass = $this->GetPageClass();
            //echo "PAGE CLASS = $sClass<br>";
            $this->oPage = new $sClass();
        }
        return $this->oPage;
    }
    private $oKiosk=NULL;
    public function GetKioskObject() {
        if (is_null($this->oKiosk)) {
            $sClass = $this->GetKioskClass();
            $this->oKiosk = new $sClass($this->GetBaseWebPath());
        }
        return $this->oKiosk;
    }
    #protected function CreateKioskObject(string $sClass,string $wp) { $this->oKiosk = new $sClass($wp); }
    private $oHdrMenu = NULL;
    public function GetHeaderMenu() {
        if (is_null($this->oHdrMenu)) {
            $this->oHdrMenu = $this->GetPageObject()->GetElement_HeaderMenu();
        }
        return $this->oHdrMenu;
    }
    private $oDropinMgr = NULL;
    public function GetDropinManager() {
        if (is_null($this->oDropinMgr)) {
            $sClass = $this->GetDropinManagerClass();
            // not a sourced table
            $db = $this->GetDatabase();
            //return $db->MakeTableWrapper($sClass);
            $this->oDropinMgr = new $sClass($db);
        }
        return $this->oDropinMgr;
    }
 
    // -- OBJECTS -- //
    // ++ FIELD CALCULATIONS ++ //
 
    /*----
      NOTES:
        2016-05-22 It seems like a good idea to have this, to pass to record-creation methods that ask for it.
        2020-01-10 Maybe it should return a cElementResult and be called UserID_status()?
        2021-03-26 That * was done awhile back. Just now changed cElementResult to cValueResult.
      PUBLIC so cart.logic can use it
    */
    public function GetUserID() {
	throw new exception('2020-01-11 Call StatusOfUserID()->GetValue() instead.');
	return $this->GetSessionStatus()->Record()->GetIt()->GetUserID();
    }
    public function StatusOfUserID() : ferret\cValueResult {
        return $this->GetSessionStatus()->Record()->GetIt()->UserID();
    }
    /*----
      RETURNS: User login string, or NULL if user not logged in
      HISTORY:
        2014-07-27 Written because this seems to be where it belongs.
          May duplicate functionality in Page object. Why is that there?
    */
    public function LoginName() : ?string {
        if ($this->UserIsLoggedIn()) {
            return $this->GetUserRecord()->LoginName();
        } else {
            return NULL;
        }
    }
 
    // -- FIELD CALCULATIONS -- //
}
/*::::
  PURPOSE: App which needs data storage
  HISTORY:
    2020-11-26 split off from fcApp
*/
abstract class fcDataApp extends fcApp {
 
    static protected function TableNamesClass() : string { return \ferret\globals\cTableNames::class; }
    static private $ogTableNames = NULL;
    static public function TableNames() : \ferret\globals\cTableNames {
        if (is_null(self::$ogTableNames)) {
            $sClass = static::TableNamesClass();
            self::$ogTableNames = new $sClass();
        }
        return self::$ogTableNames;
    }
 
    abstract public function GetDatabase() : ferret\data\cDatabase;
 
}
/*::::
  PURPOSE: App which manages user logins
  HISTORY:
    2020-11-26 split off from fcApp
      Some of this stuff could be abstracted into a user-management API,
        e.g. for using LDAP or something, but for now I'm just implementing it.
*/
abstract class fcUserApp extends fcDataApp {
 
    // -- STATUS -- //
    // ++ SESSIONS ++ //
 
    protected function GetSessionsClass() : string { return KS_CLASS_USER_SESSIONS; }
 
    public function GetSessionTable() {
        $db = $this->GetDatabase();
        $sClass = $this->GetSessionsClass();
        return $db->MakeTable($sClass);
    }
    public function GetSessionStatus() : cIOResult {
        return $this->GetSessionTable()->GetIt()->MakeActiveRecord();    }
 
    // -- SESSIONS -- //
    // ++ USERS ++ //
 
    protected function GetUsersClass() : string { return \ferret\users\ctUserAccts::class; }
 
    public function UserData($id=NULL) : cTableResult {
        throw new \exception('2021-01-14 Call UserTable() or UserRecord().');
        $db = $this->GetDatabase();
        return $db->MakeTableWrapper($this->GetUsersClass(),$id);
    }
    public function UserTable() : cTableResult {
        $db = $this->GetDatabase();
        return $db->MakeTable($this->GetUsersClass());
    }
    public function UserRecord(int $id) : cRecordResult {
        $db = $this->GetDatabase();
        return $db->MakeRow($this->GetUsersClass(),$id);
    }
 
    /*----
      RETURNS: status that indicates whether the current session has a logged-in user or not
      ASSUMES: There is always a current session, and it has been updated.
      NOTE: This is the app global, so we're never interested in users aside from the current active one.
      TODO: rename to CurrentUserStatus()
    */
    public function UserStatus() : cIOResult {
        $osSess = $this->GetSessionStatus();
        $osUser = $osSess->Record()->GetIt()->LoggedInUserStatus();
        return $osUser;
    }
    /*----
      RETURNS: TRUE iff the user is logged in
      NOTE: This does not alias to UserStatus()->GetHasRow() because
        that requires retrieving the user record, which we don't need
        to do. All we need here is whether the session record has a User ID set.
    */
    public function UserIsLoggedIn() : bool { return $this->GetSessionStatus()->Record()->GetIt()->UserIsLoggedIn(); }
    public function CurrentUserCan(string $sPerm) : bool {
        $osrcUser = $this->UserStatus()->Record();
        if ($osrcUser->HasIt()) {
            $rcUser = $osrcUser->GetIt();
            return $rcUser->CanDo(self::Globals()->Perm_CanViewAllNodes());
        } else {
            return FALSE;
        }
    }
 
    // ++ USERS ++ //
}
/*::::
  ABSTRACT: n/i - GetDatabase(), GetPageClass(), GetKioskClass()
  HISTORY:
    2019-08-17 moved GetAppKeyString() into fcGlobals as an abstract
    2020-01-18 adding OData access methods
*/
abstract class fcAppStandard extends fcUserApp {
 
    // ++ MAIN ++ //
 
    public function Go() {
        try {
            $this->Main();
        } catch(\ferret\except\cBase | \ferret\except\cDebug $e) {
            echo $e->React();
        } catch(Throwable $e) {
            $code = $e->getCode();
            $sNative = ($code==0)?'':"<b>Native error $code</b> caught:";
            $ex = new \ferret\except\cWrapper($e);
 
            echo '<html><head><title>'
              .self::Globals()->GetText_SiteName_short()
              .' native error catch</title></head><body>'
              /*
              .$sNative
              .'<ul>'
              .'<li><b>Error message</b>: '.$e->getMessage().'</li>'
              .'<li><b>Thrown in</b> '.$e->getFile().' <b>line</b> '.$e->getLine()
              .'<li><b>Stack trace</b>:'.nl2br($e->getTraceAsString()).'</li>'
              .'</ul>'
              */
              .$ex->MessageToShow()
              .'<br><small>Caught '.get_class($e).' code '.$e->getCode().' in '.__FILE__.' line '.__LINE__.'</small>'
              .'</body></html>'
              ;
            // TO DO: generate an email as well (2019-08-17 It was doing this awhile back, but I disabled it somewhere because self-spam?)
        }
    }
    public function ReportSimpleError(string $s) {
        $this->DoEmail_fromAdmin_Auto(
          static::Globals()->GetEmailAddr_forErrors(),
          static::Globals()->GetEmailName_forErrors(),
          'Silent Internal Error',$s);
        // TODO: log it also?
    }
    protected function Main() {
        $db = $this->GetDatabase();
        $db->Open();
        if ($db->Action()->GetOkay()) {
            $oPage = $this->GetPageObject();
            $oPage->DoBuilding();
            $oPage->DoFiguring();
            $oPage->DoOutput();
            $db->Shut();
        } else {
            throw new fcDebugException('Ferreteria Config Error: Could not open the database.');
        }
    }
 
    // -- MAIN -- //
    // ++ PROFILING ++ //
 
    private $fltStart;
    public function SetStartTime() { $this->fltStart = microtime(true); }
    /*----
      RETURNS: how long since StartTime() was called, in microseconds
    */
    protected function ExecTime() { return microtime(true) - $this->fltStart; }
 
    // -- PROFILING -- //
    // ++ CLASSES ++ //
 
    /*
      NOTE: These should return the non-admin versions of classes.
        fcAppStandardAdmin will override with admin classes.
    */
 
    /* 2020-02-02 not needed anymore
    abstract protected function GetEventsClass();
    */
    abstract protected function GetODataClass() : string;
    protected function GetDropinManagerClass() : string { return ferret\ctDropInManager::class; }
 
    // -- CLASSES -- //
    // ++ TABLES ++ //
 
    protected function LogDataClass() : string { return \ferret\odata\cLogData::class; }
    private $oLog = NULL;
    public function LogData() : \ferret\odata\cLogData {
        if (is_null($this->oLog)) {
            $sClass = $this->LogDataClass();
            $this->oLog = new $sClass($this->GetDatabase());
        }
        return $this->oLog;
    }
    public function ODataTable() : cTableResult {
        $db = $this->GetDatabase();
        return $db->MakeTableWrapper($this->GetODataClass());
    }
 
    // -- TABLES -- //
    // ++ LOGGING ++ //
 
    public function CreateEvent($sCode,$sText,array $arData=NULL) {
    throw new exception('2020-03-11 No longer using this way of creating events.');
    // Use tLoggable* trait and call $this->CreateEvent(), or $this->LogData()->Nodes()->CreateEvent()
	$t = $this->EventData()->GetTable();
	$id = $t->CreateBaseEvent($sCode,$sText,$arData);
	return $id;
    }
    public function FinishEvent($idEvent,$sState,$sText=NULL,array $arData=NULL) {
	throw new exception('2019-08-05 Is anything still using this?');
	$t = $this->EventTable_Done();
	$t->CreateRecord($idEvent,$sState,$sText,$arData);
    }
 
    // -- LOGGING -- //
    // ++ ACTION ++ //
 
    // CEMENT
 
    public function AddContentString(string $s) {
        $oPage = $this->GetPageObject();
        $oPage->GetElement_PageContent()->AddText($s);
    }
 
    // -- ACTION -- //
    // ++ EMAIL ++ //
 
    // TODO: rename to GetEmailAddress_FROM()
    protected function EmailAddr_FROM(string $sTag) {
        $ar = array('tag'=>$sTag);
        $tplt = new fcTemplate_array(
          \fcApp::Globals()->GetTemplateVariableOpen(),
          \fcApp::Globals()->GetTemplateVariableShut(),
          $ar
          );
 
        return $tplt->Replace(self::Globals()->GetTemplate_EmailAddress_Admin());
    }
    /*----
      ACTION: send an automatic email (i.e. not a message from an actual person)
      USAGE: called from clsEmailAuth::SendPassReset_forAddr()
    */
    /* 2020-11-17 presumably no longer needed
    public function DoEmail_fromAdminAuto_OLD(string $sToAddr,string $sToName,string $sSubj,string $sMsg) {
        if (empty($sToName)) {
            $sAddrToFull = $sToAddr;
        } else {
            $sAddrToFull = $sToName.' <'.$sToAddr.'>';
        }
 
        $sHdr = 'From: '.$this->EmailAddr_FROM(date('Y'));
        $ok = mail($sAddrToFull,$sSubj,$sMsg,$sHdr);
        return $ok;
    } */
    /*----
      ACTION: send an automatic administrative email
      USAGE: called from vcPageAdmin::SendEmail_forLoginSuccess()
      TODO: should probably return cActionStatus (or similar) instead.
    */
    public function DoEmail_fromAdmin_Auto(string $sToAddr,string $sToName,string $sSubj,string $sMsg) : bool {
        if ($this->UserIsLoggedIn()) {
            $idUser = $this->StatusOfUserID()->GetIt();
            $sTag = 'user-'.$idUser;
        } else {
            $sTag = date('Y');
        }
 
        $oSpec = new ferret\strings\cTemplateSpec_standard(
          self::Globals()->GetTemplate_EmailAddress_Admin()
          );
        $oTplt = new \ferret\strings\cTemplate_array($oSpec);
        /*
        $oTplt = new \ferret\strings\cTemplate_array(
          \fcApp::Globals()->GetTemplateVariableOpen(),
          \fcApp::Globals()->GetTemplateVariableShut(),
          self::Globals()->GetTemplate_EmailAddress_Admin()
          );
        */
        $oTplt->SetVariableValues(array('tag'=>$sTag));
 
        $sAddrFrom = $oTplt->Render();
        if (empty($sToName)) {
            $sAddrToFull = $sToAddr;
        } else {
            $sAddrToFull = $sToName.' <'.$sToAddr.'>';
        }
 
        $sHdr = 'From: '.$sAddrFrom;
        $ok = mail($sAddrToFull,$sSubj,$sMsg,$sHdr);
 
        $arData = array(
            'action'    => 'admin email sent',
            'to-addr'	=> $sToAddr,
            'to-name'	=> $sToName,
            'subject'	=> $sSubj,
            'message'	=> $sMsg
          );
        #$this->CreateEvent(KS_EVENT_FERRETERIA_EMAIL_SENT,'admin email sent',$arData);
        $os = $this->LogData()->Nodes()->CreateEvent(KS_EVENT_FERRETERIA_EMAIL_SENT,$arData);   // cInsertStatus
 
        if (!$os->GetOkay()) {
            // 2020-03-11 honestly not sure what should happen here but this at least seems like a good idea:
            $ok = FALSE;
        }
 
        return $ok;
    }
 
    // -- EMAIL -- //
 
}
/*----
  PURPOSE: standard App class with basic functionality, no admin stuff
  HISTORY:
    2018-04-27 created for better handling of Event classes
    2019-06-15 adding Kiosk class because everything seems to need that, one way or another
  ABSTRACT: needs GetPageClass(), GetDatabase()
*/
abstract class fcAppStandardBasic extends fcAppStandard {
 
    // ++ CLASSES ++ //
 
    protected function GetEventsClass() : string { return fctEventPlex_standard::class; }
    protected function GetODataClass() : string { return \ferret\odata\ctNodeLogic::class; }
    protected function GetKioskClass() : string { return \fcMenuKiosk::class; }
 
    // -- CLASSES -- //
}
/*----
  PURPOSE: standard App class with admin functionality
  HISTORY:
    2018-04-27 created for better handling of Event classes
*/
abstract class fcAppStandardAdmin extends fcAppStandard {
 
    // ++ CLASSES ++ //
 
    /* 2020-02-02 no longer needed
    protected function GetEventsClass() {
	return KS_CLASS_EVENT_LOG_ADMIN;
    } */
    protected function LogDataClass() : string { return \ferret\odata\cAdminLogData::class; }
    protected function GetODataClass() : string { return KS_CLASS_ADMIN_NODE_CORE; }
    // OVERRIDE
    protected function GetSessionsClass() : string {
        if (ferret\ctDropInManager::Me()->HasDropinModule('ferret.users')) {
            return KS_CLASS_ADMIN_USER_SESSIONS;	// admin features
        } else {
            return parent::GetSessionsClass();		// basic logic
        }
    }
    // OVERRIDE
    protected function GetUsersClass() : string { return KS_CLASS_ADMIN_USER_ACCOUNTS; }
 
    // -- CLASSES -- //
}