app/classes/ReleaseInsights/Utils.php (150 lines of code) (raw):

<?php declare(strict_types=1); namespace ReleaseInsights; use Cache\Cache; use DateTime; use GuzzleHttp\Client; class Utils { /** * Get the list of crashes for a Build ID from Socorro * * @param int $buildid Firefox build ID * * @return array<mixed> a list of crashes */ public static function getCrashesForBuildID(int $buildid): array { // We only fetch Desktop builds crashes, I haven't found out how to get Fenix crashes per buildID yet $cache_id = URL::Socorro->value . 'SuperSearch/?build_id=' . $buildid . '&_facets=signature&product=Firefox'; if (defined('TESTING_CONTEXT')) { $cache_id = TEST_FILES .'/crash-stats.mozilla.org.json'; if ($buildid == '20190927094817') { $cache_id = TEST_FILES .'/empty.json'; } } return Json::load(url: $cache_id, ttl: 30); } /** * Get the list of bugs for a Build ID from Socorro * * @param string $signature Crash signature * @param int $ttl caching time in seconds * @return array<mixed> a list of crashes */ public static function getBugsforCrashSignature(string $signature, int $ttl = 21_600): array { // The signature in the string varies so we create a unique file name in cache $cache_id = URL::Socorro->value . 'Bugs/?signatures=' . $signature; if (defined('TESTING_CONTEXT')) { $cache_id = TEST_FILES .'/crash-stats.mozilla.org_signature.json'; if ($signature == 'failure') { $cache_id = TEST_FILES .'/empty.json'; } } // If we can't retrieve cached data, we create and cache it. // We cache because we want to avoid http request latency if (! $data = Cache::getKey($cache_id, $ttl)) { $data = file_get_contents($cache_id); // Error fetching data, don't cache. // @codeCoverageIgnoreStart if ($data === false) { return ['error' => 'URL provided no data']; } // @codeCoverageIgnoreEnd // No data returned, bug or incorrect date, don't cache. if (empty($data)) { return []; } Cache::setKey($cache_id, $data); } return Json::toArray($data); } /** * Get a date provided by the user in the query string. *Fallback to today's date. * * @return string Date as a Ymd string */ public static function getDate(string $format = 'Ymd'): string { // No date provided by the http call, return Today if (! isset($_GET['date'])) { return date($format); } // Magical 'today' value if ($_GET['date'] === 'today') { return date($format); } // Cast user provided date to an int for security $date = Utils::secureText($_GET['date']); $d = DateTime::createFromFormat($format, $date); // Date is invalid, return Today if (! $d) { return date($format); } return $d->format($format); } /** * Get a Firefox BuildID and sanitize it * * @param int $buildid Firefox Build ID in format 20191014213051 * * @return int sanitized buildID */ public static function getBuildID(int $buildid): int { // Check that the string provided is correct if (! self::isBuildID((string) $buildid)) { return 20191014213051; // hardcoded fallback value } return $buildid; } public static function isBuildID(string $buildid): bool { // BuildIDs should be 14 digits if (strlen($buildid) !== 14) { return false; } // BuildIDs should be valid dates, if we can't create a date return false if (! $date = date_create($buildid)) { return false; } // The date shouldn't be in the future $date = new DateTime($buildid)->format('Ymd'); $today = new DateTime()->format('Ymd'); if ($date > $today) { return false; } return true; } /** * Sanitize a string for security before template use. * This is in addition to twig default sanitizinf for cases * where we may want to disable it. */ public static function secureText(string $string): string { // CRLF XSS $string = str_replace(['%0D', '%0A'], '', $string); // We want to convert line breaks into spaces $string = str_replace("\n", ' ', $string); // Escape HTML tags and remove ASCII characters below 32 return filter_var( $string, FILTER_SANITIZE_SPECIAL_CHARS, FILTER_FLAG_STRIP_LOW ); } /** * getFile code coverage is done through its main consumer Json::load() */ public static function getFile(string $url): string|bool { // Local file if (! isset(parse_url($url)['scheme'])) { // Does it exist ? if (! file_exists($url)) { return ''; } return file_get_contents($url); } // We don't want to make external requests in Unit Tests // @codeCoverageIgnoreStart $client = new Client([ 'headers' => [ 'User-Agent' => 'WhatTrainIsItNow/1.0', 'Referer' => 'https://whattrainisitnow.com' ] ]); // We know that some queries fail for hg.mozilla.org but we deal with that in templates // We ignore warnings for 404 errors as we don't want to spam Sentry $response = $client->request('GET', $url, ['http_errors' => false]); // Request to Product-details failed (no answer from remote) // We prefer to die here because this data is essential to the whole app. if ($response->getStatusCode() != 200 && str_contains($url, 'product-details.mozilla.org')) { die("Key external resource {$url} currently not available, please try reloading the page."); } // Request failed, let's return an empty string for now if ($response->getStatusCode() != 200) { return ''; } return $response->getBody()->getContents(); // @codeCoverageIgnoreEnd } public static function mtrim(string $string): string { $string = explode(' ', $string); $string = array_filter($string); return implode(' ', $string); } /** * Check if $haystack starts with a string in $needles. * $needles can be a string or an array of strings. * * @param string $haystack String to analyse * @param string|array<string> $needles The string to look for * * @return bool True if the $haystack string starts with a string in $needles */ public static function startsWith(string $haystack, string|array $needles): bool { foreach ((array) $needles as $needle) { if (str_starts_with($haystack, $needle)) { return true; } } return false; } /** * Check if $needles are in $haystack. * * @param string $haystack String to analyze * @param mixed $needles The string (or array of strings) to look for * @param bool $match_all True if we need to match all $needles, false * if it's enough to match one. Default: false * * @return bool True if the $haystack string contains any/all $needles */ public static function inString(string $haystack, mixed $needles, bool $match_all = false): bool { $needles = (array) $needles; $matches = 0; foreach ((array) $needles as $needle) { // Missing needle if (! str_contains($haystack, (string) $needle) && $match_all) { return false; } if (str_contains($haystack, (string) $needle)) { // If I need to match any needle, I can stop at the first match if (! $match_all) { return true; } $matches++; } } if (! $match_all) { return false; } return $matches == count($needles) > 0; } /** * @param DateTime $date Date that is to be checked if it falls between $startDate and $endDate * @param DateTime $startDate Date should be after this date to return true * @param DateTime $endDate Date should be before this date to return true */ public static function isDateBetweenDates(DateTime $date, DateTime $startDate, DateTime $endDate): bool { return $date > $startDate && $date < $endDate; } }