diff --git a/.gitignore b/.gitignore index 25871d043..d6cdfd5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules build/ +composer.lock diff --git a/pwa-wp.php b/pwa-wp.php index 90cf5bcd8..39e53b247 100644 --- a/pwa-wp.php +++ b/pwa-wp.php @@ -26,22 +26,22 @@ define( 'PWAWP_PLUGIN_FILE', __FILE__ ); define( 'PWAWP_PLUGIN_DIR', dirname( __FILE__ ) ); -pwawp_init(); +/** WP_Web_App_Manifest Class */ +require PWAWP_PLUGIN_DIR . '/wp-includes/class-wp-web-app-manifest.php'; -/** - * Loads and instantiates the classes. - */ -function pwawp_init() { - $classes = array( - 'web-app-manifest', - 'https-detection', - ); - foreach ( $classes as $class ) { - require PWAWP_PLUGIN_DIR . "/php/class-wp-{$class}.php"; - } +/** WP_HTTPS_Detection Class */ +require PWAWP_PLUGIN_DIR . '/wp-includes/class-wp-https-detection.php'; + +/** WP_Service_Workers Class */ +require PWAWP_PLUGIN_DIR . '/wp-includes/class-wp-service-workers.php'; + +/** WordPress Service Worker Functions */ +require PWAWP_PLUGIN_DIR . '/wp-includes/service-workers.php'; + +/** Amend default filters */ +require PWAWP_PLUGIN_DIR . '/wp-includes/default-filters.php'; - $wp_web_app_manifest = new WP_Web_App_Manifest(); - $wp_web_app_manifest->init(); - $wp_https_detection = new WP_HTTPS_Detection(); - $wp_https_detection->init(); -} +$wp_web_app_manifest = new WP_Web_App_Manifest(); +$wp_web_app_manifest->init(); +$wp_https_detection = new WP_HTTPS_Detection(); +$wp_https_detection->init(); diff --git a/tests/test-class-wp-service-workers.php b/tests/test-class-wp-service-workers.php new file mode 100644 index 000000000..7ac591ae6 --- /dev/null +++ b/tests/test-class-wp-service-workers.php @@ -0,0 +1,59 @@ +instance = new WP_Service_Workers(); + } + + /** + * Test class constructor. + * + * @covers WP_Service_Workers::__construct() + */ + public function test_construct() { + $service_workers = new WP_Service_Workers(); + $this->assertEquals( 'WP_Service_Workers', get_class( $service_workers ) ); + } + + /** + * Test adding new service worker. + * + * @covers WP_Service_Workers::add() + */ + public function test_register() { + $this->instance->register( 'foo', '/test-sw.js', array( 'bar' ) ); + + $default_scope = site_url( '/', 'relative' ); + + $this->assertTrue( in_array( $default_scope, $this->instance->get_scopes(), true ) ); + $this->assertTrue( isset( $this->instance->registered['foo'] ) ); + + $registered_sw = $this->instance->registered['foo']; + + $this->assertEquals( '/test-sw.js', $registered_sw->src ); + $this->assertTrue( in_array( $default_scope, $registered_sw->args['scopes'], true ) ); + $this->assertEquals( array( 'bar' ), $registered_sw->deps ); + } +} diff --git a/php/class-wp-https-detection.php b/wp-includes/class-wp-https-detection.php similarity index 100% rename from php/class-wp-https-detection.php rename to wp-includes/class-wp-https-detection.php diff --git a/wp-includes/class-wp-service-workers.php b/wp-includes/class-wp-service-workers.php new file mode 100644 index 000000000..72eb5580f --- /dev/null +++ b/wp-includes/class-wp-service-workers.php @@ -0,0 +1,205 @@ +get_scopes(), true ) ) { + status_header( 404 ); + return; + } + + $scope_items = array(); + + // Get handles from the relevant scope only. + foreach ( $this->registered as $handle => $item ) { + if ( in_array( $scope, $item->args['scopes'], true ) ) { + $scope_items[] = $handle; + } + } + + $this->output = ''; + $this->do_items( $scope_items ); + + $file_hash = md5( $this->output ); + header( "Etag: $file_hash" ); + + $etag_header = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( $_SERVER['HTTP_IF_NONE_MATCH'] ) : false; + if ( $file_hash === $etag_header ) { + status_header( 304 ); + return; + } + + echo $this->output; // phpcs:ignore WordPress.XSS.EscapeOutput, WordPress.Security.EscapeOutput + } + + /** + * Get all scopes. + * + * @return array Array of scopes. + */ + public function get_scopes() { + + $scopes = array(); + foreach ( $this->registered as $handle => $item ) { + $scopes = array_merge( $scopes, $item->args['scopes'] ); + } + return array_unique( $scopes ); + } + + /** + * Process one registered script. + * + * @param string $handle Handle. + * @param bool $group Group. + * @return void + */ + public function do_item( $handle, $group = false ) { + + $obj = $this->registered[ $handle ]; + + if ( is_callable( $obj->src ) ) { + $this->output .= call_user_func( $obj->src ) . "\n"; + } else { + $validated_path = $this->get_validated_file_path( $obj->src ); + if ( is_wp_error( $validated_path ) ) { + _doing_it_wrong( + __FUNCTION__, + /* translators: %s is file URL */ + sprintf( esc_html__( 'Service worker src is incorrect: %s', 'pwa' ), esc_html( $obj->src ) ), + '0.1' + ); + + /* translators: %s is file URL */ + $this->output .= "console.warn( '" . sprintf( esc_html__( 'Service worker src is incorrect: %s', 'pwa' ), esc_html( $obj->src ) ) . "' );\n"; + } else { + /* translators: %s is file URL */ + $this->output .= sprintf( esc_html( "\n/* Source: %s */\n" ), esc_url( $obj->src ) ); + + $this->output .= @file_get_contents( $this->get_validated_file_path( $obj->src ) ) . "\n"; // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents + } + } + } + + /** + * Remove URL scheme. + * + * @param string $schemed_url URL. + * @return string URL. + */ + public function remove_url_scheme( $schemed_url ) { + return preg_replace( '#^\w+:(?=//)#', '', $schemed_url ); + } + + /** + * Get validated path to file. + * + * @param string $url Relative path. + * @return null|string|WP_Error + */ + public function get_validated_file_path( $url ) { + if ( ! is_string( $url ) ) { + return new WP_Error( 'incorrect_path_format', esc_html__( 'URL has to be a string', 'pwa' ) ); + } + + $needs_base_url = ! preg_match( '|^(https?:)?//|', $url ); + $base_url = site_url(); + + if ( $needs_base_url ) { + $url = $base_url . $url; + } + + // Strip URL scheme, query, and fragment. + $url = $this->remove_url_scheme( preg_replace( ':[\?#].*$:', '', $url ) ); + + $content_url = $this->remove_url_scheme( content_url( '/' ) ); + $allowed_host = wp_parse_url( $content_url, PHP_URL_HOST ); + + $url_host = wp_parse_url( $url, PHP_URL_HOST ); + + if ( $allowed_host !== $url_host ) { + /* translators: %s is file URL */ + return new WP_Error( 'external_file_url', sprintf( __( 'URL is located on an external domain: %s.', 'pwa' ), $url_host ) ); + } + + $file_path = null; + if ( 0 === strpos( $url, $content_url ) ) { + $file_path = WP_CONTENT_DIR . substr( $url, strlen( $content_url ) - 1 ); + } + + if ( ! $file_path || false !== strpos( '../', $file_path ) || 0 !== validate_file( $file_path ) || ! file_exists( $file_path ) ) { + /* translators: %s is file URL */ + return new WP_Error( 'file_path_not_found', sprintf( __( 'Unable to locate filesystem path for %s.', 'pwa' ), $url ) ); + } + + return $file_path; + } +} diff --git a/php/class-wp-web-app-manifest.php b/wp-includes/class-wp-web-app-manifest.php similarity index 100% rename from php/class-wp-web-app-manifest.php rename to wp-includes/class-wp-web-app-manifest.php diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php new file mode 100644 index 000000000..18417d143 --- /dev/null +++ b/wp-includes/default-filters.php @@ -0,0 +1,14 @@ +register( $handle, $src, $deps, $scopes ); + + return $registered; +} + +/** + * Get service worker URL by scope. + * + * @todo Use home_url() instead? See get_rest_url(). + * + * @param string $scope Scope, for example 'wp-admin'. Defaults to '/'. + * @return string Service Worker URL. + */ +function wp_get_service_worker_url( $scope = null ) { + + if ( ! $scope ) { + $scope = site_url( '/', 'relative' ); + } + + return add_query_arg( + array( 'wp_service_worker' => $scope ), + site_url( '/', 'https' ) + ); +} + +/** + * Print service workers' scripts. + */ +function wp_print_service_workers() { + + $scopes = wp_service_workers()->get_scopes(); + if ( empty( $scopes ) ) { + return; + } + ?> + + query_vars['wp_service_worker'] ) ) { + wp_service_workers()->serve_request( $GLOBALS['wp']->query_vars['wp_service_worker'] ); + exit; + } +}