├── .wp-env.json ├── .distignore ├── phpunit.xml ├── .editorconfig ├── includes ├── deprecated.php ├── class-autoloader.php ├── functions.php ├── class-admin.php ├── class-user.php ├── class-legacy.php ├── class-webfinger.php └── class-health-check.php ├── package.json ├── phpcs.xml ├── composer.json ├── LICENSE ├── templates └── profile-settings.php ├── webfinger.php ├── tests └── phpunit │ ├── bootstrap.php │ └── includes │ ├── Test_Functions.php │ ├── Test_Webfinger.php │ └── Test_User.php ├── bin └── install-wp-tests.sh └── readme.md /.wp-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "core": null, 3 | "plugins": [ 4 | "." 5 | ], 6 | "port": 8686, 7 | "testsPort": 8687, 8 | "config": { 9 | "WP_DEBUG": true, 10 | "WP_DEBUG_LOG": true, 11 | "WP_DEBUG_DISPLAY": true 12 | }, 13 | "env": { 14 | "tests": { 15 | "port": 8687 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.distignore: -------------------------------------------------------------------------------- 1 | /.wordpress-org 2 | /.git 3 | /.github 4 | /node_modules 5 | /bin 6 | /sass 7 | /vendor 8 | /tests 9 | /config 10 | .wp-env.json 11 | package.json 12 | package-lock.json 13 | composer.json 14 | composer.lock 15 | Gruntfile.js 16 | push.sh 17 | phpunit.xml 18 | phpcs.xml 19 | .travis.yml 20 | .distignore 21 | .gitignore 22 | .gitattributes 23 | docker-compose.yml 24 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ./tests/phpunit/includes/ 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | 12 | [*.php] 13 | indent_style = tab 14 | indent_size = 4 15 | 16 | [Gruntfile.js] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [readme.txt] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [package.json] 25 | indent_style = space 26 | indent_size = 2 27 | -------------------------------------------------------------------------------- /includes/deprecated.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | WordPress WebFinger Standards 4 | 5 | . 6 | .(git|github|vscode|idea|wordpress-org) 7 | *\.(inc|css|js|svg) 8 | */vendor/* 9 | */node_modules/* 10 | *.asset.php 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pfefferle/wordpress-webfinger", 3 | "description": "WebFinger for WordPress", 4 | "type": "wordpress-plugin", 5 | "license": "MIT", 6 | "authors": [{ 7 | "name": "Matthias Pfefferle", 8 | "homepage": "https://notiz.blog/" 9 | }], 10 | "require": { 11 | "php": ">=7.2", 12 | "composer/installers": "~2.2" 13 | }, 14 | "require-dev": { 15 | "phpcompatibility/php-compatibility": "*", 16 | "phpcompatibility/phpcompatibility-wp": "*", 17 | "squizlabs/php_codesniffer": "^3.7", 18 | "wp-coding-standards/wpcs": "^3.0", 19 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", 20 | "yoast/phpunit-polyfills": "^2.0", 21 | "phpunit/phpunit": "^8.5 || ^9.6" 22 | }, 23 | "extra": { 24 | "installer-name": "webfinger" 25 | }, 26 | "config": { 27 | "allow-plugins": { 28 | "composer/installers": true, 29 | "dealerdirect/phpcodesniffer-composer-installer": true 30 | } 31 | }, 32 | "scripts": { 33 | "lint": "phpcs", 34 | "lint:fix": "phpcbf", 35 | "test": "phpunit" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Matthias Pfefferle 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 | -------------------------------------------------------------------------------- /templates/profile-settings.php: -------------------------------------------------------------------------------- 1 | 9 |

10 |

11 | 12 | 13 | 14 | 17 | 26 | 27 | 28 | 31 | 41 | 42 | 43 |
15 | 16 | 18 |

19 | 20 | @ 21 |

22 |

23 | 24 |

25 |
29 | 30 | 32 |
    33 | ID ) as $resource ) { ?> 34 |
  • 35 | 36 | (verify) 37 |
  • 38 | 39 |
40 |
44 | -------------------------------------------------------------------------------- /webfinger.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 44 | $this->path = \rtrim( $path, '/' ) . '/'; 45 | } 46 | 47 | /** 48 | * Register the autoloader. 49 | * 50 | * @param string $prefix The namespace prefix. 51 | * @param string $path The path to the classes directory. 52 | */ 53 | public static function register_path( string $prefix, string $path ): void { 54 | $autoloader = new self( $prefix, $path ); 55 | \spl_autoload_register( array( $autoloader, 'load' ) ); 56 | } 57 | 58 | /** 59 | * Load a class file. 60 | * 61 | * @param string $class_name The fully-qualified class name. 62 | * 63 | * @return bool True if the file was loaded, false otherwise. 64 | */ 65 | public function load( string $class_name ): bool { 66 | // Check if the class is in our namespace. 67 | if ( 0 !== \strpos( $class_name, $this->prefix ) ) { 68 | return false; 69 | } 70 | 71 | // Remove the namespace prefix and convert to file path format. 72 | $relative_class = \substr( $class_name, \strlen( $this->prefix ) ); 73 | $relative_class = \strtolower( \str_replace( array( '\\', '_' ), array( '/', '-' ), $relative_class ) ); 74 | 75 | // Split into path and class name. 76 | $last_slash = \strrpos( $relative_class, '/' ); 77 | if ( false !== $last_slash ) { 78 | $sub_path = \substr( $relative_class, 0, $last_slash + 1 ); 79 | $class_file = \substr( $relative_class, $last_slash + 1 ); 80 | } else { 81 | $sub_path = ''; 82 | $class_file = $relative_class; 83 | } 84 | 85 | // Try each type prefix. 86 | foreach ( self::TYPE_PREFIXES as $type ) { 87 | $file = $this->path . $sub_path . $type . '-' . $class_file . '.php'; 88 | 89 | if ( \file_exists( $file ) ) { 90 | require_once $file; 91 | return true; 92 | } 93 | } 94 | 95 | return false; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /includes/functions.php: -------------------------------------------------------------------------------- 1 | wp_rewrite_rules(); 36 | 37 | // Not using rewrite rules, and 'author=N' method failed, so we're out of options. 38 | if ( empty( $rewrite ) ) { 39 | return 0; 40 | } 41 | 42 | // Generate rewrite rule for the author url. 43 | $author_rewrite = $wp_rewrite->get_author_permastruct(); 44 | $author_regexp = \str_replace( '%author%', '', $author_rewrite ); 45 | 46 | // Match the rewrite rule with the passed url. 47 | if ( \preg_match( '/https?:\/\/(.+)' . \preg_quote( $author_regexp, '/' ) . '([^\/]+)/i', $url, $match ) ) { 48 | $user = \get_user_by( 'slug', $match[2] ); 49 | if ( $user ) { 50 | return $user->ID; 51 | } 52 | } 53 | 54 | return 0; 55 | } 56 | endif; 57 | 58 | if ( ! function_exists( 'get_user_by_various' ) ) : 59 | /** 60 | * Convenience method to get user data by ID, username, object or from current user. 61 | * 62 | * @param mixed $id_or_name_or_object The username, ID or object. If not provided, the current user will be used. 63 | * 64 | * @return \WP_User|false WP_User on success, false on failure. 65 | * 66 | * @author Will Norris 67 | * 68 | * @see get_user_by_various() # DiSo OpenID-Plugin 69 | */ 70 | function get_user_by_various( $id_or_name_or_object = null ) { 71 | if ( null === $id_or_name_or_object ) { 72 | $user = \wp_get_current_user(); 73 | return $user->exists() ? $user : false; 74 | } 75 | 76 | if ( $id_or_name_or_object instanceof \WP_User ) { 77 | return $id_or_name_or_object; 78 | } 79 | 80 | if ( \is_numeric( $id_or_name_or_object ) ) { 81 | return \get_user_by( 'id', $id_or_name_or_object ); 82 | } 83 | 84 | return \get_user_by( 'login', $id_or_name_or_object ); 85 | } 86 | endif; 87 | 88 | /** 89 | * Build WebFinger endpoint. 90 | * 91 | * @return string The WebFinger URL. 92 | */ 93 | function get_webfinger_endpoint() { 94 | global $wp_rewrite; 95 | 96 | if ( $wp_rewrite->using_permalinks() ) { 97 | return \home_url( '/.well-known/webfinger' ); 98 | } 99 | 100 | return \add_query_arg( 'well-known', 'webfinger', \home_url( '/' ) ); 101 | } 102 | 103 | /** 104 | * Returns all WebFinger "resources". 105 | * 106 | * @param mixed $id_or_name_or_object The username, ID or object. 107 | * @param bool $with_protocol Whether to include the protocol prefix. 108 | * 109 | * @return string The user-resource. 110 | */ 111 | function get_webfinger_resource( $id_or_name_or_object, $with_protocol = true ) { 112 | return \Webfinger\User::get_resource( $id_or_name_or_object, $with_protocol ); 113 | } 114 | 115 | /** 116 | * Returns a WebFinger "username" (the part before the "@"). 117 | * 118 | * @param mixed $id_or_name_or_object The username, ID or object. 119 | * 120 | * @return string The username. 121 | */ 122 | function get_webfinger_username( $id_or_name_or_object ) { 123 | return \Webfinger\User::get_username( $id_or_name_or_object ); 124 | } 125 | 126 | /** 127 | * Check if a passed URI has the same domain as the blog. 128 | * 129 | * @param string $uri The URI to check. 130 | * 131 | * @return bool True if the URI has the same host as the blog, false otherwise. 132 | */ 133 | function is_same_host( $uri ) { 134 | $blog_host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 135 | 136 | // Check if $uri is a valid URL. 137 | if ( \filter_var( $uri, \FILTER_VALIDATE_URL ) ) { 138 | return \wp_parse_url( $uri, \PHP_URL_HOST ) === $blog_host; 139 | } elseif ( \str_contains( $uri, '@' ) ) { 140 | // Check if $uri is a valid E-Mail. 141 | $host = \substr( \strrchr( $uri, '@' ), 1 ); 142 | 143 | return $host === $blog_host; 144 | } 145 | 146 | return false; 147 | } 148 | -------------------------------------------------------------------------------- /includes/class-admin.php: -------------------------------------------------------------------------------- 1 | $user ) ); 57 | } 58 | 59 | /** 60 | * The save action. 61 | * 62 | * @param int $user_id The ID of the current user. 63 | * 64 | * @return bool|string Meta ID if the key didn't exist, true on successful update, false on failure. 65 | */ 66 | public static function update_user_meta( $user_id ) { 67 | // Check that the current user have the capability to edit the $user_id. 68 | if ( ! \current_user_can( 'edit_user', $user_id ) ) { 69 | return false; 70 | } 71 | 72 | // Verify nonce to prevent CSRF. 73 | $nonce = isset( $_POST['webfinger_profile_nonce'] ) ? \sanitize_text_field( \wp_unslash( $_POST['webfinger_profile_nonce'] ) ) : ''; 74 | if ( empty( $nonce ) || ! \wp_verify_nonce( $nonce, 'webfinger_profile_settings' ) ) { 75 | return false; 76 | } 77 | 78 | if ( ! isset( $_POST['webfinger_resource'] ) ) { 79 | return false; 80 | } 81 | if ( empty( $_POST['webfinger_resource'] ) ) { 82 | \delete_user_meta( $user_id, 'webfinger_resource' ); 83 | return false; 84 | } 85 | 86 | $webfinger = \sanitize_title( \wp_unslash( $_POST['webfinger_resource'] ) ); 87 | $valid = self::is_valid_webfinger_resource( $webfinger, $user_id ); 88 | 89 | if ( ! $valid ) { 90 | return false; 91 | } 92 | 93 | // Create/update user meta for the $user_id. 94 | \update_user_meta( 95 | $user_id, 96 | 'webfinger_resource', 97 | $webfinger 98 | ); 99 | 100 | return $webfinger; 101 | } 102 | 103 | /** 104 | * Check if an error should be shown. 105 | * 106 | * @param \WP_Error $errors WP_Error object (passed by reference). 107 | * @param bool $update Whether this is a user update. 108 | * @param \WP_User $user User object (passed by reference). 109 | * 110 | * @return \WP_Error Updated list of errors. 111 | */ 112 | public static function maybe_show_errors( $errors, $update, $user ) { 113 | // Verify nonce for CSRF protection. 114 | $nonce = isset( $_POST['webfinger_profile_nonce'] ) ? \sanitize_text_field( \wp_unslash( $_POST['webfinger_profile_nonce'] ) ) : ''; 115 | if ( empty( $nonce ) || ! \wp_verify_nonce( $nonce, 'webfinger_profile_settings' ) ) { 116 | return $errors; 117 | } 118 | if ( ! isset( $_POST['webfinger_resource'] ) ) { 119 | return $errors; 120 | } 121 | 122 | $webfinger_resource = \sanitize_text_field( \wp_unslash( $_POST['webfinger_resource'] ) ); 123 | $valid = self::is_valid_webfinger_resource( $webfinger_resource, $user->ID ); 124 | 125 | if ( ! $valid ) { 126 | $errors->add( 'webfinger_resource', \__( 'WebFinger resource is already in use by a different user', 'webfinger' ) ); 127 | } 128 | 129 | return $errors; 130 | } 131 | 132 | /** 133 | * Check if the WebFinger resource is valid. 134 | * 135 | * @param string $webfinger_resource The WebFinger resource. 136 | * @param int $user_id The user ID. 137 | * 138 | * @return bool True if valid, false otherwise. 139 | */ 140 | public static function is_valid_webfinger_resource( $webfinger_resource, $user_id ) { 141 | $webfinger = \sanitize_title( $webfinger_resource, true ); 142 | 143 | $args = array( 144 | 'meta_key' => 'webfinger_resource', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 145 | 'meta_value' => $webfinger, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 146 | 'meta_compare' => '=', 147 | 'exclude' => $user_id, 148 | ); 149 | 150 | // Check if already exists. 151 | $user_query = new \WP_User_Query( $args ); 152 | $results = $user_query->get_results(); 153 | 154 | return empty( $results ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/phpunit/includes/Test_Functions.php: -------------------------------------------------------------------------------- 1 | user->create( 26 | array( 27 | 'user_login' => 'functiontestuser', 28 | 'user_email' => 'functiontestuser@example.org', 29 | 'user_nicename' => 'functiontestuser', 30 | 'display_name' => 'Function Test User', 31 | ) 32 | ); 33 | } 34 | 35 | /** 36 | * Clean up after tests. 37 | */ 38 | public static function wpTearDownAfterClass() { 39 | if ( self::$user_id ) { 40 | \wp_delete_user( self::$user_id ); 41 | } 42 | } 43 | 44 | /** 45 | * Test get_webfinger_endpoint returns valid URL. 46 | * 47 | * @covers ::get_webfinger_endpoint 48 | */ 49 | public function test_get_webfinger_endpoint_returns_url() { 50 | $endpoint = \get_webfinger_endpoint(); 51 | 52 | $this->assertNotEmpty( $endpoint ); 53 | $this->assertStringContainsString( 'webfinger', $endpoint ); 54 | } 55 | 56 | /** 57 | * Test get_webfinger_resource returns resource for user. 58 | * 59 | * @covers ::get_webfinger_resource 60 | */ 61 | public function test_get_webfinger_resource_returns_resource() { 62 | $resource = \get_webfinger_resource( self::$user_id ); 63 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 64 | 65 | $this->assertStringStartsWith( 'acct:', $resource ); 66 | $this->assertStringContainsString( '@' . $host, $resource ); 67 | } 68 | 69 | /** 70 | * Test get_webfinger_resource without protocol. 71 | * 72 | * @covers ::get_webfinger_resource 73 | */ 74 | public function test_get_webfinger_resource_without_protocol() { 75 | $resource = \get_webfinger_resource( self::$user_id, false ); 76 | 77 | $this->assertStringNotContainsString( 'acct:', $resource ); 78 | } 79 | 80 | /** 81 | * Test get_webfinger_username returns username. 82 | * 83 | * @covers ::get_webfinger_username 84 | */ 85 | public function test_get_webfinger_username_returns_username() { 86 | $username = \get_webfinger_username( self::$user_id ); 87 | 88 | $this->assertEquals( 'functiontestuser', $username ); 89 | } 90 | 91 | /** 92 | * Test is_same_host returns true for same host URL. 93 | * 94 | * @covers ::is_same_host 95 | */ 96 | public function test_is_same_host_returns_true_for_same_host() { 97 | $result = \is_same_host( \home_url( '/test-path' ) ); 98 | 99 | $this->assertTrue( $result ); 100 | } 101 | 102 | /** 103 | * Test is_same_host returns false for different host URL. 104 | * 105 | * @covers ::is_same_host 106 | */ 107 | public function test_is_same_host_returns_false_for_different_host() { 108 | $result = \is_same_host( 'https://different-domain.com/test-path' ); 109 | 110 | $this->assertFalse( $result ); 111 | } 112 | 113 | /** 114 | * Test is_same_host returns true for same host email. 115 | * 116 | * @covers ::is_same_host 117 | */ 118 | public function test_is_same_host_returns_true_for_same_host_email() { 119 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 120 | $result = \is_same_host( 'user@' . $host ); 121 | 122 | $this->assertTrue( $result ); 123 | } 124 | 125 | /** 126 | * Test is_same_host returns false for different host email. 127 | * 128 | * @covers ::is_same_host 129 | */ 130 | public function test_is_same_host_returns_false_for_different_host_email() { 131 | $result = \is_same_host( 'user@different-domain.com' ); 132 | 133 | $this->assertFalse( $result ); 134 | } 135 | 136 | /** 137 | * Test url_to_authorid returns user ID for author URL. 138 | * 139 | * @covers ::url_to_authorid 140 | */ 141 | public function test_url_to_authorid_returns_user_id() { 142 | $author_url = \get_author_posts_url( self::$user_id ); 143 | $result = \url_to_authorid( $author_url ); 144 | 145 | $this->assertEquals( self::$user_id, $result ); 146 | } 147 | 148 | /** 149 | * Test url_to_authorid returns 0 for different host. 150 | * 151 | * @covers ::url_to_authorid 152 | */ 153 | public function test_url_to_authorid_returns_zero_for_different_host() { 154 | $result = \url_to_authorid( 'https://different-domain.com/author/test/' ); 155 | 156 | $this->assertEquals( 0, $result ); 157 | } 158 | 159 | /** 160 | * Test url_to_authorid with author query parameter. 161 | * 162 | * @covers ::url_to_authorid 163 | */ 164 | public function test_url_to_authorid_with_query_param() { 165 | $url = \add_query_arg( 'author', self::$user_id, \home_url() ); 166 | $result = \url_to_authorid( $url ); 167 | 168 | $this->assertEquals( self::$user_id, $result ); 169 | } 170 | 171 | /** 172 | * Test get_user_by_various with user ID. 173 | * 174 | * @covers ::get_user_by_various 175 | */ 176 | public function test_get_user_by_various_with_id() { 177 | $user = \get_user_by_various( self::$user_id ); 178 | 179 | $this->assertInstanceOf( \WP_User::class, $user ); 180 | $this->assertEquals( self::$user_id, $user->ID ); 181 | } 182 | 183 | /** 184 | * Test get_user_by_various with username. 185 | * 186 | * @covers ::get_user_by_various 187 | */ 188 | public function test_get_user_by_various_with_username() { 189 | $user = \get_user_by_various( 'functiontestuser' ); 190 | 191 | $this->assertInstanceOf( \WP_User::class, $user ); 192 | $this->assertEquals( self::$user_id, $user->ID ); 193 | } 194 | 195 | /** 196 | * Test get_user_by_various with user object. 197 | * 198 | * @covers ::get_user_by_various 199 | */ 200 | public function test_get_user_by_various_with_object() { 201 | $user_object = \get_user_by( 'id', self::$user_id ); 202 | $user = \get_user_by_various( $user_object ); 203 | 204 | $this->assertSame( $user_object, $user ); 205 | } 206 | 207 | /** 208 | * Test get_user_by_various returns false for invalid user. 209 | * 210 | * @covers ::get_user_by_various 211 | */ 212 | public function test_get_user_by_various_returns_false_for_invalid() { 213 | $user = \get_user_by_various( 'nonexistent_user_xyz' ); 214 | 215 | $this->assertFalse( $user ); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /includes/class-user.php: -------------------------------------------------------------------------------- 1 | get_results(); 65 | 66 | return ! empty( $results ) ? $results[0] : null; 67 | } 68 | 69 | /** 70 | * Get user query arguments based on scheme. 71 | * 72 | * @param string $scheme The URI scheme. 73 | * @param string $host The host/identifier part. 74 | * @param string $uri The full URI. 75 | * 76 | * @return array|null Query arguments or null if user found/invalid. 77 | */ 78 | private static function get_user_query_args( $scheme, $host, $uri ) { 79 | switch ( $scheme ) { 80 | case 'http': 81 | case 'https': 82 | $author_id = \url_to_authorid( $uri ); 83 | if ( $author_id ) { 84 | return array( 85 | 'search' => $author_id, 86 | 'search_columns' => array( 'ID' ), 87 | ); 88 | } 89 | return array( 90 | 'search' => $uri, 91 | 'search_columns' => array( 'user_url' ), 92 | ); 93 | 94 | case 'acct': 95 | $pos = \strrpos( $host, '@' ); 96 | if ( false === $pos ) { 97 | return null; 98 | } 99 | $id = \sanitize_title( \substr( $host, 0, $pos ) ); 100 | if ( ! $id ) { 101 | return null; 102 | } 103 | 104 | // First check for custom webfinger_resource meta. 105 | $meta_query = new \WP_User_Query( 106 | array( 107 | 'meta_key' => 'webfinger_resource', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 108 | 'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 109 | 'meta_compare' => '=', 110 | 'number' => 1, 111 | ) 112 | ); 113 | $results = $meta_query->get_results(); 114 | if ( ! empty( $results ) ) { 115 | // Return null to signal user was found (handled in caller). 116 | return array( 117 | 'include' => array( $results[0]->ID ), 118 | ); 119 | } 120 | 121 | return array( 122 | 'search' => $id, 123 | 'search_columns' => array( 'user_nicename', 'user_login' ), 124 | ); 125 | 126 | case 'mailto': 127 | $email = \sanitize_email( $host ); 128 | if ( ! $email ) { 129 | return null; 130 | } 131 | return array( 132 | 'search' => $email, 133 | 'search_columns' => array( 'user_email' ), 134 | ); 135 | 136 | default: 137 | return array(); 138 | } 139 | } 140 | 141 | /** 142 | * Returns a users default user specific part of the WebFinger resource. 143 | * 144 | * @param mixed $id_or_name_or_object The username, ID or object. 145 | * 146 | * @return string|null The username or null if not found. 147 | */ 148 | public static function get_username( $id_or_name_or_object ) { 149 | $user = \get_user_by_various( $id_or_name_or_object ); 150 | 151 | if ( ! $user ) { 152 | return null; 153 | } 154 | 155 | $custom_resource = \get_user_meta( $user->ID, 'webfinger_resource', true ); 156 | 157 | return $custom_resource ?: $user->user_login; 158 | } 159 | 160 | /** 161 | * Returns a users default WebFinger resource. 162 | * 163 | * @param mixed $id_or_name_or_object The username, ID or object. 164 | * @param bool $with_protocol Whether to include the protocol prefix. 165 | * 166 | * @return string|null The resource or null if not found. 167 | */ 168 | public static function get_resource( $id_or_name_or_object, $with_protocol = true ) { 169 | $user = \get_user_by_various( $id_or_name_or_object ); 170 | 171 | if ( ! $user ) { 172 | return \apply_filters( 'webfinger_user_resource', null, null ); 173 | } 174 | 175 | $username = self::get_username( $user ); 176 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 177 | $resource = $username . '@' . $host; 178 | 179 | if ( $with_protocol ) { 180 | $resource = 'acct:' . $resource; 181 | } 182 | 183 | return \apply_filters( 'webfinger_user_resource', $resource, $user ); 184 | } 185 | 186 | /** 187 | * Returns all WebFinger "resources". 188 | * 189 | * @param mixed $id_or_name_or_object The username, ID or object. 190 | * 191 | * @return array The array of resources. 192 | */ 193 | public static function get_resources( $id_or_name_or_object ) { 194 | $user = \get_user_by_various( $id_or_name_or_object ); 195 | 196 | if ( ! $user ) { 197 | return array(); 198 | } 199 | 200 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 201 | $resources = array( 202 | self::get_resource( $user ), 203 | \get_author_posts_url( $user->ID, $user->user_nicename ), 204 | ); 205 | 206 | // Add user_login as alias if different from custom resource. 207 | $custom_resource = \get_user_meta( $user->ID, 'webfinger_resource', true ); 208 | if ( $custom_resource && $custom_resource !== $user->user_login ) { 209 | $resources[] = 'acct:' . $user->user_login . '@' . $host; 210 | } 211 | 212 | if ( $user->user_email && is_same_host( $user->user_email ) ) { 213 | $resources[] = 'mailto:' . $user->user_email; 214 | } 215 | 216 | return \array_values( \array_unique( \apply_filters( 'webfinger_user_resources', $resources, $user ) ) ); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | SKIP_DB_CREATE=${6-false} 14 | 15 | TMPDIR=${TMPDIR-/tmp} 16 | TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") 17 | WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} 18 | WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} 19 | 20 | download() { 21 | if [ `which curl` ]; then 22 | curl -s "$1" > "$2"; 23 | elif [ `which wget` ]; then 24 | wget -nv -O "$2" "$1" 25 | fi 26 | } 27 | 28 | if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then 29 | WP_BRANCH=${WP_VERSION%\-*} 30 | WP_TESTS_TAG="branches/$WP_BRANCH" 31 | 32 | elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then 33 | WP_TESTS_TAG="branches/$WP_VERSION" 34 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then 35 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 36 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 37 | WP_TESTS_TAG="tags/${WP_VERSION%??}" 38 | else 39 | WP_TESTS_TAG="tags/$WP_VERSION" 40 | fi 41 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 42 | WP_TESTS_TAG="trunk" 43 | else 44 | # http serves a single offer, whereas https serves multiple. we only want one 45 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 46 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 47 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 48 | if [[ -z "$LATEST_VERSION" ]]; then 49 | echo "Latest WordPress version could not be found" 50 | exit 1 51 | fi 52 | WP_TESTS_TAG="tags/$LATEST_VERSION" 53 | fi 54 | set -ex 55 | 56 | install_wp() { 57 | 58 | if [ -d $WP_CORE_DIR ]; then 59 | return; 60 | fi 61 | 62 | mkdir -p $WP_CORE_DIR 63 | 64 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 65 | mkdir -p $TMPDIR/wordpress-nightly 66 | download https://wordpress.org/nightly-builds/wordpress-latest.zip $TMPDIR/wordpress-nightly/wordpress-nightly.zip 67 | unzip -q $TMPDIR/wordpress-nightly/wordpress-nightly.zip -d $TMPDIR/wordpress-nightly/ 68 | mv $TMPDIR/wordpress-nightly/wordpress/* $WP_CORE_DIR 69 | else 70 | if [ $WP_VERSION == 'latest' ]; then 71 | local ARCHIVE_NAME='latest' 72 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then 73 | # https serves multiple offers, whereas http serves single. 74 | download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json 75 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 76 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 77 | LATEST_VERSION=${WP_VERSION%??} 78 | else 79 | # otherwise, scan the releases and get the most up to date minor version of the major release 80 | local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` 81 | LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) 82 | fi 83 | if [[ -z "$LATEST_VERSION" ]]; then 84 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 85 | else 86 | local ARCHIVE_NAME="wordpress-$LATEST_VERSION" 87 | fi 88 | else 89 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 90 | fi 91 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz 92 | tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR 93 | fi 94 | 95 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 96 | } 97 | 98 | install_test_suite() { 99 | # portable in-place argument for both GNU sed and Mac OSX sed 100 | if [[ $(uname -s) == 'Darwin' ]]; then 101 | local ioption='-i.bak' 102 | else 103 | local ioption='-i' 104 | fi 105 | 106 | # set up testing suite if it doesn't yet exist 107 | if [ ! -d $WP_TESTS_DIR ]; then 108 | # set up testing suite 109 | mkdir -p $WP_TESTS_DIR 110 | svn co --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 111 | svn co --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 112 | fi 113 | 114 | if [ ! -f "$WP_TESTS_DIR"/wp-tests-config.php ]; then 115 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 116 | # remove all forward slashes in the end 117 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 118 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 119 | sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 120 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 121 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 122 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 123 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 124 | fi 125 | 126 | } 127 | 128 | recreate_db() { 129 | shopt -s nocasematch 130 | if [[ $1 =~ ^(y|yes)$ ]] 131 | then 132 | mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA 133 | create_db 134 | echo "Recreated the database ($DB_NAME)." 135 | else 136 | echo "Leaving the existing database ($DB_NAME) in place." 137 | fi 138 | shopt -u nocasematch 139 | } 140 | 141 | create_db() { 142 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 143 | } 144 | 145 | install_db() { 146 | 147 | if [ ${SKIP_DB_CREATE} = "true" ]; then 148 | return 0 149 | fi 150 | 151 | # parse DB_HOST for port or socket references 152 | local PARTS=(${DB_HOST//\:/ }) 153 | local DB_HOSTNAME=${PARTS[0]}; 154 | local DB_SOCK_OR_PORT=${PARTS[1]}; 155 | local EXTRA="" 156 | 157 | if ! [ -z $DB_HOSTNAME ] ; then 158 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 159 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 160 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 161 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 162 | elif ! [ -z $DB_HOSTNAME ] ; then 163 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 164 | fi 165 | fi 166 | 167 | # create database 168 | if [ $(mysql --user="$DB_USER" --password="$DB_PASS" --execute='show databases;' | grep ^$DB_NAME$) ] 169 | then 170 | echo "Reinstalling will delete the existing test database ($DB_NAME)" 171 | read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB 172 | recreate_db $DELETE_EXISTING_DB 173 | else 174 | create_db 175 | fi 176 | } 177 | 178 | install_wp 179 | install_test_suite 180 | install_db 181 | -------------------------------------------------------------------------------- /includes/class-legacy.php: -------------------------------------------------------------------------------- 1 | query_vars ) ) { 75 | $format = $wp->query_vars['format']; 76 | } 77 | 78 | if ( 79 | ! \in_array( 'application/xrd+xml', $accept, true ) && 80 | ! \in_array( 'application/xml+xrd', $accept, true ) && 81 | 'xrd' !== $format 82 | ) { 83 | return $webfinger; 84 | } 85 | 86 | \header( 'Content-Type: application/xrd+xml; charset=' . \get_bloginfo( 'charset' ), true ); 87 | 88 | echo '' . \PHP_EOL; 89 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- do_action returns null, XRD content is already escaped. 90 | echo '' . \PHP_EOL; 91 | 92 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- jrd_to_xrd returns escaped content. 93 | echo self::jrd_to_xrd( $webfinger ); 94 | // Add xml-only content. 95 | \do_action( 'webfinger_xrd' ); 96 | 97 | echo \PHP_EOL . ''; 98 | 99 | exit; 100 | } 101 | 102 | /** 103 | * Host-meta resource feature. 104 | * 105 | * @param string $format The format. 106 | * @param array $host_meta The host meta. 107 | * @param array $query The query. 108 | */ 109 | public static function render_host_meta( $format, $host_meta, $query ) { 110 | if ( ! \array_key_exists( 'resource', $query ) ) { 111 | return; 112 | } 113 | 114 | global $wp; 115 | 116 | // Filter WebFinger array. 117 | $webfinger = \apply_filters( 'webfinger_data', array(), $query['resource'] ); 118 | 119 | // Check if "user" exists. 120 | if ( empty( $webfinger ) ) { 121 | \status_header( 404 ); 122 | \header( 'Content-Type: text/plain; charset=' . \get_bloginfo( 'charset' ), true ); 123 | echo 'no data for resource "' . \esc_html( $query['resource'] ) . '" found'; 124 | exit; 125 | } 126 | 127 | if ( 'xrd' === $format ) { 128 | $wp->query_vars['format'] = 'xrd'; 129 | } 130 | 131 | \do_action( 'webfinger_render', $webfinger ); 132 | // Stop exactly here! 133 | exit; 134 | } 135 | 136 | /** 137 | * Add the host-meta information. 138 | * 139 | * @param array $host_meta The host meta array. 140 | * 141 | * @return array The modified host meta array. 142 | */ 143 | public static function host_meta_discovery( $host_meta ) { 144 | $host_meta['links'][] = array( 145 | 'rel' => 'lrdd', 146 | 'template' => \add_query_arg( 147 | array( 148 | 'resource' => '{uri}', 149 | 'format' => 'xrd', 150 | ), 151 | \get_webfinger_endpoint() 152 | ), 153 | 'type' => 'application/xrd+xml', 154 | ); 155 | $host_meta['links'][] = array( 156 | 'rel' => 'lrdd', 157 | 'template' => \add_query_arg( 'resource', '{uri}', \get_webfinger_endpoint() ), 158 | 'type' => 'application/jrd+xml', 159 | ); 160 | $host_meta['links'][] = array( 161 | 'rel' => 'lrdd', 162 | 'template' => \add_query_arg( 'resource', '{uri}', \get_webfinger_endpoint() ), 163 | 'type' => 'application/json', 164 | ); 165 | 166 | return $host_meta; 167 | } 168 | 169 | /** 170 | * Recursive helper to generate the xrd-xml from the jrd array. 171 | * 172 | * @param array $webfinger The webfinger data. 173 | * 174 | * @return string The XRD XML string. 175 | */ 176 | public static function jrd_to_xrd( $webfinger ) { 177 | $xrd = null; 178 | 179 | // Supported protocols. 180 | $protocols = \array_merge( 181 | array( 'aim', 'ymsgr', 'acct' ), 182 | \wp_allowed_protocols() 183 | ); 184 | 185 | foreach ( $webfinger as $type => $content ) { 186 | // Print subject. 187 | if ( 'subject' === $type ) { 188 | $xrd .= '' . \esc_url( $content, $protocols ) . ''; 189 | continue; 190 | } 191 | 192 | // Print aliases. 193 | if ( 'aliases' === $type ) { 194 | foreach ( $content as $uri ) { 195 | $xrd .= '' . \esc_url( $uri, $protocols ) . ''; 196 | } 197 | continue; 198 | } 199 | 200 | // Print properties. 201 | if ( 'properties' === $type ) { 202 | foreach ( $content as $prop_type => $uri ) { 203 | $xrd .= '' . \esc_html( $uri ) . ''; 204 | } 205 | continue; 206 | } 207 | 208 | // Print titles. 209 | if ( 'titles' === $type ) { 210 | foreach ( $content as $key => $value ) { 211 | if ( 'default' === $key ) { 212 | $xrd .= '' . \esc_html( $value ) . ''; 213 | } else { 214 | $xrd .= '' . \esc_html( $value ) . ''; 215 | } 216 | } 217 | continue; 218 | } 219 | 220 | // Print links. 221 | if ( 'links' === $type ) { 222 | foreach ( $content as $links ) { 223 | $temp = array(); 224 | $cascaded = false; 225 | $xrd .= ' $value ) { 228 | if ( \is_array( $value ) ) { 229 | $temp[ $key ] = $value; 230 | $cascaded = true; 231 | } else { 232 | $xrd .= \esc_attr( $key ) . '="' . \esc_attr( $value ) . '" '; 233 | } 234 | } 235 | if ( $cascaded ) { 236 | $xrd .= '>'; 237 | $xrd .= self::jrd_to_xrd( $temp ); 238 | $xrd .= ''; 239 | } else { 240 | $xrd .= ' />'; 241 | } 242 | } 243 | continue; 244 | } 245 | } 246 | 247 | return $xrd; 248 | } 249 | 250 | /** 251 | * Backwards compatibility for old versions. Please don't use! 252 | * 253 | * @deprecated 254 | * 255 | * @param array $webfinger The webfinger data. 256 | * @param string $resource_uri The resource. 257 | * @param \WP_User $user The user. 258 | * 259 | * @return array The filtered webfinger data. 260 | */ 261 | public static function legacy_filter( $webfinger, $resource_uri, $user ) { 262 | // Filter WebFinger array. 263 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a public WebFinger endpoint. 264 | return \apply_filters( 'webfinger', $webfinger, $user, $resource_uri, $_GET ); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /tests/phpunit/includes/Test_Webfinger.php: -------------------------------------------------------------------------------- 1 | user->create( 37 | array( 38 | 'user_login' => 'webfingeruser', 39 | 'user_email' => 'webfingeruser@example.org', 40 | 'user_nicename' => 'webfingeruser', 41 | 'display_name' => 'WebFinger User', 42 | ) 43 | ); 44 | 45 | self::$post_id = $factory->post->create( 46 | array( 47 | 'post_author' => self::$user_id, 48 | 'post_status' => 'publish', 49 | 'post_title' => 'Test Post', 50 | ) 51 | ); 52 | } 53 | 54 | /** 55 | * Clean up after tests. 56 | */ 57 | public static function wpTearDownAfterClass() { 58 | if ( self::$post_id ) { 59 | \wp_delete_post( self::$post_id, true ); 60 | } 61 | if ( self::$user_id ) { 62 | \wp_delete_user( self::$user_id ); 63 | } 64 | } 65 | 66 | /** 67 | * Test query_vars adds required variables. 68 | * 69 | * @covers ::query_vars 70 | */ 71 | public function test_query_vars_adds_required_vars() { 72 | $vars = Webfinger::query_vars( array() ); 73 | 74 | $this->assertContains( 'well-known', $vars ); 75 | $this->assertContains( 'resource', $vars ); 76 | $this->assertContains( 'rel', $vars ); 77 | } 78 | 79 | /** 80 | * Test query_vars preserves existing variables. 81 | * 82 | * @covers ::query_vars 83 | */ 84 | public function test_query_vars_preserves_existing_vars() { 85 | $existing = array( 'existing_var' ); 86 | $vars = Webfinger::query_vars( $existing ); 87 | 88 | $this->assertContains( 'existing_var', $vars ); 89 | $this->assertContains( 'well-known', $vars ); 90 | } 91 | 92 | /** 93 | * Test generate_user_data returns user data for valid resource. 94 | * 95 | * @covers ::generate_user_data 96 | */ 97 | public function test_generate_user_data_returns_user_data() { 98 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 99 | $resource = 'acct:webfingeruser@' . $host; 100 | 101 | $webfinger = Webfinger::generate_user_data( array(), $resource ); 102 | 103 | $this->assertIsArray( $webfinger ); 104 | $this->assertArrayHasKey( 'subject', $webfinger ); 105 | $this->assertArrayHasKey( 'aliases', $webfinger ); 106 | $this->assertArrayHasKey( 'links', $webfinger ); 107 | } 108 | 109 | /** 110 | * Test generate_user_data includes profile page link. 111 | * 112 | * @covers ::generate_user_data 113 | */ 114 | public function test_generate_user_data_includes_profile_link() { 115 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 116 | $resource = 'acct:webfingeruser@' . $host; 117 | 118 | $webfinger = Webfinger::generate_user_data( array(), $resource ); 119 | $has_profile = false; 120 | $profile_rel = 'http://webfinger.net/rel/profile-page'; 121 | 122 | foreach ( $webfinger['links'] as $link ) { 123 | if ( isset( $link['rel'] ) && $link['rel'] === $profile_rel ) { 124 | $has_profile = true; 125 | break; 126 | } 127 | } 128 | 129 | $this->assertTrue( $has_profile ); 130 | } 131 | 132 | /** 133 | * Test generate_user_data includes avatar link. 134 | * 135 | * @covers ::generate_user_data 136 | */ 137 | public function test_generate_user_data_includes_avatar_link() { 138 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 139 | $resource = 'acct:webfingeruser@' . $host; 140 | 141 | $webfinger = Webfinger::generate_user_data( array(), $resource ); 142 | $has_avatar = false; 143 | $avatar_rel = 'http://webfinger.net/rel/avatar'; 144 | 145 | foreach ( $webfinger['links'] as $link ) { 146 | if ( isset( $link['rel'] ) && $link['rel'] === $avatar_rel ) { 147 | $has_avatar = true; 148 | break; 149 | } 150 | } 151 | 152 | $this->assertTrue( $has_avatar ); 153 | } 154 | 155 | /** 156 | * Test generate_user_data returns empty array for invalid resource. 157 | * 158 | * @covers ::generate_user_data 159 | */ 160 | public function test_generate_user_data_returns_empty_for_invalid_resource() { 161 | $resource = 'acct:nonexistent@invalid-domain.com'; 162 | 163 | $webfinger = Webfinger::generate_user_data( array(), $resource ); 164 | 165 | $this->assertIsArray( $webfinger ); 166 | $this->assertEmpty( $webfinger ); 167 | } 168 | 169 | /** 170 | * Test generate_post_data returns post data for valid resource. 171 | * 172 | * @covers ::generate_post_data 173 | */ 174 | public function test_generate_post_data_returns_post_data() { 175 | $resource = \get_permalink( self::$post_id ); 176 | 177 | $webfinger = Webfinger::generate_post_data( array(), $resource ); 178 | 179 | $this->assertIsArray( $webfinger ); 180 | $this->assertArrayHasKey( 'subject', $webfinger ); 181 | $this->assertArrayHasKey( 'aliases', $webfinger ); 182 | $this->assertArrayHasKey( 'links', $webfinger ); 183 | } 184 | 185 | /** 186 | * Test generate_post_data includes shortlink. 187 | * 188 | * @covers ::generate_post_data 189 | */ 190 | public function test_generate_post_data_includes_shortlink() { 191 | $resource = \get_permalink( self::$post_id ); 192 | 193 | $webfinger = Webfinger::generate_post_data( array(), $resource ); 194 | $has_shortlink = false; 195 | 196 | foreach ( $webfinger['links'] as $link ) { 197 | if ( isset( $link['rel'] ) && 'shortlink' === $link['rel'] ) { 198 | $has_shortlink = true; 199 | break; 200 | } 201 | } 202 | 203 | $this->assertTrue( $has_shortlink ); 204 | } 205 | 206 | /** 207 | * Test generate_post_data includes canonical link. 208 | * 209 | * @covers ::generate_post_data 210 | */ 211 | public function test_generate_post_data_includes_canonical() { 212 | $resource = \get_permalink( self::$post_id ); 213 | 214 | $webfinger = Webfinger::generate_post_data( array(), $resource ); 215 | $has_canonical = false; 216 | 217 | foreach ( $webfinger['links'] as $link ) { 218 | if ( isset( $link['rel'] ) && 'canonical' === $link['rel'] ) { 219 | $has_canonical = true; 220 | break; 221 | } 222 | } 223 | 224 | $this->assertTrue( $has_canonical ); 225 | } 226 | 227 | /** 228 | * Test generate_post_data includes author link. 229 | * 230 | * @covers ::generate_post_data 231 | */ 232 | public function test_generate_post_data_includes_author() { 233 | $resource = \get_permalink( self::$post_id ); 234 | 235 | $webfinger = Webfinger::generate_post_data( array(), $resource ); 236 | $has_author = false; 237 | 238 | foreach ( $webfinger['links'] as $link ) { 239 | if ( isset( $link['rel'] ) && 'author' === $link['rel'] ) { 240 | $has_author = true; 241 | break; 242 | } 243 | } 244 | 245 | $this->assertTrue( $has_author ); 246 | } 247 | 248 | /** 249 | * Test generate_post_data returns empty for invalid resource. 250 | * 251 | * @covers ::generate_post_data 252 | */ 253 | public function test_generate_post_data_returns_empty_for_invalid_resource() { 254 | $resource = 'https://example.com/invalid-post/'; 255 | 256 | $webfinger = Webfinger::generate_post_data( array(), $resource ); 257 | 258 | $this->assertIsArray( $webfinger ); 259 | $this->assertEmpty( $webfinger ); 260 | } 261 | 262 | /** 263 | * Test filter_by_rel returns webfinger unchanged when no rel param. 264 | * 265 | * @covers ::filter_by_rel 266 | */ 267 | public function test_filter_by_rel_returns_unchanged_without_rel() { 268 | $webfinger = array( 269 | 'subject' => 'acct:test@example.org', 270 | 'links' => array( 271 | array( 272 | 'rel' => 'self', 273 | 'href' => 'https://example.org/test', 274 | ), 275 | ), 276 | ); 277 | 278 | // Ensure no rel is set. 279 | unset( $_GET['rel'] ); 280 | 281 | $result = Webfinger::filter_by_rel( $webfinger ); 282 | 283 | $this->assertEquals( $webfinger, $result ); 284 | } 285 | 286 | /** 287 | * Test filter_by_rel returns empty webfinger unchanged. 288 | * 289 | * @covers ::filter_by_rel 290 | */ 291 | public function test_filter_by_rel_returns_empty_unchanged() { 292 | $webfinger = array(); 293 | 294 | $result = Webfinger::filter_by_rel( $webfinger ); 295 | 296 | $this->assertEmpty( $result ); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WebFinger 2 | 3 | - Contributors: pfefferle, willnorris 4 | - Donate link: https://notiz.blog/donate/ 5 | - Tags: discovery, webfinger, JRD, ostatus, activitypub 6 | - Requires at least: 4.2 7 | - Tested up to: 6.9 8 | - Stable tag: 4.0.1 9 | - License: MIT 10 | - License URI: https://opensource.org/licenses/MIT 11 | 12 | WebFinger for WordPress 13 | 14 | ## Description 15 | 16 | WebFinger allows you to be discovered on the web using an identifier like `you@yourdomain.com` — similar to how email works, but for your online identity. 17 | 18 | **Why is this useful?** 19 | 20 | * **Fediverse & Mastodon:** WebFinger is essential for federation. It allows Mastodon and other ActivityPub-powered platforms to find and follow your WordPress site. 21 | * **Decentralized Identity:** People can look you up using your WordPress domain, making your site the canonical source for your online identity. 22 | * **Works with other plugins:** This plugin provides the foundation that other plugins (like the ActivityPub plugin) build upon. 23 | 24 | **How it works:** 25 | 26 | When someone searches for `@you@yourdomain.com` on Mastodon or another federated service, their server asks your WordPress site: "Who is this person?" WebFinger answers that question by providing information about you and links to your profiles. 27 | 28 | **Technical details:** 29 | 30 | WebFinger is an open standard ([RFC 7033](http://tools.ietf.org/html/rfc7033)) that enables discovery of information about people and resources on the internet. It works by responding to requests at `/.well-known/webfinger` on your domain. 31 | 32 | ## Frequently Asked Questions 33 | 34 | ### How do I customize my WebFinger identifier? 35 | 36 | Go to **Users → Profile** in your WordPress admin and scroll down to the "WebFinger" section. There you can set a custom identifier (the part before the @) and see all your WebFinger aliases. 37 | 38 | ### How do I check if WebFinger is working? 39 | 40 | Visit **Tools → Site Health** in your WordPress admin. The plugin adds checks that verify your WebFinger endpoint is accessible and properly configured. If there are any issues, you'll see guidance on how to fix them. 41 | 42 | ### Does this work with Mastodon? 43 | 44 | Yes! WebFinger is the standard that Mastodon and other Fediverse platforms use to discover users. When someone searches for `@you@yourdomain.com`, WebFinger tells them where to find your profile. 45 | 46 | ### Do I need pretty permalinks? 47 | 48 | Yes. WebFinger requires pretty permalinks to be enabled. Go to **Settings → Permalinks** and select any option other than "Plain". 49 | 50 | ### For developers: How do I add custom data to the WebFinger response? 51 | 52 | Use the `webfinger_data` filter to add your own links or properties: 53 | 54 | add_filter( 'webfinger_data', function( $data ) { 55 | $data['links'][] = array( 56 | 'rel' => 'http://example.com/rel/profile', 57 | 'href' => 'http://example.com/profile', 58 | 'type' => 'text/html', 59 | ); 60 | return $data; 61 | } ); 62 | 63 | ### For developers: How do I add alternate output formats? 64 | 65 | Use the `webfinger_render` action to output custom formats (like XRD): 66 | 67 | add_action( 'webfinger_render', function( $webfinger ) { 68 | // Set custom headers and output your format 69 | // ... 70 | exit; 71 | }, 5 ); 72 | 73 | See for a complete example. 74 | 75 | ### Where can I learn more about WebFinger? 76 | 77 | * WebFinger specification: [RFC 7033](http://tools.ietf.org/html/rfc7033) 78 | * Community resources: 79 | 80 | ## Upgrade Notice 81 | 82 | ### 4.0.0 83 | 84 | This is a major update with new features (Site Health checks, user profile settings) and requires PHP 7.2 or higher. After updating, visit **Tools → Site Health** to verify your WebFinger setup is working correctly. 85 | 86 | ### 3.0.0 87 | 88 | This version drops classic WebFinger (XRD) support to keep the plugin lightweight. If you need legacy XRD format support, install the [WebFinger Legacy](https://github.com/pfefferle/wordpress-webfinger-legacy) plugin. 89 | 90 | ## Changelog 91 | 92 | Project maintained on github at [pfefferle/wordpress-webfinger](https://github.com/pfefferle/wordpress-webfinger). 93 | 94 | ### 4.0.1 95 | 96 | * Fixed: Handle WP_Error objects in `filter_by_rel` to prevent errors when WebFinger lookup fails 97 | 98 | ### 4.0.0 99 | 100 | * Added: Site Health integration to check your WebFinger setup status directly in WordPress 101 | * Added: User profile settings to customize your WebFinger identifier 102 | * Added: Verification links to easily test your WebFinger aliases 103 | * Improved: Security hardening for URI parsing and input validation 104 | * Improved: Modernized codebase for PHP 7.2+ with namespace support 105 | * Improved: Better organized code structure with separate classes 106 | * Updated: Development infrastructure with GitHub Actions for automated testing 107 | 108 | ### 3.2.7 109 | 110 | * Added: better output escaping 111 | * Fixed: stricter queries 112 | 113 | ### 3.2.6 114 | 115 | * remove E-Mail address 116 | 117 | ### 3.2.5 118 | 119 | * fix typo 120 | 121 | ### 3.2.4 122 | 123 | * update requirements 124 | 125 | ### 3.2.3 126 | 127 | * fixed `acct` scheme for discovery 128 | 129 | ### 3.2.2 130 | 131 | * fixed typo (thanks @ivucica) 132 | * use `acct` as default scheme 133 | 134 | ### 3.2.1 135 | 136 | * make `acct` protocol optional 137 | 138 | ### 3.2.0 139 | 140 | * global refactoring 141 | 142 | ### 3.1.6 143 | 144 | * added `user_nicename` as resource 145 | * fixed WordPress coding standard issues 146 | 147 | ### 3.1.5 148 | 149 | * fixed PHP warning 150 | 151 | ### 3.1.4 152 | 153 | * updated requirements 154 | 155 | ### 3.1.3 156 | 157 | * add support for the 'aim', 'ymsgr' and 'acct' protocol 158 | 159 | ### 3.1.2 160 | 161 | * fixed the legacy code 162 | * added feeds 163 | 164 | ### 3.1.1 165 | 166 | * fixed 'get_user_by_various' function 167 | 168 | ### 3.1.0 169 | 170 | * Added WebFinger legacy plugin, because the legacy version is still very popular and used by for example OStatus (Mastodon, Status.NET and GNU Social) 171 | * Added Webfinger for posts support 172 | 173 | ### 3.0.3 174 | 175 | * composer support 176 | * compatibility updates 177 | 178 | ### 3.0.2 179 | 180 | * `get_avatar_url` instead of custom code 181 | * some small code improvements 182 | * nicer PHP-docs 183 | 184 | ### 3.0.1 185 | 186 | * updated version informations 187 | * support the WordPress Coding Standard 188 | 189 | ### 3.0.0 190 | 191 | * added correct error-responses 192 | * remove legacy support for XRD and host-meta (props to Will Norris) 193 | 194 | ### 2.0.1 195 | 196 | * small bugfix 197 | 198 | ### 2.0.0 199 | 200 | * complete refactoring 201 | * removed simple-web-discovery 202 | * more filters and actions 203 | * works without /.well-known/ plugin 204 | 205 | ### 1.4.0 206 | 207 | * small fixes 208 | * added "webfinger" as well-known uri 209 | 210 | ### 1.3.1 211 | 212 | * added "rel"-filter (work in progress) 213 | * added more aliases 214 | 215 | ### 1.3 216 | 217 | * added host-meta resource feature (see latest spec) 218 | 219 | ### 1.2 220 | 221 | * added 404 http error if user doesn't exist 222 | * added jrd discovery for host-meta 223 | 224 | ### 1.1 225 | 226 | * fixed an odd problem with lower WordPress versions 227 | * added support for the http://wordpress.org/extend/plugins/extended-profile/ (thanks to Singpolyma) 228 | 229 | ### 1.0.1 230 | 231 | * api improvements 232 | 233 | ### 1.0 234 | 235 | * basic simple-seb-discovery 236 | * json support 237 | * some small improvements 238 | 239 | ### 0.9.1 240 | 241 | * some changes to support http://unhosted.org 242 | 243 | ### 0.9 244 | 245 | * OStatus improvements 246 | * Better uri handling 247 | * Identifier overview (more to come) 248 | * Added filters 249 | * Added functions to get a users webfingers 250 | 251 | ### 0.7 252 | 253 | * Added do_action param (for future OStatus plugin) 254 | * Author-Url as Webfinger-Identifier 255 | 256 | ### 0.5 257 | 258 | * Initial release 259 | 260 | ## Installation 261 | 262 | ### From WordPress.org (recommended) 263 | 264 | 1. Go to **Plugins → Add New** in your WordPress admin 265 | 2. Search for "webfinger" 266 | 3. Click **Install Now**, then **Activate** 267 | 4. Make sure pretty permalinks are enabled (**Settings → Permalinks** — select any option except "Plain") 268 | 5. Visit **Tools → Site Health** to verify everything is working 269 | 270 | ### Manual Installation 271 | 272 | 1. Download the plugin from [WordPress.org](https://wordpress.org/plugins/webfinger/) or [GitHub](https://github.com/pfefferle/wordpress-webfinger/releases) 273 | 2. Upload the `webfinger` folder to `/wp-content/plugins/` 274 | 3. Activate the plugin in **Plugins → Installed Plugins** 275 | 4. Enable pretty permalinks if not already active 276 | 5. Check **Tools → Site Health** to confirm the setup 277 | -------------------------------------------------------------------------------- /includes/class-webfinger.php: -------------------------------------------------------------------------------- 1 | query_vars['well-known'] ) || 'webfinger' !== $wp->query_vars['well-known'] ) { 70 | return; 71 | } 72 | 73 | \header( 'Access-Control-Allow-Origin: *' ); 74 | 75 | // Check if "resource" param exists. 76 | if ( empty( $wp->query_vars['resource'] ) ) { 77 | self::send_error( 400, 'missing "resource" parameter' ); 78 | } 79 | 80 | $resource = $wp->query_vars['resource']; 81 | 82 | // Filter WebFinger array. 83 | $webfinger = \apply_filters( 'webfinger_data', array(), $resource ); 84 | 85 | // Check if data exists. 86 | if ( empty( $webfinger ) ) { 87 | self::send_error( 404, \sprintf( 'no data for resource "%s" found', \esc_html( $resource ) ) ); 88 | } 89 | 90 | \do_action( 'webfinger_render', $webfinger ); 91 | 92 | exit; 93 | } 94 | 95 | /** 96 | * Send an error response and exit. 97 | * 98 | * @param int $status HTTP status code. 99 | * @param string $message Error message. 100 | */ 101 | private static function send_error( $status, $message ) { 102 | \status_header( $status ); 103 | \header( 'Content-Type: text/plain; charset=' . \get_bloginfo( 'charset' ), true ); 104 | echo \esc_html( $message ); 105 | exit; 106 | } 107 | 108 | /** 109 | * Render the JRD representation of the WebFinger resource. 110 | * 111 | * @param array $webfinger The WebFinger data-array. 112 | */ 113 | public static function render_jrd( $webfinger ) { 114 | \header( 'Content-Type: application/jrd+json; charset=' . \get_bloginfo( 'charset' ), true ); 115 | 116 | echo \wp_json_encode( $webfinger ); 117 | exit; 118 | } 119 | 120 | /** 121 | * Generates the WebFinger base array for users. 122 | * 123 | * @param array $webfinger The WebFinger data-array. 124 | * @param string $resource_uri The resource param. 125 | * 126 | * @return array The enriched WebFinger data-array. 127 | */ 128 | public static function generate_user_data( $webfinger, $resource_uri ) { 129 | // Find matching user. 130 | $user = User::get_user_by_uri( $resource_uri ); 131 | 132 | if ( ! $user ) { 133 | return $webfinger; 134 | } 135 | 136 | // Generate "profile" url. 137 | $url = \get_author_posts_url( $user->ID, $user->user_nicename ); 138 | 139 | // Generate default photo-url. 140 | $photo = \get_avatar_url( $user->ID ); 141 | 142 | // Generate default array. 143 | $webfinger = array( 144 | 'subject' => User::get_resource( $user->ID ), 145 | 'aliases' => User::get_resources( $user->ID ), 146 | 'links' => array( 147 | array( 148 | 'rel' => 'http://webfinger.net/rel/profile-page', 149 | 'href' => \esc_url( $url ), 150 | 'type' => 'text/html', 151 | ), 152 | array( 153 | 'rel' => 'http://webfinger.net/rel/avatar', 154 | 'href' => \esc_url( $photo ), 155 | ), 156 | ), 157 | ); 158 | 159 | // Add user_url if set. 160 | if ( 161 | isset( $user->user_url ) && 162 | ! empty( $user->user_url ) && 163 | is_same_host( $user->user_url ) 164 | ) { 165 | $webfinger['links'][] = array( 166 | 'rel' => 'http://webfinger.net/rel/profile-page', 167 | 'href' => \esc_url( $user->user_url ), 168 | 'type' => 'text/html', 169 | ); 170 | } 171 | 172 | return \apply_filters( 'webfinger_user_data', $webfinger, $resource_uri, $user ); 173 | } 174 | 175 | /** 176 | * Generates the WebFinger base array for posts. 177 | * 178 | * @param array $webfinger The WebFinger data-array. 179 | * @param string $resource_uri The resource param. 180 | * 181 | * @return array The enriched WebFinger data-array. 182 | */ 183 | public static function generate_post_data( $webfinger, $resource_uri ) { 184 | // Find matching post. 185 | $post_id = \url_to_postid( $resource_uri ); 186 | 187 | // Check if there is a matching post-id. 188 | if ( ! $post_id ) { 189 | return $webfinger; 190 | } 191 | 192 | // Get post by id. 193 | $post = \get_post( $post_id ); 194 | 195 | // Check if there is a matching post. 196 | if ( ! $post ) { 197 | return $webfinger; 198 | } 199 | 200 | $author = \get_user_by( 'id', $post->post_author ); 201 | 202 | if ( ! $author ) { 203 | return $webfinger; 204 | } 205 | 206 | // Default webfinger array for posts. 207 | $webfinger = array( 208 | 'subject' => \get_permalink( $post->ID ), 209 | 'aliases' => \apply_filters( 210 | 'webfinger_post_resource', 211 | array( 212 | \home_url( '?p=' . $post->ID ), 213 | \get_permalink( $post->ID ), 214 | ), 215 | $post 216 | ), 217 | 'links' => array( 218 | array( 219 | 'rel' => 'shortlink', 220 | 'type' => 'text/html', 221 | 'href' => \wp_get_shortlink( $post ), 222 | ), 223 | array( 224 | 'rel' => 'canonical', 225 | 'type' => 'text/html', 226 | 'href' => \get_permalink( $post->ID ), 227 | ), 228 | array( 229 | 'rel' => 'author', 230 | 'type' => 'text/html', 231 | 'href' => \get_author_posts_url( $author->ID, $author->user_nicename ), 232 | ), 233 | array( 234 | 'rel' => 'alternate', 235 | 'type' => 'application/rss+xml', 236 | 'href' => \get_post_comments_feed_link( $post->ID, 'rss2' ), 237 | ), 238 | array( 239 | 'rel' => 'alternate', 240 | 'type' => 'application/atom+xml', 241 | 'href' => \get_post_comments_feed_link( $post->ID, 'atom' ), 242 | ), 243 | ), 244 | ); 245 | 246 | return \apply_filters( 'webfinger_post_data', $webfinger, $resource_uri, $post ); 247 | } 248 | 249 | /** 250 | * Filters the WebFinger array by request params like "rel". 251 | * 252 | * @link http://tools.ietf.org/html/rfc7033#section-4.3 253 | * 254 | * @param array $webfinger The WebFinger data-array. 255 | * 256 | * @return array The filtered WebFinger data-array. 257 | */ 258 | public static function filter_by_rel( $webfinger ) { 259 | // Check if WebFinger is empty or has no links. 260 | if ( \is_wp_error( $webfinger ) || empty( $webfinger ) || ! isset( $webfinger['links'] ) ) { 261 | return $webfinger; 262 | } 263 | 264 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public WebFinger endpoint. 265 | if ( ! isset( $_GET['rel'] ) ) { 266 | return $webfinger; 267 | } 268 | 269 | $rels = self::get_rel_params(); 270 | 271 | if ( empty( $rels ) ) { 272 | return $webfinger; 273 | } 274 | 275 | $webfinger['links'] = \array_values( 276 | \array_filter( 277 | $webfinger['links'], 278 | function ( $link ) use ( $rels ) { 279 | return isset( $link['rel'] ) && \in_array( $link['rel'], $rels, true ); 280 | } 281 | ) 282 | ); 283 | 284 | return $webfinger; 285 | } 286 | 287 | /** 288 | * Parse rel parameters from query string. 289 | * 290 | * PHP does not support multiple query params with the same name, 291 | * so we parse the query string manually. 292 | * 293 | * @return array List of rel values. 294 | */ 295 | private static function get_rel_params() { 296 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- We sanitize below. 297 | $query_string = $_SERVER['QUERY_STRING'] ?? ''; 298 | $rels = array(); 299 | 300 | foreach ( \explode( '&', $query_string ) as $param ) { 301 | $parts = \explode( '=', $param, 2 ); 302 | 303 | if ( 2 === \count( $parts ) && 'rel' === $parts[0] && '' !== $parts[1] ) { 304 | $rels[] = \sanitize_text_field( \urldecode( $parts[1] ) ); 305 | } 306 | } 307 | 308 | return $rels; 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /tests/phpunit/includes/Test_User.php: -------------------------------------------------------------------------------- 1 | user->create( 30 | array( 31 | 'user_login' => 'testuser', 32 | 'user_email' => 'testuser@example.org', 33 | 'user_nicename' => 'testuser', 34 | 'display_name' => 'Test User', 35 | ) 36 | ); 37 | } 38 | 39 | /** 40 | * Clean up after tests. 41 | */ 42 | public static function wpTearDownAfterClass() { 43 | if ( self::$user_id ) { 44 | \wp_delete_user( self::$user_id ); 45 | } 46 | } 47 | 48 | /** 49 | * Test get_username returns the user_login by default. 50 | * 51 | * @covers ::get_username 52 | */ 53 | public function test_get_username_returns_user_login() { 54 | $username = User::get_username( self::$user_id ); 55 | 56 | $this->assertEquals( 'testuser', $username ); 57 | } 58 | 59 | /** 60 | * Test get_username returns custom webfinger_resource meta if set. 61 | * 62 | * @covers ::get_username 63 | */ 64 | public function test_get_username_returns_custom_resource() { 65 | \update_user_meta( self::$user_id, 'webfinger_resource', 'customuser' ); 66 | 67 | $username = User::get_username( self::$user_id ); 68 | 69 | $this->assertEquals( 'customuser', $username ); 70 | 71 | \delete_user_meta( self::$user_id, 'webfinger_resource' ); 72 | } 73 | 74 | /** 75 | * Test get_username returns null for invalid user. 76 | * 77 | * @covers ::get_username 78 | */ 79 | public function test_get_username_returns_null_for_invalid_user() { 80 | $username = User::get_username( 999999 ); 81 | 82 | $this->assertNull( $username ); 83 | } 84 | 85 | /** 86 | * Test get_resource returns acct: URI. 87 | * 88 | * @covers ::get_resource 89 | */ 90 | public function test_get_resource_returns_acct_uri() { 91 | $resource = User::get_resource( self::$user_id ); 92 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 93 | 94 | $this->assertStringStartsWith( 'acct:', $resource ); 95 | $this->assertStringContainsString( 'testuser@', $resource ); 96 | $this->assertStringEndsWith( '@' . $host, $resource ); 97 | } 98 | 99 | /** 100 | * Test get_resource without protocol. 101 | * 102 | * @covers ::get_resource 103 | */ 104 | public function test_get_resource_without_protocol() { 105 | $resource = User::get_resource( self::$user_id, false ); 106 | 107 | $this->assertStringNotContainsString( 'acct:', $resource ); 108 | $this->assertStringContainsString( 'testuser@', $resource ); 109 | } 110 | 111 | /** 112 | * Test get_resources returns array of resources. 113 | * 114 | * @covers ::get_resources 115 | */ 116 | public function test_get_resources_returns_array() { 117 | $resources = User::get_resources( self::$user_id ); 118 | 119 | $this->assertIsArray( $resources ); 120 | $this->assertNotEmpty( $resources ); 121 | } 122 | 123 | /** 124 | * Test get_resources includes acct: URI. 125 | * 126 | * @covers ::get_resources 127 | */ 128 | public function test_get_resources_includes_acct_uri() { 129 | $resources = User::get_resources( self::$user_id ); 130 | $has_acct = false; 131 | 132 | foreach ( $resources as $resource ) { 133 | if ( \strpos( $resource, 'acct:' ) === 0 ) { 134 | $has_acct = true; 135 | break; 136 | } 137 | } 138 | 139 | $this->assertTrue( $has_acct ); 140 | } 141 | 142 | /** 143 | * Test get_resources includes author URL. 144 | * 145 | * @covers ::get_resources 146 | */ 147 | public function test_get_resources_includes_author_url() { 148 | $resources = User::get_resources( self::$user_id ); 149 | $author_url = \get_author_posts_url( self::$user_id ); 150 | 151 | $this->assertContains( $author_url, $resources ); 152 | } 153 | 154 | /** 155 | * Test get_resources returns empty array for invalid user. 156 | * 157 | * @covers ::get_resources 158 | */ 159 | public function test_get_resources_returns_empty_for_invalid_user() { 160 | $resources = User::get_resources( 999999 ); 161 | 162 | $this->assertIsArray( $resources ); 163 | $this->assertEmpty( $resources ); 164 | } 165 | 166 | /** 167 | * Test get_user_by_uri with acct: scheme. 168 | * 169 | * @covers ::get_user_by_uri 170 | */ 171 | public function test_get_user_by_uri_with_acct_scheme() { 172 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 173 | $uri = 'acct:testuser@' . $host; 174 | 175 | $user = User::get_user_by_uri( $uri ); 176 | 177 | $this->assertInstanceOf( \WP_User::class, $user ); 178 | $this->assertEquals( self::$user_id, $user->ID ); 179 | } 180 | 181 | /** 182 | * Test get_user_by_uri with invalid domain returns null. 183 | * 184 | * @covers ::get_user_by_uri 185 | */ 186 | public function test_get_user_by_uri_with_invalid_domain() { 187 | $uri = 'acct:testuser@invalid-domain.com'; 188 | 189 | $user = User::get_user_by_uri( $uri ); 190 | 191 | $this->assertNull( $user ); 192 | } 193 | 194 | /** 195 | * Test get_user_by_uri with author URL. 196 | * 197 | * @covers ::get_user_by_uri 198 | */ 199 | public function test_get_user_by_uri_with_author_url() { 200 | $author_url = \get_author_posts_url( self::$user_id ); 201 | 202 | $user = User::get_user_by_uri( $author_url ); 203 | 204 | $this->assertInstanceOf( \WP_User::class, $user ); 205 | $this->assertEquals( self::$user_id, $user->ID ); 206 | } 207 | 208 | /** 209 | * Test get_user_by_uri with custom webfinger_resource meta. 210 | * 211 | * @covers ::get_user_by_uri 212 | */ 213 | public function test_get_user_by_uri_with_custom_resource() { 214 | \update_user_meta( self::$user_id, 'webfinger_resource', 'customidentifier' ); 215 | 216 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 217 | $uri = 'acct:customidentifier@' . $host; 218 | 219 | $user = User::get_user_by_uri( $uri ); 220 | 221 | $this->assertInstanceOf( \WP_User::class, $user ); 222 | $this->assertEquals( self::$user_id, $user->ID ); 223 | 224 | \delete_user_meta( self::$user_id, 'webfinger_resource' ); 225 | } 226 | 227 | /** 228 | * Test get_user_by_uri strips SQL wildcard percent character. 229 | * 230 | * @covers ::get_user_by_uri 231 | */ 232 | public function test_get_user_by_uri_strips_percent_wildcard() { 233 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 234 | 235 | // Attempt SQL LIKE injection with % wildcard. 236 | $uri = 'acct:%@' . $host; 237 | 238 | $user = User::get_user_by_uri( $uri ); 239 | 240 | // Should return null, not match any user via LIKE query. 241 | $this->assertNull( $user ); 242 | } 243 | 244 | /** 245 | * Test get_user_by_uri strips SQL wildcard asterisk character. 246 | * 247 | * @covers ::get_user_by_uri 248 | */ 249 | public function test_get_user_by_uri_strips_asterisk_wildcard() { 250 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 251 | 252 | // Attempt injection with * wildcard. 253 | $uri = 'acct:*@' . $host; 254 | 255 | $user = User::get_user_by_uri( $uri ); 256 | 257 | // Should return null, not match any user. 258 | $this->assertNull( $user ); 259 | } 260 | 261 | /** 262 | * Test get_user_by_uri with wildcard in username does not match all users. 263 | * 264 | * @covers ::get_user_by_uri 265 | */ 266 | public function test_get_user_by_uri_wildcard_does_not_match_all() { 267 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 268 | 269 | // Attempt to match all users with wildcards. 270 | $uri = 'acct:test%user@' . $host; 271 | 272 | $user = User::get_user_by_uri( $uri ); 273 | 274 | // Should return null because % is stripped, leaving "testuser" which should match. 275 | // Actually after stripping %, it becomes "testuser" which is a valid user. 276 | // Let's test with a pattern that won't match after stripping. 277 | $uri2 = 'acct:%test%@' . $host; 278 | 279 | $user2 = User::get_user_by_uri( $uri2 ); 280 | 281 | // After stripping %, becomes "test" which is not our user. 282 | $this->assertNull( $user2 ); 283 | } 284 | 285 | /** 286 | * Test get_user_by_uri with malicious scheme is sanitized. 287 | * 288 | * @covers ::get_user_by_uri 289 | */ 290 | public function test_get_user_by_uri_sanitizes_scheme() { 291 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 292 | 293 | // Attempt XSS in scheme (should be sanitized by esc_attr). 294 | $uri = ':testuser@' . $host; 295 | 296 | $user = User::get_user_by_uri( $uri ); 297 | 298 | // Should return null due to invalid scheme/host mismatch. 299 | $this->assertNull( $user ); 300 | } 301 | 302 | /** 303 | * Test get_user_by_uri with malicious host is sanitized. 304 | * 305 | * @covers ::get_user_by_uri 306 | */ 307 | public function test_get_user_by_uri_sanitizes_host() { 308 | // Attempt injection in host part. 309 | $uri = 'acct:testuser@'; 310 | 311 | $user = User::get_user_by_uri( $uri ); 312 | 313 | // Should return null due to host not matching blog host. 314 | $this->assertNull( $user ); 315 | } 316 | 317 | /** 318 | * Test get_user_by_uri with empty URI after sanitization. 319 | * 320 | * @covers ::get_user_by_uri 321 | */ 322 | public function test_get_user_by_uri_empty_after_sanitization() { 323 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 324 | 325 | // URI that becomes empty after stripping wildcards. 326 | $uri = 'acct:%%%@' . $host; 327 | 328 | $user = User::get_user_by_uri( $uri ); 329 | 330 | // Should return null. 331 | $this->assertNull( $user ); 332 | } 333 | 334 | /** 335 | * Test get_user_by_uri with URL-encoded wildcards. 336 | * 337 | * @covers ::get_user_by_uri 338 | */ 339 | public function test_get_user_by_uri_strips_encoded_wildcards() { 340 | $host = \wp_parse_url( \home_url(), \PHP_URL_HOST ); 341 | 342 | // URL-encoded % is %25, after urldecode becomes %. 343 | $uri = 'acct:%25@' . $host; 344 | 345 | $user = User::get_user_by_uri( $uri ); 346 | 347 | // Should return null after decoding and stripping. 348 | $this->assertNull( $user ); 349 | } 350 | 351 | /** 352 | * Test get_user_by_uri handles null/empty input gracefully. 353 | * 354 | * @covers ::get_user_by_uri 355 | */ 356 | public function test_get_user_by_uri_handles_empty_input() { 357 | $this->assertNull( User::get_user_by_uri( '' ) ); 358 | $this->assertNull( User::get_user_by_uri( null ) ); 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /includes/class-health-check.php: -------------------------------------------------------------------------------- 1 | \__( 'WebFinger Permalinks', 'webfinger' ), 34 | 'test' => array( static::class, 'test_permalinks' ), 35 | ); 36 | 37 | $tests['direct']['webfinger_endpoint'] = array( 38 | 'label' => \__( 'WebFinger Endpoint', 'webfinger' ), 39 | 'test' => array( static::class, 'test_webfinger_endpoint' ), 40 | ); 41 | 42 | return $tests; 43 | } 44 | 45 | /** 46 | * Test if pretty permalinks are enabled. 47 | * 48 | * @return array The test result. 49 | */ 50 | public static function test_permalinks() { 51 | global $wp_rewrite; 52 | 53 | $result = array( 54 | 'label' => \__( 'Pretty permalinks are enabled', 'webfinger' ), 55 | 'status' => 'good', 56 | 'badge' => array( 57 | 'label' => \__( 'WebFinger', 'webfinger' ), 58 | 'color' => 'green', 59 | ), 60 | 'description' => \sprintf( 61 | '

%s

', 62 | \__( 'Pretty permalinks are enabled, which is required for the WebFinger endpoint to work correctly.', 'webfinger' ) 63 | ), 64 | 'actions' => '', 65 | 'test' => 'webfinger_permalinks', 66 | ); 67 | 68 | if ( ! $wp_rewrite->using_permalinks() ) { 69 | $result['status'] = 'critical'; 70 | $result['label'] = \__( 'Pretty permalinks are not enabled', 'webfinger' ); 71 | $result['badge']['color'] = 'red'; 72 | $result['description'] = \sprintf( 73 | '

%s

%s

', 74 | \__( 'WebFinger requires pretty permalinks to be enabled. The .well-known/webfinger endpoint will not work with plain permalinks.', 'webfinger' ), 75 | \__( 'Without pretty permalinks, other servers and services will not be able to discover your users via WebFinger.', 'webfinger' ) 76 | ); 77 | $result['actions'] = \sprintf( 78 | '

%s

', 79 | \esc_url( \admin_url( 'options-permalink.php' ) ), 80 | \__( 'Go to Permalink Settings and select any option other than "Plain".', 'webfinger' ) 81 | ); 82 | } 83 | 84 | return $result; 85 | } 86 | 87 | /** 88 | * Test if the WebFinger endpoint is accessible. 89 | * 90 | * @return array The test result. 91 | */ 92 | public static function test_webfinger_endpoint() { 93 | $result = array( 94 | 'label' => \__( 'WebFinger endpoint is accessible', 'webfinger' ), 95 | 'status' => 'good', 96 | 'badge' => array( 97 | 'label' => \__( 'WebFinger', 'webfinger' ), 98 | 'color' => 'green', 99 | ), 100 | 'description' => \sprintf( 101 | '

%s

', 102 | \__( 'The WebFinger endpoint is properly configured and accessible.', 'webfinger' ) 103 | ), 104 | 'actions' => '', 105 | 'test' => 'webfinger_endpoint', 106 | ); 107 | 108 | // Get a test user for the WebFinger request. 109 | $users = \get_users( 110 | array( 111 | 'number' => 1, 112 | 'orderby' => 'registered', 113 | 'order' => 'ASC', 114 | ) 115 | ); 116 | 117 | if ( empty( $users ) ) { 118 | $result['status'] = 'recommended'; 119 | $result['label'] = \__( 'WebFinger endpoint cannot be tested', 'webfinger' ); 120 | $result['badge']['color'] = 'orange'; 121 | $result['description'] = \sprintf( 122 | '

%s

', 123 | \__( 'No users found to test the WebFinger endpoint. Create a user to enable this test.', 'webfinger' ) 124 | ); 125 | 126 | return $result; 127 | } 128 | 129 | $user = $users[0]; 130 | $resource = User::get_resource( $user->ID ); 131 | 132 | // Always use the rewritten URL to test if URL rewriting is working. 133 | $endpoint = \home_url( '/.well-known/webfinger' ); 134 | $url = \add_query_arg( 'resource', $resource, $endpoint ); 135 | 136 | $response = \wp_remote_get( 137 | $url, 138 | array( 139 | 'timeout' => 10, 140 | 'sslverify' => false, 141 | ) 142 | ); 143 | 144 | if ( \is_wp_error( $response ) ) { 145 | $result['status'] = 'critical'; 146 | $result['label'] = \__( 'WebFinger endpoint is not accessible', 'webfinger' ); 147 | $result['badge']['color'] = 'red'; 148 | $result['description'] = \sprintf( 149 | '

%s

%s %s

', 150 | \__( 'The WebFinger endpoint could not be reached. This may prevent federation and discovery from working properly.', 'webfinger' ), 151 | \__( 'Error:', 'webfinger' ), 152 | \esc_html( $response->get_error_message() ) 153 | ); 154 | $result['actions'] = self::get_guidance_actions( 'connection_error' ); 155 | 156 | return $result; 157 | } 158 | 159 | $status_code = \wp_remote_retrieve_response_code( $response ); 160 | $body = \wp_remote_retrieve_body( $response ); 161 | 162 | if ( 200 !== $status_code ) { 163 | $result['status'] = 'critical'; 164 | $result['label'] = \__( 'WebFinger endpoint returned an error', 'webfinger' ); 165 | $result['badge']['color'] = 'red'; 166 | $result['description'] = \sprintf( 167 | '

%s

%s %d

', 168 | \__( 'The WebFinger endpoint returned an unexpected status code.', 'webfinger' ), 169 | \__( 'Status code:', 'webfinger' ), 170 | $status_code 171 | ); 172 | 173 | if ( 404 === $status_code ) { 174 | $result['actions'] = self::get_guidance_actions( 'not_found' ); 175 | } else { 176 | $result['actions'] = self::get_guidance_actions( 'server_error' ); 177 | } 178 | 179 | return $result; 180 | } 181 | 182 | // Check if the response is valid JSON. 183 | $data = \json_decode( $body, true ); 184 | 185 | if ( null === $data || ! isset( $data['subject'] ) ) { 186 | $result['status'] = 'recommended'; 187 | $result['label'] = \__( 'WebFinger endpoint returned invalid data', 'webfinger' ); 188 | $result['badge']['color'] = 'orange'; 189 | $result['description'] = \sprintf( 190 | '

%s

', 191 | \__( 'The WebFinger endpoint is accessible but returned invalid JSON data.', 'webfinger' ) 192 | ); 193 | $result['actions'] = self::get_guidance_actions( 'invalid_response' ); 194 | 195 | return $result; 196 | } 197 | 198 | // Check content type header. 199 | $content_type = \wp_remote_retrieve_header( $response, 'content-type' ); 200 | 201 | if ( false === \strpos( $content_type, 'application/jrd+json' ) ) { 202 | $result['status'] = 'recommended'; 203 | $result['label'] = \__( 'WebFinger endpoint has incorrect content type', 'webfinger' ); 204 | $result['badge']['color'] = 'orange'; 205 | $result['description'] = \sprintf( 206 | '

%s

%s %s

%s application/jrd+json

', 207 | \__( 'The WebFinger endpoint is working but returns an incorrect content type header.', 'webfinger' ), 208 | \__( 'Current:', 'webfinger' ), 209 | \esc_html( $content_type ), 210 | \__( 'Expected:', 'webfinger' ) 211 | ); 212 | 213 | return $result; 214 | } 215 | 216 | // All checks passed. 217 | $result['description'] = \sprintf( 218 | '

%s

%s

', 219 | \__( 'The WebFinger endpoint is properly configured and returning valid responses.', 'webfinger' ), 220 | \esc_html( $endpoint ) 221 | ); 222 | 223 | return $result; 224 | } 225 | 226 | /** 227 | * Get guidance actions based on the error type. 228 | * 229 | * @param string $error_type The type of error encountered. 230 | * 231 | * @return string HTML string with guidance actions. 232 | */ 233 | private static function get_guidance_actions( $error_type ) { 234 | $actions = '

' . \__( 'Troubleshooting Steps:', 'webfinger' ) . '

    '; 235 | 236 | switch ( $error_type ) { 237 | case 'not_found': 238 | $actions .= '
  1. ' . \__( 'Go to Settings → Permalinks and click "Save Changes" to flush rewrite rules.', 'webfinger' ) . '
  2. '; 239 | $actions .= '
  3. ' . \__( 'Ensure your web server supports URL rewriting (mod_rewrite for Apache, try_files for Nginx).', 'webfinger' ) . '
  4. '; 240 | $actions .= '
  5. ' . \__( 'Check if your .htaccess file (Apache) or server configuration (Nginx) is properly configured.', 'webfinger' ) . '
  6. '; 241 | $actions .= '
  7. ' . \sprintf( 242 | /* translators: %s: .well-known/webfinger URL */ 243 | \__( 'Verify that requests to %s are not blocked by security plugins or server rules.', 'webfinger' ), 244 | '/.well-known/webfinger' 245 | ) . '
  8. '; 246 | break; 247 | 248 | case 'connection_error': 249 | $actions .= '
  9. ' . \__( 'Check if your site is accessible from the internet (not localhost or behind a firewall).', 'webfinger' ) . '
  10. '; 250 | $actions .= '
  11. ' . \__( 'Verify your SSL certificate is valid if using HTTPS.', 'webfinger' ) . '
  12. '; 251 | $actions .= '
  13. ' . \__( 'Check if your hosting provider allows loopback connections.', 'webfinger' ) . '
  14. '; 252 | $actions .= '
  15. ' . \__( 'Temporarily disable security plugins to check for conflicts.', 'webfinger' ) . '
  16. '; 253 | break; 254 | 255 | case 'server_error': 256 | $actions .= '
  17. ' . \__( 'Check your server error logs for more details.', 'webfinger' ) . '
  18. '; 257 | $actions .= '
  19. ' . \__( 'Temporarily disable other plugins to check for conflicts.', 'webfinger' ) . '
  20. '; 258 | $actions .= '
  21. ' . \__( 'Verify PHP has enough memory allocated (at least 128MB recommended).', 'webfinger' ) . '
  22. '; 259 | $actions .= '
  23. ' . \__( 'Contact your hosting provider if the issue persists.', 'webfinger' ) . '
  24. '; 260 | break; 261 | 262 | case 'invalid_response': 263 | $actions .= '
  25. ' . \__( 'Check if another plugin is intercepting the WebFinger request.', 'webfinger' ) . '
  26. '; 264 | $actions .= '
  27. ' . \__( 'Temporarily disable caching plugins and try again.', 'webfinger' ) . '
  28. '; 265 | $actions .= '
  29. ' . \__( 'Verify the WebFinger plugin is up to date.', 'webfinger' ) . '
  30. '; 266 | break; 267 | } 268 | 269 | $actions .= '
'; 270 | 271 | // Add link to test endpoint manually. 272 | $endpoint = \home_url( '/.well-known/webfinger' ); 273 | $actions .= '

' . \sprintf( 274 | /* translators: %s: WebFinger endpoint URL */ 275 | \__( 'Test the endpoint manually: %s', 'webfinger' ), 276 | '' . \esc_html( $endpoint ) . '' 277 | ) . '

'; 278 | 279 | return $actions; 280 | } 281 | } 282 | --------------------------------------------------------------------------------