PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / modules / cookie-policy / shortcode.php

shortcode.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/cookie-policy/shortcode.php

484 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cookie Policy Content Shortcode.
4 *
5 * Registers [surecookie_cookie_policy_content] shortcode that renders
6 * dynamically generated cookie tables grouped by category and provider.
7 *
8 * @package SureCookie\Inc\Modules\CookiePolicy
9 * @since 0.0.0-alpha.1
10 */
11
12 namespace SureCookie\Inc\Modules\CookiePolicy;
13
14 use SureCookie\Inc\Functions\Get;
15 use SureCookie\Inc\Functions\Sanitize;
16 use SureCookie\Inc\Functions\Settings;
17 use SureCookie\Inc\Integrations\Multilingual\Translation_Filter;
18 use SureCookie\Inc\Traits\GetInstance;
19 use SureCookie\Inc\Traits\Shortcode as Shortcode_Trait;
20
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit; // Exit if accessed directly.
23 }
24
25 /**
26 * Shortcode
27 *
28 * @since 0.0.0-alpha.1
29 */
30 class Shortcode {
31 use GetInstance;
32 use Shortcode_Trait;
33
34 /**
35 * Constructor.
36 *
37 * @since 0.0.0-alpha.1
38 */
39 private function __construct() {
40 $this->register_shortcodes( [ 'surecookie_cookie_policy_content' => 'content' ] );
41 }
42
43 /**
44 * Render the cookie policy content shortcode.
45 *
46 * Supports a `show_timestamp` attribute (default "true", accepts
47 * true/false/yes/no/on/off/1/0) to toggle the last updated notice.
48 *
49 * @param array<string, mixed> $atts Shortcode attributes.
50 * @return string Rendered HTML.
51 * @since 0.0.0-alpha.1
52 */
53 public function render_content( $atts ): string {
54 $atts = shortcode_atts(
55 [
56 'show_timestamp' => 'true',
57 ],
58 $atts,
59 'surecookie_cookie_policy_content'
60 );
61
62 wp_enqueue_style(
63 'surecookie-cookie-policy',
64 SURECOOKIE_URL . 'assets/css/' . Get::cookie_policy_style_css(),
65 [],
66 SURECOOKIE_VERSION
67 );
68
69 $category_data = self::build_category_data();
70
71 if ( empty( $category_data ) ) {
72 return '';
73 }
74
75 $output = '<div class="surecookie-cookie-policy">';
76 $output .= self::build_table_of_contents( $category_data );
77 $output .= self::build_cookie_tables( $category_data );
78
79 if ( filter_var( $atts['show_timestamp'], FILTER_VALIDATE_BOOLEAN ) ) {
80 $output .= self::build_last_updated();
81 }
82
83 $output .= '</div>';
84
85 return $output;
86 }
87
88 /**
89 * Build category data with merged cookies for non-empty categories.
90 *
91 * Pre-computes the list of categories that have cookies, so both the
92 * table of contents and the cookie tables share the same data.
93 *
94 * @since 0.0.0-alpha.1
95 * @return array<int, array{id: string, name: string, description: string, cookies: array<int, array<string, mixed>>}> Categories with cookies.
96 */
97 private static function build_category_data(): array {
98 $categories = Settings::get( 'cookie_categories' );
99 $scanned_raw = Get::scanned_cookies_for_display();
100 $custom_grouped = Get::formatted_custom_cookies();
101
102 if ( ! is_array( $categories ) || empty( $categories ) ) {
103 return [];
104 }
105
106 $data = [];
107
108 foreach ( $categories as $category ) {
109 if ( ! is_array( $category ) || empty( $category['id'] ) ) {
110 continue;
111 }
112
113 $category_id = (string) $category['id'];
114 $cat_key = sanitize_key( $category_id );
115 $cookies = self::get_merged_cookies( $category_id, $scanned_raw, $custom_grouped );
116
117 if ( empty( $cookies ) ) {
118 continue;
119 }
120
121 $data[] = [
122 'id' => $category_id,
123 'name' => Translation_Filter::translate_string( (string) ( $category['name'] ?? '' ), 'surecookie_cat_' . $cat_key . '_name' ),
124 'description' => Translation_Filter::translate_string( (string) ( $category['description'] ?? '' ), 'surecookie_cat_' . $cat_key . '_description' ),
125 'cookies' => $cookies,
126 ];
127 }
128
129 return $data;
130 }
131
132 /**
133 * Build the table of contents navigation.
134 *
135 * Generates anchor links to each cookie category that has cookies.
136 *
137 * @param array<int, array{id: string, name: string, description: string, cookies: array<int, array<string, mixed>>}> $category_data Category data.
138 * @since 0.0.0-alpha.2
139 * @return string HTML for the table of contents.
140 */
141 private static function build_table_of_contents( array $category_data ): string {
142 if ( count( $category_data ) < 2 ) {
143 return '';
144 }
145
146 $html = '<nav class="surecookie-cookie-policy-toc" aria-label="' . esc_attr__( 'Table of Contents', 'surecookie' ) . '">';
147 $html .= '<h3>' . esc_html__( 'Table of Contents', 'surecookie' ) . '</h3>';
148 $html .= '<ol>';
149
150 foreach ( $category_data as $cat ) {
151 $html .= '<li><a href="#surecookie-cat-' . esc_attr( $cat['id'] ) . '">' . esc_html( $cat['name'] ) . '</a></li>';
152 }
153
154 $html .= '</ol>';
155 $html .= '</nav>';
156
157 return $html;
158 }
159
160 /**
161 * Build HTML cookie tables grouped by category and provider.
162 *
163 * @param array<int, array{id: string, name: string, description: string, cookies: array<int, array<string, mixed>>}> $category_data Category data.
164 * @since 0.0.0-alpha.2
165 * @return string HTML tables.
166 */
167 private static function build_cookie_tables( array $category_data ): string {
168 $html = '';
169
170 foreach ( $category_data as $cat ) {
171 $html .= '<div class="surecookie-cookie-policy-category">';
172 $html .= '<h3 id="surecookie-cat-' . esc_attr( $cat['id'] ) . '">' . esc_html( $cat['name'] ) . '</h3>';
173
174 if ( ! empty( $cat['description'] ) ) {
175 $html .= '<p>' . esc_html( $cat['description'] ) . '</p>';
176 }
177
178 $provider_groups = self::group_cookies_by_provider( $cat['cookies'] );
179 $has_multi_provider = count( $provider_groups ) > 1;
180
181 foreach ( $provider_groups as $provider_name => $cookies ) {
182 $html .= '<div class="surecookie-cookie-policy-provider">';
183
184 if ( $has_multi_provider ) {
185 $html .= '<h4 class="surecookie-cookie-policy-provider-name">' . esc_html( $provider_name ) . '</h4>';
186 }
187
188 $html .= self::build_single_table( $cookies );
189 $html .= '</div>';
190 }
191
192 $html .= '</div>';
193 }
194
195 return $html;
196 }
197
198 /**
199 * Build a single cookie table.
200 *
201 * @param array<int, array<string, mixed>> $cookies Cookies to display.
202 * @since 0.0.0-alpha.2
203 * @return string HTML table.
204 */
205 private static function build_single_table( array $cookies ): string {
206 $label_name = esc_html__( 'Cookie Name', 'surecookie' );
207 $label_purpose = esc_html__( 'Purpose', 'surecookie' );
208 $label_duration = esc_html__( 'Duration', 'surecookie' );
209 $label_domain = esc_html__( 'Domain', 'surecookie' );
210
211 $html = '<div class="surecookie-cookie-policy-table-wrap">';
212 $html .= '<table class="surecookie-cookie-policy-table">';
213 $html .= '<thead><tr>';
214 $html .= '<th scope="col">' . $label_name . '</th>';
215 $html .= '<th scope="col">' . $label_purpose . '</th>';
216 $html .= '<th scope="col">' . $label_duration . '</th>';
217 $html .= '<th scope="col">' . $label_domain . '</th>';
218 $html .= '</tr></thead>';
219 $html .= '<tbody>';
220
221 foreach ( $cookies as $cookie ) {
222 $name = esc_html( $cookie['name'] ?? '' );
223 // The scan writer always sets 'description', usually to '', so prefer non-empty over non-null.
224 $described = (string) ( $cookie['description'] ?? '' );
225 $purpose = esc_html( $described !== '' ? $described : (string) ( $cookie['purpose'] ?? '' ) );
226 $duration = esc_html( self::format_duration( $cookie ) );
227 $domain = esc_html( self::format_domain( $cookie ) );
228
229 $html .= '<tr>';
230 $html .= '<td data-label="' . esc_attr( $label_name ) . '">' . ( ! empty( $name ) ? $name : '-' ) . '</td>';
231 $html .= '<td data-label="' . esc_attr( $label_purpose ) . '">' . ( ! empty( $purpose ) ? $purpose : '-' ) . '</td>';
232 $html .= '<td data-label="' . esc_attr( $label_duration ) . '">' . ( $duration !== '' ? $duration : '-' ) . '</td>';
233 $html .= '<td data-label="' . esc_attr( $label_domain ) . '">' . ( ! empty( $domain ) ? $domain : '-' ) . '</td>';
234 $html .= '</tr>';
235 }
236
237 $html .= '</tbody></table>';
238 $html .= '</div>';
239
240 return $html;
241 }
242
243 /**
244 * Build the last updated notice.
245 *
246 * @since 0.0.0-alpha.2
247 * @return string HTML for the last updated date.
248 */
249 private static function build_last_updated(): string {
250 /**
251 * Filters the resolved "Last updated" timestamp for the cookie policy.
252 *
253 * Return a falsy value to hide the notice.
254 *
255 * @since 1.1.0
256 * @param int|null $timestamp Unix timestamp, or null when unavailable.
257 * @param int $page_id Configured cookie policy page ID.
258 */
259 $timestamp = apply_filters(
260 'surecookie_cookie_policy_last_updated_date',
261 self::get_last_updated_timestamp(),
262 absint( Settings::get( 'cookie_policy_page_id' ) )
263 );
264
265 if ( ! is_numeric( $timestamp ) || (int) $timestamp <= 0 ) {
266 return '';
267 }
268
269 $formatted_date = date_i18n( get_option( 'date_format' ), (int) $timestamp );
270
271 return '<p class="surecookie-cookie-policy-last-updated">'
272 . esc_html(
273 sprintf(
274 /* translators: %s: formatted date string. */
275 __( 'Last updated: %s', 'surecookie' ),
276 $formatted_date
277 )
278 )
279 . '</p>';
280 }
281
282 /**
283 * Resolve the last updated timestamp.
284 *
285 * Uses the later of the policy page's modified date and the last scan
286 * date, so manual page edits and fresh scans both refresh the notice.
287 *
288 * @since 1.1.0
289 * @return int|null Unix timestamp, or null when none is available.
290 */
291 private static function get_last_updated_timestamp(): ?int {
292 $timestamps = array_filter(
293 [
294 self::get_modified_post_timestamp(),
295 self::get_scan_timestamp(),
296 ]
297 );
298
299 return empty( $timestamps ) ? null : max( $timestamps );
300 }
301
302 /**
303 * Get the modified timestamp of the rendered post, falling back to the
304 * configured cookie policy page when it is published.
305 *
306 * @since 1.1.0
307 * @return int|null Unix timestamp, or null when unavailable.
308 */
309 private static function get_modified_post_timestamp(): ?int {
310 $timestamp = get_post_modified_time( 'U' );
311
312 if ( $timestamp === false ) {
313 $page_id = absint( Settings::get( 'cookie_policy_page_id' ) );
314
315 if ( $page_id > 0 && get_post_status( $page_id ) === 'publish' ) {
316 $timestamp = get_post_modified_time( 'U', false, $page_id );
317 }
318 }
319
320 return $timestamp === false ? null : (int) $timestamp;
321 }
322
323 /**
324 * Get the last cookie scan timestamp.
325 *
326 * @since 1.1.0
327 * @return int|null Unix timestamp, or null when unavailable.
328 */
329 private static function get_scan_timestamp(): ?int {
330 $scan_details = Get::option( SURECOOKIE_SCANNED_DETAILS_OPTION, [], 'array' );
331 $scan_date = $scan_details['date'] ?? '';
332 $timestamp = is_string( $scan_date ) && $scan_date !== '' ? strtotime( $scan_date ) : false;
333
334 return $timestamp === false ? null : $timestamp;
335 }
336
337 /**
338 * Group cookies by provider.
339 *
340 * Uses a fallback chain: provider field → domain extraction → site host.
341 *
342 * @param array<int, array<string, mixed>> $cookies Flat list of cookies.
343 * @since 0.0.0-alpha.2
344 * @return array<string, array<int, array<string, mixed>>> Cookies grouped by provider name.
345 */
346 private static function group_cookies_by_provider( array $cookies ): array {
347 $groups = [];
348
349 foreach ( $cookies as $cookie ) {
350 // Resolved here, not trusted from the producer: this is the sink that
351 // uses the leaf as an array key, and an array one is a fatal.
352 $provider = Sanitize::scalar( $cookie['provider'] ?? '' );
353
354 if ( empty( $provider ) ) {
355 $provider = ltrim( Sanitize::scalar( $cookie['domain'] ?? '' ), '.' );
356 }
357
358 if ( empty( $provider ) ) {
359 $provider = wp_parse_url( home_url(), PHP_URL_HOST );
360
361 if ( empty( $provider ) ) {
362 $provider = __( 'This website', 'surecookie' );
363 }
364 }
365
366 $groups[ $provider ][] = $cookie;
367 }
368
369 return $groups;
370 }
371
372 /**
373 * Merge scanned and custom cookies for a given category.
374 *
375 * @param string $category_id Category identifier.
376 * @param array<string, array<int, mixed>> $scanned_raw Raw scanned cookies grouped by category.
377 * @param array<string, list<array<string, mixed>>> $custom_grouped Custom cookies grouped by category.
378 * @return array<int, array<string, mixed>> Merged cookie list.
379 * @since 0.0.0-alpha.2
380 */
381 private static function get_merged_cookies( string $category_id, array $scanned_raw, array $custom_grouped ): array {
382 $scanned = [];
383 $custom = [];
384
385 if ( isset( $scanned_raw[ $category_id ] ) && is_array( $scanned_raw[ $category_id ] ) ) {
386 $scanned = $scanned_raw[ $category_id ];
387 }
388
389 if ( isset( $custom_grouped[ $category_id ] ) && is_array( $custom_grouped[ $category_id ] ) ) {
390 $custom = $custom_grouped[ $category_id ];
391 }
392
393 return array_merge( $scanned, $custom );
394 }
395
396 /**
397 * Format a cookie duration for display.
398 *
399 * @param array<string, mixed> $cookie Cookie data.
400 * @return string Formatted duration string.
401 * @since 0.0.0-alpha.1
402 */
403 private static function format_duration( array $cookie ): string {
404 $session = __( 'Session', 'surecookie' );
405
406 // Checked before any day count: a row the scanner marked as session-scoped is
407 // session-scoped whatever number a catalog pinned alongside it.
408 if ( ( $cookie['expiry_bucket'] ?? '' ) === 'session' ) {
409 return $session;
410 }
411
412 $duration = (string) ( $cookie['duration'] ?? '' );
413
414 if ( $duration !== '' ) {
415 // A sub-day lifetime floors to zero days - true of catalog entries such as
416 // DoubleClick's test_cookie. "0" reads as an error to a visitor, and it is
417 // the same figure the old decaying count produced, so neither may ship.
418 return is_numeric( $duration ) && (float) $duration <= 0 ? $session : $duration;
419 }
420
421 // Rows stored before the day count was derived at scan time still carry only the
422 // absolute expiry, which must not reach the table as a raw ISO-8601 string.
423 if ( ! empty( $cookie['expires'] ) ) {
424 $days = self::expires_to_days( $cookie['expires'] );
425
426 return $days === '0' ? $session : $days;
427 }
428
429 return '';
430 }
431
432 /**
433 * Convert an absolute cookie expiry into a whole number of days from now.
434 *
435 * Accepts the ISO-8601 timestamps relayed from the scan API, the
436 * 'Y-m-d H:i:s' UTC strings written for custom and declared cookies, and
437 * bare Unix timestamps from older records.
438 *
439 * @param mixed $expires Raw expiry value.
440 * @since 1.3.0
441 * @return string Day count, or an empty string when the value is unusable.
442 */
443 private static function expires_to_days( $expires ): string {
444 if ( is_numeric( $expires ) ) {
445 $timestamp = (int) $expires;
446 } elseif ( is_string( $expires ) ) {
447 // 'Y-m-d H:i:s' carries no timezone but is stored as UTC, so pin it
448 // to UTC instead of letting strtotime() assume server-local time.
449 $normalized = preg_match( '/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/', trim( $expires ) )
450 ? str_replace( ' ', 'T', trim( $expires ) ) . '+00:00'
451 : trim( $expires );
452
453 $timestamp = strtotime( $normalized );
454 } else {
455 return '';
456 }
457
458 if ( empty( $timestamp ) ) {
459 return '';
460 }
461
462 $diff = $timestamp - time();
463
464 return $diff <= 0 ? '0' : (string) (int) ceil( $diff / DAY_IN_SECONDS );
465 }
466
467 /**
468 * Format a cookie domain for display.
469 *
470 * @param array<string, mixed> $cookie Cookie data.
471 * @return string Formatted domain string.
472 * @since 0.0.0-alpha.1
473 */
474 private static function format_domain( array $cookie ): string {
475 $domain = $cookie['domain'] ?? '';
476
477 if ( ! empty( $domain ) ) {
478 return (string) $domain;
479 }
480
481 return '';
482 }
483 }
484