├── LICENSE ├── composer.json ├── languages └── git-updater-lite.pot ├── README.md └── Lite.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andy Fragen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "afragen/git-updater-lite", 3 | "description": "A simple class to integrate with Git Updater for standalone plugin/theme updates.", 4 | "version": "2.5.0", 5 | "type": "library", 6 | "keywords": [ 7 | "wordpress", 8 | "package", 9 | "updater" 10 | ], 11 | "license": "MIT", 12 | "repositories": [ 13 | { 14 | "type": "vcs", 15 | "url": "https://github.com/afragen/git-updater-lite" 16 | } 17 | ], 18 | "authors": [ 19 | { 20 | "name": "Andy Fragen", 21 | "email": "andy@thefragens.com", 22 | "homepage": "https://github.com/afragen/git-updater-lite", 23 | "role": "Developer" 24 | } 25 | ], 26 | "prefer-stable": true, 27 | "require": { 28 | "php": ">=7.4" 29 | }, 30 | "autoload": { 31 | "classmap": [ 32 | "Lite.php" 33 | ] 34 | }, 35 | "require-dev": { 36 | "wp-coding-standards/wpcs": "^3", 37 | "yoast/phpunit-polyfills": "*" 38 | }, 39 | "config": { 40 | "allow-plugins": { 41 | "dealerdirect/phpcodesniffer-composer-installer": true 42 | } 43 | }, 44 | "scripts": { 45 | "lint": "@php ./vendor/bin/phpcs .", 46 | "format": "@php ./vendor/bin/phpcbf ." 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /languages/git-updater-lite.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: \n" 4 | "Report-Msgid-Bugs-To: https://github.com/afragen/git-updater-lite/issues\n" 5 | "Last-Translator: FULL NAME \n" 6 | "Language-Team: LANGUAGE \n" 7 | "MIME-Version: 1.0\n" 8 | "Content-Type: text/plain; charset=UTF-8\n" 9 | "Content-Transfer-Encoding: 8bit\n" 10 | "POT-Creation-Date: 2025-12-24T21:27:55+00:00\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "X-Generator: WP-CLI 2.12.0\n" 13 | "X-Domain: git-updater-lite\n" 14 | 15 | #. translators: %s: theme name 16 | #: Lite.php:394 17 | #, php-format 18 | msgid "There is a new version of %s available." 19 | msgstr "" 20 | 21 | #. translators: 1: version number, 2: closing anchor tag, 3: update URL 22 | #: Lite.php:405 23 | #, php-format 24 | msgid "View version %1$s details%2$s or %3$supdate now%2$s." 25 | msgstr "" 26 | 27 | #. translators: %s: theme name 28 | #: Lite.php:410 29 | #, php-format 30 | msgid "Update %s now" 31 | msgstr "" 32 | 33 | #. translators: 1: version number, 2: closing anchor tag, 3: update URL 34 | #: Lite.php:417 35 | #, php-format 36 | msgid "View version %1$s details%2$s." 37 | msgstr "" 38 | 39 | #. translators: %s: opening/closing paragraph and italic tags 40 | #: Lite.php:423 41 | #, php-format 42 | msgid "%1$sAutomatic update is unavailable for this theme.%2$s" 43 | msgstr "" 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Git Updater Lite 2 | 3 | * Contributors: [Andy Fragen](https://github.com/afragen) 4 | * Tags: plugin, theme, updater, git-updater 5 | * Requires at least: 6.6 6 | * Requires PHP: 7.4 7 | * Donate link: 8 | * License: MIT 9 | 10 | A simple standalone library to enable automatic updates to your git hosted WordPress plugins or themes. 11 | 12 | ## Description 13 | 14 | **This is version 2.x and contains a breaking change from 1.5.x.** 15 | 16 | This library was designed to be added to your git hosted plugin or theme to enable standalone updates. 17 | 18 | You must have a publicly reachable site that will be used for dynamically retrieving the update API data. 19 | 20 | * [Git Updater](https://git-updater.com) is required on a site where all of the release versions of your plugins and themes are installed. 21 | * All of your plugins/themes **must** be integrated with Git Updater. 22 | * You must be using Git Updater v12.9.0 or better. 23 | 24 | Git Updater is capable of returning a [REST endpoint](https://git-updater.com/knowledge-base/remote-management-restful-endpoints/#articleTOC_3/) containing the `plugins_api()` or `themes_api()` data for your plugin/theme. You will pass this endpoint during the integration. 25 | 26 | The REST endpoint format is as follows. 27 | 28 | * plugins - `https://my-site.com/wp-json/git-updater/v1/update-api/?slug=my-plugin` 29 | * themes - `https://my-site.com/wp-json/git-updater/v1/update-api/?slug=my-theme` 30 | 31 | ## Installation 32 | 33 | Add via composer. `composer require afragen/git-updater-lite:^2` 34 | 35 | * Add the `Update URI: ` header to your plugin or theme headers. Where `` is the domain to the update server, eg `https://git-updater.com`. 36 | 37 | * Add the following code to your plugin file or theme's functions.php file. 38 | 39 | ```php 40 | require_once __DIR__ . '/vendor/afragen/git-updater-lite/Lite.php'; 41 | ( new \Fragen\Git_Updater\Lite( __FILE__ ) )->run(); 42 | ``` 43 | 44 | An example integrated plugin is here, https://github.com/afragen/test-plugin-gu-lite 45 | 46 | ```php 47 | run(); 69 | ``` 70 | 71 | FWIW, I test by decreasing the version number locally to see an update. 72 | -------------------------------------------------------------------------------- /Lite.php: -------------------------------------------------------------------------------- 1 | slug = basename( dirname( $file_path ) ); 55 | 56 | if ( str_ends_with( $file_path, 'functions.php' ) ) { 57 | $this->file = $this->slug . '/style.css'; 58 | $file_path = dirname( $file_path ) . '/style.css'; 59 | } else { 60 | $this->file = $this->slug . '/' . basename( $file_path ); 61 | } 62 | 63 | $file_data = get_file_data( 64 | $file_path, 65 | array( 66 | 'Version' => 'Version', 67 | 'UpdateURI' => 'Update URI', 68 | ) 69 | ); 70 | $this->local_version = $file_data['Version']; 71 | $this->update_server = $this->check_update_uri( $file_data['UpdateURI'] ); 72 | } 73 | 74 | /** 75 | * Ensure properly formatted Update URI. 76 | * 77 | * @param string $updateUri Data from Update URI header. 78 | * 79 | * @return string|WP_Error 80 | */ 81 | private function check_update_uri( $updateUri ) { 82 | $updateUri = untrailingslashit( trim( $updateUri ) ); 83 | if ( ! ( filter_var( $updateUri, FILTER_VALIDATE_URL ) 84 | && null === parse_url( $updateUri, PHP_URL_PATH ) ) // null means no path is present. 85 | ) { 86 | return new WP_Error( 'invalid_header_data', 'Invalid data from Update URI header', $updateUri ); 87 | } 88 | 89 | return $updateUri; 90 | } 91 | 92 | /** 93 | * Get API data. 94 | * 95 | * @global string $pagenow Current page. 96 | * @return void|WP_Error 97 | */ 98 | public function run() { 99 | global $pagenow; 100 | 101 | // Needed for mu-plugin. 102 | if ( ! isset( $pagenow ) ) { 103 | $php_self = isset( $_SERVER['PHP_SELF'] ) ? sanitize_url( wp_unslash( $_SERVER['PHP_SELF'] ) ) : null; 104 | if ( null !== $php_self ) { 105 | // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited 106 | $pagenow = basename( $php_self ); 107 | } 108 | } 109 | 110 | // Only run on the following pages. 111 | $pages = array( 'update-core.php', 'update.php', 'plugins.php', 'themes.php' ); 112 | $view_details = array( 'plugin-install.php', 'theme-install.php' ); 113 | $autoupdate_pages = array( 'admin-ajax.php', 'index.php', 'wp-cron.php' ); 114 | if ( ! in_array( $pagenow, array_merge( $pages, $view_details, $autoupdate_pages ), true ) ) { 115 | return; 116 | } 117 | 118 | if ( empty( $this->update_server ) || is_wp_error( $this->update_server ) ) { 119 | return new WP_Error( 'invalid_domain', 'Invalid update server domain', $this->update_server ); 120 | } 121 | $url = add_query_arg( 122 | array( 'slug' => $this->slug ), 123 | sprintf( '%s/wp-json/git-updater/v1/update-api/', $this->update_server ) 124 | ); 125 | $response = get_site_transient( "git-updater-lite_{$this->file}" ); 126 | if ( ! $response ) { 127 | $response = wp_remote_get( $url ); 128 | if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) === 404 ) { 129 | return $response; 130 | } 131 | 132 | $this->api_data = (object) json_decode( wp_remote_retrieve_body( $response ), true ); 133 | if ( null === $this->api_data || empty( (array) $this->api_data ) || property_exists( $this->api_data, 'error' ) ) { 134 | return new WP_Error( 'non_json_api_response', 'Poorly formed JSON', $response ); 135 | } 136 | $this->api_data->file = $this->file; 137 | 138 | // Set timeout for transient via filter. 139 | $timeout = apply_filters( 'git_updater_lite_transient_timeout', 6 * HOUR_IN_SECONDS, $this->api_data ); 140 | set_site_transient( "git-updater-lite_{$this->file}", $this->api_data, $timeout ); 141 | } else { 142 | if ( property_exists( $response, 'error' ) ) { 143 | return new WP_Error( 'repo-no-exist', 'Specified repo does not exist' ); 144 | } 145 | $this->api_data = $response; 146 | } 147 | 148 | $this->load_hooks(); 149 | } 150 | 151 | /** 152 | * Load hooks. 153 | * 154 | * @return void 155 | */ 156 | public function load_hooks() { 157 | $type = $this->api_data->type; 158 | add_filter( 'upgrader_source_selection', array( $this, 'upgrader_source_selection' ), 10, 4 ); 159 | add_filter( "{$type}s_api", array( $this, 'repo_api_details' ), 99, 3 ); 160 | add_filter( "site_transient_update_{$type}s", array( $this, 'update_site_transient' ), 20, 1 ); 161 | if ( ! is_multisite() ) { 162 | add_filter( 'wp_prepare_themes_for_js', array( $this, 'customize_theme_update_html' ) ); 163 | } 164 | 165 | // Load hook for adding authentication headers for download packages. 166 | add_filter( 167 | 'upgrader_pre_download', 168 | function () { 169 | add_filter( 'http_request_args', array( $this, 'add_auth_header' ), 20, 2 ); 170 | return false; // upgrader_pre_download filter default return value. 171 | } 172 | ); 173 | } 174 | 175 | /** 176 | * Correctly rename dependency for activation. 177 | * 178 | * @param string|WP_Error $source Path of $source, or a WP_Error object. 179 | * @param string $remote_source Path of $remote_source. 180 | * @param WP_Upgrader $upgrader An Upgrader object. 181 | * @param array $hook_extra Array of hook data. 182 | * 183 | * @throws TypeError If the type of $upgrader is not correct. 184 | * 185 | * @return string|WP_Error 186 | */ 187 | public function upgrader_source_selection( $source, string $remote_source, WP_Upgrader $upgrader, $hook_extra = null ) { 188 | global $wp_filesystem; 189 | 190 | // Exit early for errors. 191 | if ( is_wp_error( $source ) ) { 192 | return $source; 193 | } 194 | 195 | $new_source = $source; 196 | 197 | // Exit if installing. 198 | if ( isset( $hook_extra['action'] ) && 'install' === $hook_extra['action'] ) { 199 | return $source; 200 | } 201 | 202 | if ( ! $upgrader instanceof Plugin_Upgrader && ! $upgrader instanceof Theme_Upgrader ) { 203 | throw new TypeError( __METHOD__ . '(): Argument #3 ($upgrader) must be of type Plugin_Upgrader|Theme_Upgrader, ' . esc_attr( gettype( $upgrader ) ) . ' given.' ); 204 | } 205 | 206 | // Rename plugins. 207 | if ( $upgrader instanceof Plugin_Upgrader ) { 208 | if ( isset( $hook_extra['plugin'] ) ) { 209 | $slug = dirname( $hook_extra['plugin'] ); 210 | $new_source = trailingslashit( $remote_source ) . $slug; 211 | } 212 | } 213 | 214 | // Rename themes. 215 | if ( $upgrader instanceof Theme_Upgrader ) { 216 | if ( isset( $hook_extra['theme'] ) ) { 217 | $slug = $hook_extra['theme']; 218 | $new_source = trailingslashit( $remote_source ) . $slug; 219 | } 220 | } 221 | 222 | if ( basename( $source ) === $slug ) { 223 | return $source; 224 | } 225 | 226 | if ( trailingslashit( strtolower( $source ) ) !== trailingslashit( strtolower( $new_source ) ) ) { 227 | $wp_filesystem->move( $source, $new_source, true ); 228 | } 229 | 230 | return trailingslashit( $new_source ); 231 | } 232 | 233 | /** 234 | * Put changelog in plugins_api, return WP.org data as appropriate 235 | * 236 | * @param bool $result Default false. 237 | * @param string $action The type of information being requested from the Plugin Installation API. 238 | * @param stdClass $response Repo API arguments. 239 | * 240 | * @return stdClass|bool 241 | */ 242 | public function repo_api_details( $result, string $action, stdClass $response ) { 243 | if ( "{$this->api_data->type}_information" !== $action ) { 244 | return $result; 245 | } 246 | 247 | // Exit if not our repo. 248 | if ( $response->slug !== $this->api_data->slug ) { 249 | return $result; 250 | } 251 | 252 | return $this->api_data; 253 | } 254 | 255 | /** 256 | * Hook into site_transient_update_{plugins|themes} to update from GitHub. 257 | * 258 | * @param stdClass $transient Plugin|Theme update transient. 259 | * 260 | * @return stdClass 261 | */ 262 | public function update_site_transient( $transient ) { 263 | // needed to fix PHP 7.4 warning. 264 | if ( ! is_object( $transient ) ) { 265 | $transient = new stdClass(); 266 | } 267 | 268 | $response = array( 269 | 'slug' => $this->api_data->slug, 270 | $this->api_data->type => 'theme' === $this->api_data->type ? $this->api_data->slug : $this->api_data->file, 271 | 'url' => isset( $this->api_data->url ) ? $this->api_data->url : $this->api_data->slug, 272 | 'icons' => (array) $this->api_data->icons, 273 | 'banners' => $this->api_data->banners, 274 | 'branch' => $this->api_data->branch, 275 | 'type' => "{$this->api_data->git}-{$this->api_data->type}", 276 | 'update-supported' => true, 277 | 'requires' => $this->api_data->requires, 278 | 'requires_php' => $this->api_data->requires_php, 279 | 'new_version' => $this->api_data->version, 280 | 'package' => $this->api_data->download_link, 281 | 'tested' => $this->api_data->tested, 282 | ); 283 | if ( 'theme' === $this->api_data->type ) { 284 | $response['theme_uri'] = $response['url']; 285 | } 286 | 287 | $key = 'plugin' === $this->api_data->type ? $this->api_data->file : $this->api_data->slug; 288 | $response = 'plugin' === $this->api_data->type ? (object) $response : $response; 289 | 290 | if ( version_compare( $this->api_data->version, $this->local_version, '>' ) ) { 291 | $transient->response[ $key ] = $response; 292 | } else { 293 | // Add repo without update to $transient->no_update for 'View details' link. 294 | $transient->no_update[ $key ] = $response; 295 | } 296 | 297 | return $transient; 298 | } 299 | 300 | /** 301 | * Add auth header for download package. 302 | * 303 | * @param array $args Array of http args. 304 | * @param string $url Download URL. 305 | * 306 | * @return array 307 | */ 308 | public function add_auth_header( $args, $url ) { 309 | if ( property_exists( $this->api_data, 'auth_header' ) 310 | && str_contains( $url, $this->api_data->slug ) 311 | ) { 312 | $args = array_merge( $args, $this->api_data->auth_header ); 313 | } 314 | return $args; 315 | } 316 | 317 | 318 | /** 319 | * Call theme messaging for single site installation. 320 | * 321 | * @author Seth Carstens 322 | * 323 | * @param array $prepared_themes Array of prepared themes. 324 | * 325 | * @return array 326 | */ 327 | public function customize_theme_update_html( $prepared_themes ) { 328 | $theme = $this->api_data; 329 | 330 | if ( 'theme' !== $theme->type ) { 331 | return $prepared_themes; 332 | } 333 | 334 | if ( ! empty( $prepared_themes[ $theme->slug ]['hasUpdate'] ) ) { 335 | $prepared_themes[ $theme->slug ]['update'] = $this->append_theme_actions_content( $theme ); 336 | } else { 337 | $prepared_themes[ $theme->slug ]['description'] .= $this->append_theme_actions_content( $theme ); 338 | } 339 | 340 | return $prepared_themes; 341 | } 342 | 343 | /** 344 | * Create theme update messaging for single site installation. 345 | * 346 | * @author Seth Carstens 347 | * 348 | * @access protected 349 | * @codeCoverageIgnore 350 | * 351 | * @param stdClass $theme Theme object. 352 | * 353 | * @return string (content buffer) 354 | */ 355 | protected function append_theme_actions_content( $theme ) { 356 | $details_url = esc_attr( 357 | add_query_arg( 358 | array( 359 | 'tab' => 'theme-information', 360 | 'theme' => $theme->slug, 361 | 'TB_iframe' => 'true', 362 | 'width' => 270, 363 | 'height' => 400, 364 | ), 365 | self_admin_url( 'theme-install.php' ) 366 | ) 367 | ); 368 | $nonced_update_url = wp_nonce_url( 369 | esc_attr( 370 | add_query_arg( 371 | array( 372 | 'action' => 'upgrade-theme', 373 | 'theme' => rawurlencode( $theme->slug ), 374 | ), 375 | self_admin_url( 'update.php' ) 376 | ) 377 | ), 378 | 'upgrade-theme_' . $theme->slug 379 | ); 380 | 381 | $current = get_site_transient( 'update_themes' ); 382 | 383 | /** 384 | * Display theme update links. 385 | */ 386 | ob_start(); 387 | if ( isset( $current->response[ $theme->slug ] ) ) { 388 | ?> 389 |

390 | 391 | name ) 396 | ); 397 | printf( 398 | ' ', 399 | esc_url( $details_url ), 400 | esc_attr( $theme->name ) 401 | ); 402 | if ( ! empty( $current->response[ $theme->slug ]['package'] ) ) { 403 | printf( 404 | /* translators: 1: version number, 2: closing anchor tag, 3: update URL */ 405 | esc_html__( 'View version %1$s details%2$s or %3$supdate now%2$s.', 'git-updater-lite' ), 406 | $theme->remote_version = isset( $theme->remote_version ) ? esc_attr( $theme->remote_version ) : null, 407 | '', 408 | sprintf( 409 | /* translators: %s: theme name */ 410 | '', 411 | esc_attr( $theme->name ) 412 | ) 413 | ); 414 | } else { 415 | printf( 416 | /* translators: 1: version number, 2: closing anchor tag, 3: update URL */ 417 | esc_html__( 'View version %1$s details%2$s.', 'git-updater-lite' ), 418 | $theme->remote_version = isset( $theme->remote_version ) ? esc_attr( $theme->remote_version ) : null, 419 | '' 420 | ); 421 | printf( 422 | /* translators: %s: opening/closing paragraph and italic tags */ 423 | esc_html__( '%1$sAutomatic update is unavailable for this theme.%2$s', 'git-updater-lite' ), 424 | '

', 425 | '

' 426 | ); 427 | } 428 | ?> 429 | 430 |

431 |