PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/mcp/class-mcp-static-discovery.php +144 -2 2.0.0 → 2.9.0 View file →
@@ -24,8 +24,19 @@
24 24 * and no files are written, so metadata can never go stale there);
25 25 * - content is refreshed on every publish, so each self-test run keeps the
26 26 * files current with home_url()/settings changes.
27 27 *
28 + * The documents embed absolute, home_url()-derived identifiers (issuer,
29 + * resource, authorization_endpoint, token_endpoint), and the whole point of
30 + * writing them as files is that the web server serves them BEFORE WordPress.
31 + * So once the site's URL changes, the stale copy wins over the correct dynamic
32 + * route and the site advertises its previous domain's issuer — which a
33 + * spec-compliant client is required to reject. Nothing used to rewrite them:
34 + * publish() ran only from the self-test and remove() only on deactivation, so
35 + * the files sat there advertising a domain the site no longer has, with no
36 + * signal anywhere (#486). refresh(), hooked to both URL options, is what keeps
37 + * them honest.
38 + *
28 39 * Known limitation: the files are extensionless (the URL path has no .json),
29 40 * so an edge server may send them without an application/json Content-Type.
30 41 * Every client observed so far parses the body regardless, and a 200 with a
31 42 * loose Content-Type strictly beats the 404 it replaces.
@@ -64,8 +75,133 @@
64 75 ];
65 76 }
66 77
67 78 /**
79 + * Hook the site-URL options so published files cannot outlive the URL they
80 + * were generated for.
81 + *
82 + * Registered unconditionally, not behind `enable_mcp`: a stale document is
83 + * harmful whether or not the MCP server is currently switched on, and
84 + * these files are served without WordPress having any say in it.
85 + *
86 + * @since 2.1.0
87 + *
88 + * @return void
89 + */
90 + public static function init(): void {
91 + // accepted_args = 0 on purpose. update_option_{$option} fires with
92 + // ( $old_value, $value, $option ), and refresh()'s only parameter is a
93 + // base DIRECTORY — so the default of 1 would hand it the previous site
94 + // URL as a filesystem path and it would silently find nothing to do.
95 + add_action( 'update_option_home', [ __CLASS__, 'refresh' ], 10, 0 );
96 + add_action( 'update_option_siteurl', [ __CLASS__, 'refresh' ], 10, 0 );
97 + }
98 +
99 + /**
100 + * Bring published files back in line with the current site URL.
101 + *
102 + * Deliberately does NOT publish where nothing was published before —
103 + * writing these files is the self-test's call, made only after it has
104 + * measured that the dynamic route is dead. This just keeps an existing
105 + * set honest.
106 + *
107 + * If a rewrite is not possible (permissions changed with the move, which
108 + * is common), the files are removed instead. No document beats a document
109 + * naming the wrong issuer: without the files the dynamic route serves
110 + * again, and the next self-test run republishes if it is still needed.
111 + *
112 + * @since 2.1.0
113 + *
114 + * @param string|null $base Base directory (defaults to ABSPATH); a
115 + * parameter so tests can point it at a sandbox.
116 + * @return void
117 + */
118 + public static function refresh( ?string $base = null ): void {
119 + if ( ! self::published( $base ) ) {
120 + return;
121 + }
122 +
123 + if ( ! self::publish( $base ) ) {
124 + self::remove( $base );
125 + }
126 + }
127 +
128 + /**
129 + * Whether any of the discovery documents exist on disk.
130 + *
131 + * @since 2.1.0
132 + *
133 + * @param string|null $base Base directory (defaults to ABSPATH).
134 + * @return bool
135 + */
136 + public static function published( ?string $base = null ): bool {
137 + $base = trailingslashit( $base ?? ABSPATH );
138 +
139 + foreach ( array_keys( self::files() ) as $relative ) {
140 + if ( is_file( $base . $relative ) ) {
141 + return true;
142 + }
143 + }
144 +
145 + return false;
146 + }
147 +
148 + /**
149 + * The identifier a published file advertises, when it disagrees with what
150 + * this site is now.
151 + *
152 + * Read off disk rather than over HTTP on purpose. The HTTP probe only sees
153 + * these files on a host that actually serves /.well-known/ ahead of
154 + * WordPress, and loopback does not always take the same path an external
155 + * client does — so a site can serve a stale document to the whole internet
156 + * while the self-test's own request never sees it.
157 + *
158 + * @since 2.1.0
159 + *
160 + * @param string|null $base Base directory (defaults to ABSPATH).
161 + * @return array{file:string,key:string,found:string,expected:string}|null
162 + */
163 + public static function stale_document( ?string $base = null ): ?array {
164 + $base = trailingslashit( $base ?? ABSPATH );
165 +
166 + $identifiers = [
167 + '.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH => [
168 + 'key' => 'resource',
169 + 'expected' => Mcp_Pairing::site_endpoint(),
170 + ],
171 + '.well-known/oauth-authorization-server/' . Mcp_Pairing::SITE_ENDPOINT_PATH => [
172 + 'key' => 'issuer',
173 + 'expected' => Mcp_OAuth::issuer(),
174 + ],
175 + ];
176 +
177 + foreach ( $identifiers as $relative => $spec ) {
178 + $path = $base . $relative;
179 +
180 + if ( ! is_file( $path ) ) {
181 + continue;
182 + }
183 +
184 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file we wrote ourselves.
185 + $document = json_decode( (string) file_get_contents( $path ), true );
186 + $found = is_array( $document ) && isset( $document[ $spec['key'] ] )
187 + ? (string) $document[ $spec['key'] ]
188 + : '';
189 +
190 + if ( $found !== $spec['expected'] ) {
191 + return [
192 + 'file' => $relative,
193 + 'key' => $spec['key'],
194 + 'found' => $found,
195 + 'expected' => $spec['expected'],
196 + ];
197 + }
198 + }
199 +
200 + return null;
201 + }
202 +
203 + /**
68 204 * Whether static publishing is even applicable here.
69 205 *
70 206 * @return bool
71 207 */
@@ -102,10 +238,16 @@
102 238 if ( ! wp_mkdir_p( dirname( $path ) ) ) {
103 239 $all_current = false;
104 240 continue;
105 241 }
106 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- small static file at a fixed path; WP_Filesystem adds credential prompts this non-interactive path cannot answer.
107 - if ( false === file_put_contents( $path, $json ) ) {
242 + // Silenced deliberately: failing to write here is an ANTICIPATED
243 + // outcome, handled by the return value — the site root is often
244 + // not writable, and after a migration the files can be owned by
245 + // someone else (#486). A raw PHP warning would be emitted into
246 + // whatever response happens to be open, which for the self-test
247 + // means corrupting its JSON body.
248 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- small static file at a fixed path; WP_Filesystem adds credential prompts this non-interactive path cannot answer.
249 + if ( false === @file_put_contents( $path, $json ) ) {
108 250 $all_current = false;
109 251 }
110 252 }
111 253