1212
1313class manager
1414{
15+ public const CONSENT_CATEGORY = 'marketing ' ;
16+
17+ /**
18+ * Google ad/tag scripts that support Google Consent Mode.
19+ *
20+ * These should run immediately so Consent Mode can control storage and
21+ * personalization instead of blocking the ad tag entirely.
22+ */
23+ protected const GOOGLE_CONSENT_AWARE_SCRIPT_SOURCE_PATTERNS = array (
24+ '~(^|[/.])pagead2\.googlesyndication\.com/pagead/js/adsbygoogle\.js(?:[?#]|$)~i ' ,
25+ '~(^|[/.])securepubads\.g\.doubleclick\.net/tag/js/gpt\.js(?:[?#]|$)~i ' ,
26+ '~(^|[/.])www\.googletagservices\.com/tag/js/gpt\.js(?:[?#]|$)~i ' ,
27+ '~(^|[/.])www\.googletagmanager\.com/(?:gtag/js|gtm\.js)(?:[?#]|$)~i ' ,
28+ );
29+
1530 /** @var \phpbb\db\driver\driver_interface */
1631 protected $ db ;
1732
@@ -87,7 +102,7 @@ public function get_ads($ad_locations, $user_groups, $non_content_page = false)
87102 $ user_now = $ this ->user ->create_datetime ();
88103 $ sql_time = $ this ->user ->get_timestamp_from_format ('Y-m-d H:i:s ' , $ user_now ->format ('Y-m-d H:i:s ' ), new \DateTimeZone ('UTC ' ));
89104
90- $ sql = 'SELECT al.location_id, a.ad_id, a.ad_code, a.ad_centering
105+ $ sql = 'SELECT al.location_id, a.ad_id, a.ad_code, a.ad_centering, a.ad_consent
91106 FROM ' . $ this ->ad_locations_table . ' al
92107 LEFT JOIN ' . $ this ->ads_table . ' a
93108 ON (al.ad_id = a.ad_id)
@@ -372,6 +387,194 @@ public function load_groups($ad_id)
372387 return $ groups ;
373388 }
374389
390+ /**
391+ * Prepare ad code for output, applying consent-manager deferrals when enabled.
392+ *
393+ * @param string $ad_code Stored advertisement code
394+ * @param bool $consent_enabled Whether marketing consent is required
395+ * @return string
396+ */
397+ public function prepare_ad_code ($ ad_code , $ consent_enabled )
398+ {
399+ $ ad_code = htmlspecialchars_decode ($ ad_code , ENT_COMPAT );
400+ $ original_ad_code = $ ad_code ;
401+
402+ if (!$ consent_enabled || $ ad_code === '' )
403+ {
404+ return $ ad_code ;
405+ }
406+
407+ $ google_consent_aware_sources = self ::get_google_consent_aware_script_sources ($ ad_code );
408+
409+ $ ad_code = preg_replace_callback ('#<script\b([^>]*)>(.*?)</script\s*>#is ' , function ($ matches ) use ($ google_consent_aware_sources )
410+ {
411+ $ attributes = $ matches [1 ] ?? '' ;
412+ $ content = $ matches [2 ] ?? '' ;
413+
414+ if (!$ this ->should_defer_script_tag ($ attributes , $ content , $ google_consent_aware_sources ))
415+ {
416+ return $ matches [0 ];
417+ }
418+
419+ return '<script ' . $ this ->inject_consent_attributes ($ attributes ) . '> ' . $ content . '</script> ' ;
420+ }, $ ad_code );
421+
422+ return $ ad_code ?? $ original_ad_code ;
423+ }
424+
425+ /**
426+ * Determine whether a script tag is executable and should be deferred.
427+ *
428+ * @param string $attributes Script tag attributes
429+ * @param string $content Script tag content
430+ * @param array $google_consent_aware_sources Known Google loader sources in this ad block
431+ * @return bool
432+ */
433+ protected function should_defer_script_tag ($ attributes , $ content = '' , array $ google_consent_aware_sources = array ())
434+ {
435+ if (preg_match ('/\bdata-consent-category\s*=/i ' , $ attributes ))
436+ {
437+ return false ;
438+ }
439+
440+ if (preg_match ('/\btype\s*=\s*([ \'"])(.*?)\1/i ' , $ attributes , $ matches ))
441+ {
442+ $ type = strtolower (trim (explode ('; ' , $ matches [2 ])[0 ]));
443+ }
444+ else
445+ {
446+ $ type = '' ;
447+ }
448+
449+ $ is_executable = $ type === ''
450+ || $ type === 'text/plain '
451+ || $ type === 'module '
452+ || strpos ($ type , 'javascript ' ) !== false
453+ || strpos ($ type , 'ecmascript ' ) !== false ;
454+
455+ if (!$ is_executable )
456+ {
457+ return false ;
458+ }
459+
460+ return !self ::is_google_consent_aware_script ($ attributes , $ content , $ google_consent_aware_sources );
461+ }
462+
463+ /**
464+ * Determine whether a script should run under Google Consent Mode.
465+ *
466+ * @param string $attributes Script tag attributes
467+ * @param string $content Script tag content
468+ * @param array $google_consent_aware_sources Known Google loader sources in this ad block
469+ * @return bool
470+ */
471+ public static function is_google_consent_aware_script ($ attributes , $ content , array $ google_consent_aware_sources )
472+ {
473+ $ source = self ::extract_script_source ($ attributes );
474+ if ($ source !== '' )
475+ {
476+ return isset ($ google_consent_aware_sources [self ::normalize_script_source ($ source )]);
477+ }
478+
479+ return !empty ($ google_consent_aware_sources )
480+ && preg_match ('/\b(?:adsbygoogle|googletag|gtag|dataLayer)\b/ ' , $ content );
481+ }
482+
483+ /**
484+ * Return known Google Consent Mode-aware loader sources in an ad block.
485+ *
486+ * @param string $ad_code Advertisement code
487+ * @return array
488+ */
489+ public static function get_google_consent_aware_script_sources ($ ad_code )
490+ {
491+ $ sources = array ();
492+
493+ if (!preg_match_all ('#<script\b([^>]*)>#is ' , $ ad_code , $ matches ))
494+ {
495+ return $ sources ;
496+ }
497+
498+ foreach ($ matches [1 ] as $ attributes )
499+ {
500+ $ source = self ::extract_script_source ($ attributes );
501+ if ($ source !== '' && self ::is_google_consent_aware_script_source ($ source ))
502+ {
503+ $ sources [self ::normalize_script_source ($ source )] = true ;
504+ }
505+ }
506+
507+ return $ sources ;
508+ }
509+
510+ /**
511+ * Extract the src attribute from a script tag attribute string.
512+ *
513+ * @param string $attributes Script tag attributes
514+ * @return string
515+ */
516+ public static function extract_script_source ($ attributes )
517+ {
518+ return preg_match ('/\bsrc\s*=\s*([ \'"])(.*?)\1/i ' , $ attributes , $ matches ) ? $ matches [2 ] : '' ;
519+ }
520+
521+ /**
522+ * Check whether a script source is a known Google Consent Mode-aware loader.
523+ *
524+ * @param string $source Script source URL
525+ * @return bool
526+ */
527+ protected static function is_google_consent_aware_script_source ($ source )
528+ {
529+ $ source = self ::normalize_script_source ($ source );
530+
531+ foreach (self ::GOOGLE_CONSENT_AWARE_SCRIPT_SOURCE_PATTERNS as $ pattern )
532+ {
533+ if (preg_match ($ pattern , $ source ))
534+ {
535+ return true ;
536+ }
537+ }
538+
539+ return false ;
540+ }
541+
542+ /**
543+ * Normalize a script source before comparing against allowlisted loaders.
544+ *
545+ * @param string $source Script source URL
546+ * @return string
547+ */
548+ protected static function normalize_script_source ($ source )
549+ {
550+ return preg_replace ('#^//# ' , 'https:// ' , trim ($ source ));
551+ }
552+
553+ /**
554+ * Replace script tag attributes with consent-aware placeholders.
555+ *
556+ * @param string $attributes Script tag attributes
557+ * @return string
558+ */
559+ protected function inject_consent_attributes ($ attributes )
560+ {
561+ if (preg_match ('/\btype\s*=\s*([ \'"])(.*?)\1/i ' , $ attributes ))
562+ {
563+ $ attributes = preg_replace ('/\btype\s*=\s*([ \'"])(.*?)\1/i ' , 'type="text/plain" ' , $ attributes , 1 );
564+ }
565+ else
566+ {
567+ $ attributes .= ' type="text/plain" ' ;
568+ }
569+
570+ if (!preg_match ('/\bdata-consent-category\s*=/i ' , $ attributes ))
571+ {
572+ $ attributes .= ' data-consent-category=" ' . self ::CONSENT_CATEGORY . '" ' ;
573+ }
574+
575+ return $ attributes ;
576+ }
577+
375578 /**
376579 * Make sure only necessary data make their way to SQL query
377580 *
@@ -393,6 +596,7 @@ protected function intersect_ad_data($data)
393596 'ad_owner ' => '' ,
394597 'ad_content_only ' => '' ,
395598 'ad_centering ' => '' ,
599+ 'ad_consent ' => '' ,
396600 ]);
397601 }
398602
0 commit comments