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 | '