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/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-package.php b/inc/updater/class-package.php new file mode 100644 index 00000000..c57516ab --- /dev/null +++ b/inc/updater/class-package.php @@ -0,0 +1,109 @@ +did = $did; + $this->filepath = $filepath; + $this->local_version = $filepath ? get_file_data( $filepath, [ 'Version' => 'Version' ] )['Version'] : null; + } + + /** + * Get the package slug. + * + * @return string The slug (directory name for plugins, stylesheet for themes). + */ + abstract public function get_slug(): string; + + /** + * Get the relative path used in update transients. + * + * @return string The relative path. + */ + abstract public function get_relative_path(): string; + + /** + * Get the metadata document, fetching and caching if needed. + * + * @return \FAIR\Packages\MetadataDocument|\WP_Error|null + */ + final public function get_metadata() { + if ( $this->metadata === null ) { + $metadata = Packages\fetch_package_metadata( $this->did ); + if ( ! is_wp_error( $metadata ) ) { + $this->metadata = $metadata; + } + return $metadata; + } + return $this->metadata; + } + + /** + * Get the release document, fetching and caching if needed. + * + * @return \FAIR\Packages\ReleaseDocument|\WP_Error|null + */ + final public function get_release() { + if ( $this->release === null ) { + $release = Packages\get_latest_release_from_did( $this->did ); + if ( ! is_wp_error( $release ) ) { + $this->release = $release; + } + return $release; + } + return $this->release; + } +} diff --git a/inc/updater/class-pluginpackage.php b/inc/updater/class-pluginpackage.php new file mode 100644 index 00000000..14949bb4 --- /dev/null +++ b/inc/updater/class-pluginpackage.php @@ -0,0 +1,32 @@ +filepath ) ); + } + + /** + * Get the relative path used in update transients. + * + * @return string The relative path (e.g., 'my-plugin/my-plugin.php'). + */ + public function get_relative_path(): string { + return plugin_basename( $this->filepath ); + } +} diff --git a/inc/updater/class-themepackage.php b/inc/updater/class-themepackage.php new file mode 100644 index 00000000..30a949b7 --- /dev/null +++ b/inc/updater/class-themepackage.php @@ -0,0 +1,32 @@ +filepath ) ); + } + + /** + * Get the relative path used in update transients. + * + * @return string The relative path (theme directory name). + */ + public function get_relative_path(): string { + return dirname( plugin_basename( $this->filepath ) ); + } +} diff --git a/inc/updater/class-updater.php b/inc/updater/class-updater.php index aa6f415b..caa28a63 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; /** @@ -20,69 +21,25 @@ class Updater { /** - * DID. + * Registered plugins. * - * @var string + * @var array */ - protected $did; + private static array $plugins = []; /** - * Absolute path to the "main" file. + * Registered themes. * - * For plugins, this is the PHP file with the plugin header. For themes, - * this is the style.css file. - * - * @var string|null - */ - protected $filepath; - - /** - * Current installed version of the package. - * - * @var string|null - */ - protected $local_version; - - /** - * Package type, plugin or theme. - * - * @var string - */ - protected $type; - - /** - * Metadata document. - * - * @var \FAIR\Packages\MetadataDocument - */ - protected $metadata; - - /** - * Release document. - * - * @var \FAIR\Packages\ReleaseDocument + * @var array */ - protected $release; + private static array $themes = []; /** - * Constructor. - * - * @param string $did DID. - * @param string $filepath Absolute file path. - */ - public function __construct( string $did, string $filepath = '' ) { - $this->did = $did; - $this->filepath = $filepath; - $this->local_version = $filepath ? get_file_data( $filepath, [ 'Version' => 'Version' ] )['Version'] : null; - } - - /** - * Get API data. + * Check if we should run on the current page. * * @global string $pagenow Current page. - * @return void|WP_Error */ - public function run() { + public static function should_run_on_current_page(): bool { global $pagenow; // Needed for mu-plugin. @@ -100,20 +57,10 @@ public function run() { $view_details = [ 'plugin-install.php', 'theme-install.php' ]; $autoupdate_pages = [ 'admin-ajax.php', 'index.php', 'wp-cron.php' ]; if ( ! in_array( $pagenow, array_merge( $pages, $view_details, $autoupdate_pages ), true ) ) { - return; - } - - $this->metadata = Packages\fetch_package_metadata( $this->did ); - if ( is_wp_error( $this->metadata ) ) { - return $this->metadata; - } - $this->release = Packages\get_latest_release_from_did( $this->did ); - if ( is_wp_error( $this->release ) ) { - return $this->release; + return false; } - $this->type = str_replace( 'wp-', '', $this->metadata->type ); - $this->load_hooks(); + return true; } /** @@ -121,16 +68,16 @@ public function run() { * * @return void */ - public function load_hooks() { - add_filter( 'upgrader_source_selection', [ $this, 'upgrader_source_selection' ], 10, 4 ); - add_filter( "{$this->type}s_api", [ $this, 'repo_api_details' ], 99, 3 ); + public static function load_hooks() { + add_filter( 'upgrader_source_selection', [ __CLASS__, 'upgrader_source_selection' ], 10, 4 ); + add_filter( 'plugins_api', [ __CLASS__, 'plugin_api_details' ], 99, 3 ); + add_filter( 'themes_api', [ __CLASS__, 'theme_api_details' ], 99, 3 ); - if ( ! empty( $this->filepath ) && ! empty( $this->local_version ) ) { - add_filter( "site_transient_update_{$this->type}s", [ $this, 'update_site_transient' ], 20, 1 ); - } + add_filter( 'site_transient_update_plugins', [ __CLASS__, 'handle_update_plugins_transient' ], 20, 1 ); + add_filter( 'site_transient_update_themes', [ __CLASS__, 'handle_update_themes_transient' ], 20, 1 ); if ( ! is_multisite() ) { - add_filter( 'wp_prepare_themes_for_js', [ $this, 'customize_theme_update_html' ] ); + add_filter( 'wp_prepare_themes_for_js', [ __CLASS__, 'customize_theme_update_html' ] ); } /** @@ -143,13 +90,18 @@ public function load_hooks() { add_filter( 'upgrader_pre_download', 'FAIR\\Updater\\verify_signature_on_download', 10, 4 ); } - Packages\add_package_to_release_cache( $this->did ); + foreach ( self::$plugins as $package ) { + Packages\add_package_to_release_cache( $package->did ); + } + foreach ( self::$themes as $package ) { + Packages\add_package_to_release_cache( $package->did ); + } } /** * 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 +110,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 static 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. @@ -174,18 +131,20 @@ public function upgrader_source_selection( string $source, string $remote_source // Rename plugins. if ( $upgrader instanceof Plugin_Upgrader ) { - if ( isset( $hook_extra['plugin'] ) ) { - $slug = dirname( $hook_extra['plugin'] ); - $new_source = trailingslashit( $remote_source ) . $slug; + if ( ! isset( $hook_extra['plugin'] ) ) { + return $source; } + $slug = dirname( $hook_extra['plugin'] ); + $new_source = trailingslashit( $remote_source ) . $slug; } // Rename themes. if ( $upgrader instanceof Theme_Upgrader ) { - if ( isset( $hook_extra['theme'] ) ) { - $slug = $hook_extra['theme']; - $new_source = trailingslashit( $remote_source ) . $slug; + if ( ! isset( $hook_extra['theme'] ) ) { + return $source; } + $slug = $hook_extra['theme']; + $new_source = trailingslashit( $remote_source ) . $slug; } if ( basename( $source ) === $slug ) { @@ -208,48 +167,130 @@ public function upgrader_source_selection( string $source, string $remote_source * * @return stdClass|bool */ - public function repo_api_details( $result, string $action, stdClass $response ) { - if ( "{$this->type}_information" !== $action ) { + public static function plugin_api_details( $result, string $action, stdClass $response ) { + if ( 'plugin_information' !== $action ) { + return $result; + } + + return self::handle_plugin_api( $result, $response->slug ?? '' ); + } + + /** + * Put changelog in themes_api, return WP.org data as appropriate + * + * @param bool $result Default false. + * @param string $action The type of information being requested from the Theme Installation API. + * @param stdClass $response Repo API arguments. + * + * @return stdClass|bool + */ + public static function theme_api_details( $result, string $action, stdClass $response ) { + if ( 'theme_information' !== $action ) { return $result; } - // Exit if not our repo. - $slug_arr = [ $this->metadata->slug, $this->metadata->slug . '-' . Packages\get_did_hash( $this->did ) ]; - if ( ! in_array( $response->slug, $slug_arr, true ) ) { + return self::handle_theme_api( $result, $response->slug ?? '' ); + } + + /** + * Find a package by its API slug. + * + * @param bool|object $result The result object or false. + * @param string $slug The package slug. + * @param Package[] $packages The packages to search. + * @return bool|object The result. + */ + private static function find_package_by_api_slug( $result, string $slug, array $packages ) { + if ( empty( $slug ) ) { return $result; } - return (object) Packages\get_package_data( $this->did ); + foreach ( $packages as $package ) { + $metadata = $package->get_metadata(); + if ( is_wp_error( $metadata ) || ! $metadata ) { + continue; + } + + // Check if slug matches (with or without DID hash suffix). + $slug_arr = [ $metadata->slug, $metadata->slug . '-' . Packages\get_did_hash( $package->did ) ]; + if ( in_array( $slug, $slug_arr, true ) ) { + return (object) Packages\get_package_data( $package->did ); + } + } + + return $result; } /** - * Hook into site_transient_update_{plugins|themes} to update from GitHub. + * Handle site_transient_update_plugins filter. * * @param stdClass $transient Plugin|Theme update transient. + * @return stdClass The modified transient. + */ + public static function handle_update_plugins_transient( $transient ) { + $transient = self::update_site_transient( $transient, self::$plugins ); + + // WordPress expects plugin responses as objects. + foreach ( $transient->response ?? [] as $key => $value ) { + $transient->response[ $key ] = (object) $value; + } + foreach ( $transient->no_update ?? [] as $key => $value ) { + $transient->no_update[ $key ] = (object) $value; + } + + return $transient; + } + + /** + * Handle site_transient_update_themes filter. * + * @param stdClass $transient Plugin|Theme update transient. + * @return stdClass The modified transient. + */ + public static function handle_update_themes_transient( $transient ) { + return self::update_site_transient( $transient, self::$themes ); + } + + /** + * Hook into site_transient_update_{plugins|themes} to update from GitHub. + * + * @param stdClass $transient Plugin|Theme update transient. + * @param array $packages Array of packages to process. * @return stdClass */ - public function update_site_transient( $transient ) { + private static function update_site_transient( $transient, array $packages ) { // needed to fix PHP 7.4 warning. if ( ! is_object( $transient ) ) { $transient = new stdClass(); } - $rel_path = plugin_basename( $this->filepath ); - $rel_path = 'theme' === $this->type ? dirname( $rel_path ) : $rel_path; - $response = Packages\get_package_data( $this->did ); - if ( is_wp_error( $response ) ) { - return $transient; - } - $response['slug'] = $response['slug_didhash']; - $response = 'plugin' === $this->type ? (object) $response : $response; - $is_compatible = Packages\check_requirements( $this->release ); - - if ( $is_compatible && version_compare( $this->release->version, $this->local_version, '>' ) ) { - $transient->response[ $rel_path ] = $response; - } else { - // Add repo without update to $transient->no_update for 'View details' link. - $transient->no_update[ $rel_path ] = $response; + foreach ( $packages as $package ) { + if ( empty( $package->filepath ) || empty( $package->local_version ) ) { + continue; + } + + $release = $package->get_release(); + if ( is_wp_error( $release ) || ! $release ) { + continue; + } + + $response = Packages\get_package_data( $package->did ); + if ( is_wp_error( $response ) ) { + continue; + } + + $rel_path = $package->get_relative_path(); + + $response['slug'] = $response['slug_didhash']; + + $is_compatible = Packages\check_requirements( $release ); + + if ( $is_compatible && version_compare( $release->version, $package->local_version, '>' ) ) { + $transient->response[ $rel_path ] = $response; + } else { + // Add repo without update to $transient->no_update for 'View details' link. + $transient->no_update[ $rel_path ] = $response; + } } return $transient; @@ -264,17 +305,22 @@ public function update_site_transient( $transient ) { * * @return array */ - public function customize_theme_update_html( $prepared_themes ) { - $theme = $this->metadata; + public static function customize_theme_update_html( $prepared_themes ) { + foreach ( self::$themes as $package ) { + $theme = $package->get_metadata(); + if ( is_wp_error( $theme ) || ! $theme ) { + continue; + } - if ( 'theme' !== $this->type ) { - return $prepared_themes; - } + if ( ! isset( $prepared_themes[ $theme->slug ] ) ) { + continue; + } - if ( ! empty( $prepared_themes[ $theme->slug ]['hasUpdate'] ) ) { - $prepared_themes[ $theme->slug ]['update'] = $this->append_theme_actions_content( $theme ); - } else { - $prepared_themes[ $theme->slug ]['description'] .= $this->append_theme_actions_content( $theme ); + if ( ! empty( $prepared_themes[ $theme->slug ]['hasUpdate'] ) ) { + $prepared_themes[ $theme->slug ]['update'] = self::append_theme_actions_content( $theme ); + } else { + $prepared_themes[ $theme->slug ]['description'] .= self::append_theme_actions_content( $theme ); + } } return $prepared_themes; @@ -292,7 +338,7 @@ public function customize_theme_update_html( $prepared_themes ) { * * @return string (content buffer) */ - protected function append_theme_actions_content( $theme ) { + private static function append_theme_actions_content( $theme ) { $details_url = esc_attr( add_query_arg( [ @@ -370,4 +416,137 @@ protected function append_theme_actions_content( $theme ) { return trim( ob_get_clean(), '1' ); } + + /** + * Handle plugin API requests. + * + * @param bool|object $result The result object or false. + * @param string $slug The plugin slug. + * @return bool|object The result. + */ + private static function handle_plugin_api( $result, string $slug ) { + return self::find_package_by_api_slug( $result, $slug, self::$plugins ); + } + + /** + * Handle theme API requests. + * + * @param bool|object $result The result object or false. + * @param string $slug The theme slug. + * @return bool|object The result. + */ + private static function handle_theme_api( $result, string $slug ) { + return self::find_package_by_api_slug( $result, $slug, self::$themes ); + } + + /** + * Register a plugin with the registry. + * + * @param string $did The DID of the plugin. + * @param string $filepath Absolute path to the main plugin file. + */ + public static function register_plugin( string $did, string $filepath ): void { + self::$plugins[ $did ] = new PluginPackage( $did, $filepath ); + } + + /** + * Register a theme with the registry. + * + * @param string $did The DID of the theme. + * @param string $filepath Absolute path to the theme's style.css file. + */ + public static function register_theme( string $did, string $filepath ): void { + self::$themes[ $did ] = new ThemePackage( $did, $filepath ); + } + + /** + * Get a plugin by DID. + * + * @param string $did The DID to look up. + */ + public static function get_plugin( string $did ): ?PluginPackage { + return self::$plugins[ $did ] ?? null; + } + + /** + * Get a theme by DID. + * + * @param string $did The DID to look up. + */ + public static function get_theme( string $did ): ?ThemePackage { + return self::$themes[ $did ] ?? null; + } + + /** + * Get all registered plugins. + * + * @return array All registered plugins. + */ + public static function get_plugins(): array { + return self::$plugins; + } + + /** + * Get all registered themes. + * + * @return array All registered themes. + */ + public static function get_themes(): array { + return self::$themes; + } + + /** + * Find a plugin by the plugin file path (relative to plugins directory). + * + * @param string $plugin_file Plugin file path relative to plugins directory (e.g., 'my-plugin/my-plugin.php'). + */ + public static function get_plugin_by_file( string $plugin_file ): ?PluginPackage { + $plugin_path = trailingslashit( WP_PLUGIN_DIR ) . $plugin_file; + + foreach ( self::$plugins as $package ) { + if ( $package->filepath === $plugin_path ) { + return $package; + } + } + + return null; + } + + /** + * Find a plugin by its slug. + * + * @param string $slug The plugin directory name. + */ + public static function get_plugin_by_slug( string $slug ): ?PluginPackage { + foreach ( self::$plugins as $package ) { + if ( $package->get_slug() === $slug ) { + return $package; + } + } + + return null; + } + + /** + * Find a theme by its slug. + * + * @param string $slug The theme stylesheet. + */ + public static function get_theme_by_slug( string $slug ): ?ThemePackage { + foreach ( self::$themes as $package ) { + if ( $package->get_slug() === $slug ) { + return $package; + } + } + + return null; + } + + /** + * Reset the registry. + */ + public static function reset(): void { + self::$plugins = []; + self::$themes = []; + } } diff --git a/inc/updater/namespace.php b/inc/updater/namespace.php index 3db5114b..ede4e357 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' ); } /** @@ -70,23 +73,99 @@ function get_packages() : array { * @return void */ function run() { + if ( ! Updater::should_run_on_current_page() ) { + return; + } + $packages = get_packages(); $plugins = $packages['plugins'] ?? []; $themes = $packages['themes'] ?? []; - $packages = array_merge( $plugins, $themes ); - foreach ( $packages as $did => $filepath ) { - ( new Updater( $did, $filepath ) )->run(); + + foreach ( $plugins as $did => $filepath ) { + Updater::register_plugin( $did, $filepath ); + } + + foreach ( $themes as $did => $filepath ) { + Updater::register_theme( $did, $filepath ); + } + + // Load hooks once for all packages. + Updater::load_hooks(); +} + +/** + * 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( + ' + +

%4$s

+ + ', + 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..2149827a 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[34]\.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-7]\.0)(\.\d+|)|(Maci|X1{2}).+ Version\/26\.[1-3]([,.]\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-3])([._]\d+|)|Opera Mini|Android:?[ /-]14{2}(\.0|)(\.\d+|)|Mobile Safari.+OPR\/8(0\.){2}\d+|Android.+Firefox\/147\.0(\.\d+|)|Android.+Chrom(ium|e)\/14{2}\.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 ] : ''; +} diff --git a/languages/fair.pot b/languages/fair.pot index f69dec09..91e798da 100644 --- a/languages/fair.pot +++ b/languages/fair.pot @@ -1,15 +1,15 @@ -# Copyright (C) 2025 FAIR Contributors +# Copyright (C) 2026 FAIR Contributors # This file is distributed under the GPLv2. msgid "" msgstr "" -"Project-Id-Version: FAIR Connect - Federated and Independent Repositories 1.1.0\n" +"Project-Id-Version: FAIR Connect - Federated and Independent Repositories 1.2.2\n" "Report-Msgid-Bugs-To: https://github.com/fairpm/fair-plugin/issues\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2025-12-11T04:32:53+00:00\n" +"POT-Creation-Date: 2026-01-07T22:20:21+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: fair\n" @@ -319,68 +319,76 @@ msgstr "" msgid "The PLC directory did not return the DID that was sent or the DID was invalid." msgstr "" -#: inc/packages/namespace.php:47 +#: inc/packages/namespace.php:50 msgid "ID is not a valid DID." msgstr "" -#: inc/packages/namespace.php:52 +#: inc/packages/namespace.php:55 msgid "DID could not be parsed as a URI." msgstr "" -#: inc/packages/namespace.php:63 +#: inc/packages/namespace.php:66 msgid "Unsupported DID method." msgstr "" -#: inc/packages/namespace.php:128 +#: inc/packages/namespace.php:146 +msgid "The package's file list could not be retrieved." +msgstr "" + +#: inc/packages/namespace.php:162 +msgid "No FAIR packages were found." +msgstr "" + +#: inc/packages/namespace.php:180 msgid "DID is not a valid package to fetch metadata for." msgstr "" -#: inc/packages/namespace.php:139 +#: inc/packages/namespace.php:191 msgid "Fetched metadata does not match the requested DID." msgstr "" -#: inc/packages/namespace.php:172 +#: inc/packages/namespace.php:224 msgid "HTTP error code received" msgstr "" -#: inc/packages/namespace.php:252 +#: inc/packages/namespace.php:306 msgid "DID does not contain valid signing keys." msgstr "" -#: inc/packages/namespace.php:262 +#: inc/packages/namespace.php:316 msgid "No releases found in the repository." msgstr "" -#: inc/packages/namespace.php:827 +#: inc/packages/namespace.php:954 msgctxt "alias validation error" msgid "Multiple aliases set in DID; packages may only have a single alias" msgstr "" -#: inc/packages/namespace.php:837 +#: inc/packages/namespace.php:964 msgctxt "alias validation error" msgid "Invalid FAIR alias format" msgstr "" -#: inc/packages/namespace.php:846 +#: inc/packages/namespace.php:973 msgctxt "alias validation error" msgid "FAIR alias format exceeds valid domain length" msgstr "" #. translators: %s: domain -#: inc/packages/namespace.php:859 +#: inc/packages/namespace.php:986 #, php-format msgctxt "alias validation error" msgid "Missing verification record for \"%s\"" msgstr "" #. translators: %s: domain -#: inc/packages/namespace.php:873 +#: inc/packages/namespace.php:1000 #, php-format msgctxt "alias validation error" msgid "Verification record for \"%s\" is invalid" msgstr "" -#: inc/packages/namespace.php:884 +#: inc/packages/namespace.php:1011 msgctxt "alias validation error" msgid "DID in validation record does not match" msgstr "" diff --git a/plugin.php b/plugin.php index bdfb20b0..043df1cd 100644 --- a/plugin.php +++ b/plugin.php @@ -2,7 +2,7 @@ /** * Plugin Name: FAIR Connect - Federated and Independent Repositories * Description: Make your site more FAIR. - * Version: 1.2.2 + * Version: 1.3.0-RC1 * Author: FAIR Contributors * Author URI: https://fair.pm * Security: security@fair.pm @@ -20,7 +20,7 @@ namespace FAIR; -const VERSION = '1.2.2'; +const VERSION = '1.3.0-RC1'; const PLUGIN_DIR = __DIR__; const PLUGIN_FILE = __FILE__;