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

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

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

File size: 64.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 * Utilities.inc.php
25 */
26
27
28/**
29 * Print variable dump.
30 *
31 * @param  mixed    $var        The variable to dump.
32 * @param  bool     $display    Print the dump in <pre> tags or hide it in html comments (non-CLI only).
33 * @param  bool     $dump_method   Dump method. See SC_DUMP_* constants.
34 * @param  string   $file       Value of __FILE__.
35 * @param  string   $line       Value of __LINE__
36 */
37define('SC_DUMP_PRINT_R', 0);
38define('SC_DUMP_VAR_DUMP', 1);
39define('SC_DUMP_VAR_EXPORT', 2);
40define('SC_DUMP_JSON', 3);
41function dump($var, $display=false, $dump_method=SC_DUMP_PRINT_R, $file='', $line='')
42{
43    $app =& App::getInstance();
44
45    if ($app->isCLI()) {
46        echo ('' != $file . $line) ? "DUMP FROM: $file $line\n" : "DUMP:\n";
47    } else {
48        echo $display ? "\n<br />DUMP <strong>$file $line</strong><br /><pre>\n" : "\n<!-- DUMP $file $line\n";
49    }
50
51    switch ($dump_method) {
52    case SC_DUMP_PRINT_R:
53    default:
54        // Print human-readable descriptions of invisible types.
55        if (null === $var) {
56            echo '(null)';
57        } else if (true === $var) {
58            echo '(bool: true)';
59        } else if (false === $var) {
60            echo '(bool: false)';
61        } else if (is_scalar($var) && '' === $var) {
62            echo '(empty string)';
63        } else if (is_scalar($var) && preg_match('/^\s+$/', $var)) {
64            echo '(only white space)';
65        } else {
66            print_r($var);
67        }
68        break;
69
70    case SC_DUMP_VAR_DUMP:
71        var_dump($var);
72        break;
73
74    case SC_DUMP_VAR_EXPORT:
75        var_export($var);
76        break;
77
78    case SC_DUMP_JSON:
79        echo json_encode($var, JSON_PRETTY_PRINT);
80        break;
81    }
82
83    if ($app->isCLI()) {
84        echo "\n";
85    } else {
86        echo $display ? "\n</pre><br />\n" : "\n-->\n";
87    }
88}
89
90/*
91* Log a PHP variable to javascript console. Relies on getDump(), below.
92*
93* @access   public
94* @param    mixed   $var      The variable to dump.
95* @param    string  $prefix   A short note to print before the output to make identifying output easier.
96* @param    string  $file     The value of __FILE__.
97* @param    string  $line     The value of __LINE__.
98* @return   null
99* @author   Quinn Comendant <quinn@strangecode.com>
100*/
101function jsDump($var, $prefix='jsDump', $file='-', $line='-')
102{
103    if (!empty($var)) {
104        ?>
105        <script type="text/javascript">
106        /* <![CDATA[ */
107        console.log('<?php printf('%s: %s (on line %s of %s)', $prefix, str_replace("'", "\\'", getDump($var, true)), $line, $file); ?>');
108        /* ]]> */
109        </script>
110        <?php
111    }
112}
113
114/*
115* Return a string version of any variable, optionally serialized on one line.
116*
117* @access   public
118* @param    mixed   $var        The variable to dump.
119* @param    bool    $serialize  If true, remove line-endings. Useful for logging variables.
120* @return   string              The dumped variable.
121* @author   Quinn Comendant <quinn@strangecode.com>
122*/
123function getDump($var, $serialize=false)
124{
125    $app =& App::getInstance();
126
127    ob_start();
128    print_r($var);
129    $d = ob_get_contents();
130    ob_end_clean();
131    return $serialize ? preg_replace('/\s+/m' . $app->getParam('preg_u'), ' ', $d) : $d;
132}
133
134/*
135* Return dump as cleaned text. Useful for dumping data into emails or output from CLI scripts.
136* To output tab-style lists set $indent to "\t" and $depth to 0;
137* To output markdown-style lists set $indent to '- ' and $depth to 1;
138* Also see yaml_emit() https://secure.php.net/manual/en/function.yaml-emit.php
139*
140* @param  array    $var        Variable to dump.
141* @param  string   $indent     A string to prepend indented lines.
142* @param  string   $depth      Starting depth of this iteration of recursion (set to 0 to have no initial indentation).
143* @return string               Pretty dump of $var.
144* @author   Quinn Comendant <quinn@strangecode.com>
145* @version 2.0
146*/
147function fancyDump($var, $indent='- ', $depth=1)
148{
149    $app =& App::getInstance();
150
151    $indent_str = str_repeat($indent, $depth);
152    $output = '';
153    if (is_array($var)) {
154        foreach ($var as $k=>$v) {
155            $k = ucfirst(mb_strtolower(str_replace(array('_', '  '), ' ', $k)));
156            if (is_array($v)) {
157                $output .= sprintf("\n%s%s:\n%s\n", $indent_str, $k, fancyDump($v, $indent, $depth+1));
158            } else {
159                $output .= sprintf("%s%s: %s\n", $indent_str, $k, $v);
160            }
161        }
162    } else {
163        $output .= sprintf("%s%s\n", $indent_str, $var);
164    }
165    return preg_replace(['/^[ \t]+$/' . $app->getParam('preg_u'), '/\n\n+/' . $app->getParam('preg_u'), '/^(?:\S( ))?(?:\S( ))?(?:\S( ))?(?:\S( ))?(?:\S( ))?(?:\S( ))?(?:\S( ))?(?:\S( ))?(\S )/m' . $app->getParam('preg_u')], ['', "\n", '$1$1$2$2$3$3$4$4$5$5$6$6$7$7$8$8$9'], $output);
166}
167
168/**
169 * @param string|mixed $value A string to UTF8-encode.
170 *
171 * @returns string|mixed The UTF8-encoded string, or the object passed in if
172 *    it wasn't a string.
173 */
174function conditionalUTF8Encode($value)
175{
176  if (is_string($value) && mb_detect_encoding($value, 'UTF-8', true) != 'UTF-8') {
177    return utf8_encode($value);
178  } else {
179    return $value;
180  }
181}
182
183
184/**
185 * Returns text with appropriate html translations (a smart wrapper for htmlspecialchars()).
186 *
187 * @param  string $text             Text to clean.
188 * @param  bool   $preserve_html    If set to true, oTxt will not translate <, >, ", or '
189 *                                  characters into HTML entities. This allows HTML to pass through undisturbed.
190 * @return string                   HTML-safe text.
191 */
192function oTxt($text, $preserve_html=false)
193{
194    $app =& App::getInstance();
195
196    $search = array();
197    $replace = array();
198
199    // Make converted ampersand entities into normal ampersands (they will be done manually later) to retain HTML entities.
200    $search['retain_ampersand']     = '/&amp;/';
201    $replace['retain_ampersand']    = '&';
202
203    if ($preserve_html) {
204        // Convert characters that must remain non-entities for displaying HTML.
205        $search['retain_left_angle']       = '/&lt;/';
206        $replace['retain_left_angle']      = '<';
207
208        $search['retain_right_angle']      = '/&gt;/';
209        $replace['retain_right_angle']     = '>';
210
211        $search['retain_single_quote']     = '/&#039;/';
212        $replace['retain_single_quote']    = "'";
213
214        $search['retain_double_quote']     = '/&quot;/';
215        $replace['retain_double_quote']    = '"';
216    }
217
218    // & becomes &amp;. Exclude any occurrence where the & is followed by a alphanum or unicode character.
219    $search['ampersand']        = '/&(?![\w\d#]{1,10};)/';
220    $replace['ampersand']       = '&amp;';
221
222    return preg_replace($search, $replace, htmlspecialchars($text, ENT_QUOTES, $app->getParam('character_set')));
223}
224
225/**
226 * Returns text with stylistic modifications. Warning: this will break some HTML attributes!
227 * TODO: Allow a string such as this to be passed: <a href="javascript:openPopup('/foo/bar.php')">Click here</a>
228 *
229 * @param  string   $text Text to clean.
230 * @return string         Cleaned text.
231 */
232function fancyTxt($text, $extra_search=null, $extra_replace=null)
233{
234    $search = array();
235    $replace = array();
236
237    // "double quoted text"  →  “double quoted text”
238    $search['_double_quotes']    = '/(?<=^|[^\w=(])(?:"|&quot;|&#0?34;|&#x22;|&ldquo;)([\w\'.
(—–-][^"]*?)(?:"|&quot;|&#0?34;|&#x22;|&rdquo;)(?=[^)\w]|$)/imsu'; // " is the same as &quot; and &#34; and &#034; and &#x22;
239    $replace['_double_quotes']   = '“$1”';
240
241    // text's apostrophes  →  text’s apostrophes (except foot marks: 6'3")
242    $search['_apostrophe']       = '/(?<=[a-z])(?:\'|&#0?39;)(?=\w)/imsu';
243    $replace['_apostrophe']      = '’';
244
245    // 'single quoted text'  →  ‘single quoted text’
246    $search['_single_quotes']    = '/(?<=^|[^\w=(])(?:\'|&#0?39;|&lsquo;)([\w"][^\']+?)(?:\'|&#0?39;|&rsquo;)(?=[^)\w]|$)/imsu';
247    $replace['_single_quotes']   = '‘$1’';
248
249    // plural posessives' apostrophes  →  posessives’  (except foot marks: 6')
250    $search['_apostrophes']      = '/(?<=s)(?:\'|&#0?39;|&rsquo;)(?=\s)/imsu';
251    $replace['_apostrophes']     = '’';
252
253    // double--hyphens  →  en — dashes
254    $search['_em_dash']          = '/(?<=[\w\s"\'”’)])--(?=[\w\s“”‘"\'(?])/imsu';
255    $replace['_em_dash']         = ' – ';
256
257    // ...  →  

258    $search['_elipsis']          = '/(?<=^|[^.])\.\.\.(?=[^.]|$)/imsu';
259    $replace['_elipsis']         = '
';
260
261    if (is_array($extra_search) && is_array($extra_replace) && sizeof($extra_search) == sizeof($extra_replace)) {
262        // Append additional search replacements.
263        $search = array_merge($search, $extra_search);
264        $replace = array_merge($replace, $extra_replace);
265    }
266
267    return trim(preg_replace($search, $replace, $text));
268}
269
270/*
271* Finds all URLs in text and hyperlinks them.
272*
273* @access   public
274* @param    string  $text   Text to search for URLs.
275* @param    bool    $strict True to only include URLs starting with a scheme (http:// ftp:// im://), or false to include URLs starting with 'www.'.
276* @param    mixed   $length Number of characters to truncate URL, or NULL to disable truncating.
277* @param    string  $delim  Delimiter to append, indicate truncation.
278* @return   string          Same input text, but URLs hyperlinked.
279* @author   Quinn Comendant <quinn@strangecode.com>
280* @version  2.2
281* @since    22 Mar 2015 23:29:04
282*/
283function hyperlinkTxt($text, $strict=false, $length=null, $delim='
')
284{
285    // A list of schemes we allow at the beginning of a URL.
286    $schemes = 'mailto:|tel:|skype:|callto:|facetime:|bitcoin:|geo:|magnet:\?|sip:|sms:|xmpp:|view-source:(?:https?://)?|[\w-]{2,}://';
287
288    // Capture the full URL into the first match and only the first X characters into the second match.
289    // This will match URLs not preceded by " ' or = (URLs inside an attribute) or ` (Markdown quoted) or double-scheme (http://http://www.asdf.com)
290    // https://stackoverflow.com/questions/1547899/which-characters-make-a-url-invalid/1547940#1547940
291    $regex = '@
292        \b                                 # Start with a word-boundary.
293        (?<!"|\'|=|>|`|\]\(|\[\d\] |[:/]/) # Negative look-behind to exclude URLs already in <a> tag, <tags>beween</tags>, `Markdown quoted`, [Markdown](link), [1] www.markdown.footnotes, and avoid broken:/ and doubled://schemes://
294        (                                  # Begin match 1
295            (                              # Begin match 2
296                (?:%s)                     # URL starts with known scheme or www. if strict = false
297                [^\s/$.?#]+                # Any domain-valid characters
298                [^\s"`<>]{1,%s}            # Match 2 is limited to a maximum of LENGTH valid URL characters
299            )
300            [^\s"`<>]*                     # Match 1 continues with any further valid URL characters
301            ([^\P{Any}\s
<>«»"—–%s])       # Final character not a space or common end-of-sentence punctuation (.,:;?!, etc). Using double negation set, see http://stackoverflow.com/a/4786560/277303
302        )
303        @Suxi
304    ';
305    $regex = sprintf($regex,
306        ($strict ? $schemes : $schemes . '|www\.'), // Strict=false adds "www." to the list of allowed start-of-URL.
307        ($length ? $length : ''),
308        ($strict ? '' : '?!.,:;)\'-') // Strict=false excludes some "URL-valid" characters from the last character of URL. (Hyphen must remain last character in this class.)
309    );
310
311    // Use a callback function to decide when to append the delim.
312    // Also encode special chars with oTxt().
313    return preg_replace_callback($regex, function ($m) use ($length, $delim) {
314        $url = $m[1];
315        $truncated_url = $m[2] . $m[3];
316        $absolute_url = preg_replace('!^www\.!', 'http://www.', $url);
317        if (is_null($length) || $url == $truncated_url) {
318            // If not truncating, or URL was not truncated.
319            // Remove http schemas, and any single trailing / to make the display URL.
320            $display_url = preg_replace(['!^https?://!u', '!^([^/]+)/$!u'], ['', '$1'], $url);
321            return sprintf('<a href="%s">%s</a>', oTxt($absolute_url), oTxt($display_url));
322        } else {
323            // Truncated URL.
324            // Remove http schemas, and any single trailing / to make the display URL.
325            $display_url = preg_replace(['!^https?://!u', '!^([^/]+)/$!u'], ['', '$1'], trim($truncated_url));
326            return sprintf('<a href="%s">%s%s</a>', oTxt($absolute_url), oTxt($display_url), $delim);
327        }
328    }, $text);
329}
330
331/**
332 * Applies a class to search terms to highlight them ala google results.
333 *
334 * @param  string   $text   Input text to search.
335 * @param  string   $search String of word(s) that will be highlighted.
336 * @param  string   $class  CSS class to apply.
337 * @return string           Text with searched words wrapped in <span>.
338 */
339function highlightWords($text, $search, $class='sc-highlightwords')
340{
341    $app =& App::getInstance();
342
343    $words = preg_split('/[^\w]/', $search, -1, PREG_SPLIT_NO_EMPTY);
344
345    $search = array();
346    $replace = array();
347
348    foreach ($words as $w) {
349        if ('' != trim($w)) {
350            $search[] = '/\b(' . preg_quote($w) . ')\b/i' . $app->getParam('preg_u');
351            $replace[] = '<span class="' . oTxt($class) . '">$1</span>';
352        }
353    }
354
355    return empty($replace) ? $text : preg_replace($search, $replace, $text);
356}
357
358/**
359 * Generates a hexadecimal html color based on provided word.
360 *
361 * @access public
362 * @param  string $text  A string for which to convert to color.
363 * @param  float  $n     Brightness value between 0-1.
364 * @return string        A hexadecimal html color.
365 */
366function getTextColor($text, $method=1, $n=0.87)
367{
368    $hash = md5($text);
369    $rgb = array(
370        mb_substr($hash, 0, 1),
371        mb_substr($hash, 1, 1),
372        mb_substr($hash, 2, 1),
373        mb_substr($hash, 3, 1),
374        mb_substr($hash, 4, 1),
375        mb_substr($hash, 5, 1),
376    );
377
378    switch ($method) {
379    case 1 :
380    default :
381        // Reduce all hex values slightly to avoid all white.
382        array_walk($rgb, function (&$v) use ($n) {
383            $v = dechex(round(hexdec($v) * $n));
384        });
385        break;
386
387    case 2 :
388        foreach ($rgb as $i => $v) {
389            if (hexdec($v) > hexdec('c')) {
390                $rgb[$i] = dechex(hexdec('f') - hexdec($v));
391            }
392        }
393        break;
394    }
395
396    return join('', $rgb);
397}
398
399/**
400 * Encodes a string into unicode values 128-255.
401 * Useful for hiding an email address from spambots.
402 *
403 * @access  public
404 * @param   string   $text   A line of text to encode.
405 * @return  string   Encoded text.
406 */
407function encodeAscii($text)
408{
409    $output = '';
410    $num = mb_strlen($text);
411    for ($i=0; $i<$num; $i++) {
412        $output .= sprintf('&#%03s', ord($text[$i]));
413    }
414    return $output;
415}
416
417/**
418 * Encodes an email into a "user at domain dot com" format.
419 *
420 * @access  public
421 * @param   string   $email   An email to encode.
422 * @param   string   $at      Replaces the @.
423 * @param   string   $dot     Replaces the ..
424 * @return  string   Encoded email.
425 */
426function encodeEmail($email, $at=' at ', $dot=' dot ')
427{
428    $app =& App::getInstance();
429
430    $search = array('/@/' . $app->getParam('preg_u'), '/\./' . $app->getParam('preg_u'));
431    $replace = array($at, $dot);
432    return preg_replace($search, $replace, $email);
433}
434
435/**
436 * Truncates "a really long string" into a string of specified length
437 * at the beginning: "
long string"
438 * at the middle: "a rea
string"
439 * or at the end: "a really
".
440 *
441 * The regular expressions below first match and replace the string to the specified length and position,
442 * and secondly they remove any whitespace from around the delimiter (to avoid "this 
 " from happening).
443 *
444 * @access  public
445 * @param   string  $str    Input string
446 * @param   int     $len    Maximum string length.
447 * @param   string  $where  Where to cut the string. One of: 'start', 'middle', or 'end'.
448 * @return  string          Truncated output string.
449 * @author  Quinn Comendant <quinn@strangecode.com>
450 * @since   29 Mar 2006 13:48:49
451 */
452function truncate($str, $len=50, $where='end', $delim='
')
453{
454    $app =& App::getInstance();
455
456    $dlen = mb_strlen($delim);
457    if ($len <= $dlen || mb_strlen($str) <= $dlen) {
458        return substr($str, 0, $len);
459    }
460    $part1 = floor(($len - $dlen) / 2);
461    $part2 = ceil(($len - $dlen) / 2);
462
463    if ($len > ini_get('pcre.backtrack_limit')) {
464        $app =& App::getInstance();
465        $app->logMsg(sprintf('Asked to truncate string len of %s > pcre.backtrack_limit of %s', $len, ini_get('pcre.backtrack_limit')), LOG_DEBUG, __FILE__, __LINE__);
466        ini_set('pcre.backtrack_limit', $len);
467    }
468
469    switch ($where) {
470    case 'start' :
471        return preg_replace(array(sprintf('/^.{%s,}(.{%s})$/s' . $app->getParam('preg_u'), $dlen + 1, $part1 + $part2), sprintf('/\s*%s{%s,}\s*/s' . $app->getParam('preg_u'), preg_quote($delim), $dlen)), array($delim . '$1', $delim), $str);
472
473    case 'middle' :
474        return preg_replace(array(sprintf('/^(.{%s}).{%s,}(.{%s})$/s' . $app->getParam('preg_u'), $part1, $dlen + 1, $part2), sprintf('/\s*%s{%s,}\s*/s' . $app->getParam('preg_u'), preg_quote($delim), $dlen)), array('$1' . $delim . '$2', $delim), $str);
475
476    case 'end' :
477    default :
478        return preg_replace(array(sprintf('/^(.{%s}).{%s,}$/s' . $app->getParam('preg_u'), $part1 + $part2, $dlen + 1), sprintf('/\s*%s{%s,}\s*/s' . $app->getParam('preg_u'), preg_quote($delim), $dlen)), array('$1' . $delim, $delim), $str);
479    }
480}
481
482/*
483* A substitution for the missing mb_ucfirst function.
484*
485* @access   public
486* @param    string  $string The string
487* @return   string          String with upper-cased first character.
488* @author   Quinn Comendant <quinn@strangecode.com>
489* @version  1.0
490* @since    06 Dec 2008 17:04:01
491*/
492if (!function_exists('mb_ucfirst')) {
493    function mb_ucfirst($string)
494    {
495        return mb_strtoupper(mb_substr($string, 0, 1)) . mb_substr($string, 1, mb_strlen($string));
496    }
497}
498
499/*
500* A substitution for the missing mb_strtr function.
501*
502* @access   public
503* @param    string  $string The string
504* @param    string  $from   String of characters to translate from
505* @param    string  $to     String of characters to translate to
506* @return   string          String with translated characters.
507* @author   Quinn Comendant <quinn@strangecode.com>
508* @version  1.0
509* @since    20 Jan 2013 12:33:26
510*/
511if (!function_exists('mb_strtr')) {
512    function mb_strtr($string, $from, $to)
513    {
514        return str_replace(mb_split('.', $from), mb_split('.', $to), $string);
515    }
516}
517
518/*
519* A substitution for the missing mb_str_pad function.
520*
521* @access   public
522* @param    string  $input      The string that receives padding.
523* @param    string  $pad_length Total length of resultant string.
524* @param    string  $pad_string The string to use for padding
525* @param    string  $pad_type   Flags STR_PAD_RIGHT or STR_PAD_LEFT or STR_PAD_BOTH
526* @return   string          String with translated characters.
527* @author   Quinn Comendant <quinn@strangecode.com>
528* @version  1.0
529* @since    20 Jan 2013 12:33:26
530*/
531if (!function_exists('mb_str_pad')) {
532    function mb_str_pad($input, $pad_length, $pad_string=' ', $pad_type=STR_PAD_RIGHT) {
533        $diff = strlen($input) - mb_strlen($input);
534        return str_pad($input, $pad_length + $diff, $pad_string, $pad_type);
535    }
536}
537
538/**
539 * Return a human readable disk space measurement. Input value measured in bytes.
540 *
541 * @param       int    $size        Size in bytes.
542 * @param       int    $unit        The maximum unit
543 * @param       int    $format      The return string format
544 * @author      Aidan Lister <aidan@php.net>
545 * @author      Quinn Comendant <quinn@strangecode.com>
546 * @version     1.2.0
547 */
548function humanFileSize($size, $format='%01.2f %s', $max_unit=null, $multiplier=1024)
549{
550    // Units
551    $units = array('B', 'KB', 'MB', 'GB', 'TB');
552    $ii = count($units) - 1;
553
554    // Max unit
555    $max_unit = array_search((string) $max_unit, $units);
556    if ($max_unit === null || $max_unit === false) {
557        $max_unit = $ii;
558    }
559
560    // Loop
561    $i = 0;
562    while ($max_unit != $i && $size >= $multiplier && $i < $ii) {
563        $size /= $multiplier;
564        $i++;
565    }
566
567    return sprintf($format, $size, $units[$i]);
568}
569
570/*
571* Returns a human readable amount of time for the given amount of seconds.
572*
573* 45 seconds
574* 12 minutes
575* 3.5 hours
576* 2 days
577* 1 week
578* 4 months
579*
580* Months are calculated using the real number of days in a year: 365.2422 / 12.
581*
582* @access   public
583* @param    int $seconds Seconds of time.
584* @param    string $max_unit Key value from the $units array.
585* @param    string $format Sprintf formatting string.
586* @return   string Value of units elapsed.
587* @author   Quinn Comendant <quinn@strangecode.com>
588* @version  1.0
589* @since    23 Jun 2006 12:15:19
590*/
591function humanTime($seconds, $max_unit=null, $format='%01.1f')
592{
593    // Units: array of seconds in the unit, singular and plural unit names.
594    $units = array(
595        'second' => array(1, _("second"), _("seconds")),
596        'minute' => array(60, _("minute"), _("minutes")),
597        'hour' => array(3600, _("hour"), _("hours")),
598        'day' => array(86400, _("day"), _("days")),
599        'week' => array(604800, _("week"), _("weeks")),
600        'month' => array(2629743.84, _("month"), _("months")),
601        'year' => array(31556926.08, _("year"), _("years")),
602        'decade' => array(315569260.8, _("decade"), _("decades")),
603        'century' => array(3155692608, _("century"), _("centuries")),
604    );
605
606    // Max unit to calculate.
607    $max_unit = isset($units[$max_unit]) ? $max_unit : 'year';
608
609    $final_time = $seconds;
610    $final_unit = 'second';
611    foreach ($units as $k => $v) {
612        if ($seconds >= $v[0]) {
613            $final_time = $seconds / $v[0];
614            $final_unit = $k;
615        }
616        if ($max_unit == $final_unit) {
617            break;
618        }
619    }
620    $final_time = sprintf($format, $final_time);
621    return sprintf('%s %s', $final_time, (1 == $final_time ? $units[$final_unit][1] : $units[$final_unit][2]));
622}
623
624/*
625* Calculate a prorated amount for the duration between two dates.
626*
627* @access   public
628* @param    float   $amount     Original price per duration.
629* @param    string  $duration   Unit of time for the original price (`year`, `quarter`, `month`, or `day`).
630* @param    string  $start_date Start date of prorated period (strtotime-compatible date).
631* @param    string  $end_date   End date of prorated period (strtotime-compatible date).
632* @return   float               The prorated amount.
633* @author   Quinn Comendant <quinn@strangecode.com>
634* @since    03 Nov 2021 22:44:30
635*/
636function prorate($amount, $duration, $start_date, $end_date)
637{
638    $app =& App::getInstance();
639
640    switch ($duration) {
641    case 'yr':
642    case 'year':
643        $amount_per_day = $amount / 365;
644        break;
645
646    case 'quarter':
647        $amount_per_day = $amount / 91.25;
648        break;
649
650    case 'mo':
651    case 'month':
652        $amount_per_day = $amount / 30.4167;
653        break;
654
655    case 'week':
656        $amount_per_day = $amount / 7;
657        break;
658
659    case 'day':
660        $amount_per_day = $amount;
661        break;
662
663    default:
664        $app->logMsg(sprintf('Unknown prorate duration “%s”. Please use one of: year, yr, quarter, month, mo, week, day.', $duration), LOG_ERR, __FILE__, __LINE__);
665        return false;
666    }
667
668    $diff_time = strtotime($end_date) - strtotime($start_date);
669    $days = $diff_time / (60 * 60 * 24);
670    return $amount_per_day * $days;
671}
672
673/*
674* Converts strange characters into ASCII using a htmlentities hack. If a character does not have a specific rule, it will remain as its entity name, e.g., `5¢` becomes `5&cent;` which becomes `5cent`.
675*
676* @access   public
677* @param    string  $str    Input string of text containing accents.
678* @return   string          String with accented characters converted to ASCII equivalents.
679* @author   Quinn Comendant <quinn@strangecode.com>
680* @since    30 Apr 2020 21:29:16
681*/
682function simplifyAccents($str)
683{
684    $app =& App::getInstance();
685
686    return preg_replace([
687        '/&amp;(?=[\w\d#]{1,10};)/i' . $app->getParam('preg_u'),
688        '/&([a-z]{1,2})(?:acute|cedil|circ|grave|lig|orn|ring|slash|th|tilde|uml|caron);/i' . $app->getParam('preg_u'),
689        '/&(?:ndash|mdash|horbar);/i' . $app->getParam('preg_u'),
690        '/&(?:nbsp);/i' . $app->getParam('preg_u'),
691        '/&(?:bdquo|ldquo|ldquor|lsquo|lsquor|rdquo|rdquor|rsquo|rsquor|sbquo|lsaquo|rsaquo);/i' . $app->getParam('preg_u'),
692        '/&(?:amp);/i' . $app->getParam('preg_u'), // This replacement must come after matching all other entities.
693        '/[&;]+/' . $app->getParam('preg_u'),
694    ], [
695        '&',
696        '$1',
697        '-',
698        ' ',
699        '',
700        'and',
701        '',
702    ], htmlentities($str, ENT_NOQUOTES | ENT_IGNORE, $app->getParam('character_set')));
703}
704
705/*
706* Converts a string into a URL-safe slug, removing spaces and non word characters.
707*
708* @access   public
709* @param    string  $str    String to convert.
710* @return   string          URL-safe slug.
711* @author   Quinn Comendant <quinn@strangecode.com>
712* @version  1.0
713* @since    18 Aug 2014 12:54:29
714*/
715function URLSlug($str)
716{
717    $app =& App::getInstance();
718
719    return strtolower(urlencode(preg_replace(['/[-\s–—.:;?!@#=+_\/\\\]+|(?:&nbsp;|&#160;|&ndash;|&#8211;|&mdash;|&#8212;|%c2%a0|%e2%80%93|%e2%80%9)+/' . $app->getParam('preg_u'), '/-+/' . $app->getParam('preg_u'), '/[^\w-]+/' . $app->getParam('preg_u'), '/^-+|-+$/' . $app->getParam('preg_u')], ['-', '-', '', ''], simplifyAccents($str))));
720}
721
722/**
723 * Converts a string of text into a safe file name by removing non-ASCII characters and non-word characters.
724 *
725 * @access  public
726 * @param   string  $file_name  A name of a file.
727 * @return  string              The same name, but cleaned.
728 */
729function cleanFileName($file_name)
730{
731    $app =& App::getInstance();
732
733    $file_name = preg_replace(['/[^a-zA-Z0-9()@._=+-]+/' . $app->getParam('preg_u'), '/^_+|_+$/' . $app->getParam('preg_u')], ['_', ''], simplifyAccents($file_name));
734    return mb_substr($file_name, 0, 250);
735}
736
737/**
738 * Returns the extension of a file name, or an empty string if none exists.
739 *
740 * @access  public
741 * @param   string  $file_name  A name of a file, with extension after a dot.
742 * @return  string              The value found after the dot
743 */
744function getFilenameExtension($file_name)
745{
746    preg_match('/.*?\.(\w+)$/i', trim($file_name), $ext);
747    return isset($ext[1]) ? $ext[1] : '';
748}
749
750/*
751* Convert a php.ini value (8M, 512K, etc), into integer value of bytes.
752*
753* @access   public
754* @param    string  $val    Value from php config, e.g., upload_max_filesize.
755* @return   int             Value converted to bytes as an integer.
756* @author   Quinn Comendant <quinn@strangecode.com>
757* @version  1.0
758* @since    20 Aug 2014 14:32:41
759*/
760function phpIniGetBytes($val)
761{
762    $val = trim(ini_get($val));
763    if ($val != '') {
764        $unit = strtolower($val[mb_strlen($val) - 1]);
765        $val = preg_replace('/\D/', '', $val);
766
767        switch ($unit) {
768            // No `break`, so these multiplications are cumulative.
769            case 'g':
770                $val *= 1024;
771            case 'm':
772                $val *= 1024;
773            case 'k':
774                $val *= 1024;
775        }
776    }
777
778    return (int)$val;
779}
780
781/**
782 * Tests the existence of a file anywhere in the include path.
783 * Replaced by stream_resolve_include_path() in PHP 5 >= 5.3.2
784 *
785 * @param   string  $file   File in include path.
786 * @return  mixed           False if file not found, the path of the file if it is found.
787 * @author  Quinn Comendant <quinn@strangecode.com>
788 * @since   03 Dec 2005 14:23:26
789 */
790function fileExistsIncludePath($file)
791{
792    $app =& App::getInstance();
793
794    foreach (explode(PATH_SEPARATOR, get_include_path()) as $path) {
795        $fullpath = $path . DIRECTORY_SEPARATOR . $file;
796        if (file_exists($fullpath)) {
797            $app->logMsg(sprintf('Found file "%s" at path: %s', $file, $fullpath), LOG_DEBUG, __FILE__, __LINE__);
798            return $fullpath;
799        } else {
800            $app->logMsg(sprintf('File "%s" not found in include_path: %s', $file, get_include_path()), LOG_DEBUG, __FILE__, __LINE__);
801            return false;
802        }
803    }
804}
805
806/**
807 * Returns stats of a file from the include path.
808 *
809 * @param   string  $file   File in include path.
810 * @param   mixed   $stat   Which statistic to return (or null to return all).
811 * @return  mixed           Value of requested key from fstat(), or false on error.
812 * @author  Quinn Comendant <quinn@strangecode.com>
813 * @since   03 Dec 2005 14:23:26
814 */
815function statIncludePath($file, $stat=null)
816{
817    // Open file pointer read-only using include path.
818    if ($fp = fopen($file, 'r', true)) {
819        // File opened successfully, get stats.
820        $stats = fstat($fp);
821        fclose($fp);
822        // Return specified stats.
823        return is_null($stat) ? $stats : $stats[$stat];
824    } else {
825        return false;
826    }
827}
828
829/*
830* Writes content to the specified file. This function emulates the functionality of file_put_contents from PHP 5.
831* It makes an exclusive lock on the file while writing.
832*
833* @access   public
834* @param    string  $filename   Path to file.
835* @param    string  $content    Data to write into file.
836* @return   bool                Success or failure.
837* @author   Quinn Comendant <quinn@strangecode.com>
838* @since    11 Apr 2006 22:48:30
839*/
840function filePutContents($filename, $content)
841{
842    $app =& App::getInstance();
843
844    // Open file for writing and truncate to zero length.
845    if ($fp = fopen($filename, 'w')) {
846        if (flock($fp, LOCK_EX)) {
847            if (!fwrite($fp, $content, mb_strlen($content))) {
848                $app->logMsg(sprintf('Failed writing to file: %s', $filename), LOG_ERR, __FILE__, __LINE__);
849                fclose($fp);
850                return false;
851            }
852            flock($fp, LOCK_UN);
853        } else {
854            $app->logMsg(sprintf('Could not lock file for writing: %s', $filename), LOG_ERR, __FILE__, __LINE__);
855            fclose($fp);
856            return false;
857        }
858        fclose($fp);
859        // Success!
860        $app->logMsg(sprintf('Wrote to file: %s', $filename), LOG_DEBUG, __FILE__, __LINE__);
861        return true;
862    } else {
863        $app->logMsg(sprintf('Could not open file for writing: %s', $filename), LOG_ERR, __FILE__, __LINE__);
864        return false;
865    }
866}
867
868/**
869 * If $var is net set or null, set it to $default. Otherwise leave it alone.
870 * Returns the final value of $var. Use to find a default value of one is not available.
871 *
872 * @param  mixed $var       The variable that is being set.
873 * @param  mixed $default   What to set it to if $val is not currently set.
874 * @return mixed            The resulting value of $var.
875 */
876function setDefault(&$var, $default='')
877{
878    if (!isset($var)) {
879        $var = $default;
880    }
881    return $var;
882}
883
884/**
885 * Like preg_quote() except for arrays, it takes an array of strings and puts
886 * a backslash in front of every character that is part of the regular
887 * expression syntax.
888 *
889 * @param  array $array    input array
890 * @param  array $delim    optional character that will also be escaped.
891 * @return array    an array with the same values as $array1 but shuffled
892 */
893function pregQuoteArray($array, $delim='/')
894{
895    if (!empty($array)) {
896        if (is_array($array)) {
897            foreach ($array as $key=>$val) {
898                $quoted_array[$key] = preg_quote($val, $delim);
899            }
900            return $quoted_array;
901        } else {
902            return preg_quote($array, $delim);
903        }
904    }
905}
906
907/**
908 * Converts a PHP Array into encoded URL arguments and return them as an array.
909 *
910 * @param  mixed $data        An array to transverse recursively, or a string
911 *                            to use directly to create url arguments.
912 * @param  string $prefix     The name of the first dimension of the array.
913 *                            If not specified, the first keys of the array will be used.
914 * @return array              URL with array elements as URL key=value arguments.
915 */
916function urlEncodeArray($data, $prefix='', $_return=true)
917{
918    // Data is stored in static variable.
919    static $args = array();
920
921    if (is_array($data)) {
922        foreach ($data as $key => $val) {
923            // If the prefix is empty, use the $key as the name of the first dimension of the "array".
924            // ...otherwise, append the key as a new dimension of the "array".
925            $new_prefix = ('' == $prefix) ? urlencode($key) : $prefix . '[' . urlencode($key) . ']';
926            // Enter recursion.
927            urlEncodeArray($val, $new_prefix, false);
928        }
929    } else {
930        // We've come to the last dimension of the array, save the "array" and its value.
931        $args[$prefix] = urlencode($data);
932    }
933
934    if ($_return) {
935        // This is not a recursive execution. All recursion is complete.
936        // Reset static var and return the result.
937        $ret = $args;
938        $args = array();
939        return is_array($ret) ? $ret : array();
940    }
941}
942
943/**
944 * Converts a PHP Array into encoded URL arguments and return them in a string.
945 *
946 * Todo: probably update to use the built-in http_build_query().
947 *
948 * @param  mixed $data        An array to transverse recursively, or a string
949 *                            to use directly to create url arguments.
950 * @param  string $prefix     The name of the first dimension of the array.
951 *                            If not specified, the first keys of the array will be used.
952 * @return string url         A string ready to append to a url.
953 */
954function urlEncodeArrayToString($data, $prefix='')
955{
956    $array_args = urlEncodeArray($data, $prefix);
957    $url_args = '';
958    $delim = '';
959    foreach ($array_args as $key=>$val) {
960        $url_args .= $delim . $key . '=' . $val;
961        $delim = ini_get('arg_separator.output');
962    }
963    return $url_args;
964}
965
966/**
967 * Fills an array with the result from a multiple ereg search.
968 * Courtesy of Bruno - rbronosky@mac.com - 10-May-2001
969 *
970 * @param  mixed $pattern   regular expression needle
971 * @param  mixed $string   haystack
972 * @return array    populated with each found result
973 */
974function eregAll($pattern, $string)
975{
976    do {
977        if (!mb_ereg($pattern, $string, $temp)) {
978             continue;
979        }
980        $string = str_replace($temp[0], '', $string);
981        $results[] = $temp;
982    } while (mb_ereg($pattern, $string, $temp));
983    return $results;
984}
985
986/**
987 * Prints the word "checked" if a variable is set, and optionally matches
988 * the desired value, otherwise prints nothing,
989 * used for printing the word "checked" in a checkbox form input.
990 *
991 * @param  mixed $var     the variable to compare
992 * @param  mixed $value   optional, what to compare with if a specific value is required.
993 */
994function frmChecked($var, $value=null)
995{
996    if (func_num_args() == 1 && $var) {
997        // 'Checked' if var is true.
998        echo ' checked="checked" ';
999    } else if (func_num_args() == 2 && $var == $value) {
1000        // 'Checked' if var and value match.
1001        echo ' checked="checked" ';
1002    } else if (func_num_args() == 2 && is_array($var)) {
1003        // 'Checked' if the value is in the key or the value of an array.
1004        if (isset($var[$value])) {
1005            echo ' checked="checked" ';
1006        } else if (in_array($value, $var)) {
1007            echo ' checked="checked" ';
1008        }
1009    }
1010}
1011
1012/**
1013 * prints the word "selected" if a variable is set, and optionally matches
1014 * the desired value, otherwise prints nothing,
1015 * otherwise prints nothing, used for printing the word "checked" in a
1016 * select form input
1017 *
1018 * @param  mixed $var     the variable to compare
1019 * @param  mixed $value   optional, what to compare with if a specific value is required.
1020 */
1021function frmSelected($var, $value=null)
1022{
1023    if (func_num_args() == 1 && $var) {
1024        // 'selected' if var is true.
1025        echo ' selected="selected" ';
1026    } else if (func_num_args() == 2 && $var == $value) {
1027        // 'selected' if var and value match.
1028        echo ' selected="selected" ';
1029    } else if (func_num_args() == 2 && is_array($var)) {
1030        // 'selected' if the value is in the key or the value of an array.
1031        if (isset($var[$value])) {
1032            echo ' selected="selected" ';
1033        } else if (in_array($value, $var)) {
1034            echo ' selected="selected" ';
1035        }
1036    }
1037}
1038
1039/**
1040 * Adds slashes to values of an array and converts the array to a comma
1041 * delimited list. If value provided is a string return the string
1042 * escaped.  This is useful for putting values coming in from posted
1043 * checkboxes into a SET column of a database.
1044 *
1045 *
1046 * @param  array $in      Array to convert.
1047 * @return string         Comma list of array values.
1048 */
1049function escapedList($in, $separator="', '")
1050{
1051    require_once dirname(__FILE__) . '/DB.inc.php';
1052    $db =& DB::getInstance();
1053
1054    if (is_array($in) && !empty($in)) {
1055        return join($separator, array_map(array($db, 'escapeString'), $in));
1056    } else {
1057        return $db->escapeString($in);
1058    }
1059}
1060
1061/**
1062 * Converts a human string date into a SQL-safe date.  Dates nearing
1063 * infinity use the date 2038-01-01 so conversion to unix time format
1064 * remain within valid range.
1065 *
1066 * @param  array $date     String date to convert.
1067 * @param  array $format   Date format to pass to date(). Default produces MySQL datetime: YYYY-MM-DD hh:mm:ss
1068 * @return string          SQL-safe date.
1069 */
1070function strToSQLDate($date, $format='Y-m-d H:i:s')
1071{
1072    require_once dirname(__FILE__) . '/DB.inc.php';
1073    $db =& DB::getInstance();
1074
1075    if ($db->isConnected() && mb_strpos($db->getParam('zero_date'), '-') !== false) {
1076        // Mysql version >= 5.7.4 stopped allowing a "zero" date of 0000-00-00.
1077        // https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_no_zero_date
1078        $zero_date_parts = explode('-', $db->getParam('zero_date'));
1079        $zero_y = $zero_date_parts[0];
1080        $zero_m = $zero_date_parts[1];
1081        $zero_d = $zero_date_parts[2];
1082    } else {
1083        $zero_y = '0000';
1084        $zero_m = '00';
1085        $zero_d = '00';
1086    }
1087    // Translate the human string date into SQL-safe date format.
1088    if (empty($date) || mb_strpos($date, sprintf('%s-%s-%s', $zero_y, $zero_m, $zero_d)) !== false || strtotime($date) === -1 || strtotime($date) === false || strtotime($date) === null) {
1089        // Return a string of zero time, formatted the same as $format.
1090        return strtr($format, array(
1091            'Y' => $zero_y,
1092            'm' => $zero_m,
1093            'd' => $zero_d,
1094            'H' => '00',
1095            'i' => '00',
1096            's' => '00',
1097        ));
1098    } else {
1099        return date($format, strtotime($date));
1100    }
1101}
1102
1103/**
1104 * If magic_quotes_gpc is in use, run stripslashes() on $var. If $var is an
1105 * array, stripslashes is run on each value, recursively, and the stripped
1106 * array is returned.
1107 *
1108 * @param  mixed $var   The string or array to un-quote, if necessary.
1109 * @return mixed        $var, minus any magic quotes.
1110 */
1111function dispelMagicQuotes($var, $always=false)
1112{
1113    static $magic_quotes_gpc;
1114
1115    if (!isset($magic_quotes_gpc)) {
1116        $magic_quotes_gpc = version_compare(PHP_VERSION, '5.4.0', '<') ? get_magic_quotes_gpc() : false;
1117    }
1118
1119    if ($always || $magic_quotes_gpc) {
1120        if (!is_array($var)) {
1121            $var = stripslashes($var);
1122        } else {
1123            foreach ($var as $key=>$val) {
1124                if (is_array($val)) {
1125                    $var[$key] = dispelMagicQuotes($val, $always);
1126                } else {
1127                    $var[$key] = stripslashes($val);
1128                }
1129            }
1130        }
1131    }
1132    return $var;
1133}
1134
1135/**
1136 * Get a form variable from GET or POST data, stripped of magic
1137 * quotes if necessary.
1138 *
1139 * @param string $key       The name of a $_REQUEST key (optional).
1140 * @param string $default   The value to return if the variable is set (optional).
1141 * @return mixed      A cleaned GET or POST array if no key specified.
1142 * @return string     A cleaned form value if set, or $default.
1143 */
1144function getFormData($key=null, $default=null)
1145{
1146    $app =& App::getInstance();
1147
1148    if (null === $key) {
1149        // Return entire array.
1150        switch (strtoupper(getenv('REQUEST_METHOD'))) {
1151        case 'POST':
1152            return dispelMagicQuotes($_POST, $app->getParam('always_dispel_magicquotes'));
1153
1154        case 'GET':
1155            return dispelMagicQuotes($_GET, $app->getParam('always_dispel_magicquotes'));
1156
1157        default:
1158            return dispelMagicQuotes($_REQUEST, $app->getParam('always_dispel_magicquotes'));
1159        }
1160    }
1161
1162    if (isset($_REQUEST[$key])) {
1163        // $key is found in the flat array of REQUEST.
1164        return dispelMagicQuotes($_REQUEST[$key], $app->getParam('always_dispel_magicquotes'));
1165    } else if (mb_strpos($key, '[') !== false && isset($_REQUEST[strtok($key, '[')]) && preg_match_all('/\[([a-z0-9._~-]+)\]/', $key, $matches)) {
1166        // $key is formatted with sub-keys, e.g., getFormData('foo[bar][baz]') and top level key (`foo`) exists in REQUEST.
1167        // Extract these as sub-keys and access REQUEST as a multi-dimensional array, e.g., $_REQUEST[foo][bar][baz].
1168        $leaf = $_REQUEST[strtok($key, '[')];
1169        foreach ($matches[1] as $subkey) {
1170            if (is_array($leaf) && isset($leaf[$subkey])) {
1171                $leaf = $leaf[$subkey];
1172            } else {
1173                $leaf = null;
1174            }
1175        }
1176        return $leaf;
1177    } else {
1178        return $default;
1179    }
1180}
1181
1182function getPost($key=null, $default=null)
1183{
1184    $app =& App::getInstance();
1185
1186    if (null === $key) {
1187        return dispelMagicQuotes($_POST, $app->getParam('always_dispel_magicquotes'));
1188    }
1189    if (isset($_POST[$key])) {
1190        return dispelMagicQuotes($_POST[$key], $app->getParam('always_dispel_magicquotes'));
1191    } else {
1192        return $default;
1193    }
1194}
1195
1196function getGet($key=null, $default=null)
1197{
1198    $app =& App::getInstance();
1199
1200    if (null === $key) {
1201        return dispelMagicQuotes($_GET, $app->getParam('always_dispel_magicquotes'));
1202    }
1203    if (isset($_GET[$key])) {
1204        return dispelMagicQuotes($_GET[$key], $app->getParam('always_dispel_magicquotes'));
1205    } else {
1206        return $default;
1207    }
1208}
1209
1210/*
1211* Sets a $_GET or $_POST variable.
1212*
1213* @access   public
1214* @param    string  $key    The key of the request array to set.
1215* @param    mixed   $val    The value to save in the request array.
1216* @return   void
1217* @author   Quinn Comendant <quinn@strangecode.com>
1218* @version  1.0
1219* @since    01 Nov 2009 12:25:29
1220*/
1221function putFormData($key, $val)
1222{
1223    switch (strtoupper(getenv('REQUEST_METHOD'))) {
1224    case 'POST':
1225        $_POST[$key] = $val;
1226        break;
1227
1228    case 'GET':
1229        $_GET[$key] = $val;
1230        break;
1231    }
1232
1233    $_REQUEST[$key] = $val;
1234}
1235
1236/*
1237* Generates a base-65-encoded sha512 hash of $string truncated to $length.
1238*
1239* @access   public
1240* @param    string  $string Input string to hash.
1241* @param    int     $length Length of output hash string.
1242* @return   string          String of hash.
1243* @author   Quinn Comendant <quinn@strangecode.com>
1244* @version  1.0
1245* @since    03 Apr 2016 19:48:49
1246*/
1247function hash64($string, $length=18)
1248{
1249    $app =& App::getInstance();
1250
1251    return mb_substr(preg_replace('/[^\w]/' . $app->getParam('preg_u'), '', base64_encode(hash('sha512', $string, true))), 0, $length);
1252}
1253
1254/**
1255 * Signs a value using md5 and a simple text key. In order for this
1256 * function to be useful (i.e. secure) the salt must be kept secret, which
1257 * means keeping it as safe as database credentials. Putting it into an
1258 * environment variable set in httpd.conf is a good place.
1259 *
1260 * @access  public
1261 * @param   string  $val    The string to sign.
1262 * @param   string  $salt   (Optional) A text key to use for computing the signature.
1263 * @param   string  $length (Optional) The length of the added signature. Longer signatures are safer. Must match the length passed to verifySignature() for the signatures to match.
1264 * @return  string  The original value with a signature appended.
1265 */
1266function addSignature($val, $salt=null, $length=18)
1267{
1268    $app =& App::getInstance();
1269
1270    if ('' == trim($val)) {
1271        $app->logMsg(sprintf('Cannot add signature to an empty string.', null), LOG_INFO, __FILE__, __LINE__);
1272        return '';
1273    }
1274
1275    if (!isset($salt)) {
1276        $salt = $app->getParam('signing_key');
1277    }
1278
1279    switch ($app->getParam('signing_method')) {
1280    case 'sha512+base64':
1281        return $val . '-' . mb_substr(preg_replace('/[^\w]/' . $app->getParam('preg_u'), '', base64_encode(hash('sha512', $val . $salt, true))), 0, $length);
1282
1283    case 'md5':
1284    default:
1285        return $val . '-' . mb_strtolower(mb_substr(md5($salt . md5($val . $salt)), 0, $length));
1286    }
1287}
1288
1289/**
1290 * Strips off the signature appended by addSignature().
1291 *
1292 * @access  public
1293 * @param   string  $signed_val     The string to sign.
1294 * @return  string  The original value with a signature removed.
1295 */
1296function removeSignature($signed_val)
1297{
1298    if (empty($signed_val) || mb_strpos($signed_val, '-') === false) {
1299        return '';
1300    }
1301    return mb_substr($signed_val, 0, mb_strrpos($signed_val, '-'));
1302}
1303
1304/**
1305 * Verifies a signature appended to a value by addSignature().
1306 *
1307 * @access  public
1308 * @param   string  $signed_val A value with appended signature.
1309 * @param   string  $salt       (Optional) A text key to use for computing the signature.
1310 * @param   string  $length (Optional) The length of the added signature.
1311 * @return  bool    True if the signature matches the var.
1312 */
1313function verifySignature($signed_val, $salt=null, $length=18)
1314{
1315    // Strip the value from the signed value.
1316    $val = removeSignature($signed_val);
1317    // If the signed value matches the original signed value we consider the value safe.
1318    if ('' != $signed_val && $signed_val == addSignature($val, $salt, $length)) {
1319        // Signature verified.
1320        return true;
1321    } else {
1322        $app =& App::getInstance();
1323        $app->logMsg(sprintf('Failed signature (%s should be %s)', $signed_val, addSignature($val, $salt, $length)), LOG_DEBUG, __FILE__, __LINE__);
1324        return false;
1325    }
1326}
1327
1328/**
1329 * Sends empty output to the browser and flushes the php buffer so the client
1330 * will see data before the page is finished processing.
1331 */
1332function flushBuffer()
1333{
1334    echo str_repeat('          ', 205);
1335    flush();
1336}
1337
1338/**
1339 * A stub for apps that still use this function.
1340 *
1341 * @access  public
1342 * @return  void
1343 */
1344function mailmanAddMember($email, $list, $send_welcome_message=false)
1345{
1346    $app =& App::getInstance();
1347    $app->logMsg(sprintf('mailmanAddMember called and ignored: %s, %s, %s', $email, $list, $send_welcome_message), LOG_WARNING, __FILE__, __LINE__);
1348}
1349
1350/**
1351 * A stub for apps that still use this function.
1352 *
1353 * @access  public
1354 * @return  void
1355 */
1356function mailmanRemoveMember($email, $list, $send_user_ack=false)
1357{
1358    $app =& App::getInstance();
1359    $app->logMsg(sprintf('mailmanRemoveMember called and ignored: %s, %s, %s', $email, $list, $send_user_ack), LOG_WARNING, __FILE__, __LINE__);
1360}
1361
1362/*
1363* Returns the remote IP address, taking into consideration proxy servers.
1364*
1365* If strict checking is enabled, we will only trust REMOTE_ADDR or an HTTP header
1366* value if REMOTE_ADDR is a trusted proxy (configured as an array in $cfg['trusted_proxies']).
1367*
1368* @access   public
1369* @param    bool $dolookup            Resolve to IP to a hostname?
1370* @param    bool $trust_all_proxies   Should we trust any IP address set in HTTP_* variables? Set to FALSE for secure usage.
1371* @return   mixed Canonicalized IP address (or a corresponding hostname if $dolookup is true), or false if no IP was found.
1372* @author   Alix Axel <http://stackoverflow.com/a/2031935/277303>
1373* @author   Corey Ballou <http://blackbe.lt/advanced-method-to-obtain-the-client-ip-in-php/>
1374* @author   Quinn Comendant <quinn@strangecode.com>
1375* @version  1.0
1376* @since    12 Sep 2014 19:07:46
1377*/
1378function getRemoteAddr($dolookup=false, $trust_all_proxies=true)
1379{
1380    global $cfg;
1381
1382    if (!isset($_SERVER['REMOTE_ADDR'])) {
1383        // In some cases this won't be set, e.g., CLI scripts.
1384        return null;
1385    }
1386
1387    // Use an HTTP header value only if $trust_all_proxies is true or when REMOTE_ADDR is in our $cfg['trusted_proxies'] array.
1388    // $cfg['trusted_proxies'] is an array of proxy server addresses we expect to see in REMOTE_ADDR.
1389    if ($trust_all_proxies || isset($cfg['trusted_proxies']) && is_array($cfg['trusted_proxies']) && in_array($_SERVER['REMOTE_ADDR'], $cfg['trusted_proxies'], true)) {
1390        // Then it's probably safe to use an IP address value set in an HTTP header.
1391        // Loop through possible IP address headers from those most likely to contain the correct value first.
1392        // HTTP_CLIENT_IP: set by Apache Module mod_remoteip
1393        // HTTP_REAL_IP: set by Nginx Module ngx_http_realip_module
1394        // HTTP_CF_CONNECTING_IP: set by Cloudflare proxy
1395        // HTTP_X_FORWARDED_FOR: defacto standard for web proxies
1396        foreach (array('HTTP_CLIENT_IP', 'HTTP_REAL_IP', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED') as $key) {
1397            // Loop through and if
1398            if (array_key_exists($key, $_SERVER)) {
1399                foreach (explode(',', $_SERVER[$key]) as $addr) {
1400                    // Strip non-address data to avoid "PHP Warning:  inet_pton(): Unrecognized address for=189.211.197.173 in ./Utilities.inc.php on line 1293"
1401                    $addr = preg_replace('/[^=]=/', '', $addr);
1402                    $addr = canonicalIPAddr(trim($addr));
1403                    if (false !== filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
1404                        return $dolookup && '' != $addr ? gethostbyaddr($addr) : $addr;
1405                    }
1406                }
1407            }
1408        }
1409    }
1410
1411    $addr = canonicalIPAddr(trim($_SERVER['REMOTE_ADDR']));
1412    return $dolookup && $addr ? gethostbyaddr($addr) : $addr;
1413}
1414
1415/*
1416* Converts an ipv4 IP address in hexadecimal form into canonical form (i.e., it removes the prefix).
1417*
1418* @access   public
1419* @param    string  $addr   IP address.
1420* @return   string          Canonical IP address.
1421* @author   Sander Steffann <http://stackoverflow.com/a/12436099/277303>
1422* @author   Quinn Comendant <quinn@strangecode.com>
1423* @version  1.0
1424* @since    15 Sep 2012
1425*/
1426function canonicalIPAddr($addr)
1427{
1428    if (!preg_match('/^([0-9a-f:]+|[0-9.])$/', $addr)) {
1429        // Definitely not an IPv6 or IPv4 address.
1430        return $addr;
1431    }
1432
1433    // Known prefix
1434    $v4mapped_prefix_bin = pack('H*', '00000000000000000000ffff');
1435
1436    // Parse
1437    $addr_bin = inet_pton($addr);
1438
1439    // Check prefix
1440    if (substr($addr_bin, 0, strlen($v4mapped_prefix_bin)) == $v4mapped_prefix_bin) {
1441        // Strip prefix
1442        $addr_bin = substr($addr_bin, strlen($v4mapped_prefix_bin));
1443    }
1444
1445    // Convert back to printable address in canonical form
1446    return inet_ntop($addr_bin);
1447}
1448
1449/**
1450 * Tests whether a given IP address can be found in an array of IP address networks.
1451 * Elements of networks array can be single IP addresses or an IP address range in CIDR notation
1452 * See: http://en.wikipedia.org/wiki/Classless_inter-domain_routing
1453 *
1454 * @access  public
1455 * @param   string  IP address to search for.
1456 * @param   array   Array of networks to search within.
1457 * @return  mixed   Returns the network that matched on success, false on failure.
1458 */
1459function ipInRange($addr, $networks)
1460{
1461    if (!is_array($networks)) {
1462        $networks = array($networks);
1463    }
1464
1465    $addr_binary = sprintf('%032b', ip2long($addr));
1466    foreach ($networks as $network) {
1467        if (mb_strpos($network, '/') !== false) {
1468            // IP is in CIDR notation.
1469            list($cidr_ip, $cidr_bitmask) = explode('/', $network);
1470            $cidr_ip_binary = sprintf('%032b', ip2long($cidr_ip));
1471            if (mb_substr($addr_binary, 0, $cidr_bitmask) === mb_substr($cidr_ip_binary, 0, $cidr_bitmask)) {
1472               // IP address is within the specified IP range.
1473               return $network;
1474            }
1475        } else {
1476            if ($addr === $network) {
1477               // IP address exactly matches.
1478               return $network;
1479            }
1480        }
1481    }
1482
1483    return false;
1484}
1485
1486/**
1487 * If the given $url is on the same web site, return true. This can be used to
1488 * prevent from sending sensitive info in a get query (like the SID) to another
1489 * domain.
1490 *
1491 * @param  string $url    the URI to test.
1492 * @return bool True if given $url is our domain or has no domain (is a relative url), false if it's another.
1493 */
1494function isMyDomain($url)
1495{
1496    static $urls = array();
1497
1498    if (!isset($urls[$url])) {
1499        if (!preg_match('!^https?://!i', $url)) {
1500            // If we can't find a domain we assume the URL is local (i.e. "/my/url/path/" or "../img/file.jpg").
1501            $urls[$url] = true;
1502        } else {
1503            $urls[$url] = preg_match('!^https?://' . preg_quote(getenv('HTTP_HOST'), '!') . '!i', $url);
1504        }
1505    }
1506    return $urls[$url];
1507}
1508
1509/**
1510 * Takes a URL and returns it without the query or anchor portion
1511 *
1512 * @param  string $url   any kind of URI
1513 * @return string        the URI with ? or # and everything after removed
1514 */
1515function stripQuery($url)
1516{
1517    $app =& App::getInstance();
1518
1519    return preg_replace('/[?#].*$/' . $app->getParam('preg_u'), '', $url);
1520}
1521
1522/*
1523* Merge query arguments into a URL.
1524* Usage:
1525* Add ?lang=it or replace an existing ?lang= argument:
1526* $url = urlMerge('https://example.com/?lang=en', ['lang' => 'it']).
1527*
1528* @access   public
1529* @param    string  $url        Original URL.
1530* @param    array   $new_args   New/modified query arguments.
1531* @return   string              Modified URL.
1532* @author   Quinn Comendant <quinn@strangecode.com>
1533* @since    20 Feb 2021 21:21:53
1534*/
1535function urlMergeQuery($url, Array $new_args)
1536{
1537    $u = parse_url($url);
1538    if (isset($u['query']) && '' != $u['query']) {
1539        parse_str($u['query'], $args);
1540    } else {
1541        $args = [];
1542    }
1543    $u['query'] = http_build_query(array_merge($args, $new_args));
1544    return sprintf('%s%s%s%s%s',
1545        (isset($u['scheme'])    && '' != $u['scheme']   ? $u['scheme'] . '://' : ''),
1546        (isset($u['host'])      && '' != $u['host']     ? $u['host']           : ''),
1547        (isset($u['path'])      && '' != $u['path']     ? $u['path']           : '/'),
1548        (isset($u['query'])     && '' != $u['query']    ? '?' . $u['query']    : ''),
1549        (isset($u['fragment'])  && '' != $u['fragment'] ? '#' . $u['fragment'] : '')
1550    );
1551}
1552
1553/**
1554 * Returns a fully qualified URL to the current script, including the query. If you don't need the scheme://, use REQUEST_URI instead.
1555 *
1556 * @return string    a full url to the current script
1557 */
1558function absoluteMe()
1559{
1560    $app =& App::getInstance();
1561
1562    $safe_http_host = preg_replace('/[^a-z\d.:-]/' . $app->getParam('preg_u'), '', getenv('HTTP_HOST'));
1563    return sprintf('%s://%s%s', (getenv('HTTPS') ? 'https' : 'http'), $safe_http_host, getenv('REQUEST_URI'));
1564}
1565
1566/**
1567 * Compares the current url with the referring url.
1568 *
1569 * @param  bool $exclude_query  Remove the query string first before comparing.
1570 * @return bool                 True if the current URL is the same as the referring URL, false otherwise.
1571 */
1572function refererIsMe($exclude_query=false)
1573{
1574    $current_url = absoluteMe();
1575    $referrer_url = getenv('HTTP_REFERER');
1576
1577    // If one of the hostnames is an IP address, compare only the path of both.
1578    if (preg_match('/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/', parse_url($current_url, PHP_URL_HOST)) || preg_match('/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/', parse_url($referrer_url, PHP_URL_HOST))) {
1579        $current_url = preg_replace('@^https?://[^/]+@u', '', $current_url);
1580        $referrer_url = preg_replace('@^https?://[^/]+@u', '', $referrer_url);
1581    }
1582
1583    if ($exclude_query) {
1584        return (stripQuery($current_url) == stripQuery($referrer_url));
1585    } else {
1586        $app =& App::getInstance();
1587        $app->logMsg(sprintf('refererIsMe comparison: %s == %s', $current_url, $referrer_url), LOG_DEBUG, __FILE__, __LINE__);
1588        return ($current_url == $referrer_url);
1589    }
1590}
1591
1592/*
1593* Returns true if the given URL resolves to a resource with a HTTP 2xx or 3xx header response.
1594* The download will abort if it retrieves >= 10KB of data to avoid downloading large files.
1595* We couldn't use CURLOPT_NOBODY (a HEAD request) because some services don't behave without a GET request (ahem, BBC).
1596* This function may not be very portable, if the server doesn't support CURLOPT_PROGRESSFUNCTION.
1597*
1598* @access   public
1599* @param    string  $url     URL to a file.
1600* @param    int     $timeout The maximum number of seconds to allow the HTTP query to execute.
1601* @return   bool             True if the resource exists, false otherwise.
1602* @author   Quinn Comendant <quinn@strangecode.com>
1603* @version  2.0
1604* @since    02 May 2015 15:10:09
1605*/
1606function httpExists($url, $timeout=5)
1607{
1608    $ch = curl_init($url);
1609    curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
1610    curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
1611    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
1612    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
1613    curl_setopt($ch, CURLOPT_USERAGENT, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");
1614    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Don't pass through data to the browser.
1615    curl_setopt($ch, CURLOPT_BUFFERSIZE, 128); // Frequent progress function calls.
1616    curl_setopt($ch, CURLOPT_NOPROGRESS, false); // Required to use CURLOPT_PROGRESSFUNCTION.
1617    // Function arguments for CURLOPT_PROGRESSFUNCTION changed with php 5.5.0.
1618    if (version_compare(PHP_VERSION, '5.5.0', '>=')) {
1619        curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($ch, $dltot, $dlcur, $ultot, $ulcur){
1620            // Return a non-zero value to abort the transfer. In which case, the transfer will set a CURLE_ABORTED_BY_CALLBACK error
1621            // 10KB should be enough to catch a few 302 redirect headers and get to the actual content.
1622            return ($dlcur > 10*1024) ? 1 : 0;
1623        });
1624    } else {
1625        curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($dltot, $dlcur, $ultot, $ulcur){
1626            // Return a non-zero value to abort the transfer. In which case, the transfer will set a CURLE_ABORTED_BY_CALLBACK error
1627            // 10KB should be enough to catch a few 302 redirect headers and get to the actual content.
1628            return ($dlcur > 10*1024) ? 1 : 0;
1629        });
1630    }
1631    curl_exec($ch);
1632    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1633    return preg_match('/^[23]\d\d$/', $http_code);
1634}
1635
1636/*
1637* Get a HTTP response header.
1638*
1639* @access   public
1640* @param    string  $url    URL to hit.
1641* @param    string  $key    Name of the header to return.
1642* @param    array   $valid_response_codes   Array of acceptable HTTP return codes.
1643* @return   string  Value of the http header.
1644* @author   Quinn Comendant <quinn@strangecode.com>
1645* @since    28 Oct 2020 20:00:36
1646*/
1647function getHttpHeader($url, $key, Array $valid_response_codes=[200])
1648{
1649    $headers = @get_headers($url, 1);
1650
1651    if ($headers && preg_match(sprintf('/\b(%s)\b/', join('|', $valid_response_codes)), $headers[0])) {
1652        $headers = array_change_key_case($headers, CASE_LOWER);
1653        $key = strtolower($key);
1654        if (isset($headers[$key])) {
1655            return $headers[$key];
1656        }
1657    }
1658
1659    return false;
1660}
1661
1662/*
1663* Load JSON data from a file and return it as an array (as specified by the json_decode options passed below.)
1664*
1665* @access   public
1666* @param    string  $filename   Name of the file to load. Just exist in the include path.
1667* @param    bool    $assoc      When TRUE, returned objects will be converted into associative arrays.
1668* @param    int     $depth      Recursion depth.
1669* @param    const   $options    Bitmask of JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_IGNORE, JSON_INVALID_UTF8_SUBSTITUTE, JSON_OBJECT_AS_ARRAY, JSON_THROW_ON_ERROR.
1670* @return   array               Array of data from the file, or null if there was a problem.
1671* @author   Quinn Comendant <quinn@strangecode.com>
1672* @since    09 Oct 2019 21:32:47
1673*/
1674function jsonDecodeFile($filename, $assoc=true, $depth=512, $options=0)
1675{
1676    $app =& App::getInstance();
1677
1678    if (false === ($resolved_filename = stream_resolve_include_path($filename))) {
1679        $app->logMsg(sprintf('JSON file "%s" not found in path "%s"', $filename, get_include_path()), LOG_ERR, __FILE__, __LINE__);
1680        return null;
1681    }
1682
1683    if (!is_readable($resolved_filename)) {
1684        $app->logMsg(sprintf('JSON file is unreadable: %s', $resolved_filename), LOG_ERR, __FILE__, __LINE__);
1685        return null;
1686    }
1687
1688    if (null === ($data = json_decode(file_get_contents($resolved_filename), $assoc, $depth, $options))) {
1689        $app->logMsg(sprintf('JSON is unparsable: %s', $resolved_filename), LOG_ERR, __FILE__, __LINE__);
1690        return null;
1691    }
1692
1693    return $data;
1694}
1695
1696/*
1697* Get IP address status from IP Intelligence. https://getipintel.net/free-proxy-vpn-tor-detection-api/#expected_output
1698*
1699* @access   public
1700* @param    string  $ip         IP address to check.
1701* @param    float   $threshold  Return true if the IP score is above this threshold (0-1).
1702* @param    string  $email      Requester email address.
1703* @return   boolean             True if the IP address appears to be a robot, proxy, or VPN.
1704*                               False if the IP address is a residential or business IP address, or the API failed to return a valid response.
1705* @author   Quinn Comendant <quinn@strangecode.com>
1706* @since    26 Oct 2019 15:39:17
1707*/
1708function IPIntelligenceBadIP($ip, $threshold=0.95, $email='hello@strangecode.com')
1709{
1710    $app =& App::getInstance();
1711
1712    $ch = curl_init(sprintf('http://check.getipintel.net/check.php?ip=%s&contact=%s', urlencode($ip), urlencode($email)));
1713    curl_setopt($ch, CURLOPT_TIMEOUT, 2);
1714    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1715    $response = curl_exec($ch);
1716    $errorno = curl_errno($ch);
1717    $error = curl_error($ch);
1718    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1719    curl_close($ch);
1720
1721    if ($errorno == CURLE_OPERATION_TIMEOUTED) {
1722        $http_code = 408;
1723    }
1724
1725    switch ($http_code) {
1726    case 200:
1727    case 400:
1728        // Check response value, below.
1729        break;
1730
1731    case 408:
1732        $app->logMsg(sprintf('IP Intelligence timeout', null), LOG_WARNING, __FILE__, __LINE__);
1733        return false;
1734    case 429:
1735        $app->logMsg(sprintf('IP Intelligence number of allowed queries exceeded (rate limit 15 requests/minute)', null), LOG_WARNING, __FILE__, __LINE__);
1736        return false;
1737    default:
1738        $app->logMsg(sprintf('IP Intelligence unexpected response (%s): %s: %s', $http_code, $error, $response), LOG_ERR, __FILE__, __LINE__);
1739        return false;
1740    }
1741
1742    switch ($response) {
1743    case -1:
1744        $app->logMsg('IP Intelligence: Invalid no input', LOG_WARNING, __FILE__, __LINE__);
1745        return false;
1746    case -2:
1747        $app->logMsg('IP Intelligence: Invalid IP address', LOG_WARNING, __FILE__, __LINE__);
1748        return false;
1749    case -3:
1750        $app->logMsg('IP Intelligence: Unroutable or private address', LOG_WARNING, __FILE__, __LINE__);
1751        return false;
1752    case -4:
1753        $app->logMsg('IP Intelligence: Unable to reach database', LOG_WARNING, __FILE__, __LINE__);
1754        return false;
1755    case -5:
1756        $app->logMsg('IP Intelligence: Banned: exceeded query limits, no permission, or invalid email address', LOG_WARNING, __FILE__, __LINE__);
1757        return false;
1758    case -6:
1759        $app->logMsg('IP Intelligence: Invalid contact information', LOG_WARNING, __FILE__, __LINE__);
1760        return false;
1761    default:
1762        if (!is_numeric($response) || $response < 0) {
1763            $app->logMsg(sprintf('IP Intelligence: Unknown status for IP (%s): %s', $response, $ip), LOG_NOTICE, __FILE__, __LINE__);
1764            return false;
1765        }
1766        if ($response >= $threshold) {
1767            $app->logMsg(sprintf('IP Intelligence: Bad IP (%s): %s', $response, $ip), LOG_NOTICE, __FILE__, __LINE__);
1768            return true;
1769        }
1770        $app->logMsg(sprintf('IP Intelligence: Good IP (%s): %s', $response, $ip), LOG_NOTICE, __FILE__, __LINE__);
1771        return false;
1772    }
1773}
1774
1775/*
1776* Test if a string is valid json.
1777* https://stackoverflow.com/questions/6041741/fastest-way-to-check-if-a-string-is-json-in-php
1778*
1779* @access   public
1780* @param    string  $str  The string to test.
1781* @return   boolean       True if the string is valid json.
1782* @author   Quinn Comendant <quinn@strangecode.com>
1783* @since    06 Dec 2020 18:41:51
1784*/
1785function isJSON($str)
1786{
1787    json_decode($str);
1788    return (json_last_error() === JSON_ERROR_NONE);
1789}
Note: See TracBrowser for help on using the repository browser.