Utils.php 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104
  1. <?php
  2. /**
  3. * @package Grav\Common
  4. *
  5. * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common;
  9. use DateTime;
  10. use DateTimeZone;
  11. use Exception;
  12. use Grav\Common\Flex\Types\Pages\PageObject;
  13. use Grav\Common\Helpers\Truncator;
  14. use Grav\Common\Page\Interfaces\PageInterface;
  15. use Grav\Common\Markdown\Parsedown;
  16. use Grav\Common\Markdown\ParsedownExtra;
  17. use Grav\Common\Page\Markdown\Excerpts;
  18. use Grav\Common\Page\Pages;
  19. use Grav\Framework\Flex\Flex;
  20. use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
  21. use InvalidArgumentException;
  22. use Negotiation\Accept;
  23. use Negotiation\Negotiator;
  24. use RocketTheme\Toolbox\Event\Event;
  25. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  26. use RuntimeException;
  27. use function array_key_exists;
  28. use function array_slice;
  29. use function count;
  30. use function extension_loaded;
  31. use function function_exists;
  32. use function in_array;
  33. use function is_array;
  34. use function is_callable;
  35. use function is_string;
  36. use function strlen;
  37. /**
  38. * Class Utils
  39. * @package Grav\Common
  40. */
  41. abstract class Utils
  42. {
  43. /** @var array */
  44. protected static $nonces = [];
  45. protected const ROOTURL_REGEX = '{^((?:http[s]?:\/\/[^\/]+)|(?:\/\/[^\/]+))(.*)}';
  46. // ^((?:http[s]?:)?[\/]?(?:\/))
  47. /**
  48. * Simple helper method to make getting a Grav URL easier
  49. *
  50. * @param string|object $input
  51. * @param bool $domain
  52. * @param bool $fail_gracefully
  53. * @return string|false
  54. */
  55. public static function url($input, $domain = false, $fail_gracefully = false)
  56. {
  57. if ((!is_string($input) && !is_callable([$input, '__toString'])) || !trim($input)) {
  58. if ($fail_gracefully) {
  59. $input = '/';
  60. } else {
  61. return false;
  62. }
  63. }
  64. $input = (string)$input;
  65. if (Uri::isExternal($input)) {
  66. return $input;
  67. }
  68. $grav = Grav::instance();
  69. /** @var Uri $uri */
  70. $uri = $grav['uri'];
  71. $resource = false;
  72. if (static::contains((string)$input, '://')) {
  73. /** @var UniformResourceLocator $locator */
  74. $locator = $grav['locator'];
  75. $parts = Uri::parseUrl($input);
  76. if (is_array($parts)) {
  77. // Make sure we always have scheme, host, port and path.
  78. $scheme = $parts['scheme'] ?? '';
  79. $host = $parts['host'] ?? '';
  80. $port = $parts['port'] ?? '';
  81. $path = $parts['path'] ?? '';
  82. if ($scheme && !$port) {
  83. // If URL has a scheme, we need to check if it's one of Grav streams.
  84. if (!$locator->schemeExists($scheme)) {
  85. // If scheme does not exists as a stream, assume it's external.
  86. return str_replace(' ', '%20', $input);
  87. }
  88. // Attempt to find the resource (because of parse_url() we need to put host back to path).
  89. $resource = $locator->findResource("{$scheme}://{$host}{$path}", false);
  90. if ($resource === false) {
  91. if (!$fail_gracefully) {
  92. return false;
  93. }
  94. // Return location where the file would be if it was saved.
  95. $resource = $locator->findResource("{$scheme}://{$host}{$path}", false, true);
  96. }
  97. } elseif ($host || $port) {
  98. // If URL doesn't have scheme but has host or port, it is external.
  99. return str_replace(' ', '%20', $input);
  100. }
  101. if (!empty($resource)) {
  102. // Add query string back.
  103. if (isset($parts['query'])) {
  104. $resource .= '?' . $parts['query'];
  105. }
  106. // Add fragment back.
  107. if (isset($parts['fragment'])) {
  108. $resource .= '#' . $parts['fragment'];
  109. }
  110. }
  111. } else {
  112. // Not a valid URL (can still be a stream).
  113. $resource = $locator->findResource($input, false);
  114. }
  115. } else {
  116. $root = $uri->rootUrl();
  117. if (static::startsWith($input, $root)) {
  118. $input = static::replaceFirstOccurrence($root, '', $input);
  119. }
  120. $input = ltrim($input, '/');
  121. $resource = $input;
  122. }
  123. if (!$fail_gracefully && $resource === false) {
  124. return false;
  125. }
  126. $domain = $domain ?: $grav['config']->get('system.absolute_urls', false);
  127. return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?? '');
  128. }
  129. /**
  130. * Helper method to find the full path to a file, be it a stream, a relative path, or
  131. * already a full path
  132. *
  133. * @param string $path
  134. * @return string
  135. */
  136. public static function fullPath($path)
  137. {
  138. $locator = Grav::instance()['locator'];
  139. if ($locator->isStream($path)) {
  140. $path = $locator->findResource($path, true);
  141. } elseif (!Utils::startsWith($path, GRAV_ROOT)) {
  142. $base_url = Grav::instance()['base_url'];
  143. $path = GRAV_ROOT . '/' . ltrim(Utils::replaceFirstOccurrence($base_url, '', $path), '/');
  144. }
  145. return $path;
  146. }
  147. /**
  148. * Check if the $haystack string starts with the substring $needle
  149. *
  150. * @param string $haystack
  151. * @param string|string[] $needle
  152. * @param bool $case_sensitive
  153. * @return bool
  154. */
  155. public static function startsWith($haystack, $needle, $case_sensitive = true)
  156. {
  157. $status = false;
  158. $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos';
  159. foreach ((array)$needle as $each_needle) {
  160. $status = $each_needle === '' || $compare_func($haystack, $each_needle) === 0;
  161. if ($status) {
  162. break;
  163. }
  164. }
  165. return $status;
  166. }
  167. /**
  168. * Check if the $haystack string ends with the substring $needle
  169. *
  170. * @param string $haystack
  171. * @param string|string[] $needle
  172. * @param bool $case_sensitive
  173. * @return bool
  174. */
  175. public static function endsWith($haystack, $needle, $case_sensitive = true)
  176. {
  177. $status = false;
  178. $compare_func = $case_sensitive ? 'mb_strrpos' : 'mb_strripos';
  179. foreach ((array)$needle as $each_needle) {
  180. $expectedPosition = mb_strlen($haystack) - mb_strlen($each_needle);
  181. $status = $each_needle === '' || $compare_func($haystack, $each_needle, 0) === $expectedPosition;
  182. if ($status) {
  183. break;
  184. }
  185. }
  186. return $status;
  187. }
  188. /**
  189. * Check if the $haystack string contains the substring $needle
  190. *
  191. * @param string $haystack
  192. * @param string|string[] $needle
  193. * @param bool $case_sensitive
  194. * @return bool
  195. */
  196. public static function contains($haystack, $needle, $case_sensitive = true)
  197. {
  198. $status = false;
  199. $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos';
  200. foreach ((array)$needle as $each_needle) {
  201. $status = $each_needle === '' || $compare_func($haystack, $each_needle) !== false;
  202. if ($status) {
  203. break;
  204. }
  205. }
  206. return $status;
  207. }
  208. /**
  209. * Function that can match wildcards
  210. *
  211. * match_wildcard('foo*', $test), // TRUE
  212. * match_wildcard('bar*', $test), // FALSE
  213. * match_wildcard('*bar*', $test), // TRUE
  214. * match_wildcard('**blob**', $test), // TRUE
  215. * match_wildcard('*a?d*', $test), // TRUE
  216. * match_wildcard('*etc**', $test) // TRUE
  217. *
  218. * @param string $wildcard_pattern
  219. * @param string $haystack
  220. * @return false|int
  221. */
  222. public static function matchWildcard($wildcard_pattern, $haystack)
  223. {
  224. $regex = str_replace(
  225. array("\*", "\?"), // wildcard chars
  226. array('.*', '.'), // regexp chars
  227. preg_quote($wildcard_pattern, '/')
  228. );
  229. return preg_match('/^' . $regex . '$/is', $haystack);
  230. }
  231. /**
  232. * Render simple template filling up the variables in it. If value is not defined, leave it as it was.
  233. *
  234. * @param string $template Template string
  235. * @param array $variables Variables with values
  236. * @param array $brackets Optional array of opening and closing brackets or symbols
  237. * @return string Final string filled with values
  238. */
  239. public static function simpleTemplate(string $template, array $variables, array $brackets = ['{', '}']): string
  240. {
  241. $opening = $brackets[0] ?? '{';
  242. $closing = $brackets[1] ?? '}';
  243. $expression = '/' . preg_quote($opening, '/') . '(.*?)' . preg_quote($closing, '/') . '/';
  244. $callback = static function ($match) use ($variables) {
  245. return $variables[$match[1]] ?? $match[0];
  246. };
  247. return preg_replace_callback($expression, $callback, $template);
  248. }
  249. /**
  250. * Returns the substring of a string up to a specified needle. if not found, return the whole haystack
  251. *
  252. * @param string $haystack
  253. * @param string $needle
  254. * @param bool $case_sensitive
  255. *
  256. * @return string
  257. */
  258. public static function substrToString($haystack, $needle, $case_sensitive = true)
  259. {
  260. $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos';
  261. if (static::contains($haystack, $needle, $case_sensitive)) {
  262. return mb_substr($haystack, 0, $compare_func($haystack, $needle, $case_sensitive));
  263. }
  264. return $haystack;
  265. }
  266. /**
  267. * Utility method to replace only the first occurrence in a string
  268. *
  269. * @param string $search
  270. * @param string $replace
  271. * @param string $subject
  272. *
  273. * @return string
  274. */
  275. public static function replaceFirstOccurrence($search, $replace, $subject)
  276. {
  277. if (!$search) {
  278. return $subject;
  279. }
  280. $pos = mb_strpos($subject, $search);
  281. if ($pos !== false) {
  282. $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search));
  283. }
  284. return $subject;
  285. }
  286. /**
  287. * Utility method to replace only the last occurrence in a string
  288. *
  289. * @param string $search
  290. * @param string $replace
  291. * @param string $subject
  292. * @return string
  293. */
  294. public static function replaceLastOccurrence($search, $replace, $subject)
  295. {
  296. $pos = strrpos($subject, $search);
  297. if ($pos !== false) {
  298. $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search));
  299. }
  300. return $subject;
  301. }
  302. /**
  303. * Multibyte compatible substr_replace
  304. *
  305. * @param string $original
  306. * @param string $replacement
  307. * @param int $position
  308. * @param int $length
  309. * @return string
  310. */
  311. public static function mb_substr_replace($original, $replacement, $position, $length)
  312. {
  313. $startString = mb_substr($original, 0, $position, 'UTF-8');
  314. $endString = mb_substr($original, $position + $length, mb_strlen($original), 'UTF-8');
  315. return $startString . $replacement . $endString;
  316. }
  317. /**
  318. * Merge two objects into one.
  319. *
  320. * @param object $obj1
  321. * @param object $obj2
  322. *
  323. * @return object
  324. */
  325. public static function mergeObjects($obj1, $obj2)
  326. {
  327. return (object)array_merge((array)$obj1, (array)$obj2);
  328. }
  329. /**
  330. * @param array $array
  331. * @return bool
  332. */
  333. public static function isAssoc(array $array)
  334. {
  335. return (array_values($array) !== $array);
  336. }
  337. /**
  338. * Lowercase an entire array. Useful when combined with `in_array()`
  339. *
  340. * @param array $a
  341. * @return array|false
  342. */
  343. public static function arrayLower(array $a)
  344. {
  345. return array_map('mb_strtolower', $a);
  346. }
  347. /**
  348. * Simple function to remove item/s in an array by value
  349. *
  350. * @param array $search
  351. * @param string|array $value
  352. * @return array
  353. */
  354. public static function arrayRemoveValue(array $search, $value)
  355. {
  356. foreach ((array)$value as $val) {
  357. $key = array_search($val, $search);
  358. if ($key !== false) {
  359. unset($search[$key]);
  360. }
  361. }
  362. return $search;
  363. }
  364. /**
  365. * Recursive Merge with uniqueness
  366. *
  367. * @param array $array1
  368. * @param array $array2
  369. * @return array
  370. */
  371. public static function arrayMergeRecursiveUnique($array1, $array2)
  372. {
  373. if (empty($array1)) {
  374. // Optimize the base case
  375. return $array2;
  376. }
  377. foreach ($array2 as $key => $value) {
  378. if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) {
  379. $value = static::arrayMergeRecursiveUnique($array1[$key], $value);
  380. }
  381. $array1[$key] = $value;
  382. }
  383. return $array1;
  384. }
  385. /**
  386. * Returns an array with the differences between $array1 and $array2
  387. *
  388. * @param array $array1
  389. * @param array $array2
  390. * @return array
  391. */
  392. public static function arrayDiffMultidimensional($array1, $array2)
  393. {
  394. $result = array();
  395. foreach ($array1 as $key => $value) {
  396. if (!is_array($array2) || !array_key_exists($key, $array2)) {
  397. $result[$key] = $value;
  398. continue;
  399. }
  400. if (is_array($value)) {
  401. $recursiveArrayDiff = static::ArrayDiffMultidimensional($value, $array2[$key]);
  402. if (count($recursiveArrayDiff)) {
  403. $result[$key] = $recursiveArrayDiff;
  404. }
  405. continue;
  406. }
  407. if ($value != $array2[$key]) {
  408. $result[$key] = $value;
  409. }
  410. }
  411. return $result;
  412. }
  413. /**
  414. * Array combine but supports different array lengths
  415. *
  416. * @param array $arr1
  417. * @param array $arr2
  418. * @return array|false
  419. */
  420. public static function arrayCombine($arr1, $arr2)
  421. {
  422. $count = min(count($arr1), count($arr2));
  423. return array_combine(array_slice($arr1, 0, $count), array_slice($arr2, 0, $count));
  424. }
  425. /**
  426. * Array is associative or not
  427. *
  428. * @param array $arr
  429. * @return bool
  430. */
  431. public static function arrayIsAssociative($arr)
  432. {
  433. if ([] === $arr) {
  434. return false;
  435. }
  436. return array_keys($arr) !== range(0, count($arr) - 1);
  437. }
  438. /**
  439. * Return the Grav date formats allowed
  440. *
  441. * @return array
  442. */
  443. public static function dateFormats()
  444. {
  445. $now = new DateTime();
  446. $date_formats = [
  447. 'd-m-Y H:i' => 'd-m-Y H:i (e.g. ' . $now->format('d-m-Y H:i') . ')',
  448. 'Y-m-d H:i' => 'Y-m-d H:i (e.g. ' . $now->format('Y-m-d H:i') . ')',
  449. 'm/d/Y h:i a' => 'm/d/Y h:i a (e.g. ' . $now->format('m/d/Y h:i a') . ')',
  450. 'H:i d-m-Y' => 'H:i d-m-Y (e.g. ' . $now->format('H:i d-m-Y') . ')',
  451. 'h:i a m/d/Y' => 'h:i a m/d/Y (e.g. ' . $now->format('h:i a m/d/Y') . ')',
  452. ];
  453. $default_format = Grav::instance()['config']->get('system.pages.dateformat.default');
  454. if ($default_format) {
  455. $date_formats = array_merge([$default_format => $default_format . ' (e.g. ' . $now->format($default_format) . ')'], $date_formats);
  456. }
  457. return $date_formats;
  458. }
  459. /**
  460. * Get current date/time
  461. *
  462. * @param string|null $default_format
  463. * @return string
  464. * @throws Exception
  465. */
  466. public static function dateNow($default_format = null)
  467. {
  468. $now = new DateTime();
  469. if (null === $default_format) {
  470. $default_format = Grav::instance()['config']->get('system.pages.dateformat.default');
  471. }
  472. return $now->format($default_format);
  473. }
  474. /**
  475. * Truncate text by number of characters but can cut off words.
  476. *
  477. * @param string $string
  478. * @param int $limit Max number of characters.
  479. * @param bool $up_to_break truncate up to breakpoint after char count
  480. * @param string $break Break point.
  481. * @param string $pad Appended padding to the end of the string.
  482. * @return string
  483. */
  484. public static function truncate($string, $limit = 150, $up_to_break = false, $break = ' ', $pad = '&hellip;')
  485. {
  486. // return with no change if string is shorter than $limit
  487. if (mb_strlen($string) <= $limit) {
  488. return $string;
  489. }
  490. // is $break present between $limit and the end of the string?
  491. if ($up_to_break && false !== ($breakpoint = mb_strpos($string, $break, $limit))) {
  492. if ($breakpoint < mb_strlen($string) - 1) {
  493. $string = mb_substr($string, 0, $breakpoint) . $pad;
  494. }
  495. } else {
  496. $string = mb_substr($string, 0, $limit) . $pad;
  497. }
  498. return $string;
  499. }
  500. /**
  501. * Truncate text by number of characters in a "word-safe" manor.
  502. *
  503. * @param string $string
  504. * @param int $limit
  505. * @return string
  506. */
  507. public static function safeTruncate($string, $limit = 150)
  508. {
  509. return static::truncate($string, $limit, true);
  510. }
  511. /**
  512. * Truncate HTML by number of characters. not "word-safe"!
  513. *
  514. * @param string $text
  515. * @param int $length in characters
  516. * @param string $ellipsis
  517. * @return string
  518. */
  519. public static function truncateHtml($text, $length = 100, $ellipsis = '...')
  520. {
  521. return Truncator::truncateLetters($text, $length, $ellipsis);
  522. }
  523. /**
  524. * Truncate HTML by number of characters in a "word-safe" manor.
  525. *
  526. * @param string $text
  527. * @param int $length in words
  528. * @param string $ellipsis
  529. * @return string
  530. */
  531. public static function safeTruncateHtml($text, $length = 25, $ellipsis = '...')
  532. {
  533. return Truncator::truncateWords($text, $length, $ellipsis);
  534. }
  535. /**
  536. * Generate a random string of a given length
  537. *
  538. * @param int $length
  539. * @return string
  540. */
  541. public static function generateRandomString($length = 5)
  542. {
  543. return substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length);
  544. }
  545. /**
  546. * Provides the ability to download a file to the browser
  547. *
  548. * @param string $file the full path to the file to be downloaded
  549. * @param bool $force_download as opposed to letting browser choose if to download or render
  550. * @param int $sec Throttling, try 0.1 for some speed throttling of downloads
  551. * @param int $bytes Size of chunks to send in bytes. Default is 1024
  552. * @throws Exception
  553. */
  554. public static function download($file, $force_download = true, $sec = 0, $bytes = 1024)
  555. {
  556. if (file_exists($file)) {
  557. // fire download event
  558. Grav::instance()->fireEvent('onBeforeDownload', new Event(['file' => $file]));
  559. $file_parts = pathinfo($file);
  560. $mimetype = static::getMimeByExtension($file_parts['extension']);
  561. $size = filesize($file); // File size
  562. // clean all buffers
  563. while (ob_get_level()) {
  564. ob_end_clean();
  565. }
  566. // required for IE, otherwise Content-Disposition may be ignored
  567. if (ini_get('zlib.output_compression')) {
  568. ini_set('zlib.output_compression', 'Off');
  569. }
  570. header('Content-Type: ' . $mimetype);
  571. header('Accept-Ranges: bytes');
  572. if ($force_download) {
  573. // output the regular HTTP headers
  574. header('Content-Disposition: attachment; filename="' . $file_parts['basename'] . '"');
  575. }
  576. // multipart-download and download resuming support
  577. if (isset($_SERVER['HTTP_RANGE'])) {
  578. [$a, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);
  579. [$range] = explode(',', $range, 2);
  580. [$range, $range_end] = explode('-', $range);
  581. $range = (int)$range;
  582. if (!$range_end) {
  583. $range_end = $size - 1;
  584. } else {
  585. $range_end = (int)$range_end;
  586. }
  587. $new_length = $range_end - $range + 1;
  588. header('HTTP/1.1 206 Partial Content');
  589. header("Content-Length: {$new_length}");
  590. header("Content-Range: bytes {$range}-{$range_end}/{$size}");
  591. } else {
  592. $range = 0;
  593. $new_length = $size;
  594. header('Content-Length: ' . $size);
  595. if (Grav::instance()['config']->get('system.cache.enabled')) {
  596. $expires = Grav::instance()['config']->get('system.pages.expires');
  597. if ($expires > 0) {
  598. $expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
  599. header('Cache-Control: max-age=' . $expires);
  600. header('Expires: ' . $expires_date);
  601. header('Pragma: cache');
  602. }
  603. header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
  604. // Return 304 Not Modified if the file is already cached in the browser
  605. if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
  606. strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {
  607. header('HTTP/1.1 304 Not Modified');
  608. exit();
  609. }
  610. }
  611. }
  612. /* output the file itself */
  613. $chunksize = $bytes * 8; //you may want to change this
  614. $bytes_send = 0;
  615. $fp = @fopen($file, 'rb');
  616. if ($fp) {
  617. if ($range) {
  618. fseek($fp, $range);
  619. }
  620. while (!feof($fp) && (!connection_aborted()) && ($bytes_send < $new_length)) {
  621. $buffer = fread($fp, $chunksize);
  622. echo($buffer); //echo($buffer); // is also possible
  623. flush();
  624. usleep($sec * 1000000);
  625. $bytes_send += strlen($buffer);
  626. }
  627. fclose($fp);
  628. } else {
  629. throw new RuntimeException('Error - can not open file.');
  630. }
  631. exit;
  632. }
  633. }
  634. /**
  635. * Returns the output render format, usually the extension provided in the URL. (e.g. `html`, `json`, `xml`, etc).
  636. *
  637. * @return string
  638. */
  639. public static function getPageFormat(): string
  640. {
  641. /** @var Uri $uri */
  642. $uri = Grav::instance()['uri'];
  643. // Set from uri extension
  644. $uri_extension = $uri->extension();
  645. if (is_string($uri_extension) && $uri->isValidExtension($uri_extension)) {
  646. return ($uri_extension);
  647. }
  648. // Use content negotiation via the `accept:` header
  649. $http_accept = $_SERVER['HTTP_ACCEPT'] ?? null;
  650. if (is_string($http_accept)) {
  651. $negotiator = new Negotiator();
  652. $supported_types = Utils::getSupportPageTypes(['html', 'json']);
  653. $priorities = Utils::getMimeTypes($supported_types);
  654. $media_type = $negotiator->getBest($http_accept, $priorities);
  655. $mimetype = $media_type instanceof Accept ? $media_type->getValue() : '';
  656. return Utils::getExtensionByMime($mimetype);
  657. }
  658. return 'html';
  659. }
  660. /**
  661. * Return the mimetype based on filename extension
  662. *
  663. * @param string $extension Extension of file (eg "txt")
  664. * @param string $default
  665. * @return string
  666. */
  667. public static function getMimeByExtension($extension, $default = 'application/octet-stream')
  668. {
  669. $extension = strtolower($extension);
  670. // look for some standard types
  671. switch ($extension) {
  672. case null:
  673. return $default;
  674. case 'json':
  675. return 'application/json';
  676. case 'html':
  677. return 'text/html';
  678. case 'atom':
  679. return 'application/atom+xml';
  680. case 'rss':
  681. return 'application/rss+xml';
  682. case 'xml':
  683. return 'application/xml';
  684. }
  685. $media_types = Grav::instance()['config']->get('media.types');
  686. if (isset($media_types[$extension])) {
  687. if (isset($media_types[$extension]['mime'])) {
  688. return $media_types[$extension]['mime'];
  689. }
  690. }
  691. return $default;
  692. }
  693. /**
  694. * Get all the mimetypes for an array of extensions
  695. *
  696. * @param array $extensions
  697. * @return array
  698. */
  699. public static function getMimeTypes(array $extensions)
  700. {
  701. $mimetypes = [];
  702. foreach ($extensions as $extension) {
  703. $mimetype = static::getMimeByExtension($extension, false);
  704. if ($mimetype && !in_array($mimetype, $mimetypes)) {
  705. $mimetypes[] = $mimetype;
  706. }
  707. }
  708. return $mimetypes;
  709. }
  710. /**
  711. * Return the mimetype based on filename extension
  712. *
  713. * @param string $mime mime type (eg "text/html")
  714. * @param string $default default value
  715. * @return string
  716. */
  717. public static function getExtensionByMime($mime, $default = 'html')
  718. {
  719. $mime = strtolower($mime);
  720. // look for some standard mime types
  721. switch ($mime) {
  722. case '*/*':
  723. case 'text/*':
  724. case 'text/html':
  725. return 'html';
  726. case 'application/json':
  727. return 'json';
  728. case 'application/atom+xml':
  729. return 'atom';
  730. case 'application/rss+xml':
  731. return 'rss';
  732. case 'application/xml':
  733. return 'xml';
  734. }
  735. $media_types = (array)Grav::instance()['config']->get('media.types');
  736. foreach ($media_types as $extension => $type) {
  737. if ($extension === 'defaults') {
  738. continue;
  739. }
  740. if (isset($type['mime']) && $type['mime'] === $mime) {
  741. return $extension;
  742. }
  743. }
  744. return $default;
  745. }
  746. /**
  747. * Get all the extensions for an array of mimetypes
  748. *
  749. * @param array $mimetypes
  750. * @return array
  751. */
  752. public static function getExtensions(array $mimetypes)
  753. {
  754. $extensions = [];
  755. foreach ($mimetypes as $mimetype) {
  756. $extension = static::getExtensionByMime($mimetype, false);
  757. if ($extension && !in_array($extension, $extensions, true)) {
  758. $extensions[] = $extension;
  759. }
  760. }
  761. return $extensions;
  762. }
  763. /**
  764. * Return the mimetype based on filename
  765. *
  766. * @param string $filename Filename or path to file
  767. * @param string $default default value
  768. * @return string
  769. */
  770. public static function getMimeByFilename($filename, $default = 'application/octet-stream')
  771. {
  772. return static::getMimeByExtension(pathinfo($filename, PATHINFO_EXTENSION), $default);
  773. }
  774. /**
  775. * Return the mimetype based on existing local file
  776. *
  777. * @param string $filename Path to the file
  778. * @param string $default
  779. * @return string|bool
  780. */
  781. public static function getMimeByLocalFile($filename, $default = 'application/octet-stream')
  782. {
  783. $type = false;
  784. // For local files we can detect type by the file content.
  785. if (!stream_is_local($filename) || !file_exists($filename)) {
  786. return false;
  787. }
  788. // Prefer using finfo if it exists.
  789. if (extension_loaded('fileinfo')) {
  790. $finfo = finfo_open(FILEINFO_SYMLINK | FILEINFO_MIME_TYPE);
  791. $type = finfo_file($finfo, $filename);
  792. finfo_close($finfo);
  793. } else {
  794. // Fall back to use getimagesize() if it is available (not recommended, but better than nothing)
  795. $info = @getimagesize($filename);
  796. if ($info) {
  797. $type = $info['mime'];
  798. }
  799. }
  800. return $type ?: static::getMimeByFilename($filename, $default);
  801. }
  802. /**
  803. * Returns true if filename is considered safe.
  804. *
  805. * @param string $filename
  806. * @return bool
  807. */
  808. public static function checkFilename($filename)
  809. {
  810. $dangerous_extensions = Grav::instance()['config']->get('security.uploads_dangerous_extensions', []);
  811. $extension = pathinfo($filename, PATHINFO_EXTENSION);
  812. return !(
  813. // Empty filenames are not allowed.
  814. !$filename
  815. // Filename should not contain horizontal/vertical tabs, newlines, nils or back/forward slashes.
  816. || strtr($filename, "\t\v\n\r\0\\/", '_______') !== $filename
  817. // Filename should not start or end with dot or space.
  818. || trim($filename, '. ') !== $filename
  819. // File extension should not be part of configured dangerous extensions
  820. || in_array($extension, $dangerous_extensions)
  821. );
  822. }
  823. /**
  824. * Normalize path by processing relative `.` and `..` syntax and merging path
  825. *
  826. * @param string $path
  827. * @return string
  828. */
  829. public static function normalizePath($path)
  830. {
  831. // Resolve any streams
  832. /** @var UniformResourceLocator $locator */
  833. $locator = Grav::instance()['locator'];
  834. if ($locator->isStream($path)) {
  835. $path = $locator->findResource($path);
  836. }
  837. // Set root properly for any URLs
  838. $root = '';
  839. preg_match(self::ROOTURL_REGEX, $path, $matches);
  840. if ($matches) {
  841. $root = $matches[1];
  842. $path = $matches[2];
  843. }
  844. // Strip off leading / to ensure explode is accurate
  845. if (static::startsWith($path, '/')) {
  846. $root .= '/';
  847. $path = ltrim($path, '/');
  848. }
  849. // If there are any relative paths (..) handle those
  850. if (static::contains($path, '..')) {
  851. $segments = explode('/', trim($path, '/'));
  852. $ret = [];
  853. foreach ($segments as $segment) {
  854. if (($segment === '.') || $segment === '') {
  855. continue;
  856. }
  857. if ($segment === '..') {
  858. array_pop($ret);
  859. } else {
  860. $ret[] = $segment;
  861. }
  862. }
  863. $path = implode('/', $ret);
  864. }
  865. // Stick everything back together
  866. $normalized = $root . $path;
  867. return $normalized;
  868. }
  869. /**
  870. * Check whether a function exists.
  871. *
  872. * Disabled functions count as non-existing functions, just like in PHP 8+.
  873. *
  874. * @param string $function the name of the function to check
  875. * @return bool
  876. */
  877. public static function functionExists($function): bool
  878. {
  879. if (!function_exists($function)) {
  880. return false;
  881. }
  882. // In PHP 7 we need to also exclude disabled methods.
  883. return !static::isFunctionDisabled($function);
  884. }
  885. /**
  886. * Check whether a function is disabled in the PHP settings
  887. *
  888. * @param string $function the name of the function to check
  889. * @return bool
  890. */
  891. public static function isFunctionDisabled($function): bool
  892. {
  893. static $list;
  894. if (null === $list) {
  895. $str = trim(ini_get('disable_functions') . ',' . ini_get('suhosin.executor.func.blacklist'), ',');
  896. $list = $str ? array_flip(preg_split('/\s*,\s*/', $str)) : [];
  897. }
  898. return array_key_exists($function, $list);
  899. }
  900. /**
  901. * Get the formatted timezones list
  902. *
  903. * @return array
  904. */
  905. public static function timezones()
  906. {
  907. $timezones = DateTimeZone::listIdentifiers(DateTimeZone::ALL);
  908. $offsets = [];
  909. $testDate = new DateTime();
  910. foreach ($timezones as $zone) {
  911. $tz = new DateTimeZone($zone);
  912. $offsets[$zone] = $tz->getOffset($testDate);
  913. }
  914. asort($offsets);
  915. $timezone_list = [];
  916. foreach ($offsets as $timezone => $offset) {
  917. $offset_prefix = $offset < 0 ? '-' : '+';
  918. $offset_formatted = gmdate('H:i', abs($offset));
  919. $pretty_offset = "UTC${offset_prefix}${offset_formatted}";
  920. $timezone_list[$timezone] = "(${pretty_offset}) " . str_replace('_', ' ', $timezone);
  921. }
  922. return $timezone_list;
  923. }
  924. /**
  925. * Recursively filter an array, filtering values by processing them through the $fn function argument
  926. *
  927. * @param array $source the Array to filter
  928. * @param callable $fn the function to pass through each array item
  929. * @return array
  930. */
  931. public static function arrayFilterRecursive(array $source, $fn)
  932. {
  933. $result = [];
  934. foreach ($source as $key => $value) {
  935. if (is_array($value)) {
  936. $result[$key] = static::arrayFilterRecursive($value, $fn);
  937. continue;
  938. }
  939. if ($fn($key, $value)) {
  940. $result[$key] = $value; // KEEP
  941. continue;
  942. }
  943. }
  944. return $result;
  945. }
  946. /**
  947. * Flatten a multi-dimensional associative array into query params.
  948. *
  949. * @param array $array
  950. * @param string $prepend
  951. * @return array
  952. */
  953. public static function arrayToQueryParams($array, $prepend = '')
  954. {
  955. $results = [];
  956. foreach ($array as $key => $value) {
  957. $name = $prepend ? $prepend . '[' . $key . ']' : $key;
  958. if (is_array($value)) {
  959. $results = array_merge($results, static::arrayToQueryParams($value, $name));
  960. } else {
  961. $results[$name] = $value;
  962. }
  963. }
  964. return $results;
  965. }
  966. /**
  967. * Flatten an array
  968. *
  969. * @param array $array
  970. * @return array
  971. */
  972. public static function arrayFlatten($array)
  973. {
  974. $flatten = [];
  975. foreach ($array as $key => $inner) {
  976. if (is_array($inner)) {
  977. foreach ($inner as $inner_key => $value) {
  978. $flatten[$inner_key] = $value;
  979. }
  980. } else {
  981. $flatten[$key] = $inner;
  982. }
  983. }
  984. return $flatten;
  985. }
  986. /**
  987. * Flatten a multi-dimensional associative array into dot notation
  988. *
  989. * @param array $array
  990. * @param string $prepend
  991. * @return array
  992. */
  993. public static function arrayFlattenDotNotation($array, $prepend = '')
  994. {
  995. $results = array();
  996. foreach ($array as $key => $value) {
  997. if (is_array($value)) {
  998. $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend . $key . '.'));
  999. } else {
  1000. $results[$prepend . $key] = $value;
  1001. }
  1002. }
  1003. return $results;
  1004. }
  1005. /**
  1006. * Opposite of flatten, convert flat dot notation array to multi dimensional array.
  1007. *
  1008. * If any of the parent has a scalar value, all children get ignored:
  1009. *
  1010. * admin.pages=true
  1011. * admin.pages.read=true
  1012. *
  1013. * becomes
  1014. *
  1015. * admin:
  1016. * pages: true
  1017. *
  1018. * @param array $array
  1019. * @param string $separator
  1020. * @return array
  1021. */
  1022. public static function arrayUnflattenDotNotation($array, $separator = '.')
  1023. {
  1024. $newArray = [];
  1025. foreach ($array as $key => $value) {
  1026. $dots = explode($separator, $key);
  1027. if (count($dots) > 1) {
  1028. $last = &$newArray[$dots[0]];
  1029. foreach ($dots as $k => $dot) {
  1030. if ($k === 0) {
  1031. continue;
  1032. }
  1033. // Cannot use a scalar value as an array
  1034. if (null !== $last && !is_array($last)) {
  1035. continue 2;
  1036. }
  1037. $last = &$last[$dot];
  1038. }
  1039. // Cannot use a scalar value as an array
  1040. if (null !== $last && !is_array($last)) {
  1041. continue;
  1042. }
  1043. $last = $value;
  1044. } else {
  1045. $newArray[$key] = $value;
  1046. }
  1047. }
  1048. return $newArray;
  1049. }
  1050. /**
  1051. * Checks if the passed path contains the language code prefix
  1052. *
  1053. * @param string $string The path
  1054. *
  1055. * @return bool|string Either false or the language
  1056. *
  1057. */
  1058. public static function pathPrefixedByLangCode($string)
  1059. {
  1060. $languages_enabled = Grav::instance()['config']->get('system.languages.supported', []);
  1061. $parts = explode('/', trim($string, '/'));
  1062. if (count($parts) > 0 && in_array($parts[0], $languages_enabled)) {
  1063. return $parts[0];
  1064. }
  1065. return false;
  1066. }
  1067. /**
  1068. * Get the timestamp of a date
  1069. *
  1070. * @param string $date a String expressed in the system.pages.dateformat.default format, with fallback to a
  1071. * strtotime argument
  1072. * @param string|null $format a date format to use if possible
  1073. * @return int the timestamp
  1074. */
  1075. public static function date2timestamp($date, $format = null)
  1076. {
  1077. $config = Grav::instance()['config'];
  1078. $dateformat = $format ?: $config->get('system.pages.dateformat.default');
  1079. // try to use DateTime and default format
  1080. if ($dateformat) {
  1081. $datetime = DateTime::createFromFormat($dateformat, $date);
  1082. } else {
  1083. $datetime = new DateTime($date);
  1084. }
  1085. // fallback to strtotime() if DateTime approach failed
  1086. if ($datetime !== false) {
  1087. return $datetime->getTimestamp();
  1088. }
  1089. return strtotime($date);
  1090. }
  1091. /**
  1092. * @param array $array
  1093. * @param string $path
  1094. * @param null $default
  1095. * @return mixed
  1096. *
  1097. * @deprecated 1.5 Use ->getDotNotation() method instead.
  1098. */
  1099. public static function resolve(array $array, $path, $default = null)
  1100. {
  1101. user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getDotNotation() method instead', E_USER_DEPRECATED);
  1102. return static::getDotNotation($array, $path, $default);
  1103. }
  1104. /**
  1105. * Checks if a value is positive (true)
  1106. *
  1107. * @param string $value
  1108. * @return bool
  1109. */
  1110. public static function isPositive($value)
  1111. {
  1112. return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true);
  1113. }
  1114. /**
  1115. * Checks if a value is negative (false)
  1116. *
  1117. * @param string $value
  1118. * @return bool
  1119. */
  1120. public static function isNegative($value)
  1121. {
  1122. return in_array($value, [false, 0, '0', 'no', 'off', 'false'], true);
  1123. }
  1124. /**
  1125. * Generates a nonce string to be hashed. Called by self::getNonce()
  1126. * We removed the IP portion in this version because it causes too many inconsistencies
  1127. * with reverse proxy setups.
  1128. *
  1129. * @param string $action
  1130. * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours)
  1131. * @return string the nonce string
  1132. */
  1133. private static function generateNonceString($action, $previousTick = false)
  1134. {
  1135. $grav = Grav::instance();
  1136. $username = isset($grav['user']) ? $grav['user']->username : '';
  1137. $token = session_id();
  1138. $i = self::nonceTick();
  1139. if ($previousTick) {
  1140. $i--;
  1141. }
  1142. return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . $grav['config']->get('security.salt'));
  1143. }
  1144. /**
  1145. * Get the time-dependent variable for nonce creation.
  1146. *
  1147. * Now a tick lasts a day. Once the day is passed, the nonce is not valid any more. Find a better way
  1148. * to ensure nonces issued near the end of the day do not expire in that small amount of time
  1149. *
  1150. * @return int the time part of the nonce. Changes once every 24 hours
  1151. */
  1152. private static function nonceTick()
  1153. {
  1154. $secondsInHalfADay = 60 * 60 * 12;
  1155. return (int)ceil(time() / $secondsInHalfADay);
  1156. }
  1157. /**
  1158. * Creates a hashed nonce tied to the passed action. Tied to the current user and time. The nonce for a given
  1159. * action is the same for 12 hours.
  1160. *
  1161. * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage)
  1162. * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours)
  1163. * @return string the nonce
  1164. */
  1165. public static function getNonce($action, $previousTick = false)
  1166. {
  1167. // Don't regenerate this again if not needed
  1168. if (isset(static::$nonces[$action][$previousTick])) {
  1169. return static::$nonces[$action][$previousTick];
  1170. }
  1171. $nonce = md5(self::generateNonceString($action, $previousTick));
  1172. static::$nonces[$action][$previousTick] = $nonce;
  1173. return static::$nonces[$action][$previousTick];
  1174. }
  1175. /**
  1176. * Verify the passed nonce for the give action
  1177. *
  1178. * @param string|string[] $nonce the nonce to verify
  1179. * @param string $action the action to verify the nonce to
  1180. * @return boolean verified or not
  1181. */
  1182. public static function verifyNonce($nonce, $action)
  1183. {
  1184. //Safety check for multiple nonces
  1185. if (is_array($nonce)) {
  1186. $nonce = array_shift($nonce);
  1187. }
  1188. //Nonce generated 0-12 hours ago
  1189. if ($nonce === self::getNonce($action)) {
  1190. return true;
  1191. }
  1192. //Nonce generated 12-24 hours ago
  1193. return $nonce === self::getNonce($action, true);
  1194. }
  1195. /**
  1196. * Simple helper method to get whether or not the admin plugin is active
  1197. *
  1198. * @return bool
  1199. */
  1200. public static function isAdminPlugin()
  1201. {
  1202. return isset(Grav::instance()['admin']);
  1203. }
  1204. /**
  1205. * Get a portion of an array (passed by reference) with dot-notation key
  1206. *
  1207. * @param array $array
  1208. * @param string|int|null $key
  1209. * @param null $default
  1210. * @return mixed
  1211. */
  1212. public static function getDotNotation($array, $key, $default = null)
  1213. {
  1214. if (null === $key) {
  1215. return $array;
  1216. }
  1217. if (isset($array[$key])) {
  1218. return $array[$key];
  1219. }
  1220. foreach (explode('.', $key) as $segment) {
  1221. if (!is_array($array) || !array_key_exists($segment, $array)) {
  1222. return $default;
  1223. }
  1224. $array = $array[$segment];
  1225. }
  1226. return $array;
  1227. }
  1228. /**
  1229. * Set portion of array (passed by reference) for a dot-notation key
  1230. * and set the value
  1231. *
  1232. * @param array $array
  1233. * @param string|int|null $key
  1234. * @param mixed $value
  1235. * @param bool $merge
  1236. *
  1237. * @return mixed
  1238. */
  1239. public static function setDotNotation(&$array, $key, $value, $merge = false)
  1240. {
  1241. if (null === $key) {
  1242. return $array = $value;
  1243. }
  1244. $keys = explode('.', $key);
  1245. while (count($keys) > 1) {
  1246. $key = array_shift($keys);
  1247. if (!isset($array[$key]) || !is_array($array[$key])) {
  1248. $array[$key] = array();
  1249. }
  1250. $array =& $array[$key];
  1251. }
  1252. $key = array_shift($keys);
  1253. if (!$merge || !isset($array[$key])) {
  1254. $array[$key] = $value;
  1255. } else {
  1256. $array[$key] = array_merge($array[$key], $value);
  1257. }
  1258. return $array;
  1259. }
  1260. /**
  1261. * Utility method to determine if the current OS is Windows
  1262. *
  1263. * @return bool
  1264. */
  1265. public static function isWindows()
  1266. {
  1267. return strncasecmp(PHP_OS, 'WIN', 3) === 0;
  1268. }
  1269. /**
  1270. * Utility to determine if the server running PHP is Apache
  1271. *
  1272. * @return bool
  1273. */
  1274. public static function isApache()
  1275. {
  1276. return isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== false;
  1277. }
  1278. /**
  1279. * Sort a multidimensional array by another array of ordered keys
  1280. *
  1281. * @param array $array
  1282. * @param array $orderArray
  1283. * @return array
  1284. */
  1285. public static function sortArrayByArray(array $array, array $orderArray)
  1286. {
  1287. $ordered = [];
  1288. foreach ($orderArray as $key) {
  1289. if (array_key_exists($key, $array)) {
  1290. $ordered[$key] = $array[$key];
  1291. unset($array[$key]);
  1292. }
  1293. }
  1294. return $ordered + $array;
  1295. }
  1296. /**
  1297. * Sort an array by a key value in the array
  1298. *
  1299. * @param mixed $array
  1300. * @param string|int $array_key
  1301. * @param int $direction
  1302. * @param int $sort_flags
  1303. * @return array
  1304. */
  1305. public static function sortArrayByKey($array, $array_key, $direction = SORT_DESC, $sort_flags = SORT_REGULAR)
  1306. {
  1307. $output = [];
  1308. if (!is_array($array) || !$array) {
  1309. return $output;
  1310. }
  1311. foreach ($array as $key => $row) {
  1312. $output[$key] = $row[$array_key];
  1313. }
  1314. array_multisort($output, $direction, $sort_flags, $array);
  1315. return $array;
  1316. }
  1317. /**
  1318. * Get relative page path based on a token.
  1319. *
  1320. * @param string $path
  1321. * @param PageInterface|null $page
  1322. * @return string
  1323. * @throws RuntimeException
  1324. */
  1325. public static function getPagePathFromToken($path, PageInterface $page = null)
  1326. {
  1327. return static::getPathFromToken($path, $page);
  1328. }
  1329. /**
  1330. * Get relative path based on a token.
  1331. *
  1332. * Path supports following syntaxes:
  1333. *
  1334. * 'self@', 'self@/path'
  1335. * 'page@:/route', 'page@:/route/filename.ext'
  1336. * 'theme@:', 'theme@:/path'
  1337. *
  1338. * @param string $path
  1339. * @param FlexObjectInterface|PageInterface|null $object
  1340. * @return string
  1341. * @throws RuntimeException
  1342. */
  1343. public static function getPathFromToken($path, $object = null)
  1344. {
  1345. $matches = static::resolveTokenPath($path);
  1346. if (null === $matches) {
  1347. return $path;
  1348. }
  1349. $grav = Grav::instance();
  1350. switch ($matches[0]) {
  1351. case 'self':
  1352. if (null === $object) {
  1353. throw new RuntimeException(sprintf('Page not available for self@ reference: %s', $path));
  1354. }
  1355. if ($matches[2] === '') {
  1356. if ($object->exists()) {
  1357. $route = '/' . $matches[1];
  1358. if ($object instanceof PageInterface) {
  1359. return trim($object->relativePagePath() . $route, '/');
  1360. }
  1361. $folder = $object->getMediaFolder();
  1362. if ($folder) {
  1363. return trim($folder . $route, '/');
  1364. }
  1365. } else {
  1366. return '';
  1367. }
  1368. }
  1369. break;
  1370. case 'page':
  1371. if ($matches[1] === '') {
  1372. $route = '/' . $matches[2];
  1373. // Exclude filename from the page lookup.
  1374. if (pathinfo($route, PATHINFO_EXTENSION)) {
  1375. $basename = '/' . basename($route);
  1376. $route = \dirname($route);
  1377. } else {
  1378. $basename = '';
  1379. }
  1380. $key = trim($route === '/' ? $grav['config']->get('system.home.alias') : $route, '/');
  1381. if ($object instanceof PageObject) {
  1382. $object = $object->getFlexDirectory()->getObject($key);
  1383. } elseif (static::isAdminPlugin()) {
  1384. /** @var Flex|null $flex */
  1385. $flex = $grav['flex'] ?? null;
  1386. $object = $flex ? $flex->getObject($key, 'pages') : null;
  1387. } else {
  1388. /** @var Pages $pages */
  1389. $pages = $grav['pages'];
  1390. $object = $pages->find($route);
  1391. }
  1392. if ($object instanceof PageInterface) {
  1393. return trim($object->relativePagePath() . $basename, '/');
  1394. }
  1395. }
  1396. break;
  1397. case 'theme':
  1398. if ($matches[1] === '') {
  1399. $route = '/' . $matches[2];
  1400. $theme = $grav['locator']->findResource('theme://', false);
  1401. if (false !== $theme) {
  1402. return trim($theme . $route, '/');
  1403. }
  1404. }
  1405. break;
  1406. }
  1407. throw new RuntimeException(sprintf('Token path not found: %s', $path));
  1408. }
  1409. /**
  1410. * Returns [token, route, path] from '@token/route:/path'. Route and path are optional. If pattern does not match, return null.
  1411. *
  1412. * @param string $path
  1413. * @return string[]|null
  1414. */
  1415. private static function resolveTokenPath(string $path): ?array
  1416. {
  1417. if (strpos($path, '@') !== false) {
  1418. $regex = '/^(@\w+|\w+@|@\w+@)([^:]*)(.*)$/u';
  1419. if (preg_match($regex, $path, $matches)) {
  1420. return [
  1421. trim($matches[1], '@'),
  1422. trim($matches[2], '/'),
  1423. trim($matches[3], ':/')
  1424. ];
  1425. }
  1426. }
  1427. return null;
  1428. }
  1429. /**
  1430. * @return int
  1431. */
  1432. public static function getUploadLimit()
  1433. {
  1434. static $max_size = -1;
  1435. if ($max_size < 0) {
  1436. $post_max_size = static::parseSize(ini_get('post_max_size'));
  1437. if ($post_max_size > 0) {
  1438. $max_size = $post_max_size;
  1439. } else {
  1440. $max_size = 0;
  1441. }
  1442. $upload_max = static::parseSize(ini_get('upload_max_filesize'));
  1443. if ($upload_max > 0 && $upload_max < $max_size) {
  1444. $max_size = $upload_max;
  1445. }
  1446. }
  1447. return $max_size;
  1448. }
  1449. /**
  1450. * Convert bytes to the unit specified by the $to parameter.
  1451. *
  1452. * @param int $bytes The filesize in Bytes.
  1453. * @param string $to The unit type to convert to. Accepts K, M, or G for Kilobytes, Megabytes, or Gigabytes, respectively.
  1454. * @param int $decimal_places The number of decimal places to return.
  1455. * @return int Returns only the number of units, not the type letter. Returns 0 if the $to unit type is out of scope.
  1456. *
  1457. */
  1458. public static function convertSize($bytes, $to, $decimal_places = 1)
  1459. {
  1460. $formulas = array(
  1461. 'K' => number_format($bytes / 1024, $decimal_places),
  1462. 'M' => number_format($bytes / 1048576, $decimal_places),
  1463. 'G' => number_format($bytes / 1073741824, $decimal_places)
  1464. );
  1465. return $formulas[$to] ?? 0;
  1466. }
  1467. /**
  1468. * Return a pretty size based on bytes
  1469. *
  1470. * @param int $bytes
  1471. * @param int $precision
  1472. * @return string
  1473. */
  1474. public static function prettySize($bytes, $precision = 2)
  1475. {
  1476. $units = array('B', 'KB', 'MB', 'GB', 'TB');
  1477. $bytes = max($bytes, 0);
  1478. $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
  1479. $pow = min($pow, count($units) - 1);
  1480. // Uncomment one of the following alternatives
  1481. $bytes /= 1024 ** $pow;
  1482. // $bytes /= (1 << (10 * $pow));
  1483. return round($bytes, $precision) . ' ' . $units[$pow];
  1484. }
  1485. /**
  1486. * Parse a readable file size and return a value in bytes
  1487. *
  1488. * @param string|int|float $size
  1489. * @return int
  1490. */
  1491. public static function parseSize($size)
  1492. {
  1493. $unit = preg_replace('/[^bkmgtpezy]/i', '', $size);
  1494. $size = (float)preg_replace('/[^0-9\.]/', '', $size);
  1495. if ($unit) {
  1496. $size *= 1024 ** stripos('bkmgtpezy', $unit[0]);
  1497. }
  1498. return (int)abs(round($size));
  1499. }
  1500. /**
  1501. * Multibyte-safe Parse URL function
  1502. *
  1503. * @param string $url
  1504. * @return array
  1505. * @throws InvalidArgumentException
  1506. */
  1507. public static function multibyteParseUrl($url)
  1508. {
  1509. $enc_url = preg_replace_callback(
  1510. '%[^:/@?&=#]+%usD',
  1511. function ($matches) {
  1512. return urlencode($matches[0]);
  1513. },
  1514. $url
  1515. );
  1516. $parts = parse_url($enc_url);
  1517. if ($parts === false) {
  1518. throw new InvalidArgumentException('Malformed URL: ' . $url);
  1519. }
  1520. foreach ($parts as $name => $value) {
  1521. $parts[$name] = urldecode($value);
  1522. }
  1523. return $parts;
  1524. }
  1525. /**
  1526. * Process a string as markdown
  1527. *
  1528. * @param string $string
  1529. * @param bool $block Block or Line processing
  1530. * @param null $page
  1531. * @return string
  1532. * @throws Exception
  1533. */
  1534. public static function processMarkdown($string, $block = true, $page = null)
  1535. {
  1536. $grav = Grav::instance();
  1537. $page = $page ?? $grav['page'] ?? null;
  1538. $defaults = [
  1539. 'markdown' => $grav['config']->get('system.pages.markdown', []),
  1540. 'images' => $grav['config']->get('system.images', [])
  1541. ];
  1542. $extra = $defaults['markdown']['extra'] ?? false;
  1543. $excerpts = new Excerpts($page, $defaults);
  1544. // Initialize the preferred variant of Parsedown
  1545. if ($extra) {
  1546. $parsedown = new ParsedownExtra($excerpts);
  1547. } else {
  1548. $parsedown = new Parsedown($excerpts);
  1549. }
  1550. if ($block) {
  1551. $string = $parsedown->text($string);
  1552. } else {
  1553. $string = $parsedown->line($string);
  1554. }
  1555. return $string;
  1556. }
  1557. /**
  1558. * Find the subnet of an ip with CIDR prefix size
  1559. *
  1560. * @param string $ip
  1561. * @param int $prefix
  1562. * @return string
  1563. */
  1564. public static function getSubnet($ip, $prefix = 64)
  1565. {
  1566. if (!filter_var($ip, FILTER_VALIDATE_IP)) {
  1567. return $ip;
  1568. }
  1569. // Packed representation of IP
  1570. $ip = (string)inet_pton($ip);
  1571. // Maximum netmask length = same as packed address
  1572. $len = 8 * strlen($ip);
  1573. if ($prefix > $len) {
  1574. $prefix = $len;
  1575. }
  1576. $mask = str_repeat('f', $prefix >> 2);
  1577. switch ($prefix & 3) {
  1578. case 3:
  1579. $mask .= 'e';
  1580. break;
  1581. case 2:
  1582. $mask .= 'c';
  1583. break;
  1584. case 1:
  1585. $mask .= '8';
  1586. break;
  1587. }
  1588. $mask = str_pad($mask, $len >> 2, '0');
  1589. // Packed representation of netmask
  1590. $mask = pack('H*', $mask);
  1591. // Bitwise - Take all bits that are both 1 to generate subnet
  1592. $subnet = inet_ntop($ip & $mask);
  1593. return $subnet;
  1594. }
  1595. /**
  1596. * Wrapper to ensure html, htm in the front of the supported page types
  1597. *
  1598. * @param array|null $defaults
  1599. * @return array
  1600. */
  1601. public static function getSupportPageTypes(array $defaults = null)
  1602. {
  1603. $types = Grav::instance()['config']->get('system.pages.types', $defaults);
  1604. if (!is_array($types)) {
  1605. return [];
  1606. }
  1607. // remove html/htm
  1608. $types = static::arrayRemoveValue($types, ['html', 'htm']);
  1609. // put them back at the front
  1610. $types = array_merge(['html', 'htm'], $types);
  1611. return $types;
  1612. }
  1613. /**
  1614. * @param string $name
  1615. * @return bool
  1616. */
  1617. public static function isDangerousFunction(string $name): bool
  1618. {
  1619. static $commandExecutionFunctions = [
  1620. 'exec',
  1621. 'passthru',
  1622. 'system',
  1623. 'shell_exec',
  1624. 'popen',
  1625. 'proc_open',
  1626. 'pcntl_exec',
  1627. ];
  1628. static $codeExecutionFunctions = [
  1629. 'assert',
  1630. 'preg_replace',
  1631. 'create_function',
  1632. 'include',
  1633. 'include_once',
  1634. 'require',
  1635. 'require_once'
  1636. ];
  1637. static $callbackFunctions = [
  1638. 'ob_start' => 0,
  1639. 'array_diff_uassoc' => -1,
  1640. 'array_diff_ukey' => -1,
  1641. 'array_filter' => 1,
  1642. 'array_intersect_uassoc' => -1,
  1643. 'array_intersect_ukey' => -1,
  1644. 'array_map' => 0,
  1645. 'array_reduce' => 1,
  1646. 'array_udiff_assoc' => -1,
  1647. 'array_udiff_uassoc' => [-1, -2],
  1648. 'array_udiff' => -1,
  1649. 'array_uintersect_assoc' => -1,
  1650. 'array_uintersect_uassoc' => [-1, -2],
  1651. 'array_uintersect' => -1,
  1652. 'array_walk_recursive' => 1,
  1653. 'array_walk' => 1,
  1654. 'assert_options' => 1,
  1655. 'uasort' => 1,
  1656. 'uksort' => 1,
  1657. 'usort' => 1,
  1658. 'preg_replace_callback' => 1,
  1659. 'spl_autoload_register' => 0,
  1660. 'iterator_apply' => 1,
  1661. 'call_user_func' => 0,
  1662. 'call_user_func_array' => 0,
  1663. 'register_shutdown_function' => 0,
  1664. 'register_tick_function' => 0,
  1665. 'set_error_handler' => 0,
  1666. 'set_exception_handler' => 0,
  1667. 'session_set_save_handler' => [0, 1, 2, 3, 4, 5],
  1668. 'sqlite_create_aggregate' => [2, 3],
  1669. 'sqlite_create_function' => 2,
  1670. ];
  1671. static $informationDiscosureFunctions = [
  1672. 'phpinfo',
  1673. 'posix_mkfifo',
  1674. 'posix_getlogin',
  1675. 'posix_ttyname',
  1676. 'getenv',
  1677. 'get_current_user',
  1678. 'proc_get_status',
  1679. 'get_cfg_var',
  1680. 'disk_free_space',
  1681. 'disk_total_space',
  1682. 'diskfreespace',
  1683. 'getcwd',
  1684. 'getlastmo',
  1685. 'getmygid',
  1686. 'getmyinode',
  1687. 'getmypid',
  1688. 'getmyuid'
  1689. ];
  1690. static $otherFunctions = [
  1691. 'extract',
  1692. 'parse_str',
  1693. 'putenv',
  1694. 'ini_set',
  1695. 'mail',
  1696. 'header',
  1697. 'proc_nice',
  1698. 'proc_terminate',
  1699. 'proc_close',
  1700. 'pfsockopen',
  1701. 'fsockopen',
  1702. 'apache_child_terminate',
  1703. 'posix_kill',
  1704. 'posix_mkfifo',
  1705. 'posix_setpgid',
  1706. 'posix_setsid',
  1707. 'posix_setuid',
  1708. ];
  1709. if (in_array($name, $commandExecutionFunctions)) {
  1710. return true;
  1711. }
  1712. if (in_array($name, $codeExecutionFunctions)) {
  1713. return true;
  1714. }
  1715. if (isset($callbackFunctions[$name])) {
  1716. return true;
  1717. }
  1718. if (in_array($name, $informationDiscosureFunctions)) {
  1719. return true;
  1720. }
  1721. if (in_array($name, $otherFunctions)) {
  1722. return true;
  1723. }
  1724. return static::isFilesystemFunction($name);
  1725. }
  1726. /**
  1727. * @param string $name
  1728. * @return bool
  1729. */
  1730. public static function isFilesystemFunction(string $name): bool
  1731. {
  1732. static $fileWriteFunctions = [
  1733. 'fopen',
  1734. 'tmpfile',
  1735. 'bzopen',
  1736. 'gzopen',
  1737. // write to filesystem (partially in combination with reading)
  1738. 'chgrp',
  1739. 'chmod',
  1740. 'chown',
  1741. 'copy',
  1742. 'file_put_contents',
  1743. 'lchgrp',
  1744. 'lchown',
  1745. 'link',
  1746. 'mkdir',
  1747. 'move_uploaded_file',
  1748. 'rename',
  1749. 'rmdir',
  1750. 'symlink',
  1751. 'tempnam',
  1752. 'touch',
  1753. 'unlink',
  1754. 'imagepng',
  1755. 'imagewbmp',
  1756. 'image2wbmp',
  1757. 'imagejpeg',
  1758. 'imagexbm',
  1759. 'imagegif',
  1760. 'imagegd',
  1761. 'imagegd2',
  1762. 'iptcembed',
  1763. 'ftp_get',
  1764. 'ftp_nb_get',
  1765. ];
  1766. static $fileContentFunctions = [
  1767. 'file_get_contents',
  1768. 'file',
  1769. 'filegroup',
  1770. 'fileinode',
  1771. 'fileowner',
  1772. 'fileperms',
  1773. 'glob',
  1774. 'is_executable',
  1775. 'is_uploaded_file',
  1776. 'parse_ini_file',
  1777. 'readfile',
  1778. 'readlink',
  1779. 'realpath',
  1780. 'gzfile',
  1781. 'readgzfile',
  1782. 'stat',
  1783. 'imagecreatefromgif',
  1784. 'imagecreatefromjpeg',
  1785. 'imagecreatefrompng',
  1786. 'imagecreatefromwbmp',
  1787. 'imagecreatefromxbm',
  1788. 'imagecreatefromxpm',
  1789. 'ftp_put',
  1790. 'ftp_nb_put',
  1791. 'hash_update_file',
  1792. 'highlight_file',
  1793. 'show_source',
  1794. 'php_strip_whitespace',
  1795. ];
  1796. static $filesystemFunctions = [
  1797. // read from filesystem
  1798. 'file_exists',
  1799. 'fileatime',
  1800. 'filectime',
  1801. 'filemtime',
  1802. 'filesize',
  1803. 'filetype',
  1804. 'is_dir',
  1805. 'is_file',
  1806. 'is_link',
  1807. 'is_readable',
  1808. 'is_writable',
  1809. 'is_writeable',
  1810. 'linkinfo',
  1811. 'lstat',
  1812. //'pathinfo',
  1813. 'getimagesize',
  1814. 'exif_read_data',
  1815. 'read_exif_data',
  1816. 'exif_thumbnail',
  1817. 'exif_imagetype',
  1818. 'hash_file',
  1819. 'hash_hmac_file',
  1820. 'md5_file',
  1821. 'sha1_file',
  1822. 'get_meta_tags',
  1823. ];
  1824. if (in_array($name, $fileWriteFunctions)) {
  1825. return true;
  1826. }
  1827. if (in_array($name, $fileContentFunctions)) {
  1828. return true;
  1829. }
  1830. if (in_array($name, $filesystemFunctions)) {
  1831. return true;
  1832. }
  1833. return false;
  1834. }
  1835. }