200 ) { throw new \RuntimeException( 'cdn_address' ); } $host = strtolower( (string) $p['host'] ); if ( ! preg_match( '/^(?=.{1,190}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/D', $host ) ) { throw new \RuntimeException( 'cdn_address' ); } foreach ( array( home_url( '/' ), site_url( '/' ), wp_get_upload_dir()['baseurl'] ) as $own ) { if ( strtolower( (string) wp_parse_url( $own, PHP_URL_HOST ) ) === $host ) { throw new \RuntimeException( 'cdn_same_host' ); } } $path = isset( $p['path'] ) ? rtrim( (string) $p['path'], '/' ) : ''; if ( '' !== $path && ! preg_match( '~^(/[A-Za-z0-9._-]+){1,4}$~D', $path ) ) { throw new \RuntimeException( 'cdn_address' ); } return 'https://' . $host . ( isset( $p['port'] ) && 443 !== (int) $p['port'] ? ':' . (int) $p['port'] : '' ) . $path; } /** * The CDN address of a file in the uploads folder: the CDN mirrors this site's paths. * * @param string $cdn The CDN address. * @param string $path The file's path on this site. * @return string The file's CDN address. */ public static function address( string $cdn, string $path ): string { return rtrim( $cdn, '/' ) . '/' . ltrim( $path, '/' ); } /** * Check: a new test picture in Image Manager's own folder, asked for through the CDN, must come back byte for byte. * The test picture is deleted afterwards. Returns the saved state. * * @param string $cdn The CDN address. * @return array The check: address, time, result and reason. */ public static function check( string $cdn ): array { $state = array( 'url' => $cdn, 'checked' => time(), 'ok' => false, 'reason' => '', ); if ( self::jetpack() ) { $state['reason'] = 'cdn_jetpack'; return $state; } $token = bin2hex( random_bytes( 12 ) ); $name = 'cdn-check-' . $token . '.png'; $path = ''; try { $bytes = self::test_picture( $token ); $path = Settings::root( true ) . '/' . $name; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- A small generated test picture in Image Manager's own validated folder, deleted after the check. if ( Reviewed_Store::file_put_contents( $path, $bytes, LOCK_EX ) !== strlen( $bytes ) ) { $state['reason'] = 'cdn_storage'; return $state; } $local = wp_parse_url( trailingslashit( wp_get_upload_dir()['baseurl'] ) . 'affstackdev-image-manager/' . $name, PHP_URL_PATH ); $response = wp_safe_remote_get( self::address( $cdn, (string) $local ), array( 'timeout' => 15, 'redirection' => 2, 'limit_response_size' => 65536, 'cookies' => array(), 'headers' => array( 'Accept' => 'image/png', 'Cache-Control' => 'no-cache', ), 'user-agent' => 'AffStackDev-ImageManager/' . VERSION . ' cdn-check', 'sslverify' => true, 'reject_unsafe_urls' => true, ) ); if ( is_wp_error( $response ) ) { $state['reason'] = 'cdn_unreachable'; return $state; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { $state['reason'] = 'cdn_status'; $state['status'] = $code; return $state; } if ( ! hash_equals( hash( 'sha256', $bytes ), hash( 'sha256', (string) wp_remote_retrieve_body( $response ) ) ) ) { $state['reason'] = 'cdn_wrong_file'; return $state; } $state['ok'] = true; $state['reason'] = 'cdn_ok'; return $state; } catch ( \Throwable $e ) { $state['reason'] = 'cdn_storage'; return $state; } finally { if ( '' !== $path && is_file( $path ) && ! is_link( $path ) ) { Reviewed_Store::wp_delete_file( $path ); } } } /** * A one-pixel PNG whose text chunk carries the token, so every check asks for a file no cache has seen. * * @param string $token A new random token. * @return string The PNG's bytes. */ private static function test_picture( string $token ): string { $ihdr = pack( 'NNCCCCC', 1, 1, 8, 6, 0, 0, 0 ); return "\x89PNG\r\n\x1a\n" . ImageInput::png_chunk( 'IHDR', $ihdr ) . ImageInput::png_chunk( 'tEXt', "Comment\0AffStackDev Image Manager CDN check " . $token ) . ImageInput::png_chunk( 'IDAT', (string) gzcompress( "\0\0\0\0\0", 9 ) ) . ImageInput::png_chunk( 'IEND', '' ); } /** * Save the address and switch (Settings, CDN form). Switching on checks first; switching off never needs a check. * Returns the state after saving. * * @param string $url The CDN address. * @param bool $on True to switch the CDN on. * @return array The CDN state after saving. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function save( string $url, bool $on ): array { $held = Reviewed_Store::enter( 8 ); try { if ( ! current_user_can( 'manage_options' ) ) { throw new \RuntimeException( 'image_access_denied' ); } $cdn = self::clean_url( $url ); $s = Settings::fresh(); if ( $on && ! $s['public_policy'] ) { throw new \RuntimeException( 'Please confirm that these are public website images.' ); } if ( $on && '' === $cdn ) { throw new \RuntimeException( 'cdn_address' ); } if ( $on && self::jetpack() ) { throw new \RuntimeException( 'cdn_jetpack' ); } $state = $on ? self::check( $cdn ) : array( 'url' => $cdn, 'checked' => 0, 'ok' => false, 'reason' => '', ); $enabled = $on && ! empty( $state['ok'] ); Settings::patch( array( 'cdn' => $enabled, 'cdn_url' => $cdn, 'cdn_state' => $state, 'generation' => (int) $s['generation'] + 1, ) ); self::schedule( $enabled ); if ( $enabled !== (bool) $s['cdn'] ) { Activity::add( $enabled ? 'cdn_on' : 'cdn_off' ); } return $state; } finally { Reviewed_Store::leave( $held ); } } /** * Schedule the daily check while the CDN is on; clear it when off. * * @param bool $on Whether the CDN is on. */ public static function schedule( bool $on ): void { if ( ! $on ) { Reviewed_Store::wp_clear_scheduled_hook( self::HOOK ); return; } if ( ! wp_next_scheduled( self::HOOK ) ) { Reviewed_Store::wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::HOOK ); } } /** * The daily check. A CDN that no longer returns this site's file stops being used at once. * * @throws \RuntimeException What a step inside threw, passed on after cleaning up. */ public static function scheduled(): void { try { $held = Reviewed_Store::enter(); } catch ( \RuntimeException $e ) { if ( Reviewed_Store::busy( $e ) ) { return; } throw $e; } // Busy: tomorrow's run checks. try { $s = Settings::fresh(); if ( ! $s['cdn'] || '' === $s['cdn_url'] ) { self::schedule( false ); return; } $state = self::check( (string) $s['cdn_url'] ); if ( empty( $state['ok'] ) ) { $state['stopped'] = time(); Settings::patch( array( 'cdn' => false, 'cdn_state' => $state, 'generation' => (int) $s['generation'] + 1, ) ); self::schedule( false ); Activity::add( 'cdn_stopped' ); return; } Settings::patch( array( 'cdn_state' => $state ) ); } finally { Reviewed_Store::leave( $held ); } } /* ---- The page a visitor gets ---- */ /** * Rewrite a public page's picture addresses to the CDN, where delivery allows it: a pass on the page's whole HTML * through WordPress's template enhancement output buffer (Hooks::PAGE_HTML), last of Image Manager's passes. */ public static function start_buffer(): void { if ( ! self::active() || ! Delivery::allowed( 'cdn' ) ) { return; } $cdn = (string) Settings::get()['cdn_url']; add_filter( Hooks::PAGE_HTML, static function ( string $html ) use ( $cdn ): string { try { return self::rewrite( $html, $cdn ); } catch ( \Throwable $e ) { return $html; } // An address change never breaks a page: the site keeps serving its own. }, Hooks::PASS_CDN ); } /** * Every picture address in the uploads folder (same host, absolute, protocol-relative or root-relative) in a value. * * @param string $value An attribute or style value. * @param string $cdn The CDN address. * @return string The value with those addresses pointing to the CDN. */ public static function value( string $value, string $cdn ): string { $u = wp_parse_url( wp_get_upload_dir()['baseurl'] ); if ( ! is_array( $u ) || empty( $u['host'] ) ) { return $value; } $host = preg_quote( strtolower( (string) $u['host'] ), '~' ) . ( isset( $u['port'] ) ? ':' . (int) $u['port'] : '' ); $raw = rtrim( (string) ( $u['path'] ?? '' ), '/' ); $pattern = '~(?:(?:https?:)?//' . $host . '|(?<=^|[\s,("\'=]))(' . preg_quote( $raw, '~' ) . '/[^\s,"\'()<>?#]+\.(?:jpe?g|png|gif|webp|avif))(\?[^\s,"\'()<>#]*)?(?=$|[\s,"\'()<>#])~i'; $out = preg_replace_callback( $pattern, static function ( $m ) use ( $cdn, $raw ) { $relative = substr( $m[1], strlen( $raw ) + 1 ); foreach ( self::PRIVATE_FOLDERS as $folder ) { if ( str_starts_with( $relative, $folder ) ) { return $m[0]; } } if ( str_contains( $m[1], '..' ) ) { return $m[0]; } return self::address( $cdn, $m[1] ) . ( $m[2] ?? '' ); }, $value ); return is_string( $out ) ? $out : $value; } /** * Bounded attribute pass over the finished page. Only addresses change. * * @param string $html The page. * @param string $cdn The CDN address. * @return string The page with its picture addresses on the CDN. */ public static function rewrite( string $html, string $cdn ): string { if ( '' === $html || strlen( $html ) > 2097152 || ! class_exists( '\WP_HTML_Tag_Processor' ) ) { return $html; } foreach ( headers_list() as $header ) { if ( preg_match( '/^content-type:\s*([^;]+)/i', $header, $m ) && ! in_array( strtolower( trim( $m[1] ) ), array( 'text/html', 'application/xhtml+xml' ), true ) ) { return $html; } } $p = new \WP_HTML_Tag_Processor( $html ); if ( ! method_exists( $p, 'set_attribute' ) ) { return $html; } $n = 0; $changed = 0; while ( $n++ < 20000 && $p->next_tag() ) { $tag = $p->get_tag(); if ( in_array( $tag, array( 'SCRIPT', 'STYLE' ), true ) ) { continue; } foreach ( self::ATTRS as $attr ) { $value = $p->get_attribute( $attr ); if ( ! is_string( $value ) || '' === $value ) { continue; } if ( 'href' === $attr && ! in_array( $tag, array( 'A', 'LINK' ), true ) ) { continue; } if ( 'LINK' === $tag && 'href' === $attr && 'preload' !== strtolower( (string) $p->get_attribute( 'rel' ) ) ) { continue; } $new = self::value( $value, $cdn ); if ( $new !== $value ) { $p->set_attribute( $attr, $new ); ++$changed; } } } return $changed ? $p->get_updated_html() : $html; } /** * The words for a CDN check's reason. * * @param string $reason The reason code. * @param int $status The HTTP status the CDN answered with, when it is about one. * @return string The translated words. */ public static function human( string $reason, int $status = 0 ): string { $messages = array( 'cdn_ok' => __( 'Checked: the CDN fetched a new test picture from this site and returned it unchanged.', 'affstackdev-image-manager' ), 'cdn_unreachable' => __( 'The CDN address did not answer (or this server may not connect to it). Pictures come from your site.', 'affstackdev-image-manager' ), /* translators: %d: HTTP status code, such as 404. */ 'cdn_status' => sprintf( __( 'The CDN answered with status %d instead of the test picture. Check that the CDN pulls from this site (its origin is this site’s address). Pictures come from your site.', 'affstackdev-image-manager' ), $status ), 'cdn_wrong_file' => __( 'The CDN returned a different file than this site has. If it optimizes or converts pictures itself, switch that off for this site. Pictures come from your site.', 'affstackdev-image-manager' ), 'cdn_storage' => __( 'Image Manager could not write its test picture in the uploads folder. Check Tools for storage status.', 'affstackdev-image-manager' ), 'cdn_jetpack' => __( 'Jetpack’s image CDN is active. Use one CDN for pictures: switch Jetpack’s image CDN off to use your own.', 'affstackdev-image-manager' ), 'cdn_address' => __( 'Enter the CDN address as https://cdn.example.com (an optional path is allowed, nothing after it).', 'affstackdev-image-manager' ), 'cdn_same_host' => __( 'That is this site’s own address. Enter the CDN’s own address, such as https://example.b-cdn.net.', 'affstackdev-image-manager' ), ); return $messages[ $reason ] ?? __( 'The CDN check could not finish. Pictures come from your site.', 'affstackdev-image-manager' ); } }