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 / matching / OldPermalinkPostResolver.php

OldPermalinkPostResolver.php in 404 Solution trunk, at includes/matching/OldPermalinkPostResolver.php

304 lines 11.3 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 * Given a regex match against an old permalink structure, resolves and
9 * validates a currently-published WordPress post: extracts named captures,
10 * looks up the post by ID or by slug, and checks it against the structural
11 * constraints implied by the captures (post type, date, author, category,
12 * slug).
13 *
14 * Extracted from ABJ_404_Solution_OldPermalinkStructureResolver because
15 * resolving a regex match to a validated post is a distinct concern from
16 * compiling a structure into a regex or gathering candidate structures.
17 */
18 class ABJ_404_Solution_OldPermalinkPostResolver {
19
20 /** @var ABJ_404_Solution_ContentRepositoryInterface */
21 private $contentRepository;
22
23 /** @var ABJ_404_Solution_Logging */
24 private $logger;
25
26 /**
27 * @param ABJ_404_Solution_ContentRepositoryInterface $contentRepository
28 * @param ABJ_404_Solution_Logging $logger
29 */
30 public function __construct(
31 ABJ_404_Solution_ContentRepositoryInterface $contentRepository,
32 ABJ_404_Solution_Logging $logger
33 ) {
34 $this->contentRepository = $contentRepository;
35 $this->logger = $logger;
36 }
37
38 /**
39 * @param array<int|string, mixed> $matches Raw preg_match() result.
40 * @param array{structure: string, post_types: array<int, string>} $candidate
41 * @param array<string, mixed> $options
42 * @return int|null
43 */
44 public function resolve(array $matches, array $candidate, array $options): ?int {
45 $captures = $this->namedCaptures($matches);
46
47 return isset($captures['post_id'])
48 ? $this->resolveByPostId($captures, $candidate, $options)
49 : $this->resolveBySlug($captures, $candidate, $options);
50 }
51
52 /**
53 * @param array<int|string, mixed> $matches
54 * @return array<string, string>
55 */
56 private function namedCaptures(array $matches): array {
57 $captures = array();
58 foreach ($matches as $key => $value) {
59 if (is_string($key) && is_scalar($value)) {
60 $captures[$key] = (string)$value;
61 }
62 }
63 return $captures;
64 }
65
66 /**
67 * @param array<string, string> $captures
68 * @param array{structure: string, post_types: array<int, string>} $candidate
69 * @param array<string, mixed> $options
70 * @return int|null
71 */
72 private function resolveByPostId(array $captures, array $candidate, array $options): ?int {
73 $postId = absint($captures['post_id'] ?? 0);
74 if ($postId <= 0) {
75 return null;
76 }
77
78 $post = $this->loadPost($postId);
79 if ($post === null || !$this->postMatchesConstraints($post, $captures, $candidate, $options)) {
80 return null;
81 }
82
83 $this->logger->debugMessage('Old permalink structure resolved by post ID: ' . $postId);
84 return $postId;
85 }
86
87 /**
88 * @param array<string, string> $captures
89 * @param array{structure: string, post_types: array<int, string>} $candidate
90 * @param array<string, mixed> $options
91 * @return int|null
92 */
93 private function resolveBySlug(array $captures, array $candidate, array $options): ?int {
94 $slug = $captures['postname'] ?? ($captures['pagename'] ?? '');
95 if ($slug === '') {
96 return null;
97 }
98
99 $rows = $this->contentRepository->getPublishedPagesAndPostsIDs(array(
100 'slug' => $slug,
101 'limit_results' => '11',
102 ));
103 $matches = array();
104 foreach ($rows as $row) {
105 $postId = $this->idFromRow($row);
106 if ($postId <= 0) {
107 continue;
108 }
109 $post = $this->loadPost($postId, $row);
110 if ($post !== null && $this->postMatchesConstraints($post, $captures, $candidate, $options)) {
111 $matches[] = $postId;
112 }
113 }
114
115 $matches = array_values(array_unique($matches));
116 if (count($matches) !== 1) {
117 if (count($matches) > 1) {
118 $this->logger->debugMessage('Old permalink structure ambiguous for slug: ' . $slug);
119 }
120 return null;
121 }
122
123 $this->logger->debugMessage('Old permalink structure resolved by slug: ' . $slug);
124 return (int)$matches[0];
125 }
126
127 /**
128 * @param mixed $row
129 * @return int
130 */
131 private function idFromRow($row): int {
132 if (is_object($row) && isset($row->id) && is_scalar($row->id)) {
133 return absint($row->id);
134 }
135 if (is_object($row) && isset($row->ID) && is_scalar($row->ID)) {
136 return absint($row->ID);
137 }
138 if (is_array($row) && isset($row['id']) && is_scalar($row['id'])) {
139 return absint($row['id']);
140 }
141 if (is_array($row) && isset($row['ID']) && is_scalar($row['ID'])) {
142 return absint($row['ID']);
143 }
144 return 0;
145 }
146
147 /**
148 * @param int $postId
149 * @param mixed|null $fallback
150 * @return object|null
151 */
152 private function loadPost(int $postId, $fallback = null): ?object {
153 if (function_exists('get_post')) {
154 $post = get_post($postId);
155 if (is_object($post)) {
156 return $post;
157 }
158 }
159 return is_object($fallback) ? $fallback : null;
160 }
161
162 /**
163 * @param object $post
164 * @param array<string, string> $captures
165 * @param array{structure: string, post_types: array<int, string>} $candidate
166 * @param array<string, mixed> $options
167 * @return bool
168 */
169 private function postMatchesConstraints(object $post, array $captures, array $candidate, array $options): bool {
170 $postId = isset($post->ID) ? absint($post->ID) : (isset($post->id) ? absint($post->id) : 0);
171 $status = isset($post->post_status) ? (string)$post->post_status
172 : (function_exists('get_post_status') ? (string)get_post_status($postId) : '');
173 if (!in_array($status, array('publish', 'published'), true)) {
174 return false;
175 }
176
177 $postType = isset($post->post_type) ? sanitize_key((string)$post->post_type) : '';
178 $allowedTypes = $this->recognizedPostTypes($options, $candidate['post_types']);
179 if ($postType === '' || !in_array($postType, $allowedTypes, true)) {
180 return false;
181 }
182
183 // Positive-evidence check: when the captured old permalink specifies
184 // a slug segment, the candidate post must actually carry a matching
185 // post_name, not merely "no post_name to contradict it". The DB
186 // query in getPublishedPagesAndPostsIDs() usually pre-filters by
187 // slug, but for a UTF8MB4 slug it drops the SQL-level slug clause
188 // entirely (PublishedContentRepository::buildPostSlugClause()) and
189 // returns every published post/page of the recognized types -- this
190 // check is the only remaining filter in that path. An empty
191 // post_name must not be treated as "no evidence against a match";
192 // it must be treated as "no evidence for one".
193 $postName = isset($post->post_name) ? (string)$post->post_name : '';
194 $slugCapture = $captures['postname'] ?? ($captures['pagename'] ?? null);
195 if ($slugCapture !== null && $postName !== $slugCapture) {
196 return false;
197 }
198
199 if (!$this->dateMatches($post, $captures)) {
200 return false;
201 }
202
203 if (isset($captures['author']) && !$this->authorMatches($post, $captures['author'])) {
204 return false;
205 }
206
207 if (isset($captures['category']) && !$this->categoryMatches($postId, $captures['category'])) {
208 return false;
209 }
210
211 return true;
212 }
213
214 /**
215 * @param array<string, mixed> $options
216 * @param array<int, string> $candidatePostTypes
217 * @return array<int, string>
218 */
219 private function recognizedPostTypes(array $options, array $candidatePostTypes): array {
220 $raw = isset($options['recognized_post_types']) && is_scalar($options['recognized_post_types'])
221 ? (string)$options['recognized_post_types']
222 : "page\npost\nproduct";
223 $types = preg_split('/[\s,]+/', $raw) ?: array();
224 $types = array_values(array_filter(array_map(static function($type): string {
225 return sanitize_key((string)$type);
226 }, $types)));
227
228 if (empty($types)) {
229 $types = array('page', 'post', 'product');
230 }
231
232 if (!empty($candidatePostTypes)) {
233 $types = array_values(array_intersect($types, $candidatePostTypes));
234 }
235
236 return $types;
237 }
238
239 /**
240 * $post->post_date is a WP-stored value expressed in the site's
241 * configured timezone (Settings > General), the same convention WP
242 * core uses to generate the %year%/%monthnum%/%day% permalink tags
243 * these captures came from. Parsing and formatting it both anchor to
244 * the WP site timezone (SiteTimezone) rather than PHP's implicit
245 * default timezone, matching the convention established by
246 * RedirectScheduleTimezone -- raw strtotime()/date() would parse and
247 * re-format using PHP's default timezone instead, which is fragile:
248 * it only stays a no-op because parsing and formatting happen to use
249 * the same implicit zone today, a coupling a future refactor could
250 * easily break.
251 *
252 * @param object $post
253 * @param array<string, string> $captures
254 * @return bool
255 */
256 private function dateMatches(object $post, array $captures): bool {
257 if (!isset($captures['year']) && !isset($captures['monthnum']) && !isset($captures['day'])) {
258 return true;
259 }
260 $date = isset($post->post_date) ? (string)$post->post_date : '';
261 if ($date === '') {
262 return false;
263 }
264 try {
265 $postDateTime = new DateTimeImmutable($date, ABJ_404_Solution_SiteTimezone::resolve());
266 } catch (Exception $e) {
267 $this->logger->warn('OldPermalinkPostResolver: unparseable post_date "' . $date .
268 '" on post ID ' . (isset($post->ID) ? (string)$post->ID : 'unknown') . ': ' . $e->getMessage());
269 return false;
270 }
271 if (isset($captures['year']) && $postDateTime->format('Y') !== $captures['year']) {
272 return false;
273 }
274 if (isset($captures['monthnum']) && $postDateTime->format('m') !== $captures['monthnum']) {
275 return false;
276 }
277 if (isset($captures['day']) && $postDateTime->format('d') !== $captures['day']) {
278 return false;
279 }
280 return true;
281 }
282
283 /** @param object $post @param string $authorSlug @return bool */
284 private function authorMatches(object $post, string $authorSlug): bool {
285 if (!function_exists('get_userdata') || !isset($post->post_author)) {
286 return false;
287 }
288 $user = get_userdata(absint($post->post_author));
289 return is_object($user) && isset($user->user_nicename) && (string)$user->user_nicename === $authorSlug;
290 }
291
292 /** @param int $postId @param string $categoryPath @return bool */
293 private function categoryMatches(int $postId, string $categoryPath): bool {
294 if (!function_exists('has_category')) {
295 return false;
296 }
297 $segments = array_values(array_filter(explode('/', $categoryPath)));
298 if (empty($segments)) {
299 return false;
300 }
301 return (bool)has_category(end($segments), $postId);
302 }
303 }
304