PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / RedirectConditionEvaluator.php

RedirectConditionEvaluator.php in 404 Solution 4.1.19, at includes/RedirectConditionEvaluator.php

325 lines 10.4 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 * Evaluates optional conditions attached to a manual redirect.
9 *
10 * Conditions are stored in abj404_redirect_conditions and evaluated at
11 * request time, after URL matching. Each condition has a `logic` field
12 * (AND or OR) that controls how it combines with the previous result.
13 *
14 * Evaluation proceeds left-to-right (ordered by sort_order):
15 * - The first condition initialises the running result.
16 * - Each subsequent condition's `logic` field determines whether its
17 * outcome is ANDed or ORed into the running result.
18 *
19 * If no conditions are stored the redirect always fires (return true).
20 */
21 class ABJ_404_Solution_RedirectConditionEvaluator {
22
23 /** @var ABJ_404_Solution_DataAccess */
24 private $dao;
25
26 /** @var array<int, array{type: string, result: bool}> */
27 private $lastTrace = [];
28
29 /**
30 * @param ABJ_404_Solution_DataAccess $dao
31 */
32 public function __construct($dao) {
33 $this->dao = $dao;
34 }
35
36 /**
37 * Return details of the most recent shouldApplyRedirect() call.
38 *
39 * @return array<int, array{type: string, result: bool}>
40 */
41 public function getLastEvaluationTrace(): array {
42 return $this->lastTrace;
43 }
44
45 /**
46 * Evaluate whether conditions allow this redirect to fire.
47 *
48 * @param int $redirectId
49 * @return bool true if redirect should proceed, false if conditions block it
50 */
51 public function shouldApplyRedirect(int $redirectId): bool {
52 $conditions = $this->getConditionsForRedirect($redirectId);
53 $this->lastTrace = [];
54
55 // No conditions = always redirect.
56 if (empty($conditions)) {
57 return true;
58 }
59
60 $result = null;
61
62 foreach ($conditions as $condition) {
63 $outcome = $this->evaluateCondition($condition);
64
65 $condType = isset($condition['condition_type']) && is_string($condition['condition_type'])
66 ? $condition['condition_type'] : 'unknown';
67 $this->lastTrace[] = ['type' => $condType, 'result' => $outcome];
68
69 if ($result === null) {
70 // First condition initialises the running result.
71 $result = $outcome;
72 } else {
73 $logic = isset($condition['logic']) && is_string($condition['logic'])
74 ? strtoupper(trim($condition['logic']))
75 : 'AND';
76
77 if ($logic === 'OR') {
78 $result = $result || $outcome;
79 } else {
80 // Default to AND.
81 $result = $result && $outcome;
82 }
83 }
84 }
85
86 return (bool)$result;
87 }
88
89 /**
90 * Get conditions for a redirect from the database.
91 *
92 * @param int $redirectId
93 * @return array<int, array<string, mixed>>
94 */
95 public function getConditionsForRedirect(int $redirectId): array {
96 return $this->dao->getRedirectConditions($redirectId);
97 }
98
99 /**
100 * Evaluate a single condition against the current request.
101 *
102 * @param array<string, mixed> $condition
103 * @return bool
104 */
105 private function evaluateCondition(array $condition): bool {
106 $type = isset($condition['condition_type']) && is_string($condition['condition_type'])
107 ? $condition['condition_type'] : '';
108 $operator = isset($condition['operator']) && is_string($condition['operator'])
109 ? $condition['operator'] : 'equals';
110 $value = isset($condition['value']) && is_string($condition['value'])
111 ? $condition['value'] : '';
112
113 switch ($type) {
114 case 'login_status':
115 return $this->evaluateLoginStatus($operator, $value);
116 case 'user_role':
117 return $this->evaluateUserRole($operator, $value);
118 case 'referrer':
119 return $this->evaluateReferrer($operator, $value);
120 case 'user_agent':
121 return $this->evaluateUserAgent($operator, $value);
122 case 'ip_range':
123 return $this->evaluateIpRange($value);
124 case 'http_header':
125 return $this->evaluateHttpHeader($operator, $value);
126 default:
127 // Unknown condition type — treat as blocking to be safe.
128 return false;
129 }
130 }
131
132 /**
133 * Evaluate a login_status condition.
134 *
135 * Expected value: 'logged_in' or 'logged_out'.
136 *
137 * @param string $operator (unused — login status is a boolean)
138 * @param string $value
139 * @return bool
140 */
141 private function evaluateLoginStatus(string $operator, string $value): bool {
142 $isLoggedIn = function_exists('is_user_logged_in') ? is_user_logged_in() : false;
143
144 if ($value === 'logged_in') {
145 return $isLoggedIn;
146 }
147 if ($value === 'logged_out') {
148 return !$isLoggedIn;
149 }
150
151 // Unknown value — fail safe (block redirect).
152 return false;
153 }
154
155 /**
156 * Evaluate a user_role condition.
157 *
158 * Expected operator: 'equals' (user has this role) or 'not_equals'.
159 * Expected value: a WordPress role slug, e.g. 'administrator', 'editor'.
160 *
161 * @param string $operator
162 * @param string $value
163 * @return bool
164 */
165 private function evaluateUserRole(string $operator, string $value): bool {
166 if (!function_exists('wp_get_current_user')) {
167 return false;
168 }
169
170 $currentUser = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user());
171 if ($currentUser === null || !$currentUser->exists()) {
172 $hasRole = false;
173 } else {
174 $hasRole = $currentUser->hasRole($value);
175 }
176
177 if ($operator === 'not_equals') {
178 return !$hasRole;
179 }
180
181 // 'equals' (default)
182 return $hasRole;
183 }
184
185 /**
186 * Evaluate a referrer condition.
187 *
188 * @param string $operator equals|contains|regex|not_equals|not_contains
189 * @param string $value
190 * @return bool
191 */
192 private function evaluateReferrer(string $operator, string $value): bool {
193 $referrer = isset($_SERVER['HTTP_REFERER']) && is_string($_SERVER['HTTP_REFERER'])
194 ? $_SERVER['HTTP_REFERER'] : '';
195
196 return $this->matchStringValue($referrer, $operator, $value);
197 }
198
199 /**
200 * Evaluate a user_agent condition.
201 *
202 * @param string $operator equals|contains|regex|not_equals|not_contains
203 * @param string $value
204 * @return bool
205 */
206 private function evaluateUserAgent(string $operator, string $value): bool {
207 $userAgent = isset($_SERVER['HTTP_USER_AGENT']) && is_string($_SERVER['HTTP_USER_AGENT'])
208 ? $_SERVER['HTTP_USER_AGENT'] : '';
209
210 return $this->matchStringValue($userAgent, $operator, $value);
211 }
212
213 /**
214 * Evaluate an ip_range condition using CIDR notation.
215 *
216 * Falls back to exact-match when no prefix length is specified.
217 * IPv6 addresses are not in CIDR scope; they use exact match.
218 *
219 * @param string $value e.g. "192.168.1.0/24" or "10.0.0.1"
220 * @return bool
221 */
222 private function evaluateIpRange(string $value): bool {
223 $clientIp = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR'])
224 ? $_SERVER['REMOTE_ADDR'] : '';
225
226 if ($clientIp === '') {
227 return false;
228 }
229
230 // No slash → exact IP match.
231 if (strpos($value, '/') === false) {
232 return $clientIp === $value;
233 }
234
235 $parts = explode('/', $value, 2);
236 $subnet = $parts[0];
237 $bits = (int)$parts[1];
238
239 $subnetLong = ip2long($subnet);
240 $clientLong = ip2long($clientIp);
241
242 // ip2long returns false for non-IPv4 addresses.
243 if ($subnetLong === false || $clientLong === false) {
244 return $clientIp === $subnet;
245 }
246
247 // Guard against invalid prefix lengths.
248 if ($bits < 0 || $bits > 32) {
249 return false;
250 }
251
252 if ($bits === 0) {
253 // /0 matches everything.
254 return true;
255 }
256
257 $mask = ~((1 << (32 - $bits)) - 1);
258 return ($clientLong & $mask) === ($subnetLong & $mask);
259 }
260
261 /**
262 * Evaluate an http_header condition.
263 *
264 * The value field must be formatted as "Header-Name: expected-value",
265 * e.g. "X-Custom-Header: myvalue".
266 *
267 * @param string $operator equals|contains|regex|not_equals|not_contains
268 * @param string $value "Header-Name: expected-value"
269 * @return bool
270 */
271 private function evaluateHttpHeader(string $operator, string $value): bool {
272 // Expect "Header-Name: expected-value"
273 $colonPos = strpos($value, ':');
274 if ($colonPos === false) {
275 return false;
276 }
277
278 $headerName = trim(substr($value, 0, $colonPos));
279 $expectedValue = trim(substr($value, $colonPos + 1));
280
281 // Convert header name to SERVER key format: e.g. "X-Custom" → "HTTP_X_CUSTOM"
282 $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $headerName));
283 $actualValue = isset($_SERVER[$serverKey]) && is_string($_SERVER[$serverKey])
284 ? $_SERVER[$serverKey] : '';
285
286 return $this->matchStringValue($actualValue, $operator, $expectedValue);
287 }
288
289 /**
290 * Match a string value against an expected value using the given operator.
291 *
292 * @param string $actual
293 * @param string $operator equals|contains|regex|not_equals|not_contains
294 * @param string $expected
295 * @return bool
296 */
297 private function matchStringValue(string $actual, string $operator, string $expected): bool {
298 switch ($operator) {
299 case 'equals':
300 return $actual === $expected;
301
302 case 'not_equals':
303 return $actual !== $expected;
304
305 case 'contains':
306 return $expected !== '' && strpos($actual, $expected) !== false;
307
308 case 'not_contains':
309 return $expected === '' || strpos($actual, $expected) === false;
310
311 case 'regex':
312 if ($expected === '') {
313 return false;
314 }
315 // Suppress errors for invalid patterns — treat as non-match.
316 $matched = @preg_match($expected, $actual);
317 return $matched === 1;
318
319 default:
320 // Unknown operator — fail safe.
321 return false;
322 }
323 }
324 }
325