$2$3', $string); $string = preg_replace('#(^|[\n \-*/.])_(.+?)_($|[ \-*/.!?,:])#is', '$1$2$3', $string); $string = preg_replace('#(^|[\n \-_*.])/(.+?)/($|[ \-_*.!?,:])#is', '$1$2$3', $string); return nl2br($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 */ 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 $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 /= 1024 ** $factor; if ($decimals === -1) { $decimals = 2 - strlen((string)floor($bytes)) - 1; } } return Dictionary::number((float)$bytes, $decimals) . "\xe2\x80\x89" . ($sz[$factor + $shift] ?? '#>PiB#'); } public static function sanitizeFilename(string $name): string { return preg_replace('/[^a-zA-Z0-9_\-]+/', '_', $name); } /** * 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 null; $path = trim($path); if ($path[0] == '/' || preg_match('/[\x00-\x19?*]/', $path)) return null; if (strpos($path, '..') !== false) return null; if (substr($path, 0, 2) !== './') $path = "./$path"; if (!empty($prefix)) { if (substr($prefix, 0, 2) !== './') $prefix = "./$prefix"; if (substr($path, 0, strlen($prefix)) !== $prefix) return null; } return $path; } /** * Create human readable error description from a $_FILES[<..>]['error'] code * * @param int $code the code to turn into an error description * @return string the error description */ public static function uploadErrorString(int $code): string { switch ($code) { case UPLOAD_ERR_INI_SIZE: $message = "The uploaded file exceeds the upload_max_filesize directive in php.ini"; break; case UPLOAD_ERR_FORM_SIZE: $message = "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form"; break; case UPLOAD_ERR_PARTIAL: $message = "The uploaded file was only partially uploaded"; break; case UPLOAD_ERR_NO_FILE: $message = "No file was uploaded"; break; case UPLOAD_ERR_NO_TMP_DIR: $message = "Missing a temporary folder"; break; case UPLOAD_ERR_CANT_WRITE: $message = "Failed to write file to disk"; break; case UPLOAD_ERR_EXTENSION: $message = "File upload stopped by extension"; break; default: $message = "Unknown upload error"; break; } return $message; } /** * Is given string a public ipv4 address? * * @param string $ip_addr input to check * @return boolean true iff $ip_addr is a valid public ipv4 address */ 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; $parts = explode(".", $ip_addr); foreach ($parts as $part) { if (!is_numeric($part) || $part > 255 || $part < 0) return false; } if ($parts[0] == 0 || $parts[0] == 10 || $parts[0] == 127 || ($parts[0] > 223 && $parts[0] < 240)) return false; if (($parts[0] == 192 && $parts[1] == 168) || ($parts[0] == 169 && $parts[1] == 254)) return false; if ($parts[0] == 172 && $parts[1] > 15 && $parts[1] < 32) return false; return true; } /** * Send a file to user for download. * * @param string $file path of local file * @param string $name name of file to send to user agent * @param boolean $delete delete the file when done? * @return boolean false: file could not be opened. * true: error while reading the file * - on success, the function does not return */ public static function sendFile(string $file, string $name, bool $delete = false): bool { while ((@ob_get_level()) > 0) @ob_end_clean(); $fh = fopen($file, 'rb'); if ($fh === false) { Message::addError('main.error-read', $file); return false; } Header('Content-Type: application/octet-stream', true); Header('Content-Disposition: attachment; filename=' . str_replace(array(' ', '=', ',', '/', '\\', ':', '?'), '_', iconv('UTF-8', 'ASCII//TRANSLIT', $name))); Header('Content-Length: ' . filesize($file)); fpassthru($fh); fclose($fh); if ($delete) { unlink($file); } exit(0); } /** * Return a binary string of given length, containing * random bytes. If $secure is given, only methods of * obtaining cryptographically strong random bytes will * be used, otherwise, weaker methods might be used. * * @param int $length number of bytes to return * @param bool $secure true = only use strong random sources * @return ?string string of requested length, false on error */ public static function randomBytes(int $length, bool $secure = true): ?string { if (function_exists('random_bytes')) { try { return random_bytes($length); } catch (Exception $e) { // Continue below } } if ($secure) { if (function_exists('openssl_random_pseudo_bytes')) { $bytes = openssl_random_pseudo_bytes($length, $ok); if ($bytes !== false && $ok) { return $bytes; } } $file = '/dev/random'; } else { $file = '/dev/urandom'; } $fh = @fopen($file, 'r'); if ($fh !== false) { $bytes = fread($fh, $length); while ($bytes !== false && strlen($bytes) < $length) { $new = fread($fh, $length - strlen($bytes)); if ($new === false) { $bytes = false; break; } $bytes .= $new; } fclose($fh); if ($bytes !== false) { return $bytes; } } if ($secure) { return null; } $bytes = ''; while ($length > 0) { $bytes .= chr(mt_rand(0, 255)); } return $bytes; } /** * @return string a random UUID, v4. */ public static function randomUuid(): string { $b = unpack('h8a/h4b/h12c', self::randomBytes(12, false)); return sprintf('%08s-%04s-%04x-%04x-%012s', // 32 bits for "time_low" $b['a'], // 16 bits for "time_mid" $b['b'], // 16 bits for "time_hi_and_version", // four most significant bits holds version number 4 mt_rand(0, 0x0fff) | 0x4000, // 16 bits, 8 bits for "clk_seq_hi_res", // 8 bits for "clk_seq_low", // two most significant bits holds zero and one for variant DCE1.1 mt_rand(0, 0x3fff) | 0x8000, // 48 bits for "node" $b['c'] ); } /** * 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 */ public static function prettyTime(int $ts): string { settype($ts, 'int'); if ($ts === 0) return '???'; static $TODAY = false, $ETODAY = false, $YESTERDAY = false, $YEARCUTOFF = false; if (!$ETODAY) $ETODAY = strtotime('today 23:59:59'); if ($ts > $ETODAY) // TODO: Do we need strings for future too? return date('d.m.Y H:i', $ts); if (!$TODAY) $TODAY = strtotime('today 0:00'); if ($ts >= $TODAY) return Dictionary::translate('lang_today') . ' ' . date('H:i', $ts); if (!$YESTERDAY) $YESTERDAY = strtotime('yesterday 0:00'); if ($ts >= $YESTERDAY) return Dictionary::translate('lang_yesterday') . ' ' . date('H:i', $ts); if (!$YEARCUTOFF) $YEARCUTOFF = min(strtotime('-3 month'), strtotime('this year 1/1')); if ($ts >= $YEARCUTOFF) return date('d.m. H:i', $ts); return date('d.m.Y', $ts); } /** * 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 $bool): string { if ($bool) 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 bool $showSecs whether to show seconds, or rather cut after minutes */ public static function formatDuration(int $seconds, bool $showSecs = true): string { static $UNITS = ['y' => 31536000, 'mon' => 2592000, 'd' => 86400]; $parts = []; $prev = false; foreach ($UNITS as $k => $v) { if ($seconds < $v) continue; $n = floor($seconds / $v); $seconds -= $n * $v; if ($prev) { $parts[] = sprintf('%02d', $n) . $k; } else { $parts[] = $n . $k; $prev = true; } } $parts[] = gmdate($showSecs ? 'H:i:s' : 'H:i', (int)$seconds); return implode(' ', $parts); } /** * Properly clear a cookie from the user's browser. * This recursively wipes it from the current script's path. There * 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(string $name): void { $parts = explode('/', $_SERVER['SCRIPT_NAME']); $path = ''; foreach ($parts as $part) { $path .= $part; setcookie($name, '', 0, $path); $path .= '/'; setcookie($name, '', 0, $path); } } /** * Remove any non-utf8 sequences from string. */ public static function cleanUtf8(string $string): string { // https://stackoverflow.com/a/1401716/2043481 $regex = '/ ( (?: [\x00-\x7F] # single-byte sequences 0xxxxxxx | [\xC0-\xDF][\x80-\xBF] # double-byte sequences 110xxxxx 10xxxxxx | [\xE0-\xEF][\x80-\xBF]{2} # triple-byte sequences 1110xxxx 10xxxxxx * 2 | [\xF0-\xF7][\x80-\xBF]{3} # quadruple-byte sequence 11110xxx 10xxxxxx * 3 ){1,100} # ...one or more times ) | . # anything else /x'; return preg_replace($regex, '$1', $string); } /** * Remove non-printable < 0x20 chars from ANSI string, then convert to UTF-8 */ public static function ansiToUtf8(string $string): string { $regex = '/ ( [\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'); } } }