PluginProbe
Site Reviews / trunk
Site Reviews vtrunk
8.3.1 8.3.0 8.2.2 8.2.1 8.2.0 8.1.0 8.0.13 8.0.12 8.0.11 trunk 1.2.2 2.17.1 3.5.4 4.7.0 5.25.1 6.11.8 7.0.10 7.0.11 7.0.12 7.0.13 7.0.14 7.0.15 7.0.16 7.0.17 7.0.18 All 54 releases
site-reviews / plugin / Controllers / ReviewController.php

ReviewController.php in Site Reviews trunk, at plugin/Controllers/ReviewController.php

529 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace GeminiLabs\SiteReviews\Controllers;
4
5 use GeminiLabs\SiteReviews\Commands\AssignPosts;
6 use GeminiLabs\SiteReviews\Commands\AssignTerms;
7 use GeminiLabs\SiteReviews\Commands\AssignUsers;
8 use GeminiLabs\SiteReviews\Commands\CreateReview;
9 use GeminiLabs\SiteReviews\Commands\ToggleStatus;
10 use GeminiLabs\SiteReviews\Commands\UnassignPosts;
11 use GeminiLabs\SiteReviews\Commands\UnassignTerms;
12 use GeminiLabs\SiteReviews\Commands\UnassignUsers;
13 use GeminiLabs\SiteReviews\Database;
14 use GeminiLabs\SiteReviews\Database\Cache;
15 use GeminiLabs\SiteReviews\Database\CountManager;
16 use GeminiLabs\SiteReviews\Database\PostMeta;
17 use GeminiLabs\SiteReviews\Database\Query;
18 use GeminiLabs\SiteReviews\Database\ReviewManager;
19 use GeminiLabs\SiteReviews\Defaults\RatingDefaults;
20 use GeminiLabs\SiteReviews\Helper;
21 use GeminiLabs\SiteReviews\Helpers\Arr;
22 use GeminiLabs\SiteReviews\Helpers\Cast;
23 use GeminiLabs\SiteReviews\Metaboxes\ResponseMetabox;
24 use GeminiLabs\SiteReviews\Modules\Avatar;
25 use GeminiLabs\SiteReviews\Modules\Html\Attributes;
26 use GeminiLabs\SiteReviews\Modules\Html\ReviewHtml;
27 use GeminiLabs\SiteReviews\Modules\Queue;
28 use GeminiLabs\SiteReviews\Request;
29 use GeminiLabs\SiteReviews\Review;
30
31 class ReviewController extends AbstractController
32 {
33 /**
34 * The reviews assigned to whatever is ABOUT to be deleted, remembered so that they can
35 * be purged from the cache once it has been.
36 *
37 * They cannot be looked up afterwards. On InnoDB the assigned_posts and assigned_users
38 * rows have ON DELETE CASCADE foreign keys onto wp_posts and wp_users, so by the time
39 * `deleted_post` fires there is nothing left to join against and the query comes back
40 * empty. The ids have to be taken while the rows still exist.
41 *
42 * @var array<string, int[]>
43 */
44 protected array $assignedReviewIds = [];
45
46 /**
47 * @param \WP_Post[] $posts
48 *
49 * @return \WP_Post[]
50 *
51 * @filter the_posts
52 */
53 public function filterPostsToCacheReviews(array $posts): array
54 {
55 $reviews = array_filter($posts, fn ($post) => glsr()->post_type === $post->post_type);
56 if ($postIds = wp_list_pluck($reviews, 'ID')) {
57 glsr(Query::class)->reviews([], $postIds); // this caches the associated Review objects
58 }
59 return $posts;
60 }
61
62 /**
63 * @filter wp_insert_post_data
64 */
65 public function filterReviewPostData(array $data, array $sanitized): array
66 {
67 if (empty($sanitized['ID']) || empty($sanitized['action']) || glsr()->post_type !== Arr::get($sanitized, 'post_type')) {
68 return $data;
69 }
70 if (!empty(filter_input(\INPUT_GET, 'bulk_edit'))) {
71 if (is_numeric(filter_input(\INPUT_GET, 'post_author'))) {
72 $data['post_author'] = filter_input(\INPUT_GET, 'post_author');
73 } else {
74 unset($data['post_author']);
75 }
76 }
77 if (is_numeric(filter_input(\INPUT_POST, 'post_author_override'))) {
78 // use the value from the author meta box
79 $data['post_author'] = filter_input(\INPUT_POST, 'post_author_override');
80 }
81 return $data;
82 }
83
84 /**
85 * @filter site-reviews/rendered/template/review
86 */
87 public function filterReviewTemplate(string $template, array $data): string
88 {
89 $attributes = array_filter([
90 'data-id' => $data['review']['ID'] ?? 0,
91 'data-type' => $data['review']['type'] ?? 'local',
92 'data-pinned' => $data['review']['is_pinned'] ?? 0,
93 'data-verified' => $data['review']['is_verified'] ?? 0,
94 ]);
95 $attributes = glsr(Attributes::class)->div($attributes)->toString();
96 $search = 'id="review-';
97 return str_replace($search, "{$attributes} {$search}", $template);
98 }
99
100 /**
101 * Unknown tags are removed before interpolation as a reviewer
102 * may have written braces of their own in the review.
103 *
104 * @filter site-reviews/build/template/review
105 */
106 public function filterReviewTemplateTagsRemoved(string $template, array $data): string
107 {
108 $context = Arr::consolidate(Arr::get($data, 'context'));
109 if (empty($context)) {
110 // Not a review being rendered: the editor builds this same
111 // template with no context to get the skeleton, tags intact.
112 return $template;
113 }
114 return (string) preg_replace_callback(
115 '/\{\{\s*([a-z0-9_]+)\s*\}\}/',
116 fn (array $matches) => array_key_exists($matches[1], $context) ? $matches[0] : '',
117 $template
118 );
119 }
120
121 /**
122 * @filter site-reviews/query/sql/clause/operator
123 */
124 public function filterSqlClauseOperator(string $operator): string
125 {
126 $operators = ['loose' => 'OR', 'strict' => 'AND'];
127 return Arr::get($operators, glsr_get_option('reviews.assignment', 'strict', 'string'), $operator);
128 }
129
130 /**
131 * @filter site-reviews/review/build/after
132 */
133 public function filterTemplateTags(array $tags, Review $review, ReviewHtml $reviewHtml): array
134 {
135 $tags['assigned_links'] = $reviewHtml->buildTemplateTag($review, 'assigned_links', $review->assigned_posts);
136 return $tags;
137 }
138
139 /**
140 * Triggered after one or more categories are added or removed from a review.
141 *
142 * @action set_object_terms
143 */
144 public function onAfterChangeAssignedTerms(
145 int $postId,
146 array $terms,
147 array $newTTIds,
148 string $taxonomy,
149 bool $append,
150 array $oldTTIds,
151 ): void {
152 if (Review::isReview($postId)) {
153 $review = glsr(ReviewManager::class)->get($postId);
154 $diff = $this->getAssignedDiffs($oldTTIds, $newTTIds);
155 $this->execute(new UnassignTerms($review, $diff['old']));
156 $this->execute(new AssignTerms($review, $diff['new']));
157 }
158 }
159
160 /**
161 * Triggered when a post status changes or when a review is approved|unapproved|trashed.
162 *
163 * @action transition_post_status
164 */
165 public function onAfterChangeStatus(string $new, string $old, ?\WP_Post $post): void
166 {
167 if (is_null($post)) {
168 return; // This should never happen, but some plugins are bad actors so...
169 }
170 if (in_array($old, ['new', $new])) {
171 return;
172 }
173 if (Review::isReview($post)) {
174 $isAutoDraft = 'auto-draft' === $old && 'auto-draft' !== $new;
175 if ($isAutoDraft) {
176 glsr(ReviewManager::class)->createFromPost($post->ID);
177 }
178 $isPublished = 'publish' === $new;
179 glsr(ReviewManager::class)->updateRating($post->ID, ['is_approved' => $isPublished]);
180 glsr(Cache::class)->delete($post->ID, 'reviews');
181 glsr(CountManager::class)->recalculate();
182 $review = glsr_get_review($post->ID);
183 if ($isAutoDraft) {
184 $this->updateReview($review, $post);
185 return; // transition hooks should only fire on existing reviews
186 }
187 if ('publish' === $new) {
188 glsr()->action('review/approved', $review, $old, $new);
189 } elseif ('pending' === $new) {
190 glsr()->action('review/unapproved', $review, $old, $new);
191 } elseif ('trash' === $new) {
192 glsr()->action('review/trashed', $review, $old, $new);
193 }
194 glsr()->action('review/transitioned', $review, $new, $old);
195 } else {
196 glsr(ReviewManager::class)->updateAssignedPost($post->ID);
197 }
198 }
199
200 /**
201 * Fallback action if ajax is not working for any reason.
202 *
203 * @action admin_action_approve
204 */
205 public function onApprove(): void
206 {
207 if (glsr()->id === filter_input(\INPUT_GET, 'plugin')) {
208 check_admin_referer('approve-review_'.($postId = $this->getPostId()));
209 $this->execute(new ToggleStatus(new Request([
210 'post_id' => $postId,
211 'status' => 'publish',
212 ])));
213 wp_safe_redirect(wp_get_referer());
214 glsr_exit();
215 }
216 }
217
218 /**
219 * Triggered before a post is deleted, whatever the storage engine.
220 *
221 * @action before_delete_post
222 */
223 public function onBeforeDeletePost(int $postId, ?\WP_Post $post = null): void
224 {
225 $postType = get_post_type($post ?? $postId);
226 if (in_array($postType, [glsr()->post_type, 'revision'])) {
227 return;
228 }
229 $this->assignedReviewIds["post_{$postId}"] = glsr(Query::class)->reviewIds([
230 'assigned_posts' => $postId,
231 'per_page' => -1,
232 'status' => 'all',
233 ]);
234 }
235
236 /**
237 * Triggered before a user is deleted, whatever the storage engine.
238 *
239 * @action delete_user
240 */
241 public function onBeforeDeleteUser(int $userId): void
242 {
243 $this->assignedReviewIds["user_{$userId}"] = glsr(Query::class)->reviewIds([
244 'assigned_users' => $userId,
245 'per_page' => -1,
246 'status' => 'all',
247 ]);
248 }
249
250 /**
251 * Triggered when a review's assigned post IDs are updated.
252 *
253 * @action site-reviews/review/updated/post_ids
254 */
255 public function onChangeAssignedPosts(Review $review, array $postIds = []): void
256 {
257 $diff = $this->getAssignedDiffs($review->assigned_posts, $postIds);
258 $this->execute(new UnassignPosts($review, $diff['old']));
259 $this->execute(new AssignPosts($review, $diff['new']));
260 }
261
262 /**
263 * Triggered when a review's assigned users IDs are updated.
264 *
265 * @action site-reviews/review/updated/user_ids
266 */
267 public function onChangeAssignedUsers(Review $review, array $userIds = []): void
268 {
269 $diff = $this->getAssignedDiffs($review->assigned_users, $userIds);
270 $this->execute(new UnassignUsers($review, $diff['old']));
271 $this->execute(new AssignUsers($review, $diff['new']));
272 }
273
274 /**
275 * Triggered after a review is created.
276 *
277 * @action site-reviews/review/created
278 */
279 public function onCreatedReview(Review $review, CreateReview $command): void
280 {
281 $this->execute(new AssignPosts($review, $command->assigned_posts));
282 $this->execute(new AssignUsers($review, $command->assigned_users));
283 }
284
285 /**
286 * Triggered when a review is created.
287 *
288 * @action site-reviews/review/create
289 */
290 public function onCreateReview(int $postId, CreateReview $command): void
291 {
292 $values = glsr()->args($command->toArray()); // this filters the values
293 $data = glsr(RatingDefaults::class)->restrict($values->toArray());
294 $data['review_id'] = $postId;
295 $data['is_approved'] = 'publish' === get_post_status($postId);
296 if (false === glsr(Database::class)->insert('ratings', $data)) {
297 glsr_log()->error('A review could not be created. Here are some things to try which may fix the problem:'.
298 \PHP_EOL.'1. First, deactivate Site Reviews and then reactivate it (this should fix any broken database table indexes).'.
299 \PHP_EOL.'2. Next, hold down the ALT key (Option key if using a Mac) and run the Migrate Plugin tool.'.
300 \PHP_EOL.'3. Finally, run the "Repair Review Relations" tool.'.
301 \PHP_EOL.'4. If the problem persists, please use the "Contact Support" section on the Help page.'
302 );
303 glsr_log()->debug($data);
304 wp_delete_post($postId, true); // remove post as review was not created
305 return;
306 }
307 $termIds = wp_set_object_terms($postId, $values->assigned_terms, glsr()->taxonomy);
308 if (is_wp_error($termIds)) {
309 glsr_log()->error($termIds->get_error_message());
310 }
311 $excluded = Cast::toArray($command->request()->excluded);
312 if (!empty($excluded)) { // save the fields hidden in the review form
313 glsr(PostMeta::class)->set($postId, 'excluded', $excluded);
314 }
315 }
316
317 /**
318 * Triggered when a review or any other post type is deleted, whatever the storage engine.
319 *
320 * On MyISAM there is no foreign key, so the rows are deleted here. On InnoDB the
321 * cascade has already removed them and the delete affects nothing but the cache
322 * purge still has to happen.
323 *
324 * @todo Reviews are cached with no expiry, should this change?
325 *
326 * @action deleted_post
327 */
328 public function onDeletePost(int $postId, \WP_Post $post): void
329 {
330 if (glsr()->post_type === $post->post_type) {
331 $this->onDeleteReview($postId);
332 return;
333 }
334 glsr(Database::class)->delete('assigned_posts', ['post_id' => $postId]);
335 $this->purgeAssignedReviews("post_{$postId}");
336 }
337
338 /**
339 * Triggered when a review is deleted, whatever the storage engine.
340 *
341 * @see $this->onDeletePost()
342 */
343 public function onDeleteReview(int $reviewId): void
344 {
345 glsr(ReviewManager::class)->deleteRating($reviewId); // always purges the cache
346 }
347
348 /**
349 * Triggered when a user is deleted, whatever the storage engine.
350 *
351 * @see $this->onDeletePost()
352 *
353 * @action deleted_user
354 */
355 public function onDeleteUser(int $userId = 0): void
356 {
357 glsr(Database::class)->delete('assigned_users', ['user_id' => $userId]);
358 $this->purgeAssignedReviews("user_{$userId}");
359 }
360
361 /**
362 * Triggered when a review is edited or trashed.
363 * It's unnecessary to trigger a term recount as this is done by the set_object_terms hook
364 * We need to use "post_updated" to support revisions (vs "save_post").
365 *
366 * @action post_updated
367 */
368 public function onEditReview(int $postId, ?\WP_Post $post, ?\WP_Post $oldPost): void
369 {
370 if (is_null($post) || is_null($oldPost)) {
371 return; // This should never happen, but some plugins are bad actors so...
372 }
373 if ('auto-draft' === $oldPost->post_status) {
374 return; // the ratings row has not been created yet, this is handled by onAfterChangeStatus
375 }
376 if (!glsr()->can('edit_posts') || !$this->isEditedReview($post, $oldPost)) {
377 return;
378 }
379 if (glsr()->id === filter_input(\INPUT_GET, 'plugin')) {
380 return; // the fallback approve/unapprove action is being run
381 }
382 if (!in_array(glsr_current_screen()->base, ['edit', 'post'])) {
383 return; // only trigger this action from the Site Reviews edit/post screens
384 }
385 $review = glsr(ReviewManager::class)->get($postId);
386 if ('edit' === glsr_current_screen()->base) {
387 $this->bulkUpdateReview($review, $oldPost);
388 } else {
389 $this->updateReview($review, $oldPost);
390 }
391 }
392
393 /**
394 * Fallback action if ajax is not working for any reason.
395 *
396 * @action admin_action_unapprove
397 */
398 public function onUnapprove(): void
399 {
400 if (glsr()->id === filter_input(\INPUT_GET, 'plugin')) {
401 $postId = $this->getPostId();
402 check_admin_referer("unapprove-review_{$postId}");
403 $this->execute(new ToggleStatus(new Request([
404 'post_id' => $postId,
405 'status' => 'unapprove',
406 ])));
407 wp_safe_redirect(wp_get_referer());
408 glsr_exit();
409 }
410 }
411
412 /**
413 * @action site-reviews/review/created
414 */
415 public function sendNotification(Review $review): void
416 {
417 if (defined('WP_IMPORTING')) {
418 return;
419 }
420 if (empty(glsr_get_option('general.notifications'))) {
421 return;
422 }
423 if (!in_array($review->status, ['pending', 'publish'])) {
424 return; // this review is likely a draft made in the wp-admin
425 }
426 glsr(Queue::class)->async('queue/notification', ['review_id' => $review->ID]);
427 }
428
429 protected function bulkUpdateReview(Review $review, \WP_Post $oldPost): void
430 {
431 if ($assignedPostIds = filter_input(\INPUT_GET, 'post_ids', \FILTER_SANITIZE_NUMBER_INT, \FILTER_FORCE_ARRAY)) {
432 glsr()->action('review/updated/post_ids', $review, Cast::toArray($assignedPostIds)); // trigger a recount of assigned posts
433 }
434 if ($assignedUserIds = filter_input(\INPUT_GET, 'user_ids', \FILTER_SANITIZE_NUMBER_INT, \FILTER_FORCE_ARRAY)) {
435 glsr()->action('review/updated/user_ids', $review, Cast::toArray($assignedUserIds)); // trigger a recount of assigned users
436 }
437 $review->refresh();
438 glsr()->action('review/updated', $review, [], $oldPost); // pass an empty array since review values are unchanged
439 }
440
441 protected function getAssignedDiffs(array $existing, array $replacements): array
442 {
443 sort($existing);
444 sort($replacements);
445 $new = $old = [];
446 if ($existing !== $replacements) {
447 $ignored = array_intersect($existing, $replacements);
448 $new = array_diff($replacements, $ignored);
449 $old = array_diff($existing, $ignored);
450 }
451 return [
452 'new' => $new,
453 'old' => $old,
454 ];
455 }
456
457 protected function isEditedReview(\WP_Post $post, \WP_Post $oldPost): bool
458 {
459 if (glsr()->post_type !== $post->post_type) {
460 return false;
461 }
462 if (in_array('trash', [$post->post_status, $oldPost->post_status])) {
463 return false; // trashed posts cannot be edited
464 }
465 $input = 'edit' === glsr_current_screen()->base ? \INPUT_GET : \INPUT_POST;
466 return filter_input($input, 'action') !== glsr()->prefix.'admin_action'; // abort if not a proper post update (i.e. approve/unapprove)
467 }
468
469 /**
470 * Drop the reviews that were assigned to the thing that has just been deleted from the cache.
471 */
472 protected function purgeAssignedReviews(string $key): void
473 {
474 $reviewIds = $this->assignedReviewIds[$key] ?? [];
475 unset($this->assignedReviewIds[$key]);
476 array_walk($reviewIds, function ($reviewId) {
477 glsr(Cache::class)->delete($reviewId, 'reviews');
478 });
479 }
480
481 protected function refreshAvatar(array $data, Review $review): string
482 {
483 $avatarUrl = Cast::toString($data['avatar'] ?? '');
484 if ($review->author === ($data['name'] ?? false)) {
485 return $avatarUrl;
486 }
487 $url = preg_replace('/(.*)\/site-reviews\/avatars\/[\p{L&}]+\.svg$/u', '', $avatarUrl);
488 if (empty($url)) { // only update the initials fallback avatar
489 $review->set('author', $data['name'] ?? '');
490 $avatarUrl = glsr(Avatar::class)->generateInitials($review);
491 }
492 return $avatarUrl;
493 }
494
495 /**
496 * This is run after editing a review in the admin.
497 */
498 protected function updateReview(Review $review, \WP_Post $oldPost): void
499 {
500 $customDefaults = array_fill_keys(array_keys($review->custom()->toArray()), '');
501 $data = Helper::filterInputArray(glsr()->id);
502 $data = wp_parse_args($data, $customDefaults); // this ensures we save all empty custom values
503 if (Arr::get($data, 'is_editing_review')) {
504 $data['avatar'] = $this->refreshAvatar($data, $review);
505 $data['rating'] ??= '';
506 $data['terms'] ??= 0;
507 }
508 if (Arr::getAs('bool', $data, 'is_pinned') === $review->is_pinned) {
509 unset($data['is_pinned']);
510 }
511 if (Arr::getAs('bool', $data, 'is_verified') === $review->is_verified || !glsr()->filterBool('verification/enabled', false)) {
512 unset($data['is_verified']);
513 }
514 if (!empty($data)) {
515 glsr(ReviewManager::class)->updateCustom($review->ID, $data); // values are sanitized here
516 glsr(ReviewManager::class)->updateGeolocation($review->ID, $data); // queues new geolocation request if needed
517 glsr(ReviewManager::class)->updateRating($review->ID, $data); // values are sanitized here
518 $review->refresh();
519 }
520 $assignedPostIds = filter_input(\INPUT_POST, 'post_ids', \FILTER_SANITIZE_NUMBER_INT, \FILTER_FORCE_ARRAY);
521 $assignedUserIds = filter_input(\INPUT_POST, 'user_ids', \FILTER_SANITIZE_NUMBER_INT, \FILTER_FORCE_ARRAY);
522 glsr()->action('review/updated/post_ids', $review, Cast::toArray($assignedPostIds)); // trigger a recount of assigned posts
523 glsr()->action('review/updated/user_ids', $review, Cast::toArray($assignedUserIds)); // trigger a recount of assigned users
524 glsr(ResponseMetabox::class)->save($review);
525 $review->refresh();
526 glsr()->action('review/updated', $review, $data, $oldPost);
527 }
528 }
529