Skip to content

Commit 5b9852d

Browse files
authored
Merge pull request #197 from phpbb-extensions/consent-manager
Consent Manager ext integration
2 parents 096afce + 54b3b63 commit 5b9852d

24 files changed

Lines changed: 1110 additions & 53 deletions

ad/manager.php

Lines changed: 205 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@
1212

1313
class 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

adm/style/manage_ads.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ <h3>{{ lang('WARNING') }}</h3>
4141
<dd><textarea id="ad_note" name="ad_note" rows="5" cols="60" style="width: 95%;">{{ AD_NOTE }}</textarea></dd>
4242
</dl>
4343
<dl>
44-
<dt><label for="ad_code">{{ lang('AD_CODE') ~ lang('COLON') }}</label><br><span>{{ lang('AD_CODE_EXPLAIN') }}</span></dt>
44+
<dt><label for="ad_code">{{ lang('AD_CODE') ~ lang('COLON') }}</label><br><span>{{ lang('AD_CODE_EXPLAIN') }}{% if S_ADS_CONSENTMANAGER_AVAILABLE %}{{ lang('AD_CODE_CONSENT_EXPLAIN') }}{% endif %}</span></dt>
4545
<dd>
4646
<textarea id="ad_code" name="ad_code" rows="20" cols="60" style="width: 95%;">{{ AD_CODE }}</textarea>
4747
<button class="button2 phpbb-ads-button" id="analyse_ad_code" name="analyse_ad_code"><i class="icon fa-fw fa-stethoscope"></i> <span>{{ lang('ANALYSE_AD_CODE') }}</span></button>
@@ -117,6 +117,15 @@ <h3>{{ lang('WARNING') }}</h3>
117117
</fieldset>
118118
<fieldset>
119119
<legend>{{ lang('AD_OPTIONS') }}</legend>
120+
{% if S_ADS_CONSENTMANAGER_AVAILABLE %}
121+
<dl>
122+
<dt><label for="ad_consent">{{ lang('AD_CONSENT') ~ lang('COLON') }}</label><br /><span>{{ lang('AD_CONSENT_EXPLAIN') }}</span></dt>
123+
<dd><label><input type="radio" class="radio" id="ad_consent" name="ad_consent" value="1"{% if AD_CONSENT is not defined or AD_CONSENT %} checked{% endif %} /> {{ lang('YES') }}</label>
124+
<label><input type="radio" class="radio" name="ad_consent" value="0"{% if AD_CONSENT is defined and not AD_CONSENT %} checked{% endif %} /> {{ lang('NO') }}</label></dd>
125+
</dl>
126+
{% else %}
127+
<input type="hidden" name="ad_consent" value="{{ AD_CONSENT is defined ? AD_CONSENT : 1 }}" />
128+
{% endif %}
120129
<dl>
121130
<dt><label for="ad_owner">{{ lang('AD_OWNER') ~ lang('COLON') }}</label><br><span>{{ lang('AD_OWNER_EXPLAIN') }}</span></dt>
122131
<dd><input class="text medium" id="ad_owner" name="ad_owner" value="{{ AD_OWNER }}"></dd>

analyser/test/iframe.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
/**
3+
*
4+
* Advertisement management. An extension for the phpBB Forum Software package.
5+
*
6+
* @copyright (c) 2026 phpBB Limited <https://www.phpbb.com>
7+
* @license GNU General Public License, version 2 (GPL-2.0)
8+
*
9+
*/
10+
11+
namespace phpbb\ads\analyser\test;
12+
13+
class iframe implements test_interface
14+
{
15+
/**
16+
* {@inheritDoc}
17+
*
18+
* Iframes test.
19+
* This test looks for iframe tags with src attributes. Such scripts could introduce
20+
* external trackers and data collectors that could require user consent.
21+
*/
22+
public function run($ad_code)
23+
{
24+
if (preg_match('/&lt;iframe(?>(?!&gt;).)*?(?<=\s|&quot;)src\s*=\s*&quot;.*?&gt;/is', $ad_code))
25+
{
26+
return array(
27+
'severity' => 'notice',
28+
'message' => 'IFRAME_USAGE',
29+
);
30+
}
31+
32+
return false;
33+
}
34+
}

0 commit comments

Comments
 (0)