mirror of
https://gh.wpcy.net/https://github.com/stingray82/uupd.git
synced 2026-05-26 02:57:41 +08:00
1656 lines
55 KiB
PHP
1656 lines
55 KiB
PHP
<?php
|
||
/**
|
||
* Universal Updater Drop-In (UUPD) 2.0 for Plugins & Themes
|
||
* =========================================================
|
||
*
|
||
* A lightweight, self-contained WordPress updater supporting both
|
||
* private JSON endpoints and GitHub Releases (public or private),
|
||
* now with vendor + slug scoped identity.
|
||
*
|
||
* Designed to be copied directly into plugins or themes with no
|
||
* external dependencies.
|
||
*
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* Supported Features
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
*
|
||
* ✔ Private update servers (JSON metadata)
|
||
* ✔ GitHub Releases-based updates (public or private)
|
||
* ✔ Manual “Check for updates” trigger
|
||
* ✔ WordPress-native update UI integration
|
||
* ✔ Private GitHub release assets (via API + token)
|
||
* ✔ Caching via WordPress transients
|
||
* ✔ Pre-release (alpha/beta/RC/dev) handling
|
||
* ✔ Optional branding (icons, banners, screenshots)
|
||
* ✔ Vendor + slug scoped filters and cache keys
|
||
*
|
||
* Safe to include multiple times, as long as namespace/class naming is isolated
|
||
* when bundling separate copies.
|
||
*
|
||
* ───────────────────────── Compatibility / Upgrade Notes ─────────────────────
|
||
*
|
||
* Version 2.0 is a breaking upgrade from the legacy slug-only identity model.
|
||
*
|
||
* Every updater registration is now uniquely identified by:
|
||
*
|
||
* vendor + slug
|
||
*
|
||
* This prevents collisions when multiple plugin authors bundle UUPD in
|
||
* their own products.
|
||
*
|
||
* Important upgrade notes from V1:
|
||
*
|
||
* • `vendor` is now REQUIRED
|
||
* • Core filters now support layered resolution: base, vendor-wide, and vendor + slug scoped
|
||
* • Cache keys are vendor-aware by default
|
||
* • Manual checks are vendor-aware
|
||
* • Slug-only filter naming is not supported; use base, vendor-wide, or vendor + slug filters
|
||
*
|
||
* Existing V1-style slug-only registrations must be updated before using V2.
|
||
*
|
||
* ⚠️ Notes for edge cases:
|
||
*
|
||
* • GitHub auto-detection is strict:
|
||
* GitHub Releases mode is triggered ONLY when `server` is a repo-root URL:
|
||
*
|
||
* ✅ https://github.com/owner/repo
|
||
* ❌ https://github.com/owner/repo/releases
|
||
* ❌ https://raw.githubusercontent.com/...
|
||
*
|
||
* If you want to force GitHub Releases mode, explicitly set:
|
||
*
|
||
* 'mode' => 'github_release'
|
||
*
|
||
* • If a GitHub token is configured, UUPD automatically injects
|
||
* Authorization headers for outgoing GitHub API and asset requests
|
||
* when appropriate.
|
||
*
|
||
* This is required for private repositories, private assets,
|
||
* and may help avoid GitHub API rate limits.
|
||
*
|
||
* ───────────────────────────── Update Modes ─────────────────────────────
|
||
*
|
||
* UUPD supports two update modes:
|
||
*
|
||
* 1) JSON Mode (Private Update Server)
|
||
* -----------------------------------
|
||
* Set `server` to a JSON metadata URL (recommended: ends with `.json`)
|
||
*
|
||
* Example:
|
||
* https://example.com/uupd/index.json
|
||
*
|
||
* JSON metadata may include:
|
||
* - version
|
||
* - download_url
|
||
* - homepage
|
||
* - author / author_homepage
|
||
* - sections (changelog, description, installation, etc)
|
||
* - icons, banners, screenshots
|
||
*
|
||
* 2) GitHub Releases Mode
|
||
* -----------------------
|
||
* Set `server` to the GitHub repository root:
|
||
*
|
||
* https://github.com/<owner>/<repo>
|
||
*
|
||
* UUPD will call:
|
||
* https://api.github.com/repos/<owner>/<repo>/releases/latest
|
||
*
|
||
* • Public repos work without a token
|
||
* • Private repos and/or private assets REQUIRE a GitHub Token
|
||
* Note: GitHub Releases mode uses /releases/latest, which returns GitHub’s
|
||
* latest non-prerelease release. For GitHub-hosted prerelease support, use
|
||
* JSON/static metadata mode with stable_version and prerelease_version fields.
|
||
*
|
||
* ───────────────────────── Mode Auto-Detection ─────────────────────────
|
||
*
|
||
* Default mode: 'auto'
|
||
*
|
||
* • If `server` is a GitHub repo root → GitHub Releases mode
|
||
* • Otherwise → JSON mode
|
||
*
|
||
* You may force a mode explicitly:
|
||
*
|
||
* 'mode' => 'auto' // default
|
||
* 'mode' => 'json' // always use JSON metadata
|
||
* 'mode' => 'github_release' // always use GitHub Releases
|
||
*
|
||
* ───────────────────────── Required Config ─────────────────────────
|
||
*
|
||
* vendor Unique creator/vendor namespace, e.g. 'tdlab'
|
||
* slug Plugin or theme slug
|
||
* name Human-readable name
|
||
* version Current installed version
|
||
* server Update server base URL, JSON metadata URL, or GitHub repo root
|
||
*
|
||
* Optional keys include:
|
||
*
|
||
* plugin_file plugin_basename( __FILE__ ) for plugins
|
||
* real_slug Theme folder slug where needed
|
||
* key License/auth key appended to JSON metadata requests
|
||
* github_token Token for private GitHub release access
|
||
* github_asset_name Preferred asset name for GitHub Releases
|
||
* mode auto|json|github_release
|
||
* allow_prerelease true|false
|
||
* release_channel Optional channel: stable|dev|alpha|beta|rc|prerelease
|
||
* cache_prefix Transient key prefix. Default: 'uupd_<vendor>__'
|
||
* icons Optional icons array
|
||
* banners Optional banners array
|
||
* screenshots Optional screenshots array
|
||
* screenshot Optional single screenshot URL
|
||
*
|
||
* ───────────────────────── Filter Hierarchy ─────────────────────────
|
||
*
|
||
* UUPD 2.0 supports layered filter resolution for flexibility during
|
||
* development, testing, fleet-wide overrides, and per-product exceptions.
|
||
*
|
||
* Filters are applied in this order:
|
||
*
|
||
* 1) Base/global filter:
|
||
* uupd/<filter>
|
||
*
|
||
* 2) Vendor-wide filter:
|
||
* uupd/<filter>/<vendor>
|
||
*
|
||
* 3) Fully scoped filter:
|
||
* uupd/<filter>/<vendor>/<slug>
|
||
*
|
||
* Later filters receive the result of earlier ones and may override them.
|
||
*
|
||
* Example:
|
||
*
|
||
* add_filter( 'uupd/server_url', function( $url, $vendor, $slug, $instance_key ) {
|
||
* if ( $vendor === 'tdlab' ) {
|
||
* return 'https://updates.example.com/';
|
||
* }
|
||
* return $url;
|
||
* }, 10, 4 );
|
||
*
|
||
* add_filter( 'uupd/server_url/tdlab', function( $url, $vendor, $slug, $instance_key ) {
|
||
* return 'https://staging.example.com/';
|
||
* }, 10, 4 );
|
||
*
|
||
* add_filter( 'uupd/server_url/tdlab/my-plugin', function( $url, $vendor, $slug, $instance_key ) {
|
||
* return 'https://example.com/custom-endpoint.json';
|
||
* }, 10, 4 );
|
||
*
|
||
* Common filters:
|
||
*
|
||
* uupd/filter_config
|
||
* uupd/filter_config/<vendor>
|
||
* uupd/filter_config/<vendor>/<slug>
|
||
* uupd/server_url
|
||
* uupd/server_url/<vendor>
|
||
* uupd/server_url/<vendor>/<slug>
|
||
* uupd/cache_prefix
|
||
* uupd/cache_prefix/<vendor>
|
||
* uupd/cache_prefix/<vendor>/<slug>
|
||
* uupd/github_token_override
|
||
* uupd/github_token_override/<vendor>
|
||
* uupd/github_token_override/<vendor>/<slug>
|
||
* uupd/icons
|
||
* uupd/icons/<vendor>
|
||
* uupd/icons/<vendor>/<slug>
|
||
* uupd/banners
|
||
* uupd/banners/<vendor>
|
||
* uupd/banners/<vendor>/<slug>
|
||
* uupd/screenshots
|
||
* uupd/screenshots/<vendor>
|
||
* uupd/screenshots/<vendor>/<slug>
|
||
* uupd/screenshot
|
||
* uupd/screenshot/<vendor>
|
||
* uupd/screenshot/<vendor>/<slug>
|
||
* uupd_success_cache_ttl
|
||
* uupd_success_cache_ttl/<vendor>
|
||
* uupd_success_cache_ttl/<vendor>/<slug>
|
||
* uupd_fetch_remote_error_ttl
|
||
* uupd_fetch_remote_error_ttl/<vendor>
|
||
* uupd_fetch_remote_error_ttl/<vendor>/<slug>
|
||
* uupd/manual_check_redirect
|
||
* uupd/manual_check_redirect/<vendor>
|
||
* uupd/manual_check_redirect/<vendor>/<slug>
|
||
* uupd/allow_prerelease
|
||
* uupd/allow_prerelease/<vendor>
|
||
* uupd/allow_prerelease/<vendor>/<slug>
|
||
* uupd/remote_url
|
||
* uupd/remote_url/<vendor>
|
||
* uupd/remote_url/<vendor>/<slug>
|
||
* uupd/metadata_result
|
||
* uupd/metadata_result/<vendor>
|
||
* uupd/metadata_result/<vendor>/<slug>
|
||
*
|
||
*
|
||
* ───────────────────────── Actions ─────────────────────────
|
||
*
|
||
* Generic actions:
|
||
*
|
||
* uupd/before_fetch_remote
|
||
* uupd_metadata_fetch_failed
|
||
* uupd/log
|
||
*
|
||
* Scoped actions:
|
||
*
|
||
* uupd/before_fetch_remote/<vendor>/<slug>
|
||
* uupd_metadata_fetch_failed/<vendor>/<slug>
|
||
*
|
||
* Legacy slug-only failure actions may also be emitted for compatibility:
|
||
*
|
||
* uupd_metadata_fetch_failed/<slug>
|
||
*
|
||
* ───────────────────────── GitHub Token Filters ─────────────────────────
|
||
*
|
||
* Override GitHub tokens per vendor + slug:
|
||
*
|
||
* add_filter( 'uupd/github_token_override/tdlab/my-plugin', function( $token, $vendor, $slug ) {
|
||
* return 'ghp_tokenForThisProject';
|
||
* }, 10, 3 );
|
||
*
|
||
* Token scopes:
|
||
* • Private repos generally require appropriate `repo` access
|
||
*
|
||
* ───────────────────────── Visual Assets & Branding ─────────────────────────
|
||
*
|
||
* In JSON mode, icons/banners are usually read directly from metadata.
|
||
*
|
||
* In GitHub Releases mode, UUPD does not fetch separate remote JSON metadata
|
||
* unless you explicitly use JSON mode, so branding may be supplied via config
|
||
* or via scoped filters.
|
||
*
|
||
* Via config:
|
||
*
|
||
* 'icons' => [
|
||
* '1x' => 'https://cdn.example.com/icon-128.png',
|
||
* '2x' => 'https://cdn.example.com/icon-256.png',
|
||
* ],
|
||
*
|
||
* 'banners' => [
|
||
* 'low' => 'https://cdn.example.com/banner-772x250.png',
|
||
* 'high' => 'https://cdn.example.com/banner-1544x500.png',
|
||
* ],
|
||
*
|
||
* Via scoped filters:
|
||
*
|
||
* add_filter( 'uupd/icons/tdlab/my-plugin', function( $icons ) {
|
||
* return [
|
||
* '1x' => 'https://cdn.example.com/icon-128.png',
|
||
* '2x' => 'https://cdn.example.com/icon-256.png',
|
||
* ];
|
||
* } );
|
||
*
|
||
* add_filter( 'uupd/banners/tdlab/my-plugin', function( $banners ) {
|
||
* return [
|
||
* 'low' => 'https://cdn.example.com/banner-772x250.png',
|
||
* 'high' => 'https://cdn.example.com/banner-1544x500.png',
|
||
* ];
|
||
* } );
|
||
*
|
||
* ─────────────────────────── Plugin Integration ───────────────────────────
|
||
*
|
||
* add_action( 'plugins_loaded', function() {
|
||
* require_once __DIR__ . '/includes/updater.php';
|
||
*
|
||
* \UUPD\V2\UUPD_Updater_V2::register( [
|
||
* 'vendor' => 'tdlab',
|
||
* 'plugin_file' => plugin_basename( __FILE__ ),
|
||
* 'slug' => 'my-plugin-slug',
|
||
* 'name' => 'My Plugin Name',
|
||
* 'version' => MY_PLUGIN_VERSION,
|
||
* 'server' => 'https://github.com/user/repo',
|
||
* 'github_token'=> 'ghp_YourTokenHere',
|
||
* ] );
|
||
* }, 1 );
|
||
*
|
||
* ─────────────────────────── Theme Integration ───────────────────────────
|
||
*
|
||
* add_action( 'after_setup_theme', function() {
|
||
* require_once get_stylesheet_directory() . '/includes/updater.php';
|
||
*
|
||
* add_action( 'admin_init', function() {
|
||
* \UUPD\V2\UUPD_Updater_V2::register( [
|
||
* 'vendor' => 'tdlab',
|
||
* 'slug' => 'my-theme-folder',
|
||
* 'real_slug' => 'my-theme-folder',
|
||
* 'name' => 'My Theme Name',
|
||
* 'version' => '1.0.0',
|
||
* 'server' => 'https://github.com/user/repo',
|
||
* 'github_token' => 'ghp_YourTokenHere',
|
||
* ] );
|
||
* } );
|
||
* } );
|
||
*
|
||
* ───────────────────────── Cache Duration Filters ─────────────────────────
|
||
*
|
||
* add_filter( 'uupd_success_cache_ttl/tdlab/my-plugin', function( $ttl, $vendor, $slug ) {
|
||
* return 1 * HOUR_IN_SECONDS;
|
||
* }, 10, 3 );
|
||
*
|
||
* add_filter( 'uupd_fetch_remote_error_ttl/tdlab/my-plugin', function( $ttl, $vendor, $slug ) {
|
||
* return 15 * MINUTE_IN_SECONDS;
|
||
* }, 10, 3 );
|
||
*
|
||
* ───────────────────────── Optional Debugging ─────────────────────────
|
||
*
|
||
* add_filter( 'updater_enable_debug', '__return_true' );
|
||
*
|
||
* In wp-config.php:
|
||
* define( 'WP_DEBUG', true );
|
||
* define( 'WP_DEBUG_LOG', true );
|
||
*
|
||
* ───────────────────────── Release Channels ─────────────────────────
|
||
*
|
||
* UUPD supports hierarchical release channels for controlling which
|
||
* versions are considered valid updates.
|
||
*
|
||
* Channels are cumulative, meaning each level includes all previous ones:
|
||
*
|
||
* stable → Stable releases only (default)
|
||
* dev → Stable + dev
|
||
* alpha → Stable + dev + alpha
|
||
* beta → Stable + dev + alpha + beta
|
||
* rc → Stable + dev + alpha + beta + rc
|
||
* prerelease → All versions (stable + all pre-release types)
|
||
*
|
||
* Example:
|
||
*
|
||
* 'release_channel' => 'beta'
|
||
*
|
||
* Allows updates from:
|
||
* - stable
|
||
* - dev
|
||
* - alpha
|
||
* - beta
|
||
*
|
||
* But excludes:
|
||
* - rc (if considered higher than beta in your system)
|
||
*
|
||
* To allow ALL non-stable releases, use:
|
||
*
|
||
* 'release_channel' => 'prerelease'
|
||
*
|
||
* Note:
|
||
* • If `release_channel` is not set, it defaults to:
|
||
* - 'stable' when allow_prerelease = false
|
||
* - 'prerelease' when allow_prerelease = true
|
||
*
|
||
* • `allow_prerelease` acts as a convenience flag but
|
||
* `release_channel` provides full control.
|
||
*
|
||
* ───────────────────────── Summary ─────────────────────────
|
||
*
|
||
* • Fetches update metadata from JSON or GitHub Releases
|
||
* • Injects updates into native WordPress transients
|
||
* • Supports private repos, private assets, and branding
|
||
* • Uses vendor + slug scoped identity to avoid collisions
|
||
* • Zero dependencies, safe to bundle anywhere
|
||
*
|
||
* @package UUPD\V2
|
||
*/
|
||
namespace UUPD\V2;
|
||
|
||
if ( ! class_exists( __NAMESPACE__ . '\UUPD_Updater_V2' ) ) {
|
||
|
||
class UUPD_Updater_V2 {
|
||
|
||
const VERSION = '2.0.0-beta.1';
|
||
|
||
/** @var array Configuration settings */
|
||
private $config;
|
||
|
||
private static function sanitize_identity_part( $value ) {
|
||
return sanitize_key( (string) $value );
|
||
}
|
||
|
||
private static function build_instance_key( $vendor, $slug ) {
|
||
return self::sanitize_identity_part( $vendor ) . '__' . self::sanitize_identity_part( $slug );
|
||
}
|
||
|
||
/**
|
||
* Apply a layered filter using base, vendor-wide, and vendor+slug scopes.
|
||
*
|
||
* Filters are resolved in this order:
|
||
* 1) {$filter_base}
|
||
* 2) {$filter_base}/{$vendor}
|
||
* 3) {$filter_base}/{$vendor}/{$slug}
|
||
*
|
||
* Each later filter receives the value returned by the previous stage.
|
||
*
|
||
* Callbacks receive:
|
||
* - $value
|
||
* - $vendor
|
||
* - $slug
|
||
* - $instance_key
|
||
*
|
||
* @param string $filter_base Filter base name without trailing identity.
|
||
* @param mixed $default Default value.
|
||
* @param string $vendor Vendor identity.
|
||
* @param string $slug Plugin/theme slug.
|
||
* @return mixed
|
||
*/
|
||
private static function apply_filters_scoped( $filter_base, $default, $vendor, $slug ) {
|
||
$vendor = self::sanitize_identity_part( $vendor );
|
||
$slug = self::sanitize_identity_part( $slug );
|
||
$instance_key = self::build_instance_key( $vendor, $slug );
|
||
|
||
$value = apply_filters(
|
||
$filter_base,
|
||
$default,
|
||
$vendor,
|
||
$slug,
|
||
$instance_key
|
||
);
|
||
|
||
$value = apply_filters(
|
||
"{$filter_base}/{$vendor}",
|
||
$value,
|
||
$vendor,
|
||
$slug,
|
||
$instance_key
|
||
);
|
||
|
||
$value = apply_filters(
|
||
"{$filter_base}/{$vendor}/{$slug}",
|
||
$value,
|
||
$vendor,
|
||
$slug,
|
||
$instance_key
|
||
);
|
||
|
||
return $value;
|
||
}
|
||
|
||
/**
|
||
* Emit metadata failure actions in generic, legacy slug-only, and vendor+slug-scoped forms.
|
||
*
|
||
* @param string $vendor Vendor.
|
||
* @param string $slug Slug.
|
||
* @param array $data Failure payload.
|
||
* @return void
|
||
*/
|
||
private static function do_metadata_failure_actions( $vendor, $slug, array $data ) {
|
||
$vendor = self::sanitize_identity_part( $vendor );
|
||
$slug = self::sanitize_identity_part( $slug );
|
||
|
||
do_action( 'uupd_metadata_fetch_failed', $data );
|
||
|
||
// Legacy slug-only compatibility.
|
||
do_action( "uupd_metadata_fetch_failed/{$slug}", $data );
|
||
|
||
// Vendor-aware scoped action.
|
||
do_action( "uupd_metadata_fetch_failed/{$vendor}/{$slug}", $data );
|
||
}
|
||
|
||
/**
|
||
* Constructor.
|
||
*
|
||
* @param array $config {
|
||
* @type string $vendor Vendor namespace/identity. Required.
|
||
* @type string $slug Plugin or theme slug. Required.
|
||
* @type string $name Human-readable name.
|
||
* @type string $version Current version.
|
||
* @type string $key Secret/auth key for JSON metadata requests.
|
||
* @type string $server Base URL, JSON metadata URL, or GitHub repo root URL.
|
||
* @type string $plugin_file Optional plugin_basename(__FILE__) for plugins.
|
||
* @type bool $allow_prerelease Optional whether prerelease versions are allowed.
|
||
* @type string $release_channel Optional release channel: stable|dev|alpha|beta|rc|prerelease.
|
||
* @type string $cache_prefix Optional transient prefix. Default 'uupd_<vendor>__'.
|
||
* @type string $mode Optional mode: auto|json|github_release.
|
||
* @type string $github_token Optional GitHub token for private release access.
|
||
* @type string $github_asset_name Optional preferred release asset filename.
|
||
* @type string $mode Optional mode: auto|json|github_release.
|
||
* @type string $github_asset_name Optional preferred GitHub release asset filename.
|
||
* @type array $icons Optional icons array.
|
||
* @type array $banners Optional banners array.
|
||
* @type array $screenshots Optional screenshots array.
|
||
* @type string $screenshot Optional single screenshot URL.
|
||
* }
|
||
*/
|
||
public function __construct( array $config ) {
|
||
$config['vendor'] = self::sanitize_identity_part( $config['vendor'] ?? '' );
|
||
$config['slug'] = self::sanitize_identity_part( $config['slug'] ?? '' );
|
||
|
||
if ( $config['vendor'] === '' ) {
|
||
_doing_it_wrong( __METHOD__, __( 'Missing vendor in UUPD_Updater_V2 configuration.', 'default' ), self::VERSION );
|
||
return;
|
||
}
|
||
|
||
if ( $config['slug'] === '' ) {
|
||
_doing_it_wrong( __METHOD__, __( 'Missing slug in UUPD_Updater_V2 configuration.', 'default' ), self::VERSION );
|
||
return;
|
||
}
|
||
|
||
$config['instance_key'] = self::build_instance_key( $config['vendor'], $config['slug'] );
|
||
|
||
$config = self::apply_filters_scoped( 'uupd/filter_config', $config, $config['vendor'], $config['slug'] );
|
||
|
||
$config['allow_prerelease'] = self::apply_filters_scoped(
|
||
'uupd/allow_prerelease',
|
||
$config['allow_prerelease'] ?? false,
|
||
$config['vendor'],
|
||
$config['slug']
|
||
);
|
||
|
||
$config['server'] = self::apply_filters_scoped(
|
||
'uupd/server_url',
|
||
$config['server'] ?? '',
|
||
$config['vendor'],
|
||
$config['slug']
|
||
);
|
||
|
||
$default_cache_prefix = 'uupd_' . $config['vendor'] . '__';
|
||
$config['cache_prefix'] = self::apply_filters_scoped(
|
||
'uupd/cache_prefix',
|
||
$config['cache_prefix'] ?? $default_cache_prefix,
|
||
$config['vendor'],
|
||
$config['slug']
|
||
);
|
||
|
||
$this->config = $config;
|
||
$this->log( '✓ Using UUPD_Updater_V2 version ' . self::VERSION );
|
||
$this->register_hooks();
|
||
}
|
||
|
||
/**
|
||
* Filter outgoing HTTP requests so GitHub downloads include auth headers when needed.
|
||
*
|
||
* @param array $args HTTP request arguments.
|
||
* @param string $url Request URL.
|
||
* @return array
|
||
*/
|
||
public function filter_http_request_args( $args, $url ) {
|
||
$url = (string) $url;
|
||
|
||
// Only touch GitHub URLs (public + API).
|
||
if ( strpos( $url, 'github.com/' ) === false && strpos( $url, 'api.github.com/' ) === false ) {
|
||
return $args;
|
||
}
|
||
|
||
return $this->add_github_auth_headers_for_download( $args, $url );
|
||
}
|
||
|
||
/** Attach update and info filters for plugin or theme. */
|
||
private function register_hooks() {
|
||
if ( ! empty( $this->config['plugin_file'] ) ) {
|
||
add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'plugin_update' ] );
|
||
add_filter( 'site_transient_update_plugins', [ $this, 'plugin_update' ] ); // WP 6.8+
|
||
add_filter( 'plugins_api', [ $this, 'plugin_info' ], 10, 3 );
|
||
} else {
|
||
add_filter( 'pre_set_site_transient_update_themes', [ $this, 'theme_update' ] );
|
||
add_filter( 'site_transient_update_themes', [ $this, 'theme_update' ] ); // WP 6.8+
|
||
add_filter( 'themes_api', [ $this, 'theme_info' ], 10, 3 );
|
||
}
|
||
|
||
// Add GitHub auth headers when WP downloads metadata or zip packages.
|
||
add_filter( 'http_request_args', [ $this, 'filter_http_request_args' ], 10, 2 );
|
||
}
|
||
|
||
/**
|
||
* Resolve a download URL from various common metadata keys.
|
||
*
|
||
* Supports multiple providers that may return different field names.
|
||
*
|
||
* @param object $meta Metadata object.
|
||
* @return string
|
||
*/
|
||
private function resolve_download_url( $meta ) {
|
||
if ( ! is_object( $meta ) ) {
|
||
return '';
|
||
}
|
||
|
||
$candidates = [
|
||
$meta->download_url ?? '',
|
||
$meta->package ?? '',
|
||
$meta->download_link ?? '',
|
||
$meta->download_uri ?? '',
|
||
$meta->trunk ?? '',
|
||
];
|
||
|
||
foreach ( $candidates as $u ) {
|
||
$u = trim( (string) $u );
|
||
if ( $u !== '' ) {
|
||
return $u;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
private function select_metadata_track( $meta ) {
|
||
if ( ! is_object( $meta ) ) {
|
||
return $meta;
|
||
}
|
||
|
||
if ( ! empty( $meta->stable_version ) ) {
|
||
$meta->version = $meta->stable_version;
|
||
}
|
||
|
||
if ( ! empty( $meta->stable_download_url ) ) {
|
||
$meta->download_url = $meta->stable_download_url;
|
||
$meta->package = $meta->stable_download_url;
|
||
$meta->download_link = $meta->stable_download_url;
|
||
}
|
||
|
||
$allow_prerelease = ! empty( $this->config['allow_prerelease'] );
|
||
|
||
if ( ! $allow_prerelease ) {
|
||
$meta->selected_release_channel = 'stable';
|
||
return $meta;
|
||
}
|
||
|
||
$stable_version = $meta->version ?? '';
|
||
$pre_version = $meta->prerelease_version ?? '';
|
||
|
||
if ( '' === (string) $pre_version ) {
|
||
return $meta;
|
||
}
|
||
|
||
if ( version_compare( $this->normalize_version( $pre_version ), $this->normalize_version( $stable_version ), '>' ) ) {
|
||
$meta->version = $pre_version;
|
||
|
||
if ( ! empty( $meta->prerelease_download_url ) ) {
|
||
$meta->download_url = $meta->prerelease_download_url;
|
||
$meta->package = $meta->prerelease_download_url;
|
||
$meta->download_link = $meta->prerelease_download_url;
|
||
}
|
||
|
||
$meta->selected_release_channel = $meta->prerelease_channel ?? 'prerelease';
|
||
}
|
||
|
||
return $meta;
|
||
}
|
||
|
||
|
||
|
||
/** Fetch metadata JSON from remote server and cache it. */
|
||
private function fetch_remote() {
|
||
$c = $this->config;
|
||
$slug_plain = $c['slug'] ?? '';
|
||
$vendor = $c['vendor'] ?? '';
|
||
$prefix = $c['cache_prefix'] ?? 'uupd_' . $vendor . '__';
|
||
|
||
if ( empty( $c['server'] ) ) {
|
||
$this->log( 'No server URL configured — skipping fetch and caching an error state.' );
|
||
$ttl = self::apply_filters_scoped( 'uupd_fetch_remote_error_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug_plain );
|
||
set_transient( $this->get_metadata_cache_key() . '_error', time(), $ttl );
|
||
|
||
self::do_metadata_failure_actions( $vendor, $slug_plain, [
|
||
'vendor' => $vendor,
|
||
'slug' => $slug_plain,
|
||
'server' => '',
|
||
'message' => 'No server configured',
|
||
] );
|
||
return;
|
||
}
|
||
|
||
$slug_qs = rawurlencode( $slug_plain );
|
||
$key_qs = rawurlencode( isset( $c['key'] ) ? $c['key'] : '' );
|
||
$host_qs = rawurlencode( wp_parse_url( untrailingslashit( home_url() ), PHP_URL_HOST ) );
|
||
|
||
$is_json = self::ends_with( $c['server'], '.json' );
|
||
|
||
if ( $is_json ) {
|
||
$url = $c['server'];
|
||
} else {
|
||
$separator = strpos( $c['server'], '?' ) === false ? '?' : '&';
|
||
$allow_prerelease_qs = ! empty( $c['allow_prerelease'] ) ? '1' : '0';
|
||
$release_channel_qs = rawurlencode( $this->get_release_channel() );
|
||
|
||
$url = untrailingslashit( $c['server'] ) . $separator . "action=get_metadata&slug={$slug_qs}&key={$key_qs}&domain={$host_qs}&allow_prerelease={$allow_prerelease_qs}&release_channel={$release_channel_qs}";
|
||
}
|
||
|
||
$url = self::apply_filters_scoped( 'uupd/remote_url', $url, $vendor, $slug_plain );
|
||
|
||
$failure_cache_key = $this->get_metadata_cache_key() . '_error';
|
||
|
||
$this->log( " Fetching metadata: {$url}" );
|
||
do_action( 'uupd/before_fetch_remote', $vendor, $slug_plain, $c );
|
||
do_action( "uupd/before_fetch_remote/{$vendor}/{$slug_plain}", $c );
|
||
$this->log( "→ Triggered action: uupd/before_fetch_remote for '{$slug_plain}'" );
|
||
|
||
$resp = wp_remote_get(
|
||
$url,
|
||
[
|
||
'timeout' => 15,
|
||
'headers' => [ 'Accept' => 'application/json' ],
|
||
]
|
||
);
|
||
|
||
if ( is_wp_error( $resp ) ) {
|
||
$msg = $resp->get_error_message();
|
||
$this->log( " WP_Error: $msg — caching failure for 6 hours" );
|
||
$ttl = self::apply_filters_scoped( 'uupd_fetch_remote_error_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug_plain );
|
||
set_transient( $failure_cache_key, time(), $ttl );
|
||
|
||
self::do_metadata_failure_actions( $vendor, $slug_plain, [
|
||
'vendor' => $vendor,
|
||
'slug' => $slug_plain,
|
||
'server' => $c['server'],
|
||
'message' => $msg,
|
||
] );
|
||
return;
|
||
}
|
||
|
||
$code = wp_remote_retrieve_response_code( $resp );
|
||
$body = wp_remote_retrieve_body( $resp );
|
||
|
||
$this->log( '← HTTP ' . $code . ': ' . trim( $body ) );
|
||
|
||
if ( 200 !== (int) $code ) {
|
||
$this->log( "Unexpected HTTP {$code} — update fetch will pause until next cycle" );
|
||
$ttl = self::apply_filters_scoped( 'uupd_fetch_remote_error_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug_plain );
|
||
set_transient( $failure_cache_key, time(), $ttl );
|
||
|
||
self::do_metadata_failure_actions( $vendor, $slug_plain, [
|
||
'vendor' => $vendor,
|
||
'slug' => $slug_plain,
|
||
'server' => $c['server'],
|
||
'code' => $code,
|
||
] );
|
||
return;
|
||
}
|
||
|
||
$meta = json_decode( $body );
|
||
if ( ! $meta ) {
|
||
$this->log( ' JSON decode failed — caching error state' );
|
||
$ttl = self::apply_filters_scoped( 'uupd_fetch_remote_error_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug_plain );
|
||
set_transient( $failure_cache_key, time(), $ttl );
|
||
|
||
self::do_metadata_failure_actions( $vendor, $slug_plain, [
|
||
'vendor' => $vendor,
|
||
'slug' => $slug_plain,
|
||
'server' => $c['server'],
|
||
'code' => 200,
|
||
'message' => 'Invalid JSON',
|
||
] );
|
||
return;
|
||
}
|
||
|
||
$meta = self::apply_filters_scoped( 'uupd/metadata_result', $meta, $vendor, $slug_plain );
|
||
|
||
$ttl = self::apply_filters_scoped( 'uupd_success_cache_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug_plain );
|
||
set_transient( $this->get_metadata_cache_key(), $meta, $ttl );
|
||
delete_transient( $failure_cache_key );
|
||
$this->log( " Cached metadata '{$slug_plain}' → v" . ( $meta->version ?? 'unknown' ) );
|
||
}
|
||
|
||
private function normalize_version( $v ) {
|
||
$v = trim( (string) $v );
|
||
|
||
$v = preg_replace( '/\+.*$/', '', $v );
|
||
$v = ltrim( $v, 'vV' );
|
||
$v = str_replace( '_', '-', $v );
|
||
|
||
if ( preg_match( '/^\d+\.\d+$/', $v ) ) {
|
||
$v .= '.0';
|
||
} elseif ( preg_match( '/^\d+$/', $v ) ) {
|
||
$v .= '.0.0';
|
||
}
|
||
|
||
if ( preg_match( '/^(\d+\.\d+\.\d+)[\.\-]?((?:alpha|a|beta|b|rc|dev|pre|preview))(?:(?:[\.\-]?)(\d+))?$/i', $v, $m ) ) {
|
||
$core = $m[1];
|
||
$tag = strtolower( $m[2] );
|
||
$num = isset( $m[3] ) && $m[3] !== '' ? $m[3] : '0';
|
||
|
||
switch ( $tag ) {
|
||
case 'a':
|
||
$tag = 'alpha';
|
||
break;
|
||
case 'b':
|
||
$tag = 'beta';
|
||
break;
|
||
case 'pre':
|
||
case 'preview':
|
||
$tag = 'beta';
|
||
break;
|
||
case 'rc':
|
||
$tag = 'rc';
|
||
break;
|
||
case 'dev':
|
||
$tag = 'dev';
|
||
break;
|
||
}
|
||
|
||
$v = "{$core}-{$tag}.{$num}";
|
||
}
|
||
|
||
$v = preg_replace( '/^(\d+\.\d+\.\d+)-(alpha|beta|rc|dev)(?=$)/i', '$1-$2.0', $v );
|
||
|
||
return $v;
|
||
}
|
||
|
||
/**
|
||
* Resolve icons/banners/screenshots from config and allow scoped filters.
|
||
*
|
||
* Supports both:
|
||
* - config values: 'icons', 'banners', 'screenshots', 'screenshot'
|
||
* - filters: uupd/icons, uupd/banners, uupd/screenshots, uupd/screenshot
|
||
*
|
||
* @return array{icons:array,banners:array,screenshots:array,screenshot:string}
|
||
*/
|
||
private function resolve_visual_assets() {
|
||
$slug = $this->config['slug'] ?? '';
|
||
$vendor = $this->config['vendor'] ?? '';
|
||
|
||
$icons = $this->config['icons'] ?? [];
|
||
$banners = $this->config['banners'] ?? [];
|
||
$screenshots = $this->config['screenshots'] ?? [];
|
||
$screenshot = $this->config['screenshot'] ?? '';
|
||
|
||
$icons = (array) self::apply_filters_scoped( 'uupd/icons', $icons, $vendor, $slug );
|
||
$banners = (array) self::apply_filters_scoped( 'uupd/banners', $banners, $vendor, $slug );
|
||
$screenshots = (array) self::apply_filters_scoped( 'uupd/screenshots', $screenshots, $vendor, $slug );
|
||
$screenshot = (string) self::apply_filters_scoped( 'uupd/screenshot', $screenshot, $vendor, $slug );
|
||
|
||
return [
|
||
'icons' => $icons,
|
||
'banners' => $banners,
|
||
'screenshots' => $screenshots,
|
||
'screenshot' => $screenshot,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Apply visual assets (icons/banners/screenshots) from config/filters to meta.
|
||
* Metadata wins; only missing fields are backfilled.
|
||
*
|
||
* @param object $meta Metadata.
|
||
* @return object
|
||
*/
|
||
private function apply_visual_assets_to_meta( $meta ) {
|
||
if ( ! is_object( $meta ) ) {
|
||
return $meta;
|
||
}
|
||
|
||
$va = $this->resolve_visual_assets();
|
||
|
||
if ( empty( $meta->icons ) && ! empty( $va['icons'] ) ) {
|
||
$meta->icons = $va['icons'];
|
||
}
|
||
if ( empty( $meta->banners ) && ! empty( $va['banners'] ) ) {
|
||
$meta->banners = $va['banners'];
|
||
}
|
||
if ( empty( $meta->screenshots ) && ! empty( $va['screenshots'] ) ) {
|
||
$meta->screenshots = $va['screenshots'];
|
||
}
|
||
if ( empty( $meta->screenshot ) && ! empty( $va['screenshot'] ) ) {
|
||
$meta->screenshot = $va['screenshot'];
|
||
}
|
||
|
||
return $meta;
|
||
}
|
||
|
||
/** Handle plugin update injection. */
|
||
public function plugin_update( $trans ) {
|
||
if ( ! is_object( $trans ) || ! isset( $trans->checked ) || ! is_array( $trans->checked ) ) {
|
||
return $trans;
|
||
}
|
||
|
||
$c = $this->config;
|
||
$file = $c['plugin_file'];
|
||
$slug = $c['slug'];
|
||
$vendor = $c['vendor'] ?? '';
|
||
$prefix = $c['cache_prefix'] ?? 'uupd_' . $vendor . '__';
|
||
$cache_id = $this->get_metadata_cache_key();
|
||
$error_key = $cache_id . '_error';
|
||
|
||
$this->log( "Plugin-update hook for '{$slug}'" );
|
||
|
||
$current = $trans->checked[ $file ] ?? $c['version'];
|
||
$meta = get_transient( $cache_id );
|
||
|
||
if ( false === $meta && get_transient( $error_key ) ) {
|
||
$this->log( " Skipping plugin update check for '{$slug}' — previous error cached" );
|
||
return $trans;
|
||
}
|
||
|
||
if ( false === $meta ) {
|
||
if ( $this->should_use_github_release_mode() ) {
|
||
$repo_url = rtrim( $c['server'], '/' );
|
||
$cache_key = 'uupd_github_release_' . ( $c['instance_key'] ?? self::build_instance_key( $vendor, $slug ) ) . '_' . md5( $repo_url );
|
||
$release = get_transient( $cache_key );
|
||
|
||
if ( false === $release ) {
|
||
$api_url = $this->github_latest_release_api_url( $repo_url );
|
||
|
||
$token = self::apply_filters_scoped(
|
||
'uupd/github_token_override',
|
||
$c['github_token'] ?? '',
|
||
$vendor,
|
||
$slug
|
||
);
|
||
|
||
$headers = [
|
||
'Accept' => 'application/vnd.github.v3+json',
|
||
'User-Agent' => 'WordPress-UUPD',
|
||
];
|
||
|
||
if ( $token ) {
|
||
$headers['Authorization'] = 'token ' . $token;
|
||
}
|
||
|
||
$this->log( " GitHub fetch: $api_url" );
|
||
$response = wp_remote_get( $api_url, [ 'headers' => $headers, 'timeout' => 15 ] );
|
||
|
||
if ( ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) === 200 ) {
|
||
$release = json_decode( wp_remote_retrieve_body( $response ) );
|
||
$ttl = self::apply_filters_scoped( 'uupd_success_cache_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug );
|
||
set_transient( $cache_key, $release, $ttl );
|
||
} else {
|
||
$msg = is_wp_error( $response ) ? $response->get_error_message() : ( 'HTTP ' . wp_remote_retrieve_response_code( $response ) );
|
||
$this->log( "✗ GitHub API failed — {$msg} — caching error state" );
|
||
|
||
set_transient(
|
||
$error_key,
|
||
time(),
|
||
self::apply_filters_scoped( 'uupd_fetch_remote_error_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug )
|
||
);
|
||
|
||
self::do_metadata_failure_actions( $vendor, $slug, [
|
||
'vendor' => $vendor,
|
||
'slug' => $slug,
|
||
'server' => $repo_url,
|
||
'message' => $msg,
|
||
] );
|
||
return $trans;
|
||
}
|
||
}
|
||
|
||
if ( isset( $release->tag_name ) ) {
|
||
$zip_url = $this->github_release_download_url( $repo_url, $release );
|
||
|
||
$meta = (object) [
|
||
'version' => ltrim( (string) $release->tag_name, 'v' ),
|
||
'download_url' => $zip_url,
|
||
'homepage' => $release->html_url ?? $repo_url,
|
||
'sections' => [ 'changelog' => $release->body ?? '' ],
|
||
];
|
||
} else {
|
||
$meta = (object) [
|
||
'version' => $c['version'],
|
||
'download_url' => '',
|
||
'homepage' => $repo_url,
|
||
'sections' => [ 'changelog' => '' ],
|
||
];
|
||
}
|
||
|
||
$meta = $this->apply_visual_assets_to_meta( $meta );
|
||
|
||
set_transient(
|
||
$cache_id,
|
||
$meta,
|
||
self::apply_filters_scoped( 'uupd_success_cache_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug )
|
||
);
|
||
|
||
delete_transient( $error_key );
|
||
} else {
|
||
$this->fetch_remote();
|
||
$meta = get_transient( $cache_id );
|
||
|
||
if ( $meta ) {
|
||
$meta = $this->apply_visual_assets_to_meta( $meta );
|
||
set_transient(
|
||
$cache_id,
|
||
$meta,
|
||
self::apply_filters_scoped( 'uupd_success_cache_ttl', 6 * HOUR_IN_SECONDS, $vendor, $slug )
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if ( ! $meta ) {
|
||
$this->log( 'No metadata found, skipping update logic.' );
|
||
return $trans;
|
||
}
|
||
|
||
$meta = $this->select_metadata_track( $meta );
|
||
|
||
$resolved_pkg = $this->resolve_download_url( $meta );
|
||
|
||
if ( $resolved_pkg && empty( $meta->download_url ) ) {
|
||
$meta->download_url = $resolved_pkg;
|
||
}
|
||
|
||
$this->log( 'Resolved package URL (normalized): ' . ( $resolved_pkg ? $resolved_pkg : 'EMPTY' ) );
|
||
|
||
$remote_version = $meta->version ?? '0.0.0';
|
||
$allow_prerelease = $this->config['allow_prerelease'] ?? false;
|
||
|
||
$current_normalized = $this->normalize_version( $current );
|
||
$remote_normalized = $this->normalize_version( $remote_version );
|
||
|
||
$this->log( "Original versions: installed={$current}, remote={$remote_version}" );
|
||
$this->log( "Normalized versions: installed={$current_normalized}, remote={$remote_normalized}" );
|
||
$this->log( "Comparing (normalized): installed={$current_normalized} vs remote={$remote_normalized}" );
|
||
|
||
if (
|
||
( ! $allow_prerelease && preg_match( '/^\d+\.\d+\.\d+-(alpha|beta|rc|dev|preview)(?:[.\-]\d+)?$/i', $remote_normalized ) ) ||
|
||
version_compare( $current_normalized, $remote_normalized, '>=' )
|
||
) {
|
||
$this->log( "Plugin '{$slug}' is up to date (v{$current})" );
|
||
$trans->no_update[ $file ] = (object) [
|
||
'id' => $file,
|
||
'slug' => $slug,
|
||
'plugin' => $file,
|
||
'new_version' => $current,
|
||
'url' => $meta->homepage ?? '',
|
||
'package' => '',
|
||
'icons' => (array) ( $meta->icons ?? [] ),
|
||
'banners' => (array) ( $meta->banners ?? [] ),
|
||
'tested' => $meta->tested ?? '',
|
||
'requires' => $meta->requires ?? $meta->min_wp_version ?? '',
|
||
'requires_php' => $meta->requires_php ?? '',
|
||
'compatibility' => new \stdClass(),
|
||
];
|
||
return $trans;
|
||
}
|
||
|
||
$this->log( "Injecting plugin update '{$slug}' → v{$meta->version}" );
|
||
$pkg = $resolved_pkg;
|
||
$this->log( 'Resolved package URL: ' . ( $pkg ? $pkg : 'EMPTY' ) );
|
||
|
||
$trans->response[ $file ] = (object) [
|
||
'id' => $file,
|
||
'name' => $c['name'],
|
||
'slug' => $slug,
|
||
'plugin' => $file,
|
||
'new_version' => $meta->version ?? $c['version'],
|
||
'package' => $pkg,
|
||
'url' => $meta->homepage ?? '',
|
||
'tested' => $meta->tested ?? '',
|
||
'requires' => $meta->requires ?? $meta->min_wp_version ?? '',
|
||
'requires_php' => $meta->requires_php ?? '',
|
||
'sections' => (array) ( $meta->sections ?? [] ),
|
||
'icons' => (array) ( $meta->icons ?? [] ),
|
||
'banners' => (array) ( $meta->banners ?? [] ),
|
||
'compatibility' => new \stdClass(),
|
||
];
|
||
|
||
unset( $trans->no_update[ $file ] );
|
||
return $trans;
|
||
}
|
||
|
||
/** Handle theme update injection. */
|
||
public function theme_update( $trans ) {
|
||
if ( ! is_object( $trans ) || ! isset( $trans->checked ) || ! is_array( $trans->checked ) ) {
|
||
return $trans;
|
||
}
|
||
|
||
$c = $this->config;
|
||
$slug = $c['real_slug'] ?? $c['slug'];
|
||
$vendor = $c['vendor'] ?? '';
|
||
$prefix = $c['cache_prefix'] ?? 'uupd_' . $vendor . '__';
|
||
$cache_id = $this->get_metadata_cache_key();
|
||
$error_key = $cache_id . '_error';
|
||
|
||
$this->log( "Theme-update hook for '{$c['slug']}'" );
|
||
|
||
$current = $trans->checked[ $slug ] ?? wp_get_theme( $slug )->get( 'Version' );
|
||
$meta = get_transient( $cache_id );
|
||
|
||
if ( false === $meta && get_transient( $error_key ) ) {
|
||
$this->log( "Skipping theme update check for '{$c['slug']}' — previous error cached" );
|
||
return $trans;
|
||
}
|
||
|
||
if ( false === $meta ) {
|
||
if ( $this->should_use_github_release_mode() ) {
|
||
$repo_url = rtrim( $c['server'], '/' );
|
||
$cache_key = 'uupd_github_release_' . ( $c['instance_key'] ?? self::build_instance_key( $vendor, $c['slug'] ) ) . '_' . md5( $repo_url );
|
||
$release = get_transient( $cache_key );
|
||
|
||
if ( false === $release ) {
|
||
$api_url = $this->github_latest_release_api_url( $repo_url );
|
||
|
||
$token = self::apply_filters_scoped(
|
||
'uupd/github_token_override',
|
||
$c['github_token'] ?? '',
|
||
$vendor,
|
||
$c['slug'] ?? ''
|
||
);
|
||
|
||
$headers = [
|
||
'Accept' => 'application/vnd.github.v3+json',
|
||
'User-Agent' => 'WordPress-UUPD',
|
||
];
|
||
|
||
if ( $token ) {
|
||
$headers['Authorization'] = 'token ' . $token;
|
||
}
|
||
|
||
$this->log( " GitHub fetch: $api_url" );
|
||
$response = wp_remote_get( $api_url, [ 'headers' => $headers, 'timeout' => 15 ] );
|
||
|
||
if ( ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) === 200 ) {
|
||
$release = json_decode( wp_remote_retrieve_body( $response ) );
|
||
$ttl = self::apply_filters_scoped( 'uupd_success_cache_ttl', 6 * HOUR_IN_SECONDS, $vendor, $c['slug'] );
|
||
set_transient( $cache_key, $release, $ttl );
|
||
} else {
|
||
$msg = is_wp_error( $response ) ? $response->get_error_message() : ( 'HTTP ' . wp_remote_retrieve_response_code( $response ) );
|
||
$this->log( "✗ GitHub API failed — {$msg} — caching error state" );
|
||
|
||
set_transient(
|
||
$error_key,
|
||
time(),
|
||
self::apply_filters_scoped( 'uupd_fetch_remote_error_ttl', 6 * HOUR_IN_SECONDS, $vendor, $c['slug'] )
|
||
);
|
||
|
||
self::do_metadata_failure_actions( $vendor, $c['slug'], [
|
||
'vendor' => $vendor,
|
||
'slug' => $c['slug'],
|
||
'server' => $repo_url,
|
||
'message' => $msg,
|
||
] );
|
||
return $trans;
|
||
}
|
||
}
|
||
|
||
if ( isset( $release->tag_name ) ) {
|
||
$zip_url = $this->github_release_download_url( $repo_url, $release );
|
||
|
||
$meta = (object) [
|
||
'version' => ltrim( (string) $release->tag_name, 'v' ),
|
||
'download_url' => $zip_url,
|
||
'homepage' => $release->html_url ?? $repo_url,
|
||
'sections' => [ 'changelog' => $release->body ?? '' ],
|
||
];
|
||
} else {
|
||
$meta = (object) [
|
||
'version' => $c['version'],
|
||
'download_url' => '',
|
||
'homepage' => $repo_url,
|
||
'sections' => [ 'changelog' => '' ],
|
||
];
|
||
}
|
||
|
||
$meta = $this->apply_visual_assets_to_meta( $meta );
|
||
|
||
set_transient(
|
||
$cache_id,
|
||
$meta,
|
||
self::apply_filters_scoped( 'uupd_success_cache_ttl', 6 * HOUR_IN_SECONDS, $vendor, $c['slug'] )
|
||
);
|
||
|
||
delete_transient( $error_key );
|
||
} else {
|
||
$this->fetch_remote();
|
||
$meta = get_transient( $cache_id );
|
||
|
||
if ( $meta ) {
|
||
$meta = $this->apply_visual_assets_to_meta( $meta );
|
||
set_transient(
|
||
$cache_id,
|
||
$meta,
|
||
self::apply_filters_scoped( 'uupd_success_cache_ttl', 6 * HOUR_IN_SECONDS, $vendor, $c['slug'] ?? $slug )
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if ( ! $meta ) {
|
||
$this->log( 'No metadata found, skipping update logic.' );
|
||
return $trans;
|
||
}
|
||
|
||
$meta = $this->select_metadata_track( $meta );
|
||
|
||
$resolved_pkg = $this->resolve_download_url( $meta );
|
||
|
||
if ( $resolved_pkg && empty( $meta->download_url ) ) {
|
||
$meta->download_url = $resolved_pkg;
|
||
}
|
||
|
||
$this->log( 'Resolved package URL (normalized): ' . ( $resolved_pkg ? $resolved_pkg : 'EMPTY' ) );
|
||
|
||
$base_info = [
|
||
'theme' => $slug,
|
||
'url' => $meta->homepage ?? '',
|
||
'requires' => $meta->requires ?? $meta->min_wp_version ?? '',
|
||
'requires_php' => $meta->requires_php ?? '',
|
||
'screenshot' => $meta->screenshot ?? '',
|
||
'tested' => $meta->tested ?? '',
|
||
];
|
||
|
||
$remote_version = $meta->version ?? '0.0.0';
|
||
$allow_prerelease = $this->config['allow_prerelease'] ?? false;
|
||
|
||
$current_normalized = $this->normalize_version( $current );
|
||
$remote_normalized = $this->normalize_version( $remote_version );
|
||
|
||
$this->log( "Original versions: installed={$current}, remote={$remote_version}" );
|
||
$this->log( "Normalized versions: installed={$current_normalized}, remote={$remote_normalized}" );
|
||
$this->log( "Comparing (normalized): installed={$current_normalized} vs remote={$remote_normalized}" );
|
||
|
||
if (
|
||
( ! $allow_prerelease && preg_match( '/^\d+\.\d+\.\d+-(alpha|beta|rc|dev|preview)(?:[.\-]\d+)?$/i', $remote_normalized ) ) ||
|
||
version_compare( $current_normalized, $remote_normalized, '>=' )
|
||
) {
|
||
$this->log( " Theme '{$c['slug']}' is up to date (v{$current})" );
|
||
$trans->no_update[ $slug ] = (object) array_merge(
|
||
$base_info,
|
||
[
|
||
'new_version' => $current,
|
||
'package' => '',
|
||
]
|
||
);
|
||
return $trans;
|
||
}
|
||
|
||
$this->log( " Injecting theme update '{$c['slug']}' → v{$meta->version}" );
|
||
$pkg = $resolved_pkg;
|
||
$this->log( 'Resolved package URL: ' . ( $pkg ? $pkg : 'EMPTY' ) );
|
||
|
||
$trans->response[ $slug ] = array_merge(
|
||
$base_info,
|
||
[
|
||
'new_version' => $meta->version ?? $current,
|
||
'package' => $pkg,
|
||
]
|
||
);
|
||
|
||
unset( $trans->no_update[ $slug ] );
|
||
return $trans;
|
||
}
|
||
|
||
/** Provide plugin information for the details popup. */
|
||
public function plugin_info( $res, $action, $args ) {
|
||
$c = $this->config;
|
||
if ( 'plugin_information' !== $action || $args->slug !== $c['slug'] ) {
|
||
return $res;
|
||
}
|
||
|
||
$meta = get_transient( $this->get_metadata_cache_key() );
|
||
if ( ! $meta ) {
|
||
return $res;
|
||
}
|
||
|
||
$sections = [];
|
||
if ( isset( $meta->sections ) ) {
|
||
foreach ( (array) $meta->sections as $key => $content ) {
|
||
$sections[ $key ] = $content;
|
||
}
|
||
}
|
||
|
||
return (object) [
|
||
'name' => $c['name'],
|
||
'title' => $c['name'],
|
||
'slug' => $c['slug'],
|
||
'version' => $meta->version ?? '',
|
||
'author' => $meta->author ?? '',
|
||
'author_homepage' => $meta->author_homepage ?? '',
|
||
'requires' => $meta->requires ?? $meta->min_wp_version ?? '',
|
||
'tested' => $meta->tested ?? '',
|
||
'requires_php' => $meta->requires_php ?? '',
|
||
'last_updated' => $meta->last_updated ?? '',
|
||
'download_link' => $this->resolve_download_url( $meta ),
|
||
'homepage' => $meta->homepage ?? '',
|
||
'sections' => $sections,
|
||
'icons' => isset( $meta->icons ) ? (array) $meta->icons : [],
|
||
'banners' => isset( $meta->banners ) ? (array) $meta->banners : [],
|
||
'screenshots' => isset( $meta->screenshots ) ? (array) $meta->screenshots : [],
|
||
];
|
||
}
|
||
|
||
/** Provide theme information for the details popup. */
|
||
public function theme_info( $res, $action, $args ) {
|
||
$c = $this->config;
|
||
$slug = $c['real_slug'] ?? $c['slug'];
|
||
|
||
if ( 'theme_information' !== $action || $args->slug !== $slug ) {
|
||
return $res;
|
||
}
|
||
|
||
$meta = get_transient( $this->get_metadata_cache_key() );
|
||
if ( ! $meta ) {
|
||
return $res;
|
||
}
|
||
|
||
if ( isset( $meta->changelog_html ) ) {
|
||
$changelog = $meta->changelog_html;
|
||
} elseif ( isset( $meta->sections ) ) {
|
||
if ( is_array( $meta->sections ) ) {
|
||
$changelog = $meta->sections['changelog'] ?? '';
|
||
} elseif ( is_object( $meta->sections ) ) {
|
||
$changelog = $meta->sections->changelog ?? '';
|
||
} else {
|
||
$changelog = '';
|
||
}
|
||
} else {
|
||
$changelog = '';
|
||
}
|
||
|
||
return (object) [
|
||
'name' => $c['name'],
|
||
'slug' => $c['real_slug'] ?? $c['slug'],
|
||
'version' => $meta->version ?? '',
|
||
'tested' => $meta->tested ?? '',
|
||
'requires' => $meta->min_wp_version ?? '',
|
||
'sections' => [ 'changelog' => $changelog ],
|
||
'download_link' => $this->resolve_download_url( $meta ),
|
||
'icons' => isset( $meta->icons ) ? (array) $meta->icons : [],
|
||
'banners' => isset( $meta->banners ) ? (array) $meta->banners : [],
|
||
];
|
||
}
|
||
|
||
/** Optional debug logger. */
|
||
private function log( $msg ) {
|
||
$slug = $this->config['slug'] ?? '';
|
||
|
||
if ( apply_filters( 'updater_enable_debug', false, $slug ) ) {
|
||
error_log( "[Updater][{$slug}] {$msg}" );
|
||
do_action( 'uupd/log', $msg, $slug );
|
||
}
|
||
}
|
||
|
||
private function is_github_repo_root_url( $url ) {
|
||
$url = trim( (string) $url );
|
||
if ( $url === '' ) {
|
||
return false;
|
||
}
|
||
|
||
$parts = wp_parse_url( $url );
|
||
if ( empty( $parts['host'] ) ) {
|
||
return false;
|
||
}
|
||
|
||
if ( strtolower( $parts['host'] ) !== 'github.com' ) {
|
||
return false;
|
||
}
|
||
|
||
$path = trim( $parts['path'] ?? '', '/' );
|
||
if ( $path === '' ) {
|
||
return false;
|
||
}
|
||
|
||
$segments = explode( '/', $path );
|
||
return count( $segments ) === 2;
|
||
}
|
||
|
||
private function get_mode() {
|
||
$mode = $this->config['mode'] ?? 'auto';
|
||
$mode = strtolower( trim( (string) $mode ) );
|
||
return in_array( $mode, [ 'auto', 'json', 'github_release' ], true ) ? $mode : 'auto';
|
||
}
|
||
|
||
private function should_use_github_release_mode() {
|
||
$mode = $this->get_mode();
|
||
$server = $this->config['server'] ?? '';
|
||
|
||
if ( $mode === 'json' ) {
|
||
return false;
|
||
}
|
||
if ( $mode === 'github_release' ) {
|
||
return true;
|
||
}
|
||
|
||
return $this->is_github_repo_root_url( $server );
|
||
}
|
||
|
||
/**
|
||
* Add GitHub auth headers for downloads/metadata when a scoped token is configured.
|
||
*
|
||
* @param array $args Request args.
|
||
* @param string $url Request URL.
|
||
* @return array
|
||
*/
|
||
private function add_github_auth_headers_for_download( $args, $url ) {
|
||
$vendor = $this->config['vendor'] ?? '';
|
||
$slug = $this->config['slug'] ?? '';
|
||
|
||
$token = self::apply_filters_scoped(
|
||
'uupd/github_token_override',
|
||
$this->config['github_token'] ?? '',
|
||
$vendor,
|
||
$slug
|
||
);
|
||
|
||
if ( ! $token ) {
|
||
return $args;
|
||
}
|
||
|
||
$args['headers'] = $args['headers'] ?? [];
|
||
$args['headers']['Authorization'] = 'token ' . $token;
|
||
$args['headers']['User-Agent'] = $args['headers']['User-Agent'] ?? 'WordPress-UUPD';
|
||
|
||
if ( strpos( $url, 'api.github.com/repos/' ) !== false && strpos( $url, '/releases/assets/' ) !== false ) {
|
||
$args['headers']['Accept'] = 'application/octet-stream';
|
||
}
|
||
|
||
return $args;
|
||
}
|
||
|
||
/**
|
||
* Determine which asset name to pick from a GitHub release.
|
||
*
|
||
* Priority:
|
||
* 1) config['github_asset_name']
|
||
* 2) config['slug'] . '.zip'
|
||
* 3) config['real_slug'] . '.zip'
|
||
* 4) null (means first .zip asset)
|
||
*
|
||
* @return string|null
|
||
*/
|
||
private function get_github_asset_name() {
|
||
$c = $this->config;
|
||
|
||
if ( ! empty( $c['github_asset_name'] ) ) {
|
||
return (string) $c['github_asset_name'];
|
||
}
|
||
|
||
if ( ! empty( $c['slug'] ) ) {
|
||
return (string) $c['slug'] . '.zip';
|
||
}
|
||
|
||
if ( ! empty( $c['real_slug'] ) ) {
|
||
return (string) $c['real_slug'] . '.zip';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Build the GitHub API URL for /releases/latest from a repo root URL.
|
||
*
|
||
* @param string $repo_url GitHub repo root URL.
|
||
* @return string
|
||
*/
|
||
private function github_latest_release_api_url( $repo_url ) {
|
||
$repo_url = rtrim( (string) $repo_url, '/' );
|
||
$path = trim( (string) wp_parse_url( $repo_url, PHP_URL_PATH ), '/' );
|
||
return "https://api.github.com/repos/{$path}/releases/latest";
|
||
}
|
||
|
||
/**
|
||
* Resolve the download URL for a release:
|
||
* - Prefer a matching .zip asset
|
||
* - If a token is available, prefer the private-safe API asset endpoint
|
||
* - Otherwise fall back to browser_download_url
|
||
* - Finally fall back to zipball_url
|
||
*
|
||
* @param string $repo_url Repo root URL.
|
||
* @param object $release Release payload.
|
||
* @return string
|
||
*/
|
||
private function github_release_download_url( $repo_url, $release ) {
|
||
$repo_url = rtrim( (string) $repo_url, '/' );
|
||
$path = trim( (string) wp_parse_url( $repo_url, PHP_URL_PATH ), '/' );
|
||
|
||
$vendor = $this->config['vendor'] ?? '';
|
||
$slug = $this->config['slug'] ?? '';
|
||
|
||
$token = self::apply_filters_scoped(
|
||
'uupd/github_token_override',
|
||
$this->config['github_token'] ?? '',
|
||
$vendor,
|
||
$slug
|
||
);
|
||
$use_api_assets = ! empty( $token );
|
||
|
||
$wanted = $this->get_github_asset_name();
|
||
$wanted_lc = $wanted ? strtolower( $wanted ) : null;
|
||
|
||
if ( ! empty( $release->assets ) && is_array( $release->assets ) ) {
|
||
if ( $wanted_lc ) {
|
||
foreach ( $release->assets as $asset ) {
|
||
if ( ! empty( $asset->name ) && strtolower( (string) $asset->name ) === $wanted_lc ) {
|
||
if ( $use_api_assets && ! empty( $asset->id ) ) {
|
||
return "https://api.github.com/repos/{$path}/releases/assets/{$asset->id}";
|
||
}
|
||
return $asset->browser_download_url ?? '';
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach ( $release->assets as $asset ) {
|
||
if ( ! empty( $asset->name ) && self::ends_with( strtolower( (string) $asset->name ), '.zip' ) ) {
|
||
if ( $use_api_assets && ! empty( $asset->id ) ) {
|
||
return "https://api.github.com/repos/{$path}/releases/assets/{$asset->id}";
|
||
}
|
||
return $asset->browser_download_url ?? '';
|
||
}
|
||
}
|
||
}
|
||
|
||
return $release->zipball_url ?? '';
|
||
}
|
||
|
||
private function get_release_channel() {
|
||
$channel = isset( $this->config['release_channel'] )
|
||
? strtolower( sanitize_key( (string) $this->config['release_channel'] ) )
|
||
: '';
|
||
|
||
if ( in_array( $channel, [ 'stable', 'dev', 'alpha', 'beta', 'rc', 'prerelease' ], true ) ) {
|
||
return $channel;
|
||
}
|
||
|
||
return ! empty( $this->config['allow_prerelease'] ) ? 'prerelease' : 'stable';
|
||
}
|
||
|
||
private function get_metadata_cache_key() {
|
||
$c = $this->config;
|
||
$vendor = $c['vendor'] ?? '';
|
||
$slug = $c['slug'] ?? '';
|
||
$prefix = $c['cache_prefix'] ?? 'uupd_' . $vendor . '__';
|
||
$channel = $this->get_release_channel();
|
||
|
||
return $prefix . $slug . '_' . $channel;
|
||
}
|
||
|
||
private static function ends_with( $haystack, $needle ) {
|
||
if ( function_exists( 'str_ends_with' ) ) {
|
||
return \str_ends_with( (string) $haystack, (string) $needle );
|
||
}
|
||
$haystack = (string) $haystack;
|
||
$needle = (string) $needle;
|
||
if ( $needle === '' ) {
|
||
return true;
|
||
}
|
||
if ( strlen( $needle ) > strlen( $haystack ) ) {
|
||
return false;
|
||
}
|
||
return substr( $haystack, -strlen( $needle ) ) === $needle;
|
||
}
|
||
|
||
/**
|
||
* Register the updater and the manual-check action.
|
||
*
|
||
* @param array $config Updater config.
|
||
* @return void
|
||
*/
|
||
public static function register( array $config ) {
|
||
$config['vendor'] = self::sanitize_identity_part( $config['vendor'] ?? '' );
|
||
$config['slug'] = self::sanitize_identity_part( $config['slug'] ?? '' );
|
||
|
||
if ( $config['vendor'] === '' || $config['slug'] === '' ) {
|
||
_doing_it_wrong( __METHOD__, __( 'UUPD_Updater_V2::register() requires both vendor and slug.', 'default' ), self::VERSION );
|
||
return;
|
||
}
|
||
|
||
$config['instance_key'] = self::build_instance_key( $config['vendor'], $config['slug'] );
|
||
$config['cache_prefix'] = $config['cache_prefix'] ?? 'uupd_' . $config['vendor'] . '__';
|
||
|
||
new self( $config );
|
||
|
||
$our_file = $config['plugin_file'] ?? null;
|
||
$slug = $config['slug'];
|
||
$vendor = $config['vendor'];
|
||
$textdomain = ! empty( $config['textdomain'] ) ? $config['textdomain'] : $slug;
|
||
|
||
if ( $our_file ) {
|
||
add_filter(
|
||
'plugin_row_meta',
|
||
function( array $links, string $file, array $plugin_data ) use ( $our_file, $vendor, $slug, $textdomain ) {
|
||
if ( $file === $our_file ) {
|
||
$nonce = wp_create_nonce( 'uupd_v2_manual_check_' . $vendor . '__' . $slug );
|
||
$check_url = admin_url(
|
||
sprintf(
|
||
'admin.php?action=uupd_v2_manual_check&vendor=%s&slug=%s&_wpnonce=%s',
|
||
rawurlencode( $vendor ),
|
||
rawurlencode( $slug ),
|
||
rawurlencode( $nonce )
|
||
)
|
||
);
|
||
|
||
$links[] = sprintf(
|
||
'<a href="%s">%s</a>',
|
||
esc_url( $check_url ),
|
||
esc_html__( 'Check for updates', $textdomain )
|
||
);
|
||
}
|
||
return $links;
|
||
},
|
||
10,
|
||
3
|
||
);
|
||
}
|
||
|
||
add_action(
|
||
'admin_action_uupd_v2_manual_check',
|
||
function() use ( $vendor, $slug, $config ) {
|
||
$request_vendor = isset( $_REQUEST['vendor'] ) ? sanitize_key( wp_unslash( $_REQUEST['vendor'] ) ) : '';
|
||
$request_slug = isset( $_REQUEST['slug'] ) ? sanitize_key( wp_unslash( $_REQUEST['slug'] ) ) : '';
|
||
|
||
if ( $request_vendor !== $vendor || $request_slug !== $slug ) {
|
||
return;
|
||
}
|
||
|
||
if ( ! current_user_can( 'update_plugins' ) && ! current_user_can( 'update_themes' ) ) {
|
||
wp_die( __( 'Cheatin’ uh?' ) );
|
||
}
|
||
|
||
$nonce = isset( $_REQUEST['_wpnonce'] ) ? wp_unslash( $_REQUEST['_wpnonce'] ) : '';
|
||
$checkname = 'uupd_v2_manual_check_' . $vendor . '__' . $slug;
|
||
if ( ! wp_verify_nonce( $nonce, $checkname ) ) {
|
||
wp_die( __( 'Security check failed.' ) );
|
||
}
|
||
|
||
$prefix = $config['cache_prefix'] ?? 'uupd_' . $vendor . '__';
|
||
foreach ( [ 'stable', 'dev', 'alpha', 'beta', 'rc', 'prerelease' ] as $channel ) {
|
||
delete_transient( $prefix . $slug . '_' . $channel );
|
||
delete_transient( $prefix . $slug . '_' . $channel . '_error' );
|
||
}
|
||
|
||
// Legacy cache cleanup.
|
||
delete_transient( $prefix . $slug );
|
||
delete_transient( $prefix . $slug . '_error' );
|
||
|
||
if ( isset( $config['server'] ) && strpos( $config['server'], 'github.com' ) !== false ) {
|
||
$repo_url = rtrim( $config['server'], '/' );
|
||
$gh_key = 'uupd_github_release_' . self::build_instance_key( $vendor, $slug ) . '_' . md5( $repo_url );
|
||
delete_transient( $gh_key );
|
||
}
|
||
|
||
if ( ! empty( $config['plugin_file'] ) ) {
|
||
wp_update_plugins();
|
||
$redirect = wp_get_referer() ?: admin_url( 'plugins.php' );
|
||
} else {
|
||
wp_update_themes();
|
||
$redirect = wp_get_referer() ?: admin_url( 'themes.php' );
|
||
}
|
||
|
||
$redirect = self::apply_filters_scoped( 'uupd/manual_check_redirect', $redirect, $vendor, $slug );
|
||
wp_safe_redirect( $redirect );
|
||
exit;
|
||
}
|
||
);
|
||
}
|
||
}
|
||
}
|