PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / redirects / RegexAutoPromote.php

RegexAutoPromote.php in 404 Solution trunk, at includes/redirects/RegexAutoPromote.php

191 lines 8.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Server-side regex auto-promotion helpers.
9 *
10 * The JS in includes/ajax/redirect_to_ajax.js already auto-ticks the
11 * "Treat as regex" checkbox when an admin types a URL that looks like a
12 * regex pattern. This class is the server-side counterpart: it lets the
13 * admin-save, import, and runtime-matching paths reach the same conclusion
14 * when the JS detection didn't run (paste-and-submit with JS disabled, CSV
15 * import, existing MANUAL row written by an older version, etc.).
16 *
17 * The character set used here (`* [ ] | ^ \ { }`) is intentionally narrower
18 * than the JS detector: every character is reserved/unsafe per RFC 3986, so
19 * browsers URL-encode them. Their RAW presence in a stored from_url is
20 * near-certain regex intent. False positives would require a user to
21 * paste an already URL-encoded query string verbatim, which is not a real
22 * workflow this plugin needs to support.
23 *
24 * Explicitly NOT included: `. ? + ( ) & = #`. These appear in real URLs
25 * constantly (file extensions, query strings, fragments) and a sniff that
26 * promoted them would catch many real MANUAL redirects by mistake.
27 */
28 class ABJ_404_Solution_RegexAutoPromote {
29
30 /**
31 * Decide whether a raw from_url contains regex metachars unambiguous
32 * enough to auto-promote status=MANUAL to status=REGEX.
33 *
34 * @param string $url The raw from_url as stored / as posted.
35 * @return bool True when the URL contains at least one unambiguous regex
36 * metacharacter from the safe set described in the class
37 * docblock.
38 */
39 public static function looksLikeUnambiguousRegex($url) {
40 if (!is_string($url) || $url === '') {
41 return false;
42 }
43
44 // Each char in the bracket below is RFC 3986-reserved or non-URL-safe.
45 // A browser submitting these via the address bar would URL-encode
46 // them (`%5B`, `%7C`, etc.), so a raw presence is strong regex
47 // intent. Backslash is included because a plain URL never contains
48 // one. `\d`, `\w`, `\s` are PCRE shorthand classes only.
49 return preg_match('/[\*\[\]\|\^\\\\\{\}]/', $url) === 1;
50 }
51
52 /**
53 * Apply minimal glob-to-regex normalization on the auto-promote path
54 * only. This handles the "user typed `/sales/*` and meant `/sales/.*`"
55 * case: shells use bare `*` for "zero or more of anything", but PCRE's
56 * `*` is a quantifier that needs something in front of it. Without this
57 * fix, `/sales/*` compiles as the literal string `/sales/` followed by
58 * the regex error "nothing to repeat". Every 404 hit logs a PHP warning
59 * and the redirect never fires.
60 *
61 * Rules (deliberately narrow). The user can always uncheck the
62 * "Treat as regex" box if we guess wrong, and the admin notice points
63 * out the rewrite so it is visible:
64 *
65 * 1. Pattern contains `*` AND nowhere contains `.*`: replace every
66 * bare `*` with `.*`. Covers `/sales/*` and `*-old.html` both.
67 * 2. Pattern does not contain `*`: no change.
68 * 3. Pattern already contains `.*` somewhere: leave the bare `*`s
69 * alone. The user (or upstream) clearly knows the difference.
70 *
71 * Explicitly NOT applied:
72 * - Auto-escaping `.` (would break legitimate `.*` users).
73 * - Auto-anchoring with `^` / `$` (some users want substring match).
74 * - Quoting other metachars. We trust the pattern shape from here on
75 * and the import-time validator (validateRegexPattern) catches the
76 * leftover compile failures.
77 *
78 * This MUST be called only on the auto-promote path. When the user
79 * explicitly checks "Treat as regex" on a weird pattern like `(foo)*`,
80 * they meant exactly what they wrote and we don't second-guess them.
81 *
82 * @param string $url Raw from_url to consider for normalization.
83 * @return array{url: string, changed: bool}
84 */
85 public static function applyGlobFixup($url) {
86 if (!is_string($url)) {
87 return array('url' => '', 'changed' => false);
88 }
89 if ($url === '') {
90 return array('url' => '', 'changed' => false);
91 }
92 if (strpos($url, '*') === false) {
93 return array('url' => $url, 'changed' => false);
94 }
95 if (strpos($url, '.*') !== false) {
96 return array('url' => $url, 'changed' => false);
97 }
98 // Replace each bare `*` with `.*`. preg_replace with a negative
99 // lookbehind for `.` would also work, but a straight str_replace
100 // suffices since we already proved no `.*` exists anywhere.
101 $rewritten = str_replace('*', '.*', $url);
102 return array('url' => $rewritten, 'changed' => $rewritten !== $url);
103 }
104
105 /**
106 * Single-tenant transient key holding the most recent auto-promote
107 * event. Sites with concurrent admins editing redirects will see
108 * latest-wins behavior on this notice; the underlying redirect rows
109 * are still per-row so no data is mixed up, just the dismissable
110 * UI banner. This is the canonical "small UX feature, big
111 * complexity if multi-keyed" tradeoff: a per-user key would force
112 * every test that renders the admin header to stub
113 * get_current_user_id even after Brain Monkey teardown, because
114 * Patchwork keeps the function registered globally.
115 */
116 const NOTICE_TRANSIENT_KEY = 'abj404_regex_autopromote_notice';
117
118 /**
119 * Persist an auto-promote event. The payload is small (~5 fields)
120 * and one-shot per save; the next admin page render reads it back
121 * and shows a notice with [Edit] and [Undo] links.
122 *
123 * Kept on the helper class (not PluginLogic) so the view layer can
124 * read the notice without going through a typed PluginLogic method
125 * call. Mockery's strict-call behavior would otherwise force every
126 * existing PluginLogic mock to pre-declare an expectation on the
127 * read method.
128 *
129 * @param int $redirectId The redirect row id that was just saved.
130 * @param string $originalURL The from_url posted by the admin (pre-rewrite).
131 * @param string $newURL The from_url that ended up stored.
132 * @param bool $urlRewritten True when the glob fixup mutated the URL.
133 * @return void
134 */
135 public static function saveNotice($redirectId, $originalURL, $newURL, $urlRewritten) {
136 if ($redirectId <= 0 || !function_exists('set_transient')) {
137 return;
138 }
139 $payload = array(
140 'redirect_id' => (int)$redirectId,
141 'original_url' => (string)$originalURL,
142 'new_url' => (string)$newURL,
143 'url_rewritten' => (bool)$urlRewritten,
144 'created_at' => abj_clock()->now(),
145 );
146 $ttl = defined('HOUR_IN_SECONDS') ? HOUR_IN_SECONDS : 3600;
147 // allow-cache-empty: $payload built locally from method args, never a failure-derived empty/null.
148 set_transient(self::NOTICE_TRANSIENT_KEY, $payload, $ttl);
149 }
150
151 /**
152 * Read the pending notice payload, or null when no notice is
153 * pending. Leaves the transient in place so the Undo handler can
154 * still find the original from_url after the notice has been
155 * rendered. The transient self-expires via its TTL.
156 *
157 * @return array{redirect_id: int, original_url: string, new_url: string, url_rewritten: bool, created_at: int}|null
158 */
159 public static function readNotice() {
160 if (!function_exists('get_transient')) {
161 return null;
162 }
163 $data = get_transient(self::NOTICE_TRANSIENT_KEY);
164 if (!is_array($data) || !isset($data['redirect_id'])) {
165 return null;
166 }
167 $redirectId = isset($data['redirect_id']) && is_numeric($data['redirect_id']) ? (int)$data['redirect_id'] : 0;
168 $createdAt = isset($data['created_at']) && is_numeric($data['created_at']) ? (int)$data['created_at'] : 0;
169 return array(
170 'redirect_id' => $redirectId,
171 'original_url' => isset($data['original_url']) && is_string($data['original_url']) ? $data['original_url'] : '',
172 'new_url' => isset($data['new_url']) && is_string($data['new_url']) ? $data['new_url'] : '',
173 'url_rewritten' => !empty($data['url_rewritten']),
174 'created_at' => $createdAt,
175 );
176 }
177
178 /**
179 * Delete the pending notice (called after a successful Undo or an
180 * explicit dismissal).
181 *
182 * @return void
183 */
184 public static function clearNotice() {
185 if (!function_exists('delete_transient')) {
186 return;
187 }
188 delete_transient(self::NOTICE_TRANSIENT_KEY);
189 }
190 }
191