PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
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 1.1.0 1.10.0 All 48 releases
thinkrank / includes / mcp / class-mcp-static-discovery.php

class-mcp-static-discovery.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.2, at includes/mcp/class-mcp-static-discovery.php

154 lines 5.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Static OAuth discovery files — the /.well-known/ escape hatch.
4 *
5 * Some hosts (SiteGround shared hosting confirmed, #374) resolve every
6 * request under the site root's /.well-known/ directory at their Nginx edge
7 * as a physical path. No rewrite, no Apache, no WordPress — a missing file is
8 * a server-level 404, and no amount of permalink flushing can fix it because
9 * the request never reaches PHP.
10 *
11 * The primary mitigation is that the 401 challenge now advertises a
12 * REST-served metadata URL (Mcp_OAuth::resource_metadata_url), which such
13 * hosts do pass through. But a client that ignores the challenge pointer and
14 * derives the RFC 9728 / RFC 8414 path-insert URLs itself still fetches
15 * /.well-known/oauth-*\/thinkrank/mcp — so this class turns the host's own
16 * behaviour into the fix: if Nginx insists on serving physical files from
17 * /.well-known/, we write the discovery documents AS physical files.
18 *
19 * Publishing is best-effort and deliberately conservative:
20 * - only for a site installed at the domain root (on a subdirectory
21 * install the domain's /.well-known/ belongs to a different tree);
22 * - only invoked from the self-test, and only after it has measured that
23 * the dynamic route is dead (on healthy hosts the rewrites keep serving
24 * and no files are written, so metadata can never go stale there);
25 * - content is refreshed on every publish, so each self-test run keeps the
26 * files current with home_url()/settings changes.
27 *
28 * Known limitation: the files are extensionless (the URL path has no .json),
29 * so an edge server may send them without an application/json Content-Type.
30 * Every client observed so far parses the body regardless, and a 200 with a
31 * loose Content-Type strictly beats the 404 it replaces.
32 *
33 * @package ThinkRank\Mcp
34 */
35
36 declare(strict_types=1);
37
38 namespace ThinkRank\Mcp;
39
40 if ( ! defined( 'ABSPATH' ) ) {
41 exit; // Exit if accessed directly.
42 }
43
44 /**
45 * Writes/removes physical /.well-known/ OAuth discovery documents.
46 */
47 final class Mcp_Static_Discovery {
48
49 /**
50 * The path-insert discovery files, relative to the site root.
51 *
52 * Only the path-suffixed forms: the bare root forms would need
53 * `oauth-protected-resource` to be a file AND a directory at once, and
54 * spec-compliant clients derive the suffixed form from our path-based
55 * issuer anyway.
56 *
57 * @return array<string,array<string,mixed>> relative path => document.
58 */
59 private static function files(): array {
60 $suffix = Mcp_Pairing::SITE_ENDPOINT_PATH; // thinkrank/mcp.
61 return [
62 '.well-known/oauth-protected-resource/' . $suffix => Mcp_OAuth::protected_resource_metadata(),
63 '.well-known/oauth-authorization-server/' . $suffix => Mcp_OAuth::authorization_server_metadata(),
64 ];
65 }
66
67 /**
68 * Whether static publishing is even applicable here.
69 *
70 * @return bool
71 */
72 public static function applicable(): bool {
73 // Subdirectory install: the domain root (where /.well-known/ lives)
74 // is not ours to write into.
75 return '/' === ( wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ?? '/' );
76 }
77
78 /**
79 * Write the discovery documents as physical files. Returns true only when
80 * every file exists with current content afterwards.
81 *
82 * @param string|null $base Base directory (defaults to ABSPATH); a
83 * parameter so tests can point it at a sandbox.
84 * @return bool
85 */
86 public static function publish( ?string $base = null ): bool {
87 if ( null === $base && ! self::applicable() ) {
88 return false;
89 }
90 $base = trailingslashit( $base ?? ABSPATH );
91
92 $all_current = true;
93 foreach ( self::files() as $relative => $document ) {
94 $path = $base . $relative;
95 $json = (string) wp_json_encode( $document );
96
97 // Already current — don't touch the filesystem.
98 if ( is_file( $path ) && (string) file_get_contents( $path ) === $json ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file freshness check.
99 continue;
100 }
101
102 if ( ! wp_mkdir_p( dirname( $path ) ) ) {
103 $all_current = false;
104 continue;
105 }
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 ) ) {
108 $all_current = false;
109 }
110 }
111
112 return $all_current;
113 }
114
115 /**
116 * Remove the published files (and their directories when empty). Called
117 * on plugin deactivation so a static copy cannot keep advertising an
118 * OAuth server that is no longer running.
119 *
120 * @param string|null $base Base directory (defaults to ABSPATH).
121 * @return void
122 */
123 public static function remove( ?string $base = null ): void {
124 $base = trailingslashit( $base ?? ABSPATH );
125
126 foreach ( array_keys( self::files() ) as $relative ) {
127 $path = $base . $relative;
128 if ( is_file( $path ) ) {
129 wp_delete_file( $path );
130 }
131 // Prune now-empty directories up to .well-known itself, but never
132 // .well-known — other software (ACME, Apple Pay) shares it.
133 $dir = dirname( $path );
134 $stop = untrailingslashit( $base . '.well-known' );
135 while ( $dir !== $stop && is_dir( $dir ) && self::dir_is_empty( $dir ) ) {
136 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- removing only directories this class created, verified empty.
137 rmdir( $dir );
138 $dir = dirname( $dir );
139 }
140 }
141 }
142
143 /**
144 * Whether a directory contains nothing.
145 *
146 * @param string $dir Directory path.
147 * @return bool
148 */
149 private static function dir_is_empty( string $dir ): bool {
150 $entries = scandir( $dir );
151 return is_array( $entries ) && count( $entries ) <= 2; // Only . and ..
152 }
153 }
154