source: trunk/lib/App.inc.php

Last change on this file was 815, checked in by anonymous, 19 hours ago

Minor fixes

File size: 96.2 KB
Line 
1<?php
2/**
3 * The Strangecode Codebase - a general application development framework for PHP
4 * For details visit the project site: <http://trac.strangecode.com/codebase/>
5 * Copyright 2001-2012 Strangecode, LLC
6 *
7 * This file is part of The Strangecode Codebase.
8 *
9 * The Strangecode Codebase is free software: you can redistribute it and/or
10 * modify it under the terms of the GNU General Public License as published by the
11 * Free Software Foundation, either version 3 of the License, or (at your option)
12 * any later version.
13 *
14 * The Strangecode Codebase is distributed in the hope that it will be useful, but
15 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 * details.
18 *
19 * You should have received a copy of the GNU General Public License along with
20 * The Strangecode Codebase. If not, see <http://www.gnu.org/licenses/>.
21 */
22
23/**
24 * App.inc.php
25 *
26 * Primary application framework class.
27 *
28 * @author  Quinn Comendant <quinn@strangecode.com>
29 * @version 2.1
30 */
31
32// Message Types.
33define('MSG_ERR', 1);
34define('MSG_ERROR', MSG_ERR);
35define('MSG_WARNING', 2);
36define('MSG_NOTICE', 4);
37define('MSG_SUCCESS', 8);
38define('MSG_ALL', MSG_SUCCESS | MSG_NOTICE | MSG_WARNING | MSG_ERROR);
39
40require_once dirname(__FILE__) . '/Utilities.inc.php';
41
42class App
43{
44    // Minimum version of PHP required for this version of the Codebase.
45    const CODEBASE_MIN_PHP_VERSION = '5.3.0';
46
47    // A place to keep an object instance for the singleton pattern.
48    protected static $instance = null;
49
50    // Namespace of this application instance.
51    protected $_ns;
52
53    // If $app->start has run successfully.
54    public $running = false;
55
56    // Copy of the ScriptTimer object to measure app performance.
57    public $timer;
58
59    // Instance of database object (from mysql_connect() PHP version < 7).
60    public $db;
61
62    // Instance of PDO object.
63    public $pdo;
64
65    // Instance of database session handler object.
66    public $db_session;
67
68    // Array of query arguments will be carried persistently between requests.
69    protected $_carry_queries = array();
70
71    // Array of raised message counters.
72    protected $_raised_msg_counter = array(MSG_NOTICE => 0, MSG_SUCCESS => 0, MSG_WARNING => 0, MSG_ERR => 0);
73
74    // We're running as CLI. Public because we must force this as false when testing sessions via CLI.
75    public $cli = false;
76
77    // Dictionary of global application parameters.
78    protected $_params = array();
79
80    // Default parameters.
81    protected $_param_defaults = array(
82
83        // Public name and email address for this application.
84        'site_name' => null,
85        'site_email' => '', // Set to no-reply@HTTP_HOST if not set here.
86        'site_hostname' => '', // The hostname of this application (if not set, derived from HTTP_HOST).
87        'site_port' => '', // The hostname of this application (if not set, derived from HTTP_HOST).
88        'site_url' => '', // URL to the root of the site (created during App->start()).
89        'page_url' => '', // URL to the current page (created during App->start()).
90        'images_path' => '', // Location for codebase-generated interface widgets (ex: "/admin/i").
91        'site_version' => '', // Version of this application (set automatically during start() if site_version_file is used).
92        'site_version_file' => 'docs/version.txt', // File containing version number of this app, relative to the include path.
93        'template_ext' => 'ihtml', // Template filename extension. Legacy template files use .ihtml, newer sites use .inc.html or .inc.php.
94
95        // The location the user will go if the system doesn't know where else to send them.
96        'redirect_home_url' => '/',
97
98        // Use CSRF tokens. See notes in the getCSRFToken() method.
99        'csrf_token_enabled' => true,
100        // Form tokens will expire after this duration, in seconds.
101        'csrf_token_timeout' => 86400, // 86400 seconds = 24 hours.
102        'csrf_token_name' => 'csrf_token',
103
104        // HMAC signing method
105        'signing_method' => 'sha512+base64',
106
107        // Content type of output sent in the Content-type: http header.
108        'content_type' => 'text/html',
109
110        // Allow HTTP caching with max-age setting. Possible values:
111        //  >= 1    Allow HTTP caching with this value set as the max-age (in seconds, i.e., 3600 = 1 hour).
112        //  0       Disallow HTTP caching.
113        //  false   Don't send any cache-related HTTP headers (if you want to control this via server config or custom headers)
114        // This should be '0' for websites that use authentication or have frequently changing dynamic content.
115        'http_cache_headers' => 0,
116
117        // Character set for page output. Used in the Content-Type header and the HTML <meta content-type> tag.
118        'character_set' => 'utf-8',
119
120        // Human-readable format used to display dates.
121        'date_format' => 'd M Y', // Format accepted by DateTimeInterface::format() https://www.php.net/manual/en/datetime.format.php
122        'time_format' => 'h:i A', // Format accepted by DateTimeInterface::format() https://www.php.net/manual/en/datetime.format.php
123        'lc_date_format' => '%d %b %Y', // Localized date for strftime() https://www.php.net/manual/en/function.strftime.php
124        'lc_time_format' => '%k:%M', // Localized time for strftime() https://www.php.net/manual/en/function.strftime.php
125        'sql_date_format' => '%e %b %Y',
126        'sql_time_format' => '%k:%i',
127
128        // Timezone support. No codebase apps currently support switching timezones, but we explicitly set these so they're consistent.
129        'user_timezone' => 'UTC',
130        'php_timezone' => 'UTC',
131        'db_timezone' => 'UTC',
132
133        // Use php sessions?
134        'enable_session' => false,
135        'session_cache_limiter' => 'nocache', //Session cache-control header: `nocache`, `private`, `private_no_expire`, or `public`. Defaults to `nocache`.
136        'session_cookie_path' => '/',
137        'session_name' => '_session',
138        'session_use_cookies' => true,
139        'session_use_trans_sid' => false, // Pass the session-id through URLs if cookies are not enabled? Disable this to prevent session ID theft.
140
141        // Use database?
142        'enable_db' => false,
143        'enable_db_pdo' => false,
144
145        // Use db-based sessions?
146        'enable_db_session_handler' => false,
147
148        // db_* parameters are passed to the DB object in $app->start().
149        // DB credentials should be set as apache environment variables in httpd.conf, readable only by root.
150        'db_server' => null,
151        'db_name' => null,
152        'db_user' => null,
153        'db_pass' => null,
154        'db_character_set' => '',
155        'db_collation' => '',
156
157        // CLI scripts will need this JSON file in the include path.
158        'db_auth_file' => 'db_auth.json',
159
160        // Database debugging.
161        'db_always_debug' => false, // TRUE = display all SQL queries.
162        'db_debug' => false, // TRUE = display db errors.
163        'db_die_on_failure' => false, // TRUE = script stops on db error.
164
165        // For classes that require db tables, do we check that a table exists and create if missing?
166        'db_create_tables' => true,
167
168        // The level of error reporting. Don't change this to suppress messages, instead use display_errors to control display.
169        'error_reporting' => E_ALL,
170
171        // Don't display errors by default; it is preferable to log them to a file. For CLI scripts, set this to the string 'stderr'.
172        'display_errors' => false,
173
174        // Directory in which to store log files.
175        'log_directory' => '',
176
177        // PHP error log.
178        'php_error_log' => 'php_error_log',
179
180        // General application log.
181        'log_filename' => 'app_log',
182
183        // Don't email or SMS duplicate messages that happen more often than this value (in seconds).
184        'log_multiple_timeout' => 3600, // Hourly
185
186        // Logging priority can be any of the following, or false to deactivate:
187        // LOG_EMERG     system is unusable
188        // LOG_ALERT     action must be taken immediately
189        // LOG_CRIT      critical conditions
190        // LOG_ERR       error conditions
191        // LOG_WARNING   warning conditions
192        // LOG_NOTICE    normal, but significant, condition
193        // LOG_INFO      informational message
194        // LOG_DEBUG     debug-level message
195        'log_file_priority' => LOG_INFO,
196        'log_email_priority' => false,
197        'log_sms_priority' => false,
198        'log_screen_priority' => false,
199
200        // Email address to receive log event emails. Use multiple addresses by separating them with commas.
201        'log_to_email_address' => null,
202
203        // SMS Email address to receive log event SMS messages. Use multiple addresses by separating them with commas.
204        'log_to_sms_address' => null,
205
206        // 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.
207        'log_ignore_repeated_events' => true,
208
209        // Maximum length of log messages, in bytes.
210        'log_message_max_length' => 1024,
211
212        // Strip line-endings from log messages.
213        'log_serialize' => true,
214
215        // Temporary files directory.
216        'tmp_dir' => '/tmp',
217
218        // Session files directory. If not defined, the default value from php.ini will be used.
219        'session_dir' => '',
220
221        // A key for calculating simple cryptographic signatures. Set using as an environment variables in the httpd.conf with 'SetEnv SIGNING_KEY <key>'.
222        // Existing password hashes rely on the same key/salt being used to compare encryptions.
223        // Don't change this unless you know existing hashes or signatures will not be affected!
224        'signing_key' => 'aae6abd6209d82a691a9f96384a7634a',
225
226        // Force getFormData, getPost, and getGet to always run dispelMagicQuotes() with stripslashes().
227        // This should be set to 'true' when using the codebase with Wordpress because
228        // WP forcefully adds slashes to all input despite the setting of magic_quotes_gpc (which was removed from PHP in v5.4).
229        'always_dispel_magicquotes' => false,
230
231        // The /u pattern modifier should only be used on UTF-8 strings. This value will be changed to `u` if character_set = `utf-8`.
232        // Use the unicode modifier like this:  preg_replace('/[^0-9]/' . $app->getParam('preg_u'), '', $str);
233        'preg_u' => '',
234
235        // Prettify json when testing.
236        'json_options' => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
237    );
238
239    /**
240     * Constructor.
241     */
242    public function __construct($namespace='')
243    {
244        // Initialize default parameters.
245        $this->_params = array_merge($this->_params, array('namespace' => $namespace), $this->_param_defaults);
246
247        // Begin timing script.
248        require_once dirname(__FILE__) . '/ScriptTimer.inc.php';
249        $this->timer = new ScriptTimer();
250        $this->timer->start('_app');
251
252        // Are we running as a CLI?
253        $this->cli = ('cli' === php_sapi_name() || defined('_CLI'));
254    }
255
256    /**
257     * This method enforces the singleton pattern for this class. Only one application is running at a time.
258     *
259     * $param   string  $namespace  Name of this application.
260     * @return  object  Reference to the global Cache object.
261     * @access  public
262     * @static
263     */
264    public static function &getInstance($namespace='')
265    {
266        if (self::$instance === null) {
267            // FIXME: Yep, having a namespace with one singleton instance is not very useful. This is a design flaw with the Codebase.
268            // We're currently getting instances of App throughout the codebase using `$app =& App::getInstance();`
269            // with no way to determine what the namespace of the containing application is (e.g., `public` vs. `admin`).
270            // Option 1: provide the project namespace to all classes that use App, and then instantiate with `$app =& App::getInstance($this->_ns);`.
271            // In this case the namespace of the App and the namespace of the auxiliary class must match.
272            // Option 2: may be to clone a specific instance to the "default" instance, so, e.g., `$app =& App::getInstance();`
273            // refers to the same namespace as `$app =& App::getInstance('admin');`
274            // Option 3: is to check if there is only one instance, and return it if an unspecified namespace is requested.
275            // However, in the case when multiple namespaces are in play at the same time, this will fail; unspecified namespaces
276            // would cause the creation of an additional instance, since there would not be an obvious named instance to return.
277            self::$instance = new self($namespace);
278        }
279
280        if ('' != $namespace) {
281            // We may not be able to request a specific instance, but we can specify a specific namespace.
282            // We're ignoring all instance requests with a blank namespace, so we use the last given one.
283            self::$instance->_ns = $namespace;
284        }
285
286        return self::$instance;
287    }
288
289    /**
290     * Set (or overwrite existing) parameters by passing an array of new parameters.
291     *
292     * @access public
293     * @param  array    $params     Array of parameters (key => val pairs).
294     */
295    public function setParam(Array $params)
296    {
297        if (!isset($params) || !is_array($params)) {
298            trigger_error(sprintf('%s failed; not an array: %s', __METHOD__, getDump($params, false, SC_DUMP_PRINT_R)), E_USER_ERROR);
299        }
300
301        // Merge new parameters with old overriding old ones that are passed.
302        $this->_params = array_merge($this->_params, $params);
303
304        if ($this->running) {
305            // Params that require additional processing if set during runtime.
306            foreach ($params as $key => $val) {
307                switch ($key) {
308                case 'namespace':
309                    $this->logMsg(sprintf('Setting namespace not allowed', null), LOG_WARNING, __FILE__, __LINE__);
310                    break;
311
312                case 'session_name':
313                    session_name($val);
314                    break;
315
316                case 'session_use_cookies':
317                    if (session_status() !== PHP_SESSION_ACTIVE) {
318                        ini_set('session.use_cookies', $val);
319                    } else {
320                        $this->logMsg('Unable to set session_use_cookies; session is already active', LOG_NOTICE, __FILE__, __LINE__);
321                    }
322                    break;
323
324                case 'error_reporting':
325                    ini_set('error_reporting', $val);
326                    break;
327
328                case 'php_timezone':
329                    // Set timezone used by PHP.
330                    $this->setTimezone($val);
331                    break;
332
333                case 'db_timezone':
334                    // Set timezone used by MySQL.
335                    if (true === $this->getParam('enable_db_pdo')) {
336                        $this->pdo->setParam(['timezone' => $val]);
337                    }
338                    if (true === $this->getParam('enable_db')) {
339                        $this->db->setParam(['timezone' => $val]);
340                    }
341                    break;
342
343                case 'display_errors':
344                    ini_set('display_errors', $val);
345                    break;
346
347                case 'log_errors':
348                    ini_set('log_errors', true);
349                    break;
350
351                case 'log_directory':
352                    if (is_dir($val) && is_writable($val)) {
353                        ini_set('error_log', $val . '/' . $this->getParam('php_error_log'));
354                    }
355                    break;
356                }
357            }
358        }
359    }
360
361    /**
362     * Return the value of a parameter.
363     *
364     * @access  public
365     * @param   string  $param      The key of the parameter to return.
366     * @param   string  $default    The value to return if $param does not exist in $this->_params.
367     * @return  mixed               Parameter value, or null if not existing.
368     */
369    public function getParam($param=null, $default=null)
370    {
371        if ($param === null) {
372            return $this->_params;
373        } else if (array_key_exists($param, $this->_params)) {
374            return $this->_params[$param];
375        } else {
376            return $default;
377        }
378    }
379
380    /**
381     * Begin running this application.
382     *
383     * @access  public
384     * @author  Quinn Comendant <quinn@strangecode.com>
385     * @since   15 Jul 2005 00:32:21
386     */
387    public function start()
388    {
389        if ($this->running) {
390            return false;
391        }
392
393        // Error reporting.
394        ini_set('error_reporting', $this->getParam('error_reporting'));
395        ini_set('display_errors', $this->getParam('display_errors'));
396        ini_set('log_errors', true);
397        if (is_dir($this->getParam('log_directory')) && is_writable($this->getParam('log_directory'))) {
398            ini_set('error_log', $this->getParam('log_directory') . '/' . $this->getParam('php_error_log'));
399        }
400
401        // Set character set to use for multi-byte string functions.
402        mb_internal_encoding($this->getParam('character_set'));
403        ini_set('default_charset', $this->getParam('character_set'));
404        switch (mb_strtolower($this->getParam('character_set'))) {
405        case 'utf-8' :
406            $this->setParam(['preg_u' => 'u']);
407            mb_language('uni');
408            break;
409
410        case 'iso-2022-jp' :
411            mb_language('ja');
412            break;
413
414        case 'iso-8859-1' :
415        default :
416            mb_language('en');
417            break;
418        }
419
420        // Server timezone used internally by PHP.
421        if ($this->getParam('php_timezone')) {
422            $this->setTimezone($this->getParam('php_timezone'));
423        }
424
425        // 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.
426        if (strtolower(getenv('HTTP_X_FORWARDED_PROTO')) == 'https' && strtolower(getenv('REQUEST_SCHEME')) == 'http') {
427            $this->logMsg(sprintf('Detected HTTPS via X-Forwarded-Proto; setting HTTPS=on', null), LOG_DEBUG, __FILE__, __LINE__);
428            putenv('HTTPS=on'); // Available via getenv(
)
429            isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] = 'on'; // Available via $_SERVER[
]
430        }
431
432        /**
433         * 1. Start Database.
434         */
435
436        if (true === $this->getParam('enable_db') || true === $this->getParam('enable_db_pdo')) {
437
438            // DB connection parameters taken from environment variables in the server httpd.conf file (readable only by root)

439            if (isset($_SERVER['DB_SERVER']) && '' != $_SERVER['DB_SERVER'] && null === $this->getParam('db_server')) {
440                $this->setParam(array('db_server' => $_SERVER['DB_SERVER']));
441            }
442            if (isset($_SERVER['DB_NAME']) && '' != $_SERVER['DB_NAME'] && null === $this->getParam('db_name')) {
443                $this->setParam(array('db_name' => $_SERVER['DB_NAME']));
444            }
445            if (isset($_SERVER['DB_USER']) && '' != $_SERVER['DB_USER'] && null === $this->getParam('db_user')) {
446                $this->setParam(array('db_user' => $_SERVER['DB_USER']));
447            }
448            if (isset($_SERVER['DB_PASS']) && '' != $_SERVER['DB_PASS'] && null === $this->getParam('db_pass')) {
449                $this->setParam(array('db_pass' => $_SERVER['DB_PASS']));
450            }
451            unset($_SERVER['DB_SERVER'], $_SERVER['DB_NAME'], $_SERVER['DB_USER'], $_SERVER['DB_PASS']);
452
453            // 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--------
454            // But not if all DB credentials have been defined already by other means.
455            if ('' != $this->getParam('db_auth_file') && (!$this->getParam('db_server') || !$this->getParam('db_name') || !$this->getParam('db_user') || !$this->getParam('db_pass'))) {
456                if (false !== ($db_auth_file = stream_resolve_include_path($this->getParam('db_auth_file')))) {
457                    if (is_readable($db_auth_file)) {
458                        $db_auth = json_decode(file_get_contents($db_auth_file), true);
459                        if (is_null($db_auth)) {
460                            $this->logMsg(sprintf('Unable to decode json in DB auth file: %s', $db_auth_file), LOG_ERR, __FILE__, __LINE__);
461                        } else {
462                            $this->setParam($db_auth);
463                        }
464                    } else {
465                        $this->logMsg(sprintf('Unable to read DB auth file: %s', $db_auth_file), LOG_ERR, __FILE__, __LINE__);
466                    }
467                } else {
468                    $missing_db_params = [];
469                    foreach (['db_auth_file', 'db_server', 'db_name', 'db_user', 'db_pass'] as $required_db_param) {
470                        if (!$this->getParam($required_db_param)) {
471                            $missing_db_params[] = $required_db_param;
472                        }
473                    }
474                    $this->logMsg(sprintf('DB credentials or %s not found. Missing parameters: %s', $this->getParam('db_auth_file'), join(', ', $missing_db_params)), LOG_ERR, __FILE__, __LINE__);
475                }
476            }
477
478            // If the app wants a DB connection, always set up a PDO object.
479            require_once dirname(__FILE__) . '/PDO.inc.php';
480            $this->pdo =& \Strangecode\Codebase\PDO::getInstance();
481            $this->pdo->setParam(array(
482                'db_server' => $this->getParam('db_server'),
483                'db_name' => $this->getParam('db_name'),
484                'db_user' => $this->getParam('db_user'),
485                'db_pass' => $this->getParam('db_pass'),
486                'db_always_debug' => $this->getParam('db_always_debug'),
487                'db_debug' => $this->getParam('db_debug'),
488                'db_die_on_failure' => $this->getParam('db_die_on_failure'),
489                'timezone' => $this->getParam('db_timezone'),
490                'character_set' => $this->getParam('db_character_set'),
491                'collation' => $this->getParam('db_collation'),
492            ));
493            $this->pdo->connect();
494
495            // Only create a legacy mysql_* DB object if it is explicitly requested.
496            if (true === $this->getParam('enable_db')) {
497                require_once dirname(__FILE__) . '/../polyfill/mysql.inc.php';
498                require_once dirname(__FILE__) . '/DB.inc.php';
499                $this->db =& DB::getInstance();
500                $this->db->setParam(array(
501                    'db_server' => $this->getParam('db_server'),
502                    'db_name' => $this->getParam('db_name'),
503                    'db_user' => $this->getParam('db_user'),
504                    'db_pass' => $this->getParam('db_pass'),
505                    'db_always_debug' => $this->getParam('db_always_debug'),
506                    'db_debug' => $this->getParam('db_debug'),
507                    'db_die_on_failure' => $this->getParam('db_die_on_failure'),
508                    'timezone' => $this->getParam('db_timezone'),
509                    'character_set' => $this->getParam('db_character_set'),
510                    'collation' => $this->getParam('db_collation'),
511                ));
512                $this->db->connect();
513            }
514        }
515
516
517        /**
518         * 2. Start PHP session.
519         */
520
521        // Use sessions if enabled and not a CLI script.
522        if (true === $this->getParam('enable_session') && !$this->cli) {
523
524            // Session parameters.
525            // https://www.php.net/manual/en/session.security.ini.php
526            // TODO: Reliance on gc_maxlifetime is not recommended. Developers should manage the lifetime of sessions with a timestamp by themselves.
527            ini_set('session.gc_maxlifetime', 604800); // 7 days.
528            ini_set('session.cookie_lifetime', 604800); // 7 days.
529            ini_set('session.cache_limiter', $this->getParam('session_cache_limiter'));
530            ini_set('session.cookie_httponly', true);
531            ini_set('session.cookie_path', $this->getParam('session_cookie_path'));
532            ini_set('session.cookie_samesite', 'Strict'); // Only PHP >= 7.3
533            ini_set('session.cookie_secure', getenv('HTTPS') == 'on');
534            ini_set('session.entropy_file', '/dev/urandom');
535            ini_set('session.entropy_length', '512');
536            ini_set('session.gc_divisor', 1000);
537            ini_set('session.gc_probability', 1);
538            ini_set('session.sid_length', '48'); // Only PHP >= 7.1
539            ini_set('session.use_cookies', $this->getParam('session_use_cookies'));
540            ini_set('session.use_only_cookies', $this->getParam('session_use_cookies'));
541            ini_set('session.use_strict_mode', true);
542            ini_set('session.use_trans_sid', $this->getParam('session_use_trans_sid'));
543            if ('' != $this->getParam('session_dir') && is_dir($this->getParam('session_dir'))) {
544                ini_set('session.save_path', $this->getParam('session_dir'));
545            }
546            session_name($this->getParam('session_name'));
547
548            if (true === $this->getParam('enable_db_session_handler') && true === $this->getParam('enable_db')) {
549                // Database session handling.
550                require_once dirname(__FILE__) . '/DBSessionHandler.inc.php';
551                $this->db_session = new DBSessionHandler($this->db, array(
552                    'db_table' => 'session_tbl',
553                    'create_table' => $this->getParam('db_create_tables'),
554                ));
555            }
556
557            // Start the session.
558            session_start();
559
560            if (!isset($_SESSION['_app'][$this->_ns])) {
561                // Access session data using: $_SESSION['...'].
562                // Initialize here _after_ session has started.
563                $_SESSION['_app'][$this->_ns] = array(
564                    'messages' => array(),
565                    'boomerang' => array(),
566                );
567            }
568        }
569
570
571        /**
572         * 3. Misc setup.
573         */
574
575        // To get a safe hostname, remove port and invalid hostname characters.
576        $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`
577        // If strtok() matched a ':' in the previous line, the rest of the string contains the port number (or FALSE)
578        $safe_http_port = preg_replace('/[^0-9]/' . $this->getParam('preg_u'), '', strtok(''));
579        if ('' != $safe_http_host && '' == $this->getParam('site_hostname')) {
580            $this->setParam(array('site_hostname' => $safe_http_host));
581        }
582        if ('' != $safe_http_port && '' == $this->getParam('site_port')) {
583            $this->setParam(array('site_port' => $safe_http_port));
584        }
585
586        // Site URL will become something like http://host.name.tld (no ending slash)
587        // and is used whenever a URL need be used to the current site.
588        // Not available on CLI scripts obviously.
589        if ('' != $safe_http_host && '' == $this->getParam('site_url')) {
590            $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))));
591        }
592
593        // Page URL will become a permalink to the current page.
594        // Also not available on CLI scripts obviously.
595        if ('' != $safe_http_host) {
596            $this->setParam(array('page_url' => sprintf('%s%s', $this->getParam('site_url'), getenv('REQUEST_URI'))));
597        }
598
599        // In case site_email isn't set, use something halfway presentable.
600        if ('' != $safe_http_host && '' == $this->getParam('site_email')) {
601            $this->setParam(array('site_email' => sprintf('no-reply@%s', $safe_http_host)));
602        }
603
604        // A key for calculating simple cryptographic signatures.
605        if (isset($_SERVER['SIGNING_KEY'])) {
606            $this->setParam(array('signing_key' => $_SERVER['SIGNING_KEY']));
607        }
608        unset($_SERVER['SIGNING_KEY']);
609
610        // Character set. This should also be printed in the html header template.
611        if (!$this->cli) {
612            if (!headers_sent($h_file, $h_line)) {
613                header(sprintf('Content-type: %s; charset=%s', $this->getParam('content_type'), $this->getParam('character_set')));
614            } else {
615                $this->logMsg(sprintf('Unable to set Content-type; headers already sent (output started in %s : %s)', $h_file, $h_line), LOG_DEBUG, __FILE__, __LINE__);
616            }
617        }
618
619        // Cache control headers.
620        if (!$this->cli && false !== $this->getParam('http_cache_headers')) {
621            if (!headers_sent($h_file, $h_line)) {
622                if ($this->getParam('http_cache_headers') > 0) {
623                    // Allow HTTP caching, for this many seconds.
624                    header(sprintf('Cache-Control: max-age=%d', $this->getParam('http_cache_headers')));
625                    header('Vary: Accept-Encoding');
626                } else {
627                    // Disallow HTTP caching entirely. http://stackoverflow.com/a/2068407
628                    header('Cache-Control: no-cache, no-store, must-revalidate'); // HTTP 1.1.
629                    header('Pragma: no-cache'); // HTTP 1.0.
630                    header('Expires: 0'); // Proxies.
631                }
632            } else {
633                $this->logMsg(sprintf('Unable to set Cache-Control; headers already sent (output started in %s : %s)', $h_file, $h_line), LOG_DEBUG, __FILE__, __LINE__);
634            }
635        }
636
637        // Set the version of the codebase we're using.
638        $codebase_version_file = dirname(__FILE__) . '/../docs/version.txt';
639        $codebase_version = '';
640        if (is_readable($codebase_version_file) && !is_dir($codebase_version_file)) {
641            $codebase_version = trim(file_get_contents($codebase_version_file));
642            $this->setParam(array('codebase_version' => $codebase_version));
643            if (!$this->cli && $this->getParam('codebase_version')) {
644                if (!headers_sent($h_file, $h_line)) {
645                    header(sprintf('X-Codebase-Version: %s', $this->getParam('codebase_version')));
646                } else {
647                    $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__);
648                }
649            }
650        }
651
652        if (version_compare(PHP_VERSION, self::CODEBASE_MIN_PHP_VERSION, '<')) {
653            $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__);
654        }
655
656        // Set the application version if defined.
657        if (false !== ($site_version_file = stream_resolve_include_path($this->getParam('site_version_file')))) {
658            if (mb_strpos($site_version_file, '.json') !== false) {
659                $version_json = json_decode(trim(file_get_contents($site_version_file)), true);
660                $site_version = isset($version_json['version']) ? $version_json['version'] : null;
661            } else {
662                $site_version = trim(file_get_contents($site_version_file));
663            }
664            $this->setParam(array('site_version' => $site_version));
665        }
666        if (!$this->cli && $this->getParam('site_version')) {
667            if (!headers_sent($h_file, $h_line)) {
668                header(sprintf('X-Site-Version: %s', $this->getParam('site_version')));
669            } else {
670                $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__);
671            }
672        }
673
674        $this->running = true;
675        return true;
676    }
677
678    /**
679     * Stop running this application.
680     *
681     * @access  public
682     * @author  Quinn Comendant <quinn@strangecode.com>
683     * @since   17 Jul 2005 17:20:18
684     */
685    public function stop()
686    {
687        session_write_close();
688        $this->running = false;
689        $num_queries = 0;
690        if ($this->db instanceof \DB && true === $this->getParam('enable_db')) {
691            $num_queries += $this->db->numQueries();
692            if ($num_queries > 0 && true === $this->getParam('enable_db_pdo')) {
693                // If the app wants to use PDO, warn if any legacy db queries are made.
694                $this->logMsg(sprintf('%s queries using legacy DB functions', $num_queries), LOG_DEBUG, __FILE__, __LINE__);
695            }
696            $this->db->close();
697        }
698        if ($this->pdo instanceof \Strangecode\Codebase\PDO && (true === $this->getParam('enable_db') || true === $this->getParam('enable_db_pdo'))) {
699            $num_queries += $this->pdo->numQueries();
700            $this->pdo->close();
701        }
702        $mem_current = memory_get_usage();
703        $mem_peak = memory_get_peak_usage();
704        $this->timer->stop('_app');
705        $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__);
706    }
707
708    /*
709    * @access   public
710    * @return   bool    True if running in the context of a CLI script.
711    * @author   Quinn Comendant <quinn@strangecode.com>
712    * @since    10 Feb 2019 12:31:59
713    */
714    public function isCLI()
715    {
716        return $this->cli;
717    }
718
719    /**
720     * Add a message to the session, which is printed in the header.
721     * Just a simple way to print messages to the user.
722     *
723     * @access public
724     *
725     * @param string $message The text description of the message.
726     * @param int    $type    The type of message: MSG_NOTICE,
727     *                        MSG_SUCCESS, MSG_WARNING, or MSG_ERR.
728     * @param string $file    __FILE__.
729     * @param string $line    __LINE__.
730     */
731    public function raiseMsg($message, $type=MSG_NOTICE, $file=null, $line=null)
732    {
733        $message = trim($message);
734
735        if (!$this->running) {
736            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
737            return false;
738        }
739
740        if (!$this->getParam('enable_session')) {
741            $this->logMsg(sprintf('Canceled %s, session not enabled.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
742            return false;
743        }
744
745        if ('' == trim($message)) {
746            $this->logMsg(sprintf('Raised message is an empty string.', null), LOG_NOTICE, __FILE__, __LINE__);
747            return false;
748        }
749
750        // Avoid duplicate full-stops..
751        $message = trim(preg_replace('/\.{2}$/', '.', $message));
752
753        // Save message in session under unique key to avoid duplicate messages.
754        $msg_id = md5($type . $message);
755        if (!isset($_SESSION['_app'][$this->_ns]['messages'][$msg_id])) {
756            $_SESSION['_app'][$this->_ns]['messages'][$msg_id] = array(
757                'type'    => $type,
758                'message' => $message,
759                'file'    => $file,
760                'line'    => $line,
761                'count'   => (isset($_SESSION['_app'][$this->_ns]['messages'][$msg_id]['count']) ? (1 + $_SESSION['_app'][$this->_ns]['messages'][$msg_id]['count']) : 1)
762            );
763        }
764
765        if (!in_array($type, array(MSG_NOTICE, MSG_SUCCESS, MSG_WARNING, MSG_ERR))) {
766            $this->logMsg(sprintf('Invalid MSG_* type: %s', $type), LOG_NOTICE, __FILE__, __LINE__);
767        }
768
769        // Increment the counter for this message type.
770        $this->_raised_msg_counter[$type] += 1;
771    }
772
773    /*
774    * 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)
775    *
776    * @access   public
777    * @param
778    * @return
779    * @author   Quinn Comendant <quinn@strangecode.com>
780    * @version  1.0
781    * @since    30 Apr 2015 17:13:03
782    */
783    public function getRaisedMessageCount($type='all')
784    {
785        if ('all' == $type) {
786            return array_sum($this->_raised_msg_counter);
787        } else if (isset($this->_raised_msg_counter[$type])) {
788            return $this->_raised_msg_counter[$type];
789        } else {
790            $this->logMsg(sprintf('Cannot return count of unknown raised message type: %s', $type), LOG_WARNING, __FILE__, __LINE__);
791            return false;
792        }
793    }
794
795    /**
796     * Returns an array of the raised messages.
797     *
798     * @access  public
799     * @return  array   List of messages in FIFO order.
800     * @author  Quinn Comendant <quinn@strangecode.com>
801     * @since   21 Dec 2005 13:09:20
802     */
803    public function getRaisedMessages()
804    {
805        if (!$this->running) {
806            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
807            return false;
808        }
809        return isset($_SESSION['_app'][$this->_ns]['messages']) ? $_SESSION['_app'][$this->_ns]['messages'] : array();
810    }
811
812    /**
813     * Resets the message list.
814     *
815     * @access  public
816     * @author  Quinn Comendant <quinn@strangecode.com>
817     * @since   21 Dec 2005 13:21:54
818     */
819    public function clearRaisedMessages()
820    {
821        if (!$this->running) {
822            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
823            return false;
824        }
825
826        $_SESSION['_app'][$this->_ns]['messages'] = array();
827    }
828
829    /**
830     * Prints the HTML for displaying raised messages.
831     *
832     * @param   string  $above    Additional message to print above error messages (e.g. "Oops!").
833     * @param   string  $below    Additional message to print below error messages (e.g. "Please fix and resubmit").
834     * @param   string  $print_gotohash_js  Print a line of javascript that scrolls the browser window down to view any error messages.
835     * @param   string  $hash     The #hashtag to scroll to.
836     * @access  public
837     * @author  Quinn Comendant <quinn@strangecode.com>
838     * @since   15 Jul 2005 01:39:14
839     */
840    public function printRaisedMessages($above='', $below='', $print_gotohash_js=false, $hash='sc-msg')
841    {
842
843        if (!$this->running) {
844            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
845            return false;
846        }
847
848        $messages = $this->getRaisedMessages();
849        if (empty($messages)) {
850            return false;
851        }
852
853        ?><div id="sc-msg" class="sc-msg"><?php
854        if ('' != $above) {
855            ?><div class="sc-above"><?php echo oTxt($above); ?></div><?php
856        }
857        foreach ($messages as $m) {
858            if (error_reporting() > 0 && $this->getParam('display_errors') && isset($m['file']) && isset($m['line'])) {
859                echo "\n<!-- [" . $m['file'] . ' : ' . $m['line'] . '] -->';
860            }
861            switch ($m['type']) {
862            case MSG_ERR:
863                echo '<div data-alert data-closable class="sc-msg-error alert-box callout alert">' . $m['message'] . '<a class="close close-button" aria-label="Dismiss alert" data-close><span aria-hidden="true">&times;</span></a></div>';
864                break;
865
866            case MSG_WARNING:
867                echo '<div data-alert data-closable class="sc-msg-warning alert-box callout warning">' . $m['message'] . '<a class="close close-button" aria-label="Dismiss alert" data-close><span aria-hidden="true">&times;</span></a></div>';
868                break;
869
870            case MSG_SUCCESS:
871                echo '<div data-alert data-closable class="sc-msg-success alert-box callout success">' . $m['message'] . '<a class="close close-button" aria-label="Dismiss alert" data-close><span aria-hidden="true">&times;</span></a></div>';
872                break;
873
874            case MSG_NOTICE:
875            default:
876                echo '<div data-alert data-closable class="sc-msg-notice alert-box callout primary info">' . $m['message'] . '<a class="close close-button" aria-label="Dismiss alert" data-close><span aria-hidden="true">&times;</span></a></div>';
877                break;
878            }
879        }
880        if ('' != $below) {
881            ?><div class="sc-below"><?php echo oTxt($below); ?></div><?php
882        }
883        ?></div><?php
884        if ($print_gotohash_js) {
885            ?>
886            <script type="text/javascript">
887            /* <![CDATA[ */
888            window.location.hash = '#<?php echo urlencode($hash); ?>';
889            /* ]]> */
890            </script>
891            <?php
892        }
893
894        $this->clearRaisedMessages();
895        return true;
896    }
897
898    /**
899     * Logs messages to defined channels: file, email, sms, and screen. Repeated messages are
900     * not repeated but printed once with count. Log events that match a sendable channel (email or SMS)
901     * are sent once per 'log_multiple_timeout' setting (to avoid a flood of error emails).
902     *
903     * @access public
904     * @param string $message   The text description of the message.
905     * @param int    $priority  The type of message priority (in descending order):
906     *                          LOG_EMERG     0 system is unusable
907     *                          LOG_ALERT     1 action must be taken immediately
908     *                          LOG_CRIT      2 critical conditions
909     *                          LOG_ERR       3 error conditions
910     *                          LOG_WARNING   4 warning conditions
911     *                          LOG_NOTICE    5 normal, but significant, condition
912     *                          LOG_INFO      6 informational message
913     *                          LOG_DEBUG     7 debug-level message
914     * @param string $file      The file where the log event occurs.
915     * @param string $line      The line of the file where the log event occurs.
916     * @param string $url       The URL where the log event occurs ($_SERVER['REQUEST_URI'] will be used if left null).
917     */
918    public function logMsg($message, $priority=LOG_INFO, $file=null, $line=null, $url=null)
919    {
920        static $previous_events = array();
921
922        // If priority is not specified, assume the worst.
923        if (!$this->logPriorityToString($priority)) {
924            $this->logMsg(sprintf('Log priority %s not defined. (Message: %s)', $priority, $message), LOG_EMERG, $file, $line);
925            $priority = LOG_EMERG;
926        }
927
928        // In case __FILE__ and __LINE__ are not provided, note that fact.
929        $file = '' == $file ? 'unknown-file' : $file;
930        $line = '' == $line ? 'unknown-line' : $line;
931
932        // Get the URL, or used the provided value.
933        if (!isset($url)) {
934            $url = isset($_SERVER['REQUEST_URI']) ? mb_substr($_SERVER['REQUEST_URI'], 0, $this->getParam('log_message_max_length')) : '';
935        }
936
937        // If log file is not specified, don't log to a file.
938        if (!$this->getParam('log_directory') || !$this->getParam('log_filename') || !is_dir($this->getParam('log_directory')) || !is_writable($this->getParam('log_directory'))) {
939            $this->setParam(array('log_file_priority' => false));
940            // We must use trigger_error to report this problem rather than calling $app->logMsg, which might lead to an infinite loop.
941            trigger_error(sprintf('Codebase error: log directory (%s) not found or writable.', $this->getParam('log_directory')), E_USER_NOTICE);
942        }
943
944        // Before we get any further, let's see if ANY log events are configured to be reported.
945        if ((false === $this->getParam('log_file_priority') || $priority > $this->getParam('log_file_priority'))
946        && (false === $this->getParam('log_email_priority') || $priority > $this->getParam('log_email_priority'))
947        && (false === $this->getParam('log_sms_priority') || $priority > $this->getParam('log_sms_priority'))
948        && (false === $this->getParam('log_screen_priority') || $priority > $this->getParam('log_screen_priority'))) {
949            // This event would not be recorded, skip it entirely.
950            return false;
951        }
952
953        if ($this->getParam('log_serialize')) {
954            // Serialize multi-line messages.
955            $message = preg_replace('/\s+/m', ' ', trim($message));
956        }
957
958        // Store this event under a unique key, counting each time it occurs so that it only gets reported a limited number of times.
959        $msg_id = md5($message . $priority . $file . $line);
960        if ($this->getParam('log_ignore_repeated_events') && isset($previous_events[$msg_id])) {
961            $previous_events[$msg_id]++;
962            if ($previous_events[$msg_id] == 2) {
963                $this->logMsg(sprintf('%s (Event repeated %s or more times)', $message, $previous_events[$msg_id]), $priority, $file, $line);
964            }
965            return false;
966        } else {
967            $previous_events[$msg_id] = 1;
968        }
969
970        // For email and SMS notification types use "lock" files to prevent sending email and SMS notices ad infinitum.
971        if ((false !== $this->getParam('log_email_priority') && $priority <= $this->getParam('log_email_priority'))
972        || (false !== $this->getParam('log_sms_priority') && $priority <= $this->getParam('log_sms_priority'))) {
973            // This event will generate a "send" notification. Prepare lock file.
974            $site_hash = md5(empty($_SERVER['SERVER_NAME']) ? $_SERVER['SCRIPT_FILENAME'] : $_SERVER['SERVER_NAME']);
975            $lock_dir = $this->getParam('tmp_dir') . "/codebase_msgs_$site_hash/";
976            // Just use the file and line for the msg_id to limit the number of possible messages
977            // (the message string itself shan't be used as it may contain innumerable combinations).
978            $lock_file = $lock_dir . md5($file . ':' . $line);
979            if (!is_dir($lock_dir)) {
980                mkdir($lock_dir);
981            }
982            $send_notifications = true;
983            if (is_file($lock_file)) {
984                $msg_last_sent = filectime($lock_file);
985                // Has this message been sent more recently than the timeout?
986                if ((time() - $msg_last_sent) <= $this->getParam('log_multiple_timeout')) {
987                    // This message was already sent recently.
988                    $send_notifications = false;
989                } else {
990                    // Timeout has expired; send notifications again and reset timeout.
991                    touch($lock_file);
992                }
993            } else {
994                touch($lock_file);
995            }
996        }
997
998        // Use the system's locale for log messages (return to previous setting below).
999        $locale = setlocale(LC_TIME, 0);
1000        setlocale(LC_TIME, 'C');
1001
1002        // Logs should always be in UTC (return to previous setting below).
1003        $tz = date_default_timezone_get();
1004        date_default_timezone_set('UTC');
1005
1006        // Data to be stored for a log event.
1007        $event = array(
1008            'date'      => date('Y-m-d H:i:s'),
1009            'remote ip' => getRemoteAddr(),
1010            'pid'       => getmypid(),
1011            'type'      => $this->logPriorityToString($priority),
1012            'file:line' => "$file : $line",
1013            'url'       => $url,
1014            'message'   => mb_substr($message, 0, $this->getParam('log_message_max_length')),
1015        );
1016        // Here's a shortened version of event data.
1017        $event_short = $event;
1018        $event_short['url'] = truncate($event_short['url'], 120);
1019
1020        // Email info.
1021        $hostname = ('' != $this->getParam('site_hostname')) ? $this->getParam('site_hostname') : php_uname('n');
1022        $hostname = preg_replace('/^ww+\./', '', $hostname);
1023        $headers = sprintf("From: %s\nX-File: %s\nX-Line: %s", $this->getParam('site_email'), $file, $line);
1024
1025        // FILE ACTION
1026        if (false !== $this->getParam('log_file_priority') && $priority <= $this->getParam('log_file_priority')) {
1027            $event_str = '[' . join('] [', $event_short) . ']';
1028            error_log("$event_str\n", 3, $this->getParam('log_directory') . '/' . $this->getParam('log_filename'));
1029        }
1030
1031        // EMAIL ACTION
1032        if (false !== $this->getParam('log_email_priority') && $priority <= $this->getParam('log_email_priority') && '' != $this->getParam('log_to_email_address') && $send_notifications) {
1033            $subject = sprintf('[%s %s] %s', $hostname, $event['type'], mb_substr($event['message'], 0, 64));
1034            $email_msg = sprintf("A log event of type '%s' occurred on %s\n\n", $event['type'], $hostname);
1035            foreach ($event as $k=>$v) {
1036                $email_msg .= sprintf("%-16s %s\n", $k, $v);
1037            }
1038            $email_msg .= sprintf("%-16s %s\n", 'codebase version', $this->getParam('codebase_version'));
1039            $email_msg .= sprintf("%-16s %s\n", 'site version', $this->getParam('site_version'));
1040            mb_send_mail($this->getParam('log_to_email_address'), $subject, $email_msg, $headers);
1041        }
1042
1043        // SMS ACTION
1044        if (false !== $this->getParam('log_sms_priority') && $priority <= $this->getParam('log_sms_priority') && '' != $this->getParam('log_to_sms_address') && $send_notifications) {
1045            $subject = sprintf('[%s %s]', $hostname, $this->logPriorityToString($priority));
1046            $sms_msg = sprintf('%s [%s:%s]', mb_substr($event_short['message'], 0, 64), basename($file), $line);
1047            mb_send_mail($this->getParam('log_to_sms_address'), $subject, $sms_msg, $headers);
1048        }
1049
1050        // SCREEN ACTION
1051        if (false !== $this->getParam('log_screen_priority') && $priority <= $this->getParam('log_screen_priority')) {
1052            file_put_contents('php://stderr', "[{$event['type']}] [{$event['message']}]\n", FILE_APPEND);
1053        }
1054
1055        // Restore original locale.
1056        setlocale(LC_TIME, $locale);
1057
1058        // Restore original timezone.
1059        date_default_timezone_set($tz);
1060
1061        return true;
1062    }
1063
1064    /**
1065     * Returns the string representation of a LOG_* integer constant.
1066     * Updated: also returns the LOG_* integer constant if passed a string log value ('info' returns 6).
1067     *
1068     * @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..
1069     *
1070     * @return                The string representation of $priority (if integer given), or integer representation (if string given).
1071     */
1072    public function logPriorityToString($priority) {
1073        $priorities = array(
1074            LOG_EMERG   => 'emergency',
1075            LOG_ALERT   => 'alert',
1076            LOG_CRIT    => 'critical',
1077            LOG_ERR     => 'error',
1078            LOG_WARNING => 'warning',
1079            LOG_NOTICE  => 'notice',
1080            LOG_INFO    => 'info',
1081            LOG_DEBUG   => 'debug'
1082        );
1083        if (isset($priorities[$priority])) {
1084            return $priorities[$priority];
1085        } else if (is_string($priority) && false !== ($key = array_search($priority, $priorities))) {
1086            return $key;
1087        } else {
1088            return false;
1089        }
1090    }
1091
1092    /**
1093     * Forcefully set a query argument even if one currently exists in the request.
1094     * Values in the _carry_queries array will be copied to URLs (via $app->url()) and
1095     * to hidden input values (via printHiddenSession()).
1096     *
1097     * @access  public
1098     * @param   mixed   $query_key  The key (or keys, as an array) of the query argument to save.
1099     * @param   mixed   $val        The new value of the argument key.
1100     * @author  Quinn Comendant <quinn@strangecode.com>
1101     * @since   13 Oct 2007 11:34:51
1102     */
1103    public function setQuery($query_key, $val)
1104    {
1105        if (!is_array($query_key)) {
1106            $query_key = array($query_key);
1107        }
1108        foreach ($query_key as $k) {
1109            // Set the value of the specified query argument into the _carry_queries array.
1110            $this->_carry_queries[$k] = $val;
1111        }
1112    }
1113
1114    /**
1115     * Specify which query arguments will be carried persistently between requests.
1116     * Values in the _carry_queries array will be copied to URLs (via $app->url()) and
1117     * to hidden input values (via printHiddenSession()).
1118     *
1119     * @access  public
1120     * @param   mixed   $query_key   The key (or keys, as an array) of the query argument to save.
1121     * @param   mixed   $default    If the key is not available, set to this default value.
1122     * @author  Quinn Comendant <quinn@strangecode.com>
1123     * @since   14 Nov 2005 19:24:52
1124     */
1125    public function carryQuery($query_key, $default=false)
1126    {
1127        if (!is_array($query_key)) {
1128            $query_key = array($query_key);
1129        }
1130        foreach ($query_key as $k) {
1131            // If not already set, and there is a non-empty value provided in the request...
1132            if (isset($k) && '' != $k && !isset($this->_carry_queries[$k]) && false !== getFormData($k, $default)) {
1133                // Copy the value of the specified query argument into the _carry_queries array.
1134                $this->_carry_queries[$k] = getFormData($k, $default);
1135                $this->logMsg(sprintf('Carrying query: %s => %s', $k, truncate(getDump($this->_carry_queries[$k], true), 128, 'end')), LOG_DEBUG, __FILE__, __LINE__);
1136            }
1137        }
1138    }
1139
1140    /**
1141     * dropQuery() is the opposite of carryQuery(). The specified value will not appear in
1142     * url()/ohref()/printHiddenSession() modified URLs unless explicitly written in.
1143     *
1144     * @access  public
1145     * @param   mixed   $query_key  The key (or keys, as an array) of the query argument to remove.
1146     * @param   bool    $unset      Remove any values set in the request matching the given $query_key.
1147     * @author  Quinn Comendant <quinn@strangecode.com>
1148     * @since   18 Jun 2007 20:57:29
1149     */
1150    public function dropQuery($query_key, $unset=false)
1151    {
1152        if (!is_array($query_key)) {
1153            $query_key = array($query_key);
1154        }
1155        foreach ($query_key as $k) {
1156            if (array_key_exists($k, $this->_carry_queries)) {
1157                // Remove the value of the specified query argument from the _carry_queries array.
1158                $this->logMsg(sprintf('Dropping carried query: %s => %s', $k, $this->_carry_queries[$k]), LOG_DEBUG, __FILE__, __LINE__);
1159                unset($this->_carry_queries[$k]);
1160            }
1161            if ($unset && (isset($_REQUEST) && array_key_exists($k, $_REQUEST))) {
1162                unset($_REQUEST[$k], $_GET[$k], $_POST[$k], $_COOKIE[$k]);
1163            }
1164        }
1165    }
1166
1167    /**
1168     * Outputs a fully qualified URL with a query of all the used (ie: not empty)
1169     * keys and values, including optional queries. This allows mindless retention
1170     * of query arguments across page requests. If cookies are not
1171     * used and session_use_trans_sid=true the session id will be propagated in the URL.
1172     *
1173     * @param  string $url              The initial url
1174     * @param  mixed  $carry_args       Additional url arguments to carry in the query,
1175     *                                  or FALSE to prevent carrying queries. Can be any of the following formats:
1176     *                                      array('key1', key2', key3')  <-- to save these keys, if they exist in the request data.
1177     *                                      array('key1'=>'value', key2'='value')  <-- to set keys to default values if not present in request data.
1178     *                                      false  <-- To not carry any queries. If URL already has queries those will be retained.
1179     *
1180     * @param  mixed  $always_include_sid  Always add the session id, even if using_trans_sid = true. This is required when
1181     *                                     URL starts with http, since PHP using_trans_sid doesn't do those and also for
1182     *                                     header('Location...') redirections.
1183     *
1184     * @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.
1185     * @return string url with attached queries and, if not using cookies, the session id
1186     */
1187    public function url($url='', $carry_args=null, $always_include_sid=false, $include_csrf_token=false)
1188    {
1189        if (!$this->running) {
1190            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1191            return false;
1192        }
1193
1194        if ($this->getParam('csrf_token_enabled') && $include_csrf_token) {
1195            // Include the csrf_token as a carried query argument.
1196            // This token can be validated upon form submission with $app->verifyCSRFToken() or $app->requireValidCSRFToken()
1197            $carry_args = is_array($carry_args) ? $carry_args : array();
1198            $carry_args = array_merge($carry_args, array($this->getParam('csrf_token_name') => $this->getCSRFToken()));
1199        }
1200
1201        // Get any provided query arguments to include in the final URL.
1202        // If FALSE is a provided here, DO NOT carry the queries.
1203        $do_carry_queries = true;
1204        $one_time_carry_queries = array();
1205        if (!is_null($carry_args)) {
1206            if (is_array($carry_args)) {
1207                if (!empty($carry_args)) {
1208                    foreach ($carry_args as $key=>$arg) {
1209                        // Get query from appropriate source.
1210                        if (false === $arg) {
1211                            $do_carry_queries = false;
1212                        } else if (false !== getFormData($arg, false)) {
1213                            $one_time_carry_queries[$arg] = getFormData($arg); // Set arg to form data if available.
1214                        } else if (!is_numeric($key) && '' != $arg) {
1215                            $one_time_carry_queries[$key] = getFormData($key, $arg); // Set to arg to default if specified (overwritten by form data).
1216                        }
1217                    }
1218                }
1219            } else if (false !== getFormData($carry_args, false)) {
1220                $one_time_carry_queries[$carry_args] = getFormData($carry_args);
1221            } else if (false === $carry_args) {
1222                $do_carry_queries = false;
1223            }
1224        }
1225
1226        // If the URL is empty, use REQUEST_URI stripped of its query string.
1227        if ('' == $url) {
1228            $url = (strstr(getenv('REQUEST_URI'), '?', true) ?: getenv('REQUEST_URI')); // strstr() returns false if '?' is not found, so use a shorthand ternary operator.
1229        }
1230
1231        // Get the first delimiter that is needed in the url.
1232        $delim = mb_strpos($url, '?') !== false ? ini_get('arg_separator.output') : '?';
1233
1234        $q = '';
1235        if ($do_carry_queries) {
1236            // Join the global _carry_queries and local one_time_carry_queries.
1237            $query_args = urlEncodeArray(array_merge($this->_carry_queries, $one_time_carry_queries));
1238            foreach ($query_args as $key=>$val) {
1239
1240                // Avoid indexed-array query params because in a URL array param keys should all match.
1241                // I.e, we want to use `array[]=A&array[]=B` instead of `array[0]=A&array[1]=B`.
1242                // 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?
1243                // $key = preg_replace('/\[\d+\]$/' . $this->getParam('preg_u'), '[]', $key);
1244
1245                // Check value is set and value does not already exist in the url.
1246                if (!preg_match('/[?&]' . preg_quote($key) . '=/', $url)) {
1247                    $q .= $delim . $key . '=' . $val;
1248                    $delim = ini_get('arg_separator.output');
1249                }
1250            }
1251        }
1252
1253        // Pop off any named anchors to push them back on after appending additional query args.
1254        $parts = explode('#', $url, 2);
1255        $url = $parts[0];
1256        $anchor = isset($parts[1]) ? $parts[1] : '';
1257
1258        // Include the necessary SID if the following is true:
1259        // - no cookie in http request OR cookies disabled in App
1260        // - sessions are enabled
1261        // - the link stays on our site
1262        // - transparent SID propagation with session.use_trans_sid is not being used OR url begins with protocol (using_trans_sid has no effect here)
1263        // OR
1264        // - 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)
1265        // AND
1266        // - the SID is not already in the query.
1267        if (
1268            (
1269                (
1270                    (
1271                        !isset($_COOKIE[session_name()])
1272                        || !$this->getParam('session_use_cookies')
1273                    )
1274                    && $this->getParam('session_use_trans_sid')
1275                    && $this->getParam('enable_session')
1276                    && isMyDomain($url)
1277                    && (
1278                        !ini_get('session.use_trans_sid')
1279                        || preg_match('!^(http|https)://!i', $url)
1280                    )
1281                )
1282                || $always_include_sid
1283            )
1284            && !preg_match('/[?&]' . preg_quote(session_name()) . '=/', $url)
1285        ) {
1286            $url = sprintf('%s%s%s%s=%s%s', $url, $q, $delim, session_name(), session_id(), ('' == $anchor ? '' : "#$anchor"));
1287        } else {
1288            $url = sprintf('%s%s%s', $url, $q, ('' == $anchor ? '' : "#$anchor"));
1289        }
1290
1291        if ('' == $url) {
1292            $this->logMsg(sprintf('Generated empty URL. Args: %s', getDump(func_get_args())), LOG_NOTICE, __FILE__, __LINE__);
1293        }
1294
1295        return $url;
1296    }
1297
1298    /**
1299     * Returns a HTML-friendly URL processed with $app->url and & replaced with &amp;
1300     *
1301     * @access  public
1302     * @param   (see param reference for url() method)
1303     * @return  string          URL passed through $app->url() with ampersands transformed to $amp;
1304     * @author  Quinn Comendant <quinn@strangecode.com>
1305     * @since   09 Dec 2005 17:58:45
1306     */
1307    public function oHREF($url='', $carry_args=null, $always_include_sid=false, $include_csrf_token=false)
1308    {
1309        // Process the URL.
1310        $url = $this->url($url, $carry_args, $always_include_sid, $include_csrf_token);
1311
1312        // Replace any & not followed by an html or unicode entity with its &amp; equivalent.
1313        $url = preg_replace('/&(?![\w\d#]{1,10};)/' . $this->getParam('preg_u'), '&amp;', $url);
1314
1315        return $url;
1316    }
1317
1318    /*
1319    * Returns a string containing <input type="hidden" > for session, carried queries, and CSRF token.
1320    *
1321    * @access   public
1322    * @param    (see printHiddenSession)
1323    * @return   string
1324    * @author   Quinn Comendant <quinn@strangecode.com>
1325    * @since    25 May 2019 15:01:40
1326    */
1327    public function getHiddenSession($carry_args=null, $include_csrf_token=false)
1328    {
1329        if (!$this->running) {
1330            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1331            return false;
1332        }
1333
1334        $out = '';
1335
1336        // Get any provided query arguments to include in the final hidden form data.
1337        // If FALSE is a provided here, DO NOT carry the queries.
1338        $do_carry_queries = true;
1339        $one_time_carry_queries = array();
1340        if (!is_null($carry_args)) {
1341            if (is_array($carry_args)) {
1342                if (!empty($carry_args)) {
1343                    foreach ($carry_args as $key=>$arg) {
1344                        // Get query from appropriate source.
1345                        if (false === $arg) {
1346                            $do_carry_queries = false;
1347                        } else if (false !== getFormData($arg, false)) {
1348                            $one_time_carry_queries[$arg] = getFormData($arg); // Set arg to form data if available.
1349                        } else if (!is_numeric($key) && '' != $arg) {
1350                            $one_time_carry_queries[$key] = getFormData($key, $arg); // Set to arg to default if specified (overwritten by form data).
1351                        }
1352                    }
1353                }
1354            } else if (false !== getFormData($carry_args, false)) {
1355                $one_time_carry_queries[$carry_args] = getFormData($carry_args);
1356            } else if (false === $carry_args) {
1357                $do_carry_queries = false;
1358            }
1359        }
1360
1361        // For each existing request value, we create a hidden input to carry it through a form.
1362        if ($do_carry_queries) {
1363            // Join the global _carry_queries and local one_time_carry_queries.
1364            // urlencode is not used here, not for form data!
1365            $query_args = array_merge($this->_carry_queries, $one_time_carry_queries);
1366            foreach ($query_args as $key => $val) {
1367                if (is_array($val)) {
1368                    foreach ($val as $subval) {
1369                        if ('' != $key && '' != $subval) {
1370                            $out .= sprintf('<input type="hidden" name="%s[]" value="%s" />', oTxt($key), oTxt($subval));
1371                        }
1372                    }
1373                } else if ('' != $key && '' != $val) {
1374                    $out .= sprintf('<input type="hidden" name="%s" value="%s" />', oTxt($key), oTxt($val));
1375                }
1376            }
1377            unset($query_args, $key, $val, $subval);
1378        }
1379
1380        // Include the SID if:
1381        // * cookies are disabled
1382        // * the system isn't automatically adding trans_sid
1383        // * the session is enabled
1384        // * and we're configured to use trans_sid
1385        if (!isset($_COOKIE[session_name()])
1386        && !ini_get('session.use_trans_sid')
1387        && $this->getParam('enable_session')
1388        && $this->getParam('session_use_trans_sid')
1389        ) {
1390            $out .= sprintf('<input type="hidden" name="%s" value="%s" />', oTxt(session_name()), oTxt(session_id()));
1391        }
1392
1393        // Include the csrf_token in the form.
1394        // This token can be validated upon form submission with $app->verifyCSRFToken() or $app->requireValidCSRFToken()
1395        if ($this->getParam('csrf_token_enabled') && $include_csrf_token) {
1396            $out .= sprintf('<input type="hidden" name="%s" value="%s" />', oTxt($this->getParam('csrf_token_name')), oTxt($this->getCSRFToken()));
1397        }
1398
1399        return $out;
1400    }
1401
1402    /**
1403     * Prints a hidden form element with the PHPSESSID when cookies are not used, as well
1404     * as hidden form elements for GET_VARS that might be in use.
1405     *
1406     * @param  mixed  $carry_args        Additional url arguments to carry in the query,
1407     *                                   or FALSE to prevent carrying queries. Can be any of the following formats:
1408     *                                      array('key1', key2', key3')  <-- to save these keys if in the form data.
1409     *                                      array('key1'=>'value', key2'='value')  <-- to set keys to default values if not present in form data.
1410     *                                      false  <-- To not carry any queries. If URL already has queries those will be retained.
1411     * @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.
1412     */
1413    public function printHiddenSession($carry_args=null, $include_csrf_token=false)
1414    {
1415        echo $this->getHiddenSession($carry_args, $include_csrf_token);
1416    }
1417
1418    /*
1419    * 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
1420    *
1421    * @access   public
1422    * @param    string  $url    URL to media (e.g., /foo.js)
1423    * @return   string          URL with cache-busting version appended (/foo.js?v=1234567890)
1424    * @author   Quinn Comendant <quinn@strangecode.com>
1425    * @version  1.0
1426    * @since    03 Sep 2014 22:40:24
1427    */
1428    public function cacheBustURL($url)
1429    {
1430        // Get the first delimiter that is needed in the url.
1431        $delim = mb_strpos($url, '?') !== false ? ini_get('arg_separator.output') : '?';
1432        $v = crc32($this->getParam('codebase_version') . '|' . $this->getParam('site_version'));
1433        return sprintf('%s%sv=%s', $url, $delim, $v);
1434    }
1435
1436    /*
1437    * Generate a csrf_token if it doesn't exist or is expired, save it to the session and return its value.
1438    * Otherwise just return the current token.
1439    * Details on the synchronizer token pattern:
1440    * https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#General_Recommendation:_Synchronizer_Token_Pattern
1441    *
1442    * @access   public
1443    * @param    bool    $force_new_token    Generate a new token, replacing any existing token in the session (used by $app->resetCSRFToken())
1444    * @return   string The new or current csrf_token
1445    * @author   Quinn Comendant <quinn@strangecode.com>
1446    * @version  1.0
1447    * @since    15 Nov 2014 17:57:17
1448    */
1449    public function getCSRFToken($force_new_token=false)
1450    {
1451        if ($force_new_token || !isset($_SESSION['_app'][$this->_ns]['csrf_token']) || (removeSignature($_SESSION['_app'][$this->_ns]['csrf_token']) + $this->getParam('csrf_token_timeout') < time())) {
1452            // No token, or token is expired; generate one and return it.
1453            return $_SESSION['_app'][$this->_ns]['csrf_token'] = addSignature(time(), null, 64);
1454        }
1455        // Current token is not expired; return it.
1456        return $_SESSION['_app'][$this->_ns]['csrf_token'];
1457    }
1458
1459    /*
1460    * 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.
1461    *
1462    * @access   public
1463    * @return   void
1464    * @author   Quinn Comendant <quinn@strangecode.com>
1465    * @since    14 Oct 2021 17:35:19
1466    */
1467    public function resetCSRFToken()
1468    {
1469        $this->getCSRFToken(true);
1470    }
1471
1472    /*
1473    * Compares the given csrf_token with the current or previous one saved in the session.
1474    *
1475    * @access   public
1476    * @param    string  $user_submitted_csrf_token The user-submitted token to compare with the session token.
1477    * @return   bool    True if the tokens match, false otherwise.
1478    * @author   Quinn Comendant <quinn@strangecode.com>
1479    * @version  1.0
1480    * @since    15 Nov 2014 18:06:55
1481    */
1482    public function verifyCSRFToken($user_submitted_csrf_token)
1483    {
1484
1485        if (!$this->getParam('csrf_token_enabled')) {
1486            $this->logMsg(sprintf('%s called, but csrf_token_enabled=false', __METHOD__), LOG_ERR, __FILE__, __LINE__);
1487            return true;
1488        }
1489        if ('' == trim($user_submitted_csrf_token)) {
1490            $this->logMsg(sprintf('Empty string failed CSRF verification.', null), LOG_NOTICE, __FILE__, __LINE__);
1491            return false;
1492        }
1493        if (!verifySignature($user_submitted_csrf_token, null, 64)) {
1494            $this->logMsg(sprintf('Input failed CSRF verification (invalid signature in %s).', $user_submitted_csrf_token), LOG_WARNING, __FILE__, __LINE__);
1495            return false;
1496        }
1497        $csrf_token = $this->getCSRFToken();
1498        if ($user_submitted_csrf_token != $csrf_token) {
1499            $this->logMsg(sprintf('Input failed CSRF verification (%s not in %s).', $user_submitted_csrf_token, $csrf_token), LOG_WARNING, __FILE__, __LINE__);
1500            return false;
1501        }
1502        $this->logMsg(sprintf('Verified CSRF token %s', $user_submitted_csrf_token), LOG_DEBUG, __FILE__, __LINE__);
1503        return true;
1504    }
1505
1506    /*
1507    * Bounce user if they submit a token that doesn't match the one saved in the session.
1508    * Because this function calls dieURL() it must be called before any other HTTP header output.
1509    *
1510    * @access   public
1511    * @param    string  $message    Optional message to display to the user (otherwise default message will display). Set to an empty string to display no message.
1512    * @param    int    $type    The type of message: MSG_NOTICE,
1513    *                           MSG_SUCCESS, MSG_WARNING, or MSG_ERR.
1514    * @param    string $file    __FILE__.
1515    * @param    string $line    __LINE__.
1516    * @return   void
1517    * @author   Quinn Comendant <quinn@strangecode.com>
1518    * @version  1.0
1519    * @since    15 Nov 2014 18:10:17
1520    */
1521    public function requireValidCSRFToken($message=null, $type=MSG_NOTICE, $file=null, $line=null)
1522    {
1523        if (!$this->verifyCSRFToken(getFormData($this->getParam('csrf_token_name')))) {
1524            $message = isset($message) ? $message : _("Sorry, the form token expired. Please try again.");
1525            $this->raiseMsg($message, $type, $file, $line);
1526            $this->dieBoomerangURL();
1527        }
1528    }
1529
1530    /**
1531     * Uses an http header to redirect the client to the given $url. If sessions are not used
1532     * and the session is not already defined in the given $url, the SID is appended as a URI query.
1533     * As with all header generating functions, make sure this is called before any other output.
1534     * Using relative URI with Location: header is valid as per https://tools.ietf.org/html/rfc7231#section-7.1.2
1535     *
1536     * @param   string  $url                    The URL the client will be redirected to.
1537     * @param   mixed   $carry_args             Additional url arguments to carry in the query,
1538     *                                          or FALSE to prevent carrying queries. Can be any of the following formats:
1539     *                                          -array('key1', key2', key3')  <-- to save these keys if in the form data.
1540     *                                          -array('key1' => 'value', key2' => 'value')  <-- to set keys to default values if not present in form data.
1541     *                                          -false  <-- To not carry any queries. If URL already has queries those will be retained.
1542     * @param   bool    $always_include_sid     Force session id to be added to Location header.
1543     * @param   int     $http_response_code     The HTTP response code to include with the Location header. Use 303 when the redirect should be GET, or
1544     *                                          use 307 when the redirect should use the same method as the original request.
1545     */
1546    public function dieURL($url, $carry_args=null, $always_include_sid=false, $http_response_code=303)
1547    {
1548        if (!$this->running) {
1549            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1550            $this->stop();
1551            die;
1552        }
1553
1554        if ('' == $url) {
1555            // If URL is not specified, use the redirect_home_url.
1556            $url = $this->getParam('redirect_home_url');
1557        }
1558
1559        $url = $this->url($url, $carry_args, $always_include_sid);
1560
1561        if (!headers_sent($h_file, $h_line)) {
1562            header(sprintf('Location: %s', $url), true, $http_response_code);
1563            $this->logMsg(sprintf('dieURL: %s', $url), LOG_DEBUG, __FILE__, __LINE__);
1564        } else {
1565            // Fallback: die using meta refresh instead.
1566            printf('<meta http-equiv="refresh" content="0;url=%s" />', oTxt($url));
1567            $this->logMsg(sprintf('dieURL (refresh): %s; headers already sent (output started in %s : %s)', $url, $h_file, $h_line), LOG_NOTICE, __FILE__, __LINE__);
1568        }
1569
1570        // End application.
1571        // Recommended, although I'm not sure it's necessary: https://www.php.net/session_write_close
1572        $this->stop();
1573        die;
1574    }
1575
1576    /*
1577    * Redirects a user by calling $app->dieURL(). It will use:
1578    * 1. the stored boomerang URL, it it exists
1579    * 2. a specified $default_url, it it exists
1580    * 3. the referring URL, it it exists.
1581    * 4. redirect_home_url configuration variable.
1582    *
1583    * @access   public
1584    * @param    string  $id             Identifier for this script.
1585    * @param    mixed   $carry_args     Additional arguments to carry in the URL automatically (see $app->url()).
1586    * @param    string  $default_url    A default URL if there is not a valid specified boomerang URL.
1587    * @param    bool    $queryless_referrer_comparison   Exclude the URL query from the refererIsMe() comparison.
1588    * @return   bool                    False if the session is not running. No return otherwise.
1589    * @author   Quinn Comendant <quinn@strangecode.com>
1590    * @since    31 Mar 2006 19:17:00
1591    */
1592    public function dieBoomerangURL($id=null, $carry_args=null, $default_url=null, $queryless_referrer_comparison=false)
1593    {
1594        if (!$this->running) {
1595            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1596            return false;
1597        }
1598
1599        // Get URL from stored boomerang. Allow non specific URL if ID not valid.
1600        if ($this->validBoomerangURL($id, true)) {
1601            if (isset($id) && isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) {
1602                $url = $_SESSION['_app'][$this->_ns]['boomerang'][$id]['url'];
1603                $this->logMsg(sprintf('dieBoomerangURL(%s) found: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1604            } else {
1605                $url = end($_SESSION['_app'][$this->_ns]['boomerang'])['url'];
1606                $this->logMsg(sprintf('dieBoomerangURL(%s) using: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1607            }
1608            // Delete stored boomerang.
1609            $this->deleteBoomerangURL($id);
1610        } else if (isset($default_url)) {
1611            $url = $default_url;
1612        } else if (!refererIsMe(true === $queryless_referrer_comparison) && '' != ($url = getenv('HTTP_REFERER'))) {
1613            // Ensure that the redirecting page is not also the referrer.
1614            $this->logMsg(sprintf('dieBoomerangURL(%s) using referrer: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1615        } else {
1616            // If URL is not specified, use the redirect_home_url.
1617            $url = $this->getParam('redirect_home_url');
1618            $this->logMsg(sprintf('dieBoomerangURL(%s) using redirect_home_url: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1619        }
1620
1621        // A redirection will never happen immediately twice. Set the time so we can ensure this doesn't happen.
1622        $_SESSION['_app'][$this->_ns]['boomerang_last_redirect_time'] = time();
1623
1624        // Do it.
1625        $this->dieURL($url, $carry_args);
1626    }
1627
1628    /**
1629     * Set the URL to return to when $app->dieBoomerangURL() is called.
1630     *
1631     * @param string  $url  A fully validated URL.
1632     * @param bool  $id     An identification tag for this url.
1633     * FIXME: url garbage collection?
1634     */
1635    public function setBoomerangURL($url=null, $id=null)
1636    {
1637        if (!$this->running) {
1638            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1639            return false;
1640        }
1641
1642        if ('' != $url && is_string($url)) {
1643            // Delete any boomerang request keys in the query string (along with any trailing delimiters after the deletion).
1644            $url = preg_replace(array('/([&?])boomerang=[^&?]+[&?]?/' . $this->getParam('preg_u'), '/[&?]$/'), array('$1', ''), $url);
1645
1646            if (isset($_SESSION['_app'][$this->_ns]['boomerang']) && is_array($_SESSION['_app'][$this->_ns]['boomerang']) && !empty($_SESSION['_app'][$this->_ns]['boomerang'])) {
1647                // If the ID already exists in the boomerang array, delete it.
1648                foreach (array_keys($_SESSION['_app'][$this->_ns]['boomerang']) as $existing_id) {
1649                    // $existing_id could be null if existing boomerang URL was set without an ID.
1650                    if ($existing_id === $id) {
1651                        $this->logMsg(sprintf('Deleted existing boomerang URL matching ID: %s=>%s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1652                        unset($_SESSION['_app'][$this->_ns]['boomerang'][$existing_id]);
1653                    }
1654                }
1655            }
1656
1657            // A redirection will never happen immediately after setting the boomerang URL.
1658            // Set the time so ensure this doesn't happen. See $app->validBoomerangURL for more.
1659            if (isset($id)) {
1660                $_SESSION['_app'][$this->_ns]['boomerang'][$id] = array(
1661                    'url' => $url,
1662                    'added_time' => time(),
1663                );
1664            } else {
1665                $_SESSION['_app'][$this->_ns]['boomerang'][] = array(
1666                    'url' => $url,
1667                    'added_time' => time(),
1668                );
1669            }
1670
1671            $this->logMsg(sprintf('setBoomerangURL(%s): %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1672            return true;
1673        } else {
1674            $this->logMsg(sprintf('setBoomerangURL(%s) is empty!', $id, $url), LOG_NOTICE, __FILE__, __LINE__);
1675            return false;
1676        }
1677    }
1678
1679    /**
1680     * Return the URL set for the specified $id, or an empty string if one isn't set.
1681     *
1682     * @param string  $id     An identification tag for this url.
1683     */
1684    public function getBoomerangURL($id=null)
1685    {
1686        if (!$this->running) {
1687            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1688            return false;
1689        }
1690
1691        if (isset($id)) {
1692            if (isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) {
1693                return $_SESSION['_app'][$this->_ns]['boomerang'][$id]['url'];
1694            } else {
1695                return '';
1696            }
1697        } else if (isset($_SESSION['_app'][$this->_ns]['boomerang']) && is_array($_SESSION['_app'][$this->_ns]['boomerang']) && !empty($_SESSION['_app'][$this->_ns]['boomerang'])) {
1698            return end($_SESSION['_app'][$this->_ns]['boomerang'])['url'];
1699        } else {
1700            return false;
1701        }
1702    }
1703
1704    /**
1705     * Delete the URL set for the specified $id.
1706     *
1707     * @param string  $id     An identification tag for this url.
1708     */
1709    public function deleteBoomerangURL($id=null)
1710    {
1711        if (!$this->running) {
1712            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1713            return false;
1714        }
1715
1716        if (isset($id) && isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) {
1717            $url = $this->getBoomerangURL($id);
1718            unset($_SESSION['_app'][$this->_ns]['boomerang'][$id]);
1719        } else if (is_array($_SESSION['_app'][$this->_ns]['boomerang'])) {
1720            $url = array_pop($_SESSION['_app'][$this->_ns]['boomerang'])['url'];
1721        }
1722        $this->logMsg(sprintf('deleteBoomerangURL(%s): %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1723    }
1724
1725    /**
1726     * Check if a valid boomerang URL value has been set. A boomerang URL is considered
1727     * valid if: 1) it is not empty, 2) it is not the current URL, and 3) has not been accessed within n seconds.
1728     *
1729     * @return bool  True if it is set and valid, false otherwise.
1730     */
1731    public function validBoomerangURL($id=null, $use_nonspecificboomerang=false)
1732    {
1733        if (!$this->running) {
1734            $this->logMsg(sprintf('Canceled %s, application not running.', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
1735            return false;
1736        }
1737
1738        if (!isset($_SESSION['_app'][$this->_ns]['boomerang']) || !is_array($_SESSION['_app'][$this->_ns]['boomerang']) || empty($_SESSION['_app'][$this->_ns]['boomerang'])) {
1739            $this->logMsg(sprintf('validBoomerangURL(%s) no boomerang URL set, not an array, or empty.', $id), LOG_DEBUG, __FILE__, __LINE__);
1740            return false;
1741        }
1742
1743        $url = '';
1744        if (isset($id) && isset($_SESSION['_app'][$this->_ns]['boomerang'][$id])) {
1745            $url = $_SESSION['_app'][$this->_ns]['boomerang'][$id]['url'];
1746            $added_time = $_SESSION['_app'][$this->_ns]['boomerang'][$id]['added_time'];
1747        } else if (!isset($id) || $use_nonspecificboomerang) {
1748            // Use most recent, non-specific boomerang if available.
1749            $url = end($_SESSION['_app'][$this->_ns]['boomerang'])['url'];
1750            $added_time = end($_SESSION['_app'][$this->_ns]['boomerang'])['added_time'];
1751        }
1752
1753        if ('' == trim($url)) {
1754            $this->logMsg(sprintf('validBoomerangURL(%s) not valid, empty!', $id), LOG_DEBUG, __FILE__, __LINE__);
1755            return false;
1756        }
1757
1758        if ($url == absoluteMe() || $url == getenv('REQUEST_URI')) {
1759            // The URL we are directing to is the current page.
1760            $this->logMsg(sprintf('validBoomerangURL(%s) not valid, same as absoluteMe or REQUEST_URI: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1761            return false;
1762        }
1763
1764        // 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).
1765        $boomerang_last_redirect_time = isset($_SESSION['_app'][$this->_ns]['boomerang_last_redirect_time']) ? $_SESSION['_app'][$this->_ns]['boomerang_last_redirect_time'] : null;
1766        if (isset($boomerang_last_redirect_time) && $boomerang_last_redirect_time >= (time() - 2)) {
1767            // Last boomerang direction was less than 2 seconds ago.
1768            $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__);
1769            return false;
1770        }
1771
1772        if (isset($added_time) && $added_time < (time() - 72000)) {
1773            // Last boomerang direction was more than 20 hours ago.
1774            $this->logMsg(sprintf('validBoomerangURL(%s) not valid, added_time too old: %s seconds', $id, time() - $added_time), LOG_DEBUG, __FILE__, __LINE__);
1775            // Delete this defunct boomerang.
1776            $this->deleteBoomerangURL($id);
1777            return false;
1778        }
1779
1780        $this->logMsg(sprintf('validBoomerangURL(%s) is valid: %s', $id, $url), LOG_DEBUG, __FILE__, __LINE__);
1781        return true;
1782    }
1783
1784    /**
1785     * This function has changed to do nothing. SSL redirection should happen at the server layer, doing so here may result in a redirect loop.
1786     */
1787    public function sslOn()
1788    {
1789        $this->logMsg(sprintf('sslOn was called and ignored.', null), LOG_DEBUG, __FILE__, __LINE__);
1790    }
1791
1792    /**
1793     * 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.
1794     */
1795    public function sslOff()
1796    {
1797        $this->logMsg(sprintf('sslOff was called and ignored.', null), LOG_DEBUG, __FILE__, __LINE__);
1798    }
1799
1800    /*
1801    * Sets a cookie, with error checking and some sane defaults.
1802    *
1803    * @access   public
1804    * @param    string  $name       The name of the cookie.
1805    * @param    string  $value      The value of the cookie.
1806    * @param    string  $expire     The time the cookie expires, as a unix timestamp or string value passed to strtotime.
1807    * @param    string  $path       The path on the server in which the cookie will be available on.
1808    * @param    string  $domain     The domain that the cookie is available to.
1809    * @param    bool    $secure     Indicates that the cookie should only be transmitted over a secure HTTPS connection from the client.
1810    * @param    bool    $httponly   When TRUE the cookie will be made accessible only through the HTTP protocol (makes cookies unreadable to javascript).
1811    * @param    string  $samesite   Value of the SameSite key ('None', 'Lax', or 'Strict'). PHP 7.3+ only.
1812    * @return   bool                True on success, false on error.
1813    * @author   Quinn Comendant <quinn@strangecode.com>
1814    * @version  1.0
1815    * @since    02 May 2014 16:36:34
1816    */
1817    public function setCookie($name, $value, $expire='+10 years', $path='/', $domain=null, $secure=null, $httponly=null, $samesite=null)
1818    {
1819        if (!is_scalar($name)) {
1820            $this->logMsg(sprintf('Cookie name must be scalar, is not: %s', getDump($name)), LOG_NOTICE, __FILE__, __LINE__);
1821            return false;
1822        }
1823        if (!is_scalar($value)) {
1824            $this->logMsg(sprintf('Cookie "%s" value must be scalar, is not: %s', $name, getDump($value)), LOG_NOTICE, __FILE__, __LINE__);
1825            return false;
1826        }
1827
1828        // Defaults.
1829        $expire = (is_numeric($expire) ? $expire : (is_string($expire) ? strtotime($expire) : $expire));
1830        $secure = $secure ?: getenv('HTTPS') == 'on';
1831        $httponly = $httponly ?: true;
1832        $samesite = $samesite ?: 'Lax';
1833
1834        // Make sure the expiration date is a valid 32bit integer.
1835        if (is_int($expire) && $expire > 2147483647) {
1836            $this->logMsg(sprintf('Cookie "%s" expire time exceeds a 32bit integer (%s)', $key, date('r', $expire)), LOG_NOTICE, __FILE__, __LINE__);
1837        }
1838
1839        // Measure total cookie length and warn if larger than max recommended size of 4093.
1840        // https://stackoverflow.com/questions/640938/what-is-the-maximum-size-of-a-web-browsers-cookies-key
1841        // The date and header name adds 51 bytes: Set-Cookie: ; expires=Fri, 03-May-2024 00:04:47 GMT
1842        $cookielen = strlen($name . $value . $path . $domain . ($secure ? '; secure' : '') . ($httponly ? '; httponly' : '') . ($samesite ? '; SameSite=' . $samesite : '')) + 51;
1843        if ($cookielen > 4093) {
1844            $this->logMsg(sprintf('Cookie "%s" has a size greater than 4093 bytes (is %s bytes)', $key, $cookielen), LOG_NOTICE, __FILE__, __LINE__);
1845        }
1846
1847        // Ensure PHP version allow use of httponly.
1848        if (version_compare(PHP_VERSION, '7.3.0', '>=')) {
1849            $ret = setcookie($name, $value, [
1850                'expires' => $expire,
1851                'path' => $path,
1852                'domain' => $domain,
1853                'secure' => $secure,
1854                'httponly' => $httponly,
1855                'samesite' => $samesite,
1856            ]);
1857        } else if (version_compare(PHP_VERSION, '5.2.0', '>=')) {
1858            $ret = setcookie($name, $value, $expire, $path, $domain, $secure, $httponly);
1859        } else {
1860            $ret = setcookie($name, $value, $expire, $path, $domain, $secure);
1861        }
1862
1863        if (false === $ret) {
1864            $this->logMsg(sprintf('Failed to set cookie (%s=%s) probably due to output before headers.', $name, $value), LOG_NOTICE, __FILE__, __LINE__);
1865        }
1866        return $ret;
1867    }
1868
1869    /*
1870    * Delete a cookie previously created by setCookie(). The same function arguments must be used to unset a cookie as were used to set it.
1871    *
1872    * @access   public
1873    * @param    string  $name       The name of the cookie.
1874    * @param    string  $path       The path on the server in which the cookie will be available on.
1875    * @param    string  $domain     The domain that the cookie is available to.
1876    * @param    bool    $secure     Indicates that the cookie should only be transmitted over a secure HTTPS connection from the client.
1877    * @param    bool    $httponly   When TRUE the cookie will be made accessible only through the HTTP protocol (makes cookies unreadable to javascript).
1878    * @param    string  $samesite   Value of the SameSite key ('None', 'Lax', or 'Strict'). PHP 7.3+ only.
1879    * @return   bool                True on success, false on error.
1880    * @author   Quinn Comendant <quinn@strangecode.com>
1881    * @since    14 Mar 2023 12:12:15
1882    */
1883    public function unsetCookie($name, $path='/', $domain=null, $secure=null, $httponly=null, $samesite=null)
1884    {
1885        return $this->setCookie($name, '', 1, $path, $domain, $secure, $httponly, $samesite);
1886    }
1887
1888    /*
1889    * Set timezone used internally by PHP. See full list at https://www.php.net/manual/en/timezones.php
1890    *
1891    * @access   public
1892    * @param    string  $tz     Timezone, e.g., America/Mexico_City
1893    * @return
1894    * @author   Quinn Comendant <quinn@strangecode.com>
1895    * @since    28 Jan 2019 16:38:38
1896    */
1897    public function setTimezone($tz)
1898    {
1899        // Set timezone for PHP.
1900        if (date_default_timezone_set($tz)) {
1901            $this->logMsg(sprintf('Using php timezone: %s', $tz), LOG_DEBUG, __FILE__, __LINE__);
1902        } else {
1903            // Failed!
1904            $this->logMsg(sprintf('Failed to set php timezone: %s', $tz), LOG_WARNING, __FILE__, __LINE__);
1905        }
1906    }
1907
1908    /*
1909    * Create a DateTime object from a string and convert its timezone.
1910    *
1911    * @access   public
1912    * @param    string  $datetime   A date-time string or unit timestamp, e.g., `now + 60 days` or `1606165903`.
1913    * @param    string  $from_tz    A PHP timezone, e.g., UTC
1914    * @param    string  $to_tz      A PHP timezone, e.g., America/Mexico_City
1915    * @return   DateTime            A DateTime object ready to use with, e.g., ->format(
).
1916    * @author   Quinn Comendant <quinn@strangecode.com>
1917    * @since    23 Nov 2020 15:08:45
1918    */
1919    function convertTZ($datetime, $from_tz, $to_tz)
1920    {
1921        if (preg_match('/^\d+$/', $datetime)) {
1922            // It's a timestamp, format as required by DateTime::__construct().
1923            $datetime = "@$datetime";
1924        }
1925
1926        $dt = new DateTime($datetime, new DateTimeZone($from_tz));
1927        $dt->setTimezone(new DateTimeZone($to_tz));
1928
1929        return $dt;
1930    }
1931
1932    /*
1933    * Convert a given date-time string from php_timezone to user_timezone, and return formatted.
1934    *
1935    * @access   public
1936    * @param    string  $datetime   A date-time string or unit timestamp, e.g., `now + 60 days` or `1606165903`.
1937    * @param    string  $format     A date format string for DateTime->format(
) or strftime(
). Set to lc_date_format by default.
1938    * @return   string              A formatted date in the user's timezone.
1939    * @author   Quinn Comendant <quinn@strangecode.com>
1940    * @since    23 Nov 2020 15:13:26
1941    */
1942    function dateToUserTZ($datetime, $format=null)
1943    {
1944        if (empty($datetime) || in_array($datetime, ['0000-00-00 00:00:00', '0000-00-00', '1000-01-01 00:00:00', '1000-01-01'])) {
1945            // Zero dates in MySQL should never be displayed.
1946            return '';
1947        }
1948
1949        try {
1950            // Create a DateTime object and convert the timezone from server to user.
1951            $dt = $this->convertTZ($datetime, $this->getParam('php_timezone'), $this->getParam('user_timezone'));
1952        } catch (Exception $e) {
1953            $this->logMsg(sprintf('DateTime failed to parse string in %s: %s', __METHOD__, $datetime), LOG_NOTICE, __FILE__, __LINE__);
1954            return '';
1955        }
1956
1957        // By default, we try to use a localized date format. Set lc_date_format to null to use regular date_format instead.
1958        $format = $format ?: $this->getParam('lc_date_format');
1959        if ($format && mb_strpos($format, '%') !== false) {
1960            // The date 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 DateTime->format('U') because that would convert the date back to UTC).
1961            // FIXME: The strftime() function is deprecated as of PHP 8.1.
1962            // Consider replacing strftime() with a non-deprecated alternative like DateTime::format() or IntlDateFormatter for locale-specific formatting.
1963            return @strftime($format, strtotime($dt->format('Y-m-d H:i:s')));
1964        } else {
1965            // Otherwise use a regular date format.
1966            $format = $format ?: $this->getParam('date_format');
1967            return $dt->format($format);
1968        }
1969    }
1970
1971    /*
1972    * Convert a given date-time string from user_timezone to php_timezone, and formatted as YYYY-MM-DD HH:MM:SS.
1973    *
1974    * @access   public
1975    * @param    string  $datetime   A date-time string or unit timestamp, e.g., `now + 60 days` or `1606165903`.
1976    * @param    string  $format     A date format string for DateTime->format(
). Set to 'Y-m-d H:i:s' by default.
1977    * @return   string              A formatted date in the server's timezone.
1978    * @author   Quinn Comendant <quinn@strangecode.com>
1979    * @since    23 Nov 2020 15:13:26
1980    */
1981    function dateToServerTZ($datetime, $format='Y-m-d H:i:s')
1982    {
1983        try {
1984            // Create a DateTime object and convert the timezone from server to user.
1985            $dt = $this->convertTZ($datetime, $this->getParam('user_timezone'), $this->getParam('php_timezone'));
1986        } catch (Exception $e) {
1987            $this->logMsg(sprintf('DateTime failed to parse string in %s: %s', __METHOD__, $datetime), LOG_NOTICE, __FILE__, __LINE__);
1988            return '';
1989        }
1990
1991        return $dt->format($format);
1992    }
1993
1994    /*
1995    *
1996    *
1997    * @access   public
1998    * @param
1999    * @return
2000    * @author   Quinn Comendant <quinn@strangecode.com>
2001    * @since    17 Feb 2019 13:11:20
2002    */
2003    public function colorCLI($color)
2004    {
2005        switch ($color) {
2006        case 'white':
2007            echo "\033[0;37m";
2008            break;
2009        case 'black':
2010            echo "\033[0;30m";
2011            break;
2012        case 'red':
2013            echo "\033[0;31m";
2014            break;
2015        case 'yellow':
2016            echo "\033[0;33m";
2017            break;
2018        case 'green':
2019            echo "\033[0;32m";
2020            break;
2021        case 'cyan':
2022            echo "\033[0;36m";
2023            break;
2024        case 'blue':
2025            echo "\033[0;34m";
2026            break;
2027        case 'purple':
2028            echo "\033[0;35m";
2029            break;
2030        case 'off':
2031        default:
2032            echo "\033[0m";
2033            break;
2034        }
2035    }
2036} // End.
Note: See TracBrowser for help on using the repository browser.