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 / core / SiteRef.php

SiteRef.php in 404 Solution trunk, at includes/core/SiteRef.php

349 lines 9.9 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 site (multisite blog) as passed to
9 * the `wp_initialize_site` action and returned by `get_site()` /
10 * `get_sites()` when the `fields` argument is left at its default.
11 *
12 * Boundary normalizer (task: type-pressure at module boundaries).
13 *
14 * Multisite WP exposes a site via the `WP_Site` class, whose properties
15 * (`blog_id`, `site_id`, `domain`, `path`, `registered`, `last_updated`,
16 * `public`, `archived`, `mature`, `spam`, `deleted`) are all declared
17 * `string` in the WP stubs because they come straight from the
18 * `wp_blogs` table. Consumers must therefore cast every access:
19 *
20 * $blogId = (int)$site->blog_id;
21 * $domain = property_exists($site, 'domain') ? (string)$site->domain : '';
22 *
23 * In the 404 Solution codebase only one consumer takes a WP_Site object
24 * directly (the `wp_initialize_site` hook handler, which receives the
25 * full object). The rest of the plugin uses `get_sites(['fields' => 'ids'])`
26 * and operates on raw IDs. That single consumer is currently:
27 *
28 * $blogId = (int)$site->blog_id;
29 * switch_to_blog($blogId);
30 *
31 * which silently treats a malformed `$site` (`null`, an array, a missing
32 * `blog_id`) as `switch_to_blog(0)`. `switch_to_blog(0)` is a no-op in
33 * some WP versions and a fatal in others. The VO catches the malformed
34 * input at the boundary so the consumer either gets a valid VO or a
35 * clean `null` to early-return on.
36 *
37 * Schema (after normalization):
38 *
39 * - blogId : int >= 1 (zero or negative rejected at boundary)
40 * - networkId : int >= 0 (site_id; 0 if absent)
41 * - domain : string
42 * - path : string
43 * - registered : string (raw datetime; consumers parse if needed)
44 * - lastUpdated : string
45 * - public : bool ('0'/'1' string normalized)
46 * - archived : bool
47 * - mature : bool
48 * - spam : bool
49 * - deleted : bool
50 *
51 * Construction accepts a `WP_Site` object, any object that mirrors the
52 * documented property surface, an associative array (some WP filters
53 * pass the row shape as ARRAY_A), or `null` / non-object input.
54 * `fromWpSite` returns `null` when `blog_id` cannot be coerced to a
55 * positive integer: without it the VO cannot serve its primary purpose
56 * (driving `switch_to_blog`).
57 */
58 final class ABJ_404_Solution_SiteRef {
59
60 /** @var int */
61 private $blogId;
62
63 /** @var int */
64 private $networkId;
65
66 /** @var string */
67 private $domain;
68
69 /** @var string */
70 private $path;
71
72 /** @var string */
73 private $registered;
74
75 /** @var string */
76 private $lastUpdated;
77
78 /** @var bool */
79 private $public;
80
81 /** @var bool */
82 private $archived;
83
84 /** @var bool */
85 private $mature;
86
87 /** @var bool */
88 private $spam;
89
90 /** @var bool */
91 private $deleted;
92
93 private function __construct(
94 int $blogId,
95 int $networkId,
96 string $domain,
97 string $path,
98 string $registered,
99 string $lastUpdated,
100 bool $public,
101 bool $archived,
102 bool $mature,
103 bool $spam,
104 bool $deleted
105 ) {
106 $this->blogId = $blogId;
107 $this->networkId = $networkId;
108 $this->domain = $domain;
109 $this->path = $path;
110 $this->registered = $registered;
111 $this->lastUpdated = $lastUpdated;
112 $this->public = $public;
113 $this->archived = $archived;
114 $this->mature = $mature;
115 $this->spam = $spam;
116 $this->deleted = $deleted;
117 }
118
119 /**
120 * Normalize a `WP_Site` / `get_site()` return (or compatible payload)
121 * into a typed VO. Returns null when the input cannot yield a usable
122 * `blog_id` (non-positive or missing), because a SiteRef without a
123 * `blog_id` cannot drive `switch_to_blog()` and every consumer would
124 * have to re-check anyway.
125 *
126 * @param mixed $raw
127 */
128 public static function fromWpSite($raw): ?self {
129 if ($raw === null || is_bool($raw)) {
130 return null;
131 }
132 if (is_object($raw)) {
133 $blogId = self::coerceObjectInt($raw, 'blog_id');
134 if ($blogId <= 0) {
135 return null;
136 }
137 return new self(
138 $blogId,
139 self::coerceObjectInt($raw, 'site_id'),
140 self::coerceObjectString($raw, 'domain'),
141 self::coerceObjectString($raw, 'path'),
142 self::coerceObjectString($raw, 'registered'),
143 self::coerceObjectString($raw, 'last_updated'),
144 self::coerceObjectBool($raw, 'public', true),
145 self::coerceObjectBool($raw, 'archived', false),
146 self::coerceObjectBool($raw, 'mature', false),
147 self::coerceObjectBool($raw, 'spam', false),
148 self::coerceObjectBool($raw, 'deleted', false)
149 );
150 }
151 if (is_array($raw)) {
152 $blogId = self::coerceArrayInt($raw, 'blog_id');
153 if ($blogId <= 0) {
154 return null;
155 }
156 return new self(
157 $blogId,
158 self::coerceArrayInt($raw, 'site_id'),
159 self::coerceArrayString($raw, 'domain'),
160 self::coerceArrayString($raw, 'path'),
161 self::coerceArrayString($raw, 'registered'),
162 self::coerceArrayString($raw, 'last_updated'),
163 self::coerceArrayBool($raw, 'public', true),
164 self::coerceArrayBool($raw, 'archived', false),
165 self::coerceArrayBool($raw, 'mature', false),
166 self::coerceArrayBool($raw, 'spam', false),
167 self::coerceArrayBool($raw, 'deleted', false)
168 );
169 }
170 return null;
171 }
172
173 public function getBlogId(): int {
174 return $this->blogId;
175 }
176
177 public function getNetworkId(): int {
178 return $this->networkId;
179 }
180
181 public function getDomain(): string {
182 return $this->domain;
183 }
184
185 public function getPath(): string {
186 return $this->path;
187 }
188
189 public function getRegistered(): string {
190 return $this->registered;
191 }
192
193 public function getLastUpdated(): string {
194 return $this->lastUpdated;
195 }
196
197 public function isPublic(): bool {
198 return $this->public;
199 }
200
201 public function isArchived(): bool {
202 return $this->archived;
203 }
204
205 public function isMature(): bool {
206 return $this->mature;
207 }
208
209 public function isSpam(): bool {
210 return $this->spam;
211 }
212
213 public function isDeleted(): bool {
214 return $this->deleted;
215 }
216
217 /**
218 * True iff this site can be safely activated against (not archived,
219 * not deleted, not spam). Matches the gate that the plugin's
220 * lifecycle hooks should respect before calling `switch_to_blog(...)`
221 * followed by `activateSingleSite()`.
222 */
223 public function isActivatable(): bool {
224 return !$this->archived && !$this->deleted && !$this->spam;
225 }
226
227 /**
228 * @param object $obj
229 */
230 private static function coerceObjectString($obj, string $key): string {
231 if (!property_exists($obj, $key)) {
232 return '';
233 }
234 $v = $obj->{$key};
235 if (is_string($v)) {
236 return $v;
237 }
238 if (is_scalar($v)) {
239 return (string)$v;
240 }
241 return '';
242 }
243
244 /**
245 * @param object $obj
246 */
247 private static function coerceObjectInt($obj, string $key): int {
248 if (!property_exists($obj, $key)) {
249 return 0;
250 }
251 return self::scalarToInt($obj->{$key});
252 }
253
254 /**
255 * @param object $obj
256 */
257 private static function coerceObjectBool($obj, string $key, bool $default): bool {
258 if (!property_exists($obj, $key)) {
259 return $default;
260 }
261 return self::scalarToBool($obj->{$key}, $default);
262 }
263
264 /**
265 * @param array<mixed, mixed> $arr
266 */
267 private static function coerceArrayString(array $arr, string $key): string {
268 if (!isset($arr[$key])) {
269 return '';
270 }
271 $v = $arr[$key];
272 if (is_string($v)) {
273 return $v;
274 }
275 if (is_scalar($v)) {
276 return (string)$v;
277 }
278 return '';
279 }
280
281 /**
282 * @param array<mixed, mixed> $arr
283 */
284 private static function coerceArrayInt(array $arr, string $key): int {
285 if (!isset($arr[$key])) {
286 return 0;
287 }
288 return self::scalarToInt($arr[$key]);
289 }
290
291 /**
292 * @param array<mixed, mixed> $arr
293 */
294 private static function coerceArrayBool(array $arr, string $key, bool $default): bool {
295 if (!isset($arr[$key])) {
296 return $default;
297 }
298 return self::scalarToBool($arr[$key], $default);
299 }
300
301 /**
302 * @param mixed $v
303 */
304 private static function scalarToInt($v): int {
305 if (is_int($v)) {
306 return $v < 0 ? 0 : $v;
307 }
308 if (is_float($v)) {
309 $i = (int)$v;
310 return $i < 0 ? 0 : $i;
311 }
312 if (is_string($v) && is_numeric($v)) {
313 $i = (int)$v;
314 return $i < 0 ? 0 : $i;
315 }
316 if (is_bool($v)) {
317 return $v ? 1 : 0;
318 }
319 return 0;
320 }
321
322 /**
323 * WP stores boolean flags from `wp_blogs` as '0' / '1' strings. Coerce
324 * other truthy/falsy scalar shapes too (some test fixtures use real
325 * booleans / ints). Non-scalar / unknown shape returns the default
326 * rather than silently treating malformed input as one of true/false.
327 *
328 * @param mixed $v
329 */
330 private static function scalarToBool($v, bool $default): bool {
331 if (is_bool($v)) {
332 return $v;
333 }
334 if (is_int($v) || is_float($v)) {
335 return ((int)$v) === 1;
336 }
337 if (is_string($v)) {
338 if ($v === '1') {
339 return true;
340 }
341 if ($v === '0' || $v === '') {
342 return false;
343 }
344 return $default;
345 }
346 return $default;
347 }
348 }
349