PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.11.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / SinglePostCache.php
reviews-feed / class / Common Last commit date
Admin 1 week ago Builder 1 week ago Customizer 1 week ago Exceptions 1 week ago Helpers 1 week ago Integrations 1 week ago Migrations 1 week ago ReviewAlerts 1 week ago Services 1 week ago Settings 1 week ago Support 1 week ago Traits 1 week ago UsageTracking 1 week ago Utils 1 week ago AuthorizationStatusCheck.php 1 week ago BusinessDataCache.php 1 week ago Clear_Cache.php 1 week ago Container.php 1 week ago DisplayElements.php 1 week ago Email_Notification.php 1 week ago Error_Reporter.php 1 week ago Feed.php 1 week ago FeedCache.php 1 week ago FeedCacheUpdater.php 1 week ago FeedDisplay.php 1 week ago Feed_Locator.php 1 week ago Parser.php 1 week ago PostAggregator.php 1 week ago RemoteRequest.php 1 week ago SBR_Education.php 1 week ago SBR_Schema_Service.php 1 week ago SBR_Settings.php 1 week ago ServiceContainer.php 1 week ago SinglePostCache.php 1 week ago TemplateRenderer.php 1 week ago Tooltip_Wizard.php 1 week ago Util.php 1 week ago
SinglePostCache.php
549 lines
1 <?php
2
3 /**
4 * Class SinglePostCache
5 *
6 * @since 1.0
7 */
8
9 namespace SmashBalloon\Reviews\Common;
10
11 use SmashBalloon\Reviews\Common\Helpers\Data_Encryption;
12
13 class SinglePostCache {
14 public const UPLOAD_FOLDER_NAME = 'sbr-feed-images';
15
16 public const POSTS_TABLE_NAME = SBR_POSTS_TABLE;
17
18 /**
19 * @var array
20 */
21 private $post_data;
22
23 private $upload_dir;
24
25 private $storage_data;
26 private $provider_id;
27 private $lang;
28 public $encryption;
29
30 public function __construct($post_data, $media_finder = null, $provider_id = null)
31 {
32 // SMASH-1587: coerce a scalar 'provider' slug into ['name' => ...] before
33 // any $this->post_data['provider']['name'] read in this class (which would
34 // fatal on PHP 8). Shared normalizer in Util — single source of truth.
35 $this->post_data = Util::normalize_review_shape($post_data);
36
37 $upload = wp_upload_dir();
38 $upload_dir = $upload['basedir'];
39 $upload_dir = trailingslashit($upload_dir) . self::UPLOAD_FOLDER_NAME;
40 $this->upload_dir = $upload_dir;
41
42 $this->lang = '';
43
44 $this->storage_data = array(
45 'media_id' => '',
46 'sizes' => '[]',
47 'aspect_ratio' => 1,
48 'images_done' => 0
49 );
50 $this->encryption = new Data_Encryption();
51 }
52
53 public function get_storage_data()
54 {
55 return $this->storage_data;
56 }
57
58 public function get_post_data()
59 {
60 return $this->post_data;
61 }
62
63 public function set_provider_id($provider_id)
64 {
65 $this->provider_id = $provider_id;
66 }
67
68 public function set_lang($lang)
69 {
70 $this->lang = $lang;
71 }
72
73 /**
74 * Check and process API media (reviews photos)
75 *
76 * Stub method for Common version. Pro version overrides with full implementation.
77 *
78 * @since 2.5.0
79 * @return void
80 */
81 public function check_api_media()
82 {
83 // No-op in Common version. Pro version handles media processing.
84 }
85
86 public function get_provider_id()
87 {
88 return $this->provider_id;
89 }
90
91 public function set_storage_data($key, $value)
92 {
93 return $this->storage_data[ $key ] = $value;
94 }
95
96
97 public function resize_images($image_sizes)
98 {
99 $new_file_name = $this->post_data['provider']['name'] . '-' . $this->post_data['review_id'];
100
101 $image_source_set = ! empty($this->post_data['media']) ? $this->post_data['media'] : array();
102
103 $image_source_set = empty($image_source_set) && !empty($this->post_data['reviews_photos']) ? $this->post_data['reviews_photos'] : $image_source_set;
104 // the process is considered a success if one image is successfully resized
105 $one_successful_image_resize = false;
106
107 foreach ($image_sizes as $image_size) {
108 $i = 0;
109 foreach ($image_source_set as $image_file_to_resize) {
110 // A scalar element (e.g. a flat URL string in reviews_photos) would
111 // fatal the ['type'] read on PHP 8; a partial array element (no
112 // 'type'/'url') would notice. Guard both (SMASH-1587 + PR #482 Copilot).
113 if ($i < 10 && is_array($image_file_to_resize) && isset($image_file_to_resize['type'], $image_file_to_resize['url']) && $image_file_to_resize['type'] === 'image') {
114 $this_image_file_name = $new_file_name . '-' . $i . '-' . $image_size . '.jpg';
115
116 $image_editor = wp_get_image_editor($image_file_to_resize['url']);
117 // not uncommon for the image editor to not work using it this way
118 if (! is_wp_error($image_editor)) {
119 $sizes = $image_editor->get_size();
120
121 $image_editor->resize($image_size, null);
122
123 $full_file_name = trailingslashit($this->upload_dir) . $this_image_file_name;
124
125 $saved_image = $image_editor->save($full_file_name);
126
127 if ($saved_image) {
128 $one_successful_image_resize = true;
129 }
130 } else {
131 // was error
132 }
133 }
134
135 $i++;
136 }
137 }
138
139 if ($one_successful_image_resize) {
140 $aspect_ratio = round($sizes['width'] / $sizes['height'], 2);
141 $media_id = $new_file_name;
142 } else {
143 $aspect_ratio = 1;
144 if (empty($image_source_set)) {
145 $media_id = '';
146 $image_sizes = array();
147 } else {
148 $media_id = 'error';
149 }
150 }
151
152 $this->storage_data['media_id'] = $media_id;
153 $this->storage_data['sizes'] = wp_json_encode($image_sizes);
154 $this->storage_data['aspect_ratio'] = $aspect_ratio;
155 $this->storage_data['images_done'] = 1;
156 }
157
158 public function resize_avatar($image_size)
159 {
160 $new_file_name = $this->post_data['provider']['name'] . '-' . $this->post_data['review_id'] . '-avatar';
161 $avatar = ! empty($this->post_data['reviewer']['avatar']) ? $this->post_data['reviewer']['avatar'] : false;
162
163 if (empty($avatar)) {
164 return;
165 }
166
167 $avatar_id = $new_file_name . '-' . $image_size;
168 $this_image_file_name = $avatar_id . '.png';
169
170 $image_editor = wp_get_image_editor($avatar);
171 // not uncommon for the image editor to not work using it this way
172 if (! is_wp_error($image_editor)) {
173 $sizes = $image_editor->get_size();
174
175 $image_editor->resize($image_size, null);
176
177 $full_file_name = trailingslashit($this->upload_dir) . $this_image_file_name;
178
179 $saved_image = $image_editor->save($full_file_name);
180
181 if (! $saved_image) {
182 $avatar_id = 'error';
183 }
184 } else {
185 $avatar_id = 'error';
186 }
187
188
189 $this->storage_data['avatar_id'] = $avatar_id;
190 }
191
192 public function db_record_exists()
193 {
194 $feed_id_match = $this->db_record();
195 if (! empty($feed_id_match)) {
196 $this->storage_data['media_id'] = $feed_id_match['media_id'];
197 $this->storage_data['sizes'] = $feed_id_match['sizes'];
198 $this->storage_data['aspect_ratio'] = $feed_id_match['aspect_ratio'];
199 $this->storage_data['images_done'] = $feed_id_match['images_done'];
200 $this->storage_data['json_data'] = $feed_id_match['json_data'];
201 // SMASH-1785: mirror the stored avatar_id so update_single() writes back
202 // the same value it read, and so localized_avatar_missing() can see it.
203 $this->storage_data['avatar_id'] = $feed_id_match['avatar_id'] ?? '';
204 }
205 return null !== $feed_id_match;
206 }
207
208 /**
209 * Whether this review's localized avatar file has gone missing.
210 *
211 * `avatar_id` is written once, when the review is first cached, and
212 * `resize_avatar()` only runs for new reviews. So a cleared or deleted avatar
213 * file left the row pointing at something that no longer exists, forever
214 * (SMASH-1785). Callers use this on the fetch path to re-resize.
215 *
216 * `error` means the original download/resize already failed; retrying it on
217 * every fetch would hammer a permanently bad remote URL, so it is left alone.
218 *
219 * @return bool
220 */
221 public function localized_avatar_missing()
222 {
223 // Read through the getter, NOT $this->storage_data: both this class and
224 // Pro\SinglePostCache declare `private $storage_data`, so a method defined here
225 // only ever sees the Common copy, which the Pro subclass never populates. The
226 // getter is overridden in Pro, so it returns whichever copy is actually in use.
227 $storage_data = $this->get_storage_data();
228 $avatar_id = is_array($storage_data) && isset($storage_data['avatar_id'])
229 ? $storage_data['avatar_id']
230 : '';
231
232 if (! is_string($avatar_id) || $avatar_id === '' || $avatar_id === 'error') {
233 return false;
234 }
235
236 return ! file_exists(Util::get_upload_folder_name() . $avatar_id . '.png');
237 }
238
239 public function db_record()
240 {
241 global $wpdb;
242 $table_name = esc_sql($wpdb->prefix . self::POSTS_TABLE_NAME);
243 $provider_id = !empty($this->post_data['provider_id'])
244 ? $this->post_data['provider_id']
245 : $this->provider_id;
246
247 if (isset($this->post_data['review_id'])) {
248 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is prefixed constant
249 $feed_id_match = $wpdb->get_results(
250 $wpdb->prepare(
251 "SELECT * FROM $table_name
252 WHERE post_id = %s AND lang = %s AND provider_id = %s LIMIT 1",
253 $this->post_data['review_id'],
254 $this->lang,
255 $provider_id
256 ),
257 ARRAY_A
258 );
259 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
260
261 if (! empty($feed_id_match[0])) {
262 return $feed_id_match[0];
263 }
264 }
265
266 return null;
267 }
268
269 public function store()
270 {
271 $rating = $this->post_data['rating'];
272 if (Util::is_facebook_collection_post($this->post_data)) {
273 $rating = $this->post_data['rating'] === 'positive' ? 5 : 1;
274 }
275
276 $now = date('Y-m-d H:i:s');
277 $post_id = $this->post_data['review_id'];
278 $provider_id = $this->get_provider_id();
279 $json_data = $this->should_encrypt($this->post_data, wp_json_encode($this->post_data));
280 $post_content = $this->should_encrypt($this->post_data, $this->post_data['text']);
281 $provider = $this->post_data['provider']['name'];
282 $business = $this->post_data['business']['id'] ?? '';
283 $time_stamp = date('Y-m-d H:i:s', $this->post_data['time']);
284
285 global $wpdb;
286 $table_name = esc_sql($wpdb->prefix . self::POSTS_TABLE_NAME);
287
288 // Use INSERT ... ON DUPLICATE KEY UPDATE to prevent duplicates
289 // This handles race conditions where two requests try to insert the same review
290 // Note: We pass values directly in UPDATE clause instead of using VALUES() function
291 // for cross-version MySQL compatibility (VALUES() deprecated in MySQL 8.0.20+)
292 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safely prefixed constant
293 $result = $wpdb->query(
294 $wpdb->prepare(
295 "INSERT INTO $table_name
296 (created_on, post_id, time_stamp, json_data, post_content, rating, provider, provider_id, business, media_id, avatar_id, sizes, aspect_ratio, images_done, last_requested, lang)
297 VALUES (%s, %s, %s, %s, %s, %d, %s, %s, %s, %s, %s, %s, %s, %d, %s, %s)
298 ON DUPLICATE KEY UPDATE
299 json_data = %s,
300 post_content = %s,
301 rating = %d,
302 last_requested = %s",
303 // INSERT values
304 $now,
305 $post_id,
306 $time_stamp,
307 $json_data,
308 $post_content,
309 $rating,
310 $provider,
311 $provider_id,
312 $business,
313 $this->storage_data['media_id'],
314 $this->storage_data['avatar_id'] ?? '',
315 $this->storage_data['sizes'],
316 $this->storage_data['aspect_ratio'],
317 $this->storage_data['images_done'],
318 $now,
319 $this->lang,
320 // UPDATE values (repeated for ON DUPLICATE KEY UPDATE)
321 $json_data,
322 $post_content,
323 $rating,
324 $now
325 )
326 );
327 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
328 }
329
330 public function update_single($strict_update = false)
331 {
332 $rating = $this->post_data['rating'];
333 if (Util::is_facebook_collection_post($this->post_data)) {
334 $rating = $this->post_data['rating'] === 'positive' ? 5 : 1;
335 }
336
337 $to_store = array(
338 array('post_id', $this->post_data['review_id'], '%s'),
339 array('time_stamp', date('Y-m-d H:i:s', $this->post_data['time']), '%s'),
340 array('json_data', $this->should_encrypt($this->post_data, wp_json_encode($this->post_data)), '%s'),
341 array('post_content', $this->should_encrypt($this->post_data, $this->post_data['text']), '%s'),
342 array('rating', $rating, '%d'),
343 array('provider', $this->post_data['provider']['name'], '%s'),
344 array('provider_id', $this->get_provider_id(), '%s'),
345 array('business', $this->post_data['business']['id'] ?? '', '%s'),
346 array('media_id', $this->storage_data['media_id'], '%s'),
347 array('sizes', $this->storage_data['sizes'], '%s'),
348 array('aspect_ratio', $this->storage_data['aspect_ratio'], '%s'),
349 array('images_done', $this->storage_data['images_done'], '%d'),
350 array('last_requested', date('Y-m-d H:i:s'), '%s'),
351 );
352
353 // SMASH-1785 (hardened after PR #510 review): persist avatar_id ONLY when we
354 // actually hold one. db_record_exists() mirrors the stored value, but not every
355 // caller routes through it — SBR_Feed_Saver_Manager::create_update_collection_review()
356 // branches on its own $is_new flag and reaches update_single() with storage_data
357 // still at the constructor default. Writing unconditionally clobbered that
358 // review's stored avatar_id to '' and un-localized its avatar. Omitting the
359 // column leaves the stored value untouched, so a caller that skipped the mirror
360 // is a no-op instead of destructive. Nothing legitimately needs to blank it —
361 // a failed resize stores the non-empty marker 'error'.
362 $avatar_id = $this->storage_data['avatar_id'] ?? '';
363 if (is_string($avatar_id) && $avatar_id !== '') {
364 $to_store[] = array('avatar_id', $avatar_id, '%s');
365 }
366 $data = array();
367 $format = array();
368 foreach ($to_store as $single_store) {
369 $data[$single_store[0]] = $single_store[1];
370 $format[] = $single_store[2];
371 }
372
373 global $wpdb;
374 $table_name = esc_sql($wpdb->prefix . self::POSTS_TABLE_NAME);
375 $where = array();
376 $where_format = array();
377
378 $where['post_id'] = $this->post_data['review_id'];
379 $where_format[] = '%s';
380
381 // Scope by language: db_record() (the existence gate that precedes this update)
382 // keys on (post_id, lang, provider_id), but this UPDATE matched post_id alone —
383 // so a single-language update overwrote every sibling-language row for the same
384 // review, collapsing them onto the last-written text. Only 'google' carries a
385 // non-'' lang, so other providers keep lang='' here and are unaffected (SMASH-1631).
386 $where['lang'] = $this->lang;
387 $where_format[] = '%s';
388
389 if ($strict_update) {
390 $where['provider_id'] = $this->get_provider_id();
391 $where_format[] = '%s';
392 }
393
394 $error = $wpdb->update($table_name, $data, $where, $format, $where_format);
395
396 if ($error !== false) {
397 $insert_id = $wpdb->insert_id;
398 } else {
399 // log error
400 }
401 }
402
403 public function update($to_update)
404 {
405 $data = array();
406 $format = array();
407 foreach ($to_update as $single_update) {
408 $data[ $single_update[0] ] = $single_update[1];
409 $format[] = $single_update[2];
410 }
411
412 global $wpdb;
413 $where = array();
414 $where_format = array();
415
416 $where['post_id'] = $this->post_data['review_id'];
417 $where_format[] = '%s';
418 // Scope by language, same reason as update_single(): without it a post_id-only
419 // UPDATE overwrites every sibling-language row for the review. Callers set
420 // $this->lang to the row's own language (or leave the '' default for non-lang
421 // providers), so this targets exactly the intended row (SMASH-1631).
422 $where['lang'] = $this->lang;
423 $where_format[] = '%s';
424 $table_name = esc_sql($wpdb->prefix . self::POSTS_TABLE_NAME);
425 $error = $wpdb->update($table_name, $data, $where, $format, $where_format);
426
427 if ($error !== false) {
428 $insert_id = $wpdb->insert_id;
429 } else {
430 // log error
431 }
432 }
433
434 public static function delete_resizing_table_and_images()
435 {
436 $upload = wp_upload_dir();
437
438 global $wpdb;
439
440 $table_name = esc_sql($wpdb->prefix . self::POSTS_TABLE_NAME);
441
442 self::delete_local_images();
443
444 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is prefixed constant
445 $wpdb->query("DROP TABLE IF EXISTS $table_name");
446 }
447
448 public static function create_resizing_table_and_uploads_folder()
449 {
450 $upload = wp_upload_dir();
451 $upload_dir = $upload['basedir'];
452 $upload_dir = trailingslashit($upload_dir) . self::UPLOAD_FOLDER_NAME;
453 if (! file_exists($upload_dir)) {
454 $created = wp_mkdir_p($upload_dir);
455 }
456
457 global $wpdb;
458 $table_name = esc_sql($wpdb->prefix . self::POSTS_TABLE_NAME);
459 $max_index_length = 191;
460 $charset_collate = '';
461 if (method_exists($wpdb, 'get_charset_collate')) { // get_charset_collate introduced in WP 3.5
462 $charset_collate = $wpdb->get_charset_collate();
463 }
464
465 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is prefixed constant
466 if ($wpdb->get_var("show tables like '$table_name'") !== $table_name) {
467 $sql = 'CREATE TABLE ' . $table_name . " (
468 id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
469 created_on DATETIME,
470 post_id VARCHAR(1000) DEFAULT '' NOT NULL,
471 time_stamp DATETIME,
472 json_data LONGTEXT DEFAULT '' NOT NULL,
473 post_content LONGTEXT DEFAULT '' NOT NULL,
474 rating INT(1) UNSIGNED NOT NULL,
475 provider VARCHAR(1000) DEFAULT '' NOT NULL,
476 provider_id VARCHAR(1000) DEFAULT '' NOT NULL,
477 business VARCHAR(1000) DEFAULT '' NOT NULL,
478 media_id VARCHAR(1000) DEFAULT '' NOT NULL,
479 sizes VARCHAR(1000) DEFAULT '' NOT NULL,
480 aspect_ratio DECIMAL (4,2) DEFAULT 0 NOT NULL,
481 avatar_id VARCHAR(1000) DEFAULT '' NOT NULL,
482 images_done TINYINT(1) DEFAULT 0 NOT NULL,
483 last_requested DATE,
484 lang VARCHAR(1000) DEFAULT '' NOT NULL,
485 INDEX provider (provider($max_index_length)),
486 INDEX business (business($max_index_length)),
487 INDEX provider_business (provider(10), business(15)),
488 INDEX provider_lang (provider(140),lang(51))
489 ) $charset_collate;";
490 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is a CREATE TABLE statement with prefixed table name
491 $wpdb->query($sql);
492 }
493 $error = $wpdb->last_error;
494 $query = $wpdb->last_query;
495 }
496
497 public static function delete_least_used_image()
498 {
499 }
500
501 public function max_total_records_reached()
502 {
503 }
504
505 public function media_supplied()
506 {
507 return $this->post_data['provider']['name'] !== 'yelp' && $this->post_data['provider']['name'] !== 'tripadvisor';
508 }
509
510
511 public function should_encrypt($post, $element)
512 {
513 return $post['provider']['name'] === 'facebook' && $element !== null ? $this->encryption->maybe_encrypt($element) : $element;
514 }
515
516
517 /**
518 * Used to update or insert Single Post Data
519 *
520 *
521 * @return void
522 *
523 * @since 1.6
524 */
525 public function update_or_insert($strict_update = false)
526 {
527 if ($this->db_record_exists()) {
528 $this->update_single($strict_update);
529 } else {
530 $this->store();
531 }
532 }
533
534 public static function delete_local_images()
535 {
536 $upload = wp_upload_dir();
537 $upload_dir = $upload['basedir'];
538 $upload_dir = trailingslashit($upload_dir) . self::UPLOAD_FOLDER_NAME;
539 $image_files = glob(trailingslashit($upload_dir) . '*');
540 if (!empty($image_files)) {
541 foreach ($image_files as $file) {
542 if (is_file($file)) {
543 unlink($file);
544 }
545 }
546 }
547 }
548 }
549