| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The engine-profile table: where profile rows live, and how they are read, |
| 9 |
* written and deleted. |
| 10 |
* |
| 11 |
* Split out of ABJ_404_Solution_EngineProfileResolver, which had grown to hold |
| 12 |
* three separate jobs -- deciding which engines run for a URL, memoizing that |
| 13 |
* decision's input per blog, and owning every query against |
| 14 |
* wp_abj404_engine_profiles. The first is business logic and the third is data |
| 15 |
* access, and this codebase keeps those in different files. The resolver now |
| 16 |
* asks this class for rows and never builds SQL. |
| 17 |
* |
| 18 |
* The resolver keeps two thin published accessors over this |
| 19 |
* (getAllProfilesForAdmin / adminProfileListWasTruncated) because the admin |
| 20 |
* AJAX handlers reach the profile subsystem through that singleton, which is |
| 21 |
* also the seam their tests inject at. They carry no SQL and no policy; the |
| 22 |
* table is owned here. |
| 23 |
*/ |
| 24 |
final class ABJ_404_Solution_EngineProfileRepository { |
| 25 |
|
| 26 |
/** |
| 27 |
* Ceiling on rows one admin profile-list request may read and return. |
| 28 |
* |
| 29 |
* Sized to be unreachable by the only thing that creates profiles -- an |
| 30 |
* administrator saving them one at a time through the admin screen -- while |
| 31 |
* still keeping a single request's work bounded by a number this code |
| 32 |
* chooses rather than by whatever the table has grown to. See readAllForAdmin(). |
| 33 |
*/ |
| 34 |
const MAX_ADMIN_PROFILES = 2000; |
| 35 |
|
| 36 |
/** Ceiling on the active-profile read that feeds matching. */ |
| 37 |
const MAX_ACTIVE_PROFILES = 200; |
| 38 |
|
| 39 |
/** @var ABJ_404_Solution_DatabaseCore|null */ |
| 40 |
private $dbCore = null; |
| 41 |
|
| 42 |
/** @var bool Whether the last admin read filled MAX_ADMIN_PROFILES. */ |
| 43 |
private $adminReadTruncated = false; |
| 44 |
|
| 45 |
/** |
| 46 |
* Lazy dbCore accessor, deferring resolution until first use so unit tests |
| 47 |
* that exercise pure-logic methods never boot the database layer. |
| 48 |
* |
| 49 |
* @return ABJ_404_Solution_DatabaseCore |
| 50 |
*/ |
| 51 |
private function dbCore() { |
| 52 |
if ($this->dbCore === null) { |
| 53 |
$this->dbCore = abj_service('db_core'); |
| 54 |
} |
| 55 |
return $this->dbCore; |
| 56 |
} |
| 57 |
|
| 58 |
/** |
| 59 |
* The fully-prefixed table name. |
| 60 |
* |
| 61 |
* Read from $wpdb->prefix on every call rather than memoized, because |
| 62 |
* switch_to_blog() changes the prefix and this table is genuinely per-blog. |
| 63 |
*/ |
| 64 |
public function tableName(): string { |
| 65 |
global $wpdb; |
| 66 |
return strtolower($wpdb->prefix) . 'abj404_engine_profiles'; |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Active profiles for matching, priority first. |
| 71 |
* |
| 72 |
* @return array<int, object> |
| 73 |
*/ |
| 74 |
public function readActiveProfiles(): array { |
| 75 |
$table = $this->tableName(); |
| 76 |
if ($this->tableIsAbsent($table)) { |
| 77 |
return []; |
| 78 |
} |
| 79 |
|
| 80 |
$queryResult = $this->dbCore()->queryAndGetResults( |
| 81 |
"SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority` |
| 82 |
FROM `{$table}` |
| 83 |
WHERE `status` = 1 |
| 84 |
ORDER BY `priority` ASC, `id` ASC |
| 85 |
LIMIT %d", |
| 86 |
['query_params' => [self::MAX_ACTIVE_PROFILES], 'result_type' => OBJECT] |
| 87 |
); |
| 88 |
$rows = $queryResult['rows'] ?? []; |
| 89 |
if (!is_array($rows)) { |
| 90 |
return []; |
| 91 |
} |
| 92 |
// Rebuilt as a list of objects so the declared return type is checked |
| 93 |
// rather than asserted: the DAO answers array<mixed>. |
| 94 |
$profiles = []; |
| 95 |
foreach ($rows as $row) { |
| 96 |
if (is_object($row)) { |
| 97 |
$profiles[] = $row; |
| 98 |
} |
| 99 |
} |
| 100 |
|
| 101 |
return $profiles; |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* Every profile, active or not, for the admin edit screen, up to a ceiling. |
| 106 |
* |
| 107 |
* The query used to have no LIMIT, so the work one authenticated admin |
| 108 |
* request could cause was whatever the table happened to hold: every row |
| 109 |
* materialised into PHP and encoded into a single JSON response. Profiles |
| 110 |
* are only ever created one at a time through the admin screen, so no real |
| 111 |
* site is near this -- but "nobody would do that" is not a bound, and this |
| 112 |
* plugin has already shipped an out-of-memory failure from an unbounded |
| 113 |
* load (4.3.0/4.3.1). |
| 114 |
* |
| 115 |
* A CEILING, deliberately not a page. This list feeds the screen that edits |
| 116 |
* these rows, so quietly returning the first N would leave profiles that are |
| 117 |
* live in matching but invisible and uneditable -- a worse failure than a |
| 118 |
* slow query, and exactly the kind of silent partial answer this codebase |
| 119 |
* treats as a defect elsewhere. Reaching the ceiling is recorded rather than |
| 120 |
* absorbed: see lastAdminReadWasTruncated(), which the caller reports. |
| 121 |
* |
| 122 |
* @return array<int, array<string, mixed>> |
| 123 |
*/ |
| 124 |
public function readAllForAdmin(): array { |
| 125 |
$this->adminReadTruncated = false; |
| 126 |
$table = $this->tableName(); |
| 127 |
|
| 128 |
if ($this->tableIsAbsent($table)) { |
| 129 |
return []; |
| 130 |
} |
| 131 |
|
| 132 |
$queryResult = $this->dbCore()->queryAndGetResults( |
| 133 |
"SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status` |
| 134 |
FROM `{$table}` |
| 135 |
ORDER BY `priority` ASC, `id` ASC |
| 136 |
LIMIT " . self::MAX_ADMIN_PROFILES |
| 137 |
); |
| 138 |
$rows = $queryResult['rows'] ?? []; |
| 139 |
if (!is_array($rows)) { |
| 140 |
return []; |
| 141 |
} |
| 142 |
$this->adminReadTruncated = count($rows) >= self::MAX_ADMIN_PROFILES; |
| 143 |
|
| 144 |
// Rebuilt as a list of row arrays rather than returned as-is: the DAO |
| 145 |
// answers array<mixed>, so handing it straight back made the declared |
| 146 |
// return type a claim nothing checked. |
| 147 |
$profiles = []; |
| 148 |
foreach ($rows as $row) { |
| 149 |
if (is_array($row)) { |
| 150 |
$profiles[] = $row; |
| 151 |
} |
| 152 |
} |
| 153 |
|
| 154 |
return $profiles; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Whether the last readAllForAdmin() call filled its ceiling. |
| 159 |
* |
| 160 |
* A truncated list is indistinguishable from a short one, so an admin whose |
| 161 |
* profiles stopped appearing could not tell that from having deleted them. |
| 162 |
*/ |
| 163 |
public function lastAdminReadWasTruncated(): bool { |
| 164 |
return $this->adminReadTruncated; |
| 165 |
} |
| 166 |
|
| 167 |
/** |
| 168 |
* Insert or update a profile row. |
| 169 |
* |
| 170 |
* @param array<string, mixed> $data |
| 171 |
* @return int|false Inserted/updated row ID, or false on failure. |
| 172 |
*/ |
| 173 |
public function insertOrUpdate(array $data) { |
| 174 |
$table = $this->tableName(); |
| 175 |
|
| 176 |
$id = isset($data['id']) && is_numeric($data['id']) ? (int)$data['id'] : 0; |
| 177 |
[$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status] |
| 178 |
= $this->normalizedColumns($data); |
| 179 |
|
| 180 |
if ($id > 0) { |
| 181 |
$queryResult = $this->dbCore()->queryAndGetResults( |
| 182 |
"UPDATE `{$table}` |
| 183 |
SET `name` = %s, `url_pattern` = %s, `is_regex` = %d, |
| 184 |
`enabled_engines` = %s, `priority` = %d, `status` = %d |
| 185 |
WHERE `id` = %d", |
| 186 |
['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status, $id]] |
| 187 |
); |
| 188 |
$updateError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; |
| 189 |
return $updateError === '' ? $id : false; |
| 190 |
} |
| 191 |
|
| 192 |
$queryResult = $this->dbCore()->queryAndGetResults( |
| 193 |
"INSERT INTO `{$table}` (`name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`) |
| 194 |
VALUES (%s, %s, %d, %s, %d, %d)", |
| 195 |
['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status]] |
| 196 |
); |
| 197 |
$lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; |
| 198 |
if ($lastError !== '') { |
| 199 |
return false; |
| 200 |
} |
| 201 |
$insertId = isset($queryResult['insert_id']) && is_scalar($queryResult['insert_id']) ? (int)$queryResult['insert_id'] : 0; |
| 202 |
return $insertId > 0 ? $insertId : false; |
| 203 |
} |
| 204 |
|
| 205 |
/** |
| 206 |
* Coerce one untrusted profile payload into the six column values. |
| 207 |
* |
| 208 |
* Lifted out of insertOrUpdate() because it was the whole of that method's |
| 209 |
* branching: six independent isset/type ternaries plus the JSON check, |
| 210 |
* which is what pushed one INSERT-or-UPDATE decision past the complexity |
| 211 |
* ceiling. Returned positionally and immediately destructured at the single |
| 212 |
* call site rather than as a keyed array, so the column ORDER stays stated |
| 213 |
* once, next to the SQL that consumes it. |
| 214 |
* |
| 215 |
* @param array<string, mixed> $data |
| 216 |
* @return array{0: string, 1: string, 2: int, 3: string, 4: int, 5: int} |
| 217 |
*/ |
| 218 |
private function normalizedColumns(array $data): array { |
| 219 |
$name = isset($data['name']) ? sanitize_text_field(is_string($data['name']) ? $data['name'] : '') : ''; |
| 220 |
$urlPattern = isset($data['url_pattern']) ? wp_unslash(is_string($data['url_pattern']) ? $data['url_pattern'] : '') : ''; |
| 221 |
$isRegex = isset($data['is_regex']) ? (int)(bool)$data['is_regex'] : 0; |
| 222 |
$enabledEngines = isset($data['enabled_engines']) && is_string($data['enabled_engines']) |
| 223 |
? $data['enabled_engines'] : '[]'; |
| 224 |
$priority = isset($data['priority']) && is_numeric($data['priority']) ? (int)$data['priority'] : 0; |
| 225 |
$status = isset($data['status']) ? (int)(bool)$data['status'] : 1; |
| 226 |
|
| 227 |
// enabled_engines is stored as a JSON array; anything else becomes one. |
| 228 |
if (!is_array(json_decode($enabledEngines, true))) { |
| 229 |
$enabledEngines = '[]'; |
| 230 |
} |
| 231 |
|
| 232 |
return [(string)$name, (string)$urlPattern, $isRegex, $enabledEngines, $priority, $status]; |
| 233 |
} |
| 234 |
|
| 235 |
/** Delete a profile by ID. */ |
| 236 |
public function delete(int $id): bool { |
| 237 |
$table = $this->tableName(); |
| 238 |
$queryResult = $this->dbCore()->queryAndGetResults( |
| 239 |
"DELETE FROM `{$table}` WHERE `id` = %d", |
| 240 |
['query_params' => [$id]] |
| 241 |
); |
| 242 |
$deleteError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; |
| 243 |
return $deleteError === ''; |
| 244 |
} |
| 245 |
|
| 246 |
/** |
| 247 |
* Whether the engine profiles table is known NOT to be there. |
| 248 |
* |
| 249 |
* SHOW TABLES LIKE returns no rows both for a table that is not there and |
| 250 |
* for a probe that never ran, and the two answers must not share a code |
| 251 |
* path: a false "absent" caches an empty profile set for the rest of the |
| 252 |
* request, so one failed probe silently drops every custom search-engine |
| 253 |
* profile out of matching (and out of the admin list) until the next |
| 254 |
* request. last_error is what separates them, and an unanswerable probe |
| 255 |
* lets the SELECT run so the centralized handler reports the fault. |
| 256 |
*/ |
| 257 |
private function tableIsAbsent(string $table): bool { |
| 258 |
$queryResult = $this->dbCore()->queryAndGetResults( |
| 259 |
'SHOW TABLES LIKE %s', |
| 260 |
['query_params' => [$table]] |
| 261 |
); |
| 262 |
$lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) |
| 263 |
? trim($queryResult['last_error']) : ''; |
| 264 |
if ($lastError !== '' || !empty($queryResult['timed_out'])) { |
| 265 |
return false; |
| 266 |
} |
| 267 |
$rows = isset($queryResult['rows']) && is_array($queryResult['rows']) ? $queryResult['rows'] : []; |
| 268 |
$first = $rows[0] ?? null; |
| 269 |
if (!is_array($first)) { |
| 270 |
return true; |
| 271 |
} |
| 272 |
$firstValue = reset($first); |
| 273 |
return $firstValue !== $table; |
| 274 |
} |
| 275 |
} |
| 276 |
|