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;
}
}