fair-plugin/inc/updater/namespace.php
Carrie Dils 701d07e85c
Release: merge 1.4.0 into development for BETA testing (#469)
Signed-off-by: John Blackbourn <john@johnblackbourn.com>
Signed-off-by: Andy Fragen <andy@thefragens.com>
Signed-off-by: Carrie Dils <carriedils@gmail.com>
Signed-off-by: Norcross <andrew.norcross@gmail.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: joedolson <joedolson@users.noreply.github.com>
Signed-off-by: Joe Dolson <design@joedolson.com>
Signed-off-by: Shadi Sharaf <shady@sharaf.me>
Signed-off-by: Chuck Adams <chaz@chaz.works>
Signed-off-by: Mika Ipstenu Epstein <ipstenu@halfelf.org>
Signed-off-by: Ipstenu (Mika Epstein) <Ipstenu@users.noreply.github.com>
Signed-off-by: Mika <ipstenu@halfelf.org>
Signed-off-by: Mika Epstein <ipstenu@halfelf.org>
Signed-off-by: Marc Armengou <83702259+marcarmengou@users.noreply.github.com>
Signed-off-by: Carrie Dils <cdils@users.noreply.github.com>
Co-authored-by: John Blackbourn <john@johnblackbourn.com>
Co-authored-by: Chuck Adams <chaz@chaz.works>
Co-authored-by: Andy Fragen <andy@thefragens.com>
Co-authored-by: Norcross <andrew.norcross@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rmccue <21655+rmccue@users.noreply.github.com>
Co-authored-by: cdils <3099408+cdils@users.noreply.github.com>
Co-authored-by: joedolson <joedolson@users.noreply.github.com>
Co-authored-by: Joe Dolson <design@joedolson.com>
Co-authored-by: Shady Sharaf <shady@sharaf.me>
Co-authored-by: Mika Ipstenu Epstein <ipstenu@halfelf.org>
Co-authored-by: Ipstenu (Mika Epstein) <Ipstenu@users.noreply.github.com>
Co-authored-by: Marc Armengou <83702259+marcarmengou@users.noreply.github.com>
Co-authored-by: Namith Jawahar <48271037+namithj@users.noreply.github.com>
Co-authored-by: Kaspars Dambis <hi@kaspars.net>
2026-04-13 11:42:06 -05:00

279 lines
7.6 KiB
PHP

<?php
/**
* Update FAIR packages.
*
* @package FAIR
*/
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\DID\Crypto\DidCodec;
use FAIR\Packages;
use function FAIR\is_wp_cli;
use Plugin_Upgrader;
use Theme_Upgrader;
use WP_CLI;
use WP_Error;
use WP_Upgrader;
/**
* Bootstrap.
*/
function bootstrap() {
add_action( 'init', __NAMESPACE__ . '\\run' );
add_action( 'admin_init', __NAMESPACE__ . '\\register_plugin_row_hooks' );
}
/**
* Gather all plugins/themes with data in Update URI and DID header.
*
* @return array
*/
function get_packages() : array {
$packages = [];
// Seems to be required for PHPUnit testing on GitHub workflow.
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_path = trailingslashit( WP_PLUGIN_DIR );
$plugins = get_plugins();
foreach ( $plugins as $file => $plugin ) {
$plugin_id = get_file_data( $plugin_path . $file, [ 'PluginID' => 'Plugin ID' ] )['PluginID'];
if ( ! empty( $plugin_id ) ) {
$packages['plugins'][ $plugin_id ] = $plugin_path . $file;
}
}
$themes = wp_get_themes();
foreach ( $themes as $theme ) {
$stylesheet_directory = $theme->get_stylesheet_directory();
if ( empty( $stylesheet_directory ) ) {
// The theme root is missing.
continue;
}
$stylesheet_file = trailingslashit( $stylesheet_directory ) . 'style.css';
$theme_id = get_file_data( $stylesheet_file, [ 'ThemeID' => 'Theme ID' ] )['ThemeID'];
if ( ! empty( $theme_id ) ) {
$packages['themes'][ $theme_id ] = $stylesheet_file;
}
}
return $packages;
}
/**
* Run FAIR\Updater\Updater for potential packages.
*
* @return void
*/
function run() {
if ( ! Updater::should_run_on_current_page() ) {
return;
}
$packages = get_packages();
$plugins = $packages['plugins'] ?? [];
$themes = $packages['themes'] ?? [];
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(
'<tr class="plugin-update-tr%1$s" id="fair-error-%2$s">
<td colspan="%3$d" class="plugin-update colspanchange">
<div class="update-message notice inline notice-error notice-alt"><p>%4$s</p></div>
</td>
</tr>',
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.
* @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 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 = [];
if ( false !== $reply || ( ! $upgrader instanceof Plugin_Upgrader && ! $upgrader instanceof Theme_Upgrader ) ) {
return $reply;
}
$did = get_site_transient( CACHE_DID_FOR_INSTALL );
if ( ! $did ) {
return $reply;
}
// This method is hooked to 'upgrader_pre_download', which is used in WP_Upgrader::download_package().
// Bailing on subsequent runs for the same package URI prevents an infinite loop.
$key = sha1( $did . '_' . $package );
if ( isset( $has_run[ $key ] ) ) {
return $reply;
}
$has_run[ $key ] = true;
// Local files should be returned untouched.
if ( ! preg_match( '!^(http|https|ftp)://!i', $package ) && file_exists( $package ) ) {
return $package;
}
$releases = get_site_transient( CACHE_RELEASE_PACKAGES ) ?? [];
if ( empty( $releases ) || ! isset( $releases[ $did ] ) ) {
return $reply;
}
$artifact = Packages\pick_artifact_by_lang( $releases[ $did ]->artifacts->package );
if ( ! $artifact || $package !== $artifact->url ) {
return $reply;
}
$path = $upgrader->download_package( $package, false, $hook_extra );
if ( is_wp_error( $path ) ) {
return $path;
}
add_filter( 'wp_trusted_keys', __NAMESPACE__ . '\\get_trusted_keys', 100 );
$decoded_base64url = sodium_base642bin( $artifact->signature, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING );
$result = verify_file_signature( $path, base64_encode( $decoded_base64url ) );
remove_filter( 'wp_trusted_keys', __NAMESPACE__ . '\\get_trusted_keys', 100 );
if ( $result === true ) {
if ( is_wp_cli() ) {
WP_CLI::success(
sprintf(
/* translators: %s: The DID of the package. */
__( 'Verified signature for %s', 'fair' ),
$did
)
);
}
return $path;
}
if ( is_wp_error( $result ) ) {
return $result;
}
return new WP_Error(
'fair.packages.signature_verification.failed',
sprintf(
/* translators: %s: The package's URL. */
__( 'Signature verification could not be performed for the package: %s', 'fair' ),
$package
)
);
}
/**
* Get trusted keys for signature verification.
*
* @return array
*/
function get_trusted_keys(): array {
$did = get_site_transient( CACHE_DID_FOR_INSTALL );
if ( ! $did ) {
return [];
}
$doc = Packages\get_did_document( $did );
if ( is_wp_error( $doc ) ) {
return [];
}
$keys = Packages\get_fair_signing_keys( $doc );
if ( empty( $keys ) ) {
return [];
}
// FAIR uses Base58BTC-encoded Ed25519 keys.
// Core expects base64-encoded keys.
$recoded_keys = [];
foreach ( $keys as $key ) {
$decoded = DidCodec::from_multibase_key( $key['publicKeyMultibase'] );
// Ed25519 keys only.
if ( $decoded['codec'] !== DidCodec::MULTICODEC_ED25519_PUB ) {
continue;
}
$recoded_keys[] = base64_encode( $decoded['key'] );
}
return $recoded_keys;
}