_ decoded 200 answer of the core API * ahcl_sums_plugin_ PluginChecksums::to_array() of a 200 answer, or array( 'not_found' => true ) * ahcl_info_plugin_ outcome of plugin_information after a 404 * * @package Timaub\AfterHackChecklist */ namespace Timaub\AfterHackChecklist\WordPress; use Timaub\AfterHackChecklist\Domain\Assumptions; use Timaub\AfterHackChecklist\Domain\CoreChecksums; use Timaub\AfterHackChecklist\Domain\PluginChecksums; defined( 'ABSPATH' ) || exit; /** * WordPress.org checksums client. */ final class Checksums { /** Seconds before an HTTP call gives up (SPEC: short timeout). */ const TIMEOUT = 10; /** * Fetches the core checksums for the site package locale, with the en_US fallback. * * @param string $version WordPress version. * @param string $locale Package locale. * @return array{status:string, locale?:string} status 'ok' (with the locale of the sums used), * 'network_error', 'not_published' or 'unexpected_response'. */ public static function fetch_core( $version, $locale ) { $data = self::core_data( $version, $locale ); if ( null === $data ) { return array( 'status' => 'network_error' ); } if ( null !== CoreChecksums::fromApiResponse( $data, $version, $locale, $locale ) ) { return array( 'status' => 'ok', 'locale' => $locale, ); } if ( ! self::is_not_published( $data ) ) { return array( 'status' => 'unexpected_response' ); } if ( Assumptions::FALLBACK_LOCALE === $locale ) { return array( 'status' => 'not_published' ); } // SPEC Tranché 3: locale without checksums, fall back to en_US. $fallback = self::core_data( $version, Assumptions::FALLBACK_LOCALE ); if ( null === $fallback ) { return array( 'status' => 'network_error' ); } if ( null !== CoreChecksums::fromApiResponse( $fallback, $version, Assumptions::FALLBACK_LOCALE, $locale ) ) { return array( 'status' => 'ok', 'locale' => Assumptions::FALLBACK_LOCALE, ); } return array( 'status' => self::is_not_published( $fallback ) ? 'not_published' : 'unexpected_response' ); } /** * Core checksums already fetched in this pass (transient, fetched again if it expired). * * @param string $version WordPress version. * @param string $locale Locale of the sums used. * @param string $requested_locale Package locale of the site. * @return CoreChecksums|null */ public static function core( $version, $locale, $requested_locale ) { $data = self::core_data( $version, $locale ); return null === $data ? null : CoreChecksums::fromApiResponse( $data, $version, $locale, $requested_locale ); } /** * Decoded body of a 200 answer of the core API, null on a network error or another status. * * @param string $version WordPress version. * @param string $locale Locale, always passed (N§A1). * @return array|null */ private static function core_data( $version, $locale ) { $key = 'ahcl_sums_core_' . $version . '_' . $locale; $cached = get_transient( $key ); if ( is_array( $cached ) ) { return $cached; } $answer = self::get( sprintf( Assumptions::CORE_CHECKSUMS_URL, rawurlencode( $version ), rawurlencode( $locale ) ) ); if ( 200 !== $answer['code'] ) { return null; } $data = json_decode( $answer['body'], true ); if ( ! is_array( $data ) ) { return array( 'unexpected' => true ); } set_transient( $key, $data, DAY_IN_SECONDS ); return $data; } /** * Whether the answer is {"checksums":false}, the documented answer for a version or locale without sums (N§A1). * * @param array $data Decoded answer. */ private static function is_not_published( array $data ) { return array_key_exists( 'checksums', $data ) && false === $data['checksums']; } /** * Fetches the checksums of one plugin version; on a 404, asks plugin_information (Tranché 1). * * @param string $slug Plugin slug. * @param string $version Plugin version. * @return string 'ok', 'network_error', 'no_sums_for_version', 'not_on_wporg', or '' when the * answer has a shape this plugin does not know (then reported as "not compared"). */ public static function fetch_plugin( $slug, $version ) { $key = self::plugin_key( $slug, $version ); $cached = get_transient( $key ); if ( ! is_array( $cached ) ) { $answer = self::get( sprintf( Assumptions::PLUGIN_CHECKSUMS_URL, rawurlencode( $slug ), rawurlencode( $version ) ) ); if ( 404 === $answer['code'] ) { $cached = array( 'not_found' => true ); } elseif ( 200 === $answer['code'] ) { $sums = PluginChecksums::fromApiResponse( json_decode( $answer['body'], true ), $slug, $version ); if ( null === $sums ) { return ''; } $cached = $sums->toArray(); } else { return 'network_error'; } set_transient( $key, $cached, DAY_IN_SECONDS ); } if ( empty( $cached['not_found'] ) ) { return 'ok'; } return self::plugin_info( $slug ); } /** * Checksums of one plugin version already fetched in this pass. * * @param string $slug Plugin slug. * @param string $version Plugin version. * @return PluginChecksums|null */ public static function plugin( $slug, $version ) { $cached = get_transient( self::plugin_key( $slug, $version ) ); if ( ! is_array( $cached ) && 'ok' === self::fetch_plugin( $slug, $version ) ) { $cached = get_transient( self::plugin_key( $slug, $version ) ); } if ( ! is_array( $cached ) || ! isset( $cached['files'] ) ) { return null; } return PluginChecksums::fromArray( $cached ); } /** * Whether a slug is on WordPress.org, after a 404 of its checksums. * * @param string $slug Plugin slug. * @return string 'no_sums_for_version', 'not_on_wporg', 'network_error' or ''. */ private static function plugin_info( $slug ) { $key = 'ahcl_info_plugin_' . md5( $slug ); $cached = get_transient( $key ); if ( is_string( $cached ) && '' !== $cached ) { return $cached; } $answer = self::get( sprintf( Assumptions::PLUGIN_INFO_URL, rawurlencode( $slug ) ) ); if ( null === $answer['code'] ) { return 'network_error'; } $data = json_decode( $answer['body'], true ); if ( ! is_array( $data ) ) { return 200 === $answer['code'] || 404 === $answer['code'] ? '' : 'network_error'; } // Answers read on 2026-10-23: a 404 with the error Plugin not found., or a 404 with the error closed and closed true. if ( ( isset( $data['error'] ) && in_array( $data['error'], array( 'Plugin not found.', 'closed' ), true ) ) || ! empty( $data['closed'] ) ) { $outcome = 'not_on_wporg'; } elseif ( 200 === $answer['code'] && isset( $data['slug'] ) ) { $outcome = 'no_sums_for_version'; } else { return ''; } set_transient( $key, $outcome, DAY_IN_SECONDS ); return $outcome; } /** * Transient name of one plugin version. * * @param string $slug Plugin slug. * @param string $version Plugin version. */ private static function plugin_key( $slug, $version ) { return 'ahcl_sums_plugin_' . md5( $slug . '@' . $version ); } /** * One GET request. * * @param string $url URL. * @return array{code:int|null, body:string} code null on a transport error. */ private static function get( $url ) { $response = wp_remote_get( $url, array( 'timeout' => self::TIMEOUT ) ); if ( is_wp_error( $response ) ) { return array( 'code' => null, 'body' => '', ); } return array( 'code' => (int) wp_remote_retrieve_response_code( $response ), 'body' => (string) wp_remote_retrieve_body( $response ), ); } }