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

Last change on this file since 767 was 767, checked in by anonymous, 23 months ago

Add App param ‘template_ext’ used to inform services where to find header and footer templates. Minor fixes.

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

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