updater.php
428 lines
| 1 | <?php |
| 2 | /** |
| 3 | * SiteOrigin Updater |
| 4 | * |
| 5 | * A utility for updating SiteOrigin plugins from GitHub. |
| 6 | * |
| 7 | * License: GPLv3 |
| 8 | * License URI: https://www.gnu.org/licenses/gpl-3.0.html |
| 9 | */ |
| 10 | |
| 11 | class SiteOrigin_Updater { |
| 12 | private $file; |
| 13 | private $plugin_slug; |
| 14 | private $owner; |
| 15 | private $actual_repo_name; |
| 16 | private $updates_branch = 'master'; |
| 17 | |
| 18 | public function __construct( $file, $plugin_slug, $repo_name_with_owner, $updates_branch = 'master' ) { |
| 19 | $this->file = $file; |
| 20 | $this->plugin_slug = $plugin_slug; |
| 21 | $this->updates_branch = $updates_branch; |
| 22 | |
| 23 | if ( |
| 24 | empty( $repo_name_with_owner ) || |
| 25 | strpos( $repo_name_with_owner, '/' ) === false |
| 26 | ) { |
| 27 | throw new InvalidArgumentException( |
| 28 | 'SiteOrigin_Updater: Repository identifier (argument 3) must be a non-empty string in "owner/repository-name" format. E.g., "my-github-username/my-plugin-repo". Provided: ' . var_export($repo_name_with_owner, true) |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | list($this->owner, $this->actual_repo_name) = explode('/', $repo_name_with_owner, 2); |
| 33 | |
| 34 | if ( |
| 35 | empty( $this->owner ) || |
| 36 | empty( $this->actual_repo_name ) |
| 37 | ) { |
| 38 | throw new InvalidArgumentException( |
| 39 | 'SiteOrigin_Updater: Owner and repository name segments cannot be empty in "owner/repository-name" format. Provided: "' . $repo_name_with_owner . '"' |
| 40 | ); |
| 41 | } |
| 42 | |
| 43 | // Check if WordPress 5.8+ is available for Update URI system. |
| 44 | global $wp_version; |
| 45 | if ( |
| 46 | version_compare( $wp_version, '5.8', '>=' ) && |
| 47 | $this->has_update_uri() |
| 48 | ) { |
| 49 | // Use modern WordPress 5.8+ Update URI system (avoids jQuery selector bug). |
| 50 | add_filter( 'update_plugins_github.com', array( $this, 'check_for_github_update' ), 10, 4 ); |
| 51 | } else { |
| 52 | // Fall back to legacy system for older WordPress versions. |
| 53 | add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'check_for_update' ), 15 ); |
| 54 | } |
| 55 | |
| 56 | add_filter( 'plugins_api', array( $this, 'plugin_api_call' ), 10, 3 ); |
| 57 | } |
| 58 | |
| 59 | public function check_for_update( $transient ) { |
| 60 | $all_headers = $this->get_plugin_headers(); |
| 61 | $current_version = $this->get_current_version(); |
| 62 | |
| 63 | if ( |
| 64 | ! empty( $all_headers ) && |
| 65 | version_compare( $current_version, $all_headers['Version'], '<' ) |
| 66 | ) { |
| 67 | // There is a newer version available on GitHub (comparing local version to remote plugin header). |
| 68 | $update = $this->get_plugin_data(); |
| 69 | $update->new_version = $all_headers['Version']; |
| 70 | $update->stable_version = $all_headers['Version']; |
| 71 | |
| 72 | // Prevent potential warnings if we're the first thing to add data here. |
| 73 | if ( ! is_object( $transient ) ) { |
| 74 | $transient = new stdClass(); |
| 75 | } |
| 76 | if ( ! isset( $transient->response ) ) { |
| 77 | $transient->response = array(); |
| 78 | } |
| 79 | |
| 80 | $transient->response[ $update->slug ] = $update; |
| 81 | } |
| 82 | |
| 83 | return $transient; |
| 84 | } |
| 85 | |
| 86 | public function get_plugin_headers() { |
| 87 | static $all_headers = array(); |
| 88 | |
| 89 | if ( ! empty( $all_headers ) ) { |
| 90 | return $all_headers; |
| 91 | } |
| 92 | |
| 93 | // Fetch the remote plugin file from GitHub to read its header. |
| 94 | $response = wp_remote_get( 'https://raw.githubusercontent.com/' . $this->owner . '/' . $this->actual_repo_name . '/' . $this->updates_branch . '/' . $this->plugin_slug . '.php' ); |
| 95 | |
| 96 | if ( |
| 97 | is_wp_error( $response ) || |
| 98 | empty( $response['body'] ) |
| 99 | ) { |
| 100 | return false; |
| 101 | } |
| 102 | |
| 103 | $file_data = $response['body']; |
| 104 | $all_headers = array( |
| 105 | 'Name' => 'Plugin Name', |
| 106 | 'PluginURI' => 'Plugin URI', |
| 107 | 'Version' => 'Version', |
| 108 | 'Description' => 'Description', |
| 109 | 'Author' => 'Author', |
| 110 | 'AuthorURI' => 'Author URI', |
| 111 | ); |
| 112 | |
| 113 | foreach ( $all_headers as $field => $regex ) { |
| 114 | if ( |
| 115 | preg_match( '/^[ \t\/*#@]*' . preg_quote( $regex, '/' ) . ':(.*)$/mi', $file_data, $match ) && |
| 116 | ! empty( $match[1] ) |
| 117 | ) { |
| 118 | $all_headers[ $field ] = _cleanup_header_comment( $match[1] ); |
| 119 | } else { |
| 120 | $all_headers[ $field ] = ''; |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | return $all_headers; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Get and parse a markdown file from GitHub. |
| 129 | * |
| 130 | * @param string $file |
| 131 | * |
| 132 | * @return bool|string |
| 133 | */ |
| 134 | public function get_github_markdown( $file = 'readme.md' ) { |
| 135 | $response = wp_remote_get( 'https://raw.githubusercontent.com/' . $this->owner . '/' . $this->actual_repo_name . '/' . $this->updates_branch . '/' . urlencode( $file ) ); |
| 136 | |
| 137 | if ( |
| 138 | is_wp_error( $response ) || |
| 139 | empty( $response['body'] ) |
| 140 | ) { |
| 141 | return false; |
| 142 | } |
| 143 | |
| 144 | if ( ! class_exists( 'Parsedown' ) ) { |
| 145 | $parsedown_path = dirname( __FILE__ ) . '/Parsedown/Parsedown.php'; |
| 146 | if ( file_exists( $parsedown_path ) ) { |
| 147 | require_once $parsedown_path; |
| 148 | } else { |
| 149 | // Fallback: return raw markdown if Parsedown not found. |
| 150 | return $response['body']; |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | if ( class_exists( 'Parsedown' ) ) { |
| 155 | $parsedown = new Parsedown(); |
| 156 | return $parsedown->parse( $response['body'] ); |
| 157 | } |
| 158 | |
| 159 | // Fallback: return raw markdown. |
| 160 | return $response['body']; |
| 161 | } |
| 162 | |
| 163 | private function get_plugin_data() { |
| 164 | $headers = $this->get_plugin_headers(); |
| 165 | if ( empty( $headers ) ) { |
| 166 | return array(); |
| 167 | } |
| 168 | $data = new stdClass(); |
| 169 | $data->slug = plugin_basename( $this->file ); |
| 170 | $data->id = $this->plugin_slug; |
| 171 | $data->plugin_name = $headers['Name']; |
| 172 | $data->name = $headers['Name']; |
| 173 | $data->version = $headers['Version']; |
| 174 | $data->author = $headers['Author']; |
| 175 | $data->url = $headers['PluginURI']; |
| 176 | $data->homepage = $headers['PluginURI']; |
| 177 | $data->download_link = 'https://github.com/' . $this->owner . '/' . $this->actual_repo_name . '/archive/' . $this->updates_branch . '.zip'; |
| 178 | $data->package = 'https://github.com/' . $this->owner . '/' . $this->actual_repo_name . '/archive/' . $this->updates_branch . '.zip'; |
| 179 | |
| 180 | // Add required fields for modal display. |
| 181 | $data->short_description = ! empty( $headers['Description'] ) ? $headers['Description'] : 'A WordPress plugin by SiteOrigin.'; |
| 182 | $data->requires = '4.7'; |
| 183 | $data->tested = '6.8.1'; |
| 184 | $data->requires_php = '7.0.0'; |
| 185 | $data->last_updated = date( 'Y-m-d' ); |
| 186 | $data->added = date( 'Y-m-d' ); |
| 187 | |
| 188 | // Set icon - use conventional naming with PNG preference. |
| 189 | $icon_png_path = plugin_dir_path( $this->file ) . 'img/icon.png'; |
| 190 | |
| 191 | if ( file_exists( $icon_png_path ) ) { |
| 192 | $data->icons = array( |
| 193 | '1x' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 194 | '2x' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 195 | 'default' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 196 | ); |
| 197 | } else { |
| 198 | // Fallback to WordPress.org geopattern icon. |
| 199 | $data->icons = array( |
| 200 | 'default' => "https://s.w.org/plugins/geopattern-icon/{$data->slug}.svg", |
| 201 | ); |
| 202 | } |
| 203 | |
| 204 | // Set banner - only if a proper banner exists. |
| 205 | $banner_path = plugin_dir_path( $this->file ) . 'img/banner.png'; |
| 206 | if ( file_exists( $banner_path ) ) { |
| 207 | $data->banners = array( |
| 208 | 'low' => plugin_dir_url( $this->file ) . 'img/banner.png', |
| 209 | 'high' => plugin_dir_url( $this->file ) . 'img/banner.png', |
| 210 | ); |
| 211 | } |
| 212 | |
| 213 | $data->sections = array( |
| 214 | 'description' => $this->get_github_markdown( 'readme.md' ), |
| 215 | 'changelog' => $this->get_github_markdown( 'changelog.md' ), |
| 216 | ); |
| 217 | return $data; |
| 218 | } |
| 219 | |
| 220 | /** |
| 221 | * Add all the plugin details to the Plugin API call. |
| 222 | * |
| 223 | * @return stdClass |
| 224 | */ |
| 225 | public function plugin_api_call( $def, $action, $args ) { |
| 226 | if ( $action !== 'plugin_information' ) { |
| 227 | return $def; |
| 228 | } |
| 229 | |
| 230 | // Handle both legacy slug format and modern dirname format. |
| 231 | $plugin_basename = plugin_basename( $this->file ); |
| 232 | $plugin_dirname = dirname( $plugin_basename ); |
| 233 | |
| 234 | if ( ! isset( $args->slug ) ) { |
| 235 | return $def; |
| 236 | } |
| 237 | |
| 238 | // Check if this is our plugin (support both slug formats). |
| 239 | if ( |
| 240 | $args->slug !== $plugin_basename && |
| 241 | $args->slug !== $plugin_dirname |
| 242 | ) { |
| 243 | return $def; |
| 244 | } |
| 245 | |
| 246 | // Get plugin data using the appropriate method. |
| 247 | global $wp_version; |
| 248 | if ( |
| 249 | version_compare( $wp_version, '5.8', '>=' ) && |
| 250 | $this->has_update_uri() |
| 251 | ) { |
| 252 | // Use GitHub API for modern system. |
| 253 | $data = $this->get_github_plugin_data(); |
| 254 | } else { |
| 255 | // Use legacy method. |
| 256 | $data = $this->get_plugin_data(); |
| 257 | } |
| 258 | |
| 259 | return empty( $data ) ? $def : $data; |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Get plugin data from GitHub API (for WordPress 5.8+ system). |
| 264 | */ |
| 265 | private function get_github_plugin_data() { |
| 266 | // Get plugin headers from local file. |
| 267 | $plugin_data = get_file_data( $this->file, array( |
| 268 | 'Name' => 'Plugin Name', |
| 269 | 'PluginURI' => 'Plugin URI', |
| 270 | 'Version' => 'Version', |
| 271 | 'Description' => 'Description', |
| 272 | 'Author' => 'Author', |
| 273 | 'AuthorURI' => 'Author URI', |
| 274 | ) ); |
| 275 | |
| 276 | // Get latest release info from GitHub Releases API. |
| 277 | $response = wp_remote_get( 'https://api.github.com/repos/' . $this->owner . '/' . $this->actual_repo_name . '/releases/latest' ); |
| 278 | |
| 279 | if ( is_wp_error( $response ) ) { |
| 280 | return false; |
| 281 | } |
| 282 | |
| 283 | $release_data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 284 | |
| 285 | $data = new stdClass(); |
| 286 | $data->slug = dirname( plugin_basename( $this->file ) ); |
| 287 | $data->plugin_name = $plugin_data['Name']; |
| 288 | $data->name = $plugin_data['Name']; |
| 289 | $data->version = ! empty( $release_data['tag_name'] ) ? ltrim( $release_data['tag_name'], 'v' ) : $plugin_data['Version']; |
| 290 | $data->author = $plugin_data['Author']; |
| 291 | $data->homepage = $plugin_data['PluginURI']; |
| 292 | $data->download_link = ! empty( $release_data ) ? $this->get_github_download_url( $release_data ) : ''; |
| 293 | $data->requires = '4.7'; |
| 294 | $data->tested = '6.8.1'; |
| 295 | $data->requires_php = '7.0.0'; |
| 296 | |
| 297 | // Add required fields for modal display. |
| 298 | $data->short_description = ! empty( $plugin_data['Description'] ) ? $plugin_data['Description'] : 'A WordPress plugin by SiteOrigin.'; |
| 299 | $data->last_updated = ! empty( $release_data['published_at'] ) ? date( 'Y-m-d', strtotime( $release_data['published_at'] ) ) : date( 'Y-m-d' ); |
| 300 | $data->added = date( 'Y-m-d' ); |
| 301 | |
| 302 | // Set icon - use conventional naming with PNG preference. |
| 303 | $icon_png_path = plugin_dir_path( $this->file ) . 'img/icon.png'; |
| 304 | |
| 305 | if ( file_exists( $icon_png_path ) ) { |
| 306 | $data->icons = array( |
| 307 | '1x' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 308 | '2x' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 309 | 'default' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 310 | ); |
| 311 | } else { |
| 312 | // Fallback to WordPress.org geopattern icon. |
| 313 | $data->icons = array( |
| 314 | 'default' => "https://s.w.org/plugins/geopattern-icon/{$data->slug}.svg", |
| 315 | ); |
| 316 | } |
| 317 | |
| 318 | // Set banner - only if a proper banner exists. |
| 319 | $banner_path = plugin_dir_path( $this->file ) . 'img/banner.png'; |
| 320 | if ( file_exists( $banner_path ) ) { |
| 321 | $data->banners = array( |
| 322 | 'low' => plugin_dir_url( $this->file ) . 'img/banner.png', |
| 323 | 'high' => plugin_dir_url( $this->file ) . 'img/banner.png', |
| 324 | ); |
| 325 | } |
| 326 | |
| 327 | $data->sections = array( |
| 328 | 'description' => $this->get_github_markdown( 'readme.md' ), |
| 329 | 'changelog' => $this->get_github_markdown( 'changelog.md' ), |
| 330 | ); |
| 331 | |
| 332 | return $data; |
| 333 | } |
| 334 | |
| 335 | private function get_current_version() { |
| 336 | $plugin_data = get_file_data( $this->file, array( 'Version' => 'Version' ) ); |
| 337 | return isset( $plugin_data['Version'] ) ? $plugin_data['Version'] : '0.0.0'; |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Check if the plugin has an Update URI header pointing to GitHub. |
| 342 | */ |
| 343 | private function has_update_uri() { |
| 344 | $plugin_data = get_file_data( $this->file, array( 'UpdateURI' => 'Update URI' ) ); |
| 345 | return ( |
| 346 | ! empty( $plugin_data['UpdateURI'] ) && |
| 347 | strpos( $plugin_data['UpdateURI'], 'github.com' ) !== false |
| 348 | ); |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * WordPress 5.8+ Update URI system - check for GitHub updates. |
| 353 | */ |
| 354 | public function check_for_github_update( $update, $plugin_data, $plugin_file, $locales ) { |
| 355 | // Only handle our plugin. |
| 356 | if ( plugin_basename( $this->file ) !== $plugin_file ) { |
| 357 | return $update; |
| 358 | } |
| 359 | |
| 360 | // Skip if update already found. |
| 361 | if ( ! empty( $update ) ) { |
| 362 | return $update; |
| 363 | } |
| 364 | |
| 365 | // Get latest release from GitHub Releases API. |
| 366 | $response = wp_remote_get( 'https://api.github.com/repos/' . $this->owner . '/' . $this->actual_repo_name . '/releases/latest' ); |
| 367 | |
| 368 | if ( |
| 369 | is_wp_error( $response ) || |
| 370 | wp_remote_retrieve_response_code( $response ) !== 200 |
| 371 | ) { |
| 372 | return false; |
| 373 | } |
| 374 | |
| 375 | $release_data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 376 | |
| 377 | if ( empty( $release_data['tag_name'] ) ) { |
| 378 | return false; |
| 379 | } |
| 380 | |
| 381 | $new_version = ltrim( $release_data['tag_name'], 'v' ); |
| 382 | $current_version = $plugin_data['Version']; |
| 383 | |
| 384 | // Check if update is available (comparing local version to GitHub release tag). |
| 385 | if ( version_compare( $current_version, $new_version, '<' ) ) { |
| 386 | // Get icon data for the update notification. |
| 387 | $icon_png_path = plugin_dir_path( $this->file ) . 'img/icon.png'; |
| 388 | $icons = array(); |
| 389 | |
| 390 | if ( file_exists( $icon_png_path ) ) { |
| 391 | $icons = array( |
| 392 | '1x' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 393 | '2x' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 394 | 'default' => plugin_dir_url( $this->file ) . 'img/icon.png', |
| 395 | ); |
| 396 | } |
| 397 | |
| 398 | return array( |
| 399 | 'slug' => dirname( plugin_basename( $this->file ) ), |
| 400 | 'version' => $new_version, |
| 401 | 'url' => $release_data['html_url'], |
| 402 | 'package' => $this->get_github_download_url( $release_data ), |
| 403 | 'icons' => $icons, |
| 404 | 'tested' => '6.8.1', |
| 405 | ); |
| 406 | } |
| 407 | |
| 408 | return false; |
| 409 | } |
| 410 | |
| 411 | /** |
| 412 | * Get download URL from GitHub release data. |
| 413 | */ |
| 414 | private function get_github_download_url( $release_data ) { |
| 415 | // Look for ZIP asset first. |
| 416 | if ( ! empty( $release_data['assets'] ) ) { |
| 417 | foreach ( $release_data['assets'] as $asset ) { |
| 418 | if ( strpos( $asset['name'], '.zip' ) !== false ) { |
| 419 | return $asset['browser_download_url']; |
| 420 | } |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | // Fallback to source ZIP. |
| 425 | return $release_data['zipball_url']; |
| 426 | } |
| 427 | } |
| 428 |