PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 / core / UserRef.php

UserRef.php in 404 Solution 4.3.0, at includes/core/UserRef.php

291 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 * Typed value object for a WordPress user as returned by
9 * `wp_get_current_user()`, `get_user_by()`, and `get_userdata()`.
10 *
11 * Boundary normalizer (task: type-pressure at module boundaries).
12 *
13 * WordPress's user APIs return `WP_User|false`. The `WP_User` class has a
14 * stable property surface (`ID`, `user_login`, `user_email`, `display_name`,
15 * `roles`), but every public property is typed `string|int|array` in the
16 * stubs (raw DB column types) and is also subject to:
17 *
18 * 1. Anonymous-user objects with `ID = 0` (the documented sentinel for
19 * visitors not logged in: `WP_User::exists()` returns false).
20 * 2. Third-party plugins that mutate `roles` into something other than
21 * `string[]` (observed in legacy multisite migrations).
22 * 3. A historical mix of `wp_get_current_user()` returning a real
23 * `WP_User` versus `null` in stub-only test harnesses where the
24 * pluggable function never resolved.
25 *
26 * Before this VO, six call sites (DataAccessTrait_Logs, Privacy,
27 * ViewTrait_Stats, ErrorHandler, ViewUpdater, RedirectConditionEvaluator)
28 * each reinvented some subset of the probing:
29 *
30 * $u = wp_get_current_user();
31 * if (is_object($u) && property_exists($u, 'roles') && is_array($u->roles)) { ... }
32 *
33 * $u = wp_get_current_user();
34 * $login = $u->user_login ?? ''; // misses 'absent property' case
35 *
36 * $u = wp_get_current_user();
37 * $login = $u->user_login; // crashes if $u is null in stubs
38 *
39 * $u = wp_get_current_user();
40 * if (!($u instanceof WP_User) || !$u->exists()) { ... }
41 *
42 * This VO collapses them into one boundary:
43 *
44 * $ref = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user());
45 * if ($ref === null || !$ref->exists()) { return; }
46 * if ($ref->isAdministrator()) { ... }
47 * $login = $ref->getLogin(); // always a string, never null
48 *
49 * Schema (after normalization):
50 *
51 * - id : int >= 0 (0 is the documented anonymous sentinel)
52 * - login : string ('' if absent / non-scalar)
53 * - email : string
54 * - displayName : string
55 * - roles : string[] (non-string entries skipped)
56 *
57 * Construction accepts a `WP_User` object, any object that mirrors the
58 * documented property surface, or `null` / non-object input.
59 * `fromWpUser` returns `null` only when the input is truly unrecoverable
60 * (not an object, or a non-array shape posing as an array).
61 * An object with `ID = 0` is *not* null. It is a real anonymous user
62 * VO whose `exists()` returns false, matching `WP_User::exists()`.
63 */
64 final class ABJ_404_Solution_UserRef {
65
66 /** @var int */
67 private $id;
68
69 /** @var string */
70 private $login;
71
72 /** @var string */
73 private $email;
74
75 /** @var string */
76 private $displayName;
77
78 /** @var array<int, string> */
79 private $roles;
80
81 /**
82 * @param array<int, string> $roles
83 */
84 private function __construct(
85 int $id,
86 string $login,
87 string $email,
88 string $displayName,
89 array $roles
90 ) {
91 $this->id = $id;
92 $this->login = $login;
93 $this->email = $email;
94 $this->displayName = $displayName;
95 $this->roles = $roles;
96 }
97
98 /**
99 * Normalize a `wp_get_current_user()` / `get_user_by()` /
100 * `get_userdata()` return into a typed VO. Returns null only when
101 * the input is unrecoverably malformed (not an object or array).
102 *
103 * @param mixed $raw
104 */
105 public static function fromWpUser($raw): ?self {
106 if ($raw === null || is_bool($raw)) {
107 return null;
108 }
109 if (is_object($raw)) {
110 return new self(
111 self::coerceObjectInt($raw, 'ID'),
112 self::coerceObjectString($raw, 'user_login'),
113 self::coerceObjectString($raw, 'user_email'),
114 self::coerceObjectString($raw, 'display_name'),
115 self::coerceObjectRoles($raw)
116 );
117 }
118 if (is_array($raw)) {
119 return new self(
120 self::coerceArrayInt($raw, 'ID'),
121 self::coerceArrayString($raw, 'user_login'),
122 self::coerceArrayString($raw, 'user_email'),
123 self::coerceArrayString($raw, 'display_name'),
124 self::coerceArrayRoles($raw)
125 );
126 }
127 return null;
128 }
129
130 public function getId(): int {
131 return $this->id;
132 }
133
134 public function getLogin(): string {
135 return $this->login;
136 }
137
138 public function getEmail(): string {
139 return $this->email;
140 }
141
142 public function getDisplayName(): string {
143 return $this->displayName;
144 }
145
146 /** @return array<int, string> */
147 public function getRoles(): array {
148 return $this->roles;
149 }
150
151 /**
152 * True iff this VO represents an authenticated user. Mirrors
153 * `WP_User::exists()`: `ID === 0` is the documented sentinel for
154 * an anonymous visitor and must read as "no user".
155 */
156 public function exists(): bool {
157 return $this->id > 0;
158 }
159
160 /** Case-sensitive role match (WordPress role slugs are lowercase). */
161 public function hasRole(string $role): bool {
162 return in_array($role, $this->roles, true);
163 }
164
165 public function isAdministrator(): bool {
166 return $this->hasRole('administrator');
167 }
168
169 /**
170 * @param object $obj
171 */
172 private static function coerceObjectString($obj, string $key): string {
173 if (!property_exists($obj, $key)) {
174 return '';
175 }
176 $v = $obj->{$key};
177 if (is_string($v)) {
178 return $v;
179 }
180 if (is_scalar($v)) {
181 return (string)$v;
182 }
183 return '';
184 }
185
186 /**
187 * @param object $obj
188 */
189 private static function coerceObjectInt($obj, string $key): int {
190 if (!property_exists($obj, $key)) {
191 return 0;
192 }
193 return self::scalarToInt($obj->{$key});
194 }
195
196 /**
197 * Extract a clean `string[]` from `$user->roles`. WP_User stores roles
198 * as `string[]`, but the property is `mixed` in the stubs and third-
199 * party plugins have been observed shoving in nested arrays / objects.
200 * Non-string entries are dropped rather than coerced: a role slug
201 * that wasn't a string was never matchable by `in_array(..., true)`
202 * anyway, and silently coercing would mask the upstream bug.
203 *
204 * @param object $obj
205 * @return array<int, string>
206 */
207 private static function coerceObjectRoles($obj): array {
208 if (!property_exists($obj, 'roles')) {
209 return array();
210 }
211 $v = $obj->{'roles'};
212 if (!is_array($v)) {
213 return array();
214 }
215 return self::filterRoleStrings($v);
216 }
217
218 /**
219 * @param array<mixed, mixed> $arr
220 */
221 private static function coerceArrayString(array $arr, string $key): string {
222 if (!isset($arr[$key])) {
223 return '';
224 }
225 $v = $arr[$key];
226 if (is_string($v)) {
227 return $v;
228 }
229 if (is_scalar($v)) {
230 return (string)$v;
231 }
232 return '';
233 }
234
235 /**
236 * @param array<mixed, mixed> $arr
237 */
238 private static function coerceArrayInt(array $arr, string $key): int {
239 if (!isset($arr[$key])) {
240 return 0;
241 }
242 return self::scalarToInt($arr[$key]);
243 }
244
245 /**
246 * @param array<mixed, mixed> $arr
247 * @return array<int, string>
248 */
249 private static function coerceArrayRoles(array $arr): array {
250 if (!isset($arr['roles']) || !is_array($arr['roles'])) {
251 return array();
252 }
253 return self::filterRoleStrings($arr['roles']);
254 }
255
256 /**
257 * @param array<mixed, mixed> $candidate
258 * @return array<int, string>
259 */
260 private static function filterRoleStrings(array $candidate): array {
261 $clean = array();
262 foreach ($candidate as $entry) {
263 if (is_string($entry) && $entry !== '') {
264 $clean[] = $entry;
265 }
266 }
267 return $clean;
268 }
269
270 /**
271 * @param mixed $v
272 */
273 private static function scalarToInt($v): int {
274 if (is_int($v)) {
275 return $v < 0 ? 0 : $v;
276 }
277 if (is_float($v)) {
278 $i = (int)$v;
279 return $i < 0 ? 0 : $i;
280 }
281 if (is_string($v) && is_numeric($v)) {
282 $i = (int)$v;
283 return $i < 0 ? 0 : $i;
284 }
285 if (is_bool($v)) {
286 return $v ? 1 : 0;
287 }
288 return 0;
289 }
290 }
291