PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-headless-url-builder.php

class-metasync-headless-url-builder.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at includes/class-metasync-headless-url-builder.php

379 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Public URL builder for headless mode.
4 *
5 * On a headless site every URL WordPress reports names the WordPress host, not
6 * the host the page is actually served from. get_permalink(), get_term_link(),
7 * get_author_posts_url() and home_url() all do this, so canonicals, og:url and
8 * every URL in a payload come out pointing at a backend nobody should reach.
9 *
10 * The fix is to stop asking WordPress for the host: keep the path it produced,
11 * throw the host away, and prefix the stored frontend domain. That is all this
12 * class does. It has no opinion about which object the path belongs to — the
13 * per-object-type resolvers own that and hand a WordPress URL down here.
14 *
15 * Three rules are applied on the way through, all of them stored settings:
16 *
17 * - the frontend domain replaces whatever host the input carried;
18 * - a per-post-type path prefix can be inserted, for a frontend that serves a
19 * post type under a path WordPress does not use;
20 * - a trailing-slash policy is applied last, because the frontend router — not
21 * WordPress — decides whether a trailing slash is correct.
22 *
23 * Every method fails closed: given an unconfigured install, or input it cannot
24 * make sense of, it returns an empty string rather than a half-built URL. A
25 * caller that gets '' must fall back to its existing behaviour, which keeps a
26 * misconfigured headless site on the path it would have taken anyway.
27 *
28 * @package Metasync
29 * @subpackage Metasync/includes
30 */
31
32 if (!defined('ABSPATH')) {
33 exit; # Exit if accessed directly
34 }
35
36 class Metasync_Headless_Url_Builder
37 {
38 /**
39 * Build the public URL for a URL WordPress produced.
40 *
41 * The normal entry point: hand it whatever get_permalink() / get_term_link()
42 * / home_url() returned and it comes back on the public host.
43 *
44 * @param mixed $wp_url A URL or path from WordPress.
45 * @param string $post_type Post type the URL belongs to, for the prefix
46 * rule. Pass '' for anything that is not a single
47 * post — terms, archives, the homepage.
48 * @return string Public URL, or '' when one cannot be built.
49 */
50 public static function from_wp_url($wp_url, $post_type = '')
51 {
52 $path = self::extract_path($wp_url);
53 if ($path === null) {
54 return '';
55 }
56
57 return self::from_path($path, $post_type);
58 }
59
60 /**
61 * Build the public URL for a site-relative path.
62 *
63 * @param mixed $path Site-relative path, with or without a leading slash.
64 * @param string $post_type Post type the path belongs to, for the prefix rule.
65 * @return string Public URL, or '' when one cannot be built.
66 */
67 public static function from_path($path, $post_type = '')
68 {
69 # Gated on is_active(), not merely on a stored domain. Two independent
70 # reviews of this branch pointed at the same hazard: a builder that
71 # answers whenever a domain happens to be stored leaves flag-off
72 # correctness resting on every caller remembering to check first, and a
73 # domain left behind from an earlier experiment is enough to hand back a
74 # frontend URL. Refusing here makes the whole subsystem inert by
75 # construction rather than by caller discipline.
76 if (!Metasync_Headless_Config::is_active()) {
77 return '';
78 }
79
80 $domain = Metasync_Headless_Config::get_frontend_domain();
81
82 if (!is_string($path) && !is_numeric($path)) {
83 return '';
84 }
85
86 $path = self::normalize_path((string) $path);
87
88 # Fail closed on a relative segment rather than resolve it. A permalink
89 # has no legitimate reason to contain one, and joining it to the frontend
90 # origin would produce a URL that resolves somewhere else entirely —
91 # https://frontend/a/../b/ is https://frontend/b/ to every client that
92 # normalises it. Silently emitting a canonical for a different page is
93 # worse than emitting none.
94 if (Metasync_Headless_Config::has_relative_segment($path)) {
95 return '';
96 }
97
98 $path = self::apply_prefix($path, $post_type);
99
100 return $domain . self::apply_slash_policy($path);
101 }
102
103 /**
104 * The public homepage URL.
105 *
106 * @return string Public URL, or '' when one cannot be built.
107 */
108 public static function home()
109 {
110 return self::from_path('/');
111 }
112
113 /**
114 * Reduce a WordPress URL to the path the frontend serves it under.
115 *
116 * Two things are dropped beyond the host. The query string and fragment go
117 * because this builds addresses for canonical-style output, where a
118 * parameterised URL is not the address of the page. The WordPress install's
119 * own subdirectory goes because it belongs to WordPress: a backend at
120 * https://backend.example.com/wp/ serves /wp/about/, but the frontend serves
121 * /about/ — carrying the /wp across would produce a path that does not exist
122 * there.
123 *
124 * A path handed in without a host is accepted as-is, which is what makes
125 * from_path() and from_wp_url() interchangeable for callers that already
126 * hold a path.
127 *
128 * @param mixed $wp_url URL or path from WordPress.
129 * @return string|null Path with a leading slash, or null when unusable.
130 */
131 private static function extract_path($wp_url)
132 {
133 if (!is_string($wp_url)) {
134 return null;
135 }
136
137 $wp_url = trim($wp_url);
138 if ($wp_url === '') {
139 return null;
140 }
141
142 $parts = wp_parse_url($wp_url);
143 if ($parts === false) {
144 return null;
145 }
146
147 $path = isset($parts['path']) ? $parts['path'] : '';
148
149 # A plain-permalink install produces https://host/?p=123 — host, empty
150 # path, everything identifying the post in the query string this builder
151 # deliberately discards. Treating that as the root would give every post
152 # on the site the homepage as its canonical, which is a de-indexing
153 # signal emitted silently. Fail closed instead; the caller keeps its own
154 # behaviour. The rendered path reaches the same conclusion about `?p=`
155 # permalinks and rebuilds them rather than trusting them.
156 if (self::identifies_object_by_query($parts)) {
157 return null;
158 }
159
160 # A bare host with no path and no query ("https://example.com") really
161 # does address the root.
162 if ($path === '' && !empty($parts['host'])) {
163 $path = '/';
164 }
165
166 if ($path === '') {
167 return null;
168 }
169
170 return self::strip_site_subdirectory($path);
171 }
172
173 /**
174 * Is the object identified by the query string rather than the path?
175 *
176 * A plain-permalink install produces `https://host/?p=123`: the path is just
177 * `/` and everything naming the post sits in the query, which this builder
178 * discards. Left alone that yields the frontend root as the canonical for
179 * every post on the site — a sitewide canonical-to-homepage, which is a
180 * de-indexing signal emitted silently.
181 *
182 * Only a query that actually identifies an object counts, so an ordinary URL
183 * carrying tracking parameters still resolves to its path. The parameter list
184 * mirrors the checks the rendered path already makes before deciding a
185 * permalink is unusable.
186 *
187 * @param array $parts Parsed URL.
188 * @return bool
189 */
190 private static function identifies_object_by_query($parts)
191 {
192 if (empty($parts['query'])) {
193 return false;
194 }
195
196 $path = isset($parts['path']) ? trim((string) $parts['path'], '/') : '';
197 if ($path !== '') {
198 return false;
199 }
200
201 $query = array();
202 parse_str((string) $parts['query'], $query);
203
204 foreach (self::object_identifying_query_vars() as $param) {
205 if (isset($query[$param]) && $query[$param] !== '') {
206 return true;
207 }
208 }
209
210 return false;
211 }
212
213 /**
214 * Query variables that supply an address rather than decorate one.
215 *
216 * Each of these appears in a URL WordPress produces when no permalink
217 * structure is in use, and in every case the address lives in the query
218 * rather than the path — so joining the path alone to the frontend domain
219 * yields the frontend root, handing a page the homepage as its canonical.
220 *
221 * The taxonomy pair matters as much as the post ones. get_term_link()
222 * returns `?cat=` only for categories; every other taxonomy comes back as
223 * `?taxonomy=genre&term=jazz`, so listing the post variables alone left
224 * every custom-taxonomy term on such a site resolving to the root.
225 *
226 * Anything absent from this list — utm_*, fbclid, a session id — merely
227 * decorates an address the path already carries, so a URL bearing only
228 * those still resolves normally.
229 *
230 * @return string[]
231 */
232 private static function object_identifying_query_vars()
233 {
234 return array(
235 # A single post, page or attachment.
236 'p',
237 'page_id',
238 'pagename',
239 'name',
240 'attachment',
241 'attachment_id',
242 'post_type',
243
244 # A taxonomy archive.
245 'cat',
246 'category_name',
247 'tag',
248 'tag_id',
249 'taxonomy',
250 'term',
251 'term_id',
252
253 # An author archive.
254 'author',
255 'author_name',
256
257 # A date archive.
258 'year',
259 'monthnum',
260 'day',
261 'm',
262 'w',
263
264 # A search results page, which is not an addressable page at all.
265 's',
266 );
267 }
268
269 /**
270 * Remove the WordPress install's subdirectory from the front of a path.
271 *
272 * A no-op on the overwhelmingly common root install, where home_url() has no
273 * path component.
274 *
275 * @param string $path Path with a leading slash.
276 * @return string
277 */
278 private static function strip_site_subdirectory($path)
279 {
280 $home = wp_parse_url(home_url('/'));
281 if (!is_array($home) || empty($home['path'])) {
282 return $path;
283 }
284
285 $base = '/' . trim($home['path'], '/');
286 if ($base === '/') {
287 return $path;
288 }
289
290 # Only strip a whole leading segment: a base of /wp must not eat the
291 # front of /wpengine-something.
292 if (strpos($path, $base . '/') === 0) {
293 return substr($path, strlen($base));
294 }
295
296 if ($path === $base) {
297 return '/';
298 }
299
300 return $path;
301 }
302
303 /**
304 * Normalise a path to a single leading slash and no duplicate separators.
305 *
306 * @param string $path Raw path.
307 * @return string Path with a leading slash.
308 */
309 private static function normalize_path($path)
310 {
311 $path = trim($path);
312 if ($path === '') {
313 return '/';
314 }
315
316 $path = str_replace('\\', '/', $path);
317 $path = preg_replace('#/+#', '/', $path);
318 $path = '/' . ltrim((string) $path, '/');
319
320 return $path;
321 }
322
323 /**
324 * Insert the configured path prefix for a post type.
325 *
326 * Skipped when the path already starts with the prefix. That is not
327 * defensive padding: a site can filter post_link so get_permalink() already
328 * returns the public path, prefix included, and prefixing again would
329 * produce /blog/blog/my-post. Since the guard makes the operation
330 * idempotent, both kinds of install can share one code path.
331 *
332 * @param string $path Normalised path.
333 * @param string $post_type Post type name, or '' for none.
334 * @return string
335 */
336 private static function apply_prefix($path, $post_type)
337 {
338 $prefix = Metasync_Headless_Config::get_path_prefix($post_type);
339 if ($prefix === '') {
340 return $path;
341 }
342
343 $prefixed = '/' . $prefix;
344
345 if ($path === $prefixed || strpos($path, $prefixed . '/') === 0) {
346 return $path;
347 }
348
349 return $prefixed . $path;
350 }
351
352 /**
353 * Apply the configured trailing-slash policy.
354 *
355 * The root path is exempt from `strip`: reducing '/' to '' would leave the
356 * bare origin, and while a browser treats that as the root, it is a
357 * different string from every other canonical the site emits — enough to
358 * read as a distinct URL to anything comparing them.
359 *
360 * @param string $path Normalised, prefixed path.
361 * @return string
362 */
363 private static function apply_slash_policy($path)
364 {
365 $policy = Metasync_Headless_Config::get_slash_policy();
366
367 if ($policy === Metasync_Headless_Config::SLASH_STRIP) {
368 return $path === '/' ? '/' : rtrim($path, '/');
369 }
370
371 if ($policy === Metasync_Headless_Config::SLASH_ADD) {
372 return substr($path, -1) === '/' ? $path : $path . '/';
373 }
374
375 # SLASH_PRESERVE — hand back whatever the permalink structure produced.
376 return $path;
377 }
378 }
379