source: trunk/lib/App.inc.php @ 790

Last change on this file since 790 was 790, checked in by anonymous, 14 months ago

Fix signing_key bug

File size: 95.1 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
236    /**
237     * Constructor.
238     */
239    public function __construct($namespace='')
240    {
241        // Initialize default parameters.
242        $this->_params = array_merge($this->_params, array('namespace' => $namespace), $this->_param_defaults);
243
244        // Begin timing script.
245        require_once dirname(__FILE__) . '/ScriptTimer.inc.php';
246        $this->timer = new ScriptTimer();
247        $this->timer->start('_app');
248
249        // Are we running as a CLI?
250        $this->cli = ('cli' === php_sapi_name() || defined('_CLI'));
251    }
252
253    /**
254     * This method enforces the singleton pattern for this class. Only one application is running at a time.
255     *
256     * $param   string  $namespace  Name of this application.
257     * @return  object  Reference to the global Cache object.
258     * @access  public
259     * @static
260     */
261    public static function &getInstance($namespace='')
262    {
263        if (self::$instance === null) {
264            // FIXME: Yep, having a namespace with one singleton instance is not very useful. This is a design flaw with the Codebase.
265            // We're currently getting instances of App throughout the codebase using `$app =& App::getInstance();`
266            // with no way to determine what the namespace of the containing application is (e.g., `public` vs. `admin`).
267            // Option 1: provide the project namespace to all classes that use App, and then instantiate with `$app =& App::getInstance($this->_ns);`.
268            // In this case the namespace of the App and the namespace of the auxiliary class must match.
269            // Option 2: may be to clone a specific instance to the "default" instance, so, e.g., `$app =& App::getInstance();`
270            // refers to the same namespace as `$app =& App::getInstance('admin');`
271            // Option 3: is to check if there is only one instance, and return it if an unspecified namespace is requested.
272            // However, in the case when multiple namespaces are in play at the same time, this will fail; unspecified namespaces
273            // would cause the creation of an additional instance, since there would not be an obvious named instance to return.
274            self::$instance = new self($namespace);
275        }
276
277        if ('' != $namespace) {
278            // We may not be able to request a specific instance, but we can specify a specific namespace.
279            // We're ignoring all instance requests with a blank namespace, so we use the last given one.
280            self::$instance->_ns = $namespace;
281        }
282
283        return self::$instance;
284    }
285
286    /**
287     * Set (or overwrite existing) parameters by passing an array of new parameters.
288     *
289     * @access public
290     * @param  array    $param     Array of parameters (key => val pairs).
291     */
292    public function setParam($param=null)
293    {
294        if (isset($param) && is_array($param)) {
295            // Merge new parameters with old overriding old ones that are passed.
296            $this->_params = array_merge($this->_params, $param);
297
298            if ($this->running) {
299                // Params that require additional processing if set during runtime.
300                foreach ($param as $key => $val) {
301                    switch ($key) {
302                    case 'namespace':
303                        $this->logMsg(sprintf('Setting namespace not allowed', null), LOG_WARNING, __FILE__, __LINE__);
304                        return false;
305
306                    case 'session_name':
307                        session_name($val);
308                        return true;
309
310                    case 'session_use_cookies':
311                        if (session_status() !== PHP_SESSION_ACTIVE) {
312                            ini_set('session.use_cookies', $val);
313                        } else {
314                            $this->logMsg('Unable to set session_use_cookies; session is already active', LOG_NOTICE, __FILE__, __LINE__);
315                        }
316                        return true;
317
318                    case 'error_reporting':
319                        ini_set('error_reporting', $val);
320                        return true;
321
322                    case 'display_errors':
323                        ini_set('display_errors', $val);
324                        return true;
325
326                    case 'log_errors':
327                        ini_set('log_errors', true);
328                        return true;
329
330                    case 'log_directory':
331                        if (is_dir($val) && is_writable($val)) {
332                            ini_set('error_log', $val . '/' . $this->getParam('php_error_log'));
333                        }
334                        return true;
335                    }
336                }
337            }
338        }
339    }
340
341    /**
342     * Return the value of a parameter.
343     *
344     * @access  public
345     * @param   string  $param      The key of the parameter to return.
346     * @param   string  $default    The value to return if $param does not exist in $this->_params.
347     * @return  mixed               Parameter value, or null if not existing.
348     */
349    public function getParam($param=null, $default=null)
350    {
351        if ($param === null) {
352            return $this->_params;
353        } else if (array_key_exists($param, $this->_params)) {
354            return $this->_params[$param];
355        } else {
356            return $default;
357        }
358    }
359
360    /**
361     * Begin running this application.
362     *
363     * @access  public
364     * @author  Quinn Comendant <quinn@strangecode.com>
365     * @since   15 Jul 2005 00:32:21
366     */
367    public function start()
368    {
369        if ($this->running) {
370            return false;
371        }
372
373        // Error reporting.
374        ini_set('error_reporting', $this->getParam('error_reporting'));
375        ini_set('display_errors', $this->getParam('display_errors'));
376        ini_set('log_errors', true);
377        if (is_dir($this->getParam('log_directory')) && is_writable($this->getParam('log_directory'))) {
378            ini_set('error_log', $this->getParam('log_directory') . '/' . $this->getParam('php_error_log'));
379        }
380
381        // Set character set to use for multi-byte string functions.
382        mb_internal_encoding($this->getParam('character_set'));
383        ini_set('default_charset', $this->getParam('character_set'));
384        switch (mb_strtolower($this->getParam('character_set'))) {
385        case 'utf-8' :
386            $this->setParam(['preg_u' => 'u']);
387            mb_language('uni');
388            break;
389
390        case 'iso-2022-jp' :
391            mb_language('ja');
392            break;
393
394        case 'iso-8859-1' :
395        default :
396            mb_language('en');
397            break;
398        }
399
400        // Server timezone used internally by PHP.
401        if ($this->getParam('php_timezone')) {
402            $this->setTimezone($this->getParam('php_timezone'));
403        }
404
405        // 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.
406        if (strtolower(getenv('HTTP_X_FORWARDED_PROTO')) == 'https' && strtolower(getenv('REQUEST_SCHEME')) == 'http') {
407            $this->logMsg(sprintf('Detected HTTPS via X-Forwarded-Proto; setting HTTPS=on', null), LOG_DEBUG, __FILE__, __LINE__);
408            putenv('HTTPS=on'); // Available via getenv(
)
409            isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] = 'on'; // Available via $_SERVER[
]
410        }
411
412        /**
413         * 1. Start Database.
414         */
415
416        if (true === $this->getParam('enable_db') || true === $this->getParam('enable_db_pdo')) {
417
418            // DB connection parameters taken from environment variables in the server httpd.conf file (readable only by root)

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