* Copyright 2001-2012 Strangecode, LLC * * This file is part of The Strangecode Codebase. * * The Strangecode Codebase is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as published by the * Free Software Foundation, either version 3 of the License, or (at your option) * any later version. * * The Strangecode Codebase is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along with * The Strangecode Codebase. If not, see . */ /** * App.inc.php * * Primary application framework class. * * @author Quinn Comendant * @version 2.1 */ // Message Types. define('MSG_ERR', 1); define('MSG_ERROR', MSG_ERR); define('MSG_WARNING', 2); define('MSG_NOTICE', 4); define('MSG_SUCCESS', 8); define('MSG_ALL', MSG_SUCCESS | MSG_NOTICE | MSG_WARNING | MSG_ERROR); require_once dirname(__FILE__) . '/Utilities.inc.php'; class App { // Minimum version of PHP required for this version of the Codebase. const CODEBASE_MIN_PHP_VERSION = '5.3.0'; // A place to keep an object instance for the singleton pattern. protected static $instance = null; // Namespace of this application instance. protected $_ns; // If $app->start has run successfully. public $running = false; // Instance of database object (from mysql_connect() PHP version < 7). public $db; // Instance of PDO object. public $pdo; // Instance of database session handler object. public $db_session; // Array of query arguments will be carried persistently between requests. protected $_carry_queries = array(); // Array of raised message counters. protected $_raised_msg_counter = array(MSG_NOTICE => 0, MSG_SUCCESS => 0, MSG_WARNING => 0, MSG_ERR => 0); // We're running as CLI. Public because we must force this as false when testing sessions via CLI. public $cli = false; // Dictionary of global application parameters. protected $_params = array(); // Default parameters. protected $_param_defaults = array( // Public name and email address for this application. 'site_name' => null, 'site_email' => '', // Set to no-reply@HTTP_HOST if not set here. 'site_hostname' => '', // The hostname of this application (if not set, derived from HTTP_HOST). 'site_port' => '', // The hostname of this application (if not set, derived from HTTP_HOST). 'site_url' => '', // URL to the root of the site (created during App->start()). 'page_url' => '', // URL to the current page (created during App->start()). 'images_path' => '', // Location for codebase-generated interface widgets (ex: "/admin/i"). 'site_version' => '', // Version of this application (set automatically during start() if site_version_file is used). 'site_version_file' => 'docs/version.txt', // File containing version number of this app, relative to the include path. // The location the user will go if the system doesn't know where else to send them. 'redirect_home_url' => '/', // Use CSRF tokens. See notes in the getCSRFToken() method. 'csrf_token_enabled' => true, // Form tokens will expire after this duration, in seconds. 'csrf_token_timeout' => 86400, // 86400 seconds = 24 hours. 'csrf_token_name' => 'csrf_token', // HMAC signing method 'signing_method' => 'sha512+base64', // Content type of output sent in the Content-type: http header. 'content_type' => 'text/html', // Allow HTTP caching with max-age setting. Possible values: // >= 1 Allow HTTP caching with this value set as the max-age (in seconds, i.e., 3600 = 1 hour). // 0 Disallow HTTP caching. // false Don't send any cache-related HTTP headers (if you want to control this via server config or custom headers) // This should be '0' for websites that use authentication or have frequently changing dynamic content. 'http_cache_headers' => 0, // Character set for page output. Used in the Content-Type header and the HTML tag. 'character_set' => 'utf-8', // Human-readable format used to display dates. 'date_format' => 'd M Y', // Format accepted by DateTimeInterface::format() https://www.php.net/manual/en/datetime.format.php 'time_format' => 'h:i A', // Format accepted by DateTimeInterface::format() https://www.php.net/manual/en/datetime.format.php 'lc_date_format' => '%d %b %Y', // Localized date for strftime() https://www.php.net/manual/en/function.strftime.php 'lc_time_format' => '%k:%M', // Localized time for strftime() https://www.php.net/manual/en/function.strftime.php 'sql_date_format' => '%e %b %Y', 'sql_time_format' => '%k:%i', // Timezone support. No codebase apps currently support switching timezones, but we explicitly set these so they're consistent. 'user_timezone' => 'UTC', 'php_timezone' => 'UTC', 'db_timezone' => 'UTC', // Use php sessions? 'enable_session' => false, 'session_name' => '_session', 'session_use_cookies' => true, // Pass the session-id through URLs if cookies are not enabled? // Disable this to prevent session ID theft. 'session_use_trans_sid' => false, // Use database? 'enable_db' => false, 'enable_db_pdo' => false, // Use db-based sessions? 'enable_db_session_handler' => false, // db_* parameters are passed to the DB object in $app->start(). // DB credentials should be set as apache environment variables in httpd.conf, readable only by root. 'db_server' => null, 'db_name' => null, 'db_user' => null, 'db_pass' => null, 'db_character_set' => '', 'db_collation' => '', // CLI scripts will need this JSON file in the include path. 'db_auth_file' => 'db_auth.json', // Database debugging. 'db_always_debug' => false, // TRUE = display all SQL queries. 'db_debug' => false, // TRUE = display db errors. 'db_die_on_failure' => false, // TRUE = script stops on db error. // For classes that require db tables, do we check that a table exists and create if missing? 'db_create_tables' => true, // The level of error reporting. Don't change this to suppress messages, instead use display_errors to control display. 'error_reporting' => E_ALL, // Don't display errors by default; it is preferable to log them to a file. For CLI scripts, set this to the string 'stderr'. 'display_errors' => false, // Directory in which to store log files. 'log_directory' => '', // PHP error log. 'php_error_log' => 'php_error_log', // General application log. 'log_filename' => 'app_log', // Don't email or SMS duplicate messages that happen more often than this value (in seconds). 'log_multiple_timeout' => 3600, // Hourly // Logging priority can be any of the following, or false to deactivate: // LOG_EMERG system is unusable // LOG_ALERT action must be taken immediately // LOG_CRIT critical conditions // LOG_ERR error conditions // LOG_WARNING warning conditions // LOG_NOTICE normal, but significant, condition // LOG_INFO informational message // LOG_DEBUG debug-level message 'log_file_priority' => LOG_INFO, 'log_email_priority' => false, 'log_sms_priority' => false, 'log_screen_priority' => false, // Email address to receive log event emails. Use multiple addresses by separating them with commas. 'log_to_email_address' => null, // SMS Email address to receive log event SMS messages. Use multiple addresses by separating them with commas. 'log_to_sms_address' => null, // Should we avoid logging repeated logMsg() events? You might want to set this false if you need to see more accurate logging, particularly for long-running scripts. 'log_ignore_repeated_events' => true, // Maximum length of log messages, in bytes. 'log_message_max_length' => 1024, // Strip line-endings from log messages. 'log_serialize' => true, // Temporary files directory. 'tmp_dir' => '/tmp', // Session files directory. If not defined, the default value from php.ini will be used. 'session_dir' => '', // A key for calculating simple cryptographic signatures. Set using as an environment variables in the httpd.conf with 'SetEnv SIGNING_KEY '. // Existing password hashes rely on the same key/salt being used to compare encryptions. // Don't change this unless you know existing hashes or signatures will not be affected! 'signing_key' => 'aae6abd6209d82a691a9f96384a7634a', // Force getFormData, getPost, and getGet to always run dispelMagicQuotes() with stripslashes(). // This should be set to 'true' when using the codebase with Wordpress because // WP forcefully adds slashes to all input despite the setting of magic_quotes_gpc (which was removed from PHP in v5.4). 'always_dispel_magicquotes' => false, // The /u pattern modifier should only be used on UTF-8 strings. This value will be changed to `u` if character_set = `utf-8`. // Use the unicode modifier like this: preg_replace('/[^0-9]/' . $app->getParam('preg_u'), '', $str); 'preg_u' => '', ); /** * Constructor. */ public function __construct($namespace='') { // Initialize default parameters. $this->_params = array_merge($this->_params, array('namespace' => $namespace), $this->_param_defaults); // Begin timing script. require_once dirname(__FILE__) . '/ScriptTimer.inc.php'; $this->timer = new ScriptTimer(); $this->timer->start('_app'); // Are we running as a CLI? $this->cli = ('cli' === php_sapi_name() || defined('_CLI')); } /** * This method enforces the singleton pattern for this class. Only one application is running at a time. * * $param string $namespace Name of this application. * @return object Reference to the global Cache object. * @access public * @static */ public static function &getInstance($namespace='') { if (self::$instance === null) { // FIXME: Yep, having a namespace with one singleton instance is not very useful. This is a design flaw with the Codebase. // We're currently getting instances of App throughout the codebase using `$app =& App::getInstance();` // with no way to determine what the namespace of the containing application is (e.g., `public` vs. `admin`). // Option 1: provide the project namespace to all classes that use App, and then instantiate with `$app =& App::getInstance($this->_ns);`. // In this case the namespace of the App and the namespace of the auxiliary class must match. // Option 2: may be to clone a specific instance to the "default" instance, so, e.g., `$app =& App::getInstance();` // refers to the same namespace as `$app =& App::getInstance('admin');` // Option 3: is to check if there is only one instance, and return it if an unspecified namespace is requested. // However, in the case when multiple namespaces are in play at the same time, this will fail; unspecified namespaces // would cause the creation of an additional instance, since there would not be an obvious named instance to return. self::$instance = new self($namespace); } if ('' != $namespace) { // We may not be able to request a specific instance, but we can specify a specific namespace. // We're ignoring all instance requests with a blank namespace, so we use the last given one. self::$instance->_ns = $namespace; } return self::$instance; } /** * Set (or overwrite existing) parameters by passing an array of new parameters. * * @access public * @param array $param Array of parameters (key => val pairs). */ public function setParam($param=null) { if (isset($param) && is_array($param)) { // Merge new parameters with old overriding old ones that are passed. $this->_params = array_merge($this->_params, $param); if ($this->running) { // Params that require additional processing if set during runtime. foreach ($param as $key => $val) { switch ($key) { case 'namespace': $this->logMsg(sprintf('Setting namespace not allowed', null), LOG_WARNING, __FILE__, __LINE__); return false; case 'session_name': session_name($val); return true; case 'session_use_cookies': if (session_status() !== PHP_SESSION_ACTIVE) { ini_set('session.use_cookies', $val); } else { $this->logMsg('Unable to set session_use_cookies; session is already active', LOG_NOTICE, __FILE__, __LINE__); } return true; case 'error_reporting': ini_set('error_reporting', $val); return true; case 'display_errors': ini_set('display_errors', $val); return true; case 'log_errors': ini_set('log_errors', true); return true; case 'log_directory': if (is_dir($val) && is_writable($val)) { ini_set('error_log', $val . '/' . $this->getParam('php_error_log')); } return true; } } } } } /** * Return the value of a parameter. * * @access public * @param string $param The key of the parameter to return. * @param string $default The value to return if $param does not exist in $this->_params. * @return mixed Parameter value, or null if not existing. */ public function getParam($param=null, $default=null) { if ($param === null) { return $this->_params; } else if (array_key_exists($param, $this->_params)) { return $this->_params[$param]; } else { return $default; } } /** * Begin running this application. * * @access public * @author Quinn Comendant * @since 15 Jul 2005 00:32:21 */ public function start() { if ($this->running) { return false; } // Error reporting. ini_set('error_reporting', $this->getParam('error_reporting')); ini_set('display_errors', $this->getParam('display_errors')); ini_set('log_errors', true); if (is_dir($this->getParam('log_directory')) && is_writable($this->getParam('log_directory'))) { ini_set('error_log', $this->getParam('log_directory') . '/' . $this->getParam('php_error_log')); } // Set character set to use for multi-byte string functions. mb_internal_encoding($this->getParam('character_set')); ini_set('default_charset', $this->getParam('character_set')); switch (mb_strtolower($this->getParam('character_set'))) { case 'utf-8' : $this->setParam(['preg_u' => 'u']); mb_language('uni'); break; case 'iso-2022-jp' : mb_language('ja'); break; case 'iso-8859-1' : default : mb_language('en'); break; } // Server timezone used internally by PHP. if ($this->getParam('php_timezone')) { $this->setTimezone($this->getParam('php_timezone')); } // If external request was HTTPS but internal request is HTTP, set $_SERVER['HTTPS']='on', which is used by the application to determine that TLS features should be enabled. if (strtolower(getenv('HTTP_X_FORWARDED_PROTO')) == 'https' && strtolower(getenv('REQUEST_SCHEME')) == 'http') { $this->logMsg(sprintf('Detected HTTPS via X-Forwarded-Proto; setting HTTPS=on', null), LOG_DEBUG, __FILE__, __LINE__); putenv('HTTPS=on'); // Available via getenv(…) isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] = 'on'; // Available via $_SERVER[…] } /** * 1. Start Database. */ if (true === $this->getParam('enable_db') || true === $this->getParam('enable_db_pdo')) { // DB connection parameters taken from environment variables in the server httpd.conf file (readable only by root)… if (isset($_SERVER['DB_SERVER']) && '' != $_SERVER['DB_SERVER'] && null === $this->getParam('db_server')) { $this->setParam(array('db_server' => $_SERVER['DB_SERVER'])); } if (isset($_SERVER['DB_NAME']) && '' != $_SERVER['DB_NAME'] && null === $this->getParam('db_name')) { $this->setParam(array('db_name' => $_SERVER['DB_NAME'])); } if (isset($_SERVER['DB_USER']) && '' != $_SERVER['DB_USER'] && null === $this->getParam('db_user')) { $this->setParam(array('db_user' => $_SERVER['DB_USER'])); } if (isset($_SERVER['DB_PASS']) && '' != $_SERVER['DB_PASS'] && null === $this->getParam('db_pass')) { $this->setParam(array('db_pass' => $_SERVER['DB_PASS'])); } // DB credentials for CLI scripts stored in a JSON file with read rights given only to the user who will be executing the scripts: -r-------- // But not if all DB credentials have been defined already by other means. if ($this->cli && '' != $this->getParam('db_auth_file') && (!$this->getParam('db_server') || !$this->getParam('db_name') || !$this->getParam('db_user') || !$this->getParam('db_pass'))) { if (false !== ($db_auth_file = stream_resolve_include_path($this->getParam('db_auth_file')))) { if (is_readable($db_auth_file)) { $db_auth = json_decode(file_get_contents($db_auth_file), true); if (is_null($db_auth)) { $this->logMsg(sprintf('Unable to decode json in DB auth file: %s', $db_auth_file), LOG_ERR, __FILE__, __LINE__); } else { $this->setParam($db_auth); } } else { $this->logMsg(sprintf('Unable to read DB auth file: %s', $db_auth_file), LOG_ERR, __FILE__, __LINE__); } } else { $this->logMsg(sprintf('DB auth file not found: %s', $this->getParam('db_auth_file')), LOG_ERR, __FILE__, __LINE__); } } // If the app wants a DB connection, always set up a PDO object. require_once dirname(__FILE__) . '/PDO.inc.php'; $this->pdo =& \Strangecode\Codebase\PDO::getInstance(); $this->pdo->setParam(array( 'db_server' => $this->getParam('db_server'), 'db_name' => $this->getParam('db_name'), 'db_user' => $this->getParam('db_user'), 'db_pass' => $this->getParam('db_pass'), 'db_always_debug' => $this->getParam('db_always_debug'), 'db_debug' => $this->getParam('db_debug'), 'db_die_on_failure' => $this->getParam('db_die_on_failure'), 'timezone' => $this->getParam('db_timezone'), 'character_set' => $this->getParam('db_character_set'), 'collation' => $this->getParam('db_collation'), )); $this->pdo->connect(); // Only create a legacy mysql_* DB object if it is explicitly requested. if (true === $this->getParam('enable_db')) { require_once dirname(__FILE__) . '/../polyfill/mysql.inc.php'; require_once dirname(__FILE__) . '/DB.inc.php'; $this->db =& DB::getInstance(); $this->db->setParam(array( 'db_server' => $this->getParam('db_server'), 'db_name' => $this->getParam('db_name'), 'db_user' => $this->getParam('db_user'), 'db_pass' => $this->getParam('db_pass'), 'db_always_debug' => $this->getParam('db_always_debug'), 'db_debug' => $this->getParam('db_debug'), 'db_die_on_failure' => $this->getParam('db_die_on_failure'), 'timezone' => $this->getParam('db_timezone'), 'character_set' => $this->getParam('db_character_set'), 'collation' => $this->getParam('db_collation'), )); $this->db->connect(); } } /** * 2. Start PHP session. */ // Use sessions if enabled and not a CLI script. if (true === $this->getParam('enable_session') && !$this->cli) { // Session parameters. // https://www.php.net/manual/en/session.security.ini.php ini_set('session.cookie_httponly', true); ini_set('session.cookie_secure', getenv('HTTPS') == 'on'); ini_set('session.cookie_samesite', 'Strict'); // Only PHP >= 7.3 // TODO: Reliance on gc_maxlifetime is not recommended. Developers should manage the lifetime of sessions with a timestamp by themselves. ini_set('session.cookie_lifetime', 604800); // 7 days. ini_set('session.gc_maxlifetime', 604800); // 7 days. ini_set('session.gc_divisor', 1000); ini_set('session.gc_probability', 1); ini_set('session.use_cookies', $this->getParam('session_use_cookies')); ini_set('session.use_only_cookies', true); ini_set('session.use_trans_sid', false); ini_set('session.use_strict_mode', true); ini_set('session.entropy_file', '/dev/urandom'); ini_set('session.entropy_length', '512'); ini_set('session.sid_length', '48'); // Only PHP >= 7.1 ini_set('session.cache_limiter', 'nocache'); if ('' != $this->getParam('session_dir') && is_dir($this->getParam('session_dir'))) { ini_set('session.save_path', $this->getParam('session_dir')); } session_name($this->getParam('session_name')); if (true === $this->getParam('enable_db_session_handler') && true === $this->getParam('enable_db')) { // Database session handling. require_once dirname(__FILE__) . '/DBSessionHandler.inc.php'; $this->db_session = new DBSessionHandler($this->db, array( 'db_table' => 'session_tbl', 'create_table' => $this->getParam('db_create_tables'), )); } // Start the session. session_start(); if (!isset($_SESSION['_app'][$this->_ns])) { // Access session data using: $_SESSION['...']. // Initialize here _after_ session has started. $_SESSION['_app'][$this->_ns] = array( 'messages' => array(), 'boomerang' => array(), ); } } /** * 3. Misc setup. */ // To get a safe hostname, remove port and invalid hostname characters. $safe_http_host = preg_replace('/[^a-z\d.:-]/' . $this->getParam('preg_u'), '', strtok(getenv('HTTP_HOST'), ':')); // FIXME: strtok shouldn't be used if there is a chance HTTP_HOST may be empty except for the port, e.g., `:80` will return `80` // If strtok() matched a ':' in the previous line, the rest of the string contains the port number (or FALSE) $safe_http_port = preg_replace('/[^0-9]/' . $this->getParam('preg_u'), '', strtok('')); if ('' != $safe_http_host && '' == $this->getParam('site_hostname')) { $this->setParam(array('site_hostname' => $safe_http_host)); } if ('' != $safe_http_port && '' == $this->getParam('site_port')) { $this->setParam(array('site_port' => $safe_http_port)); } // Site URL will become something like http://host.name.tld (no ending slash) // and is used whenever a URL need be used to the current site. // Not available on CLI scripts obviously. if ('' != $safe_http_host && '' == $this->getParam('site_url')) { $this->setParam(array('site_url' => sprintf('%s://%s%s', (getenv('HTTPS') ? 'https' : 'http'), $safe_http_host, (preg_match('/^(|80|443)$/', $safe_http_port) ? '' : ':' . $safe_http_port)))); } // Page URL will become a permalink to the current page. // Also not available on CLI scripts obviously. if ('' != $safe_http_host) { $this->setParam(array('page_url' => sprintf('%s%s', $this->getParam('site_url'), getenv('REQUEST_URI')))); } // In case site_email isn't set, use something halfway presentable. if ('' != $safe_http_host && '' == $this->getParam('site_email')) { $this->setParam(array('site_email' => sprintf('no-reply@%s', $safe_http_host))); } // A key for calculating simple cryptographic signatures. if (isset($_SERVER['SIGNING_KEY'])) { $this->setParam(array('signing_key' => $_SERVER['SIGNING_KEY'])); } // Character set. This should also be printed in the html header template. if (!$this->cli) { if (!headers_sent($h_file, $h_line)) { header(sprintf('Content-type: %s; charset=%s', $this->getParam('content_type'), $this->getParam('character_set'))); } else { $this->logMsg(sprintf('Unable to set Content-type; headers already sent (output started in %s : %s)', $h_file, $h_line), LOG_DEBUG, __FILE__, __LINE__); } } // Cache control headers. if (!$this->cli && false !== $this->getParam('http_cache_headers')) { if (!headers_sent($h_file, $h_line)) { if ($this->getParam('http_cache_headers') > 0) { // Allow HTTP caching, for this many seconds. header(sprintf('Cache-Control: no-transform, public, max-age=%d', $this->getParam('http_cache_headers'))); header('Vary: Accept-Encoding'); } else { // Disallow HTTP caching entirely. http://stackoverflow.com/a/2068407 header('Cache-Control: no-cache, no-store, must-revalidate'); // HTTP 1.1. header('Pragma: no-cache'); // HTTP 1.0. header('Expires: 0'); // Proxies. } } else { $this->logMsg(sprintf('Unable to set Cache-Control; headers already sent (output started in %s : %s)', $h_file, $h_line), LOG_DEBUG, __FILE__, __LINE__); } } // Set the version of the codebase we're using. $codebase_version_file = dirname(__FILE__) . '/../docs/version.txt'; $codebase_version = ''; if (is_readable($codebase_version_file) && !is_dir($codebase_version_file)) { $codebase_version = trim(file_get_contents($codebase_version_file)); $this->setParam(array('codebase_version' => $codebase_version)); if (!$this->cli && $this->getParam('codebase_version')) { if (!headers_sent($h_file, $h_line)) { header(sprintf('X-Codebase-Version: %s', $this->getParam('codebase_version'))); } else { $this->logMsg(sprintf('Unable to set X-Codebase-Version; headers already sent (output started in %s : %s)', $h_file, $h_line), LOG_DEBUG, __FILE__, __LINE__); } } } if (version_compare(PHP_VERSION, self::CODEBASE_MIN_PHP_VERSION, '<')) { $this->logMsg(sprintf('PHP %s required for Codebase %s, using %s; some things will break.', self::CODEBASE_MIN_PHP_VERSION, $codebase_version, PHP_VERSION), LOG_NOTICE, __FILE__, __LINE__); } // Set the application version if defined. if (false !== ($site_version_file = stream_resolve_include_path($this->getParam('site_version_file')))) { if (mb_strpos($site_version_file, '.json') !== false) { $version_json = json_decode(trim(file_get_contents($site_version_file)), true); $site_version = isset($version_json['version']) ? $version_json['version'] : null; } else { $site_version = trim(file_get_contents($site_version_file)); } $this->setParam(array('site_version' => $site_version)); } if (!$this->cli && $this->getParam('site_version')) { if (!headers_sent($h_file, $h_line)) { header(sprintf('X-Site-Version: %s', $this->getParam('site_version'))); } else { $this->logMsg(sprintf('Unable to set X-Site-Version; headers already sent (output started in %s : %s)', $h_file, $h_line), LOG_DEBUG, __FILE__, __LINE__); } } // Unset environment variables we're done with. unset($_SERVER['DB_SERVER'], $_SERVER['DB_NAME'], $_SERVER['DB_USER'], $_SERVER['DB_PASS'], $_SERVER['SIGNING_KEY']); $this->running = true; return true; } /** * Stop running this application. * * @access public * @author Quinn Comendant * @since 17 Jul 2005 17:20:18 */ public function stop() { session_write_close(); $this->running = false; $num_queries = 0; if ($this->db instanceof \DB && true === $this->getParam('enable_db')) { $num_queries += $this->db->numQueries(); if ($num_queries > 0 && true === $this->getParam('enable_db_pdo')) { // If the app wants to use PDO, warn if any legacy db queries are made. $this->logMsg(sprintf('%s queries using legacy DB functions', $num_queries), LOG_WARNING, __FILE__, __LINE__); } $this->db->close(); } if ($this->pdo instanceof \Strangecode\Codebase\PDO && (true === $this->getParam('enable_db') || true === $this->getParam('enable_db_pdo'))) { $num_queries += $this->pdo->numQueries(); $this->pdo->close(); } $mem_current = memory_get_usage(); $mem_peak = memory_get_peak_usage(); $this->timer->stop('_app'); $this->logMsg(sprintf('Script ended gracefully. Execution time: %s. Number of db queries: %s. Memory usage: %s. Peak memory: %s.', $this->timer->getTime('_app'), $num_queries, $mem_current, $mem_peak), LOG_DEBUG, __FILE__, __LINE__); } /* * @access public * @return bool True if running in the context of a CLI script. * @author Quinn Comendant * @since 10 Feb 2019 12:31:59 */ public function isCLI() { return $this->cli; } /** * Add a message to the session, which is printed in the header. * Just a simple way to print messages to the user. * * @access public * * @param string $message The text description of the message. * @param int $type The type of message: MSG_NOTICE, * MSG_SUCCESS, MSG_WARNING, or MSG_ERR. * @param string $file __FILE__. * @param string $line __LINE__. */ public function raiseMsg($message, $type=MSG_NOTICE, $file=null, $line=null) { $message = trim($message); if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if (!$this->getParam('enable_session')) { $this->logMsg(sprintf('Canceled %s, session not enabled.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if ('' == trim($message)) { $this->logMsg(sprintf('Raised message is an empty string.', null), LOG_NOTICE, __FILE__, __LINE__); return false; } // Avoid duplicate full-stops.. $message = trim(preg_replace('/\.{2}$/', '.', $message)); // Save message in session under unique key to avoid duplicate messages. $msg_id = md5($type . $message); if (!isset($_SESSION['_app'][$this->_ns]['messages'][$msg_id])) { $_SESSION['_app'][$this->_ns]['messages'][$msg_id] = array( 'type' => $type, 'message' => $message, 'file' => $file, 'line' => $line, 'count' => (isset($_SESSION['_app'][$this->_ns]['messages'][$msg_id]['count']) ? (1 + $_SESSION['_app'][$this->_ns]['messages'][$msg_id]['count']) : 1) ); } if (!in_array($type, array(MSG_NOTICE, MSG_SUCCESS, MSG_WARNING, MSG_ERR))) { $this->logMsg(sprintf('Invalid MSG_* type: %s', $type), LOG_NOTICE, __FILE__, __LINE__); } // Increment the counter for this message type. $this->_raised_msg_counter[$type] += 1; } /* * Returns the number of raised message (all, or by type) for the current script execution (this number may not match the total number of messages stored in session for multiple script executions) * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 30 Apr 2015 17:13:03 */ public function getRaisedMessageCount($type='all') { if ('all' == $type) { return array_sum($this->_raised_msg_counter); } else if (isset($this->_raised_msg_counter[$type])) { return $this->_raised_msg_counter[$type]; } else { $this->logMsg(sprintf('Cannot return count of unknown raised message type: %s', $type), LOG_WARNING, __FILE__, __LINE__); return false; } } /** * Returns an array of the raised messages. * * @access public * @return array List of messages in FIFO order. * @author Quinn Comendant * @since 21 Dec 2005 13:09:20 */ public function getRaisedMessages() { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } return isset($_SESSION['_app'][$this->_ns]['messages']) ? $_SESSION['_app'][$this->_ns]['messages'] : array(); } /** * Resets the message list. * * @access public * @author Quinn Comendant * @since 21 Dec 2005 13:21:54 */ public function clearRaisedMessages() { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } $_SESSION['_app'][$this->_ns]['messages'] = array(); } /** * Prints the HTML for displaying raised messages. * * @param string $above Additional message to print above error messages (e.g. "Oops!"). * @param string $below Additional message to print below error messages (e.g. "Please fix and resubmit"). * @param string $print_gotohash_js Print a line of javascript that scrolls the browser window down to view any error messages. * @param string $hash The #hashtag to scroll to. * @access public * @author Quinn Comendant * @since 15 Jul 2005 01:39:14 */ public function printRaisedMessages($above='', $below='', $print_gotohash_js=false, $hash='sc-msg') { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } $messages = $this->getRaisedMessages(); if (!empty($messages)) { ?>
0 && $this->getParam('display_errors') && isset($m['file']) && isset($m['line'])) { echo "\n'; } switch ($m['type']) { case MSG_ERR: echo '
' . $m['message'] . '
'; break; case MSG_WARNING: echo '
' . $m['message'] . '
'; break; case MSG_SUCCESS: echo '
' . $m['message'] . '
'; break; case MSG_NOTICE: default: echo '
' . $m['message'] . '
'; break; } } if ('' != $below) { ?>
clearRaisedMessages(); } /** * Logs messages to defined channels: file, email, sms, and screen. Repeated messages are * not repeated but printed once with count. Log events that match a sendable channel (email or SMS) * are sent once per 'log_multiple_timeout' setting (to avoid a flood of error emails). * * @access public * @param string $message The text description of the message. * @param int $priority The type of message priority (in descending order): * LOG_EMERG 0 system is unusable * LOG_ALERT 1 action must be taken immediately * LOG_CRIT 2 critical conditions * LOG_ERR 3 error conditions * LOG_WARNING 4 warning conditions * LOG_NOTICE 5 normal, but significant, condition * LOG_INFO 6 informational message * LOG_DEBUG 7 debug-level message * @param string $file The file where the log event occurs. * @param string $line The line of the file where the log event occurs. * @param string $url The URL where the log event occurs ($_SERVER['REQUEST_URI'] will be used if left null). */ public function logMsg($message, $priority=LOG_INFO, $file=null, $line=null, $url=null) { static $previous_events = array(); // If priority is not specified, assume the worst. if (!$this->logPriorityToString($priority)) { $this->logMsg(sprintf('Log priority %s not defined. (Message: %s)', $priority, $message), LOG_EMERG, $file, $line); $priority = LOG_EMERG; } // In case __FILE__ and __LINE__ are not provided, note that fact. $file = '' == $file ? 'unknown-file' : $file; $line = '' == $line ? 'unknown-line' : $line; // Get the URL, or used the provided value. if (!isset($url)) { $url = isset($_SERVER['REQUEST_URI']) ? mb_substr($_SERVER['REQUEST_URI'], 0, $this->getParam('log_message_max_length')) : ''; } // If log file is not specified, don't log to a file. if (!$this->getParam('log_directory') || !$this->getParam('log_filename') || !is_dir($this->getParam('log_directory')) || !is_writable($this->getParam('log_directory'))) { $this->setParam(array('log_file_priority' => false)); // We must use trigger_error to report this problem rather than calling $app->logMsg, which might lead to an infinite loop. trigger_error(sprintf('Codebase error: log directory (%s) not found or writable.', $this->getParam('log_directory')), E_USER_NOTICE); } // Before we get any further, let's see if ANY log events are configured to be reported. if ((false === $this->getParam('log_file_priority') || $priority > $this->getParam('log_file_priority')) && (false === $this->getParam('log_email_priority') || $priority > $this->getParam('log_email_priority')) && (false === $this->getParam('log_sms_priority') || $priority > $this->getParam('log_sms_priority')) && (false === $this->getParam('log_screen_priority') || $priority > $this->getParam('log_screen_priority'))) { // This event would not be recorded, skip it entirely. return false; } if ($this->getParam('log_serialize')) { // Serialize multi-line messages. $message = preg_replace('/\s+/m', ' ', trim($message)); } // Store this event under a unique key, counting each time it occurs so that it only gets reported a limited number of times. $msg_id = md5($message . $priority . $file . $line); if ($this->getParam('log_ignore_repeated_events') && isset($previous_events[$msg_id])) { $previous_events[$msg_id]++; if ($previous_events[$msg_id] == 2) { $this->logMsg(sprintf('%s (Event repeated %s or more times)', $message, $previous_events[$msg_id]), $priority, $file, $line); } return false; } else { $previous_events[$msg_id] = 1; } // For email and SMS notification types use "lock" files to prevent sending email and SMS notices ad infinitum. if ((false !== $this->getParam('log_email_priority') && $priority <= $this->getParam('log_email_priority')) || (false !== $this->getParam('log_sms_priority') && $priority <= $this->getParam('log_sms_priority'))) { // This event will generate a "send" notification. Prepare lock file. $site_hash = md5(empty($_SERVER['SERVER_NAME']) ? $_SERVER['SCRIPT_FILENAME'] : $_SERVER['SERVER_NAME']); $lock_dir = $this->getParam('tmp_dir') . "/codebase_msgs_$site_hash/"; // Just use the file and line for the msg_id to limit the number of possible messages // (the message string itself shan't be used as it may contain innumerable combinations). $lock_file = $lock_dir . md5($file . ':' . $line); if (!is_dir($lock_dir)) { mkdir($lock_dir); } $send_notifications = true; if (is_file($lock_file)) { $msg_last_sent = filectime($lock_file); // Has this message been sent more recently than the timeout? if ((time() - $msg_last_sent) <= $this->getParam('log_multiple_timeout')) { // This message was already sent recently. $send_notifications = false; } else { // Timeout has expired; send notifications again and reset timeout. touch($lock_file); } } else { touch($lock_file); } } // Use the system's locale for log messages (return to previous setting below). $locale = setlocale(LC_TIME, 0); setlocale(LC_TIME, 'C'); // Logs should always be in UTC (return to previous setting below). $tz = date_default_timezone_get(); date_default_timezone_set('UTC'); // Data to be stored for a log event. $event = array( 'date' => date('Y-m-d H:i:s'), 'remote ip' => getRemoteAddr(), 'pid' => getmypid(), 'type' => $this->logPriorityToString($priority), 'file:line' => "$file : $line", 'url' => $url, 'message' => mb_substr($message, 0, $this->getParam('log_message_max_length')), ); // Here's a shortened version of event data. $event_short = $event; $event_short['url'] = truncate($event_short['url'], 120); // Email info. $hostname = ('' != $this->getParam('site_hostname')) ? $this->getParam('site_hostname') : php_uname('n'); $hostname = preg_replace('/^ww+\./', '', $hostname); $headers = sprintf("From: %s\nX-File: %s\nX-Line: %s", $this->getParam('site_email'), $file, $line); // FILE ACTION if (false !== $this->getParam('log_file_priority') && $priority <= $this->getParam('log_file_priority')) { $event_str = '[' . join('] [', $event_short) . ']'; error_log("$event_str\n", 3, $this->getParam('log_directory') . '/' . $this->getParam('log_filename')); } // EMAIL ACTION if (false !== $this->getParam('log_email_priority') && $priority <= $this->getParam('log_email_priority') && '' != $this->getParam('log_to_email_address') && $send_notifications) { $subject = sprintf('[%s %s] %s', $hostname, $event['type'], mb_substr($event['message'], 0, 64)); $email_msg = sprintf("A log event of type '%s' occurred on %s\n\n", $event['type'], $hostname); foreach ($event as $k=>$v) { $email_msg .= sprintf("%-16s %s\n", $k, $v); } $email_msg .= sprintf("%-16s %s\n", 'codebase version', $this->getParam('codebase_version')); $email_msg .= sprintf("%-16s %s\n", 'site version', $this->getParam('site_version')); mb_send_mail($this->getParam('log_to_email_address'), $subject, $email_msg, $headers); } // SMS ACTION if (false !== $this->getParam('log_sms_priority') && $priority <= $this->getParam('log_sms_priority') && '' != $this->getParam('log_to_sms_address') && $send_notifications) { $subject = sprintf('[%s %s]', $hostname, $this->logPriorityToString($priority)); $sms_msg = sprintf('%s [%s:%s]', mb_substr($event_short['message'], 0, 64), basename($file), $line); mb_send_mail($this->getParam('log_to_sms_address'), $subject, $sms_msg, $headers); } // SCREEN ACTION if (false !== $this->getParam('log_screen_priority') && $priority <= $this->getParam('log_screen_priority')) { file_put_contents('php://stderr', "[{$event['type']}] [{$event['message']}]\n", FILE_APPEND); } // Restore original locale. setlocale(LC_TIME, $locale); // Restore original timezone. date_default_timezone_set($tz); return true; } /** * Returns the string representation of a LOG_* integer constant. * Updated: also returns the LOG_* integer constant if passed a string log value ('info' returns 6). * * @param int $priority The LOG_* integer constant (to return the string name), or a string name of a log priority to return the integer LOG_* value.. * * @return The string representation of $priority (if integer given), or integer representation (if string given). */ public function logPriorityToString($priority) { $priorities = array( LOG_EMERG => 'emergency', LOG_ALERT => 'alert', LOG_CRIT => 'critical', LOG_ERR => 'error', LOG_WARNING => 'warning', LOG_NOTICE => 'notice', LOG_INFO => 'info', LOG_DEBUG => 'debug' ); if (isset($priorities[$priority])) { return $priorities[$priority]; } else if (is_string($priority) && false !== ($key = array_search($priority, $priorities))) { return $key; } else { return false; } } /** * Forcefully set a query argument even if one currently exists in the request. * Values in the _carry_queries array will be copied to URLs (via $app->url()) and * to hidden input values (via printHiddenSession()). * * @access public * @param mixed $query_key The key (or keys, as an array) of the query argument to save. * @param mixed $val The new value of the argument key. * @author Quinn Comendant * @since 13 Oct 2007 11:34:51 */ public function setQuery($query_key, $val) { if (!is_array($query_key)) { $query_key = array($query_key); } foreach ($query_key as $k) { // Set the value of the specified query argument into the _carry_queries array. $this->_carry_queries[$k] = $val; } } /** * Specify which query arguments will be carried persistently between requests. * Values in the _carry_queries array will be copied to URLs (via $app->url()) and * to hidden input values (via printHiddenSession()). * * @access public * @param mixed $query_key The key (or keys, as an array) of the query argument to save. * @param mixed $default If the key is not available, set to this default value. * @author Quinn Comendant * @since 14 Nov 2005 19:24:52 */ public function carryQuery($query_key, $default=false) { if (!is_array($query_key)) { $query_key = array($query_key); } foreach ($query_key as $k) { // If not already set, and there is a non-empty value provided in the request... if (isset($k) && '' != $k && !isset($this->_carry_queries[$k]) && false !== getFormData($k, $default)) { // Copy the value of the specified query argument into the _carry_queries array. $this->_carry_queries[$k] = getFormData($k, $default); $this->logMsg(sprintf('Carrying query: %s => %s', $k, truncate(getDump($this->_carry_queries[$k], true), 128, 'end')), LOG_DEBUG, __FILE__, __LINE__); } } } /** * dropQuery() is the opposite of carryQuery(). The specified value will not appear in * url()/ohref()/printHiddenSession() modified URLs unless explicitly written in. * * @access public * @param mixed $query_key The key (or keys, as an array) of the query argument to remove. * @param bool $unset Remove any values set in the request matching the given $query_key. * @author Quinn Comendant * @since 18 Jun 2007 20:57:29 */ public function dropQuery($query_key, $unset=false) { if (!is_array($query_key)) { $query_key = array($query_key); } foreach ($query_key as $k) { if (array_key_exists($k, $this->_carry_queries)) { // Remove the value of the specified query argument from the _carry_queries array. $this->logMsg(sprintf('Dropping carried query: %s => %s', $k, $this->_carry_queries[$k]), LOG_DEBUG, __FILE__, __LINE__); unset($this->_carry_queries[$k]); } if ($unset && (isset($_REQUEST) && array_key_exists($k, $_REQUEST))) { unset($_REQUEST[$k], $_GET[$k], $_POST[$k], $_COOKIE[$k]); } } } /** * Outputs a fully qualified URL with a query of all the used (ie: not empty) * keys and values, including optional queries. This allows mindless retention * of query arguments across page requests. If cookies are not * used and session_use_trans_sid=true the session id will be propagated in the URL. * * @param string $url The initial url * @param mixed $carry_args Additional url arguments to carry in the query, * or FALSE to prevent carrying queries. Can be any of the following formats: * array('key1', key2', key3') <-- to save these keys, if they exist in the request data. * array('key1'=>'value', key2'='value') <-- to set keys to default values if not present in request data. * false <-- To not carry any queries. If URL already has queries those will be retained. * * @param mixed $always_include_sid Always add the session id, even if using_trans_sid = true. This is required when * URL starts with http, since PHP using_trans_sid doesn't do those and also for * header('Location...') redirections. * * @param bool $include_csrf_token Set to true to include the csrf_token in the form. Only use this for forms with action="post" to prevent the token from being revealed in the URL. * @return string url with attached queries and, if not using cookies, the session id */ public function url($url='', $carry_args=null, $always_include_sid=false, $include_csrf_token=false) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if ($this->getParam('csrf_token_enabled') && $include_csrf_token) { // Include the csrf_token as a carried query argument. // This token can be validated upon form submission with $app->verifyCSRFToken() or $app->requireValidCSRFToken() $carry_args = is_array($carry_args) ? $carry_args : array(); $carry_args = array_merge($carry_args, array($this->getParam('csrf_token_name') => $this->getCSRFToken())); } // Get any provided query arguments to include in the final URL. // If FALSE is a provided here, DO NOT carry the queries. $do_carry_queries = true; $one_time_carry_queries = array(); if (!is_null($carry_args)) { if (is_array($carry_args)) { if (!empty($carry_args)) { foreach ($carry_args as $key=>$arg) { // Get query from appropriate source. if (false === $arg) { $do_carry_queries = false; } else if (false !== getFormData($arg, false)) { $one_time_carry_queries[$arg] = getFormData($arg); // Set arg to form data if available. } else if (!is_numeric($key) && '' != $arg) { $one_time_carry_queries[$key] = getFormData($key, $arg); // Set to arg to default if specified (overwritten by form data). } } } } else if (false !== getFormData($carry_args, false)) { $one_time_carry_queries[$carry_args] = getFormData($carry_args); } else if (false === $carry_args) { $do_carry_queries = false; } } // If the URL is empty, use REQUEST_URI stripped of its query string. if ('' == $url) { $url = (strstr(getenv('REQUEST_URI'), '?', true) ?: getenv('REQUEST_URI')); // strstr() returns false if '?' is not found, so use a shorthand ternary operator. } // Get the first delimiter that is needed in the url. $delim = mb_strpos($url, '?') !== false ? ini_get('arg_separator.output') : '?'; $q = ''; if ($do_carry_queries) { // Join the global _carry_queries and local one_time_carry_queries. $query_args = urlEncodeArray(array_merge($this->_carry_queries, $one_time_carry_queries)); foreach ($query_args as $key=>$val) { // Avoid indexed-array query params because in a URL array param keys should all match. // I.e, we want to use `array[]=A&array[]=B` instead of `array[0]=A&array[1]=B`. // This is disabled because sometimes we need to retain a numeric array key, e.g., ?metadata_id[54]=on. Can't remember where having indexed-array queries was a problem, hopefully this was only added as an aesthetic feature? // $key = preg_replace('/\[\d+\]$/' . $this->getParam('preg_u'), '[]', $key); // Check value is set and value does not already exist in the url. if (!preg_match('/[?&]' . preg_quote($key) . '=/', $url)) { $q .= $delim . $key . '=' . $val; $delim = ini_get('arg_separator.output'); } } } // Pop off any named anchors to push them back on after appending additional query args. $parts = explode('#', $url, 2); $url = $parts[0]; $anchor = isset($parts[1]) ? $parts[1] : ''; // Include the necessary SID if the following is true: // - no cookie in http request OR cookies disabled in App // - sessions are enabled // - the link stays on our site // - transparent SID propagation with session.use_trans_sid is not being used OR url begins with protocol (using_trans_sid has no effect here) // OR // - we must include the SID because we say so (it's used in a context where cookies will not be effective, ie. moving from http to https) // AND // - the SID is not already in the query. if ( ( ( ( !isset($_COOKIE[session_name()]) || !$this->getParam('session_use_cookies') ) && $this->getParam('session_use_trans_sid') && $this->getParam('enable_session') && isMyDomain($url) && ( !ini_get('session.use_trans_sid') || preg_match('!^(http|https)://!i', $url) ) ) || $always_include_sid ) && !preg_match('/[?&]' . preg_quote(session_name()) . '=/', $url) ) { $url = sprintf('%s%s%s%s=%s%s', $url, $q, $delim, session_name(), session_id(), ('' == $anchor ? '' : "#$anchor")); } else { $url = sprintf('%s%s%s', $url, $q, ('' == $anchor ? '' : "#$anchor")); } if ('' == $url) { $this->logMsg(sprintf('Generated empty URL. Args: %s', getDump(func_get_args())), LOG_NOTICE, __FILE__, __LINE__); } return $url; } /** * Returns a HTML-friendly URL processed with $app->url and & replaced with & * * @access public * @param (see param reference for url() method) * @return string URL passed through $app->url() with ampersands transformed to $amp; * @author Quinn Comendant * @since 09 Dec 2005 17:58:45 */ public function oHREF($url='', $carry_args=null, $always_include_sid=false, $include_csrf_token=false) { // Process the URL. $url = $this->url($url, $carry_args, $always_include_sid, $include_csrf_token); // Replace any & not followed by an html or unicode entity with its & equivalent. $url = preg_replace('/&(?![\w\d#]{1,10};)/' . $this->getParam('preg_u'), '&', $url); return $url; } /* * Returns a string containing for session, carried queries, and CSRF token. * * @access public * @param (see printHiddenSession) * @return string * @author Quinn Comendant * @since 25 May 2019 15:01:40 */ public function getHiddenSession($carry_args=null, $include_csrf_token=false) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } $out = ''; // Get any provided query arguments to include in the final hidden form data. // If FALSE is a provided here, DO NOT carry the queries. $do_carry_queries = true; $one_time_carry_queries = array(); if (!is_null($carry_args)) { if (is_array($carry_args)) { if (!empty($carry_args)) { foreach ($carry_args as $key=>$arg) { // Get query from appropriate source. if (false === $arg) { $do_carry_queries = false; } else if (false !== getFormData($arg, false)) { $one_time_carry_queries[$arg] = getFormData($arg); // Set arg to form data if available. } else if (!is_numeric($key) && '' != $arg) { $one_time_carry_queries[$key] = getFormData($key, $arg); // Set to arg to default if specified (overwritten by form data). } } } } else if (false !== getFormData($carry_args, false)) { $one_time_carry_queries[$carry_args] = getFormData($carry_args); } else if (false === $carry_args) { $do_carry_queries = false; } } // For each existing request value, we create a hidden input to carry it through a form. if ($do_carry_queries) { // Join the global _carry_queries and local one_time_carry_queries. // urlencode is not used here, not for form data! $query_args = array_merge($this->_carry_queries, $one_time_carry_queries); foreach ($query_args as $key => $val) { if (is_array($val)) { foreach ($val as $subval) { if ('' != $key && '' != $subval) { $out .= sprintf('', oTxt($key), oTxt($subval)); } } } else if ('' != $key && '' != $val) { $out .= sprintf('', oTxt($key), oTxt($val)); } } unset($query_args, $key, $val, $subval); } // Include the SID if: // * cookies are disabled // * the system isn't automatically adding trans_sid // * the session is enabled // * and we're configured to use trans_sid if (!isset($_COOKIE[session_name()]) && !ini_get('session.use_trans_sid') && $this->getParam('enable_session') && $this->getParam('session_use_trans_sid') ) { $out .= sprintf('', oTxt(session_name()), oTxt(session_id())); } // Include the csrf_token in the form. // This token can be validated upon form submission with $app->verifyCSRFToken() or $app->requireValidCSRFToken() if ($this->getParam('csrf_token_enabled') && $include_csrf_token) { $out .= sprintf('', oTxt($this->getParam('csrf_token_name')), oTxt($this->getCSRFToken())); } return $out; } /** * Prints a hidden form element with the PHPSESSID when cookies are not used, as well * as hidden form elements for GET_VARS that might be in use. * * @param mixed $carry_args Additional url arguments to carry in the query, * or FALSE to prevent carrying queries. Can be any of the following formats: * array('key1', key2', key3') <-- to save these keys if in the form data. * array('key1'=>'value', key2'='value') <-- to set keys to default values if not present in form data. * false <-- To not carry any queries. If URL already has queries those will be retained. * @param bool $include_csrf_token Set to true to include the csrf_token in the form. Only use this for forms with action="post" to prevent the token from being revealed in the URL. */ public function printHiddenSession($carry_args=null, $include_csrf_token=false) { echo $this->getHiddenSession($carry_args, $include_csrf_token); } /* * Return a URL with a version number attached. This is useful for overriding network caches ("cache buster") for sourced media, e.g., /style.css?812763482 * * @access public * @param string $url URL to media (e.g., /foo.js) * @return string URL with cache-busting version appended (/foo.js?v=1234567890) * @author Quinn Comendant * @version 1.0 * @since 03 Sep 2014 22:40:24 */ public function cacheBustURL($url) { // Get the first delimiter that is needed in the url. $delim = mb_strpos($url, '?') !== false ? ini_get('arg_separator.output') : '?'; $v = crc32($this->getParam('codebase_version') . '|' . $this->getParam('site_version')); return sprintf('%s%sv=%s', $url, $delim, $v); } /* * Generate a csrf_token if it doesn't exist or is expired, save it to the session and return its value. * Otherwise just return the current token. * Details on the synchronizer token pattern: * https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#General_Recommendation:_Synchronizer_Token_Pattern * * @access public * @param bool $force_new_token Generate a new token, replacing any existing token in the session (used by $app->resetCSRFToken()) * @return string The new or current csrf_token * @author Quinn Comendant * @version 1.0 * @since 15 Nov 2014 17:57:17 */ public function getCSRFToken($force_new_token=false) { if ($force_new_token || !isset($_SESSION['_app'][$this->_ns]['csrf_token']) || (removeSignature($_SESSION['_app'][$this->_ns]['csrf_token']) + $this->getParam('csrf_token_timeout') < time())) { // No token, or token is expired; generate one and return it. return $_SESSION['_app'][$this->_ns]['csrf_token'] = addSignature(time(), null, 64); } // Current token is not expired; return it. return $_SESSION['_app'][$this->_ns]['csrf_token']; } /* * Generate a new token, replacing any existing token in the session. Call this function after $app->requireValidCSRFToken() for a new token to be required for each request. * * @access public * @return void * @author Quinn Comendant * @since 14 Oct 2021 17:35:19 */ public function resetCSRFToken() { $this->getCSRFToken(true); } /* * Compares the given csrf_token with the current or previous one saved in the session. * * @access public * @param string $user_submitted_csrf_token The user-submitted token to compare with the session token. * @return bool True if the tokens match, false otherwise. * @author Quinn Comendant * @version 1.0 * @since 15 Nov 2014 18:06:55 */ public function verifyCSRFToken($user_submitted_csrf_token) { if (!$this->getParam('csrf_token_enabled')) { $this->logMsg(sprintf('%s called, but csrf_token_enabled=false', __METHOD__), LOG_ERR, __FILE__, __LINE__); return true; } if ('' == trim($user_submitted_csrf_token)) { $this->logMsg(sprintf('Empty string failed CSRF verification.', null), LOG_NOTICE, __FILE__, __LINE__); return false; } if (!verifySignature($user_submitted_csrf_token, null, 64)) { $this->logMsg(sprintf('Input failed CSRF verification (invalid signature in %s).', $user_submitted_csrf_token), LOG_WARNING, __FILE__, __LINE__); return false; } $csrf_token = $this->getCSRFToken(); if ($user_submitted_csrf_token != $csrf_token) { $this->logMsg(sprintf('Input failed CSRF verification (%s not in %s).', $user_submitted_csrf_token, $csrf_token), LOG_WARNING, __FILE__, __LINE__); return false; } $this->logMsg(sprintf('Verified CSRF token %s', $user_submitted_csrf_token), LOG_DEBUG, __FILE__, __LINE__); return true; } /* * Bounce user if they submit a token that doesn't match the one saved in the session. * Because this function calls dieURL() it must be called before any other HTTP header output. * * @access public * @param string $message Optional message to display to the user (otherwise default message will display). Set to an empty string to display no message. * @param int $type The type of message: MSG_NOTICE, * MSG_SUCCESS, MSG_WARNING, or MSG_ERR. * @param string $file __FILE__. * @param string $line __LINE__. * @return void * @author Quinn Comendant * @version 1.0 * @since 15 Nov 2014 18:10:17 */ public function requireValidCSRFToken($message=null, $type=MSG_NOTICE, $file=null, $line=null) { if (!$this->verifyCSRFToken(getFormData($this->getParam('csrf_token_name')))) { $message = isset($message) ? $message : _("Sorry, the form token expired. Please try again."); $this->raiseMsg($message, $type, $file, $line); $this->dieBoomerangURL(); } } /** * Uses an http header to redirect the client to the given $url. If sessions are not used * and the session is not already defined in the given $url, the SID is appended as a URI query. * As with all header generating functions, make sure this is called before any other output. * Using relative URI with Location: header is valid as per https://tools.ietf.org/html/rfc7231#section-7.1.2 * * @param string $url The URL the client will be redirected to. * @param mixed $carry_args Additional url arguments to carry in the query, * or FALSE to prevent carrying queries. Can be any of the following formats: * -array('key1', key2', key3') <-- to save these keys if in the form data. * -array('key1' => 'value', key2' => 'value') <-- to set keys to default values if not present in form data. * -false <-- To not carry any queries. If URL already has queries those will be retained. * @param bool $always_include_sid Force session id to be added to Location header. * @param int $http_response_code The HTTP response code to include with the Location header. Use 303 when the redirect should be GET, or * use 307 when the redirect should use the same method as the original request. */ public function dieURL($url, $carry_args=null, $always_include_sid=false, $http_response_code=303) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if ('' == $url) { // If URL is not specified, use the redirect_home_url. $url = $this->getParam('redirect_home_url'); } $url = $this->url($url, $carry_args, $always_include_sid); if (!headers_sent($h_file, $h_line)) { header(sprintf('Location: %s', $url), true, $http_response_code); $this->logMsg(sprintf('dieURL: %s', $url), LOG_DEBUG, __FILE__, __LINE__); } else { // Fallback: die using meta refresh instead. printf('', oTxt($url)); $this->logMsg(sprintf('dieURL (refresh): %s; headers already sent (output started in %s : %s)', $url, $h_file, $h_line), LOG_NOTICE, __FILE__, __LINE__); } // End application. // Recommended, although I'm not sure it's necessary: https://www.php.net/session_write_close $this->stop(); die; } /* * Redirects a user by calling $app->dieURL(). It will use: * 1. the stored boomerang URL, it it exists * 2. a specified $default_url, it it exists * 3. the referring URL, it it exists. * 4. redirect_home_url configuration variable. * * @access public * @param string $id Identifier for this script. * @param mixed $carry_args Additional arguments to carry in the URL automatically (see $app->url()). * @param string $default_url A default URL if there is not a valid specified boomerang URL. * @param bool $queryless_referrer_comparison Exclude the URL query from the refererIsMe() comparison. * @return bool False if the session is not running. No return otherwise. * @author Quinn Comendant * @since 31 Mar 2006 19:17:00 */ public function dieBoomerangURL($id=null, $carry_args=null, $default_url=null, $queryless_referrer_comparison=false) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } // Get URL from stored boomerang. Allow non specific URL if ID not valid. if ($this->validBoomerangURL($id, true)) { if (isset($id) && isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) { $url = $_SESSION['_app'][$this->_ns]['boomerang'][$id]['url']; $this->logMsg(sprintf('dieBoomerangURL(%s) found: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); } else { $url = end($_SESSION['_app'][$this->_ns]['boomerang'])['url']; $this->logMsg(sprintf('dieBoomerangURL(%s) using: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); } // Delete stored boomerang. $this->deleteBoomerangURL($id); } else if (isset($default_url)) { $url = $default_url; } else if (!refererIsMe(true === $queryless_referrer_comparison) && '' != ($url = getenv('HTTP_REFERER'))) { // Ensure that the redirecting page is not also the referrer. $this->logMsg(sprintf('dieBoomerangURL(%s) using referrer: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); } else { // If URL is not specified, use the redirect_home_url. $url = $this->getParam('redirect_home_url'); $this->logMsg(sprintf('dieBoomerangURL(%s) using redirect_home_url: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); } // A redirection will never happen immediately twice. Set the time so we can ensure this doesn't happen. $_SESSION['_app'][$this->_ns]['boomerang_last_redirect_time'] = time(); // Do it. $this->dieURL($url, $carry_args); } /** * Set the URL to return to when $app->dieBoomerangURL() is called. * * @param string $url A fully validated URL. * @param bool $id An identification tag for this url. * FIXME: url garbage collection? */ public function setBoomerangURL($url=null, $id=null) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if ('' != $url && is_string($url)) { // Delete any boomerang request keys in the query string (along with any trailing delimiters after the deletion). $url = preg_replace(array('/([&?])boomerang=[^&?]+[&?]?/' . $this->getParam('preg_u'), '/[&?]$/'), array('$1', ''), $url); if (isset($_SESSION['_app'][$this->_ns]['boomerang']) && is_array($_SESSION['_app'][$this->_ns]['boomerang']) && !empty($_SESSION['_app'][$this->_ns]['boomerang'])) { // If the ID already exists in the boomerang array, delete it. foreach (array_keys($_SESSION['_app'][$this->_ns]['boomerang']) as $existing_id) { // $existing_id could be null if existing boomerang URL was set without an ID. if ($existing_id === $id) { $this->logMsg(sprintf('Deleted existing boomerang URL matching ID: %s=>%s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); unset($_SESSION['_app'][$this->_ns]['boomerang'][$existing_id]); } } } // A redirection will never happen immediately after setting the boomerang URL. // Set the time so ensure this doesn't happen. See $app->validBoomerangURL for more. if (isset($id)) { $_SESSION['_app'][$this->_ns]['boomerang'][$id] = array( 'url' => $url, 'added_time' => time(), ); } else { $_SESSION['_app'][$this->_ns]['boomerang'][] = array( 'url' => $url, 'added_time' => time(), ); } $this->logMsg(sprintf('setBoomerangURL(%s): %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); return true; } else { $this->logMsg(sprintf('setBoomerangURL(%s) is empty!', $id, $url), LOG_NOTICE, __FILE__, __LINE__); return false; } } /** * Return the URL set for the specified $id, or an empty string if one isn't set. * * @param string $id An identification tag for this url. */ public function getBoomerangURL($id=null) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if (isset($id)) { if (isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) { return $_SESSION['_app'][$this->_ns]['boomerang'][$id]['url']; } else { return ''; } } else if (isset($_SESSION['_app'][$this->_ns]['boomerang']) && is_array($_SESSION['_app'][$this->_ns]['boomerang']) && !empty($_SESSION['_app'][$this->_ns]['boomerang'])) { return end($_SESSION['_app'][$this->_ns]['boomerang'])['url']; } else { return false; } } /** * Delete the URL set for the specified $id. * * @param string $id An identification tag for this url. */ public function deleteBoomerangURL($id=null) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if (isset($id) && isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) { $url = $this->getBoomerangURL($id); unset($_SESSION['_app'][$this->_ns]['boomerang'][$id]); } else if (is_array($_SESSION['_app'][$this->_ns]['boomerang'])) { $url = array_pop($_SESSION['_app'][$this->_ns]['boomerang'])['url']; } $this->logMsg(sprintf('deleteBoomerangURL(%s): %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); } /** * Check if a valid boomerang URL value has been set. A boomerang URL is considered * valid if: 1) it is not empty, 2) it is not the current URL, and 3) has not been accessed within n seconds. * * @return bool True if it is set and valid, false otherwise. */ public function validBoomerangURL($id=null, $use_nonspecificboomerang=false) { if (!$this->running) { $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); return false; } if (!isset($_SESSION['_app'][$this->_ns]['boomerang']) || !is_array($_SESSION['_app'][$this->_ns]['boomerang']) || empty($_SESSION['_app'][$this->_ns]['boomerang'])) { $this->logMsg(sprintf('validBoomerangURL(%s) no boomerang URL set, not an array, or empty.', $id), LOG_DEBUG, __FILE__, __LINE__); return false; } $url = ''; if (isset($id) && isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) { $url = $_SESSION['_app'][$this->_ns]['boomerang'][$id]['url']; $added_time = $_SESSION['_app'][$this->_ns]['boomerang'][$id]['added_time']; } else if (!isset($id) || $use_nonspecificboomerang) { // Use most recent, non-specific boomerang if available. $url = end($_SESSION['_app'][$this->_ns]['boomerang'])['url']; $added_time = end($_SESSION['_app'][$this->_ns]['boomerang'])['added_time']; } if ('' == trim($url)) { $this->logMsg(sprintf('validBoomerangURL(%s) not valid, empty!', $id), LOG_DEBUG, __FILE__, __LINE__); return false; } if ($url == absoluteMe() || $url == getenv('REQUEST_URI')) { // The URL we are directing to is the current page. $this->logMsg(sprintf('validBoomerangURL(%s) not valid, same as absoluteMe or REQUEST_URI: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); return false; } // Last redirect time is the time stamp of the last boomerangURL redirection, if any. A boomerang redirection should always occur at least several seconds after the last boomerang redirect (time it takes to load a page and receive user interaction). $boomerang_last_redirect_time = isset($_SESSION['_app'][$this->_ns]['boomerang_last_redirect_time']) ? $_SESSION['_app'][$this->_ns]['boomerang_last_redirect_time'] : null; if (isset($boomerang_last_redirect_time) && $boomerang_last_redirect_time >= (time() - 2)) { // Last boomerang direction was less than 2 seconds ago. $this->logMsg(sprintf('validBoomerangURL(%s) not valid, boomerang_last_redirect_time too short: %s seconds', $id, time() - $boomerang_last_redirect_time), LOG_DEBUG, __FILE__, __LINE__); return false; } if (isset($added_time) && $added_time < (time() - 72000)) { // Last boomerang direction was more than 20 hours ago. $this->logMsg(sprintf('validBoomerangURL(%s) not valid, added_time too old: %s seconds', $id, time() - $added_time), LOG_DEBUG, __FILE__, __LINE__); // Delete this defunct boomerang. $this->deleteBoomerangURL($id); return false; } $this->logMsg(sprintf('validBoomerangURL(%s) is valid: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__); return true; } /** * This function has changed to do nothing. SSL redirection should happen at the server layer, doing so here may result in a redirect loop. */ public function sslOn() { $this->logMsg(sprintf('sslOn was called and ignored.', null), LOG_DEBUG, __FILE__, __LINE__); } /** * This function has changed to do nothing. There is no reason to prefer a non-SSL connection, and doing so may result in a redirect loop. */ public function sslOff() { $this->logMsg(sprintf('sslOff was called and ignored.', null), LOG_DEBUG, __FILE__, __LINE__); } /* * Sets a cookie, with error checking and some sane defaults. * * @access public * @param string $name The name of the cookie. * @param string $value The value of the cookie. * @param string $expire The time the cookie expires, as a unix timestamp or string value passed to strtotime. * @param string $path The path on the server in which the cookie will be available on. * @param string $domain The domain that the cookie is available to. * @param bool $secure Indicates that the cookie should only be transmitted over a secure HTTPS connection from the client. * @param bool $httponly When TRUE the cookie will be made accessible only through the HTTP protocol (makes cookies unreadable to javascript). * @param string $samesite Value of the SameSite key ('None', 'Lax', or 'Strict'). PHP 7.3+ only. * @return bool True on success, false on error. * @author Quinn Comendant * @version 1.0 * @since 02 May 2014 16:36:34 */ public function setCookie($name, $value, $expire='+10 years', $path='/', $domain=null, $secure=null, $httponly=null, $samesite=null) { if (!is_scalar($name)) { $this->logMsg(sprintf('Cookie name must be scalar, is not: %s', getDump($name)), LOG_NOTICE, __FILE__, __LINE__); return false; } if (!is_scalar($value)) { $this->logMsg(sprintf('Cookie "%s" value must be scalar, is not: %s', $name, getDump($value)), LOG_NOTICE, __FILE__, __LINE__); return false; } // Defaults. $expire = (is_numeric($expire) ? $expire : (is_string($expire) ? strtotime($expire) : $expire)); $secure = $secure ?: getenv('HTTPS') == 'on'; $httponly = $httponly ?: true; $samesite = $samesite ?: 'Lax'; // Make sure the expiration date is a valid 32bit integer. if (is_int($expire) && $expire > 2147483647) { $this->logMsg(sprintf('Cookie "%s" expire time exceeds a 32bit integer (%s)', $key, date('r', $expire)), LOG_NOTICE, __FILE__, __LINE__); } // Measure total cookie length and warn if larger than max recommended size of 4093. // https://stackoverflow.com/questions/640938/what-is-the-maximum-size-of-a-web-browsers-cookies-key // The date and header name adds 51 bytes: Set-Cookie: ; expires=Fri, 03-May-2024 00:04:47 GMT $cookielen = strlen($name . $value . $path . $domain . ($secure ? '; secure' : '') . ($httponly ? '; httponly' : '') . ($samesite ? '; SameSite=' . $samesite : '')) + 51; if ($cookielen > 4093) { $this->logMsg(sprintf('Cookie "%s" has a size greater than 4093 bytes (is %s bytes)', $key, $cookielen), LOG_NOTICE, __FILE__, __LINE__); } // Ensure PHP version allow use of httponly. if (version_compare(PHP_VERSION, '7.3.0', '>=')) { $ret = setcookie($name, $value, [ 'expires' => $expire, 'path' => $path, 'domain' => $domain, 'secure' => $secure, 'httponly' => $httponly, 'samesite' => $samesite, ]); } else if (version_compare(PHP_VERSION, '5.2.0', '>=')) { $ret = setcookie($name, $value, $expire, $path, $domain, $secure, $httponly); } else { $ret = setcookie($name, $value, $expire, $path, $domain, $secure); } if (false === $ret) { $this->logMsg(sprintf('Failed to set cookie (%s=%s) probably due to output before headers.', $name, $value), LOG_NOTICE, __FILE__, __LINE__); } return $ret; } /* * Set timezone used internally by PHP. See full list at https://www.php.net/manual/en/timezones.php * * @access public * @param string $tz Timezone, e.g., America/Mexico_City * @return * @author Quinn Comendant * @since 28 Jan 2019 16:38:38 */ public function setTimezone($tz) { // Set timezone for PHP. if (date_default_timezone_set($tz)) { $this->logMsg(sprintf('Using php timezone: %s', $tz), LOG_DEBUG, __FILE__, __LINE__); } else { // Failed! $this->logMsg(sprintf('Failed to set php timezone: %s', $tz), LOG_WARNING, __FILE__, __LINE__); } } /* * Create a DateTime object from a string and convert its timezone. * * @access public * @param string $datetime A date-time string or unit timestamp, e.g., `now + 60 days` or `1606165903`. * @param string $from_tz A PHP timezone, e.g., UTC * @param string $to_tz A PHP timezone, e.g., America/Mexico_City * @return DateTime A DateTime object ready to use with, e.g., ->format(…). * @author Quinn Comendant * @since 23 Nov 2020 15:08:45 */ function convertTZ($datetime, $from_tz, $to_tz) { if (preg_match('/^\d+$/', $datetime)) { // It's a timestamp, format as required by DateTime::__construct(). $datetime = "@$datetime"; } $dt = new DateTime($datetime, new DateTimeZone($from_tz)); $dt->setTimezone(new DateTimeZone($to_tz)); return $dt; } /* * Convert a given date-time string from php_timezone to user_timezone, and return formatted. * * @access public * @param string $datetime A date-time string or unit timestamp, e.g., `now + 60 days` or `1606165903`. * @param string $format A date format string for DateTime->format(…) or strftime(…). Set to lc_date_format by default. * @return string A formatted date in the user's timezone. * @author Quinn Comendant * @since 23 Nov 2020 15:13:26 */ function dateToUserTZ($datetime, $format=null) { if (empty($datetime) || in_array($datetime, ['0000-00-00 00:00:00', '0000-00-00', '1000-01-01 00:00:00', '1000-01-01'])) { // Zero dates in MySQL should never be displayed. return ''; } try { // Create a DateTime object and convert the timezone from server to user. $dt = $this->convertTZ($datetime, $this->getParam('php_timezone'), $this->getParam('user_timezone')); } catch (Exception $e) { $this->logMsg(sprintf('DateTime failed to parse string in %s: %s', __METHOD__, $datetime), LOG_NOTICE, __FILE__, __LINE__); return ''; } // By default, we try to use a localized date format. Set lc_date_format to null to use regular date_format instead. $format = $format ?: $this->getParam('lc_date_format'); if ($format && mb_strpos($format, '%') !== false) { // The data format is localized for strftime(). It only accepts a timestamp, which are always in UTC, so we hack this by offering the date from the user's timezone in a format without a TZ specified, which is used to a make a timestamp for strftime (we can't use DaateTime->format('U') because that would convert the date back to UTC). return strftime($format, strtotime($dt->format('Y-m-d H:i:s'))); } else { // Otherwise use a regular date format. $format = $format ?: $this->getParam('date_format'); return $dt->format($format); } } /* * Convert a given date-time string from user_timezone to php_timezone, and formatted as YYYY-MM-DD HH:MM:SS. * * @access public * @param string $datetime A date-time string or unit timestamp, e.g., `now + 60 days` or `1606165903`. * @param string $format A date format string for DateTime->format(…). Set to 'Y-m-d H:i:s' by default. * @return string A formatted date in the server's timezone. * @author Quinn Comendant * @since 23 Nov 2020 15:13:26 */ function dateToServerTZ($datetime, $format='Y-m-d H:i:s') { try { // Create a DateTime object and conver the timezone from server to user. $dt = $this->convertTZ($datetime, $this->getParam('user_timezone'), $this->getParam('php_timezone')); } catch (Exception $e) { $this->logMsg(sprintf('DateTime failed to parse string in %s: %s', __METHOD__, $datetime), LOG_NOTICE, __FILE__, __LINE__); return ''; } return $dt->format($format); } /* * * * @access public * @param * @return * @author Quinn Comendant * @since 17 Feb 2019 13:11:20 */ public function colorCLI($color) { switch ($color) { case 'white': echo "\033[0;37m"; break; case 'black': echo "\033[0;30m"; break; case 'red': echo "\033[0;31m"; break; case 'yellow': echo "\033[0;33m"; break; case 'green': echo "\033[0;32m"; break; case 'cyan': echo "\033[0;36m"; break; case 'blue': echo "\033[0;34m"; break; case 'purple': echo "\033[0;35m"; break; case 'off': default: echo "\033[0m"; break; } } } // End.