| 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 |
|