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

class-object-redirect.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/seo/class-object-redirect.php

237 lines 7.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Object Redirect
5 *
6 * @package ThinkRank\SEO
7 * @since 2.7.0
8 */
9
10 declare(strict_types=1);
11
12 namespace ThinkRank\SEO;
13
14 // Prevent direct access
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 /**
20 * Object Redirect
21 *
22 * The free-side contract behind the "Redirect this to" field on the post and
23 * term SEO surfaces: one destination URL and one status code per object.
24 *
25 * Free owns the field; it does not own the data. Redirects are a Pro feature,
26 * and a redirect stored here as post meta would be a second source of truth
27 * next to the Pro rules table — the edit screen would keep showing a redirect
28 * that a manager deletion had already stopped serving, and the 404 monitor
29 * would never see it. So this class only brokers: it reads through
30 * `thinkrank_object_redirect` and writes through
31 * `thinkrank_object_redirect_save`, and with Pro inactive nothing answers
32 * either, which is what makes the field an honest upsell rather than an input
33 * that quietly discards what is typed into it.
34 *
35 * @since 2.7.0
36 */
37 class Object_Redirect {
38
39 /**
40 * Status codes the field offers.
41 *
42 * 301 is the default because the field's purpose — a retired page pointing
43 * at its replacement — is a permanent move.
44 */
45 public const TYPES = [301, 302, 307];
46
47 /**
48 * Default status code.
49 */
50 public const DEFAULT_TYPE = 301;
51
52 /**
53 * Whether a redirect provider is active.
54 *
55 * @return bool
56 */
57 public static function is_supported(): bool {
58 /**
59 * Filter whether per-object redirects can be stored.
60 *
61 * Answered by ThinkRank Pro when the Redirections feature is running.
62 *
63 * @since 2.7.0
64 *
65 * @param bool $supported Whether a provider is available.
66 */
67 return (bool) apply_filters('thinkrank_object_redirect_supported', false);
68 }
69
70 /**
71 * The redirect currently set on an object.
72 *
73 * @since 2.7.0
74 *
75 * @param string $object_type 'post' or 'term'.
76 * @param int $object_id Object ID.
77 * @return array{url:string,type:int} Empty url when there is no redirect.
78 */
79 public static function get(string $object_type, int $object_id): array {
80 $empty = ['url' => '', 'type' => self::DEFAULT_TYPE];
81
82 if (!self::is_supported()) {
83 return $empty;
84 }
85
86 /**
87 * Filter the stored redirect for an object.
88 *
89 * @since 2.7.0
90 *
91 * @param array|null $redirect {url, type}, or null when none is set.
92 * @param string $object_type 'post' or 'term'.
93 * @param int $object_id Object ID.
94 */
95 $redirect = apply_filters('thinkrank_object_redirect', null, $object_type, $object_id);
96
97 if (!is_array($redirect) || empty($redirect['url'])) {
98 return $empty;
99 }
100
101 return [
102 'url' => (string) $redirect['url'],
103 'type' => self::normalize_type($redirect['type'] ?? self::DEFAULT_TYPE),
104 ];
105 }
106
107 /**
108 * Set or clear an object's redirect.
109 *
110 * @since 2.7.0
111 *
112 * @param string $object_type 'post' or 'term'.
113 * @param int $object_id Object ID.
114 * @param string $url Destination; '' clears the redirect.
115 * @param mixed $type Status code.
116 * @return true|\WP_Error
117 */
118 public static function save(string $object_type, int $object_id, string $url, $type = self::DEFAULT_TYPE) {
119 if (!self::is_supported()) {
120 return new \WP_Error(
121 'thinkrank_redirect_unsupported',
122 __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
123 );
124 }
125
126 $url = trim($url);
127
128 // Validate what was typed, then sanitize — not the other way round.
129 // esc_url_raw() answers a scheme it will not allow with an empty
130 // string, and an empty destination already means "remove the
131 // redirect", so validating the sanitized value turned `javascript:`
132 // into a silent clear of whatever redirect the object already had.
133 if ('' !== $url) {
134 if (!self::is_valid_url($url)) {
135 return self::invalid_url_error();
136 }
137
138 $url = self::sanitize_url($url);
139
140 if ('' === $url) {
141 return self::invalid_url_error();
142 }
143 }
144
145 /**
146 * Store an object's redirect.
147 *
148 * Implementations return true, or a WP_Error explaining why the
149 * redirect could not be stored. Returning null (nothing listening)
150 * is treated as a failure by the caller below.
151 *
152 * @since 2.7.0
153 *
154 * @param mixed $result Result so far.
155 * @param string $object_type 'post' or 'term'.
156 * @param int $object_id Object ID.
157 * @param string $url Destination; '' clears the redirect.
158 * @param int $type Status code.
159 */
160 $result = apply_filters(
161 'thinkrank_object_redirect_save',
162 null,
163 $object_type,
164 $object_id,
165 $url,
166 self::normalize_type($type)
167 );
168
169 if (is_wp_error($result)) {
170 return $result;
171 }
172
173 if (true !== $result) {
174 return new \WP_Error(
175 'thinkrank_redirect_not_stored',
176 __('The redirect could not be stored.', 'thinkrank')
177 );
178 }
179
180 return true;
181 }
182
183 /**
184 * Clamp a submitted status code to one the field offers.
185 *
186 * @param mixed $type Submitted code.
187 * @return int
188 */
189 public static function normalize_type($type): int {
190 $type = (int) $type;
191
192 return in_array($type, self::TYPES, true) ? $type : self::DEFAULT_TYPE;
193 }
194
195 /**
196 * The one message for every unusable destination.
197 *
198 * @return \WP_Error
199 */
200 private static function invalid_url_error(): \WP_Error {
201 return new \WP_Error(
202 'thinkrank_redirect_invalid_url',
203 __('Enter a full URL starting with http:// or https://, or a path starting with /.', 'thinkrank')
204 );
205 }
206
207 /**
208 * Sanitize an already-validated destination.
209 *
210 * @param string $url Validated value.
211 * @return string
212 */
213 private static function sanitize_url(string $url): string {
214 return esc_url_raw($url, ['http', 'https']);
215 }
216
217 /**
218 * Whether a destination is one we are willing to redirect to.
219 *
220 * @param string $url Trimmed value as entered.
221 * @return bool
222 */
223 private static function is_valid_url(string $url): bool {
224 // A site-relative path is the common case for an internal move.
225 // "//evil.com" is a protocol-relative absolute URL wearing a path's
226 // clothes, so a leading slash alone is not enough.
227 if (str_starts_with($url, '/') && !str_starts_with($url, '//')) {
228 return true;
229 }
230
231 $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
232
233 return in_array($scheme, ['http', 'https'], true)
234 && '' !== (string) wp_parse_url($url, PHP_URL_HOST);
235 }
236 }
237