diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index 49937bd6..b9b60a9a 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -37,6 +37,18 @@ jobs:
/tmp/${{ github.event.repository.name }}-${{ steps.tag.outputs.tag }}.zip
/tmp/fair-dist/*
+ - name: Upload to Fastly Object Storage
+ # note the plugin filename is always 'fair-connect', regardless of the repo name or slug
+ run: |
+ aws s3 cp /tmp/${{ github.event.repository.name }}-${{ steps.tag.outputs.tag }}.zip s3://download.fair.pm/release/fair-connect-${{ steps.tag.outputs.tag }}.zip
+ aws s3 cp --recursive --exclude '*' --include '*.zip' /tmp/fair-dist s3://download.fair.pm/release/
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOAD_BUCKET_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOAD_BUCKET_SECRET_KEY }}
+ AWS_DEFAULT_REGION: 'us-west'
+ AWS_ENDPOINT_URL: 'https://us-west.object.fastlystorage.app'
+ AWS_REQUEST_CHECKSUM_CALCULATION: 'WHEN_REQUIRED'
+
- name: Build provenance attestation
uses: actions/attest-build-provenance@v2
with:
diff --git a/.github/workflows/update-browserslist.yaml b/.github/workflows/update-browserslist.yaml
index 55b25b0b..1cae411b 100644
--- a/.github/workflows/update-browserslist.yaml
+++ b/.github/workflows/update-browserslist.yaml
@@ -2,8 +2,6 @@ name: Update browserslist regex
on:
workflow_dispatch:
- schedule:
- - cron: "0 0 * * 1" # Every Monday at midnight
jobs:
update-browserslist-regex:
diff --git a/bin/bundle.sh b/bin/bundle.sh
index 577ccfae..fedcb914 100755
--- a/bin/bundle.sh
+++ b/bin/bundle.sh
@@ -19,6 +19,22 @@ touch /tmp/fair-dist/SHA1SUMS
touch /tmp/fair-dist/SHA256SUMS
touch /tmp/fair-dist/SHA384SUMS
+# Auto-detect and hash plugin zip.
+PLUGIN_VERSION=$(get_plugin_header "Version")
+REPO_NAME=$(basename "$GITHUB_REPOSITORY")
+PLUGIN_ZIP="/tmp/${REPO_NAME}-${PLUGIN_VERSION}.zip"
+
+if [ -f "$PLUGIN_ZIP" ]; then
+ echo "Hashing plugin zip: $PLUGIN_ZIP" >&2
+ # Change to /tmp so hash files contain just filename, not full path
+ cd /tmp
+ md5sum -b "$(basename "$PLUGIN_ZIP")" >> /tmp/fair-dist/MD5SUMS
+ sha1sum -b "$(basename "$PLUGIN_ZIP")" >> /tmp/fair-dist/SHA1SUMS
+ sha256sum -b "$(basename "$PLUGIN_ZIP")" >> /tmp/fair-dist/SHA256SUMS
+ sha384sum -b "$(basename "$PLUGIN_ZIP")" >> /tmp/fair-dist/SHA384SUMS
+ cd - > /dev/null
+fi
+
# Bundle our plugin first.
[ -d /tmp/fair-temp ] && rm -rf /tmp/fair-temp
mkdir -p /tmp/fair-temp/wordpress/wp-content/plugins/fair-plugin
@@ -54,6 +70,12 @@ for VERSION in $AVAILABLE_VERSIONS; do
curl -sSL "$WP_ZIP_URL" -o "$WP_ZIP_FILE"
EXPECTED_HASH=$(curl -sSL "$WP_ZIP_URL.sha1")
+ # Skip if we can't get a valid hash.
+ if [[ ! "$EXPECTED_HASH" =~ ^[a-z0-9]{40}$ ]]; then
+ echo "Failed to fetch valid hash for $VERSION" >&2
+ continue
+ fi
+
# Verify the checksum.
# (sha1 is suboptimal, but it's all we've got.)
echo " Verifying checksum" >&2
diff --git a/inc/default-repo/namespace.php b/inc/default-repo/namespace.php
index 6719ede4..7cf89b7e 100644
--- a/inc/default-repo/namespace.php
+++ b/inc/default-repo/namespace.php
@@ -53,6 +53,7 @@ function replace_repo_api_urls( $status, $args, $url ) {
if (
! str_contains( $url, 'api.wordpress.org/plugins/' )
&& ! str_contains( $url, 'api.wordpress.org/themes/' )
+ && ! str_contains( $url, 'api.wordpress.org/translations/' )
&& ! str_contains( $url, 'api.wordpress.org/core/version-check/' )
) {
return $status;
diff --git a/inc/namespace.php b/inc/namespace.php
index e12ed82d..de17542a 100644
--- a/inc/namespace.php
+++ b/inc/namespace.php
@@ -11,6 +11,7 @@
const CACHE_BASE = 'fair-';
const CACHE_LIFETIME = 12 * HOUR_IN_SECONDS;
+const CACHE_LIFETIME_FAILURE = HOUR_IN_SECONDS;
const NS_SEPARATOR = '\\';
/**
diff --git a/inc/packages/namespace.php b/inc/packages/namespace.php
index 9733ae4f..1dfff720 100644
--- a/inc/packages/namespace.php
+++ b/inc/packages/namespace.php
@@ -9,6 +9,7 @@
use const FAIR\CACHE_BASE;
use const FAIR\CACHE_LIFETIME;
+use const FAIR\CACHE_LIFETIME_FAILURE;
use FAIR\Packages\DID\Document as DIDDocument;
use FAIR\Packages\DID\PLC;
use FAIR\Packages\DID\Web;
@@ -22,12 +23,33 @@
const CACHE_KEY = CACHE_BASE . 'packages-';
const CACHE_METADATA_DOCUMENTS = CACHE_BASE . 'metadata-documents-';
const CACHE_RELEASE_PACKAGES = CACHE_BASE . 'release-packages';
+const CACHE_UPDATE_ERRORS = CACHE_BASE . 'update-errors-';
const CACHE_DID_FOR_INSTALL = 'fair-install-did';
const CONTENT_TYPE = 'application/json+fair';
const SERVICE_ID = 'FairPackageManagementRepo';
// phpcs:disable WordPress.NamingConventions.ValidVariableName
+/**
+ * Cache an update error for a package.
+ *
+ * @param string $did DID of the package.
+ * @param WP_Error $error The error to cache.
+ */
+function cache_update_error( string $did, WP_Error $error ): void {
+ $error->add_data( [ 'timestamp' => time() ], $error->get_error_code() );
+ set_site_transient( CACHE_UPDATE_ERRORS . $did, $error, CACHE_LIFETIME_FAILURE );
+}
+
+/**
+ * Clear a cached update error for a package.
+ *
+ * @param string $did DID of the package.
+ */
+function clear_update_error( string $did ): void {
+ delete_site_transient( CACHE_UPDATE_ERRORS . $did );
+}
+
/**
* Bootstrap.
*
@@ -93,6 +115,12 @@ function get_did_hash( string $id ) {
* @return DIDDocument|WP_Error
*/
function get_did_document( string $id ) {
+ // Check for cached error from previous failed request.
+ $cached_error = get_site_transient( CACHE_UPDATE_ERRORS . $id );
+ if ( is_wp_error( $cached_error ) ) {
+ return $cached_error;
+ }
+
$cached = get_site_transient( CACHE_METADATA_DOCUMENTS . $id );
if ( $cached ) {
return $cached;
@@ -101,13 +129,18 @@ function get_did_document( string $id ) {
// Parse the DID, then fetch the details.
$did = parse_did( $id );
if ( is_wp_error( $did ) ) {
+ cache_update_error( $id, $did );
return $did;
}
$document = $did->fetch_document();
if ( is_wp_error( $document ) ) {
+ cache_update_error( $id, $document );
return $document;
}
+
+ // Clear any previous error on success.
+ clear_update_error( $id );
set_site_transient( CACHE_METADATA_DOCUMENTS . $id, $document, CACHE_LIFETIME );
return $document;
@@ -177,20 +210,27 @@ function fetch_package_metadata( string $id ) {
// Fetch data from the repository.
$service = $document->get_service( SERVICE_ID );
if ( empty( $service ) ) {
- return new WP_Error( 'fair.packages.fetch_metadata.no_service', __( 'DID is not a valid package to fetch metadata for.', 'fair' ) );
+ $error = new WP_Error( 'fair.packages.fetch_metadata.no_service', __( 'DID is not a valid package to fetch metadata for.', 'fair' ) );
+ cache_update_error( $id, $error );
+ return $error;
}
$repo_url = $service->serviceEndpoint;
- $metadata = fetch_metadata_doc( $repo_url );
+ $metadata = fetch_metadata_doc( $repo_url, $id );
if ( is_wp_error( $metadata ) ) {
return $metadata;
}
if ( $metadata->id !== $id ) {
- return new WP_Error( 'fair.packages.fetch_metadata.mismatch', __( 'Fetched metadata does not match the requested DID.', 'fair' ) );
+ $error = new WP_Error( 'fair.packages.fetch_metadata.mismatch', __( 'Fetched metadata does not match the requested DID.', 'fair' ) );
+ cache_update_error( $id, $error );
+ return $error;
}
+ // Clear any previous error on success.
+ clear_update_error( $id );
+
return $metadata;
}
@@ -198,9 +238,10 @@ function fetch_package_metadata( string $id ) {
* Fetch the metadata document for a package.
*
* @param string $url URL for the metadata document.
+ * @param string $did DID of the package.
* @return MetadataDocument|WP_Error
*/
-function fetch_metadata_doc( string $url ) {
+function fetch_metadata_doc( string $url, string $did ) {
$cache_key = CACHE_KEY . md5( $url );
$response = get_site_transient( $cache_key );
$response = fetch_metadata_from_local( $response, $url );
@@ -219,9 +260,15 @@ function fetch_metadata_doc( string $url ) {
$response = wp_remote_get( $url, $options );
$code = wp_remote_retrieve_response_code( $response );
if ( is_wp_error( $response ) ) {
+ cache_update_error( $did, $response );
return $response;
} elseif ( $code !== 200 ) {
- return new WP_Error( 'fair.packages.metadata.failure', __( 'HTTP error code received', 'fair' ) );
+ $error = new WP_Error(
+ 'fair.packages.metadata.http_error',
+ sprintf( __( 'HTTP %d error received', 'fair' ), $code )
+ );
+ cache_update_error( $did, $error );
+ return $error;
}
// Reorder sections before caching.
@@ -727,6 +774,9 @@ function cache_did_for_install( array $options ): array {
$did = array_find_key(
$releases,
function ( $release ) use ( $options ) {
+ if ( ! is_array( $release->artifacts->package ) ) {
+ return false;
+ }
$artifact = pick_artifact_by_lang( $release->artifacts->package );
return $artifact && $artifact->url === $options['package'];
}
@@ -754,7 +804,7 @@ function delete_cached_did_for_install(): void {
*
* This is commonly required for packages from Git hosts.
*
- * @param string $source Path of $source.
+ * @param string|WP_Error $source Path of $source, or a WP_Error object.
* @param string $remote_source Path of $remote_source.
* @param WP_Upgrader $upgrader An Upgrader object.
* @param array $hook_extra Array of hook data.
diff --git a/inc/updater/class-lite.php b/inc/updater/class-lite.php
index ca54e93a..c9c67ebb 100644
--- a/inc/updater/class-lite.php
+++ b/inc/updater/class-lite.php
@@ -125,6 +125,13 @@ public function run() {
);
$response = get_site_transient( "git-updater-lite_{$this->file}" );
if ( ! $response ) {
+ /* Apply filter to API URL.
+ * Add `channel=development` query arg to URL to get pre-release versions.
+ *
+ * @param string $url The API URL.
+ * @param string $slug The plugin/theme slug
+ */
+ $url = apply_filters( 'git_updater_lite_api_url', $url, $this->slug );
$response = wp_remote_get( $url );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) === 404 ) {
return $response;
@@ -136,11 +143,9 @@ public function run() {
}
$this->api_data->file = $this->file;
- /*
- * Set transient for 5 minutes as AWS sets 5 minute timeout
- * for release asset redirect.
- */
- set_site_transient( "git-updater-lite_{$this->file}", $this->api_data, 5 * \MINUTE_IN_SECONDS );
+ // Set timeout for transient via filter.
+ $timeout = apply_filters( 'git_updater_lite_transient_timeout', 6 * HOUR_IN_SECONDS, $this->file );
+ set_site_transient( "git-updater-lite_{$this->file}", $this->api_data, $timeout );
} else {
if ( property_exists( $response, 'error' ) ) {
return new WP_Error( 'repo-no-exist', 'Specified repo does not exist' );
@@ -178,18 +183,23 @@ function () {
/**
* Correctly rename dependency for activation.
*
- * @param string $source Path of $source.
- * @param string $remote_source Path of $remote_source.
- * @param WP_Upgrader $upgrader An Upgrader object.
- * @param array $hook_extra Array of hook data.
+ * @param string|WP_Error $source Path of $source, or a WP_Error object.
+ * @param string $remote_source Path of $remote_source.
+ * @param WP_Upgrader $upgrader An Upgrader object.
+ * @param array $hook_extra Array of hook data.
*
* @throws TypeError If the type of $upgrader is not correct.
*
* @return string|WP_Error
*/
- public function upgrader_source_selection( string $source, string $remote_source, WP_Upgrader $upgrader, $hook_extra = null ) {
+ public function upgrader_source_selection( $source, string $remote_source, WP_Upgrader $upgrader, $hook_extra = null ) {
global $wp_filesystem;
+ // Exit early for errors.
+ if ( is_wp_error( $source ) ) {
+ return $source;
+ }
+
$new_source = $source;
// Exit if installing.
diff --git a/inc/updater/class-updater.php b/inc/updater/class-updater.php
index aa6f415b..611f6417 100644
--- a/inc/updater/class-updater.php
+++ b/inc/updater/class-updater.php
@@ -12,6 +12,7 @@
use stdClass;
use Theme_Upgrader;
use TypeError;
+use WP_Error;
use WP_Upgrader;
/**
@@ -149,7 +150,7 @@ public function load_hooks() {
/**
* Correctly rename dependency for activation.
*
- * @param string $source Path of $source.
+ * @param string|WP_Error $source Path of $source, or a WP_Error object.
* @param string $remote_source Path of $remote_source.
* @param WP_Upgrader $upgrader An Upgrader object.
* @param array $hook_extra Array of hook data.
@@ -158,9 +159,14 @@ public function load_hooks() {
*
* @return string|WP_Error
*/
- public function upgrader_source_selection( string $source, string $remote_source, WP_Upgrader $upgrader, $hook_extra = null ) {
+ public function upgrader_source_selection( $source, string $remote_source, WP_Upgrader $upgrader, $hook_extra = null ) {
global $wp_filesystem;
+ // Exit early for errors.
+ if ( is_wp_error( $source ) ) {
+ return $source;
+ }
+
$new_source = $source;
// Exit if installing.
diff --git a/inc/updater/namespace.php b/inc/updater/namespace.php
index 3db5114b..361b61dc 100644
--- a/inc/updater/namespace.php
+++ b/inc/updater/namespace.php
@@ -7,8 +7,10 @@
namespace FAIR\Updater;
+use const FAIR\CACHE_LIFETIME_FAILURE;
use const FAIR\Packages\CACHE_DID_FOR_INSTALL;
use const FAIR\Packages\CACHE_RELEASE_PACKAGES;
+use const FAIR\Packages\CACHE_UPDATE_ERRORS;
use FAIR\Packages;
use function FAIR\is_wp_cli;
use Plugin_Upgrader;
@@ -22,6 +24,7 @@
*/
function bootstrap() {
add_action( 'init', __NAMESPACE__ . '\\run' );
+ add_action( 'admin_init', __NAMESPACE__ . '\\register_plugin_row_hooks' );
}
/**
@@ -79,14 +82,79 @@ function run() {
}
}
+/**
+ * Register hooks to display update errors below plugin rows.
+ */
+function register_plugin_row_hooks(): void {
+ $packages = get_packages();
+ $plugins = $packages['plugins'] ?? [];
+
+ foreach ( $plugins as $did => $path ) {
+ $plugin_file = plugin_basename( $path );
+ add_action(
+ "after_plugin_row_{$plugin_file}",
+ function ( $file, $plugin_data, $status ) use ( $did ) {
+ display_plugin_update_error( $file, $plugin_data, $status, $did );
+ },
+ 10,
+ 3
+ );
+ }
+}
+
+/**
+ * Display a cached update error below the plugin row.
+ *
+ * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+ * @param array $plugin_data An array of plugin data.
+ * @param string $status Status filter currently applied to the plugin list.
+ * @param string $did The DID of the plugin.
+ */
+function display_plugin_update_error( $plugin_file, $plugin_data, $status, $did ): void {
+ $error = get_site_transient( CACHE_UPDATE_ERRORS . $did );
+ if ( ! is_wp_error( $error ) ) {
+ return;
+ }
+
+ $wp_list_table = _get_list_table( 'WP_Plugins_List_Table' );
+ $colspan = $wp_list_table->get_column_count();
+
+ // Calculate time remaining until retry.
+ $error_data = $error->get_error_data();
+ $timestamp = $error_data['timestamp'] ?? 0;
+ $retry_time = $timestamp + CACHE_LIFETIME_FAILURE;
+ $time_remaining = human_time_diff( time(), $retry_time );
+
+ $message = sprintf(
+ /* translators: %1$s: Error message, %2$s: Time period */
+ __( 'Error: %1$s. Update checks paused for %2$s.', 'fair' ),
+ $error->get_error_message(),
+ $time_remaining,
+ );
+
+ $active_class = is_plugin_active( $plugin_file ) ? ' active' : '';
+
+ printf(
+ '
+ |
+
+ |
+
',
+ esc_attr( $active_class ),
+ esc_attr( sanitize_title( $plugin_file ) ),
+ esc_attr( $colspan ),
+ esc_html( $message ),
+ );
+}
+
/**
* Download a package with signature verification.
*
- * @param bool|string|WP_Error $reply Whether to proceed with the download, the path to the downloaded package, or an existing WP_Error object. Default true.
+ * @param bool|string|WP_Error $reply Whether to proceed with the download, the path to the downloaded package, or an existing WP_Error object.
* @param string $package The URI of the package. If this is the full path to an existing local file, it will be returned untouched.
* @param WP_Upgrader $upgrader The WP_Upgrader instance.
* @param array $hook_extra Extra hook data.
- * @return true|WP_Error True if the signature is valid, otherwise WP_Error.
+ * @return string|WP_Error The package path if the signature is valid, otherwise WP_Error.
*/
function verify_signature_on_download( $reply, string $package, WP_Upgrader $upgrader, $hook_extra ) {
static $has_run = [];
diff --git a/inc/version-check/namespace.php b/inc/version-check/namespace.php
index 13c2269e..a988676a 100644
--- a/inc/version-check/namespace.php
+++ b/inc/version-check/namespace.php
@@ -14,7 +14,7 @@
*
* DO NOT EDIT THIS CONSTANT MANUALLY.
*/
-const BROWSER_REGEX = '/Edge?\/14[0-2]\.0(\.\d+|)|Firefox\/(140\.0|14[3-8]\.0)(\.\d+|)|Chrom(ium|e)\/(109\.0|1{2}2\.0|12[56]\.0|130\.0|134\.0|1(39|4[0-6])\.0)(\.\d+|)|(Maci|X1{2}).+ Version\/26\.[12]([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/12[12]\.0\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(18[._][56]|26[._][01])([._]\d+|)|Opera Mini|Android:?[ /-]142(\.0|)(\.\d+|)|Mobile Safari.+OPR\/8(0\.){2}\d+|Android.+Firefox\/14{2}\.0(\.\d+|)|Android.+Chrom(ium|e)\/142\.0(\.\d+|)|Android.+(UC? ?Browser|UCWEB|U3)[ /]?1(5\.){2}\d+|SamsungBrowser\/2[89]\.0|Android.+MQ{2}Browser\/14(\.9|)(\.\d+|)|K[Aa][Ii]OS\/(2\.5|3\.[01])(\.\d+|)/';
+const BROWSER_REGEX = '/Edge?\/14[3-5]\.0(\.\d+|)|Firefox\/(140\.0|1(4[6-9]|50)\.0)(\.\d+|)|Chrom(ium|e)\/(109\.0|1{2}2\.0|131\.0|13{2}\.0|139\.0|14[2-8]\.0)(\.\d+|)|(Maci|X1{2}).+ Version\/26\.[2-4]([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/12[45]\.0\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(18[._][5-7]|26[._][1-4])([._]\d+|)|Opera Mini|Android:?[ /-]145(\.0|)(\.\d+|)|Mobile Safari.+OPR\/8(0\.){2}\d+|Android.+Firefox\/147\.0(\.\d+|)|Android.+Chrom(ium|e)\/145\.0(\.\d+|)|Android.+(UC? ?Browser|UCWEB|U3)[ /]?1(5\.){2}\d+|SamsungBrowser\/2[89]\.0|Android.+MQ{2}Browser\/14(\.9|)(\.\d+|)|K[Aa][Ii]OS\/(2\.5|3\.[01])(\.\d+|)/';
/**
* The latest branch of PHP which WordPress.org recommends.
@@ -87,23 +87,27 @@ function get_browser_check_response( string $agent ) {
// Switch delimiter to avoid conflicts.
$regex = '#' . trim( BROWSER_REGEX, '/' ) . '#';
$supported = preg_match( $regex, $agent, $matches );
+ $data = parse_user_agent( $agent );
+
+ $default_data = [
+ 'platform' => _x( 'your platform', 'operating system check', 'fair' ),
+ 'name' => _x( 'your browser', 'browser version check', 'fair' ),
+ 'version' => '',
+ 'current_version' => '',
+ 'upgrade' => ! $supported,
+ 'insecure' => ! $supported,
+ 'update_url' => 'https://browsehappy.com/',
+ 'img_src' => '',
+ 'img_src_ssl' => '',
+ ];
+ $data = array_merge( $default_data, $data );
return [
'response' => [
'code' => 200,
'message' => 'OK',
],
- 'body' => json_encode( [
- 'platform' => _x( 'your platform', 'operating system check', 'fair' ),
- 'name' => _x( 'your browser', 'browser version check', 'fair' ),
- 'version' => '',
- 'current_version' => '',
- 'upgrade' => ! $supported,
- 'insecure' => ! $supported,
- 'update_url' => 'https://browsehappy.com/',
- 'img_src' => '',
- 'img_src_ssl' => '',
- ] ),
+ 'body' => json_encode( $data ),
'headers' => [],
'cookies' => [],
'http_response_code' => 200,
@@ -262,3 +266,447 @@ function get_server_check_response( string $version ) {
'http_response_code' => 200,
];
}
+
+/**
+ * Returns current version numbers for all browsers.
+ *
+ * These are for major release branches, not full build numbers.
+ * Firefox 3.6, 4, etc., not Chrome 11.0.696.65.
+ *
+ * @return array Associative array of browser names with their respective
+ * current (or somewhat current) version number.
+ */
+function get_browser_current_versions() {
+ return [
+ 'Chrome' => '18', // Lowest version at the moment (mobile).
+ 'Firefox' => '56',
+ 'Microsoft Edge' => '15.15063',
+ 'Opera' => '12.18',
+ 'Safari' => '11',
+ 'Internet Explorer' => '11',
+ ];
+}
+
+/**
+ * Returns browser data for a given browser.
+ *
+ * @param string|false $browser The name of the browser. Default false.
+ * @return false|array|object {
+ * Array of data objects about browsers. False if the browser is unknown.
+ *
+ * @type string $name Name of the browser.
+ * @type string $url The home URL for the browser.
+ * @type string $img_src The non-HTTPs URL for the browser's logo image.
+ * @type string $img_src_ssl The HTTPS URL for the browser's logo image.
+ * }
+ */
+function get_browser_data( $browser = false ) {
+
+ $data = [
+ 'Internet Explorer' => (object) [
+ 'name' => 'Internet Explorer',
+ 'url' => 'https://support.microsoft.com/help/17621/internet-explorer-downloads',
+ ],
+ 'Edge' => (object) [
+ 'name' => 'Microsoft Edge',
+ 'url' => 'https://www.microsoft.com/edge',
+ ],
+ 'Firefox' => (object) [
+ 'name' => 'Mozilla Firefox',
+ 'url' => 'https://www.mozilla.org/firefox/',
+ ],
+ 'Safari' => (object) [
+ 'name' => 'Safari',
+ 'url' => 'https://www.apple.com/safari/',
+ ],
+ 'Opera' => (object) [
+ 'name' => 'Opera',
+ 'url' => 'https://www.opera.com/',
+ ],
+ 'Chrome' => (object) [
+ 'name' => 'Google Chrome',
+ 'url' => 'https://www.google.com/chrome',
+ ],
+ ];
+
+ if ( false === $browser ) {
+ return $data;
+ }
+
+ if ( ! isset( $data[ $browser ] ) ) {
+ return false;
+ }
+
+ return $data[ $browser ];
+}
+
+/**
+ * Returns an associative array of explicit browser token names and their
+ * associated info.
+ *
+ * Explicit tokens are tokens that, if present, indicate a specific browser.
+ *
+ * If a browser is not identified by an explicit token, or s special
+ * handling not supported by the default handler, then a new conditional block
+ * for the browser instead needs to be added in parse_user_agent().
+ *
+ * In any case, the browser token name also needs to be added to the regex for
+ * browser tokens in parse_user_agent().
+ *
+ * @return array {
+ * Associative array of browser tokens and their associated data.
+ *
+ * @type array $data {
+ * Associative array of browser data. All are optional.
+ *
+ * @type string $name Name of browser, if it differs from the
+ * token name. Default is token name.
+ * @type bool $use_version Should the 'Version' token, if present,
+ * supercede the version associated with the
+ * browser token? Default false.
+ * @type bool $mobile Does the browser signify the platform is
+ * mobile (for situations where it may no
+ * already be apparent)? Default false.
+ * @type string $platform The name of the platform, to supercede
+ * whatever platform may have been detected.
+ * Default empty string.
+ * }
+ * }
+ */
+function get_explicit_browser_tokens() {
+ return [
+ 'Camino' => [],
+ 'Chromium' => [],
+ 'Edge' => [
+ 'name' => 'Microsoft Edge',
+ ],
+ 'Kindle' => [
+ 'name' => 'Kindle Browser',
+ 'use_version' => true,
+ ],
+ 'Konqueror' => [],
+ 'konqueror' => [
+ 'name' => 'Konqueror',
+ ],
+ 'NokiaBrowser' => [
+ 'name' => 'Nokia Browser',
+ 'mobile' => true,
+ ],
+ 'Opera Mini' => [ // Must be before 'Opera'.
+ 'mobile' => true,
+ 'use_version' => true,
+ ],
+ 'Opera' => [
+ 'use_version' => true,
+ ],
+ 'OPR' => [
+ 'name' => 'Opera',
+ 'use_version' => true,
+ ],
+ 'PaleMoon' => [
+ 'name' => 'Pale Moon',
+ ],
+ 'QQBrowser' => [
+ 'name' => 'QQ Browser',
+ ],
+ 'RockMelt' => [],
+ 'SamsungBrowser' => [
+ 'name' => 'Samsung Browser',
+ ],
+ 'SeaMonkey' => [],
+ 'Silk' => [
+ 'name' => 'Amazon Silk',
+ ],
+ 'S40OviBrowser' => [
+ 'name' => 'Ovi Browser',
+ 'mobile' => true,
+ 'platform' => 'Symbian',
+ ],
+ 'UCBrowser' => [ // Must be before 'UCWEB'.
+ 'name' => 'UC Browser',
+ ],
+ 'UCWEB' => [
+ 'name' => 'UC Browser',
+ ],
+ 'Vivaldi' => [],
+ 'IEMobile' => [ // Keep last just in case.
+ 'name' => 'Internet Explorer Mobile',
+ ],
+ ];
+}
+
+/**
+ * Parses a user agent string into its important parts.
+ *
+ * @param string $user_agent The user agent string for a browser.
+ * @return array {
+ * Array containing data based on the parsing of the user agent.
+ *
+ * @type string $platform The platform running the browser.
+ * @type string $name The name of the browser.
+ * @type string $version The reported version of the browser.
+ * @type string $update_url The URL to obtain the update for the browser.
+ * @type string $img_src The non-HTTPS URL for the browser's logo image.
+ * @type string $img_src_ssl The HTTPS URL for the browser's logo image.
+ * @type string $current_version The current latest version of the browser.
+ * @type bool $upgrade Is there an update available for the browser?
+ * @type bool $insecure Is the browser insecure?
+ * @type bool $mobile Is the browser on a mobile platform?
+ * }
+ */
+function parse_user_agent( $user_agent ) {
+ $data = [
+ 'name' => '',
+ 'version' => '',
+ 'platform' => '',
+ 'update_url' => '',
+ 'img_src' => '',
+ 'img_src_ssl' => '',
+ 'current_version' => '',
+ 'upgrade' => false,
+ 'insecure' => false,
+ 'mobile' => false,
+ ];
+ $mobile_device = '';
+
+ /**
+ * Identify platform/OS in user-agent string.
+ * '/(?P' // Capture subpattern matches into 'platform' array.
+ * . 'Windows Phone( OS)?|Symbian|SymbOS|Android|iPhone' // Platform tokens.
+ * . '|iPad|Windows|Linux|Macintosh|FreeBSD|OpenBSD' // More platform tokens.
+ * . '|SunOS|RIM Tablet OS|PlayBook' // More platform tokens.
+ * . ')'
+ * . '(?:'
+ * . ' (NT|amd64|armv7l|zvav)' // Possibly followed by specific modifiers/specifiers.
+ * . ')*'
+ * . '(?:'
+ * . ' [ix]?[0-9._]+' // Possibly followed by architecture modifier (e.g. x86_64).
+ * . '(\-[0-9a-z\.\-]+)?' // Possibly followed by a hypenated version number.
+ * . ')*'
+ * . '(;|\))' // Ending in a semi-colon or close parenthesis.
+ * . '/im', // Case insensitive, multiline.
+ */
+ if ( preg_match(
+ '/(?PWindows Phone( OS)?|Symbian|SymbOS|Android|iPhone|iPad|Windows|Linux|Macintosh|FreeBSD|OpenBSD|SunOS|RIM Tablet OS|PlayBook)(?: (NT|amd64|armv7l|zvav))*(?: [ix]?[0-9._]+(\-[0-9a-z\.\-]+)?)*(;|\))/im',
+ $user_agent,
+ $regs
+ ) ) {
+ $data['platform'] = $regs['platform'];
+ }
+
+ /**
+ * Find tokens of interest in user-agent string.
+ *
+ * '%(?P' // Capture subpattern matches into the 'name' array.
+ * . 'Opera Mini|Opera|OPR|Edge|UCBrowser|UCWEB' // Browser tokens.
+ * . '|QQBrowser|SymbianOS|Symbian|S40OviBrowser' // More browser tokens.
+ * . '|Trident|Silk|Konqueror|PaleMoon|Puffin' // More browser tokens.
+ * . '|SeaMonkey|Vivaldi|Camino|Chromium|Kindle|Firefox' // More browser tokens.
+ * . '|SamsungBrowser|(?:Mobile )?Safari|NokiaBrowser' // More browser tokens.
+ * . '|MSIE|RockMelt|AppleWebKit|Chrome|IEMobile' // More browser tokens.
+ * . '|Version' // Version token.
+ * . ')'
+ * . '(?:'
+ * . '[/ ]' // Forward slash or space.
+ * . ')'
+ * . '(?P' // Capture subpattern matches into 'version' array.
+ * . '[0-9.]+' // One or more numbers and/or decimal points.
+ * . ')'
+ * . '%im', // Case insensitive, multiline.
+ */
+ preg_match_all(
+ '%(?POpera Mini|Opera|OPR|Edge|UCBrowser|UCWEB|QQBrowser|SymbianOS|Symbian|S40OviBrowser|Trident|Silk|Konqueror|PaleMoon|Puffin|SeaMonkey|Vivaldi|Camino|Chromium|Kindle|Firefox|SamsungBrowser|(?:Mobile )?Safari|NokiaBrowser|MSIE|RockMelt|AppleWebKit|Chrome|IEMobile|Version)(?:[/ ])(?P[0-9.]+)%im',
+ $user_agent,
+ $result,
+ PREG_PATTERN_ORDER
+ );
+
+ // Create associative array with tokens as keys and versions as values.
+ $tokens = array_combine( array_reverse( $result['name'] ), array_reverse( $result['version'] ) );
+
+ // Properly set platform if Android is actually being reported.
+ if ( 'Linux' === $data['platform'] && false !== strpos( $user_agent, 'Android' ) ) {
+ if ( strpos( $user_agent, 'Kindle' ) ) {
+ $data['platform'] = 'Fire OS';
+ } else {
+ $data['platform'] = 'Android';
+ }
+ } elseif ( 'Windows Phone' === $data['platform'] ) {
+ // Normalize Windows Phone OS name when "OS" is omitted.
+ $data['platform'] = 'Windows Phone OS';
+ } elseif ( in_array( $data['platform'], [ 'Symbian', 'SymbOS' ] ) || ! empty( $tokens['SymbianOS'] ) || ! empty( $tokens['Symbian'] ) ) {
+ // Standardize Symbian OS name.
+ if ( ! in_array( $data['platform'], [ 'Symbian', 'SymbOS' ] ) ) {
+ unset( $tokens['SymbianOS'] );
+ unset( $tokens['Symbian'] );
+ }
+ $data['platform'] = 'Symbian';
+ } elseif ( ! $data['platform'] && preg_match( '/BlackBerry|Nokia|SonyEricsson/', $user_agent, $matches ) ) {
+ // Generically detect some mobile devices.
+ $data['platform'] = 'Mobile';
+ $mobile_device = $matches[0];
+ }
+
+ // Flag known mobile platforms as mobile.
+ if ( in_array( $data['platform'], [ 'Android', 'Fire OS', 'iPad', 'iPhone', 'Mobile', 'PlayBook', 'RIM Tablet OS', 'Symbian', 'Windows Phone OS' ] ) ) {
+ $data['mobile'] = true;
+ }
+
+ // If Version/x.x.x was specified in UA string store it and ignore it.
+ if ( ! empty( $tokens['Version'] ) ) {
+ $version = $tokens['Version'];
+ unset( $tokens['Version'] );
+ }
+
+ $explicit_tokens = get_explicit_browser_tokens();
+
+ // No indentifiers provided.
+ if ( ! $tokens ) {
+ if ( 'BlackBerry' === $mobile_device ) {
+ $data['name'] = 'BlackBerry Browser';
+ } else {
+ $data['name'] = 'unknown';
+ }
+ } elseif ( $found = array_intersect( array_keys( $explicit_tokens ), array_keys( $tokens ) ) ) { // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure
+ // Explicitly identified browser (info defined above in $explicit_tokens).
+ $token = reset( $found );
+
+ $data['name'] = $explicit_tokens[ $token ]['name'] ?? $token;
+ $data['version'] = $tokens[ $token ];
+ if ( empty( $explicit_tokens[ $token ]['use_version'] ) ) {
+ $version = '';
+ }
+ if ( ! empty( $explicit_tokens[ $token ]['mobile'] ) ) {
+ $data['mobile'] = true;
+ }
+ if ( ! empty( $explicit_tokens[ $token ]['platform'] ) ) {
+ $data['platform'] = $explicit_tokens[ $token ]['platform'];
+ }
+ } elseif ( ! empty( $tokens['Puffin'] ) ) {
+ // Puffin.
+ $data['name'] = 'Puffin';
+ $data['version'] = $tokens['Puffin'];
+ $version = '';
+ // If not an already-identified mobile platform, set it as such.
+ if ( ! $data['mobile'] ) {
+ $data['mobile'] = true;
+ $data['platform'] = '';
+ }
+ } elseif ( ! empty( $tokens['Trident'] ) ) {
+ // Trident (Internet Explorer).
+ // IE 8-10 more reliably report version via Trident token than MSIE token.
+ // IE 11 uses Trident token without an MSIE token.
+ // https://msdn.microsoft.com/library/hh869301(v=vs.85).aspx.
+ $data['name'] = 'Internet Explorer';
+ $trident_ie_mapping = [
+ '4.0' => '8.0',
+ '5.0' => '9.0',
+ '6.0' => '10.0',
+ '7.0' => '11.0',
+ ];
+ $ver = $tokens['Trident'];
+ $data['version'] = $trident_ie_mapping[ $ver ] ?? $ver;
+ } elseif ( ! empty( $tokens['MSIE'] ) ) {
+ // Internet Explorer (pre v8.0).
+ $data['name'] = 'Internet Explorer';
+ $data['version'] = $tokens['MSIE'];
+ } elseif ( ! empty( $tokens['AppleWebKit'] ) ) {
+ // AppleWebKit-emulating browsers.
+ if ( ! empty( $tokens['Mobile Safari'] ) ) {
+ if ( ! empty( $tokens['Chrome'] ) ) {
+ $data['name'] = 'Chrome';
+ $version = $tokens['Chrome'];
+ } elseif ( 'Android' === $data['platform'] ) {
+ $data['name'] = 'Android Browser';
+ } elseif ( 'Fire OS' === $data['platform'] ) {
+ $data['name'] = 'Kindle Browser';
+ } elseif ( false !== strpos( $user_agent, 'BlackBerry' ) || false !== strpos( $user_agent, 'BB10' ) ) {
+ $data['name'] = 'BlackBerry Browser';
+ $data['mobile'] = true;
+
+ if ( false !== stripos( $user_agent, 'BB10' ) ) {
+ $tokens['Mobile Safari'] = '';
+ $version = '';
+ }
+ } else {
+ $data['name'] = 'Mobile Safari';
+ }
+ } elseif ( ! empty( $tokens['Chrome'] ) ) {
+ $data['name'] = 'Chrome';
+ $version = '';
+ } elseif ( ! empty( $data['platform'] ) && 'PlayBook' == $data['platform'] ) {
+ $data['name'] = 'PlayBook';
+ } elseif ( ! empty( $tokens['Safari'] ) ) {
+ if ( 'Android' === $data['platform'] ) {
+ $data['name'] = 'Android Browser';
+ } elseif ( 'Symbian' === $data['platform'] ) {
+ $data['name'] = 'Nokia Browser';
+ $tokens['Safari'] = '';
+ } else {
+ $data['name'] = 'Safari';
+ }
+ } else {
+ $data['name'] = 'unknown';
+ $tokens['AppleWebKit'] = '';
+ $version = '';
+ }
+ $data['version'] = $tokens[ $data['name'] ] ?? '';
+ } else {
+ // Fall back to whatever is being reported.
+ $ordered_tokens = array_reverse( $tokens );
+ $data['version'] = reset( $ordered_tokens );
+ $data['name'] = key( $ordered_tokens );
+ }
+
+ // Set the platform for Amazon-related browsers.
+ if ( in_array( $data['name'], [ 'Amazon Silk', 'Kindle Browser' ] ) ) {
+ $data['platform'] = 'Fire OS';
+ $data['mobile'] = true;
+ }
+
+ // If Version/x.x.x was specified in UA string.
+ if ( ! empty( $version ) ) {
+ $data['version'] = $version;
+ }
+
+ if ( $data['mobile'] ) {
+ // Generically set "Mobile" as the platform if a platform hasn't been set.
+ if ( ! $data['platform'] ) {
+ $data['platform'] = 'Mobile';
+ }
+
+ // Don't fetch additional browser data for mobile platform browsers at this time.
+ return $data;
+ }
+
+ $browser_data = get_browser_data( $data['name'] );
+ $data['update_url'] = $browser_data ? $browser_data->url : '';
+ $data['current_version'] = get_browser_version_from_name( $data['name'] );
+ $data['upgrade'] = ( ! empty( $data['current_version'] ) && version_compare( $data['version'], $data['current_version'], '<' ) );
+
+ if ( 'Internet Explorer' === $data['name'] ) {
+ $data['insecure'] = true;
+ $data['upgrade'] = true;
+ } elseif ( 'Firefox' === $data['name'] && version_compare( $data['version'], '52', '<' ) ) {
+ $data['insecure'] = true;
+ } elseif ( 'Opera' === $data['name'] && version_compare( $data['version'], '12.18', '<' ) ) {
+ $data['insecure'] = true;
+ } elseif ( 'Safari' === $data['name'] && version_compare( $data['version'], '10', '<' ) ) {
+ $data['insecure'] = true;
+ }
+
+ return $data;
+}
+
+/**
+ * Returns the current version for the given browser.
+ *
+ * @param string $name The name of the browser.
+ * @return string The version for the browser or an empty string if an
+ * unknown browser.
+ */
+function get_browser_version_from_name( $name ) {
+ $versions = get_browser_current_versions();
+
+ return isset( $versions[ $name ] ) ? $versions[ $name ] : '';
+}