diff options
Diffstat (limited to 'inc')
-rw-r--r-- | inc/arrayutil.inc.php | 66 | ||||
-rw-r--r-- | inc/crypto.inc.php | 18 | ||||
-rw-r--r-- | inc/dashboard.inc.php | 35 | ||||
-rw-r--r-- | inc/database.inc.php | 155 | ||||
-rw-r--r-- | inc/dictionary.inc.php | 101 | ||||
-rw-r--r-- | inc/download.inc.php | 76 | ||||
-rw-r--r-- | inc/errorhandler.inc.php | 153 | ||||
-rw-r--r-- | inc/event.inc.php | 16 | ||||
-rw-r--r-- | inc/eventlog.inc.php | 61 | ||||
-rw-r--r-- | inc/fileutil.inc.php | 18 | ||||
-rw-r--r-- | inc/hook.inc.php | 19 | ||||
-rw-r--r-- | inc/iputil.inc.php | 41 | ||||
-rw-r--r-- | inc/mailer.inc.php | 186 | ||||
-rw-r--r-- | inc/message.inc.php | 47 | ||||
-rw-r--r-- | inc/module.inc.php | 69 | ||||
-rw-r--r-- | inc/paginate.inc.php | 39 | ||||
-rw-r--r-- | inc/permission.inc.php | 20 | ||||
-rw-r--r-- | inc/property.inc.php | 159 | ||||
-rw-r--r-- | inc/render.inc.php | 63 | ||||
-rw-r--r-- | inc/request.inc.php | 16 | ||||
-rw-r--r-- | inc/session.inc.php | 178 | ||||
-rw-r--r-- | inc/taskmanager.inc.php | 38 | ||||
-rw-r--r-- | inc/taskmanagercallback.inc.php | 61 | ||||
-rw-r--r-- | inc/trigger.inc.php | 56 | ||||
-rw-r--r-- | inc/user.inc.php | 68 | ||||
-rw-r--r-- | inc/util.inc.php | 298 |
26 files changed, 1284 insertions, 773 deletions
diff --git a/inc/arrayutil.inc.php b/inc/arrayutil.inc.php index 490b5a4f..3d93d7d5 100644 --- a/inc/arrayutil.inc.php +++ b/inc/arrayutil.inc.php @@ -1,33 +1,24 @@ <?php +declare(strict_types=1); + class ArrayUtil { /** * Take an array of arrays, take given key from each sub-array and return * new array with just those corresponding values. - * @param array $list - * @param string $key - * @return array */ - public static function flattenByKey(array $list, string $key) + public static function flattenByKey(array $list, string $key): array { - $ret = []; - foreach ($list as $item) { - if (array_key_exists($key, $item)) { - $ret[] = $item[$key]; - } - } - return $ret; + return array_column($list, $key); } /** * Pass an array of arrays you want to merge. The keys of the outer array will become * the inner keys of the resulting array, and vice versa. - * @param array $arrays - * @return array */ - public static function mergeByKey(array $arrays) + public static function mergeByKey(array $arrays): array { $empty = array_combine(array_keys($arrays), array_fill(0, count($arrays), false)); $out = []; @@ -42,4 +33,51 @@ class ArrayUtil return $out; } + /** + * Sort array by given column. + */ + public static function sortByColumn(array &$array, string $column, int $sortOrder = SORT_ASC, int $sortFlags = SORT_REGULAR): void + { + $sorter = array_column($array, $column); + array_multisort($sorter, $sortOrder, $sortFlags, $array); + } + + /** + * Check whether $array contains all keys given in $keyList + * + * @param array $array An array + * @param array $keyList A list of strings which must all be valid keys in $array + */ + public static function hasAllKeys(array $array, array $keyList): bool + { + foreach ($keyList as $key) { + if (!isset($array[$key])) + return false; + } + return true; + } + + /** + * Check if all elements in given array are primitive types, + * i.e. not object, array or resource. + */ + public static function isOnlyPrimitiveTypes(array $array): bool + { + foreach ($array as $item) { + if (is_array($item) || is_object($item) || is_resource($item)) + return false; + } + return true; + } + + /** + * Force each element of given array to be of type $type. + */ + public static function forceType(array &$array, string $type): void + { + foreach ($array as &$elem) { + settype($elem, $type); + } + } + }
\ No newline at end of file diff --git a/inc/crypto.inc.php b/inc/crypto.inc.php index 56f5073c..acefcf67 100644 --- a/inc/crypto.inc.php +++ b/inc/crypto.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + class Crypto { @@ -8,19 +10,25 @@ class Crypto * which translates to ~130 bit salt * and 5000 rounds of hashing with SHA-512. */ - public static function hash6($password) + public static function hash6(string $password): string { - $salt = substr(str_replace('+', '.', base64_encode(pack('N4', mt_rand(), mt_rand(), mt_rand(), mt_rand()))), 0, 16); + $bytes = Util::randomBytes(16); + if ($bytes === null) + ErrorHandler::traceError('Could not get random bytes'); + $salt = substr(str_replace('+', '.', + base64_encode($bytes)), 0, 16); $hash = crypt($password, '$6$' . $salt); - if (strlen($hash) < 60) Util::traceError('Error hashing password using SHA-512'); + if ($hash === null || strlen($hash) < 60) { + ErrorHandler::traceError('Error hashing password using SHA-512'); + } return $hash; } /** - * Check if the given password matches the given cryp hash. + * Check if the given password matches the given crypt hash. * Useful for checking a hashed password. */ - public static function verify($password, $hash) + public static function verify(string $password, string $hash): bool { return crypt($password, $hash) === $hash; } diff --git a/inc/dashboard.inc.php b/inc/dashboard.inc.php index 0b0f69e3..449fe7c0 100644 --- a/inc/dashboard.inc.php +++ b/inc/dashboard.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + class Dashboard { @@ -7,12 +9,12 @@ class Dashboard private static $subMenu = array(); private static $disabled = false; - public static function disable() + public static function disable(): void { self::$disabled = true; } - public static function createMenu() + public static function createMenu(): void { if (self::$disabled) return; @@ -22,7 +24,7 @@ class Dashboard if (isset($MENU_CAT_OVERRIDE)) { foreach ($MENU_CAT_OVERRIDE as $cat => $list) { foreach ($list as $mod) { - $modByCategory[$cat][$mod] = false; + $modByCategory[$cat][$mod] = null; $modById[$mod] =& $modByCategory[$cat][$mod]; } } @@ -30,10 +32,10 @@ class Dashboard $all = Module::getEnabled(true); foreach ($all as $module) { $cat = $module->getCategory(); - if ($cat === false) + if (empty($cat)) continue; $modId = $module->getIdentifier(); - if (isset($modById[$modId])) { + if (array_key_exists($modId, $modById)) { $modById[$modId] = $module; } else { $modByCategory[$cat][$modId] = $module; @@ -43,10 +45,10 @@ class Dashboard $categories = array(); foreach ($modByCategory as $catId => $modList) { $collapse = true; - /* @var Module[] $modList */ + /* @var (?Module)[] $modList */ $modules = array(); - foreach ($modList as $modId => $module) { - if ($module === false) + foreach ($modList as $module) { + if ($module === null) continue; // Was set in $MENU_CAT_OVERRIDE, but is not enabled $newEntry = array( 'displayName' => $module->getDisplayName(), @@ -62,28 +64,25 @@ class Dashboard } $modules[] = $newEntry; } - $categories[] = array( + $categories[] = [ 'icon' => self::getCategoryIcon($catId), 'displayName' => Dictionary::getCategoryName($catId), 'modules' => $modules, 'collapse' => $collapse, - ); + ]; } - Render::setDashboard(array( + Render::setDashboard([ 'categories' => $categories, 'url' => urlencode($_SERVER['REQUEST_URI']), 'langs' => Dictionary::getLanguages(true), 'user' => User::getName(), 'warning' => User::getName() !== false && User::hasPermission('.eventlog.*') && User::getLastSeenEvent() < Property::getLastWarningId(), 'needsSetup' => User::getName() !== false && Property::getNeedsSetup() - )); + ]); } - public static function getCategoryIcon($category) + public static function getCategoryIcon(string $category): string { - if ($category === false) { - return ''; - } if (!preg_match('/^(\w+)\.(.*)$/', $category, $out)) { error_log('Requested category icon for invalid category "' . $category . '"'); return ''; @@ -105,12 +104,12 @@ class Dashboard return 'glyphicon glyphicon-' . self::$iconCache[$module][$icon]; } - public static function addSubmenu($url, $name) + public static function addSubmenu(string $url, string $name): void { self::$subMenu[] = array('url' => $url, 'name' => $name); } - public static function getSubmenus() + public static function getSubmenus(): array { return self::$subMenu; } diff --git a/inc/database.inc.php b/inc/database.inc.php index eddd4faf..83720baa 100644 --- a/inc/database.inc.php +++ b/inc/database.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Handle communication with the database * This is a very thin layer between you and PDO. @@ -11,37 +13,38 @@ class Database * @var \PDO Database handle */ private static $dbh = false; - /* - * @var \PDOStatement[] - */ - private static $statements = array(); - private static $returnErrors; - private static $lastError = false; + + /** @var bool */ + private static $returnErrors = false; + /** @var ?string */ + private static $lastError = null; private static $explainList = array(); private static $queryCount = 0; + /** @var float */ private static $queryTime = 0; /** * Connect to the DB if not already connected. */ - public static function init($returnErrors = false) + public static function init(bool $returnErrors = false): bool { if (self::$dbh !== false) return true; self::$returnErrors = $returnErrors; try { - if (CONFIG_SQL_FORCE_UTF8) { - self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS, array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8")); - } else { - self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS); - } + self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS, [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', // Somehow needed, even if charset=utf8mb4 is in DSN? + ]); } catch (PDOException $e) { if (self::$returnErrors) return false; - Util::traceError('Connecting to the local database failed: ' . $e->getMessage()); + ErrorHandler::traceError('Connecting to the local database failed: ' . $e->getMessage()); } if (CONFIG_DEBUG) { - Database::exec("SET sql_mode='STRICT_TRANS_TABLES'"); + Database::exec("SET SESSION sql_mode='STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,ERROR_FOR_DIVISION_BY_ZERO'"); + Database::exec("SET SESSION innodb_strict_mode=ON"); register_shutdown_function(function() { self::examineLoggedQueries(); }); @@ -54,12 +57,12 @@ class Database * * @return array|boolean Associative array representing row, or false if no row matches the query */ - public static function queryFirst($query, $args = array(), $ignoreError = null) + public static function queryFirst(string $query, array $args = [], bool $ignoreError = null) { $res = self::simpleQuery($query, $args, $ignoreError); if ($res === false) return false; - return $res->fetch(PDO::FETCH_ASSOC); + return $res->fetch(); } /** @@ -69,12 +72,12 @@ class Database * * @return array|bool List of associative arrays representing rows, or false on error */ - public static function queryAll($query, $args = array(), $ignoreError = null) + public static function queryAll(string $query, array $args = [], bool $ignoreError = null) { $res = self::simpleQuery($query, $args, $ignoreError); if ($res === false) return false; - return $res->fetchAll(PDO::FETCH_ASSOC); + return $res->fetchAll(); } /** @@ -82,7 +85,7 @@ class Database * * @return array|bool List of values representing first column of query */ - public static function queryColumnArray($query, $args = array(), $ignoreError = null) + public static function queryColumnArray(string $query, array $args = [], bool $ignoreError = null) { $res = self::simpleQuery($query, $args, $ignoreError); if ($res === false) @@ -91,15 +94,63 @@ class Database } /** + * Fetch two columns as key => value list. + * + * @return array|bool Associative array, first column is key, second column is value + */ + public static function queryKeyValueList(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** + * Fetch and group by first column. First column is key, value is a list of rows with remaining columns. + * [ + * col1 => [ + * [col2, col3], + * [col2, col3], + * ], + * ..., + * ] + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryGroupList(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP); + } + + /** + * Fetch and use first column as key of returned array. + * This is like queryGroup list, but it is assumed that the first column is unique, so + * the remaining columns won't be wrapped in another array. + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryIndexedList(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE); + } + + /** * Execute the given query and return the number of rows affected. * Mostly useful for UPDATEs or INSERTs * * @param string $query Query to run * @param array $args Arguments to query - * @param boolean $ignoreError Ignore query errors and just return false + * @param ?bool $ignoreError Ignore query errors and just return false * @return int|boolean Number of rows affected, or false on error */ - public static function exec($query, $args = array(), $ignoreError = null) + public static function exec(string $query, array $args = [], bool $ignoreError = null) { $res = self::simpleQuery($query, $args, $ignoreError); if ($res === false) @@ -108,19 +159,19 @@ class Database } /** - * Get id (promary key) of last row inserted. + * Get id (primary key) of last row inserted. * * @return int the id */ - public static function lastInsertId() + public static function lastInsertId(): int { - return self::$dbh->lastInsertId(); + return (int)self::$dbh->lastInsertId(); } /** - * @return string|bool return last error returned by query + * @return ?string return last error returned by query */ - public static function lastError() + public static function lastError(): ?string { return self::$lastError; } @@ -133,10 +184,10 @@ class Database * * @return \PDOStatement|false The query result object */ - public static function simpleQuery($query, $args = array(), $ignoreError = null) + public static function simpleQuery(string $query, array $args = [], bool $ignoreError = null) { self::init(); - if (CONFIG_DEBUG && !isset(self::$explainList[$query]) && preg_match('/^\s*SELECT/is', $query)) { + if (CONFIG_DEBUG && !isset(self::$explainList[$query]) && preg_match('/^\s*SELECT/i', $query)) { self::$explainList[$query] = [$args]; } // Support passing nested arrays for IN statements, automagically refactor @@ -152,17 +203,13 @@ class Database } } try { - if (!isset(self::$statements[$query])) { - self::$statements[$query] = self::$dbh->prepare($query); - } else { - //self::$statements[$query]->closeCursor(); - } + $stmt = self::$dbh->prepare($query); $start = microtime(true); - if (self::$statements[$query]->execute($args) === false) { - self::$lastError = implode("\n", self::$statements[$query]->errorInfo()); + if ($stmt->execute($args) === false) { + self::$lastError = implode("\n", $stmt->errorInfo()); if ($ignoreError === true || ($ignoreError === null && self::$returnErrors)) return false; - Util::traceError("Database Error: \n" . self::$lastError); + ErrorHandler::traceError("Database Error: \n" . self::$lastError); } if (CONFIG_DEBUG) { $duration = microtime(true) - $start; @@ -175,14 +222,13 @@ class Database } self::$queryCount += 1; } - return self::$statements[$query]; + return $stmt; } catch (Exception $e) { self::$lastError = '(' . $e->getCode() . ') ' . $e->getMessage(); if ($ignoreError === true || ($ignoreError === null && self::$returnErrors)) return false; - Util::traceError("Database Error: \n" . self::$lastError); + ErrorHandler::traceError("Database Error: \n" . self::$lastError); } - return false; } public static function examineLoggedQueries() @@ -192,7 +238,7 @@ class Database } } - private static function explainQuery($query, $data) + private static function explainQuery(string $query, array $data) { $args = array_shift($data); $slow = false; @@ -212,7 +258,7 @@ class Database $res = self::simpleQuery('EXPLAIN ' . $query, $args, true); if ($res === false) return; - $rows = $res->fetchAll(PDO::FETCH_ASSOC); + $rows = $res->fetchAll(); if (empty($rows)) return; $log = $veryslow; @@ -225,7 +271,7 @@ class Database $log = true; } foreach ($row as $key => $col) { - $l = strlen($col); + $l = strlen((string)($col ?? 'NULL')); if ($l > $lens[$key]) { $lens[$key] = $l; } @@ -246,7 +292,7 @@ class Database foreach ($rows as $row) { $line = ''; foreach ($lens as $key => $len) { - $line .= '| '. str_pad($row[$key], $len) . ' '; + $line .= '| '. str_pad((string)($row[$key] ?? 'NULL'), $len) . ' '; } error_log($line . "|"); } @@ -264,8 +310,9 @@ class Database * * @param string $query sql query string * @param array $args query arguments + * @return void */ - private static function handleArrayArgument(&$query, &$args) + private static function handleArrayArgument(string &$query, array &$args) { $again = false; foreach (array_keys($args) as $key) { @@ -279,7 +326,7 @@ class Database continue; } $newkey = $key; - if ($newkey{0} !== ':') { + if ($newkey[0] !== ':') { $newkey = ":$newkey"; } $new = array(); @@ -306,7 +353,7 @@ class Database * Simply calls PDO::prepare and returns the PDOStatement. * You must call PDOStatement::execute manually on it. */ - public static function prepare($query) + public static function prepare(string $query) { self::init(); self::$queryCount += 1; // Cannot know actual count @@ -334,17 +381,17 @@ class Database * @param string $table table to insert into * @param string $aiKey name of the AUTO_INCREMENT column * @param array $uniqueValues assoc array containing columnName => value mapping - * @param array $additionalValues assoc array containing columnName => value mapping - * @return int[] list of AUTO_INCREMENT values matching the list of $values + * @param ?array $additionalValues assoc array containing columnName => value mapping + * @return int AUTO_INCREMENT value matching the given unique values entry */ - public static function insertIgnore($table, $aiKey, $uniqueValues, $additionalValues = false) + public static function insertIgnore(string $table, string $aiKey, array $uniqueValues, array $additionalValues = null): int { // Sanity checks if (array_key_exists($aiKey, $uniqueValues)) { - Util::traceError("$aiKey must not be in \$uniqueValues"); + ErrorHandler::traceError("$aiKey must not be in \$uniqueValues"); } if (is_array($additionalValues) && array_key_exists($aiKey, $additionalValues)) { - Util::traceError("$aiKey must not be in \$additionalValues"); + ErrorHandler::traceError("$aiKey must not be in \$additionalValues"); } // Simple SELECT first $selectSql = 'SELECT ' . $aiKey . ' FROM ' . $table . ' WHERE 1'; @@ -402,17 +449,17 @@ class Database // Insert done, retrieve key again $res = self::queryFirst($selectSql, $uniqueValues); if ($res === false) { - Util::traceError('Could not find value in table ' . $table . ' that was just inserted'); + ErrorHandler::traceError('Could not find value in table ' . $table . ' that was just inserted'); } return $res[$aiKey]; } - public static function getQueryCount() + public static function getQueryCount(): int { return self::$queryCount; } - public static function getQueryTime() + public static function getQueryTime(): float { return self::$queryTime; } diff --git a/inc/dictionary.inc.php b/inc/dictionary.inc.php index e366207f..3a2f9c2b 100644 --- a/inc/dictionary.inc.php +++ b/inc/dictionary.inc.php @@ -1,21 +1,22 @@ <?php +declare(strict_types=1); + class Dictionary { /** * @var string[] Array of languages, numeric index, two letter CC as values */ - private static $languages = false; + private static $languages = []; /** - * @var array Array of languages, numeric index, values are ['name' => 'Language Name', 'cc' => 'xx'] + * @var array{'name': string, 'cc': string}|null Long name of language, and CC */ - private static $languagesLong = false; - private static $stringCache = array(); + private static $languagesLong = null; + private static $stringCache = []; - public static function init() + public static function init(): void { - self::$languages = array(); foreach (glob('lang/??', GLOB_ONLYDIR) as $lang) { if (!file_exists($lang . '/name.txt') && !file_exists($lang . '/flag.png')) continue; @@ -64,29 +65,56 @@ class Dictionary } /** + * Format given number using country-specific decimal point and thousands + * separator. + * @param float $num Number to format + * @param int $decimals How many decimals to display + */ + public static function number(float $num, int $decimals = 0): string + { + static $dec = null, $tho = null; + if ($dec === null) { + if (LANG === 'de') { + $dec = ','; + $tho = '.'; + } elseif (LANG !== 'en' && file_exists("lang/" . LANG . "/format.txt")) { + $tmp = file_get_contents("lang/" . LANG . "/format.txt"); + $dec = $tmp[0]; + $tho = $tmp[1]; + } else { + $dec = '.'; + $tho = ','; + } + } + return number_format($num, $decimals, $dec, $tho); + } + + /** * Get complete key=>value list for given module, file, language * * @param string $module Module name * @param string $file Dictionary name - * @param string|false $lang Language CC, false === current language + * @param ?string $lang Language CC, false === current language * @return array assoc array mapping language tags to the translated strings */ - public static function getArray($module, $file, $lang = false) + public static function getArray(string $module, string $file, ?string $lang = null): array { - if ($lang === false) + if ($lang === null) $lang = LANG; $path = Util::safePath("modules/{$module}/lang/{$lang}/{$file}.json"); + if ($path === null) + ErrorHandler::traceError("Invalid path"); if (isset(self::$stringCache[$path])) return self::$stringCache[$path]; if (!file_exists($path)) - return array(); + return []; $content = file_get_contents($path); if ($content === false) { // File does not exist for language $content = '[]'; } $json = json_decode($content, true); if (!is_array($json)) { - $json = array(); + $json = []; } return self::$stringCache[$path] = $json; } @@ -101,7 +129,7 @@ class Dictionary * @param bool $returnTagOnMissing If true, the tag name enclosed in {{}} will be returned if the tag does not exist * @return string|false The requested tag's translation, or false if not found and $returnTagOnMissing === false */ - public static function translateFileModule($moduleId, $file, $tag, $returnTagOnMissing = false) + public static function translateFileModule(string $moduleId, string $file, string $tag, bool $returnTagOnMissing = true) { $strings = self::getArray($moduleId, $file); if (!isset($strings[$tag])) { @@ -121,7 +149,7 @@ class Dictionary * @param bool $returnTagOnMissing If true, the tag name enclosed in {{}} will be returned if the tag does not exist * @return string|false The requested tag's translation, or false if not found and $returnTagOnMissing === false */ - public static function translateFile($file, $tag, $returnTagOnMissing = false) + public static function translateFile(string $file, string $tag, bool $returnTagOnMissing = true) { if (!class_exists('Page') || Page::getModule() === false) return false; // We have no page - return false for now, as we're most likely running in api or install mode @@ -135,9 +163,9 @@ class Dictionary * @param bool $returnTagOnMissing If true, the tag name enclosed in {{}} will be returned if the tag does not exist * @return string|false The requested tag's translation, or false if not found and $returnTagOnMissing === false */ - public static function translate($tag, $returnTagOnMissing = false) + public static function translate(string $tag, bool $returnTagOnMissing = true) { - $string = self::translateFile('module', $tag); + $string = self::translateFile('module', $tag, false); if ($string !== false) return $string; $string = self::translateFileModule('main', 'global-tags', $tag); @@ -151,9 +179,8 @@ class Dictionary * * @param string $module Module the message belongs to * @param string $id Message id - * @return string|false */ - public static function getMessage($module, $id) + public static function getMessage(string $module, string $id): string { $string = self::translateFileModule($module, 'messages', $id); if ($string === false) { @@ -165,19 +192,18 @@ class Dictionary /** * Get translation of the given category. * - * @param string $category + * @param string $category Menu category to get localized name for * @return string Category name, or some generic fallback to the given category id */ - public static function getCategoryName($category) + public static function getCategoryName(string $category): string { - if ($category === false) { - return 'No Category'; - } - if (!preg_match('/^(\w+)\.(.*)$/', $category, $out)) { - return 'Invalid Category ID format: ' . $category; + if (!empty($category)) { + if (!preg_match('/^(\w+)\.(.*)$/', $category, $out)) { + return 'Invalid Category ID format: ' . $category; + } + $string = self::translateFileModule($out[1], 'categories', $out[2]); } - $string = self::translateFileModule($out[1], 'categories', $out[2]); - if ($string === false) { + if (empty($category) || $string === false) { return "!!{$category}!!"; } return $string; @@ -190,12 +216,12 @@ class Dictionary * false = regular array containing only the ccs * @return array List of languages */ - public static function getLanguages($withName = false) + public static function getLanguages(bool $withName = false): ?array { if (!$withName) return self::$languages; - if (self::$languagesLong === false) { - self::$languagesLong = array(); + if (self::$languagesLong === null) { + self::$languagesLong = []; foreach (self::$languages as $lang) { if (file_exists("lang/$lang/name.txt")) { $name = file_get_contents("lang/$lang/name.txt"); @@ -205,10 +231,10 @@ class Dictionary if (!isset($name) || $name === false) { $name = $lang; } - self::$languagesLong[] = array( + self::$languagesLong[] = [ 'cc' => $lang, - 'name' => $name - ); + 'name' => $name, + ]; } } return self::$languagesLong; @@ -217,11 +243,8 @@ class Dictionary /** * Get name of language matching given language CC. * Default to the CC if the language isn't known. - * - * @param string $langCC - * @return string */ - public static function getLanguageName($langCC) + public static function getLanguageName(string $langCC): string { if (file_exists("lang/$langCC/name.txt")) { $name = file_get_contents("lang/$langCC/name.txt"); @@ -239,12 +262,12 @@ class Dictionary * to the image, otherwise, it is just added as the title attribute. * * @param $caption bool with caption next to <img> - * @param $langCC string Language cc to get flag code for - defaults to current language + * @param $langCC ?string Language cc to get flag code for - defaults to current language * @return string html code of img tag for language */ - public static function getFlagHtml($caption = false, $langCC = false) + public static function getFlagHtml(bool $caption = false, string $langCC = null): string { - if ($langCC === false) { + if ($langCC === null) { $langCC = LANG; } $flag = "lang/$langCC/flag.png"; diff --git a/inc/download.inc.php b/inc/download.inc.php index 39f8e2e2..8358eaa3 100644 --- a/inc/download.inc.php +++ b/inc/download.inc.php @@ -1,67 +1,53 @@ <?php +declare(strict_types=1); + class Download { + /** + * @var false|resource + */ private static $curlHandle = false; /** * Common initialization for download and downloadToFile * Return file handle to header file + * @return false|resource */ - private static function initCurl($url, $timeout, &$head) + private static function initCurl(string $url, int $timeout) { if (self::$curlHandle === false) { self::$curlHandle = curl_init(); if (self::$curlHandle === false) { - Util::traceError('Could not initialize cURL'); + ErrorHandler::traceError('Could not initialize cURL'); } curl_setopt(self::$curlHandle, CURLOPT_CONNECTTIMEOUT, ceil($timeout / 2)); curl_setopt(self::$curlHandle, CURLOPT_TIMEOUT, $timeout); curl_setopt(self::$curlHandle, CURLOPT_FOLLOWLOCATION, true); curl_setopt(self::$curlHandle, CURLOPT_AUTOREFERER, true); - curl_setopt(self::$curlHandle, CURLOPT_BINARYTRANSFER, true); curl_setopt(self::$curlHandle, CURLOPT_MAXREDIRS, 6); + curl_setopt(self::$curlHandle, CURLOPT_ACCEPT_ENCODING, ''); + curl_setopt(self::$curlHandle, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) SLX-Admin/1.0'); + curl_setopt(self::$curlHandle, CURLOPT_PROTOCOLS, CURLPROTO_FTP | CURLPROTO_FTPS | CURLPROTO_HTTP | CURLPROTO_HTTPS); } curl_setopt(self::$curlHandle, CURLOPT_URL, $url); - $tmpfile = tempnam('/tmp/', 'bwlp-'); - $head = fopen($tmpfile, 'w+b'); - unlink($tmpfile); - if ($head === false) - Util::traceError("Could not open temporary head file $tmpfile for writing."); - curl_setopt(self::$curlHandle, CURLOPT_WRITEHEADER, $head); return self::$curlHandle; } /** - * Read 10kb from the given file handle, seek to 0 first, - * close the file after reading. Returns data read - */ - private static function getContents($fh) - { - fseek($fh, 0, SEEK_SET); - $data = fread($fh, 10000); - fclose($fh); - return $data; - } - - /** * Download file, obey given timeout in seconds * Return data on success, false on failure */ - public static function asString($url, $timeout, &$code) + public static function asString(string $url, int $timeout, ?int &$code) { - $ch = self::initCurl($url, $timeout, $head); + $ch = self::initCurl($url, $timeout); + curl_setopt($ch, CURLOPT_FILE, null); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $data = curl_exec($ch); - $head = self::getContents($head); - if (preg_match_all('#^HTTP/\d+\.\d+ (\d+) #m', $head, $out)) { - $code = (int) array_pop($out[1]); - } else { - $code = 999; - } + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); return $data; } @@ -71,9 +57,10 @@ class Download * @param string $url URL to fetch * @param array|false $params POST params to set in body, list of key-value-pairs * @param int $timeout timeout in seconds - * @param int $code HTTP response code, or 999 on error + * @param ?int $code HTTP response code, or 999 on error + * @return string|false */ - public static function asStringPost($url, $params, $timeout, &$code) + public static function asStringPost(string $url, $params, int $timeout, ?int &$code) { $string = ''; if (is_array($params)) { @@ -84,17 +71,13 @@ class Download $string .= $k . '=' . urlencode($v); } } - $ch = self::initCurl($url, $timeout, $head); + $ch = self::initCurl($url, $timeout); + curl_setopt($ch, CURLOPT_FILE, null); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $string); $data = curl_exec($ch); - $head = self::getContents($head); - if (preg_match_all('#^HTTP/\d+\.\d+ (\d+) #m', $head, $out)) { - $code = (int) array_pop($out[1]); - } else { - $code = 999; - } + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); return $data; } @@ -104,28 +87,23 @@ class Download * @param string $target destination path to download file to * @param string $url URL of file to download * @param int $timeout timeout in seconds - * @param int $code HTTP status code passed out by reference - * @return boolean + * @param ?int $code HTTP status code passed out by reference */ - public static function toFile($target, $url, $timeout, &$code) + public static function toFile(string $target, string $url, int $timeout, ?int &$code): bool { $fh = fopen($target, 'wb'); if ($fh === false) - Util::traceError("Could not open $target for writing."); - $ch = self::initCurl($url, $timeout, $head); + ErrorHandler::traceError("Could not open $target for writing."); + $ch = self::initCurl($url, $timeout); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); curl_setopt($ch, CURLOPT_FILE, $fh); $res = curl_exec($ch); - $head = self::getContents($head); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); fclose($fh); if ($res === false) { @unlink($target); return false; } - if (preg_match_all('#^HTTP/\d+\.\d+ (\d+) #m', $head, $out)) { - $code = (int) array_pop($out[1]); - } else { - $code = '999 ' . curl_error($ch); - } return true; } diff --git a/inc/errorhandler.inc.php b/inc/errorhandler.inc.php new file mode 100644 index 00000000..ce969966 --- /dev/null +++ b/inc/errorhandler.inc.php @@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +use JetBrains\PhpStorm\NoReturn; + +class ErrorHandler +{ + + + /** + * Displays an error message and stops script execution. + * If CONFIG_DEBUG is true, it will also dump a stack trace + * and all globally defined variables. + * (As this might reveal sensitive data you should never enable it in production) + */ + #[NoReturn] + public static function traceError(string $message): void + { + if ((defined('API') && API) || (defined('AJAX') && AJAX) || php_sapi_name() === 'cli') { + error_log('API ERROR: ' . $message); + error_log(self::formatBacktracePlain(debug_backtrace())); + } + if (php_sapi_name() === 'cli') { + // Don't spam HTML when invoked via cli, above error_log should have gone to stdout/stderr + exit(1); + } + Header('HTTP/1.1 500 Internal Server Error'); + if (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'html') === false) { + Header('Content-Type: text/plain; charset=utf-8'); + echo 'API ERROR: ', $message, "\n", self::formatBacktracePlain(debug_backtrace()); + exit(0); + } + Header('Content-Type: text/html; charset=utf-8'); + echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><style>', "\n", + ".arg { color: red; background: white; }\n", + "h1 a { color: inherit; text-decoration: inherit; font-weight: inherit; }\n", + '</style><title>Fatal Error</title></head><body>'; + echo '<h1>Flagrant <a href="https://www.youtube.com/watch?v=7rrZ-sA4FQc&t=2m2s" target="_blank">S</a>ystem error</h1>'; + echo "<h2>Message</h2><pre>$message</pre>"; + if (strpos($message, 'Database') !== false) { + echo '<div><a href="install.php">Try running database setup</a></div>'; + } + echo "<br><br>"; + if (defined('CONFIG_DEBUG') && CONFIG_DEBUG) { + global $SLX_ERRORS; + if (!empty($SLX_ERRORS)) { + echo '<h2>PHP Errors</h2><pre>'; + foreach ($SLX_ERRORS as $error) { + echo htmlspecialchars("{$error['errstr']} ({$error['errfile']}:{$error['errline']}\n"); + } + echo '</pre>'; + } + echo "<h2>Stack Trace</h2>"; + echo '<pre>', self::formatBacktraceHtml(debug_backtrace()), '</pre>'; + echo "<h2>Globals</h2><pre>"; + echo htmlspecialchars(print_r($GLOBALS, true)); + echo '</pre>'; + } else { + echo <<<SADFACE +<pre> +________________________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶________ +____________________¶¶¶___________________¶¶¶¶_____ +________________¶¶¶_________________________¶¶¶¶___ +______________¶¶______________________________¶¶¶__ +___________¶¶¶_________________________________¶¶¶_ +_________¶¶_____________________________________¶¶¶ +________¶¶_________¶¶¶¶¶___________¶¶¶¶¶_________¶¶ +______¶¶__________¶¶¶¶¶¶__________¶¶¶¶¶¶_________¶¶ +_____¶¶___________¶¶¶¶____________¶¶¶¶___________¶¶ +____¶¶___________________________________________¶¶ +___¶¶___________________________________________¶¶_ +__¶¶____________________¶¶¶¶____________________¶¶_ +_¶¶_______________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶______________¶¶__ +_¶¶____________¶¶¶¶___________¶¶¶¶¶___________¶¶___ +¶¶¶_________¶¶¶__________________¶¶__________¶¶____ +¶¶_________¶______________________¶¶________¶¶_____ +¶¶¶______¶________________________¶¶_______¶¶______ +¶¶¶_____¶_________________________¶¶_____¶¶________ +_¶¶¶___________________________________¶¶__________ +__¶¶¶________________________________¶¶____________ +___¶¶¶____________________________¶¶_______________ +____¶¶¶¶______________________¶¶¶__________________ +_______¶¶¶¶¶_____________¶¶¶¶¶_____________________ +</pre> +SADFACE; + } + echo '</body></html>'; + exit(0); + } + + public static function formatBacktraceHtml(array $trace): string + { + $output = ''; + foreach ($trace as $idx => $line) { + $args = array(); + foreach ($line['args'] as $arg) { + $arg = self::formatArgument($arg); + $args[] = '<span class="arg">' . htmlspecialchars($arg) . '</span>'; + } + $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT); + $function = htmlspecialchars($line['function']); + $args = implode(', ', $args); + $file = preg_replace('~(/[^/]+)$~', '<b>$1</b>', htmlspecialchars($line['file'])); + // Add line + $output .= $frame . ' ' . $function . '<b>(</b>' + . $args . '<b>)</b>' . ' @ <i>' . $file . '</i>:' . $line['line'] . "\n"; + } + return $output; + } + + public static function formatBacktracePlain(array $trace): string + { + $output = ''; + foreach ($trace as $idx => $line) { + $args = array(); + foreach ($line['args'] as $arg) { + $args[] = self::formatArgument($arg); + } + $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT); + $args = implode(', ', $args); + // Add line + $output .= "\n" . $frame . ' ' . $line['function'] . '(' + . $args . ')' . ' @ ' . $line['file'] . ':' . $line['line']; + } + return $output; + } + + private static function formatArgument($arg, bool $expandArray = true): string + { + if (is_string($arg)) { + $arg = "'$arg'"; + } elseif (is_object($arg)) { + $arg = 'instanceof ' . get_class($arg); + } elseif (is_array($arg)) { + if ($expandArray && count($arg) < 20) { + $expanded = ''; + foreach ($arg as $key => $value) { + if (!empty($expanded)) { + $expanded .= ', '; + } + $expanded .= $key . ': ' . self::formatArgument($value, false); + if (strlen($expanded) > 200) + break; + } + if (strlen($expanded) <= 200) + return '[' . $expanded . ']'; + } + $arg = 'Array(' . count($arg) . ')'; + } + return (string)$arg; + } +}
\ No newline at end of file diff --git a/inc/event.inc.php b/inc/event.inc.php index 2d916b48..4d02b580 100644 --- a/inc/event.inc.php +++ b/inc/event.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Class with static functions that are called when a specific event * took place, like the server has been booted, or the interface address @@ -15,7 +17,7 @@ class Event * Called when the system (re)booted. Could be implemented * by a @reboot entry in crontab (running as the same user php does) */ - public static function systemBooted() + public static function systemBooted(): void { EventLog::info('System boot...'); $everythingFine = true; @@ -45,7 +47,7 @@ class Event // Check status of all tasks // Mount vm store - if ($mountId === false) { + if ($mountId === null) { EventLog::info('No VM store type defined.'); $everythingFine = false; } else { @@ -58,13 +60,13 @@ class Event $everythingFine = false; } // iPXE generation - if ($ipxeId === false) { + if ($ipxeId === null) { EventLog::failure('Cannot generate PXE menu: Taskmanager unreachable!'); $everythingFine = false; } else { $res = Taskmanager::waitComplete($ipxeId, 5000); if (Taskmanager::isFailed($res)) { - EventLog::failure('Update PXE Menu failed', $res['data']['error'] ?? $res['data']['error'] ?? ''); + EventLog::failure('Update PXE Menu failed', $res['data']['error'] ?? $res['statusCode'] ?? ''); $everythingFine = false; } } @@ -78,10 +80,10 @@ class Event $mountId = Trigger::mount(); $mountStatus = Taskmanager::waitComplete($mountId, 10000); } - if ($mountId !== false && Taskmanager::isFailed($mountStatus)) { + if ($mountId !== null && Taskmanager::isFailed($mountStatus)) { EventLog::failure('Mounting VM store failed', $mountStatus['data']['messages'] ?? ''); $everythingFine = false; - } elseif ($mountId !== false && !Taskmanager::isFinished($mountStatus)) { + } elseif ($mountId !== null && !Taskmanager::isFinished($mountStatus)) { // TODO: Still running - create callback } @@ -96,7 +98,7 @@ class Event /** * Server's primary IP address changed. */ - public static function serverIpChanged() + public static function serverIpChanged(): void { Trigger::ipxe(); if (Module::isAvailable('sysconfig')) { // TODO: Modularize events diff --git a/inc/eventlog.inc.php b/inc/eventlog.inc.php index 3ebb82a4..99585abd 100644 --- a/inc/eventlog.inc.php +++ b/inc/eventlog.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Class to add entries to the event log. Technically this class belongs to the * eventlog module, but since it is used in so many places, this helper resides @@ -10,46 +12,71 @@ class EventLog { - private static function log($type, $message, $details) + private static function log(string $type, string $message, string $details, bool $markWarning): void { if (!Module::isAvailable('eventlog')) { // Eventlog module does not exist; the eventlog table might not exist, so bail out error_log($message); return; } - Database::exec("INSERT INTO eventlog (dateline, logtypeid, description, extra)" - . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", array( - 'type' => $type, - 'message' => $message, - 'details' => $details - ), true); + if (mb_strlen($message) > 255) { + $message = mb_substr($message, 0, 255); + } + if (mb_strlen($details) > 65535) { + $details = mb_substr($details, 0, 65535); + } + $data = [ + 'type' => $type, + 'message' => $message, + 'details' => $details, + ]; + if (Database::exec("INSERT INTO eventlog (dateline, logtypeid, description, extra)" + . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", $data, true) === false) { + error_log($message); + } else { + // Insert ok, see if we should update the "latest warning" id + $id = Database::lastInsertId(); + if ($id !== 0 && $markWarning) { + Property::setLastWarningId($id); + } + } + self::applyFilterRules('#serverlog', $data); } - public static function failure($message, $details = '') + public static function failure(string $message, string $details = ''): void { - self::log('failure', $message, $details); - Property::setLastWarningId(Database::lastInsertId()); + self::log('failure', $message, $details, true); } - public static function warning($message, $details = '') + public static function warning(string $message, string $details = ''): void { - self::log('warning', $message, $details); - Property::setLastWarningId(Database::lastInsertId()); + self::log('warning', $message, $details, true); } - public static function info($message, $details = '') + public static function info(string $message, string $details = ''): void { - self::log('info', $message, $details); + self::log('info', $message, $details, false); } /** * DELETE ENTIRE EVENT LOG! */ - public static function clear() + public static function clear(): void { if (!Module::isAvailable('eventlog')) return; Database::exec("TRUNCATE eventlog"); } - + + /** + * @param string $type the event. Will either be client state like ~poweron, ~runstate etc. or a client log type + * @param array $data A structured array containing event specific data that can be matched. + */ + public static function applyFilterRules(string $type, array $data): void + { + if (!Module::isAvailable('eventlog')) + return; + FilterRuleProcessor::applyFilterRules($type, $data); + } + } diff --git a/inc/fileutil.inc.php b/inc/fileutil.inc.php index f35f987e..e986b2de 100644 --- a/inc/fileutil.inc.php +++ b/inc/fileutil.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + class FileUtil { @@ -8,9 +10,9 @@ class FileUtil * * @param string $file file to read * @param int $maxBytes maximum length to read - * @return boolean|string data, false on error + * @return false|string data, false on error */ - public static function readFile($file, $maxBytes = 1000) + public static function readFile(string $file, int $maxBytes = 1000) { $fh = @fopen($file, 'rb'); if ($fh === false) @@ -19,18 +21,18 @@ class FileUtil fclose($fh); return $data; } - + /** * Read a file of key=value lines to assoc array. * * @param string $file Filename - * @return boolean|array assoc array, false on error + * @return ?array assoc array, null on error */ - public static function fileToArray($file) + public static function fileToArray(string $file): ?array { $data = self::readFile($file, 2000); if ($data === false) - return false; + return null; $data = explode("\n", str_replace("\r", "\n", $data)); $ret = array(); foreach ($data as $line) { @@ -40,7 +42,7 @@ class FileUtil } return $ret; } - + /** * Write given associative array to file as key=value pairs. * @@ -48,7 +50,7 @@ class FileUtil * @param array $array Associative array to write * @return boolean success of operation */ - public static function arrayToFile($file, $array) + public static function arrayToFile(string $file, array $array): bool { $fh = fopen($file, 'wb'); if ($fh === false) diff --git a/inc/hook.inc.php b/inc/hook.inc.php index 05078f72..f7ca617d 100644 --- a/inc/hook.inc.php +++ b/inc/hook.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Generic helper for getting and executing hooks. */ @@ -11,11 +13,12 @@ class Hook * Internally, this scans for "modules/<*>/hooks/$hookName.inc.php" * and optionally checks if the module's dependencies are fulfilled, * then returns a list of all matching modules. + * * @param string $hookName Name of hook to search for. * @param bool $filterBroken if true, modules that have a hook but have missing deps will not be returned - * @return \Hook[] list of modules with requested hooks + * @return Hook[] list of modules with requested hooks */ - public static function load($hookName, $filterBroken = true) + public static function load(string $hookName, bool $filterBroken = true): array { $retval = array(); foreach (glob('modules/*/hooks/' . $hookName . '.inc.php', GLOB_NOSORT) as $file) { @@ -33,17 +36,17 @@ class Hook * @param string $moduleName Module * @param string $hookName Hook * @param bool $filterBroken return false if the module has missing deps - * @return Hook|false hook instance, false on error or if module doesn't have given hook + * @return ?Hook hook instance, false on error or if module doesn't have given hook */ - public static function loadSingle($moduleName, $hookName, $filterBroken = true) + public static function loadSingle(string $moduleName, string $hookName, bool $filterBroken = true): ?Hook { if (Module::get($moduleName) === false) // No such module - return false; + return null; if ($filterBroken && !Module::isAvailable($moduleName)) // Broken - return false; + return null; $file = 'modules/' . $moduleName . '/hooks/' . $hookName . '.inc.php'; if (!file_exists($file)) // No hook - return false; + return null; return new Hook($moduleName, $file); } @@ -72,7 +75,7 @@ class Hook try { return (include $this->file); } catch (Exception $e) { - error_log($e); + error_log($e->getMessage()); return false; } } diff --git a/inc/iputil.inc.php b/inc/iputil.inc.php index 69311d7f..a50f22eb 100644 --- a/inc/iputil.inc.php +++ b/inc/iputil.inc.php @@ -1,40 +1,43 @@ <?php +declare(strict_types=1); + class IpUtil { - public static function rangeToCidr($start, $end) + public static function rangeToCidr(int $start, int $end): string { - $value = (int)$start ^ (int)$end; + $value = $start ^ $end; if (!self::isAllOnes($value)) return 'NOT SUBNET: ' . long2ip($start) . '-' . long2ip($end); - $ones = self::countOnes($value); + $ones = self::bitLength($value); return long2ip($start) . '/' . (32 - $ones); } - public static function isValidSubnetRange($start, $end) + public static function isValidSubnetRange(int $start, int $end): bool { - return self::isAllOnes((int)$start ^ (int)$end); + return self::isAllOnes($start ^ $end); } /** - * Return number of one bits required to represent - * this number. Assumes given number is 2^n - 1. + * Return number of bits required to represent + * this number. + * !! Assumes given number is 2^n - 1 !! */ - private static function countOnes($value) + private static function bitLength(int $value): int { // This is log(value) / log(2) // It should actually be $value + 1, but floating point errors // start to happen either way at higher values, so with // the round() thrown in, it doesn't matter... - return round(log($value) / 0.69314718055995); + return (int)round(log($value) / 0.69314718055995); } /** * Is the given number just ones if converted to * binary (ignoring leading zeros)? */ - private static function isAllOnes($value) + private static function isAllOnes(int $value): bool { return ($value & ($value + 1)) === 0; } @@ -44,35 +47,31 @@ class IpUtil * ['start' => (int), 'end' => (int)] representing * the according start and end addresses as integer * values. Returns false on malformed input. + * * @param string $cidr 192.168.101/24, 1.2.3.4/16, ... - * @return array|false start and end address, false on error + * @return array{start: int, end: int}|null start and end address, false on error */ - public static function parseCidr($cidr) + public static function parseCidr(string $cidr): ?array { $parts = explode('/', $cidr); if (count($parts) !== 2) { $ip = ip2long($cidr); if ($ip === false) - return false; - if (PHP_INT_SIZE === 4) { - $ip = sprintf('%u', $ip); - } + return null; return ['start' => $ip, 'end' => $ip]; } $ip = $parts[0]; $bits = $parts[1]; if (!is_numeric($bits) || $bits < 0 || $bits > 32) - return false; + return null; $dots = substr_count($ip, '.'); if ($dots < 3) { $ip .= str_repeat('.0', 3 - $dots); } $ip = ip2long($ip); if ($ip === false) - return false; - $bits = pow(2, 32 - $bits) - 1; - if (PHP_INT_SIZE === 4) - return ['start' => sprintf('%u', $ip & ~$bits), 'end' => sprintf('%u', $ip | $bits)]; + return null; + $bits = (int)((2 ** (32 - $bits)) - 1); return ['start' => $ip & ~$bits, 'end' => $ip | $bits]; } diff --git a/inc/mailer.inc.php b/inc/mailer.inc.php new file mode 100644 index 00000000..bfdcd320 --- /dev/null +++ b/inc/mailer.inc.php @@ -0,0 +1,186 @@ +<?php + +declare(strict_types=1); + +class Mailer +{ + + /** @var array|null */ + private static $configs = null; + + /** @var array */ + private $curlOptions; + + /** @var string|null */ + private $replyTo; + + /** @var string */ + private $errlog = ''; + + /** @var string */ + private $from; + + // $keys = array('host', 'port', 'ssl', 'senderAddress', 'replyTo', 'username', 'password', 'serverName'); + public function __construct(string $hosturi, bool $startTls, string $from, string $user, string $pass, string $replyTo = null) + { + $this->from = $from; + if (preg_match('/[^<>"\'\s]+@[^<>"\'\s]+/i', $from, $out)) { + $from = $out[0]; + } + $this->curlOptions = [ + CURLOPT_URL => $hosturi, + CURLOPT_USE_SSL => $startTls ? CURLUSESSL_ALL : CURLUSESSL_TRY, + CURLOPT_MAIL_FROM => $from, + CURLOPT_UPLOAD => 1, + CURLOPT_VERBOSE => 1, // XXX + ]; + if (!empty($user)) { + $this->curlOptions += [ + CURLOPT_LOGIN_OPTIONS => 'AUTH=NTLM;AUTH=DIGEST-MD5;AUTH=CRAM-MD5;AUTH=PLAIN;AUTH=LOGIN', + CURLOPT_USERNAME => $user, + CURLOPT_PASSWORD => $pass, + ]; + } + $this->replyTo = $replyTo; + } + + /** + * Send a mail to given list of recipients. + * @param string[] $rcpts Recipients + * @param string $subject Mail subject + * @param string $text Mail body + * @return int curl error code, CURLE_OK on success + */ + public function send(array $rcpts, string $subject, string $text): int + { + // Convert all line breaks to CRLF while trying to avoid introducing additional ones + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = str_replace("\n", "\r\n", $text); + $mail = ["Date: " . date('r')]; + foreach ($rcpts as $r) { + $mail[] = self::mimeEncode('To', $r); + } + $mail[] = self::mimeEncode('Content-Type', 'text/plain; charset="utf-8"'); + $mail[] = self::mimeEncode('Subject', $subject); + $mail[] = self::mimeEncode('From', $this->from); + $mail[] = self::mimeEncode('Message-ID', '<' . Util::randomUuid() . '@rfcpedant.example.org>'); + if (!empty($this->replyTo)) { + $mail[] = self::mimeEncode('Reply-To', $this->replyTo); + } + + $mail = implode("\r\n", $mail) . "\r\n\r\n" . $text; + $c = curl_init(); + $pos = 0; + $this->curlOptions[CURLOPT_MAIL_RCPT] = array_map(function ($mail) { + return preg_replace('/\s.*$/', '', $mail); + }, $rcpts); + $this->curlOptions[CURLOPT_READFUNCTION] = function($ch, $fp, $len) use (&$pos, $mail) { + $txt = substr($mail, $pos, $len); + $pos += strlen($txt); + return $txt; + }; + $err = fopen('php://temp', 'w+'); + $this->curlOptions[CURLOPT_STDERR] = $err; + curl_setopt_array($c, $this->curlOptions); + curl_exec($c); + rewind($err); + $this->errlog = stream_get_contents($err); + fclose($err); + return curl_errno($c); + } + + public function getLog(): string + { + // Remove repeated "Expire" messages, keep only last one + return preg_replace('/^\* Expire in \d+ ms for.*[\\r\\n]+(?=\* Expire)/m','$1', $this->errlog); + } + + public static function queue(int $configid, array $rcpts, string $subject, string $text): void + { + foreach ($rcpts as $rcpt) { + Database::exec("INSERT INTO mail_queue (rcpt, subject, body, dateline, configid) + VALUES (:rcpt, :subject, :body, UNIX_TIMESTAMP(), :config)", + ['rcpt' => $rcpt, 'subject' => $subject, 'body' => $text, 'config' => $configid]); + } + } + + public static function flushQueue(): void + { + $list = Database::queryGroupList("SELECT Concat(configid, rcpt, subject) AS keyval, + mailid, configid, rcpt, subject, body, dateline FROM mail_queue + WHERE nexttry <= UNIX_TIMESTAMP() LIMIT 20"); + $cutoff = time() - 43200; // 12h + $mailers = []; + // Loop over mails, grouped by configid-rcpt-subject + foreach ($list as $mails) { + $delete = []; + $body = []; + // Loop over individual mails in current group + foreach ($mails as $mail) { + $delete[] = $mail['mailid']; + if ($mail['dateline'] < $cutoff) { + EventLog::info("Dropping queued mail '{$mail['subject']}' for {$mail['rcpt']} as it's too old."); + continue; // Ignore, too old + } + $body[] = ' *** ' . date('d.m.Y H:i:s', $mail['dateline']) . "\r\n" + . $mail['body']; + } + if (!empty($body) && isset($mail)) { + $body = implode("\r\n\r\n - - - - -\r\n\r\n", $body); + if (!isset($mailers[$mail['configid']])) { + $mailers[$mail['configid']] = self::instanceFromConfig($mail['configid']); + if ($mailers[$mail['configid']] === null) { + EventLog::failure("Invalid mailer config id: " . $mail['configid']); + } + } + if (($mailer = $mailers[$mail['configid']]) !== null) { + $ret = $mailer->send([$mail['rcpt']], $mail['subject'], $body); + if ($ret !== CURLE_OK) { + Database::exec("UPDATE mail_queue SET nexttry = UNIX_TIMESTAMP() + 7200 + WHERE mailid IN (:ids)", ['ids' => $delete]); + $delete = []; + EventLog::info("Error sending mail '{$mail['subject']}' for {$mail['rcpt']}.", + $mailer->getLog()); + } + } + } + // Now delete these, either sending succeeded or it's too old + if (!empty($delete)) { + Database::exec("DELETE FROM mail_queue WHERE mailid IN (:ids)", ['ids' => $delete]); + } + } + } + + private static function mimeEncode(string $field, string $string): string + { + if (preg_match('/[\r\n\x7f-\xff]/', $string)) { + return iconv_mime_encode($field, $string); + } + return "$field: $string"; + } + + private static function getConfig(int $configId): array + { + if (!is_array(self::$configs)) { + self::$configs = Database::queryIndexedList("SELECT configid, host, port, `ssl`, senderaddress, replyto, + username, password + FROM mail_config"); + } + return self::$configs[$configId] ?? []; + } + + public static function instanceFromConfig(int $configId): ?Mailer + { + $config = self::getConfig($configId); + if (empty($config)) + return null; + if ($config['ssl'] === 'IMPLICIT') { + $uri = "smtps://{$config['host']}:{$config['port']}"; + } else { + $uri = "smtp://{$config['host']}:{$config['port']}"; + } + return new Mailer($uri, $config['ssl'] === 'EXPLICIT', $config['senderaddress'], $config['username'], + $config['password'], $config['replyto']); + } + +}
\ No newline at end of file diff --git a/inc/message.inc.php b/inc/message.inc.php index 9197e4c2..119bb2ba 100644 --- a/inc/message.inc.php +++ b/inc/message.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + class Message { private static $list = array(); @@ -11,40 +13,39 @@ class Message * yet, it will be added to the queue, otherwise it will be added * in place during rendering. */ - public static function addError($id) + public static function addError(string $id, ...$params): void { - self::add('danger', $id, func_get_args()); + self::add('danger', $id, $params); } - public static function addWarning($id) + public static function addWarning(string $id, ...$params): void { - self::add('warning', $id, func_get_args()); + self::add('warning', $id, $params); } - public static function addInfo($id) + public static function addInfo(string $id, ...$params): void { - self::add('info', $id, func_get_args()); + self::add('info', $id, $params); } - public static function addSuccess($id) + public static function addSuccess(string $id, ...$params): void { - self::add('success', $id, func_get_args()); + self::add('success', $id, $params); } /** * Internal function that adds a message. Used by * addError/Success/Info/... above. */ - private static function add($type, $id, $params) + private static function add(string $type, string $id, array $params): void { if (strstr($id, '.') === false) { $id = Page::getModule()->getIdentifier() . '.' . $id; } - if (count($params) > 1 && $params[1] === true) { - $params = array_slice($params, 2); + if (!empty($params) && $params[0] === true) { + $params = array_slice($params, 1); $linkModule = true; } else { - $params = array_slice($params, 1); $linkModule = false; } switch ($type) { @@ -63,6 +64,7 @@ class Message default: $icon = ''; } + ArrayUtil::forceType($params, 'string'); self::$list[] = array( 'type' => $type, 'icon' => $icon, @@ -70,7 +72,9 @@ class Message 'params' => $params, 'link' => $linkModule ); - if (self::$flushed) self::renderList(); + if (self::$flushed) { + self::renderList(); + } } /** @@ -78,7 +82,7 @@ class Message * After calling this, any further calls to add* will be rendered in * place in the current page output. */ - public static function renderList() + public static function renderList(): void { self::$flushed = true; if (empty(self::$list)) @@ -122,7 +126,7 @@ class Message * Get all queued messages, flushing the queue. * Useful in api/ajax mode. */ - public static function asString() + public static function asString(): string { $return = ''; foreach (self::$list as $item) { @@ -145,17 +149,20 @@ class Message * Deserialize any messages from the current HTTP request and * place them in the message queue. */ - public static function fromRequest() + public static function fromRequest(): void { $messages = is_array($_REQUEST['message']) ? $_REQUEST['message'] : array($_REQUEST['message']); foreach ($messages as $message) { $data = explode('|', $message); + if (count($data) < 2) + continue; if (substr($data[0], -1) === '@') { $data[0] = substr($data[0], 0, -1); - array_splice($data, 1, 0, true); + array_splice($data, 2, 0, true); } - if (count($data) < 2 || !preg_match('/^(danger|warning|info|success)$/', $data[0])) continue; - self::add($data[0], $data[1], array_slice($data, 1)); + if (!preg_match('/^(danger|warning|info|success)$/', $data[0])) + continue; + self::add($data[0], $data[1], array_slice($data, 2)); } } @@ -163,7 +170,7 @@ class Message * Turn the current message queue into a serialized version, * suitable for appending to a GET or POST request */ - public static function toRequest() + public static function toRequest(): string { $parts = array(); foreach (array_merge(self::$list, self::$alreadyDisplayed) as $item) { diff --git a/inc/module.inc.php b/inc/module.inc.php index 55713cd0..b072c4a2 100644 --- a/inc/module.inc.php +++ b/inc/module.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + class Module { /* @@ -7,16 +9,16 @@ class Module */ /** - * @var \Module[] + * @var ?Module[] */ - private static $modules = false; + private static $modules = null; /** * @param string $name ID/Internal name of module - * @param false $ignoreDepFail whether to return the module even if some of its dependencies failed + * @param bool $ignoreDepFail whether to return the module even if some of its dependencies failed * @return false|Module */ - public static function get($name, $ignoreDepFail = false) + public static function get(string $name, bool $ignoreDepFail = false) { if (!isset(self::$modules[$name])) return false; @@ -28,13 +30,13 @@ class Module /** * Check whether given module is available, that is, all dependencies are * met. If the module is available, it will be activated, so all its classes - * are available through the auto-loader, and any js or css is added to the + * are available through the autoloader, and any js or css is added to the * final page output. * * @param string $moduleId module to check * @return bool true if module is available and activated */ - public static function isAvailable($moduleId, $activate = true) + public static function isAvailable(string $moduleId, bool $activate = true): bool { $module = self::get($moduleId); if ($module === false) @@ -45,7 +47,7 @@ class Module return !$module->hasMissingDependencies(); } - private static function resolveDepsByName($name) + private static function resolveDepsByName(string $name): bool { if (!isset(self::$modules[$name])) return false; @@ -57,7 +59,7 @@ class Module * @param \Module $mod the module to check * @return boolean true iff module deps are all found and enabled */ - private static function resolveDeps($mod) + private static function resolveDeps(Module $mod): bool { if (!$mod->depsChecked) { $mod->depsChecked = true; @@ -75,7 +77,7 @@ class Module /** * @return \Module[] List of valid, enabled modules */ - public static function getEnabled($sortById = false) + public static function getEnabled(bool $sortById = false): array { $ret = array(); $sort = array(); @@ -96,7 +98,7 @@ class Module /** * @return \Module[] List of all modules, including with missing deps */ - public static function getAll() + public static function getAll(): array { foreach (self::$modules as $module) { self::resolveDeps($module); @@ -107,7 +109,7 @@ class Module /** * @return \Module[] List of modules that have been activated */ - public static function getActivated() + public static function getActivated(): array { $ret = array(); $i = 0; @@ -120,9 +122,9 @@ class Module return $ret; } - public static function init() + public static function init(): void { - if (self::$modules !== false) + if (self::$modules !== null) return; $dh = opendir('modules'); if ($dh === false) @@ -143,7 +145,8 @@ class Module * Non-static */ - private $category = false; + /** @var ?string category id */ + private $category = null; private $clientPlugin = false; private $depsMissing = false; private $depsChecked = false; @@ -161,7 +164,7 @@ class Module */ private $scripts = array(); - private function __construct($name) + private function __construct(string $name) { $file = 'modules/' . $name . '/config.json'; $json = @json_decode(@file_get_contents($file), true); @@ -173,30 +176,30 @@ class Module if (isset($json['category']) && is_string($json['category'])) { $this->category = $json['category']; } - $this->collapse = isset($json['collapse']) && (bool)$json['collapse']; + $this->collapse = isset($json['collapse']) && $json['collapse']; if (isset($json['client-plugin'])) { $this->clientPlugin = (bool)$json['client-plugin']; } $this->name = $name; } - public function hasMissingDependencies() + public function hasMissingDependencies(): bool { return $this->depsMissing; } - public function newPage() + public function newPage(): Page { $modulePath = 'modules/' . $this->name . '/page.inc.php'; if (!file_exists($modulePath)) { - Util::traceError("Module doesn't have a page: " . $modulePath); + ErrorHandler::traceError("Module doesn't have a page: " . $modulePath); } require_once $modulePath; $class = 'Page_' . $this->name; return new $class(); } - public function activate($depth, $direct) + public function activate(?int $depth, ?bool $direct): bool { if ($this->depsMissing) return false; @@ -228,14 +231,14 @@ class Module return true; } - public function getDependencies() + public function getDependencies(): array { $deps = array(); $this->getDepsInternal($deps); return array_keys($deps); } - private function getDepsInternal(&$deps) + private function getDepsInternal(array &$deps): void { if (!is_array($this->dependencies)) return; @@ -250,49 +253,49 @@ class Module } } - public function getIdentifier() + public function getIdentifier(): string { return $this->name; } - public function getDisplayName() + public function getDisplayName(): string { - $string = Dictionary::translateFileModule($this->name, 'module', 'module_name'); + $string = Dictionary::translateFileModule($this->name, 'module', 'module_name', false); if ($string === false) { return '!!' . $this->name . '!!'; } return $string; } - public function getPageTitle() + public function getPageTitle(): string { - $val = Dictionary::translateFileModule($this->name, 'module', 'page_title'); + $val = Dictionary::translateFileModule($this->name, 'module', 'page_title', false); if ($val !== false) return $val; return $this->getDisplayName(); } - public function getCategory() + public function getCategory(): ?string { return $this->category; } - public function getCategoryName() + public function getCategoryName(): string { return Dictionary::getCategoryName($this->category); } - public function doCollapse() + public function doCollapse(): bool { return $this->collapse; } - public function getDir() + public function getDir(): string { return 'modules/' . $this->name; } - public function getScripts() + public function getScripts(): array { if ($this->directActivation && $this->clientPlugin) { if (!in_array('clientscript.js', $this->scripts) && file_exists($this->getDir() . '/clientscript.js')) { @@ -303,7 +306,7 @@ class Module return []; } - public function getCss() + public function getCss(): array { if ($this->directActivation && $this->clientPlugin) { if (!in_array('style.css', $this->css) && file_exists($this->getDir() . '/style.css')) { diff --git a/inc/paginate.inc.php b/inc/paginate.inc.php index b212e252..7757228a 100644 --- a/inc/paginate.inc.php +++ b/inc/paginate.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + class Paginate { private $query; @@ -9,43 +11,44 @@ class Paginate private $totalRows = false; /** - * @query - The query that will return lines to show - * @currentPage - 0based index of currently viewed page - * @perPage - Number of items to show per page - * @url - URL of current wegpage + * @param string $query - The query that will return lines to show + * @param int $perPage - Number of items to show per page + * @param ?string $url - URL of current wegpage */ - public function __construct($query, $perPage, $url = false) + public function __construct(string $query, int $perPage, string $url = null) { $this->currentPage = (isset($_GET['page']) ? (int)$_GET['page'] : 0); - $this->perPage = (int)$perPage; + $this->perPage = $perPage; if ($this->currentPage < 0) { - Util::traceError('Current page < 0'); + ErrorHandler::traceError('Current page < 0'); } if ($this->perPage < 1) { - Util::traceError('Per page < 1'); + ErrorHandler::traceError('Per page < 1'); } // Query - if (!preg_match('/\s*SELECT\s/is', $query)) { - Util::traceError('Query has to start with SELECT!'); + if (!preg_match('/\s*SELECT\s/i', $query)) { + ErrorHandler::traceError('Query has to start with SELECT!'); } // XXX: MySQL only - if (preg_match('/^mysql/i', CONFIG_SQL_DSN)) { + if (preg_match('/^(mysql|mariadb)/i', CONFIG_SQL_DSN)) { // Sanity: Check for LIMIT specification at the end if (preg_match('/LIMIT\s+(\d+|\:\w+|\?)\s*,\s*(\d+|\:\w+|\?)(\s|;)*(\-\-.*)?$/is', $query)) { - Util::traceError("You cannot pass a query containing a LIMIT to the Paginator class!"); + ErrorHandler::traceError("You cannot pass a query containing a LIMIT to the Paginator class!"); } // Sanity: no comment or semi-colon at end (sloppy, might lead to false negatives) - if (preg_match('/(\-\-|;)(\s|[^\'"`])*$/is', $query)) { - Util::traceError("Your query must not end in a comment or semi-colon!"); + if (preg_match('/(\-\-|;)(\s|[^\'"`])*$/i', $query)) { + ErrorHandler::traceError("Your query must not end in a comment or semi-colon!"); } // Don't use SQL_CALC_FOUND_ROWS as it leads to filesort frequently thus being slower than two queries // See https://www.percona.com/blog/2007/08/28/to-sql_calc_found_rows-or-not-to-sql_calc_found_rows/ } else { - Util::traceError('Unsupported database engine'); + ErrorHandler::traceError('Unsupported database engine'); } // Mangle URL - if ($url === false) $url = $_SERVER['REQUEST_URI']; + if ($url === null) { + $url = $_SERVER['REQUEST_URI']; + } if (strpos($url, '?') === false) { $url .= '?'; } else { @@ -60,7 +63,7 @@ class Paginate /** * Execute the query, returning the PDO query object */ - public function exec($args = array()) + public function exec(array $args = []) { $countQuery = preg_replace('/ORDER\s+BY\s.*?(\sASC|\sDESC|$)/is', '', $this->query); $countQuery = preg_replace('/SELECT\s.*?\sFROM\s/is', 'SELECT Count(*) AS rowcount FROM ', $countQuery); @@ -71,7 +74,7 @@ class Paginate return $retval; } - public function render($template, $data) + public function render(string $template, array $data): void { if ($this->totalRows == 0) { // Shortcut for no content diff --git a/inc/permission.inc.php b/inc/permission.inc.php index cd9cc43c..f346f1da 100644 --- a/inc/permission.inc.php +++ b/inc/permission.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + class Permission { private static $permissions = array( @@ -9,18 +11,21 @@ class Permission 'translation' => 8, // Can edit translations ); - public static function get($permission) + public static function get(string $permission): int { - if (!isset(self::$permissions[$permission])) Util::traceError('Invalid permission: ' . $permission); + if (!isset(self::$permissions[$permission])) ErrorHandler::traceError('Invalid permission: ' . $permission); return self::$permissions[$permission]; } // TODO: Doc/Refactor - public static function addGlobalTags(&$array, $locationid, $disabled, $noneAvailDisabled = null) + public static function addGlobalTags(?array &$array, ?int $locationid, array $disabled, ?string $noneAvailDisabled = null): void { if (Module::get('permissionmanager') === false) return; + if ($array === null) { + $array = []; + } $one = false; foreach ($disabled as $perm) { if (User::hasPermission($perm, $locationid)) { @@ -47,7 +52,7 @@ class Permission } } - public static function moduleHasPermissions($moduleId) + public static function moduleHasPermissions(string $moduleId): bool { if (Module::get('permissionmanager') === false) return true; @@ -58,13 +63,8 @@ class Permission * Takes a list of locations, removes any locations from it where the user doesn't have permission, * and then re-adds locations resulting from the given query. The given query should return only * one column per row, which is a location id. - * @param $passedLocations - * @param $permission - * @param $query - * @param $params - * @return array */ - public static function mergeWithDisallowed($passedLocations, $permission, $query, $params) + public static function mergeWithDisallowed(array $passedLocations, string $permission, string $query, array $params): array { $allowed = User::getAllowedLocations($permission); if (in_array(0, $allowed)) diff --git a/inc/property.inc.php b/inc/property.inc.php index 3911b0d4..aaf03254 100644 --- a/inc/property.inc.php +++ b/inc/property.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Get or set simple key-value-pairs, backed by the database * to make them persistent. @@ -16,12 +18,13 @@ class Property * @param mixed $default value to return if $key does not exist in the property store * @return mixed the value attached to $key, or $default if $key does not exist */ - public static function get($key, $default = false) + public static function get(string $key, $default = false) { if (self::$cache === false) { + self::$cache = []; $NOW = time(); $res = Database::simpleQuery("SELECT name, dateline, value FROM property"); - while ($row = $res->fetch(PDO::FETCH_ASSOC)) { + foreach ($res as $row) { if ($row['dateline'] != 0 && $row['dateline'] < $NOW) continue; self::$cache[$row['name']] = $row['value']; @@ -33,24 +36,30 @@ class Property } /** - * Set value in property store. + * Set value in property store. Passing null or false as the value deletes the + * entry from the property table. * * @param string $key key of value to set - * @param string $value the value to store for $key + * @param string|null|false $value the value to store for $key * @param int $maxAgeMinutes how long to keep this entry around at least, in minutes. 0 for infinite */ - public static function set($key, $value, $maxAgeMinutes = 0) + public static function set(string $key, $value, int $maxAgeMinutes = 0): void { - if (self::$cache === false || self::get($key) != $value) { // Simple compare, so it works for numbers accidentally casted to string somewhere + if ($value === false || $value === null) { + Database::exec("DELETE FROM property WHERE name = :key", ['key' => $key]); + if (self::$cache !== false) { + unset(self::$cache[$key]); + } + } else { Database::exec("INSERT INTO property (name, value, dateline) VALUES (:key, :value, :dateline)" - . " ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)", array( + . " ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)", [ 'key' => $key, 'value' => $value, 'dateline' => ($maxAgeMinutes === 0 ? 0 : time() + ($maxAgeMinutes * 60)) - )); - } - if (self::$cache !== false) { - self::$cache[$key] = $value; + ]); + if (self::$cache !== false) { + self::$cache[$key] = $value; + } } } @@ -60,33 +69,76 @@ class Property * @param string $key Key of list to get all items for * @return array All the items matching the key */ - public static function getList($key) + public static function getList(string $key): array { - $res = Database::simpleQuery("SELECT dateline, value FROM property_list WHERE name = :key", compact('key')); + $res = Database::simpleQuery("SELECT subkey, dateline, value FROM property_list + WHERE `name` = :key", compact('key')); $NOW = time(); - $return = array(); - while ($row = $res->fetch(PDO::FETCH_ASSOC)) { + $return = []; + foreach ($res as $row) { if ($row['dateline'] != 0 && $row['dateline'] < $NOW) continue; - $return[] = $row['value']; + $return[$row['subkey']] = $row['value']; } return $return; } /** + * @return ?string entry from property list + */ + public static function getListEntry(string $key, int $subkey): ?string + { + $row = Database::queryFirst("SELECT dateline, `value` FROM property_list + WHERE `name` = :key AND subkey = :subkey", ['key' => $key, 'subkey' => $subkey]); + if ($row === false || ($row['dateline'] != 0 && $row['dateline'] < time())) + return null; + return $row['value']; + } + + /** * Add item to property list. * * @param string $key key of value to set * @param string $value the value to add for $key * @param int $maxAgeMinutes how long to keep this entry around at least, in minutes. 0 for infinite + * @return int The auto generated sub-key */ - public static function addToList($key, $value, $maxAgeMinutes = 0) + public static function addToList(string $key, string $value, int $maxAgeMinutes = 0): int { Database::exec("INSERT INTO property_list (name, value, dateline) VALUES (:key, :value, :dateline)", array( 'key' => $key, 'value' => $value, 'dateline' => ($maxAgeMinutes === 0 ? 0 : time() + ($maxAgeMinutes * 60)) )); + return Database::lastInsertId(); + } + + /** + * Update existing entry in property list. + * + * @param string $key key of list + * @param int $subkey subkey of entry in list + * @param string $value new value to set entry to + * @param int $maxAgeMinutes the new lifetime of that entry + * @param ?string $expectedValue if not null, the value will only be updated if it currently has this value + * @return bool whether the entry existed and has been updated + */ + public static function updateListEntry(string $key, int $subkey, string $value, + int $maxAgeMinutes = 0, string $expectedValue = null): bool + { + $args = [ + 'name' => $key, + 'subkey' => $subkey, + 'newvalue' => $value, + 'dateline' => ($maxAgeMinutes === 0 ? 0 : time() + ($maxAgeMinutes * 60)), + ]; + if ($expectedValue !== null) { + $args['oldvalue'] = $expectedValue; + return Database::exec("UPDATE property_list SET `value` = :newvalue, dateline = :dateline + WHERE `name` = :name AND subkey = :subkey AND `value` = :oldvalue", $args) > 0; + } + return Database::exec("UPDATE property_list SET `value` = :newvalue, dateline = :dateline + WHERE `name` = :name AND subkey = :subkey", $args) > 0; } /** @@ -97,7 +149,7 @@ class Property * @param string $value item to remove * @return int number of items removed */ - public static function removeFromList($key, $value) + public static function removeFromListByVal(string $key, string $value): int { return Database::exec("DELETE FROM property_list WHERE name = :key AND value = :value", array( 'key' => $key, @@ -106,12 +158,28 @@ class Property } /** + * Remove given item from property list. If the list contains this item + * multiple times, they will all be removed. + * + * @param string $key Key of list + * @param int $value item to remove + * @return bool whether item was found and removed + */ + public static function removeFromListByKey(string $key, int $subkey): bool + { + return Database::exec("DELETE FROM property_list WHERE name = :key AND subkey = :subkey", array( + 'key' => $key, + 'subkey' => $subkey, + )) > 0; + } + + /** * Delete entire list with given key. * * @param string $key Key of list * @return int number of items removed */ - public static function clearList($key) + public static function clearList(string $key): int { return Database::exec("DELETE FROM property_list WHERE name = :key", compact('key')); } @@ -120,12 +188,12 @@ class Property * Legacy getters/setters */ - public static function getServerIp() + public static function getServerIp(): string { - return self::get('server-ip', 'none'); + return self::get('server-ip', 'invalid'); } - public static function setServerIp($value, $automatic = false) + public static function setServerIp(string $value, bool $automatic = false): bool { if ($value === self::getServerIp()) return false; @@ -135,19 +203,15 @@ class Property return true; } - public static function getBootMenu() - { - return json_decode(self::get('ipxe-menu'), true); - } - - public static function setBootMenu($value) - { - self::set('ipxe-menu', json_encode($value)); - } - - public static function getVmStoreConfig() + public static function getVmStoreConfig(): array { - return json_decode(self::get('vmstore-config'), true); + $data = self::get('vmstore-config'); + if (!is_string($data)) + return []; + $data = json_decode($data, true); + if (!is_array($data)) + return []; + return $data; } public static function getVmStoreUrl() @@ -169,21 +233,6 @@ class Property self::set('vmstore-config', json_encode($value)); } - public static function getDownloadTask($name) - { - return self::get('dl-' . $name); - } - - public static function setDownloadTask($name, $taskId) - { - self::set('dl-' . $name, $taskId, 5); - } - - public static function getCurrentSchemaVersion() - { - return self::get('webif-version'); - } - public static function setLastWarningId($id) { self::set('last-warn-event-id', $id); @@ -194,22 +243,22 @@ class Property return self::get('last-warn-event-id', 0); } - public static function setNeedsSetup($value) + public static function setNeedsSetup(bool $value) { - self::set('needs-setup', $value); + self::set('needs-setup', (int)$value); } - public static function getNeedsSetup() + public static function getNeedsSetup(): bool { - return self::get('needs-setup'); + return self::get('needs-setup') != 0; } - public static function setPasswordFieldType($value) + public static function setPasswordFieldType(string $value) { self::set('password-type', $value); } - public static function getPasswordFieldType() + public static function getPasswordFieldType(): string { return self::get('password-type', 'password'); } diff --git a/inc/render.inc.php b/inc/render.inc.php index 2c3a1da7..a636382e 100644 --- a/inc/render.inc.php +++ b/inc/render.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + require_once('inc/util.inc.php'); require_once('Mustache/Autoloader.php'); @@ -14,21 +16,22 @@ class Render { /** - * @var Mustache_Engine + * @var ?Mustache_Engine */ - private static $mustache = false; + private static $mustache = null; private static $body = ''; private static $header = ''; - private static $dashboard = false; + /** @var ?array */ + private static $dashboard = null; private static $footer = ''; private static $title = ''; private static $templateCache = array(); private static $tags = array(); - public static function init() + public static function init(): void { - if (self::$mustache !== false) - Util::traceError('Called Render::init() twice!'); + if (self::$mustache !== null) + ErrorHandler::traceError('Called Render::init() twice!'); $options = array(); $tmp = '/tmp/bwlp-cache'; $dir = is_dir($tmp); @@ -41,7 +44,7 @@ class Render self::$mustache = new Mustache_Engine($options); } - private static function cssEsc($str) + private static function cssEsc(string $str): string { return str_replace(array('"', '&', '<', '>'), array('\\000022', '\\000026', '\\00003c', '\\00003e'), $str); } @@ -49,12 +52,10 @@ class Render /** * Output the buffered, generated page */ - public static function output() + public static function output(): void { Header('Content-Type: text/html; charset=utf-8'); - /* @var $modules Module[] */ $modules = array_reverse(Module::getActivated()); - $pageModule = Page::getModule(); $title = Property::get('page-title-prefix', ''); $bgcolor = Property::get('logo-background', ''); if (!empty($bgcolor) || !empty($title)) { @@ -99,7 +100,7 @@ class Render ' </head> <body> ', - (self::$dashboard !== false ? self::parse('main-menu', self::$dashboard, 'main') : ''), + (self::$dashboard !== null ? self::parse('main-menu', self::$dashboard, 'main') : ''), '<div class="main" id="mainpage"><div class="container-fluid"> ', self::$body @@ -128,7 +129,7 @@ class Render /** * Set the page title (title-tag) */ - public static function setTitle($title, $override = true) + public static function setTitle(string $title, bool $override = true): void { if (!$override && !empty(self::$title)) return; @@ -138,7 +139,7 @@ class Render /** * Add raw html data to the header-section of the generated page */ - public static function addHeader($html) + public static function addHeader(string $html): void { self::$header .= $html . "\n"; } @@ -146,7 +147,7 @@ class Render /** * Add raw html data to the footer-section of the generated page (right before the closing body tag) */ - public static function addFooter($html) + public static function addFooter(string $html): void { self::$footer .= $html . "\n"; } @@ -154,7 +155,7 @@ class Render /** * Add the given template to the output, using the given params for placeholders in the template */ - public static function addTemplate($template, $params = false, $module = false) + public static function addTemplate(string $template, array $params = [], ?string $module = null) { self::$body .= self::parse($template, $params, $module); } @@ -167,7 +168,7 @@ class Render * @param string $template template used to fill the dialog body * @param array $params parameters for rendering the body template */ - public static function addDialog($title, $next, $template, $params = false) + public static function addDialog(string $title, bool $next, string $template, array $params = []): void { self::addTemplate('dialog-generic', array( 'title' => $title, @@ -179,7 +180,7 @@ class Render /** * Add error message to page */ - public static function addError($message) + public static function addError($message): void { self::addTemplate('messagebox-error', array('message' => $message)); } @@ -188,12 +189,13 @@ class Render * Parse template with given params and return; do not add to body * @param string $template name of template, relative to templates/, without .html extension * @param array $params tags to render into template - * @param string $module name of module to load template from; defaults to currently active module + * @param ?string $module name of module to load template from; defaults to currently active module + * @param ?string $lang override language if not null * @return string Rendered template */ - public static function parse($template, $params = false, $module = false, $lang = false) + public static function parse(string $template, array $params = [], ?string $module = null, ?string $lang = null): string { - if ($module === false && class_exists('Page')) { + if ($module === null && class_exists('Page', false)) { $module = Page::getModule()->getIdentifier(); } // Load html snippet @@ -201,9 +203,6 @@ class Render if ($html === false) { return '<h3>Template ' . htmlspecialchars($template) . '</h3>' . nl2br(htmlspecialchars(print_r($params, true))) . '<hr>'; } - if (!is_array($params)) { - $params = array(); - } // Now find all language tags in this array if (preg_match_all('/{{\s*(lang_.+?)\s*}}/', $html, $out) > 0) { $dictionary = Dictionary::getArray($module, 'template-tags', $lang); @@ -211,14 +210,14 @@ class Render foreach ($out[1] as $tag) { if ($fallback === false && empty($dictionary[$tag])) { $fallback = true; // Fallback to general dictionary of main module - $dictionary = $dictionary + Dictionary::getArray('main', 'global-tags'); + $dictionary += Dictionary::getArray('main', 'global-tags'); } // Add untranslated strings to the dictionary, so their tag is seen in the rendered page if (empty($dictionary[$tag])) { $dictionary[$tag] = '{{' . $tag . '}}'; } } - $params = $params + $dictionary; + $params += $dictionary; } // Always add token to parameter list $params['token'] = Session::get('token'); @@ -244,7 +243,7 @@ class Render */ public static function openTag($tag, $params = false) { - array_push(self::$tags, $tag); + self::$tags[] = $tag; if (!is_array($params)) { self::$body .= '<' . $tag . '>'; } else { @@ -262,17 +261,18 @@ class Render public static function closeTag($tag) { if (empty(self::$tags)) - Util::traceError('Tried to close tag ' . $tag . ' when no open tags exist.'); + ErrorHandler::traceError('Tried to close tag ' . $tag . ' when no open tags exist.'); $last = array_pop(self::$tags); if ($last !== $tag) - Util::traceError('Tried to close tag ' . $tag . ' when last opened tag was ' . $last); + ErrorHandler::traceError('Tried to close tag ' . $tag . ' when last opened tag was ' . $last); self::$body .= '</' . $tag . '>'; } /** * Private helper: Load the given template and return it + * @return false|string */ - private static function getTemplate($template, $module) + private static function getTemplate(string $template, string $module) { $id = "$template/$module"; if (isset(self::$templateCache[$id])) { @@ -287,12 +287,13 @@ class Render /** * Create the dashboard menu */ - public static function setDashboard($params) + public static function setDashboard(array $params): void { self::$dashboard = $params; } - public static function readableColor($hex) { + public static function readableColor(string $hex): string + { if (strlen($hex) <= 4) { $cnt = 1; } else { diff --git a/inc/request.inc.php b/inc/request.inc.php index 7e9ed97e..cd782d99 100644 --- a/inc/request.inc.php +++ b/inc/request.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Wrapper for getting fields from the request (GET, POST, ...) */ @@ -23,7 +25,7 @@ class Request * @param string $type if the parameter exists, cast it to given type * @return mixed Field from $_GET, or $default if not set */ - public static function get($key, $default = false, $type = false) + public static function get(string $key, $default = false, $type = false) { return self::handle($_GET, $key, $default, $type); } @@ -34,7 +36,7 @@ class Request * @param string $default Value to return if $_POST does not contain $key * @return mixed Field from $_POST, or $default if not set */ - public static function post($key, $default = false, $type = false) + public static function post(string $key, $default = false, $type = false) { return self::handle($_POST, $key, $default, $type); } @@ -45,7 +47,7 @@ class Request * @param string $default Value to return if $_REQUEST does not contain $key * @return mixed Field from $_REQUEST, or $default if not set */ - public static function any($key, $default = false, $type = false) + public static function any(string $key, $default = false, $type = false) { return self::handle($_REQUEST, $key, $default, $type); } @@ -53,7 +55,7 @@ class Request /** * @return true iff the request is a POST request */ - public static function isPost() + public static function isPost(): bool { return $_SERVER['REQUEST_METHOD'] === 'POST'; } @@ -61,21 +63,21 @@ class Request /** * @return true iff the request is a GET request */ - public static function isGet() + public static function isGet(): bool { return $_SERVER['REQUEST_METHOD'] === 'GET'; } private static function handle(&$array, $key, $default, $type) { - if (!isset($array[$key])) { + if (!array_key_exists($key, $array)) { if ($default === self::REQUIRED || $default === self::REQUIRED_EMPTY) { Message::addError('main.parameter-missing', $key); Util::redirect('?do=' . $_REQUEST['do']); } return $default; } - if ($default === self::REQUIRED && (string)$array[$key] === '') { + if ($default === self::REQUIRED && $array[$key] === '') { Message::addError('main.parameter-empty', $key); Util::redirect('?do=' . $_REQUEST['do']); } diff --git a/inc/session.inc.php b/inc/session.inc.php index cb52cd38..ccb878cd 100644 --- a/inc/session.inc.php +++ b/inc/session.inc.php @@ -1,19 +1,21 @@ <?php -require_once('config.php'); +declare(strict_types=1); -@mkdir(CONFIG_SESSION_DIR, 0700, true); -@chmod(CONFIG_SESSION_DIR, 0700); -if (!is_writable(CONFIG_SESSION_DIR)) die('Config error: Session Path not writable!'); +require_once('config.php'); class Session { private static $sid = false; private static $data = false; + private static $dataChanged = false; + private static $userId = 0; + private static $updateSessionDateline = false; - private static function generateSessionId($salt) + private static function generateSessionId(string $salt): void { - if (self::$sid !== false) Util::traceError('Error: Asked to generate session id when already set.'); + if (self::$sid !== false) + ErrorHandler::traceError('Error: Asked to generate session id when already set.'); self::$sid = sha1($salt . ',' . mt_rand(0, 65535) . $_SERVER['REMOTE_ADDR'] @@ -27,26 +29,42 @@ class Session ); } - public static function create($salt = '') + public static function create(string $salt, int $userId, bool $fixedAddress): void { self::generateSessionId($salt); - self::$data = array(); + self::$data = []; + self::$userId = $userId; + Database::exec("INSERT INTO session (sid, userid, dateline, lastip, fixedip, data) + VALUES (:sid, :userid, 0, '', :fixedip, '')", [ + 'sid' => self::$sid, + 'userid' => $userId, + 'fixedip' => $fixedAddress ? 1 : 0, + ]); + self::setupSessionAccounting(true); } - public static function load() + public static function load(): bool { // Try to load session id from cookie - if (!self::loadSessionId()) return false; + if (!self::loadSessionId()) + return false; // Succeeded, now try to load session data. If successful, job is done - if (self::readSessionData()) return true; + if (self::readSessionData()) + return true; // Loading session data failed - self::delete(); + self::$sid = false; return false; } - public static function get($key) + public static function getUserId(): int { - if (!isset(self::$data[$key]) || !is_array(self::$data[$key])) return false; + return self::$userId; + } + + public static function get(string $key) + { + if (!isset(self::$data[$key]) || !is_array(self::$data[$key])) + return false; return self::$data[$key][0]; } @@ -55,80 +73,132 @@ class Session * @param mixed $value data to store for key, false = delete * @param int|false $validMinutes validity in minutes, or false = forever */ - public static function set($key, $value, $validMinutes = false) + public static function set(string $key, $value, $validMinutes = 60): void { - if (self::$data === false) Util::traceError('Tried to set session data with no active session'); + if (self::$data === false) + ErrorHandler::traceError('Tried to set session data with no active session'); if ($value === false) { unset(self::$data[$key]); } else { self::$data[$key] = [$value, $validMinutes === false ? false : time() + $validMinutes * 60]; } + self::$dataChanged = true; } - private static function loadSessionId() + private static function loadSessionId(): bool { - if (self::$sid !== false) die('Error: Asked to load session id when already set.'); - if (empty($_COOKIE['sid'])) return false; + if (self::$sid !== false) + ErrorHandler::traceError('Error: Asked to load session id when already set.'); + if (empty($_COOKIE['sid'])) + return false; $id = preg_replace('/[^a-zA-Z0-9]/', '', $_COOKIE['sid']); - if (empty($id)) return false; + if (empty($id)) + return false; self::$sid = $id; return true; } - public static function delete() + public static function delete(): void { - if (self::$sid === false) return; - @unlink(self::getSessionFile()); + if (self::$sid === false) + return; + Database::exec("DELETE FROM session WHERE sid = :sid", + ['sid' => self::$sid]); self::deleteCookie(); self::$sid = false; self::$data = false; } - public static function deleteCookie() + /** + * Kill all sessions of currently logged-in user. This can be used as + * a security measure if the user suspects that a session left open on + * another device could be/is being abused. + */ + public static function deleteAllButCurrent(): void { - Util::clearCookie('sid'); + if (self::$sid === false) + return; + Database::exec("DELETE FROM session WHERE sid <> :sid AND userid = :uid", + ['sid' => self::$sid, 'uid' => self::$userId]); } - - private static function getSessionFile() + + public static function deleteCookie(): void { - if (self::$sid === false) Util::traceError('Error: Tried to access session file when no session id was set.'); - return CONFIG_SESSION_DIR . '/' . self::$sid; + Util::clearCookie('sid'); } - private static function readSessionData() + private static function readSessionData(): bool { - if (self::$data !== false) Util::traceError('Tried to call read session data twice'); - $sessionfile = self::getSessionFile(); - if (!is_readable($sessionfile) || filemtime($sessionfile) + CONFIG_SESSION_TIMEOUT < time()) { - @unlink($sessionfile); - return false; - } - self::$data = @unserialize(@file_get_contents($sessionfile)); - if (self::$data === false) - return false; + if (self::$data !== false) + ErrorHandler::traceError('Tried to call read session data twice'); + $row = Database::queryFirst("SELECT userid, dateline, lastip, fixedip, data FROM session WHERE sid = :sid", + ['sid' => self::$sid]); $now = time(); - $save = false; + if ($row === false || $row['dateline'] < $now) { + self::delete(); + return false; + } + if ($row['fixedip'] && $row['lastip'] !== $_SERVER['REMOTE_ADDR']) { + return false; // Ignore but don't invalidate + } + // Refresh cookie if appropriate + self::setupSessionAccounting(Request::isGet() && $row['dateline'] + 86400 < $now + CONFIG_SESSION_TIMEOUT); + self::$userId = (int)$row['userid']; + self::$data = @json_decode($row['data'], true); + if (!is_array(self::$data)) { + self::$data = []; + } foreach (array_keys(self::$data) as $key) { if (self::$data[$key][1] !== false && self::$data[$key][1] < $now) { unset(self::$data[$key]); - $save = true; + self::$dataChanged = true; } } - if ($save) { - self::save(); - } return true; } - - public static function save() + + private static function setupSessionAccounting(bool $cookie): void { - if (self::$sid === false || self::$data === false) return; //Util::traceError('Called saveSession with no active session'); - $sessionfile = self::getSessionFile(); - $ret = @file_put_contents($sessionfile, @serialize(self::$data)); - if (!$ret) Util::traceError('Storing session data in ' . $sessionfile . ' failed.'); - Util::clearCookie('sid'); - $ret = setcookie('sid', self::$sid, time() + CONFIG_SESSION_TIMEOUT, null, null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true); - if (!$ret) Util::traceError('Error: Could not set Cookie for Client (headers already sent)'); + if ($cookie) { + self::$updateSessionDateline = true; + $ret = setcookie('sid', self::$sid, time() + CONFIG_SESSION_TIMEOUT, + '', '', !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true); + if (!$ret) + ErrorHandler::traceError('Error: Could not set Cookie for Client (headers already sent)'); + } + register_shutdown_function(function () { + self::saveOnShutdown(); + }); } -} + private static function saveOnShutdown(): void + { + $now = time(); + $args = ['lastip' => $_SERVER['REMOTE_ADDR']]; + if (self::$updateSessionDateline) { + $args['dateline'] = $now + CONFIG_SESSION_TIMEOUT; + } + if (self::$dataChanged) { + $args['data'] = json_encode(self::$data); + } + self::saveData($args); + } + + public static function saveExtraData(): void + { + if (!self::$dataChanged) + return; + self::saveData(['data' => json_encode(self::$data)]); + self::$dataChanged = false; + } + + private static function saveData(array $args): void + { + $query = "UPDATE session SET " . implode(', ', array_map(function ($key) { + return "$key = :$key"; + }, array_keys($args))) . " WHERE sid = :sid"; + $args['sid'] = self::$sid; + Database::exec($query, $args); + } + +} diff --git a/inc/taskmanager.inc.php b/inc/taskmanager.inc.php index f7c72e04..d9396901 100644 --- a/inc/taskmanager.inc.php +++ b/inc/taskmanager.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Interface to the external task manager. */ @@ -19,7 +21,7 @@ class Taskmanager */ private static $sock = false; - private static function init() + private static function init(): void { if (self::$sock !== false) return; @@ -32,7 +34,7 @@ class Taskmanager self::send(CONFIG_TM_PASSWORD); } - private static function send($message) + private static function send(string $message): bool { $len = strlen($message); $sent = socket_send(self::$sock, pack('N', $len) . $message, $len + 4, 0); @@ -49,9 +51,9 @@ class Taskmanager * @param array $data data to pass to the task. the structure depends on the task. * @param boolean $async if true, the function will not wait for the reply of the taskmanager, which means * the return value is just true (and you won't know if the task could actually be started) - * @return array|false struct representing the task status (as a result of submit); false on communication error + * @return array{id: string, statusCode: string, data: array}|bool struct representing the task status (as a result of submit); false on communication error */ - public static function submit($task, $data = false, $async = false) + public static function submit(string $task, array $data = null, bool $async = false) { self::init(); $seq = (string) mt_rand(); @@ -109,7 +111,7 @@ class Taskmanager * @param string|array $taskid a task id or a task array returned by ::status or ::submit * @return boolean true if taskid exists in taskmanager */ - public static function isTask($task) + public static function isTask($task): bool { if ($task === false) return false; @@ -127,7 +129,7 @@ class Taskmanager * @param int $timeout maximum time in ms to wait for completion of task * @return array|false result/status of task, or false if it couldn't be queried */ - public static function waitComplete($task, $timeout = 2500) + public static function waitComplete($task, int $timeout = 2500) { if (is_array($task) && isset($task['id'])) { if ($task['statusCode'] !== Taskmanager::TASK_PROCESSING && $task['statusCode'] !== Taskmanager::TASK_WAITING) { @@ -140,8 +142,9 @@ class Taskmanager return false; $done = false; $deadline = microtime(true) + $timeout / 1000; + $status = false; while (($remaining = $deadline - microtime(true)) > 0) { - usleep(min(100000, $remaining * 100000)); + usleep((int)min(100000, $remaining * 100000)); $status = self::status($task); if (!isset($status['statusCode'])) break; @@ -163,7 +166,7 @@ class Taskmanager * @param array|false $task struct representing task, obtained by ::status * @return boolean true if task failed, false if finished successfully or still waiting/running */ - public static function isFailed($task) + public static function isFailed($task): bool { if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id'])) return true; @@ -176,10 +179,10 @@ class Taskmanager * Check whether the given task is finished, i.e. either failed or succeeded, * but is not running, still waiting for execution or simply unknown. * - * @param array $task struct representing task, obtained by ::status + * @param mixed $task struct representing task, obtained by ::status * @return boolean true if task failed or finished, false if waiting for execution or currently executing, no valid task, etc. */ - public static function isFinished($task) + public static function isFinished($task): bool { if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id'])) return false; @@ -192,10 +195,10 @@ class Taskmanager * Check whether the given task is running, that is either waiting for execution * or currently executing. * - * @param array $task struct representing task, obtained by ::status + * @param mixed $task struct representing task, obtained by ::status * @return boolean true if task is waiting or executing, false if waiting for execution or currently executing, no valid task, etc. */ - public static function isRunning($task) + public static function isRunning($task): bool { if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id'])) return false; @@ -204,7 +207,7 @@ class Taskmanager return false; } - public static function addErrorMessage($task) + public static function addErrorMessage($task): void { static $failure = false; if ($task === false) { @@ -231,7 +234,7 @@ class Taskmanager * @param string|array $task task to release. can either be its id, or a struct representing the task, as returned * by ::submit() or ::status() */ - public static function release($task) + public static function release($task): void { if (is_array($task) && isset($task['id'])) { $task = $task['id']; @@ -247,7 +250,6 @@ class Taskmanager /** * Read reply from socket for given sequence number. * - * @param string $seq * @return mixed the decoded json data for that message as an array, or false on error */ private static function readReply(string $seq) @@ -288,7 +290,7 @@ class Taskmanager if (count($parts) !== 2) { error_log('TM: Invalid reply, no "," in payload'); } elseif ($parts[0] === 'ERROR') { - Util::traceError('Taskmanager remote error: ' . $parts[1]); + ErrorHandler::traceError('Taskmanager remote error: ' . $parts[1]); } elseif ($parts[0] === 'WARNING') { Message::addWarning('main.taskmanager-warning', $parts[1]); } else { @@ -324,7 +326,7 @@ class Taskmanager * sending or receiving and the send or receive * buffer might be in an undefined state. */ - private static function reset() + private static function reset(): void { if (self::$sock === false) return; @@ -335,7 +337,7 @@ class Taskmanager /** * @param float $deadline end time */ - private static function updateRecvTimeout($deadline) + private static function updateRecvTimeout(float $deadline): void { $to = $deadline - microtime(true); if ($to <= 0) { diff --git a/inc/taskmanagercallback.inc.php b/inc/taskmanagercallback.inc.php index 5f153baa..c6b447c9 100644 --- a/inc/taskmanagercallback.inc.php +++ b/inc/taskmanagercallback.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * Contains all callbacks for detached taskmanager tasks. */ @@ -13,7 +15,7 @@ class TaskmanagerCallback * @param string|array $task Task or Task ID to define callback for * @param string $callback name of callback function, must be a static method in this class */ - public static function addCallback($task, $callback, $args = NULL) + public static function addCallback($task, string $callback, $args = NULL): void { if (!call_user_func_array('method_exists', array('TaskmanagerCallback', $callback))) { EventLog::warning("addCallback: Invalid callback function: $callback"); @@ -50,13 +52,13 @@ class TaskmanagerCallback * * @return array list of array(taskid => list of callbacks) */ - public static function getPendingCallbacks() + public static function getPendingCallbacks(): array { $res = Database::simpleQuery("SELECT taskid, cbfunction, args FROM callback", array(), true); if ($res === false) return array(); $retval = array(); - while ($row = $res->fetch(PDO::FETCH_ASSOC)) { + foreach ($res as $row) { $retval[$row['taskid']][] = $row; } return $retval; @@ -67,9 +69,9 @@ class TaskmanagerCallback * table if appropriate. * * @param array $callback entry from the callback table (cbfunction + taskid + args) - * @param array $status status of the task as returned by the taskmanager. If NULL it will be queried. + * @param ?array $status status of the task as returned by the taskmanager. If NULL it will be queried. */ - public static function handleCallback($callback, $status = NULL) + public static function handleCallback(array $callback, array $status = NULL): void { if (is_null($status)) $status = Taskmanager::status($callback['taskid']); @@ -82,12 +84,13 @@ class TaskmanagerCallback } } if (Taskmanager::isFinished($status)) { + Taskmanager::release($status); $func = array('TaskmanagerCallback', preg_replace('/\W/', '', $callback['cbfunction'])); if (!call_user_func_array('method_exists', $func)) { Eventlog::warning("handleCallback: Callback {$callback['cbfunction']} doesn't exist."); } else { if (empty($callback['args'])) - call_user_func($func, $status); + call_user_func($func, $status, null); else call_user_func($func, $status, unserialize($callback['args'])); } @@ -99,7 +102,7 @@ class TaskmanagerCallback /** * Result of trying to (re)launch ldadp. */ - public static function ldadpStartup($task) + public static function ldadpStartup(array $task) { if (Taskmanager::isFailed($task)) { if (!isset($task['data']['messages'])) { @@ -112,14 +115,14 @@ class TaskmanagerCallback /** * Result of restoring the server configuration */ - public static function dbRestored($task) + public static function dbRestored(array $task) { if (!Taskmanager::isFailed($task)) { EventLog::info('Configuration backup restored.'); } } - public static function adConfigCreate($task) + public static function adConfigCreate(array $task) { if (Taskmanager::isFailed($task)) EventLog::warning("Could not generate Active Directory configuration", $task['data']['error']); @@ -131,7 +134,7 @@ class TaskmanagerCallback * @param array $task task obj * @param array $args has keys 'moduleid' and optionally 'deleteOnError' and 'tmpTgz' */ - public static function cbConfModCreated($task, $args) + public static function cbConfModCreated(array $task, array $args) { $mod = Module::get('sysconfig'); if ($mod === false) @@ -150,7 +153,7 @@ class TaskmanagerCallback * @param array $task task obj * @param array $args has keys 'configid' and optionally 'deleteOnError' */ - public static function cbConfTgzCreated($task, $args) + public static function cbConfTgzCreated(array $task, array $args) { $mod = Module::get('sysconfig'); if ($mod === false) @@ -163,7 +166,7 @@ class TaskmanagerCallback } } - public static function manualMount($task, $args) + public static function manualMount(array $task, $args) { if (!isset($task['data']['exitCode'])) return; @@ -180,11 +183,10 @@ class TaskmanagerCallback unset($data['storetype']); Property::setVmStoreConfig($data); } - return; } } - public static function mlGotList($task, $args) + public static function mlGotList(array $task, $args) { $mod = Module::get('minilinux'); if ($mod === false) @@ -193,7 +195,7 @@ class TaskmanagerCallback MiniLinux::listDownloadCallback($task, $args); } - public static function mlGotLinux($task, $args) + public static function mlGotLinux(array $task, $args) { $mod = Module::get('minilinux'); if ($mod === false) @@ -202,7 +204,7 @@ class TaskmanagerCallback MiniLinux::linuxDownloadCallback($task, $args); } - public static function rbcConnCheck($task, $args) + public static function rbcConnCheck(array $task, $args) { $mod = Module::get('rebootcontrol'); if ($mod === false) @@ -211,4 +213,31 @@ class TaskmanagerCallback RebootControl::connectionCheckCallback($task, $args); } + public static function ipxeVersionSet(array $task) + { + $mod = Module::get('serversetup'); + if ($mod === false) + return; + $mod->activate(1, false); + IPxeBuilder::setIPxeVersionCallback($task); + } + + public static function ipxeCompileDone(array $task) + { + $mod = Module::get('serversetup'); + if ($mod === false) + return; + $mod->activate(1, false); + IPxeBuilder::compileCompleteCallback($task); + } + + public static function ssUpgradable(array $task): void + { + $mod = Module::get('systemstatus'); + if ($mod === false) + return; + $mod->activate(1, false); + SystemStatus::setUpgradableData($task); + } + } diff --git a/inc/trigger.inc.php b/inc/trigger.inc.php index 5024b907..d0d5d365 100644 --- a/inc/trigger.inc.php +++ b/inc/trigger.inc.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + /** * This is one giant class containing various functions that will generate * required config files, daemon instances and more, mostly through the Taskmanager. @@ -14,20 +16,21 @@ class Trigger /** * Compile iPXE pxelinux menu. Needs to be done whenever the server's IP * address changes. - * - * @param boolean $force force recompilation even if it seems up to date - * @return boolean|string false if launching task failed, task-id otherwise + * + * @return ?string null if launching task failed, task-id otherwise */ - public static function ipxe() + public static function ipxe(string $taskId = null): ?string { + static $lastResult = null; + if ($lastResult !== null) + return $lastResult; $hooks = Hook::load('ipxe-update'); - static $taskId = false; foreach ($hooks as $hook) { $ret = function($taskId) use ($hook) { $ret = include_once($hook->file); if (is_string($ret)) return $ret; - return isset($taskId) ? $taskId : false; + return $taskId; }; $ret = $ret($taskId); if (is_string($ret)) { @@ -36,7 +39,7 @@ class Trigger $taskId = $ret['id']; } } - Property::set('ipxe-task-id', $taskId, 15); + $lastResult = $taskId; return $taskId; } @@ -48,7 +51,7 @@ class Trigger * @return boolean true if current configured IP address is still valid, or if a new address could * successfully be determined, false otherwise */ - public static function autoUpdateServerIp() + public static function autoUpdateServerIp(): bool { for ($i = 0; $i < 5; ++$i) { $task = Taskmanager::submit('LocalAddressesList'); @@ -59,7 +62,7 @@ class Trigger if ($task === false) return false; $task = Taskmanager::waitComplete($task, 10000); - if (!isset($task['data']['addresses']) || empty($task['data']['addresses'])) + if (empty($task['data']['addresses'])) return false; $serverIp = Property::getServerIp(); @@ -96,22 +99,18 @@ class Trigger /** * Mount the VM store into the server. * - * @param array $vmstore VM Store configuration to use. If false, read from properties + * @param array|false $vmstore VM Store configuration to use. If false, read from properties * @param bool $ifLocalOnly Only execute task if the storage type is local (used for DNBD3) - * @return string|false task id of mount procedure, or false on error + * @return ?string task id of mount procedure, or false on error */ - public static function mount($vmstore = false, $ifLocalOnly = false) + public static function mount($vmstore = false, bool $ifLocalOnly = false): ?string { if ($vmstore === false) { $vmstore = Property::getVmStoreConfig(); } if (!is_array($vmstore)) - return false; - if (isset($vmstore['storetype'])) { - $storetype = $vmstore['storetype']; - } else { - $storetype = 'unknown'; - } + return null; + $storetype = $vmstore['storetype'] ?? 'unknown'; if ($storetype === 'nfs') { $addr = $vmstore['nfsaddr']; $opts = 'nfsopts'; @@ -124,12 +123,8 @@ class Trigger } // Bail out if storage is not local, and we only want to run it in that case if ($ifLocalOnly && $addr !== 'null') - return false; - if (isset($vmstore[$opts])) { - $opts = $vmstore[$opts]; - }else { - $opts = null; - } + return null; + $opts = $vmstore[$opts] ?? null; $status = Taskmanager::submit('MountVmStore', array( 'address' => $addr, 'type' => 'images', @@ -143,7 +138,7 @@ class Trigger // for the taskmanager to give us the existing id $status = Taskmanager::waitComplete($status, 100); } - return $status['data']['existingTask'] ?? $status['id'] ?? false; + return $status['data']['existingTask'] ?? $status['id'] ?? null; } /** @@ -151,7 +146,7 @@ class Trigger * * @return boolean Whether there are still callbacks pending */ - public static function checkCallbacks() + public static function checkCallbacks(): bool { $tasksLeft = false; $callbackList = TaskmanagerCallback::getPendingCallbacks(); @@ -162,15 +157,16 @@ class Trigger foreach ($callbacks as $callback) { TaskmanagerCallback::handleCallback($callback, $status); } - if (Taskmanager::isFailed($status) || Taskmanager::isFinished($status)) + if (Taskmanager::isFailed($status) || Taskmanager::isFinished($status)) { Taskmanager::release($status); - else + } else { $tasksLeft = true; + } } return $tasksLeft; } - private static function triggerDaemons($action, $parent, &$taskids) + private static function triggerDaemons(string $action, ?string $parent, array &$taskids): ?string { $task = Taskmanager::submit('Systemctl', array( 'operation' => $action, @@ -198,7 +194,7 @@ class Trigger /** * Stop any daemons that might be sitting on the VMstore, or database. */ - public static function stopDaemons($parent, &$taskids) + public static function stopDaemons(?string $parent, array &$taskids): ?string { $parent = self::triggerDaemons('stop', $parent, $taskids); $task = Taskmanager::submit('LdadpLauncher', array( diff --git a/inc/user.inc.php b/inc/user.inc.php index 20e8cd3d..9ef27cd0 100644 --- a/inc/user.inc.php +++ b/inc/user.inc.php @@ -1,5 +1,9 @@ <?php +declare(strict_types=1); + +use JetBrains\PhpStorm\NoReturn; + require_once('inc/session.inc.php'); class User @@ -7,7 +11,7 @@ class User private static $user = false; - public static function isLoggedIn() + public static function isLoggedIn(): bool { return self::$user !== false; } @@ -26,12 +30,12 @@ class User return self::$user['fullname']; } - public static function hasPermission($permission, $locationid = NULL) + public static function hasPermission(string $permission, ?int $locationid = NULL): bool { if (!self::isLoggedIn()) return false; if (Module::isAvailable("permissionmanager")) { - if ($permission{0} === '.') { + if ($permission[0] === '.') { $permission = substr($permission, 1); } else { if (class_exists('Page')) { @@ -54,11 +58,12 @@ class User /** * Confirm current user has the given permission, stop execution and show error message * otherwise. + * * @param string $permission Permission to check for * @param null|int $locationid location this permission has to apply to, NULL if any location is sufficient * @param null|string $redirect page to redirect to if permission is not given, NULL defaults to main page */ - public static function assertPermission($permission, $locationid = NULL, $redirect = NULL) + public static function assertPermission(string $permission, ?int $locationid = NULL, ?string $redirect = NULL): void { if (User::hasPermission($permission, $locationid)) return; @@ -70,7 +75,7 @@ class User Message::addError('main.no-permission'); Util::redirect($redirect); } elseif (Module::isAvailable('permissionmanager')) { - if ($permission{0} !== '.') { + if ($permission[0] !== '.') { $module = Page::getModule(); if ($module !== false) { $permission = '.' . $module->getIdentifier() . '.' . $permission; @@ -83,12 +88,12 @@ class User } } - public static function getAllowedLocations($permission) + public static function getAllowedLocations(string $permission): array { if (!self::isLoggedIn()) return []; if (Module::isAvailable("permissionmanager")) { - if ($permission{0} === '.') { + if ($permission[0] === '.') { $permission = substr($permission, 1); } else { $module = Page::getModule(); @@ -105,16 +110,19 @@ class User } return $a; } - return array(); + return []; } - public static function load() + public static function load(): bool { if (self::isLoggedIn()) return true; if (Session::load()) { - $uid = Session::get('uid'); - if ($uid === false || $uid < 1) + if (empty(Session::get('token'))) { + self::generateToken(); + } + $uid = Session::getUserId(); + if ($uid < 1) self::logout(); self::$user = Database::queryFirst('SELECT * FROM user WHERE userid = :uid LIMIT 1', array(':uid' => $uid)); if (self::$user === false) @@ -125,7 +133,7 @@ class User return false; } - public static function testPassword($userid, $password) + public static function testPassword(string $userid, string $password): bool { $ret = Database::queryFirst('SELECT passwd FROM user WHERE userid = :userid LIMIT 1', compact('userid')); if ($ret === false) @@ -133,7 +141,7 @@ class User return Crypto::verify($password, $ret['passwd']); } - public static function updatePassword($password) + public static function updatePassword(string $password): bool { if (!self::isLoggedIn()) return false; @@ -142,36 +150,27 @@ class User return Database::exec('UPDATE user SET passwd = :passwd WHERE userid = :userid LIMIT 1', compact('userid', 'passwd')) > 0; } - public static function login($user, $pass) + public static function login(string $user, string $pass, bool $fixedIp): bool { $ret = Database::queryFirst('SELECT userid, passwd FROM user WHERE login = :user LIMIT 1', array(':user' => $user)); if ($ret === false) return false; if (!Crypto::verify($pass, $ret['passwd'])) return false; - Session::create($ret['passwd']); - Session::set('uid', $ret['userid']); - Session::set('token', md5($ret['passwd'] . ',' - . rand() . ',' - . time() . ',' - . rand() . ',' - . $_SERVER['REMOTE_ADDR'] . ',' - . rand() . ',' - . $_SERVER['REMOTE_PORT'] . ',' - . rand() . ',' - . $_SERVER['HTTP_USER_AGENT'])); - Session::save(); + Session::create($ret['passwd'], (int)$ret['userid'], $fixedIp); + self::generateToken($ret['passwd']); return true; } - public static function logout() + #[NoReturn] + public static function logout(): void { Session::delete(); Header('Location: ?do=Main&fromlogout'); exit(0); } - public static function setLastSeenEvent($eventid) + public static function setLastSeenEvent(int $eventid): void { if (!self::isLoggedIn()) return; @@ -189,4 +188,17 @@ class User return self::$user['lasteventid']; } + private static function generateToken($salt = ''): void + { + Session::set('token', md5($salt . ',' + . rand() . ',' + . time() . ',' + . rand() . ',' + . $_SERVER['REMOTE_ADDR'] . ',' + . rand() . ',' + . $_SERVER['REMOTE_PORT'] . ',' + . rand() . ',' + . $_SERVER['HTTP_USER_AGENT']), false); + } + } diff --git a/inc/util.inc.php b/inc/util.inc.php index 365dc045..267a3971 100644 --- a/inc/util.inc.php +++ b/inc/util.inc.php @@ -1,164 +1,27 @@ <?php +declare(strict_types=1); + +use JetBrains\PhpStorm\NoReturn; + class Util { private static $redirectParams = array(); /** - * Displays an error message and stops script execution. - * If CONFIG_DEBUG is true, it will also dump a stack trace - * and all globally defined variables. - * (As this might reveal sensitive data you should never enable it in production) - */ - public static function traceError($message) - { - if ((defined('API') && API) || (defined('AJAX') && AJAX) || php_sapi_name() === 'cli') { - error_log('API ERROR: ' . $message); - error_log(self::formatBacktracePlain(debug_backtrace())); - } - if (php_sapi_name() === 'cli') { - // Don't spam HTML when invoked via cli, above error_log should have gone to stdout/stderr - exit(1); - } - Header('HTTP/1.1 500 Internal Server Error'); - if (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'html') === false ) { - Header('Content-Type: text/plain; charset=utf-8'); - echo 'API ERROR: ', $message, "\n", self::formatBacktracePlain(debug_backtrace()); - exit(0); - } - Header('Content-Type: text/html; charset=utf-8'); - echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><style>', "\n", - ".arg { color: red; background: white; }\n", - "h1 a { color: inherit; text-decoration: inherit; font-weight: inherit; }\n", - '</style><title>Fatal Error</title></head><body>'; - echo '<h1>Flagrant <a href="https://www.youtube.com/watch?v=7rrZ-sA4FQc&t=2m2s" target="_blank">S</a>ystem error</h1>'; - echo "<h2>Message</h2><pre>$message</pre>"; - if (strpos($message, 'Database') !== false) { - echo '<div><a href="install.php">Try running database setup</a></div>'; - } - echo "<br><br>"; - if (defined('CONFIG_DEBUG') && CONFIG_DEBUG) { - global $SLX_ERRORS; - if (!empty($SLX_ERRORS)) { - echo '<h2>PHP Errors</h2><pre>'; - foreach ($SLX_ERRORS as $error) { - echo htmlspecialchars("{$error['errstr']} ({$error['errfile']}:{$error['errline']}\n"); - } - echo '</pre>'; - } - echo "<h2>Stack Trace</h2>"; - echo '<pre>', self::formatBacktraceHtml(debug_backtrace()), '</pre>'; - echo "<h2>Globals</h2><pre>"; - echo htmlspecialchars(print_r($GLOBALS, true)); - echo '</pre>'; - } else { - echo <<<SADFACE -<pre> -________________________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶________ -____________________¶¶¶___________________¶¶¶¶_____ -________________¶¶¶_________________________¶¶¶¶___ -______________¶¶______________________________¶¶¶__ -___________¶¶¶_________________________________¶¶¶_ -_________¶¶_____________________________________¶¶¶ -________¶¶_________¶¶¶¶¶___________¶¶¶¶¶_________¶¶ -______¶¶__________¶¶¶¶¶¶__________¶¶¶¶¶¶_________¶¶ -_____¶¶___________¶¶¶¶____________¶¶¶¶___________¶¶ -____¶¶___________________________________________¶¶ -___¶¶___________________________________________¶¶_ -__¶¶____________________¶¶¶¶____________________¶¶_ -_¶¶_______________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶______________¶¶__ -_¶¶____________¶¶¶¶___________¶¶¶¶¶___________¶¶___ -¶¶¶_________¶¶¶__________________¶¶__________¶¶____ -¶¶_________¶______________________¶¶________¶¶_____ -¶¶¶______¶________________________¶¶_______¶¶______ -¶¶¶_____¶_________________________¶¶_____¶¶________ -_¶¶¶___________________________________¶¶__________ -__¶¶¶________________________________¶¶____________ -___¶¶¶____________________________¶¶_______________ -____¶¶¶¶______________________¶¶¶__________________ -_______¶¶¶¶¶_____________¶¶¶¶¶_____________________ -</pre> -SADFACE; - } - echo '</body></html>'; - exit(0); - } - - private static function formatArgument($arg, $expandArray = true) - { - if (is_string($arg)) { - $arg = "'$arg'"; - } elseif (is_object($arg)) { - $arg = 'instanceof ' . get_class($arg); - } elseif (is_array($arg)) { - if ($expandArray && count($arg) < 20) { - $expanded = ''; - foreach ($arg as $key => $value) { - if (!empty($expanded)) { - $expanded .= ', '; - } - $expanded .= $key . ': ' . self::formatArgument($value, false); - if (strlen($expanded) > 200) - break; - } - if (strlen($expanded) <= 200) - return '[' . $expanded . ']'; - } - $arg = 'Array(' . count($arg) . ')'; - } - return $arg; - } - - public static function formatBacktraceHtml($trace) - { - $output = ''; - foreach ($trace as $idx => $line) { - $args = array(); - foreach ($line['args'] as $arg) { - $arg = self::formatArgument($arg); - $args[] = '<span class="arg">' . htmlspecialchars($arg) . '</span>'; - } - $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT); - $function = htmlspecialchars($line['function']); - $args = implode(', ', $args); - $file = preg_replace('~(/[^/]+)$~', '<b>$1</b>', htmlspecialchars($line['file'])); - // Add line - $output .= $frame . ' ' . $function . '<b>(</b>' - . $args . '<b>)</b>' . ' @ <i>' . $file . '</i>:' . $line['line'] . "\n"; - } - return $output; - } - - public static function formatBacktracePlain($trace) - { - $output = ''; - foreach ($trace as $idx => $line) { - $args = array(); - foreach ($line['args'] as $arg) { - $args[] = self::formatArgument($arg); - } - $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT); - $args = implode(', ', $args); - // Add line - $output .= "\n" . $frame . ' ' . $line['function'] . '(' - . $args . ')' . ' @ ' . $line['file'] . ':' . $line['line']; - } - return $output; - } - - /** * Redirects the user via a '302 Moved' header. * An active session will be saved, any messages that haven't * been displayed yet will be appended to the redirect. + * * @param string|false $location Location to redirect to. "false" to redirect to same URL (useful after POSTs) * @param bool $preferRedirectPost if true, use the value from $_POST['redirect'] instead of $location */ - public static function redirect($location = false, $preferRedirectPost = false) + #[NoReturn] + public static function redirect($location = false, bool $preferRedirectPost = false): void { if ($location === false) { - $location = preg_replace('/([&?])message\[\]\=[^&]*/', '\1', $_SERVER['REQUEST_URI']); + $location = preg_replace('/([&?])message\[\]=[^&]*/', '\1', $_SERVER['REQUEST_URI']); } - Session::save(); $messages = Message::toRequest(); if ($preferRedirectPost && ($redirect = Request::post('redirect', false, 'string')) !== false @@ -190,7 +53,7 @@ SADFACE; exit(0); } - public static function addRedirectParam($key, $value) + public static function addRedirectParam(string $key, string $value): void { self::$redirectParams[] = $key . '=' . urlencode($value); } @@ -202,7 +65,7 @@ SADFACE; * token, this function will return false and display an error. * If the token matches, or the user is not logged in, it will return true. */ - public static function verifyToken() + public static function verifyToken(): bool { if (!User::isLoggedIn() && Session::get('token') === false) return true; @@ -219,67 +82,71 @@ SADFACE; * _word_ is underlined * \n is line break */ - public static function markup($string) + public static function markup(string $string): string { $string = htmlspecialchars($string); - $string = preg_replace('#(^|[\n \-_/\.])\*(.+?)\*($|[ \-_/\.\!\?,:])#is', '$1<b>$2</b>$3', $string); - $string = preg_replace('#(^|[\n \-\*/\.])_(.+?)_($|[ \-\*/\.\!\?,:])#is', '$1<u>$2</u>$3', $string); - $string = preg_replace('#(^|[\n \-_\*\.])/(.+?)/($|[ \-_\*\.\!\?,:])#is', '$1<i>$2</i>$3', $string); + $string = preg_replace('#(^|[\n \-_/.])\*(.+?)\*($|[ \-_/.!?,:])#is', '$1<b>$2</b>$3', $string); + $string = preg_replace('#(^|[\n \-*/.])_(.+?)_($|[ \-*/.!?,:])#is', '$1<u>$2</u>$3', $string); + $string = preg_replace('#(^|[\n \-_*.])/(.+?)/($|[ \-_*.!?,:])#is', '$1<i>$2</i>$3', $string); return nl2br($string); } /** - * Convert given number to human readable file size string. + * Convert given number to human-readable file size string. * Will append Bytes, KiB, etc. depending on magnitude of number. * * @param float|int $bytes numeric value of the filesize to make readable * @param int $decimals number of decimals to show, -1 for automatic * @param int $shift how many units to skip, i.e. if you pass in KiB or MiB - * @return string human readable string representing the given file size + * @return string human-readable string representing the given file size */ - public static function readableFileSize($bytes, $decimals = -1, $shift = 0) + public static function readableFileSize($bytes, int $decimals = -1, int $shift = 0): string { // round doesn't reliably work for large floats, pick workaround depending on OS - if (PHP_INT_SIZE === 4) { - $bytes = sprintf('%.0f', $bytes); - } else { - $bytes = sprintf('%u', $bytes); - } + $bytes = sprintf('%u', $bytes); static $sz = array('Byte', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'); $factor = (int)floor((strlen($bytes) - 1) / 3); if ($factor === 0) { $decimals = 0; } else { - $bytes = $bytes / pow(1024, $factor); + $bytes /= 1024 ** $factor; if ($decimals === -1) { - $decimals = 2 - floor(strlen((int)$bytes) - 1); + $decimals = 2 - strlen((string)floor($bytes)) - 1; } } - return sprintf("%.{$decimals}f", $bytes) . "\xe2\x80\x89" . $sz[$factor + $shift]; + return Dictionary::number((float)$bytes, $decimals) . "\xe2\x80\x89" . ($sz[$factor + $shift] ?? '#>PiB#'); } - public static function sanitizeFilename($name) + public static function sanitizeFilename(string $name): string { return preg_replace('/[^a-zA-Z0-9_\-]+/', '_', $name); } - public static function safePath($path, $prefix = '') + /** + * Make sure given path is not absolute, and does not contain '..', or weird characters. + * Returns sanitized path, or false if invalid. If prefix is given, also make sure + * $path starts with it. + * + * @param string $path path to check for safety + * @param string $prefix required prefix of $path + */ + public static function safePath(string $path, string $prefix = ''): ?string { if (empty($path)) - return false; + return null; $path = trim($path); - if ($path{0} == '/' || preg_match('/[\x00-\x19\?\*]/', $path)) - return false; + if ($path[0] == '/' || preg_match('/[\x00-\x19?*]/', $path)) + return null; if (strpos($path, '..') !== false) - return false; + return null; if (substr($path, 0, 2) !== './') $path = "./$path"; - if (empty($prefix)) - return $path; - if (substr($prefix, 0, 2) !== './') - $prefix = "./$prefix"; - if (substr($path, 0, strlen($prefix)) !== $prefix) - return false; + if (!empty($prefix)) { + if (substr($prefix, 0, 2) !== './') + $prefix = "./$prefix"; + if (substr($path, 0, strlen($prefix)) !== $prefix) + return null; + } return $path; } @@ -289,7 +156,7 @@ SADFACE; * @param int $code the code to turn into an error description * @return string the error description */ - public static function uploadErrorString($code) + public static function uploadErrorString(int $code): string { switch ($code) { case UPLOAD_ERR_INI_SIZE: @@ -327,7 +194,7 @@ SADFACE; * @param string $ip_addr input to check * @return boolean true iff $ip_addr is a valid public ipv4 address */ - public static function isPublicIpv4($ip_addr) + public static function isPublicIpv4(string $ip_addr): bool { if (!preg_match("/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/", $ip_addr)) return false; @@ -349,23 +216,6 @@ SADFACE; } /** - * Check whether $arrax contains all keys given in $keyList - * @param array $array An array - * @param array $keyList A list of strings which must all be valid keys in $array - * @return boolean - */ - public static function hasAllKeys($array, $keyList) - { - if (!is_array($array)) - return false; - foreach ($keyList as $key) { - if (!isset($array[$key])) - return false; - } - return true; - } - - /** * Send a file to user for download. * * @param string $file path of local file @@ -375,7 +225,7 @@ SADFACE; * true: error while reading the file * - on success, the function does not return */ - public static function sendFile($file, $name, $delete = false) + public static function sendFile(string $file, string $name, bool $delete = false): bool { while ((@ob_get_level()) > 0) @ob_end_clean(); @@ -403,9 +253,9 @@ SADFACE; * * @param int $length number of bytes to return * @param bool $secure true = only use strong random sources - * @return string|bool string of requested length, false on error + * @return ?string string of requested length, false on error */ - public static function randomBytes($length, $secure = true) + public static function randomBytes(int $length, bool $secure = true): ?string { if (function_exists('random_bytes')) { try { @@ -442,7 +292,7 @@ SADFACE; } } if ($secure) { - return false; + return null; } $bytes = ''; while ($length > 0) { @@ -454,9 +304,9 @@ SADFACE; /** * @return string a random UUID, v4. */ - public static function randomUuid() + public static function randomUuid(): string { - $b = unpack('h8a/h4b/h12c', self::randomBytes(12)); + $b = unpack('h8a/h4b/h12c', self::randomBytes(12, false)); return sprintf('%08s-%04s-%04x-%04x-%012s', // 32 bits for "time_low" @@ -482,10 +332,11 @@ SADFACE; /** * Transform timestamp to easily readable string. * The format depends on how far the timestamp lies in the past. + * * @param int $ts unix timestamp - * @return string human readable representation + * @return string human-readable representation */ - public static function prettyTime($ts) + public static function prettyTime(int $ts): string { settype($ts, 'int'); if ($ts === 0) @@ -508,25 +359,25 @@ SADFACE; /** * Return localized strings for yes or no depending on $bool + * * @param bool $bool Input to evaluate * @return string Yes or No, in user's selected language */ - public static function boolToString($bool) + public static function boolToString(bool $bool): string { if ($bool) - return Dictionary::translate('lang_yes', true); - return Dictionary::translate('lang_no', true); + return Dictionary::translate('lang_yes'); + return Dictionary::translate('lang_no'); } /** * Format a duration, in seconds, into a readable string. + * * @param int $seconds The number to format - * @param int $showSecs whether to show seconds, or rather cut after minutes - * @return string + * @param bool $showSecs whether to show seconds, or rather cut after minutes */ - public static function formatDuration($seconds, $showSecs = true) + public static function formatDuration(int $seconds, bool $showSecs = true): string { - settype($seconds, 'int'); static $UNITS = ['y' => 31536000, 'mon' => 2592000, 'd' => 86400]; $parts = []; $prev = false; @@ -542,7 +393,8 @@ SADFACE; $prev = true; } } - return implode(' ', $parts) . ' ' . gmdate($showSecs ? 'H:i:s' : 'H:i', $seconds); + $parts[] = gmdate($showSecs ? 'H:i:s' : 'H:i', (int)$seconds); + return implode(' ', $parts); } /** @@ -551,9 +403,10 @@ SADFACE; * was a weird problem where firefox would keep sending a cookie with * path /slx-admin/ but trying to delete it from /slx-admin, which php's * setcookie automatically sends by default, did not clear it. + * * @param string $name cookie name */ - public static function clearCookie($name) + public static function clearCookie(string $name): void { $parts = explode('/', $_SERVER['SCRIPT_NAME']); $path = ''; @@ -568,7 +421,7 @@ SADFACE; /** * Remove any non-utf8 sequences from string. */ - public static function cleanUtf8(string $string) : string + public static function cleanUtf8(string $string): string { // https://stackoverflow.com/a/1401716/2043481 $regex = '/ @@ -587,15 +440,34 @@ SADFACE; /** * Remove non-printable < 0x20 chars from ANSI string, then convert to UTF-8 */ - public static function ansiToUtf8(string $string) : string + public static function ansiToUtf8(string $string): string { $regex = '/ ( - (?: [\x20-\xFF] ){1,100} # ignore lower non-printable range + [\x20-\xFF]{1,100} # ignore lower non-printable range ) | . # anything else /x'; return iconv('MS-ANSI', 'UTF-8', preg_replace($regex, '$1', $string)); } + /** + * Clamp given value into [min, max] range. + * @param mixed $value value to clamp. This should be a number. + * @param int $min lower bound + * @param int $max upper bound + * @param bool $toInt if true, variable type of $value will be set to int in addition to clamping + */ + public static function clamp(&$value, int $min, int $max, bool $toInt = true): void + { + if (!is_numeric($value) || $value < $min) { + $value = $min; + } elseif ($value > $max) { + $value = $max; + } + if ($toInt) { + settype($value, 'int'); + } + } + } |