diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml
index 87143ea..8af327e 100644
--- a/.github/workflows/coding-standards.yml
+++ b/.github/workflows/coding-standards.yml
@@ -5,6 +5,7 @@ on:
branches:
- main
pull_request:
+
workflow_dispatch:
# Cancels all previous workflow runs for pull requests that have not completed.
diff --git a/README.md b/README.md
index 24b0a64..38efaf2 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ FAIR can be installed manually by downloading the latest zip from releases and i
To remove the FAIR plugin and its features, you can deactivate and delete the plugin. There are no changes made to your database outside of the plugin settings, and no external files are edited. FAIR is a self-contained plugin, using the accepted WordPress standards and practices.
-### Features
+## Features
> [!NOTE]
> The FAIR project is brand new. This plugin is a pre-release and some features are yet to be fully implemented.
@@ -44,7 +44,15 @@ In addition to the key FAIR implementations, a few other features in WordPress a
* Media features provided by OpenVerse are disabled, pending discussion and work by the FAIR working group
* Ping services are configured to use IndexNow in place of Pingomatic
-### Data Privacy
+
+### Experimental Features
+
+As FAIR works towards our plans for full decentralized package management, some features are marked as experimental. These features must be manually opted-in to during development.
+
+* `FAIR_EXPERIMENTAL_PACKAGES` - Define as `true` to enable decentralized package installation, via direct DID input.
+
+
+## Data Privacy
* See Also: [Linux Foundation Projects Privacy Policy](https://lfprojects.org/policies/privacy-policy/)
@@ -60,6 +68,7 @@ In addition we self-host certain features that could not be properly protected o
* WordPress Events (`https://api.fair.pm/fair/v1/events`) - Retrieved from [The WP World](https://thewp.world) hourly and then cached on our servers. No user data is sent to The WP World.
* WordPress Planet/News (`https://planet.fair.pm/atom.xml`)
+
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for information on contributing.
diff --git a/assets/css/packages.css b/assets/css/packages.css
new file mode 100644
index 0000000..f2e3294
--- /dev/null
+++ b/assets/css/packages.css
@@ -0,0 +1,42 @@
+.fair-direct-install {
+ max-width: 500px;
+ margin: 3em auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.fair-direct-install__help {
+ color: #50575e;
+ font-size: 18px;
+ font-style: normal;
+ margin: 0;
+ padding: 0;
+ text-align: center;
+}
+
+.fair-direct-install__form {
+ background: #f6f7f7;
+ border: 1px solid #c3c4c7;
+ padding: 30px;
+ margin: 30px auto;
+ display: inline-flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.fair-direct-install__form input[type="text"] {
+ font-size: 13px;
+ line-height: 2.15384615;
+ font-family: Consolas, Monaco, monospace;
+ width: 24em;
+ margin-right: 1em;
+}
+
+#fair-direct-install__note {
+ font-style: italic;
+ text-align: center;
+}
+#fair-direct-install__note code {
+ font-style: normal;
+}
diff --git a/inc/icons/namespace.php b/inc/icons/namespace.php
index 8f33874..65b54e7 100644
--- a/inc/icons/namespace.php
+++ b/inc/icons/namespace.php
@@ -9,6 +9,8 @@ namespace FAIR\Icons;
use const FAIR\PLUGIN_FILE;
+use stdClass;
+
/**
* Bootstrap
*/
@@ -24,6 +26,11 @@ function bootstrap() {
* @return stdClass
*/
function set_default_icon( $transient ) {
+ // The transient may not be set yet.
+ if ( ! is_object( $transient ) ) {
+ $transient = new stdClass();
+ }
+
if ( ! property_exists( $transient, 'response' ) ) {
return $transient;
}
diff --git a/inc/namespace.php b/inc/namespace.php
index ed7df71..0a9bbe9 100644
--- a/inc/namespace.php
+++ b/inc/namespace.php
@@ -23,7 +23,7 @@ function bootstrap() {
$did_init = true;
- register_class_path( __NAMESPACE__, __DIR__ . '/inc' );
+ register_class_path( __NAMESPACE__, __DIR__ . DIRECTORY_SEPARATOR );
// Modules.
Avatars\bootstrap();
@@ -33,11 +33,14 @@ function bootstrap() {
Disable_Openverse\bootstrap();
Icons\bootstrap();
Importers\bootstrap();
+ if ( defined( 'FAIR_EXPERIMENTAL_PACKAGES' ) && FAIR_EXPERIMENTAL_PACKAGES ) {
+ Packages\bootstrap();
+ Updater\bootstrap();
+ }
Pings\bootstrap();
Salts\bootstrap();
Settings\bootstrap();
Upgrades\bootstrap();
- Updater\bootstrap();
User_Notification\bootstrap();
Version_Check\bootstrap();
diff --git a/inc/packages/admin/info.php b/inc/packages/admin/info.php
new file mode 100644
index 0000000..a8aef3e
--- /dev/null
+++ b/inc/packages/admin/info.php
@@ -0,0 +1,473 @@
+ [
+ 'href' => [],
+ 'title' => [],
+ 'target' => [],
+ ],
+ 'abbr' => [
+ 'title' => [],
+ ],
+ 'acronym' => [
+ 'title' => [],
+ ],
+ 'code' => [],
+ 'pre' => [],
+ 'em' => [],
+ 'strong' => [],
+ 'div' => [
+ 'class' => [],
+ ],
+ 'span' => [
+ 'class' => [],
+ ],
+ 'p' => [],
+ 'br' => [],
+ 'ul' => [],
+ 'ol' => [],
+ 'li' => [],
+ 'h1' => [],
+ 'h2' => [],
+ 'h3' => [],
+ 'h4' => [],
+ 'h5' => [],
+ 'h6' => [],
+ 'img' => [
+ 'src' => [],
+ 'class' => [],
+ 'alt' => [],
+ ],
+ 'blockquote' => [
+ 'cite' => true,
+ ],
+ ];
+ return wp_kses( $html, $allowed );
+}
+
+/**
+ * Get section title.
+ *
+ * @param string $id DID.
+ *
+ * @return string
+ */
+function get_section_title( string $id ) {
+ switch ( $id ) {
+ case 'description':
+ return _x( 'Description', 'Plugin installer section title', 'fair' );
+ case 'installation':
+ return _x( 'Installation', 'Plugin installer section title', 'fair' );
+ case 'faq':
+ return _x( 'FAQ', 'Plugin installer section title', 'fair' );
+ case 'screenshots':
+ return _x( 'Screenshots', 'Plugin installer section title', 'fair' );
+ case 'changelog':
+ return _x( 'Changelog', 'Plugin installer section title', 'fair' );
+ case 'reviews':
+ return _x( 'Reviews', 'Plugin installer section title', 'fair' );
+ case 'other_notes':
+ return _x( 'Other Notes', 'Plugin installer section title', 'fair' );
+ default:
+ return ucwords( str_replace( '_', ' ', $id ) );
+ }
+}
+
+/**
+ * Render page.
+ *
+ * @param MetadataDocument $metadata Metadata for page render.
+ * @param string $tab Page tab.
+ * @param string $section Page section.
+ *
+ * @return void
+ */
+function render_page( MetadataDocument $metadata, string $tab, string $section ) {
+ iframe_header( __( 'Plugin Installation', 'fair' ) );
+ render( $metadata, $tab, $section );
+ wp_print_request_filesystem_credentials_modal();
+ wp_print_admin_notice_templates();
+
+ iframe_footer();
+}
+
+/**
+ * Displays plugin information in dialog box form.
+ *
+ * @since 2.7.0
+ *
+ * @global string $tab
+ * @param MetadataDocument $doc Metadata for page render.
+ * @param string $tab Page tab.
+ * @param string $section Page section.
+ */
+function render( MetadataDocument $doc, string $tab, string $section ) {
+ $sections = (array) $doc->sections;
+
+ if ( ! isset( $sections[ $section ] ) ) {
+ $section = array_keys( $sections )[0];
+ }
+
+ $releases = array_values( $doc->releases );
+ usort( $releases, fn ( $a, $b ) => version_compare( $b->version, $a->version ) );
+ $latest = ! empty( $releases ) ? reset( $releases ) : null;
+
+ // Add banners, if available.
+ $_with_banner = '';
+ if ( ! empty( $latest->artifacts->banner ) ) {
+ $_with_banner = 'with-banner';
+ render_banner( $latest );
+ }
+
+ ?>
+
+
+
+ artifacts->banner ) ) {
+ return;
+ }
+
+ $banners = $release->artifacts->banner;
+
+ $regular = array_find( $banners, fn ( $banner ) => $banner->width === 772 && $banner->height === 250 );
+ $high_res = array_find( $banners, fn ( $banner ) => $banner->width === 1544 && $banner->height === 500 );
+ if ( empty( $regular ) && empty( $high_res ) ) {
+ return;
+ }
+
+ ?>
+
+
+
+
+
+ - = __( 'Version:', 'fair' ); ?> = esc_attr( $release->version ); ?>
+
+ slug ) ) : ?>
+ - = __( 'Slug:', 'fair' ); ?> = esc_attr( $doc->slug ); ?>
+
+ requires ) ) : ?>
+ -
+ = __( 'Requires:', 'fair' ); ?>
+
+ requires as $type => $constraint ) : ?>
+ - = esc_html( name_requirement( $type ) ); ?> = esc_html( $constraint ); ?>
+
+
+
+
+ suggests ) ) : ?>
+ -
+ = __( 'Suggests:', 'fair' ); ?>
+
+ suggests as $type => $constraint ) : ?>
+ - = esc_html( name_requirement( $type ) ); ?> = esc_html( $constraint ); ?>
+
+
+
+
+
+ authors ) ) :
+ ?>
+
= __( 'Authors', 'fair' ); ?>
+
+ authors as $author ) {
+ if ( empty( $author->name ) ) {
+ continue;
+ }
+ $url = $author->url ?? ( isset( $author->email ) ? 'mailto:' . $author->email : null );
+ printf(
+ '%s',
+ esc_url( $url ),
+ esc_html( $author->name )
+ );
+ break;
+ }
+ ?>
+
+
+
+ requires );
+ $unmet_suggests = Packages\get_unmet_requirements( (array) $release->suggests );
+ if ( empty( $unmet_requires ) && empty( $unmet_suggests ) ) {
+ return;
+ }
+
+ if ( isset( $unmet_requires['env:php'] ) ) {
+ $compatible_php_notice_message = '';
+ $compatible_php_notice_message .= __( 'Error: This plugin requires a newer version of PHP.', 'fair' );
+
+ if ( current_user_can( 'update_php' ) ) {
+ $compatible_php_notice_message .= sprintf(
+ /* translators: %s: URL to Update PHP page. */
+ ' ' . __( 'Click here to learn more about updating PHP.', 'fair' ),
+ esc_url( wp_get_update_php_url() )
+ ) . wp_update_php_annotation( '
', '', false );
+ } else {
+ $compatible_php_notice_message .= '
';
+ }
+
+ wp_admin_notice(
+ $compatible_php_notice_message,
+ [
+ 'type' => 'error',
+ 'additional_classes' => [ 'notice-alt' ],
+ 'paragraph_wrap' => false,
+ ]
+ );
+ }
+
+ $is_dev = (bool) preg_match( '/alpha|beta|RC/', get_bloginfo( 'version' ) );
+ if ( isset( $unmet_suggests['env:wp'] ) && ! $is_dev ) {
+ wp_admin_notice(
+ __( 'Warning: This plugin has not been tested with your current version of WordPress.', 'fair' ),
+ [
+ 'type' => 'warning',
+ 'additional_classes' => [ 'notice-alt' ],
+ ]
+ );
+ } elseif ( isset( $unmet_requires['env:wp'] ) ) {
+ $compatible_wp_notice_message = __( 'Error: This plugin requires a newer version of WordPress.', 'fair' );
+ if ( current_user_can( 'update_core' ) ) {
+ $compatible_wp_notice_message .= sprintf(
+ /* translators: %s: URL to WordPress Updates screen. */
+ ' ' . __( 'Click here to update WordPress.', 'fair' ),
+ esc_url( self_admin_url( 'update-core.php' ) )
+ );
+ }
+
+ wp_admin_notice(
+ $compatible_wp_notice_message,
+ [
+ 'type' => 'error',
+ 'additional_classes' => [ 'notice-alt' ],
+ ]
+ );
+ }
+}
+
+/**
+ * Gets the markup for the plugin install action button.
+ *
+ * @param MetadataDocument $doc Metadata document.
+ * @param ReleaseDocument $release Release document.
+ *
+ * @return string The markup for the dependency row button. An empty string if the user does not have capabilities.
+ */
+function get_action_button( MetadataDocument $doc, ReleaseDocument $release ) {
+ if ( ! current_user_can( 'install_plugins' ) && ! current_user_can( 'update_plugins' ) ) {
+ // How did you get here, pal?
+ return '';
+ }
+
+ $type = str_replace( 'wp-', '', $doc->type );
+ $installed_version = Packages\get_installed_version( $doc->id, $type );
+ if ( $installed_version === null ) {
+ $status = 'install';
+ } elseif ( version_compare( $installed_version, $release->version, '<' ) ) {
+ $status = 'update';
+ } else {
+ $status = 'installed';
+ }
+
+ // Do we actually meet the requirements?
+ $compatible = Packages\check_requirements( $release );
+ switch ( $status ) {
+ case 'install':
+ if ( ! $compatible ) {
+ return sprintf(
+ '',
+ esc_html__( 'Install Now', 'fair' )
+ );
+ }
+
+ $slug = $doc->slug . '-' . str_replace( ':', '--', $doc->id );
+ return sprintf(
+ '%s',
+ esc_attr( $doc->id ),
+ esc_url( Admin\get_direct_install_url( $doc, $release ) ),
+ esc_attr( $slug ),
+ /* translators: %s: The package's name. */
+ esc_attr( sprintf( __( 'Install %s now', 'fair' ), $doc->name ) ),
+ esc_attr( $doc->name ),
+ esc_html__( 'Install Now', 'fair' )
+ );
+
+ case 'update':
+ if ( ! $compatible ) {
+ return sprintf(
+ '',
+ esc_html__( 'Update Now', 'fair' )
+ );
+ }
+
+ $file = Updater\get_packages()[ "{$type}s" ][ $doc->id ];
+ $file = $type === 'plugin' ? plugin_basename( $file ) : basename( dirname( $file ) );
+ $slug = $type === 'plugin' ? dirname( $file ) : $file;
+
+ return sprintf(
+ '%s',
+ esc_attr( $doc->id ),
+ esc_attr( $type ),
+ esc_attr( $file ),
+ esc_attr( $slug ),
+ esc_url( Admin\get_direct_update_url( $doc ) ),
+ /* translators: %s: The package's name. */
+ esc_attr( sprintf( __( 'Update %s now', 'fair' ), $doc->name ) ),
+ esc_attr( $doc->name ),
+ esc_html__( 'Update Now', 'fair' )
+ );
+
+ case 'installed':
+ return sprintf(
+ '',
+ esc_html__( 'Installed', 'fair' )
+ );
+ }
+}
diff --git a/inc/packages/admin/namespace.php b/inc/packages/admin/namespace.php
new file mode 100644
index 0000000..910f0f0
--- /dev/null
+++ b/inc/packages/admin/namespace.php
@@ -0,0 +1,273 @@
+slug ) ) {
+ return $result;
+ }
+
+ $slug = sanitize_text_field( $args->slug );
+ if ( ! str_contains( $slug, '-did--' ) ) {
+ return $result;
+ }
+
+ $did = 'did:' . explode( '-did:', str_replace( '--', ':', $slug ), 2 )[1];
+ if ( ! preg_match( '/^did:(web|plc):.+$/', $did ) ) {
+ return $result;
+ }
+
+ wp_cache_set( ACTION_INSTALL_DID, $did );
+ Packages\add_package_to_release_cache( $did );
+ add_filter( 'http_request_args', 'FAIR\\Packages\\maybe_add_accept_header', 20, 2 );
+
+ return (object) Packages\get_update_data( $did );
+}
+
+/**
+ * Enqueue assets.
+ *
+ * @return void
+ */
+function load_plugin_install() {
+ enqueue_assets();
+}
+
+/**
+ * Enqueue assets.
+ *
+ * @return void
+ */
+function enqueue_assets() {
+ wp_enqueue_style(
+ 'fair-admin',
+ esc_url( plugin_dir_url( FAIR\PLUGIN_FILE ) . 'assets/css/packages.css' ),
+ [],
+ FAIR\VERSION
+ );
+}
+
+/**
+ * Render direct installer tab.
+ *
+ * @return void
+ */
+function render_tab_direct() {
+ ?>
+
+
+ = __( 'Enter a plugin ID to view details and install.', 'fair' ); ?>
+
+
+
+ = __( 'Plugin IDs should be in the format did:web:...
or did:plc:...
', 'fair' ); ?>
+
+
+
+ ACTION_INSTALL,
+ 'id' => urlencode( $doc->id ),
+ 'version' => urlencode( $release->version ),
+ ];
+ $url = add_query_arg( $args, self_admin_url( 'update.php' ) );
+ return wp_nonce_url( $url, ACTION_INSTALL_NONCE . $doc->id );
+}
+
+/**
+ * Get direct update URL.
+ *
+ * @param MetadataDocument $doc Metadata document.
+ *
+ * @return string
+ */
+function get_direct_update_url( MetadataDocument $doc ): string {
+ $type = str_replace( 'wp-', '', $doc->type );
+ $action = "upgrade-{$type}";
+ $packages = Updater\get_packages();
+ $file = $packages[ "{$type}s" ][ $doc->id ];
+ $file = $type === 'plugin' ? plugin_basename( $file ) : basename( dirname( $file ) );
+ $args = [
+ 'action' => $action,
+ $type => $file,
+ ];
+ $url = add_query_arg( $args, self_admin_url( 'update.php' ) );
+ return wp_nonce_url( $url, "{$action}_{$file}" );
+}
+
+/**
+ * Set slug to hashed slug from escaped slug-did.
+ *
+ * Needed for check_plugin_dependencies_during_ajax().
+ *
+ * @return void
+ */
+function set_slug_to_hashed() : void {
+ check_ajax_referer( 'updates' );
+
+ if ( ! isset( $_POST['slug'] ) ) {
+ return;
+ }
+
+ $escaped_slug = sanitize_text_field( wp_unslash( $_POST['slug'] ) );
+ $did = 'did:' . explode( '-did:', str_replace( '--', ':', $escaped_slug ), 2 )[1];
+ if ( ! preg_match( '/^did:(web|plc):.+$/', $did ) ) {
+ return;
+ }
+
+ // Reset to proper hashed slug.
+ $_POST['slug'] = explode( '-did--', $escaped_slug, 2 )[0] . '-' . Packages\get_did_hash( $did );
+}
+
+/**
+ * Maybe hijack plugin info.
+ *
+ * @return void
+ */
+function maybe_hijack_plugin_info() {
+ // phpcs:disable HM.Security.NonceVerification.Recommended
+ if ( empty( $_REQUEST['plugin'] ) ) {
+ return;
+ }
+
+ // Hijack, if the plugin is a FAIR package.
+ $id = sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ) );
+ if ( ! preg_match( '/^did:(web|plc):.+$/', $id ) ) {
+ return;
+ }
+
+ $metadata = Packages\fetch_package_metadata( $id );
+ if ( is_wp_error( $metadata ) ) {
+ wp_die( esc_html( $metadata->get_error_message() ) );
+ }
+
+ $tab = esc_attr( $GLOBALS['tab'] ?? 'plugin-information' );
+ $section = isset( $_REQUEST['section'] ) ? sanitize_key( wp_unslash( $_REQUEST['section'] ) ) : 'description';
+ // phpcs:enable
+
+ Info\render_page( $metadata, $tab, $section );
+ exit;
+}
diff --git a/inc/packages/class-metadatadocument.php b/inc/packages/class-metadatadocument.php
new file mode 100644
index 0000000..26aa695
--- /dev/null
+++ b/inc/packages/class-metadatadocument.php
@@ -0,0 +1,183 @@
+{$key} ) ) {
+ return new WP_Error( 'fair.packages.metadata_document.missing_field', sprintf( __( 'Missing mandatory field: %s', 'fair' ), $key ) );
+ }
+
+ $doc->{$key} = $data->{$key};
+ }
+
+ $optional = [
+ 'name',
+ 'slug',
+ 'filename',
+ 'description',
+ 'keywords',
+ 'sections',
+ ];
+ foreach ( $optional as $key ) {
+ if ( isset( $data->{$key} ) ) {
+ $doc->{$key} = $data->{$key};
+ }
+ }
+
+ // Parse releases.
+ if ( empty( $data->releases ) ) {
+ return new WP_Error( 'fair.packages.metadata_document.missing_releases', __( 'No releases found in the metadata document.', 'fair' ) );
+ }
+ foreach ( $data->releases as $release ) {
+ $release_doc = ReleaseDocument::from_data( $release );
+ if ( is_wp_error( $release_doc ) ) {
+ return $release_doc;
+ }
+ $doc->releases[] = $release_doc;
+ }
+
+ return $doc;
+ }
+
+ /**
+ * Collate response.
+ *
+ * @param array $response Response data.
+ * @return static|WP_Error Instance if valid, WP_Error otherwise.
+ */
+ public static function from_response( array $response ) {
+ $data = json_decode( $response['body'] );
+ if ( json_last_error() !== JSON_ERROR_NONE ) {
+ return new WP_Error( 'fair.packages.fetch_repository.invalid_json', __( 'Could not decode repository response.', 'fair' ) );
+ }
+
+ $doc = static::from_data( $data );
+ if ( is_wp_error( $doc ) ) {
+ return $doc;
+ }
+
+ // Pull the cache data as well.
+ $headers = $response['headers'];
+ $doc->_headers = $response['headers'];
+
+ return $doc;
+ }
+}
diff --git a/inc/packages/class-releasedocument.php b/inc/packages/class-releasedocument.php
new file mode 100644
index 0000000..f4fd105
--- /dev/null
+++ b/inc/packages/class-releasedocument.php
@@ -0,0 +1,100 @@
+{$key} ) ) {
+ return new WP_Error( 'fair.packages.metadata_document.missing_field', sprintf( __( 'Missing mandatory field: %s', 'fair' ), $key ) );
+ }
+ $doc->{$key} = $data->{$key};
+ }
+
+ $optional = [
+ 'provides',
+ 'requires',
+ 'suggests',
+ 'auth',
+ ];
+ foreach ( $optional as $key ) {
+ if ( isset( $data->{$key} ) ) {
+ $doc->{$key} = $data->{$key};
+ }
+ }
+
+ return $doc;
+ }
+}
diff --git a/inc/packages/did/class-did.php b/inc/packages/did/class-did.php
new file mode 100644
index 0000000..c2a3e8d
--- /dev/null
+++ b/inc/packages/did/class-did.php
@@ -0,0 +1,32 @@
+id = $id;
+ $this->service = $service;
+ $this->verificationMethod = $verificationMethod;
+ }
+
+ /**
+ * Get a service by type.
+ *
+ * @param string $type Service type.
+ * @return stdClass Service data, including id and serviceEndpoint
+ */
+ public function get_service( string $type ) : ?stdClass {
+ return array_find( $this->service, fn ( $service ) => $service->type === $type );
+ }
+
+ /**
+ * Get valid signing keys for FAIR.
+ *
+ * Gets valid keys from the document which can be used to sign packages.
+ *
+ * @return stdClass[] List of keys, including id and publicKeyMultibase
+ */
+ public function get_fair_signing_keys() : array {
+ return array_filter( $this->verificationMethod, function ( $key ) {
+ // Only multibase keys are supported.
+ if ( $key->type !== 'Multikey' ) {
+ return false;
+ }
+
+ $parsed = parse_url( $key->id );
+
+ // Only permit keys with IDs prefixed with 'fair'.
+ return str_starts_with( $parsed['fragment'], 'fair' );
+ } );
+ }
+ // phpcs:enable
+}
diff --git a/inc/packages/did/class-plc.php b/inc/packages/did/class-plc.php
new file mode 100644
index 0000000..71f1491
--- /dev/null
+++ b/inc/packages/did/class-plc.php
@@ -0,0 +1,85 @@
+id = $id;
+ }
+
+ /**
+ * Get the DID type.
+ *
+ * One of plc, web.
+ */
+ public function get_method() : string {
+ return static::METHOD;
+ }
+
+ /**
+ * Get the full decentralized ID (DID).
+ */
+ public function get_id() : string {
+ return $this->id;
+ }
+
+ /**
+ * Fetch PLC document.
+ *
+ * @return Document|WP_Error
+ */
+ public function fetch_document() {
+ $url = static::DIRECTORY_URL . $this->id;
+ $response = wp_remote_get( $url );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $data = json_decode( $response['body'] );
+ if ( json_last_error() !== JSON_ERROR_NONE ) {
+ return new WP_Error( 'fair.packages.did.json_error', __( 'Unable to parse DID document response.', 'fair' ) );
+ }
+ if ( 200 !== wp_remote_retrieve_response_code( $response ) && property_exists( $data, 'message' ) ) {
+ return new WP_Error( 'fair.packages.did.fetch.error', esc_html( $data->message ) );
+ }
+ if ( empty( $data->id ) || $data->id !== $this->id ) {
+ return new WP_Error( 'fair.packages.did.fetch.mismatch', __( 'The PLC directory did not return the DID that was sent or the DID was invalid.', 'fair' ) );
+ }
+
+ $document = new Document(
+ $data->id,
+ $data->service ?? [],
+ $data->verificationMethod ?? []
+ );
+ return $document;
+ }
+ // phpcs:enable
+}
diff --git a/inc/packages/did/class-web.php b/inc/packages/did/class-web.php
new file mode 100644
index 0000000..8ca3dbf
--- /dev/null
+++ b/inc/packages/did/class-web.php
@@ -0,0 +1,56 @@
+id = $id;
+ }
+
+ /**
+ * Get the DID type.
+ *
+ * One of plc, web.
+ */
+ public function get_method() : string {
+ return static::METHOD;
+ }
+
+ /**
+ * Get the full decentralized ID (DID).
+ */
+ public function get_id() : string {
+ return $this->id;
+ }
+
+ /**
+ * Fetch PLC Web document.
+ *
+ * @return void|null
+ */
+ public function fetch_document() {
+ return null; // todo.
+ }
+}
diff --git a/inc/packages/namespace.php b/inc/packages/namespace.php
new file mode 100644
index 0000000..1c490bb
--- /dev/null
+++ b/inc/packages/namespace.php
@@ -0,0 +1,677 @@
+get_id() ), 0, 6 );
+}
+
+/**
+ * Get DID document.
+ *
+ * @param string $id DID.
+ * @return DIDDocument|WP_Error
+ */
+function get_did_document( string $id ) {
+ $cached = get_site_transient( $id );
+ if ( $cached ) {
+ return $cached;
+ }
+
+ // Parse the DID, then fetch the details.
+ $did = parse_did( $id );
+ if ( is_wp_error( $did ) ) {
+ return $did;
+ }
+
+ $document = $did->fetch_document();
+ if ( is_wp_error( $document ) ) {
+ return $document;
+ }
+ set_site_transient( $id, $document, CACHE_LIFETIME );
+
+ return $document;
+}
+
+/**
+ * Fetch metadata for a package.
+ *
+ * @param string $id DID of the package to fetch metadata for.
+ * @return MetadataDocument|WP_Error Metadata document on success, WP_Error on failure.
+ */
+function fetch_package_metadata( string $id ) {
+ $document = get_did_document( $id );
+ if ( is_wp_error( $document ) ) {
+ return $document;
+ }
+
+ // Fetch data from the repository.
+ $service = $document->get_service( SERVICE_ID );
+ if ( empty( $service ) ) {
+ return new WP_Error( 'fair.packages.fetch_metadata.no_service', __( 'DID is not a valid package to fetch metadata for.', 'fair' ) );
+ }
+ $repo_url = $service->serviceEndpoint;
+
+ return fetch_metadata_doc( $repo_url );
+}
+
+/**
+ * Fetch the metadata document for a package.
+ *
+ * @param string $url URL for the metadata document.
+ * @return MetadataDocument|WP_Error
+ */
+function fetch_metadata_doc( string $url ) {
+ $cache_key = md5( $url );
+ $response = get_site_transient( $cache_key );
+
+ if ( ! $response ) {
+ $response = wp_remote_get( $url, [
+ 'headers' => [
+ 'Accept' => sprintf( '%s;q=1.0, application/json;q=0.8', CONTENT_TYPE ),
+ ],
+ 'timeout' => 7,
+ ] );
+ $code = wp_remote_retrieve_response_code( $response );
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ } elseif ( $code !== 200 ) {
+ return new WP_Error( 'fair.packages.metadata.failure', __( 'HTTP error code received', 'fair' ) );
+ }
+ set_site_transient( $cache_key, $response, CACHE_LIFETIME );
+ }
+
+ return MetadataDocument::from_response( $response );
+}
+
+/**
+ * Select the best release from a list of releases.
+ *
+ * @param array $releases List of releases to choose from.
+ * @param string|null $version Version to select. If null, the latest release is returned.
+ * @return ReleaseDocument|null The selected release or null if not found.
+ */
+function pick_release( array $releases, ?string $version = null ) : ?ReleaseDocument {
+ // Sort releases by version, descending.
+ usort( $releases, fn ( $a, $b ) => version_compare( $b->version, $a->version ) );
+
+ // If no version is specified, return the latest release.
+ if ( empty( $version ) ) {
+ return reset( $releases );
+ }
+
+ return array_find( $releases, fn ( $release ) => $release->version === $version );
+}
+
+/**
+ * Get the latest release for a DID.
+ *
+ * @param string $id DID.
+ *
+ * @return ReleaseDocument|WP_Error The latest release, or a WP_Error object on failure.
+ */
+function get_latest_release_from_did( $id ) {
+ $document = get_did_document( $id );
+ if ( is_wp_error( $document ) ) {
+ return $document;
+ }
+
+ $valid_keys = $document->get_fair_signing_keys();
+ if ( empty( $valid_keys ) ) {
+ return new WP_Error( 'fair.packages.install.no_signing_keys', __( 'DID does not contain valid signing keys.', 'fair' ) );
+ }
+
+ $metadata = fetch_package_metadata( $id );
+ if ( is_wp_error( $metadata ) ) {
+ return $metadata;
+ }
+
+ $release = pick_release( $metadata->releases );
+ if ( empty( $release ) ) {
+ return new WP_Error( 'fair.packages.install.no_releases', __( 'No releases found in the repository.', 'fair' ) );
+ }
+
+ return $release;
+}
+
+/**
+ * Get viable languages for a given locale.
+ *
+ * Based on the RFC4647 language matching algorithm, with slight modifications.
+ * In particular, the base language code (e.g. "de") is treated as equivalent
+ * to language-plus-country/region with the same name (e.g. "de-DE").
+ *
+ * Additionally, for WordPress-compatibility, underscores are treated as
+ * separators equivalent to hyphens. The default language is "en-US" or "en".
+ *
+ * The priority list can be filtered using the
+ * `fair.packages.language_priority_list` filter.
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc4647
+ * @see https://datatracker.ietf.org/doc/html/rfc5646
+ *
+ * @param string|null $locale Locale to match against. Defaults to the current locale.
+ * @return string[]|null Prioritized list of language codes.
+ */
+function get_language_priority_list( ?string $locale = null ) {
+ $locale = $locale ?: get_locale();
+ $locale = strtolower( str_replace( '_', '-', $locale ) );
+ $langs = [];
+ $langs[] = $locale;
+
+ if ( strpos( $locale, '-' ) !== false ) {
+ // Add all possible prefixes.
+ $i = strlen( $locale );
+ do {
+ $i = strrpos( substr( $locale, 0, $i ), '-' );
+ if ( $i === false ) {
+ break;
+ }
+
+ // If this is just "x", skip it.
+ if ( substr( $locale, $i - 1, 1 ) === 'x' ) {
+ continue;
+ }
+
+ $langs[] = substr( $locale, 0, $i );
+ } while ( $i > 0 );
+ }
+
+ /*
+ * Double the primary language code, to catch cases where the
+ * locale matches the country code. (e.g. de becomes de-DE.)
+ */
+ $primary = substr( $locale, 0, strpos( $locale, '-' ) );
+ $langs[] = $primary . '-' . $primary;
+
+ // Defaults.
+ $langs[] = 'en-us';
+ $langs[] = 'en';
+
+ /**
+ * Filter the list of languages to prioritize.
+ */
+ return apply_filters( 'fair.packages.language_priority_list', $langs, $locale );
+}
+
+/**
+ * Pick the best matching artifact based on the current locale.
+ *
+ * Uses the language priority list to pick the best scoring artifact. The
+ * algorithm can be overridden by the
+ * `fair.packages.pick_artifact_by_lang` filter.
+ *
+ * @see get_language_priority_list()
+ *
+ * @param array $artifacts List of artifacts to choose from.
+ * @param string|null $locale Locale to match against. Defaults to the current locale.
+ * @return stdClass|null The best matching artifact or null if none found.
+ */
+function pick_artifact_by_lang( array $artifacts, ?string $locale = null ) {
+ $langs = get_language_priority_list( $locale );
+
+ // Score artifacts based on match.
+ $score_artifact = function ( $artifact ) use ( $langs ) {
+ $score = 0;
+
+ // Check for lang match.
+ $idx = array_search( strtolower( $artifact->lang ), $langs, true );
+ if ( $idx !== false ) {
+ $score += ( count( $langs ) - $idx ) * 100;
+ }
+
+ return $score;
+ };
+ usort( $artifacts, function ( $a, $b ) use ( $score_artifact ) {
+ $a_score = $score_artifact( $a );
+ $b_score = $score_artifact( $b );
+
+ return $b_score <=> $a_score;
+ } );
+
+ // Return the best match.
+ $selected = reset( $artifacts );
+
+ /**
+ * Filter the selected artifact.
+ */
+ return apply_filters( 'fair.packages.pick_artifact_by_lang', $selected, $artifacts, $locale, $langs );
+}
+
+/**
+ * Get version requirements.
+ *
+ * @param ReleaseDocument $release Release document.
+ *
+ * @return array
+ */
+function version_requirements( ReleaseDocument $release ) {
+ $required_versions = [];
+ foreach ( $release->requires as $pkg => $vers ) {
+ $vers = preg_replace( '/^[^0-9]+/', '', $vers );
+ if ( $pkg === 'env:php' ) {
+ $required_versions['requires_php'] = $vers;
+ }
+ if ( $pkg === 'env:wp' ) {
+ $required_versions['requires_wp'] = $vers;
+ }
+ }
+ foreach ( $release->suggests as $pkg => $vers ) {
+ $vers = preg_replace( '/^[^0-9]+/', '', $vers );
+ if ( $pkg === 'env:wp' ) {
+ $required_versions['tested_to'] = $vers;
+ }
+ }
+
+ return $required_versions;
+}
+
+/**
+ * Get unmet requirements.
+ *
+ * @param array $requirements Requirements to check. Map of package names to requirement strings.
+ * @return array Map of package names to unmet requirements.
+ */
+function get_unmet_requirements( array $requirements ) : array {
+ $unmet = [];
+ foreach ( $requirements as $pkg => $req_list ) {
+ $req_parts = explode( ',', $req_list );
+ $req_unmet = [];
+ foreach ( $req_parts as $req ) {
+ $req = trim( $req );
+ $comp_spn = strspn( $req, '<>=!' );
+ if ( $comp_spn === 0 ) {
+ // Invalid requirement, for now.
+ continue;
+ }
+
+ $comp = trim( substr( $req, 0, $comp_spn ) );
+ $ver = trim( substr( $req, $comp_spn ) );
+
+ switch ( true ) {
+ case $pkg === 'env:wp':
+ // From is_wp_version_compatible()
+ // We use our own copy to allow passing $comp.
+ if (
+ defined( 'WP_RUN_CORE_TESTS' )
+ && WP_RUN_CORE_TESTS
+ && isset( $GLOBALS['_wp_tests_wp_version'] )
+ ) {
+ $wp_version = $GLOBALS['_wp_tests_wp_version'];
+ } else {
+ $wp_version = wp_get_wp_version();
+ }
+
+ $valid = version_compare( $wp_version, $ver, $comp );
+ if ( ! $valid ) {
+ $req_unmet[] = $req;
+ }
+ break;
+
+ case $pkg === 'env:php':
+ $valid = version_compare( PHP_VERSION, $ver, $comp );
+ if ( ! $valid ) {
+ $req_unmet[] = $req;
+ }
+ break;
+
+ case str_starts_with( $pkg, 'env:php-' ):
+ // todo: check extensions.
+ break;
+
+ case str_starts_with( $pkg, 'env:' ):
+ // todo: check other env, or fail.
+ break;
+
+ default:
+ // todo: check packages.
+ break;
+ }
+ }
+ if ( ! empty( $req_unmet ) ) {
+ $unmet[ $pkg ] = implode( ', ', $req_unmet );
+ }
+ }
+
+ return $unmet;
+}
+
+/**
+ * Check if a release meets the requirements.
+ *
+ * @param ReleaseDocument $release Release document.
+ *
+ * @return bool True if the release meets the requirements, false otherwise.
+ */
+function check_requirements( ReleaseDocument $release ) {
+ $requires = get_unmet_requirements( (array) $release->requires );
+ return empty( $requires );
+}
+
+/**
+ * Get the installed version of a package.
+ *
+ * @param string $id DID of the package to check.
+ * @param string $type Type of the package (e.g. 'plugin', 'theme').
+ *
+ * @return string|null The installed version, or null if not installed.
+ */
+function get_installed_version( string $id, string $type ) {
+ $type .= 's';
+ $packages = Updater\get_packages();
+
+ if ( empty( $packages[ $type ][ $id ] ) ) {
+ // Not installed.
+ return null;
+ }
+
+ return get_file_data( $packages[ $type ][ $id ], [ 'Version' => 'Version' ] )['Version'];
+}
+
+/**
+ * Get icons.
+ *
+ * @param array $icons Array of icon data.
+ *
+ * @return array
+ */
+function get_icons( $icons ) : array {
+ if ( empty( $icons ) ) {
+ return [];
+ }
+
+ $icons_arr = [];
+ $regular = array_find( $icons, fn ( $icon ) => $icon->width === 772 && $icon->height === 250 );
+ $high_res = array_find( $icons, fn ( $icon ) => $icon->width === 1544 && $icon->height === 500 );
+ $svg = array_find( $icons, fn ( $icon ) => str_contains( $icon->{'content-type'}, 'svg+xml' ) );
+
+ if ( empty( $regular ) && empty( $high_res ) && empty( $svg ) ) {
+ return [];
+ }
+
+ $icons_arr['1x'] = $regular->url ?? '';
+ $icons_arr['2x'] = $high_res->url ?? '';
+ if ( str_contains( $svg->url, 's.w.org/plugins' ) ) {
+ $icons_arr['default'] = $svg->url;
+ } else {
+ $icons_arr['svg'] = $svg->url ?? '';
+ }
+
+ return $icons_arr;
+}
+
+/**
+ * Get banners.
+ *
+ * @param array $banners Array of banner data.
+ *
+ * @return array
+ */
+function get_banners( $banners ) : array {
+ if ( empty( $banners ) ) {
+ return [];
+ }
+
+ $banners_arr = [];
+ $regular = array_find( $banners, fn ( $banner ) => $banner->width === 772 && $banner->height === 250 );
+ $high_res = array_find( $banners, fn ( $banner ) => $banner->width === 1544 && $banner->height === 500 );
+
+ if ( empty( $regular ) && empty( $high_res ) ) {
+ return [];
+ }
+
+ $banners_arr['low'] = $regular->url;
+ $banners_arr['high'] = $high_res->url;
+
+ return $banners_arr;
+}
+
+/**
+ * Get hashed file name from MetadataDocument.
+ *
+ * @param MetadataDocument $metadata MetadataDocument.
+ *
+ * @return string
+ */
+function get_hashed_filename( $metadata ) : string {
+ $filename = $metadata->filename;
+ $type = str_replace( 'wp-', '', $metadata->type );
+ $did_hash = '-' . get_did_hash( $metadata->id );
+
+ list( $slug, $file ) = explode( '/', $filename, 2 );
+ if ( 'plugin' === $type ) {
+ if ( ! str_contains( $slug, $did_hash ) ) {
+ $slug .= $did_hash;
+ }
+ $filename = $slug . '/' . $file;
+ } else {
+ $filename = $slug . $did_hash;
+ }
+
+ return $filename;
+}
+
+/**
+ * Get update data for use with transient and API responses.
+ *
+ * @param string $did DID.
+ * @return array|WP_Error
+ */
+function get_update_data( $did ) {
+ $metadata = fetch_package_metadata( $did );
+ if ( is_wp_error( $metadata ) ) {
+ return $metadata;
+ }
+
+ $release = get_latest_release_from_did( $did );
+ if ( is_wp_error( $release ) ) {
+ return $release;
+ }
+
+ $required_versions = version_requirements( $release );
+ $filename = get_hashed_filename( $metadata );
+ $type = str_replace( 'wp-', '', $metadata->type );
+
+ $response = [
+ 'name' => $metadata->name,
+ 'author' => $metadata->authors[0]->name,
+ 'author_uri' => $metadata->authors[0]->url,
+ 'slug' => $metadata->slug . '-' . get_did_hash( $did ),
+ $type => $filename,
+ 'file' => $filename,
+ 'url' => $metadata->url ?? $metadata->slug,
+ 'sections' => (array) $metadata->sections,
+ 'icons' => isset( $release->artifacts->icon ) ? get_icons( $release->artifacts->icon ) : [],
+ 'banners' => isset( $release->artifacts->banner ) ? get_banners( $release->artifacts->banner ) : [],
+ 'update-supported' => true,
+ 'requires' => $required_versions['requires_wp'],
+ 'requires_php' => $required_versions['requires_php'],
+ 'new_version' => $release->version,
+ 'version' => $release->version,
+ 'remote_version' => $release->version,
+ 'package' => $release->artifacts->package[0]->url,
+ 'download_link' => $release->artifacts->package[0]->url,
+ 'tested' => $required_versions['tested_to'],
+ 'external' => 'xxx',
+ ];
+ if ( 'theme' === $type ) {
+ $response['theme_uri'] = $response['url'];
+ }
+
+ return $response;
+}
+
+/**
+ * Send upgrader_pre_download filter to hook `upgrader_source_selection` during AJAX
+ * and send to `maybe_add_accept_header()`.
+ *
+ * @param bool $false Whether to bail without returning the package.
+ * Default false.
+ * @return bool
+ */
+function upgrader_pre_download( $false ) : bool {
+ add_filter( 'http_request_args', 'FAIR\\Packages\\maybe_add_accept_header', 20, 2 );
+ add_filter( 'upgrader_source_selection', __NAMESPACE__ . '\\rename_source_selection', 10, 3 );
+ return $false;
+}
+
+/**
+ * Renames a package's directory when it doesn't match the slug.
+ *
+ * This is commonly required for packages from Git hosts.
+ *
+ * @param string $source Path of $source.
+ * @param string $remote_source Path of $remote_source.
+ * @param WP_Upgrader $upgrader An Upgrader object.
+ *
+ * @return string|WP_Error
+ */
+function rename_source_selection( string $source, string $remote_source, WP_Upgrader $upgrader ) {
+ global $wp_filesystem;
+
+ $did = wp_cache_get( Admin\ACTION_INSTALL_DID );
+
+ if ( ! $did ) {
+ return $source;
+ }
+
+ $metadata = fetch_package_metadata( $did );
+ if ( is_wp_error( $metadata ) ) {
+ return $metadata;
+ }
+
+ // Sanity check.
+ if ( $upgrader->new_plugin_data['Name'] !== $metadata->name ) {
+ return $source;
+ }
+
+ if ( str_contains( $source, get_did_hash( $did ) ) && basename( $source ) === $metadata->slug ) {
+ return $source;
+ }
+
+ $new_source = trailingslashit( $remote_source ) . $metadata->slug . '-' . get_did_hash( $did );
+
+ if ( trailingslashit( strtolower( $source ) ) !== trailingslashit( strtolower( $new_source ) ) ) {
+ $wp_filesystem->move( $source, $new_source, true );
+ }
+
+ return trailingslashit( $new_source );
+}
+
+/**
+ * Add FAIR ReleaseDocument data to cache.
+ *
+ * @param string $did DID.
+ * @return void
+ */
+function add_package_to_release_cache( string $did ) : void {
+ if ( empty( $did ) ) {
+ return;
+ }
+ $releases = wp_cache_get( RELEASE_PACKAGES_CACHE_KEY ) ?: [];
+ $releases[ $did ] = get_latest_release_from_did( $did );
+ wp_cache_set( RELEASE_PACKAGES_CACHE_KEY, $releases );
+}
+
+/**
+ * Maybe add accept header for release asset package binary.
+ *
+ * ReleaseDocument artifact package content-type will be application/octet-stream.
+ * Only for GitHub release assets.
+ *
+ * @param array $args Array of http args.
+ * @param string $url Download URL.
+ *
+ * @return array
+ */
+function maybe_add_accept_header( $args, $url ) : array {
+ $releases = wp_cache_get( RELEASE_PACKAGES_CACHE_KEY ) ?: [];
+
+ if ( ! str_contains( $url, 'api.github.com' ) ) {
+ return $args;
+ }
+
+ foreach ( $releases as $release ) {
+ if ( $url === $release->artifacts->package[0]->url ) {
+ $content_type = $release->artifacts->package[0]->{'content-type'};
+ if ( $content_type === 'application/octet-stream' ) {
+ $args = array_merge( $args, [ 'headers' => [ 'Accept' => $content_type ] ] );
+ break;
+ }
+ }
+ }
+
+ return $args;
+}
+
+// phpcs:enable
diff --git a/inc/updater/class-updater.php b/inc/updater/class-updater.php
new file mode 100644
index 0000000..007d852
--- /dev/null
+++ b/inc/updater/class-updater.php
@@ -0,0 +1,358 @@
+did = $did;
+ $this->filepath = $filepath;
+ $this->local_version = get_file_data( $filepath, [ 'Version' => 'Version' ] )['Version'];
+ }
+
+ /**
+ * Get API data.
+ *
+ * @global string $pagenow Current page.
+ * @return void|WP_Error
+ */
+ public function run() {
+ global $pagenow;
+
+ // Needed for mu-plugin.
+ if ( ! isset( $pagenow ) ) {
+ // phpcs:ignore HM.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.WP.DeprecatedFunctions.sanitize_urlFound
+ $php_self = isset( $_SERVER['PHP_SELF'] ) ? sanitize_url( wp_unslash( $_SERVER['PHP_SELF'] ) ) : null;
+ if ( null !== $php_self ) {
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $pagenow = basename( $php_self );
+ }
+ }
+
+ // Only run on the following pages.
+ $pages = [ 'update-core.php', 'update.php', 'plugins.php', 'themes.php' ];
+ $view_details = [ 'plugin-install.php', 'theme-install.php' ];
+ $autoupdate_pages = [ 'admin-ajax.php', 'index.php', 'wp-cron.php' ];
+ if ( ! in_array( $pagenow, array_merge( $pages, $view_details, $autoupdate_pages ), true ) ) {
+ return;
+ }
+
+ $this->metadata = Packages\fetch_package_metadata( $this->did );
+ if ( is_wp_error( $this->metadata ) ) {
+ return $this->metadata;
+ }
+ $this->release = Packages\get_latest_release_from_did( $this->did );
+ if ( is_wp_error( $this->release ) ) {
+ return $this->release;
+ }
+ $this->type = str_replace( 'wp-', '', $this->metadata->type );
+
+ $this->load_hooks();
+ }
+
+ /**
+ * Load hooks.
+ *
+ * @return void
+ */
+ public function load_hooks() {
+ add_filter( 'upgrader_source_selection', [ $this, 'upgrader_source_selection' ], 10, 4 );
+ add_filter( "{$this->type}s_api", [ $this, 'repo_api_details' ], 99, 3 );
+ add_filter( "site_transient_update_{$this->type}s", [ $this, 'update_site_transient' ], 20, 1 );
+ if ( ! is_multisite() ) {
+ add_filter( 'wp_prepare_themes_for_js', [ $this, 'customize_theme_update_html' ] );
+ }
+
+ Packages\add_package_to_release_cache( $this->did );
+ }
+
+ /**
+ * Correctly rename dependency for activation.
+ *
+ * @param string $source Path of $source.
+ * @param string $remote_source Path of $remote_source.
+ * @param WP_Upgrader $upgrader An Upgrader object.
+ * @param array $hook_extra Array of hook data.
+ *
+ * @throws TypeError If the type of $upgrader is not correct.
+ *
+ * @return string|WP_Error
+ */
+ public function upgrader_source_selection( string $source, string $remote_source, WP_Upgrader $upgrader, $hook_extra = null ) {
+ global $wp_filesystem;
+
+ $new_source = $source;
+
+ // Exit if installing.
+ if ( isset( $hook_extra['action'] ) && 'install' === $hook_extra['action'] ) {
+ return $source;
+ }
+
+ if ( ! $upgrader instanceof Plugin_Upgrader && ! $upgrader instanceof Theme_Upgrader ) {
+ throw new TypeError( __METHOD__ . '(): Argument #3 ($upgrader) must be of type Plugin_Upgrader|Theme_Upgrader, ' . esc_attr( gettype( $upgrader ) ) . ' given.' );
+ }
+
+ // Rename plugins.
+ if ( $upgrader instanceof Plugin_Upgrader ) {
+ if ( isset( $hook_extra['plugin'] ) ) {
+ $slug = dirname( $hook_extra['plugin'] );
+ $new_source = trailingslashit( $remote_source ) . $slug;
+ }
+ }
+
+ // Rename themes.
+ if ( $upgrader instanceof Theme_Upgrader ) {
+ if ( isset( $hook_extra['theme'] ) ) {
+ $slug = $hook_extra['theme'];
+ $new_source = trailingslashit( $remote_source ) . $slug;
+ }
+ }
+
+ if ( basename( $source ) === $slug ) {
+ return $source;
+ }
+
+ if ( trailingslashit( strtolower( $source ) ) !== trailingslashit( strtolower( $new_source ) ) ) {
+ $wp_filesystem->move( $source, $new_source, true );
+ }
+
+ return trailingslashit( $new_source );
+ }
+
+ /**
+ * Put changelog in plugins_api, return WP.org data as appropriate
+ *
+ * @param bool $result Default false.
+ * @param string $action The type of information being requested from the Plugin Installation API.
+ * @param stdClass $response Repo API arguments.
+ *
+ * @return stdClass|bool
+ */
+ public function repo_api_details( $result, string $action, stdClass $response ) {
+ if ( "{$this->type}_information" !== $action ) {
+ return $result;
+ }
+
+ // Exit if not our repo.
+ $slug_arr = [ $this->metadata->slug, $this->metadata->slug . '-' . Packages\get_did_hash( $this->did ) ];
+ if ( ! in_array( $response->slug, $slug_arr, true ) ) {
+ return $result;
+ }
+
+ return (object) Packages\get_update_data( $this->did );
+ }
+
+ /**
+ * Hook into site_transient_update_{plugins|themes} to update from GitHub.
+ *
+ * @param stdClass $transient Plugin|Theme update transient.
+ *
+ * @return stdClass
+ */
+ public function update_site_transient( $transient ) {
+ // needed to fix PHP 7.4 warning.
+ if ( ! is_object( $transient ) ) {
+ $transient = new stdClass();
+ }
+
+ $rel_path = plugin_basename( $this->filepath );
+ $rel_path = 'theme' === $this->type ? dirname( $rel_path ) : $rel_path;
+ $response = Packages\get_update_data( $this->did );
+ if ( is_wp_error( $response ) ) {
+ return $transient;
+ }
+ $response = 'plugin' === $this->type ? (object) $response : $response;
+ $is_compatible = Packages\check_requirements( $this->release );
+
+ if ( $is_compatible && version_compare( $this->release->version, $this->local_version, '>' ) ) {
+ $transient->response[ $rel_path ] = $response;
+ } else {
+ // Add repo without update to $transient->no_update for 'View details' link.
+ $transient->no_update[ $rel_path ] = $response;
+ }
+
+ return $transient;
+ }
+
+ /**
+ * Call theme messaging for single site installation.
+ *
+ * @author Seth Carstens
+ *
+ * @param array $prepared_themes Array of prepared themes.
+ *
+ * @return array
+ */
+ public function customize_theme_update_html( $prepared_themes ) {
+ $theme = $this->metadata;
+
+ if ( 'theme' !== $this->type ) {
+ return $prepared_themes;
+ }
+
+ if ( ! empty( $prepared_themes[ $theme->slug ]['hasUpdate'] ) ) {
+ $prepared_themes[ $theme->slug ]['update'] = $this->append_theme_actions_content( $theme );
+ } else {
+ $prepared_themes[ $theme->slug ]['description'] .= $this->append_theme_actions_content( $theme );
+ }
+
+ return $prepared_themes;
+ }
+
+ /**
+ * Create theme update messaging for single site installation.
+ *
+ * @author Seth Carstens
+ *
+ * @access protected
+ * @codeCoverageIgnore
+ *
+ * @param stdClass $theme Theme object.
+ *
+ * @return string (content buffer)
+ */
+ protected function append_theme_actions_content( $theme ) {
+ $details_url = esc_attr(
+ add_query_arg(
+ [
+ 'tab' => 'theme-information',
+ 'theme' => $theme->slug,
+ 'TB_iframe' => 'true',
+ 'width' => 270,
+ 'height' => 400,
+ ],
+ self_admin_url( 'theme-install.php' )
+ )
+ );
+ $nonced_update_url = wp_nonce_url(
+ esc_attr(
+ add_query_arg(
+ [
+ 'action' => 'upgrade-theme',
+ 'theme' => rawurlencode( $theme->slug ),
+ ],
+ self_admin_url( 'update.php' )
+ )
+ ),
+ 'upgrade-theme_' . $theme->slug
+ );
+
+ $current = get_site_transient( 'update_themes' );
+
+ /**
+ * Display theme update links.
+ */
+ ob_start();
+ if ( isset( $current->response[ $theme->slug ] ) ) {
+ ?>
+
+
+ name )
+ );
+ printf(
+ ' ',
+ esc_url( $details_url ),
+ esc_attr( $theme->name )
+ );
+ if ( ! empty( $current->response[ $theme->slug ]['package'] ) ) {
+ printf(
+ /* translators: 1: opening anchor with version number, 2: closing anchor tag, 3: opening anchor with update URL */
+ esc_html__( 'View version %1$s details%2$s or %3$supdate now%2$s.', 'fair' ),
+ $theme->remote_version = isset( $theme->remote_version ) ? esc_attr( $theme->remote_version ) : null,
+ '',
+ sprintf(
+ /* translators: %s: theme name */
+ '',
+ esc_attr( $theme->name )
+ )
+ );
+ } else {
+ printf(
+ /* translators: 1: opening anchor with version number, 2: closing anchor tag, 3: opening anchor with update URL */
+ esc_html__( 'View version %1$s details%2$s.', 'fair' ),
+ $theme->remote_version = isset( $theme->remote_version ) ? esc_attr( $theme->remote_version ) : null,
+ ''
+ );
+ echo(
+ '' . esc_html__( 'Automatic update is unavailable for this theme.', 'fair' ) . '
'
+ );
+ }
+ ?>
+
+
+ $plugin ) {
- if ( empty( $plugin['UpdateURI'] ) ) {
- continue;
- }
$plugin_id = get_file_data( $plugin_path . $file, [ 'PluginID' => 'Plugin ID' ] )['PluginID'];
-
if ( ! empty( $plugin_id ) ) {
- $packages['plugins'][] = $plugin_path . $file;
+ $packages['plugins'][ $plugin_id ] = $plugin_path . $file;
}
}
$theme_path = WP_CONTENT_DIR . '/themes/';
$themes = wp_get_themes();
foreach ( $themes as $file => $theme ) {
- if ( empty( $theme->get( 'UpdateURI' ) ) ) {
- continue;
- }
$theme_id = get_file_data( $theme_path . $file . '/style.css', [ 'ThemeID' => 'Theme ID' ] )['ThemeID'];
-
if ( ! empty( $theme_id ) ) {
- $packages['themes'][] = $theme_path . $file . '/style.css';
+ $packages['themes'][ $theme_id ] = $theme_path . $file . '/style.css';
}
}
@@ -62,7 +51,7 @@ function get_packages() {
}
/**
- * Run Git Updater Lite for potential packages.
+ * Run FAIR\Updater\Updater for potential packages.
*
* @return void
*/
@@ -70,8 +59,8 @@ function run() {
$packages = get_packages();
$plugins = $packages['plugins'] ?? [];
$themes = $packages['themes'] ?? [];
- $packages = array_merge( $plugins, $themes);
- foreach ( $packages as $package ) {
- ( new Git_Updater\Lite( $package ) )->run();
+ $packages = array_merge( $plugins, $themes );
+ foreach ( $packages as $did => $filepath ) {
+ ( new Updater( $did, $filepath ) )->run();
}
}
diff --git a/plugin.php b/plugin.php
index 3305724..428cc56 100644
--- a/plugin.php
+++ b/plugin.php
@@ -29,6 +29,9 @@ require_once __DIR__ . '/inc/default-repo/namespace.php';
require_once __DIR__ . '/inc/disable-openverse/namespace.php';
require_once __DIR__ . '/inc/icons/namespace.php';
require_once __DIR__ . '/inc/importers/namespace.php';
+require_once __DIR__ . '/inc/packages/namespace.php';
+require_once __DIR__ . '/inc/packages/admin/namespace.php';
+require_once __DIR__ . '/inc/packages/admin/info.php';
require_once __DIR__ . '/inc/pings/namespace.php';
require_once __DIR__ . '/inc/salts/namespace.php';
require_once __DIR__ . '/inc/settings/namespace.php';