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

Last change on this file since 763 was 763, checked in by anonymous, 2 years ago

Include boomerang in hidden input on login form so the user will be redirected if the revisit the login form after session is garbage collected. Add escape values used in html attributes.

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

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