<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Helpers;
use Exception;
use InvalidArgumentException;
use ReflectionClass;
use Kohana;
use karmabunny\pdb\Exceptions\QueryException;
/**
* Useful functions for sprout in general
**/
class Sprout
{
/**
* Determines the file path for a class, usually for autoloading
* @param string $class The class, including namespace
* @return string|false path or false if it couldn't be found
*/
public static function determineFilePath($class)
{
$sep = DIRECTORY_SEPARATOR;
$sprout_ns = 'Sprout\\';
$sprout_ns_len = strlen($sprout_ns); $modules_ns = 'SproutModules\\';
$modules_ns_len = strlen($modules_ns); $file = false;
// Load Sprout core
if (substr($class, 0, $sprout_ns_len) == $sprout_ns) { $file = substr($class, $sprout_ns_len); $dir = realpath(__DIR__
. $sep . '..') . $sep;
// Load modules
} else if (substr($class, 0, $modules_ns_len) == $modules_ns) { $file = substr($class, $modules_ns_len);
// Strip vendor name to get directory within modules/
// e.g. SproutModules\Karmabunny\Pages => Pages
$slash_pos = strpos($file, '\\'); if ($slash_pos === false) return;
$file = substr($file, $slash_pos + 1);
$dir .= 'modules' . $sep;
} else {
$dir = DOCROOT . 'vendor/';
$file = $class;
}
if (!$file) return false;
}
/**
* Removes the namespaces from a class-like entity,
* e.g. Sprout\Helpers\Text => Text
* @param string $classlike
* @return string
*/
public static function removeNs($classlike)
{
if ($pos !== false) {
return substr($classlike, $pos + 1); }
return $classlike;
}
/**
* Creates an object of a class specified by a string name, with a list of possible namespaces to lookup if the
* specified class name doesn't contain a namespace
* @param string $class The class to instantiate
* @param array $possible_nses Possible namespaces to try
* @return object
* @throws Exception if the class lookup failed
*/
public static
function nsNew
($class, array $possible_nses) {
if (strpos($class, '\\') !== false) { return new $class;
} else {
throw new Exception("Unable to load class {$class}");
}
}
foreach ($possible_nses as $ns) {
$full_class = $ns . '\\' . $class;
return new $full_class;
}
}
throw new Exception("Class lookup failed: {$class}");
}
/**
* Gets the full class name (including namespace) for a specified class, with a list of namespaces to search
* @param string $class The class to instantiate, e.g. 'Fb'
* @param array $possible_nses Possible namespaces to try, e.g. ['Sprout\Helpers']
* @return string
* @throws Exception if the class lookup failed
*/
public static
function nsClass
($class, array $possible_nses) {
if (strpos($class, '\\') !== false) { }
foreach ($possible_nses as $ns) {
$full_class = $ns . '\\' . $class;
}
throw new Exception("Class lookup failed: {$class}");
}
/**
* Gets the full name (including namespaced class) for a specified function
* @param string $func The function to find, e.g. 'Fb::dropdown'
* @param array $possible_nses Possible namespaces to try, e.g. ['Sprout\Helpers']
* @return string
* @throws Exception if the function lookup failed
*/
public static
function nsFunc
($func, array $possible_nses) {
$class = '';
if (strpos($func, '::') !== false) { }
if (strpos($func, '\\') !== false) { if ($class) {
}
}
foreach ($possible_nses as $ns) {
if ($class) {
$full_class = $ns . '\\' . $class;
if (method_exists($full_class, $func)) return "{$full_class}::{$func}"; } else {
$full_fn = $ns . '\\' . $func;
}
}
throw new Exception("Function lookup failed: {$func}");
}
/**
* Construct a new instance of a class with a given name
*
* @example
* $inst = Sprout::instance($widget_class, 'Sprout\\Widgets\\Widget');
*
* @param string $class_name The name of the class
* @param string|array $base_class_name The base class or interface which the class must extend/implement.
* Can be a string for a single check, or an array for multiple checks.
* NULL disables this check.
* @throws InvalidArgumentException If the class does not exist
* @return mixed The new instance
*/
public static function instance($class_name, $base_class_name = null)
{
throw new InvalidArgumentException("Class <{$class_name}> does not exist");
}
// Check the class isn't abstract
$class = new ReflectionClass($class_name);
if ($class->isAbstract()) {
throw new InvalidArgumentException("Class <{$class_name}> is abstract");
}
// Check that the class extends/implements everything it's required to
if (!empty($base_class_name)) { $base_class_name = [$base_class_name];
}
foreach ($base_class_name as $chk) {
if (!$class->isSubclassOf($chk)) {
throw new InvalidArgumentException("Class <{$class_name}> is not a sub-class of <{$chk}>");
}
}
}
// Interesting little way to report an instantiation error even if display_errors is off.
// On fatal errors, the output buffer gets flushed to the beowser, reporting our message.
// On success, we just clear the buffer.
// On the test server, we just let it die naturally.
if (IN_PRODUCTION) {
echo '<p><b>FATAL ERROR:</b><br>Unable to instance class "' . Enc::html($class_name) . '".</p>';
$inst = @new $class_name;
} else {
$inst = new $class_name;
}
return $inst;
}
/**
* Returns the current version of sprout
*
* @param bool $git_version Optional flag to return git version, returns branding version by default
*/
public static function getVersion($git_version = false)
{
if (!empty($git_version)) return Kohana
::config('core.version'); return Kohana::config('core.version_brand');
}
/**
* Returns true if the specified module is currently installed, false otherwise
**/
public static function moduleInstalled($module_name)
{
return in_array($module_name, Register
::getModules()); }
/**
* Gets a simplified backtrace with fewer elements and no recursion
* @param array $trace If empty, the trace is automatically determined
* @return array
*/
public static
function simpleBacktrace
(array $trace = []) {
if (count($trace) == 0) {
// Remove this and its caller
}
$simple_trace = [];
foreach ($trace as $call) {
$simple_call = [];
if (isset($call['file'])) { $file = $call['file'];
if (IN_PRODUCTION or @$_SERVER['SERVER_ADDR'] != @$_SERVER['REMOTE_ADDR']) {
}
}
$simple_call['file'] = $file;
}
if (isset($call['line'])) { $simple_call['line'] = $call['line'];
}
if (!empty($call['function'])) { $call_func = $call['function'];
if (isset($call['class'])) { $class = $call['class'];
if (isset($call['type'])) { $class .= $call['type'];
} else {
$class .= '-??-';
}
$call_func = $class . $call_func;
}
$simple_call['function'] = $call_func;
}
if (!empty($call['args'])) { $args = [];
foreach ($call['args'] as $akey => $aval) {
$args[$akey] = "array({$len}): " . self::condenseArray($aval);
} else {
$args[$akey] = self::readableVar($aval);
}
}
$simple_call['args'] = $args;
}
$simple_trace[] = $simple_call;
}
return $simple_trace;
}
/**
* Converts a variable into something human readable
* @param mixed $var
* @return string
*/
public static function readableVar($var)
{
if (is_array($var)) return self::condenseArray($var); if (is_bool($var)) return $var?
'true': 'false'; }
return 'unknown';
}
/**
* Condenses an array into a string
*/
public static
function condenseArray
(array $arr) {
$int_keys = true;
foreach ($keys as $key) {
$int_keys = false;
break;
}
}
$str = '[';
$arg_num = 0;
foreach ($arr as $key => $val) {
if (++$arg_num != 1) $str .= ', ';
if (!$int_keys) $str .= self::readableVar($key) . ' => ';
$str .= self::readableVar($val);
}
$str .= ']';
return $str;
}
/**
* Checks a URL that is to be used for redirection is valid.
*
* Will allow remote URLs beginning with 'http://' and local URLs beginning with '/'
**/
public static function checkRedirect($text)
{
$text = Enc::cleanfunky($text);
if (preg_match('!^http(s?)://[a-z]!', $text)) return true;
return false;
}
/**
* Returns an absolute URL for the web root of this server
*
* Example: 'http://thejosh.info/sprout_test/'
*
* @param string $protocol Protocol to use. 'http' or 'https'.
* Defaults to server config option, which if not set, uses current request protocol.
**/
public static function absRoot($protocol = '')
{
if ($protocol == '') {
$protocol = Kohana::config('config.site_protocol');
}
if ($protocol == '') {
$protocol = Request::protocol();
}
if ($protocol == '') {
$protocol = 'http';
}
return Url::base(true, $protocol);
}
/**
* Takes a mysql DATETIME value (will probably also work with a TIME or DATE value)
* and formats it according to the format codes specified by the PHP date() function.
*
* The format is optional, if omittted, uses 'd/m/Y g:i a' = '7/11/2010 5:27 pm'
**/
public static function formatMysqlDatetime($date, $format = 'd/m/Y g:i a')
{
}
/**
* Returns a string of random numbers and letters
**/
public static function randStr($length = 16, $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890')
{
$num_chars = strlen($chars) - 1;
$string = '';
for ($i = 0; $i < $length; $i++) {
$string .= $chars[mt_rand(0, $num_chars)]; }
return $string;
}
/**
* Returns a time in 'x minutes ago' format.
*
* Very small times (0, 1 seconds) are considered 'Just now'.
* Times are represneted in seconds, minutes, hours or days.
*
* @param int $timediff Amount of time that has passed, in seconds.
**/
public static function timeAgo($timediff)
{
$timediff = (int) $timediff;
if ($timediff < 2) return 'Just now';
if ($timediff >= 86400) {
$unit = ' day';
$time = floor($timediff / 86400);
} else if ($timediff >= 3600) {
$unit = ' hour';
$time = floor($timediff / 3600);
} else if ($timediff >= 60) {
$unit = ' minute';
$time = floor($timediff / 60);
} else {
$unit = ' second';
$time = $timediff;
}
return $time . $unit . ($time == 1 ? ' ago' : 's ago');
}
/**
* Load the text for an extra page.
* Returns NULL on error.
*
* @param int $type The page type, should be one of the type constants
**/
public static function extraPage($type)
{
$subsite_id = SubsiteSelector::$content_id;
$q = "SELECT text
FROM ~extra_pages
WHERE subsite_id = ? AND type = ?
ORDER BY id
LIMIT 1";
try {
$row = Pdb::q($q, [$subsite_id, $type], 'row');
} catch (QueryException $ex) {
return null;
}
return $row['text'];
}
/**
* Attempts to put the handbrake on a script which is doing malicious inserts to the database
*
* @param string $table The table name, not prefixed
* @param string $column The column to check
* @param string $value The value to check
* @param string $limit The number of inserts allowed in the provided time
* @param string $time The amount of time the limit applies for, in seconds. Default = 1 hour
* @param array $conds Additional conditions for the WHERE clause, formatted as per {@see Pdb::buildClause}
* @return bool True if the insert rate is OK
**/
public static
function checkInsertRate
($table, $column, $value, $limit, $time = 3600, array $conds = []) {
Pdb::validateIdentifier($table);
Pdb::validateIdentifier($column);
$params = [$value, (int)$time];
$clause = Pdb::buildClause($conds, $params);
if ($clause) $clause = 'AND ' . $clause;
$q = "SELECT COUNT(id) AS C
FROM ~{$table}
WHERE {$column} LIKE ?
AND date_added > DATE_SUB(NOW(), INTERVAL ? SECOND)
{$clause}";
$count = Pdb::q($q, $params, 'val');
if ($count >= $limit) return false;
return true;
}
/**
* Back-end for link-checking tool
**/
public static function linkChecker()
{
throw new Exception('Not in use any more; use the worker "WorkerLinkChecker".');
}
/**
* Takes two strings of text (which will be stripped of HTML tags)
* and returns HTML which is a table showing the differences
* in a nice colourful way
**/
public static function colorisedDiff($orig, $new)
{
$tmp_name1 = tempnam('/tmp', 'dif');
$tmp_name2 = tempnam('/tmp', 'dif');
$diff = shell_exec("diff -yat --left-column --width=3004 {$tmp_name1} {$tmp_name2}");
// Colorise diff
$out = '<table cellpadding="5" cellspacing="3">';
$out .= '<tr><td> </td>';
$out .= '<th style="width: 420px;" bgcolor="#CECECE">Old revision (paragraph-by-paragraph)</th>';
$out .= '<th style="width: 420px;" bgcolor="#CECECE">New revision (paragraph-by-paragraph)</th>';
$out .= '</tr>';
foreach ($diff as &$line) {
if (! preg_match('/^(.{1,1500}) (.) ? ?(.{1,1500})?$/', $line, $matches)) continue; @list($nop, $left, $char, $right) = $matches;
if ($left == '' and $right == '') continue;
$line = $left . '<b>' . $char . '</b>' . $right;
if (strlen($left) >= 1500) $left .= '...'; if (strlen($right) >= 1500) $right .= '...';
switch ($char) {
case '(':
//$out .= '<tr><td><b>Not changed</b></td><td>' . $left . '</td><td>' . $left . '</td></tr>';
break;
case '|':
$out .= '<tr><td><b>Changed</b></td><td bgcolor="#D8F1FF">' . $left . '</td><td bgcolor="#D8F1FF">' . $right . '</td></tr>';
break;
case '<':
$out .= '<tr><td><b>Removed</b></td><td bgcolor="#FCA7AE">' . $left . '</td><td bgcolor="#FFDDDF"> </td></tr>';
break;
case '>':
$out .= '<tr><td><b>Added</b></td><td bgcolor="#E6FADD"> </td><td bgcolor="#C9FFB3">' . $right . '</td></tr>';
break;
}
}
$out .= '</table>';
return $out;
}
/**
* Set the etag header, and some expiry headers.
* Checks if the etag matches - if it does, terminates the script with '304 Not Modified'.
*
* ETag should be specified as a string.
* Expires should be specified as a number of seconds, after that time the URL will expire.
*
* ETags should be something which is unique for that version of the URL. They should use
* something which is collission-resistant, such as MD5. They should vary based on the
* Accept-Encoding header, or any other 'accept' headers, if you are supporting them.
**/
public static function etag($etag, $expires)
{
header('ETag: "' . $etag . '"'); header('Cache-Control: store, cache, must-revalidate, max-age=' . $expires);
if ($_SERVER['HTTP_IF_NONE_MATCH']) {
$match = str_replace('"', '', $_SERVER['HTTP_IF_NONE_MATCH']); if ($match == $etag) {
header('HTTP/1.0 304 Not Modified'); }
}
}
/**
* Translate an array which may contain a page_id, filename or url into the final URL to use
**/
public static function translateLink($row)
{
if ($row['page_id']) {
$root = Navigation::getRootNode();
$page = $root->findNodeValue('id', $row['page_id']);
if ($page) {
return $page->getFriendlyUrl();
}
}
if ($row['filename']) {
return File::absUrl($row['filename']); }
if ($row['url']) {
return $row['url'];
}
return null;
}
/**
* Return the last-modified date of all pages on the (sub-)site
* Returns NULL on error.
*
* The date is formatted using the php date function.
* The default date format is "d/m/Y".
*
* @param string $date_format The date format to return the date in
* @return string Last modified date
* @return null On error
**/
public static function lastModified($date_format = 'd/m/Y')
{
try {
$q = "SELECT date_modified
FROM ~pages
WHERE subsite_id = ?
ORDER BY date_modified DESC
LIMIT 1";
$date = Pdb::query($q, [SubsiteSelector::$content_id], 'val');
} catch (QueryException $ex) {
return null;
}
}
/**
* Adds classes, analytics and target-blank to file links.
* Also adds a random string, which prevents caching, solving some problems we were having with some clients.
**/
public static function specialFileLinks($html)
{
// Grabs <a> links, with href containing:
// - optional something
// - "files/"
// - something
// - "."
// - some letters (a-z)
// and the A must only have non HTML content (doesn't contain < or >)
//
'!<a[^>]+href="([^"]*)files/([^"]+\.([a-z]+))"[^>]*>([^<>]+)</a>!',
function($matches) {
// Only mangle local URLs; leave remote URLs alone
$http_pattern = '#^(?:https?:)?//([^/]*)#';
$link_matches = [];
$link_matches_pattern = preg_match($http_pattern, $matches[1], $link_matches); $own_domain_matches = [];
$url_base = Subsites::getAbsRoot(SubsiteSelector::$subsite_id);
$own_domain_matches_pattern = preg_match($http_pattern, $url_base, $own_domain_matches);
// Local URLs
$url = File::relUrl($matches[2]) . '?v=' . mt_rand(100, 999);
// Remote URLs
if ($link_matches_pattern and $own_domain_matches_pattern) {
$link_domain = preg_replace('/^www\./', '', $link_matches[1]); $own_domain = preg_replace('/^www\./', '', $own_domain_matches[1]); if ($link_domain != $own_domain) {
return $matches[0];
}
}
$class = 'document document-' . $matches[3];
$onclick = "ga('send', 'event', 'Document', 'Download', '" . Enc::js($matches[2]) . "');";
if (preg_match('!class="([^"]+)"!', $matches[0], $m)) { }
$out = '<a href="' . Enc::html($url) . '"';
$out .= ' class="' . Enc
::html(trim($class)) . '"'; $out .= ' target="_blank"';
$out .= ' data-ext="' . Enc::html($matches[3]) . '"';
$out .= ' data-size="' . Enc
::html(File::humanSize(File::size($matches[2]))) . '"'; $out .= ' onclick="' . Enc::html($onclick) . '">';
$out .= Enc::html($matches[4]);
$out .= '</a>';
return $out;
},
$html
);
}
/**
* Return true if the browser supports drag-and-drop uploads.
**/
public static function browserDragdropUploads()
{
'Firefox' => '4.0.0',
'Internet Explorer' => '10.0',
'Chrome' => '13.0.0',
'Safari' => '6.0.0',
);
if (! isset($supported[Kohana
::userAgent('browser')])) { return false;
}
$min_version = $supported[Kohana::userAgent('browser')];
}
/**
* @deprecated Use {@see Security::passwordComplexity} instead
**/
public static function passwordComplexity($str)
{
$errs = Security::passwordComplexity($str, 8, 0, false);
if (count($errs) == 0) return true; return $errs;
}
/**
* Return a list of admins to send emails to.
*
* The return value is an array of arrays.
* The inner arrays contains the keys "name" and "email".
**/
public static function adminEmails()
{
$ops = AdminPerms::getOperatorsWithAccess('access_reportemail');
foreach ($ops as $row) {
'name' => $row['name'],
'email' => $row['email'],
);
}
return $out;
}
/**
* Check an IP against a list of IP addresses, with logic for CIDR ranges
*
* @return bool True if the IP is in the list, false if it's not
**/
public static function ipaddressInArray($needle, $haystack)
{
foreach ($haystack as $check) {
if (count($parts) == 1) { // Plain IP
if ($needle == $parts[0]) return true;
} else {
// CIDR
list($subnet, $mask) = $parts; $mask = ~((1 << (32 - $mask)) - 1);
// Correctly handle unaligned subnets
$subnet = ip2long($subnet) & $mask; if ((ip2long($needle) & $mask) === $subnet) { return true;
}
}
}
return false;
}
/**
* Returns the memory limit in bytes. If there is no limit, returns INT_MAX.
*
* @return int Bytes
*/
public static function getMemoryLimit()
{
$memory_limit = ini_get('memory_limit');
if ($memory_limit == -1) return INT_MAX;
if (preg_match('/^(\d+)(.)$/', $memory_limit, $matches)) { if ($matches[2] == 'G') return $matches[1] * 1024 * 1024 * 1024;
if ($matches[2] == 'M') return $matches[1] * 1024 * 1024;
if ($matches[2] == 'K') return $matches[1] * 1024;
} else {
return $memory_limit;
}
}
/**
* Gets the first key value pair of an iterable
*
* This is to replace `reset` which has been deprecated in 7.2. While this lacks the
* stateful behaviour of the original (i.e. changing the internal pointer) it does
* recreate the most used feature: fetching the first element without knowing its key.
*
* @param iterable $iter An array or Traversable
* @return array|null An array of [key, value] or null if the iterable is empty
* @example
* list ($key, $value) = Sprout::iterableFirst(['an' => 'array']);
*/
public static function iterableFirst($iter)
{
foreach ($iter as $k => $v) {
return [$k, $v];
}
return null;
}
/**
* Gets the first key of an iterable
*
* @param iterable $iter An array or Traversable
* @return mixed|null The value or null if the iterable is emtpy
*/
public static function iterableFirstKey($iter)
{
return @static::iterableFirst($iter)[0];
}
/**
* Gets the first value of an iterable
*
* @param iterable $iter An array or Traversable
* @return mixed|null The value or null if the iterable is empty
*/
public static function iterableFirstValue($iter)
{
return @static::iterableFirst($iter)[1];
}
}